同学们,你是否想学习Vue
的数据响应式原理而无从下手呢?是否有过被复杂的源码教程劝退的经历呢?如果你和我一样,做过一个项目之后想深入原理的话,恭喜你,你来对地方了。这个系列文章将从纯粹的Vue
响应式原理出发,没有其他因素的干扰,带领大家实现一个自己的响应式系统。
友情提示:因为我们的代码会经过多个版本的修改,所以我希望大家在看文章的时候能够把涉及到的代码手敲一遍,这样能够帮助理解。
项目地址:gitee
系列地址:
0.前言
数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。
使用Vue
时,我们只需要修改数据(state
),视图就能够获得相应的更新,这就是响应式系统。要实现一个自己的响应式系统,我们首先要明白要做什么事情:
- 数据劫持:当数据变化时,我们可以做一些特定的事情
- 依赖收集:我们要知道那些视图层的内容(
DOM
)依赖了哪些数据(state
) - 派发更新:数据变化后,如何通知依赖这些数据的
DOM
接下来,我们将一步步地实现一个自己的玩具响应式系统
1. 数据劫持
几乎所有的文章和教程,在讲解Vue
响应式系统时都会先讲:Vue
使用Object.defineProperty
来进行数据劫持。那么,我们也从数据劫持讲起,大家可能会对劫持
这个概念有些迷茫,没有关系,看完下面的内容,你一定会明白。
Object.defineProperty
的用法在此不多做介绍,不明白的同学可在MDN上查阅。下面,我们为obj
定义一个a
属性
const obj = {}
let val = 1
Object.defineProperty(obj, a, {
get() { // 下文中该方法统称为getter
console.log('get property a')
return val
},
set(newVal) { // 下文中该方法统称为setter
if (val === newVal) return
console.log(`set property a -> ${newVal}`)
val = newVal
}
})
复制代码
这样,当我们访问obj.a
时,打印get property a
并返回1,obj.a = 2
设置新的值时,打印set property a -> 2
。这相当于我们自定义了obj.a
取值和赋值的行为,使用自定义的getter
和setter
来重写了原有的行为,这也就是数据劫持
的含义。
但是上面的代码有一个问题:我们需要一个全局的变量来保存这个属性的值,因此,我们可以用下面的写法
// value使用了参数默认值
function defineReactive(data, key, value = data[key]) {
Object.defineProperty(data, key, {
get: function reactiveGetter() {
return value
},
set: function reactiveSetter(newValue) {
if (newValue === value) return
value = newValue
}
})
}
defineReactive(obj, a, 1)
复制代码
class Observer {
constructor(value) {
this.value = value
this.walk()
}
walk() {
Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
}
}
const obj = { a: 1, b: 2 }
new Observer(obj)
复制代码
如果obj
内有嵌套的属性呢?我们可以使用递归来完成嵌套属性的数据劫持
// 入口函数
function observe(data) {
if (typeof data !== 'object') return
// 调用Observer
new Observer(data)
}
class Observer {
constructor(value) {
this.value = value
this.walk()
}
walk() {
// 遍历该对象,并进行数据劫持
Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
}
}
function defineReactive(data, key, value = data[key]) {
// 如果value是对象,递归调用observe来监测该对象
// 如果value不是对象,observe函数会直接返回
observe(value)
Object.defineProperty(data, key, {
get: function reactiveGetter() {
return value
},
set: function reactiveSetter(newValue) {
if (newValue === value) return
value = newValue
observe(newValue) // 设置的新值也要被监听
}
})
}
const obj = {
a: 1,
b: {
c: 2
}
}
observe(obj)
对于这一部分,大家可能有点晕,接下来梳理一下:
执行observe(obj)
├── new Observer(obj),并执行this.walk()遍历obj的属性,执行defineReactive()
├── defineReactive(obj, a)
├── 执行observe(obj.a) 发现obj.a不是对象,直接返回
├── 执行defineReactive(obj, a) 的剩余代码
├── defineReactive(obj, b)
├── 执行observe(obj.b) 发现obj.b是对象
├── 执行 new Observer(obj.b),遍历obj.b的属性,执行defineReactive()
├── 执行defineReactive(obj.b, c)
├── 执行observe(obj.b.c) 发现obj.b.c不是对象,直接返回
├── 执行defineReactive(obj.b, c)的剩余代码
├── 执行defineReactive(obj, b)的剩余代码
代码执行结束
复制代码
可以看出,上面三个函数的调用关系如下:
三个函数相互调用从而形成了递归,与普通的递归有所不同。 有些同学可能会想,只要在setter
中调用一下渲染函数来重新渲染页面,不就能完成在数据变化时更新页面了吗?确实可以,但是这样做的代价就是:任何一个数据的变化,都会导致这个页面的重新渲染,代价未免太大了吧。我们想做的效果是:数据变化时,只更新与这个数据有关的DOM
结构,那就涉及到下文的内容了:依赖
2. 收集依赖与派发更新
依赖
在正式讲解依赖收集之前,我们先看看什么是依赖。举一个生活中的例子:淘宝购物。现在淘宝某店铺上有一块显卡(空气)处于预售阶段,如果我们想买的话,我们可以点击预售提醒
,当显卡开始卖的时候,淘宝为我们推送一条消息,我们看到消息后,可以开始购买。
将这个例子抽象一下就是发布-订阅模式:买家点击预售提醒,就相当于在淘宝上登记了自己的信息(订阅),淘宝则会将买家的信息保存在一个数据结构中(比如数组)。显卡正式开放购买时,淘宝会通知所有的买家:显卡开卖了(发布),买家会根据这个消息进行一些动作(比如买回来挖矿)。
在Vue
响应式系统中,显卡对应数据,那么例子中的买家对应什么呢?就是一个抽象的类: Watcher
。大家不必纠结这个名字的含义,只需要知道它做什么事情:每个Watcher
实例订阅一个或者多个数据,这些数据也被称为wacther
的依赖(商品就是买家的依赖);当依赖发生变化,Watcher
实例会接收到数据发生变化这条消息,之后会执行一个回调函数来实现某些功能,比如更新页面(买家进行一些动作)。
因此Watcher
类可以如下实现
class Watcher {
constructor(data, expression, cb) {
// data: 数据对象,如obj
// expression:表达式,如b.c,根据data和expression就可以获取watcher依赖的数据
// cb:依赖变化时触发的回调
this.data = data
this.expression = expression
this.cb = cb
// 初始化watcher实例时订阅数据
this.value = this.get()
}
get() {
const value = parsePath(this.data, this.expression)
return value
}
// 当收到数据变化的消息时执行该方法,从而调用cb
update() {
this.value = parsePath(this.data, this.expression) // 对存储的数据进行更新
cb()
}
}
function parsePath(obj, expression) {
const segments = expression.split('.')
for (let key of segments) {
if (!obj) return
obj = obj[key]
}
return obj
}
复制代码
如果你对
Watcher
这个类什么时候实例化有疑问的话,没关系,下面马上就会讲到
其实前文例子中还有一个点我们尚未提到:显卡例子中说到,淘宝会将买家信息保存在一个数组中,那么我们的响应式系统中也应该有一个数组来保存买家信息,也就是watcher
。
总结一下我们需要实现的功能:
- 有一个数组来存储
watcher
watcher
实例需要订阅(依赖)数据,也就是获取依赖或者收集依赖watcher
的依赖发生变化时触发watcher
的回调函数,也就是派发更新。
每个数据都应该维护一个属于自己的数组,该数组来存放依赖自己的watcher
,我们可以在defineReactive
中定义一个数组dep
,这样通过闭包,每个属性就能拥有一个属于自己的dep
function defineReactive(data, key, value = data[key]) {
const dep = [] // 增加
observe(value)
Object.defineProperty(data, key, {
get: function reactiveGetter() {
return value
},
set: function reactiveSetter(newValue) {
if (newValue === value) return
value = newValue
observe(newValue)
dep.notify()
}
})
}
复制代码
到这里,我们实现了第一个功能,接下来实现收集依赖的过程。
依赖收集
现在我们把目光集中到页面的初次渲染过程中(暂时忽略渲染函数和虚拟DOM
等部分):渲染引擎会解析模板,比如引擎遇到了一个插值表达式,如果我们此时实例化一个watcher
,会发生什么事情呢?从Watcher
的代码中可以看到,实例化时会执行get
方法,get
方法的作用就是获取
自己依赖的数据,而我们重写了数据的访问行为,为每个数据定义了getter
,因此getter
函数就会执行,如果我们在getter
中把当前的watcher
添加到dep
数组中(淘宝低登记买家信息),不就能够完成依赖收集了吗!!
注意:执行到
getter
时,new Watcher()
的get
方法还没有执行完毕。
new Watcher()
时执行constructor
,调用了实例的get
方法,实例的get
方法会读取数据的值,从而触发了数据的getter
,getter
执行完毕后,实例的get
方法执行完毕,并返回值,constructor
执行完毕,实例化完毕。
有些同学可能会有疑惑:明明是
watcher
收集依赖,应该是watcher
收集数据,怎么成了数据的dep
收集watcher
了呢?有此疑问的同学可以再看一下前面淘宝的例子(是淘宝记录了用户信息),或者深入了解一下发布-订阅模式。
通过上面的分析,我们只需要对getter
进行一些修改:
get: function reactiveGetter() {
dep.push(watcher) // 新增
return value
}
复制代码
问题又来了,watcher
这个变量从哪里来呢?我们是在模板编译函数中的实例化watcher
的,getter
中取不到这个实例啊。解决方法也很简单,将watcher
实例放到全局不就行了吗,比如放到window.target
上。因此,Watcher
的get
方法做如下修改
get() {
window.target = this // 新增
const value = parsePath(this.data, this.expression)
return value
}
这样,将get
方法中的dep.push(watcher)
修改为dep.push(window.target)
即可。
注意,不能这样写
window.target = new Watcher()
。因为执行到getter
的时候,实例化watcher
还没有完成,所以window.target
还是undefined
依赖收集过程:渲染页面时碰到插值表达式,
v-bind
等需要数据等地方,会实例化一个watcher
,实例化watcher
就会对依赖的数据求值,从而触发getter
,数据的getter
函数就会添加依赖自己的watcher
,从而完成依赖收集。我们可以理解为watcher
在收集依赖,而代码的实现方式是在数据中存储依赖自己的watcher
细心的读者可能会发现,利用这种方法,每遇到一个插值表达式就会新建一个
watcher
,这样每个节点就会对应一个watcher
。实际上这是vue1.x
的做法,以节点为单位进行更新,粒度较细。而vue2.x
的做法是每个组件对应一个watcher
,实例化watcher
时传入的也不再是一个expression
,而是渲染函数,渲染函数由组件的模板转化而来,这样一个组件的watcher
就能收集到自己的所有依赖,以组件为单位进行更新,是一种中等粒度的方式。要实现vue2.x
的响应式系统涉及到很多其他的东西,比如组件化,虚拟DOM
等,而这个系列文章只专注于数据响应式的原理,因此不能实现vue2.x
,但是两者关于响应式的方面,原理相同。
vue官网解释
当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,
并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,
这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。
这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式化并不同,所以建议安装 vue-devtools 来获取对检查数据更加友好的用户界面。
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。
之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
这里如何理解vue中三种watcher呢?
派发更新
实现依赖收集后,我们最后要实现的功能是派发更新,也就是依赖变化时触发watcher
的回调。从依赖收集部分我们知道,获取哪个数据,也就是说触发哪个数据的getter
,就说明watcher
依赖哪个数据,那数据变化的时候如何通知watcher
呢?相信很多同学都已经猜到了:在setter
中派发更新。
set: function reactiveSetter(newValue) {
if (newValue === value) return
value = newValue
observe(newValue)
dep.forEach(d => d.update()) // 新增 update方法见Watcher类
}
3. 优化代码
1. Dep类
我们可以将dep
数组抽象为一个类:
class Dep {
constructor() {
this.subs = []
}
depend() {
this.addSub(Dep.target)
}
notify() {
const subs = [...this.subs]
subs.forEach((s) => s.update())
}
addSub(sub) {
this.subs.push(sub)
}
}
defineReactive
函数只需做相应的修改
function defineReactive(data, key, value = data[key]) {
const dep = new Dep() // 修改
observe(value)
Object.defineProperty(data, key, {
get: function reactiveGetter() {
dep.depend() // 修改
return value
},
set: function reactiveSetter(newValue) {
if (newValue === value) return
value = newValue
observe(newValue)
dep.notify() // 修改
}
})
}
2. window.target
在watcher
的get
方法中
get() {
window.target = this // 设置了window.target
const value = parsePath(this.data, this.expression)
return value
}
大家可能注意到了,我们没有重置window.target
。有些同学可能认为这没什么问题,但是考虑如下场景:有一个对象obj: { a: 1, b: 2 }
我们先实例化了一个watcher1
,watcher1
依赖obj.a
,那么window.target
就是watcher1
。之后我们访问了obj.b
,会发生什么呢?访问obj.b
会触发obj.b
的getter
,getter
会调用dep.depend()
,那么obj.b
的dep
就会收集window.target
, 也就是watcher1
,这就导致watcher1
依赖了obj.b
,但事实并非如此。为解决这个问题,我们做如下修改:
// Watcher的get方法
get() {
window.target = this
const value = parsePath(this.data, this.expression)
window.target = null // 新增,求值完毕后重置window.target
return value
}
// Dep的depend方法
depend() {
if (Dep.target) { // 新增
this.addSub(Dep.target)
}
}
通过上面的分析能够看出,window.target
的含义就是当前执行上下文中的watcher
实例。由于js
单线程的特性,同一时刻只有一个watcher
的代码在执行,因此window.target
就是当前正在处于实例化过程中的watcher
3. update方法
我们之前实现的update
方法如下:
update() {
this.value = parsePath(this.data, this.expression)
this.cb()
}
大家回顾一下vm.$watch
方法,我们可以在定义的回调中访问this
,并且该回调可以接收到监听数据的新值和旧值,因此做如下修改
update() {
const oldValue = this.value
this.value = parsePath(this.data, this.expression)
this.cb.call(this.data, this.value, oldValue)
}
4. 学习一下Vue源码
在Vue源码–56行中,我们会看到这样一个变量:targetStack
,看起来好像和我们的window.target
有点关系,没错,确实有关系。设想一个这样的场景:我们有两个嵌套的父子组件,渲染父组件时会新建一个父组件的watcher
,渲染过程中发现还有子组件,就会开始渲染子组件,也会新建一个子组件的watcher
。在我们的实现中,新建父组件watcher
时,window.target
会指向父组件watcher
,之后新建子组件watcher
,window.target
将被子组件watcher
覆盖,子组件渲染完毕,回到父组件watcher
时,window.target
变成了null
,这就会出现问题,因此,我们用一个栈结构来保存watcher
。
const targetStack = []
function pushTarget(_target) {
targetStack.push(window.target)
window.target = _target
}
function popTarget() {
window.target = targetStack.pop()
}
Watcher
的get
方法做如下修改
get() {
pushTarget(this) // 修改
const value = parsePath(this.data, this.expression)
popTarget() // 修改
return value
}
此外,Vue
中使用Dep.target
而不是window.target
来保存当前的watcher
,这一点影响不大,只要能保证有一个全局唯一的变量来保存当前的watcher
即可
5.总结代码
现将代码总结如下:
// 调用该方法来检测数据
function observe(data) {
if (typeof data !== 'object') return
new Observer(data)
}
class Observer {
constructor(value) {
this.value = value
this.walk()
}
walk() {
Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
}
}
// 数据拦截
function defineReactive(data, key, value = data[key]) {
const dep = new Dep()
observe(value)
Object.defineProperty(data, key, {
get: function reactiveGetter() {
dep.depend()
return value
},
set: function reactiveSetter(newValue) {
if (newValue === value) return
value = newValue
observe(newValue)
dep.notify()
}
})
}
// 依赖
class Dep {
constructor() {
this.subs = []
}
depend() {
if (Dep.target) {
this.addSub(Dep.target)
}
}
notify() {
const subs = [...this.subs]
subs.forEach((s) => s.update())
}
addSub(sub) {
this.subs.push(sub)
}
}
Dep.target = null
const TargetStack = []
function pushTarget(_target) {
TargetStack.push(Dep.target)
Dep.target = _target
}
function popTarget() {
Dep.target = TargetStack.pop()
}
// watcher
class Watcher {
constructor(data, expression, cb) {
this.data = data
this.expression = expression
this.cb = cb
this.value = this.get()
}
get() {
pushTarget(this)
const value = parsePath(this.data, this.expression)
popTarget()
return value
}
update() {
const oldValue = this.value
this.value = parsePath(this.data, this.expression)
this.cb.call(this.data, this.value, oldValue)
}
}
// 工具函数
function parsePath(obj, expression) {
const segments = expression.split('.')
for (let key of segments) {
if (!obj) return
obj = obj[key]
}
return obj
}
// for test
let obj = {
a: 1,
b: {
m: {
n: 4
}
}
}
observe(obj)
let w1 = new Watcher(obj, 'a', (val, oldVal) => {
console.log(`obj.a 从 ${oldVal}(oldVal) 变成了 ${val}(newVal)`)
})
4. 注意事项
1. 闭包
Vue
能够实现如此强大的功能,离不开闭包的功劳:在defineReactive
中就形成了闭包,这样每个对象的每个属性就能保存自己的值value
和依赖对象dep
。
2. 只要触发getter就会收集依赖吗
答案是否定的。在Dep
的depend
方法中,我们看到,只有Dep.target
为真时才会添加依赖。比如在派发更新时会触发watcher
的update
方法,该方法也会触发parsePath
来取值,但是此时的Dep.target
为null
,不会添加依赖。仔细观察可以发现,只有watcher
的get
方法中会调用pushTarget(this)
来对Dep.target
赋值,其他时候Dep.target
都是null
,而get
方法只会在实例化watcher
的时候调用,因此,在我们的实现中,一个watcher
的依赖在其实例化时就已经确定了,之后任何读取值的操作均不会增加依赖。
3. 依赖嵌套的对象属性
我们结合上面的代码来思考下面这个问题:
let w2 = new Watcher(obj, 'b.m.n', (val, oldVal) => {
console.log(`obj.b.m.n 从 ${oldVal}(oldVal) 变成了 ${val}(newVal)`)
})
我们知道,w2
会依赖obj.b.m.n
, 但是w2
会依赖obj.b, obj.b.m
吗?或者说,obj.b,和obj.b.m
,它们闭包中保存的dep
中会有w2
吗?答案是会。我们先不从代码角度分析,设想一下,如果我们让obj.b = null
,那么很显然w2
的回调函数应该被触发,这就说明w2
会依赖中间层级的对象属性。
接下来我们从代码层面分析一下:new Watcher()
时,会调用watcher的get
方法,将Dep.target
设置为w2
,get
方法会调用parsePath
来取值,我们来看一下取值的具体过程:
function parsePath(obj, expression) {
const segments = expression.split('.') // 先将表达式分割,segments:['b', 'm', 'n']
// 循环取值
for (let key of segments) {
if (!obj) return
obj = obj[key]
}
return obj
}
以上代码流程如下:
- 局部变量
obj
为对象obj
,读取obj.b
的值,触发getter
,触发dep.depend()
(该dep
是obj.b
的闭包中的dep
),Dep.target
存在,添加依赖 - 局部变量
obj
为obj.b
,读取obj.b.m
的值,触发getter
,触发dep.depend()
(该dep
是obj.b.m
的闭包中的dep
),Dep.target
存在,添加依赖 - 局部变量
obj
为对象obj.b.m
,读取obj.b.m.n
的值,触发getter
,触发dep.depend()
(该dep
是obj.b.m.n
的闭包中的dep
),Dep.target
存在,添加依赖
从上面的代码可以看出,w2
会依赖与目标属性相关的每一项,这也是符合逻辑的。
5. 总结
总结一下:
- 调用
observe(obj)
,将obj
设置为响应式对象,observe函数,Observe, defineReactive函数
三者互相调用,从而递归地将obj
设置为响应式对象 - 渲染页面时实例化
watcher
,这个过程会读取依赖数据的值,从而完成在getter中获取依赖
- 依赖变化时触发
setter
,从而派发更新,执行回调,完成在setter中派发更新
占个坑
从严格意义来说,我们现在完成的响应式系统还不能用于渲染页面,因为真正用于渲染页面的watcher
是不需要设置回调函数的,我们称之为渲染watcher
。此外,渲染watcher
可以接收一个渲染函数而不是表达式作为参数,当依赖变化时自动重新渲染,而这样又会带来重复依赖的问题。此外,另一个重要的内容我们还没有涉及到,就是数组的处理。
现在看不懂前面提到的问题,没有关系,这个系列之后的文章会一步步来解决这些问题,希望大家能够继续关注。