响应式原理示例
前面几节我们时常看到渲染 watcher 实例的生成,呢么接下来我们重点介绍。
首先介绍一下,响应式数据是如何实现的,示例源代码如下所示。
Observer 类是实现响应式数据的关键,生成一个实例并绑定在数据的 __ob__ 属性上,并且该属性不可遍历/枚举。然后判断 value 类型,若为数组类型则遍历每一项执行 observe() 函数尝试将其转为响应式数据,若为对象类型则遍历每一个 key 去执行 defineReactive() 函数给对应的 key 定义 get/set 函数进行依赖收集、派发更新。
class Observer {
constructor(value) {
this.value = value;
this.dep = new Dep();
def(value, '__ob__', this); // 定义 __ob__ 属性且不可枚举
if (Array.isArray(value)) {
data.forEach(val => {
observe(val);
});
} else {
Object.keys(value).forEach(key => {
defineReactive(value, key);
});
}
}
}
function def (obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable, // 默认不能枚举
writable: true,
configurable: true
})
}
该 observe() 函数首先对数据类型进行判断,若为对象类型则通过调用 Observer 类将数据转为响应式数据并返回对应的实例 data.__ob__ 的值,否则直接返回原数据。
function observe(data) {
if(!isObject(data)){
return data
}
const ob = new Observer(data);
return ob;
}
// 判断数据是否为对象类型
function isObject (obj) {
return obj !== null && typeof obj === 'object'
}
该 defineReactive() 函数将对象中的 key 进行 get/set 劫持,然后在 get 阶段进行依赖收集,在 set 阶段进行派发更新,从而实现响应式的功能。
在该 defineReactive() 函数内首先获得键 key 对应的 val,然后尝试将其转为响应式数据并缓存其属性 __ob__ 的值为 childOb,因为该值也持有 dep 实例可以进行依赖收集,收集与当前数据相关的 watcher 实例。
在 get 阶段除了 key 持有一个 dep 实例进行依赖收集外,还会判断当前的 childOb 是否存在,因为该属性也持有一个 dep 实例,若该属性存则在也进行依赖收集。为什么要这么设计呢?因为修改 key 的值进行派发通知我们都能理解,若 val 为对象,我们并没有完全覆盖修改 val 的值,而是新增/删除 val 对象的某个键值对,则不会触发 key 所持有的 dep 实例,而是通过 Vue.set() 函数触发 childOb 所持有的 dep 实例进行派发通知,即为执行 childOb.dep.notify(); 语句。
在 set 阶段首先判断新旧值是否相同,若相同则直接 return,否则尝试将新值尝试转为响应式数据并为 childOb 赋新值,然后触发 key 所持有的 dep 实例进行派发通知:dep.notify();
function defineReactive(obj, key) {
let val = obj[key];
let dep = new Dep();
let childOb = observe(val); // 递归处理
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
if (Dep.target) { // 收集依赖
dep.depend();
if (childOb) {
childOb.dep.depend()
}
}
return val;
},
set(newVal) {
if(val === newVal) {
return;
}
val = newVal;
childOb = observe(newVal);
dep.notify(); // 派发更新
}
});
}
该 Dep 类用 Set 集合做存储,用来存储 watcher 实例,是依赖收集/派发通知的主要功能实现。依赖收集主要是收集与数据相关的 watcher 实例。
watcher 执行栈的运行顺序是: 开始 -> ParentWatcher -> SonWatcher -> ... -> SonWatcher -> ... -> ParentWatcher -> 结束。
let id = 0;
class Dep {
constructor() {
this.id = id++;
this.subs = []; // 存储 watcher 实例
}
depend() {
if (Dep.target) {
Dep.target.addDep(this);
}
}
// 遍历 watcher 触发更新
notify(){
this.subs.forEach(watcher =>
watcher.update()
)
}
// 添加 watcher
addSub(watcher) {
this.subs.push(watcher)
}
// 移除 watcher
removeSub(watcher) {
let inx = this.subs.indexOf(watcher)
if(inx !== -1) {
this.subs.splice(inx, 1)
}
}
}
// 静态全局变量,表示当前正在运行的 watcher 实例
Dep.target = null;
// watcher 执行栈,先进后出
const targetStack = []
// 入栈,设置当前正在执行的 watcher 实例并入栈
export function pushTarget (target) {
targetStack.push(target)
Dep.target = target
}
// 出栈,设置上一个 watcher 为当前正在执行的 watcher 实例
export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
Watcher 类的本质是存储了一个需要在特定时机触发的函数 (getter)。
active属性表示该watcher实例是否激活可用。lazy属性是计算属性的标志。dirty是计算属性的开关,表示是否需要重新计算。user属性表示为用户手写定义的watcher实例。before()函数是渲染watcher所独有的属性,表示执行beforeUpdate()钩子函数。
class Watcher {
constructor(vm, getter, cb, options) {
this.deps = []; // 上次添加的依赖
this.newDeps = []; // 本次新添加的依赖
this.depIds = new Set();
this.newDepIds = new Set();
this.active = true;
this.lazy = !!options.lazy;
this.dirty = this.lazy;
this.user = !!options.user;
this.before = options.before;
this.cb = cb;
this.getter = typeof getter === "function" ? getter : vm[getter];
this.value = this.lazy ? undefined : this.get();
}
get() {
pushTarget(this);
let value = this.getter();
popTarget();
this.cleanupDeps();
return value;
}
addDep(dep) {
let id = dep.id;
// 收集与本次页面渲染相关的数据所持有的 dep
if(!this.newDepIds.has(id)){
this.newDeps.push(dep);
this.newDepIds.add(id);
// 若上次页面渲染的收集的 dep 实例中不包含该 dep,则说明该 dep 实例与当前 watcher 实例没有任何关联,则该 dep 实例收集当前 watcher 实例
if(!this.depIds.has(id)){
dep.addSub(this);
}
}
}
// 清除依赖
// 若上次添加的依赖,在本次新添加的依赖中不存在,则清除上次添加的依赖,保证页面渲染只保存最新的依赖
cleanupDeps() {
let len = this.deps.length;
while(len--) {
const dep = this.deps[len];
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;
}
update() {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
// 通过 nextTick() 函数保证,当循环结束后,再执行 watcher.run()
queueWatcher(this)
}
}
run() {
if (this.active) {
const newValue = this.getter()
const oldValue = this.value
this.value = newValue
// 渲染 watcher 的 cb 为空函数
this.cb.call(this.vm, newValue, oldValue)
}
}
evaluate() {
this.value = this.get()
this.dirty = false
}
depend() {
let i = this.deps.length
while (i--) {
this.deps[i].depend()
}
}
// 清空依赖,关闭当前 watcher
teardown() {
if (this.active) {
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)
}
this.active = false
}
}
}
上述的三段代码就是 Vue 响应式原理的核心源码实现,当然了,这个是简写啊,下面我们用一段小代码来看看具体的运行过程。
const data = observe({ msg: { a: 1 } });
new Watcher(
vm,
() => { document.getElementById("app").innerHTML = data.msg.a; },
() => {},
{}
);
上述代码是一个简单的响应式示例,首先通过 observe() 函数生成响应式数据对象,然后通过 Watcher 类构造器传入一个匿名函数作为参数生成对应的渲染 watcher 实例,这说明:Watcher 的本质就是存储了一个需要在特定时机触发的函数。然后渲染页面,当数据变化的时候重新渲染页面,下面就重点叙述一下整个过程。
依赖收集过程
1、首先执行 const data = observe({ msg: { a: 1 } }); 该段代码的时候会将对象转为响应式数据。
observe()函数先将对象{msg:{a:1}}添加属性__ob__且该属性不可遍历,该属性也是响应式数据的标志位,同时该属性持有一个dep实例。该dep实例主要监听该对象是否 新增/删除 键。- 然后遍历对象的键,给键
msg定义get/set函数并持有一个dep实例。 - 然后将对象
{a:1}添加属性__ob__且该属性不可遍历,同时该属性持有一个dep实例。 - 然后遍历对象的键,给键
a定义get/set函数并持有一个dep实例。 - 响应式数据转换完成!
2、当执行 new Watcher() 构造器函数时会进行诸多属性的定义与赋值,在该函数的最后执行 this.value = this.get(); 求值。在 this.get() 函数内:
先执行 pushTarget(this); 函数设置 Dep.target 为当前的渲染 watcher 实例并压入栈。
然后执行 this.getter() 函数进行求值,而此时 this.getter() 函数为构造器的第二个参数,执行该函数会进行页面渲染生成 DOM 节点,在此过程中会访问 data.msg.a 的值,由于访问的是对象最深层次的 key,所以会触发上述全部 dep 实例进行依赖收集,此时会调用 dep.denpend() 函数,也就是执行 Dep.target.addDep(this) 函数,而此时的 Dep.targe 的值为当前的渲染 watcher 实例,该 addDep() 函数保证了 dep 收集当前的渲染 watcher 实例,而当前的渲染 watcher 实例也存储了页面渲染所需数据的所持有的 dep 实例,即为:收集与本次页面渲染相关的数据所持有的 dep 实例。
回到 this.get() 内最后执行 popTarget() 函数把 Dep.target 恢复为上一个 watcher 实例。最后执行 this.cleanupDeps() 函数进行依赖清空,清空的是上次页面渲染所需的数据,只保留本次页面渲染所需要的数据依赖。为什么这么做呢?这是一个优化的过程非常巧妙哦!
在图一知,通过点击切换显示按钮来控制页面显示 msg1/msg2 的值,而此时通过 addDep() 函数进行依赖收集,渲染 watcher 收集 msg1/msg2 所持有的 dep 实例,然后 msg1/msg2 所持有的 dep 实例也收集渲染 watcher .
由图一可知页面只能显示 msg1/msg2 中的一个值。若没有依赖清空,当页面只显示 msg1 的时,此时修改 msg1 肯定会触发页面重新渲染,而修改 msg2 的值也会触发页面重新渲染,但是页面并没有显示 msg2 的值,所以清空依赖就是为了避免这样的情况。保证:只收集与本次渲染相关的数据依赖!
首先说明一下 newDeps/newDepIds 表示当前本次页面渲染新添加的 dep 实例,而 deps/depIds 表示上次页面渲染添加的 dep 实例。cleanupDeps() 函数进行清空依赖,清空的是上次页面渲染所需的数据,只保留本次页面渲染所需要的数据依赖。
在图二中首先遍历 deps 数组,若上次渲染的数据所持有的 dep 实例不在本次渲染 this.newDepIds 集合内,则解绑上次渲染的数据与渲染 watcher 之间的联系。最后将 this.newDeps 集合赋值给 watcher.deps 集合,清空 watcher.newDeps 集合数据,等待后续下一轮的渲染。
派发通知过程
1、当数据发生变化时,会触发数据劫持的 set() 函数。首先将 newVal 与 val 进行比较是否相等,然后通过 childOb = observe(newVal); 函数尝试转为响应式数据,最后通过 dep.notify() 函数进行派发更新通知,如下图:
2、对应渲染 watcher 而言,由于是循环执行 watcher.update() => queueWatcher() 过程,所以会多次执行 queueWatcher() 函数。在该函数内使用对象 has[watcher.id] = true 保证同一 watcher 实例只添加一次到队列,最后在该函数末尾通过 nextTick() 函数实现了在当前循环结束后的下一个 tick 执行 flushSchedulerQueue() 函数,如下图:
3、在 flushSchedulerQueue() 函数内先对 queue 队列进行 id 自增排序,接着对它进行遍历。因为只有渲染 watcher 有 before 属性,所以执行 watcher.before() 函数就是执行生命周期 beforeUpdate() 钩子函数。然后从 has 对象内清除当前的 watcher 实例。
然后执行 watcher.run() 函数,在该函数内重新执行 watcher.get() 函数进行依赖收集并渲染页面并返回新值。 然后执行回调函数 this.cb.call(vm, val, oldVal),这就是我们在添加自定义 watcher 的时候能在回调函数内拿到新值、旧值的原因。
在 queue 队列遍历结束以后执行 resetSchedulerState() 状态恢复,就是把这些控制流程状态的一些变量恢复到初始值,把 watcher 队列清空。最后执行生命周期 updated 钩子函数,如下:
计算属性 watch
计算属性也是一种 Watcher,俗称为 computed watcher。我们知道在组件初始化时会合并配置,初始化 data、computed、watch 等,而计算属性的初始化是在 initComputed() 函数内。该过程也是相对简单一些。
在 initComputed(vm,computed) 函数内:
-
首先定义
vm._computedWatchers = {}属性用来存储生成的计算属性watcher -
接着遍历我们在组件中定义的
computed属性对象,然后拿到每个key所对应的get()函数,然后生成对应的计算属性watcher并保存于vm._computedWatchers属性上。属性lazy表示是否为计算属性watcher,属性dirty表示是否需要为计算属性watcher重新计算求值watcher.value. -
接着执行
defineComputed()函数。该函数主要通过Objetct.defineProperty()函数将计算属性的key定义在组件实例vm上,并设置get()/set()函数。可以重点看一下计算属性的get()函数的实现。
计算属性的 get() 函数是通过 createComputedGetter() 函数实现的。在该函数内首先获取对应的计算属性 watcher 实例,然后:
-
然后判断若
watcher.dirty为true,则执行watcher.evaluate()函数。在该函数内先执行watcher.value = watcher.get();函数计算value的值并并行依赖收集。相当于重新走依赖收集的过程,只不过此时Dep.target为计算属性watcher,最后再设置watcher.dirty = false;关闭标志位,下次访问计算属性直接返回watcher.value值。 -
然后判断若
Dep.target存在,则Dep.target一定为渲染watcher实例,若存在则执行watcher.depend()函数。该函数的主要功能:因为计算属性watcher已经在上一步进行依赖收集,然后遍历该依赖,让渲染watcher也进行收集该依赖。 -
最后
return watcher.value;计算属性的get()函数就此结束。
当数据依赖发生变化时,会触发计算属性 watcher.update() 函数,然后设置 watcher.dirty = true; 等待下次访问计算属性时进行重新计算。
解释一下上文中为什么说若 Dep.target 存在,则一定为渲染 watcher 呢?
因为 Vue 框架只有渲染 watcher、计算属性 watcher、用户手写 watcher 三类。首先排除用户手写 watcher,从辩证法角度的分析,一个数据对象往往只会从 computed watcher 与 user watcher 中二选一,所以就只剩下渲染 watcher 了。什么时候触发计算属性 get() 函数呢?如下:
- 用户在
JS代码内直接使用计算属性,如:console.log()。此时并不会触发页面渲染所以Dep.target的值为空。 - 页面模板使用计算属性并进行页面渲染时
Dep.target为渲染watcher。此时当数据变化时,计算属性并不会立刻进行计算watcher.value的值,而是打开标志位等待重新计算watcher.value的值,这就造成页面不会重新渲染,但是数据又发生了变化,所以渲染watcher也要收集该数据的依赖,当数据变化时进行页面重新渲染并触发计算属性watcher.get()函数进行重新求值。
function initComputed (vm, computed) {
const watchers = vm._computedWatchers = Object.create(null)
for (const key in computed) {
const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
{ lazy: true }
)
defineComputed(vm, key, userDef)
}
}
const noop = () => {};
const sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
};
function defineComputed(target, key, userDef) {
if (typeof userDef === "function") {
sharedPropertyDefinition.get = createComputedGetter(key);
sharedPropertyDefinition.set = noop;
} else {
sharedPropertyDefinition.get = createComputedGetter(key);
sharedPropertyDefinition.set = userDef.set || noop;
}
Object.defineProperty(target, key, sharedPropertyDefinition);
}
function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}
用户自定义 watch
我们知道在组件初始化时会合并配置,初始化 data、computed、watch 等,而计算属性的初始化是在 initWatcher() 函数内。该过程也是相对简单一些。
在 initWatcher(vm,computed) 函数内首先遍历 watch 对象获取每个 key 对应的值 handler,该 handler 值可以为数组/对象/函数/字符串类型。若为数组类型,则遍历 handler 执行 createWatcher(key, handler[i]) 函数,否则直接执行 createWatcher(key, handler) 函数。
在 createWatcher(key,handler,options) 函数内,主要是获取 key 对应的回调函数 handler、options 配置项 ,最后执行 return this.$watch(key,handler,options); 语句,呢么接下来,我们看看 this.$watch() 函数的实现。
this.$watch() 函数即为 Vue.prototype.$watch(expOrFn,cb,options) 函数。
-
在该函数内先判断
cb若为对象类型则重走createWatcher()函数流程。 -
然后获取
options配置项,再设置标志位options.user = true; 表示为用户手写类型的watcher实例。 -
然后创建对应的实例
new Watcher(vm, expOrFn, cb, options),在此过程中会计算对应的watcher.value值,此时的该值为vm[expOrFn]。 -
再判断
options.immediate若为 true,则立即执行该用户手写watcher对应的回调函数cb.call(vm, watcher.value); -
最后返回一个匿名函数,执行该匿名函数即为执行
watcher.teardown()函数。在该函数内销毁当前的watcher实例,清空依赖收集,并设置watch.active = false;关闭当前watcher实例的状态。
function initWatch (vm, watch) {
for (const key in watch) {
// handler 可以为数组/对象/函数/字符串
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)
}
}
}
function createWatcher (
vm,
key,
handler: any,
options?: Object
) {
// 获取回调函数
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
if (typeof handler === 'string') {
handler = vm[handler]
}
//
return vm.$watch(key, handler, options)
}
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
// cb 为对象,执行 createWatcher
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
// 表示为 user watcher
options = options || {}
options.user = true
// 创建 user watcher
const watcher = new Watcher(vm, expOrFn, cb, options)
// immediate 为 true,立即执行回调
if (options.immediate) {
cb.call(vm, watcher.value)
}
// 执行该函数 销毁 watcher
return function unwatchFn () {
watcher.teardown()
}
}