今天看啥  ›  专栏  ›  白马笑西风

Vue2.0源码阅读笔记(五):模板编译

白马笑西风  · 掘金  ·  · 2019-07-31 07:29
阅读 283

Vue2.0源码阅读笔记(五):模板编译

  在使用Vue进行实际开发的过程中,大多数时候使用模板来创建HTML,模板功能强大且简洁直观,最终模板会编译成渲染函数,本文主要介绍模板编译的具体过程。

一、编译入口

  Vue从能否处理 template 选项的角度分为两个版本:运行时+编译器只包含运行时运行时+编译器版本也被称为完整版只包含运行时完整版体积小30%左右,使用只包含运行时版本需要借助 vue-loadervueify 等工具编译模板。
  本文从 web 平台的编译入口开始探究 Vue 完整版的模板编译过程。在 src/platforms/web/entry-runtime-with-compiler.js 文件下的 $mount 方法中通过 compileToFunctions 方法将模板编译成渲染函数。编译方法的生成过程如下如所示:

编译入口
  首先,向 createCompilerCreator() 函数传入 baseCompile() 函数,返回值为 createCompiler() 函数。
  基础编译函数 baseCompile 代码如下所示:

function baseCompile (template, options){
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}
复制代码

  这几行代码是Vue模板编译的核心,由以上代码可以看出,编译的第一步是将模板通过 parse 函数解析成 AST(抽象语法树),第二步优化AST,第三步根据优化后的抽象语法树生成包含渲染函数字符串的对象。
  其次,向createCompiler() 函数传入基本配置对象 baseOptions,返回包含函数属性 compilecompileToFunctions 的对象。
  compile 函数接收两个参数:模板字符串以及编译选项。另外还通过闭包引用了前面传入的基础编译函数 baseCompile 与基本编译配置对象 baseOptions。该函数的功能主要有三点:

1、合并基础配置选项与传入的编译选项,生成 finalOptions。
2、收集编译过程中的错误。
3、调用基础编译函数 baseCompile。

  compileToFunctions 函数是将 compile 函数作为参数传入 createCompileToFunctionFn() 函数生成的返回值。createCompileToFunctionFn 函数定义一个缓存变量 cache,然后返回函数 compileToFunctions。模板字符串的编译比较费时,使用缓存变量 cache 是为了防止重复编译,从而提升性能。
  compileToFunctions 函数接受三个参数:模板字符串、编译选项、Vue实例。该函数的主要作用有以下五点:

1、缓存编译结果,防止重复编译。
2、检测内容安全策略,保证 new Function() 能够使用。
3、调用 compile 函数将模板字符串转成渲染函数字符串
4、调用 createFunction 函数将渲染函数字符串转成真正的渲染函数
5、打印编译错误。

  最后,将要编译的模板字符串、编译选项与 Vue 的实例对象传入 compileToFunctions 函数,返回包含 renderstaticRenderFns 属性的对象。
  render 为最终生成的渲染函数字符串,staticRenderFns 为存储静态根节点渲染函数字符串。这些函数字符串会通过 new Function() 来生成最终的渲染函数。
  Vue利用函数柯里化的技巧生成编译模板的方法,在初读代码的时候让人感觉十分繁琐,实际却是设计的十分巧妙。
  这样设计的原因是 Vue 能够在不同平台运行,比如在服务器端做SSR,也可以在weex下使用。不同平台都会有编译过程,所依赖的基本编译选项 baseOptions 会有所不同。Vue 将基础的编译过程抽离出来,并且可以在多处添加编译器选项,然后将添加的编译器选项和基本编译选项合并起来,最终灵活实现在不同平台下的编译。

二、生成AST

  关于 AST 的概念参照如下维基百科的描述:

  在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。
  在源代码的翻译和编译过程中,语法分析器创建出分析树,然后从分析树生成AST。一旦AST被创建出来,在后续的处理过程中,比如语义分析阶段,会添加一些信息。

  Vue 编译过程的核心的第一步是调用 parse 方法将模板字符串解析为 AST 。

const ast = parse(template.trim(), options)
复制代码

  生成AST的过程分为两步:词法分析句法分析parse 函数中实现的功能主要是句法分析词法分析功能由 parse 内部调用的 parseHTML 函数来完成。我们首先分析模板字符串做词法分析的过程。

1、词法分析函数 parseHTML

  parseHTML 函数的省略具体细节的代码如下所示:

export function parseHTML (html, options) {
  const stack = []
  let last, lastTag
  /*省略。。。*/
  while (html) {
    last = html
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')

      if (textEnd === 0) {/*省略具体实现*/}

      let text, rest, next

      if (textEnd >= 0) {/*省略具体实现*/}
      if (textEnd < 0) { text = html }

      if (text) { advance(text.length) }

      if (options.chars && text) {
        options.chars(text, index - text.length, index)
      }
    } else {
      /*省略具体实现*/
    }

    if (html === last) {/*省略具体实现*/}
  }

  parseEndTag()

  function advance (n) {
    index += n
    html = html.substring(n)
  }

  function parseStartTag () {/*省略具体实现*/}

  function handleStartTag (match) {/*省略具体实现*/}

  function parseEndTag (tagName, start, end) {/*省略具体实现*/}
}
复制代码

(一)、整体流程分析

  parseHTML 函数的具体功能如下图所示:

parser函数
  parseHTML 逐个字符解析模板字符串。在 while 循环中,每次解析完一段字符串后都调用 advance 函数删除已解析的字符串。
  在了解具体流程之前,先要弄明白一个问题:如何判断一个非一元标签是否缺少结束标签呢?即如何检测出像以下例子中发生错误的情况:

<div><span></div>
复制代码

  parseHTML 函数利用栈的数据结构来实现的:解析到开始标签时,将开始标签推入到数组 stack 中,变量 lastTag 始终指向栈顶元素。当解析到结束标签时,会与栈顶的开始元素相匹配,如果是一对非一元标签,则将栈顶开始标签推出栈,同时继续向前解析。如果匹配失败或者解析完毕后栈中仍有开始标签,则表示非一元标签未闭合。
  如上例所示,先将 <div> 推入数组 stack 中,继续解析后将 <span> 也推入栈中,此时栈顶标签为 <span>,解析到结束标签 </div> 时会与栈顶标签对比,<span></div> 不是一对非一元便签,则说明模板字符串缺少 <span> 的结束标签。
  parseHTML 函数首先判断将要解析的字符串是不是在纯文本标签里的内容,纯文本标签是指 <script><style><textarea> ,如果为纯文本标签的内容,则抽取纯文本标签里的内容,直接使用传入的 chars() 进行处理。
  如果不是在纯文本标签里的内容,则根据字符 '<' 的位置来判断要解析的字符串开头是标签还是文本。如果是文本,则使用传入的 chars() 进行处理。
  如果是标签,则有五种可能性:

1、若是注释标签 <!---->,则使用传入的 comment() 方法处理注释内容。
2、若是条件注释标签<!--[]>,则不做任何处理,直接跳过。
3、若是文档类型声明<!DOCTYPE>,则不做任何处理,直接跳过。
4、若是结束标签,则调用 parseEndTag() 函数处理。
5、若是开始标签,则调用 parseStartTag()handleStartTag() 函数进行处理。

  总之,parseHTML 函数解析到文本调用 chars() 方法处理,解析到注释标签调用 comment() 方法处理,解析到条件注释标签文档类型声明跳过不做处理, chars()comment() 作为传入的方法将会在讲解 parse() 方法时加以讲解。
  对开始标签与结束标签的处理相对麻烦一些,在调用传入的处理开始标签与结束标签的函数之前,parseHTML 函数会先对其做一些处理。

(二)、对开始标签的处理

  解析开始标签是会首先调用 parseStartTag() 函数,然后将函数返回值作为参数传入 handleStartTag() 函数进行处理。
  parseStartTag() 函数利用正则表达式来解析开始标签,各项解析结果作为 match 对象的属性。

match = {
  tagName: '', // 开始标签的标签名
  attrs: [], // 标签中各属性的信息数组
  start: startIndex, // 标签开始下标
  unarySlash: undefined || '/', // 判断标签是否为一元标签
  end: endIndex // 标签结束下标
}
复制代码

  handleStartTag() 函数接收 match 对象作为参数。主要有以下五个功能:

1、stack 栈顶标签为 <p>,且当前解析的开始标签为段落式内容模型时,调用 parseEndTag() 方法闭合 <p>。
2、当前解析标签可以省略结束标签,且与栈顶标签相同,则调用 parseEndTag() 方法关闭当前解析标签然后给出警告。
3、格式化 match.attrs 存储属性数组,格式化后 attrs 为对象数组,每个对象有两个属性:name(属性名)、value(解码后的属性值)。
4、将当前解析标签的信息推入到 stack 中,并将变量 lastTag 的值改成栈顶标签名称。
5、调用传入的 start 函数,参数为当前解析标签的信息。

(三)、对结束标签的处理

  解析结束标签是会调用 parseEndTag() 函数。该函数主要有以下四个功能:

1、检测是否缺少闭合标签。
2、处理 stack 栈中剩余的标签。
3、处理 </br></p> 标签。
4、调用传入的 end() 方法处理结束标签。

  在 handleStartTag() 函数中有讲到遇到 <p> 调用 parseEndTag() 函数的情况。以下是 <p> 标签MDN的介绍:

起始标签是必需的,结束标签在以下情形中可以省略。
<p>元素后紧跟<address>, <article>, <aside>, <blockquote>, 
<div>, <dl>, <fieldset>, <footer>, <form>, <h1>, <h2>, <h3>, 
<h4>, <h5>, <h6>, <header>, <hr>, <menu>, <nav>, <ol>, <pre>, 
<section>, <table>, <ul>或另一个<p>元素;
或者父元素中没有其他内容了,而且父元素不是<a>元素。
复制代码

  如果 <p> 后面跟以上元素,parseEndTag() 函数会模拟浏览器的行为,自动补全 <p> 标签。如下所示:

<p><h5></h5></p>
复制代码

  上述html代码会被解析成如下代码:

<p></p><h5></h5><p></p>
复制代码

  在 handleStartTag() 函数中讲到:当前解析标签可以省略结束标签,且与栈顶标签相同,则调用 parseEndTag() 方法。 parseEndTag() 会闭合第二个标签,并因第一个标签未闭合而发出警告。

<li>123<li>456
复制代码

  上述html代码会被解析成如下代码,并警告第一个标签未闭合。

<li>123<li></li>456
复制代码

  另外,仅仅写下闭合标签 </p></br> 时,浏览器会将 </p> 转化成 <p></p>,将 </br> 转化成 <br> 。Vue在转换模板字符串的时候与浏览器保持一致,在 handleStartTag() 函数中将这两个闭合标签进行转换处理。

2、句法分析函数 parse

  句法分析函数 parse 的代码在 /src/compiler/parser/index.js 中。省略具体内容的 parse 函数代码如下所示:

export function parse (template,options){
  const stack = []
  let root
  let currentParent
  /*省略。。。*/

  parseHTML(template, {
    // 省略一些参数
    start (tag, attrs, unary, start, end) {/*省略具体实现*/},
    end (tag, start, end) {/*省略具体实现*/},
    chars (text, start, end) {/*省略具体实现*/},
    comment (text, start, end) {/*省略具体实现*/}
  })
  return root
}
复制代码

(一)、句法分析函数整体分析

  变量 rootparseHTML 函数的返回值,即最终生成的AST。Vue将模板中节点分为四种:标签节点包含字面量表达式的文本节点普通文本节点注释节点,其中普通文本节点与注释节点都是纯文本节点,算作同一类型。
  AST中的节点描述对象有三种类型:标签节点描述对象、表达式文本节点描述对象、纯文本节点描述对象。不同类型节点描述对象的基本属性如下所示:

// 标签节点类型描述对象基本属性
element = {
  type: 1, // 标签节点类型标识
  tag: '', // 标签名称
  attrsList: [], // 对象数组,对象存储着标签属性的名和值
  attrsMap: {}, // 标签属性对象,以键值对的形式存储标签属性
  rawAttrsMap: {} // 将attrsList转化为对象,其属性为标签属性名
  parent: {}, // 父标签节点
  children: [], // 子节点数组
  start: Number, // 开始标签第一个字符在html字符串的位置
  end: Number // 结束标签最后一个字符在html字符串的位置
}

// 表达式文本节点描述对象基本属性
expression = {
  type: 2, // 表达式文本节点类型标识
  expression: '', // 表达式文本字符串,变量被 _s() 包裹
  tokens: [] // 存储文本的token,有文本和表达式两种类型
  text: '', // 文本字符串
  start: Number, // 表达式文本第一个字符在html字符串的位置
  end: Number // 表达式文本最后一个字符在html字符串的位置
}

// 纯文本节点描述对象基本属性
text = {
  type: 3, // 纯文本节点类型标识
  text: '', // 文本字符串
  start: Number, // 纯文本第一个字符在html字符串的位置
  end: Number // 纯文本最后一个字符在html字符串的位置
}
复制代码

  AST是树状结构的对象,通过标签节点描述对象的 parentchildren 来实现。parent 属性指向父节点元素描述对象,children 属性存储着该节点所有子节点的元素描述对象。根节点的 parent 属性值为 undefined
  变量 stackcurrentParent 配合使用来完成将子节点正确添加到父节点 children 属性中的任务。stack 是栈的数据结构,用来存储当前解析的节点的父节点以及祖先节点。currentParent 指向当前解析内容的父节点。
  在词法分析的过程中,解析节点时会调用对应的函数进行处理,下面分别加以介绍。

(二)、开始标签处理函数 start

  在 start() 函数中,首先会调用 createASTElement() 函数,将标签名、标签属性以及标签的父节点作为参数传入,生成一个标签节点类型描述对象。

let element = createASTElement(tag, attrs, currentParent)
复制代码

  此时标签节点对象如下所示:

element = {
  type: 1,
  tag,
  attrsList: attrs,
  attrsMap: makeAttrsMap(attrs),
  rawAttrsMap: {},
  parent,
  children: []
}
复制代码

  如果开始标签是 svg 或者 math,则额外添加 ns 属性,属性值与标签名相同。接着向 element 对象添加 startend 属性,使用 attrsList 属性格式化 rawAttrsMap 属性。
  然后调用 preTransforms 函数数组中的每一个函数来处理 element 对象,以及以 process 开头的一系列函数。在 parse 函数所在的文件中声明了很多 process* 函数,比如 processForprocessIfprocessOnce等。这些函数和 preTransforms 函数数组中的函数作用都是一样的,都是用来对当前元素描述对象做进一步处理。这是出于平台化的考虑,将这一系列的函数放在不同的文件夹里。process 系列函数是通用的,而 preTransforms 函数数组根据平台不同而不同。
  这些根据不同属性对 element 进行不同处理的过程相当繁杂,本文的主旨是讲述模板字符串到渲染函数的编译过程,这些具体的属性处理会在后续文章讲述相应指令时详细阐述。
  最后,判断开始标签是否为一元标签,如果是则调用 closeElement 方法进行处理,closeElement 方法的具体内容将在下一节介绍;如果不是则将 element 对象赋值给变量 currentParent,作为后续解析的父节点存在,并将 element 对象推入 stack 栈中。

(三)、结束标签处理函数 end

  结束标签处理函数 end 逻辑相对简单,代码如下所示:

end (tag, start, end) {
  const element = stack[stack.length - 1]
  // pop stack
  stack.length -= 1
  currentParent = stack[stack.length - 1]
  if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
    element.end = end
  }
  closeElement(element)
}
复制代码

  首先将栈顶节点取出赋值给 element 变量,然后删除 stack 中栈顶节点,并将 currentParent 变量指向栈顶节点。这样做因为当前节点作为父节点的情况已经处理完毕,要将作用域还给上层节点。
  接着将 end 方法添加在结束标签所在的节点上,最后将 element 变量传入 closeElement 函数。
  closeElement 函数除了调用 postTransforms 数组中的函数处理节点之外,还根据不同情况调用对应的 process* 对节点进行进一步处理。该函数的另一主要功能是将当前节点推入到父节点 children 属性中,并添加 parent 节点指向父节点。

currentParent.children.push(element)
element.parent = currentParent
复制代码

(四)、文本处理函数 chars

  函数 chars 的核心代码如下所示:

let res
let child
if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
  child = {
    type: 2,
    expression: res.expression,
    tokens: res.tokens,
    text
  }
} else if (text !== ' ' || !children.length || children[children.length - 1].t!== ' ') {
  child = {
    type: 3,
    text
  }
}
if (child) {
  if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
    child.start = start
    child.end = end
  }
  children.push(child)
}
复制代码

  函数 chars 会调用 parseText 函数处理文本字符串,parseText 主要解析包含字面量表达式的文本,如果文本中没有字面量表达式则返回空值,否则返回包含 expressiontokens 属性的对象。
  若文本包含字面量表达式,则生成 type 值为2的节点描述对象,若为纯文本,则生成 type 值为3的节点描述对象。然后将字符串开始字符的位置 start 与 结束字符的位置 end 添加到节点对象上,最后将节点描述对象推入到父节点的 children 数组属性中。
  举个例子,其中 title 为变量数据:

<div>标题:{{title}}。</div>
<div>456<div>
复制代码

  第一个<div> 标签下的包含的包含字面量表达式的文本被 parseText 解析后返回如下对象:

{
  expression: "标题:"+_s(title)+"。",
  tokens: [ "标题:", { @binding: "title" }, "。" ]
}
复制代码

  第一个<div> 标签下文本最终生成的节点描述对象为:

{
  type: 2,
  expression: "标题:"+_s(title)+"。",
  tokens: [ "标题:", { @binding: "title" }, "。" ],
  text: "标题:{{title}}。",
  start: Number,
  end: Number
}
复制代码

  第二个<div> 标签下文本最终生成的节点描述对象为:

{
  type: 3,
  text: "456",
  start: Number,
  end: Number
}
复制代码

(五)、注释文本处理函数 comment

  注释文本处理的逻辑跟 chars 函数中处理不含字面量表达式的文本很像,只是生成的 type 值为3的节点描述对象多了一个属性:isComment,其值为 true,是注释文本描述节点的标识。处理函数 comment 代码如下所示:

comment (text, start, end) {
  if (currentParent) {
    const child = {
      type: 3,
      text,
      isComment: true
    }
    if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
      child.start = start
      child.end = end
    }
    currentParent.children.push(child)
  }
}
复制代码

三、优化AST

  AST 的优化途径主要是检测出不需要更改的DOM的纯静态子树,这样做有两个好处:

1、将纯静态节点描述对象提升为常量,在重新渲染时不用重新生成。
2、在 Virtual DOM patching 的过程跳过这部分。

  AST的优化是通过 optimize 函数来完成的,函数代码如下:

export function optimize (root, options) {
  if (!root) return
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  isPlatformReservedTag = options.isReservedTag || no
  
  markStatic(root)
  
  markStaticRoots(root, false)
}
复制代码

  AST优化逻辑相对比较简单,分为两步:

1、使用 markStatic 函数标记静态节点。
2、使用 markStaticRoots 方法标记静态根节点。

1、标记静态节点

  标记静态节点函数 markStatic 代码如下所示:

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
      }
    }
    /* 省略处理 if else 等指令的情况,具体讲解指令时补充 */
  }
}
复制代码

  markStatic 函数首先调用 isStatic 函数判断是否为静态节点,在节点描述对象上添加布尔变量 static 标识是否为静态节点。
  如果是元素节点且有子节点则递归调用 markStatic 函数处理每个子节点,如果子节点中有一个不是静态节点的,该元素节点就不是静态节点,即 static 属性值为 false
  判断节点是否为静态的函数 markStatic 代码如下:

function isStatic (node) {
  if (node.type === 2) { return false }
  if (node.type === 3) { return true }
  return !!(node.pre || (
    !node.hasBindings && // no dynamic bindings
    !node.if && !node.for && // not v-if or v-for or v-else
    !isBuiltInTag(node.tag) && // not a built-in
    isPlatformReservedTag(node.tag) && // not a component
    !isDirectChildOfTemplateFor(node) &&
    Object.keys(node).every(isStaticKey)
  ))
}
复制代码

  判断节点是否为静态的规则有以下四条:

1、含有字面表达式的文本节点为非静态节点。
2、纯文本节点为静态节点。
3、节点描述对象拥有 pre 属性(即标签有 v-pre 属性)为静态节点。
4、如果一个标签节点同时满足以下条件即为静态节点:没有使用 v-if、v-for、没有使用除 v-once 外的其它指令、非平台保留的标签、不是组件、不是带有 v-fortemplate 标签的直接子节点、节点的所有属性的 key 都满足静态 key

2、标记静态根节点

  标记静态根节点的函数 markStaticRoots 代码如下所示:

function markStaticRoots (node, isInFor) {
  if (node.type === 1) {
    if (node.static || node.once) {
      node.staticInFor = isInFor
    }
    if (node.static && node.children.length && !(
      node.children.length === 1 &&
      node.children[0].type === 3
    )) {
      node.staticRoot = true
      return
    } else {
      node.staticRoot = false
    }
    if (node.children) {
      for (let i = 0, l = node.children.length; i < l; i++) {
        markStaticRoots(node.children[i], isInFor || !!node.for)
      }
    }
    /* 省略处理 if else 等指令的情况,具体讲解指令时补充 */
  }
}
复制代码

  属性 staticRoot 是用来标记节点是否为静态根节点的,只有标签节点才有可能是静态根节点,判断静态根节点的标准为同时满足一下三点:

1、节点 statictrue,即为静态节点。
2、标签节点拥有子节点。
3、标签节点不是只拥有一个纯文本节点。

  之所以要求标签节点不是只拥有一个纯文本节点,是将一个这样的节点标记为静态根节点收益比较小,最好是让其总是保持新鲜。
  判断当前节点是否为静态根节点之后,会递归调用 markStaticRoots 函数处理该节点的每一个子节点。
  总之,经过AST优化函数 optimize 处理之后,每个节点的描述对象上增加了布尔类型的属性 static 用来标识是否为静态节点。type 属性为1的标签节点描述对象上增加了布尔类型的属性 staticRoot 用来标识是否为静态根节点。

四、生成渲染函数

  将优化后的 AST 转化成渲染函数字符串是在 generate 函数中完成的,代码如下所示:

export function generate (ast, options){
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}
复制代码

  generate 函数代码看似简单,其中包含的逻辑却比较复杂,因为要对各种各样的情况进行处理。本文通过一个简单的例子来大致阐述AST生成渲染函数字符串的过程,对示例之外的其它指令例如:v-forv-if 等存在时的情况在后续的具体文章中再加以介绍。

  <div id="app" class="home" @click="showTitle">
    <div class="title">标题:{{title}}。</div>
    <div class="content">
      <span>456</span>
    </div>
  </div>
复制代码

  以上模板字符串经过 parse 函数解析成AST,然后经过 optimize 函数优化之后的AST如下所示:

ast = {
  tag: "div",
  type: 1,
  attrs: [{dynamic: undefined,end: 13,name: "id",start: 5,value: ""app""}],
  attrsList: [
    { end: 13,name: "id",start: 5,value: "app" },
    { end: 45,name: "@click",start: 27,value: "showTitle" }
  ],
  attrsMap: {id: "app", class: "home", @click: "showTitle"},
  end: 178,
  events: {click: {dynamic: false,end: 45,start: 27,value: "showTitle"}},
  hasBindings: true,
  parent: undefined,
  plain: false,
  rawAttrsMap: {
    @click: {end: 45,name: "@click",start: 27,value: "showTitle"},
    class: {end: 26,name: "class",start: 14,value: "home"},
    id: {end: 13,name: "id",start: 5,value: "app"}
  },
  start: 0,
  static: false,
  staticClass: ""home"",
  staticRoot: false,
  children: [
    {
      tag: "div",
      type: 1,
      attrsList: [],
      attrsMap: {class: "title"},
      children: [{
        text: "标题:{{title}}。",
        type: 2,
        end: 87,
        expression: ""标题:"+_s(title)+""",
        start: 74,
        static: false,
        tokens: (3) ["标题:", {@binding: "title"}, "。"]
      }],
      end: 93,
      parent: {/*对父节点描述对象的引入*/},
      plain: false,
      rawAttrsMap: {
        class: {end: 73,name: "class",start: 60,value: "title"}
      },
      start: 55,
      static: false,
      staticClass: ""title"",
      staticRoot: false
    },
    {
      text: " ",
      type: 3,
      end: 102,
      start: 93,
      static: true
    },
    {
      tag: "div",
      type: 1,
      attrsList: [],
      attrsMap: {class: "content"},
      children: [
        {
          tag: "span",
          type: 1,
          attrsList: [],
          attrsMap: {},
          children: [{text: "456",type: 3,end: 145,start: 142,static: true}],
          end: 152,
          parent: {/*对父节点描述对象的引入*/},
          plain: true,
          rawAttrsMap: {},
          start: 136,
          static: true 
        }
      ],
      end: 167,
      parent: {/*对父节点描述对象的引入*/},
      plain: false,
      rawAttrsMap: {class: {end: 122,name: "class",start: 107,value: "content"}},
      start: 102,
      static: true,
      staticClass: ""content"",
      staticInFor: false,
      staticRoot: true
    }
  ]
}
复制代码

  idapp<div> 节点描述对象 children 属性数组中有三个对象,这是因为其两个 <div> 子节点中间有空格,算作一个纯文本节点。
  generate 函数首先根据传入的配置参数对象 options 实例化 CodegenState 对象。类 CodegenState 的代码如下所示:

export class CodegenState {
  constructor (options) {
    this.options = options
    this.warn = options.warn || baseWarn
    this.transforms = pluckModuleFunction(options.modules, 'transformCode')
    this.dataGenFns = pluckModuleFunction(options.modules, 'genData')
    this.directives = extend(extend({}, baseDirectives), options.directives)
    const isReservedTag = options.isReservedTag || no
    this.maybeComponent = (el) => !!el.component || !isReservedTag(el.tag)
    this.onceId = 0
    this.staticRenderFns = []
    this.pre = false
  }
}
复制代码

  在这里我们重点关注该对象上的 dataGenFnsstaticRenderFns 属性。staticRenderFns 属性是一个数组,存储着静态根节点的渲染函数字符串,是 generate 函数的返回对象属性之一。dataGenFns 数组中存储着选项 modules 中的 genData 函数,分别处理标签描述对象的class:class属性、style:style属性。

dataGenFns = [
  function genData (el) {
    var data = '';
    if (el.staticClass) {
      data += "staticClass:" + (el.staticClass) + ",";
    }
    if (el.classBinding) {
      data += "class:" + (el.classBinding) + ",";
    }
    return data
  },
  function genData(el) {
    var data = '';
    if (el.staticStyle) {
      data += "staticStyle:" + (el.staticStyle) + ",";
    }
    if (el.styleBinding) {
      data += "style:(" + (el.styleBinding) + "),";
    }
    return data
  }
]
复制代码

  在生成 CodegenState 的实例化对象 state 之后,generate 函数将 AST 和 state 传入 genElement 函数,最终生成渲染函数字符串。genElement 函数跟示例html有关的代码如下:

export function genElement (el, state) {
  if (el.staticRoot && !el.staticProcessed) {
    return genStatic(el, state)
  } 
  /* 省略一些判断条件 */
  else {
    let code
    /* 省略为标签组件的情况 */
    let data
    if (!el.plain || (el.pre && state.maybeComponent(el))) {
      data = genData(el, state)
    }

    const children = el.inlineTemplate ? null : genChildren(el, state, true)
    code = `_c('${el.tag}'${
      data ? `,${data}` : '' // data
    }${
      children ? `,${children}` : '' // children
    })`
    /* 省略一些代码 */
    return code
  }
}
复制代码

  根据示例生成的 ast 情况,在genElement 函数中会首先调用:

data = genData(el, state)
复制代码

  genData 函数主要是处理标签中的属性,将其转化成字符串返回。在标签拥有 class 或者 style 属性时会循环调用前面讲过的 state.dataGenFns 数组中的函数加以处理。当前标签描述对象的属性经过 genData 函数处理后 data 值为:

"{staticClass:"home",attrs:{"id":"app"},on:{"click":showTitle}}"
复制代码

  然后调用 genChildren 函数处理当前标签描述对象 children 属性数组中的对象,即处理其子节点描述对象。

const children = genChildren(el, state, true)
复制代码

  genChildren 函数代码如下所示:

function genChildren (el,state,checkSkip,altGenElement,altGenNode) {
  const children = el.children
  if (children.length) {
    const el = children[0]
    /* 省略一些代码 */
    const gen = altGenNode || genNode
    return `[${children.map(c => gen(c, state)).join(',')}]${
      normalizationType ? `,${normalizationType}` : ''
    }`
  }
}
复制代码

  函数的主要逻辑是使用 genNode 函数分别处理对象的 children 属性中的各个节点描述对象。

function genNode (node, state) {
  if (node.type === 1) {
    return genElement(node, state)
  } else if (node.type === 3 && node.isComment) {
    return genComment(node)
  } else {
    return genText(node)
  }
}
复制代码

  genNode 函数根据节点类型的不同分别调用不同的函数进行处理,使用 genElement 函数处理标签节点、使用 genComment 函数处理注释节点、使用 genText 函数处理文本节点
  注释节点的处理方式比较简单,直接用 _e() 函数的字符串形式包装注释节点的 text 属性。

function genComment(comment) {
  return `_e(${JSON.stringify(comment.text)})`
}
复制代码

  文本节点的处理函数 genText 使用 _v() 函数的字符串形式包装文本内容,纯文本节点内容为节点描述对象 text 属性的值,含字面量表达式的文本内容为节点描述对象 expression 属性的值。

function genText (text) {
  return `_v(${text.type === 2
    ? text.expression // no need for () because already wrapped in _s()
    : transformSpecialNewlines(JSON.stringify(text.text))
  })`
}
复制代码

  接着讲 genElement 函数,在拿到子节点的函数字符串后,使用逗号拼接标签名、标签属性字符串、子节点函数字符串,最后使用 _v() 函数的字符串形式加以包装。使用 new Function 处理后变成如下代码:

_c(tag,data,children)
复制代码

  classcontent<div> 是静态根节点,在 genElement 中会调用 genStatic 函数处理。

function genStatic (el, state) {
  /* 省略一些代码 */
  state.staticRenderFns.push(`with(this){return ${genElement(el, state)}}`)
  /* ··· */
  return `_m(${
    state.staticRenderFns.length - 1
  }${
    el.staticInFor ? ',true' : ''
  })`
}
复制代码

  处理后的函数字符串会被推入到 state.staticRenderFns 数组中,静态根节点函数字符串如下:

"with(this){return _c('div',{staticClass:"content"},[_c('span',[_v("456")])])}"
复制代码

  总之,函数 generate 的返回值为:

{
  render: "with(this){return _c('div',{staticClass:"home",attrs:{"id":"app"},on:{"click":showTitle}},[_c('div',{staticClass:"title"},[_v("标题:"+_s(title)+"")]),_v(" "),_m(0)])}",
  staticRenderFns: ["with(this){return _c('div',{staticClass:"content"},[_c('span',[_v("456")])])}"]
}
复制代码

  编译实例代码生成的函数字符串以及静态根节点函数字符串经过 new Function 处理之后如下所示:

render = function() {
  with(this){
    return _c(
      'div',
      {
        staticClass:"home",
        attrs:{"id":"app"},
        on:{"click":showTitle}
      },
      [
        _c(
          'div',
          {staticClass:"title"},
          [_v("标题:"+_s(title)+"。")]
        ),
        _v(" "),
        _m(0)
      ]
    )
  }
}
复制代码

  _c 函数定义在 src/core/instance/render.js 中,用来创建 VNode。其它的编译渲染的内部函数定义在 src/core/instance/render-helpers/index.jsinstallRenderHelpers 函数中。
  _v 函数用来创建文本类型的 VNode_s 函数用来处理字面量表达式返回结果字符串;_m 函数处理静态根节点。这些根据渲染函数生成 VNode 的过程会在后续讲解 Virtual DOM 时详细阐述。

五、总结

  Vue 使用函数柯里化的技巧来实现不同平台下的编译函数,核心编译过程分为三步:根据模板字符串生成AST、优化AST、根据AST生成渲染函数。
  生成AST的过程分为两步:词法分析、语法分析。在词法分析的过程中,逐个字符的解析html字符串。首先判断待解析的字符串开头是元素标签还是文本,标签又分为:开始标签、结束标签、注释标签、文档类型声明标签和条件注释标签,然后根据待解析字符串的类型做相应的处理。句法分析函数 parse 根据词法解析的结果生成三种节点描述对象:标签节点描述对象、字面量表达式文本节点描述对象、纯文本节点描述对象。AST依靠标签节点的指向父节点的 parent 属性与包含子节点的 children 属性构建树状结构。
  AST的优化分为两步:标记静态节点、标记静态根节点。优化的主要途径是标记出不需要重复编译且DOM不会发生改变的静态根节点,在做相关处理时忽略掉该类节点。
  渲染函数字符串的生成主要是根据AST将各种节点拼接成包裹在不同函数中的字符串,最后通过new Function 将函数字符串转化成真正的渲染函数。

欢迎关注公众号:前端桃花源,互相交流学习!




原文地址:访问原文地址
快照地址: 访问文章快照