《vue.js设计与实现》读书笔记(一):响应式的基本实现与完善

27 阅读14分钟

写在前面

当前笔记是在读书和写代码的同时做的记录,后续也未做整理,可能在某些方面写的比较简单。

更加详细的内容请直接查看项目代码

1. 实现目标

const obj = reactive({
  name: '小明'
})

effect(() => {
  document.querySelector('#name').innerText = obj.name
})

setTimeOut(() => {
  obj.name = '小红'
}, 1000)

将原始数据通过reative包裹,生成响应式数据,向effect传递匿名函数并执行,通过拦截器搜集依赖,在一秒后更新响应式数据并再次触发匿名函数。

2. 基本实现

这里使用Vue 3 中使用的Proxy

let activeEffect = null
const effects = new Set() // 用于存储所有的响应函数

function reactive(data) {
  return new Proxy(data, {
    get(target, key) {
      // 如果没有活跃函数,则说明该数据不需要响应式,直接返回
      if (!activeEffect) {
        return target[key]
      }
      effects.add(activeEffect) // 添加上当前活跃的fn
      return target[key]
    },
    set(target, key, newVal) {
      target[key] = newVal

      effects.forEach(fn => fn()) // 遍历所有的活跃函数,并执行
    }
  })
}

/**
 * 存储当前函数
 * 立即执行一次
*/
function effect(fn) {
  activeEffect = fn
  fn()
}

本次主要实现了最基本的响应式处理,但本次在get函数中并没有对读取的key做区分,使得数据中的所有key都会触发同一组响应函数。

查看源码

区分读取的key

在上一版本的基础之上,我们加上对数据内key的区分。

创立一个Map对象,使得每个key都对应一个用于存储响应函数的Set对象。

let activeEffect = null
const depsMap = new Map() // key => new Set(fn1、fn2、fn3)

function reactive(data) {
  return new Proxy(data, {
    get(target, key) {
      if (!activeEffect) {
        return target[key]
      }
      let deps = depsMap.get(key)
      if (!deps) {
        // 没有在类型桶中,则新建一个
        depsMap.set(key, (deps = new Set()))
      }
      deps.add(activeEffect) // 添加上当前存储的fn
      return target[key]
    },
    set(target, key, newVal) {
      target[key] = newVal

      const deps = depsMap.get(key)
      deps && deps.forEach(fn => fn())

      return true // 定义Set时要返回true,不然会报错 'set' on proxy: trap returned falsish for property ‘xxx’
    }
  })
}

function effect(fn) {
  activeEffect = fn
  fn()
}

查看源码

区分data

由于reactive函数并不是只调用一次,我们还要对原始数据进行区分。

创建WeakMap对象,使得每个data都对应我们之前创立的Map

bucket => new WeakMap()
|
├─ data1: new Map()
|   |
|   ├─ key1: new Set()
|   |   |
|   |   ├─ fn1()
|   |   |
|   |   └─ fn2()
|   | 
|   └─ key2: new Set()
|       |  
|       ├─ fn1()
|       |
|       └─ fn2()
|
└─ data2: new Map()
let activeEffect = null
const bucket = new WeakMap() // data => new Map()

function reactive(data) {
  return new Proxy(data, {
    get(target, key) {
      if (!activeEffect) {
        return target[key]
      }
      // 根据target从“桶”中取得depsMap, 它是一个Map类型: key -> effects
      let depsMap = bucket.get(target)
      if (!depsMap) {
        // 如果不存在, 则创建一个新的Map与target关联
        bucket.set(target, (depsMap = new Map()))
      }
      // 根据key从depsMap中取得effects,它是一个Set类型: effects
      // 这里记录着当前key的所有副作用函数
      let deps = depsMap.get(key)
      if (!deps) {
        // 没有,则新建一个Set,并且将其与key关联
        depsMap.set(key, (deps = new Set()))
      }
      deps.add(activeEffect) // 添加上当前存储的fn
      return target[key]
    },
    set(target, key, newVal) {
      target[key] = newVal
      // 根据target从“桶”中取得depsMap, 它是一个Map类型: key -> effects
      const depsMap = bucket.get(target)
      if (!depsMap) return
      // 根据key从depsMap中取得effects,它是一个Set类型: effects
      const deps = depsMap.get(key)
      // 执行所有副作用函数
      deps && deps.forEach(fn => fn())

      return true // 定义Set时要返回true,不然会报错 'set' on proxy: trap returned falsish for property ‘xxx’
    }
  })
}

function effect(fn) {
  activeEffect = fn
  fn()
}

实现完成,最后将函数抽取整理一下即可。

查看源码

Q: WeakMap、Map、Set的区别

首先 WeakMapMap 相似,都是用来存储键值对的([object Object]: value),但是 Map 存储的是对象,而 WeakMap 存储的是弱引用。

const map = new Map()
const weakMap = new WeakMap()

(() => {
  const foo = {foo: 1}
  const bar = {bar: 2}

  map.set(foo, 1)
  weakMap.set(bar, 1)
})()

在立即执行函数内部,我们创建了两个对象foo、bar,他们分别作为mapweakMap的key。当函数执行完成时,对于foo来说,它仍然作为map的key被引用着,无法进行回收。对于bar来说,weakMap的key是弱引用,它不影响回收器的工作,当函数执行完成,垃圾回收器就会把对象bar从内存中移除。

Set 是 ES6 提供的新的数据结构,类似于数组,但是成员的值都是唯一的,没有重复的值

分支切换

首先需要明确分支切换的概念

const obj = reactive({
  ok: true,
  text: 'Hello, world'
})

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

理想状态下,当obj.oktrue时,我们修改obj.text的值会触发副作用函数,而obj.okfalse时,页面只显示静态的'not',此时不以来其他字段,我们修改obj.text的值时就不应该触发副作用函数。

然而,按照目前的代码写法,我们没法在obj.ok修改时,将不再需要监听的字段解除其对应的副作用函数的绑定关系。

解法思路:在副作用函数执行之前,先把副函数与它所有有关系的字段解绑,之后在执行函数时仅针对本次用到的字段进行绑定。

我们先来看一下之前的的effect 以及 track

export function reactive(data) {
  return new Proxy(data, {
    get(target, key) {
      track(target, key)
      return target[key]
    },
    set(target, key, newVal) {
      target[key] = newVal
      trigger(target, key)
      return true // 定义Set时要返回true,不然会报错 'set' on proxy: trap returned falsish for property ‘xxx’
    }
  })
}

function track(target, key) {
  if (!activeEffect) {
    return target[key]
  }
  // 根据target从“桶”中取得depsMap, 它是一个Map类型: key -> effects
  let depsMap = bucket.get(target)
  if (!depsMap) {
    // 如果不存在, 则创建一个新的Map与target关联
    bucket.set(target, (depsMap = new Map()))
  }
  // 根据key从depsMap中取得effects,它是一个Set类型: effects
  // 这里记录着当前key的所有副作用函数
  let deps = depsMap.get(key)
  if (!deps) {
    // 没有,则新建一个Set,并且将其与key关联
    depsMap.set(key, (deps = new Set()))
  }
  // 添加上当前存储的fn
  deps.add(activeEffect)
}

export function effect(fn) {
  activeEffect = fn
  fn()
}

每次effect执行时,触发了get的参数就会将本次的effect记录在自己的“桶”中,但如果我们要清除当前effect触发过的参数绑定时,就不那么容易。

所以,我们需要在effect执行时,将所有触发get的参数记录在effect自己的“桶”中,这样就可以通过自己的“桶”中记录的参数,一一对应,清除参数“桶”中关于本次effect的绑定。 (写的啰嗦)

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)
  /**
   * 将当前触发get的参数的“桶”关联到activeEffect的“桶”中
   */
  activeEffect.deps.push(deps)
}


// 用一个全局变量存储当前激活的 effect 函数
export function effect(fn) {
  /**
   * 这里我们对传入的fn重新包装一下 
   * 声明 deps 属性,用来记录执行时触发get的参数
   */
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    fn()
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

/**
 * 用来清理参数中关联的effect
*/
function cleanup(effectFn) {
  /**
   * ffectFn.deps记录了所有触发get的参数的deps
   * 
   * 将这些deps中有关本次effect的属性删掉
   * 
   * 现在这个参数就与effect没有关系了
  */
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

注意!

此时看似功能已经完成,但有一个致命的问题 => 无限循环

function trigger(target, key) {
  // ...
  const effects = depsMap.get(key)
  effects && effects.forEach(effectFn => effectFn())
}

trigger中我们会循环调用参数的effect集合,而effectFn又会将自身从参数的以何种删除掉并重新执行,导致自身又被添加到参数的effect集合中,但此时的forEach还没有结束,又会重新执行effectFn,如此反复。

  • 方案一,在forEach结束时再执行effectFn (抖机灵)

我们将effectFn变成一个异步函数,

  const effectFn = async () => {
    await timeout(0)
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    fn()
  }

利用Event Loop的机制,将函数的调用时机放在下次任务中,这样就不会影响到forEach的循环。

当然,这样会造成不必要的时间浪费。

  • 方案二
const set = new Set([1])

set.forEach(i => {
  set.delete(1)
  set.add(1)
  console.log('run')
})

我们将上述问题进行简化。要解决这个问题其实很简单,我们只需要构造另一个Set合集,遍历与操作对应不同的合集,这样就可以避免无限循环。

const set = new Set([1])
const newSet = new Set(set)
newSet.forEach(i => {
  set.delete(1)
  set.add(1)
  console.log('run')
})

那么回到我们的问题, 在trigger中,用同样的手段来避免无限循环。

function trigger(target, key) {
  // ...
  const effects = depsMap.get(key)
  
  const effectsToRun = new Set(effects)
  effectsToRun.forEach(effectFn => effectFn())
  // effects && effects.forEach(effectFn => effectFn()) // 删除
}

查看源码

嵌套的Effect

查看源码

查看修改前的版本,请将./core.js替换为../03-1/core.js

首先我们来分析问题,当点击effect1 切换名称的按钮时,控制栏打印出

effect1 小红
effect2 1
effect2 1

此时,执行了一次外部的effect, 内部的effect执行了两次。

原因: 当我们点击effect1的按钮时,会先触发bar的get,造成bar对当前activeEffect的收集,然而当前activeEffect则是effect2,因为我们再执行effect后并没有做出及时清理,导致bar即搜集了外层effect又收集了内层的effect,遍历执行后导致内层effect执行两次。

由于时嵌套的Effect,所以我们要考虑当内部的effect执行结束时,将activeEffect还给外层。

let activeEffect = null
const effectStack = [] // 用来维护effect的函数栈

export function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    effectStack.push(effectFn) // 添加进栈
    fn()
    // 弹出
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1] // 回滚到上一个effect
  }
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}

避免无限循环。

举个栗子

const data = reactive({
  foo: 1
})

effect(() => {
  data.foo++ // => data.foo = data.foo + 1
})

在这段代码中,由于在同一个副作用函数中即触发了foo的收集(track),也触发了foo的修改(trigger),这样就会造成无限循环。

因此,我们需要在trigger添加判断:当trigger中触发的副作用函数与当前正在触发的activeEffect相同时,则不执行。

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 => {
    // 与当前的activeEffect相同,为避免无限循环,不触发执行
    if (effectFn !== activeEffect) {
      effectFn()
    }
  })
}

调度执行

本节主要讨论trigger中副作用函数的触发时机、次数。

  • 场景一
const data = reactive({
  foo: 1
})

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

obj.foo++

console.log('结束了')

这段代码输出如下:

1
2
'结束了'

现在我们要将trigger中触发的副作用函数改至微任务中,使得输出变为:

1
'结束了'
2

代码实现如下:

// 我们在`effect`中添加一个`options`参数
export function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    // 弹出
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  // 将options添加到effectFn中
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  effectFn()
}


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 => {
    // 与当前的activeEffect相同,为避免无限循环,不触发执行
    if (effectFn !== activeEffect) {
      // 这里进行判断,如果有调度器,则调度器执行,否则执行副作用函数
      if (effectFn.options.scheduler) {
        effectFn.options.scheduler(effectFn)
      } else {
        effectFn()
      }
    }
  })
}

那么,在传入effect时,需要手动编写对应需求的调度器代码。

effect(() => {
  console.log(obj.foo)
}, {
  scheduler(effectFn) {
    setTimeout(effectFn, 0) // 将代码执行到下一个宏任务再执行
  }
})
  • 场景二
const data = reactive({
  foo: 1
})
effect(() => {
  console.log(obj.foo)
})

obj.foo++
obj.foo++

这段代码输出如下:

1
2
3

由于两次obj.foo++是在同一个宏任务中连续执行的,而由此触发了两次副作用函数,输出了三次结果。如果我们只关心结果而不关心过程,那么三次打印操作时多余的,我们期望的打印结果是:

1
3

代码实现:

// 创建一个任务列表
const jobQueue = new Set()
const q = Promise.resolve() // 创建一个微任务环境

// 正在刷新队列
let isFlushing = false
function flushJob () {
  // 如果正在刷新,不执行
  if (isFlushing) return
  isFlushing = true // 更改状态
  q.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    // 当执行结束时,重置状态
    isFlushing = false
  })
}

effect(() => {
  console.log(obj.foo)
}, {
  scheduler(effectFn) {
    jobQueue.add(effectFn) // 将当前副作用函数添加到待执行列表中
    flushJob() // 调用执行
  }
})

我们通过调度器,将所有副作用函数的执行环境放在微任务中(通过promise.resolve实现),搜集宏任务中的触发的副作用函数,并将其加入到待执行队列中,并且由于Set的特性,使得相同的副作用函数只会被加入一次。

computed && lazy

按照书中的顺序,我们先来讨论一下lazy,即lazyeffect

在某些场景下,我们可能并不希望effect立即执行。在上文中,我们给effect添加了options的入参,所以我们可以通过在options中添加lazy来控制effect的执行。

// 该effect不会立即执行
effect(() => {
  console.log(obj.foo)
}, {
  lazy: true
})
// 用一个全局变量存储当前激活的 effect 函数
export function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    // 弹出
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []

  // 非lazy时,才会立即执行
  if (!options.lazy) {
    // 执行副作用函数
    effectFn()
  }

  // lazy 属性下,将函数返回出去
  return effectFn
}

在添加了lazy属性后,effect不会立即执行,而是将封装好的副作用函数返回,之后我们可以自行调用函数。

const effectFn = effect(
  () => obj.foo + obj.bar,
  {lazy: true}
)

const value = effectFn()

为了实现这种操作,我们还需要改造一下封装副作用函数的方法。

// ...
const effectFn = () => {
  cleanup(effectFn)
  // 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
  activeEffect = effectFn
  effectStack.push(effectFn)
  const res = fn() // 这里在执行完用户传入的函数后,将返回值赋值给 res
  // 弹出
  effectStack.pop()
  activeEffect = effectStack[effectStack.length - 1]

  return res // 返回结果
}
// ...

至此,稍微有点computed函数的影子了,我们先写一次computed函数的大致样子。

const res = computed(() => {
  return obj.foo + obj.bar
})

console.log(res.value)

为了实现这个效果,我们可以利用对象的get字符计算出所需要的函数值。

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

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

  return obj
}

至此我们大致实现了computed,但是在每次访问value属性时,都会重新执行一次effect,做不到对值的缓存。

export function computed(getter) {
  let value // 缓存上次计算的值
  let dirty = true // 是否需要重新计算

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

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        // 将dirty设置为false, 那么下次取值时不会重新计算
        dirty = false
      }
      return value
    }
  }

  return obj
}

可以看到,我们对函数的结果进行了保存,在一次执行后,后续的取值不再重新计算。那么当函数中的值发生更改,触发了trigger后,我们就需要重新执行函数,即修改dirty的值,而修改的时机我们之前已经写好,就是scheduler函数。

// ...
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      // 添加一个调度器,当数据改变触发了trigger时,就会调用`scheduler`函数,此时我们就将dirty改为true,在下次获取值时重新执行函数。
      dirty = true
    }
  })
// ...

现在我们的计算属性还有一个缺陷。

const res = computed(() => {
  return obj.foo + obj.bar
})

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

obj.foo++

我们在effect中读取计算属性的值时,会触发一次副作用函数执行,但当obj中的值发生改变时,副作用函数并没有再次执行。

分析问题原因,这个问题本质上还是一个典型的effect嵌套。计算属性内部拥有自己的effect,并且它是懒执行,只有当读取计算属性的值时才会执行。当计算属性内部的effect执行时,它会将计算函数的effect作为本次的activeEffect被响应式函数进行依赖收集,当函数执行完成后则会把activeEffect丢给外层的effect,但是外层的effect并没有被响应式数据收集。

解决思路,在读取计算属性后,我们需要将当前的结果值与外层的effect进行关联,当结果值发生改变时,则会触发外层的effect。那么我们需要手动调用tracktrigger函数进行收集与响应。

function computed(getter) {
  let value // 缓存上次计算的值
  let dirty = true // 是否需要重新计算

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      // 添加一个调度器,当数据改变触发了trigger时,就会调用`scheduler`函数,此时我们就将dirty改为true,在下次获取值时重新执行函数。
      dirty = true
      // 当响应式数据发生改变,触发`obj`的绑定函数
      trigger(obj, 'value')
    }
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        // 将dirty设置为false, 那么下次取值时不会重新计算
        dirty = false
      }
      /**
       * effectFn 执行完毕,activeEffect已经跑给了外层的`effect`
       * 
       * 我们将当前的obj.value 与外层的`effect`进行关联
       */
      track(obj, 'value')
      return value
    }
  }

  return obj
}

至此,computed的实现完成。

写在最后

更完善的代码请看my-vue