实现一个简单的MVVM-4
这部分讲MVVM中编译器的实现。
又要被编译原理折磨了(悲
作为前端,我们应用编译技术的场景通常是:表格、报表中的自定义公式计算器,设计一种领域特定语言(DSL)等。
编译器
编译器其实只是一段程序,它用来将“一种语言 A”翻译成“另外一种语言
B”。其中,语言 A 通常叫作源代码(source code),语言 B
通常叫作目标代码(object code 或 target
code)。编译器将源代码翻译为目标代码的过程叫作编译(compile)。以下是教科书式完整编译过程:
DSL模板编译
对于 Vue.js
模板编译器来说,源代码就是组件的模板,而目标代码是能够在浏览器平台上运行的
JavaScript 代码,或其他拥有 JavaScript
运行时的平台代码,模板编译器的目标代码其实就是渲染函数:
模板编译器会首先对模板进行词法分析和语法分析,得到模板
AST。接着,将模板 AST 转换(transform)成 JavaScript AST。最后,根据
JavaScript AST 生成 JavaScript 代码,即渲染函数代码。
比如我们有这么一段模板:
1 2 3 4 5 <div> <h1 v-if="ok"> Template </h1> </div>
将会被编译成如下的AST:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 const ast = { type : 'Root' , children : [ type : 'Element' , tag : 'div' , children : [ { type : 'Element' , tag : 'h1' , props : [ { type : 'Directive' , name : 'if' exp : { type : 'Expression' , conten : 'ok' } } ] } ] ] }
模板 AST 具有与模板同构的嵌套结构。每一棵 AST
都有一个逻辑上的根节点,其类型为 Root。模板中真正的根节点则作为 Root
节点的 children 存在。
不同类型的节点是通过节点的 type 属性进行区分的。例如标签节点的 type
值为 ‘Element’。
标签节点的子节点存储在其 children 数组中。
标签节点的属性节点和指令节点会存储在 props 数组中。
不同类型的节点会使用不同的对象属性进行描述。例如指令节点拥有 name
属性,用来表达指令的名称,而表达式节点拥有 content
属性,用来描述表达式的内容。
parse
函数接收字符串模板作为参数,并将解析后得到的 AST
作为返回值返回。
有了模板 AST 后,我们就可以对其进行语义分析,并对模板
AST进行转换了。接着,我们还需要将模板 AST 转换为 JavaScript AST。因为
Vue.js 模板编译器的最终目标是生成渲染函数,而渲染函数本质上是 JavaScript
代码,所以我们需要将模板 AST 转换成用于描述渲染函数的 JavaScript
AST。
有了 JavaScript AST
后,我们就可以根据它生成渲染函数了,generate
函数会将渲染函数的代码以字符
串的形式返回,并存储在code
常量中。
下面是完整流程:
parse
实现原理与状态机
解析器的入参是字符串模板,解析器会逐个读取字符串模板中的字符,并根据一定的规则将整个字符串切割为一个个
Token。下面是这个过程的有限状态自动机,有的圆圈是单线的,而有的圆圈是双线的。双线代表此时状态机是一个合法的
Token:
对于模板<div>Hello</div>
,有如下过程:
状态机处于初始状态1
初始状态1 读取<
,进入标签开始状态2
标签开始状态2 读取div
,由于是字母,直到读取到>
前都处于标签名称状态3
标签名称状态3 读取>
,回到初始状态1
初始状态1 读取到文本内容Hello
,直到读取<
前都处于文本状态4 ,并记录在文本状态
4下产生的文本内容,
文本状态4 读取<
,进入标签开始状态2
标签开始状态2 读取/
,进入结束标签状态5
结束标签状态5 读取div
,直到读取到>
前都处于结束标签名称状态6
结束标签名称状态6 读取>
,回到初始状态1 ,分析结束,并记录在结束标签名称状态
6 下生成的结束标签名称。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 export function tokenize (str : string ) { let currentState = State .initial const chars : Array <string > = [] const tokens : Array <token> = [] while (str) { const char : string = str[0 ] switch (currentState) { case State .initial : if (char === '<' ) { currentState = State .tagStart str = str.substring (1 ) } else if (isAlpha (char)) { currentState = State .text chars.push (char) str = str.substring (1 ) } break case State .tagStart : if (isAlpha (char)) { currentState = State .tagName chars.push (char) str = str.substring (1 ) } else if (char === '/' ) { currentState = State .tagEnd str = str.substring (1 ) } break case State .tagName : if (isAlpha (char)) { chars.push (char) str = str.substring (1 ) } else if (char === '>' ) { currentState = State .initial tokens.push ({ type : "tag" , name : chars.join ('' ) }) chars.length = 0 str = str.substring (1 ) } break case State .text : if (isAlpha (char)) { chars.push (char) str = str.substring (1 ) } else if (char === '<' ) { currentState = State .tagStart tokens.push ({ type : "text" , content : chars.join ('' ) }) chars.length = 0 str = str.substring (1 ) } break case State .tagEnd : if (isAlpha (char)) { currentState = State .tagEndName chars.push (char) str = str.substring (1 ) } break case State .tagEndName : if (isAlpha (char)) { chars.push (char) str = str.substring (1 ) } else if (char === '>' ) { currentState = State .initial tokens.push ({ type : 'tagEnd' , name : chars.join ('' ) }) chars.length = 0 str = str.substring (1 ) } } } return tokens }
上面的代码是最基础的实现方式,易于理解,后续会继续优化这段代码。我们可以通过正则表达式来精简
tokenize 函数的代码。
构造AST
HTML
是一种标记语言,它的格式非常固定,标签元素之间天然嵌套,形成父子关系。因此,一棵用于描述
HTML 的 AST 将拥有与 HTML 标签非常相似的树型结构。
根据 Token 列表构建 AST 的过程,其实就是对 Token
列表进行扫描的过程。从第一个 Token 开始,顺序地扫描整个 Token
列表,直到列表中的所有 Token
处理完毕。在这个过程中,我们需要维护一个栈elementStack
,这个栈将用于维护元素间的父子关系。
每遇到一个开始标签节点,我们就构造一个Element
类型的 AST
节点,并将其压入栈中。
类似地,每当遇到一个结束标签节点,我们就将当前栈顶的节点弹出。
这样,栈顶的节点将始终充当父节点的角色。扫描过程中遇到的所有节点,都会作为当前栈顶节点的子节点,并添加到栈顶节点的children
属性下。
结束状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 function parse (templateStr ) { const tokens = tokenize (templateStr) const root = { type : 'Root' , children : [] } const elementStack = [root] while (tokens.length ) { const parent = elementStack[elementStack.length - 1 ] as AstNode const token = tokens[0 ] switch (token.type ) { case 'tag' : const elementNode = { type : 'Element' , tag : token.name , children : [] } parent.children ?.push (elementNode) elementStack.push (elementNode) break case 'text' : const textNode = { type : 'Text' , content : token.content } parent.children ?.push (textNode) break case 'tagEnd' : elementStack.pop () break } tokens.shift () } return root }
AST 的转换与插件化架构
所谓 AST 的转换,指的是对 AST 进行一系列操作,将其转换为新的 AST
的过程。新的 AST 可以是原语言或原 DSL 的描述,也可以是其他语言或其他 DSL
的描述。转换后的 AST 可以用于代码生成。这其实就是 Vue
的模板编译器将模板编译为渲染函数的过程。
为了对 AST 进行转换,我们需要能访问 AST
的每一个节点,这样才有机会对特定节点进行修改、替换、删除等操作。由于 AST
是树型数据结构,所以我们需要编写一个深度优先的遍历算法,从而实现对 AST
中节点的访问。
我们先完成一个工具函数dump
用于打印节点信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function dump (node, indent = 0 ) { const type = node.type const desc = type === 'Root' ? '' : type === 'Element' ? node.tag : node.content console .log (`${'-' .repeat(indent)} ${type } : ${desc} ` ) if (node.children ) { node.children .forEach ((child : AstNode ) => {dump (child, indent + 2 )}) } }
接下来,我们将着手实现对 AST 中节点的访问,从 AST
根节点开始,进行深度优先遍历,我们可以在遍历时对AST进行转换操作:
1 2 3 4 5 6 7 8 9 10 11 function traverseNode (ast ) { if (ast.type === 'Element' && ast.tag = 'p' ) ast.tag = 'h1' const children = ast.children if (children) { children.forEach ((child : AstNode ) => { traverseNode (child) }) } }
随着功能的不断增加,traverseNode
函数将会变得越来越“臃肿””,我们可以使用回调函数的机制来实现解
耦,为traverseNode
函数添加一个上下文。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 function traverseNode (ast, ctx ) { const children = ast.children const transforms = ctx.nodeTransforms transforms.forEach (transform => { transform (ast, ctx) }) if (children) { children.forEach ((child : AstNode ) => { traverseNode (child, ctx) }) } }function transform (ast ) { const ctx = { nodeTransforms : [ transformElement, transformText ] } traverseNode (ast, ctx) }function transformElement ( ) {}function transformText ( ) {}
接下来我们继续丰富上下文信息的构造:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 export interface transformCtx { nodeTransforms : Array <Function > currentNode : AstNode | null childIndex : number parent : AstNode | null replaceNode : (node : AstNode ) => void removeNode : () => void }
修改transform
和traverseNode
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 function transform (ast ) { const ctx = { nodeTransforms : [ transformElement, transformText ], currentNode : null , childIndex : 0 , parent : null , replaceNode (node ) { if (ctx.parent && ctx.parent .children ) { ctx.parent .children [ctx.childIndex ] = node ctx.currentNode = node } else { console .error ("children do not exist." ) } }, removeNode ( ) { if (ctx.parent && ctx.parent .children ) { ctx.parent .children .splice (ctx.childIndex , 1 ) ctx.currentNode = null } else { console .error ("children do not exist." ) } } } traverseNode (ast, ctx) }function traverseNode (ast, ctx ) { ctx.currentNode = ast ... transforms.forEach (transform => { transform (ast, ctx) if (!ctx.currentNode ) { return } }) if (children) { children.forEach ((child : AstNode ) => { ctx.parent = ast ctx.childIndex = children.indexOf (child) traverseNode (child, ctx) }) } }
目前的顺序处理的工作流存在的问题是,当一个节点被处理时,意味着它的父节点已经被处理完毕了,并且我们无法再回过头重新处理父节点。父节点的转换操作必须等待其所有子节点全部转换完毕后再执行,我们目前设计的转换工作流并不支持这一能力。
对节点的访问分为两个阶段,即进入阶段和退出阶段。当转换函数处于进入阶段时,它会先进入父节点,再进入子节点。而当转换函数处于退出阶段时,则会先退出子节点,再退出父节点。我们重新修改traverseNode
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 export function traverseNode (ast : AstNode , ctx : transformCtx ) { ctx.currentNode = ast const exitFns : Array <Function > = [] ... transforms.forEach (transform => { const onExit = transform (ctx.currentNode , ctx) if (onExit) { exitFns.push (onExit) } ... }) ... let i = exitFns.length while (i--) { exitFns[i]() } }
在traverseNode
函数的最后,执行这些缓存在exitFns
数组中的回调函数保证当退出阶段的回调函数执行时,当前访问的节点的子节点已经全部处理过了。我们在编写转换函数时,可以将转换逻辑编写在退出阶段的回调函数中,从而保证在对当前访问的节点进行转换之前,其子节点一定全部处理完毕。
需要注意的是,退出阶段的回调函数是反序执行的。这意味着,如果注册了多个转换函数,则它们的注册顺序将决定代码的执行结果。例如:
1 2 3 4 5 6 7 8 9 10 nodeTransForm : [ transformA, transformB ] transformA transformB transformB transformA
将模板 AST 转为 JavaScript
AST
我们需要将模板 AST 转换为用于描述渲染函数的 JavaScript
AST。下面时函数声明语句的组成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 export interface JavaScriptAstNode { type : string id : JavaScriptAstIdentifier params : [] body : JavaScriptAstState [] }
为了简化问题,这里我们不考虑箭头函数、生成器函数、async
函数等情况。
id
:函数名称,它是一个标识符Identifier
。
params
:函数的参数,它是一个数组。
body
:函数体,由于函数体可以包含多个语句,因此它也是一个数组。
我们分别用CallExpression, StringLiteral, ArrayExpression
分别描述函数调用、字符串字面量和数组:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 export interface CallExp { type : "CallExpression" callee : Identifier arguments : AstState [] }export interface StringLiteral { type : "StringLiteral" value : string }export interface ArrayExp { type : "ArrayExpression" elements : [] }
下面是一个例子,使用之前的模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 ]) } */const FunctionDeclNode = { type : 'FunctionDecl' id : { type : 'Identifier' , name : 'render' }, params : [], body : [ { type : 'ReturnStatement' , return : { type : 'CallExpression' , callee : { type : 'Identifier' , name : 'h' , arguments : [ { type : 'StringLiteral' , value : 'div' }, { type : 'ArrayExpression' , elements : [ { type : 'CallExpression' , callee : { type : 'Identifier' , name : 'h' }, arguments : { {type : "StringLiteral" , value : 'p' }, {type : "StringLiteral" , value : 'Vue' }, } }, ] } ] } } } ] }
接下来我们开始编写转换函数,将模板 AST 转换为上述 JavaScript
AST。不过在开始之前,我们需要编写一些用来创建 JavaScript AST
节点的辅助函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 createStringLiteral (value ) { return { type : 'StringLiteral' , value : value } }function createIdentifier (name ) { return { type : 'Identifier' , name : name } }function ArrayExpression (elements ) { return { type : 'ArrayExpression' , elements : elements } }function CallExpression (callee, args ) { return { type : 'CallExpression' , callee : createIdentifier (callee), arguments : args } }
为了把模板 AST 转换为 JavaScript
AST,我们同样需要两个转换函数transformElement
和transformText
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 function transformElement (node ) { return () => { if (node.type !== "Element" ) { return } const callExp : CallExp = createCallExpression ('h' , [ createStringLiteral (node.tag as string ) ]) if (node.children && node.children [0 ].jsNode ) { node.children .length === 1 ? callExp.arguments .push (node.children [0 ].jsNode ) : callExp.arguments .push ( createArrayExpression (node.children .map (c => c.jsNode )) ) } node.jsNode = callExp } }function transformText (node ) { if (node.type !== 'Text' ) { return } node.jsNode = createStringLiteral (node.content as string ) }
转换后得到的 AST
只是用来描述渲染函数render
的返回值的,所以我们最后一步要做的就是,补全
JavaScript AST,即把用来描述 render 函数本身的函数声明语句节点附加到
JavaScript AST 中。这需要我们编写transformRoot
函数来实现对
Root 根节点的转换:
代码生成
我们将实现 generate
函数来完成代码生成的任务,代码生成也是编译器的最后一步。与 AST
转换一样,代码生成也需要上下文对象。该上下文对象用来维护代码生成过程中程序的运行状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export function generate (node ) { const ctx : generateCtx = { code : '' , push (code : string ) { ctx.code += code } } genNode (node, ctx) return ctx.code }
我们希望最终生成的代码具有较强的可读性,因此我们应该考虑生成代码的格式,例如缩进和换行等。这就需要我们扩展context
对象,为其增加用来完成换行和缩进的工具函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 export function generate (node : JsAstNode ) { const ctx : generateCtx = { code : '' , push (code : string ) { ctx.code += code }, currentIndent : 0 , newline ( ) { ctx.code += '\n' + ` ` .repeat (ctx.currentIndent ) }, indent ( ) { ctx.currentIndent ++ ctx.newline () }, deIndent ( ) { ctx.currentIndent -- ctx.newline } } genNode (node, ctx) return ctx.code }
有了这些基础能力之后,我们就可以开始编写genNode
函数来完成代码生成的工作了。代码生成的原理其实很简单,只需要匹配各种类型的
JavaScript AST 节点,并调用对应的生成函数即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 function genNode (node : JsAstNode , context : generateCtx ) { switch (node.type ) { case 'FunctionDecl' : genFunctionDecl (node, context) break case 'ReturnStatement' : genReturnStatement (node, context) break case 'CallExpression' : genCallExpression (node, context) break case 'StringLiteral' : genStringLiteral (node, context) break case 'ArrayExpression' : genArrayExpression (node, context) break } }
接下来我们分别实现每一种情况对应的生成函数。首先来看genFunctionDecl
,用来为函数声明类型的节点生成对应的
JavaScript 代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 function genFunctionDecl (node : JsAstNode , context : generateCtx ) { const { push, indent, deIndent} = context push (`function ${(node.id as Identifier).name} ` ) push (`(` ) genNodeList (node.params as any [], context) push (`) {` ) indent () (node.body as JsAstNode []).forEach ((n : JsAstNode ) => genNode (n, context)) deIndent () push ('}' ) }function genNodeList (params : Array <JsAstNode >, context : generateCtx ) { const { push } = context for (let i = 0 ; i < params.length ; i++) { const param = params[i] genNode (param, context) if (i < params.length - 1 ) { push (', ' ) } } }
这里有一个需要注意的点:
1 2 3 indent () (node.body as JsAstNode []).forEach ((n : JsAstNode ) => genNode (n, context))
这段代码经过Webpack打包后的代码为:
1 2 indent ()(node.body ).forEach ((n ) => genNode (n, context));
会引发indent() is not a function
错误。为了避免这个问题,最好在indent()
后面加上;
。
genArrayExpression
函数的实现与genNodelist
相似,只需要包裹[]
即可:
1 2 3 4 5 6 function genArrayExpression (node : ArrayExp , context : generateCtx ) { const { push } = context push ('[' ) genNodeList (node.elements , context) push (']' ) }
genCallExpression
也用到了genNodeList
用于处理参数:
1 2 3 4 5 6 7 8 9 function genCallExpression (node : CallExp , context : generateCtx ) { const { push } = context const { callee, arguments : args } = node push (`${callee.name} (` ) genNodeList (args, context) push (')' ) }
对于ReturnStatement
和StringLiteral
类型的节点来说,为它们生成代码很简单:
1 2 3 4 5 6 7 8 9 10 function genReturnStatement (node : JsAstNode , context : generateCtx ) { const { push } = context push ('return ' ) genNode (node.return , context) }function genStringLiteral (node : StringLiteral , context : generateCtx ) { const { push } = context push (`${node.value} ` ) }
解析器
我们将更多地利用正则表达式来实现 HTML 解析器。WHATWG 关于 HTML
的解析规范,其中定义了完整的错误处理和状态机的状态迁移流程,还提及了一些特殊的状态,例如
DATA、CDATA、RCDATA、RAWTEXT 等。
文本模式
文本模式指的是解析器在工作时所进入的一些特殊状态,在不同的特殊状态下,解析器对文本的解析行为会有所不同。具体来说,当解析器遇到一些特殊标签时,会切换模式,从而影响其对文本的解析行为。这些特殊标签是:
<title>, <textarea>
标签会切换到RCDATA
模式
<style>, <xmp>, <iframe>, <noembed>, <noframes>, <noscript>
等标签会切换到RAWTEXT
模式
当解析器遇到<![CDATA[
字符串进入CDATA
模式
解析器的初始模式则是 DATA 模式。对于 Vue 的模板 DSL
来说,模板中不允许出现<script>
标签,因此 Vue
模板解析器在遇到<script>
标签时也会切换到 RAWTEXT
模式。
在默认的 DATA 模式下:
解析器在遇到字符<
时,会切换到标签开始状态(tag open
state)。换句话说,在该模式下,解析器能够解析标签元素。
当解析器遇到字符&
时,会切换到字符引用状态(character
reference state),也称 HTML字符实体状态。也就是说,在 DATA
模式下,解析器能够处理 HTML字符实体。
在 RCDATA 状态下:
当解析器遇到字符<
时,不会再切换到标签开始状态,而会切换到RCDATA less-than sign state
状态。
如果解析器遇到字符/
,则直接切换到 RCDATA
的结束标签状态,即RCDATA end tag open state
否则会将当前字符<
作为普通字符处理,然后继续处理后面的字符,间接说明了在<textarea>
内可以将字符
<
作为普通文本,解析器并不会认为字符<
是标签开始的标志
解析器仍然支持 HTML
实体。因为当解析器遇到字符&
时,会切换到字符引用状态
在 RAWTEXT 模式下的工作方式与在 RCDATA 模式下类似:
CDATA 模式在 RAWTEXT 模式的基础上更进一步:
解析器将把任何字符都作为普通字符处理,直到遇到 CDATA
的结束标志为止。
模式
能否解析标签
能否支持HTML实体
DATA
能
是
RCDATA
否
是
RAWTEXT
否
否
CDATA
否
否
后续编写解析器代码时,我们会将上述模式定义为状态表:
递归下降算法构造模板 AST
解析器的基本架构模型如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function parse (templateStr : string ): AstNode { const context : parseCtx = { source : templateStr, mode : TextModes .DATA } const nodes : AstNode [] = parseChildren (context, []) return { type : 'Root' , children : nodes } }
与之前的实现不同,创建 Token 与构造模板 AST
的过程可以同时进行,因为模板和模板 AST
具有同构的特性。parseChildren
函数是整个解析器的核心。后续我们会递归地调用它来不断地消费模板内容。parseChildren
函数会返回解析后得到的子节点。
parseChildren
函数本质上也是一个状态机,该状态机有多少种状态取决于子节点的类型数量:
标签节点,例如<div>
。
文本插值节点,例如{{ val }}
。
普通文本节点,例如:text
。
注释节点,例如<!---->
。
CDATA
节点,例如<![CDATA[ xxx ]]>
注意正则表达式/a-z/i
中的i
,意思是忽略大小写(case
insensitive)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 export function parseChildren (ctx : parseCtx, ancestors : AstNode [] ) { let nodes : AstNode [] = [] const { mode, source } = ctx while (!isEnd (ctx, ancestors)) { let node if (mode === TextModes .DATA || mode === TextModes .RCDATA ) { if (mode === TextModes .DATA && source[0 ] === '<' ) { if (source[1 ] === '!' ) { if (source.startsWith ('<!--' )) { node = parseComment (ctx) } else if (source.startsWith ('<![CDATA[' )) { node = parseCDATA (ctx, ancestors) } } } else if (source[1 ] === '/' ) { } else if (/a-z/i .test (source[1 ])) { node = parseElement (ctx, ancestors) } else if (source.startsWith ('{{' )) { node = parseInterpolation (ctx) } } if (!node) { node = parseText (ctx) } nodes.push (node) } return nodes }
在解析模板时,我们不能忽略空白字符。这些空白字符包括:换行符()、回车符(、空格(’
’)、制表符(以及换页符(。我们用加号(+)代表换行符,用减号(-)代表空格字符
接下来我们先实现元素标签解析parseElement
。如果一个标签不是自闭合标签,则可以认为,一个完整的标签元素是由开始标签、子节点和结束标签这三部分构成的。为了解析标签的子节点,我们递归地调用了parseChildren
函数。这意味着,一个新的状态机开始运行了。随着标签嵌套层次的增加,新的状态机会随着parseChildren
函数被递归地调用而不断创建,这就是“递归下降”中“递归”二字的含义。而上级parseChildren
函数的调用用于构造上级模板
AST
节点,被递归调用的下级parseChildren
函数则用于构造下级模板
AST 节点。最终,会构造出一棵树型结构的模板
AST,这就是“递归下降”中“下降”二字的含义。
状态机的开启与停止
我们在上一节中有一个没有实现的函数isEnd
,这个函数用于判断状态机何时停止。
此时“状态机
2”拥有程序的执行权,它持续解析模板直到遇到结束标签</p>
。因为这是一个结束标签,并且在父级节点栈中存在与该结束标签同名的标签节点,所以“状态机
2”会停止运行,并弹出父级节点栈中处于栈顶的节点。此时“状态机
2”已经停止运行了,但“状态机
1”仍在运行中,于是会继续解析模板,直到遇到下一个<p>
标签。这时“状态机
1”会再次调用parseElement
函数解析标签节点,因此又会执行压栈并开启新的“状态机
3”。
“状态机
3”会继续解析模板,直到遇到结束标签</p>
。因为这是一个结束标签,并且在父级节点栈中存在与该结束标签同名的标签节点,所以“状态机
3”会停止运行,并弹出父级节点栈中处于栈顶的节点。当“状态机
3”停止运行后,程序的执行权交还给“状态机 1”。“状态机
1”会继续解析模板,直到遇到最后的</div>
结束标签。这时“状态机
1”发现父级节点栈中存在与结束标签同名的标签节点,于是将该节点弹出父级节点栈:
这时父级节点栈为空,状态机全部停止运行,模板解析完毕。当解析器遇到开始标签时,会将该标签压入父级节点栈,同时开启新的状态机。当解析器遇到结束标签,并且父级节点栈中存在与该标签同名的开始标签节点时,会停止当前正在运行的状态机。
1 2 3 4 5 6 7 8 9 10 11 12 function isEnd (ctx : parseCtx, ancestors : AstNode [] ) { if (!ctx.source ) return true const parent = ancestors[ancestors.length - 1 ] if (parent && ctx.source .startsWith (`</${parent.tag} ` )) { return true } }
目前的实现有个问题:
1 <div > <span > </div > </span >
有两种解释方式:
我们先来实现辅助函数parseTag
和parseEndTag
。我们为上下文对象增加了advanceBy
函数和advanceSpaces
函数。其中advanceBy
函数用来消费指定数量的字符。advanceSpaces
函数则用来消费无用的空白字符,因为标签中可能存在空白字符,例如在模板<div---->
中减号(-)代表空白字符。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const context : parseCtx = { source : templateStr, mode : TextModes .DATA , advanceBy (num : number ) { context.source = context.source .slice (num) }, advanceSpaces ( ) { const match = /^[\t\r\n\f ]+/ .exec (context.source ) if (match) { context.advanceBy (match[0 ].length ) } } }
有了advanceBy
和
advanceSpaces
函数后,我们就可以给出parseTag
函数的实现了,如下面的代码所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 export function parseTag (context, type = 'start' ) { const { advanceBy, advanceSpaces } = context const match = type === 'start' ? /^<([a-z][^\t\r\n\f />]*)/i .exec (context.source ) : /^<\/([a-z][^\t\r\n\f />]*)/i .exec (context.source ) if (match) { const tag = match[1 ] advanceBy (match[0 ]) advanceSpaces () const isSelfClosing = context.source .startsWith ("/>" ) advanceBy (isSelfClosing ? 2 : 1 ) return { type : 'Element' , tag : tag, props : [], children : [], isSelfClosing : isSelfClosing } } else { console .error ("tag does not match." ) return } }
实现了parseTag
,我们就可以开始实现parseElement
了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 function parseElement (context : parseCtx, ancestors : AstNode [] ) { const element = parseTag (context) as AstNode if (element.isSelfClosing ) return element if (element.tag === 'textarea' || element.tag ==='title' ) { context.mode = TextModes .RCDATA } else if (/style | xmp | iframe | noembed | noframes | noscipt/ .test (element.tag as string )) { context.mode = TextModes .RAWTEXT } else { context.mode = TextModes .DATA } ancestors.push (element) element.children = parseChildren (context, ancestors) ancestors.pop () if (context.source .startsWith (`</${element.tag} ` )) { parseTag (context, 'end' ) } else { console .error (`${element.tag} is lack of end tag.` ) } return element }
解析属性
parseTag
解析函数会消费整个开始标签,这意味着该函数需要有能力处理开始标签中存在属性与指令。为了处理属性和指令,我们需要在parseTag
函数中增加parseAttributes
解析函数。我们先来看parseAttributes
函数:
parseAttributes
函数消费模板内容的过程,就是不断地解析属性名称、等于号、属性值的过程。在属性名称解析完毕之后,模板剩余内容一定是以等于号开头的,即:
1 = 'foo' v-show='display'>
我们需要消费等于号字符。由于等于号和属性值之间也可能存在空白字符,所以我们也需要消费对应的空白字符。接下来,到了处理属性值的环节。模板中的属性值存在三种情况:
属性值被双引号包裹:id="foo"
属性值被单引号包裹:id='foo'
属性值没有引号包裹:id=foo
因此我们可以通过检查当前模板内容是否以引号开头来确定属性值是否被引用。既然属性值被引号引用了,就意味着在剩余模板内容中,下一个引号之前的内容都应该被解析为属性值。如果属性值没有被引号引用,那么在剩余模板内容中,下一个空白字符之前的所有字符都应该作为属性值。
当属性值和引号被消费之后,由于属性值与下一个属性名称之间可能存在空白字符,所以我们还要消费对应的空白字符。
接下来重新执行上述步骤直到遇到标签的“结束部分”,即字符>
,这时,parseAttributes
函数中的
while 循环将会停止,完成属性和指令的解析。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 function parseAttributes (context : parseCtx ) { const { advanceBy, advanceSpaces } = context const props : any [] = [] while (!context.source .startsWith ('>' ) && !context.source .startsWith ('/>' )) { const match = /^[\t\r\n\f />][^\t\r\n\f />=]*/ .exec (context.source ) if (match) { const name = match[0 ] advanceBy (name.length ) advanceSpaces () advanceBy (1 ) advanceSpaces () let value = '' const quote = context.source [0 ] const isQuoted = quote === '"' || quote === "'" if (isQuoted) { advanceBy (1 ) const endQuoteIndex = context.source .indexOf (quote) if (endQuoteIndex > -1 ) { value = context.source .slice (0 , endQuoteIndex) advanceBy (value.length ) advanceBy (1 ) } else { console .error (`${context.source} : Missing quote` ) } } else { const match = /^[^\t\r\n\f >]+/ .exec (context.source ) if (match) { value = match[0 ] advanceBy (value.length ) } else { console .error (`${context.source} can not match.` ) } } advanceSpaces () props.push ({ type : 'Attribute' , name, value }) } else { console .error (`${context.source} can not match.` ) } } }
然后修改parseTag
以支持属性的解析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 export function parseTag (context : parseCtx, type = 'start' ): AstNode | undefined { const { advanceBy, advanceSpaces } = context const match = type === 'start' ? /^<([a-z][^\t\r\n\f />]*)/i .exec (context.source ) : /^<\/([a-z][^\t\r\n\f />]*)/i .exec (context.source ) if (match) { const tag = match[1 ] advanceBy (match[0 ]) advanceSpaces () const props = parseAttributes (context) const isSelfClosing = context.source .startsWith ("/>" ) advanceBy (isSelfClosing ? 2 : 1 ) return { type : 'Element' , tag : tag, props : props, children : [], isSelfClosing : isSelfClosing } } else { console .error ("tag does not match." ) return } }
解析文本与解码HTML实体
解析文本
调用parseText
函数处理文本内容。此时解析器会在模板中寻找下一个<字符或插值定界符的位置索引,记为索引
I。然后,解析器会从模板的头部到索引 I
的位置截取内容,这段截取出来的字符串将作为文本节点的内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 function parseText (context : parseCtx ) { let endIndex = context.source .length const ltIndex = context.source .indexOf ('<' ) const delimiterIndex = context.source .indexOf ('{{' ) if (ltIndex > -1 && ltIndex < endIndex) { endIndex = ltIndex } if (delimiterIndex > -1 && delimiterIndex < endIndex) { endIndex = delimiterIndex } const content = context.source .slice (0 , endIndex) context.advanceBy (endIndex) return { type : 'Text' , content } }
解码命名字符引用
HTML 实体是一段以字符&
开始的文本内容。实体用来描述
HTML
中的保留字符和一些难以通过普通键盘输入的字符,以及一些不可见的字符。HTML
实体总是以字符&
开头,以字符;
结尾。之所以需要解析命名字符引用,是因为
Vue 模板中,文本节点所包含的 HTML
实体不会被浏览器解析。这是因为模板中的文本节点最终将通过如el.textContent
等文本操作方法设置到页面,而通过el.textContent
设置的文本内容是不会经过
HTML 实体解码的。
解析 HTML 实体也是一种状态机:
关于字符引用中的分号:
当存在分号时:执行完整匹配。
当省略分号时:执行最短匹配。
为此,我们需要精心设计命名字符引用表。由于命名字符引用的数量非常多,因此这里我们只取其中一部分作为命名字符引用表的内容:
1 2 3 4 5 6 7 export const namedCharacterReferences = { "gt" : ">" , "gt;" : ">" , "lt" : "<" , "lt;" : "<" , "ltcc;" : "⪦" }
对于普通文本部分,由于它不需要被解码,因此索引原封不动地保留。而对于可能是字符引用的部分,执行解码工作。
计算出命名字符引用表中实体名称的最大长度。
根据最大长度截取字符串。
用截取后的字符串作为键去命名字符引用表中查询对应的值,即解码。
当发现不匹配时,我们将最大长度减
1,并重新执行第二步,直到找到匹配项为止。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 function decodeHTML (rawText : string , asAttr = false ) { let offset = 0 const end = rawText.length let decodedText = '' let maxCRNameLength = 0 function advance (length : number ) { offset += length rawText = rawText.slice (length) } while (offset < end) { const head = /&(?:#x?)?/i .exec (rawText) if (!head) { const remain = end - offset decodedText += rawText.slice (0 , remain) advance (remain) break } decodedText += rawText.slice (0 , head.index ) advance (head.index ) if (head[0 ] === '&' ) { let name = '' let value : any if (/[0-9a-z]/i .test (rawText[1 ])) { if (!maxCRNameLength) { maxCRNameLength = Object .keys (namedCharacterReferences).reduce ( (max, name ) => Math .max (Number (max), name.length ), 0 ) } for (let length = maxCRNameLength; !value && length > 0 ; length--) { name = rawText.substring (1 , length) value = namedCharacterReferences[name] } if (value) { const semi = name.endsWith (";" ) if (asAttr && !semi && /[=a-z0-9]/i .test (rawText[name.length + 1 ] || '' ) ) { decodedText += '&' + name advance (1 + name.length ) } else { decodedText += value advance (1 + name.length ) } } else { decodedText += '&' + name advance (1 + name.length ) } } else { decodedText += '&' advance (1 ) } } } return decodedText }
解码数字字符引用
数字字符引用的格式是:前缀 + Unicode
码点。解码数字字符引用的关键在于,如何提取字符引用中的 Unicode
码点。考虑到数字字符引用的前缀可以是以十进制表示(&#),也可以是以十六进制表示(&#x),所以我们使用下面的代码来完成码点的提取:
1 2 3 4 5 6 7 8 9 10 11 const hex = head[0 ] === '&#x' const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/i const body = pattern.exec (rawText)if (body) { const cp = parseInt (body[1 ], hex ? 16 : 10 ) const char = String .fromCodePoint (cp) }
在真正进行解码前,需要对码点的值进行合法性检查,这里就不做过多叙述了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 if (body) { let cp = parseInt (body[1 ], hex ? 16 : 10 ) if (cp === 0 ) { cp = 0xfffd } else if (cp > 0x10ffff ) { cp = 0xfffd } else if (cp >= 0xd800 && cp <= 0xdfff ) { cp = 0xfffd } else if ((cp >= 0xfdd0 && cp <= 0xfdef ) || (cp & 0xfffe ) === 0xfffe ) { } else if ( (cp >= 0x01 && cp <= 0x08 ) || cp === 0x0b || (cp >= 0x0d && cp <= 0x1f ) || (cp >= 0x7f && cp <= 0x9f ) ) { cp = CCR_REPLACEMENTS [cp] || cp } const char = String .fromCodePoint (cp) }
我们将上述代码整合到 decodeHtml 函数中,这样就实现一个完善的 HTML
文本解码函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 export function decodeHTML (rawText : string , asAttr = false ) { ... while (offset < end) { ... if (head[0 ] === '&' ) { ... } else { const hex = head[0 ] === '&#x' const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/i const body = pattern.exec (rawText) if (body) { let cp = parseInt (body[1 ], hex ? 16 : 10 ) if (cp === 0 ) { cp = 0xfffd } else if (cp > 0x10ffff ) { cp = 0xfffd } else if (cp >= 0xd800 && cp <= 0xdfff ) { cp = 0xfffd } else if ((cp >= 0xfdd0 && cp <= 0xfdef ) || (cp & 0xfffe ) === 0xfffe ) { } else if ( (cp >= 0x01 && cp <= 0x08 ) || cp === 0x0b || (cp >= 0x0d && cp <= 0x1f ) || (cp >= 0x7f && cp <= 0x9f ) ) { cp = CCR_REPLACEMENTS [cp] || cp } const char = String .fromCodePoint (cp) decodedText += char advance (body[0 ].length ) } else { decodedText += head[0 ] advance (head[0 ].length ) } } } return decodedText }
解析插值与注释
文本插值是 Vue.js
模板中用来渲染动态数据的常用方法,默认情况下,插值以字符串
结尾。我们通常将这两个特殊的字符串称为定界符。定界符中间的内容可以是任意合法的
JavaScript 表达式:
解析器在解析插值时,只需要将文本插值的开始定界符与结束定界符之间的内容提取出来,作为
JavaScript 表达式即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 function parseInterpolation (context : parseCtx ) { context.advanceBy ('{{' .length ) const closeIndex = context.source .indexOf ("}}" ) if (closeIndex < 0 ) { console .error (`${context.source} is lack of '}}'` ) } const content = context.source .slice (0 , closeIndex) context.advanceBy (content.length ) context.advanceBy ("}}" .length ) return { type : 'Interpolation' , content : { type : "Expression" , content : decodeHTML (content) } } }
解析注释的思路与解析插值非常相似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 function parseComment (context : parseCtx ) { context.advanceBy ('<!--' .length ) const closeIndex = context.source .indexOf ("-->" ) if (closeIndex < 0 ) { console .error (`${context.source} is lack of '-->'` ) } const content = context.source .slice (0 , closeIndex) context.advanceBy (content.length ) context.advanceBy ("-->" .length ) return { type : 'Comment' , content } }
编译优化
编译优化指的是编译器将模板编译为渲染函数的过程中,尽可能多地提取关键信息,并以此指导生成最优代码的过程。优化的方向基本一致,即尽可能地区分动态内容和静态内容,并针对不同的内容采用不同的优化策略。
动态节点收集与补丁标志
传统 Diff 算法的问题
无论哪一种 Diff 算法,当它在比对新旧两棵虚拟 DOM
树的时候,总是要按照虚拟 DOM
的层级结构“一层一层”地遍历。但是我们会发现,在有些模板中并不是所有的 DOM
节点都会发生变化,因此传统 diff 算法存在很多无意义的对比操作。
通过编译手段,我们可以分析出很多关键信息,例如哪些节点是静态的,哪些节点是动态的。为什么虚拟
DOM
会产生额外的性能开销呢?根本原因在于,渲染器在运行时得不到足够的信息。传统
Diff
算法无法利用编译时提取到的任何关键信息,这导致渲染器在运行时不可能去做相关的优化。而
Vue.js 3 的编译器会将编译时得到的关键信息“附着”在它生成的虚拟 DOM
上,这些信息会通过虚拟 DOM
传递给渲染器。最终,渲染器会根据这些关键信息执行“快捷路径”,从而提升运行时的性能。
Block
与PatchFlags
只要运行时能够区分动态内容和静态内容,即可实现极致的优化策略。对于动态节点,我们为它加上一个额外的属性补丁标志patchFlag
。我们可以把补丁标志理解为一系列数字标记,并根据数字值的不同赋予它不同的含义,示例如下:
数字 1 :代表节点有动态的 textContent
数字 2 :代表元素有动态的 class
绑定
数字 3 : 代表元素有动态的 style
绑定
数字 4 : …
有了这项信息,我们就可以在虚拟节点的创建阶段,把它的动态子节点提取出来,并将其存储到该虚拟节点的dynamicChildren
数组内,我们把带有该属性dynamicChildren
数组的虚拟节点称为“块”(Block
)。一个Block
不仅能够收集它的直接动态子节点,还能够收集所有动态子代节点。
有了Block
这个概念之后,渲染器的更新操作将会以Block
为维度。也就是说,当渲染器在更新一个Block
时,会忽略虚拟节点的children
数组,而是直接找到该虚拟节点的dynamicChildren
数组,并只更新该数组中的动态节点。同时,由于动态节点中存在对应的补丁标志,所以在更新动态节点的时候,也能够做到靶向更新。
当我们在编写模板代码的时候,所有模板的根节点都会是一个Block
节点,带指令的节点也需要作为Block
节点。
收集动态节点
在编译器生成的渲染函数代码中,并不会直接包含用来描述虚拟节点的数据结构,而是包含着用来创建虚拟
DOM 节点的辅助函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 render ( ) { return createVNode (...) } function createVNode ( tag : string , props : Array <any >, children : Array <any >, patchFlag?: boolean ) { const key = props && props.key props && delete props.key return { tag, props, children, key } }
当外层createVNode
函数执行时,内层的createVNode
函数已经执行完毕了。因此,为了让外层Block
节点能够收集到内层动态节点,就需要一个栈结构的数据来临时存储内层的动态节点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 const dynamicChildrenStack = []let currentDynamicChildren = undefined function openBlock ( ) { dynamicChildrenStack.push ((currentDynamicChildren = [])) }function closeBlock ( ) { currentDynamicChildren = dynamicChildrenStack.pop () }function createVNode ( tag, props, children, patchFlag ) { const key = props && props.key props && delete props.key const vnode : Block = { tag, props, children, key, PatchFlag : patchFlag } if (typeof patchFlag !== 'undefined' && currentDynamicChildren) { currentDynamicChildren.push (vnode) } return vnode }
在createVNode
函数内部,检测节点是否存在补丁标志。如果存在,则说明该节点是动态节点,于是将其添加到当前动态节点集合。我们还需要重新设计渲染函数的执行方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 render ( ) { return (openBlock (), createBlock ('div' , null , [ createVNode ('div' , { class : 'foo' }, null , 1 ), createVNode ('p' , { class : 'bar' }, null ,), ]) }function createBlock ( tag, props, children ) { const block = createVNode (tag, props, children) block.dynamicChildren = currentDynamicChildren closeBlock () return block }
由于createVNode
函数和createBlock
函数的执行顺序是从内向外,所以当createBlock
数执行时,内层的所有createVNode
函数已经执行完毕了。这时,currentDynamicChildren
数组中所存储的就是属于当前Block
的所有动态子代节点。
渲染器的运行时支持
在之前实现的patchElement
函数中,渲染器在更新标签节点时,使用patchChildren
函数来更新标签的子节点。但该函数会使用传统虚拟
DOM 的 Diff
算法进行更新,这样做效率比较低。有了dynamicChildren
之后,我们可以直接对比动态节点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 function patchElement (oldNode, newNode ) { ... if (newNode.dynamicChildren ) { patchBlockChildren (oldNode, newNode) } else { patchChildren (oldNode, newNode, el) } }function patchBlockChildren (oldNode, newNode ) { for (let i = 0 ; newNode.dynamicChildren && i < newNode.dynamicChildren .length ; i++) { if (oldNode.dynamicChildren ) { patchElement (oldNode.dynamicChildren [i], newNode.dynamicChildren [i]) } else { console .error ("Block does not exist." ) break } } }
动态节点集合能够使得渲染器在执行更新时跳过静态节点,但对于单个动态节点的更新来说,由于它存在对应的补丁标志,因此我们可以针对性地完成靶向更新:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 function patchElement (oldNode, newNode ) { ... if (newNode.PatchFlag ) { const flag = newNode.PatchFlag if (flag === 1 ) { } else if (flag === 2 ) { } else if (flag === 3 ) { } } else { for (const key in newProps) { if (newProps[key] !== oldProps[key]) { patchProps (el, key, oldProps[key], newProps[key]) } } for (const key in oldProps) { if (!(key in newProps)) { patchProps (el, key, oldProps[key], null ) } } } ... }
Block
树
带有结构化指令的节点,如带有v-if
和v-for
指令的节点,都应该作为Block
角色。
v-if
1 2 3 4 5 6 7 8 9 10 11 12 <div> <section v-if='foo'> <p> {{ a }} </p> </section> <div v-else> <p> {{ a }} </p> </div> </div>
dynamicChildren
数组中收集的动态节点是忽略虚拟 DOM
树层级的。换句话说,结构化指令会导致更新前后模板的结构发生变化,即模板结构不稳定。我们也需要让带有v-if/v-else-if/v-else
等结构化指令的节点作为Block
角色即可。
1 2 3 Block(Div) - Block(Section v-if) - Block(Div v-else)
父级Block
除了会收集动态子代节点之外,也会收集子Block
。因此,两个子Block(section)
将作为父级Block(div)
的动态节点被收集到父级Block(div)
的dynamicChildren
数组中。
1 2 3 4 5 6 7 8 9 10 11 const block = { tag : 'div' , dynamicChildren : [ { tag : 'section' , {key : 0 }, dynamicChildren : [...] } ] }
当v-if
条件为真时,父级Block
的dynamicChildren
数组中包含的是Block(section v-if)
;当v-if
的条件为假时,父级Block
的dynamicChildren
数组中包含的将是Block(section v-else)
。在
Diff
过程中,渲染器能够根据Block
的key
值区分出更新前后的两个Block
是不同的,并使用新的Block
替换旧的Block
。这样就解决了
DOM 结构不稳定引起的更新问题。
v-for
对于如下模板:
1 2 3 4 5 6 7 8 9 10 11 <div> <p v-for="item in list"> {{ item }} </p> <i> {{ foo }} </i> <i> {{ bar }} </i> </div>
由于传统 Diff 的一个非常重要的前置条件是:进行 Diff
操作的节点必须是同层级节点。但是dynamicChildren
数组内的节点未必是同层级的。解决方法很简单,我们只需要让带有
v-for 指令的标签也作为 Block 角色即可。这样就能够保证虚拟 DOM
树具有稳定的结构,即无论 v-for 在运行时怎样变化,这棵 Block
树看上去都是一样的:
1 2 3 4 5 6 7 8 const block = { tag : 'div' , dynamicChildren : [ { tag : Fragment , dynamicChildren : [] }, { tag : 'i' , children : ctx.foo , 1 }, { tag : 'i' , children : ctx.bar , 1 }, ] }
由于v-for
指令渲染的是一个片段,所以我们需要使用类型为Fragment
的节点来表达v-for
指令的渲染结果,并作为
Block 角色。
Fragment
稳定性
所谓结构不稳定,从结果上看,指的是更新前后一个 block
的dynamicChildren
数组中收集的动态节点的数量或顺序不一致。其实对于这种情况,没有更好的解决办法,我们只能放弃根据dynamicChildren
数组中的动态节点进行靶向更新的思路,并回退到传统虚拟
DOM 的 Diff
手段,即直接使用Fragment
的children
而非dynamicChildren
来进行
Diff 操作。
但需要注意的是,Fragment
的子节点(children)仍然可以是由
Block 组成的数组
而对于稳定的Fragment
:
v-for 指令的表达式是常量
模板中有多个根节点。同时,用于描述具有多个根节点的模板的Fragment
也是稳定的。
静态提升
静态提升,即把纯静态的节点提升到渲染函数之外,能够减少更新时创建虚拟
DOM 带来的性能开销和内存占用。
1 2 3 4 5 6 7 8 9 const hoist1 = createVNode ('p' , null , 'text' )function render ( ) { return (openBlock (), createBlock ('div' , null , [ hoist1, createVNode ('p' , null , ctx.title , 1 ) ])) }
静态提升是以树为单位的:
1 2 3 4 5 6 7 <div> <section> <p> <span> abc </span> </p> </section> </div>
在上面这段模板中,除了根节点会作为Block角色不可提升之外,整个section
元素及其子节点都会被提升,如果把上面模板中的静态字符串abc
换成动态绑定的{{ abc }}
,那么整棵树都不会被提升。
虽然包含动态绑定的节点本身不会被提升,但是该动态节点上仍然可能存在纯静态的属性。遇到静态属性时,可以把它们提升到渲染函数之外:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const hoistProp = { foo : 'bar' , a : 'b' } functiono render (ctx ) { return (openBlock (), createBlock ('div' , null , [ createVNode ('p' , hoistProp, ctx.text ) ])) }
预字符串化
假设模板中包含大量连续纯静态的标签节点,当采用了静态提升优化策略后,预字符串化能够将这些静态节点序列化为字符串,并生成一个Static
类型的VNode
:
1 2 3 4 5 6 7 const hoistStatic = createStaticVNode ('<p></p><p></p><p></p><p></p>' )render ( ) { return (openBlock (), createBlock ('div' , null , [ hoistStatic ])) }
这么做有几个明显的优势:
大块的静态内容可以通过 innerHTML
进行设置,在性能上具有一定优势。
减少创建虚拟节点产生的性能开销。
减少内存占用。
缓存内联事件处理函数
缓存内联事件处理函数可以避免不必要的更新。
对于这样的模板,编译器会为其创建一个内联事件处理函数:
1 2 3 4 5 6 function render (ctx ) { return h (Comp , { onChange : () => (ctx.a + ctx.b ) }) }
每次重新渲染时(即 render 函数重新执行时),都会为 Comp
组件创建一个全新的props
对象。同时,props
对象中onChange
属性的值也会是全新的函数。这会导致渲染器对Comp
组件进行更新,造成额外的性能开销。为了避免这类无用的更新,我们需要对内联事件处理函数进行缓存:
1 2 3 4 5 6 7 function render (ctx ) { return h (Comp , { onChange : () => cache[0 ] || (cache[0 ] = ($event ) => (ctx.a + ctx.b )) }) }
这样,当渲染函数重新执行并创建新的虚拟 DOM
树时,会优先读取缓存中的事件处理函数。无论执行多少次渲染函数,props
对象中onChange
属性的值始终不变,于是就不会触发
Comp 组件更新了。
v-once
配合v-once
还可实现对虚拟 DOM
的缓存。当编译器遇到v-once
指令时,会利用我们上一节介绍的cache
数组来缓存渲染函数的全部或者部分执行结果。
1 2 3 4 5 <section> <div v-once> {{ foo }} </div> </section>
函数重新执行时,会优先读取缓存的内容,而不会重新创建虚拟节点。同时,由于虚拟节点被缓存,意味着更新前后的虚拟节点不会发生变化,因此也就不需要这些被缓存的虚拟节点参与
Diff 操作了。
1 2 3 4 5 6 7 8 9 render (ctx, cache ) { return (openBlock (), createBlock ('div' , null , [ cache[1 ] || ( setBlockTracking (-1 ), cache[1 ] = h ('div' , null , ctx.foo , 1 ), setBlockTracking (1 ), cache[1 ] ])) }
上面这段代码中的setBlockTracking(-1)
函数调用,它用来暂停动态节点的收集。换句话说,使用v-once
包裹的动态节点不会被父级Block
收集。因此,被v-once
包裹的动态节点在组件更新时,自然不会参与Diff
操作。
v-once
指令通常用于不会发生改变的动态绑定中,为了提升性能,我们可以使用v-once
来标记,这样,在组件更新时就会跳过这段内容的更新,从而提升更新性能:
避免组件更新时重新创建虚拟 DOM 带来的性能开销。因为虚拟 DOM
被缓存了,所以更新时无须重新创建。
避免无用的 Diff 开销。这是因为被 v-once 标记的虚拟 DOM 树不会被父级
Block 节点收集。
总结
Vue.js
的模板编译器用于把模板编译为渲染函数。它的工作流程大致分为三个步骤:
分析模板,将其解析为模板 AST。
将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
根据 JavaScript AST 生成渲染函数代码。
我们使用parse
来用有限状态自动机构造一个词法分析器,用transform
进行转换,最后调用generate
进行代码生成。
对于解析器,我们需要了解文本模式,指的是解析器在工作时所进入的一些特殊状态。在解析模板构建
AST
的过程中,parseChildren
函数是核心。每次调用parseChildren
函数,就意味着新状态机的开启。对于命名字符引用,命名字符引用的解码方案可以总结为两种:当存在分号时执行完整匹配;当省略分号时执行最短匹配。对于数字字符引用,则需要按照
WHATWG 规范中定义的规则逐步实现。
编译优化指的是通过编译的手段提取关键信息,并以此指导生成最优代码的过程。编译优化的核心在于,区分动态节点与静态节点。一个
Block
本质上也是一个虚拟节点,但与普通虚拟节点相比,会多出一个dynamicChildren
数组。该数组用来收集所有动态子代节点,这利用了createVNode
函数和createBlock
函数的层层嵌套调用的特点,即以“由内向外”的方式执行。再配合一个用来临时存储动态节点的节点栈,即可完成动态子代节点的收集。
v-if
、v-for
等结构化指令会影响 DOM
层级结构,使之不稳定。这会间接导致基于Block
树的比对算法失效。而解决方式很简单,只需要让带有v-if
、v-for
等指令的节点也作为
Block 角色即可。
静态提升:能够减少更新时创建虚拟 DOM
带来的性能开销和内存占用。
预字符串化:在静态提升的基础上,对静态节点进行字符串化。这样做能够减少创建虚拟节点产生的性能开销以及内存占用。
缓存内联事件处理函数:避免造成不必要的组件更新。
v-once
指令:缓存全部或部分虚拟节点,能够避免组件更新时重新创建虚拟
DOM 带来的性能开销,也可以避免无用的 Diff 操作。