You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
无论是Vue还是React,抛开路由、状态管理、SSR等周边生态而言,Vue和React的核心库只关注视图层,正如官网所言 - for building user interface,就是为快速创建UI界面而诞生。整体来看,它们都是对用户暴露声明式API,另一端则封装了对浏览器的命令式代码。比如说Vue,你在使用Vue时,在template中写下<div>this is div ele</div>,Vue框架则会将其进行编译成命令式代码。最终通知浏览器的代码可以理解为如此:
document.createElement('div').innerText = 'this is div ele'
Vue框架本质
无论是Vue还是React,抛开路由、状态管理、SSR等周边生态而言,Vue和React的核心库只关注视图层,正如官网所言 - for building user interface,就是为快速创建UI界面而诞生。整体来看,它们都是对用户暴露声明式API,另一端则封装了对浏览器的命令式代码。比如说Vue,你在使用Vue时,在template中写下
<div>this is div ele</div>
,Vue框架则会将其进行编译成命令式代码。最终通知浏览器的代码可以理解为如此:document.createElement('div').innerText = 'this is div ele'
我们熟悉的jQuery就是经典的命令式框架。那么Vue为何采用声明式方案来创建UI界面呢?那其实是在性能和可维护性两者之间的综合考虑。jQuery这种命令式代码最大的问题就是无法解放生产力,开发者的心智负担太重,因为开发者需要关注过程中的每一个细节,元素的创建、属性添加、销毁等等。因此随之而来的另一个严重问题就是可维护性极差。试想,你要为某个元素添加一个属性,你是否应该一步步找到创建这个元素的位置,在后面添加属性,并且还需确保是否在其他位置会修改属性。但是jQuery也有一个最大的好处,那就是 - 性能。理论上来讲,命令式代码的效率是最优的。为何?因为它是直接通知浏览器该如何做,没有其他前置过程。Vue采用的声明式方案则会有一个编译和计算的过程。
那么Vue核心库具体包含哪些模块?我们来看:
tips:所有示例代码仅描述核心逻辑实现,具体可自行查阅源码。个人观点 - 最好的源码讲解方式不是对照源码进行逐行功能讲解,而是在理解核心思想后自驱性学习源码,自己领悟的才是真正属于自己的!
编译器
编译器是什么?它有什么作用?几乎所有的编译器都可以理解为“将一种语言A(源码)翻译成另一种语言B(目标代码)”。结合Vue来看,我们在template中所写的代码就是源码A。经过编译器的一系列处理最终输出的JavaScript代码就是目标代码B。Vue编译器的工作流程主要有三大环节:
解析阶段/parse - 解析器将模板DSL解析成模板AST。
转换阶段/transform - 转换器将模板AST转换为JavaScript AST。
生成阶段/generate - 生成器根据JavaScript AST 生成 JS代码,也就是render函数。
接下来,我们来看这三个环节具体是如何处理的。
解析器
解析器的作用是将模板DSL解析为模板AST。何为DSL?DSL是领域特定语言的简称,在Vue中template就是DSL,在React中jsx就是DSL。何为AST?AST是抽象语法树的简称,本质上理解,AST就是用数据结构来描述代码。
传统解析模式
传统的解析模式包含词法分析和语法分析两个过程。词法分析就是将接收的template字符串分析处理成一个个的词法token。语法分析则是将tokens再次进行处理,组建成一个描述模板代码的树状结构,即模板AST。如下为词法分析示例:
那么词法分析具体是如何实现的?
传统的词法分析方法基本上都是采用
"有限状态自动机"
技术。所谓有限状态自动机,其中有两个关键字眼 -有限状态
和自动机
。通俗理解就是,在有限个状态中自动切换不同状态的过程。结合JS,我们很自然的会想到最直接的实现方式就是开启一个while循环,然后不断的处理所接收的字符串,依据不同的字符串生成不同的标签。下面来看核心代码实现:通过以上函数处理即可得到一个tokens数组。接下来就是将tokens处理成AST的过程,也就是语法分析。如下效果:
那么语法分析是如何实现的?语法分析本质上就是循环处理tokens中的每一个token,将其重组成一个树状结构。其中有一个重点 - 如何将子节点精准的归属在其父节点的children属性中呢?这其实很简单,只需要借助一个额外的stack容器存储即可。遍历tokens时,每当遇到一个开始标签,会将开始标签做为父节点推入栈顶,同时,在ast树中相应的节点下设置当前父节点。当遇到结束标签时,会将其对应的开始标签从栈中弹出,以此保证标签正确的开始结束以及归属在正确的父标签下面,如下为核心代码实现:
经过这两个过程就完成了解析,得到 模板AST。但是,我们可以发现这种解析方式其实有很大的优化空间。比如:词法分析和语法分析其实可以同时执行的,因为两者具有同构性。还有,这种有限状态机需要编写的代码量太多,而JS 的正则本质上是一个天然的有限状态机,所以可用正则来优化。接下来我们来看Vue3中是如何升级解析过程的。
解析升级-四种解析模式
由于标签编码实际情况错综复杂,解析器在遇到相同标签时也会有不一样的解析行为,比如
<div><p></p></div>
中的p标签可以正常解析,但遇到<textarea><p></p></textarea>
时,p标签则会被当做普通文本来解析,因为我们知道textarea中是不能有其他标签的。因此我们有必要先定义解析过程中有哪些模式。如下图:详细说明可查看WHATWG官方规范文档
解析升级- 递归下降算法
首先,我们来看递归下降解析算法的核心实现代码,如下:
首先定义了前面提到的四种解析模式,因为不同模式下解析行为会不同。然后重写parse函数,函数中定义了一个context对象 - 解析过程中上下文对象。它的作用可以通俗理解为“全局对象”,因为在接下来的递归处理过程中,需要一个存储全局变量的对象来实现各种控制,比如全局变量source存储的是模板字符串,mode存储的是解析模式,后续还会加很多全局变量。然后调用parseChildren函数对模板进行解析,这里可以大胆的事先猜测一下,parseChildren函数肯定是整个解析算法的核心,后续会重点讲解,parseChildren返回的就是节点list。
传统解析模式中提到了“有限状态自动机”,那么递归下降这种新型解析算法是如何实现的呢?具体来讲,parseChildren函数有哪些状态,以及状态间如何迁移转变呢?可以理解为调用一遍parseChildren,就开启了一个状态机。如下图:
下面结合四种解析模式来看parseChildren具体是如何来实现的:
从parseChildren函数可以发现如下重点:
parseChildren中用到了几个重点函数需要说明下:
1、isEnd函数用于判断ancestors数组中是否包含当前模板字符串的起始字符,比如ancestors中有div这个起始标签,而此时模板字符串以</div开头,说明遇到了结束标签,当前状态机要停止。
2、parseElement函数,顾名思义,这个函数用于解析元素,核心逻辑如下:
至此,我们实现了递归下降算法的核心,可以更加系统的描述算法原理。正如下图所示:
总结:每次调用parseElement解析元素时,会默认调用parseChildren解析子节点,这就是“递归”的由来;上层parseChildren调用是为了构造上层模板AST的节点,下层parseChildren调用是为了构造下层模板AST的节点,这就是“下降”的由来。
解析标签
从前面可以看出,在parseElement函数中调用了parseTag首先来解析标签节点,其实解析标签的核心就是用正则去匹配合法的标签,接下来看具体函数实现:
另外,有必要说明的是,parseTag中调用了advanceBy、advanceSpaces两个工具函数。其实从命名和调用时机上就可以知道这两个函数就是为了在模板字符串中去除已经处理的字符,实现方式与前面传统解析模式中提到的一样,调用slice处理原模板字符串。此处将其抽离成工具函数,放在上下文对象中更便于管理。
解析属性
从上面可以看到,parseTag中调用了parseAttributes函数来解析标签的属性。解析属性也是用正则来匹配,但需要考虑属性名称、属性值的正则差异,以及属性值还有无引号、单引号、双引号三种情况。下面来看具体实现:
解析{{}}动态内容
{{}}是Vue中用来绑定数据的最常用方式,从前面parseChildren状态切换图中可以发现,当状态机遇到{{定界符时就会parseInterpolation来解析动态插值。下面来看此函数的实现:
解析其他内容
解析文本、解析转义字符、解析注释这三种场景与解析标签、属性等核心模式是一致的,这里不再讲述。
转换器
转换器的作用是将模板AST转换为JavaScript AST,AST前面已经提了,那么JavaScript AST就是用数据结构来描述JavaScript代码。首先我们来看转换示例:
转换为JavaScript AST后,效果如下:
那么转换具体是如何实现的呢?编译器的最终结果是输出render函数,因此我们需要思考的是如何用AST来描述render函数。函数无外乎由三部分组成:id、params、body。id描述函数名称,params描述函数的参数,body是函数体。所以可定义如下结构描述函数:
接下来设计转换器的核心逻辑:
在transform中,依然需要定义一个上下文对象,然后调用traverseNode,traverseNode采用深度优先的方式遍历模板AST,是整个转换器的核心。核心逻辑如下:
从以上代码可以发现,traverseNode中会循环调用traverseNode来处理子节点,具体的处理方法封装在transformElement中,做为入参传入。traverseNode中有两个重点务必需要理解清楚:
1、nodeTransform中存放的是具体的转换函数,如前面提到的转换标签节点用transformElement,转换文本用transformText函数。之所以采用这种模式是为了解耦转换逻辑与核心的遍历逻辑,也便于后续新增各种转换函数,这种模式为插件化模式。
2、为什么转换函数需要返回一个回调函数,并且在traverseNode的最末尾才执行呢?由上面的模板AST结构以及traverseNode的调用顺序我们知道,转换过程是先处理的父节点,因为遍历是由外至内的。但是往往我们需要确保子节点处理完毕之后才能真正去处理父节点,这样才更稳妥。所以就有了这种模式,每次调用traverseNode转换节点时,先转换子节点,再转换父节点。
下面来看具体的AST转换函数:
通俗理解,模板AST中的节点我们最终如何创建?在render函数中是通过调用内置函数h实现的,比如h('div', [h('p')])会创建一个div父节点,里面还有一个p子节点。所以模板AST转换的目的就是用JavaScript AST 来表示 h('div', [h('p')])。
transformElement中调用了createCallExpression、createArrayExpression、createStringLiteral函数,此处不全部讲解,源码中还有很多与之类似的辅助函数,这些函数的作用就是用来创建各种JavaScript AST单元,比如createCallExpression,返回一个type为CallExpression的对象:
最后,我们需要补全JavaScript AST,类似transformElement,再并列新增一个transformRoot函数:
通过以上函数转换,最终就能得到JavaScript AST。
生成器
代码生成是整个编译阶段的最后一步,也是最简单的一步。代码生成就是js字符串拼接的过程,访问JavaScript AST中的每一个节点,为每一种类型的节点生成相应的JS代码。核心逻辑如下:
与解析、转换一样,生成阶段也需要定义上下文对象。核心是利用genNode来生成JS代码。接下来看genNode的核心逻辑:
genNode内的核心逻辑很简单,判断节点类型,调用相应的函数生成JS代码。genReturnStatement、genCallExpression等这些函数核心原理是一样的,此处不全部讲解,以genFunctionDecl为例:
以上就是Vue编译过程中的三大核心环节 - 解析、转换、生成。
The text was updated successfully, but these errors were encountered: