‘壹’ vue3中的编译器原理和优化策略
学习目标编译器原理
vue3编译过程剖析
vue3编译优化策略
在初始化之前可能有编译的过程,最终的产物是个渲染函数,我们知道渲染函数返回的值是一个虚拟DOM(vnode),那么这个虚拟DOM在我们后续的更新过程中到底有什么作用呢?我们今天就来探讨一下。
编译器原理1.概念广义上的编译原理:编译器是将源代码转化成机器码的软件;所以编译的过程则是将源代码转化成机器码的过程,也就是cpu可执行的二进制代码。例如使用高级语言java编写的程序需要编译成我们看不懂但计算机能看懂的的字节码。
如果了解过编译器的工作流程的同学应该知道,一个完整的编译器的工作流程会是这样:
首先,parse解析原始代码字符串,生成抽象语法树AST。
其次,transform转化抽象语法树,让它变成更贴近目标“DSL”的结构。
最后,codegen根据转化后的抽象语法树生成目标“DSL”的可执行代码。
2.vue中的编译在vue里也有编译的过程,我们经常写的那个HTML模版,在真正工作的时候,并不是那个HTML模版,它实际上是一个渲染函数,在这个过程中就发生了转换,也就是编译,也就是那个字符串的模版最终会变成一个JS函数,叫render函数。所以在这个过程中我们就需要引入编译器的概念。在计算机中当一种东西从一种形态到另一种形态进行转换的时候,就需要编译。编译器:用来将模板字符串编译成为JavaScript渲染函数的代码
那么vue中的编译发生在什么时候呢?
这个时候我们就需要进一步了解vue包的不同版本的不同功能了。vue有携带编译器和不携带编译的包(对不同构建版本的解释)。
3.运行时编译在使用携带编译器(compiler)的vue包的时候,vue编译的时刻是发生在挂载($mount)的时候。
4.运行时不编译如果使用未携带编译器的vue包的时候,vue在运行时是不会进行编译的。那么它的编译又发生在什么时候呢?使用未携带编译器的vue包的时候,需要进行预编译,也就是基于构建工具使用,就是我们平时使用的vue-cli进行构建的项目,就是使用webpack调用vue-loader进行预编译,将所有vue文件,就是SFC,将里面的template模版部分转换成render函数。这样做的好处就是vue的包体积变小了,执行的时候速度更快了,因为不需要进行编译了。
vue编译器原理简单来说就是:先将template模版转换成ast抽象语法树,ast再转换成渲染函数render。
那么什么是是ast抽象语法树呢?
1.ast抽象语法树在template模版和render函数之间有一个中间产物叫做ast抽象语法树。它就是个js对象,它能够描述当前模版的结构信息,跟vnode很类似。注意,ast只是程序运行过程中编译产生的,它跟我们最终程序的运行是没有任何关系的。也就是当这个渲染函数生成之后,ast的生命周期就结束了,不再需要了,而那个虚拟DOM则伴随整个程序的生命周期。这个就是ast和虚拟DOM的本质区别。
2.为什么需要ast呢在ast转换成render函数的过程中,需要进行特别的操作。第一次,将template转成的ast是个非常粗糙的js对象,是一次非常粗糙的转换,类似正则表达式的匹配,然后我们的template模版中还有很多表达式,指令,事件需要重新解析,经过这些具体的深加工的解析(transform)之后会得到一个终极ast,然后这个对这个终极ast进行generate,生成render函数
template=>ast=>transform=>ast=>render3.mini版vue编译器下面我们来看一个mini版的vue编译器,具体代码已省略,具体代码我已经放在Github上了:mini-vue-compiler
functiontokenizer(input){...}functionparse(template){consttokens=tokenizer(template)...}functiontransform(ast){...}functiontraverse(ast,context){...}functiongenerate(ast){...}functioncompile(template){//1.解析constast=parse(template)console.log(JSON.stringify(ast,null,2))//2.转换transform(ast)//3.生成constcode=generate(ast)console.log(code)//returnfunctionrender(ctx){//returnh("h3",{},//ctx.title//)}returnnewFunction(code)()}lettmpl=`<h3>{{title}}</h3>`compile(tmpl)大概有以上操作,其中parse函数就是发生在把template转换成ast的这过程,具体是通过一些正则表达式的匹配template中的字符串。比如将
xxx转成ast对象,那么就是通过正则表达式匹配如果是
那么就设置一个开始标记,再往后面匹配到xxx内容,然后就设置一个子元素,最后匹配到那么就设置一个结束标记,以此类推。parse解析之后得到的是一个粗糙的ast对象。经过parse解析得到一个粗糙的ast对象之后,就用transform进行深加工,最后要经过generate生成代码。
Vue3编译过程剖析挂载的时候先把template编译成render函数,在创建实例之后,直接调用组件实例的render函数创建这个组件的真实DOM,然后继续向下做递归。
1.vue2.x和vue3.x的编译对比Vue2.x中的Compile过程会是这样:
parse词法分析,编译模板生成原始粗糙的AST。
optimize优化原始AST,标记ASTElement为静态根节点或静态节点。
generate根据优化后的AST,生成可执行代码,例如_c、_l之类的。
在Vue3中,整体的Compile过程仍然是三个阶段,但是不同于Vue2.x的是,第二个阶段换成了正常编译器都会存在的阶段transform。
parse词法分析,编译模板生成原始粗糙的AST。
transform遍历AST,对每一个ASTelement进行转化,例如文本元素、指令元素、动态元素等等的转化
generate根据优化后的AST,生成可执行代码函数。
2.源码编译入口我们先从一个入口来开始我们的源码阅读,packages/vue/index.ts。
//web平台特有编译函数functioncompileToFunction(template:string|HTMLElement,options?:CompilerOptions):RenderFunction{//省略...if(template[0]==='#'){//获取模版内容constel=document.querySelector(template)//省略...template=el?el.innerHTML:''}//编译const{code}=compile(template,extend({//省略...},options))constrender=(__GLOBAL__?newFunction(code)():newFunction('Vue',code)(runtimeDom))asRenderFunction//省略...return(compileCache[key]=render)}//注册编译函数registerRuntimeCompiler(compileToFunction)export{compileToFunctionascompile}这个入口文件的代码比较简单,只有一个compileToFunction函数,但函数体内的内容却又比较关键,主要是经历以下步骤:
依赖注入编译函数至(compileToFunction)
runtime调用编译函数compileToFunction
调用compile函数
返回包含code的编译结果
将code作为参数传入Function的构造函数将生成的函数赋值给render变量
将render函数作为编译结果返回
3.template获取app.mount()获取了templatepackages/runtime-dom/src/index.ts
compile将传?template编译为render函数,packages/runtime-core/src/component.ts
实际执?的是baseCompile,packages/compiler-core/src/compile.ts
第?步解析-parse:解析字符串template为抽象语法树ast
第?步转换-transform:解析属性、样式、指令等
第三步?成-generate:将ast转换为渲染函数
Vue3编译器优化策略这是一个非常典型的用内存换时间的操作
1.静态节点提升<div><div>{{msg}}</div><p>coboy</p><p>coboy</p><p>coboy</p></div>以上这个段template如果没有开启静态节点提升它编译后是这样的:
import{toDisplayStringas_toDisplayString,createVNodeas_createVNode,openBlockas_openBlock,createBlockas_createBlock}from"vue"exportfunctionrender(_ctx,_cache,$props,$setup,$data,$options){return(_openBlock(),_createBlock("div",null,[_createVNode("div",null,_toDisplayString(_ctx.msg),1/*TEXT*/),_createVNode("p",null,"coboy"),_createVNode("p",null,"coboy"),_createVNode("p",null,"coboy")]))}如果开启了静态节点提升之后它编译后则是这样的:
import{toDisplayStringas_toDisplayString,createVNodeas_createVNode,openBlockas_openBlock,createBlockas_createBlock}from"vue"const_hoisted_1=/*#__PURE__*/_createVNode("p",null,"coboy",-1/*HOISTED*/)const_hoisted_2=/*#__PURE__*/_createVNode("p",null,"coboy",-1/*HOISTED*/)const_hoisted_3=/*#__PURE__*/_createVNode("p",null,"coboy",-1/*HOISTED*/)exportfunctionrender(_ctx,_cache,$props,$setup,$data,$options){return(_openBlock(),_createBlock("div",null,[_createVNode("div",null,_toDisplayString(_ctx.msg),1/*TEXT*/),_hoisted_1,_hoisted_2,_hoisted_3]))}我们可以看到template里存在大量的不会变的p标签,所以当这个组件重新渲染的时候,这些静态的不会变的标签就不应该再次创建了。所以vue3就把这些静态的不会变的标签的VNode放在了render函数作用域的外面,在下次render函数再次执行的时候,那些静态标签的VNode已经在内存里了,不需要重新创建了。相当于占用当前机器的内存,避免重复创建VNode,用内存来换时间。大家仔细斟酌一番静态提升的字眼,静态二字我们可以不看,但是提升二字,直抒本意地表达出它(静态节点)被提高了。
2.补丁标记和动态属性记录<div><div:title="title">coboy</div></div>意思就是在编译的过程中,像人眼一样对模版进行扫描看哪些东西是动态的,然后提前把这些动态的东西提前保存起来,作个标记和记录,等下次更新的时候,只更新这些保存起来的动态的记录。比如上面模版的title是动态的,提前做个标记和记录,更新的时候就只更新title部分的内容。
import{createVNodeas_createVNode,openBlockas_openBlock,createBlockas_createBlock}from"vue"exportfunctionrender(_ctx,_cache,$props,$setup,$data,$options){return(_openBlock(),_createBlock("div",null,[_createVNode("div",{title:_ctx.title},"coboy",8/*PROPS*/,["title"])]))}<div><div:title="title">{{text}}</div></div>import{toDisplayStringas_toDisplayString,createVNodeas_createVNode,openBlockas_openBlock,createBlockas_createBlock}from"vue"exportfunctionrender(_ctx,_cache,$props,$setup,$data,$options){return(_openBlock(),_createBlock("div",null,[_createVNode("div",{title:_ctx.title},_toDisplayString(_ctx.text),9/*TEXT,PROPS*/,["title"])]))}我们可以观察到在_createVNode函数的第四个参数是个9,后面是一个注释:/TEXT,PROPS/,这个是表示在当前的节点里面有两个东西是动态的,一个是内部的文本,一个是属性,然后具体是哪个属性,在第五个参数的数组里面则记录了下来["title"],有个title的属性是动态的。
在将来进行patch更新的时候,就可以根据当前记录的信息,进行更新,缩减更新过程和操作,可以非常精确地只进行title和文本的更新。
如果div标签里是静态文本的话,_createVNode函数的第四个参数则变成了8,后面的注释变成了:/PROPS/,后面的第五个参数数据不变。
_createVNode函数的第四个参数的数字其实是一个二进制数字转成十进制的数字。
8的二进制是1000,9的二进制是1001,很容易可以看出二进制的每一位的数字都代表着特殊的含义。这些数字就是patchFlag,那么什么是patchFlag呢?
什么是patchFlagpatchFlag是complier时的transform阶段解析ASTElement打上的补丁标记。它会为runtime时的patchVNode提供依据,从而实现靶向更新VNode和静态提升的效果。
patchFlag被定义为一个数字枚举类型,它的每一个枚举值对应的标识意义是:
TEXT=1动态文本的元素
CLASS=2动态绑定class的元素
STYLE=4动态绑定style的元素
PROPS=8动态props的元素,且不含有class、style绑定
FULL_PROPS=16动态props和带有key值绑定的元素
HYDRATE_EVENTS=32事件监听的元素
STABLE_FRAGMENT=64子元素的订阅不会改变的Fragment元素
KEYED_FRAGMENT=128自己或子元素带有key值绑定的Fragment元素
UNKEYED_FRAGMENT=256没有key值绑定的Fragment元素
NEED_PATCH=512带有ref、指令的元素
DYNAMIC_SLOTS=1024动态slot的组件元素
HOISTED=-1静态的元素
BAIL=-2不是render函数生成的一些元素,例如renderSlot
整体上patchFlag的分为两大类:
当patchFlag的值大于0时,代表所对应的元素在patchVNode时或render时是可以被优化生成或更新的
当patchFlag的值小于0时,代表所对应的元素在patchVNode时,是需要被fulldiff,即进行递归遍历VNodetree的比较更新过程。
以上就是vue3的一个非常高效的优化策略叫补丁标记和动态属性记录。
3.缓存事件处理程序functiontokenizer(input){...}functionparse(template){consttokens=tokenizer(template)...}functiontransform(ast){...}functiontraverse(ast,context){...}functiongenerate(ast){...}functioncompile(template){//1.解析constast=parse(template)console.log(JSON.stringify(ast,null,2))//2.转换transform(ast)//3.生成constcode=generate(ast)console.log(code)//returnfunctionrender(ctx){//returnh("h3",{},//ctx.title//)}returnnewFunction(code)()}lettmpl=`<h3>{{title}}</h3>`compile(tmpl)0将来框架会像react那样把@click="onClick"变成@click="()=>onClick()",最后可能是这样的一个箭头函数。那就意味着每次onClick的函数都是一个全新的函数,那就会造成这个回调函数明明没有变,都会被认为变了,那就必须进行一系列的更新,那么如果能把这个回调函数缓存起来,更新的时候,就不要再创建了。
未进行缓存事件处理程序之前的编译
functiontokenizer(input){...}functionparse(template){consttokens=tokenizer(template)...}functiontransform(ast){...}functiontraverse(ast,context){...}functiongenerate(ast){...}functioncompile(template){//1.解析constast=parse(template)console.log(JSON.stringify(ast,null,2))//2.转换transform(ast)//3.生成constcode=generate(ast)console.log(code)//returnfunctionrender(ctx){//returnh("h3",{},//ctx.title//)}returnnewFunction(code)()}lettmpl=`<h3>{{title}}</h3>`compile(tmpl)1进行缓存事件处理程序之后的编译
functiontokenizer(input){...}functionparse(template){consttokens=tokenizer(template)...}functiontransform(ast){...}functiontraverse(ast,context){...}functiongenerate(ast){...}functioncompile(template){//1.解析constast=parse(template)console.log(JSON.stringify(ast,null,2))//2.转换transform(ast)//3.生成constcode=generate(ast)console.log(code)//returnfunctionrender(ctx){//returnh("h3",{},//ctx.title//)}returnnewFunction(code)()}lettmpl=`<h3>{{title}}</h3>`compile(tmpl)24.块block这是什么意思呢?根据尤雨溪本人的解析,他说,根据他的统计那个动态的部分最多只有三分之一,基本上都是静态部分,所以在编译的过程中,能不能发现那个比较小的动态部分,把它放到比较靠上
‘贰’ 原来 vue3 文件编译是这样工作的!看完后更懂vue3了
理解Vue3文件编译过程的关键在于熟悉vue-loader和@vitejs/plugin-vue的工作机制。以@vitejs/plugin-vue为例,让我们通过调试来揭示这个过程。首先,Vue源代码在App.vue文件中编写,如设置变量msg并在template中渲染。在浏览器中,这些文件需要经过编译转换为浏览器能理解的js。
编译实际发生于node环境,而不是浏览器端。通过调试工具如VSCode的Javascript Debug Terminal,我们可以在vite.config.ts中使用@vitejs/plugin-vue的地方设置断点。这里,关键的函数是vuePlugin,它包含了buildStart和transform钩子,分别在服务器启动和模块解析时被调用。
当启动服务,会首先调用buildStart钩子,此时compiler变量为null,随后通过resolveCompiler函数定位到vue/compiler-sfc库。在transform钩子中,我们重点关注App.vue文件的解析,这时transformMain函数会执行,它处理了template、scriptSetup(如果有setup)和styles内容。
在transformMain中,createDescriptor函数被调用,传入App.vue的文件路径和代码。这个函数内部,会使用vue/compiler-sfc的parse函数,该函数接收源代码和选项,解析出包含模板、script内容以及可能的style内容的SFCDescriptor对象。
通过这些步骤,Vue文件被逐步分解成浏览器可识别的组件描述符,包括了HTML模板、脚本逻辑和样式信息。这让你更深入地理解了Vue3文件的编译过程,使得源代码和浏览器之间的转换过程更加清晰。
‘叁’ vue 如何实现 AOT 编译
Vue 2 引入了 AOT 编译功能,开发者可以在构建过程中使用 AOT 编译,将模板转化为 JavaScript 代码。使用 AOT 编译可以减少应用程序的初始化时间、减小文件大小,并帮助开发者在构建过程中发现问题。
AOT 预编译是一种将应用程序的代码在构建过程中编译成可执行的机器代码的技术。与即时编译(JIT)相比,AOT编译在应用程序运行之前就完成,因此在运行时不需要再进行编译。
‘肆’ vue3源码学习--编译阶段汇总
Vue3编译阶段汇总:
核心包与工具:
编译流程:
模式差异:
总结: