响应式数据的基本实现

135 阅读4分钟

副作用:effect 函数的执行会直接或间接影响其他函数的执行。

响应式数据:当数据发生变化时,页面相应使用该数据的地方也会自动跟着发生变化。

响应式数据实现基本原理:使用 proxy 代理原始对象,从而拦截原始对象的读取和设置操作。在读取对象的属性时,定义一个数据桶,将副作用存放在这个数据桶中;在设置对象的属性时,遍历这个数据桶,将副作用取出执行。

第一版

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
    }
})

// test
function effect() {
    document.body.innerText = obj.text
}
effect()
setTimeout(() => {
    obj.text = 'hello vue3'
}, 1000);

目前出现的问题:硬编码了副作用函数的名字(effect),如果副作用函数的名字改变,那么上述代码就不能正常工作了。

解决思路:提供一个用来注册副作用函数的机制。让用户给副作用函数起什么名字都可以,甚至可以使用匿名函数。这样响应系统就不依赖副作用函数的名字了。

第二版

解决了响应系统依赖副作用函数的名字问题

const bucket = new Set()
const data = { text'hello world' }
let activeEffect // 新增
// 新增 start
function effect(fn) { 
    activeEffect = fn
    fn()
} 
// 新增 end
const obj = new Proxy(data, {
    get(target, key) {
        // 新增 start
        if (activeEffect) {
            bucket.add(activeEffect)
        }
        // 新增 end
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        bucket.forEach(fn => fn())
        return true
    }
})

// test
// 删除 start
// function effect() {
    // document.body.innerText = obj.text
// }
// effect()
// 删除 end
effect(() => {
    // effect 会被打印两次,effect 本身执行一次,后续 1000s 后由于出发了 set 操作又执行了一次
    console.log('effect');
    document.body.innerText = obj.text
})
setTimeout(() => {
    obj.text = 'hello vue3'
}, 1000);

目前出现的问题:没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。

// effect 还是被打印了两次,但是合理的情况应该是不会触发第二次 effect 才对,因为我们并没有设置 obj.text。
// 我们读取了 obj.text,那么会收集 effect。但是我们并没有设置 obj.text,而是设置了一个对象上不存在的属性,但是第二次的 effect 依然触发了。
// 原因就是 effect 和被操作的字段之间并没有建立联系,所以就变成了不管哪个属性设置都会触发 effect。
// 我们要想办法让响应式数据和 effect 建立对应联系。
effect(() => {
    console.log('effect');
    document.body.innerText = obj.text;
});
setTimeout(() => {
    obj.noExist = "hello vue3";
}, 1000);

解决思路:需要在副作用函数与被操作的字段之间建立联系,需要重新设置“桶”的数据结构。

effect(() => {
    document.body.innerText = obj.text;
});

树形结构:target -> key -> effect 伪代码模拟数据结构:target 原始对象,里面包含一个个属性【成员】,每个属性包含自己的副作用函数。

const bucket = {
  target1[depsMap]: {
    key1[deps]: [effect1, effect2, ...],
    key2[deps]: [effect1, effect2, ...]
  },
  target2[depsMap]: {
    key1[deps]: [effect1, effect2, ...],
    key2[deps]: [effect1, effect2, ...]
  }
}

第三版

解决了被操作字段与副作用之间没有建立联系的问题

WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。据这个特性可知,一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了。WeakMap 经常用于存储那些只有当 key 所引用的对象存在时(没有被回收)才有价值的信息,例如上面的场景中,如果 target 对象没有任何引用了,说明用户侧不再需要它了,这时垃圾回收器会完成回收任务。但如果使用 Map 来代替 WeakMap,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出。

const bucket = new Set() // 删除
const bucket = new WeakMap() // 新增
const data = { text'hello world' }
let activeEffect
function effect(fn) { 
    activeEffect = fn
    fn()
} 
const obj = new Proxy(data, {
    get(target, key) {
        // 删除 start
        // if (activeEffect) {
            // bucket.add(activeEffect)
        // }
        // 删除 end
        // 新增 start
        if (!activeEffect) return target[key];
        // 获取 key,为对应的 key 添加 对应的 effect
        let depsMap = bucket.get(target);
        if (!depsMap) bucket.set(target, (depsMap = new Map()));
        let deps = depsMap.get(key);
        if (!deps) depsMap.set(key, (deps = new Set()));
        deps.add(activeEffect);
        // 新增 end
        return target[key]
    },
    set(target, key, newVal) {
        target[key] = newVal
        // bucket.forEach(fn => fn()) // 删除
        // 新增 start
        // 获取 target -> key -> effect
        const depsMap = bucket.get(target);
        if (!depsMap) return;
        const effects = depsMap.get(key);
        effects && effects.forEach((fn) => fn());
        // 新增 end
        return true
    }
})

// test
effect(() => {
    // 现在,只执行了一次 effect,达到了我们想要的效果
    console.log('effect');
    document.body.innerText = obj.text
})
setTimeout(() => {
    obj.noExist = 'hello vue3' // 不触发
    obj.text = 'hello vue3' // 触发
}, 1000);

封装 track 和 trigger 函数

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

const data = { text: "hello world" }
const bucket = new WeakMap()
const obj = new Proxy(data, {
    get(target, key) {
      // 封装 track 函数
      track(target, key)
      return target[key]
    },
    set(target, key, newVal) {
      target[key] = newVal
      // 封装 trigger 函数
      trigger(target, key)
    }
})

function track(target, key) {
    if (!activeEffect) return target[key]
    let depsMap = bucket.get(target)
    if (!depsMap) bucket.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) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
}

// test
effect(() => {
    console.log("effect")
    document.body.innerText = obj.text
})
setTimeout(() => {
    obj.noExist = "hello vue3"
    obj.text = "hello vue3"
}, 1000)