vue 2.x内部运行机制系列文章-template模板编译原理

1,641 阅读10分钟

template模板编译主要分为三个部分,分别是parse、optimize、genarate,最终生成render function.这部分在vue.js中不属于核心,这部分内容在精力有限的情况下仅作了解即可。

先来简要介绍下它的流程

  • 首先通过parse生成抽象生成树
  • 其次通过optimize来标记静态节点,作用是在之后dom diff时提高性能。
  • 最后通过generate形成render函数,执行render函数可以形成虚拟DOM

这边我们通过一个实例,来分析它的具体过程

<div>
    <p>我是{{name}}</p>成都市
</div>

ok,我们具体来介绍每一部分

parse

parse函数的作用就是把字符串型的template转化为AST结构。所谓AST,就是指抽象生成树,在vue中我把它理解为可嵌套的、带标签、属性以及父子关系的JS对象,以这个树形的JS对象来表示这个DOM结构。

vue中的ast类型有以下3种

ASTElement = {  // AST标签元素
  type: 1;
  tag: string;
  attrsList: Array<{ name: string; value: any }>;
  attrsMap: { [key: string]: any };
  parent: ASTElement | void;
  children: Array<ASTNode>
  
  ...
}

ASTExpression = { // AST表达式 {{ }}
  type: 2;
  expression: string;
  text: string;
  tokens: Array<string | Object>;
  static?: boolean;
};

ASTText = {  // AST文本
  type: 3;
  text: string;
  static?: boolean;
  isComment?: boolean;
};

整颗AST结构由以上三种类型组成,内部通过childern字段来表示其内部的父子关系,进而形成一颗完整的树形结构。

ok,介绍完基本概念和基本构成,我们进入正题。

pasre方法内部定义了很多正则,目的是为了匹配并截取template字符串,进而将其转换为AST结构。它的截取过程可以用下图来表示。

以下是它的一些截取的正则

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
const comment = /^<!\--/
const conditionalComment = /^<!\[/

为了方便,我们以以下html片段为例,来介绍它的截取过程。

<div :class="name">
    <p>我是{{name}}</p>
</div>

在我们正式开始截取前,我们需要明白一个问题,那就是我们是以什么规则来截取的?

vue中的截取规则主要是通过判断模板中html.indexof('<')的值,来确定我们是要截取标签还是文本,进而执行不同的操作。

  • 等于 0:这就代表要截取的是注释、条件注释、doctype、开始标签、结束标签中的某一种

  • 大于等于 0:这就说明要截取的是文本、表达式

  • 小于 0:表示 html 标签解析完了,可能会剩下一些文本、表达式

parseHTML

因为我们匹配方式是采用循环匹配的方式进行的,所以我们需要定义两个方法,一个是循环整个html的方法parseHtml,一个是去掉已经匹配过的部分的方法advance。

// 源码位置:vue\src\compiler\parser\html-parser.js
// 省略部分源码,本文不大篇幅分析源码,仅以尽可能简单的方式介绍流程,使我们理解原理。
// advance函数用来去掉已匹配过的部分
function advance (n) {
    index += n
    html = html.substring(n)
}

// parseHTML用来循环匹配整个html,内部调用advance
function parseHTML () {
    while(html) {
        if (textEnd === 0) {
        // Comment:
        if (comment.test(html)) {
          // ...匹配注释
          continue

          
        }

        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
         // ...匹配Doctype
          continue
        }

        // End tag:
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          // ...匹配结束标签
          continue
        }

        // Start tag:
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          // ...匹配开始标签
          continue
        }
      }

      let text, rest, next
      if (textEnd >= 0) {
       // 匹配文本
        rest = html.slice(textEnd)
        while (
          !endTag.test(rest) &&
          !startTagOpen.test(rest) &&
          !comment.test(rest) &&
          !conditionalComment.test(rest)
        ) {
          // < in plain text, be forgiving and treat it as text
          next = rest.indexOf('<', 1)
          if (next < 0) break
          textEnd += next
          rest = html.slice(textEnd)
        }
        text = html.substring(0, textEnd)
      }
    }
}



parseStartTag()

若要截取的是开始标签,则开始匹配开始标签,这时候需要执行parseStartTag方法。

function parseStartTag () {
    const start = html.match(startTagOpen)
    if (start) {
      const match = {
        tagName: start[1],  
        attrs: [],
        start: index
      }
      advance(start[0].length)
      // 以下匹配开始标签上的attrs
      let end, attr
      while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
        attr.start = index
        advance(attr[0].length)
        attr.end = index
        match.attrs.push(attr)
      }
      if (end) {
        match.unarySlash = end[1]
        advance(end[0].length)
        match.end = index
        return match
      }
    }
  }

看我们原先的例子,我们要截取的开始标签如下<div :class="name">

首先用 startTagOpen 正则得到标签的头部,可以得到 tagName(标签名称),同时我们需要一个数组 attrs 用来存放标签内的属性。

function parseStartTag () {
    const start = html.match(startTagOpen)
    if (start) {
      const match = {
        tagName: start[1],  
        attrs: [],
        start: index
      }
      advance(start[0].length)
      
      // ...

}

匹配完开始标签,再通过while循环和正则匹配来把所有属性一一添加到attrs数组里,一直循环到startTagClose为止。

function parseStartTag () {
    const start = html.match(startTagOpen)
    if (start) {
      // ...
      // 以下匹配开始标签上的attrs
      let end, attr
      while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
        attr.start = index
        advance(attr[0].length)
        attr.end = index
        match.attrs.push(attr)
      }
      if (end) {
        match.unarySlash = end[1]
        advance(end[0].length)
        match.end = index
        return match
      }
    }
  }

stack

同时,我们需要维护一个 stack 栈来标记DOM的深度,这个栈用来保存已经解析好的标签头,这样我们可以根据在解析尾部标签的时候得到所属的层级关系以及父标签。同时我们定义一个 currentParent 变量用来存放当前标签的父标签节点(parentNode)的引用。

stack里的最后一项,永远是当前正在解析的元素的parentNode

通过stack解析器会把当前解析的元素和stack里的最后一个元素建立父子关系。即把当前节点push到stack的最后一个节点的children里,同时将它自身的parent设为stack的最后一个节点。

当解析到结束标签,则需要通过这个结束标签的tagName从后到前匹配stack中每一项的tagName,将匹配到的那一项之后的所有项全部删除,表示这一段已经解析完成。

ok,我们接下来看如何解析结束标签。

parseEndTag

如是结束标签,则用 parseEndTag 来解析尾标签,它会从 stack 栈中取出最近的跟自己标签名一致的那个元素,将 currentParent 指向那个元素,并将该元素之前的元素都从 stack 中出栈。

有人会问,为什么取出最近的跟自己标签名一致的那个元素,而不是最上面的元素?这是因为存在类似于<input />这样的自闭合标签。

function parseEndTag (tagName) {
    let pos;
    for (pos = stack.length - 1; pos >= 0; pos--) {
        if (stack[pos].lowerCasedTag === tagName.toLowerCase()) {
            break;
        }
    }

    if (pos >= 0) {
        stack.length = pos;
        currentParent = stack[pos]; 
    }   
}

parseText

最后是解析文本,这个比较简单,只需要将文本取出,然后有两种情况:

  • 一种是普通的文本,直接构建一个节点 push 进当前 currentParent 的 children 中即可。
  • 还有一种情况是文本是如“{{item}}”这样的 Vue.js 的表达式,这时候我们需要用 parseText 来将表达式转化成代码。
text = html.substring(0, textEnd)
advance(textEnd)
let expression;
if (expression = parseText(text)) {
    currentParent.children.push({
        type: 2,
        text,
        expression
    });
} else {
    currentParent.children.push({
        type: 3,
        text,
    });
}
continue;

如果存在{{item}}表达式,则需要通过parseText函数转换一下

function parseText (text) {
    if (!defaultTagRE.test(text)) return;

    const tokens = [];
    let lastIndex = defaultTagRE.lastIndex = 0
    let match, index
    while ((match = defaultTagRE.exec(text))) {
        index = match.index
        
        if (index > lastIndex) {
            tokens.push(JSON.stringify(text.slice(lastIndex, index)))
        }
        
        const exp = match[1].trim()
        tokens.push(`_s(${exp})`)
        lastIndex = index + match[0].length
    }

    if (lastIndex < text.length) {
        tokens.push(JSON.stringify(text.slice(lastIndex)))
    }
    return tokens.join('+');
}

我们使用一个 tokens 数组来存放解析结果,通过 defaultTagRE 来循环匹配该文本,如果是普通文本直接 push 到 tokens 数组中去,如果是表达式({{item}}),则转化成“_s(${exp})”的形式。

举个例子,如果我们有这样一个文本。

<div>hello,{{name}}.</div>

之后会得到一个tokens

tokens = ['hello,', _s(name), '.'];

最后通过 join 返回表达式。

'hello' + _s(name) + '.';

optimize

optimize 主要作用就跟它的名字一样,用作「优化」。

这个涉及到后面要讲 patch 的过程,因为 patch 的过程实际上是将 VNode 节点进行一层一层的比对,然后将「差异」更新到视图上。那么一些静态节点是不会根据数据变化而产生变化的,这些节点我们没有比对的需求,是不是可以跳过这些静态节点的比对,从而节省一些性能呢?

那么我们就需要为静态的节点做上一些「标记」,在 patch 的时候我们就可以直接跳过这些被标记的节点的比对,从而达到「优化」的目的。

经过 optimize 这层的处理,每个节点会加上 static 属性,用来标记是否是静态的。

isStatic

首先实现一个 isStatic 函数,传入一个 node 判断该 node 是否是静态节点。判断的标准是当 type2(表达式节点)则是非静态节点,当 type3(文本节点)的时候则是静态节点,当然,如果存在 if 或者 for这样的条件的时候(表达式节点),也是非静态节点。

function isStatic (node) {
    if (node.type === 2) {
        return false
    }
    if (node.type === 3) {
        return true
    }
    return (!node.if && !node.for);
}

markStatic

markStatic 为所有的节点标记上 static,遍历所有节点通过 isStatic 来判断当前节点是否是静态节点,此外,会遍历当前节点的所有子节点,如果子节点是非静态节点,那么当前节点也是非静态节点。

function markStatic (node) {
    node.static = isStatic(node);
    if (node.type === 1) {
        for (let i = 0, l = node.children.length; i < l; i++) {
            const child = node.children[i];
            markStatic(child);
            if (!child.static) {
                node.static = false;
            }
        }
    }
}

markStaticRoots

接下来是 markStaticRoots 函数,用来标记 staticRoot(静态根)。这个函数实现比较简单,简单来将就是如果当前节点是静态节点,同时满足该节点并不是只有一个文本节点左右子节点时,标记 staticRoottrue,否则为 false

function markStaticRoots (node) {
    if (node.type === 1) {
        if (node.static && node.children.length && !(
        node.children.length === 1 &&
        node.children[0].type === 3
        )) {
            node.staticRoot = true;
            return;
        } else {
            node.staticRoot = false;
        }
    }
}

有了以上的函数,就可以实现 optimize 方法了。

function optimize (rootAst) {
    markStatic(rootAst);
    markStaticRoots(rootAst);
}

总之,optimize阶段用来标记静态节点,在之后dom diff时,直接跳过这些节点,有利于提高性能

generate

generate 会将 AST 转化成 render funtion 字符串,最终得到 render 的字符串以及 staticRenderFns 字符串。

render函数的就是返回一个_c('tagName',data,children)的方法

  1. 第一个参数是标签名
  2. 第二个参数是他的一些数据,包括属性/指令/方法/表达式等等。
  3. 第三个参数是当前标签的子标签,同样的,每一个子标签的格式也是_c('tagName',data,children)。

generate就是通过不断递归形成了这么一种树形结构。

image

genElement:用来生成基本的render结构或者叫createElement结构
genData: 处理ast结构上的一些属性,用来生成data
genChildren:处理ast的children,并在内部调用genElement,形成子元素的_c()方法

render字符串内部有几种方法

几种内部方法
_c:对应的是 createElement 方法,顾名思义,它的含义是创建一个元素(Vnode)
_v:创建一个文本结点。
_s:把一个值转换为字符串。(eg: {{data}})
_m:渲染静态内容
<template>
  <div id="app">
    {{val}}
    <img src="http://xx.jpg">
  </div>
</template>

{
  render: with(this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_v("\n" + _s(val) + "\n"),
        _c('img', {
              attrs: {
                "src": ""
              }
            })
        ]
    )
  }
}

那么问题来了,_c('tagName',data,children)如何拼接的,data是如何拼接的,children又是如何拼接的?

// genElement方法用来拼接每一项_c('tagName',data,children)
function genElement (el: ASTElement, state: CodegenState) {
  const data = el.plain ? undefined : genData(el, state)
  const children = el.inlineTemplate ? null : genChildren(el, state, true)
    
  let code = `_c('${el.tag}'${
    data ? `,${data}` : '' // data
  }${
    children ? `,${children}` : '' // children
  })`
  
  return code
}

先来看data的拼接逻辑

function genData (el: ASTElement, state: CodegenState): string {
  let data = '{'
  // key
  if (el.key) {
    data += `key:${el.key},`
  }
  // ref
  if (el.ref) {
    data += `ref:${el.ref},`
  }
  if (el.refInFor) {
    data += `refInFor:true,`
  }
  // ... 类似的还有很多种情况
  data = data.replace(/,$/, '') + '}'
  return data
}

从上面可以看出来,data的拼接过程就是不断的判读ast上一些属性是否存在,然后拼在data上,最后把这个data返回。

那么children怎么拼出来呢?

function genChildren (
  el: ASTElement,
  state: CodegenState
): string | void {
  const children = el.children
  if (children.length) {
    return `[${children.map(c => genNode(c, state)).join(',')}]`
  }
}

function genNode (node: ASTNode, state: CodegenState): string {
  if (node.type === 1) {
    return genElement(node, state)
  } if (node.type === 3 && node.isComment) {
    return genComment(node)
  } else {
    return genText(node)
  }
}


最后执行render函数就会形成虚拟DOM.

总结

vue template模板编译的过程经过parse()生成ast(抽象语法树),再经过optimize对静态节点优化,最后通过generate()生成render字符串

之后调用new Watcher()函数,用来监听数据的变化,render 函数就是数据监听的回调所调用的,其结果便是重新生成 vnode。

当这个 render 函数字符串在第一次 mount、或者绑定的数据更新的时候,都会被调用,生成 Vnode

如果是数据的更新,那么 Vnode 会与数据改变之前的 Vnode 做 diff,对内容做改动之后,就会更新到我们真正的 DOM