Vue源码阅读笔记之模板渲染(三)
这是”Vue源码阅读笔记”系列第三篇文章。在前面我们分析了Vue响应式数据系统,并了解到当数据变化时会通知变化属性的Watcher,然后更新视图。渲染模板和更新视图的逻辑均由Vue内部封装,我们只需要关注数据的逻辑即可,接下来就让我们学习Vue的模板系统。
参考:
我们知道Vue不仅可以运行在浏览器环境中,也可以通过SSR渲染。因此针对不同的运行环境,输出结果肯定是不一样的。我们从Vue.prototype._init
方法中可以找到相关模板挂载方法的调用
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
在不同的运行环境中,Vue.prototype.$mount
的实现是不一样的,我们从这里入手。
Vue.prototype.$mount
首先我们找到浏览器环境下$mount
的实现
// /src/platforms/entry-runtime-with-compiler.js
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
const options = this.$options
// resolve template/el and convert to render function
if (!options.render) {
let template = options.template
// 这里对template及el参数进行重载,用于获取模板内容
// ...
// 然后对template进行编译
if (template) {
const { render, staticRenderFns } = compileToFunctions(template, {
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
}
}
// 调用公共的mount方法,
// 内部调用的是 /src/core/instance/lifecycle.js中的 mountComponent 方法
return mount.call(this, el, hydrating)
}
先不考虑options.render
的情况,$mount
中做了三件事:
- 找到对应的模板
- 将模板进行解析,并将解析结果
render
和staticRenderFns
挂载到options
上面 - 调用
mountComponent
,并执行相关渲染函数render
从文档:渲染函数 & JSX可以得知,Vue允许我们通过render
配置项替代template
选项,其格式为
(createElement: () => VNode) => VNode
通过createElement
生成VNode
,通过VNode
构建虚拟DOM树。
如果配置参数中存在render
则会跳过第二步,那么先让我们来看看对应的render函数的作用,然后再去分析如果将template
转换成render
函数的。
Render渲染函数
执行流程
首先来看看mountComponent
方法的工作内容
// /src/core/instance/lifecycle.js
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
}
callHook(vm, 'beforeMount')
let updateComponent
updateComponent = () => {
const name = vm._name
const id = vm._uid
// 这里移除了性能测量相关的一些代码
// 可以看见,_render()函数返回的就是VNode节点
const vnode = vm._render()
vm._update(vnode, hydrating)
}
// 实例Watcher对象,绑定响应式数据,这在下面的模板更新会提到
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
hydrating = false
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
可以看见的是,实例化Watcher对象是在这里进行的
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
在前一篇分析响应式原理中了解到,在Watcher构造函数中,如果expOrFn
是函数(这里就是updateComponent
函数),则会将其赋值给watcher.getter
,然后调用一次getter
实现依赖收集。
了解了执行流程,我们知道了在$mount
函数中会调用vm._render
方法,在前面分析Vue原型对象的时候了解到,
vm._render()
即Vue.prototype._render
在lifecycleMixin
实现vm._update
在renderMixin
中实现,这里面主要实现diff,我们暂时放在一边
// /src/core/instance/render.js
Vue.prototype._render = function (): VNode {
const vm: Component = this
// 没错,终于找到了配置参数上面的render函数,也就是template编译后生成的render函数
const { render, _parentVnode } = vm.$options
// 这里移除了$slots相关处理代码
vm.$vnode = _parentVnode
// 这里移除了渲染错误相关处理代码
let vnode
try {
// render函数在这里被调用
// 其中vm._renderProxy在 /src/core/instance/proxy.js中定义
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
handleError(e, vm, `render`)
}
vnode.parent = _parentVnode
return vnode
}
最后,我们终于找到了调用render
的地方
vnode = render.call(vm._renderProxy, vm.$createElement)
不论是通过配置参数传入的render
函数直接,还是通过template
编译后得到的render函数,最后都会在这里被调用,并返回对应的VNode,那么render函数和VNode到底长啥样呢?
VNode
Vue2引入了虚拟DOM的概念。
高效的更新所有这些节点会是比较困难的,不过所幸你不必再手动完成这个工作了。你只需要告诉 Vue 你希望页面上的 HTML 是什么
上面提到的“这些节点”指的是页面中的真实DOM,由于操作真实DOM的性能代价比较昂贵,
- 构建一个真实的DOM对象,其大部分属性是我们不需要的
- 维护许多真实DOM对象的更新,需要占据大量的内存
而虚拟DOM可以看做是简化版的DOM树,虚拟DOM树上的虚拟节点即VNode,他们通过JavaScript对象构建:
VNode它更准确的名字可能是
createNodeDescription
,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,及其子节点
执行JavaScript代码的效率是远远高于操作真实DOM的效率的。关于虚拟DOM的深入理解及实现,可以参考这篇文章:理解 Virtual DOM 。
这里我们需要知道的是:render
函数返回的,就是一个虚拟的DOM树
render函数的形式
为了方便分析,我们来写个最简单的模板测试下,然后跟着断点前进
<div id="app">
<h1>{{ msg }}</h1>
<ul>
<li v-for="item in arr">{{item}}</li>
</ul>
</div>
<script>
let vm = new Vue({
el: "#app",
data: {
msg: "Hello Vue",
arr: [1,2,3]
},
})
</script>
先打印render
函数看看
function anonymous() {
with (this) {
return _c("div", { attrs: { id: "app" } }, [
_c("h1", [_v(_s(msg))]),
_v(" "),
_c(
"ul",
_l(arr, function(item) {
return _c("li", [_v(_s(item))]);
})
)
]);
}
}
如果把上面的模板通过配置参数的render函数实现
<div id="app"></div>
<script>
let vm = new Vue({
el: "#app",
data: {
msg: "Hello World",
arr: [1,2,3]
},
render: function (createElement) {
return createElement("div", {
attrs: {
id: "#app"
}
}, [
createElement("h1", this.msg),
createElement("ul", [
this.arr.map(function (item) {
return createElement('li', item)
})
])
])
}
})
</script>
可以发现,我们自定义的render函数,与通过template编译获得的render函数惊人地相似。
那么,也就不难理解_c
、_v
、_l
这些字母函数的作用了:生成对应的VNode和虚拟DOM树结构。
回到上面那个匿名的render函数,通过with绑定当前作用域到vm._renderProxy
(可以理解为vm自身)。也就是说,接下来我们的目标就是去寻找的来源及作用。
render函数的辅助函数
同样是在前面分析Vue构造函数和对象的时候了解到,相关的方法是通过renderMixin
函数中调用installRenderHelpers(Vue.prototype)
注册的。(PS:侧面说明先熟悉整体结构的必要性,比到处乱翻源码要轻松得多啊~)
// /src/core/instance/render-helpers.js
export function installRenderHelpers (target: any) {
target._o = markOnce
target._n = toNumber
target._s = toString
target._l = renderList
target._t = renderSlot
target._q = looseEqual
target._i = looseIndexOf
target._m = renderStatic
target._f = resolveFilter
target._k = checkKeyCodes
target._b = bindObjectProps
target._v = createTextVNode
target._e = createEmptyVNode
target._u = resolveScopedSlots
target._g = bindObjectListeners
}
慢着!上面好像漏掉了最重要的vm._c
函数,这是在_init
方法中通过initRender
方法注册的。
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
我们先不用管具体的实现细节(尤其是createElement
),只需要知道他们是用来方便生成对应的DOM结构即可,这些方法最终会组成render函数然后被执行。
OK,现在我们了解了$mount
方法的执行流程,也清楚了render
函数的大致样子和作用。接下来我们看看,如果通过配置参数传入template
,Vue内部是如何将其编译成render函数的。
生成Render函数
执行流程
回到Vue.prototype.$mount
的实现
const { render, staticRenderFns } = compileToFunctions(template, {
//...
}, this)
options.render = render
逐步找到compileToFunctions
的出处,由于代码比较多,为了更清晰地了解整个流程,简化了很多代码,只保留了参数和返回值。
// /src/platforms/web/compiler/index.js
const { compile, compileToFunctions } = createCompiler(baseOptions)
// /src/compiler/index.js
// Vue允许自定义其他的编译函数,这里声明编译函数的接口,并提供了一个基本的编译函数
export const createCompiler = createCompilerCreator(function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
// 将模板解析成AST树
const ast = parse(template.trim(), options)
// 优化AST树
optimize(ast, options)
// 编译模板数据
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
})
// /src/compiler/create-compiler.js
export function createCompilerCreator (baseCompile: Function): Function {
return function createCompiler (baseOptions: CompilerOptions) {
function compile(){
// ...
// 编译函数的返回结果,就是上面的 { ast, render, staticRenderFns }
const compiled = baseCompile(template, finalOptions)
return compiled
}
return {
compile,
// 传入compile函数,获得compileToFunctions函数
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
// /src/compiler/to-function.js
export function createCompileToFunctionFn (compile: Function): Function {
return function compileToFunctions (
template: string,
options?: CompilerOptions,
vm?: Component
): CompiledFunctionResult {
// ...
const compiled = compile(template, options)
// 这里即$mount方法中编译模板得到的结果
res.render = createFunction(compiled.render, fnGenErrors)
res.staticRenderFns = compiled.staticRenderFns.map(code => {
return createFunction(code, fnGenErrors)
})
return (cache[key] = res)
}
}
这里的使用了大量的闭包,不要被绕晕了,简单梳理一下流程
编译模板需要实现一个
compile
编译函数的接口,Vue内置了一个baseCompile
函数createCompilerCreator
接收baseCompile
函数,并返回createCompiler
函数createCompileToFunctionFn
接收一个compile编译函数,并返回compileToFunctions
createCompiler
函数中调用baseCompile
构建compile函数,并将compile函数传递给createCompileToFunctionFn
,返回compileToFunctions
函数compileToFunctions
函数接收一个template
模板和配置参数,最后返回render函数
好吧,这个确实有点绕。
baseCompile
事实上经过简单的分析,我们就可以发现,真正的解析工作是在baseCompile
这个函数中进行的
function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
const ast = parse(template.trim(), options)
optimize(ast, options)
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
可以发现这里执行的三个函数:parse
、optimize
和generate
,找到对应的定义文件,先大致了解他们的作用。
parse
这个函数主要用来将 template字符串解析成 AST
首先要理解的是AST,下面摘自维基百科:
在计算机科学中,抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构
在/flow/compiler.js
中可以找到AST的相关类型声明,包含ASTElement
、ASTExpression
和ASTText
三种类型。
也就是说,parse的主要功能就是解析template,包括模板标签、指令和属性等,并返回一个AST对象。其内部实现比较复杂,大致是通过正则匹配进行的。
之前写过的一个简单的JS模板引擎,至于Vue这里具体的解析实现,暂时没有深入。可以先看看返回的ast结构
{type: 1, tag: "div", attrsList: Array(1), attrsMap: {…}, parent: undefined, …}
optimize
这个函数主要用来标记ast的静态节点,优化虚拟DOM树,被标记为 static 的节点,会在更新时被忽略,为后面 patch 过程中对比新旧 VNode 树形结构做优化
generate
这个函数根据 ast 拼接生成 render 函数的字符串,在其内部对ast结构进行解析,使用在前面提到的render辅助函数,并拼接成对应的虚拟DOM树。
最后生成的render函数,就是我们控制台所输出的那种形式~
小结
render函数的生成是一个比较复杂的地方,这里我直接避开了其内部的具体实现,只是分析了从template到render函数的转换流程,目的是为了避免陷入模板解析的泥潭中。实际上这并不影响我们后续的分析(嗯~假装自己说的很有道理)。
模板更新
在mountComponent
函数中,我们找到了下面的代码
let updateComponent
updateComponent = () => {
const name = vm._name
const id = vm._uid
const vnode = vm._render()
vm._update(vnode, hydrating)
}
// 实例Watcher对象,绑定响应式数据
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
我们已经知道,
- 在进行依赖收集时,会对调用一次get,这里即
updateComponent
函数; - 在数据发生变化时,Dep会通知Watcher订阅者的
update
方法,实际上又会调用updateComponent
; - 在
updateComponent
方法内部,调用了vm._render
和vm._update
方法
在上一节我们已经分析了render
函数,现在让我们来看看update
的实现。
// /src/core/instance/lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const prevActiveInstance = activeInstance
activeInstance = vm
vm._vnode = vnode
if (!prevVnode) {
// 第一次初始化
vm.$el = vm.__patch__(
vm.$el, vnode, hydrating, false /* removeOnly */,
vm.$options._parentElm,
vm.$options._refElm
)
vm.$options._parentElm = vm.$options._refElm = null
} else {
// 数据变化,diff新旧虚拟DOM树,修改变化的VNode
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
其中vm.__patch__
在/src/instance/vdom/patch.js
中定义。其作用就是实现新旧 VNode 对比的 diff 函数,然后将对应的VNode转换成真实DOM,然后添加到页面上。如果想要深入diff算法,可以参考这篇文章:解析vue2.0的diff算法。
换句话说,视图的更新是在vm._update
中,通过vm.__patch__
实现的。
总结
至此,整个模板渲染和更新的过程似乎已经逐渐清晰了,简单整理一下:
在
initData
中,通过Observer修改data的属性访问描述符,将其转换为可监控的值在
$mount
中,通过updateComponent
实例化了Watcher对象,并在updateComponent
这个getter中对render
和update
求值,- 第一次执行
render
时会编译模板,并生成对应的render函数,render函数会返回虚拟DOM树; - 第一次执行
update
时,会将VNode转换为真实DOM,然后渲染页面。
- 第一次执行
当变量改变时,Dep通过调用Watcher对象的update方法,再次执行
updateComponent
- 后续执行
render
时,会从缓存中提取已编译的render函数, - 后续执行
update
时,会通过diff算法计算需要更新的VNode,然后实现re-render
,实现视图的更新。
- 后续执行
这篇文章的篇幅不短,实际上只研究了两个地方:
- template编译成render函数的过程
- 数据变化自动更新视图的实现
其中还有很多细节,比如模板转换成AST、diff算法等,都没有进一步深入,这些地方还需要继续学习才行。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。