玩命加载中 . . .

Vue源码阅读之虚拟VNode节点


写在前面

由于虚拟VNode节点部分某些关键地方涉及到了JavaScript正则表达式,在此再度简单记录正则表达式的JavaScript版本。

JavaScript的正则表达式

元字符

与Python中的元字符类似,JavaScript也有元字符,它们是正则表达式中最基础的部分,理解、掌握了它们,就相当于掌握了正则表达式的一半。

重要匹配方法

exec()

在设置了 global 或 sticky 标志位的情况下(如 /foo/g or /foo/y),JavaScript RegExp 对象是有状态的。他们会将上次成功匹配后的位置记录在 lastIndex 属性中。使用此特性,exec() 可用来对单个字符串中的多次匹配结果进行逐条的遍历(包括捕获到的匹配)。

正则表达式的lastIndex属性:记录了匹配成功的第一个元素下标。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="../vue/vue.js"></script>
</head>

<body>
    <div id="main">
        <p>
            {{ title }}
        </p>
        <span>{{ author }}</span>
    </div>

</body>
<script>
    let vm = new Vue({
        el: '#main',
        data() {
            return {
                title: '三国演义',
                author: '罗贯中'
            }
        },
    })
    const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
    let text = `<div id="main"><p>{{ title }}</p><span>{{ author }}</span></div>`
    while (match = defaultTagRE.exec(text)) {
        console.log(defaultTagRE.lastIndex);
        console.log(match); // 匹配结果,是一个特殊的数组
        console.log(match[0]);
    }
</script>

</html>
  • 使用while循环语句以及exec(),可以匹配出全部满足条件的字符串;
  • defaultTagRE是正则表达式,它被设置为全局,具有状态,可以利用它的lastIndex属性,获取上一次匹配成功时的起始字符下标,比如这里第一个匹配成功的是“Vue源码阅读之虚拟VNode节点”,从原字符串text的第19个字符开始,故第一轮循环的打印结果就是19。

上述代码的控制台输出结果:

29
(2) ['{{ title }}', ' title ', index: 18, input: '<div id="main"><p>{{ title }}</p><span>{{ author }}</span></div>', groups: undefined]
{{ title }}
51
(2) ['{{ author }}', ' author ', index: 39, input: '<div id="main"><p>{{ title }}</p><span>{{ author }}</span></div>', groups: undefined]
{{ author }}

从HTML到真实DOM的大体流程

AST语法树

AST树,即abstract syntax tree树,意为抽象语法树。经过generage过程,得到render函数,将返回Vue的虚拟DOM节点(包含标签名、子节点、文本信息等)。
比如下面这一句html语言,定义了一个input标签,是一个html表单元素:
<input class="warn" value="default text" :style="innerStyle">
解析后的AST节点:

inputAstElm = {
  type: 1,
  tag: 'input',
  attrs: [
    { name: "class", value: "\"warn\"" },
    { name: "style", value: "innerStyle" }
  ],
  props: [ { name: "value", value: "\"default text\"" } ],
}

从手写函数开始建立节点

我们希望通过调用这样的函数就可以创建一棵虚拟节点树(VNode)。

var ul =
    c('ul', [
      c('li', [ t("Item 1") ]),
      c('li', [ t("Item 2") ]),
      c('li', [ t("Item 3") ])
    ])

这其中需要用到JavaScript的正则表达式提取相关内容。细节参见源码注释。

在建立虚拟节点VNode之后,我们就可以通过VNode来间接管理DOM了——要做到这一点,我们还需要在两者之间建立关联:

  • 将当前建立的VNode渲染到DOM树上;

  • 当VNode有更新时,DOM树也要相应地作出变化。

    这其中涉及到了Vue中的diff算法。源码中由patch()函数总体控制对新旧VNode的更改:patch(oldVnode, newVnode)

    而要实现新旧VNode节点的更新,首先要做的是判断两者标签是否一致:如果两者连标签都不一样了,那么就需要相对较为复杂的操作——

    • 找到oldVnode对应的DOM节点以及父节点;
    • 将当前newVnode渲染到原来的父节点下;
    • 将旧的DOM节点移除。

    如果两者标签一致,那么就需要比较它们的子节点(children)了。为此,我们设置四个指针oldStartVnode、oldEndVnode、newStartVnode、oldEndVnode,用于指向oldVnode和newVnode的子节点开始、结束的位置。

    然后就是四种情况了。

Compile编译字符串

从字符串到HTMLElement Token流

但是相对于上面的手动调用函数,我们更希望一个函数能够解析html字符串,就比如这里的compile:

var html =
    `<ul>
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </ul>`

var ul = compile(html)

我们以Token流的形式记录每一个html标签转换后的内容,它是一个列表:

interface Attr {
	name: string,
	value: any,
}
interface StartToken {
	tagName: string,
	attrs: Attr[],
}
// Token流形状
interface Tokens {
	startToken: StartToken,
	charsToken: string,
	endToken: any,
}
// 也即:
tokens: = [
	StartToken: {
		tagName: '',
		attrs: [
			{
				name: '',
				value: '',
			},
			...
		]
	},
	StartToken: {},
	...,
	CharsToken: '',
	...,
	endToken,
	...
]

StartToken表示开始标签
EndToken表示结束标签
CharsToken表示文本节点

其中的解析(parse)过程就是一个类似括号匹配问题的栈的实际应用:

设置一个栈,用于管理字符串,当标签匹配的时候就把栈顶元素弹出,否则入栈;同时将解析出来的标签转化为Token计入Tokens流。

https://github.com/raphealguo/how-to-learn-vue2-blob/raw/master/figure/1.2/str2tokens.png

从HTMLElement Token流到AST树

拿到tokens序列后,我们可以构建AST树节点。AST树节点形状大致如下:

tagAstElement = {
	type: number,
	tag: string,
	attrsList,
	attrsMap,
	children,
	parent,
}

我们构建一棵AST树,也需要一个栈stack,记录所有的父节点,同时定义一个指向栈顶的指针currentParent:

let stack = []
let currentParent

在源码中,处理Tokens流,得到AST树的函数parse()中,在其中的parseHTML()参数中定义了三个函数:

  • start,处理开始标签流
  • end,处理结束标签流
  • chars,处理文本标签流

在处理开始标签流时,需要将上面的Tokens中的数据项往AST树的数据项转化。另外,由于AST树包含了节点之间的父子关系,因此在处理开始标签流时,需要判断它是否是单标签,如果不是单标签,就表示该标签之间还可以嵌套其他的标签,该标签也可以作为父节点;因此,该标签需要入栈stack,并且currentParent指针指向栈顶元素——该标签:

if (!unary) { 
    // 如果不是单标签,则当前节点记为父节点,并压入堆栈
    currentParent = element
    stack.push(element)
}

在处理结束标签流时,同样的道理,需要弹栈:

// 标签闭合,栈顶元素弹出,长度减一,当前父节点指针currentParent指向最新的栈顶元素
stack.length -= 1
currentParent = stack[stack.length - 1]

从AST树到VNode树

将已经生成的AST节点转化为VNode的方法如下:

_c (tag, data, children) 创建一个非文本 VNode 节点
_v (text) 创建一个文本VNode节点
_s (exp) 把 exp 输出成字符串
_e() 创建一个空的 VNode
_l (list, render) 渲染一个 VNode 列表
_k(eventKeyCode, key, builtInAlias) 判定当前事件的按键是否为预期按键值,builtInAlias == eventKeyCode

VNode节点形状如下:

VNode = {
	tag: 'xxx',	// 标签名
	data: {
		attrs: {...},	// 标签的属性
        domProps: { "value": "default text" }
	}
}

在实际的源码中,实现了逐步封装(见/compiler/codegen/index.js文件)

  • generate: (ast: any) => obj,这个函数是最外层的函数,接收传入的ast树,并将其挂载到当前的vm实例对象,用于生成一棵VNode树。

    export function generate(ast) {
      const code = ast ? genElement(ast) : '_c("div")'
    
      return {
        render: ("with(this){return " + code + "}")
      }
    }
  • genElement: (el: any) => string,接下来把目光转移到第二层函数,它用于生成具体执行的代码语句(字符串格式)。

    function genElement(el) {
      if (el.for && !el.forProcessed) { // 为了v-for和v-if的优先级: <ul v-for="(item, index) in list" v-if="index==0">,需要先处理for语句
        return genFor(el)
      }
      if (el.if && !el.ifProcessed) {
        return genIf(el)
      } else {
        let code
        const children = genChildren(el) || '[]'
        const data = genData(el)
    
        code = `_c('${el.tag}'${
          children ? `,${children}` : '' // children
        })`
    
        return code
      }
    }

    不难发现,这一层函数其实也没做多少事,关键还得看genFor、genIf、genChildren、genData这几个打工人(它们生成了模板字符串中的模板变量,也就是el上缺省的属性值el.tag, el.children, el.data)。

  • genData: (el: any) => string, 我们先来看看分量最重的data子选项。genData用于得到data,而data包含了el的很多属性,如el.key, el.attrs, el.props, el.event等,其中,el.attrs、el.props、el.events相对较复杂,因此又套了一层函数。

    function genData(el) {
      let data = '{'
    
      // key
      if (el.key) {
        data += `key:${el.key},`
      }
      if (el.attrs) {
        data += `attrs:{${genProps(el.attrs)}},`
      }
      // DOM props
      if (el.props) {
        data += `domProps:{${genProps(el.props)}},`
      }
      // event handlers
      if (el.events) {
        data += `${genHandlers(el.events)},`
      }
    
      // class
      if (el.staticClass) {
        data += `staticClass:${el.staticClass},`
      }
      if (el.classBinding) {
        data += `class:${el.classBinding},`
      }
    
      data = data.replace(/,$/, '') + '}'
    
      return data
    }
  • genProps: (props: []) => string, 这个函数用于将props配置项数组的每一项转为字符串键值对,然后全部拼接起来,并返回。

    function genProps(props) {
      let res = ''
      for (let i = 0; i < props.length; i++) {
        const prop = props[i]
        res += `"${prop.name}":${prop.value},`
      }
      return res.slice(0, -1) // 去掉尾巴的逗号
    }

_c()方法可以生成一个VNode,从而实现AST到VNode的过程。

从VNode树到DOM树

经历了patch(vnode)函数,此时在真实的DOM上处理UI渲染、事件监听等。

用一个栗子做个总结

  1. 原始字符串如下:

    <input class="warn" value="default text" :style="innerStyle">
  2. 对原始字符串进行解析,得到Token流

  3. 将Tokens流转为AST:

    inputAstElm = {
        type: 1,	// 表示类型
        tag: 'input',	// 表示html元素标签
        attrs: [	// 表示属性
            {name: 'class', value: "'warn'"},
            {name: 'style', value: "innerStyle"}
        ],
        props: [{ name: "value", value: "'default text'" }],	// 表示属性
    }
  4. 经过上面提过的render函数得到VNode:

    _c("input", {
        attrs: { "class": "warn", "style": innerStyle },
        domProps: { "value": "default text" }
    }, [])
    VNode = {
        tag: 'input',
        data: {
            attrs: { "class": "warn", "style": "vm.innerStyle运行后的值" },
            domProps: { "value": "default text" },
        }
    }
  5. 最后一步,将虚拟VNode渲染到真实DOM

    inputDom.setAttribute('class', 'warn')
    inputDom.setAttribute('style', 'vm.innerStyle运行后的值')
    

文章作者: 鹿卿
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 鹿卿 !
评论
  目录