跳到主要内容

认识

2024年03月18日
柏拉文
越努力,越幸运

一、认识


Vue.js 3.0 Compile 编译器Template 模版编译解析为 render 函数。首先对模版进行词法分析和语法分析, 得到 AST 模版。接着, 将模版 AST 转换成 JavaScript AST。 最后根据 JavaScript AST 生成 JavaScript 代码,即渲染函数代码。工作流如下:

  1. 生成模版 AST: 得到 Template 模版字符串, 从左到右, 通过有限自动状态机会逐个读取字符串模版中的字符, 完成对模版的标记化,并根据一定的规则将整个字符切割为一个个的 Token,这些个 Token 通过递归下降算法构造生成 AST。有限状态机的状态分别为: 初始状态标签开始状态标签名称状态文本状态结束标签状态。遇到 < 进入状态机的开始状态, 如果下一个字符为字母, 于是进行标签的解析; 如果下一个字符为 {{ , 则进行插值节点的解析。其中在解析标签时, 会做四件事情: 解析开始标签解析标签属性解析子节点解析结束标签。解析子节点时,会递归执行上述代码, 不断消耗字符串模版内容,产生新的状态机。 因此, parseChildren 是一个状态机,在各种状态机之间的切换,生成一个个 Tokens; 又通过递归下降算法, 将生成的 Tokens 构造为模版 AST。 模版 AST 没有实际的可用性。

  2. 生成 JavaScript AST: 模版 AST 只是简单的一些 Tokens 数组, 还需要将模版 AST 转化可用于渲染的 AST。过程如下:

    1. 深度优先遍历模版AST, 进行转换: 针对不同的 AST 节点, 通过不同的 node 转换器对当前节点进行转换。经过转换器处理过的 AST 节点会被挂载到 codeGenNode 属性上。codeGenNode 会包含一些在Parser解析阶段无法获得的信息, 用于后面的generate阶段生成vnode的创建调用。注意: transform 转换器: nodeTransforms 是一个数组,里面会存放很多转换函数,这些转换函数是有序的,不可以随意调换位置,比如对于if的处理优先级就比较高,因为如果条件不满足很可能有大部分内容都没必要进行转换。

      1. 为每一个节点添加 PatchFlags 标记: 用于标识节点如何进行更新, TEXT 表示动态文字内容, CLASS 表示动态 class, STYLE 表示动态样式, PROPS 表示动态 propsHOISTED 表示静态节点, 内容永远不会改变。在创建虚拟 DOM 的时候, 会存入 VNode 中。在节点更新时, :会根据 vnodepatchFlag上具有的属性来执行不同的patch方法, 实现靶向更新。如果没有patchFlag那么就执行full diff,也就是这里的patchProps

      2. cacheHandlers: 则表示开启事件函数缓存

      3. hoistStatic: 表示要不要开启静态节点提升

      4. prefixIdentifiers: 表示生成代码的模式, 代码模式有 function 模式和 ES6 Module 模式

    2. 如果开启 hoistStatic, 进行静态提升: 所谓静态提升, 就是将一些静态节点或者静态属性提升到渲染函数之外。所有节点遍历、转换完成后, 如果编译选项设置了 hoistStatic 开关, 会进行静态提升。除根节点之外(根节点不可以静态提升), 遍历当前节点的所有子节点。主要逻辑为: 如果遍历到的节点为普通元素或者文本 (只有普通元素和文本可以提升) 或者动态节点的静态属性(包含动态绑定的节点本身不会被提升,该动态节点上可能存在纯静态的属性,静态的属性可以被提升), 根据节点静态类型的枚举值, 判断出是静态节点, 那么将当前子节点的 codeGenNode 属性的 patchFlag 标记为 HOISTED ,即可提升, 并将节点存储到转换上下文 contexthoist 数组中

    3. 创建 Block 树, 收集所有动态子节点: 基于 patchFlag 属性, 在创建虚拟节点阶段, 把它的动态节点提取出来, 并将其存入到该虚拟节点的 dyamicChildren 数组内。我们把带有 dyamicChildren 属性的虚拟节点称为BlockBlock 不仅能够收集它的直接动态子节点, 还能够收集所有动态子代节点。有了 Block 之后, 会忽略虚拟节点的 children 数组, 而是直接找到该虚拟节点的 dynamicChildren 数组, 只更新该数组中的动态节点。这样,在更新时就实现了跳过静态内容, 只更新动态内容。同时, 由于动态节点中存在对应的补丁标志, 所以在更新动态节点时, 也能够做到靶向更新

  3. 生成 render 函数: 根据JavaScript AST代码生成器, 生成可执行代码函数 render。 通过 prefixIdentifiers 来决定使用哪种代码模式:

    1. module 模式: 使用 es6 模块来导入导出函数,也就是使用 importexport

    2. function 模式: 使用 const { helpers... } = Vue 的方式来引入帮助函数,也就是是 createVode() createCommentVNode() 这些函数。向外导出使用 return 返回整个 render() 函数

二、Parser 细节


2.1 模版 AST

ASTabstract syntax tree 的首字母缩写, 即抽象语法树。所谓模版AST, 其实是用来描述模版的抽象语法树。如下所示:

<div>
<h1 v-if="ok">Vue Template</h1>
</div>

这段模版会被编译成如下 AST

const ast = {
type: 'Root',
children: [
{
type: 'Element',
tag: 'div',
children: [
……
]
}
]
}

可以看到, AST 其实就是一个具有层级结构的对象。**模版AST**具有与模版同构的嵌套结构。每一颗AST都有一个逻辑上的根节点,其类型为Root。模版中真正的根节点则作为Root节点的children存在。

通过封装 parse 函数完成对模版的词法分析和语法分析,得到模版AST。过程如下:

const template = `
<div>
<h1 v-if="ok">Vue Template</h1>
</div>
`
const templateAST = parse(template);

得到 templateAST 之后, 我们对其进行语义分析, 分析如下:

  1. 检查 v-else 指令是否存在相符的 v-if 指令

  2. 分析属性值是否是静态的, 是否是常量等

  3. 插槽是否引用上层作用域的变量等

2.2 模版解析

parser 解析器用来将模版字符串解析为模版AST。解析器的入参是 Template 模版字符串, 解析器通过有限自动状态机会逐个读取字符串模版中的字符, 完成对模版的标记化,并根据一定的规则将整个字符切割为一个个的 Token

有限状态机的状态如下:

  1. 初始状态:

  2. 标签开始状态: 在初始状态下, 遇到 < 字符, 状态机会迁移到标签开始状态

  3. 标签名称状态: 在标签开始状态下, 遇到 d 或者 p 等字符串, 状态机会进入标签名称状态, 遇到 > 字符时, 状态机会从标签名称状态迁移到初始状态, 并记录在标签名称状态下产生的标签名称。

  4. 文本状态: 读取到字符串, 状态机进入到文本状态

  5. 结束标签状态: 读取到 < , 状态机进入结束标签状态

有以下模版:

<div>
<p> hello </p>
<p> world </p>
</div>

通过 有限状态机 转化为 Tokens 如下:

Tokens 

开始标签 <div>
开始标签 <p>
文本节点 hello
结束标签 </p>
开始标签 <p>
文本标签 world
结束标签 </p>
结束标签 </div>

2.3 构造 AST

通过有限自动状态机Template 模版字符串解析成了一个个的 Token, 这些个 Token 通过递归下降算法构造生成 AST递归下降算法(Recursive Descent Parsing) 左边是一个非终结符(Non-terminal),右边是它的产生式(Production Rule)。在语法解析的过程中,左边会被右边的替代。如果替代之后还有非终结符,那么继续这个替代过程,直到最后全部都是终结符(Terminal),也就是Token。只有终结符才可以成为AST的叶子节点,这个过程,也叫做推导(Derivation)的过程。上级文法嵌套下级文法,上级的算法调用下级的算法,表现在生成AST中,上级算法生成上级节点,下级算法生成下级节点。这就是下降的含义。

递归下降算法具体逻辑为: 根据 Token 列表构建 AST 的过程, 其实就是对 Token 列表进行扫描的过程。从第一个 Token 开始, 顺序地扫描整个 Token 列表, 直到列表中的所有 Token 处理完毕。在这个过程中, 我们需要维护一个 Stack 栈结构, 这个栈用来维护元素间的父子关系。每遇到一个开始标签节点, 我们就要构造一个 Element 类型的 AST 节点, 并将其压入栈中。类似的, 每遇到一个结束标签节点, 我们就将当前栈顶的节点弹出。这样, 栈顶的节点将始终充当父节点的角色。扫描过程中遇到的所有节点, 都会作为当前栈顶节点的子节点,并添加到栈顶节点的 children 属性下。

有以下模版:

<div>
<p> hello </p>
<p> world </p>
</div>

通过 有限状态机 转化为 Tokens 如下:

Tokens

开始标签 <div>
开始标签 <p>
文本节点 hello
结束标签 </p>
开始标签 <p>
文本标签 world
结束标签 </p>
结束标签 </div>

通过 递归下降算法 转化为 AST 如下:

Tokens                       (Array)                  AST

开始标签 <div>
开始标签 <p>
文本节点 hello
结束标签 </p> => =>
开始标签 <p>
文本标签 world
结束标签 </p>
结束标签 </div> Root

然后从开始标签 <div> 开始自上而下进行扫描, 依次进入栈Array, 并加入 AST。遇到结束标签, 将结束标签 Tokens 和之前的 Tokens 全部出栈。

2.4 parser

Vue.js 3.0 中, 通过封装 parse 函数完成对模版的词法分析和语法分析,得到模版AST

function createPraseContext(content){
return {
source: content
}
}

function parseChildren(context,ancestors){
const nodes = [];
while(!isEnd(context)){
const s = context.source;
let node;
if(s[0] === '<'){
if (s.length === 1) {
} else if (s[1] === '!') {
if (startsWith(s, '<!--')) {
} else if (startsWith(s, '<!DOCTYPE')) {
} else if (startsWith(s, '<![CDATA[')) {
} else {
}
} else if (s[1] === '/') {
if (s.length === 2) {
} else if (s[2] === '>') {
} else if (tagReg.test(s[2])) {
} else {
}
} else if (tagReg.test(s[1])) {
node = parseElement(context, ancestors);
} else if (s[1] === '?') {
} else {
}
}
nodes.push(node);
}
}

function parse(content){
const context = createPraseContext(content);
return createRoot(parseChildren(context,[]));
}

由上所示, parse 函数主要调用 parseChildren进行解析、创建Token、构造模版 AST。在 parseChildren 中, 创建 Token 与构造模版 AST 是同时进行的。parseChildren 接收两个参数, 第一个参数为 context , 表示上下文对象, 用来维护解析器程序执行过程中的各种状态;第二个参数为 ancestors, 是由父节点构成的栈,用于维护节点间的父子级关系。parseChildren 遇到 < 进入状态机的开始状态, 如果下一个字符为字母, 于是调用 parseElement 完成标签的解析; 如果下一个字符为 {{ , 则调用 parseInterpolation 完成插值节点的解析。其中 parseElement 解析标签时, 会做四件事情: 解析开始标签解析标签属性解析子节点解析结束标签。解析子节点时,会递归调用 parseChildren 函数, 不断消耗字符串模版内容,产生新的状态机。 因此, parseChildren 是一个状态机,在各种状态机之间的切换,生成一个个 Tokens; 又通过递归下降算法, 将生成的 Tokens 构造为模版 AST

2.4.1 isEnd

isEnd() 表示parseChildrenwhile 循环的停止逻辑, 基本思路如下:

function isEnd(content,ancestors){
const s = content.source;

if(s.startsWith('</')){
for(let i = ancestors.length-1; i>=0; i--){
if(s.startsWidth("</") && s.slice(2,2+ancestors[i].tag).toLowercase() === ancestors[i].tag){
return true;
}
}
}

return !s;
}
  • content: 为 parse 解析器上下文对象

  • ancestors: 表示父级节点栈, 当解析器遇到开始标签时,会将该标签压入父级节点栈,同时开启新的状态机。当解析器遇到结束标签时, 并且父级节点栈中存在与该标签同名的开始标签时, 会停止正在运行的状态机。

isEnd() 停止状态机的逻辑为:

  1. 模版内容被解析完毕时, 停止状态机

  2. 在遇到结束标签时, 解析器会取得父级节点栈栈顶的节点作为父节点, 检查该结束标签是否与父节点标签同名, 如果相同, 则状态机停止运行

2.4.2 移动游标

advanceBy()source 待解析的字符串去除已解析的部分,并向右移动游标

function advanceBy(context, numberOfCharacters) {
const { source } = context;
context.source = source.slice(numberOfCharacters);
}

2.4.3 提取标签

标签Tag正则表达式: 用于解析标签Tag, 提取之后, 注意要通过 advanceBy 截断 tag

function advanceBy(context,numberOfString){
const { source } = context;
context.source = source.slice(numberOfString);
}
function parseTag(context){
const tagReg = /^<\/?([a-z][^\r\t\n\f\s/>]*)/i;
const match = tagReg.exec(context.source);
const tag = match[1];
advanceBy(context,match[0].length);
return tag;
}

const context = {
source: '<div></div>'
};

const tag = parseTag(context);
console.log(tag)
console.log(context)

2.4.4 提取属性

提取属性名和属性值, 格式如下:

const str = `id='div-id' class='div-class' v-if='isShow' v-for='item in list'`;

提取属性原理如下:

const spaceCharsReg = /^[\t\r\n\f\s]+/;
const equalCharReg = /^[\t\r\n\f\s]*=/;
const attributeNameReg = /^[^\t\r\n\f\s/>][^\t\r\n\f\s/>=]*/;

function advanceBy(context, numberOfCharacters) {
const { source } = context;
context.source = source.slice(numberOfCharacters);
}

function advanceSpaces(context) {
const match = spaceCharsReg.exec(context.source);
if (match) {
advanceBy(context, match[0].length);
}
}

function parseTextData(context, length) {
const rawText = context.source.slice(0, length);
advanceBy(context, length);
return rawText;
}

function parseAttributeValue(context) {
let content;
const quote = context.source[0];
const isQuoted = quote === `"` || quote === `'`;
if (isQuoted) {
advanceBy(context, 1);
const endIndex = context.source.indexOf(quote);
if (endIndex === -1) {
content = parseTextData(context, context.source.length);
} else {
content = parseTextData(context, endIndex);
advanceBy(context, 1);
}
}

return {
content
};
}

function parseAttribute(context) {
advanceSpaces(context);
const match = attributeNameReg.exec(context.source);
const name = match[0];
advanceBy(context, name.length);

let value;

if (equalCharReg.test(context.source)) {
advanceSpaces(context);
advanceBy(context, 1);
value = parseAttributeValue(context);
}

return {
name,
type: 'attribute',
value: value && {
type: 'text',
content: value.content
}
};
}

function parseAttributes(str) {
const context = {
source: str
};

const props = [];

while (context.source) {
const attr = parseAttribute(context);
props.push(attr);
advanceSpaces(context);
}

return props;
}

let str = `id='div-id' class='div-class' v-if='isShow' v-for='item in list'`;
console.log(parseAttributes(str));

提取属性结果如下:

2.4.5 提取指令

提取属性名和属性值之后, 根据属性名, 可以进一步提取指令, 指令格式如下:

const dirStr1 = `v-if`;
const dirStr2 = `v-show`;
const dirStr3 = `v-for`;
const dirStr6 = `v-bind:message`;
const dirStr4 = `v-on:click`;
const dirStr5 = `v-on:click.once`;

提取指令的原理如下:

const vueDirectiveNameReg =
/(?:^v-([a-z0-9-]+))?(?:(?::|^\.|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i;

function parseVueDirective(name) {
const match = vueDirectiveNameReg.exec(name);

// dirName 指令名
let dirName =
match[1] ||
(name.startsWith(':') ? 'bind' : name.startsWidth('@') ? 'on' : 'slot');

// arg 指令参数
let arg;

if (match[2]) {
let content = match[2];

arg = {
content
};
}

// modifiers 指令修饰符
const modifiers = match[3] ? match[3].slice(1).split('.') : [];

return {
arg,
modifiers,
name: dirName,
};
}

const str1 = 'v-if';
const str2 = 'v-model';
const str3 = 'v-on:click';
const str4 = 'v-on:[event]';
const str5 = 'v-on:click.once';
const result = parseVueDirective(str5);
console.log(result);

提取指令结果如下:

2.4.6 结束开始标签

结束开始标签 的时候有两种情况:

  • 单标签: /> , 需要移动两个字符

  • 双标签: > , 需要移动一个字符

function advanceBy(context, numberOfString) {
const { source } = context;
context.source = source.slice(numberOfString);
}

function startsWith(source, searchString) {
return source.startsWith(searchString);
}

function parseTag(context) {
const tagReg = /^<\/?([a-z][^\r\t\n\f\s/>]*)/i;
const match = tagReg.exec(context.source);
const tag = match[1];
advanceBy(context, match[0].length);

let isSelfClosing = startsWith(context.source, '/>');
advanceBy(context, isSelfClosing ? 2 : 1);

return tag;
}

const context = {
source: '<div></div>'
};

const tag = parseTag(context);
console.log(tag);
console.log(context);

2.4.7 提取文字内容

提取文字内容 就是说提取截止到 < 或者 {{ 之前的文字内容

function advanceBy(context, numberOfString) {
const { source } = context;
context.source = source.slice(numberOfString);
}

function startsWith(source, searchString) {
return source.startsWith(searchString);
}

function parseTextData(context, length) {
const rawText = context.source.slice(0, length);
advanceBy(context, length);
return rawText;
}

function parseText(context) {
const endTokens = ['<'];
let endIndex = context.source.length;
for (let i = 0; i < endTokens.length; i++) {
const index = context.source.indexOf(endTokens[i], 1);
if (index !== -1 && endIndex > index) {
endIndex = index;
}
}

const content = parseTextData(context, endIndex);
return content;
}

const context = {
source: 'fdsfdsfdslkj<'
};

const tag = parseText(context);
console.log(tag);
console.log(context);

由上所示, 在判断下一个字符不是 < 也不是 插值界定符时, 会调用 parseText 解析文本节点。此时解析器会在模版中寻找下一个 < 字符或者 {{ 插值界定符的位置索引,记为 I, 然后解析器会从模版的头部到索引 I 的位置截取内容作为文本内容。由于 < 字符与定界符{{ 的出现顺序是未知的, 所以需要取两者中较小的一个作为文本截取的终点。

三、Transformer 细节


通过parse得到模版模版AST,接着我们通过transform转换为JavaScript AST

3.1 转化策略

Template AST 转化为 JavaScript AST 本质上是一个对象结构的变化,变化的本质是为了后面更方便的解析对象,生成 render 函数。在转化的过程中, 我们需要遵循如下策略:

  1. 深度优先: 父节点的状态往往是根据子节点的情况才能够确定的

  2. 转化函数分离: 针对不同的 Token , 通过不同的 transformXXX 方法进行转化,但是为了防止 transform 模块过于臃肿, 我们会通过 options 的方式对 transformXXX 方法进行注入。所有注入的方法, 会生成一个 nodeTransforms 数组, 通过 options 传入。

  3. 上下文对象: 上下文对象即 context

基本思路如下所示

function createTransformContext(root, { nodeTransform = [] }){
return {
root,
parent: null,
childIndex: 0,
nodeTransforms,
currentNode: root,
}
}

function traverseChildren(parent,context){
let i = 0;
for(; i<parent.children.length; i++){
const child = parent.children[i];
if(typeof child === 'string'){
continue;
}
context.parent = parent;
context.childIndex = i;
traverseNode(child,context);
}
}

function traverseNode(node,context){
context.currentNode = node;

const { nodeTransforms } = context;
const exitFns = [];
for(let i=0; i<nodeTransforms.length; i++){
const onExit = nodeTransforms[i](node,context);
if(onExit){
if(Array.isArray(onExit)){
exitFns.push(...onExit);
}else{
exitFns.push(onExit);
}
}
}

switch(node.type){
case NodeTypes.Root:
case NodeTypes.Element:
traverseChildren(node,context);
break;
}

context.currentNode = node;
let i = exitFns.length;
while(i){
exitFns[i]();
}
}

function transform(root,options){
const context = createTransformContext(root,options);
traverseNode(root,context);
}

const templateAST = parse(template);

function getBaseTransformPreset(){
return [
transformText,
transformElement,
]
}

transform(templateAST, { nodeTransforms: getBaseTransformPreset() });

3.2 transformText

transformText() 将相邻的文本节点和表达式合并成一个表达式。

3.3 transformElement

transformElement() 为元素生成一个 JavaScript AST, 并为当前 node 节点添加 codegenNode 属性。

3.4 transformIf

3.5 transformFor

3.6 transformOn

3.7 transformModel

3.8 transformExpression

3.9 createVNodeCall

createVNodeCall() 用于生成 VNode

四、Generator 细节


有了 JavaScript AST 之后, 我们就可以根据它生成渲染函数了。通过generate函数来完成。

模版如下

const template = `<div id='app'> 
<div> 静态节点 </div>
<div>{{ value }}</div>
<div :class="vBindClass"> v-bind 指令</div>
<div v-if="vIF"> v-if 指令</div>
<div v-show="vShow"> v-show 指令 </div>
<ul>
<li v-for="(item,index) in vFor" :key="index"> {{ item }}</li>
</ul>
</div>`

结果如下

(function anonymous(Vue
) {
const _Vue = Vue
const { createElementVNode: _createElementVNode, createCommentVNode: _createCommentVNode } = _Vue

const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, " 静态节点 ", -1 /* HOISTED */)
const _hoisted_3 = { key: 0 }

return function render(_ctx, _cache) {
with (_ctx) {
const { createElementVNode: _createElementVNode, toDisplayString: _toDisplayString, normalizeClass: _normalizeClass, openBlock: _openBlock, createElementBlock: _createElementBlock, createCommentVNode: _createCommentVNode, vShow: _vShow, withDirectives: _withDirectives, renderList: _renderList, Fragment: _Fragment } = _Vue

return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_hoisted_2,
_createElementVNode("div", null, _toDisplayString(value), 1 /* TEXT */),
_createElementVNode("div", {
class: _normalizeClass(vBindClass)
}, " v-bind 指令", 2 /* CLASS */),
vIF
? (_openBlock(), _createElementBlock("div", _hoisted_3, " v-if 指令"))
: _createCommentVNode("v-if", true),
_withDirectives(_createElementVNode("div", null, " v-show 指令 ", 512 /* NEED_PATCH */), [
[_vShow, vShow]
]),
_createElementVNode("ul", null, [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(vFor, (item, index) => {
return (_openBlock(), _createElementBlock("li", { key: index }, _toDisplayString(item), 1 /* TEXT */))
}), 128 /* KEYED_FRAGMENT */))
])
]))
}
}
})

4.1 生成函数

render 本质上是一段字符。generate 的过程其实就是各个字符串拼接的过程。generate 生成的函数可以分为四个部分:

  1. 函数的前置代码: const _Vue = Vue

  2. 函数名: function render

  3. 函数的参数: _ctx, cache

  4. 函数体

4.2 with 语句

with 用于扩展一个语句的作用域链

五、编译优化


编译优化 是编译器将模版编译为渲染函数的过程中, 尽可能多的提取关键信息, 并以此指导生成最优代码的过程。Vue.js 3.0compile 编译器会将编译时得到的关键信息附着在它生成的虚拟DOM上,这些信息会通过虚拟DOM传递给渲染器, 最终渲染器会根据这些关键信息执行快捷路径, 从而提升运行时的性能。

5.1 动态标记

在编译器优化阶段, 提取的关键信息会影响最终生成的渲染函数代码, 具体体现在用于创建虚拟 DOM 节点的辅助函数上。比如:

<div id="foo">
<p class="bar"> {{text}} </p>
</div>

编译器会对模版进行编译优化, 会生成带有补丁标志的渲染函数, 如下所示:

render(){
return createVNode('div',{id: 'foo'},[
createVNode('p',{class: 'bar'},text,patchFlags.TEXT)
])
}

patchFlags.TEXT补丁标志, 表示当前虚拟节点是一个动态节点, 并且动态因子元素是: 具有动态的文本子节点。 在编译优化中, 用来描述节点信息的虚拟节点拥有一个额外的属性, 即 patchFlag, 它的值是一个数字。只要虚拟节点存在该属性, 我们就认为它是一个动态节点。所以 patchFlag 也是一个补丁标记。补丁标记 根据数字值的不同赋予它不同的含义:

  • 1 表示节点具有动态的文本

  • 1 << 1: 表示节点具有动态的 class 绑定

  • 1 << 2: 表示节点具有动态的 style 绑定

  • 1 << 3: 表示节点具有动态的 props 属性

  • ……

基于 patchFlag 属性, 在创建虚拟节点阶段, 把它的动态节点提取出来, 并将其存入到该虚拟节点的 dyamicChildren 数组内。我们把带有 dyamicChildren 属性的虚拟节点称为BlockBlock 不仅能够收集它的直接动态子节点, 还能够收集所有动态子代节点。有了 Block 之后, 会忽略虚拟节点的 children 数组, 而是直接找到该虚拟节点的 dynamicChildren 数组, 只更新该数组中的动态节点。这样,在更新时就实现了跳过静态内容, 只更新动态内容。同时, 由于动态节点中存在对应的补丁标志, 所以在更新动态节点时, 也能够做到靶向更新。例如: 当一个动态节点的 patchFlag 值为数字 1 时, 我们知道它只存在动态的文本节点, 所以只需要更新它的文本内容即可。

优化表现一: 渲染器更新标签节点时, 使用 patchChildren 函数更新标签子节点, 优先检测是否存在 dynamicChildren 动态节点集合, 如果存在, 调用 patchBlockChildren 函数对比 dynamicChildren 动态子节点完成更新。 这样渲染函数只会更新动态节点, 而跳过所有的静态节点。

优化表现二: 动态节点存在对应的补丁标志, 因此我们可以针对性的完成靶向更新, 避免全量的 props 更新, 从而最大化的提升性能。

if(n2.patchFlags){
if(n2.patchFlags === 1){
// 只需要更新 class
}else if(n2.patchFlags === 2){
// 只需要更新 style
} else if(){

}
}else {
// 全量更新
}

5.2 静态提升

静态提升 能够减少更新时创建 虚拟DOM 带来的性能开销和内存占用。如下所示:

没有静态提升的情况下, 对应的渲染函数是:

function render(){
return (openBlock(), createBlock('div',null,[
createVNode('p',null,'static text'),
createVNode('p',null,ctx.title,1)
]))
}

可以看到, 上述 虚拟DOM 中存在两个 p 标签, 一个是纯静态的, 而另一个是拥有动态文本。当响应式数据 title 的值发生变化时, 整个渲染函数会重新执行, 并产生新的 虚拟DOM。这个过程有一个很明显的问题,即纯静态的虚拟节点在更新时也会被重新创建一次。 很显然, 这是没有必要的。因此, 我们需要想办法避免由此带来的性能开销,解决方案就是静态提升,即把纯静态的节点提升到渲染函数之外。

const hoist1 = createVNode('p',null,'static text');

function render(){
return (openBlock(), createBlock('div',null,[
hoist1,
createVNode('p',null,ctx.title,1)
]))
}

可以看到, 当把纯静态节点提升到渲染函数之外后, 在渲染函数内部只会持有对静态节点的引用。当响应式数据发生变化,并使得渲染函数重新执行时,并不会重新创建静态的虚拟节点,从而避免了额外的性能开销。

另外, 虽然包含动态绑定的节点本身不会被提升, 但是该动态节点上仍然可能存在纯静态的属性, 同样可以将纯静态的 props 提升到渲染函数之外。, 这样同样可以减少创建虚拟 DOM 产生的开销以及内存占用。

5.3 预字符串化

预字符串化 是基于静态提升的一种优化策略。预字符串化 可以将静态提升中提升出来的静态节点序列化为字符串, 并生成一个 Static 类型的 VNode。如下所示:

<div>
<p></p>
<p></p>
<p></p>
<p></p>
</div>

静态提升

const hoist1 = createVNode('p',null,null,PatchFlags.HOISTED);
const hoist2 = createVNode('p',null,null,PatchFlags.HOISTED);
const hoist3 = createVNode('p',null,null,PatchFlags.HOISTED);
const hoist4 = createVNode('p',null,null,PatchFlags.HOISTED);

render(){
return (openBlock(),createBlock('div',null,[
hoist1,hoist2,hoist3,hoist4
]))
}

预字符串化

const hoistStatic = createStaticVNode('<p></p><p></p><p></p><p></p><p></p>');

render(){
return (openBlock(),createBlock('div',null,[hoistStatic]))
}

预字符串化 有以下几大优势:

  1. 大块的静态内容可以通过 innerHTML 进行设置, 在性能上具有一定优势

  2. 减少创建虚拟节点产生的性能开销

  3. 减少内存占用

5.4 缓存内联事件处理函数

缓存内联事件处理函数 可以避免不必要的更新。如下所示:

<Comp @change=" a+b " >

对于这样的模版, 编译器会为其创建一个内联事件处理函数,如下所示:

function render(ctx){
return h(Comp,{
onChange: ()=> (ctx.a + ctx.b)
});
}

很显然, 每次重新渲染时(即 render 重新执行时), 都会为 Comp 组件创建一个全新的 props 对象。同时, props 对象中 onChange 属性的值也会是全新的函数。这会导致渲染器对 Comp 组件进行更新, 造成额外的性能开销。为了避免无用的更新, 我们需要对内联事件处理函数进行缓存, 如下所示:

function render(ctx,cache){
return h(Comp,{
onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
})
}

渲染函数的第二个参数是一个数组 cache, 该数组来自组件实例, 我们可以把内联事件处理函数添加到 cache 数组中。 这样,当渲染函数重新执行并创建新的虚拟DOM树时, 会优先读取缓存中的事件处理函数。这样, 无论执行多少次渲染函数, props 对象中的 onChange 属性的值始终不变, 于是就不会触发 Comp 组件更新了。