Vue的响应式实现

158 阅读11分钟

什么是响应式数据

一个值变化了,与之相关的副作用函数会自动执行(数据改变、模版更新...),这个值就是响应式数据。在Vue3中,reactive、ref、computed、watch、toRef、toRefs等API来创建和管理响应式数据。

渐进式实现响应式数据

1 最基础的响应式数据

实现的基本思路如下:

  1. 当读取操作发生时,将副作用函数收集到“桶”中;
  2. 当设置操作发生时,从“桶”中取出副作用函数并执行。

在Vue3中使用Proxy来拦截对象读写操作的拦截,一个最简单的响应式就这样实现了:

// 存储副作用函数的桶
const bucket = new Set();

// 原始数据
const data = { text: "hello world" };
// 对原始数据的代理
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 将副作用函数 effect 添加到存储副作用函数的桶中
        bucket.add(effect);
        // 返回属性值
        return target[key];
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal;
        // 把副作用函数从桶里取出并执行
        bucket.forEach((fn) => fn());
        // 返回 true 代表设置操作成功
        return true;
    },
});

// 副作用函数
function effect() {
  document.body.innerText = obj.text
}

// 预期会触发effect的改变
setTimeout(() => {
    obj.text = "hello vue3";
}, 1000);

2 统一注册副作用函数

在vue中指computed/watch/v-model等都会触发effect函数

目前副作用函数硬编码为effect,显然是不可取的。需要有一个统一的副作用函数注册机制,即使是匿名函数,也能被正确的收集。
注册函数effect,每个正在读取obj.text的副作用函数都命名为activeEffect

// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
    // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
    activeEffect = fn;
    // 执行副作用函数
    fn();
}

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

收集副作用函数:

// 存储副作用函数的桶
const bucket = new Set();

// 原始数据
const data = { text: "hello world" };
// 对原始数据的代理
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
        bucket.add(activeEffect);
        // 返回属性值
        return target[key];
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal;
        // 把副作用函数从桶里取出并执行
        bucket.forEach((fn) => fn());
    },
});

3 细化依赖粒度

目前依赖的粒度是对象,副作用函数effectFn依赖obj.text,但obj.name改变,effectFn也会改变,因此需要把依赖的粒度细化到属性层面。 对象target、属性key、副作用函数effectFn的关系如下:

  • target
    • key
      • effectFn1
      • effectFn2

出于性能优化的考虑,使用weakMap存储target-> (key -> effectFn),当target不被引用,这一组key、value都会被垃圾回收。

image.png

代码实现:

  • 使用weakMap存储副作用函数
  • 封装依赖收集函数track
  • 封装依赖集合执行函数trigger
// 使用weakMap存储副作用函数的桶
const bucket = new WeakMap()

// 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

// track函数,封装收集依赖的功能
function track(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)
}

// trigger函数,封装依赖变更,副作用函数执行功能
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
  // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
  activeEffect = fn
  // 执行副作用函数
  fn()
}

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

setTimeout(() => {
  obj.text = 'hello vue3'
}, 1000)

4 清除不必要的依赖

在下面的代码中,obj.ok为true时,obj.text变动,effectFn需要进入obj.text的依赖集合;obj.ok为false时,obj.text变动不需要执行任何副作用函数,因此需要清除obj.text的依赖集合。

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

effect(function effectFn() {
    document.body.innerText = obj.ok ? obj.text : "not";
});

实现思路为每次副作用函数执行前,把它从所有依赖集合中删除;副作用函数执行中,再根据本次依赖了哪些数据,生成新的依赖集合(在track函数中实现)。 为了实现把副作用函数从所有依赖集合中删除的目标,需要维护副作用函数与依赖集合的映射关系。 关系如图:

image.png

代码实现:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    fn()
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  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) {
  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)
  activeEffect.deps.push(deps)
}

完整代码:

<body></body>
<script>


// 存储副作用函数的桶
const bucket = new WeakMap()

// 原始数据
const data = { ok: true, text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal
    // 把副作用函数从桶里取出并执行
    trigger(target, key)
  }
})

// 收集依赖
function track(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)
  activeEffect.deps.push(deps)
}

// 触发副作用函数
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  // 使用一个新的set,避免同时删、增导致无限循环的问题
  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    // 当前正在触发的副作用函数不再添加,避免无限递归
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
  // effects && effects.forEach(effectFn => effectFn())
}

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    fn()
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  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
}

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

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

</script>

下面这一段用来解决无限循环的bug,effectFn执行会先清空所有依赖,又会重新建立依赖,根据set的语言规范,这种情况下,forEach会无限循环。

const effectsToRun = new Set()
effects && effects.forEach(effectFn => effectsToRun.add(effectFn))
effectsToRun.forEach(effectFn => effectFn())
// effects && effects.forEach(effectFn => effectFn())

简化情况如下,一边删,一遍加,forEach会无限循环

const set = new Set([1])

const newSet = new Set(set)
newSet.forEach(item => {
  set.delete(1)
  set.add(1)
  console.log(999)
})

5 支持嵌套的effect

嵌套effect场景如下,父组件和子组件的嵌套渲染:

effect(() => {
    Foo.render();
    // 嵌套
    effect(() => {
        Bar.render();
    });
});

假设effect的嵌套如下:

// 原始数据
const data = { foo: true, bar: true };
// 代理对象
const obj = new Proxy(data, {
    /* ... */
});

// 全局变量
let temp, temp;

// effectFn 嵌套了 effectFn
effect(function effectFn() {
    console.log("effectFn1 执行");

    effect(function effectFn() {
        console.log("effectFn2 执行");
        // 在 effectFn 中读取 obj.bar 属性
        temp = obj.bar;
    });
    // 在 effectFn 中读取 obj.foo 属性
    temp = obj.foo;
});

目前的activeEffect是全局的普通变量,执行内层effect时,activeEffect变成了effectFn2,执行21行temp = obj.foo时,本来应该把外层effectFn1收集进依赖集合,但现在却把内层effectFn2收集了。

解决方案是增加一个副作用函数栈,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数

image.png 代码实现:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// effect 栈
const effectStack = []

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压栈
    effectStack.push(effectFn)
    fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

6 支持执行时机调度

目前副作用函数是属性修改后立即执行的,但真实的副作用通常是其他代码执行完后,再批量执行副作用(例如组件渲染),因此副作用函数执行的时机需要支持调度


支持调度
effect函数支持传入options,包含scheduler函数,来控制调度

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压栈
    effectStack.push(effectFn)
    fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  // 将 options 挂在到 effectFn 上
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

trigger时,如果有scheduler,就通过scheduler执行effectFn

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
  // effects && effects.forEach(effectFn => effectFn())
}

例如通过setTimeout来控制调度,实现先执行其他语句,再执行副作用函数

effect(() => console.log(obj.text), {
    scheduler(fn) {
        setTimeout(fn)
    }
})

7 支持执行次数调度

副作用的执行次数也应该支持调度,像下面这种情况,副作用函数应该只根据最后的状态执行一次即可:

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

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

obj.foo++;
obj.foo++;

代码实现:

// 定义一个任务队列
const jobQueue = new Set();
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve();

// 一个标志代表是否正在刷新队列
let isFlushing = false;
function flushJob() {
    // 如果队列正在刷新,则什么都不做
    if (isFlushing) return;
    // 设置为 true,代表正在刷新
    isFlushing = true;
    // 在微任务队列中刷新 jobQueue 队列
    p.then(() => {
        jobQueue.forEach((job) => job());
    }).finally(() => {
        // 结束后重置 isFlushing
        isFlushing = false;
    });
}

effect(
    () => {
        console.log(obj.foo);
    },
    {
        scheduler(fn) {
            // 每次调度时,将副作用函数添加到 jobQueue 队列中
            jobQueue.add(fn);
            // 调用 flushJob 刷新队列
            flushJob();
        },
    }
);

obj.foo++;
obj.foo++;
  • 每次执行响应式数据的写操作,就会触发一次effect函数,effect函数不是直接执行副作用操作,而是把当前副作用操作放入任务队列,并执行flushJob试图执行队列中的任务
  • 这个队列使用Set数据结构,保证副作用函数不会重复
  • promise.then保证调度时机,在同步任务执行完后执行
  • isFlushing标记,保证一次事件循环只执行一次任务队列中任务

响应式api的实现

这是只介绍简化版的实现,梳理一下这些api最基础的实现思路。

使用Reflect代替直接操作属性

在实现之前,需要先介绍下Reflect,在之前的实现中,是直接使用属性读取、设置,这样可能会出现this指向的问题。

const obj = {
    foo: 1,
    get bar() {
        return this.foo;
    },
};

const p = new Proxy(obj, {
    get(target, key) {
        track(target, key);
        return target[key];
    },
});

effect(() => {
    console.log(p.bar);
});

p.foo++;

副作用函数中,访问p.bar,期望访问p.foo,但实际上访问的是obj.foo,访问原始对象的属性,不会触发get,也就不会建立响应式联系。执行p.foo++,也就不会触发副作用函数执行。 为了解决上面的问题,我们需要引入Reflect,Reflect.get的第三个参数receiver可以明确地指定谁在读取属性。

const obj = {
    foo: 1,
    get bar() {
        return this.foo;
    },
};

const p = new Proxy(obj, {
    get(target, key, receiver) {
        track(target, key);
        return Relect.get(target, key, receiver);
    },
});

effect(() => {
    console.log(p.bar);
});

p.foo++;

reactive

// 存储副作用函数的桶
const bucket = new WeakMap();

function reactive(obj) {
    return new Proxy(obj, {
        // 拦截读取操作
        get(target, key, receiver) {
            // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
            track(target, key);
            // 返回属性值
            return Reflect.get(target, key, receiver);
        },
        // 拦截设置操作
        set(target, key, newVal, receiver) {
            // 设置属性值
            const res = Reflect.set(target, key, newVal, receiver);
            trigger(target, key, type);
        },
    });
}

function track(target, key) {
    if (!activeEffect) return;
    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);
    activeEffect.deps.push(deps);
}

function trigger(target, key) {
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    const effects = depsMap.get(key);

    const effectsToRun = new Set();
    effects &&
        effects.forEach((effectFn) => {
            if (effectFn !== activeEffect) {
                effectsToRun.add(effectFn);
            }
        });

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

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect;
// effect 栈
const effectStack = [];

function effect(fn) {
    const effectFn = () => {
        cleanup(effectFn);
        // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
        activeEffect = effectFn;
        // 在调用副作用函数之前将当前副作用函数压栈
        effectStack.push(effectFn);
        const res = fn();
        // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];

        return res;
    };

    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
    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;
}

ref

proxy只能代理对象,对于原始值,只能包裹一层,实现响应式。

function ref(val) {
  const wrapper = {
    value: val
  }

  // 通过这个属性实现模版中自动脱ref
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  })

  return reactive(wrapper)
}

总结

本文为《Vue.js设计与实现》响应系统的作用与实现一章的学习记录与梳理,实现了一个较为基础的响应系统。