谁能处理 .vue 文件?
要想把 .vue 变成 .js,首先要知道什么工具能处理 .vue 文件,是不是很容易想到我们熟悉的 Webpack 和 Vite?
但是 Webpack 它本身也不能处理 .vue 文件,背后起作用的是 vue-loader。
同样地,Vite 也不是天生支持 vue,它能处理 .vue 文件是因为有了 @vitejs/plugin-vue 和 @vitejs/plugin-vue2 这两个插件,前者提供 Vue3 支持,后者提供 Vue2 支持。
vue-loader, @vitejs/plugin-vue, @vitejs/plugin-vue2 三者都依赖了一个核心模块 vue/compiler-sfc。
这里要注意 vue/compiler-sfc 和 @vue/compiler-sfc,前者相当于是一个调用入口和版本锁的作用,保证 vue 和 compiler-sfc 的版本一致性;后者是真正的代码实现。
@vue/compiler-sfc 才是真正具备处理 .vue 文件能力的源头,毕竟这种底层能力还是得看官方。@vue/compiler-sfc 具备两大能力,一个是 parse,一个是 compile。
.vue 被 parse 成了什么?
由于本文的焦点不是源码分析,所以也没必要逐行去分析 @vue/compiler-sfc 的源码,只要把握好 parse 的主干流程即可。
我们把 Vue 的源码下载下来。
git clone https://github.com/vuejs/core.git
然后运行 pnpm install 把依赖装好,保证一些依赖代码,TS 类型能看得更清楚。
我们打开 compiler-sfc 的 parse.ts 源码瞧一瞧,这里有一个很显眼的parse
方法。
了解一个函数的作用,最直接的就是看它的输入和输出,我们可以直接观察入参类型和返回值类型,做到窥一斑而知全豹。
parse
的入参有两个,一个是 string 类型的source
,它代表 .vue 文件的源码字符串;第二个参数类型是SFCParseOptions
,是一些条件参数,先不用关注。
parse
函数的返回值类型是SFCParseResult
,它下面有一个属性descriptor
。
descriptor
描述了 parse
的结果,比如 .vue 的 template 部分(也就是模板部分)被 parse 成SFCTemplateBlock
了。
SFCTemplateBlock
继承了SFCBlock
,并且有ast
属性,ast
就是抽象语法树,可以用结构化的数据描述模板的结构。
.vue 的 script 部分分为两块来看,一个是不带setup
属性的常规script
,一个是带setup
属性的 scriptSetup
,script
和 scriptSetup
的 AST 结构是不一样的。
.vue 的 style 部分则是被 parse 成一个数组styles
,它的类型是SFCStyleBlock[]
。为什么 style 的 parse 结果会是一个数组呢?这是因为我们可以在 .vue 文件中写多个 style 块。
SFCStyleBlock
包含了两个标记类的属性scoped
, module
,分别代表有没有启用 scoped 和 css module 特性。
parse 过程还会得到cssVars
,它通过分析样式中的v-bind()
得到。
slotted
代表你有没有使用 scoped 样式块,并且在这个 scoped 样式块中用了 slot 样式穿透语法:slotted()
。
了解了大致结构后,我们可以动手测试一下,加深印象。
我们准备一个简单的 test.vue 作为测试的源文件。
然后新建一个 test-compiler-sfc.js 用于编写测试逻辑,主要就是读取 test.vue 文件内容,然后调用parse
函数得到解析结果。可以断点调试,浏览一下parseResult
到底是什么样的数据,包含了哪些内容。
compiler 主要做了什么?
@vue/compiler-sfc 中有几个以 compile 开头的函数,分别是compileScirpt
, compileTemplate
, compileStyle
,我们挑一些重点看一看。
compileScript
compileScript
负责编译脚本,它的第一个参数是descriptor
,这是因为 Vue3 支持同时写 script 块和 setupScript 块,编译时需要把两个 block 整合,而 parse 得到的descriptor
中可以拿到这些信息。
由于 script 不仅支持langt="js"
,还支持 ts, jsx, tsx,所以compileScript
还会根据情况引入相关 babel 插件。
考虑到 Vue3 支持 v-bind in CSS 特性,编译脚本时还要考虑注入useCssVars
。
如果用到了 setup 语法糖,则会有更复杂的分析过程,因为 setup 语法糖带来了一些编译时特性(比如编译宏 defineProps 等),这里不再深入研究。
compileTemplate
compileTemplate
负责编译模板,先看看它的参数类型和返回值类型。
scoped
是依据 styles 中有没有 scoped 属性来决定的,用于给标签加data-v-xxx
属性。
slotted
来源于parse
过程中得到的slotted
,这个是跟插槽有关的。
compileTemplate
通过preprocessLang
支持模板预处理,基于此可以支持类似 pug 之类的模板语法。
compileTemplate.ts 代码量不多,其编译的核心工作是交给了 @vue/compiler-dom 和 @vue/compiler-core 处理(这里暂时不讨论 ssr)。
沿着调用链路找,可以观察到整体过程是一个比较标准的 parse -> transform -> generate 三部曲。
template 最终是被编译为渲染函数了,所以其实也是 js,应该不会感到意外吧?
compileStyle
compileStyle
是编译样式的,同样地,我们先看看它的参数类型和返回值类型。
我们注意到SFCStyleCompileOptions
中有一个 id,它是组件的一个 uuid,可以用hash-sum生成,具体生成时与一些路径和文件名信息有关。这个 id 也是我们在观察 DOM 结构时看到的 data-v-xxxxx 的源头。
了解了以上关键信息后,我们可以在 test-compiler-sfc.js 中补充一些简单的调用。
export async function test() {
const fileName = "test.vue"
const filePath = resolve(__dirname, fileName)
const fileContent = await readFileSync(filePath)
const { descriptor } = parse(fileContent.toString(), { filename: fileName })
const rawShortFilePath = relative(process.cwd(), filePath)
.replace(/^(\.\.[\/\\])+/, \'\')
const shortFilePath = rawShortFilePath.replace(/\\/g, \'/\')
const id = hash(shortFilePath)
descriptor.id = id;
const styles = descriptor.styles.map(block => compileStyleBlock(block, descriptor))
console.log(styles)
const script = compileScript(descriptor, { id })
console.log(script)
const hasScoped = descriptor.styles.some((s) => s.scoped);
const template = compileTemplate({ source: descriptor.template.content, filename: descriptor.filename, id, scoped: hasScoped, slotted: descriptor.slotted })
console.log(template)
}
也可以打断点调试一下过程。
有了以上信息,相信你大概知道 .vue 源码是怎么变成 .js 文件了,只要合理地将一些编译结果组合起来,缝缝补补,最后加一个fs.writeFile
写入文件的操作,这应该难不倒你吧?
涉及的源码可在vue-pro-components c10 分支找到,欢迎 star 支持!
小结
我们大致了解了 @vue/compiler-sfc 做的事情,知道了是 @vue/compiler-sfc 提供了核心的 API,但是把这些 API 真正用到生产实践中还要考虑很多细节,需要合理地串起流程,同时考虑一些特殊场景。这些在 vue-loader 和 Vite 的插件 @vitejs/plugin-vue, @vitejs/plugin-vue2 中也有体现,感兴趣的朋友可以再去研究相关代码,这里不再展开。