从需求出发阅读 Vue 源码之声明式渲染(Vue 2)
一个成熟框架或者工具的源码阅读对于我们来说常常不是一件容易的事情,面对很大的代码量和复杂的逻辑,总有同学在源码阅读的过程中逐渐被“劝退”,所以这个系列的笔记尝试从需求出发阅读源码,试试能不能让这个过程变得没有那么“难受”。
假设你需要自己实现一个类似 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'),
......
}
入口位置 entry
为 web/entry-runtime-with-compiler.js
( web
为别名,真实路径为 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 运算符做了些什么事情:
- 创建一个空对象
- 将该对象的
__proto__
指向构造函数的prototype
- 将构造函数内部的
this
指向该对象并执行构造函数中的代码 - 构造函数如果没有返回对象则将该对象返回
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
和其他参数,其他参数中主要包括 start
、end
、chars
等钩子函数。第 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
方法对文本进行解析,传入参数有 text
和 delimiters
,其中 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
}
}
在执行 updateComponent
时 vm._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.$el
用 emptyNodeAt
方法也转成虚拟节点 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.createElement
将 vnode.elm
设置为 document.createElement(vnode.tag)
,接着会先利用递归去处理子节点,并以每次递归传入的 vnode.elm
作为父节点去执行接下来的 insert
方法,insert
方法通过调用 nodeOps.insertBefore
和 nodeOps.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)))
}
}
......
至此,界面完成渲染。