手写一个简易响应式带你了解响应式原理

107 阅读13分钟

响应系统也是 Vue.js 的重要组成部分,我们尝试实现一个相对完善的响应系统。接下来,我们就从认识响应式数据和副作用函数开始,初步了解响应系统的设计与实现。

一.副作用函数

概念

"副作用"名词解释是:随着主要作用而附带着发生的不好的作用。副作用函数指的是该函数的运行,会直接或间接影响其他函数或者变量的结果,那么这个函数就产生了副作用,称之为副作用函数。

举列说明

例 1:对某些函数产生的副作用

fuction effect(text){
  document.body.innerText = text
}
function getText() {
  return document.body.innerText
}

effect函数会修改 innerText 的内容。getText会返回innerText的值。如果我们使用effect修改了innerText内容,那么会影响到getText获取内容,那么这时effect就是个副作用函数。

例 2:函数的运行修改了全局变量的值

// 全局变量
let status = false

// effect函数执行,会影响status的值
function effect() {
 status = true 
}

二.响应式数据

响应式数据就是:数据变更时,所有依赖该数据的地方都发生变更,该数据称之为响应式数据。

举列说明

let obj = { name: "哈哈", age: "18" };
function effect () {
  // effect 函数的执行会读取obj.name
  document.body.innerText = obj.name
}
effect()

1.png

obj.name = '哈哈999' // 修改obj.name 的值,同时希望副作用函数会重
新执行,从而实现页面更新

2.png

数据 obj 更新,变更所有依赖 obj 的地方,那么obj就是响应式数据。很显然,上述代码是无法实现响应式的,那么如何让数据实现响应式呢?下面我们一步一步分析实现。

三.如何实现响应式数据

简单响应系统实现

通过观察,我们不难发现两点线索:

当函数执行时,会触发 obj 的读取操作;

当修改 obj 时,会触发 obj 的设置操作.

如果说我们可以拦截一个对象的读取和设置,从以下两点出发,我们可以实现简单的响应式。

当读取obj的值时,将读取数据的副作用函数effect存储起来;

当设置obj的值时,将 effect 取出并执行。

那么我们如何拦截一个数据的读取和设置呢?Vue.js3中采用代理对象Proxy来实现。

Proxy,原意为代理, Proxy 对象是 ES6 新出的一个特性,用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

Proxy 对象由两个参数组成:目标对象处理程序对象目标对象是我们要代理的对象,处理程序对象是一个包含拦截器方法的对象。拦截器方法是一个函数,它会在目标对象的属性或者方法被访问或者修改时调用。

具体拦截方法

get(target, property, receiver)方法用于拦截某个属性的读取操作,可以接收三个参数:

target:目标对象;

property:属性名;

receiver:proxy 实例本身(可选参数)

当读取代理对象属性值时,会触发get方法(不一定是get,这里以get为例),返回属性值,将读取数据的副作用函数effect存储起来。

set(targer,property,value,receiver)方法用来拦截某个属性的赋值操作,可以接收四个参数:

target:目标对象;

property:属性名;

value:属性值;

receiver:proxy 实例本身(可选参数)

当设置代理对象属性值时,会触发set方法,更新属性值,将 effect函数取出并执行。

ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。我们使用Set 数据结构来存储数据的副作用函数合集

根据如上思路,采用 Proxy 来实现:

let data = { name: "哈哈", age: "18" };
function effect () {
  // effect 函数的执行会读取obj.name
  document.body.innerText = obj.name
}

// 存储副作用函数
const bucket = new Set()

// 给obj设置一个代理
let obj = new Proxy(data,{
  get(target,property){
    console.log("我读取了" + property);
    console.log("它的值为" + target[property]);
    // 将副作用函数 effect 添加到存储副作用函数的桶中
    bucket.add(effect)
    // 定义你要返回的值
    return target[property];
  },
  set(target, property, value){
    console.log("要设置对象属性?我拦截到了~");
    console.log("要修改成" + value);
    target[property] = value;
    // 把副作用函数从桶里取出并执行
    bucket.forEach(fn => fn())
    // 返回 true 代表设置操作成功
    return true
  }
})

// 执行副作用函数,触发读取,注意定义代理后得用代理来调用属性或方法
effect()

// 1 秒后修改响应式数据,触发设置
setTimeout(() => {
  obj.name = '哈哈999'
}, 1000)

在浏览器中运行上面这段代码,我们会发现,这样我们就实现了简单的响应式数据:

3.png 4.png

完善响应系统

上文实现的简易响应系统中,我们可以通过测试发现一下两个缺陷:

1.副作用函数的名字发生变化时,无法收集。

2.没有在副作用函数与被操作的目标字段之间建立明确的联系。

1.注册副作用函数

为了解决当函数名字发生变化时或者匿名函数,无法被正确收集这一问题。我们可以实现一个注册副作用函数的机制,专门收集副作用函数。

我们重新定义上文的effect函数,将其变成一个专门收集副作用函数的函数,然后使用一个其他名字的副作用函数作为effect的参数:

// 用一个全局变量存储被注册的副作用函数
let activeEffect 
function effect(fn){
  activeEffect = fn
  fn()
}
 effect(effectFn)

那么我们将函数的名字做一个修改:

let activeEffect 
function effect(fn){
  activeEffect = fn
  fn()
}
function effectFn2(){
  document.body.innerText = obj.name
}
effect(effectFn2)

可以发现:当函数的名字发生变化时,依然会收集。由于副作用函数已经存储到了activeEffect中,所以在 get 拦截函数内应该把activeEffect收集到bucket中,这样响应系统就不依赖副作用函数的名字了。

2.建立明确联系

由于副作用函数与被操作的字段没有明确的联系,无论读取的哪个属性,副作用函数都会被收集,无论设置哪个属性,副作用函数都会被执行。

那么如何将副作用函数与被操作的字段建立明确的联系呢?我们观察上文中的例子,可以发现3个角色:

被操作/读取的代理对象Obj;

被操作/读取的字段name;

使用effect函数注册的副作用函数effectFn

为了方便描述,我们用target来表示一个代理对象所代理的原始对象,用key来表示被操作的字段名,用effectFn来表示被注册的副作用函数。由上文的例子,我们可以给这3个角色建立如下关系图: 5.png

如果一个副作用函数读取了同一个对象中同一个属性?
如果多个副作用函数读取了同一个对象的不同属性?
如果多个副作用函数读取了不同对象的不同属性?

要想都满足以上几点,我们需要建立下图中的对应关系,如图所示: 6.png 由此图可以看出,target、key、effectFn三者的关系变得复杂,那么就需要我们重新设计 bucket 的数据结构,需要使用 WeakMap 代替 Set 作为桶的数据结构:

WeakMap是弱字典、弱映射,以键值对形式存放,键只能是对象,对对象的引用是弱引用,在没有其他引用和该键引用同一对象,这个对象将被垃圾回收机制回收。

 // 存储副作用函数
  const bucket = new WeakMap()

  // 当前激活被注册的副作用函数
  let activeEffect

当读取属性值时,会触发 get 拦截函数,将副作用函数收集到bucket中,我们将这部分逻辑单独封装到一个 track 函数中:

// 将副作用函数收集到bucket中
function track(target,key){
  // 如果当前没有要注册的副作用函数,直接return
  if(!activeEffect) return

  // 根据 target(目标对象) 从“bucket”中取得 depsMap,它是一个 Map 类型:key -->effects
  let depsMap = bucket.get(target)

  // 如果不存在 depsMap,那么新建一个
  bucket.set(target, (depsMap = new Map()))

  // 再根据 key 从 depsMap 中取得 deps(依赖合集),它是一个 Set 类型,
  // 里面存储着所有与当前 key 相关联的副作用函数:effects
  let deps = depsMap.get(key)
 // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
  if (!deps) {
  depsMap.set(key, (deps = new Set()))
  }
  // 最后将当前激活的副作用函数添加到“bucket”里
  deps.add(activeEffect)
} 

当读取属性值时,会触发 set 拦截函数,副作用函数重新执行,把这部分逻辑封装到 trigger 函数中:

function trigger(target, key) {
  // 根据 target 从bucket中取得 depsMap,它是 key --> effects
  const depsMap = bucket.get(target)

  if (!depsMap) return

  // 根据 key 取得所有副作用函数 effects
  const effects = depsMap.get(key)

  // 执行副作用函数
  effects && effects.forEach(fn => fn())
}

执行:

// 给obj设置一个代理
const obj = new Proxy(data,{
  get(target,key){
    track(target,key)
    // 定义你要返回的值
    return target[key];
  },
  set(target, key, value){
    target[key] = value;
    // 把副作用函数从bucket里取出并执行
    trigger(target, key)
  }
})

四.遗留的副作用函数

遗留的副作用函数如何产生

我们修改一下data与副作用函数:

image.png

trigger函数添加日志:

image.png

obj.ok为true时,页面展示如图: image.png

此时副作用函数 effectFn 与响应式数据之间建立的联系如下: image.png

我们在控制台中将obj.ok修改为fase,页面展示就发生了修改: image.png

从console中,我们可以发现:副作用函数 effectFn依然被obj.name所对应的依赖集合收集,当我们去修改obj.name的值,会执行副作用函数 effectFn。但此时obj.ok的值为false,不再会读取字段 obj.name 的值。 image.png

此时就产生了遗留的副作用函数,无论如何修改obj.name,都会执行副作用函数 effectFn。那么副作用函数 effectFn执行时,是否可以提前将它从obj.name所对应的依赖集合中删除?

避免产生遗留的副作用函数

当然可以,但我们需要知道哪些依赖集合中与该副作用函数有关联,将其收集起来,副作用函数执行的时候,将与其从有关联的依赖集合中删除。

在 effect 内部我们定义了新的 effectFn函数,并为其添加了 effectFn.deps 属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合: image.png

在 track 函数中我们将当前执行的副作用函数activeEffect 添加到依赖集合 deps 中,这说明 deps 就是一个与 当前副作用函数存在联系的依赖集合,于是我们也把它添加到activeEffect.deps 数组中,这样就完成了对依赖集合的收集。

image.png

运行到浏览器:

image.png

有了这个联系后,我们就可以在每次副作用函数执行时,根据effectFn.deps 获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除。副作用函数的执行会导致其重新被收集到集合中,所以副作用函数从依赖集合中移除之后,需要重置 effectFn.deps 数组,避免重复收集。

image.png

运行到浏览器,修改obj的属性值,结果导致无限循环执行:

image.png

我们将其遍历删除的过程拆分处理,新建一个cleanup函数处理,并添加日志:

image.png

运行至浏览器:

image.png

从打印信息中,分析发现,修改obj.ok时,触发set,会取出obj.ok关联的依赖合集,循环该依赖合集,执行副作用函数。

image.png image.png

当副作用函数执行时,会先从所有与该副作用函数有关联的依赖集合中,删除该副作用函数;然后再执行fn,代码如下:

image.png

函数fn执行时,会触发get,执行track函数,对应属性的依赖集合又重新收集了该副作用函数:

image.png

从而进入到一个"删除-收集-删除-收集..."的一个死循环,我们找到造成这一切的源头。该行代码执行时,就会进入死循环。

image.png

实际上cleanup函数中的清除,也会将 effects 集合中将当前执行的副作用函数剔除,因为他们的内存地址指向的是同一个值。等于说是此处循环未结束,effects的值就发生了更改。用简单例子模拟一下该现象:

const set = new Set([1])
set.forEach(item=>{
    set.delete(1)
    set.add(1)
    console.log('遍历中')
})

我们在浏览器中执行这段代码,发现它会无限执行下去:

image.png

查询语言规范中,发现对此有明确的说明:在调用forEach遍历Set集合的时候,如果一个值已经被访问过了,但是该值被删除被重新添加到集合,如果此时forEach循环并没有结束 那么该值会被重新访问。

image.png

可以参照developer.mozilla.org/zh-CN/docs/…

因此,上面的代码会无限执行。解决办法很简单,重新申请一个内存空间,让他跟被删除又重新添加的Set隔离开。我们可以构造另外一个 Set:

image.png

这样就不会无限执行了。回到 trigger 函数,我们需要同样的手段来避免无限执行:

image.png

运行至浏览器,在控制台中修改obj.ok的值,可以发现页面发生了变化,也避免了无限循环:

image.png

完整代码:

// 存储副作用函数
const bucket = new WeakMap()

// 当前激活的副作用函数
let activeEffect

// 注册副作用函数
function effect (fn) {
  const effectFn = () => {
    // 调用 cleanup 函数完成清除工作
    cleanup(effectFn)
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn
    fn()
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = []
  effectFn()
}

function cleanup (effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i]
    console.log('i', i)
 
    // 将 effectFn 从依赖集合中移除
    deps.delete(effectFn)
  }
  // 最后需要重置 effectFn.deps 数组
  effectFn.deps.length = 0
  console.log('deps',effectFn.deps)
}


// 将副作用函数收集到bucket中
function track (target, key) {
  // 如果当前没有要注册的副作用函数,直接return
  if (!activeEffect) return

  // 根据 target(目标对象) 从“bucket”中取得 depsMap,它是一个 Map 类型:key -->effects
  let depsMap = bucket.get(target)
  if (!depsMap) {
    // 如果不存在 depsMap,那么新建一个
    bucket.set(target, (depsMap = new Map()))
  }

  // 再根据 key 从 depsMap 中取得 deps(依赖合集),它是一个 Set 类型,
  // 里面存储着所有与当前 key 相关联的副作用函数:effects
  let deps = depsMap.get(key)
  // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 最后将当前激活的副作用函数添加到“bucket”里
  deps.add(activeEffect)

  activeEffect.deps.push(deps)
  console.log('所有依赖合集:', activeEffect.deps)
}

function trigger (target, key) {
  // 根据 target 从bucket中取得 depsMap,它是 key --> effects
  const depsMap = bucket.get(target)
  console.log('depsMap', depsMap)
  if (!depsMap) return

  // 根据 key 取得所有副作用函数 effects
  const effects = depsMap.get(key)
  console.log('执行', key, '的依赖集合:')
  console.log(effects)

  // 执行副作用函数
  // effects && effects.forEach(fn => fn())

  const effectsToRun = new Set(effects) 
  effectsToRun.forEach(effectFn => effectFn()) 
}

const data = { ok: true, name: "哈哈" };
// 给obj设置一个代理
const obj = new Proxy(data, {
  get (target, key) {
    track(target, key)
    // 定义你要返回的值
    return target[key];
  },
  set (target, key, value) {
    target[key] = value;
    // 把副作用函数从bucket里取出并执行
    trigger(target, key)
  }
})

function effectFn () {
  // effect 函数的执行会读取obj.name
  document.body.innerText = obj.ok ? obj.name : 'not'
}
effect(effectFn)

小结

参考《Vue.js设计与实现 (霍春阳)》,文章中如果有错误,还望大家批评指正!