2021-07-13 系统装修,补充gitee仓库 2021-02-14 补充 2019-11-26 初稿
之前尝试研究 Vue2.x
的数据响应式原理,上次更新还是2019-11-26,最近又有兴趣了,尝试补充和完善。
我建立了一个仓库 https://gitee.com/xiaoxfa/source-vue2
本次向尝试总结的是下面的大纲:
掌握了大纲,回头去看vue3 的响应式能触类旁通。
前置知识
Object.defineProperty
对一个对象里的key做拦截,手动设置 getter
setter
方法。有了这个特性,我们操作数据时候就能拦截到,配合后面的依赖搜集机制,能完成最基本的数据响应式变化。
上一段简单的代码辅助说明:
const obj = {}
/**
* 响应式
* @param {object} obj
* @param {string} key
* @param {any} val
*/
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
console.log(`你正在读取${key}:${val}`)
return val
},
set(newVal) {
if (newVal !== val) {
val = newVal
console.log(`setter: old:${val}, new:${newVal}`)
// do sth.
document.querySelector('#app').innerHTML = newVal
}
},
})
}
defineReactive(obj, 'time', '')
setInterval(() => {
obj.time = +new Date()
}, 1000)
观察控制台:
$ obj.time
> 你正在读取time:
$ obj.time=3
> setter: old:3, new:3
通过这个简单函数,我们修改对应的key属性,就能自动更新数据状态。当然了,这里只是一个简单的实现。
数据劫持
还是思考刚才的案例,这里我们没有考虑深层嵌套的情况,因此需要完善细节。
- 如果一个值是object类型,就需要进行劫持,伪代码
typeof val===object && val!==null
见代码@/state.js#initData
- 封装对象的处理方法,
class Observer{}
,定义this.walk()
方法 - 对于一个 object,需要 Object.keys 循环得到每一项key
- keys.forEach — defineReactive — defineProperty
- 处理嵌套,深度优先,先处理递归。由此可见如果处理复杂数据vue会有性能隐患,后续vue3的懒递归proxy能解决
- 此时赋值操作有两种
a={b:1}
和a.b=1
,因此value是对象还要继续递归。新属性无法被拦截 - 对象中的数组同样可以代理,但是实际情况中很少对索引取值和赋值,不必要,因此数组不必循环代理
- 数组中有7种方法会修改原始数组
push pop shift unshit splice sort reverse
但没有原生的拦截方法,因此需要hack,也就是重写数组方法,而 forEach map 不会改变原始数组可以忽略 - 思路是切面编程,继承原始数组方法,先调用原始方法,再执行自定义方法。继承的思路是es5里的 Object.create,也可以使用 object.setPrototype
- 考虑 数组中的对象,对象中的数组,因此需要判断是 普通的object还是 array 来执行不同的方法
- 数组改变数据,如果是新增的内容(insert)同样需要设置响应式,
push unshift
可以直接拿到赋值,splice第三个参数如果有值也意味着是补充数据 - insert内容也需要被观测,这里引入
__ob__
属性表示是否被观测过,值是observer的实例this,为了避免__ob__
也被观测,设置enumberable
configureable
为false
,value
设置为this
- 这样在数组中可以通过
__ob__
来拿到 实例方法,可以对insert的数据进行代理 - 数据简化代理
this._data.xx
,简化为this.xx
,思路也是 defineProperty
代码部分,见仓库。
响应式
实现了数据劫持是不够的,我们希望数据驱动,数据发生了变化就更新数据,过程是自动的。
这里就引入了新的概念,以下是简化版。
- 编译阶段,挂载到页面时候,
new Watcher(vm, updateComponent, () => {}, true);
- 这个
class Watcher
会搜集 第二个参数,也就是更新节点的方法 exprOrFn里面会访问劫持的数据get方法,也即是取值。取值时候会触发
defineReactvie
中的getlet id = 0
class Wacher {
constructor(vm, exprOrFn, ...args){
this.exprOrFn = exprOrFn
this.getter = exprOrFn
this.id = id++
this.get()
}
get(){
Dep.target = this
this.getter()
Dep.target = null
}
update(){this.get}
}
// 这意味着,每次 newWatcher会立即更新一次,后续watcher实例调用 get方法会继续更新
在get方法中添加
new dep=new Dep()
准备管理依赖
function defineReactive(data,key,value){
let dep = new Dep();
Object.defineProperty(data,key,{
get(){
if(Dep.target){ // 如果取值时有watcher
dep.depend(); // 让watcher保存dep,并且让dep 保存watcher
}
},
set(newValue){
dep.notify(); // 通知渲染watcher去更新
}
})
}
class Dep
用来管理页面中的watcher- 一个组件是一个watcher,如果用户用到了 watch会增加新的watcher>一个组件会有多个属性,每个属性出现一次就是一个 dep实例。因此 dep和watcher是多对多的关系,dep里管理多个watcher,一个watcher里有多个dep,后续如果依赖项
class Dep{
constructor(){
this.subs=[]
}
depned(){
this.subs.push(Dep.target)
}
notify(){
this.subs.forEach(w=>w.update())
}
}
Dep.target=null
重新梳理三者关系:
- new Watcher 创建watcher实例,包含了要更新的代码,同时把当前watcher实例放入 Dep.target
- 更新代码中会取值,触发 observe 的get方法,能够取得 watcher实例,通知dep.subs 塞入watcher
- 后续触发修改值set方法,通知dep.notify 这时候调用subs所有watcher实例update方法,也就是重新执行渲染
后续还有数组的部分。
这里还有一个问题,就是多次修改值,会触发多次,不太好,引入了异步更新。
异步更新
思路大致如下:
- 多次调用更新
- 准备更新任务队列queue=[], 队列去重 has={},是否排队 pending=false
- 获得当前watcher.id 准备放入has中,如果第一次,防 queue队列中
- 设定定时器统一处理queue更新方法,并设置flag
- 操作完成重置 queue,has和pending
在vue3中已经不考虑异步更新的兼容问题了,在vue2中 promise.then > MutationObserver > setImmediate > setTimeout
这块不是很感兴趣,就看了看。