在上一篇文章中,梳理了 Vue 实例化和渲染的基本逻辑,并且介绍了订阅者模式这种设计模式,Vue 的「响应式」实现本质上也是一个订阅者模式,但是由于 Vue 需要考虑更加复杂的情况,并且需要在其中作出大量优化操作,因此具体实现也会复杂很多。通过上面对订阅者模式的介绍,观察目标类,观察者管理类,观察者是订阅者模式中的三个基本要素,Vue 内部也会有对应的实现,下面通过更详细地说明 Vue「响应式」的实现,同时发掘在 Vue 中订阅者三要素分别是什么。
Vue 响应式实现
正正如上文开头所述,本文会从「实例化」、「渲染」、「数据更新」三条线讲述「响应式」的工作过程,首先可以总结出三条线的作用:
- 实例化 Vue —— 负责定义好响应式的相关逻辑。
- 渲染 —— 负责执行响应式的逻辑
- 数据更新 —— 负责响应式逻辑的二次执行
上面梳理了的是三条线的主线逻辑,下面开始聚焦到「响应式」的部分。
实例化 Vue —— 负责定义好响应式的逻辑
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value)
}
回顾前面提到的 observer
方法,这是其中核心的部分,它的基本逻辑是这样的:
- 判断如果有
__ob__
则直接使用。 - 没有
__ob__
会走一系列的判断,然后把数据传入到new Observer
创建响应式数据。
首先要分析的就是这一系列的判断,这些实际上都是对需要做响应式封装的数据进行检查的判断,shouldObserve
是默认为 true 的全局静态变量,isServerRendering
和 Array.isArray
顾名思义判断是否为服务端渲染和判断是否为数组,value._isVue
是判断是否为最根的 Vue 实例,根实例只是一个壳,是不需要处理响应式的,因此比较特别的是 isPlainObject
和 Object.isExtensible
,这是两个含义不是很直观的判断。
isPlainObject
“Plain Object — 通过 {} 或者 new Object 创建的纯粹的对象”,这是对于 Plain Object 的定义。在 JavaScript 中,Function,Array 都继承于 Object,也拥有 Object 的特性,但为了避免产生额外的问题,框架在数据上通常都会使用 Plain Object。要区分 Plain Object 也很简单,很多框架里都有关于 Plain Object 的判断实践,而 Vue 则是使用原型判断,例如以下这段代码:
// Plain object
var plainObj1 = {};
var plainObj2 = { name : 'myName' };
var plainObj3 = new Object();
// Non Plain object
var Person = function(){};
console.log(plainObj1.__proto__); // {}
console.log(plainObj2.__proto__); // {}
console.log(plainObj3.__proto__); // {}
console.log(Person.__proto__); // [Function]
打印结果中,原型的值是不一样的,Vue 的 isPlainObject
具体实现如下:
var _toString = Object.prototype.toString;
function isPlainObject (obj) {
return _toString.call(obj) === '[object Object]'
}
Object.isExtensible
“Object.isExtensible() 判断一个对象是否是可扩展的,即是否可以在它上面添加新的属性”,这是 Object.isExtensible()
的说明,看以下的例子:
// 新对象默认可扩展
var empty = {};
console.log(Object.isExtensible(empty)); // true
// 通过 Object.preventExtensions 使变得不可扩展
Object.preventExtensions(empty);
console.log(Object.isExtensible(empty)); // false
// 密封对象不可扩展
var sealed = Object.seal({});
console.log(Object.isExtensible(sealed)); // false
// 冻结对象也不可扩展
var frozen = Object.freeze({});
console.log(Object.isExtensible(frozen)); // false
// 尝试给不可扩展的对象添加属性
empty.a = 1;
console.log('modified empty: ', empty); // modified empty: {}
一个直接创建的 Plain Object 默认是可扩展的,也可以通过一些原生方法把对象变为不可扩展,另外密封和冻结对象都是不可扩展的,不可扩展的元素添加属性不会报错,但是会添加无效。那为什么 Vue 要求响应式数据对象必须要可扩展呢?原因很简单,在上面介绍 observer
方法中,核心的步骤就是要给数据对象添加 __ob__
属性,用于缓存响应式数据的封装结果。
定义响应式数据
回到实例化 Vue 的流程,在判断传入的数据对象如果没有 __ob__
属性后,会调用 new Observer
,这是响应式处理的真正入口类。
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
this.walk(value)
}
}
首先是把当前的 Observer
实例赋值给当前对象的 __ob__
属性,然后判断如果是数组则遍历每个 item 调用 observer
,由于之前调用 observer
时就进行了判断,传入的数据类型只能是数组或者对象,因此这里 else
就按对象处理,调用 walk
方法。
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
walk
主要的作用是为数据对象的每个 key 调用 defineReactive
方法,defineReactive
的主要逻辑是为传入数据的某个 key,基于 Object.defineProperty
劫持 get
和 set
操作,这样数据读取和赋值时就会调用响应式的逻辑。由于基于 Object.defineProperty
实现了这个核心逻辑,因此 Vue 不支持 IE8 下运行。
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
}
首先看看 get
操作的劫持,首先是通过原生的 getter
获取数据的值,然后判断 Dep.target
是否存在,这里可能会有疑问,没有看到它的赋值时机,所以 Dep.target
究竟是什么呢?实际上现在不用关注它的赋值,因为正如前面强调的,当前这些实例化的操作,只是把「响应式」的数据先定义好,也就是还不用运行,到了渲染过程的时候,才会对 Dep.target
进行赋值。
// 精简了部分逻辑
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
const subs = this.subs.slice()
if (process.env.NODE_ENV !== 'production' && !config.async) {
subs.sort((a, b) => a.id - b.id)
}
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
假如 Dep.target
已经赋值了,接下来会执行以下操作:
- 调用
dep.depend()
进行依赖收集,在Dep
类源码中可以发现,这个方法实际上是把当前 target,即当前渲染的Watcher
加入到dep
实例的一个数组中,保存下来。 - 如果数据中有子值也是对象,则对子值进行依赖收集。
也就是 get
调用后数据的 dep
会持有关联的 Watcher
。
// 精简非正式环境逻辑
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
然后看看 set
的操作:
- 获取当前最新的数据值。
- 判断新值如果等于旧值则直接跳过下面的操作。
newVal !== newVal && value !== value
是为了避免一些特殊情况,例如newVal
是NaN
,由于NaN === NaN
为false
,所以需要这样一个特殊的判断。 - 然后跳过没有原生
setter
但有原生getter
的情况。 - 接着调用
setter
赋新值。 - 最后是调用
dep.notify()
,根据上面Dep
类的源码可以知道,这实际上就是遍历之前收集的Watcher
,然后逐个调用它们的update
方法,Watcher
会去执行更新逻辑。
到这里,实例化中「响应式」相关逻辑已经完整分析清楚了,订阅者模式的相关要素也很清晰:
Dep
是观察目标,Watcher
是观察者,每个数据对应一个Dep
实例dep
,get
数据时会触发dep
收集了数据相关的Watcher
,相当于观察目标收集了观察者。Watcher
也记录了相关的 dep,方便后续更新时做优化。这是与普通订阅者模式最大的区别,后续会展开说明。set
数据时会触发 dep 通知相关的Watcher
更新,而具体的更新逻辑,等第三个小章节“数据更新”再详细说明。
如前面所说的,实例化中的响应式处理实际上是负责定义响应式的逻辑。接下来看看渲染的逻辑。
渲染 —— 负责执行响应式的逻辑
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
回顾调用 $mount
,挂载实例的这块代码,重新聚焦几个点:
Watcher
绑定的是 Vue 的实例vm
,传入的第二个参数是vm
的更新方法,里面会先调用vm
的_render()
方法。Watcher
的作用包括在需要时触发_render()
,即重新计算vnode
,然后_update
调用_patch
,即重新渲染 DOM,从而实现整个 Vue 实例的更新。
因此对于这个流程,响应式相关的逻辑重点在 new Watcher
,接下来看看 Watcher
的 constructor
。
// 精简了非 production 的逻辑
constructor (vm: Component, expOrFn: string | Function, cb: Function, options?: ?Object, isRenderWatcher?: boolean) {
this.vm = vm
if (isRenderWatcher) { vm._watcher = this }
vm._watchers.push(this)
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync
this.before = options.before
} else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.expression = process.env.NODE_ENV !== 'production' ? expOrFn.toString() : ''
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) { this.getter = noop }
}
this.value = this.lazy ? undefined : this.get()
}
constructor
的逻辑里,大部分都是定义变量,需要重点关注的主要是:
- 真正要处理的逻辑在
get()
方法里。 Watcher
实例的getter
就是传入的updateComponent
方法,getter
会被保存到Watcher
实例变量上。
接下来分析一下 get()
方法的逻辑:
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) { handleError(e, vm, `getter for watcher "${this.expression}"`) }
else { throw e}
} finally {
if (this.deep) { traverse(value) }
popTarget()
this.cleanupDeps()
}
return value
}
get()
方法主要是做两件事,调用 getter
以及进行「收集依赖」,getter
本质上就是 updateComponent
,即上面介绍过的渲染更新组件的逻辑,这里不再详述这点,重点关注「收集依赖」的过程。
Dep.target = null
const targetStack = []
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
上面是 pushTarget
的逻辑,它是 Dep
类下面的一个静态方法,本质上就是把当前的 Watcher
加入到一个栈中,并且赋值给 Dep.target
,这里可以回应上面在劫持响应式数据 get
逻辑的一个疑问,Dep.target
是在渲染过程中「收集依赖」时赋值的,因此真正执行响应式逻辑实际上是在渲染时才进行的。结合两个特性:
- Vue 实例渲染是递归的,从子到父逐个完成,同时只有一个
Watcher
被渲染。 - JS 是单线程的,
Dep.target
在同一时刻只会被赋值成一个Watcher
。
Vue 就是利用这两个特性,逐个执行 Watcher
的渲染逻辑,最终完成整个 Vue 应用的渲染,最后重点看看 this.cleanupDeps()
的逻辑。
为什么需要 cleanupDeps?
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) { dep.removeSub(this) }
}
let tmp = this.depIds
this.depIds = this.newDepIds
this.newDepIds = tmp
this.newDepIds.clear()
tmp = this.deps
this.deps = this.newDeps
this.newDeps = tmp
this.newDeps.length = 0
}
this.cleanupDeps()
的逻辑主要是分成两块:
- 把
newDepIds
里面不存在的dep
实例找出来,然后把当前的Watcher
从这个dep
实例中移除,也就是后续dep
对应的数据更新不用再通知当前Watcher
。 - 清空当前的
newDepIds
,把deps
赋值成newDeps
。
这样看无法直观看出来为什么需要实现一个这样的逻辑,举一个具体的例子:
<template>
<div id="app">
<div>
a:{{ a }}
<button @click="chnageA">点击修改 a 的值</button>
<HelloWorld1 v-if="a % 2 === 0" :data="a" />
<HelloWorld2 v-else :data="b" />
</div>
</div>
</template>
在这个例子中,a
、b
两个 data
,a
在模板中直接用到,而 b
仅在 HellowWorld2
中作为 props
传递,当 a
为奇数时 a
和 b
改变都会触发 App 更新渲染。
可以试想一下这样一个过程:
- 初始化时,
a
和b
都为 1,在初始化的时候经常observe
的处理,形成了两个Dep
实例,dep
(id=1,绑定a
)和dep
(id=2,绑定b
) - 渲染时
new Watcher
绑定了 App 这个 Vue 实例,然后Dep.target
赋值成当前Watcher
,经常Watcher
的getter
->updateComponent
->render()
这样一个过程,触发了a
和b
的get
,从而进行依赖收集,把当前Watcher
同时放入两个dep
中。 - 然后把
a
改为2,触发了a
的set
从而通知Watcher
更新,重新触发updateComponent
走到render()
,这个时候假如没有cleanupDeps()
,则这次render()
触发依赖收集完成后,只是更新了a
的值为2,而后续如果b
修改值时,仍会通知Watcher
更新,造成一次浪费的订阅更新。对于 Vue 这样的基础框架来说,如果每次依赖收集都重新进行,抛弃内存缓存记录,又会导致性能很差,无法适配各种常见,因此最终 Vue 的做法就是通过Watcher
和Dep
同时互相记录,来实现渲染优化,即订阅者也可以通知订阅目标抛弃掉一些无用的通知对象,减少浪费。
为了更好地说明这个过程,这里特意做了一张流程图完整表述整个过程:
数据更新 —— 负责响应式逻辑的二次执行
相对来说,数据 set
后的更新逻辑比较好理解,上面大概提到了,但其中的内部逻辑却是三条主线里最复杂的。上面稍微提到过,当数据 set
后,会触发 dep.notify()
,即遍历之前收集的 Watcher
,然后逐个调用它们的 update
方法,因此首先来看看 Watcher
的 update
方法:
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
上面 Watcher
的 constructor
的代码里展示过,lazy
和 sync
这两个变量默认都是 false
,因此可以先不用理会,也就是说 update
的主逻辑是把当前的 Watcher
作为参数调用 queueWatcher
,顾名思义是把 Watcher
放入到一个队列中,接下来看看 queueWatcher
的具体处理。
// 精简了非 production 的逻辑
let waiting = false
let flushing = false
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
// if already flushing, splice the watcher based on its id
// if already past its id, it will be run next immediately.
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// queue the flush
if (!waiting) {
waiting = true
nextTick(flushSchedulerQueue)
}
}
}
根据这里的逻辑,默认的情况都是直接把传入的 Watcher
加入到一个队列中,然后使用 nextTick
调用 flushSchedulerQueue
,nextTick
大家都比较熟悉,作用是把方法按周期调用,因此组件的实际渲染更新都不是即时的,而是每隔一个周期中集中处理,接下来看看 flushSchedulerQueue
的逻辑。
// 已精简非 production 逻辑
function flushSchedulerQueue () {
currentFlushTimestamp = getNow()
flushing = true
let watcher, id
queue.sort((a, b) => a.id - b.id)
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
}
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
resetSchedulerState()
// call component updated and activated hooks
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue)
}
这里主要处理了三件事情:
- 按 id 从小到大排序
Watcher
,Watchwe
的id
是创建时自增的,渲染是从外层递归的,也就是父元素会排在队列的前面。为什么要这样排呢?实际上也是为了性能优化,把父元素放在队列的前面,就会优先处理父元素,因此如果父元素销毁了,就可以直接跳过后面子元素的渲染更新。 - 遍历队列调用每个
Watcher
的run()
方法。 queue.length
是动态的,Vue 没有把队列长度缓存起来,是因为queue
在调用过程中可能会增删Watcher
,例如上面的例子中,a
的改变可以导致HelloWorld1
的Watcher
加入到队列中,而HellowWorld2
的Watch
则不再需要被渲染,因此queue
的长度无法缓存。
run () {
if (this.active) {
const value = this.get()
if (
value !== this.value ||
isObject(value) ||
this.deep
) {
const oldValue = this.value
this.value = value
if (this.user) {
const info = `callback for watcher "${this.expression}"`
invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
} else {
this.cb.call(this.vm, value, oldValue)
}
}
}
}
最后看看 run()
方法,首先会调用 get()
方法,也就是会重新调用 updateComponent
和进行依赖收集,这里再关注一下 get()
后半部分的逻辑,之前文章内提到的 Watcher
其实都是绑定 Vue 实例的渲染 Watcher
,Vue 中还有用户 Watcher
,也就是平常监听 data
或者 props
值变化用的 Watcher
,对于这些 Watcher
,会有有效的返回值 value,因此 run()
里面还会对比 value 是否有变化,如果有就重新赋值,并且会执行回调。
至此,Vue「响应式」的整个逻辑以及在各个环节中分别所做的处理已经讲述完成,作为 Vue 的核心部分,「响应式」的整个逻辑较为庞大,也涉及实例化、渲染、数据更新三个环节,同时内部还有很多的性能考虑,因此单纯去看「响应式」的核心代码也不大好理解,后面还会有一篇短文来解答一些数据更新的常见问题。最后制作了一张完整的 Vue「响应式」逻辑流程图供参考。
本文由 Kayo Lee 发表,本文链接:https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-two.html
Pingback
评论列表