内容简介
你能介绍下Vue的渲染机制吗?虽然是一个被问烂了的题,但真的挺考验应聘者在技术上的深度以及对框架的理解。实现一个组件,一定有数据绑定,当数据发生变化,视图在何时会触发渲染,要回答这个问题,就得了解Vue的渲染机制,本文围绕一个简单的例子,结合之前介绍的Watcher(观察者)、Dep(依赖对象)、Scheduler(调度器)、Component四个对象之间的关系来对渲染机制作介绍。
写一个简单组件
以下demo是一个基础信息编辑组件,通过data定义了几个属性,用watch来监听属性的变化,使用computed对多属性进行监听。当我们修改了表格内容,界面右部的基础信息也会随着更新。
<template>
<div class="info-editor">
<div class="info-editor-form">
<div class="info-editor-item">
<span>姓名</span>
<input v-model="name" />
</div>
<div class="info-editor-item">
<span>电话</span>
<input v-model="phone" />
</div>
<div class="info-editor-item">
<span>地址</span>
<input v-model="addr" />
</div>
</div>
<div class="info-editor-view">
<div>{{message}}</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
data() {
return {
name: '李磊',
phone: '18200000000',
addr: '北京五道口'
}
},
watch: {
phone: function phoneChange(value) {
if (!value || value.length !== 11) {
console.log('电话号码格式错误.')
}
}
},
computed: {
message: function message() {
return `${this.name},${this.phone},${this.addr}`
}
}
})
</script>
初始化
Vue在构造函数会调用_init函数执行初始化操作,_init函数直接挂在Vue的原型上,该函数会初始化生命周期、渲染、状态等等,像我们熟悉的生命周期事件beforeCreate、created会在初始化过程触发,这里我们关注两个地方,initState和vm.$mount(vm.$options.el)。
Vue.prototype._init = function (options?: Object) {
...
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
...
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
initState函数将初始化我们在组件中定义的prop、data、watch、computed属性,$mount初始化渲染过程并触发第一次渲染。initProps、 initMethod、initData会获取默认值以及将对应属性绑定到this上,例如我们在data中定义了name属性,通过initData函数,用户可以通过this.name来访问。这里我们重点介绍下initComputed和initWatch函数。这两个函数在《Vue的Watcher和Scheduler原理介绍》已经介绍过,这里我们再回顾下。
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
// 将_data属性转换为Observer对象监听起来
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
initComputed函数会遍历用户定义的所有计算属性,并为每个属性创建watcher对象,在实例化Watcher时传递了四个参数,vm表示组件自身;getter表示属性的值,例如demo中定义的message计算属性,其getter为获取value的函数function message();第三个参数为回调函数,由于计算属性不需要回调,所以用空函数noop表示;最后一个是Watcher构造函数的可选参数,只有计算属性创建Watcher才会标示为lazy,有什么作用我们介绍在Watcher对象时再说。
function initComputed (vm: Component, computed: Object) {
const watchers = vm._computedWatchers = Object.create(null)
for (const key in computed) {
const userDef = computed[key]
// 用户定义的执行函数可能是{ get: function() {} }形式
const getter = typeof userDef === 'function' ? userDef : userDef.get
// 为用户定义的每个computed属性创建watcher对象
watchers[key] = new Watcher(
vm,
getter || noop, // demo中的 function message()
noop,
{ lazy: true }
)
}
...
}
initWatch初始化组件在watch属性中自定义的监听,函数会遍历watch属性,这里的key即为组件中定义了"phone"属性。createWatcher函数会调用vm.$watch(expOrFn, handler, options)实例化Watcher对象,vm为组件自身,expOrFn为"phone“字符串,handler为我们定义的phoneChange函数。
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key]
if (Array.isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
createWatcher(vm, key, handler)
}
}
}
在demo中我们在data中有定义name、phone、addr属性,Vue在initData函数中将调用observe(data, true /* asRootData */)函数将data对象转换为Observer对象。每当有地方读取data中的属性,都会创建一个Watcher,例如对于phone属性,template中的<input v-model="phone" />和message函数return phone,以及watch中的phone监听,都会读取data中的phone属性,所以共会生成三个Watcher对象。
<input v-model="phone" />
computed: {
message: function message() {
return `${this.name},${this.phone},${this.addr}`
}
}
observe会将phone通过defineProperty函数转换为{ get, set }形式,在get中会判断Dep.target是否为空,我们知道phone一共有三处get,那么每个地方在读取phone的值,Dep.target就为对应的Watcher,例如watch中对phone的监听也会生成Watcher,dep.depend会将该Watcher附加到subs(Watcher数组)中。当我们重新设置了phone的值,例如通过input改变, 将会触发set函数,调用dep.notify触发每个watcher的update函数,执行通知。
export function defineReactive (
obj: Object,
key: string,
val: any
) {
...
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
...
}
return value
},
set: function reactiveSetter (newVal) {
...
dep.notify()
}
})
}
开始渲染
$mount函数定义
一个Vue项目,一般会在main.js函数中实例化Vue对象,在构造函数传入了渲染函数,实例化之后将会调用$mount函数启动渲染。
new Vue({
render: h => h(App),
}).$mount('#app')
$mount函数先将el转换为Dom(例如通过selector找到#app对应的Dom元素),挂在的Dom元素不能为body或者documentElement,18到48行查询模板字符串,如果template为字符串,只允许为#id形式,将会通过idToTemplate函数获取innerHTML内容。如果template为空,则直接把el的outerHTML赋给template。
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
// 如果el为body或者documentElement,则抛出警告,Vue不允许将Vue挂接到这些元素上。
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== 'production' && warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
return this
}
const options = this.$options
// 将模板转换为渲染函数
if (!options.render) {
let template = options.template
if (template) {
if (typeof template === 'string') {
// 例如$mount('#app'), 获取对应元素的innerHTML字符串
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
template = getOuterHTML(el)
}
if (template) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile')
}
// 将渲染模板转换为渲染函数,compileToFunctions函数将按options参数解析模板
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile end')
measure(`vue ${this._name} compile`, 'compile', 'compile end')
}
}
}
// 执行渲染函数
return mount.call(this, el, hydrating)
}
接下来调用compileToFunctions函数将template字符串转换为渲染函数,该函数首先将模板转换为抽象语法树AST(Abstract Syntax Tree),然后再根据AST生成函数表达,生成的匿名渲染函数中,所有的模板节点都会对应一个_c函数,该函数将创建对应tag(例如div)生成虚拟节点vnode。像input元素有绑定在data中定义的属性,例如name、phone,在_c函数中将通过_vm.name、_vm.phone、_vm.addr来获取值,这样就会命中属性的get函数,之前有介绍get函数会调用dep.depend()将观察者注册到观察目标中。这里的观察者指的是渲染Watcher(mountComponent函数创建,我们可以命名为Render Watcher),观察目标是name、phone、addr这些属性对应的dep,在渲染过程中,所有属性的dep都会附加上Render Watcher,这样当通过set函数更新属性时,将调用dep.notify函数通知Render Watcher重新渲染。
// 匿名渲染函数
function _render() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c("div", { staticClass: "info-editor" }, [
_c("div", { staticClass: "info-editor-form" }, [
_c("div", { staticClass: "info-editor-item" }, [
_c("span", [_vm._v("姓名")]),
_c("input", {
directives: [
{
name: "model",
rawName: "v-model",
value: _vm.name,
expression: "name",
},
],
domProps: { value: _vm.name },
on: {
input: function ($event) {
if ($event.target.composing) {
return
}
_vm.name = $event.target.value
},
},
}),
]),
_c("div", { staticClass: "info-editor-item" }, [
_c("span", [_vm._v("电话")]),
_c("input", {
directives: [
{
name: "model",
rawName: "v-model",
value: _vm.phone,
expression: "phone",
},
],
domProps: { value: _vm.phone },
on: {
input: function ($event) {
if ($event.target.composing) {
return
}
_vm.phone = $event.target.value
},
},
}),
]),
_c("div", { staticClass: "info-editor-item" }, [
_c("span", [_vm._v("地址")]),
_c("input", {
directives: [
{
name: "model",
rawName: "v-model",
value: _vm.addr,
expression: "addr",
},
],
domProps: { value: _vm.addr },
on: {
input: function ($event) {
if ($event.target.composing) {
return
}
_vm.addr = $event.target.value
},
},
}),
]),
]),
_c("div", { staticClass: "info-editor-view" }, [
_c("div", [_vm._v(_vm._s(_vm.message))]),
]),
])
}
这里有介绍Render Watcher,但还提到在何时创建的。回顾$mount函数,最后一行为 mount.call(this, el, hydrating),该函数为Vue最初定义的$mount函数,定义在src/platforms/web/runtime/index.js文件。
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
...
mount.call(this, el, hydrating)
}
接下来我们看src/platforms/web/runtime/index.js文件中的$mount函数是如何定义的,首先判断当前是否为浏览器环境,将el转换为Dom元素,最后一行调用了mountComponenta函数。
// public mount method
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
mountComponent首先触发beforeMount hook事件,我们可以在组件中定义beforeMount事件,在组件渲染过程自动触发。函数内定义的updateComponent函数将调用_render、_update执行渲染和更新,把最新的值(例如将name由李磊更新为韩梅梅)更新到界面上。
接下来实例化了Watcher对象,先回顾下Watcher的构造函数constructor (vm: Component, expOrFn: string | Function, cb: Function, options?: Object, isRenderWatcher?: boolean),一共包含5个参数,我们结合new Watcher传入的参数说明下:
- vm:vm对应组件自身;
- expOrFn: expOrFn可以为字符串或者函数,例如在watch监听时可以写成"a.b.c"形式, 这里updateComponent对应expOrFn;
- cb: 更新后的回调函数,这里传入的noop为空函数;
- options: 可选参数,这里传入的before属性,当watcher执行run之前调用;
- isRenderWatcher:是否为渲染Watcher,Watcher内部逻辑将对渲染Watcher做特殊处理,这里传入的参数为true,表明我们在mountComponent函数中创建的Watcher都为渲染Watcher;
/**
* 生命周期mount事件触发函数
* @param {*} vm
* @param {*} el
* @param {*} hydrating
* @returns
*/
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
...
callHook(vm, 'beforeMount')
let updateComponent = () => {
//_render函数执行渲染,生成新的虚拟节点vnode,_update函数将结合__patch__补丁算法来更新原始prevnode,并最终更新到Dom元素。
vm._update(vm._render(), hydrating)
}
// 实例化Watcher对象,在Watcher构造函数中建立Watcher和vm的关系
//
new Watcher(vm, updateComponent, noop, {
// 在执行wather.run函数之前触发before hook事件
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
// isRenderWatcher表示用于渲染的Watcher,在执行$forceupdate时会手动触发watcher.update
}, true /* isRenderWatcher */)
return vm
}
触发渲染和更新
到目前我们只知道updateComponent函数会执行渲染render和更新update函数,但updateComponent在何时将被触发目前我们还未知道。mountComponent函数中有实例化Render Watcher,接下来我们就看看在new Watcher时包含哪些逻辑。首先通过isRenderWatcher判断当前watcher是否为渲染watcher,是则将其赋值给vm._watcher,当组件创建好之后,可通过vm._watcher来触发重新渲染。组件内部创建的watcher都将附加到vm._watchers数组中,编译后续批量操作,例如$destroy函数批量注销所有watcher。
getter一般为获取值的链式表达(如a.b.c),或者为获取值的函数, 例如我们在demo中定义的计算属性message函数。但渲染Watcher把触发渲染和更新函数updateComponent当着getter,其实这也是Vue设计的巧妙之处,稍后我们再说明。
重点是最后一行代码,给this.value获取最新值,之前有提到只有计算属性watcher的lazy才会true,其他watcher的lazy都为false,所以渲染Watcher将执行this.get函数获取最新值。
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: Object,
isRenderWatcher?: boolean
) {
this.vm = vm
if (isRenderWatcher) {
// 将当前Watcher挂接到vm._watcher上。在执行$forceUpdate将使用_watcher
vm._watcher = this
}
//组件中创建的watcher都放到_watchers队列中,例如执行$destroy将对其销毁。
vm._watchers.push(this)
...
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
// 将表达式转换为函数,例如将'a.b.c'转换为函数从vm中通过链式获取c属性值
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop
}
}
// 触发get函数
this.value = this.lazy
? undefined
: this.get()
}
}
接下来就是触发渲染和更新的核心环节了,Watcher构造函数最后一行调用了get函数,首先将当前Render Watcher通过pushTarget附加到全局Dep.target上,接着调用getter来获取最新值,之前提到Render Watcher的getter为updateComponent函数,所以此时将执行updateComponent函数。
/**
* 执行getter,重新收集依赖项
*/
get () {
// 将当前Watcher附加到全局Dep.target上,并存储targetStack堆栈中
pushTarget(this)
let value
const vm = this.vm
try {
// 执行getter读取value
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// 如果deep为true,将遍历+递归value对象
// 将所有嵌套属性的dep都附加上当前watcher,所有子属性对应的dep都会从push(Dep.target)
if (this.deep) {
// 递归遍历所有嵌套属性,并触发其getter,将其对应的dep附加当前watcher
traverse(value)
}
// 退出堆栈
popTarget()
// 清理依赖
this.cleanupDeps()
}
return value
}
在updateComponent内部,先调用vm._render()将模板生成虚拟节点, _render内部会调用c_将每个模板tag生成vnode,例如将我们在模板中定义的div[class='info-editor-view']生成对应的虚拟节点vnode。vm._render执行之后返回模板root vnode节点,也就是<div class="info-editor">对应的虚拟节点。updateComponent接着调用_update函数将虚拟节点生成最终的真实DOM元素,当然_update函数内部会调用__patch__补丁函数做diff更新,保证最小的性能开销。
_c("div", { staticClass: "info-editor-view" }, [ _c("div", [_vm._v(_vm._s(_vm.message))]),
])
// _c定义
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
现在我们还是回到上面提到Watcher中的get函数,内部调用this.getter.call(vm, vm)执行getter函数,对于渲染Watcher的getter即为updateComponent,所以将触发_render函数,该函数内部会通过_vm.message、_vm.name来获取属性值,前面有提到在Vue组件中,我们定义的属性都将转换为{get,set}模式,所以调用_vm.message将触发其get函数,而get函数又会调用dep.depend(),每个属性都有对应的dep, dep.depnd函数将Dep.target(Render Watcher)附加到其维护的Watcher列表。这样,当_render执行完,组件中所有属性的dep都将有注册上Render Watcher。只要属性有更新,触发set,调用dep.notify()来通知Render Watcher执行update,从而触发重新渲染。
get函数中有判断this.deep,如果为true,递归遍历所有value的嵌套属性,并触发其getter,将其对应的dep附加上当前watcher。例如在data中定义了extra: { city: '武汉', code: '1000' }, 当我们在watch中监听了extra属性,生成的Watcher也会被city、code属性对应的dep附加上,这样当extra.city发生变化时,watch也能监听到。
Watcher实体本身也会用newDeps列表维护在pushTarget和PopTarget期间附加的依赖项,接着调用cleanupDeps清理deps列表(上一版本的依赖项),并将新的依赖项newDeps赋值给deps,这样就将最新收集的依赖项收拢了。
重新渲染
除了在初始化过程会触发渲染,有时我们在程序中也会调用$forceUpdate触发重新渲染,该函数会调用vm._watcher.update方法,这里的_watcher就是上文提到的Render Watcher,并且在Watcher的构造函数中通过vm._watcher = this绑定到vm上。那_watcher的update函数执行了什么操作?
Vue.prototype.$forceUpdate = function () {
const vm: Component = this
if (vm._watcher) {
vm._watcher.update()
}
}
Watcher的update函数一共判断了三种场景,如果为计算属性Watcher,那么lazy为true;如果是同步场景,将直接调用run函数获取最新值并通知回调cb;其他情况,将调用queueWatcher函数创建微任务批量执行watcher列表的run函数,通知更新。这里的run、queueWatcher在《Vue的Watcher和Scheduler原理介绍》中有详细介绍,这里我们只需记住在run函数中会调用构造函数总传入的getter函数获取最新值,而对于Render Watcher,其getter即为updateComponent函数,该函数内部会调用_render、_update来执行渲染和更新,这样重新渲染的目的就达到了。
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
总结
本文结合一个简单的demo,介绍了在初始化过程中是如何为组件创建Render Watcher、Computed Watcher以及Watch Watcher,当我们在创建Render Watcher时会将其附加到template中依赖的各个属性的dep中,接着触发getter(传入的updateComponent函数),执行渲染和更新。最后说明了重新渲染的情况,当程序中调用$forceUpdate函数,会执行Render Watcher的updateComponent,实现重新渲染。
本文提到了渲染和补丁更新,但没介绍细节,下一篇主题将介绍Vue如何根据模板生成虚拟节点vnode,以及vnode如何根据补丁算法来实现DOM的局部更新,waiting...。
写在最后
如果大家有其他问题可直接留言,一起探讨!最近我会持续更新Vue源码介绍、前端算法系列,感兴趣的可以持续关注。