Vue3 响应式系统实现

响应式数据与副作用函数

希望在修改了 obj.text 之后, effect 函数能够自动执行, 那么 obj 就是一个响应式对象

const obj = { text: 'hello world' };
const effect = () => {
    console.log('副作用函数执行')
};
obj.text = 'vue3';

响应式数据的基本实现

拦截对象的读取操作, 将副作用函数放入一个 ‘桶’ 内, 当设置对象某一属性的时候, 将 effect 从桶中取出并执行

const bucket = new Set();
const data = { text: 'hello world' };

const obj = new Proxy(data, {
    get(target, key) {
        bucket.add(effect);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        bucket.forEach(fn => fn());
        return true;
    }
});

const effect = () => {
    console.log(obj.text);
};
effect()
setTimeout(() => {
    obj.text = 'test';
}, 1000);

存在的缺陷是, 如果副作用的名字不叫 effect, 就无法将其加入桶中


设计一个完善的响应式系统

用一个全局变量 【activeEffect】 存储被注册的副作用函数, effect 用于注册副作用函数

// 存储当前副作用函数
let activeEffect;
// 注册副作用函数
function effect(fn) {
    activeEffect = fn;
    // 立即执行的原因是为了触发读操作
    fn();
}
const bucket = new Set();
const data = { text: 'hello world' };

const obj = new Proxy(data, {
    get(target, key) {
        bucket.add(activeEffect);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        bucket.forEach(fn => fn());
        return true;
    }
});

effect(() => {
  console.log(obj.text)
})
setTimeout(() => {
    obj.text = '123'
})

存在的缺陷是, 没有在 【副作用函数】与 【被操作的目标字段】 之间建立明确的联系

例如, 当读取属性的时候, 无论读取哪一个属性, 都会将副作用加入桶中, 设置属性的时候, 无论设置哪一个属性, 都会触发副作用函数的执行


重新设计桶的数据结构

target1
——————text1
————effectFn1
target2
——————text2
————effectFn2

// 存储当前副作用函数
let activeEffect
// 注册副作用函数
function effect(fn) {
  activeEffect = fn
  // 立即执行的原因是为了触发读操作
  fn()
}
const bucket = new WeakMap()
const data = {text: 'hello world'}
function track(target, key) {
  if (!activeEffect) return
  // 根据 target 从桶中取出 depsMap, 他也是一个 Map 类型, key ---- effects
  let depsMap = bucket.get(target)
  // 如果不存在 depsMap,则新建一个 Map 与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 再根据 key 从 depsMap 中取得 deps ,它是一个 set 类型
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
}
function trigger(target, key) {
  const depsMap = bucket.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, newVal) {
    target[key] = newVal
    trigger(target, key)
    return true
  },
})

effect(() => {
  console.log('effect')
})


obj.text
obj.text = 1

为什么用 weakMap

可以看出,当下面的 IIFE 执行完毕后, 对于对象 foo 来说, 仍然作为KEY 被 Map引用, 因此【垃圾回收器】不会将其从内存中移除,但由于 weakMap 是弱引用, 因此会被移除

const map = new Map();
const weakMap = new WeakMap();
(function(){
    const foo = { foo: 1 };
    const bar = { bar: 2 };
    map.set(foo, 1);
    weakMap.set(bar, 2);
})()

分支切换与 cleanup

分支切换的定义

可以看出, 以下effect函数存在一个三元表达式, 当obj.ok 的值发生变化的时候, 代码的分支会随之切换


分支切换可能会导致遗留的副作用函数

const data = { ok: true, text: 'test' };
const obj = new Proxy(data, {/*...*/});
effect(() => {
    document.body.innerText = obj.ok ? obj.text : 'nope';
});

当字段obj.ok 的值为 true 的时候, text 和ok 的副作用函数都建立起了依赖

我们希望当 obj.ok 的值为 false的时候, obj.text 的【副作用函数】会消失

但是目前做不到这一点, 也就是分支切换导致的问题

解决办法是  在每次副作用函数执行的时候, 先把它从所有与之关联的【依赖集合】(set) 中删除

在副作用函数执行完毕之后, 会重新建立联系, 但在新的联系中不会包含遗留的副作用函数。

function cleanUp(effectFn) {
  // 清空依赖的函数
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  // 最后需要设置 effectFn.deps 数组
  effectFn.deps.length = 0
}

// 存储当前副作用函数
let activeEffect

// 注册副作用函数
function effect(fn) {
  const effectFn = () => {
    cleanUp(effectFn)
    activeEffect = effectFn
    // 立即执行的原因是为了触发读操作
    fn()
  }
  // 设置副作用【依赖集合】(也就是 keys)
  effectFn.deps = []
  effectFn() // 执行副作用函数
}

const data = {ok: true, text: 'test'}
const bucket = new WeakMap()

function track(target, key) {
  if (!activeEffect) return
  // 根据 target 从桶中取出 depsMap, 它也是一个 Map 类型, key ---- effects
  let depsMap = bucket.get(target)
  // 如果不存在 depsMap,则新建一个 Map 与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
  // 将其添加到副作用的依赖中
  activeEffect.deps.push(deps)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach((effectFn) => effectFn())
}

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

effect(() => {
  const a = obj.ok ? obj.text : 'nope'
  console.log('effect')
})
obj.ok = false

新问题, 当前代码会无限执行

因为在副作用函数执行的时候, 会调用 cleanUp 进行清楚, 但是副作用函数的执行会导致其被重新收集到集合中

相当于

const set = new Set([1]);
set.forEach(item => {
    set.delete(1);
    set.add(1);
    console.log('遍历中');
});

将其换成如下就Ok了

const set = new Set([1]);
const newSet = new Set();
newSet.forEach(item => {
    set.delete(1);
    set.add(1);
    console.log('遍历中');
});

所以,代码应该改成

function cleanUp(effectFn) {
  // 清空依赖的函数
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  // 最后需要设置 effectFn.deps 数组
  effectFn.deps.length = 0
}

// 存储当前副作用函数
let activeEffect

// 注册副作用函数
function effect(fn) {
  const effectFn = () => {
    cleanUp(effectFn)
    activeEffect = effectFn
    // 立即执行的原因是为了触发读操作
    fn()
  }
  // 设置副作用【依赖集合】(也就是 keys)
  effectFn.deps = []
  effectFn() // 执行副作用函数
}

const data = {ok: true, text: 'test'}
const bucket = new WeakMap()

function track(target, key) {
  if (!activeEffect) return
  // 根据 target 从桶中取出 depsMap, 它也是一个 Map 类型, key ---- effects
  let depsMap = bucket.get(target)
  // 如果不存在 depsMap,则新建一个 Map 与 target 关联
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()))
  }
  // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型
  let deps = depsMap.get(key)
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  deps.add(activeEffect)
  // 将其添加到副作用的依赖中
  activeEffect.deps.push(deps)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  // 因为在副作用函数执行的时候, 会调用 cleanUp 进行清楚, 但是副作用函数的执行会导致其被重新收集到集合中
  // 因此创建一个新集合来解决这个问题
  const effectsToRun = new Set(effects)
  effectsToRun && effectsToRun.forEach((effectFn) => effectFn())
}

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

effect(() => {
  const a = obj.ok ? obj.text : 'nope'
  console.log('effect')
})
obj.ok = false

嵌套的 effect 和 effect栈

const data = { foo: true, bar: true };
const obj = reactive(data);
let temp1, temp2;
effect(function effectFn1() {
    console.log('effectFn1');
    effect(function effectFn2(){
        console.log('effect2');
        temp2 = obj.bar;
    });
    temp1 = obj.foo;
});

理想清空下,我们希望副作用函数与对象属性之间的联系如下
data
——foo
——effectFn1
——bar
——effectFn2
此时的输出值为

effectFn1, effectFn2, effectFn2

理想情况下,我们期望的输出值是 effectFn1, effectFn2, effectFn1,但是修改了 obj.foo 的值, 貌似 effectFn1 并没有执行

问题就处在 activeEffect上

使用 activeEffect 来存储通过 effect 函数注册的副作用函数, 这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个

在上述例子中, 当执行了 effectFn2, 当前的 activeEffect 就为 effectFn2 了, 所以出现了以上结果

解决: 使用一个副作用函数栈 EffectStack, 在副作用执行的时候, 将当前副作用函数压入栈中, 待副作用函数执行完毕后将其从栈中弹出, 并始终让 activeEffect 指向栈顶的副作用函数

// 存储当前副作用函数
let activeEffect;
// effect 栈
const effectStack = [];
// 注册副作用函数
function effect(fn) {
    const effectFn = () => {
        cleanUp(effectFn);
        activeEffect = fn;
        // 在调用副作用函数之前, 将当前副作用函数压入栈中
        effectStack.push(effectFn);
        fn();
        // 在调用副作用函数之后, 将其从副作用栈中弹出, 并把 activeEffect 还原为之前的值
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
    };
    // 设置副作用【依赖集合】(也就是 keys)
    effectFn.deps = [];
    effectFn(); // 执行副作用函数
}

这样栈底就存的是【外层的副作用函数】,而栈顶则存储的是 【内层的副作用函数】

避免无限递归循环

以下代码会导致无限循环

const data = { foo: 1 };
const obj = reactive(data);
effect(() => { obj.foo = obj.foo + 1 });

在 effect 函数中, 既会读取 obj.foo 的值, 又会设置 obj.foo 的值, 导致问题的根本原因就是
读取track操作将【副作用函数】加入桶中, 设置 trigger 操作将副作用函数取出执行, 但是副作用函数正在执行中, 还没执行完毕, 就要开始下一次执行, 这样会无限递归调用自己


解决办法是: 在 trigger 的时候增加守卫条件_如果 trigger 触发执行的【副作用函数】与当前【正在执行的副作用函数】相同, 则不触发执行_

function trigger(target, key) {
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    const effects = depsMap.get(key);
    // 因为在副作用函数执行的时候, 会调用 cleanUp 进行清楚, 但是副作用函数的执行会导致其被重新收集到集合中
    // 因此创建一个新集合来解决这个问题
    const effectsToRun = new Set();
    effects && effects.forEach(effectFn => {
        // 如果 trigger 触发的 【副作用函数】与当前正在执行的【副院长函数】相同,则不触发执行
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn);
        }
    });
    effectsToRun && effectsToRun.forEach(effectFn => effectFn());
}

调度执行

就是 trigger 动作触发副作用函数执行的时候, 有能力决定副作用函数的执行【时机、次数、方式】

const data = { foo: 1 };
const obj = reactive(data);
effect(() => {
    console.log(obj.foo);
});
obj.foo ++;
console.log('结束了')
// 1, 2, 结束了

现在想让代码的输出顺序为 1, 结束了, 2 如何在不修改代码顺序的情况下, 支持输出这个结果呢

effect(() => {
    console.log(obj.foo);
},
// options
{
    scheduler(fn) {
        // 这是一个调度器, 将副作用函数放到宏任务队列中执行
        setTimeout(fn);
    }
}
);

调度器的实现

// 注册副作用函数
function effect(fn, options = {}) {
    const effectFn = () => {
        cleanUp(effectFn);
        activeEffect = fn;
        // 在调用副作用函数之前, 将当前副作用函数压入栈中
        effectStack.push(effectFn);
        fn();
        // 在调用副作用函数之后, 将其从副作用栈中弹出, 并把 activeEffect 还原为之前的值
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
    };
    // 将options挂载到 effectFn 上
    effectFn.options = options;
    // 设置副作用【依赖集合】(也就是 keys)
    effectFn.deps = [];
    effectFn(); // 执行副作用函数
}

trigger 中执行

function trigger(target, key) {
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    const effects = depsMap.get(key);
    // 因为在副作用函数执行的时候, 会调用 cleanUp 进行清楚, 但是副作用函数的执行会导致其被重新收集到集合中
    // 因此创建一个新集合来解决这个问题
    const effectsToRun = new Set();
    effects && effects.forEach(effectFn => {
        // 如果 trigger 触发的 【副作用函数】与当前正在执行的【副院长函数】相同,则不触发执行
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn);
        }
    });
    effectsToRun && effectsToRun.forEach(effectFn => {
        if (effectFn.options.scheduler) {
            // 调度执行
            effectFn.options.scheduler(effectFn);
        } else {
            effectFn();
        }
    });
}

computed实现原理

lazy 的 effect

我们实现的 effect 会立即执行传递给它的副作用函数, 但是在某些情况下, 我们不希望它立即执行

effect(() => {
    console.log(999);
}, {
    lazy: true
});

修改 effect

// 注册副作用函数
function effect(fn, options = {}) {
    const effectFn = () => {
        cleanUp(effectFn);
        activeEffect = fn;
        // 在调用副作用函数之前, 将当前副作用函数压入栈中
        effectStack.push(effectFn);
        const res = fn();
        // 在调用副作用函数之后, 将其从副作用栈中弹出, 并把 activeEffect 还原为之前的值
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
        return res;
    };
    // 将options挂载到 effectFn 上
    effectFn.options = options;
    // 设置副作用【依赖集合】(也就是 keys)
    effectFn.deps = [];
    if (!options.lazy) {
        effectFn(); // 执行副作用函数
    }
    return effectFn();
}

计算属性的实现

// 注册副作用函数
function effect(fn, options = {}) {
    const effectFn = () => {
        cleanUp(effectFn);
        activeEffect = fn;
        // 在调用副作用函数之前, 将当前副作用函数压入栈中
        effectStack.push(effectFn);
        const res = fn();
        // 在调用副作用函数之后, 将其从副作用栈中弹出, 并把 activeEffect 还原为之前的值
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
        return res;
    };
    // 将options挂载到 effectFn 上
    effectFn.options = options;
    // 设置副作用【依赖集合】(也就是 keys)
    effectFn.deps = [];
    if (!options.lazy) {
        effectFn(); // 执行副作用函数
    }
    return effectFn();
}

function computed(getter) {
    let value;
    // 用来标识是否需要重新计算值, 为 true 的时候就需要重新计算, 主要用作缓存
    let dirty = true;
    // 把 getter 作为一个副作用函数, 创建一个 lazy 的 effect
    const effectFn = effect(getter, {
        lazy: true,
        scheduler() {
            // 在 getter 中的响应式数据发生变化的时候执行
           if (!dirty) {
                dirty = true;
                // 当计算属性依赖的响应式数据发生变化的时候, 手动调用 trigger 函数触发响应
                trigger(obj, 'value');
           }
        }
    });
    const obj = {
        // 只有读取 value 的值时, 才会执行 effectFn 并将其结果作为返回值返回
        get value() {
            if (dirty) {
                value = effectFn();
                // 将 dirty 置为 false, 下一次直接访问直接使用缓存到 value 中的值
                dirty = false;
            }
            // 当读取 value 的时候, 手动调用 track 进行追钟
            track(obj, 'value');
            return value;
        }
    }
    return obj;
}

const sumRes = computed(() => obj.foo + obj.bar);
effect(() => {console.log(sumRes.value)});

上述代码的结构如下

computed(obj)
——————value
——————effectFn

watch实现原理

function traverse(value, seen = new Set()) {
    // 如果要读取的数据是原始值, 或者已经被读取过了, 那么什么都不做
    if (typeof value !== 'object' || value === null || seen.has(value)) return;
    // 将数据加入 seen 中,代表读取过了
    seen.set(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();
        cb(newValue, oldValue);
        // 更新旧值
        oldValue = newValue;
    };

    const effectFn = effect(
        // 执行 getter
        () => getter(),
        {
            lazy: true,
            scheduler: () => {
                if (options.flush === 'post') {
                    // 如果 flush 为 post, 将其放入微任务队列中执行
                    const p = Promise.resolve();
                    p.then(job);
                } else {
                    job();
                }
            }
        }
    );

    if (options.immediate) {
        job();
    } else {
        oldValue = effectFn();
    }
}

过期的副作用(竞态问题)

let finalData;
watch(obj, async() => {
    const res = await fetch('/getData');
    finalData = res;
});

当_第一次请求发出去, 还没返回的时候, 修改了 obj的值_, 导致发了第二个请求, 而且_第二个请求先于第一个请求返回结果_,那么得到的 finalData 的值就不准确
这就是【竞态问题】

解决, watch 回调函数的第三个参数 onInvalidate, 它是一个函数,用来注册回调函数,会在副作用函数过期的时候执行

let finalData;
watch(obj, async(newValue, oldValue, onInvalidate) => {
    let expired = false;
    onInvalidate(() => {
        expired = true;
    });
    const res = await fetch('/getData');
    if (!expired) {
        finalData = res;
    }
});

总结

一个响应式数据最基本的实现依赖于对“读取”和“设置”操作的拦截, 从而在副作用函数与响应式数据之间建立联系。
当“读取”操作发生时, 我们将当前执行的副作用函数存储到“桶”中,当“设置”操作发生时, 再将副作用函数从“桶”里取出并执行。
接着, 实现了一个相对完善的响应系统, 使用 WeakMap 配合 Map 构建了新的“桶”结构, 从而能够在响应式数据与副作用函数之间建立更加精确的联系。同时, 我们也介绍了 WeakMap 与 Map 这两个数据结构之间的区别。 WeakMap 是弱引用, 它不会影响垃圾回收器的工作。当用户代码对一个对象没有引用关系的时候, WeakMap 不会阻止垃圾回收器回收该对象。
我们还讨论了分支切换导致冗余的副作用的问题, 这个问题会导致副作用函数进行不必要的更新。为了解决这个问题, 我么需要在每次副作用函数重新执行之前, 清除上一次建立的响应联系, 而当副作用函数重新执行后, 会子啊此建立新的响应联系,新的响应联系中, 不存在冗余的副作用。
然后,我们讨论了关于嵌套副作用函数的问题。在实际场景中, 嵌套副作用函数发生在组件嵌套的场景中, 即父子组件关系。这时为了避免在响应式数据与副作用函数之间建立的响应联系发生错乱, 我们需要使用副作用函数栈来存储不同的副作用函数。当一个副作用函数执行完毕后, 将其从副作用函数栈中弹出,当读取响应数据的时候, 被读取的响应式数据只会与当前栈顶的副作用函数建立响应联系, 从而解决问题。而后, 我们遇到了副作用函数无限递归调用自身, 导致栈溢出的问题。该问题的根本原因在于, 对响应式数据的读取和设置操作发生在同一个副作用函数内(如 test.value = test.value ++;)。解决办法很简单, 如果trigger出发执行的副作用函数与当前正在执行的副作用函数相同, 则不触发执行。
随后,我们讨论了响应系统的可调度性。所谓可调度, 指的是当 trigger 动作触发副作用函数重新执行时, 有能力决定副作用函数执行的时机、次数以及方式。为了实现调度能力, 我们为 effect 函数增加了第二个选项参数, 可以通过 scheduler 选项指定调度器, 这样用户可以根据调度器自行完成任务的调度。我们还讲解了如何通过调度器实现任务去重, 即通过一个微任务队列对任务进行缓存, 从而实现去重。

posted @ 2024-08-15 14:56  Simple5960  阅读(21)  评论(0编辑  收藏  举报