《Vue.js 技术与实现》第4章 响应式系统的作用和实现 阅读总结

☁️ 2022.03.20 15:30

这一章题序中上来就抛出了如下问题:

  • 什么是响应式数据和副作用函数?
  • 实现中如何避免无限递归?
  • 为什么需要嵌套的副作用函数?
  • 两个副作用函数之间会产生哪些影响?

花费一个晚上一个下午读完了这一章,读完的第一感觉是信息量巨大。即使之前自己实现过一遍属于自己的 mini-vue,看完本章依然觉得填补了我很多知识盲区,收获很多,但一时很难消化。

此刻难消化的原因:很难在头脑中重现源码中针对哪些问题给出了哪些解决方案?

对于看完之后合上书本让人感觉虚空的问题,决定用自己方式再走一遍!整理读书笔记加手动源码实现。

梳理本章解决了哪些问题:

  • 如何实现一个完善的响应式系统
  • 分支切换和 cleanup,effects 依赖在收集时如何避免无限循环?
  • 嵌套的 effect 的如何处理
  • 如何避免无限递归循环
  • 调度执行 scheduler 是什么?解决了什么问题?
  • computed API 的实现原理?
  • watch API 的实现原理?
  • 如何处理 watch api 中的立即执行,post 异步执行
  • watch 函数中如何解决竞态问题?

如何实现一个完善的响应式系统

抛开响应式系统的细节处理,实现一个基本的响应式系统,应该是比较简单的。

ES6 proxy 尝试

const target = {
  text: "Hello World!",
};
const proxy = new Proxy(target, {
  get(target, key, receiver) {
    return target[key];
  },
  set(target, key, value, receiver) {
    target[key] = value;
  },
});

console.log(proxy.text); // Hello World!

proxy.text = "Hello Vue3!"

console.log(proxy.text); // Hello Vue3! 

可以看到 proxy 和原来 Vue2 使用的 Object.defineProperty() 差不多,都可以对属性进行劫持。

需要注意的是

Object.defineProperty 不能监听对象属性新增和删除,在初始化阶段递归执行劫持对象属性,性能损耗较大;

Proxy 可以监听到对象属性的增删改,在访问对象属性时才递归执行劫持对象属性,在性能上有所提升。不过 Proxy API 属于 ES6 规范,目前 IE 尚不支持。

上面那段代码无法对如下对象进行劫持:

const target = {
  name: "Hello World!",
  get alias() {
    return this.name;
  },
};

通过 Reflect.get(target, key, receiver) 代替 target[key] 就可以解决上述问题。具体看我这篇博文:https://blog.csdn.net/hefeng6500/article/details/123610606?spm=1001.2014.3001.5502

下面是实现一个完善的响应式系统的过程,当然像 effect api 和 对象代理是硬编码,Vue3真实源码不是这样的,这里只做原理讲解,简单明了能说明问题即可!

const target = {
  text: "Hello World!",
};

let targetMap = new WeakMap();
let activeEffect;

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

const proxy = new Proxy(target, {
  get(target, key, receiver) {
    track(target, key);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    Reflect.set(target, key, value, receiver);
    trigger(target, key);
  },
});

function track(target, key) {
  if (!activeEffect) {
    return;
  }

  let depsMap = targetMap.get(target);

  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }

  let deps = depsMap.get(key);

  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }

  deps.add(activeEffect);
}

function trigger(target, key) {
  let depsMap = targetMap.get(target);

  if (!depsMap) {
    return;
  }

  let effects = depsMap.get(key);

  effects && effects.forEach((fn) => fn());
}

effect(() => {
  console.log(proxy.text);
});

proxy.text = "Hello Vue3!";

运行上述代码,effect 的回调函数执行两次,分别打印 Hello World!Hello Vue3!

可以看到上述代码使用 ES6 的 Proxytarget 对象进行了代理,在访问对象 target 的属性的时候被 get 函数劫持调用 track 函数进行依赖收集,并且返回 key 对应的值。依赖收集的过程简单说就是将 activeEffect 放进特定的数据结构中

在对 target 的属性进行设置时被 set 函数劫持为 target 属性设置新的值,并且调用 trigger 设置新的值。依赖触发就是将之前收集好的依赖从特定数据结构中取出来执行

这个依赖收集和触发的过程就像是发布订阅模式。

track 依赖收集的过程

将依赖收集设置成如下图所示的数据结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8oOlXahs-1648126903905)(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/64b7d539-d130-463f-9901-6486a82ee014/Untitled.png)]

  • targetMap 是一个 WeakMap 数据结构:target —> Map()
  • depsMap 是一个 Map 数据结构,其值是一个 Set 数据结构:key —> Set()

分支切换和 cleanup

很快我们就发现对应下面这种案例就会出问题

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

effect(() => {
  console.log('effect run')
  document.body.innerText = obj.ok ? obj.text : 'not'
})

obj.ok = false;

对于 effect 函数中三目运算分支切换问题,首次执行时,访问 obj.ok 对 ok 属性进行依赖收集,由于 ok 为 true,访问 obj.text ,再对 text 属性进行依赖收集。

如果接下来修改 ok 属性值为 false,将会触发副作用函数重新执行,由于 obj.ok 值为 false,所以 obj.text 不会读取,所以副作用函数不该再被 obj.text 所对应的依赖集合收集。

但是实际上上次收集的依赖还在,obj.ok 和 obj.text 在上次访问时收集的依赖并没有消失,即使这次只对 obj.ok 读取并进行依赖收集,实际上 obj.ok 和 obj.text 所对应的副作用函数还在收集的依赖中。所以我们把之前收集的依赖清除掉不就可以了吗?反正在触发依赖执行时会再次访问副作用函数,会再次进行依赖收集。

所以,我们定义一个 cleanup 函数清除之前收集的依赖。如下:

let targetMap = new WeakMap();
let activeEffect;

const target = { ok: true, text: 'hello world' }

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

    activeEffect = effectFn;
    fn();
  };

  effectFn.deps = [];

  effectFn();
}

const obj = new Proxy(target, {
  get(target, key, receiver) {
    track(target, key);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    Reflect.set(target, key, value, receiver);
    trigger(target, key);
  },
});

function track(target, key) {
  if (!activeEffect) {
    return;
  }

  let depsMap = targetMap.get(target);

  if (!depsMap) {
    targetMap.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);
}

function trigger(target, key) {
  let depsMap = targetMap.get(target);

  if (!depsMap) {
    return;
  }

  let effects = depsMap.get(key);

  effects && effects.forEach((fn) => fn());
}

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0;
}

effect(() => {
  console.log("effect run");
  document.body.innerText = obj.ok ? obj.text : "not";
});

setTimeout(() => {
  obj.ok = false;
  setTimeout(() => {
    obj.text = "hello vue3";
  }, 1000);
}, 1000);

看上去好像越来越完美了,但是运行是你会发现无限循环

原因是:trigger 函数在执行时,会遍历 fn 进行执行,fn 执行时又会 cleanup 依赖(减少),然后调用副作用函数收集依赖(增加),一减一增,如此循环导致。具体可以查看 Set.prototype.forEach

effects 依赖在收集时如何避免无限循环?

其实这个问题很好解决!重新造一个 Set 进行遍历

function trigger(target, key) {
  let depsMap = targetMap.get(target);

  if (!depsMap) {
    return;
  }

  let effects = depsMap.get(key);
  
  const effectToRun = new Set(effects)

  effectToRun && effectToRun.forEach((fn) => fn());
}

嵌套的 effect 的如何处理

测试用例是这样的

const target = { count: 0, text: "Hello World!" };
const obj = new Proxy(data, { /** ... */ })

effect(() => {
  effect(() => {
    console.log("inside effect: ", obj.text);
  });
  console.log("outside effect: ", obj.count);
});

obj.count++;

按理说应该输出

inside effect:  Hello World!
outside effect:  0
inside effect:  Hello World!
outside effect:  1

实际上

inside effect:  Hello World!
outside effect:  0
inside effect:  Hello World!

原有代码分析:

当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且不会恢复到原来的值。如果再有响应式数据发生依赖收集,即使是读取外层副作用函数中的数据,收集到的副作用函数也是内层的副作用函数。

如何解决?使用栈结构

const effectStack = [];

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

    activeEffect = effectFn;

    effectStack.push(activeEffect);

    fn();

    effectStack.pop();

    activeEffect = effectStack[effectStack.length - 1];
  };

  effectFn.deps = [];

  effectFn();
}

不得感叹,真聪明!

还没完!上述解决嵌套是 Vue3.2 之前的,来看看 Vue3.2 版本怎么处理嵌套的

run() {
  let parent = activeEffect;

  try {
    this.parent = activeEffect;
    activeEffect = this as any;

    return this.fn();
  } finally {
    activeEffect = this.parent;
    this.parent = undefined;
  }
}

执行外层副作用函数时,parent = undefined,this.parent = undefined; 然后 activeEffect 被赋值为外层副作用函数,再执行外层副作用函数,由于 外层副作用函数中存在 effect ,于是开始执行内层的 effect,同理:parent = 外层的副作用函数,内层的实例的 parent = 外层的 activeEffect,执行内层副作用函数,完成之后 finally,将 activeEffect 复位为原有父级的 activeEffect,再将内层的 parent 赋值为 undefined。

这个步骤让人的感受是:过完河,把桥拆掉。哈哈,开个玩笑!应该叫有始有终

写到这一步是不是已经够完美了,其实还有一个功能需要适配,继续!

如何避免无限递归循环

什么场景会造成无限递归循环?

const target = { foo: 1 }
const obj = new Proxy(target, { /** ... */ })

effect(() => {
	obj.foo++
})

effect 的副作用函数中读取属性并且修改属性,换句话说,副作用函数中收集依赖并且触发依赖。如果按之前的代码,一定会无限循环下去

解决方案:修改一下 trigger 函数

function trigger(target, key) {
  let depsMap = targetMap.get(target);

  if (!depsMap) {
    return;
  }

  let effects = depsMap.get(key);

  const effectToRun = new Set();

  effects.forEach((effectFn) => {
    if (effectFn !== activeEffect) {
      effectToRun.add(effectFn);
    }
  });

  effectToRun && effectToRun.forEach((fn) => fn());
}

如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行

要注意:这里 副作用函数会先执行一次,只是在执行的时候发生 track,trigger,trigger 的时候发现 effectFn !== activeEffect 所以就不会往 effectToRun 中添加 activeEffect 了。但不可否认副作用函数已经执行了一次了。

effect(() => {
	obj.foo++
})

这也让我明白了为什么源码中要将 effects 循环一遍再用的原因了,为的就是防止遇到这种使用情况下无限递归循环下去!

先到这,调度执行,computed 和 watch 下次继续总结!

梳理的过程真的非常消耗时间和精力,不过我认为这也是自我消化的最好的方法!加油!!!

调度执行 scheduler 是什么?解决了什么问题?

调度执行有能力决定副作用函数的执行的时机、次数以及方式。

const target = { foo: 1 }
const obj = new Proxy(target, { /** ... */ })

effect(() => {
	console.log(obj.foo)
})

obj.foo++

console.log("结束了")

希望输出

1
结束了
2

而不是

1
2
结束了

你可能觉得将 obj.foo++console.log("结束了") 顺序调换一下就可以了,如果不允许调换还有别的解决方案吗

解决方案:

聪明的你肯定会想到异步,那如何设计呢?

effect(() => {
	console.log(obj.foo)
}, 
{
	scheduler(fn){
		setTimeout(fn) // 进入下一次事件循环
	}
}
)
function trigger(target, key) {
  let depsMap = targetMap.get(target);

  if (!depsMap) {
    return;
  }

  let effects = depsMap.get(key);

  const effectToRun = new Set();

  effects &&
    effects.forEach((effectFn) => {
      if (effectFn !== activeEffect) {
        effectToRun.add(effectFn);
      }
    });

  effectToRun.forEach((effectFn) => {
+   if (effectFn.options.scheduler) {
+     effectFn.options.scheduler(effectFn);
    } else {
      effectFn();
    }
  });
}

这里可以看到

const effectStack = [];

function effect(fn, options) {
  const effectFn = () => {
    cleanup(effectFn);

    activeEffect = effectFn;

    effectStack.push(activeEffect);

    fn();

    effectStack.pop();

    activeEffect = effectStack[effectStack.length - 1];
  };

+	effectFn.options = options

  effectFn.deps = [];

  effectFn();
}

这个例子就提现了任务调度的时机选择问题。

下面再讲一个任务调度控制的次数问题

const target = { foo: 1 }
const obj = new Proxy(target, { /** ... */ })

effect(() => {
	console.log(obj.foo)
})

obj.foo++
obj.foo++

希望输出

1
3

而不是

1
2
3

解决方案:还是借助 scheduler 函数

const jobQueue = new Set()
const p = Promise.resolve()

let isFlushing = false
function flushJob() {
  if (isFlushing) return
  isFlushing = true
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

effect(() => {
  console.log(obj.foo)
}, {
  scheduler(fn) {
    jobQueue.add(fn)
    flushJob()
  }
})

obj.foo++
obj.foo++

连续运行 obj.foo++ 时,借助 Set 数据结构的去重能力,Set 结构里面只会保存一个副作用函数。

obj.foo++ 时会触发 trigger 函数,trigger 执行时调用 scheduler,将 fn 放进 Set,flushJob刷新任务队列,任务被挂起到微任务队列,再次 obj.foo++ 时会触发 trigger 函数,trigger 执行时调用 scheduler,由于Set的去重能力,就导致 Set 中只有一个副作用函数,flushJob刷新任务队列,任务被挂起到微任务队列。

接下来,执行微任务,由于 设置了 isFlushing 标志导致第二次 trigger 时调用的 flushJob 无法执行,第一次是执行了。但是 obj.foo++ 连续两次增加值在 Proxy set 中已经是被设置了。所以值是修改了两次,但是副作用函数只执行了一次!

需要明确一点的是:scheduler 调度函数是在 trigger 后才开始执行的

computed API 的实现原理?

function computed(getter) {
  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        trigger(obj, 'value')
      }
    }
  })
  
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      track(obj, 'value')
      return value
    }
  }

  return obj
}

还是利用了 effect 函数的功能,在对象 get 时进行依赖收集,在 set 时触发副作用函数更新。采用了 dirty 标识,在值未更新的时候继续使用之前的值。

function effect(fn, options) {
  const effectFn = () => {
    cleanup(effectFn);

    activeEffect = effectFn;

    effectStack.push(activeEffect);

    fn();

    effectStack.pop();

    activeEffect = effectStack[effectStack.length - 1];
  };

	effectFn.options = options

  effectFn.deps = [];

+  if (!options.lazy) {
+    effectFn()
+  }

+  return effectFn
}

watch API 的实现原理?

希望通过如下方式运行:

watch(() => obj.foo, (newVal, oldVal) => {
  console.log(newVal, oldVal)
}, {
  immediate: true,
  flush: 'post'
})

解决方案:

function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) return
	// 代表已经读取过了,避免循环引用引起的死循环,例如: a.b = a
  seen.add(value)
  for (const k in value) {
		// 访问属性值,收集依赖
    traverse(value[k], seen)
  }

  return value
}

function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

	const job = () => {
    newValue = effectFn()
		// watch 设置 immediate 首次执行时,oldValue 值为 undefined
    cb(oldValue, newValue)
    oldValue = newValue
  }

  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      }
    }
  )
  
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

就目前 watch api 的实现来看,traverse 深度遍历了 value,进行依赖收集,其实调用了 traverse 等于就是深度监听了。

这里需要明确的是:

effect 中的第一个参数,() => getter() 它是副作用函数,oldValue = effectFn() 时会执行副作用函数。scheduler 函数是在 trigger 时才执行的

watch 函数中如何解决竞态问题?

试想如下代码,obj.foo 变化两次,触发两次回调函数,由于请求先后的不可确定性,finallyData 就存在这种竞态问题

let finallyData

watch(() => obj.foo, async (newVal, oldVal, onInvalidate) => {
  const res = await fetch()

  finallyData = res
})

为保证值的确定性 watch 做如下修改

function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  let cleanup
  function onInvalidate(fn) {
    cleanup = fn
  }

  const job = () => {
    newValue = effectFn()
    if (cleanup) {
      cleanup()
    }
    cb(oldValue, newValue, onInvalidate)
    oldValue = newValue
  }
 
  const effectFn = effect(
    // 执行 getter
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') {
          const p = Promise.resolve()
          p.then(job)
        } else {
          job()
        }
      }
    }
  )
  
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}

watch api 可以这样使用

watch(() => obj.foo, async (newVal, oldVal, onInvalidate) => {
  let valid = true
  onInvalidate(() => {
    valid = false
  })
  const res = await fetch()

  if (!valid) return

  finallyData = res
  console.log(finallyData)
})

obj.foo++
obj.foo++

可以看到 watch api,第二个参数回调函数提供了第三个参数 onInvalidate ,在失效时修改 valid 值,如果失效函数直接 return,不再赋值。

再把目光投向 watch 函数的实现,函数内部添加了 cleanup 标识,在执行 job 时如果存在 cleanup 将会调用。再回到上述 watch 示例,如果连续两次修改 obj.foo++ ,第一次调用watch 的回调函数 onInvalidateonInvalidate 传入的回调函数被注册为 cleanup ,当第二次再次调用 watch 的回调时,存在 cleanup ,将会 cleanup(),示例中的 valid 值就设置为了 false

于是,即便是两次修改 obj.foo 触发 watch 的回调,只要第二次回调被触发,第一次的 valid 将被设置为 false,就算第一次请求返回了结果,依然不可以赋值给 finallyData

你可能感兴趣的:(Vue,3,Vue3,vue,vue.js,前端)