Vue的经典设计思想就是数据驱动,即:数据改变自动更新视图。为了实现这一目标,Vue需要观察数据的变动,当感知到数据变化后,自动执行对应的视图更新操作函数(常见为render function)。由此实现响应式数据的特性。
总体流程
根据上图我们大概可以了解响应式原理的总体流程。对于响应式数据的具体实现,通过阅读源码可以知道,Vue是通过以下几个核心模块来实现:
- Observer
- Dep
- Watcher
- Scheduler
下面分别来看下各个模块的具体作用和解决的问题。
Observer (消息)
核心作用: 将普通数据(对象)转换为响应式数据(对象)
观察的数据,主要分为两种:
- 对象自身
- 对象属性
Object.defineProperty()
Observer(观察者)属于Vue系统中的核心模块,是用于实现响应式数据的基础构造器(Constructor)。其内部会递归遍历普通对象的每个属性,并利用Javascript提供的对象操作API —— Object.defineProperty(),将其转换为带有 getter和setter 方法的属性。此后,当属性被读取或修改时,分别完成依赖收集和变化通知。Vue便拥有了对数据变化自动感知的能力。
created之前完成
需要注意的是:Observer响应式处理过程,发生在Vue生命周期中的 beforeCreate之后, created 之前。
Vue.observable()
Vue提供了静态方法:
Vue.observable(), 手动将普通对象转为响应式对象。例如:
var obj = {
a: 1,
b: 2,
c: {
d: 3,
e: 4
},
f: [
{
a: 1,
b: 2
},
3, 4, 5, 6
]
}
// 利用Vue提供的静态方法 .observable, 将一个普通对象转化为响应式对象
var reactiveObj = Vue.observable(obj)
console.log(reactiveObj)
data
Vue不允许动态添加根级响应式属性,所以需要在组件实例化之前通过配置中的 data 字段,声明所有根级响应式属性,哪怕属性值为 null。由此带来的好处有:
- 更易于维护: data对象就像组件的状态结构(schema), 提前声明所有响应式属性,后期有助于开发者理解和修改组件逻辑。
- 消除了在依赖项跟踪系统中的一类边界情况。
- 使Vue实例能够更好的配合类型检查系统工作。
特殊情况
动态添加或删除属性
由于Vue会在初始化实例时,对所有属性(配置里 data 中存在的属性)执行 getter/setter 的转化。
那么对于动态添加或删除的属性,Vue是无法自动检查其变化。
因此,Vue提供了以下方式来手动完成响应式数据。
- 添加:Vue.set(target, key, val) 或 this.$set(target, key, val)
- 删除:Vue.delete(target, key) 或 this.$delete(target, key)
- 批量操作:
this.reactiveObj = Object.assign({}, this.reactiveObj, obj)
举个例子:
<template>
<div class="demo-wrapper">
<p>obj.a -> {{ obj.a }}, obj.b -> {{ obj.b }}</p>
<!-- 非响应式式数据操作 -->
<!--
<button @click="obj.b = 2">add obj.b</button>
<button @click="delete obj.a">delete obj.a</button>
-->
<!-- 响应式数据操作 -->
<button @click="$set(obj, 'b', 2)">add obj.b</button>
<button @click="$delete(obj, 'a')">delete obj.a</button>
</div>
</template>
<script>
export default {
data() {
return {
obj: {
a: 1,
},
};
},
};
</script>
关于数组
由于js的限制, Vue不能检测到以下数组变动:
- 当利用索引直接改变数组项时, 例如:
vm.arr[idx] = newValue - 当修改数组长度时 ,例如:
vm.arr.length = newLength
举个例子:
<script>
export default {
data() {
return {
arr: [1, 2, 3, 4],
};
},
created() {
window.lesson4 = this;
},
mounted() {
this.arr[0] = 8; // 不是响应式的
this.arr.length = 2; //不是响应式的
}
};
</script>
为了让上述数组操作具有响应式,采用以下方法处理:
<script>
export default {
data() {
return {
arr: [1, 2, 3, 4],
};
},
created() {
window.lesson4 = this;
},
mounted() {
// 操作一:通过索引修改数组项
this.$set(arr, 0, 8); // 响应式的
// 或
Vue.set(arr, 0, 8); // 响应式的
// 或
this.arr.splice(0, 1, 8) //响应式的
// 操作二:修改数组长度为2
this.arr.splice(2) // 响应式的
}
};
</script>
除了可以通过静态方法 Vue.set() 和 实例方法 this.$set() 响应式的修改数组项的值。还可以使用数组方法 - splice() 。
因为,Vue对一些可以改变数组自身内容的操作API,如:splice()、sort()、push()、pop()、reverse()、shift()、unshift() 等进行了拦截和重写。从而在开发者使用这些API时,可以触发响应式数据,进而更新视图。
<script>
export default{
data() {
return {
arr: [1,2,3,4]
}
},
mounted() {
console.log(this.arr._proto_ === Array.prototype) // => false
console.log(this.arr._proto_._proto_ === Array.prototype) //=> true
}
}
</script>
图示如下:
Dep (通知)
Dep, 含义 Dependency,”依赖“的意思。 是响应式数据 用于做 依赖管理 的模块(Class)。
Dep解决的问题
- 触发响应式属性 getter时(访问属性), 如何收集依赖 —— 谁在用“我”?
- 触发响应式属性 setter时(修复属性),如何派发更新 —— 通知用“我”的谁?
依赖管理流程,分析如下:
- 在将对象转为响应式对象时,会为对象自身和每个属性设置一个实例化dep (
dep = new Dep()),用于管理依赖。 - 通过 Dep.target 设置一个全局唯一性的当前执行的Watcher。
- 当属性被访问时(触发 prop getter),会调用
dep.depend(),将当前全局Watcher添加到依赖中 - 当属性修改时(触发 prop setter), 会调用
dep.notify(),遍历当前收集的依赖(Watcher),并调用每个Watcher的update()API,然后Watcher内部会完成更新操作(更新函数)的执行。
其中涉及到一个新的概念 —— Watcher, 下一小节详细了解。
Watcher (谁)
问题: 响应式对象属性被访问时,对应的getter如何知道谁访问的?或者说:Dep如何知道谁用了自己?
想象这样一个场景:当某个函数执行时,用到了某个响应式数据。js是无法直接知道是这个函数使用了这个响应式数据,从而也就无法在数据更新时,再次找到这个函数重新执行,完成更新。
Object.defineProperty(obj, 'a', {
get() {
dep.depend()
// dep.depend() -> Dep.target.addDep(dep) -> dep.addSup(Dep.target) -> dep.subs添加了一个watcher
},
set() {
dep.notify()
// dep.notify() -> 遍历 dep.subs -> 调用每个依赖的watcher的 watcher.update() -> 每个watcher把自己交给一个 Scheduler(调度器) -> scheduler把watcher队列放入事件循环的微任务队列中异步执行。
}
})
// 举例: 组件渲染时,render函数如何被收集的
function render() {
// 这个render函数调用时,用到了响应式数据 obj.a
console.log(obj.a)
}
// ################################ ################################
// 🙋问题来了: obj.a的如何知道是render函数调用时用到了自己?
//################################ ################################
// 解决方案: Watcher
// ① 用 watcher包装render
var watcher = new Watcher(render)
// ② 第一次渲染时
watcher.run()
// ③ 此时
Dep.target = watcher // Dep.target 通过一个 栈 去管理
// ④ watcher.run() -> render()
render()
// ⑤ render() 执行期间 访问响应式数据,开始收集依赖 (各个数据会将当前的wather -> Dep.target,存储到自己的 dep.subs里)
dep.depend() -> ... -> dep.addSub(Dep.target)
// ⑥ 数据更新时, 对应数据的dep通知依赖的watcher更新
dep.notify() -> ... -> dep.subs ... watcher.update() -> queueWatcher
// ⑤ 待更新的watcher将自己交个scheduler去执行,放到微任务队列中执行。
scheduler -> nextTick -> queueWatcher -> watcher.run
为了解决上面的问题,Vue引入一种巧妙方法,引入新的概念—— Watcher。具体做法是: Dep 不是直接收集和派发的更新操作(函数),而是收集和派发 Watcher,然后通过Watcher执行更新操作(函数) 。所以,每个更新操作(函数)都应该创建一个Watcher来包装一下,通过Watcher代理执行更新操作。
Watcher可以理解为当数据更新时,那些需要执行的更新操作(如:render function)的代理执行对象。
原理图示如下:
总结一下:
-
每个Vue组件实例,都至少对应一个 watcher, 该watcher中记录了该组件的render函数
-
watcher首先执行一次 render函数,过程中,会收集依赖(在render函数中使用到的响应式数据就会记录这个watcher)
-
数据更新时,Dep会通知自身收集的所有Watcher,然后由各个Watcher去完成具体执行的更新操作(函数)。更新函数执行后,界面重新渲染。并且又会重新进行依赖的收集。 参考调用流程:
props setter -> dep.notify() -> depWatcher.update() -> nextTickWatcherRun -> render function -> updateComponent
Scheduler
问题:当多个响应式数据更新时,是否有必要多次执行同一个watcher对应的更新操作?
结合上面问题,试想一下:如果一个交给watcher的函数,它里面用到了a、b、c、d四个响应式属性,那么这四个属性都会记录依赖。于是下面的代码将触发4次更新:
this.obj.a = 'new a'
this.obj.b = 'new b'
this.obj.c = 'new c'
this.obj.d = 'new d'
这样显然是不合适的。很明显:为了提升效率,不应该频繁的执行watcher 。
于是,引入了 Scheduler(调度器)的概念。
Scheduler(调度器)的工作原理:当Dep通知watcher更新后,不能立即执行更新操作,而是将watcher交给一个Scheduler去管理。Scheduler会维护一个队列,用于存放等待被执行的watcher,且同一个watcher仅会存在一次。 然后将队列中的watcher放入事件循环的**微任务(nextTick)**中去执行。
小结:
- 组件渲染时,render函数是在事件循环的微任务队列中异步被执行的。
异步更新队列
Vue侦听到数据变化,就会开启一个队列。但是组件不会立即重新渲染,而是先会缓冲在同一个事件循环中的发生的所有数据变化。此时如果同一个watcher被多次触发,只会被推入到队列中一次,这样可以避免不必要的计算和DOM更操作。
在下一个事件循环”tick“中, Vue刷新队列并执行实际(已去重的)工作(更新渲染)。
为此,Vue提供了异步更新的监听接口 ——
Vue.nextTick(callback)或this.$nextTick(callback)。当数据发生改变,异步DOM更新完成后,callback回调将被调用。开发者可以在回调中,操作更新后的DOM。
举例1
export default {
data() {
return {
a: 1,
b: 2,
c: 3,
d: 4,
};
},
methods: {
changeAllData() {
this.$nextTick(function () {
var pre = document.querySelector("pre");
console.log(pre.textContent);
});
this.a = this.b = this.c = this.d = 10;
this.$nextTick(function () {
var pre = document.querySelector("pre");
console.log(pre.textContent);
});
},
},
render(h) {
console.log('render function')
return h('div', [
h('pre', `${this.a}, ${this.b}, ${this.c}, ${this.d}`),
h('button', {
on: {
click: () => {
this.changeAllData()
}
}
}, 'change all data')
])
}
};
上例,通过一个NextTick组件的渲染,了解下 $nextTick的用法。为了方便查看组件渲染时,render函数被调用的过程,在组件定义时,直接给出render函数。当点击按钮后, 会在数据修改前后,使用 $nextTick工具方法,分别写入两个读取界面Dom的函数。结果会发现,第一个 $nextTick 回调函数获取的数据为旧数据,第二个 $nextTick回调函数获取的数据为新数据。
分析一下:
按钮点击后,异步队列的添加步骤是:
- 第一个
$nextTick,会将自己的回调函数(fn1)加入到当前的异步队列中。 - 修改数据后, 经过派发更新,Scheduler会将包含了watcher队列执行逻辑的函数(fn2)加入到当前的异步队列中。
- 第二个
$nextTick, 已将自己的回调函数(fn3)加入到当前的异步队列中。
当异步队列执行时,会依次执行 fn1 , fn2,fn3。而当fn2执行后,界面才会更新最新数据,所以fn1,fn3获取的界面数据前者为旧数据,后者为新数据。
举例2
<template>
<span>{{a}}</span>
</template>
<script>
export default {
data() {
return {
a: 'hello'
}
},
mounted() {
this.a = 'world'
console.log(this.$el.textContent) // -> 'hello'
this.$nextTick(function() {
console.log(this.$el.textContent) // -> 'world'
})
}
}
</script>
上面代码,当设置 this.a = 'world' 后,访问DOM元素内容,但完成未更新。此时,立即使用 this.$nextTick() 监听DOM更新,并在监听回调调用时,获取更新后的DOM内容。
另外, this.$nextTick() 其内部尝试使用原生的 Promise.then、MutationObserve、setImmediate,如果执行环境不支持,则会采用 setTimeout 替代。并且最终返回一个Promise对象,所以可以使用 async/await 语法替代 callback 的写法。
<script>
export default {
data() {
return {
a: 'hello'
}
},
// $nextTick 结合 async/await语法使用
async mounted() {
this.a = 'world'
console.log(this.$el.textContent) // -> 'hello'
await this.$nextTick()
console.log(this.$el.textContent) // -> 'world'
}
}
</script>
最后
如果觉得有用,顺便点个赞吧!你的支持是我最大的鼓励!
微信关注 “乘风破浪大前端”,发现更多有趣好文的前端知识和实战。
关于本文如有任何意见或建议,欢迎评论区讨论。