一. 响应式系统

1) 实现一个基本的响应式数据

基本思路如下:

  1. 使用Proxy监听数据的读写操作
  2. 当发生数据读取的时候将副作用函数存进一个buckets中
  3. 当发生数据写入的时候从buckets中取出副作用函数执行

缺陷有很多,列举如下几点,是接下来的优化方向:

  1. 副作用函数名称不确定
  2. 只要有读取操作就会尝试在buckets中添加副作用函数。无关属性的读取也会添加副作用函数 ```javascript const buckets = new Set(); const data = { text: ‘想要设计一个基本的reactive系统’ }; const objRef = new Proxy(data, { get(target, key) {
    1. buckets.add(effect);
    2. return target[key];
    }, set(target, key, value) {
    1. target[key] = value;
    2. buckets.forEach((fn) => {
    3. fn();
    4. });
    5. return true;
    } });

function effect() { document.querySelector(‘#app’).innerHTML = objRef.text;
}

  1. <a name="crbgD"></a>
  2. #### 2) 设计一个完善的响应系统
  3. 1. 首先设计一个副作用函数的注册函数;它将副作用函数**统一命名**为一个全局变量并**执行**
  4. 1. 重新设计buckets,使`object -> key -> effectFn`对应起来;使用`WeakMap -> Map -> Set`
  5. 1. 分离装桶和出桶的逻辑,装桶叫track,出桶叫trigger
  6. ```javascript
  7. // 设计一个合理的注册副函数机制
  8. let activeEffect;
  9. function effect(fn) {
  10. activeEffect = fn;
  11. fn();
  12. }
  13. // 代理操作,分离出track和trigger逻辑
  14. function reactive(data) {
  15. return new Proxy(data, {
  16. get(target, key) {
  17. track(target, key);
  18. return target[key];
  19. },
  20. set(target, key, value) {
  21. target[key] = value;
  22. trigger(target, key);
  23. return true;
  24. }
  25. });
  26. }
  27. // track就是找到对象target对应的key的桶,将副作用函数装进去
  28. const buckets = new WeakMap();
  29. function track(target, key) {
  30. if (!activeEffect) {
  31. return;
  32. }
  33. let depsMap = buckets.get(target);
  34. if (!depsMap) {
  35. depsMap = new Map();
  36. buckets.set(target, depsMap);
  37. }
  38. let effectFnSet = depsMap.get(key);
  39. if (!effectFnSet) {
  40. effectFnSet = new Set();
  41. depsMap.set(key, effectFnSet);
  42. }
  43. effectFnSet.add(activeEffect);
  44. }
  45. // trigger同理,找到对应的key的副作用函数桶,执行里面的所有副作用函数
  46. function trigger(target, key) {
  47. let depsMap = buckets.get(target);
  48. if (!depsMap) {
  49. return;
  50. }
  51. let effectFnSet = depsMap.get(key);
  52. if (!effectFnSet) {
  53. return;
  54. }
  55. effectFnSet.forEach((fn) => {
  56. fn();
  57. });
  58. }

完整的代码参考:设计一个完善的响应系统

3) 分支切换与cleanup

考虑这样一种情况,副作用函数中有一个分支选项,当我们切换分支的时候,某些收集了副作用函数的属性(如obj.text)应该不再收集该副作用函数。

  1. function changeDOM() {
  2. document.querySelector('#app').innerText = obj.ok ? obj.text : 'none'
  3. }

当设置obj.ok = false,触发副作用函数;再修改obj.text不应该触发副作用函数;这个副作用函数应该再obj.text的依赖中剔除;
可以设计在执行副作用函数前,将副作用函数从所有依赖收集中剔除;如修改obj.ok = false,将changeDOM副作用函数从obj.okobj.text中删除;执行的时候重新添加到obj.okobj.text中就没有该副作用函数了。达到目的。

  1. 首先在注册副作用函数的时候,在副作用函数上添加一个数组deps属性用于表示哪些集合(Set)收集了这个副作用函数;
  2. 在执行前从deps中全部删除该副作用函数
  3. 在入桶的track函数中为副作用函数收集依赖的集合
  4. 由于Set的forEach函数在删除一个元素后再添加这个元素时,forEach依然会访问它,造成无限循环;可以创建一个新的Set遍历解决 ```javascript // 注册副作用函数的时候先删除,再重新收集 let activeEffect; function effect(fn) { const effectFn = () => {
    1. /** 调用前清除副作用依赖 */
    2. cleanup(effectFn);
    3. activeEffect = effectFn;
    4. fn();
    } /* 重新收集副作用依赖 / effectFn.deps = []; effectFn(); }

function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i]; deps.delete(effectFn); } /* 重置副作用依赖集合 / effectFn.deps.length = 0; }

// track中收集副作用函数的依赖 function track(target, key) { … deps.add(activeEffect); /* 在track时收集依赖 / activeEffect.deps.push(deps); }

// trigger函数中创建新的Set,避免陷入死循环 function trigger(target, key) { … /* Set.forEach 先删除后添加元素会导致无限循环,创建一个新的Set / const newSet = new Set(deps); newSet.forEach((fn) => { fn(); }); }

  1. 完整的代码参考:分支切换与cleanup
  2. <a name="BGZZZ"></a>
  3. #### 4) 嵌套的effect与effect栈
  4. effect函数的使用是可以嵌套的。对象的属性应该只收集读取它的那个副作用函数。<br />有如下一个嵌套的effect的例子:
  5. ```javascript
  6. const objRef = reactive({
  7. text: 'hello',
  8. ok: true
  9. });
  10. effect(function fn1() {
  11. console.log('在fn1中执行');
  12. effect(function fn2() {
  13. console.log('在fn2中执行');
  14. t2 = objRef.ok;
  15. });
  16. t1 = objRef.text;
  17. });

理想情况下objRef.ok -> [fn2], objRef.text -> [fn1]。但是修改objRef.text却发现只执行了fn2,我们知道这是依赖收集的副作用函数指向发生了变化。当执行fn1时,fn2也被触发;执行完fn2时我们发现正在活跃的activeEffect永远指向了fn2,无法指回fn1导致objRef.text -> [fn2]
解觉方案:可以使用保存一个栈effectStack,栈顶始终指向正在执行的副作用函数activeEffect,保证依赖收集正确;当嵌套内的函数执行完成后,栈顶元素弹出;activeEffect重新指向正在执行的外层函数。

  1. // ! 感叹号是新增的代码
  2. let activeEffect;
  3. /**
  4. * ! 添加一个栈,栈顶指向正在执行的副作用函数
  5. * ! 防止嵌套的副作用函数导致依赖收集错误
  6. */
  7. const effectStack = [];
  8. function effect(fn) {
  9. const effectFn = () => {
  10. /** 调用前清除副作用依赖 */
  11. cleanup(effectFn);
  12. activeEffect = effectFn;
  13. /**
  14. * ! 将副作用函数的引用压入栈顶
  15. */
  16. effectStack.push(effectFn);
  17. fn();
  18. /**
  19. * ! 内层函数的执行完毕后,栈顶的副作用弹出
  20. * ! 正在活跃的副作用函数activeEffect指向外层的函数
  21. */
  22. effectStack.pop();
  23. activeEffect = effectStack[effectStack.length - 1];
  24. }
  25. /** 重新收集副作用依赖 */
  26. effectFn.deps = [];
  27. effectFn();
  28. }

完整的代码参考:嵌套的effect与effect栈

5) 避免无限递归循环

当我们在同一个副作用函数中同时读取写入数据的时候。读取触发track收集副作用函数,写入触发trigger执行副作用函数,执行副作用函数的过程中又track了一次导致无限调用。

  1. // 堆栈溢出
  2. effect(function fn1() {
  3. objRef.ok++;
  4. });

解决方案:trigger的时候判断触发的副作用函数是否是当前正在执行的副作用函数activeEffect;如果是则选择不trigger这个副作用函数

  1. function trigger(target, key) {
  2. ...
  3. /** Set.forEach 先删除后添加元素会导致无限循环,创建一个新的Set */
  4. const newSet = new Set(deps);
  5. newSet.forEach((fn) => {
  6. /**
  7. * ! 判断要trigger的函数是否是正在执行的副作用函数;避免无限递归循环
  8. */
  9. if (fn !== activeEffect) {
  10. fn();
  11. }
  12. });
  13. }

6) 调度执行

为了可以让副作用函数选择在什么时候执行,即副作用函数具有可调度性;在副作用函数注册器effect中添加一个可选的options,指定一个调度器。这样我们就可以决定副作用的执行时机

  1. effect(function fn1() {
  2. objRef.ok++;
  3. }, {
  4. // 这个调度器就是将函数放在计时器中执行
  5. scheduler(fn) {
  6. setTimeout(fn)
  7. }
  8. });

为了支持调度器,在副作用函数注册器effect中将options挂载到函数上;在trigger中决定是立即执行函数fn还是把函数放在调度器中

  1. function effect(fn, options) {
  2. ...
  3. /**
  4. * ! 将options挂载到函数上
  5. */
  6. effectFn.options = options;
  7. /** 重新收集副作用依赖 */
  8. effectFn.deps = [];
  9. effectFn();
  10. }
  11. function trigger(target, key) {
  12. ...
  13. newSet.forEach((fn) => {
  14. if (fn !== activeEffect) {
  15. /**
  16. * ! 有调度器存在的时候将fn交给调度器scheduler执行
  17. */
  18. if (fn.options && fn.options.scheduler) {
  19. fn.options.scheduler(fn);
  20. } else {
  21. fn();
  22. }
  23. }
  24. });
  25. }

完整的代码参考:调度执行

7) 计算属性computed与lazy

  1. 目前而言,我们的副作用函数都是在注册的时候立即执行的,但是我们需要它不立即执行;我们可以选择手动执行等。需要我们effect注册器中添加options.lazy。不执行副作用函数而是将它返回给我们。

    1. function effect(fn, options) {
    2. ...
    3. effectFn.options = options;
    4. effectFn.deps = [];
    5. /**
    6. * ! 如果options为true,不选择执行而是return出去
    7. */
    8. if (options.lazy) {
    9. effectFn();
    10. }
    11. return effectFn;
    12. }
  2. 如果把副作用函数fn当作一个getter,需要获得它的返回值;就需要在我们生成的effectFn中把原来函数的返回值返回回来

    1. function effect(fn, options) {
    2. const effectFn = () => {
    3. ...
    4. /**
    5. * ! 将结果保存起来
    6. */
    7. const res = fn();
    8. ...
    9. /**
    10. * ! 将原函数执行的结果返回
    11. */
    12. return res;
    13. }
  3. 接下来我们利用目前实现的响应实现一个computed函数,他会收集传入的getter的依赖,返回一个对象obj,在访问obj.value时返回getter的值

    1. /**
    2. * ! 实现vuejs中的computed函数,收集依赖在访问value的时候返回计算值
    3. */
    4. function computed(getter) {
    5. const fn = effect(getter, {
    6. lazy: true
    7. });
    8. const obj = {
    9. get value() {
    10. return fn();
    11. }
    12. }
    13. return obj;
    14. }
  4. 我们还知道,vue的computed是可以缓存的,当依赖的响应数据没有发生变化;computed的副作用回调函数是不会执行的。所以我们可以添加value缓存值,添加dirty是否需要更新。

当响应数据更新的时候,可以在调度器中将脏值dirty改回true

  1. /**
  2. * ! 实现vuejs中的computed函数,收集依赖在访问value的时候返回计算值
  3. */
  4. function computed(getter) {
  5. /**
  6. * ! value缓存值,dirty脏值检测
  7. */
  8. let value;
  9. let dirty = true;
  10. const fn = effect(getter, {
  11. lazy: true,
  12. /**
  13. * ! 调度器会在数据更新的时候调用,可以用它更新dirty值
  14. */
  15. scheduler() {
  16. if (!dirty) {
  17. dirty = true;
  18. }
  19. }
  20. });
  21. const obj = {
  22. get value() {
  23. /**
  24. * ! 如果脏值为true,则调用副函数回调更新
  25. */
  26. if (dirty) {
  27. value = fn();
  28. dirty = false;
  29. }
  30. return value;
  31. }
  32. }
  33. return obj;
  34. }
  1. 如果计算属性bar也嵌套在注册的副函数中,那么bar依赖的响应数据obj2Ref发生变化的时候;bar的值发生了改变;bar的副函数应该触发。但实际上bar的副函数没有触发

那么怎么在这种情况下更新bar.value呢。就是当获取value的时候主动track跟踪副函数,在调度器中设置dirty=true,即计算属性bar的值需要更新的时候主动触发trigger。触发计算属性bar的副作用函数。让响应式数据objRef2和bar的副函数联系起来,形成obj2Ref -> bar -> effectFn的响应链条

  1. /**
  2. * ! 实现vuejs中的computed函数,收集依赖在访问value的时候返回计算值
  3. */
  4. function computed(getter) {
  5. /**
  6. * ! value缓存值,dirty脏值检测
  7. */
  8. let value;
  9. let dirty = true;
  10. let obj;
  11. const fn = effect(getter, {
  12. lazy: true,
  13. /**
  14. * ! 调度器会在数据更新的时候调用,可以用它更新dirty值
  15. */
  16. scheduler() {
  17. /**
  18. * ! 在依赖的响应值修改的时候手动trigger
  19. */
  20. if(!dirty) {
  21. dirty = true;
  22. trigger(obj, 'value');
  23. }
  24. }
  25. });
  26. obj = {
  27. get value() {
  28. /**
  29. * ! 如果脏值为true,则调用副函数回调更新
  30. */
  31. if (dirty) {
  32. value = fn();
  33. dirty = false;
  34. }
  35. /**
  36. * ! 在获取值的时候手动track
  37. */
  38. track(obj, 'value');
  39. return value;
  40. }
  41. }
  42. return obj;
  43. }

完整的代码参考:调度执行