vue.js 设计与实现 - 响应系统
2023-04-18
前端Vue

简单响应式

响应式用到作用函数,何为作用函数,即该函数操作了函数外的代码,如,访问或修改全局变量。

vue 中响应式操作放在 effect 函数中,之所以叫此名字,因其内操作了 DOM,比如:

js
const obj = { text: '甲乙丙丁' }
function effect() {
  // 此处操作了函数外的变量,故而为作用函数
  document.body.textContent = obj.text
}

此时,要将 obj 变成响应式,修改其内 text 属性,页面随之更改,需拦截其读写操作。

即,读取 obj.text 时,缓存 effect 函数;修改 obj.text 时,取出 effect 并执行,便可更新。

Vue2,要兼容低版本浏览器,用 Object.defineProperty 实现拦截,但效率偏低。Vue3,采用 Proxy,更高效。

js
// 缓存
// 之所以用 Set,因每次读区都会存缓存,Set 可保证唯一
const bucket = new Set()

// 原始数据
const data = { text: '甲乙丙丁' }
// 代理对象
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())
    // 返回 true,表示更新成功
    return true
  }
})

/** 作用函数 */
function effect() {
  document.body.innerHTML = obj.text
}

// 执行作用函数
effect()

// 一秒后更新数据
setTimeout(() => {
  obj.text = '天地玄黄'
}, 1000)

完善响应式

欲使之完善,需减少死代码。

js
// 全局变量用以存储被注册的作用函数
let activeEffect
// 作用函数
function effect(fn) {
  // 调用 effect 时,保存自定义作用函数
  activeEffect = fn
  // 执行作用函数
  fn()
}

// 如此使用:
effect(() => {
  document.body.textContent = obj.text
})

之所以定义全局变量 activeEffect,乃因需要保存自定义作用函数,以便后续调用:

js
// ...
// 代理对象
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())
    // 返回 true,表示更新成功
    return true
  }
})
// ...

Proxy 代理的是整个对象,所有操作均出发 get set,换言之,即使访问或修改的属性不存也,亦会执行作用函数:

js
effect(() => {
  console.log('执行了')
  document.body.textContent = obj.text
})

setTimeout(() => {
  // 属性不存在,亦打印 “执行了”
  obc.notExist = '不存在'
}, 1000)

显然不合理,因而改善之。将属性与作用函数对应起来,使之成为一棵树。

text
原对象(WeakMap) => 属性(Map) => 作用函数(Set)
js
// 缓存
// 改为 WeakMap
const bucket = new WeakMap()
let activeEffect

// 原始数据
const data = { text: '甲乙丙丁' }
// 代理对象
const obj = new Proxy(data, {
  // 代理读操作
  get(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()))
    }
    // 追加作用函数,因用的 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())
  }
})

整理代码:

js
// 缓存
// 改为 WeakMap
const bucket = new WeakMap()
let activeEffect

// 原始数据
const data = { ok: true, text: '甲乙丙丁' }
// 代理对象
const obj = new Proxy(data, {
  // 代理读操作
  get(target, key) {
    track(target, key)
    // 返回属性值
    return target[key]
  },
  // 代理写操作
  set(target, key, newVal) {
    // 更新数据
    target[key] = newVal
    trigger(target, key)
  }
})

/** 追踪变化 */
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()))
  }
  // 追加作用函数,因用的 Set,不会重复
  deps.add(activeEffect)
}

/** 触发变化 */
function trigger(target, key) {
  // 获取当前对象
  const depsMap = bucket.get(target)
  if (!depsMap) { return }
  // 获取当前属性关联的作用函数
  const effects = depsMap.get(key)
  // 取出作用函数并执行
  effects && effects.forEach(fn => fn())
}

/** 作用函数 */
function effect(fn) {
  activeEffect = fn
  fn()
}

分支优化

考虑如下代码:

js
const obj = { ok: true, text: '甲乙丙丁' }

effect(() => {
  console.log('走这儿了')
  document.body.textContent = obj.ok ? obj.text : '子丑寅卯'
})

此时,设 obj.ok = false,会触发两次打印。这是因为更改 ok 属性,触发 setter,会执行所有作用函数,即 effects.forEach(fn => fn()) 这句。

解决之道在于,执行作用函数前,清除所有依赖,执行是会重新建立依赖。移除前,需明确依赖项。

js
let activeEffect
function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn
    // fn 为传入的真实作用函数
    fn()
  }
  // effectFn 上挂载属性 deps,用以存放关联依赖
  effectFn.deps = []
  // 执行即表示,将 effectFn 存于 activeEffect,并执行作用函数 fn
  effectFn()
}

effectFn.deps 收集于 track

js
function track() {
  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)
  // 上面的 deps 就是当前作用函数的依赖集合
  activeEffect.deps.push(deps)
}

反向依赖收集完毕,即可在每次执行依赖函数时,先行清除之:

js
let activeEffect
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []
  effectFn()
}

function cleanup(effectFn) {
  // 遍历 effectFn.deps
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是 Set,存着依赖
    const deps = effectFn.deps[i]
    // 逐个删除
    deps.delete(effectFn)
  }
  // 清空数组
  effectFn.deps.length = 0
}

修改 trigger,以避免死循环

js
// 死循环原因
const set = new Set([1])
set.forEach((item) => {
  set.delete(1)
  set.add(1)
  console.log('遍历。。')
})
js
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) { return }
  const effects = depsMap.get(key)

  // 死循环源头
  // effects && effects.forEach(effectFn => effectFn())
  // 抽离作用函数到另一集合,再行遍历,可避免之
  const effectsToRun = new Set(effects)
  effectsToRun.forEach(effectFn => effectFn())
}

嵌套作用函数

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

effect(() => {
  console.log('fn1 执行了')

  effect(() => {
    console.log('fn2 执行了')
    temp2 = obj.bar
  })

  temp1 = obj.foo
})

// 执行修改时
obj.foo = 4
// 输出
// fn1 执行了
// fn2 执行了
// fn2 执行了  => 应当为 fn1 执行了

之所以修改 obj.foo 执行的是内层作用函数,盖因 actvieEffect 只保存最后一次注册的作用函数。欲解之,可新增一栈,作用函数执行时,压入之,执行毕,弹出之,并始终让 activeEffect 指向栈顶。

js
let activeEffect
// 栈
const effectStack = []

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    // 调用前,将`effectFn` 压入栈
    effectStack.push(effectFn)
    fn()
    // 调用毕,推出
    effectStack.pop()
    // activeEffect 指向栈顶
    activeEffect = effectStacke.at(-1)
  }

  effectFn.deps = []
  effectFn()
}

避免无限循环

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

上述代码会栈溢出。原因是,同时读写。读取,触发追加操作,收集作用函数,接着将其加一再赋回之,触发执行作用函数。而上一个还在执行,无限递归调用自己,引发栈溢出。解决之道:

js
function trigger(target, key) {
  // ...

  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  effects && effects.forEact((effectFn) => {
    // 触发的作用函数与正执行的不同,再追加
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })

  // ...
}

调度

欲控制作用函数调用时机、次数、方式,需自定义调度。

js
function effect(fn, options = {}) {
  const effectFn = () => { /* ... */ }
  // 挂载各种选项
  effectFn.options = options
  effectFn.deps = []
  effectFn()
}

function trigger(target, key) {
  // ...
  const effectsToRun = new Set()
  effects && effects.forEach((effechFn) => { /* ... */ })

  effectsToRun.forEach((effectFn) => {
    // 如果有调度函数,则回调
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    }
    // 否则直接执行
    else {
      effectFn()
    }
  })
}

计算属性

计算属性即懒执行的作用函数。处理选项 lazy

js
function effect(fn, options = {}) {
  const effectFn = () => { /* ... */ }
  effectFn.options = options
  effectFn.deps = []
  // 非 lazy 才执行
  if (!options.lazy) {
    effectFn
  }
  // 返回作用函数,以待后用
  return effectFn
}

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
}

const data = { a: 1, b: 2 }
const obj = new Proxy(data, {/* ... */})
const res = computed(() => obj.a + obj.b)
// res.value = 3

wacch

js
function watch(source, cb) {
  let getter
  // watch 可以为 getter 方式,如 () => obj.a
  if (typeof source === 'function') {
    getter = source
  }
  // 也可以为普通对象
  else {
    // 递归处理对象各个属性
    getter = () => traverse(source)
  }

  effect(
    () => getter(),
    {
      scheduler() {
        cb()
      }
    }
  )
}

function traverse(value, seen = new Set()) {
  // value 应为对象,且未被 seen 缓存
  if (typeof value !== 'object' || value === null || seen.has(value)) { return }

  // 缓存,以防止循环遍历
  seen.add(value)

  // 假定 value 为对象,递归处理
  for (const key in value) {
    traverse(value[key], seen)
  }

  // 返回
  return value
}

更改时,获取新旧值:

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

  // 定义新旧值
  let newValue, oldValue

  // lazy = true, 则返回作用函数
  const effectFn = effect(
    () => getter(),
    {
      // 激活 lazy,以返回作用函数
      lazy: true,
      scheduler() {
        // 执行作用函数,即为新值
        newValue = effectFn()
        // 回调函数传入新旧值
        cb(newValue, oldValue)
        // 回调执行后,新值即旧值
        oldValue = newValue
      }
    }
  )

  // 手动执行即为旧值
  oldValue = effectFn()
}