以前常常觉得,Vue 文件(单文件组件,Single File Component,SFC)的处理非常复杂,以至于很久一段时间,都不敢接触它,直到我看了 @vite/plugin-vue
的源码,才发现,这个过程并没有多复杂。因为 Vue 已经提供了 SFC 的编译能力,我们只需要站在巨人的肩膀上,简单地组合利用这些能力即可。
本文会用一个极其简单的例子,来说明如何处理一个 Vue 文件,并将其展示到页面中。在这个过程中,介绍 Vue 提供的编译能力,以及如何组合利用这些能力。
学完之后,你会明白 Vue 文件是如何一步一步被转换成 js 代码的,也能理解 vite
、rollup
这些打包工具,是如何对 Vue 文件进行打包的。
本文用到的项目,在该 Github 仓库中,喜欢自己动手的同学,可以下载下来玩玩
一个简单的例子
有一个 main.vue 文件如下:
<template> <div class="message">{{ message }}</div> </template> <script> import { ref } from "vue"; export default { name: "Main", setup() { const message = ref("Main"); return { message, }; }, }; </script> <style scoped> .message { font-size: 60px; font-weight: 900; } </style>
接下来,我会一步一步带大家手动处理这个 Vue 文件,并将其展示到页面中。
我们首先来了解一下,如果不使用 Vue 文件,不进行编译,要如何使用 Vue
在浏览器直接使用 Vue
这是 Vue 官方文档提供的一个例子
<!DOCTYPE html> <html lang="en"> <head> <script src="https://unpkg.com/vue@next"></script> <meta charset="UTF-8"> <title>Title</title> </head> <body> <div id="app"></div> </body> <script> const Counter = { data() { return { counter: 0 } }, render(){ return Vue.h('h1','hello-world') } } Vue.createApp(Counter).mount('#app') </script> </html>
利用 script
标签全局加载 Vue,通过全局变量 window.Vue
来获取 Vue 模块。然后定义组件,创建 Vue 实例,并挂载到对应的 DOM。
页面效果如下:
上面的例子,是使用 js
来定义组件的。
那么如果我们用 Vue SFC 来定义组件,就需要将 Vue 文件,编译成 js 对象形式的 Vue 组件对象(像上述例子一样)
Vue 文件主要由 3 部分组成:
script
脚本template
模板,可选style
样式,可选
要分别将这三部分,转换成 js
并组合成一个 Vue 对象,浏览器才能正确的运行
如何编译 Vue SFC?
Vue 提供了 @vue/compiler-sfc
,专门用于 Vue 文件的预编译。下面我会一步一步演示 @vue/compiler-sfc
的使用方法。
解析 Vue 文件
在进行处理之前,首先要读取到代码的字符串
import { readFile, writeFile } from "fs-extra"; const file = await readFile("./src/main.vue", "utf8");
然后用 @vue/compiler-sfc
提供的解析器,对代码进行解析
import { parse } from "@vue/compiler-sfc"; const { descriptor, error } = parse(file);
这个是 Vue 文件的内容
<template> <div class="message">{{ message }}</div> </template> <script> import { ref } from "vue"; export default { name: "Main", setup() { const message = ref("Main"); return { message, }; }, }; </script> <style scoped> .message { font-size: 60px; font-weight: 900; } </style>
下图是 descriptor
的解析结果
其实 parse
函数,就是把一个 Vue 文件,分成 3 个部分:
template
块script
块和scriptSetup
块- 多个
style
块
这一步做的是解析,其实并没有对代码进行编译,可以看到,每个块的 content
字段,都是跟 Vue 文件是相同的。
值得注意的是,script
包括 script
块和 scriptSetup
块,scriptSetup
块在图中没有标注,是因为刚好我们的 Vue 文件,没有使用 script setup
的特性,因此它的值为空。
style
块允许有多个,因为可以同时出现多个 style
标签,而其他标签只能有一个(script
和 script setup
能同时存在各一个)。
解析的目的,是将一个 Vue 文件中的内容,拆分成不同的块,然后分别对它们进行编译
编译 script
编译 script
的目的有如下几个:
- 处理
script setup
的代码,script setup
的代码是不能直接运行的,需要进行转换。 - 合并
script
和script setup
的代码。 - 处理 CSS 变量注入
import { compileScript } from "@vue/compiler-sfc"; // 这个 id 是 scopeId,用于 css scope,保证唯一即可 const id = Date.now().toString(); const scopeId = `data-v-${id}`; // 编译 script,因为可能有 script setup,还要进行 css 变量注入 const script = compileScript(descriptor, { id: scopeId });
compileScript
返回结果如下:
import { ref } from "vue"; export default { name: "Main", setup() { const message = ref("Main"); return { message, }; }, };
可以看出编译后的 script
没有变化,因为这里的确不需要任何处理。
如果有 script setup
或者 css
变量注入,编译后的代码就会有变化,感兴趣的可以看看 main-with-css-inject.vue
或 main-with-script-setup.vue
这两个文件的编译结果。
编译 template
编译 template
,目的是将 template
转成 render
函数
import { compileTemplate } from "@vue/compiler-sfc"; // 编译模板,转换成 render 函数 const template = compileTemplate({ source: descriptor.template.content, filename: "main.vue", // 用于错误提示 id: scopeId, });
compileTemplate
函数返回值如下:
编译后的 render 函数如下:
import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" const _hoisted_1 = { class: "message" } export function render(_ctx, _cache) { return (_openBlock(), _createElementBlock("div", _hoisted_1, _toDisplayString(_ctx.message), 1 /* TEXT */)) }
这段代码,看起来好像一个函数都不认识。但其实,你只要把 _createElementBlock
当成 Vue.h
渲染函数来看,你就觉得非常熟悉了。
现在有了 script
和 render
函数,其实已经是可以把一个组件显示到页面上了,样式可以先不管,我们先把组件渲染出来,然后再加上样式
组合 script 和 render 函数
目前 script
和 render
函数,它们都是各自一个模块,而我们需要的是一个完整的 Vue 对象,即 render
函数需要作为 Vue 对象的一个属性。
可以采用以下这种方案:
// 将 script 保存到 main.vue.script.js,拿到的是 Vue 对象 import script from '/src/main.vue.script.js' // 将 render 函数保存到 main.vue.template.js,拿到的是 render 函数 import { render } from '/src/main.vue.template.js' // 将 style 函数保存到 main.vue.style.js,import 之后就直接创建 <style> 标签了 // 这个先不加,style 还没编译 // import '/src/main.style.template.js' // 给 Vue 对象设置 render 函数 script.render = render // 设置一些组件的信息,用于开发环境 script.__file = 'example.vue' script.__scopeId = 'xxxxxx' // 这里可以加入其它代码,例如热更新 export default script
但我们其实有更简洁的方式,就是直接将 script
和 template
这两个模块内联到代码中,这样就只有一个文件了。
于是我们可以这样做:
// 用于存放代码,最后 join('\n') 合并成一份完整代码 const codeList = []; codeList.push(script.content); codeList.push(template.code); const code = codeList.join('\n')
但这样做,其实是不行的,因为你会得到以下内容:
import { ref } from "vue"; export default { setup() { // setup 实现 }, }; // ----- 上面是 script 的内容,下面是 template 的内容 import { xxx } from "vue" export function render(_ctx, _cache) { // render 函数实现 }
因为用的是 export default
,组件没有存储到变量中,我们没法给 Vue 组件设置 render
函数
因此,@vue/compiler-sfc
贴心地给我们提供了一个工具函数 rewriteDefault
,它的作用如图:
将 export default
改成 const
定义的变量。
那我们现在就可以合成代码了:
import { compileScript, compileTemplate, rewriteDefault } from "@vue/compiler-sfc"; // 这个 id 是 scopeId,用于 css scope,保证唯一即可 const id = Date.now().toString(); const scopeId = `data-v-${id}`; // 编译 script,因为可能有 script setup,还要进行 css 变量注入 const script = compileScript(descriptor, { id: scopeId }); // 用于存放代码,最后 join('\n') 合并成一份完整代码 const codeList = []; // 重写 default codeList.push(rewriteDefault(script.content, "__sfc_main__")); codeList.push(`__sfc_main__.__scopeId='${scopeId}'`); // 编译模板,转换成 render 函数 const template = compileTemplate({ source: descriptor.template!.content, filename: "main.vue", // 用于错误提示 id: scopeId, }); codeList.push(template.code); codeList.push(`__sfc_main__.render=render`); codeList.push(`export default __sfc_main__`); // 将合成的代码写到本地 await writeFile("build.temp.js", code);
得到的代码如下:
import { ref } from "vue"; // vue 组件 const __sfc_main__ = { name: "Main", setup() { const message = ref("Main"); return { message, }; }, }; __sfc_main__.__scopeId='data-v-1656415804393' import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" const _hoisted_1 = { class: "message" } // render 函数 export function render(_ctx, _cache) { return (_openBlock(), _createElementBlock("div", _hoisted_1, _toDisplayString(_ctx.message), 1 /* TEXT */)) } // 设置 render 函数到组件 __sfc_main__.render=render export default __sfc_main__
虽然代码有点丑,但还是能看出来,它的是个 Vue 组件。
那么这个代码是不是能直接给浏览器用呢?
答案还是不能,因为浏览器无法导入裸模块,即 import "vue"
,浏览器是无法识别的,不知道从哪里获取 Vue 模块。
我们可以手动将 import { ref } from "vue";
改成 Vue.ref
,但是我们有更自动的方法 —— 用打包工具打包一遍。
打包代码
直接使用 esbuild 进行打包。
import { build } from "esbuild"; import { externalGlobalPlugin } from "esbuild-plugin-external-global"; await build({ entryPoints: ["build.temp.js"], // 入口文件 format: "esm", // 打包成 esm outfile: "bundle.js", // 设置打包文件的名字 bundle: true, // bundle 为 true 才是打包模式 external: ["vue"], plugins: [ externalGlobalPlugin({ vue: "window.Vue", // 将 import vue 模块,替换成 window.Vue }), ], });
- 将
vue
模块external
,即不参与打包(因为我们在index.html
已经全局引入了 Vue,如果不全局引入 Vue,则需要将vue
也打包到代码中) - 使用
externalGlobalPlugin
插件,让external
的Vue
模块从window.Vue
中获取。
打包完成的代码,就可以直接给浏览器使用了
<!DOCTYPE html> <html lang="en"> <head> <script src="https://unpkg.com/vue@next"></script> </head> <body> <div id="app"></div> </body> <script type="module"> // 引入刚刚打包好的代码 import Comp from './bundle.js' Vue.createApp(Comp).mount('#app') </script> </html>
现在组件已经渲染到界面中了:
编译 style
编译 style
,编译产物还是 style
,不是 js
,目的是编译 vue 的一些特殊的能力,例如 style scope
、v-bind()
、:deep()
等
import { compileStyle } from "@vue/compiler-sfc"; // 一个 Vue 文件,可能有多个 style 标签 for (const styleBlock of descriptor.styles) { const styleCode = compileStyle({ source: styleBlock.content, id, // style 的 scope id, filename: "main.vue", scoped: styleBlock.scoped, }); }
编译后的对象如下:
编译后的 style
代码:
.message[data-v-1656417674368] { font-size: 60px; font-weight: 900; }
这里加上了传入的 scopeId
为什么编译产物不是 js?
因为 style
使用的不一定是 css
,还可能是 less
、sass
等语法,还需要交给其他预处理器以及后处理器,进行处理
css 最后如何转成 js?
直接用 createElement
创建 style
标签,然后拼接到页面 body
即可
const styleDOM = ` var el = document.createElement('style') el.innerHTML = \`${styleCode.code}\` document.body.append(el); `;
css
其实都是全局的,在这段样式代码被加载时,style
标签就已经被创建,然后插入到页面了。因此 css
需要使用 scope
的方式用做样式的隔离,需要提供 scopeId
给 compileStyle
函数,用来生成 [data-v-1656417674368]
这种选择器,以免影响到全局样式。
style
完整的代码如下(放在 esbuild
编译前):
import { compileStyle } from "@vue/compiler-sfc"; for (const styleBlock of descriptor.styles) { const styleCode = compileStyle({ source: styleBlock.content, id, filename: "main.vue", scoped: styleBlock.scoped, }); const styleDOM = ` var el = document.createElement('style') el.innerHTML = \`${styleCode.code}\` document.body.append(el); `; codeList.push(styleDOM); }
编译后的代码,加入到 codeList 中,最终生成一份完整的代码,然后将这份代码进行打包即可。
最终的渲染结果:
总结
我们从一个非常简单的 Vue 文件,使用 @vue/compiler-sfc
,一步一步地将 Vue 文件进行编译处理,分别编译 script
、template
、style
,并将这三部分组装到一起,最后将其进行打包,打包后的文件就能够在浏览器中正确运行,并渲染出界面。
其实@vite/plugin-vue
的处理过程,与我们手动处理的过程,大致相同,不过还加上了热更新、编译缓存、拆分成虚拟模块等能力。
明白这个过程之后,回头看,其实 Vue 的处理,真的没有多复杂,很多时候,一个技术看起来很复杂,但当你潜下心往里面钻研时,其实想要理解它,也并非那么困难。
拓展阅读
- 《Vite Server 是如何处理页面资源的?》
- 《Vite 是如何兼容 Rollup 插件生态的》
- 《Vite 热更新的主要流程》
- 《五千字剖析 vite 是如何对配置文件进行解析的》
- 《如何调试 vite 源码?》
如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。