avatar
原创2019/03/25

Vue2 模板编译

AI 总结

全面剖析 Vue 2.x 的模板编译流程,从 parse 阶段将 template 解析为 AST,到 optimize 阶段标记静态节点,再到 generate 阶段生成可执行的 render 函数,通过逐层拆解源码与配图演示,帮助你彻底理解 Vue 模板是如何被编译成虚拟 DOM 的。

Vue 的版本

  • vue.js:完整版本,包含了模板编译的能力
  • vue.runtime.js:运行时版本,不提供模板编译能力,需要通过 vue-loader 进行提前编译

简单来说,就是如果你用了 vue-loader ,就可以使用 vue.runtime.min.js,将模板编译的过程交过 vue-loader,如果你是在浏览器中直接通过 script 标签引入 Vue,需要使用 vue.min.js,运行的时候编译模板。

介绍开始之前,先看下模板编译的整体的流程

编译入口

入口文件位于 src/platforms/web/entry-runtime-with-compiler.js

// 省略了部分代码,只保留了关键部分
import { compileToFunctions } from './compiler/index'
 
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el) {
  const options = this.$options
 
  // 如果没有 render 方法,则进行 template 编译
  if (!options.render) {
    let template = options.template
    if (template) {
      // 调用 compileToFunctions,编译 template,得到 render 方法
      const { render, staticRenderFns } = compileToFunctions(
        template,
        {
          shouldDecodeNewlines,
          shouldDecodeNewlinesForHref,
          delimiters: options.delimiters,
          comments: options.comments
        },
        this
      )
      // 这里的 render 方法就是生成生成虚拟 DOM 的方法
      options.render = render
    }
  }
  return mount.call(this, el, hydrating)
}

compileToFunctions来自于src/compiler/index,当调用createCompilerCreator会返回一个createCompiler函数, 该函数返回了compileToFunctions,具体可查看代码 src/coppiler/create-compiler.js

下面继续分析主流程的执行过程, 查看src/compiler/index

export const createCompiler = createCompilerCreator(function baseCompile(
  template: string,
  options: CompilerOptions
): CompiledResult {
  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
  }
})

可以看到主流程主要是执行了三步:

  • 模板编译,将模板代码转化为 AST;
  • 优化 AST,方便后续虚拟 DOM 更新;
  • 生成代码,将 AST 转化为可执行的代码;

parse

调用 parse方法,传入模板字符串,解析 HTML 并会返回一个ASTElement对象

解析 HTML

parse 的整体流程比较复杂

import { parseHTML } from './html-parser'
 
export function parse(template, options) {
  let root
  parseHTML(template, {
    // some options...
    start() {}, // 解析到标签位置开始的回调
    end() {}, // 解析到标签位置结束的回调
    chars() {}, // 解析到文本时的回调
    comment() {} // 解析到注释时的回调
  })
  return root
}

可以看到 parse 主要通过 parseHTML 进行工作,这个 parseHTML 本身来自于开源库:simplehtmlparser

下面继续分析 parseHTML 的源码

export function parseHTML(html, options) {
  let index = 0
  let last,lastTag
  const stack = []
  while(html) {
    last = html
    let textEnd = html.indexOf('<')
 
    // "<" 字符在当前 html 字符串开始位置
    if (textEnd === 0) {
      // 1、匹配到注释: <!-- -->
      if (/^<!\\--/.test(html)) {
        const commentEnd = html.indexOf('-->')
        if (commentEnd >= 0) {
          // 调用 options.comment 回调,传入注释内容
          options.comment(html.substring(4, commentEnd))
          // 裁切掉注释部分
          advance(commentEnd + 3)
          continue
        }
      }
 
      // 2、匹配到条件注释: <![if !IE]>  <![endif]>
      if (/^<!\\[/.test(html)) {
        // ... 逻辑与匹配到注释类似
      }
 
      // 3、匹配到 Doctype: <!DOCTYPE html>
      const doctypeMatch = html.match(/^<!DOCTYPE [^>]+>/i)
      if (doctypeMatch) {
        // ... 逻辑与匹配到注释类似
      }
 
      // 4、匹配到结束标签: </div>
      const endTagMatch = html.match(endTag)
      if (endTagMatch) {}
 
      // 5、匹配到开始标签: <div>
      const startTagMatch = parseStartTag()
      if (startTagMatch) {}
    }
    // "<" 字符在当前 html 字符串中间位置
    let text, rest, next
    if (textEnd > 0) {
      // 提取中间字符
      rest = html.slice(textEnd)
      // 这一部分当成文本处理
      text = html.substring(0, textEnd)
      advance(textEnd)
    }
    // "<" 字符在当前 html 字符串中不存在
    if (textEnd < 0) {
      text = html
      html = ''
    }
 
    // 如果存在 text 文本
    // 调用 options.chars 回调,传入 text 文本
    if (options.chars && text) {
      // 字符相关回调
      options.chars(text)
    }
  }
  // 向前推进,裁切 html
  function advance(n) {
    index += n
    html = html.substring(n)
  }
}
 

上述代码为简化后的 parseHTML,while 循环中每次截取一段 html 文本,然后通过正则判断文本的类型进行处理,这就类似于编译原理中常用的有限状态机。每次拿到 "<" 字符前后的文本,"<" 字符前的就当做文本处理,"<" 字符后的通过正则判断,可推算出有限的几种状态

其他的逻辑处理都不复杂,主要是开始标签与结束标签,具体请查看 Vue编译器 token解析

值得注意的是为了 HTML 结构的完整性,代码中使用了栈结构。 当在解析开始标签的时候,如果该标签不是单标签,会将该标签放入到一个堆栈当中,每次遇到闭合标签的时候,会从栈顶找同名的开始标签并出栈。算法类似于leetcode 20

回到调用 parseHTML 的位置,调用该方法的时候,一共会传入四个回调,分别对应标签的开始和结束、文本、注释

parseHTML(template, {
  // some options...
 
  // 解析到标签位置开始的回调
  start(tag, attrs, unary) {},
  // 解析到标签位置结束的回调
  end(tag) {},
  // 解析到文本时的回调
  chars(text: string) {},
  // 解析到注释时的回调
  comment(text: string) {}
})

处理开始标签

首先看解析到开始标签时,会生成一个 AST 节点,然后处理标签上的属性,最后将 AST 节点放入树形结构中。

function makeAttrsMap(attrs) {
  const map = {}
  for (let i = 0, l = attrs.length; i < l; i++) {
    const { name, value } = attrs[i]
    map[name] = value
  }
  return map
}
function createASTElement(tag, attrs, parent) {
  const attrsList = attrs
  const attrsMap = makeAttrsMap(attrsList)
  return {
    type: 1, // 节点类型
    tag, // 节点名称
    attrsMap, // 节点属性映射
    attrsList, // 节点属性数组
    parent, // 父节点
    children: [] // 子节点
  }
}
 
const stack = []
let root // 根节点
let currentParent // 暂存当前的父节点
parseHTML(template, {
  // some options...
 
  // 解析到标签位置开始的回调
  start(tag, attrs, unary) {
    // 创建 AST 节点
    let element = createASTElement(tag, attrs, currentParent)
 
    // 处理指令: v-for v-if v-once
    processFor(element)
    processIf(element)
    processOnce(element)
    processElement(element, options)
 
    // 处理 AST 树
    // 根节点不存在,则设置该元素为根节点
    if (!root) {
      root = element
      checkRootConstraints(root)
    }
    // 存在父节点
    if (currentParent) {
      // 将该元素推入父节点的子节点中
      currentParent.children.push(element)
      element.parent = currentParent
    }
    if (!unary) {
      // 非单标签需要入栈,且切换当前父元素的位置
      currentParent = element
      stack.push(element)
    }
  }
})

处理结束标签

标签结束只需要去除栈内最后一个未闭合标签,进行闭合即可。

parseHTML(template, {
  // 解析到标签位置结束的回调
  end() {
    const element = stack[stack.length - 1]
    const lastNode = element.children[element.children.length - 1]
    // 处理尾部空格的情况
    if (lastNode && lastNode.type === 3 && lastNode.text === ' ') {
      element.children.pop()
    }
    // 出栈,重置当前的父节点
    stack.length -= 1
    currentParent = stack[stack.length - 1]
  }
})

处理文本

处理完标签后,还需要对标签内的文本进行处理。文本的处理分两种情况,一种是带表达式的文本,还一种就是纯静态的文本。

parseHTML(template, {
  // some options...
 
  // 解析到文本时的回调
  chars(text) {
    if (!currentParent) {
      // 文本节点外如果没有父节点则不处理
      return
    }
 
    const children = currentParent.children
    text = text.trim()
    if (text) {
      // parseText 用来解析表达式
      // delimiters 表示表达式标识符,默认为 ['{{', '}}']
      const res = parseText(text, delimiters))
      if (res) {
        // 表达式
        children.push({
          type: 2,
          expression: res.expression,
          tokens: res.tokens,
          text
        })
      } else {
        // 静态文本
        children.push({
          type: 3,
          text
        })
      }
    }
  }
})
 

parseText 解析表达式

// 构造匹配表达式的正则
const buildRegex = (delimiters) => {
  const open = delimiters[0]
  const close = delimiters[1]
  return new RegExp(open + '((?:.|\\\\n)+?)' + close, 'g')
}
 
function parseText(text, delimiters) {
  // delimiters 默认为 {{ }}
  const tagRE = buildRegex(delimiters || ['{{', '}}'])
  // 未匹配到表达式,直接返回
  if (!tagRE.test(text)) {
    return
  }
  const tokens = []
  const rawTokens = []
  let lastIndex = (tagRE.lastIndex = 0)
  let match, index, tokenValue
  while ((match = tagRE.exec(text))) {
    // 表达式开始的位置
    index = match.index
    // 提取表达式开始位置前面的静态字符,放入 token 中
    if (index > lastIndex) {
      rawTokens.push((tokenValue = text.slice(lastIndex, index)))
      tokens.push(JSON.stringify(tokenValue))
    }
    // 提取表达式内部的内容,使用 _s() 方法包裹
    const exp = match[1].trim()
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    lastIndex = index + match[0].length
  }
  // 表达式后面还有其他静态字符,放入 token 中
  if (lastIndex < text.length) {
    rawTokens.push((tokenValue = text.slice(lastIndex)))
    tokens.push(JSON.stringify(tokenValue))
  }
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

可以看到遇到表达式将表达式的内容存储在对象的@binding 属性上。

optimize

经过对 HTML 字符串的解析得到一颗 AST,需要对AST进行优化,确保静态的数据不会进入虚拟 DOM 的更新阶段。优化详细内容位于 src/compiler/optimizer.js

export function optimize(root: ?ASTElement, options: CompilerOptions) {
  if (!root) return
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  isPlatformReservedTag = options.isReservedTag || no
  // first pass: mark all non-static nodes.
  markStatic(root)
  // second pass: mark static roots.
  markStaticRoots(root, false)
}

markStatic 核心就是将静态节点的 static 属性设置为 true,那么什么是静态节点呢

function isStatic(node: ASTNode): boolean {
  if (node.type === 2) {
    // expression
    return false
  }
  if (node.type === 3) {
    // text
    return true
  }
  return !!(
    node.pre ||
    (!node.hasBindings && // 没有动态绑定
      !node.if &&
      !node.for && // 没有 v-if/v-for
      !isBuiltInTag(node.tag) && // 不是内置组件 slot/component
      isPlatformReservedTag(node.tag) && // not a component
      !isDirectChildOfTemplateFor(node) &&
      Object.keys(node).every(isStaticKey))
  )
}

ASTNode 的 type 来自三种数据结构

  • type=1, ASTElement
  • type=2, ASTExpression
  • type=3, ASTText

将 static 属性标记为 true还需要针对 ASTElement的子节点进行递归设置,如果子节点都是静态的,则在父节点标记 node.staticRoot = true, 在后续的 generate流程中会将生成的渲染函数放在组件的staticRenderFns属性上,并在组件的 render函数中引用。具体请看下面的流程

generate

优化完 ast 就需要生成 render方法了,详细代码位于 src/compiler/codegen/index.js

export function generate(
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

可以看到 render方法中的内容主要使用 getElement(ast, state) 生成。

export function genElement(el: ASTElement, state: CodegenState): string {
  if (el.parent) {
    el.pre = el.pre || el.parent.pre
  }
  if (el.staticRoot && !el.staticProcessed) {
    return genStatic(el, state)
  } else if (el.once && !el.onceProcessed) {
    return genOnce(el, state)
  } else if (el.for && !el.forProcessed) {
    return genFor(el, state)
  } else if (el.if && !el.ifProcessed) {
    return genIf(el, state)
  } else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
    return genChildren(el, state) || 'void 0'
  } else if (el.tag === 'slot') {
    return genSlot(el, state)
  } else {
    // component or element
    let code
    if (el.component) {
      code = genComponent(el.component, el, state)
    } else {
      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
      })`
    }
    // module transforms
    for (let i = 0; i < state.transforms.length; i++) {
      code = state.transforms[i](el, code)
    }
    return code
  }
}

生成函数的过程中

  • 会先判断当前的节点的staticRoot是不是true, 为true则说明其子节点都是静态,直接调用genStatic。
  • 使用v-once的元素,调用genOnce。 其只会渲染一次,随后即使改变了其中响应式字段的值,也不进行更新。genOnce会将元素的staticProcessed设置为true, 表示是处理过程中的静态节点
  • 使用v-for的元素,调用 genFor。其中会检查是否给元素添加了 key,没有则提示警告, 之后只用 _l进行渲染

后续针对不同节点的处理请查看源码,这里不再赘述。

上述创建 dom 的核心代码是

code = `_c('${el.tag}'${
  data ? `,${data}` : '' // data
}${
  children ? `,${children}` : '' // children
})`

HTML 标签会经过 _c的包裹, _c 表示

// a: tag, b: property, c: children, d: normalizationType
vm._c = function (a, b, c, d) {
  return createElement(vm, a, b, c, d, false)
}

createElement 函数位于 src/core/vdom/create-element.js, 其会返回一个 vnode 结构虚拟 dom 节点。createElement结构如下

export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode>

而对于其他类似于_l, _s则定义在src/core/instance/render-helpers/index.js

export function installRenderHelpers(target: any) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList
  target._t = renderSlot
  target._q = looseEqual
  target._i = looseIndexOf
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  target._v = createTextVNode
  target._e = createEmptyVNode
  target._u = resolveScopedSlots
  target._g = bindObjectListeners
}

假设现有一段模板如下所示

<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <p>
      <a href="<https://cli.vuejs.org>" target="_blank" rel="noopener"
        >vue-cli documentation</a
      >.
    </p>
    <div v-for="(num, i) in names" :key="i">{{ num }}</div>
    <h3>asdasd</h3>
  </div>
</template>

则其编译后的结果如下

var render = function () {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c(
    'div',
    { staticClass: 'hello' },
    [
      _c('h1', [_vm._v(_vm._s(_vm.msg))]),
      _vm._m(0),
      _vm._l(_vm.names, function (num, i) {
        return _c('div', { key: i }, [_vm._v(_vm._s(num))])
      }),
      _c('h3', [_vm._v('asdasd')])
    ],
    2
  )
}
var staticRenderFns = [
  function () {
    var _vm = this
    var _h = _vm.$createElement
    var _c = _vm._self._c || _h
    return _c('p', [
      _c(
        'a',
        {
          attrs: {
            href: '<https://cli.vuejs.org>',
            target: '_blank',
            rel: 'noopener'
          }
        },
        [_vm._v('vue-cli documentation')]
      ),
      _vm._v('. ')
    ])
  }
]

模板编译完成后,后续需要进行 dom 的渲染和更新

点击播放