0
点赞
收藏
分享

微信扫一扫

第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]​​​ 就可以解决上述问题。具体看我这篇博文:​​

下面是实现一个完善的响应式系统的过程,当然像 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 的 ​​Proxy​​​ 对 ​​target​​​ 对象进行了代理,在访问对象 ​​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 的回调函数 ​​onInvalidate​​​,​​onInvalidate​​​ 传入的回调函数被注册为 ​​cleanup​​​ ,当第二次再次调用 ​​watch​​​ 的回调时,存在 ​​cleanup​​​ ,将会 ​​cleanup()​​​,示例中的 ​​valid​​​ 值就设置为了 ​​false​​。

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



举报

相关推荐

0 条评论