从需求出发阅读 Vue 源码之声明式渲染(Vue 2)

tao
发布于2023-12-18 | 更新于2024-10-19
截屏2024-10-19 21.55.26.png

一个成熟框架或者工具的源码阅读对于我们来说常常不是一件容易的事情,面对很大的代码量和复杂的逻辑,总有同学在源码阅读的过程中逐渐被“劝退”,所以这个系列的笔记尝试从需求出发阅读源码,试试能不能让这个过程变得没有那么“难受”。

假设你需要自己实现一个类似 vue 的工具,你首先面临的一个需求是如何借助这个工具让你能够省去 DOM 操作,在 JS 代码中声明一个变量并在 HTML 代码中以某种方式加入这个变量,对应的值就能自动渲染在页面上,并保证良好的性能。

使用过但没有读过 vue 源码的各位同学在这里可以停一停,如果让你自己去实现这个需求,你会怎么做。

继续阅读请注意:下文中出现的代码全部为便于说明的伪代码,不可运行;同时为了关注点集中,使用了 “......” 表示被隐藏的代码。

创建测试环境

这里我选择使用 vue@2.6.14 版本,将源代码克隆下来之后在项目目录执行 yarn 命令安装项目依赖,然后执行 yarn dev 命令启动开发环境,这步操作实际上是在项目目录下面执行了:

rollup -w -c scripts/config.js --environment TARGET:web-full-dev

其中 -w 参数监听源文件改动,在源文件被修改时重新打包 vue.js 文件到 dist 目录,-c 参数指定配置文件,--environment 设置环境变量,配置文件中根据环境变量 TARGET 分出不同的配置项打包出不同的 vue 包,web-full-dev 在 script/config.js 对应的配置项如下:

// Runtime+compiler development build (Browser)
'web-full-dev': {
  entry: resolve('web/entry-runtime-with-compiler.js'),
  dest: resolve('dist/vue.js'),
  ......
}

入口位置 entryweb/entry-runtime-with-compiler.jsweb 为别名,真实路径为 src/platforms/web/entry-runtime-with-compiler.js),还有 dest 出口位置 dist/vue.js。

在 examples 文件夹里面创建测试页面 test.html,并且通过 script 标签引入刚刚打包出来的 dist/vue.js,这样就可以用浏览器直接打开这个页面进行测试,只要源码发生改变就会重新打包 vue.js,刷新页面就可以重新加载新的 dist/vue.js。

在 test.html 中使用 vue 编写下面这段代码就可以实现文章开头所描述的需求,之后对源码的解读会尽量围绕这个需求点,避免陷入太多其他分支。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Vue</title>
    <script src="../../dist/vue.js"></script>
  </head>
  <body>
    <div id="app">
      <div>{{ message }}</div>
    </div>
  </body>

  <script>
    new Vue({
      el: '#app',
      data: {
        message: 'hello vue!',
      }
    })
  </script>
</html>

Vue 构造函数的定义

根据 vue 官方文档,打包出来的 vue.js 执行后会定义一个全局构造函数 Vue,在创建 Vue 实例时构造函数的参数选项 el 将作为挂载目标,它的值可以是 CSS 选择器字符串,也可以是一个 HTMLElement 实例。

打开入口文件 src/platforms/web/entry-runtime-with-compiler.js,第 7 行从 src/platforms/web/runtime/index.js 中导入了 Vue,第 18 行将 Vue 原型上与挂载相关的方法 $mount 进行了修改,最后一行导出了 Vue

......
import Vue from './runtime/index'
......
Vue.prototype.$mount = function () { ...... }
......
export default Vue

继续打开 src/platforms/web/runtime/index.js,第 3 行从 src/core/index.js(core 是 src/core 路径的别名)导入了 Vue,第 37 行在 Vue 的原型上定义了与挂载相关的 $mount 方法,最后一行导出了 Vue

......
import Vue from 'core/index'
......
Vue.prototype.$mount = function () { ...... }
......
export default Vue

我们继续打开 src/core/index.js,第 1 行从 src/core/instance/index.js 导入了 Vue,最后一行导出了 Vue

import Vue from './instance/index'
......
export default Vue

继续打开 src/core/instance/index.js,第 8 行对 Vue 构造函数做了定义,第 14 行执行了 this._init(options),最后一行导出了 Vue

......
function Vue (options) {
  ......
  this._init(options)
}
......
export default Vue

这里插入一个小插曲,回顾一下 new 运算符做了些什么事情:

  1. 创建一个空对象
  2. 将该对象的 __proto__ 指向构造函数的 prototype
  3. 将构造函数内部的 this 指向该对象并执行构造函数中的代码
  4. 构造函数如果没有返回对象则将该对象返回

vue 就是利用 new 运算符特性,将 Vue 构造函数作为参数传递给各种函数,从而丰富构造函数的原型方法及属性。

import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
......

function Vue (options) {
  ......
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

_init 就是通过 src/core/instance/index.js 第 15 行的 initMixin 方法定义在 Vue.prototype 也就是 Vue 构造函数原型上而后在构造函数中使用 this 关键字进行调用执行,这种方法在后文中还会多次遇到。

......
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    ......
      vm.$options = ...... options ......
    ......
    if (vm.$options.el) {
      // 执行了 $mount 方法
      vm.$mount(vm.$options.el)
    }
  }
}
......

最后梳理一下路线,当我们沿着依赖路线从里层走到外层时,Vue 从定义到导出分别经过了 core/instance/index -> core/index -> web/runtime/index -> entry-runtime-with-compiler,也就是核心实例 -> 核心 -> 运行时 -> 编译时的一个过程。

其中我们注意到,src/platforms/web/runtime/index.js 定义了 $mount 方法,src/platforms/web/entry-runtime-with-compiler.js 修改了 $mount 方法,src/core/instance/index.js 执行了 $mount 方法。

获取挂载目标 HTML

打开 src/platforms/web/entry-runtime-with-compiler.js,这里经过修改的 $mount 方法在第 57 行获取到挂载目标的 HTML 并赋值给 template 变量,打印 template 变量值,输出 "<div id="app">\n <div>{{ message }}</div>\n </div>",与我们在 examples/test.html 中编写的一致,这样我们就获取到了挂载目标 HTML。

......
import { query } from './util/index'
/*
function query (el: string | Element): Element {
  if (typeof el === 'string') {
    const selected = document.querySelector(el)
    if (!selected) {
      ......
      return document.createElement('div')
    }
    return selected
  } else {
    return el
  }
}
*/
......
Vue.prototype.$mount = function (
  el?: string | Element,
  ......
): Component {
  el = el && query(el)
  ......
      template = getOuterHTML(el)
      /* 
      console.log(template)

      输出:
      <div id="app">
        <div>{{ message }}</div>
      </div>
      */
  ......
}
......
function getOuterHTML (el: Element): string {
  if (el.outerHTML) {
    return el.outerHTML
  } else {
    const container = document.createElement('div')
    container.appendChild(el.cloneNode(true))
    return container.innerHTML
  }
}
.....

生成 AST

AST(Abstract Syntax Tree) 抽象语法树,是源代码的抽象语法结构的树状表现形式,得到挂载目标的 HTML 之后 vue 首先将其转换成 AST。

compileToFunctions 函数

compileToFunctions 函数是生成 AST 的一个重要函数,所以我们从这个函数开始。

src/platforms/web/entry-runtime-with-compiler.js 第 9 行从 src/platforms/web/compiler/index.js 导入了 compileToFunctions 函数,第 65 行将 template 作为参数传入了 compileToFunctions 函数。

......
import { compileToFunctions } from './compiler/index'
......
    const { ...... } = compileToFunctions(template, {
      ......
    }, this)
......

打开 src/platforms/web/compiler/index.js,第 4 行从 src/compiler/index.js(compiler 是 src/compiler 的别名)导入了 createCompiler 函数,第 6 行向 createCompiler 函数传入 baseOptions 参数并执行,执行成功后返回了 compileToFunctions 函数,同时返回的还有 compile 函数。

import { baseOptions } from './options'
import { createCompiler } from 'compiler/index'

const { compile, compileToFunctions } = createCompiler(baseOptions)

export { compile, compileToFunctions }

打开 src/compiler/index.js,第 6 行从 src/compiler/create-compiler.js 导入了 createCompilerCreator 方法,第 11 行向 createCompilerCreator 函数中传入 baseCompile 方法并执行,执行成功后的返回结果赋值给了 createCompiler 并导出。

......
import { createCompilerCreator } from './create-compiler'

export const createCompiler = createCompilerCreator(function baseCompile (
  ......
): CompiledResult {
  ......
})

打开 src/compiler/create-compiler.js,第 7 行定义并导出了 createCompilerCreator,这里是函数柯里化的应用(把接受多个参数的函数变换成接受单一参数的函数,返回接受余下参数且返回结果的新函数),这样 createCompilerCreator 就可以固定 baseCompile 参数,返回接受 baseOptions 参数的新函数 createCompiler

在这里也能看到,执行 createCompiler 函数最终得到的两个函数中 ,compileToFunctions 是在 compile 的基础上通过 createCompileToFunctionFn 函数进行的转换。

......
import { createCompileToFunctionFn } from './to-function'
......
export function createCompilerCreator (baseCompile: Function): Function {
  return function createCompiler (baseOptions: CompilerOptions) {
    function compile (
      template: string,
      options?: CompilerOptions
    ): CompiledResult {
      ......
    }

    return {
      compile,
      compileToFunctions: createCompileToFunctionFn(compile)
    }
  }
}

梳理一下,src/compiler/create-compiler.js -> src/compiler/index.js -> src/platforms/web/compiler/index.js 就是函数柯里化 -> 固定 baseCompile 参数 -> 传入 baseOptions 得到 compileToFunctions 的过程,AST 就是在这个过程中生成的。

解析 HTML

打开 src/compiler/create-compiler.js,第 61 行执行了 baseCompile 方法。

......
      const compiled = baseCompile(template.trim(), ......)
......

打开 src/compiler/index.js,第 3 行从 src/compiler/parser/index.js 导入了 parse 函数,第 15 行调用 parse 方法生成了 AST:

import { parse } from './parser/index'
......
  const ast = parse(template.trim(), ......)
......

打开 src/compiler/parser/index.js,第 79 行定义并导出了 parse 方法,在 parse 方法中执行了 parseHTML 方法(第 4 行从 src/compiler/parser/html-parse.js 导入,第 208 行执行),向 parseHTML 方法中传入了 template 和其他参数,其他参数中主要包括 startendchars 等钩子函数。第 117 行定义了 closeElement 方法用于对解析完成的标签做处理。第 60 行定义了 createASTElement 方法用于生成 AST element。

......
import { parseHTML } from './html-parser'
......
export function createASTElement (
  tag: string,
  attrs: Array<ASTAttr>,
  ......
): ASTElement {
  return {
    type: 1,
    tag,
    attrsList: attrs,
    ......
  }
}

export function parse (
  template: string,
  ......
): ASTElement | void {
  ......
  let root
  ......
  function closeElement (......) {
    ......
  }
  ......
  parseHTML(template, {
    ......
    start (tag, attrs, ......) {
      ......
      let element: ASTElement = createASTElement(tag, attrs, ......)
      ......
      if (!root) {
        root = element
      }
    },
    end () {
      ......
      closeElement(......)
    },
    chars () {
      ......
    },
    ......
  })
  return root
}
......

打开 src/compiler/parser/html-parse.js,第 54 行定义并导出了 parseHTML 方法,第 61 行 parseHTML 方法里面的一个 while 循环实现了逐步解析 HTML:

......
export const isPlainTextElement = makeMap('script,style,textarea', true)
/*
function makeMap (
  str: string,
  expectsLowerCase?: boolean
): (key: string) => true | void {
  const map = Object.create(null)
  const list: Array<string> = str.split(',')
  for (let i = 0; i < list.length; i++) {
    map[list[i]] = true
  }
  return expectsLowerCase
    ? val => map[val.toLowerCase()]
    : val => map[val]
}
*/
......
export function parseHTML (html, ......) {
  // 堆栈
  const stack = []
  ......
  // 记录匹配位置
  let index = 0
  let last, lastTag
  while (html) {
    last = html
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      if (textEnd === 0) {
        ......
        // End tag:
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          const curIndex = index
          advance(endTagMatch[0].length)
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }

        // Start tag:
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          handleStartTag(startTagMatch)
          ......
          continue
        }
      }
    }
    ......
  }
  ......
}

while 循环中首先会匹配到开始标签,执行第 107 行的 parseStartTag 方法拿到匹配信息。

......
import { unicodeRegExp } from 'core/util/lang'
// const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
......
const attribute = /^\s*([^\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*(\/?)>/
......
  function parseStartTag () {
    // 使用正则 startTagOpen 匹配开始标记 "<div"
    const start = html.match(startTagOpen)
    if (start) {
      const match = {
        // 捕获组捕获到标签名
        tagName: start[1],
        attrs: [],
        start: index
      }
      // 更新 index 并从 html 中截掉匹配到的字符 "<div"
      advance(start[0].length)
      let end, attr
      // 使用正则匹配结束标记 ">"
      // 如果匹配到则结束 while 循环
      // 如果没有匹配到结束标记则循环匹配属性
      while (!(end = html.match(startTagClose)) && (attr = ...... html.match(attribute))) {
        attr.start = index
        advance(attr[0].length)
        attr.end = index
        match.attrs.push(attr)
      }
      if (end) {
        ......
        advance(end[0].length)
        match.end = index
        // 返回匹配信息
        return match
      }
    }
  }
......

parseStartTag 函数执行完成后 startTagMatch的值为:

{
  "tagName": "div",
  "attrs": [
    [
      " id=\"app\"",
      "id",
      "=",
      "app",
      null,
      null
    ]
  ],
  ......
}

接下来第 109 行使用 handleStartTag 方法处理 startTagMatch 匹配,在堆栈 stack 中压入匹配信息,并调用前面提到的 parseHTML 函数参数中的 start 钩子函数。

......
  function handleStartTag (match) {
    const tagName = match.tagName
    ......
    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] || ''
      ......
      attrs[i] = {
        name: args[1],
        value: ...... value ......
      }
      ......
    }

    ......
      // 入栈
      stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, ......})
      lastTag = tagName
    ......

    if (options.start) {
      // 调用钩子函数
      options.start(tagName, attrs, ......)
    }
  }
......

打开 src/compiler/parser/index.js,第 217 行 start 函数主要是对刚刚匹配到的标签生成 AST element,并将 root 指向 element,将 currentParent 指向 element,将 element 添加到堆栈 stack 当中。

// 第 60 行
export function createASTElement (
  tag: string,
  attrs: Array<ASTAttr>,
  parent: ASTElement | void
): ASTElement {
  return {
    type: 1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    rawAttrsMap: {},
    parent,
    children: []
  }
}

// 第 101 行
const stack = []

// 第 104 行
let root
let currentParent

// 第 217 行
start (tag, attrs, unary, start, end) {
  ......

  let element: ASTElement = createASTElement(tag, attrs, currentParent)

  ......

  if (!root) {
    root = element
    ......
  }

  if (!unary) {
    // 修改 currentParent 指向
    currentParent = element
    // 入栈
    stack.push(element)
  } else {
    ......
  }
}

注意:存在两个 stack,一个在 src/compiler/parser/html-parse.js 中定义,另一个在 src/compiler/parser/index.js 中定义,前者的 stack 存储着 match 信息,后者的 stack 存储着 AST element 信息。

处理完第一个标签之后 html 变为:


  <div>{{ message }}</div>
</div>

在 src/compiler/parser/html-parse.js 第 54 行 parseHTML 方法中继续 while 循环会碰到 "\n " 一个由换行符和空格字符组成的字符串,因为匹配到第一个 "<" 的位置不为 0,因此直接跳转到第 118 行,这里会将这串字符串截取出来并调用 char 钩子函数。

let text, rest, next
if (textEnd >= 0) {
  rest = html.slice(textEnd)
  // 当 html 其他部分都没有匹配到结束标签、开始标签、注释或者条件注释
  // 继续向前匹配
  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) {
  // 调用前面提到的 chars 钩子函数
  options.chars(text, index - text.length, index)
}

打开 src/compiler/parser/index.js,第 341 行判断如果 text.trim() 为空则判断 children.length 是否为 0,是的话则直接将 text 置为空并且不保留空格字符,注意trim 方法也用于删除字符串中的换行符,如果 text 为空则后续则不再执行任何操作。

chars (text: string, start: number, end: number) {
  ......
  const children = currentParent.children
  ......
  if (...... text.trim()) {
    text = isTextTag(currentParent) ? text : decodeHTMLCached(text)
  } else if (!children.length) {
    // text 置为空
    // remove the whitespace-only node right after an opening tag
    text = ''
  }
  ......
}
......
// for script (e.g. type="x/template") or style, do not decode content
function isTextTag (el): boolean {
  return el.tag === 'script' || el.tag === 'style'
}

这里将 "\n " 清除后 html 变为:

<div>{{ message }}</div>
</div>

继续在 src/compiler/parser/html-parse.js 第 54 行 parseHTML 方法中执行 while 循环,像解析第一个标签那样走到第 107 行的 parseStartTag 方法,执行完成后的 startTagMatch 的值为:

{
    "tagName": "div",
    "attrs": [],
    "start": 21,
    "unarySlash": "",
    "end": 26
}

用 handleStartTag 方法处理 startTagMatch 匹配,此时堆栈 stack 中会再次压入一条匹配信息。之后调用 src/compiler/parser/index.js 第 217 行的 start 函数对刚刚匹配到的标签生成 AST element,并将 currentParent 指向新生成的 element,将新生成的 element 添加到堆栈 stack 当中。处理完后 html 就变成了:

{{ message }}</div>
</div>

接下来处理 "{{ message }}" 字符串,仍然是在 src/compiler/parser/html-parse.js 第 118 行将字符串截取出来并调用 chars 钩子函数,不同的是 “{{ }}” 是模板语法需要特殊处理。在 src/compiler/parser/index.js 第 341 行对普通文本用 decodeHTMLCached 方法进行解码并缓存,缓存使用了闭包的技巧,感兴趣的同学可以自行继续了解 decodeHTMLCached 方法。

// 第 3 行
import he from 'he'

// 第 8 行
import { ...... cached ...... } from 'shared/util'

// 第 45 行
const decodeHTMLCached = cached(he.decode)

// 第 341 行
if (inPre || text.trim()) {
  // 这里可以直接看作 text = "{{ message }}"
  text = isTextTag(currentParent) ? text : decodeHTMLCached(text)
}

// 第 933 行
// for script (e.g. type="x/template") or style, do not decode content
function isTextTag (el): boolean {
  return el.tag === 'script' || el.tag === 'style'
}

第 357 行判断 text 存在之后开始创建 AST node,第 364 行用 parseText 方法对文本进行解析,传入参数有 textdelimiters,其中 delimiters 是我们在创建 Vue 实例时就可以设置的参数,默认为 ["{{", "}}"],你也可以将其设置为 ['${', '}'],这样我们的模板字符就得写成 ${message} 的形式而不是现在的 {{ message }}

// 第 5 行
import { parseText } from './text-parser'

// 第 99 行
delimiters = options.delimiters

// 第 357 行
if (text) {
  ......
  let res
  let child: ?ASTNode
  if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
    child = {
      type: 2,
      expression: res.expression,
      tokens: res.tokens,
      text
    }
  }
  ......
}

打开 src/compiler/parser/text-parse.js,第 20 行定义并导出了 parseText 方法。

export function parseText (
  text: string,
  delimiters?: [string, string]
): TextParseResult | void {
  // 这里直接理解为
  // const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
  const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
  if (!tagRE.test(text)) {
    // 没有匹配到则返回,text 当作普通字符串处理
    return
  }
  const tokens = []
  const rawTokens = []
  let lastIndex = tagRE.lastIndex = 0
  let match, index, tokenValue
  // 只要还能匹配到就继续 while 循环
  while ((match = tagRE.exec(text))) {
    index = match.index
    // push text token
    if (index > lastIndex) {
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // tag token
    // 这里直接理解为 exp = match[1].trim()
    const exp = parseFilters(match[1].trim())
    // 匹配到的 message 传入函数 _s 执行的字符串形式添加到 tokens 数组中
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    lastIndex = index + match[0].length
  }
  if (lastIndex < text.length) {
    // 如果匹配后还有字符则继续在 tokens 数组中添加
    rawTokens.push(tokenValue = text.slice(lastIndex))
    tokens.push(JSON.stringify(tokenValue))
  }
  // 返回处理结果
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

经过以上处理,最终 AST node 是下面这样:

{
    "type": 2,
    "expression": "_s(message)",
    "tokens": [
        {
            "@binding": "message"
        }
    ],
    "text": "{{ message }}"
}

最后在 src/compiler/parser/index.js 第 382 行,将 AST node 添加到 currentParent 的 children 属性中。

......
children.push(child)
......

现在 html 变成了这样:

</div>
</div>

这也就意味着到了收尾阶段,我们需要处理剩下的结束标签。在 src/compiler/parser/html-parse.js 第 54 行 parseHTML 方法中继续执行 while 循环,匹配到结束标签后第 98 行拿到 endTagMatch 的值然后执行 parseEndTag 方法。

......
const endTagMatch = html.match(endTag)
if (endTagMatch) {
  const curIndex = index
  advance(endTagMatch[0].length)
  parseEndTag(endTagMatch[1], curIndex, index)
  continue
}
......

endTagMatch 值是这样:

[
    "</div>",
    "div"
]

第 255 行定义了 parseEndTag 方法,主要作用是找到堆栈 stack 中与结束标签相同的标签并记下位置,然后遍历堆栈中的标签,大于这个位置的标签依次作为参数调用 end 钩子函数,调用完成后从堆栈中删除。

function parseEndTag (tagName, start, end) {
  let pos, lowerCasedTagName
  ......

  // 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 (options.end) {
        options.end(stack[i].tag, start, end)
      }
    }

    // Remove the open elements from the stack
    stack.length = pos
    lastTag = pos && stack[pos - 1].tag
  }
  ......
}

在 src/compiler/parser/index.js 第 304 行 end 钩子函数从堆栈 stack 中取出最后一个元素,然后完成元素出栈,把 currentParent 指向最后一个元素。最后以出栈的元素作为参数执行 closeElement 方法。

end (tag, start, end) {
  const element = stack[stack.length - 1]
  // pop stack
  stack.length -= 1
  currentParent = stack[stack.length - 1]
  ......
  closeElement(element)
}

第 117 行的 closeElement 方法主要对出栈的 element 作最后的处理,将 element 添加进 currentParent 的 children 数组中,并引用 currentParent 为 element 的 parent 属性。

function closeElement(element) {
  ......
  if (currentParent ......) {
    currentParent.children.push(element)
    element.parent = currentParent
  }
  ......
}

这样经过两次调用 end 钩子函数和清除空白符的操作,整个的 html 也结束解析了,最终得到的 AST 就是下面这样(有部分属性未列出):

{
  tag: "div",
  type: 1,
  parent: undefined,
  attrs: [
    {
      name: "id",
      value: "\"app\""
    }
  ],
  attrsMap: { id: 'app' },
  rawAttrsMap: {
    id: {
      name: "id",
      value: "\"app\""
    }
  },
  children: [
    {
      tag: "div",
      type: 1,
      parent: { type: 1, tag: 'div', ...... },
      attrsList: [],
      attrsMap: {},
      rawAttrsMap: {},
      children: [
        {
          type: 2,
          text: "{{ message }}",
          expression: "_s(message)",
          tokens: [
            {
              '@binding': "message"
            }
          ]
        }
      ]
    }
  ]
}

这里只是解析了这么一小段的 HTML 就花了这么长的篇幅,更不用说将所有情况考虑全面了,编写这个 HTML 解析器的工作量还是很大的。

生成渲染函数

打开 src/compiler/index.js,第 17 行对生成的 AST 进行优化,优化方向主要是标记 static 静态节点,接下来第 20 行是生成渲染函数函数体的过程。

......
import { generate } from './codegen/index'
......
const code = generate(ast, options)
return {
  ast,
  render: code.render,
  ......
}
......

打开 src/compiler/codegen/index.js,第 43 行定义并导出了 generate 方法,第 49 行调用了 genElement 方法。

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

第 56 行定义并导出了 genElement 方法,第 81 行调用 genData 方法合并属性,第 84 行调用 genChildren 方法,如果子元素存在则递归生成 children。最后生成函数体 code 并返回。以下是过程中使用到的主要函数:

export function genElement (el: ASTElement, ......): string {
  ......
      let data
      ......
        data = genData(el, state)
      ......
      
      const children = ...... genChildren(el, ......)
      code = `_c('${el.tag}'${
        data ? `,${data}` : '' // data
      }${
        children ? `,${children}` : '' // children
      })`
  ......
  return code
}

export function genData (el: ASTElement, ......): string {
  let data = '{'
  ......
  if (el.attrs) {
    data += `attrs:${genProps(el.attrs)},`
  }
  ......
  data = data.replace(/,$/, '') + '}'
  ......
  return data
}

function genProps (props: Array<ASTAttr>): string {
  let staticProps = ``

  for (let i = 0; i < props.length; i++) {
    const prop = props[i]
    ......
      staticProps += `"${prop.name}":${value},`
    ......
  }
  staticProps = `{${staticProps.slice(0, -1)}}`
  ......
    return staticProps
  ......
}

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

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

export function genText (text: ASTText | ASTExpression): string {
  return `_v(${text.type === 2
    ? text.expression // no need for () because already wrapped in _s()
    : transformSpecialNewlines(JSON.stringify(text.text))
  })`
}

经过以上过程生成渲染函数的主体:

`_c('div',{attrs:{"id":"app"}},[_c('div',[_v(_s(message))])])`

最后返回 render 属性时用 with 扩展了作用域链,所以 render 属性是这样:

`with(this){return _c('div',{attrs:{"id":"app"}},[_c('div',[_v(_s(message))])])}`

回到 src/platforms/web/entry-runtime-with-compiler.js 文件第 65 行,程序调用了 compileToFunctions 函数生成了 render 并将其赋值给了 options.render

......
      const { render, ...... } = compileToFunctions(template, {
        ......
      }, this)
      options.render = render
......

前文中提到过 compileToFunctions 是在 compile 的基础上通过 createCompileToFunctionFn 函数进行的转换。打开 src/compiler/to-function.js,第 93 行通过调用 createFunction 方法对 compile 函数生成的 render 进行了处理,将其从字符串转换成了可执行函数。

......
function createFunction (code, ......) {
  try {
    return new Function(code)
  } catch (err) {
    ......
  }
}
......
export function createCompileToFunctionFn (compile: Function): Function {
......
    // compile
    const compiled = compile(template, options)
......
    res.render = createFunction(compiled.render, ......)
......
}

因此,最终生成的渲染函数为:

function f() {
  with(this){return _c('div',{attrs:{"id":"app"}},[_c('div',[_v(_s(message))])])}
}

生成虚拟节点 vnode

生成渲染函数之后,重新回到 src/platforms/web/entry-runtime-with-compiler.js,执行第 82 行的 mount 方法:

......
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  ......
  return mount.call(this, el, ......)
  ......
)

实际执行的则是在 src/platforms/web/runtime/index.js 第 37 行所定义的函数:

......
import { mountComponent } from 'core/instance/lifecycle'
import { ......, inBrowser } from 'core/util/index'

import {
  query,
  ......
} from 'web/util/index'
......
Vue.prototype.$mount = function (
  el?: string | Element,
  ......
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, ......)
}
......

打开 src/core/instance/lifecycle.js 找到 mountComponent 方法:

......
export function mountComponent (
  vm: Component,
  el: ?Element,
  ......
): Component {
  vm.$el = el
  ......
  let updateComponent
  ......
    updateComponent = () => {
      vm._update(vm._render(), ......)
    }
  ......
  new Watcher(vm, updateComponent, ......)
  ......
  return vm
}

mountComponent 方法中定义了 updateComponent 方法,但是并没有立即执行,而是将它作为参数传递给了 Watcher 类并创建了一个 Watcher 实例。打开 src/core/observer/watcher.js,第 27 行定义了 Watcher 类,constructor 中将 updateComponent 函数赋值给了 this.getter,最后通过调用类中的方法 get 使 updateComponent 得以执行。

......
export default class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    ......
  ) {
    this.vm = vm
    ......
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    }
    ......
    this.value = ...... this.get()
  }

  get () {
    ......
    let value
    ......
    try {
      value = this.getter.call(vm, vm)
    }
    ......
    return value
  }
}

在执行 updateComponentvm._render 方法首先被执行,它在 src/core/instance/render.js 中第 69 行被定义,执行过程中则会读取 vm.$options 上的 render 属性即上文中生成的渲染函数,然后执行它得到虚拟节点 vnode。

......
  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, ...... } = vm.$options
    ......
    let vnode
    try {
      vnode = render.call(vm._renderProxy, vm.$createElement)
    }
    ......
    return vnode
  }
......

vm._renderProxy 在 src/core/instance/proxy.js 中被定义,在判定当前环境支持 Proxy 并处于开发环境时利用代理做一些警告提示,现在我们直接认为 vm._renderProxy = vm 就可以。

vm.$createElement 在 src/core/instance/render.js 中第 34 行被定义,同时渲染函数中的 _c 函数也在这里被定义,这两个函数入参都是一样的,执行时调用的 createElement 函数只在最后一个参数上有区别,以此将 vm.$createElement 函数提供给用户使用,_c 函数则在内部使用。

......
import { createElement } from '../vdom/create-element'
......
export function initRender (vm: Component) {
  ......
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  ......
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
  ......
}
......

打开 src/core/vdom/create-element.js,第 28 行定义并导出了 createElement 函数,第 47 定义了 _createElement 函数,这个函数最终返回了 vnode。

......
export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  ......
  let vnode, ......
  if (typeof tag === 'string') {
    ......
      ......
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
      ......
    ......
  }
  ......

  if (Array.isArray(vnode)) {
    ......
  } else if (isDef(vnode)) {
    ......
    return vnode
  } else {
    ......
  }
}
......

函数体中 VNode 类暂时先不看,因为这时候发现刚刚一直在解读 _c 函数,但渲染函数在执行过程,其实会最先执行最里层的 _s 函数。

function f() {
  with(this){return _c('div',{attrs:{"id":"app"}},[_c('div',[_v(_s(message))])])}
}

打开 src/core/instance/render.js,第 63 行通过执行 installRenderHelpers 方法将 _s 函数添加在 Vue.prototype 上。

......
import { installRenderHelpers } from './render-helpers/index'
......
  installRenderHelpers(Vue.prototype)
......

打开 src/core/instance/render-helper/index.js,第 18 行定义了 _s 函数。

......
import { ......, toString, ...... } from 'shared/util'
......
export function installRenderHelpers (target: any) {
  ......
  target._s = toString
  ......
}
......

_s 方法其实很简单,打开 src/shared/util.js,第 85 行定义了 toString 方法,它其实就是对传入的参数进行了一个转化成字符串的处理。

......
/**
 * Get the raw type string of a value, e.g., [object Object].
 */
const _toString = Object.prototype.toString
......
/**
 * Strict object type check. Only returns true
 * for plain JavaScript objects.
 */
export function isPlainObject (obj: any): boolean {
  return _toString.call(obj) === '[object Object]'
}
......
/**
 * Convert a value to a string that is actually rendered.
 */
export function toString (val: any): string {
  return val == null
    ? ''
    : Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)
      ? JSON.stringify(val, null, 2)
      : String(val)
}
......

令人疑惑的是,渲染函数中 _s 函数的参数 message 是什么时候绑定到 this 也就是 Vue 实例上的呢?打开 src/core/instance/state.js,第 113 行 initData 函数做了这个事情。

首先如果 vm.$options.data 属性是一个函数则通过 getData 方法执行它拿到 data 对象,如果这个函数不是箭头函数,那么通过 call 就可以改变他的执行作用域从而在初始化对象属性值就能拿到正确的 this 值,如果是箭头函数也能通过传入的 vm 参数来替代 this。最后 data 对象还会赋值给 vm._data 属性。

然后遍历 data 对象,通过 proxy 方法,在 vm 上通过 Object.defineProperty 的方式添加所有 data 对象的属性,并且每个属性的 get 方法返回的都是 vm._data 对应的属性值,同时 set 方法也是设置 vm._data 对应的属性值。这样就完成了绑定,也就是说通过 this.message 属性就可以访问到 data 对象的值。

......
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
......
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  ......
  // proxy data on instance
  const keys = Object.keys(data)
  ......
  let i = keys.length
  while (i--) {
    const key = keys[i]
    ......
      proxy(vm, `_data`, key)
    ......
  }
  ......
}
......
export function getData (data: Function, vm: Component): any {
  ......
    return data.call(vm, vm)
  ......
}
......

那么这时候渲染函数就可以简化为:

function f() {
  with(this){return _c('div',{attrs:{"id":"app"}},[_c('div',[_v('hello vue')])])}
}

接下来打开 src/core/instance/render-helper/index.js,第 27 行定义了 _v 函数,其实就是创建文本节点函数。

......
import { createTextVNode, ...... } from 'core/vdom/vnode'
......
export function installRenderHelpers (target: any) {
  ......
  target._v = createTextVNode
  ......
}

打开 src/core/vdom/vnode.js,第 81 行定义了 createTextVNode 函数,同时可以看到第 3 行定义了 VNode 类。

......
export default class VNode {
  ......
  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    ....
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    ......
}
......
export function createTextVNode (val: string | number) {
  return new VNode(undefined, undefined, undefined, String(val))
}
......

这样通过执行 _v 函数返回的 vnode 就是下面这样。

{
    "text": "hello vue!",
    ......
}

这时候回到 src/core/vdom/create-element.js 第 28 行定义的 createElement 函数,第 47 定义的 _createElement 函数,通过执行 _c('div',[VNode{ "text": "hello vue!", ...... }]) 可以得到下面这样的 vnode 对象。

{
    "tag": "div",
    "children": [
      {
        "text": "hello vue!",
        ......
      }
    ]
    ......
}

到现在渲染函数就剩下最外层的 _c 函数没有执行了,比起上一次执行 _c 函数这次有 data 参数会传入进去,至此,最终生成的虚拟节点 vnode 就是下面这样。

{
    "tag": "div",
    "data": {
      "attrs": {
        "id": "app"
      }
    },
    "children": [
      {
        "tag": "div",
        "children": [
          {
            "text": "hello vue!",
            ......
          }
        ]
        ......
      }
    ]
    ......
}

渲染到页面

回到 src/core/instance/lifecycle.js,上面我们已经将第 190 行 vm._render 函数执行完成得到了虚拟节点 vnode,接下来呢就是把 vnode 作为参数执行 vm._update 函数。

......
      vm._update(vm._render(), ......)
......

还是同一个文件第 59 行定义了 vm._update 函数,其中主要执行了 vm.__patch__ 函数将 vnode 渲染在页面上。

......
  Vue.prototype._update = function (vnode: VNode, ......) {
    const vm: Component = this
    ......
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    ......
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, ......)
    ......
  }
......

参考注释信息,可以得知 Vue.prototype.__patch__ 定义在入口位置基于渲染后端去使用,所以我们打开 src/platforms/web/runtime/index.js,第 34 行定义了 Vue.prototype.__patch__,因为我们是在浏览器端,所以直接查看 patch 函数。

......
import { patch } from './patch'
......
// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

打开 src/platforms/web/runtime/patch.js,第 12 行定义并导出了 patch 函数,并且在 createPatchFunction 函数中传入了相关的节点操作方法 nodeOps 和模块方法 modules,后面如果使用到可以随时回去翻看参考。

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })

打开 src/core/vdom/patch.js,第 700 行返回了函数 patch,在 patch 函数中会把一开始直接使用 document.querySelector 方法拿到的 vm.$elemptyNodeAt 方法也转成虚拟节点 vnode,接着尝试去用 nodeOps.parentNode 去获取它的父节点,随后执行 createElm 函数。

......
export function createPatchFunction (backend) {
  ......
  function emptyNodeAt (elm) {
    return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
  }
  ......
  return function patch (oldVnode, vnode, ......) {
    ......
      const isRealElement = isDef(oldVnode.nodeType)
      ......
        if (isRealElement) {
          ......
          oldVnode = emptyNodeAt(oldVnode)
        }

        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // create new node
        createElm(
          vnode,
          ......
          ...... parentElm,
          nodeOps.nextSibling(oldElm)
        )
        ......
      ......
    return vnode.elm
  }
}
......

现在打开的文件中第 125 行定义了 createElm 函数,如果 vnode.tag 有定义,那么就调用 nodeOps.createElementvnode.elm 设置为 document.createElement(vnode.tag),接着会先利用递归去处理子节点,并以每次递归传入的 vnode.elm 作为父节点去执行接下来的 insert 方法,insert 方法通过调用 nodeOps.insertBeforenodeOps.appendChild 完成页面的渲染。

......
  function createElm (
    vnode,
    ......
    parentElm,
    refElm,
    ......
  ) {
    ......
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    ......
    if (isDef(tag)) {
      vnode.elm = ...... nodeOps.createElement(tag, vnode)
      ......
        createChildren(vnode, children, ......)
        ......
        insert(parentElm, vnode.elm, refElm)
      ......
    }
      ......
    } else {
      vnode.elm = nodeOps.createTextNode(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    }
  }
......
  function insert (parent, elm, ref) {
    if (isDef(parent)) {
      if (isDef(ref)) {
        if (nodeOps.parentNode(ref) === parent) {
          nodeOps.insertBefore(parent, elm, ref)
        }
      } else {
        nodeOps.appendChild(parent, elm)
      }
    }
  }

  function createChildren (vnode, children, ......) {
    if (Array.isArray(children)) {
      ......
      for (let i = 0; i < children.length; ++i) {
        createElm(children[i], ......, vnode.elm, null, true, children, i)
      }
    } else if (isPrimitive(vnode.text)) {
      nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
    }
  }
......

至此,界面完成渲染。