以前常常觉得,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 源码?》
如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。