以前常常觉得,Vue 文件(单文件组件,Single File Component,SFC)的处理非常复杂,以至于很久一段时间,都不敢接触它,直到我看了 @vite/plugin-vue
的源码,才发现,这个过程并没有多复杂。因为 Vue 已经提供了 SFC 的编译能力,我们只需要站在巨人的肩膀上,简单地组合利用这些能力即可。
本文会用一个极其简单的例子,来说明如何处理一个 Vue 文件,并将其展示到页面中。在这个过程中,介绍 Vue 提供的编译能力,以及如何组合利用这些能力。
学完之后,你会明白 Vue 文件是如何一步一步被转换成 js 代码的,也能理解 vite
、rollup
这些打包工具,是如何对 Vue 文件进行打包的。
本文用到的项目,在该 Github 仓库[1]中,喜欢自己动手的同学,可以下载下来玩玩
有一个 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 官方文档提供的一个例子[2]
<!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 提供了 @vue/compiler-sfc
,专门用于 Vue 文件的预编译。下面我会一步一步演示 @vue/compiler-sfc
的使用方法。
在进行处理之前,首先要读取到代码的字符串
import { readFile, writeFile } from "fs-extra";
const file = await readFile("./src/main.vue", "utf8");
然后用 @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 setup
的代码, script setup
的代码是不能直接运行的,需要进行转换。script
和 script setup
的代码。// 这个 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
转成 render
函数
// 编译模板,转换成 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
函数,它们都是各自一个模块,而我们需要的是一个完整的 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
定义的变量。
那我们现在就可以合成代码了:
// 这个 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
,**不是 js
**,目的是编译 vue 的一些特殊的能力,例如 style scope
、v-bind()
、:deep()
等
// 一个 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
编译前):
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 的处理,真的没有多复杂,很多时候,一个技术看起来很复杂,但当你潜下心往里面钻研时,其实想要理解它,也并非那么困难。
如果这篇文章对您有所帮助,请帮忙点个赞 👍,您的鼓励是我创作路上的最大的动力。
[1]
Github 仓库: https://github.com/candy-Tong/vue-compiler-sfc-test
[2]
例子: https://v3.cn.vuejs.org/guide/introduction.html#%E5%A3%B0%E6%98%8E%E5%BC%8F%E6%B8%B2%E6%9F%93
[3]
CSS 变量注入: https://github.com/vuejs/rfcs/pull/231