Vue - The Good Parts: template到render函数

avatar
@滴滴出行

前言

熟悉 Vue 的同学都知道,从 Vue2 开始,在实际运行的时候,是将用户所写的 template 转换为 render 函数,得到 vnode 数据(虚拟 DOM),然后再继续执行,最终经过 patch 到真实的 DOM,而当有数据更新的时候,也是靠这个进行 vnode 数据的 diff,最终决定更新哪些真实的 DOM。

这个也是 Vue 的一大核心优势,尤大不止一次的讲过,因为用户自己写的是静态的模板,所以 Vue 就可以根据这个模板信息做很多标记,进而就可以做针对性的性能优化,这个在 Vue 3 中做了进一步的优化处理,block 相关设计。

所以,我们就来看一看,在 Vue 中,template 到 render 函数,到底经历了怎么样的过程,这里边有哪些是值得我们借鉴和学习的。

正文分析

What

template 到 render,在 Vue 中其实是对应的 compile 编译的部分,也就是术语编译器 cn.vuejs.org/v2/guide/in… 本质上来讲,这个也是很多框架所采用的的方案 AOT,就是将原本需要在运行时做的事情,放在编译时做好,以提升在运行时的性能。

关于 Vue 本身模板的语法这里就不详细介绍了,感兴趣的同学可以看 cn.vuejs.org/v2/guide/sy… ,大概就是形如下面的这些语法(插值和指令):

image2021-6-18_17-6-55.png

render 函数呢,这部分在 Vue 中也有着详细的介绍,大家可以参阅 cn.vuejs.org/v2/guide/re… ,简单来讲,大概就是这个样子:

image2021-6-18_17-5-1.png

那我们的核心目标就是这样:

image2021-6-18_17-9-3.png

如果你想体验,可以这里 template-explorer.vuejs.org

当然 Vue 3 的其实也是可以的 https://vue-next-template-explorer ,虽然这里我们接下来要分析的是 Vue 2 版本的。

How

要想了解是如何做到的,我们就要从源码入手,编译器相关的都在 github.com/vuejs/vue/t… 目录下,我们这里从入口文件 index.js 开始:

import { parse } from './parser/index'
import { optimize } from './optimizer'
import { generate } from './codegen/index'
import { createCompilerCreator } from './create-compiler'
 
// `createCompilerCreator` allows creating compilers that use alternative
// parser/optimizer/codegen, e.g the SSR optimizing compiler.
// Here we just export a default compiler using the default parts.
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 重点!
  // 第1步 parse 模板 得到 ast
  const ast = parse(template.trim(), options)
  // 优化 可以先忽略
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  // 第2步 根据 ast 生成代码
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

其实,你会发现,这是一个经典的编译器(Parsing、Transformation、Code Generation)实现的步骤(这里其实是简化):

  1. parse,得到 ast
  2. generate,得到目标代码

接下来我们就分别来看下对应的实现。

1. parse

parse 的实现在 github.com/vuejs/vue/b… 这里,由于代码比较长,我们一部分一部分的看,先来看暴露出来的 parse 函数:

export function parse (
  template: string,
  options: CompilerOptions
): ASTElement | void {
  // options 处理 这里已经忽略了
  // 重要的栈 stack
  const stack = []
  const preserveWhitespace = options.preserveWhitespace !== false
  const whitespaceOption = options.whitespace
  // 根节点,只有一个,因为我们知道 Vue 2 的 template 中只能有一个根元素
  // ast 是树状的结构,root 也就是这个树的根节点
  let root
  let currentParent
  let inVPre = false
  let inPre = false
  let warned = false
  // parseHTML 处理
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    outputSourceRange: options.outputSourceRange,
    // 注意后边的这些 options 函数 start end chars comment
    // 约等于是 parseHTML 所暴露出来的钩子,以便于外界处理
    // 所以纯粹的,parseHTML 只是负责 parse,但是并不会生成 ast 相关逻辑
    // 这里的 ast 生成就是靠这里的钩子函数配合
    // 直观理解也比较容易:
    // start 就是每遇到一个开始标签的时候 调用
    // end 就是结束标签的时候 调用
    // 这里重点关注 start 和 end 中的逻辑就可以,重点!!
    // chars comment 相对应的纯文本和注释
    start (tag, attrs, unary, start, end) {
      // check namespace.
      // inherit parent ns if there is one
      const ns = (currentParent && currentParent.ns) || platformGetTagNamespace(tag)
 
      // handle IE svg bug
      /* istanbul ignore if */
      if (isIE && ns === 'svg') {
        attrs = guardIESVGBug(attrs)
      }
      // 创建一个 ASTElement,根据标签 属性
      let element: ASTElement = createASTElement(tag, attrs, currentParent)
      if (ns) {
        element.ns = ns
      }
 
      if (process.env.NODE_ENV !== 'production') {
        if (options.outputSourceRange) {
          element.start = start
          element.end = end
          element.rawAttrsMap = element.attrsList.reduce((cumulated, attr) => {
            cumulated[attr.name] = attr
            return cumulated
          }, {})
        }
        attrs.forEach(attr => {
          if (invalidAttributeRE.test(attr.name)) {
            warn(
              `Invalid dynamic argument expression: attribute names cannot contain ` +
              `spaces, quotes, <, >, / or =.`,
              {
                start: attr.start + attr.name.indexOf(`[`),
                end: attr.start + attr.name.length
              }
            )
          }
        })
      }
 
      if (isForbiddenTag(element) && !isServerRendering()) {
        element.forbidden = true
        process.env.NODE_ENV !== 'production' && warn(
          'Templates should only be responsible for mapping the state to the ' +
          'UI. Avoid placing tags with side-effects in your templates, such as ' +
          `<${tag}>` + ', as they will not be parsed.',
          { start: element.start }
        )
      }
 
      // 一些前置转换 可以忽略
      for (let i = 0; i < preTransforms.length; i++) {
        element = preTransforms[i](element, options) || element
      }
 
      if (!inVPre) {
        processPre(element)
        if (element.pre) {
          inVPre = true
        }
      }
      if (platformIsPreTag(element.tag)) {
        inPre = true
      }
      if (inVPre) {
        processRawAttrs(element)
      } else if (!element.processed) {
        // structural directives
        // 处理 vue 指令 等
        processFor(element)
        processIf(element)
        processOnce(element)
      }
 
      if (!root) {
        // 如果还没有 root 即当前元素就是根元素
        root = element
        if (process.env.NODE_ENV !== 'production') {
          checkRootConstraints(root)
        }
      }
 
      if (!unary) {
        // 设置当前 parent 元素,处理 children 的时候需要
        currentParent = element
        // 因为我们知道 html 的结构是 <div><p></p></div> 这样的,所以会先 start 处理
        // 然后继续 start 处理 然后 才是两次 end 处理
        // 是一个经典的栈的处理,先进后出的方式
        // 其实任意的编译器都是离不开栈的,处理方式也是类似
        stack.push(element)
      } else {
        closeElement(element)
      }
    },
 
    end (tag, start, end) {
      // 当前处理的元素
      const element = stack[stack.length - 1]
      // 弹出最后一个
      // pop stack
      stack.length -= 1
      // 最新的尾部 就是接下来要处理的元素的 parent
      currentParent = stack[stack.length - 1]
      if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
        element.end = end
      }
      closeElement(element)
    },
 
    chars (text: string, start: number, end: number) {
      if (!currentParent) {
        if (process.env.NODE_ENV !== 'production') {
          if (text === template) {
            warnOnce(
              'Component template requires a root element, rather than just text.',
              { start }
            )
          } else if ((text = text.trim())) {
            warnOnce(
              `text "${text}" outside root element will be ignored.`,
              { start }
            )
          }
        }
        return
      }
      // IE textarea placeholder bug
      /* istanbul ignore if */
      if (isIE &&
        currentParent.tag === 'textarea' &&
        currentParent.attrsMap.placeholder === text
      ) {
        return
      }
      const children = currentParent.children
      if (inPre || text.trim()) {
        text = isTextTag(currentParent) ? text : decodeHTMLCached(text)
      } else if (!children.length) {
        // remove the whitespace-only node right after an opening tag
        text = ''
      } else if (whitespaceOption) {
        if (whitespaceOption === 'condense') {
          // in condense mode, remove the whitespace node if it contains
          // line break, otherwise condense to a single space
          text = lineBreakRE.test(text) ? '' : ' '
        } else {
          text = ' '
        }
      } else {
        text = preserveWhitespace ? ' ' : ''
      }
      if (text) {
        if (!inPre && whitespaceOption === 'condense') {
          // condense consecutive whitespaces into single space
          text = text.replace(whitespaceRE, ' ')
        }
        let res
        let child: ?ASTNode
        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].text !== ' ') {
          child = {
            type: 3,
            text
          }
        }
        if (child) {
          if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
            child.start = start
            child.end = end
          }
          children.push(child)
        }
      }
    },
    comment (text: string, start, end) {
      // adding anything as a sibling to the root node is forbidden
      // comments should still be allowed, but ignored
      if (currentParent) {
        const child: ASTText = {
          type: 3,
          text,
          isComment: true
        }
        if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
          child.start = start
          child.end = end
        }
        currentParent.children.push(child)
      }
    }
  })
  // 返回根节点
  return root
}

可以看出做的最核心的事情就是调用 parseHTML,且传的钩子中做的事情最多的还是在 start 开始标签这里最多。针对于在 Vue 的场景,利用钩子的处理,最终我们返回的 root 其实就是一个树的根节点,也就是我们的 ast,形如:

模板为:

<div id="app">{{ msg }}</div>
{
    "type": 1,
    "tag": "div",
    "attrsList": [
        {
            "name": "id",
            "value": "app"
        }
    ],
    "attrsMap": {
        "id": "app"
    },
    "rawAttrsMap": {},
    "children": [
        {
            "type": 2,
            "expression": "_s(msg)",
            "tokens": [
                {
                    "@binding": "msg"
                }
            ],
            "text": "{{ msg }}"
        }
    ],
    "plain": false,
    "attrs": [
        {
            "name": "id",
            "value": "app"
        }
    ]
}

所以接下来才是parse最核心的部分 parseHTML,取核心部分(不全),一部分一部分来分析,源文件 github.com/vuejs/vue/b…

// parse的过程就是一个遍历 html 字符串的过程
export function parseHTML (html, options) {
  // html 就是一个 HTML 字符串
  // 再次出现栈,最佳数据结构,用于处理嵌套解析问题
  // HTML 中就是处理 标签 嵌套
  const stack = []
  const expectHTML = options.expectHTML
  const isUnaryTag = options.isUnaryTag || no
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no
  // 初始索引位置 index
  let index = 0
  let last, lastTag
  // 暴力循环 目的为了遍历
  while (html) {
    last = html
    // Make sure we're not in a plaintext content element like script/style
    if (!lastTag || !isPlainTextElement(lastTag)) {
      // 没有 lastTag 即初始状态 或者说 lastTag 是 script style
      // 这种需要当做纯文本处理的标签元素
      // 正常状态下 都应进入这个分支
      // 判断标签位置,其实也就是判断了非标签的end位置
      let textEnd = html.indexOf('<')
      // 在起始位置
      if (textEnd === 0) {
        // 注释,先忽略
        if (comment.test(html)) {
          const commentEnd = html.indexOf('-->')
 
          if (commentEnd >= 0) {
            if (options.shouldKeepComment) {
              options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
            }
            advance(commentEnd + 3)
            continue
          }
        }
 
        // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
        // 条件注释,先忽略
        if (conditionalComment.test(html)) {
          const conditionalEnd = html.indexOf(']>')
 
          if (conditionalEnd >= 0) {
            advance(conditionalEnd + 2)
            continue
          }
        }
 
        // Doctype 先忽略
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
          advance(doctypeMatch[0].length)
          continue
        }
 
        // 结束标签,第一次先忽略,其他case会进入
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          const curIndex = index
          advance(endTagMatch[0].length)
          // 处理结束标签
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }
 
        // 重点,一般场景下,开始标签
        const startTagMatch = parseStartTag()
        // 如果存在开始标签
        if (startTagMatch) {
          // 处理相关逻辑
          handleStartTag(startTagMatch)
          if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
            advance(1)
          }
          continue
        }
      }
 
      let text, rest, next
      if (textEnd >= 0) {
        // 剩余的 html 去掉文本之后的
        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)
      }
      // 已经没有 < 了 所以内容就是纯文本
      if (textEnd < 0) {
        text = html
      }
 
      if (text) {
        // 重点 前进指定长度
        advance(text.length)
      }
 
      if (options.chars && text) {
        // 钩子函数处理
        options.chars(text, index - text.length, index)
      }
    } else {
      // lastTag 存在 且是 script style 这样的 将其内容当做纯文本处理
      let endTagLength = 0
      // 存在栈中的tag名
      const stackedTag = lastTag.toLowerCase()
      // 指定 tag 的 匹配正则 注意 是到对应结束标签的 正则,例如 </script>
      const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
      // 做替换
      // 即把 <div>xxxx</div></script> 这样的替换掉
      const rest = html.replace(reStackedTag, function (all, text, endTag) {
        // 结束标签本身长度 即 </script>的长度
        endTagLength = endTag.length
        if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
          text = text
            .replace(/<!\--([\s\S]*?)-->/g, '$1') // #7298
            .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
        }
        if (shouldIgnoreFirstNewline(stackedTag, text)) {
          text = text.slice(1)
        }
        // 钩子函数处理
        if (options.chars) {
          options.chars(text)
        }
        // 替换为空
        return ''
      })
      // 索引前进 注意没有用 advance 因为 html 其实是已经修正过的 即 rest
      index += html.length - rest.length
      html = rest
      // 处理结束标签
      parseEndTag(stackedTag, index - endTagLength, index)
    }
 
    if (html === last) {
      options.chars && options.chars(html)
      if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
        options.warn(`Mal-formatted tag at end of template: "${html}"`, { start: index + html.length })
      }
      break
    }
  }
}

这里边有几个重点的函数,他们都是定义在 parseHTML 整个函数上下文中的,所以他们可以直接访问上边定义的 index stack lastTag 等关键变量:

// 比较好理解,前进n个位置
function advance (n) {
    index += n
    html = html.substring(n)
}
// 开始标签
function parseStartTag () {
  // 正则匹配开始 例如 <div
  const start = html.match(startTagOpen)
  if (start) {
    // 匹配到的
    const match = {
      tagName: start[1],
      attrs: [],
      start: index
    }
    // 移到 <div 之后
    advance(start[0].length)
    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) {
      // 是否是 自闭合标签,例如 <xxxx />
      match.unarySlash = end[1]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}
// 当遇到开始标签的情况 去处理他们
// 因为开始标签的情况比较复杂 所以 单独了一个函数处理
function handleStartTag (match) {
    const tagName = match.tagName
    const unarySlash = match.unarySlash
 
    if (expectHTML) {
      // HTML 场景
      // p 标签之内不能存在 isNonPhrasingTag 的tag
      // 详细的看 https://github.com/vuejs/vue/blob/v2.6.14/src/platforms/web/compiler/util.js#L18
      if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
        // 所以在浏览器环境 也是会自动容错处理的 直接闭合他们
        parseEndTag(lastTag)
      }
      if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
        parseEndTag(tagName)
      }
    }
    // 自闭和的场景 或者 可以省略结束标签的case
    // 即 <xxx /> 或者 <br> <img> 这样的场景
    const unary = isUnaryTag(tagName) || !!unarySlash
 
    const l = match.attrs.length
    const attrs = new Array(l)
    for (let i = 0; i < l; i++) {
      const args = match.attrs[i]
      const value = args[3] || args[4] || args[5] || ''
      const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
        ? options.shouldDecodeNewlinesForHref
        : options.shouldDecodeNewlines
      attrs[i] = {
        name: args[1],
        value: decodeAttr(value, shouldDecodeNewlines)
      }
      if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
        attrs[i].start = args.start + args[0].match(/^\s*/).length
        attrs[i].end = args.end
      }
    }
 
    if (!unary) {
      // 如果不是自闭和case 也就意味着可以当做有 children 处理的
      // 栈里 push 一个当前的
      stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
      // 把 lastTag 设置为当前的
      // 为了下次进入 children 做准备
      lastTag = tagName
    }
    // start 钩子处理
    if (options.start) {
      options.start(tagName, attrs, unary, match.start, match.end)
    }
  }
// 结束标签处理
function parseEndTag (tagName, start, end) {
    let pos, lowerCasedTagName
    if (start == null) start = index
    if (end == null) end = index
 
    // Find the closest opened tag of the same type
    if (tagName) {
      lowerCasedTagName = tagName.toLowerCase()
      // 这里需要找到 最近的 相同类型的 未闭合标签
      // 相对应的配对的那个元素
      for (pos = stack.length - 1; pos >= 0; pos--) {
        if (stack[pos].lowerCasedTag === lowerCasedTagName) {
          break
        }
      }
    } else {
      // If no tag name is provided, clean shop
      pos = 0
    }
 
    if (pos >= 0) {
      // 回到那个未闭合的标签,这中间里边所有的元素都需要闭合掉
      // Close all the open elements, up the stack
      for (let i = stack.length - 1; i >= pos; i--) {
        if (process.env.NODE_ENV !== 'production' &&
          (i > pos || !tagName) &&
          options.warn
        ) {
          options.warn(
            `tag <${stack[i].tag}> has no matching end tag.`,
            { start: stack[i].start, end: stack[i].end }
          )
        }
        // end 钩子
        if (options.end) {
          options.end(stack[i].tag, start, end)
        }
      }
      // 里边的元素也不需要处理了 直接修改栈的长度即可
      // Remove the open elements from the stack
      stack.length = pos
      // 记得更新 lastTag
      lastTag = pos && stack[pos - 1].tag
    } else if (lowerCasedTagName === 'br') {
      // br 的情况 如果写的是 </br> 其实效果相当于 <br>
      if (options.start) {
        options.start(tagName, [], true, start, end)
      }
    } else if (lowerCasedTagName === 'p') {
      // p 的情况 如果找不到 <p> 直接匹配到了 </p> 那么认为是 <p></p> 因为浏览器也是这样兼容
      if (options.start) {
        options.start(tagName, [], false, start, end)
      }
      if (options.end) {
        options.end(tagName, start, end)
      }
    }
  }

所以大概了解了上边三个函数的作用,再和 parseHTML 的主逻辑结合起来,我们可以大概整理下 parseHTML 的整个过程。

这里为了方便,以一个具体的示例来进行,例如

<div id="app">
  <p :class="pClass">
    <span>
      This is dynamic msg:
        <span>{{ msg }}</span>
    </span>
  </p>
</div>

那么首先直接进入 parseHTML,进入 while 循环,很明显会走入到对于开始标签的处理 parseStartTag

image2021-6-22_21-1-11.png

image2021-6-22_20-54-41.png

image2021-6-22_20-55-37 (1).png

image2021-6-22_20-56-37 (1).png

image2021-6-22_20-57-34 (1).png

image2021-6-22_20-58-33.png

此时经过上边的一轮处理,html已经是这个样子了,因为每次都有 advance 前进:

image2021-6-22_20-59-18.png

也就是关于最开始的根标签 div 的开始部分 <div id="app"> 已经处理完成了。

接着进入到 handleStartTag 的逻辑中

image2021-6-22_21-3-52.png

此时,stack 栈中已经 push 了一个元素,即我们的开始标签 div,也保存了相关的位置和属性信息,lastTag 指向的就是 div。

接着继续 while 循环处理

image2021-6-22_21-8-5.png

因为有空格和换行的关系,此时 textEnd 的值是 3,所以要进入到文本的处理逻辑(空格和换行本来就属于文本内容)

image2021-6-22_21-11-19.png

所以这轮循环会处理好文本,然后进入下一次循环操作,此时已经和我们第一轮循环的效果差不多:

image2021-6-22_21-14-49.png

image2021-6-22_21-15-57.png

再次lastTag变为了 p,然后进入到处理文本(空格、换行)的逻辑,这里直接省略,过程是一样的;

下面直接跳到第一次处理 span

image2021-6-22_21-19-18.png

其实还是重复和第一次的循环一样,处理普通元素,处理完成后的结果:

image2021-6-22_21-20-28.png

此时栈顶的元素是外部的这个 span。然后进入新一轮的处理文本:

image2021-6-22_21-22-25.png

接着再一次进入处理里层的 span 元素,一样的逻辑,处理完成后

image2021-6-22_21-24-15.png

然后处理最里层的文本,结束后,到达最里层的结束标签 </span>

这个时候我们重点看下这一轮的循环:

image2021-6-22_21-26-33.png

image2021-6-22_21-27-7.png

image2021-6-22_21-28-29.png

image2021-6-22_21-28-58.png

image2021-6-22_21-30-21.png

可以看到经过这一圈处理,最里层的 span 已经经过闭合处理,栈和lastTag已经更新为了外层的 span 了。

剩下的循环的流程,相信你已经能够大概猜到了,一直是处理文本内容(换行 空格)以及 parseEndTag 相关处理,一次次的出栈,直到 html 字符串处理完成,为空,即停止了循环处理。

十分类似的原理,我们的 parse 函数也是一样的,根据 parseHTML 的钩子函数,一次次的压榨,处理,然后出栈 处理,直至完成,这些钩子做的核心事情就是根据 parse HTML 的过程中,一步步构建自己的 ast,那么最终的 ast 结果

image2021-6-22_21-40-30.png

到这里 parse 的阶段已经彻底完成。

2. generate

接下来看看如何根据上述的 ast 得到我们想要的 render 函数。相关的代码在 github.com/vuejs/vue/b…

export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  // fix #11483, Root level <script> tags should not be rendered.
  const code = ast ? (ast.tag === 'script' ? 'null' : genElement(ast, state)) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

可以看出,generate 核心,第一步创建了一个 CodegenState 实例,没有很具体的功能,约等于是配置项的处理,然后进入核心逻辑 genElement,相关代码 github.com/vuejs/vue/b…

// 生成元素代码
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
  }
}

基本上就是根据元素类型进行对应的处理,依旧是上边的示例的话,会进入到

image2021-6-22_21-52-8.png

接下来会是一个重要的 genChildren github.com/vuejs/vue/b…

export function genChildren (
  el: ASTElement,
  state: CodegenState,
  checkSkip?: boolean,
  altGenElement?: Function,
  altGenNode?: Function
): string | void {
  const children = el.children
  if (children.length) {
    const el: any = children[0]
    // optimize single v-for
    if (children.length === 1 &&
      el.for &&
      el.tag !== 'template' &&
      el.tag !== 'slot'
    ) {
      const normalizationType = checkSkip
        ? state.maybeComponent(el) ? `,1` : `,0`
        : ``
      return `${(altGenElement || genElement)(el, state)}${normalizationType}`
    }
    const normalizationType = checkSkip
      ? getNormalizationType(children, state.maybeComponent)
      : 0
    const gen = altGenNode || genNode
    return `[${children.map(c => gen(c, state)).join(',')}]${
      normalizationType ? `,${normalizationType}` : ''
    }`
  }
}

可以看出,基本上是循环 children,然后 调用 genNode 生成 children 的代码,genNode github.com/vuejs/vue/b…

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

这里就是判断每一个节点类型,然后基本递归调用 genElement 或者 genComment、genText 来生成对应的代码。

最终生成的代码 code 如下:

image2021-6-22_22-2-39.png

可以理解为,遍历上述的 ast,分别生成他们的对应的代码,借助于递归,很容易的就处理了各种情况。当然,有很多细节这里其实被我们忽略掉了,主要还是看的正常情况下的核心的大概简要流程,便于理解。

到此,这就是在 Vue 中是如何处理编译模板到 render 函数的完整过程。

Why

要找到背后的原因,我们可以拆分为两个点:

  • 为什么要引入 Virtual DOM
  • 为什么推荐模板(将模板转换为render函数,得到 vnode 数据)

为什么要引入 Virtual DOM

这个问题其实尤大本人自己讲过,为什么在 Vue 2 中引入 Virtual DOM,是不是有必要的等等。

来自方应杭的聚合回答:

v2-916e42ce8034a05f783b84318ef08beb_r.jpeg

这里有一些文章和回答供参考(也包含了别人的总结部分):

为什么推荐模板

这个在官网框架对比中有讲到,原文 cn.vuejs.org/v2/guide/co…

image2021-6-22_23-40-37.png

当然,除了上述原因之外,就是我们在前言中提到的,模板是静态的,Vue 可以做针对性的优化,进而利用 AOT 技术,将运行时性能进一步提升。

这个也是为什么 Vue 中有构建出来了不同的版本,详细参见 cn.vuejs.org/v2/guide/in…

总结

通过上边的分析,我们知道在 Vue 中,template到render函数的大概过程,最核心的还是:

  • 解析 HTML 字符串,得到自己定义的 AST
  • 根据 AST,生成最终的 render 函数代码

这个也是编译器做的最核心的事情。

那么我们可以从中学到什么呢?

编译器

编译器,听起来就很高大上了。通过我们上边的分析,也知道了在 Vue 中是如何处理的。

编译器的核心原理和相比较的标准化的过程基本上还是比较成熟的,不管说这里分析和研究的对于 HTML 的解析,然后生成最终的 render 函数代码,还是其他任何的语言,或者是你自己定义的”语言“都是可以的。

想要深入学习的话,最好的就是看编译原理。在社区中,也有一个很出名的项目 github.com/jamiebuilds… 里边有包含了一个”五脏俱全“的编译器,核心只有 200 行代码,里边除了代码之外,注释也是精华,甚至于注释比代码更有用,很值得我们去深入学习和研究,且易于理解。

树的这种数据结构,上述我们通过parse得到的 ast 其实就是一种树状结构,树的应用,基本上随处可见,只要你善于发现。利用他,可以很好的帮助我们进行逻辑抽象,统一处理。

在上述的分析中,我们是多次看到了对于栈的运用,之前在响应式原理中也有提到过,但是在这里是一个十分典型的场景,也可以说是栈这个数据结构的最佳实践之一。

基本上你在社区中很多的框架或者优秀库中,都能看到栈的相关应用的影子,可以说是一个相当有用的一种数据结构。

钩子

我们在 parseHTML 的 options 中看到了钩子的应用,其实不止是这里有用到这种思想。通过 parseHTML 对外暴露的钩子函数 start、end、chars、comment 可以很方便的让使用者钩入到 parseHTML 的执行逻辑当中,相信你也感受到了,这是一种很有简单,但是确实很实用的思想。当然,这种思想本身,也常常和插件化设计方案或者叫微内核的架构设计一起出现;针对于不同的场景,可以有更复杂一些的实现,进而提供更加强大的功能,例如在 webpack 中,底层的 tapable 库,本质也是这种思想的应用。

正则表达式

在整个的parser过程中,我们遇到了很多种使用正则的场景,尤其是在 github.com/vuejs/vue/b… 这里:

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being passed as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/
const encodedAttr = /&(?:lt|gt|quot|amp|#39);/g
const encodedAttrWithNewLines = /&(?:lt|gt|quot|amp|#39|#10|#9);/g

这里边还是包含了很多种正则的使用,也有正则的动态生成。正则本身有简单的,有复杂的,如果你不能很好的理解这里的正则,推荐你去看精通正则表达式这本书,相信看过之后,你会收获很多。

其他小Tips

  • 目录 模块拆分,依旧值得我们好好学习
  • ast 优化操作,虽然上边没有详细分析,但是在源码中还是专门去做了 ast 优化相关的事情的
  • 简单工厂模式的使用 Creator
  • staticRenderFns,作用是啥,为啥会有它
  • 缓存技术的再次利用,提升性能
  • 避免重复处理,各种标记的运用
  • 因为涉及到HTML解析,所以还是有必要了解下 HTML 规范的,以及常规的浏览器解析 HTML 的容错处理,源码中的一些工具也有体现 github.com/vuejs/vue/b…
  • makeMap 的作用,在 Vue 中大量使用

滴滴前端技术团队的团队号已经上线,我们也同步了一定的招聘信息,我们也会持续增加更多职位,有兴趣的同学可以一起聊聊。