深入理解vue的响应式原理
# 前言
众所周知,vue2 是通过 Object.defineProperty 实现依赖监听的,vue3 是通过 proxy 实现依赖监听的。下面就两种方式进行讲解。
# vue2
# Object.defineProperty(obj, prop, descriptor) (opens new window)
在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。
- obj,要定义属性的对象。
- prop,一个字符串或 Symbol,指定了要定义或修改的属性键。
- descriptor,要定义或修改的属性的描述符。
const obj = {};
Object.defineProperty(obj, "key2", {
enumerable: false, // 是否可枚举,为false则不会在v-for等枚举方法中出现
configurable: false, // 是否可配置,为false则不可被删除,该属性的类型不能在数据属性和访问器属性之间更改
get() { // 访问 obj.key2 触发 ,比如 if(obj.key2 === 3) 或者 console.log(obj.key2)
return bValue;
},
set(newValue) { // 设置值的时候触发,比如 obj.key2 = 2
bValue = newValue;
},
});
# 收集依赖
我们之所以要观察数据,其目的是当数据的属性发生变化时,可以通知那些曾经使用了该数据的地方。
在 Vue.js 2.0 中, 模板使用数据等同于组件使用数据, 所以当数据发生变化时, 会将通知发送到组件, 然后组件内部再通过虚拟 DOM 重新渲朵。
答案:先收集依赖, 即把用到数据 name 的地方收集起来, 然后等属性发生变化时,把之前收集好的依赖循环触发一遍就好了。总结起来, 其实就一句话, 在 getter 中收集依赖, 在 setter 中触发依赖。
# 依赖收集放在哪里
先假象一个数组,每次调用get时,我们都往这个数组放入这个依赖(可以想象成展示这个值的div节点位置),每次调用set时,我们就需要循环这个数组,来循环更新每个依赖(更新每个div节点里面的值)。
但是数组太粗糙了,所以抽象成一个类,这个类有一个subs数组,然后有添加依赖和移除依赖,向依赖发送更新的方法。
class Dep{
constructor(){
this.subs = []
}
addSub(sub){
this.subs.push(sub)
}
removeSub(sub){
if(this.subs.length){
const index = this.subs.indexOf(sub)
if(index > -1){
return this.subs.splice(index, 1)
}
}
}
depend(){ // 收集依赖,先拿window.target假装是依赖
if(window.target){
this.addSub(window.target)
}
}
notify(){
const subs = this.subs.slice()
// slice 会返回原数组的浅拷贝
for(let i=0, l = subs.length;i < l; i++) {
subs[i].update()
}
}
}
# 依赖是谁
依赖依赖,就是使用到 这个数据 的地方,收集依赖干嘛?在发生变化的时候告诉这些地方,变了。
但是使用数据的地方可能是某个div,也可能是用户写的一个watch,所以我们需要抽象出来一个类 Watcher。
# 什么是 Watcher
Watcher 是一个中介的角色,数据发生变化时通知它, 然后它再通知其他地方。
我们只需要把 watcher 实例添加到 需要监听的属性(比如 data.a.b )的 Dep 中就行了。当属性发生变化时,通知watcher,watcher再执行一个回调函数,告诉使用到数据的地方就行了。
下面的方法最难弄明白的已经通过序号标识了出来
export default class Watcher {
constructor (vm, expOrFn, cb){
this.vm = vm
// 1. parsePath 会返回一个方法,调用这个方法会读取 expOrFn 的值
this.getter = parsePath(expOrFn)
this.cb = cb // 记录回调函数
this.value = this.get()
}
get(){
// 2. 把 watcher 放在 window.target 上面,然后读取 expOrFn 的值的时候会把 window.target 放入 Dep
window.target = this
let value = this.getter.call(this.vm, this.vm); // 触发读取值的操作
window.target = undefined
return value
}
// 会在 监听的值变化时,调用此函数,此函数会触发回调方法 cb
update(){
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}
parsePath 是读取属性值,内容如下
const bailRE = /[^\w.$]/
function parsePath(path){
if(bailRE.test(path)){
return
}
const segments = path.split('.') // 通过 . 分割路径
return function (obj) {
for(let i = 0;i < segments.length; i++) {
if(!obj) return
obj = obj[segments[i]]
}
return obj
}
}
可以看到他返回了一个方法,这个方法循环读取属性路径,直到读取我们监听的值。
Function.prototype.call() 以给定的 this 值和逐个提供的参数调用该函数。
function Product(name, price) {
this.name = name;
this.price = price;
}
function Food(name, price) {
Product.call(this, name, price);
this.category = 'food';
}
console.log(new Food('cheese', 5).name);
// Expected output: "cheese"
# 一个完整的实践
让我们完整的理一遍流程。
- 通过 Object.defineProperty 我们给某个值添加了监听依赖,这个步骤我们抽象出来了方法 defineReactive
- defineReactive 中有一个 Dep 对象,这个对象负责收集依赖(get里面收集),并在监听值变化的时候(set的时候)触发依赖的更新
- 依赖就是 Watcher 对象,每个用到这个值的地方,比如某个div的内容,或者某个watch对象,都是一个 Watcher 实例。
- Watcher 有一个 expOrFn 是监听值的路径,有一个 cb 是回调函数。
- Watcher 会在初始化时,通过路径读取监听值,从而触发 Dep 的收集依赖行为,Watcher 还把自己放到了 window.target 上面,方便 Dep 来收集他
- Watcher 有一个 update 方法,在 Dep 通知依赖更新的时候,会依次调用每个 Watcher 的 update 方法,update 又会调用之前的 cb 回调函数,从而触发更新
下面的实例预览
会监听 data.a.b.c 的值,并把他放到 某个div 里面,当 data.a.b.c 变化时,更新div的内容。
# 数组的奇妙现象
push()、pop()、shift()、unshift()、splice()、sort()、reverse()这些方法会改变被操作的数组; filter()、concat()、slice()这些方法不会改变被操作的数组,返回一个新的数组; 以上方法都可以触发视图更新。
以下两种方法不可以触发视图更新;
- 利用索引直接设置一个数组项,例:this.array[index] = newValue
- 可以用this.$set(this.array,index,newValue)或this.array.splice(index,1,newValue)解决
- 直接修改数组的长度,例:this.array.length = newLength
- 可以用this.array.splice(newLength)解决