跳转到内容

响应系统的作用与实现

除了前面提到的编译器和渲染器,响应系统也是 Vue.js 的核心组成部分。响应系统的主要作用是让 Vue.js 应用程序在数据发生变化时,自动更新视图。想要提响应系统,就要从响应式数据和副作用函数这两个概念开始。

响应式数据和副作用函数

副作用函数是指那些会产生副作用的函数,即函数执行会直接或间接影响其他函数的执行或修改了全局变量。

js
const obj = { text: 'hello' };

function effect() {
  document.body.innerText = obj.text;
}
js
var a = 1;

function effect() {
  a = 2;
}

在上述代码两个例子中,effect 函数就是一个副作用函数,例子一修改了 body 的内容导致其他函数读取 body 内容时会受到影响,例子二的函数会直接修改全局变量 a 的值。

说完副作用函数,来看看什么是响应式数据。响应式数据是指那些能够自动追踪依赖并响应数据变化的值。例如上述代码组的例子一,当 obj.text 的值发生变化时,期望 effect 函数会自动重新执行,从而更新 body 的内容。

js
obj.text = 'hello world';

实现这个目标后对象 obj 就是响应式数据。

响应式数据的基本实现

JavaScript 中,读取对象会触发其 get 方法,修改对象会触发其 set 方法。因此,我们可以通过重写对象的 getset 方法来实现响应式数据。读取对象属性时,将副作用函数与属性进行关联;修改对象属性时,执行所关联的全部副作用函数。在 ES5 之前,只能通过 Object.defineProperty 来实现,ES6 之后可以使用 Proxy 代理。

js
const bunketSet = new Set();

const data = { text: 'hello' };

const obj = new Proxy(data, {
  // 读取操作
  get(target, key) {
    bunketSet.add(effect);
    return target[key];
  },
  // 修改操作
  set(target, key, value) {
    target[key] = value;
    set.forEach(fn => fn());
    return true;
  }
});
js
const effect = () => {
  document.body.innerText = obj.text;
}
effect()
setTimeout(() => {
  obj.text = 'hello world';
}, 1000);

效果实现了,不过有一定的缺陷,如目前写死函数名为 effect ,如果使用者不传 effect ,传其他名称的函数,甚至匿名函数,功能就不生效了。因此需要去掉这些硬编码机制。

设计一个完善的响应系统

基础实现的响应系统有如下几个缺陷:

  1. 响应式数据和副作用函数硬编码,用户只能传 effect 函数,传其他的函数则无法生效。
  2. 副作用函数与被操作的目标字段没有建立联系,如上方示例代码,如果为对象 obj 新增一个 otherText 属性,effect 也会触发

想要解决第一个问题,可以设计一个 effect 函数,该函数用于注册副作用函数,用户可以传入任意名称的函数,如 effectFn 或匿名函数,effect 函数内部会调用用户传入的函数,并将用户传入的函数作为副作用函数,保存在全局变量中。最后在 get 方法中直接把该变量收集保存到 Set 中即可,这样就不需要关注该函数是匿名函数还是什么名称。

js
let activeEffect;

function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
  activeEffect = fn;
  fn(); // 执行副作用函数
}
js
effect(() => { 
  console.log('effect run'); // 执行了两次
  document.body.innerText = obj.text; 
}) 
setTimeout(() => {
  obj.text = 'hello world';
}, 1000);
setTimeout(() => {
  obj.otherText = 'hello vue3';
}, 1000);
js
const bunketSet = new Set();

const data = { text: 'hello' };

const obj = new Proxy(data, {
  // 读取操作
  get(target, key) {
    if (activeEffect) bunketSet.add(activeEffect); // 把全局保存的副作用函数收集保存
    return target[key];
  },
  // 修改操作
  set(target, key, value) {
    target[key] = value;
    set.forEach(fn => fn());
    return true;
  }
});

接下来看第二个问题,上方 use.js 代码 obj.text 只修改了一次,但是 effect 函数执行了两次。理论上副作用函数没有读取到 otherText ,不应该执行。因此需要修改副作用函数收集的代码,将副作用函数与被操作的目标字段进行关联。

根据前面的代码可以得知,副作用函数涉及到以下三点:

  1. target : 被操作(读取)的对象 obj
  2. key : 被操作(读取)的属性 text
  3. effect : 副作用函数 effectFn

那么可以为他们三者建立联系,它们之间是一个树形结构。

js
effect (function effectFn1() {
  obj.key;
})
/**
|-target
  |-key
    |-effectFn1
 */
js
effect (function effectFn1() {
  obj.key;
})

effect (function effectFn2() {
  obj.key;
})
/**
|-target
  |-key
    |-effectFn1
    |-effectFn2
 */
js
effect (function effectFn1() {
  obj.key1;
  obj.key2;
})
/**
|-target
  |-key1
    |-effectFn1
  |-key2
    |-effectFn1
 */
js
effect (function effectFn1() {
  obj1.key1;
})
effect (function effectFn2() {
  obj2.key2;
})
/**
|-target1
  |-key1
    |-effectFn1
|-target2
  |-key2
    |-effectFn2
 */

可以得出,根据这个树型结构建立联系后,只需要触发对应的副作用函数即可。想要实现这个功能,要用 WeakMap 替代 Set ;然后收集对象的全部属性字段 Map ,每个 Map 对应一个属性字段;最后用 Set 收集属性字段对应的副作用函数。

js
const bunketWeakMap = new WeakMap(); 

const data = { text: 'hello' };

const obj = new Proxy(data, {
  // 读取操作
  get(target, key) {
    track(target, key); 
    return target[key];
  },
  // 修改操作
  set(target, key, value) {
    target[key] = value;
    trigger(target, key); // 获取并执行对应的副作用函数
  }
});
js
const track = (target, key) => {
  if (!activeEffect) return;
  let depsMap = bunketWeakMap.get(target); // 获取WeakMap映射对应的target对象
  if (!depsMap) bunketWeakMap.set(target, (depsMap = new Map())); // 如果没有,则为该target对象新建一个Map映射用于保存所有属性字段key
  let deps = depsMap.get(key); // 获取Map映射对应的key属性字段
  if (!deps) depsMap.set(key, (deps = new Set())); // 如果没有,则为该key属性字段新建一个Set映射用于保存所有副作用函数
  deps.add(activeEffect); // 把全局保存的副作用函数收集保存
}
js
const trigger = (target, key) => {
  const depsMap = bunketWeakMap.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  effects && effects.forEach(effect => effect());
}
目前的完整代码
html
<body>
  <div></div>
  <script>
    const divEle = document.querySelector('div');
    const data = {name: '张三'};
    let activeEffect

    const bunketWeakMap = new WeakMap(); // 存储依赖函数

    // track函数收集依赖
    const track = (target, key) => {
      if (!activeEffect) return
      let depsMap = bunketWeakMap.get(target);
      if(!depsMap) bunketWeakMap.set(target, depsMap = new Map());
      let deps = depsMap.get(key);
      if(!deps) depsMap.set(key, deps = new Set());
      deps.add(activeEffect)
    }

    // trigger函数触发依赖
    const trigger = (target, key) => {
      const depsMap = bunketWeakMap.get(target);
      if(!depsMap) return
      const effects = depsMap.get(key);
      effects && effects.forEach(fn => fn());
    }

    const obj = new Proxy(data, {
      get(target, key) {
        track(target, key);
        return target[key];
      },
      set(target, key, value) {
        target[key] = value;
        trigger(target, key);
      }
    })

    function effect(fn) {
      activeEffect = fn;
      fn();
    }


    effect (() => {
      divEle.innerHTML = obj.name;
    })
    setTimeout(() => {
      obj.name = '李四';
    }, 1000);
  </script>

</body>

分支切换与 cleanup

看下方一段代码示例:

js
const data = { ok: true, text: 'hello' };
const obj = new Proxy(data, { /* ... */ });

effect(() => {
  document.body.innerText = obj.ok ? obj.text : 'is false';
})

obj.ok = false;
obj.text = 'hello world';

这段代码中,obj.ok 的值从 true 变为 false 后,再次修改 obj.text 的值,会发现副作用函数依旧触发了。理想情况是 obj.ok 值为 false 时,没有用到 obj.text ,因此不应该触发副作用函数。但目前代码会有遗留的副作用函数导致不必要的更新。

示例图

解决思路是每次执行副作用函数时,先把它从所有关联的依赖集合中删除,然后在它执行完毕后重新建立联系,新的联系不会包含遗留的副作用函数。

想要实现这个功能,需要改写 effect 函数,为其添加 deps 数组属性,用于存储所有包含当前副作用函数的 key 依赖。在 track 函数中,将当前副作用函数添加到副作用函数的 deps 中,新建一个 cleanup 函数,将当前副作用函数从 deps 中删除。

js
let activeEffect;

function effect(fn) {
  const effectFn = () => { 
    // 调用 cleanup 函数完成清除工作
    cleanup(effectFn); 
    activeEffect = fn; 
    fn(); // 执行副作用函数
  } 

  effectFn.deps = []; // 收集当前副作用函数有关联的依赖集合
  effectFn() 
}
js
const track = (target, key) => {
  if (!activeEffect) return;
  let depsMap = bunketWeakMap.get(target); // 获取WeakMap映射对应的target对象
  if (!depsMap) bunketWeakMap.set(target, (depsMap = new Map())); // 如果没有,则为该target对象新建一个Map映射用于保存所有属性字段key
  let deps = depsMap.get(key); // 获取Map映射对应的key属性字段
  if (!deps) depsMap.set(key, (deps = new Set())); // 如果没有,则为该key属性字段新建一个Set映射用于保存所有副作用函数
  deps.add(activeEffect); // 把全局保存的副作用函数收集保存

  activeEffect.deps.push(deps); // 把当前副作用函数添加到依赖集合中
}
js
const cleanup = (effectFn) => { 
  // 遍历 effectFn.deps 数组
  effectFn.deps.forEach(deps => { 
    // 从依赖 Map 集合 deps 中把当前副作用函数 effectFn 删除
    deps.delete(effectFn); 
  }) 
  effectFn.deps.length = 0; // 清空 deps 数组
} 

此时运行会发现代码程序陷入了死循环,排查后发现问题出在 trigger.js 函数中 effects && effects.forEach(fn => fn()) 这个命令。前面 effect.js 函数中已经改写了 effectFn 函数,添加了 cleanup 函数用于清除遗留的副作用函数,但是执行了副作用函数后又会导致其重新被收集到集合中。相当于下方代码示例:

js
const set = new Set([1])

set.forEach(item => {
  set.delete(1)
  set.add(1)
  console.log('遍历中')
})

一边删除一边添加,自然就会造成死循环。解决方法很简单,再新建一个 Set 即可。

js
const trigger = (target, key) => {
  const depsMap = bunketWeakMap.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

  const newEffects = new Set(effects); 
  newEffects.forEach(effectFn => effectFn()); 
  effects && effects.forEach(effect => effect()); 
}
目前完整代码
html
<body>
  <div></div>
  <script>
    const divEle = document.querySelector('div');
    const data = {name: '张三'};
    let activeEffect

    const bunketWeakMap = new WeakMap(); // 存储依赖函数

    // track函数收集依赖
    const track = (target, key) => {
      if (!activeEffect) return
      let depsMap = bunketWeakMap.get(target);
      if(!depsMap) bunketWeakMap.set(target, depsMap = new Map());
      let deps = depsMap.get(key);
      if(!deps) depsMap.set(key, deps = new Set());
      deps.add(activeEffect)
      activeEffect.deps.push(deps)
    }

    // trigger函数触发依赖
    const trigger = (target, key) => {
      const depsMap = bunketWeakMap.get(target);
      if(!depsMap) return
      const effects = depsMap.get(key);
      const newEffects = new Set(effects); 
      newEffects.forEach(effectFn => effectFn()); 
    }

    const obj = new Proxy(data, {
      get(target, key) {
        track(target, key);
        return target[key];
      },
      set(target, key, value) {
        target[key] = value;
        trigger(target, key);
      }
    })

    function effect(fn) {
      const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn;
        fn();
      }

      effectFn.deps = [];
      effectFn();
    }

    function cleanup(effectFn) {
      effectFn.deps.forEach(dep => {
        dep.delete(effectFn);
      })
      effectFn.deps.length = 0;
    }

    effect (() => {
      divEle.innerHTML = obj.name;
    })
    setTimeout(() => {
      obj.name = '李四';
    }, 1000);
  </script>

</body>

嵌套的 effect 与 effect 栈