手写简易的MiniVue(01-实现响应式系统)

181 阅读15分钟

前言

Vue 3 的响应式系统是 Vue 3 中最重要的特性之一,它是通过 Proxy 对象实现的,与 Vue 2 中使用的 Object.defineProperty 方法不同。在 Vue 3 中,我们可以使用 reactive 函数将一个普通对象转化为响应式对象。当响应式对象的属性被修改时,相关的依赖会被重新计算,接下来我们来实现以下这个reactive函数。提前说明,我们实现的这个reactive函数只是一个简易的实现了响应式功能的初实现

响应式系统的初实现

// 用一个全局变量存储被注册的副作用函数
let activeEffect = null
// 定义一个存放副作用函数的桶
let bucket = new WeakMap()
​
// 用于注册副作用函数
function effect(fn) {
  // 将 activeEffect = 传进来的副作用函数,这样就可以存储当前被注册的副作用函数
  activeEffect = fn
  // 执行副作用函数
  fn()
}
​
​
// 响应式函数,传入一个对象,内部会用 Proxy 为这个对象做一个代理
function reactive(target) {
  return new Proxy(target, {
    // 拦截读取操作
    get(target, key) {
      // 将副作用函数  activeEffect 添加到存储副作用函数的桶中
      track(target, key)
      // 返回属性值
      return Reflect.get(target, key)
    },
    // 拦截设置操作
    set(target, key, newValue) {
      // 设置属性值
      Reflect.set(target, key, newValue)
      // 把副作用函数从桶中取出来执行
      trigger(target, key)
    }
  })
}
​
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  // 没有 activeEffect 直接 return
  if (!activeEffect) return
​
  // 根据 target 从桶中取得 depsMap,它是一个 Map 类型
  let depsMap = bucket.get(target)
  // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联存放到 bucket中
  if (!depsMap) bucket.set(target, (depsMap = new Map()))
​
  //根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  let deps = depsMap.get(key)
  // 如果不存在 deps 则新建一个 Set 并与 key 关联存储到 depsMap 中
  if (!deps) depsMap.set(key, (deps = new Set()))
​
  // 最后将当前激活的副作用函数添加到 桶 里
  deps.add(activeEffect)
}
// 在 get 拦截函数内调用 track 函数触发变化
function trigger(target, key) {
  // 根据 target 从 桶中取得 depsMap,如果没有这个depsMap,那就代表这个对象没有挂载到 bucket 中,直接 return
  const depsMap = bucket.get(target)
  if (!depsMap) return
​
  // 再根据 key 从 depsMap 中取得 effects
  const effects = depsMap.get(key)
​
  // 如果这个 effects 存在那么就遍历执行里面存放的副作用函数
  effects && effects.forEach((effectFn) => {
    effectFn()
  })
}
​
​
const obj = reactive({
  name: 'why',
  age: 18
})
​
// 调用 effect 函数,传入要注册的副作用函数
effect(() => {
  console.log(obj.name);
  console.log(obj.age);
})
// 一秒钟后改变 obj.name 的数据,触发副作用函数的执行
setTimeout(() => {
  console.log('1s后---------');
  obj.name = '周杰伦'
}, 1000);

image.png

分支切换与cleanup

通过前面实现的代码我们已经可以实现对象的响应式了,当对象发生改变时,对应的副作用函数会执行,但如果当副作用函数中存在一个三元表达式时,例如下面代码,根据 obj.ok 值的不同会执行不同的代码分支。当 obj.ok 的值发生变化时,代码执行的分支会跟着变化,这就是所谓的 分支切换

const obj = reactive({
    ok: true,
    text: "hello world"
})
effect(() => {
    document.body.innerText = obj.ok ? obj.text : 'not'
})

分支卡换可能会产生遗留的副作用函数,那上面的代码来说,第一次副作用函数执行的时候:obj.ok 是为 true,这样就会读取 obj.text 的值,这样就触发了两个字段的读取操作。所以在 track 函数中,副作用函数会被字段 ok 和 text 所对应的依赖集合收集。当 obj.ok 的值修改为 false ,并触发副作用函数重新执行之后,由于字段 obj.text 不会再被读取,只会触发字段 obj.ok 的读取操作,所以理想情况下副作用函数不应该被字段 obj.text 所对应的依赖集合收集,通俗点来说就是:在 obj.ok 为 false 的情况下,修改 obj.text 的值不会触发副作用函数的执行,但事实并非如此,看以下代码:

const obj = reactive({
  ok: true,
  text: 'hello world'
})
​
effect(() => {
  console.log('副作用函数执行了');
  document.body.innerText = obj.ok ? obj.text : 'not'
})
​
setTimeout(() => {
  console.log('1s后---------');
  obj.ok = false
}, 1000);
​
setTimeout(() => {
  console.log('2s后---------');
  obj.text = 'hello text'
}, 2000);

image.png

前两次副作用函数执行正常,可问题就是出现在第三次执行的时候,在我们想要的理想情况下,当 obj.ok 修改为 false 的时候,就代表 document.body.innerText = obj.ok ? obj.text : 'not' 这句代码永远访问不到 obj.text,也就意味在之后无论怎么修改 obj.text 的值都不会再让副作用函数重新执行了。可事实并非如此,我们会发现当 obj.ok 为 false 的情况下,修改 obj.text 的值竟然会让副作用函数重新执行,这不是我们想要的,原因就是在第一次执行副作用函数的时候,会访问 obj.ok 和 obj.text 这两个字段,这样就会在 bucket 中为这两个字段关联副作用函数。解决这个问题的思路很简单,每次副作用函数执行的时候,我们可以先把它从所有与之关联的依赖集合中删除,代码如下:

function effect(fn) {
   // 新增
  const effectFn = () => {
    // 调用 cleanup 函数完成清除工作
    cleanup(effectFn);  // 新增
    // 当 effectfn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn;
    fn();
  };
  // activeeffect.deps 用来存储所有与该副作用函数相关联的以依赖集合
  effectFn.deps = [];  // 新增
  effectFn();
}
​
function track(target, key) {
  if (!activeEffect) return;
​
  let depsMap = bucket.get(target);
  if (!depsMap) bucket.set(target, (depsMap = new Map()));
​
  let deps = depsMap.get(key);
  if (!deps) depsMap.set(key, (deps = new Set()));
​
  deps.add(activeEffect);
    
  // 新增
  // deps就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 activeEffect.deps 中
  activeEffect.deps.push(deps);
}
​
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  let effects = depsMap.get(key);
    
  // effects && effects.forEach((effectFn) => {
  //   effectFn()
  // })
    
  // 新增
  /**
   * 在上面注释的遍历代码执行时,每执行一次effectFn()会先调用 cleanup() 清除函数,实际上就是
   * 从 effects 集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致其重新被收集到集合中
   * 而此时对于 effects 集合还在遍历中,就相当于 effects.delete() 再 effects.add(),这样会无限递归执行下去
   * 解决方法很简单,就是构造另外一个 Set 集合遍历它
   */
  const newEffects = new Set(effects);
  newEffects.forEach((fn) => {
    fn();
  });
}
​
// 新增
function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i];
    // 将 effectfn 从以来集合中移除
    deps.delete(effectFn);
  }
  // 最后需要重置 effectFn 数组
  effectFn.deps.length = 0;
}

之后我们再执行,完整代码:

// 用一个全局变量存储被注册的副作用函数
let activeEffect = null
// 定义一个存放副作用函数的桶
let bucket = new WeakMap()
​
// 用于注册副作用函数
function effect(fn) {
  // 新增
 const effectFn = () => {
   // 调用 cleanup 函数完成清除工作
   cleanup(effectFn);  // 新增
   // 当 effectfn 执行时,将其设置为当前激活的副作用函数
   activeEffect = effectFn;
   fn();
 };
 // activeeffect.deps 用来存储所有与该副作用函数相关联的以依赖集合
 effectFn.deps = [];  // 新增
 effectFn();
}
​
​
// 响应式函数,传入一个对象,内部会用 Proxy 为这个对象做一个代理
function reactive(target) {
  return new Proxy(target, {
    // 拦截读取操作
    get(target, key) {
      // 将副作用函数  activeEffect 添加到存储副作用函数的桶中
      track(target, key)
      // 返回属性值
      return Reflect.get(target, key)
    },
    // 拦截设置操作
    set(target, key, newValue) {
      // 设置属性值
      Reflect.set(target, key, newValue)
      // 把副作用函数从桶中取出来执行
      trigger(target, key)
    }
  })
}
​
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  // 没有 activeEffect 直接 return
  if (!activeEffect) return
​
  // 根据 target 从桶中取得 depsMap,它是一个 Map 类型
  let depsMap = bucket.get(target)
  // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联存放到 bucket中
  if (!depsMap) bucket.set(target, (depsMap = new Map()))
​
  //根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  let deps = depsMap.get(key)
  // 如果不存在 deps 则新建一个 Set 并与 key 关联存储到 depsMap 中
  if (!deps) depsMap.set(key, (deps = new Set()))
​
  // 最后将当前激活的副作用函数添加到 桶 里
  deps.add(activeEffect)
​
  // 新增
  // deps就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 activeEffect.deps 中
  activeEffect.deps.push(deps);
}
// 在 get 拦截函数内调用 track 函数触发变化
function trigger(target, key) {
  // 根据 target 从 桶中取得 depsMap,如果没有这个depsMap,那就代表这个对象没有挂载到 bucket 中,直接 return
  const depsMap = bucket.get(target)
  if (!depsMap) return
​
  // 再根据 key 从 depsMap 中取得 effects
  const effects = depsMap.get(key)
​
  // 如果这个 effects 存在那么就遍历执行里面存放的副作用函数
  // effects && effects.forEach((effectFn) => {
  //   effectFn()
  // })
​
  // 新增
  /**
   * 在上面注释的遍历代码执行时,每执行一次effectFn()会先调用 cleanup() 清除函数,实际上就是
   * 从 effects 集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致其重新被收集到集合中
   * 而此时对于 effects 集合还在遍历中,就相当于 effects.delete() 再 effects.add(),这样会无限递归执行下去
   * 解决方法很简单,就是构造另外一个 Set 集合遍历它
   */
  const newEffects = new Set(effects)
  newEffects.forEach(effectFn => {
    effectFn()
  })
}
​
// 新增
function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i];
    // 将 effectfn 从以来集合中移除
    deps.delete(effectFn);
  }
  // 最后需要重置 effectFn 数组
  effectFn.deps.length = 0;
}
​
const obj = reactive({
  ok: true,
  text: 'hello world'
})
​
effect(() => {
  console.log('副作用函数执行了');
  document.body.innerText = obj.ok ? obj.text : 'not'
})
​
setTimeout(() => {
  console.log('1s后---------');
  obj.ok = false
}, 1000);
​
setTimeout(() => {
  console.log('2s后---------');
  obj.text = 'hello text'
}, 2000);

image.png

嵌套的 effect 与 effect 栈

我们先来看下面的代码:

const obj = reactive({
  foo: 'foo',
  bar: 'bar'
})
​
effect(() => {
  console.log('effectFn1');
​
  effect(() => {
    console.log('effectFn2');
    let temp2 = obj.bar
  })
​
  let temp1 = obj.foo
})
​
setTimeout(() => {
  console.log('1s后---------');
  obj.foo = 'foo2'
}, 1000);

在上面的代码中,effectFn1 中内部嵌套了 effectFn2,很明显,effectFn1 的执行会导致 effectFn2 的执行。需要注意的是,我们在 effectFn2 中读取了字段 obj.bar,在 effectFn1 中读取了字段 obj.foo,并且 effectFn2 的执行优于先对字段 obj.foo 的读取操作。在这种情况下我们希望当修改 obj.foo 时会触发 effectFn1 的执行。由于 effectFn2 嵌套在 effectFn1 中,所以会简介触发 effectFn2 的执行,而当修改 obj.bar 时,只会触发 effectFn2 执行。但结果不是这样的,我们尝试修改 obj.foo 的值,会发现输出为:

image.png

一共打印了三次,前两次分别是副作用函数 effectFn1 和 effectFn2 初始执行的打印结果,到这一步是正常的。问题出现在第三次打印,我们修改了字段 obj.foo 的值,发现 effectFn1 没有执行,反而是 effectFn2 执行了,这显然不符合预期。

问题其实就是出现在我们实现的 effect 函数与 activeEffect 上。观察下面代码:

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

我们用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味看同一时刻 activeEffect 所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集。即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,这就是问题所在,为了解决这个问题,我们需要一个副作用函数栈 effectStack,在副作用函数执行时、将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况,如以下代码所示:

// 用一个全局变量存储被注册的副作用函数
let activeEffect = null
// effect 栈
const effectStack = []
​
// 用于注册副作用函数
function effect(fn) {
 const effectFn = () => {
   // 调用 cleanup 函数完成清除工作
   cleanup(effectFn);
   // 当 effectfn 执行时,将其设置为当前激活的副作用函数
   activeEffect = effectFn;
    // 在调用副作用函数之前,将当前副作用函数压入栈中
   effectStack.push(effectFn)  // 新增
   fn();
   // 在当前副作用函数执行完成之后,将当前副作用函数弹出栈,并将 activeeffect 还原成之前的值
   effectStack.pop()  // 新增
   activeEffect = effectStack[effectStack.length - 1]  // 新增
 };
 // activeeffect.deps 用来存储所有与该副作用函数相关联的以依赖集合
 effectFn.deps = [];
 effectFn();
}

测试如下:

const obj = reactive({
  foo: 'foo',
  bar: 'bar'
})
​
effect(() => {
  console.log('effectFn1');
​
  effect(() => {
    console.log('effectFn2');
    let temp2 = obj.bar
  })
​
  let temp1 = obj.foo
})
​
setTimeout(() => {
  console.log('1s后---------');
  obj.foo = 'foo2'
}, 1000);

image.png

避免无限递归执行

看下面代码:

const obj = reactive({
  count: 1
})
​
effect(() => {
  obj.count++
})

可以看到,在 effect 注册的副作用函数中有一个自增操作 obj.count++,该操作会引起栈溢出:

image.png

为什么会这样呢?接下来我们就尝试搞清楚这个问题,并提供解决方案。实际上,我们可以把 obj.count++ 这个自增操作拆开来看,它相当于:

effect(() => {
  obj.count = obj.count + 1
})

在这个语句中,既会读取 obj.count 的值,又会设置 obj.count 的值,而这就是导致问题的根本原因。我们可以尝试推理一下代码的执行流程:首先读取 obj.count 的值,这会触发track操作,将当前副作用函数收集到“桶”中,接着将其+1后赋值给 obj.count,此时会发trigger 操作,即把“桶”中的副作用函数取出并执行。但问题是该副作用函数正在执行中、还没有执行完毕,就要开始下一次的执行。这样会导致无限递归地调用自己,于是就产生了栈溢出。

解决办法并不难。通过分析这个问题我们能够发现,读取和设置操作是在同一个副作用函数内进行的。此时无论是 track 时收集的副作用函数,还是 trigger 时要触发执行的副作用函数,都是 activeEffect。基于此,我们可以在 trigger 动作发生时增加守卫条件: 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行,如以下代码所示:

function trigger(target, key) {
  // 根据 target 从 桶中取得 depsMap,如果没有这个depsMap,那就代表这个对象没有挂载到 bucket 中,直接 return
  const depsMap = bucket.get(target)
  if (!depsMap) return
​
  // 再根据 key 从 depsMap 中取得 effects
  const effects = depsMap.get(key)
​
  // 如果这个 effects 存在那么就遍历执行里面存放的副作用函数
  // effects && effects.forEach((effectFn) => {
  //   effectFn()
  // })
​
  /**
   * 在上面注释的遍历代码执行时,每执行一次effectFn()会先调用 cleanup() 清除函数,实际上就是
   * 从 effects 集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致其重新被收集到集合中
   * 而此时对于 effects 集合还在遍历中,就相当于 effects.delete() 再 effects.add(),这样会无限递归执行下去
   * 解决方法很简单,就是构造另外一个 Set 集合遍历它
   */
  // const newEffects = new Set(effects)
  // newEffects.forEach(effectFn => {
  //   effectFn()
  // })
​
  // 新增
  const newEffects = new Set()
  effects && effects.forEach((effectFn) => {
    // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
    if (effectFn !== activeEffect) {
      newEffects.add(effectFn)
    }
  })
  newEffects.forEach(effectFn => {
    effectFn()
  })
}

完整代码:

// 定义一个存放副作用函数的桶
let bucket = new WeakMap()
​
// 用一个全局变量存储被注册的副作用函数
let activeEffect = null// effect 栈
const effectStack = []
​
// 用于注册副作用函数
function effect(fn) {
 const effectFn = () => {
   // 调用 cleanup 函数完成清除工作
   cleanup(effectFn);  
   // 当 effectfn 执行时,将其设置为当前激活的副作用函数
   activeEffect = effectFn;
    // 在调用副作用函数之前,将当前副作用函数压入栈中
   effectStack.push(effectFn)  
   fn();
   // 在当前副作用函数执行完成之后,将当前副作用函数弹出栈,并将 activeeffect 还原成之前的值
   effectStack.pop()  
   activeEffect = effectStack[effectStack.length - 1]  
 };
 // activeeffect.deps 用来存储所有与该副作用函数相关联的以依赖集合
 effectFn.deps = [];  
 effectFn();
}
​
​
// 响应式函数,传入一个对象,内部会用 Proxy 为这个对象做一个代理
function reactive(target) {
  return new Proxy(target, {
    // 拦截读取操作
    get(target, key) {
      // 将副作用函数  activeEffect 添加到存储副作用函数的桶中
      track(target, key)
      // 返回属性值
      return Reflect.get(target, key)
    },
    // 拦截设置操作
    set(target, key, newValue) {
      // 设置属性值
      Reflect.set(target, key, newValue)
      // 把副作用函数从桶中取出来执行
      trigger(target, key)
    }
  })
}
​
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  // 没有 activeEffect 直接 return
  if (!activeEffect) return
​
  // 根据 target 从桶中取得 depsMap,它是一个 Map 类型
  let depsMap = bucket.get(target)
  // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联存放到 bucket中
  if (!depsMap) bucket.set(target, (depsMap = new Map()))
​
  //根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  let deps = depsMap.get(key)
  // 如果不存在 deps 则新建一个 Set 并与 key 关联存储到 depsMap 中
  if (!deps) depsMap.set(key, (deps = new Set()))
​
  // 最后将当前激活的副作用函数添加到 桶 里
  deps.add(activeEffect)
​
  // 新增
  // deps就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到 activeEffect.deps 中
  activeEffect.deps.push(deps);
}
// 在 get 拦截函数内调用 track 函数触发变化
function trigger(target, key) {
  // 根据 target 从 桶中取得 depsMap,如果没有这个depsMap,那就代表这个对象没有挂载到 bucket 中,直接 return
  const depsMap = bucket.get(target)
  if (!depsMap) return
​
  // 再根据 key 从 depsMap 中取得 effects
  const effects = depsMap.get(key)
​
  // 如果这个 effects 存在那么就遍历执行里面存放的副作用函数
  // effects && effects.forEach((effectFn) => {
  //   effectFn()
  // })
​
  /**
   * 在上面注释的遍历代码执行时,每执行一次effectFn()会先调用 cleanup() 清除函数,实际上就是
   * 从 effects 集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致其重新被收集到集合中
   * 而此时对于 effects 集合还在遍历中,就相当于 effects.delete() 再 effects.add(),这样会无限递归执行下去
   * 解决方法很简单,就是构造另外一个 Set 集合遍历它
   */
  // const newEffects = new Set(effects)
  // newEffects.forEach(effectFn => {
  //   effectFn()
  // })
​
  // 新增
  const newEffects = new Set()
  effects && effects.forEach((effectFn) => {
    // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
    if (effectFn !== activeEffect) {
      newEffects.add(effectFn)
    }
  })
  newEffects.forEach(effectFn => {
    effectFn()
  })
}
​
function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i];
    // 将 effectfn 从以来集合中移除
    deps.delete(effectFn);
  }
  // 最后需要重置 effectFn 数组
  effectFn.deps.length = 0;
}
​
const obj = reactive({
  count: 1
})
​
effect(() => {
  obj.count++
})
​

总结

到此,响应式系统功能的实现就完成了。以上代码参考了《vue.js的设计与实现》是本好书,推荐大家看看 下文:实现渲染器系统