<template> <h1 class="msg">{{ msg }}</h1> </template> <script setup lang="ts"> import { ref } from "vue"; const msg = ref("hello word"); </script> <style scoped> .msg { color: red; font-weight: bold; } </style>这个例子很简单,在setup中定义了msg变量,然后在template中将msg渲染出来。下面这个是我从network中找到的编译后的js文件,已经精简过了:
import { createElementBlock as _createElementBlock, defineComponent as _defineComponent, openBlock as _openBlock, toDisplayString as _toDisplayString, ref, } from "/node_modules/.vite/deps/vue.js?v=23bfe016"; import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css"; const _sfc_main = _defineComponent({ __name: "App", setup(__props, { expose: __expose }) { __expose(); const msg = ref("hello word"); const __returned__ = { msg }; return __returned__; }, }); const _hoisted_1 = { class: "msg" }; function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { return ( _openBlock(), _createElementBlock( "h1", _hoisted_1, _toDisplayString($setup.msg), 1 /* TEXT */ ) ); } __sfc__.render = render; export default _sfc_main;编译后的js代码中我们可以看到主要有三部分,想必你也猜到了这三部分刚好对应vue文件的那三块。
import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";对应vue文件中的<style scoped>模块。
假如vue文件编译为js文件是一个毛线团,那么他的线头一定是vite.config.ts文件中使用@vitejs/plugin-vue的地方。通过这个线头开始debug我们就能够梳理清楚完整的工作流程。
function vuePlugin(rawOptions = {}) { const options = shallowRef({ compiler: null, // 省略... }); return { name: "vite:vue", handleHotUpdate(ctx) { // ... }, config(config) { // .. }, configResolved(config) { // .. }, configureServer(server) { // .. }, buildStart() { // .. }, async resolveId(id) { // .. }, load(id, opt) { // .. }, transform(code, id, opt) { // .. } }; }@vitejs/plugin-vue是作为一个plugins插件在vite中使用,vuePlugin函数返回的对象中的buildStart、transform方法就是对应的插件钩子函数。vite会在对应的时候调用这些插件的钩子函数,比如当vite服务器启动时就会调用插件里面的buildStart等函数,当vite解析每个模块时就会调用transform等函数。更多vite钩子相关内容查看官网。
我们这里主要看buildStart和transform两个钩子函数,分别是服务器启动时调用和解析每个模块时调用。给这两个钩子函数打上断点。
buildStart() { const compiler = options.value.compiler = options.value.compiler || resolveCompiler(options.value.root); }将鼠标放到options.value.compiler上面我们看到此时options.value.compiler的值为null,所以代码会走到resolveCompiler函数中,点击Step Into(F11)走到resolveCompiler函数中。看到resolveCompiler函数代码如下:
function resolveCompiler(root) { const compiler = tryResolveCompiler(root) || tryResolveCompiler(); return compiler; } function tryResolveCompiler(root) { const vueMeta = tryRequire("vue/package.json", root); if (vueMeta && vueMeta.version.split(".")[0] >= 3) { return tryRequire("vue/compiler-sfc", root); } }在resolveCompiler函数中调用了tryResolveCompiler函数,在tryResolveCompiler函数中判断当前项目是否是vue3.x版本,然后将vue/compiler-sfc包返回。所以经过初始化后options.value.compiler的值就是vue的底层库vue/compiler-sfc,记住这个后面会用。
然后点击Continue(F5)放掉断点,在浏览器中打开对应的页面,比如:http://localhost:5173/ 。此时vite将会编译这个页面要用到的所有文件,就会走到transform钩子函数断点中了。由于解析每个文件都会走到transform钩子函数中,但是我们只关注App.vue文件是如何解析的,所以为了方便我们直接在transform函数中添加了下面这段代码,并且删掉了原来在transform钩子函数中打的断点,这样就只有解析到App.vue文件的时候才会走到断点中去。
transform(code, id, opt) { const { filename, query } = parseVueRequest(id); if (!query.vue) { return transformMain( code, filename, options.value, this, ssr, customElementFilter.value(filename) ); } else { const descriptor = query.src ? getSrcDescriptor(filename, query) || getTempSrcDescriptor(filename, query) : getDescriptor(filename, options.value); if (query.type === "style") { return transformStyle( code, descriptor, Number(query.index || 0), options.value, this, filename ); } } }transformMain函数
4.调用genStyleCode函数传入第一步生成的descriptor对象将<style scoped>模块编译为类似这样的import语句,import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";。
function createDescriptor(filename, source, { root, isProduction, sourceMap, compiler, template }, hmr = false) { const { descriptor, errors } = compiler.parse(source, { filename, sourceMap, templateParseOptions: template?.compilerOptions }); const normalizedPath = slash(path.normalize(path.relative(root, filename))); descriptor.id = getHash(normalizedPath + (isProduction ? source : "")); return { descriptor, errors }; }这个compiler是不是觉得有点熟悉?compiler是调用createDescriptor函数时传入的第三个参数解构而来,而第三个参数就是options。还记得我们之前在vite启动时调用了buildStart钩子函数,然后将vue底层包vue/compiler-sfc赋值给options的compiler属性。那这里的compiler.parse其实就是调用的vue/compiler-sfc包暴露出来的parse函数,这是一个vue暴露出来的底层的API,这篇文章我们不会对底层API进行源码解析,通过查看parse函数的输入和输出基本就可以搞清楚parse函数的作用。下面这个是parse函数的类型定义:
export function parse( source: string, options: SFCParseOptions = {}, ): SFCParseResult {}从上面我们可以看到parse函数接收两个参数,第一个参数为vue文件的源代码,在我们这里就是App.vue中的code字符串,第二个参数是一些options选项。我们再来看看parse函数的返回值SFCParseResult,主要有类型为SFCDescriptor的descriptor属性需要关注。
export interface SFCParseResult { descriptor: SFCDescriptor errors: (CompilerError | SyntaxError)[] } export interface SFCDescriptor { filename: string source: string template: SFCTemplateBlock | null script: SFCScriptBlock | null scriptSetup: SFCScriptBlock | null styles: SFCStyleBlock[] customBlocks: SFCBlock[] cssVars: string[] slotted: boolean shouldForceReload: (prevImports: Record<string, ImportBinding>) => boolean }仔细看看SFCDescriptor类型,其中的template属性就是App.vue文件对应的template标签中的内容,里面包含了由App.vue文件中的template模块编译成的AST抽象语法树和原始的template中的代码。
我们再来看看styles属性,这里的styles属性是一个数组,是因为我们可以在vue文件中写多个style模块,里面同样包含了App.vue中的style模块中的内容。
所以这一步执行createDescriptor函数生成的descriptor对象中主要有三个属性,template属性包含了App.vue文件中的template模块code字符串和AST抽象语法树,scriptSetup属性包含了App.vue文件中的<script setup>模块的code字符串,styles属性包含了App.vue文件中<style>模块中的code字符串。createDescriptor函数的执行流程图如下:
const { code: scriptCode, map: scriptMap } = await genScriptCode( descriptor, options, pluginContext, ssr, customElement );将断点走到genScriptCode函数内部,在genScriptCode函数中主要就是这行代码: const script = resolveScript(descriptor, options, ssr, customElement);。将第一步生成的descriptor对象作为参数传给resolveScript函数,返回值就是编译后的js代码,genScriptCode函数的代码简化后如下:
async function genScriptCode(descriptor, options, pluginContext, ssr, customElement) { let scriptCode = `const ${scriptIdentifier} = {}`; const script = resolveScript(descriptor, options, ssr, customElement); if (script) { scriptCode = script.content; map = script.map; } return { code: scriptCode, map }; }我们继续将断点走到resolveScript函数内部,发现resolveScript中的代码其实也很简单,简化后的代码如下:
function resolveScript(descriptor, options, ssr, customElement) { let resolved = null; resolved = options.compiler.compileScript(descriptor, { ...options.script, id: descriptor.id, isProd: options.isProduction, inlineTemplate: isUseInlineTemplate(descriptor, !options.devServer), templateOptions: resolveTemplateCompilerOptions(descriptor, options, ssr), sourceMap: options.sourceMap, genDefaultAs: canInlineMain(descriptor, options) ? scriptIdentifier : void 0, customElement }); return resolved; }这里的options.compiler我们前面第一步的时候已经解释过了,options.compiler对象实际就是vue底层包vue/compiler-sfc暴露的对象,这里的options.compiler.compileScript()其实就是调用的vue/compiler-sfc包暴露出来的compileScript函数,同样也是一个vue暴露出来的底层的API,后面我们的分析defineOptions等文章时会去深入分析compileScript函数,这篇文章我们不会去读compileScript函数的源码。通过查看compileScript函数的输入和输出基本就可以搞清楚compileScript函数的作用。下面这个是compileScript函数的类型定义:
export function compileScript( sfc: SFCDescriptor, options: SFCScriptCompileOptions, ): SFCScriptBlock{}这个函数的入参是一个SFCDescriptor对象,就是我们第一步调用生成createDescriptor函数生成的descriptor对象,第二个参数是一些options选项。我们再来看返回值SFCScriptBlock类型:
export interface SFCScriptBlock extends SFCBlock { type: 'script' setup?: string | boolean bindings?: BindingMetadata imports?: Record<string, ImportBinding> scriptAst?: import('@babel/types').Statement[] scriptSetupAst?: import('@babel/types').Statement[] warnings?: string[] /** * Fully resolved dependency file paths (unix slashes) with imported types * used in macros, used for HMR cache busting in @vitejs/plugin-vue and * vue-loader. */ deps?: string[] } export interface SFCBlock { type: string content: string attrs: Record<string, string | true> loc: SourceLocation map?: RawSourceMap lang?: string src?: string }返回值类型中主要有scriptAst、scriptSetupAst、content这三个属性,scriptAst为编译不带setup属性的script标签生成的AST抽象语法树。scriptSetupAst为编译带setup属性的script标签生成的AST抽象语法树,content为vue文件中的script模块编译后生成的浏览器可执行的js代码。下面这个是执行vue/compiler-sfc的compileScript函数返回结果:
async function genScriptCode(descriptor, options, pluginContext, ssr, customElement) { let scriptCode = `const ${scriptIdentifier} = {}`; const script = resolveScript(descriptor, options, ssr, customElement); if (script) { scriptCode = script.content; map = script.map; } return { code: scriptCode, map }; }genScriptCode函数的执行流程图如下:
({ code: templateCode, map: templateMap } = await genTemplateCode( descriptor, options, pluginContext, ssr, customElement ))同样将断点走到genTemplateCode函数内部,在genTemplateCode函数中主要就是返回transformTemplateInMain函数的返回值,genTemplateCode函数的代码简化后如下:
async function genTemplateCode(descriptor, options, pluginContext, ssr, customElement) { const template = descriptor.template; return transformTemplateInMain( template.content, descriptor, options, pluginContext, ssr, customElement ); }我们继续将断点走进transformTemplateInMain函数,发现这里也主要是调用compile函数,代码如下:
function transformTemplateInMain(code, descriptor, options, pluginContext, ssr, customElement) { const result = compile( code, descriptor, options, pluginContext, ssr, customElement ); return { ...result, code: result.code.replace( /\nexport (function|const) (render|ssrRender)/, "\n$1 _sfc_$2" ) }; }同理将断点走进到compile函数内部,我们看到compile函数的代码是下面这样的:
function compile(code, descriptor, options, pluginContext, ssr, customElement) { const result = options.compiler.compileTemplate({ ...resolveTemplateCompilerOptions(descriptor, options, ssr), source: code }); return result; }同样这里也用到了options.compiler,调用options.compiler.compileTemplate()其实就是调用的vue/compiler-sfc包暴露出来的compileTemplate函数,这也是一个vue暴露出来的底层的API。不过这里和前面不同的是compileTemplate接收的不是descriptor对象,而是一个SFCTemplateCompileOptions类型的对象,所以这里需要调用resolveTemplateCompilerOptions函数将参数转换成SFCTemplateCompileOptions类型的对象。这篇文章我们不会对底层API进行解析。通过查看compileTemplate函数的输入和输出基本就可以搞清楚compileTemplate函数的作用。下面这个是compileTemplate函数的类型定义:
export function compileTemplate( options: SFCTemplateCompileOptions, ): SFCTemplateCompileResults {}入参options主要就是需要编译的template中的源代码和对应的AST抽象语法树。我们来看看返回值SFCTemplateCompileResults,这里面的code就是编译后的render函数字符串。
export interface SFCTemplateCompileResults { code: string ast?: RootNode preamble?: string source: string tips: string[] errors: (string | CompilerError)[] map?: RawSourceMap }
const stylesCode = await genStyleCode( descriptor, pluginContext, customElement, attachedProps );我们将断点走进genStyleCode函数内部,发现和前面genScriptCode和genTemplateCode函数有点不一样,下面这个是我简化后的genStyleCode函数代码:
async function genStyleCode(descriptor, pluginContext, customElement, attachedProps) { let stylesCode = ``; if (descriptor.styles.length) { for (let i = 0; i < descriptor.styles.length; i++) { const style = descriptor.styles[i]; const src = style.src || descriptor.filename; const attrsQuery = attrsToQuery(style.attrs, "css"); const srcQuery = style.src ? style.scoped ? `&src=${descriptor.id}` : "&src=true" : ""; const directQuery = customElement ? `&inline` : ``; const scopedQuery = style.scoped ? `&scoped=${descriptor.id}` : ``; const query = `?vue&type=style&index=${i}${srcQuery}${directQuery}${scopedQuery}`; const styleRequest = src + query + attrsQuery; stylesCode += ` import ${JSON.stringify(styleRequest)}`; } } return stylesCode; }我们前面讲过因为vue文件中可能会有多个style标签,所以descriptor对象的styles属性是一个数组。遍历descriptor.styles数组,我们发现for循环内全部都是一堆赋值操作,没有调用vue/compiler-sfc包暴露出来的任何API。将断点走到 return stylesCode;,看看stylesCode到底是什么东西?
transform(code, id, opt) { const { filename, query } = parseVueRequest(id); if (!query.vue) { // 省略 } else { const descriptor = query.src ? getSrcDescriptor(filename, query) || getTempSrcDescriptor(filename, query) : getDescriptor(filename, options.value); if (query.type === "style") { return transformStyle( code, descriptor, Number(query.index || 0), options.value, this, filename ); } } }当query中有vue字段,并且query中type字段值为style时就会执行transformStyle函数,我们给transformStyle函数打个断点。当执行上面那条import语句时就会走到断点中,我们进到transformStyle中看看。
async function transformStyle(code, descriptor, index, options, pluginContext, filename) { const block = descriptor.styles[index]; const result = await options.compiler.compileStyleAsync({ ...options.style, filename: descriptor.filename, id: `data-v-${descriptor.id}`, isProd: options.isProduction, source: code, scoped: block.scoped, ...options.cssDevSourcemap ? { postcssOptions: { map: { from: filename, inline: false, annotation: false } } } : {} }); return { code: result.code, map }; }transformStyle函数的实现我们看着就很熟悉了,和前面处理template和script一样都是调用的vue/compiler-sfc包暴露出来的compileStyleAsync函数,这也是一个vue暴露出来的底层的API。同样我们不会对底层API进行解析。通过查看compileStyleAsync函数的输入和输出基本就可以搞清楚compileStyleAsync函数的作用。
export function compileStyleAsync( options: SFCAsyncStyleCompileOptions, ): Promise<SFCStyleCompileResults> {}我们先来看看SFCAsyncStyleCompileOptions入参:
interface SFCAsyncStyleCompileOptions extends SFCStyleCompileOptions { isAsync?: boolean modules?: boolean modulesOptions?: CSSModulesOptions } // 堆代码 duidaima.com interface SFCStyleCompileOptions { source: string filename: string id: string scoped?: boolean trim?: boolean isProd?: boolean inMap?: RawSourceMap preprocessLang?: PreprocessLang preprocessOptions?: any preprocessCustomRequire?: (id: string) => any postcssOptions?: any postcssPlugins?: any[] map?: RawSourceMap }入参主要关注几个字段,source字段为style标签中的css原始代码。scoped字段为style标签中是否有scoped attribute。id字段为我们在观察 DOM 结构时看到的 data-v-xxxxx。这个是debug时入参截图:
interface SFCStyleCompileResults { code: string map: RawSourceMap | undefined rawResult: Result | LazyResult | undefined errors: Error[] modules?: Record<string, string> dependencies: Set<string> }这个是debug时compileStyleAsync函数返回值的截图:
async function transformMain(code, filename, options, pluginContext, ssr, customElement) { const { descriptor, errors } = createDescriptor(filename, code, options); const { code: scriptCode, map: scriptMap } = await genScriptCode( descriptor, options, pluginContext, ssr, customElement ); let templateCode = ""; ({ code: templateCode, map: templateMap } = await genTemplateCode( descriptor, options, pluginContext, ssr, customElement )); const stylesCode = await genStyleCode( descriptor, pluginContext, customElement, attachedProps ); const output = [ scriptCode, templateCode, stylesCode ]; let resolvedCode = output.join("\n"); return { code: resolvedCode, map: resolvedMap || { mappings: "" }, meta: { vite: { lang: descriptor.script?.lang || descriptor.scriptSetup?.lang || "js" } } }; }transformMain函数中的代码执行主流程,其实就是对应了一个vue文件编译成js文件的流程。
然后将scriptCode、templateCode、stylesCode使用换行符\n拼接起来得到resolvedCode,这个resolvedCode就是一个vue文件编译成js文件的代码code字符串。这个是debug时resolvedCode变量值的截图: