什么是响应式数据
一个值变化了,与之相关的副作用函数会自动执行(数据改变、模版更新...),这个值就是响应式数据。在Vue3中,reactive、ref、computed、watch、toRef、toRefs等API来创建和管理响应式数据。
渐进式实现响应式数据
1 最基础的响应式数据
实现的基本思路如下:
- 当读取操作发生时,将副作用函数收集到“桶”中;
- 当设置操作发生时,从“桶”中取出副作用函数并执行。
在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
- key
出于性能优化的考虑,使用weakMap存储target-> (key -> effectFn),当target不被引用,这一组key、value都会被垃圾回收。
代码实现:
- 使用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函数中实现)。 为了实现把副作用函数从所有依赖集合中删除的目标,需要维护副作用函数与依赖集合的映射关系。 关系如图:
代码实现:
// 用一个全局变量存储当前激活的 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 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数
代码实现:
// 用一个全局变量存储当前激活的 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设计与实现》响应系统的作用与实现一章的学习记录与梳理,实现了一个较为基础的响应系统。