跪拜 Guibai
← Back to the summary

Building a Vue SFC Compiler as a Hand-Rolled Rollup Plugin

Preface

From the previous article, we know that a Vue file generally consists of three parts: template, script, and style. During compilation, after reading the content of the Vue file, @vue/compiler-sfc is used to parse the three parts of the Vue file mentioned above. Then, the content of each part is parsed separately. For example, the template part is compiled into a render function by the template compiler. The script part checks whether it is a script setup type, because the script setup type also needs to compile syntactic sugar code such as defineProps, defineEmits, defineModel, etc., i.e., compiling the so-called compile macros, and compiling TS into JavaScript. The style part needs to compile Sass, Less, Stylus CSS code. After processing is complete, the plugin passes the results to Rollup or Vite for subsequent operations like packaging or starting the development server.

This article will explain in detail how a Rollup plugin compiles Vue files.

Preparing the Configuration File

First, let's create an App.vue file in the rollup-plugin directory with the following content:

<template>
    <div>i am Cobyte ~</div>
</template>
<script setup>
    console.log('我是掘金签约作者:Cobyte')
</script>
<style>
div {
    color: red;
}
</style>

Our current goal is to compile the code of the above App.vue file into JavaScript code through a Rollup plugin. Next, we create a Rollup plugin. Based on the Rollup plugin knowledge we learned in the previous article, a basic Rollup plugin structure is as follows:

// rollup-plugin-vue.js
export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue'
    }
}

Then we modify the rollup.config.js configuration file:

import pluginVue from './rollup-plugin-vue.js'

export default [
  {
    input: './App.vue',
    output: {
      file: 'dist/output.js',
      format: 'esm',
    },
    plugins: [
      pluginVue()
    ],
  },
]

Based on the knowledge we learned in the previous article, we know that for files in local relative directories, all hook functions of the Rollup plugin will be executed. So we can directly compile the App.vue file in the transform hook. Also, because the Rollup plugin accesses all files in the local directory, we need to add a condition to process only Vue files. The Rollup plugin is modified as follows:

import compiler from '@vue/compiler-sfc'
export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        transform(code, id) {  
            // Because the Rollup plugin accesses all files in the local directory, we need to check and process only vue files
            if (id.endsWidth('.vue')) {
                const result = compiler.parse(code);
                console.log(result)
            }
        }
    }
}

At the same time, we need to install @vue/compiler-sfc:

pnpm add @vue/compiler-sfc -D

Initial Compilation of Vue Files

Next, let's debug and see what the compilation result of the parse method from @vue/compiler-sfc looks like.

Let's set a breakpoint first:

image.png

Then start debug mode and run the command:

image.png

Next, go to the rollup-plugin directory and run pnpm build. Finally, we can see the compilation result of the @vue/compiler-sfc parse method as follows:

image.png

We can see that the parse method of @vue/compiler-sfc splits a Vue file into:

We know that a Vue file generally consists of three parts: template, script, and style. The parse method of @vue/compiler-sfc first splits the Vue file into a data structure convenient for our next compilation step. This step does not compile the code; you can see that the content of the content field in each module is still the original content of the Vue file.

Compiling the Script Module

We know that the script setup type also needs to compile syntactic sugar code like defineProps, defineEmits, defineModel, etc., and also needs to handle CSS variable injection, etc. So we need to compile the script part of the code, and this capability is also provided by @vue/compiler-sfc through the compileScript method. The usage of the compileScript method is as follows:

import compiler from '@vue/compiler-sfc'
export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        transform(code, id) {  
            if (id.endsWith('.vue')) {
                const result = compiler.parse(code);
+                // Create a unique identifier for the component, used for style scoping, etc.
+                const scopeId = `${Math.random().toString(36).substr(2, 9)}`;
+                const scriptCode = compiler.compileScript(result.descriptor, { id: scopeId });
+                console.log(scriptCode)
            }
        }
    }
}

The printed result is as follows:

image.png

That is, the content in the content field is the compiled script part code. Let's organize it as follows:

export default {
    setup(__props, { expose: __expose }) {
         __expose();
        console.log('我是掘金签约作者:Cobyte')
        const __returned__ = {  }
        Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true })
        return __returned__
    }
}

Next, we compile the template part of the code.

Compiling the Template Module

Compiling the template part of the code is also very simple, just use the compileTemplate method as follows:

import compiler from '@vue/compiler-sfc'
export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        transform(code, id) {  
            if (id.endsWith('.vue')) {
                const result = compiler.parse(code);
                // Create a unique identifier for the component, used for style scoping, etc.
                const scopeId = `${Math.random().toString(36).substr(2, 9)}`;
                const scriptCode = compiler.compileScript(result.descriptor, { id: scopeId });
+                const templateCode = compiler.compileTemplate({ source: result.descriptor.template.content, id: scopeId })
+                console.log(templateCode)
            }
        }
    }
}

The compilation result is as follows:

image.png

That is, the content in the code field is the compiled template part code, which is the render function. Let's organize it as follows:

import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache) {
   return (_openBlock(), _createElementBlock("div", null, "i am Cobyte ~"))
}

Now that we have the compiled code for the script and template parts, how do we integrate them? From the previous article, we know that integration can be done through virtual modules.

Assembling Script and Render Functions

We mentioned above that we can integrate the compiled code of the script and template parts using virtual module technology. The code in the previous article was like this:

image.png

Now we can still do this:

import compiler from '@vue/compiler-sfc'
export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        transform(code, id) {  
            if (id.endsWith('.vue')) {
                const result = compiler.parse(code);
                // Create a unique identifier for the component, used for style scoping, etc.
                const scopeId = `${Math.random().toString(36).substr(2, 9)}`;
                const scriptCode = compiler.compileScript(result.descriptor, { id: scopeId });
                const templateCode = compiler.compileTemplate({ source: result.descriptor.template.content, id: scopeId })
+                // Virtual module for script import
+                const scriptImport = `import script from '${id}?vue&type=script'\n`
+                // Virtual module for template import
+                const templateImport = `import { render } from '${id}?vue&type=template'\n`

+                // Assembly
+                const renderReplace = `script.render = render\n`
+                const exportDefault = `export default script`
+                return {
+                    code: `
+                        ${scriptImport}
+                        ${templateImport}
+                        ${renderReplace}
+                        ${exportDefault}
+                    `
+                }
            }
        }
    }
}

Based on our previous understanding of Rollup plugins, we know that if the code processed by the transform hook still contains import statements, the resolveId hook will be called again to process the module ID, and it only processes IDs of third-party modules. The module address we constructed above is like this: import script from '${id}?vue&type=script'\n, where id is an absolute path address, for example: 'D:\workspace\github\mini-vue-vapor\rollup-plugin\App.vue', which will eventually become: 'D:\workspace\github\mini-vue-vapor\rollup-plugin\App.vue?vue&type=script'. This import address is clearly not a local relative path, so it will be treated as a third-party library path for processing. Therefore, we need to handle this module path in the resolveId hook so that our Rollup plugin can continue to process this file subsequently.

Our plugin changes are as follows:

import compiler from '@vue/compiler-sfc'
+ import qs from 'querystring'
+ // Determine if it is a Vue single-file component request
+ function parseVuePartRequest(id) {
+    const [filename, query] = id.split('?', 2)

+    if (!query) return { vue: false, filename }
+    // Use the qs.parse method to parse the query string and convert it into an object  
+    const raw = qs.parse(query)
+    // If the parsed object contains the vue key, it indicates this is a Vue component request  
+    if ('vue' in raw) {
+      return {
+        ...raw,
+        filename,
+        vue: true
+      }
+    }
+    // If it does not contain the vue key, return an object indicating it is not a Vue component request  
+    return { vue: false, filename }
+ }

export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
+        async resolveId(id) {
+            // Call the parseVuePartRequest function to parse the module ID
+            const query = parseVuePartRequest(id);
+            // If the parse result shows query.vue is true, it indicates a Vue component request, so return the original module ID so that this plugin will continue to process this module
+            if (query.vue) {
+              return id
+            }
+            // If it is not a Vue component request, return null, indicating this module ID should not be processed by this plugin
+            return null
+        },
        // Omitted...
    }
}

We encapsulated a parseVuePartRequest function to determine if it is a Vue component request. The main basis for judgment is whether the vue key exists in the parameters after the ? in the file ID. This is why we set the ?vue&type=script part when constructing the virtual module above, to allow for distinction later.

At the same time, we need to install the querystring dependency module: pnpm add querystring -D

When we run the test again, the result is as follows:

image.png

When the code reaches this step, it indicates our code is correct.

Based on the knowledge we learned in the previous article, we know that when a virtual module ID is returned in the resolveId hook, the load hook is next in line for processing. Still based on previous knowledge, we know that we can return the virtual module's code in the load hook. So we can determine in the load hook, based on the type in the module ID, whether to return the script part code or the template part code.

The plugin code changes are as follows:

// Omitted...
export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        // Omitted...
+        load(id) {
+            // Call the parseVuePartRequest function to parse the module ID
+            const query = parseVuePartRequest(id);
+            // If the parse result shows query.vue is true, it indicates a Vue component request, so return the original module ID so that this plugin will continue to process this module
+            if (query.vue) {
+              if (query.type === 'script') {
+                // Return the script part code
+              } else if (query.type === 'template') {
+                // Return the template part code
+              }
+            }
+        },
    // Omitted...
    }
}

The question then is, how do we return the script and template part code in the load hook? From the text above, we know that the script and template part code has already been compiled in the transform hook. How can we get the compiled code from the transform hook into the load hook? We can cache the compilation results of the script and template parts in the transform hook, and then retrieve the corresponding compilation results from the cache in the load hook.

Setting Up Compilation Cache

We set up the cache for the script and template parts in the following way. The code is as follows:

+ const cache = new Map();
// Omitted...
export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        // Omitted...
        load(id) {
            // Call the parseVuePartRequest function to parse the module ID
            const query = parseVuePartRequest(id);
            // If the parse result shows query.vue is true, it indicates a Vue component request, so return the original module ID so that this plugin will continue to process this module
            if (query.vue) {
              if (query.type === 'script') {
                // Return the script part code
+                const code = cache.get(id).content
+                return {
+                  code
+                }
              } else if (query.type === 'template') {
                // Return the template part code
+                const code = cache.get(id).code
+                return {
+                  code
+                }
              }
            }
        },
        transform(code, id) {  
            if (/\.vue$/.test(id)) {
                // Omitted ...
-                const scriptImport = `import script from '${id}?vue&type=script'\n`
+                const scriptID = `${id}?vue&type=script`;
+                const scriptImport = `import script from '${scriptID}'\n`
-                const templateImport = `import { render } from '${id}?vue&type=template'\n`
+                const templateID = `${id}?vue&type=template`;
+                const templateImport = `import { render } from '${templateID}'\n`
+                // Cache compilation results
+                cache.set(scriptID, scriptCode)
+                cache.set(templateID, templateCode)
                // Assembly
                const renderReplace = `script.render = render\n`
                const exportDefault = `export default script`
                return {
                    // Omitted...
                }
            }
        }
    }
}

In the above code, we use the Map object instance cache to cache the compiled results. This cache uses the virtual module ID we constructed ourselves as the key, and sets the corresponding compilation result of the script or template part as the value.

When we ran the above code, an error occurred:

image.png

This error indicates that we cannot read the cached result when trying to retrieve it. So we set breakpoints in the relevant places for testing and finally discovered the following issue.

The key we initially set for the cache was like this:

image.png

But in the load hook, it became:

image.png

We found that the path was not correctly escaped. This is handled internally by Rollup. We can use JSON.stringify to ensure any special characters (such as spaces in paths, special symbols in query parameters, etc.) are correctly escaped, thereby generating a valid module request path. So we modify as follows:

// Omitted...
export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        // Omitted...
        transform(code, id) {  
            if (/\.vue$/.test(id)) {
                // Omitted...
                // Virtual module for script import
                const scriptID = `${id}?vue&type=script`;
+                const scriptRequest = JSON.stringify(scriptID);
-                const scriptImport = `import script from '${scriptID}'\n`
+                const scriptImport = `import script from ${scriptRequest}\n`
                // Virtual module for template import
                const templateID = `${id}?vue&type=template`;
+                const templateRequest = JSON.stringify(templateID);
-                const templateImport = `import { render } from '${templateID}'\n`
+                const templateImport = `import { render } from ${templateRequest}\n`
                // Cache compilation results
                cache.set(scriptID, scriptCode)
                cache.set(templateID, templateCode)
                // Omitted...
                return {
                  // Omitted...
                }
            }     
        }
    }
}

We ran the code again and found it was successful.

image.png

The compilation result is as follows:

image.png

Running the Compiled Result Code

In the previous section, we implemented the compilation of Vue files. Since our package output format is ESM, we can test it in the following way.

We go back to the root directory's src/main.js and modify the test code as follows:

import { createApp } from 'vue'
import App from '../rollup-plugin/dist/output'

const app = createApp(App)
app.mount('#app')

Then modify the content of the root directory's vite.config.js file:

import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    Vue(),
  ]
})

If you haven't installed the Vue3 dependency package yet, install the Vue3 dependency package first.

We start the Vite development mode in the root directory, and finally we can see that the Vite project starts normally. The result is as follows:

image.png

In the browser's network window, we can also see that the packaged Vue file is loaded. The result is as follows:

image.png

The log print window also printed the log output from the component's setup method:

image.png

Compiling the Style Module

For the style module, similar to the script and template modules, it needs to be compiled through the @vue/compiler-sfc package to handle the capabilities Vue itself assigns to the style module, such as whether the scoped attribute is used, and then processing the scoped attribute. In general, the following issues need to be handled:

After compilation by the @vue/compiler-sfc package, it may also need to be processed by Sass, Less, or Stylus compilers into plain CSS code. After this processing, the style part of the Vue file will be converted into standard CSS and inserted into the final packaged JavaScript code, or referenced in a separate CSS file.

Here, we will only discuss the case of inserting into the packaged JavaScript code. So how do we insert the compiled plain CSS code into the packaged JavaScript code?

For example, the CSS code in the style part of our App.vue file above will eventually be compiled into:

div {
    color: red;
}

Obviously, this code is a string and cannot be directly placed into a JavaScript file. So we can store it in a variable, allowing it to be placed into a JavaScript file.

const cssBlock = `
div {
    color: red;
}
`

Then we can dynamically add it to the HTML page using JavaScript code. The implementation is also very simple: directly use createElement to create a style tag element, then insert it into the page's head element. The code is as follows:

const cssBlock = `div {color: red;}`
function styleInject(css) {
    // Get the page's head tag element
    const head = document.getElementsByTagName('head')[0]
    // Create a style element
    const style = document.createElement('style')
    style.type = 'text/css'
    // Insert into the page's `head` element
    head.appendChild(style)
    // Add the CSS content to the style element
    style.appendChild(document.createTextNode(css))
}

styleInject(cssBlock)

This is the implementation principle for adding compiled CSS code to the page. Next, let's proceed with the specific implementation.

Like the script and template modules, the style part also needs to be processed through virtual modules, and there may be multiple style parts. So our code implementation is as follows:

export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        // Omitted...
        transform(code, id) {  
          if (/\.vue$/.test(id)) {
              // Omitted...
+              // Compile style module
+              let stylesImport = ``
+              if (result.descriptor.styles.length) {
+                result.descriptor.styles.forEach((style, i) => {
+                  // Virtual module for style import
+                  const query = `?vue&type=style&index=${i}&lang.css`
+                  const styleRequest = id + query
+                  stylesImport += `\nimport ${JSON.stringify(styleRequest)}`
+                  // Compile style module
+                  const styleCode = compiler.compileStyle({
+                    id: `data-v-${scopeId}`,
+                    source: style.content,
+                    scoped: style.scoped // Whether scoped is used
+                  })
+                  // Cache style module
+                  cache.set(styleRequest, styleCode)
+                })
+              }
              // Omitted...
              return {
                code: `

+                    ${stylesImport}
                `
              }
          }
        }
    }
}

It is worth noting that we added the lang.css part after the style file path parameter to distinguish which style preprocessor language is used. If it's scss, it ends with lang.scss, and so on. Since we only used plain CSS in the App.vue file, we ended it with .css.

Next, we need to load the style part code from the cache in the load hook. The code implementation is as follows:

export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        // Omitted...
        load(id) {
            // Call the parseVuePartRequest function to parse the module ID
            const query = parseVuePartRequest(id);
            // If the parse result shows query.vue is true, it indicates a Vue component request, so return the original module ID so that this plugin will continue to process this module
            if (query.vue) {
              if (query.type === 'script') {
                // Omitted...
+              } else if (query.type === 'style') {
+                  // Return the style part code
+                  const code = cache.get(id).code
+                  return {
+                    code,
+                  }
+              }
            }
        },
        // Omitted...
    }
}

We know that the CSS code currently loaded in the load hook cannot be directly placed into a JavaScript file, so we need to process it in the transform hook. Of course, we could also process it in the load hook before returning it. Our current approach is to process it in the transform hook, and the solution is the dynamic CSS code addition to the page scheme we mentioned above.

export default function pluginVue() {
    return {
        name: 'rollup-plugin-vue',
        // Omitted...
        transform(code, id) {  
          if (/\.vue$/.test(id)) {
            // Omitted...
+          } else if (/\.css$/.test(id))  {
+            const styleCode = `
+ const cssBlock = '${JSON.stringify(code)}';\n
+ function styleInject(css) {\n
+  const head = document.getElementsByTagName('head')[0]\n
+  const style = document.createElement('style')\n
+  style.type = 'text/css'\n
+  head.appendChild(style)\n
+  style.appendChild(document.createTextNode(css))\n
+ }\n
+ styleInject(cssBlock)\n
+            `
+            return {
+              code: styleCode
+            }
+          }
        }
    }
}

At this point, when we run pnpm build again in the ./rollup-plugin/ directory, we can see the ./rollup-plugin/dist/output.js file, which has already compiled our CSS code into it.

image.png

Now, when we go back to the root directory and run the code in our ./src/main.js, we find that our CSS code has run successfully:

image.png

At this point, the compilation of the CSS code in our Vue file is also achieved.

Summary

Through the step-by-step derivation in this article, we have fully implemented the compilation process of a Vue single-file component using a Rollup plugin. Starting from the basic structure of the plugin, we utilized core APIs provided by @vue/compiler-sfc, such as parse, compileScript, compileTemplate, and compileStyle, to extract and compile the template, script, and style parts of a Vue file into independent intermediate products. Among them, the template is compiled into a render function, the script (especially <script setup>) has its compile macros and TypeScript converted into standard JavaScript, and the style, after scoping (scoped) and CSS variable binding (v-bind) processing, generates a plain CSS string. This step-by-step compilation process not only demonstrates the power of Vue's official compilation toolchain but also reveals the underlying logic of modern front-end build tools when handling component-based frameworks.

Throughout the implementation, the clever combination of virtual module technology and caching mechanisms is the key to ensuring the Rollup plugin works correctly. We constructed module IDs with query parameters (like App.vue?vue&type=script) for each compiled part (script, template, style) and intercepted these requests in the resolveId and load hooks, returning the cached compilation results. This design allows Rollup to handle different parts of a Vue file just like ordinary JavaScript modules, while avoiding repeated compilation and improving build performance. Especially for the style module, by injecting the runtime styleInject function, we dynamically added CSS strings to the page, achieving on-demand loading of style modules. This strategy has practical value in both development environments and production packaging.

Ultimately, we successfully integrated the compiled code into a complete Vue component object and verified it could run in a real project. The implementation of this plugin not only covers the core path of Vue single-file component compilation but also reserves clear interfaces for subsequent expansion of other features (such as CSS preprocessor support, custom block processing, hot updates, etc.). Through this hands-on practice, readers can deeply understand the execution order of Rollup plugin hooks, the usage of virtual modules, and the internal working principles of Vue's official compilation tools, thereby gaining the ability to independently customize and optimize the Vue build process, laying a solid foundation for complex engineering scenarios.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

Iceberry

Can CSS name obfuscation be done?