我们对 Vue 中的虚拟 DOM 都比较了解,知道虚拟 DOM 是通过执行 render 函数获取到 vnode,然后执行 patch 方法,将 vnode 渲染成真实的 DOM。
那么,Vue 是如何获取到 render 函数的呢?获取的途径有两个:
- 我们可以直接在组件的 render 选项上直接写 render 函数,这样 Vue 内部直接从 render 选项上获取 render 函数即可,这部分可以直接看官方文档。
- Vue 可以通过模板编译将模板字符串(template)编译成 render 函数,模板字符串我们可以通过 template 选项传给 Vue,该选项可以看对应的官方文档。如果是在 Vue 脚手架构建出的项目,这个模板字符串则是 .vue 文件中 <template> 标签中的内容。
1,模板编译在整个渲染过程中的位置
每个组件的模板字符串都会被编译成 render 函数,获取到 render 函数之后,就可以结合当前的状态执行获取到最新的 vnode,然后以新旧 vnode 为参数执行 patch 方法对页面进行重新渲染。
对状态进行监控,执行 redner 函数获取最新的 vnode,以新旧 vnode 为参数执行 patch 方法的源码如下所示:
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
// 一个更新渲染组件的方法
let updateComponent = () => {
// vm._render() 函数的执行结果是一个 VNode
// vm._update() 函数执行虚拟 DOM 的 patch 方法来执行节点的比对与渲染操作
vm._update(vm._render(), hydrating)
}
// 这里的 Watcher 实例是一个渲染 Watcher,组件级别的
vm._watcher = new Watcher(vm, updateComponent, noop)
}
?执行 render 函数获取最新的 vnode,然后执行 update 方法。
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
// 上一次渲染时的 VNode
// 第一次渲染时,为空
const prevVnode = vm._vnode
vm._vnode = vnode
if (!prevVnode) {
// 首次渲染
vm.$el = vm.__patch__(
vm.$el, vnode, hydrating, false /* removeOnly */,
vm.$options._parentElm,
vm.$options._refElm
)
} else {
// 依赖的数据更新时,页面需要重新渲染
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
update 方法首先获取到新旧 vnode,然后调用 patch 方法进行页面的重新渲染。注意如果 prevVnode 为 null 的话,说明此次渲染是首次渲染。
2,模板编译的整体流程
模板编译是一个比较复杂的过程,不可能直接从模板字符串编译成 render 函数,整个编译过程被细分成了三个步骤:
- 将模板字符串解析成 AST(抽象语法树)
- 遍历抽象语法树,标记静态几点
- 使用抽象语法树生成 redner 函数
这三个步骤在 Vue 中,有专门的方法进行处理,分别被称为:
- 解析器(parse)
- 优化器(optimize)
- 代码生成器(generate)
编译的整体流程如下图所示:
?2-1,解析器
解析器的作用是将模板字符串解析成抽象语法树,这个抽象语法树并不是什么特别神奇的东西,它只不过是一个能够描述模板字符串内容的相互嵌套的对象字面量。
解析器一个很大的作用在我看来就是:将程序员所编写的各不相同的模板转换成了一个统一的、规范的中间介质,无论程序员所编写的模板是什么样子的,风格如何,最后都会被解析器解析成统一的、规范的对象字面量,这种统一化的处理是接下来进行优化器、代码生成器处理的前提。
解析器的内部可以分成很多小解析器,用于处理 Vue 中不同的特性,例如:HTML解析器、过滤器解析器、文本解析器、属性解析器、v-for 解析器、v-if 解析器等等,每个解析器都被封装到了对应的函数中。
在上面所说的众多解析器中,最为核心的就是 HTML 解析器,因为 HTML 解析器是整个解析过程的主线,在整个解析的过程中,就是使用 HTML 解析器不断的解析模板字符串,每解析完一段模板字符串,就会将这段模板字符串截取掉,直至模板字符串被解析成空字符串(""),解析器的工作也就完成了。在 HTML 解析器解析的过程中,每当解析到了标签的开始位置、结束位置、文本和注释时,都会触发执行对应的钩子函数,在钩子函数中,进行?AST 节点的生成和整个 AST 树的管理和维护。
解析器的具体细节会在一个单独的博客中详细说明。
2-2,优化器
在页面的众多节点中,有一些节点是静态的,静态的意思是:无论组件的状态怎么改变,该节点渲染的内容都是不变的,例如如下的节点:
<h1>我是静态节点</h1>
这个节点没有使用任何的状态,所以无论状态怎么变,该节点渲染的内容都不会变,正因为该节点渲染的内容不会变,所以我们就很有必要为其打上一个静态的标记,然后在组件每次重新渲染的时候,直接跳过对该静态节点的处理,以此来提高性能。
2-3,代码生成器
最后一步,是通过抽象语法树生成渲染函数的代码字符串,例如有下面的模板字符串:
<h1 class="header">人的信息</h1>
该模板字符串最终生成的代码字符串如下所示:
with (this) {
return _c('h1', {staticClass: "header"}, [_v("人的信息")])
}
代码字符串生成之后,代码生成器的任务就完成了,接下来,可以通过 new Function 将代码字符串转换成真正的渲染函数,看下面的代码:
let renderStr = `with (this) {
return _c('h1', {staticClass: "header"}, [_v("人的信息")])
}`
let renderFun = new Function(renderStr)
render 函数的作用是生成 vnode,vnode 的本质是 JS 中普通的对象字面量,只不过这些对象字面量可以很好地描述真实的 DOM 节点。
那么 render 函数是如何构造出?vnode 的呢?我们可以看看上面 render 函数的具体内容,其内部调用了 _c、_v 之类的函数,这些函数能够构造出 vnode,redner 函数将构造出的 vnode return 出去即可。构造不同类型的 vnode 需要使用不同的方法,例如,上面的 _c 可以构造元素类型的 vnode,而 _v 可以构造文本类型的 vnode。
2-4,以一个简单的例子看各个阶段的产物
假设有如下的模板字符串:
template: `
<div>
<h1>我是静态的</h1>
<h1>名字:{{person.name}}</h1>
</div>
`
其生成的 AST 如下所示:
?最终生成的代码字符串如下所示:
let renderStr = `with (this) {
return _c(
'div',
[ _c('h1', [_v("我是静态的")]),
_v(" "),
_c('h1', [_v("名字:" + _s(person.name))]) ]
)
}`
3,模板编译的源码入口
3-1,src/core/instance/init.js
Vue.prototype._init = function (options?: Object) {
// vm 就是 Vue 的实例对象,在 _init 方法中会对 vm 进行一系列的初始化操作
const vm: Component = this
// 赋值唯一的 id
vm._uid = uid++
// expose real self
vm._self = vm
// 初始化与生命周期有关的内容
initLifecycle(vm)
// 初始化与事件有关的属性以及处理父组件绑定到当前组件的方法。
initEvents(vm)
// 初始化与渲染有关的内容
initRender(vm)
// 在 beforeCreate 回调函数中,访问不到实例中的数据,因为这些数据还没有初始化
// 执行 beforeCreate 生命周期函数
callHook(vm, 'beforeCreate')
// 解析初始化当前组件的 inject
initInjections(vm) // resolve injections before data/props
// 初始化 state,包括 props、methods、data、computed、watch
initState(vm)
// 初始化 provide
initProvide(vm) // resolve provide after data/props
// 在 created 回调函数中,可以访问到实例中的数据
// 执行 created 回调函数
callHook(vm, 'created')
// 如果配置中有 el 的话,则自动执行挂载操作
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
在 _init 函数的最后,如果配置了 el 选项的话,会执行 $mount 挂载操作
3-2,src/platforms/web/entry-runtime-with-compiler.js
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 根据 el 获取其对应的 DOM 元素
el = el && query(el)
if (!options.render) {
let template = options.template
if (template) {
const { render, staticRenderFns } = compileToFunctions(template, {
shouldDecodeNewlines,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
}
}
return mount.call(this, el, hydrating)
}
compileToFunctions 函数的参数是模板字符串和一些编译配置,返回值是编译好的 redner 函数。接下来看看这个?compileToFunctions 函数是如何构建出来的,比较绕。
3-3,src/platforms/web/compiler/index.js
import { baseOptions } from './options'
import { createCompiler } from 'compiler/index'
const { compile, compileToFunctions } = createCompiler(baseOptions)
export { compile, compileToFunctions }
compileToFunctions 是通过?createCompiler 构造出来的,传递的 baseOptions 参数是与编译有关的基础配置,我们知道,Vue 是能够运行到多个平台的,不同平台下的编译配置是不一样的,在这里,利用函数柯里化技巧把适用于当前运行环境的基础编译配置保留在函数中。
接下来,看看?createCompiler 方法的内容。
3-4,src/compiler/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(
// 真正执行编译功能的函数,分为三步走:(1)解析器 ==>(2)优化器 ==>(3)代码生成器
function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
// 1,解析器。将模板字符串转换成抽象语法树
const ast = parse(template.trim(), options)
// 2,优化器。遍历抽象语法树,标记静态节点,
// 因为静态节点是不会变化的,所以重新渲染视图的时候,能够直接跳过静态节点,提升效率。
optimize(ast, options)
// 3,代码生成器。使用抽象语法树生成渲染函数字符串
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
)
从上面的代码可知:createCompiler 函数是?createCompilerCreator 构造出来的,并且传递的参数是 baseCompile 函数,这个 baseCompile 函数就是实现主要编译功能的函数,该函数内部调用了另外三个函数(parse、optimize、generate),分别对应解析器、优化器和代码生成器。
这里使用函数柯里化的技巧实现了模板编译功能的抽离,如果以后模板编译的功能有变化的话,改变?baseCompile 函数的内容即可。
接下来看看?createCompilerCreator 函数的内容。
3-5,src/compiler/create-compiler.js
export function createCompilerCreator (baseCompile: Function): Function {
return function createCompiler (baseOptions: CompilerOptions) {
// 具体的编译过程在 baseCompile 函数中:const compiled = baseCompile(template, finalOptions)
// compile 函数主要是进行了编译配置对象(baseOptions、options)的处理
function compile (
template: string,
options?: CompilerOptions
): CompiledResult {
// 对 baseOptions 和 options 进行一些处理 //
// 最终 baseCompile 函数使用的配置对象,借助 Object.create() 函数创建一个空对象,该对象的原型链指向 baseOptions
const finalOptions = Object.create(baseOptions)
const errors = []
const tips = []
finalOptions.warn = (msg, tip) => {
(tip ? tips : errors).push(msg)
}
// 如果传了 options 的话,再进行合并处理
if (options) {
// 配置对象中的 modules 和 directives 属性,进行合并处理
// merge custom modules
if (options.modules) {
finalOptions.modules =
(baseOptions.modules || []).concat(options.modules)
}
// merge custom directives
if (options.directives) {
finalOptions.directives = extend(
Object.create(baseOptions.directives),
options.directives
)
}
// 除 modules 和 directives 以外的其他属性直接赋值到 finalOptions 中,不用考虑 baseOptions 中相同 key 的配置
// copy other options
for (const key in options) {
if (key !== 'modules' && key !== 'directives') {
finalOptions[key] = options[key]
}
}
}
// 对 baseOptions 和 options 进行一些处理 //
const compiled = baseCompile(template, finalOptions)
if (process.env.NODE_ENV !== 'production') {
errors.push.apply(errors, detectErrors(compiled.ast))
}
compiled.errors = errors
compiled.tips = tips
return compiled
}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
compile 函数也是一个能够实现模板编译功能的函数,其内部调用了 baseCompile 方法实现了模板编译的功能。compile 函数除了模板编译功能外,还做了 baseOptions 和自定义?options 的合并操作,得到最终的?finalOptions。然后以 finalOptions 作为参数执行 baseCompile 方法实现模板编译。
在最后 return 的对象中,我们看到了想找的?compileToFunctions 函数,该函数是通过?createCompileToFunctionFn 方法构建出来的,使用的参数是 compile 方法,这里再一次使用了函数柯里化的技巧。
3-6,src/compiler/to-function.js
export function createCompileToFunctionFn (compile: Function): Function {
const cache: {
[key: string]: CompiledFunctionResult;
} = Object.create(null)
return function compileToFunctions (
template: string,
options?: CompilerOptions,
vm?: Component
): CompiledFunctionResult {
options = extend({}, options)
const warn = options.warn || baseWarn
delete options.warn
// check cache
// 进行缓存的处理,因为模板编译的过程比较耗时,同一个模板没有必要编译两遍
// 缓存的 key。如果定义了 options.delimiters 的话,key 就使用 String(options.delimiters) + template;
// 否则的话,就使用 template
const key = options.delimiters
? String(options.delimiters) + template
: template
if (cache[key]) {
return cache[key]
}
const compiled = compile(template, options)
// turn code into functions
// 当前模板字符串的编译结果对象
const res = {}
// fnGenErrors 数组用于保存 createFunction 函数执行过程中抛出的错误信息,用于下面的错误消息打印
const fnGenErrors = []
// 将 render 代码字符串转换成函数,并保存到 res 对象中
res.render = createFunction(compiled.render, fnGenErrors)
res.staticRenderFns = compiled.staticRenderFns.map(code => {
return createFunction(code, fnGenErrors)
})
// 保存到缓存中,并返回这个 res 对象
return (cache[key] = res)
}
}
?createCompileToFunctionFn 函数返回的函数就是我们要找的目标函数,该函数除了调用 compile 函数实现模板编译的功能外,还做了两件事:
- compile 函数编译出来的内容是代码字符串的形式,在返回的 compileToFunctions 内,通过 new Function 将代码字符串转换成了函数的形式。
- 对处理完成的 redner 函数做缓存处理,提高性能。
3-7,第三小节总结
这一小节的逻辑挺绕的,因为 Vue 并不是直接的写功能代码,而是将很多的逻辑和处理抽离出来,然后利用函数柯里化将它们组装在一起,不同的逻辑写在不同的位置,对维护和升级都很方便,值得我们学习。
4,下集预告
这篇博客主要是对模板编译整体内容做了介绍,相信大家也有了大致的了解。接下来,我会对模板编译的三个步骤(解析器、优化器、代码生成器)做详细的解析,敬请期待。
|