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

总结

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

相关推荐
cc蒲公英10 分钟前
vue2中使用vue-office库预览pdf /docx/excel文件
前端·vue.js
Sam902922 分钟前
【Webpack--013】SourceMap源码映射设置
前端·webpack·node.js
Python私教1 小时前
Go语言现代web开发15 Mutex 互斥锁
开发语言·前端·golang
A阳俊yi1 小时前
Vue(13)——router-link
前端·javascript·vue.js
小明说Java1 小时前
Vue3祖孙组件通信探秘:运用provide与inject实现跨层级数据传递
前端
好看资源平台1 小时前
前端框架对比与选择:如何在现代Web开发中做出最佳决策
前端·前端框架
4triumph1 小时前
Vue.js教程笔记
前端·vue.js
程序员大金2 小时前
基于SSM+Vue+MySQL的酒店管理系统
前端·vue.js·后端·mysql·spring·tomcat·mybatis
清灵xmf2 小时前
提前解锁 Vue 3.5 的新特性
前端·javascript·vue.js·vue3.5
程序员大金2 小时前
基于SpringBoot的旅游管理系统
java·vue.js·spring boot·后端·mysql·spring·旅游