Vue.js响应系统的作用与实现

一个响应系统的工作流程如下:

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

一、设计合理的数据结构

1、 Set 数据结构作为存储副作用函数的"桶"

首先我们需要一个全局变量,用来存储副作用函数, 使其能够正确地被收集到"桶"中: 如以下代码所示:

javascript 复制代码
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
  // 当调用 effect 注册副作用函数时, 将副作用函数 fn 赋值给 activeEffect
  activeEffect = fn;
  // 执行副作用函数
  fn();
}
javascript 复制代码
effect(()=>{
  // 一个匿名的副作用函数
  document.body.innnerText = obj.text;
})

effect 函数执行时, 首先会把匿名函数 fn 赋值给全局变量 activeEffect。执行被注册的匿名副作用函数fn,这将会触发响应式数据 obj.text 的读取操作, 进而触发代理对象 Proxy 的 get 拦截函数:

javascript 复制代码
// 作用是存储被注册的副作用函数
let activeEffect;

function effect(fn) {
  activeEffect = fn;
  fn();
}
const data = { text: 'hello world' };
// 存储副作用函数的桶
const bucket = new Set();
const obj = new Proxy(data,{
	get(target, key) {
  	if (activeEffect) {
      bucket.add(activeEffect)
    }
    return target[key]
  },
  set(target, key, value) {
    target[key] = value;
    bucket.forEach(fn => fn());
    return true;
  }
});

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


setTimeout(() => {
  // // 副作用函数中并没有读取 notExist 属性的值
  obj.notExist = 'hello vue3'
}, 1000);

匿名副作用函数内部读取了 obj.text 的值, 于是匿名副作用函数与字段 obj.text 之间会建议响应联系。

出现问题: 上述中的定时器中, 为 obj 添加了一个新的 notExist 属性的值, 理论上, obj.notExist 并没有与副作用建议响应联系, 因此, 定时器内语句的执行不应该触发匿名副作用函数重新执行。 但执行上述代码之后, 发现匿名函数重新执行了, 这是不正确的。

根本原因: 我们没有在副作用函数与被操作的目标之间建立明确的联系。副作用函数与被操作的字段之间没有明确的联系。

如何解决:只需要在副作用函数与被操作的字段之间建立联系即可, 需要重新设计"桶"的数据结构

如何设计数据结构?

javascript 复制代码
 effect(function effectFn() {
   document.body.innerText = obj.text
 })

上述代码存在 3 个角色:

  • 被操作的代理对象(obj)
  • 被操作的字段名(text)
  • 使用 effect 函数注册的副作用函数effectFn

如果用 target 来表示一个代理对象所代理的原始对象, 用 key 来表示被操作的字段名, 用 effectFn 来表示被注册的副作用函数, 那么可以为这三个角色建立如下关系:

css 复制代码
target
	-----key
        -----effectFn

如果有两个副作用函数同时读取同一个对象的属性值

javascript 复制代码
effect(function effectFn1() {
  document.body.innerText = obj.text
})
effect(function effectFn2() {
  document.body.innerText = obj.text
})

那么关系如下:

css 复制代码
target
	-----key
        -----effectFn1
        -----effectFn2

如果一个副作用函数读取了同一个对象的两个不同的属性值

javascript 复制代码
effect(function effectFn() {
  console.log(obj.text1)
  console.log(obj.text2)
})

那么关系如下:

css 复制代码
target
    -----text1
        -----effectFn
    -----text2
        -----effectFn

如果在不同的副作用函数中读取了两个不同对象的不同属性:

javascript 复制代码
effect(function effectFn1() {
  console.log(obj1.text1)
})
effect(function effectFn2() {
  console.log(obj2.text2)
})

那么关系如下:

css 复制代码
obj1
    -----text1
        -----effectFn1
obj2
    -----text2
         -----effectFn2

总之 这其实就是一个树形数据结构。这个联系建立起来之后, 就可以解决前文提到的问题了。

2、使用 WeakMap 代替 Set 作用"桶"的数据结构

使用 WeakMap 配合 Map 构建了新的"桶"结构,从而能够在响应式数据与副作用函数之间 建立更加精确的联系。WeakMap 是弱引用的,它不影响垃圾回收器的 工作。当用户代码对一个对象没有引用关系时,WeakMap 不会阻止垃 圾回收器回收该对象。

2.1 使用 WeakMap 数据结构

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

// 存储副作用函数的桶
const bucket = new WeakMap();
// 修改拦截器代码
const obj = new Proxy(data, {
	get(target, key) {
    // 没有 activeEffect, 直接 return
    if (!activeEffect) return target[key];
    let depsMap = bucket.get(target);
    // 如果不存在 depsMap, 那么新建一个 Map,并与 target 关联
    if (!depsMap){
    	bucket.set(target, ( depsMap= new Map()));
    }
    // 再根据 key 从 depsMap 中去的 deps,它是一个 Set 类型
    // 如果deps 不存在, 同样新建一个 Set 并与 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;
    // 根据 target 从桶中取得 depsMap,它是 key----effects
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    const effects = depsMap.get(key);
    effects && effects.forEach(fn => fn());
  }
  
})

从上面代码, 不难看出 WeakMap、Map 和 Set 之间的关系:

  • WeakMap 由 target--->Map构成
  • Map 由 key---->Set 构成
WeakMap、Map 和 Set 之间的关系

2.2 Map/WeakMap/Set 之间的关系

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

// 存储副作用函数的桶
const bucket = new WeakMap();
// 修改拦截器代码
const obj = new Proxy(data, {
	get(target, key) {
    track(target, key);
    return target[key]
  },
  set(target, key, newVal) {
  	// 设置属性值
    target[key] = newVal;
    trigger(target, key, newVal);
  }
})

function track(target, key) {
	 // 没有 activeEffect, 直接 return
    if (!activeEffect) return target[key];
    let depsMap = bucket.get(target);
    // 如果不存在 depsMap, 那么新建一个 Map,并与 target 关联
    if (!depsMap){
    	bucket.set(target, ( depsMap= new Map()));
    }
    // 再根据 key 从 depsMap 中去的 deps,它是一个 Set 类型
    // 如果deps 不存在, 同样新建一个 Set 并与 key 关联
    let deps = depsMap.get(key);
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    // 最后将当前激活的副作用函数添加到桶中
    deps.add(activeEffect);
  
}

function trigger (target, key, newVal) {
	 // 根据 target 从桶中取得 depsMap,它是 key----effects
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    const effects = depsMap.get(key);
    effects && effects.forEach(fn => fn());
}

二、分支切换与 cleanup

1、分支切换带来的问题

javascript 复制代码
effect(function effectFn(){
	document.body.innerText = obj.ok ? obj.text : 'not'
})

在上述代码中, effectFn 内部存在一个三元表达式, 根据字段obj.ok 值的不同会执行不同的代码分支。 当 obj.ok值发生变化时, 代码执行的分支会跟着改变, 这就是所谓的分支切换。

发生问题:产生遗留的副作用函数 字段 obj.ok 的初始值为 true,这时会读取字段 obj.text 的值, 所以当 effectFn 函数执行时会触发字段 obj.ok 和字段 obj.text 这两个属性的读取操作,此时副作用函数 effectFn 与响应式数据之 间建立的联系如下: 副作用函数 effectFn 分别被字段 data.ok 和字段 data.text 所对应的依赖集合收集,当字段 obj.ok 的值修改为 false,并触发副作用函数重新执行后,由于此时字段 obj.text 不 会被读取,只会触发字段 obj.ok 的读取操作,所以理想情况下副作 用函数 effectFn 不应该被字段 obj.text 所对应的依赖集合收集:

问题产生: 如果obj.ok 设置为 false, 这时就产生了遗留的副作用函数。无论 obj.text 如何改变, document.body.innerText 的值始终都是'not', 最好的结果是 无论obj.text 的值怎么变, 都不需要重新执行副作用函数。 但事实是, 我们修改 obj.text 会导致副作用函数重新执行。

解决思路:每次副作用函数执行时, 我们可以先把它从所有与之关联的依赖集合中删除。 也就是说, 我们如果能在每次副作用函数执行前, 将其从相关联的依赖集合中移除, 那么问题就迎刃而解了。

2、解决分支切换问题以及重新设计副作用函数

javascript 复制代码
// 用一个全局变量存储被注册的副作用函数
let activeEffect 

function effect(fn) {
	const effectFn = ()=> {
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn
    fn();
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  effectFn();
}

那么 effectFn.deps 数组中的依赖是从哪里收集的呢?(在 track 函数中)

javascript 复制代码
function track(target, key) {
	 // 没有 activeEffect, 直接 return
    if (!activeEffect) return target[key];
    let depsMap = bucket.get(target);
    // 如果不存在 depsMap, 那么新建一个 Map,并与 target 关联
    if (!depsMap){
    	bucket.set(target, ( depsMap= new Map()));
    }
    // 再根据 key 从 depsMap 中去的 deps,它是一个 Set 类型
    // 如果deps 不存在, 同样新建一个 Set 并与 key 关联
    let deps = depsMap.get(key);
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    // 最后将当前激活的副作用函数添加到桶中
    deps.add(activeEffect);
  	// 将其添加到 activeEffect.deps 数组中
    activeEffect.deps.push(deps);
}

这样就可以 每次在副作用函数执行时,根据 effectFn.deps获取所有相关联的依赖集合, 进而将副作用函数从依赖集合中移除:

javascript 复制代码
// 用一个全局变量存储被注册的副作用函数
let activeEffect 

function effect(fn) {
	const effectFn = ()=> {
    cleanup(effectFn)
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn
    fn();
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  effectFn();
}

function cleanup(){
	effectFn.deps.forEach(dep => {
    // 将 effectFn 从依赖集合中移除
    dep.delete(effectFn);
  })
  effectFn.deps.length = 0;
}

这样就可以避免副作用函数产生遗留了, 但问题出现了, 目前的实现会导致无限循环执行, 问题出现在 trigger 函数中:

javascript 复制代码
function trigger (target, key, newVal) {
	 // 根据 target 从桶中取得 depsMap,它是 key----effects
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    const effects = depsMap.get(key);
    const effectsToRun = new Set(effects);
    // effects && effects.forEach(fn => fn()); // 问题出现在这里
    effectsToRun && effectsToRun.forEach(effectFn => effectFn()); 
}

循环问题原因:副作用函数执行时, 会调用 cleanup 进行清除, 但副作用函数的执行会导致其重新被收集到集合中,而此时对于 effect集合的编辑扔在进行, 用以下示例来解释: 解决方法: 重新构造另外一个 Set 集合并遍历它;

javascript 复制代码
const set = new Set([1]);
set.forEach(item => {
	set.delete(1); // 删除
  set.add(1); // 重新添加, 该值会被重新访问
  console.log('遍历中')
})

三、嵌套的 effect 与 effect 栈

effect 是可以发生嵌套的:

javascript 复制代码
 effect(function effectFn1() {
   effect(function effectFn2() { /* ... */ })
 /* ... */
})

类比 Vue 组件,渲染函数就是一个 effect 中执行的嵌套函数:

javascript 复制代码
 // Bar 组件
const Bar = {
 render() { /* ... */ },
}
 // Foo 组件渲染了 Bar 组件
const Foo = {
  render() {
  	return <Bar /> // jsx 语法 
  },
}

问题发生 : 之前的effect设计是不支持嵌套的 , 全局变量 activeEffect 存储通过 effect 函数注册的副作用函数, 意味着, 同一时刻 activeEffect 所存储的副作用函数只能有一个,当副作用发生嵌套时, 内层副作用函数的执行会覆盖 activeEffect 的值, 并且永远不会恢复到原来的值。这时如果再 有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,这 就是问题所在。

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

javascript 复制代码
// 用一个全局变量存储被注册的副作用函数
let activeEffect 
const effectStack = [];

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

function cleanup(){
	effectFn.deps.forEach(dep => {
    // 将 effectFn 从依赖集合中移除
    dep.delete(effectFn);
  })
  effectFn.deps.length = 0;
}

如下图所示:

四、避免无限递归循环

javascript 复制代码
 const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })

effect(() => obj.foo++)

可以看到, 在 effect 注册的副作用函数内有一个自增操作obj.foo++, 该操作会引起栈溢出:

javascript 复制代码
Uncaught RangeError: Maximum call stack size exceeded

原因:obj.foo++ 拆开来看实际上就是obj.foo = obj.foo + 1; 在这个语句中,既会读取 obj.foo 的值, 又会设置 obj.foo的值, 而这个就是导致问题的根本原因。

代码的执行流程: 首先读取 obj.foo 的值, 触发 track 操作, 将当前副作用函数收集到"桶"中,接着将其加 1 再赋值给obj.foo, 此时会触发 trigger 操作, 即把"桶"中的副作用函数取出并执行。 问题是该副作用函数正在执行中, 还没有执行完毕, 就要开始下一次的执行, 这样就会导致无限递归的调用自己, 于是就产生了栈溢出。

解决方案:在 trigger 动作发生时增加守卫条件: 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同, 则不触发执行。

javascript 复制代码
function trigger(target, key) {
  // 根据 target 从桶中取得 depsMap, 他是 key----effects
  const depsMap = bucket.get(target);
  // 如果 depsMap 不存在, 那么直接返回
  if (!depsMap) return;
  // 根据 key 获取所有副作用函数的 effects
  const effects = depsMap.get(key);
  const effectsToRun = new Set() // 新增
  // 执行副作用函数
  effects && effects.forEach(effectFn => {
    // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn);
    }
  });
  effectsToRun && effectsToRun.forEach(effectFn => effectFn());
}

五、调度执行

可调度:当trigger 动作触发副作用函数重新执行时,有能力决定副作用函数的执行时机、次数以及方式。可调度性是响应系统非常重要的特性。

javascript 复制代码
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })

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

obj.foo++;

console.log('结束了');
// 输出
// 1
// 2
// 结束了

在下面的例子中: 能够根据代码的执行顺序得到打印的结果,假设现在需求变成输出为:

javascript 复制代码
// 1 
// 结束了
// 2

首先我们可能想到的是obj.foo++ 和 console.log('结束了')换个位置。 那么有没有办法在不调整代码的情况下实现需求呢? 我们可以为 effect 函数设计一个选项参数 options, 允许用户指定调度器,在 effect 函数内部我们需要把 options 选项挂载到对应的副作用函数上。

javascript 复制代码
function effect(fn,options) {
	const effectFn = ()=> {
    cleanup(effectFn)
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    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 函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数, 从而把控制权交给用户:

javascript 复制代码
function trigger(target, key) {
  // 根据 target 从桶中取得 depsMap, 他是 key----effects
  const depsMap = bucket.get(target);
  // 如果 depsMap 不存在, 那么直接返回
  if (!depsMap) return;
  // 根据 key 获取所有副作用函数的 effects
  const effects = depsMap.get(key);
  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();
    }
  });
}

有了这些基础设施之后, 就可以实现前文的需求了, 如下代码所示:

javascript 复制代码
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })

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

obj.foo++;

console.log('结束了');
// 1 
// '结束了'
// 2

除了控制副作用函数的执行顺序, 通过调度器还可以做到控制它的次数。

javascript 复制代码
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })

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

obj.foo++;
obj.foo++;
// 1
// 2
// 3

从上面的例子来看, obj.foo 会从 1 自增到 3, 2 只是它的过度状态。 如果我们只关心最终结果而不关心过程, 那么执行三次打印操作是多余的, 我们期望的打印结果是:

javascript 复制代码
// 1
// 3

其中不包含过度状态, 基于调度器我们可以很容易地实现此功能:

javascript 复制代码
// 定义一个任务队列
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(() => {
    // 刷新完成后,将标志设为 false
    isFlushing = false;
  })
}

effect(() => {
  console.log(obj.foo);
}, {
  scheduler(fn) {
    // 每次调度时, 将副作用函数添加到 jobQueue 队列中
    jobQueue.add(fn);
    // 刷新队列
    flushJob();
  }
})
  • 定义一个任务队列 jobQueue,它是一个 Set 数据结构, 目的是利用 Set 数据结构的自动去重能力。
  • 调度器scheduler的实现, 在每次调度执行时, 先将当前副作用函数添加到 jobQueue 队列中, 再调用 flushJob 函数刷新队列。
  • flushJob 函数, 该函数通过 isFlushing 标志判断是否需要执行,只有当其为 false 时才需要执行,在 flushJob 内通过 p.then 将一个函数添加到微任务队列中,在微任务队列内完成对 jobQueue 的遍历执行

六、计算属性 computed 与 lazy

在讲解计算属性之前,需要先了解关于懒执行的 effect, 即 lazy 的 effect, 我们现在所实现的 effect 函数会立即执行传递给它的副作用函数, 例如:

javascript 复制代码
effect(()=>{
  console.log('obj.foo')
})

但在有些场景下, 我们并不希望它执行, 而是希望它在需要的时候才执行, 例如: 计算属性。 我们可以通过在 options 中添加 lazy 属性来达到目的

javascript 复制代码
effect(()=>{
	console.log(obj.foo)
},{
  lazy:true
})

当 lazy 为 true 时, 则不立即执行副作用函数

javascript 复制代码
function effect(fn,options) {
	const effectFn = ()=> {
    cleanup(effectFn)
    // 当 effectFn 执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压入栈中
    effectStack.push(effectFn);
    fn();
    // 在当前副作用函数执行完毕后, 将当前副作用函数弹出去 并把activeEffect还原之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length-1];
  }
  // 将 options 挂载到 effectFn 上
  effectFn.options = options;
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 只有非 lazy 的时候,才执行
  if(!options.lazy){
    // 执行副作用函数
    effectFn();
  }
  // 将副作用函数作为返回值返回
  return effectFn;
}

问题:什么情况下执行?----- 副作用函数 effectFn 作为 effect 函数的返回值, 当调用 effect 函数时, 通过其返回值能够拿到对应的副作用函数, 这样就能手动执行该副作用函数了。

javascript 复制代码
const effectFn = effect(()=>{
	console.log(obj.foo);
},{
  lazy:true
});
effectFn();

如果仅仅手动执行副作用函数, 意义其实并不大, 但如果我们把传递给 effect 的函数看做一个 getter, 那么这个 getter 函数可以返回任何值, 例如:

javascript 复制代码
const effectFn = effect(()=>obj.foo+obj.bar,{lazy:true})
// value 是 getter 的返回值
const value = effectFn();

可以将上述代码再修改一下:

javascript 复制代码
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){
	const effectFn = effect(getter,{lazy:true});
  const obj = {
    get value(){
      return effectFn();
    }
  }
  return obj;
}

// 测试
const sumRes = computed(()=>obj.foo+obj.bar);

首先我们定义一个 computed 函数, 它接受一个 getter 函数作为参数, 我们把 getter 函数作为副作用函数, 用它创建一个 lazy 的 effect。 computed 的执行会返回一个对象, 该对象的 value 属性是一个访问器属性, 只有当读取 value 的值时, 才会执行 effectFn 并将其结果作为返回值返回。

出现问题:对值做不到缓存, 假设多次访问 sumRes.value的值, 会导致 effectFn 进行多次计算, 即使对象本身并没有发生变化。

解决:添加对值进行缓存的功能。

javascript 复制代码
function computed(getter){
  // value 用来缓存上一次计算的值
  let value;
  // 标识是否需要重新计算值, 为 true 意味着, 需要重新计算
  let dirty = true;
	const effectFn = effect(getter,{lazy:true});
  const obj = {
    get value(){
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    }
  }
  return obj;
}

问题:上述代码, 修改 obj 的值, 并没有重新计算。

解决办法:值发生变化时, dirty 的值进行重置----用 scheduler

javascript 复制代码
function computed(getter){
  // value 用来缓存上一次计算的值
  let value;
  // 标识是否需要重新计算值, 为 true 意味着, 需要重新计算
  let dirty = true;
	const effectFn = effect(getter,{
    lazy:true,
    scheduler(){
      dirty = true;
    }
  });
  const obj = {
    get value(){
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    }
  }
  return obj;
}

为 effect 添加了 scheduler 调度器函数, 它会在 getter 函数中所依赖的响应式数据变化时重新执行, 这样我们在 scheduler 函数内将 dirty 重置为 true, 那么下次访问的时候就会重新调用 effectFn 的计算值, 这样就能够得到预期的结果了。

javascript 复制代码
const sumRes = computed(()=>obj.foo+obj.bar);
effec(()=>{
  console.log(sumRes.value);
});
obj.foo++

出现问题: 计算属性在 effect 函数中使用的时候发生更改, 未重新执行。

问题原因: effect 嵌套, 一个计算属性内部拥有自己的 effect 函数, 并且他是懒执行的, 只有当真正读取计算属性的值时才会执行。 对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部的 effect 收集依赖。而当把计算属性用于另外一个 effect 时,就会发生 effect 嵌套,外层 effect 不会被内层的 effect 中的响应数据收集。

解决办法:当读取计算属性的值时, 我们可以手动调用 track 函数进行追踪, 当计算属性依赖的响应式数据发生变化时,我们可以手动调用 trigger 函数触发响应。

javascript 复制代码
function computed(getter){
  let value;
  let dirty = true;
  const effectFn = effect(getter,{
    lazy:true,
    scheduler(){
      dirty = true;
      trigger(obj, 'value')
    }
  })
  const obj = {
    get value(){
      if(dirty){
        value = effectFn();
        dirty = false;
      }
      track(obj,'value');
      return value;
    }
  }
}

当读取一个计算属性的 value 时, 我们手动调用 track 函数, 把计算属性返回的对象 obj 作为 target, 同时作为第一个参数传递给 track 函数。 当计算属性所依赖的响应式数据变化时,会执行调度起函数, 在调度起函数内手动调用 trigger 函数触发响应即可。建立的关系如下:

七、watch 的实现原理

所谓 watch, 就是观测一个响应式数据, 当数据发生变化时通知并执行相应的回调函数。

javascript 复制代码
watch(obj,()=>{
console.log("数据变了");  
})
// 修改响应数据的值, 会导致回调函数执行
obj.foo++;

实际上, watch 的本质就是利用了 effect 以及 options.scheduler选项

javascript 复制代码
effect(()=>{
  console.log(obj.foo);
},{
  scheduler(){
    // 当 obj.foo 的值变化时, 会执行 scheduler 调度函数
  }
})
javascript 复制代码
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 中, 代表遍历地读取过了, 避免循环引用引起的死循环
  seen.add(value);
  // 暂时不考虑数组等其他结构
  // 假设 value 是一个对象, 使用 for...in 读取对象的每一个值, 并递归地调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen);
  }
  return value;
}

watch 除了可以观测响应数据, 还可以接受一个 getter 函数

javascript 复制代码
watch(()=>obj.foo,()=>{
  console.log('obj.foo 的值变了');
})

修改代码如下:

javascript 复制代码
function watch(source, cb){
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
	getter = ()=> traverse(source);
    
  }
  effect(getter,{
    scheduler(){
      cb();
    }
  })
}

那么在回调函数中如何拿到旧值和新值呢?可以利用 lazy 选项:

javascript 复制代码
function watch(source, cb){
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
		getter = ()=> tranverse(source);    
  }
  // 定义旧值和新值
  let oldValue, newValue;
  // 使用 effect 注册副作用函数, 开启 lazy 选项, 并把返回值存储到 effectFn中以便后续手动调用
  const effectFn = effect(()=>getter(),{
    lazy:true,
    scheduler(){
      // 在 scheduler 中重新执行副作用函数,得到的是新值
      newValue = effectFn();
      将旧值和新值作为回调函数的参数
      cb(newValue, oldValue);
      // 更新旧值, 不然下一次会得到错误的旧值
      oldValue = newValue;
    }
  })
  // 手动调用副作用函数, 拿到的值就是旧值
  oldValue = effectFn();
}

1、立即执行的 watch

立即执行的回调函数: 默认情况下, 一个 watch的毁掉只会在响应式数据发生变化时才会执行

javascript 复制代码
watch(obj,()=>{
  console.log("变化了");
})

在 Vue.js 中可以通过选项参数 immediate 来指定回调是否需要立即执行:

javascript 复制代码
watch(()=>obj,()=>{
  console.log("变化了")
},{
  // 回调函数会在 watch 创建时立即执行一次
  immediate:true
});

想一下, 回调函数的立即执行与后续执行本质上没有任何差别, 所以我们可以把 scheduler 调度函数封装为一个通用函数, 分别在初始化和变更时执行它:

javascript 复制代码
function watch(source, cb, options={}){
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
		getter = ()=> tranverse(source);    
  }
  // 定义旧值和新值
  let oldValue, newValue;

  const job = ()=>{
      // 在 scheduler 中重新执行副作用函数,得到的是新值
      newValue = effectFn();
      将旧值和新值作为回调函数的参数
      cb(newValue, oldValue);
      // 更新旧值, 不然下一次会得到错误的旧值
      oldValue = newValue;
  }
  // 使用 effect 注册副作用函数, 开启 lazy 选项, 并把返回值存储到 effectFn中以便后续手动调用
  const effectFn = effect(()=>getter(),{
    lazy:true,
    scheduler:job
  })
  if (options.immediate) {
		job();    
  } else {
    // 手动调用副作用函数, 拿到的值就是旧值
  	oldValue = effectFn();
  }
}

2、回调执行时机

例如在 Vue.js3 中使用 flush 选项来指定:

javascript 复制代码
watch(()=>obj,()=>{
  console.log("变化了")
}, {
  flush: 'pre' // 此外还有 'post' | 'sync'
})

flush 本质上是在指定调度函数的执行时机,前文讲解过如何在微任务队列中执行调度函数 scheduler, 这与 flush 的功能相同。当 flush 的值为'post'时,代表调度函数需要将副作用函数放到一个微任务队列中, 并等待 DOM 更新结束后再执行,我们可以用如下代码进行模拟:

javascript 复制代码
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,{
    lazy:true,
    scheduler:()=>{
      // 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
      if (options.flush === 'post'){
        const p = Promise.resolve();
        p.then(job)
      } else {
      	job();  
      }
    },
  })
  if (options.immediate){
    job();
  } else {
    oldValue = effectFn();
  }
}

如以上代码所示,我们修改了调度器函数 scheduler 的实现方 式,在调度器函数内检测 options.flush 的值是否为 post,如果 是,则将 job 函数放到微任务队列 中,从而实现异步延迟执行 ;否则 直接执行 job 函数,这本质上相当于 'sync ' 的实现机制,即同步执 行。对于 options.flush 的值为 'pre' 的情况,我们暂时还没有办 法模拟,因为这涉及组件的更新时机,其中 'pre' 和 'post' 原本的 语义指的就是组件更新前和更新后,不过这并不影响我们理解如何控 制回调函数的更新时机。

八、过期的副作用

态问题通常在多进程或多线程编程中被提及,前端工程师可能 很少讨论它,但在日常工作中你可能早就遇到过与竞态问题相似的场景, 举个例子:

javascript 复制代码
let finalData;
watch(obj,async ()=>{
  const res = await fetch('path/to/request');
  finalData = res;
})

在这段代码中,我们使用 watch 观测 obj 对象的变化,每次 obj 对象发生变化都会发送网络请求,例如请求接口数据,等数据请求成 功之后,将结果赋值给 finalData 变量。

竞态问题 : 假设我们第一次修改 obj 对象的某个字段 值,这会导致回调函数执行,同时发送了第一次请求 A,随着时间的 推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行 了第二次修改,这会导致发送第二次请求 B。此时请求 A 和请求 B 都 在进行中,那么哪一个请求会先返回结果呢? 如果请求 B 先于请求 A 返回结果,就会导致最终 finalData 中存储的是 A 请 求的结果, 如下图所示: 但由于请求 B 是后发送的,因此我们认为请求 B 返回的数据才是 "最新"的,而请求 A 则应该被视为"过期"的,所以我们希望变量 finalData 存储的值应该是由请求 B 返回的结果,而非请求 A 返回 的结果。 请求 A 是副作用函 数第一次执行所产生的副作用,请求 B 是副作用函数第二次执行所产 生的副作用。由于请求 B 后发生,所以请求 B 的结果应该被视为"最 新"的,而请求 A 已经"过期"了,其产生的结果应被视为无效。通过这 种方式,就可以避免竞态问题导致的错误结果 。 归根结底,我们需要的是一个让副作用过期的手段。 在 Vue.js中, watch 函数的回调函数,接受第三个参数onInvalidate, 它是一个函数, 类似于事件监听器, 我们可以使用 onInvalidate 函数注册一个回调, 这个回调函数会在当前副作用函数过期时执行:

javascript 复制代码
watch(obj,async(newValue, oldValue, onInvalidate)=>{
  let expired = false;
  onInvalidate(()=>{
    expired = true;
  });
  const res = await fetch('/path/to/request');
  if(!expired){
    finalData = res;
  }
})

onInvalidate的原理: 在 watch 内部每次检测到变更后, 在副作用函数重新执行之前, 会先调用我们通过 onInvalidate 函数注册的过期回调。

javascript 复制代码
function watch(source,cb,options={}) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = traverse(source);
  }
  let newValue, oldValue;
  let cleanup;
  function onInvalidate(fn){
    cleanup = fn;
  }
  const job = () => {
    newValue = effectFn();
    if (cleanup) {
      cleanup();
    }
    cb(newValue, oldValue, onInvalidate);
    oldValue = newValue;
  }
  const effectFn = effect(()=>getter(), {
    lazy:true,
    scheduler:()=>{
      if (options.flush === 'post') {
        const p =Promise.resolve();
        p.then(job)
      } else {
        job();
      }
    }
  })
  if(options.immediate) {
    job();
  } else {
    oldValue = effectFn();
  }
}

总结

响应系统的根本实现原理: 一个响应式数据最基本的实现依赖于对"读取"和 "设置"操作的拦截,从而在副作用函数与响应式数据之间建立联系。当"读取"操作发生时,我们将当前执行的副作用函数存储到"桶"中;当 "设置"操作发生时,再将副作用函数从"桶"里取出并执行。

相关推荐
nvd117 小时前
企业级 LLM 实战:在受限环境中基于 Copilot API 构建 ReAct MCP Agent
前端·copilot
+VX:Fegn08957 小时前
计算机毕业设计|基于springboot + vue校园实验室管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
Dragon Wu8 小时前
TailWindCss cva+cn管理样式
前端·css
烤麻辣烫8 小时前
Web开发概述
前端·javascript·css·vue.js·html
Front思8 小时前
Vue3仿美团实现骑手路线规划
开发语言·前端·javascript
徐同保8 小时前
Nano Banana AI 绘画创作前端代码(使用claude code编写)
前端
Ulyanov8 小时前
PyVista与Tkinter桌面级3D可视化应用实战
开发语言·前端·python·3d·信息可视化·tkinter·gui开发
计算机程序设计小李同学8 小时前
基于Web和Android的漫画阅读平台
java·前端·vue.js·spring boot·后端·uniapp
干前端8 小时前
Message组件和Vue3 进阶:手动挂载组件与 Diff 算法深度解析
javascript·vue.js·算法
lkbhua莱克瓦248 小时前
HTML与CSS核心概念详解
前端·笔记·html·javaweb