我们每天写的vue代码都是写在vue文件中,但是浏览器却只认识html、css、js等文件类型。所以这个时候就需要一个工具将vue文件转换为浏览器能够认识的js文件,想必你第一时间就想到了webpack或者vite。但是webpack和vite本身是没有能力处理vue文件的,其实实际背后生效的是vue-loader和@vitejs/plugin-vue。本文以@vitejs/plugin-vue举例,通过debug的方式带你一步一步的搞清楚vue文件是如何编译为js文件的,看不懂你来打我。
这个是我的源代码App.vue文件:
这个例子很简单,在setup中定义了msg变量,然后在template中将msg渲染出来。
下面这个是我从network中找到的编译后的js文件,已经精简过了:
编译后的js代码中我们可以看到主要有三部分,想必你也猜到了这三部分刚好对应vue文件的那三块。
debug搞清楚如何将vue文件编译为js文件
大家应该都知道,前端代码运行环境主要有两个,node端和浏览器端,分别对应我们熟悉的编译时和运行时。浏览器明显是不认识vue文件的,所以vue文件编译成js这一过程肯定不是在运行时的浏览器端。很明显这一过程是在编译时的node端。
要在node端打断点,我们需要启动一个debug 终端。这里以vscode举例,首先我们需要打开终端,然后点击终端中的+号旁边的下拉箭头,在下拉中点击Javascript Debug Terminal就可以启动一个debug终端。
假如vue文件编译为js文件是一个毛线团,那么他的线头一定是vite.config.ts文件中使用@vitejs/plugin-vue的地方。通过这个线头开始debug我们就能够梳理清楚完整的工作流程。
我们给上方图片的vue函数打了一个断点,然后在debug终端上面执行yarn dev,我们看到断点已经停留在了vue函数这里。然后点击step into,断点走到了@vitejs/plugin-vue库中的一个vuePlugin函数中。我们看到vuePlugin函数中的内容代码大概是这样的:
@vitejs/plugin-vue是作为一个plugins插件在vite中使用,vuePlugin函数返回的对象中的buildStart、transform方法就是对应的插件钩子函数。vite会在对应的时候调用这些插件的钩子函数,比如当vite服务器启动时就会调用插件里面的buildStart等函数,当vite解析每个模块时就会调用transform等函数。更多vite钩子相关内容查看官网。
我们这里主要看buildStart和transform两个钩子函数,分别是服务器启动时调用和解析每个模块时调用。给这两个钩子函数打上断点。
然后点击Continue(F5),vite服务启动后就会走到buildStart钩子函数中打的断点。我们可以看到buildStart钩子函数的代码是这样的:
将鼠标放到options.value.compiler上面我们看到此时options.value.compiler的值为null,所以代码会走到resolveCompiler函数中,点击Step Into(F11)走到resolveCompiler函数中。看到resolveCompiler函数代码如下:
在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文件的时候才会走到断点中去。
经过debug我们发现解析App.vue文件时transform函数实际就是执行了transformMain函数,至于transformStyle函数后面讲解析style的时候会讲:
继续debug断点走进transformMain函数,发现transformMain函数中代码逻辑很清晰。按照顺序分别是:
我们先来看看createDescriptor函数,将断点走到createDescriptor(filename, code, options)这一行代码,可以看到传入的filename就是App.vue的文件路径,code就是App.vue中我们写的源代码。
debug走进createDescriptor函数,看到createDescriptor函数的代码如下:
这个compiler是不是觉得有点熟悉?compiler是调用createDescriptor函数时传入的第三个参数解构而来,而第三个参数就是options。还记得我们之前在vite启动时调用了buildStart钩子函数,然后将vue底层包vue/compiler-sfc赋值给options的compiler属性。那这里的compiler.parse其实就是调用的vue/compiler-sfc包暴露出来的parse函数,这是一个vue暴露出来的底层的API,这篇文章我们不会对底层API进行源码解析,通过查看parse函数的输入和输出基本就可以搞清楚parse函数的作用。下面这个是parse函数的类型定义:
从上面我们可以看到parse函数接收两个参数,第一个参数为vue文件的源代码,在我们这里就是App.vue中的code字符串,第二个参数是一些options选项。我们再来看看parse函数的返回值SFCParseResult,主要有类型为SFCDescriptor的descriptor属性需要关注。
仔细看看SFCDescriptor类型,其中的template属性就是App.vue文件对应的template标签中的内容,里面包含了由App.vue文件中的template模块编译成的AST抽象语法树和原始的template中的代码。
我们再来看script和scriptSetup属性,由于vue文件中可以写多个script标签,scriptSetup对应的就是有setup的script标签,script对应的就是没有setup对应的script标签。我们这个场景中只有scriptSetup属性,里面同样包含了App.vue中的script模块中的内容。
我们再来看看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函数的执行流程图如下:
我们再来看genScriptCode函数是如何将<script setup>模块编译成可执行的js代码,同样将断点走到调用genScriptCode函数的地方,genScriptCode函数主要接收我们上一步生成的descriptor对象,调用genScriptCode函数后会将编译后的script模块代码赋值给scriptCode变量。
将断点走到genScriptCode函数内部,在genScriptCode函数中主要就是这行代码: const script = resolveScript(descriptor, options, ssr, customElement);。将第一步生成的descriptor对象作为参数传给resolveScript函数,返回值就是编译后的js代码,genScriptCode函数的代码简化后如下:
我们继续将断点走到resolveScript函数内部,发现resolveScript中的代码其实也很简单,简化后的代码如下:
这里的options.compiler我们前面第一步的时候已经解释过了,options.compiler对象实际就是vue底层包vue/compiler-sfc暴露的对象,这里的options.compiler.compileScript()其实就是调用的vue/compiler-sfc包暴露出来的compileScript函数,同样也是一个vue暴露出来的底层的API,后面我们的分析defineOptions等文章时会去深入分析compileScript函数,这篇文章我们不会去读compileScript函数的源码。通过查看compileScript函数的输入和输出基本就可以搞清楚compileScript函数的作用。下面这个是compileScript函数的类型定义:
这个函数的入参是一个SFCDescriptor对象,就是我们第一步调用生成createDescriptor函数生成的descriptor对象,第二个参数是一些options选项。我们再来看返回值SFCScriptBlock类型:
返回值类型中主要有scriptAst、scriptSetupAst、content这三个属性,scriptAst为编译不带setup属性的script标签生成的AST抽象语法树。scriptSetupAst为编译带setup属性的script标签生成的AST抽象语法树,content为vue文件中的script模块编译后生成的浏览器可执行的js代码。下面这个是执行vue/compiler-sfc的compileScript函数返回结果:
继续将断点走回genScriptCode函数,现在逻辑就很清晰了。这里的script对象就是调用vue/compiler-sfc的compileScript函数返回对象,scriptCode就是script对象的content属性 ,也就是将vue文件中的script模块经过编译后生成浏览器可直接执行的js代码code字符串。
genScriptCode函数的执行流程图如下:
我们再来看genTemplateCode函数是如何将template模块编译成render函数的,同样将断点走到调用genTemplateCode函数的地方,genTemplateCode函数主要接收我们上一步生成的descriptor对象,调用genTemplateCode函数后会将编译后的template模块代码赋值给templateCode变量。
同样将断点走到genTemplateCode函数内部,在genTemplateCode函数中主要就是返回transformTemplateInMain函数的返回值,genTemplateCode函数的代码简化后如下:
我们继续将断点走进transformTemplateInMain函数,发现这里也主要是调用compile函数,代码如下:
同理将断点走进到compile函数内部,我们看到compile函数的代码是下面这样的:
同样这里也用到了options.compiler,调用options.compiler.compileTemplate()其实就是调用的vue/compiler-sfc包暴露出来的compileTemplate函数,这也是一个vue暴露出来的底层的API。不过这里和前面不同的是compileTemplate接收的不是descriptor对象,而是一个SFCTemplateCompileOptions类型的对象,所以这里需要调用resolveTemplateCompilerOptions函数将参数转换成SFCTemplateCompileOptions类型的对象。这篇文章我们不会对底层API进行解析。通过查看compileTemplate函数的输入和输出基本就可以搞清楚compileTemplate函数的作用。下面这个是compileTemplate函数的类型定义:
入参options主要就是需要编译的template中的源代码和对应的AST抽象语法树。我们来看看返回值SFCTemplateCompileResults,这里面的code就是编译后的render函数字符串。
genTemplateCode函数的执行流程图如下:
genStyleCode函数
我们再来看最后一个genStyleCode函数,同样将断点走到调用genStyleCode的地方。一样的接收descriptor对象。代码如下:
我们将断点走进genStyleCode函数内部,发现和前面genScriptCode和genTemplateCode函数有点不一样,下面这个是我简化后的genStyleCode函数代码:
我们前面讲过因为vue文件中可能会有多个style标签,所以descriptor对象的styles属性是一个数组。遍历descriptor.styles数组,我们发现for循环内全部都是一堆赋值操作,没有调用vue/compiler-sfc包暴露出来的任何API。将断点走到 return stylesCode;,看看stylesCode到底是什么东西?
通过打印我们发现stylesCode竟然变成了一条import语句,并且import的还是当前App.vue文件,只是多了几个query分别是:vue、type、index、scoped、lang。再来回忆一下前面讲的@vitejs/plugin-vue的transform钩子函数,当vite解析每个模块时就会调用transform等函数。所以当代码运行到这行import语句的时候会再次走到transform钩子函数中。我们再来看看transform钩子函数的代码:
当query中有vue字段,并且query中type字段值为style时就会执行transformStyle函数,我们给transformStyle函数打个断点。当执行上面那条import语句时就会走到断点中,我们进到transformStyle中看看。
transformStyle函数的实现我们看着就很熟悉了,和前面处理template和script一样都是调用的vue/compiler-sfc包暴露出来的compileStyleAsync函数,这也是一个vue暴露出来的底层的API。同样我们不会对底层API进行解析。通过查看compileStyleAsync函数的输入和输出基本就可以搞清楚compileStyleAsync函数的作用。
我们先来看看
SFCAsyncStyleCompileOptions入参:
入参主要关注几个字段,source字段为style标签中的css原始代码。scoped字段为style标签中是否有scoped attribute。id字段为我们在观察 DOM 结构时看到的 data-v-xxxxx。这个是debug时入参截图:
再来看看返回值SFCStyleCompileResults对象,主要就是code属性,这个是经过编译后的css字符串,已经加上了data-v-xxxxx。
这个是debug时compileStyleAsync函数返回值的截图:
genStyleCode函数的执行流程图如下:
现在我们可以来看transformMain函数简化后的代码:
transformMain函数中的代码执行主流程,其实就是对应了一个vue文件编译成js文件的流程。
首先调用createDescriptor函数将一个vue文件解析为一个descriptor对象。
然后以descriptor对象为参数调用genScriptCode函数,将vue文件中的<script>模块代码编译成浏览器可执行的js代码code字符串,赋值给scriptCode变量。
接着以descriptor对象为参数调用genTemplateCode函数,将vue文件中的<template>模块代码编译成render函数code字符串,赋值给templateCode变量。
然后以descriptor对象为参数调用genStyleCode函数,将vue文件中的<style>模块代码编译成了import语句code字符串,比如:import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";,赋值给stylesCode变量。
然后将scriptCode、templateCode、stylesCode使用换行符\n拼接起来得到resolvedCode,这个resolvedCode就是一个vue文件编译成js文件的代码code字符串。这个是debug时resolvedCode变量值的截图:
这篇文章通过debug的方式一步一步的带你了解vue文件编译成js文件的完整流程,下面是一个完整的流程图。如果文字太小看不清,可以将图片保存下来或者放大看:
@vitejs/plugin-vue-jsx库中有个叫transform的钩子函数,每当vite加载模块的时候就会触发这个钩子函数。所以当import一个vue文件的时候,就会走到@vitejs/plugin-vue-jsx中的transform钩子函数中,在transform钩子函数中主要调用了transformMain函数。
第一次解析这个vue文件时,在transform钩子函数中主要调用了transformMain函数。在transformMain函数中主要调用了4个函数,分别是:createDescriptor、genScriptCode、genTemplateCode、genStyleCode。
createDescriptor接收的参数为当前vue文件代码code字符串,返回值为一个descriptor对象。对象中主要有四个属性template、scriptSetup、script、styles。
genScriptCode函数为底层调用vue/compiler-sfc的compileScript函数,根据第一步的descriptor对象将vue文件的<script setup>模块转换为浏览器可直接执行的js代码。
genTemplateCode函数为底层调用vue/compiler-sfc的compileTemplate函数,根据第一步的descriptor对象将vue文件的<template>模块转换为render函数。
genStyleCode函数为将vue文件的style模块转换为import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";样子的import语句。
然后使用换行符\n将genScriptCode函数、genTemplateCode函数、genStyleCode函数的返回值拼接起来赋值给变量resolvedCode,这个resolvedCode就是vue文件编译成js文件的code字符串。
当浏览器执行到import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css";语句时,触发了加载模块操作,再次触发了@vitejs/plugin-vue-jsx中的transform钩子函数。此时由于有了type=style的query,所以在transform函数中会执行transformStyle函数,在transformStyle函数中同样也是调用vue/compiler-sfc的compileStyleAsync函数,根据第一步的descriptor对象将vue文件的<style>模块转换为编译后的css代码code字符串,至此编译style部分也讲完了。