Vue 的单文件组件 (即 *.vue 文件,英文 Single-File Component,简称 SFC) 是一种特殊的文件格式,使我们能够将一个 Vue 组件的模板、逻辑与样式封装在单个文件中。
然而浏览器对于这种文件是不能够识别的。因此就需要解析成js文件。解析的过程是复杂的,幸运的是Vue 已经提供了 SFC 的编译能力。
compiler-core:和平台无关的编译器
compiler-dom:浏览器平台下的编译器,依赖于 compiler-core
compiler-sfc:单文件组件编译器,依赖于 compiler-core 和 compiler-dom
compiler-ssr:服务端渲染的编译器,依赖于 compiler-dom
reactivity:数据响应式系统,可以独立使用
runtime-core:和平台无关的运行时
runtime-dom:针对浏览器的运行时,处理原生 DOM API 和 事件等
runtime-test:为测试所编写的轻量级运行时,由于它渲染出来的 DOM 树其实是一个 js 对象,所以 这个运行时可以运行在所有的 js 环境里,可以用它来测试渲染是否正确,还可以用于序列化 DOM,触发 DOM 事件,以及记录更新中的某次 DOM 操作
server-renderer:用于服务端渲染
shared:vue 内部使用的公共 api
size-check:私有的包,不会发布到 npm,作用是在 tree-sharking 后检查包的大小
template-explorer:浏览器里运行的实时编译组件,它会输出 render 函数
vue 用来构建完整版的 vue,依赖于 compiler 和 runtime
下面来通过一个示例子。来看如何解析一个Vue文件。
首先我们创建App.Vue文件,内容如下:
<template>
<div class="title" >{{ title }}</div>
{{count}}
<button @click="count++">+++</button>
</template>
<script>
import { ref } from "vue";
export default {
name: "Main",
setup() {
const title = ref("Hello Vue");
const count = ref(1);
return {
title,
count
};
},
};
</script>
<style scoped>
.title {
font-size: 60px;
font-weight: 900;
color: red;
}
</style>
可以看到一个Vue文件通常包括3部分,分别是template,script和style。要解析vue文件。就需要对template,script和style分别解析。
首先我们将代码读取进内存。
const {readFileSync,writeFileSync} = require('fs-extra')
const {parse, compileScript, compileTemplate, rewriteDefault, compileStyle} = require("@vue/compiler-sfc");
const file = readFileSync("./App.vue", "utf8");
const {descriptor} = parse(file);
console.log(descriptor);
打印parse方法读取的字符串:
{
filename: 'anonymous.vue',
source: '<template>\n' +
' <div class="title" >{{ message }}</div>\n' +
' {{count}}\n' +
' <button @click="count++">+++</button>\n' +
'</template>\n' +
'\n' +
'<script>\n' +
'import { ref } from "vue";\n' +
'\n' +
'export default {\n' +
' name: "Main",\n' +
' setup() {\n' +
' const message = ref("Hello Vue");\n' +
' const count = ref(1);\n' +
' return {\n' +
' message,\n' +
' count\n' +
' };\n' +
' },\n' +
'};\n' +
'</script>\n' +
'\n' +
'<style scoped>\n' +
'.title {\n' +
' font-size: 60px;\n' +
' font-weight: 900;\n' +
' color: red;\n' +
'}\n' +
'</style>\n',
template: {
type: 'template',
content: '\n' +
' <div class="title" >{{ message }}</div>\n' +
' {{count}}\n' +
' <button @click="count++">+++</button>\n',
loc: {
source: '\n' +
' <div class="title" >{{ message }}</div>\n' +
' {{count}}\n' +
' <button @click="count++">+++</button>\n',
start: [Object],
end: [Object]
},
attrs: {},
ast: {
type: 1,
ns: 0,
tag: 'template',
tagType: 0,
props: [],
isSelfClosing: false,
children: [Array],
loc: [Object],
codegenNode: undefined
},
map: {
version: 3,
sources: [Array],
names: [],
mappings: ';EACE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;EACtC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;EACR,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC',
file: 'anonymous.vue',
sourceRoot: '',
sourcesContent: [Array]
}
},
script: {
type: 'script',
content: '\n' +
'import { ref } from "vue";\n' +
'\n' +
'export default {\n' +
' name: "Main",\n' +
' setup() {\n' +
' const message = ref("Hello Vue");\n' +
' const count = ref(1);\n' +
' return {\n' +
' message,\n' +
' count\n' +
' };\n' +
' },\n' +
'};\n',
loc: {
source: '\n' +
'import { ref } from "vue";\n' +
'\n' +
'export default {\n' +
' name: "Main",\n' +
' setup() {\n' +
' const message = ref("Hello Vue");\n' +
' const count = ref(1);\n' +
' return {\n' +
' message,\n' +
' count\n' +
' };\n' +
' },\n' +
'};\n',
start: [Object],
end: [Object]
},
attrs: {},
map: {
version: 3,
sources: [Array],
names: [],
mappings: ';AAOA,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;;AAEzB,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE;EACb,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;EACZ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE;IACN,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAChC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACpB,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE;MACL,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;MACP,CAAC,CAAC,CAAC,CAAC;IACN,CAAC;EACH,CAAC;AACH,CAAC',
file: 'anonymous.vue',
sourceRoot: '',
sourcesContent: [Array]
}
},
scriptSetup: null,
styles: [
{
type: 'style',
content: '\n.title {\n font-size: 60px;\n font-weight: 900;\n color: red;\n}\n',
loc: [Object],
attrs: [Object],
scoped: true,
map: [Object]
}
],
customBlocks: [],
cssVars: [],
slotted: false,
shouldForceReload: [Function: shouldForceReload]
}
可以看到parse方法并没有做任何的解析工作。仅仅是将文件分成template,script和style。并添加对应的属性对象。另外注意点是styles是一个数组。这是因为一个单文件组件中可能不止一个style标签。
接下来就需要分别对template,script和style进行解析了。而Vue中也提供了对应的方法。
1 解析template
const {compileTemplate} = require("@vue/compiler-sfc");
const id = Date.now().toString();
// 编译模板,转换成 render 函数
const template = compileTemplate({
source: descriptor.template.content,
filename: "app.vue",
id: scopeId,
});
直接使用compileTemplate方法就可以将一个template转换成 render 函数。打印template内容如下:
console.log(template);
{
code: 'import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, createTextVNode as _createTextVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"\n' +
'\n' +
'const _hoisted_1 = { class: "title" }\n' +
'\n' +
'export function render(_ctx, _cache) {\n' +
' return (_openBlock(), _createElementBlock(_Fragment, null, [\n' +
' _createElementVNode("div", _hoisted_1, _toDisplayString(_ctx.message), 1 /* TEXT */),\n' +
' _createTextVNode(" " + _toDisplayString(_ctx.count) + " ", 1 /* TEXT */),\n' +
' _createElementVNode("button", {\n' +
' onClick: _cache[0] || (_cache[0] = $event => (_ctx.count++))\n' +
' }, "+++")\n' +
' ], 64 /* STABLE_FRAGMENT */))\n' +
'}',
....
}
2 解析Script
// 这个 id 是 scopeId,用于 css scope,保证唯一即可
const id = Date.now().toString();
const scopeId = `data-v-${id}`;
// 编译 script,因为可能有 script setup,还要进行 css 变量注入
const script = compileScript(descriptor, {id: scopeId});
3 解析Style
// 一个 Vue 文件,可能有多个 style 标签
for (const styleBlock of descriptor.styles) {
const styleCode = compileStyle({
source: styleBlock.content,
id, // style 的 scope id,
filename: "app.vue",
scoped: styleBlock.scoped,
});
const styleDOM = `
var el = document.createElement('style')
el.innerHTML = `${styleCode.code}`
document.body.append(el);
`;
}
我们分别解析了template,script和style文件。下面只需要将3个部分解析的代码合并下。然后返回就可以了。
// 用于存放代码,最后 join('\n') 合并成一份完整代码
const codeList = [];
const script = compileScript(...)
// script
codeList.push(rewriteDefault(script.content, "__sfc_main__"));
codeList.push(`__sfc_main__.__scopeId='${scopeId}'`);
// template
const template = compileTemplate(...)
codeList.push(template.code);
codeList.push(`__sfc_main__.render=render`);
codeList.push(`export default __sfc_main__`);
// style
for (const styleBlock of descriptor.styles) {
const styleCode = compileStyle({
source: styleBlock.content,
id, // style 的 scope id,
filename: "app.vue",
scoped: styleBlock.scoped,
});
const styleDOM = `
var el = document.createElement('style')
el.innerHTML = `${styleCode.code}`
document.body.append(el);
`;
codeList.push(styleDOM)
}
整合之后codeList中存储的就是解析后的代码了。然而这个代码还是不能使用的。虽然浏览器支持module导入。但是识别以/,./,../这种路径开头。
所以此时我们就可以通过rollup或者esBuilder进行打包
const {build} = require("esbuild")
const {externalGlobalPlugin} = require("esbuild-plugin-external-global")
/**
* 将 vue 模块 external,即不参与打包(因为我们在 index.html 已经全局引入了 Vue,如果不全局引入 Vue,则需要将 vue 也打包到代码中)
* 使用 externalGlobalPlugin 插件,让 external 的 Vue 模块从 window.Vue 中获取。
*/
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
}),
],
});
然后直接在index.html引入就行了
<!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 Cpm from './bundle.js'
Vue.createApp(Cpm).mount('#app')
</script>
</html>