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
}
})
上述代码有很多地方不完善,例如我们硬编码了副作用函数的名称为effect,如果它不叫effect,就会出错,我们需要提供一个注册副作用函数的机制,代码如下:
//用一个全局变量保存被注册的副作用函数
let activeEffect;
//effect方法用于注册副作用函数
function effect(fn){
//当调用effect注册副作用函数时,将副作用函数赋值给activeEffect
activeEffect = fn;
//执行副作用函数
fn()
}
如上所示,我们定义一个全局变量,当effct函数执行时,将匿名的副作用函数赋值给全局变量,再执行副作用函数,就会触发响应式数据的读取操作,进而触发get函数,将匿名函数存入桶中。代码如下:
const obj = new Proxy(data,{
get(target,key){
if(activeEffect){
bucket.add(activeEffect);
}
return target[key]
}
})
此外,我们需要为副作用函数和被操作的目标字段间建立联系,否则,修改一个属性后,其他属性的副作用函数也会执行。使用weakMap作为桶的数据结构,代码如下:
{
obj1:{
key1:[effect1,effect2],
key2:[effect3,effect4]
},
obj2:{
key3:[effect5,effect6],
key4:[effect7,effect8]
},
}
新的函数代码如下:
const bucket = new WeakMap();
const obj = new Proxy(data,{
//拦截读取操作
get(target,key){
//没有activeEffect,直接return
if(!activeEffect){
return target[key]
}
//从桶中取出与target关联的depsMap,如果没有,则设置
let depsMap = bucket.get(target);
if(!depsMap){
bucket.set(target,(depsMap = new Map())
}
//再从depsMap中取出与当前key值对应的副作用函数列表,如果没有则设置
let deps = depsMap.get(key);
if(!deps){
depsMap.set(key,(deps = new Set());
}
//将副作用函数添加到桶里
deps.add(activeEffect);
//返回属性值
return target[key]
}
//拦截设置操作
set(target,key,newVal){
//设置属性值
target[key] = newVal;
//把副作用函数从桶中取出并执行
const depsMap = bucket.get(target);
if(!depsMap){
return
}
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn())
}
})
weakMap具有一个特性,当key值被清除时,对应的value也会被清除,所以用于保存那些只有key存在,value才有意义的数据。在这里,只有被劫持的数据对象存在,它的副作用函数集合才有意义,所以使用weakMap。此外,我们把将副作用函数收集到桶中的过程封装为track函数,将触发副作用函数依次执行的过程封装为trigger函数。
2.分支切换与cleanup
代码示例:
function effect(){
document.body.innerText = obj.ok ? obj.text : 'not';
}
当obj.ok的值变化时,代码执行的分支也会改变,这就是分支切换。上述的分支切换会导致遗留的副作用函数,当obj.ok被设置为true时,会收集obj.text的依赖,当ok被设置为false时,text的依赖还在,text变化依然会导致副作用函数执行。为了解决这个问题,每次副作用函数执行时,我们先把它从所有相关依赖中移除。因此我们需要明确知道哪些依赖集合中包含这个副作用函数,我们为activeEffect添加deps属性,在依赖收集过程(即track函数)中将依赖集合添加到activeEffect.deps中。代码如下:
function track(target,key){
//没有activeEffect,直接返回
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中
deps.add(activeEffect)
//deps就是一个与当前副作用函数存在联系的依赖集合,将其添加到activeEffect.deps中
activeEffect.deps.push(deps)
在执行副作用函数之前时,我们遍历副作用函数的deps数组,这个数组的每一项都是一个依赖集合,将该副作用函数从每个依赖集合中删去,最后重置副作用函数的deps数组即可。代码如下:
function cleanup(effectFn){
//遍历effectFn.deps数组
for(const i of effectFn.deps){
i.delete(effectFn)
}
//重置effectFn.deps
effectFn.deps.length = 0;
}
3.嵌套的effect与effect栈
当组件发生嵌套时,例如a组件中渲染b组件,就会触发effect的嵌套:
effect(()=>{
a.render();
effect(()=>{
b.render();
})
})
而之前的effect是不支持嵌套的,我们使用activeEffect来存储激活的effect函数,内层effect执行时会覆盖activeEffect,而且无法复原,如果再有响应式数据进行依赖收集,它只会收集到内层effect,这就可能导致错误。 所以我们需要定义一个effectStack数组,作为储存副作用函数的栈。effect执行时入栈,执行完毕时出栈,这样栈顶元素就是需要被收集的副作用函数,代码如下:
function(){
const effectFn= ()=>{
activeEffect = effectFn;
//调用副作用函数前,将其入栈
effectStack.push(effectFn);
fn();
//副作用函数执行完毕后出栈,并还原activeEffect
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1]
}
}
4.调度执行
在vue中调度指的是当修改响应式数据时触发trigger动作,将副作用函数执行时,有能力决定副作用函数执行的时机、次数和方式。具体做法是给effect函数设计一个options参数,它指明调度选项,在trigger动作中可以根据options调度effect函数的执行。代码如下:
effect(fn,
//options选项,调度器scheduler是一个函数
{scheduler:()=>{
}
}
}
如果有scheduler选项,则执行scheduler选项,否则执行effectFn。代码如下:
function trigger(target,key){
const depsMap = bucket.get(target);
if(!depsMap){return};
const effects = depsMap.get(key),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()
}
})
}
如果在scheduler中使用promise和new Set()实现微任务队列,将副作用函数放入微任务队列中执行,Set数据结构可以自动去重,即使连续多次修改同一个响应式数据,最终副作用函数也只触发一次。
5.计算属性computed和lazy
有时候我们希望effect能延迟执行,可以通过在effect的options中添加lazy属性实现:
function effect(fn,options){
const effectFn= ()=>{
//包装fn函数及其他操作,与之前相同
}
if(!options.lazy){
//执行副作用函数
effectFn()
}
//将副作用函数作为返回值返回
return effectFn
}
我们将副作用函数effectFn作为effect函数的返回值,调用effect时可以通过返回值拿到effectFn,然后手动执行effectFn。如果把effect的第一个参数fn作为一个getter,即effectFn用于包装getter,在执行effectFn时,返回getter的返回值,代码如下:
function effect(fn,options={}){
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
const res = fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
return res;
}
effectFn.options = options;
effectFn.deps = [];
if(!options.lazy){
effectFn()
}
return effectFn();
}
接下来实现计算属性:
function computed(getter){
//把getter作为副作用函数,创建一个lazy的effect
const effectFn = effect(getter,{lazy:true});
const obj = {
//读取value时才执行effectFn
get value(){
return effectFn()
}
}
return obj;
}
如上所示,computed函数接受一个getter函数,computed函数会返回一个对象,当读取这个对象值时会触发get value,调用effectFn方法,就到达了只有读取value才会执行effectFn的效果,,也就是懒计算。接下来实现值的缓存,使用变量val存储上一次计算的值,使用变量dirty标识是否需要重新计算。代码如下:
function computed(getter){
let val;
//值为true表示需要计算
let dirty = true;
//把getter作为副作用函数,创建一个lazy的effect
const effectFn = effect(getter,{lazy:true});
const obj = {
//读取value时才执行effectFn
get value(){
if(dirty){
val = effectFn();
dirty = false;
}
return val
}
}
return obj;
}
另外,我们需要在getter函数对应的值发生变化后,将dirty重置为true,做法是:为effect的option添加scheduler调度器,它会在getter函数对应的值发生变化后执行,用于将dirty重置为true。代码如下:
const effectFn = effect(getter,{lazy:true,
scheduler(){
dirty = true
}
);
计算属性也可能发生嵌套,当在另一个副作用函数中读取计算属性的值的时候,计算属性的getter函数之会把计算属性内部的effect收集为依赖,而不会收集另一个副作用函数,解决办法是:在读取计算函数值时,主动调用track函数追踪,计算属性依赖的响应式数据变化时,主动调用trigger函数触发响应,代码如下:
function computed(getter){
let val;
//值为true表示需要计算
let dirty = true;
//把getter作为副作用函数,创建一个lazy的effect
const effectFn = effect(getter,{lazy:true,
scheduler(!dirty){
dirty = true;
trigger(obj,"value");
}
}
);
const obj = {
//读取value时才执行effectFn
get value(){
if(dirty){
val = effectFn();
dirty = false;
}
track(obj,"value");
return val
}
}
return obj;
}
6.watch的实现原理
watch的本质是监听一个响应式数据,数据变化时通知并执行对应的回调函数,它使用effect的schduler。代码如下:
//souce是被监听的数据,cb是回调函数
function watch(source,cb){
effect(()=>{traverse(source)},{
scheduler:(){
cb();
}
}
)
}
function traverse(value,seen = new Set()){
//如果要读取的数据是原始值,或者已经被读取了,就什么都不做
if(typeof value !=="object" || value === null || seen.has(value)){ return };
seen.add(value);
for(const i in value){
traverse(value[i],seen)
}
return value;
}
watch可以接收getter函数,当函数依赖的响应式数据发生变化时,触发watch的回调。可以通过effect函数的lazy选项获取新值和旧值。代码如下:
function watch(source,cb){
let getter;
if(typeof source === "function"){
getter = source
}else{
getter = () => traverse(source)
}
let oldValue,newValue;
//使用effect函数时,开启lazy选项,并把返回值存储到effectFn中
const effectFn = effect(
()=>getter,
{
lazy:true,
scheduler(){
//在scheduler中重新执行副作用函数,得到新值
newValue = effectFn();
//将新值和旧值作为回调函数的参数
cb(newValue,oldValue);
//更新旧值
oldValue = newValue
}
}
)
//手动调用副作用函数,拿到的就是旧值,即第一次执行得到的值
oldValue = effectFn();
}
watch具有两个特性立即执行的回调函数和回调函数的执行时机。指定watch的immediate为true,在watch函数内部判断如果immediate为true,则立即执行。在vue3中使用flush选项指定回调函数执行时机。
7.竞态问题与过期的副作用
假设这样一种场景,发送第一次请求A,修改变量a的值,又发送第二次请求B,也是修改变量a的值,A过了2s才返回结果,B的请求1s就返回了结果。从逻辑上讲,请求B的结果是最新的,我们希望由请求B来更新变量a的值。watch的回调函数接收第三个参数onInvalidate,它是一个函数,类似事件监听器,可以用它注册一个回调,这个回调会在当前副作用函数过期时执行,代码如下:
watch(obj,async(newValue,oldValue,onInvalidate)=>{
//声明变量记录当前副作用函数是否过期,默认为false表示没有过期
let expired = false;
//注册过期回调
onInvalidate(()=> expired = true);
//发送网络请求
const res = await fetch('/path/to/');
//副作用函数没有过期才执行后续操作
if(!expired){
finalData = res;
}
})
watch内部每次检测到变更后,在副作用函数执行前,都先调用onInvalidate函数注册的过期回调。即每次请求接口时,都使用一次闭包,闭包中含有一个expired变量,执行请求A时,会产生一个expired = false,执行请求B时,会将上一次闭包的expired设置为true,当请求A返回时,它的expired为true也就不会更新值。这样就解决了竞态问题。