Skip to content
目录

4.1 响应式数据和副作用函数

副作用函数是指会产生副作用的函数,如下例所示:

javascript
function effect() {
  document.body.innerText = 'hello vue3';
}

执行 effect 函数时,它会设置 body 的文本内容,也就是说,effect 函数的执行会直接或间接影响其他函数的执行,副作用很容易产生,比如修改一个全局变量:

javascript
// 全局变量
let val = 1;

function effect() {
  val = 2; // 修改全局变量,产生副作用
}

假设一个副作用函数中读取了某个对象的属性:

javascript
const obj = { text: 'hello world' };

function effect() {
  // effect 函数的执行会读取 obj.text
  document.body.innerText = obj.text;
}

在上例中,effect 函数会设置 body 元素的 innerText 属性,其值为 obj.text。当 obj.text 值发生变化时,我们希望 effect 函数会重新执行:

javascript
obj.text = 'hello vue3';

这里,修改 obj.text 的值后,我们希望副作用函数能自动重新执行,那么需要 obj 是响应式数据,下一节将讨论如何将数据转化为响应式数据。

4.2 基本响应式数据实现

为了使 obj 成为响应式数据,我们可以从以下两点出发:

  1. 在副作用函数 effect 执行时,触发 obj.text 的读取操作。
  2. 当修改 obj.text 时,触发 obj.text 的设置操作。

我们可以通过拦截对象的读取和设置操作来简化实现 在读取 obj.text 时,将副作用函数 effect 存储到一个“桶”中。 设置 obj.text 时,从“桶”中取出并执行副作用函数 effect。 我们如何拦截对象属性的读取和设置操作呢?在 ES2015 之前,我们可以使用 Object.defineProperty 函数,这是 Vue.js 2 的实现方式。在 ES2015+ 中,我们可以使用代理对象 Proxy,这是 Vue.js 3 的实现方式。 使用 Proxy 的基本实现:

javascript
const bucket = new Set()

const data = { text: 'hello world' }
const obj = new Proxy(data, {
  get(target, key) {
    bucket.add(effect)
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    bucket.forEach(fn => fn())
    return true
  }
})

function effect() {
  document.body.innerText = obj.text
}

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

首先,我们创建一个用于存储副作用函数的 Set 类型的桶 bucket。 然后,定义原始数据 data,obj 是原始数据的代理对象。接着设置 get 和 set 拦截函数,用于拦截读取和设置操作。 读取属性时,将副作用函数 effect 添加到桶中;设置属性时,先更新原始数据,再从桶中取出并执行副作用函数。这样,我们实现了响应式数据。 从这我们理解了响应式数据的基本实现和工作原理。

4.3 设计完善响应系统

接下来我们将构建一个更完善的响应系统,实现步骤如下:

  1. 读取操作时,将副作用函数收集到“桶”中。
  2. 设置操作时,执行“桶”中的副作用函数。

为了解决硬编码副作用函数名(effect)的问题,我们提供一个注册副作用函数的机制:

javascript
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect

// effect 函数用于注册副作用函数
function effect(fn) {
  activeEffect = fn
  fn()
}

// 使用 effect 函数注册匿名副作用函数
effect(() => {
  document.body.innerText = obj.text
})

// 代理对象
const obj = new Proxy(data, {
  get(target, key) {
    if (activeEffect) {
      bucket.add(activeEffect)
    }
    return target[key]
  },
  set(target, key, newVal) {
    target[key] = newVal
    bucket.forEach(fn => fn())
    return true
  }
})

这样我们不再依赖副作用函数的名字。但在设置不存在的属性时,仍存在问题,这时候我们需要重新设计“桶”的数据结构。

考虑以下三个角色:

  1. 被操作(读取)的代理对象 obj;
  2. 被操作(读取)的字段名 text;
  3. 使用 effect 函数注册的副作用函数 effectFn。

我们可以为这三个角色建立如下树型结构关系:

javascript
target
└── key
    └── effectFn

这种树型结构可以满足各种场景。例如: 两个副作用函数同时读取同一个对象的属性值:

javascript
target
└── text
    ├── effectFn1
    └── effectFn2

一个副作用函数中读取同一个对象的两个不同属性:

javascript
target
└── text1
    └── effectFn
└── text2
    └── effectFn

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

javascript
target1
└── text1
    └── effectFn1
target2
└── text2
    └── effectFn2

通过建立这个树型数据结构,当我们设置了 obj2.text2 的值时,只会导致 effectFn2 函数重新执行,而不会导致 effectFn1 函数重新执行。

接下来,我们将尝试用代码实现新的“桶”。首先,用 WeakMap 替换 Set 作为桶的数据结构:

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

然后修改 get/set 拦截器代码:

javascript
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)
  }
})

// 在 get 拦截函数内调用 track 函数追踪变化
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)
}

// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
  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 的键是原始对象 target,WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。这些关系如下图所示: image.png 我们可以称 Set 数据结构中存储的副作用函数集合为 key 的依赖集合 使用 WeakMap 的原因在于其键为弱引用,不影响垃圾回收器的工作。一旦 key 被垃圾回收器回收,那么对应的键和值就无法访问。 WeakMap 经常用于存储只有当 key 所引用的对象存在时(没有被回收)才有价值的信息,下面这段代码展示了 WeakMap 和 Map 的区别:

javascript
const map = new Map();
const weakmap = new WeakMap();

(function(){
  const foo = {foo: 1};
  const bar = {bar: 2};

  map.set(foo, 1);
  weakmap.set(bar, 2);
})()

当该函数表达式执行完毕后,对于对象 foo 来说,它仍然作为 map 的 key 被引用着,因此垃圾回收器(garbage collector)不会把它从内存中移除 而对于对象 bar 来说,由于 WeakMap 的 key 是弱引用,它不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就会把对象 bar 从内存中移除

4.4 分支切换与清理

首先,我们定义一个简单的响应式数据和副作用函数:

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

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

effectFn 内部,根据 obj.ok 的值,我们会执行不同的代码分支。当 obj.ok 变化时,执行的分支也会跟着变。这就是分支切换。 分支切换可能导致遗留的副作用函数。例如,如果我们更改 obj.ok 的值为 false,副作用函数仍然会被 obj.text 触发,即使 obj.text 的值不再影响输出。 解决方法是在每次执行副作用函数时,将其从所有相关联的依赖集合中移除。为了实现这一点,我们需要重新设计副作用函数,使其具有一个 deps 属性,用于存储与其相关联的依赖集合:

javascript
let activeEffect;

function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn;
    fn();
  };

  effectFn.deps = [];
  effectFn();
}

现在,每次执行副作用函数时,我们可以将其从所有关联的依赖集合中删除,从而避免遗留的副作用函数导致不必要的更新。 接下来我们讨论如何收集 effectFn.deps 数组中的依赖集合。我们在 track 函数中完成收集过程:

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

track 函数中,我们将当前执行的副作用函数 activeEffect 添加到依赖集合 deps 中,然后把依赖集合 deps 添加到 activeEffect.deps 数组中。 接下来,我们在每次执行副作用函数时,根据 effectFn.deps 获取所有相关联的依赖集合,将副作用函数从依赖集合中移除:

javascript
let activeEffect;

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    fn();
  };
	
	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;
}

cleanup 函数遍历 effectFn.deps 数组,从依赖集合中移除副作用函数,然后重置 effectFn.deps 数组。至此,我们已经可以避免副作用函数产生遗留。 但是,我们可能会遇到无限循环执行的问题。问题出在 trigger 函数中:

javascript
function trigger(target, key) {
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  // 问题出在下面这句代码 执行 effects 里面副作用函数会先清除再收集,相当于在遍历时候删除元素又添加元素,遍历永远在执行
  effects && effects.forEach(fn => fn()); 
}

为了避免无限执行,我们可以构造一个新的 Set 集合并遍历它:

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

  const effectsToRun = new Set(effects);
  effectsToRun.forEach(effectFn => effectFn());
}

我们创建了一个新的集合 effectsToRun,遍历它而不是直接遍历 effects 集合,从而避免无限执行。

4.5 嵌套的 effect 与 effect 栈

嵌套的 effect 示例:

javascript
effect(function effectFn1() {
  effect(function effectFn2() { /* ... */ })
  /* ... */
})

在 Vue.js 渲染函数中,可能会出现嵌套的 effect,如下例:

javascript
const Foo = { render() { /* ... */ } }
effect(() => { Foo.render() })

当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件,就会发生嵌套的 effect:

javascript
const Bar = { render() { /* ... */ } }
const Foo = { render() { return <Bar /> } }

这相当于:

javascript
effect(() => {
  Foo.render()
  effect(() => { Bar.render() })
})

如果 effect 不支持嵌套,会导致问题。例如,以下代码:

javascript
const data = { foo: true, bar: true }
const obj = new Proxy(data, { /* ... */ })
let temp1, temp2

effect(function effectFn1() {
  console.log('effectFn1 执行')

  effect(function effectFn2() {
    console.log('effectFn2 执行')
    temp2 = obj.bar
  })
  temp1 = obj.foo
})

修改 obj.foo 的值后,实际上 effectFn1 没有重新执行,反而使得 effectFn2 重新执行了,这不符合预期。问题出在我们实现的 effect 函数与 activeEffect 上。 解决方法是使用一个副作用函数栈 effectStack,确保 activeEffect 始终指向栈顶的副作用函数:

javascript
let activeEffect
const effectStack = []

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    // 在调用副作用函数之前将当前副作用函数压入栈中
    effectStack.push(effectFn)
    fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  
  effectFn.deps = []
  effectFn()
}

我们定义了 effectStack 数组,用它来模拟栈, activeEffect 没有变化,它仍然指向当前正在执行的副作用函数。 不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数 发生嵌套时,栈底存储的就是外层副作用函数,而栈顶存储的则是内层副作用函数, image.png 当内层副作用函数 effectFn2 执行完毕后,它会被弹出栈,并 将副作用函数 effectFn1 设置为 activeEffect image.png 使用 effectStack 可以避免因嵌套 effect 导致的依赖关系混乱。

4.6 避免无限递归循环

在实现响应式系统时,需要注意避免无限递归循环。以以下代码为例:

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

effect(() => obj.foo++) // 既会读取 obj.foo 的值,又会设置 obj.foo 的值

在这个例子中,effect 注册的副作用函数会触发栈溢出。原因是 obj.foo++ 会触发 tracktrigger 操作,导致无限递归调用。 解决办法是在 trigger 动作发生时增加守卫条件,避免触发正在执行的副作用函数。代码如下:

javascript
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 => {
    // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => effectFn())
}

通过这种方式,我们可以避免无限递归调用和栈溢出。

4.7 调度执行

调度性是响应式系统的重要特性,它允许我们决定副作用函数执行的时机、次数和方式。 为了实现可调度性,我们可以为 effect 函数添加一个选项参数 options,允许用户指定调度器:

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

effect(
  () => {
    console.log(obj.foo)
  },
  {
    scheduler(fn) {
      // ...
    }
  }
)

effect 函数内部,我们需要将 options 挂载到对应的副作用函数上:

javascript
function effect(fn, options = {}) {
  const effectFn = () => {
    // ...
  }
  effectFn.options = options
  effectFn.deps = []
  effectFn()
}

然后,在 trigger 函数中,优先判断副作用函数是否存在调度器。 如果存在,则调用调度器函数,把当前副作用函数作为参数传递给用户自己控制如何执行;否则保留之前的行为,即直接执行副作用函数:

javascript
function trigger(target, key) {
  // ...
  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

通过这种方式,我们可以实现响应式系统的调度性,让用户能够自定义副作用函数的执行策略。

在这些基础设施的支持下,我们使用 setTimeout 开启一个宏任务来执行副作用函数 fn,这样就能更灵活控制代码的执行顺序了。

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

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

obj.foo++

console.log('结束了')

输出结果:

javascript
1
'结束了'
2

通过调度器,我们还可以控制副作用函数的执行次数。这是一个重要的特性,如下例子所示:

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

const jobQueue = new Set()
const p = Promise.resolve()

let isFlushing = false
function flushJob() {
  if (isFlushing) return
  isFlushing = true
  // 在微任务队列中刷新 jobQueue 队列
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

effect(() => {
  console.log(obj.foo)
}, {
  scheduler(fn) {
    jobQueue.add(fn)
    flushJob()
  }
})

obj.foo++
obj.foo++

在这个示例中,我们定义了一个任务队列 jobQueue,它是一个 Set 数据结构,用于自动去重。 调度器 scheduler 的实现会将当前副作用函数添加到 jobQueue 队列中,然后调用 flushJob 函数刷新队列。 flushJob 函数通过 isFlushing 标志判断是否需要执行。当 isFlushingfalse 时,函数才会执行。函数开始执行后,isFlushing 标志将被设置为 true。这意味着 flushJob 函数在在一个事件循环内只会执行一次,,即在微任务队列内执行一次。 flushJob 函数通过 p.then 将一个函数添加到微任务队列中,在微任务队列中完成对 jobQueue 的遍历执行。这样,在连续对 obj.foo 执行两次自增操作时,字段 obj.foo 的值已经是 3 了,从而实现期望的输出:

javascript
1
3

这个功能类似于 Vue.js 中连续多次修改响应式数据,但只会触发一次更新。实际上,Vue.js 的内部实现了一个更完善的调度器,其思路与上文介绍的相同。

4.8 计算属性 computed 与 lazy

在某些场景下,我们希望副作用函数不立即执行,而是在需要时执行,例如计算属性。这时,我们可以在 options 中添加 lazy 属性来实现,如下:

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

它同样通过 options 选项对象指定,有了它,我们可以修改 effect 函数的实现逻辑,当 options.lazytrue 时,不立即执行副作用函数:

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 = []
  // 只有非 lazy 的时候,才执行
  if (!options.lazy) {
    effectFn()
  }
  // 将副作用函数作为返回值返回
  return effectFn
}

当调用 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 }
)

这样我们在手动执行副作用函数时,就能够拿到其返回值:

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

为了实现这个目标,我们需要再对 effect 函数做一些修改,如下:

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
}

传递给 effect 函数的参数 fn 是真正的副作用函数,而 effectFn 是我们包装后的副作用函数。 为了通过 effectFn 得到真正的副作用函数 fn 的执行结果,我们需要将其保存到 res 变量中,然后将其作为 effectFn 函数的返回值。

现在我们已经实现了具有懒执行的副作用函数,我们就可以实现计算属性了:

javascript
function computed(getter) {
  const effectFn = effect(getter, { lazy: true })

  const obj = {
    get value() {
      return effectFn()
    }
  }

  return obj
}

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

我们可以使用 computed 函数来创建计算属性:

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

const sumRes = computed(() => obj.foo + obj.bar)

console.log(sumRes.value) // 输出 3

可以看到,计算属性能够正确地工作。 但是,目前我们实现的计算属性只能进行懒计算,也就是说,只有当我们真正访问 sumRes.value 时,才会进行计算并获取值。 我们还无法对值进行缓存,即 obj.fooobj.bar 的值没有改变,effectFn 也会被多次调用并进行计算:

javascript
console.log(sumRes.value) // 输出 3
console.log(sumRes.value) // 输出 3
console.log(sumRes.value) // 输出 3

为了解决这个问题,我们需要在实现 computed 函数时添加对值的缓存功能:

javascript
function computed(getter) {
  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }

  return obj
}

现在我们访问 **sumRes.value **都只会在第一次访问时进行实际计算,后续直接从缓存读取 value 值 我们还需动态设置变量 dirtyfalse,不然后续obj.fooobj.bar 的值即使变化了 effectFn 也不会重新计算。 我们可以在 obj.fooobj.bar 的值发生变化时,将dirty 的值重置为 true。这可以通过 scheduler 选项来实现:

javascript
function computed(getter) {
  let value
  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 函数中所依赖的响应式数据发生变化时执行,从而避免缓存后不更新的问题。

上述设计还有一个缺陷,当我们在另一个 effect 中访问计算属性的值时,该 effect 不会因为计算属性值的变化而触发。 为了解决这个问题,我们可以在访问计算属性值时手动调用 track 函数进行追踪,并在计算属性所依赖的响应式数据发生变化时手动调用 trigger 函数触发响应:

javascript
function computed(getter) {
  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true
        trigger(obj, 'value')
      }
    }
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      track(obj, 'value')
      return value
    }
  }

  return obj
}

上述代码会建立这样的联系:

javascript
computed(obj)
	└── value
		└── effectFn

image.png 这样,在其他 effect 中访问计算属性的值时,该 effect 就会因为计算属性值的变化而触发:

javascript
const sumRes = computed(() => obj.foo + obj.bar)

effect(() => {
  console.log(sumRes.value)
})

obj.foo++

在这段代码中,修改 obj.foo 的值会触发 effect,因为我们在访问 sumRes.value 时手动调用了 track 函数进行追踪,并在计算属性所依赖的响应式数据发生变化时手动调用了 trigger 函数触发响应。 综上所述,我们成功实现了一个具有缓存功能和嵌套 effect 支持的计算属性。

4.9 watch 的实现原理

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

javascript
watch(obj, () => {
  console.log('数据变了')
})

// 修改响应数据的值,会导致回调函数执行
obj.foo++

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

javascript
effect(() => {
  console.log(obj.foo)
}, {
  scheduler() {
    // 当 obj.foo 的值变化时,会执行 scheduler 调度函数
  }
})

当响应式数据发生变化时,会触发 scheduler 调度函数执行,而非直接触发副作用函数执行。 从这个角度来看,其实 scheduler 调度函数就相当于一个回调函数,而 watch 的实现就是利用了这个特点。下面是最简单的 watch 函数的实现:

javascript
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(
    // 触发读取操作,从而建立联系
    () => source.foo,
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        cb()
      }
    }
  )
}

我们可以使用 watch 函数如下:

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

watch(obj, () => {
  console.log('数据变化了')
})

obj.foo++

上面这段代码能正常工作,但在之前的 watch 函数的实现中,硬编码了对 source.foo 的读取操作,我们需要封装一个通用的读取操作:

javascript
function watch(source, cb) {
  effect(
    // 调用 traverse 递归地读取
    () => traverse(source),
    {
      scheduler() {
        // 当数据变化时,调用回调函数 cb
        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(
  // getter 函数
  () => obj.foo,
  // 回调函数
  () => {
    console.log('obj.foo 的值变了')
  }
)

传递给 watch 函数变成了 getter 函数,在 getter 函数内部,用户可以指定该 watch 依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数执行。如下代码实现了这一功能:

javascript
function watch(source, cb) {
  // 定义 getter
  let getter
  // 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
  if (typeof source === 'function') {
    getter = source
  } else {
    // 否则按照原来的实现调用 traverse 递归地读取
    getter = () => traverse(source)
  }

  effect(
    // 执行 getter
    () => getter(),
    {
      scheduler() {
        cb()
      }
    }
  )
}

判断 source 类型,如果是函数类型,说明用户直接传递了 getter 函数,这时直接使用用户的 getter 函数,如果不是函数则使用原来做法。这样就实现了自定义 getter 的功能

目前 watch 的回调函数拿不到旧值与新值。 那么如何获得新值与旧值呢?这需要充分利用 effect 函数的 lazy 选项:

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

我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值。 当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 函数并得到新值,这样我们就拿到了旧值与新值,将它们作为参数传递给回调函数 cb 就可以了。 最后一件非常重要的事情是,不要忘记使用新值更新旧值,否则下一次变更发生时会得到错误的旧值。 这样,我们就完成了一个简化版的 watch 函数实现。

4.10 立即执行的 watch 与回调执行时机

我们将添加一个选项参数 immediate 以指定回调是否需要立即执行。其次,我们将通过选项参数 flush 来指定回调函数的执行时机。以下是优化后的代码:

javascript
function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === 'function') {
    getter = source;
  } else {
    getter = () => traverse(source);
  }

  let oldValue, newValue;
	
  // 提取 scheduler 调度函数为一个独立的 job 函数
  const job = () => {
    newValue = effectFn();
    cb(newValue, oldValue);
    oldValue = newValue;
  };

  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') {
          // 将 job 函数放到微任务队列中,从而实现异步延迟执行
          Promise.resolve().then(job);
        } else {
          // 直接执行 job 函数,相当于 'sync' 的实现机制,即同步执行
          job();
        }
      }
    }
  );

  if (options.immediate) {
    // 当 immediate 为 true 时立即执行 job,从而触发回调执行
    job();
  } else {
    oldValue = effectFn();
  }
}

在这个优化后的 watch 函数中,当选项参数 immediate 为 true 时,回调函数会在 watch 创建时立即执行一次。 此外,我们还添加了选项参数 flush 来控制回调函数的执行时机。当 flush 的值为 'post' 时,调度函数将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行。

4.11 过期副作用与竞态问题

竞态问题通常在多进程或多线程编程中被提及,在前端开发中,我们也会遇到类似于竞态问题的情况。例如:

javascript
let finalData;

watch(obj, async () => {
  const res = await fetch('/path/to/request');
  finalData = res;
});

在这段代码中,每次 **obj **发生变化时,都会发送网络请求并将结果赋值给 finalData。但这可能导致竞态问题。 image.png 上面我们认为应该 请求B 的数据才是最终赋值给 **finalData 的值,**为了解决这个问题,我们需要实现一个让副作用过期的手段。Vue.js 的 **watch **函数提供了 **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 **函数注册一个过期回调,在副作用函数执行过期时设置 **expired **为 true。只有当副作用没有过期时,才采用请求结果。 以下是 **watch **函数的简化实现,以便了解 **onInvalidate **的原理:

javascript
function watch(source, cb, options = {}) {
	let getter
	if (typeof source === 'function') {
		getter = source
	} else {
		getter = () => traverse(source)
	}

	let oldValue, newValue

	// cleanup 用来存储用户注册的过期回调
	let cleanup
	// 定义 onInvalidate 函数
	function onInvalidate(fn) {
		// 将过期回调存储到 cleanup 中
		cleanup = fn
	}

	const job = () => {
		newValue = effectFn()
		// 在调用回调函数 cb 之前,先调用过期回调
		if (cleanup) {
			cleanup()
		}
		// 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
		cb(newValue, oldValue, onInvalidate)
		oldValue = newValue
	}

	const effectFn = effect(
		// 执行 getter
		() => getter(),
		{
			lazy: true,
			scheduler: () => {
				if (options.flush === 'post') {
					const p = Promise.resolve()
					p.then(job)
				} else {
					job()
				}
			},
		}
	)

	if (options.immediate) {
		job()
	} else {
		oldValue = effectFn()
	}
}

在 **job **函数中,在执行回调 **cb **之前,先检查并执行过期回调函数 cleanup。然后将 **onInvalidate **作为回调函数的第三个参数传递给 cb,供用户使用。这种实现方式可以有效避免过期副作用带来的问题。 image.png

总结

响应式数据的基本实现依赖于对“读取”和“设置”操作的拦截,以在副作用函数与响应式数据间建立联系。当发生“读取”操作时,当前执行的副作用函数被存储到“桶”中;当发生“设置”操作时,副作用函数从“桶”中取出并执行。这构成了响应系统的基本原理。 用 WeakMap 和 Map 构建了新的“桶”结构,实现了响应式数据与副作用函数间更精确的联系。我们也介绍了 WeakMap(弱引用)和 Map(强引用)之间的区别。 我们探讨了冗余副作用问题,提出了在重新执行副作用函数前清除上一次建立的响应联系的解决方案。 嵌套副作用函数时,我们引入了副作用函数栈,并解决了副作用函数无限递归调用自身导致栈溢出的问题。 介绍了响应系统的调度能力,通过增加 effect 函数的选项参数实现。我们还讲解了如何通过调度器实现任务去重。 讨论了计算属性(computed)的实现,即懒执行的副作用函数,通过 lazy 选项来实现。当计算属性依赖的响应式数据发生变化时,我们会设置 dirty 标记,以重新计算属性值。 我们探讨了 watch 的实现原理,本质上利用了副作用函数重新执行时的可调度性。我们还讨论了立即执行回调的 watch、控制回调函数执行时机等。 最后,我们讨论了过期副作用函数导致的竞态问题,并通过为 watch 回调函数设计 onInvalidate 参数来解决。用户可在过期回调中将之前的副作用标记为“过期”,以解决竞态问题。

May all encounters not be in vain