前言
关于响应式原理的资料可谓是五花八门,我或多或少知道了几个单词,例如dep、watcher、observe等,同时我对这些概念比较的模糊,所以我结合源码和各种资料彻底的学习了一下响应式,这篇文章就是我对响应式的理解,明白是一回事,但是能不能写出来又是另一回事,我不保证我能把响应式的原理给写明白,但是我会把我的理解写出来,希望大家看完会有所收获。
本文实现一个简单的vue2响应式,变量命名与vue2源码保持统一,方便大家后续阅读源码。
我理解vue2的响应式一共分两步
- 遍历监听
- 收集依赖 派发更新
只要明白这两步在什么时候做的,做了什么响应式也就基本搞清楚了。
Object.defineProperty
在讲述这两步之前,我们要先了解一个api。
在data中的数据发生修改的时候,页面的内容也会随之修改,这个就是所谓的响应式。想要实现这个效果,我们首先需要对data对象里面的内容进行观察,一旦data里面的数据发生了改变我们第一时间发现,从而做出改变。
在vue2中是通过Object.defineProperty()
这个api实现的。
Object.defineProperty()
我们可以设置指定对象属性特性,如是否可以遍历,是否可修改,是否可删除等。它还提供了对象属性的get和set方法,当我们修改了对象的某个属性时会触发set方法,当读取某个属性时会触发get方法。
js
const obj = {
a:1
}
let _temp
Object.defineProperty(obj,'a',{
get(){
console.log('发出了get方法')
return _temp
},
set(newValue){
console.log('触发了set方法')
_temp = newValue
}
})
obj.a = 2 // 触发了set方法
console.log(obj.a) // 触发了get方法
注意这里需要一个中间变量_temp来承载属性值的变化,如果没有这个值直接对a本身进行修改,会导致死循环。
当我们对obj.a进行修改,触发set方法,然后set方法内部又触发set方法。。。。。
js
const obj = {
a:1
}
Object.defineProperty(obj,'a',{
get(){
console.log('发出了get方法')
return obj.a
},
set(newValue){
console.log('触发了set方法')
obj.a = newValue
}
})
obj.a = 2
这也就是vue2为什么会有defineReactive
这个方法的val参数,其实就是上面_temp的作用。后面再详细说。
关于Object.defineProperty
这个api就先介绍到这里,更加详细的介绍大家可以去mdn了解。
地址在这⬇
遍历监视data的一举一动
vue里面会有一个data对象,完成响应式的第一步就是对data中的每一项都进行监视,也就是说需要对data对象中每个属性都设置Object.defineProperty
。
js
var app = new Vue({
el: '#demo',
data: {
name: '明明',
sex: '男',
age:18,
obj:{
c:'adfadsf'
}
}
})
在源码中有这些个方法和类,我现在列举一下。
- observe方法,观察对象有没有__ob__属性,如果有就返回__ob__属性,如果没有就调用Observer 类生成__ob__属性。(这个属性的作用是标识一个数据对象是否已经被观察过,并存储对应的观察者对象的引用)
- Observer 类用于为对象添加__ob__属性,并遍历对象对的每个属性,将其作为defineReactive方法的参数调用。
- defineReactive方法是
Object.defineProperty
的封装,对具体的属性进行监听。但data中的对象可能是多层嵌套的,这种情况可能在defineReactive方法中,还会调用observe对子对象进行逐个监听。
这个三个方法的调用为 observe中调用Observer类,Observer类中调用defineReactive方法,当data数据对象嵌套时defineReactive方法中还会调用observe,进行循环调用。
observe方法
先写一个入口,index.js 定义个obj对象,内有嵌套。然后把整个对象作为参数传入observe中
js
import observe from './src/observe'
const obj = {
name: '明明',
sex: '男',
age: 18,
obj: {
c: 'adfadsf'
}
}
observe(obj)
observe
做的事情非常的单纯,就是当对象身上没有__ob__的时候创建Observer
实例。typeof data !== 'object'这句话是后面循环调用时的退出条件,如果传入的data是简单类型的时候直接退出,这里可以暂时忽略,因为刚开始data肯定是一个对象类型。
js
import Observer from './Observer'
export default function observe(data) {
if (typeof data !== 'object') {
return
}
if (data.__ob__) {
return data.__ob__
} else {
return new Observer(data)
}
}
Observer类
当创建Observer类时,触发构造函数,直接通过def函数对传入的对象添加__ob__
属性值为Observer的实例,作为标识,标志该对象已经被观察过了。
walk函数为Observer类的核心方法,对对象中的属性逐个进行defineReactive
的调用。对属性进行监视,添加get和set方法。
js
import { def } from "./utils";
import defineReactive from "./defineReactive";
class Observer {
constructor(value) {
def(value, '__ob__', this, false)
if (Array.isArray(value)) {
// 本例中忽略数组
} else {
this.walk(value)
}
}
walk(value) {
for (let i in value) {
defineReactive(value, i)
}
}
}
export default Observer
def方法,是对Object.defineProperty
的一个简单封装,方便我们在一个对象上添加某个属性,并对其特性进行设置。
js
export const def = function(obj,key,value,enumerable){
Object.defineProperty(obj,key,{
value,
enumerable,
writable:true,
configurable:true
})
}
defineReactive
这个方法是响应式中比较核心的方法,上文中提过,Object.defineProperty
需要一个中间变量进行值的中转,否则会造成死循环,这里的val本质上起到了一个中间变量的作用,运用闭包为对象中的每个属性都生成一个中间值。
js
import observe from './observe'
export default function defineReactive(obj, key, val) {
val = val || obj[key]
observe(val) // 如果对象中的属性为对象则继续调用observer为子对象中的属性添加get、set方法
Object.defineProperty(obj, key, {
set(value) {
val = value
},
get() {
return val
}
})
}
到这里就完成了监视data的一举一动,data中所有的属性都被添加上了get和set方法,一旦读取或赋值,我们都可以对其进行操作。
到这里为止应该是比较好理解的,就是给data中的所有属性添加get和set方法,如果涉及到对象嵌套,那就对子对象再调用一次observe,继续为子对象的属性添加get和set方法。比较难理解的可能就是循环调用这部分了。
依赖收集和派发更新
在get和set中具体做了什么我们现在还没有写,这块应该也是比较核心的代码。这块就涉及到依赖收集和派发跟更新了,也是比较难理解的一部分。
下面我将用比较长的一段文字描述一下响应式做了什么。
Vue在页面初始化的时候通过observe,对每个data中的每个属性都添加个get和set方法。随后vue会读取用户的计算属性,监听器方法,页面上的data属性。分别生成计算属性watcher
、用户监听器watcher
、渲染watcher
。
当这些watcher在实例化的时候会触发对应属性的get函数
,这时候会有一个数组把watcher实例记录下来,我们管这个数组叫dep
。
举个例子 data中有name属性,这个name属性在页面中用到了,在计算属性中也用到了,页面初始化的时候就会生成两个关于name属性的watcher实例,分别是计算属性watcher和渲染watcher。这两个watcher是分别实例化的,在初始化计算属性时会实例化一个计算属性watcher,实例化的过程中会触发name的get方法,会把计算属性watcher的实例化后的对象添加到dep数组中,组件渲染的时候会实例化渲染watcher,这时候又会触发name的get函数,此时又会把watcher的实例添加到dep数组中,此时dep数组中就有了两个watcher实例。这个过程我们称作依赖收集
。
随后我们可能在代码中对name属性的值进行了修改,触发name 的set方法。这时候会把dep数组中的元素都遍历一遍执行watcher的update()方法,触发watcher的回调函数,执行相应的逻辑,这就是派发更新
。例如渲染watcher的回调函数就是更新页面,监听器watcher的回调函数就是执行函数内的逻辑。
执行watcher的update()
的时候是有顺序的会先触发计算属性然后是监听器的逻辑,最后才是页面渲染。为的可能是先更新数据,最后才渲染页面。
知道了vue在get和set中具体做了什么我们就先补全一下get和set中的逻辑
js
import Dep from './Dep'
import observe from './observe'
export default function defineReactive(obj, key, val) {
val = val || obj[key]
const dep = new Dep()
observe(val)
Object.defineProperty(obj, key, {
set(value) {
if (val === value) {
return;
}
val = value
dep.noitfy()
},
get() {
dep.depend()
return val
}
})
}
Dep类的作用就是管理和存储watcher实例,它内部有一个数组,调用depend方法就是往数组里push一个watcher实例,即收集依赖。noitfy就是遍历数组挨个调用watcher的update方法,即派发更新
。
Watcher
在写Watcher
的代码之前先明确一下,它应该是怎么被调用的。根据调用的形式进行编写更容易明白入参到底是是什么,在vue2中的Watcher类的调用方法,其实和watch监听器类似,监听某个对象的某个属性,回调函数。对象是字符串类型可以是'c.d.e.f'这种类型。
js
const w1 = new Watcher(obj, 'a', (newValue, oldValue) => {
console.log(newValue, oldValue, 'w1')
})
const w2 = new Watcher(obj, 'c.d.e.f', (newValue, oldValue) => {
console.log(newValue, oldValue, 'w2')
})
为什么要把watcher的实例放到Dep.target上?因为watcher和dep在两个类中,无法共享数据,watcher在实例的过程中会触发被监听属性的get方法,get方法中回到用dep.depend()进行依赖收集,收集的对象就是watcher实例,也就是Dep.target上的this对象,Dep.target在这里是作为一个全局变量存在的,可以把Dep.target看作window.target,这样在Dep类中进行依赖收集时就可以根据Dep.target获取到watcher实例了。
js
import Dep from './Dep'
class Watcher {
constructor(target, expression, callback) {
// 就是保存监听器的回调函数
this.callback = callback
// 保存监听的对象
this.target = target
// parsePath用于解析类似 c.d.e.f的值 getter方法用于获取newVal
this.getter = parsePath(expression)
// 存储监听属性的初始值也就是oldValue,并且在内部访问被监听属性触发get方法
this.value = this.get()
}
get() {
// 将watcher的实例挂在到Dep上,这里的Dep是一个全局变量,和挂在到window上效果相同
// this就是被监听属性的watcher实例,它将作为依赖被Dep类收集。
Dep.target = this
const obj = this.target
let value
try {
// getter内部会触发监听属性的get方法,get中会进行依赖收集,调用Dep.depend()
// depend方法会把Dep.target上的watcher实例推到内部的数组中完成依赖收集
value = this.getter(obj)
} finally {
// 执行到这里时,已经完成了依赖收集Dep.target的值已经被添加到Dep的数组中
// 这时Dep.target已无意义故清除掉。
Dep.target = null
}
return value
}
addDep(dep) {
dep.addSub(this);
}
update() {
this.run()
}
run() {
const value = this.getter(this.target)
// 如果新旧值不相同,就调用传入的回调函数。
if (this.value !== value || typeof value === 'object') {
this.callback.call(this.target, value, this.value)
}
}
}
function parsePath(str) {
var segments = str.split(".");
return (obj) => {
for (let i = 0; i < segments.length; i++) {
if (!obj) return;
obj = obj[segments[i]];
}
return obj;
};
}
export default Watcher
Dep
每个属性在进行defineProperty时都会创建一个dep实例,这时创建的这个实例只会为当前的属性服务,当触发get函数时,它会把所有关于这个对象的watcher实例都存入subs数组中,触发set时会挨个调用watcher中的update()方法触发回调函数。
js
class Dep {
constructor() {
this.subs = []
}
addSub(w) {
this.subs.push(w)
}
noitfy() {
for (let i of this.subs) {
i.update()
}
}
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
}
export default Dep
总结
js
import observe from './src/observe'
import Watcher from './src/Watcher'
const obj = {
a:1,
b:2,
c:{
d:{
e:{
f:1
}
}
},
g:{
z:'zzz'
}
}
observe(obj)
// 到此为止,obj的所有属性都执行了defineReactive被添加上了get和set方法。
// 并且每个属性都有一个属于自己的dep实例用来存储依赖,只是此时依赖数组还没有值
console.log(obj, 'sdfsdfsdf')
//在实例的过程中,会触发a属性的get方法,调用a属性的dep实例上的depend方法,
//把这个w1存入dep的依赖数组中 完成依赖收集
const w1 = new Watcher(obj, 'a', (newValue, oldValue) => {
console.log(newValue, oldValue, 'w1')
})
//在实例的过程中,会触发f属性的get方法,调用f属性的dep实例上的depend方法,
//把这个w2存入dep的依赖数组中 完成依赖收集
const w2 = new Watcher(obj, 'c.d.e.f', (newValue, oldValue) => {
console.log(newValue, oldValue, 'w2')
})
// 此时触发a属性的set方法,触发dep的noitfy方法,此时会循环遍历数组 执行watcher的update方法
// 因为只收集了一个实例w1,只会执行w1的update方法,最终调用watcher的回调函数
// (newValue, oldValue) => {
// console.log(newValue, oldValue, 'w1')
// }
obj.a = '5' //打印 5 1 w1
结尾
看了好久的资料和源码,总算对vue2的响应式的整个流程是有一个较为清晰的。可能没法很好的表达出来,大家看一乐就好,希望能有所帮助。
如果有什么错误欢迎评论区指正。