站在巨人的肩膀上看vue3 第4章 响应式系统的作用与实现

站在巨人的肩膀上看vue,来自霍春阳的vue设计与实现。作者以问题的形式一步步解读vue响应式系统的作用与实现。那一起跟随文章欣赏作者那行云流水的思路和巧妙的设计,这是一个系列,也是自己读后的记录,更希望可以通过文章的形式可以和大家一起交流学习。

4.1-4.2、响应式数据与副作用函数

副作用函数:函数的执行会直接或间接的影响其他函数的执行。

响应式数据基本实现:

  • 首先创建一个用于存储副作用函数的桶 const buket = new Set();
  • 通过 Proxy 对data 设置get 和 set 拦截函数,用于拦截读取和设置操作
  • 当读取树形将副作用函数effect添加到桶里,bucket.add(effect),然后返回其属性值;
  • 当设置属性值时,先更新原始数据,再将副作用函数从桶里取出并重新执行;
const bucket = new Set()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
  get(target, key) {
    bucket.add(effect)
    return target[key]
  },
  set(targrt, key, newVal) {
    targrt[key] = newVal
    bucket.forEach(fn => fn())
    return true
  }
})
function effect(fn) {
  console.log(obj.text)
}
effect()
setTimeout(() => {
  obj.text = 'hello vue3'
}, 1000)
// 输出 hello world
// 1s后输出 hello vue3
复制代码

4.3、设计一个完善的响应系统

问题1: 副作用函数名可以动态收集,即使是匿名函数

  1. 定义全局变量存储被注册的函数 let activeEffect
  2. 定义effect函数,将需要执行的函数当入参数,赋值给activeEffect并执行,统一副作用函数名

问题2:在响应式数据obj设置一个不存在的属性,也会触发副作用函数,没有在副作用函数与被操作的目标字段之间建立明确的关系。设置属性或者读取属性都会将副作用放到桶里和执行副作用函数。

  1. 用WeakMap作为桶的数据解构 const bucket = new WeakMap()
  2. WeakMap 由 target —> Map 构成 ;Map 由 key —> Set 构成; Set存储与key有关的副作用函数
let activeEffect  // 全局变量存储被注册的副作用函数
function effect(fn) { // effect 函数用于注册副作用函数
  activeEffect = fn
  fn()
}
const bucket = new WeakMap()

const data = { text: 'hello world' }

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

effect(
  () => {
    console.log(obj.text)
  } 
)
setTimeout(() => {
  obj.text = 'hello vue3'
}, 1000)
复制代码

问题3:为什么不用Map 而是用 WeakMap

WeakMap 的 key是弱引用,不影响垃圾回收器的工作,target 对象没有任何引用了,这时垃圾回收期会完成回收任务,如果使用Map 代替,用户代码对target没有任何引用,这个target也不会回收,导致内存溢出。

4.4、分支切换与 cleanup

分支切换:函数内部存在三元运算符(条件判断)会执行不用的代码分支。

问题4: 分支切换会产生遗留的副作用函数,重复执行

console.log(obj.ok ? obj.text : ‘not’)
复制代码

当obj.ok的值变为false,不会在读取obj.text的值,无论改变obj.text的值,都不需要再重新执行副作用函数。

当副作用执行完毕后,会重新建立联系,在新的联系中不包含遗留的副作用函数,所以在每次副作用执行前,将其从相关联的依赖中移除

  1. 在effect内部定义个新的effectFn函数,并为其添加effectFn.deps属性,存储所有包含当前副作用函数的依赖集合
  2. 在track中将副作用函数push到数组中 activeeffect.deps.push(deps),这说明deps就是一个与当前副作用函数存在联系的依赖集合
  3. 在每次副作用函数执行时,将副作用函数从依赖集合中移除,定义cleanup 函数
function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}
复制代码

4.5、嵌套的 effect 和 effect栈

当组件嵌套组件时,effect就需要嵌套。

问题5:嵌套的effect函数,外层数据改变内层的副作用函数会被执行

全局变量 activeEffect 用来存储 effect 函数注册的副作用,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个,当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且不会恢复到原来的值,这时如果再有响应式数据进行依赖收集,收集的副作用函数也会是内层副作用函数

  1. 定义一个副作用函数栈 effectStack,在副作用执行时,将当前副作用函数压入栈中
  2. 副作用函数执行完毕后将其从栈中弹出,并且让 activeEffect 指向栈顶的副作用函数
const effectStack = [] // effect 栈
function effect(fn) { // effect 函数用于注册副作用函数
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    // 在调用副作用之前将副作用函数压入栈中
    effectStack.push(effectFn)
    fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把activeEffect还原为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length -1]
  }
  effectFn.deps = []
  effectFn()
}
复制代码

4.6、避免无限递归循环

问题6:在effect内部如果有一个自增操作,该操作会引起栈溢出

effect(() => obj.foo++)
复制代码

读取obj.foo 会触发track 操作,将当前副作用函数收集到桶中,接着将其加1在赋值给obj.foo,会触发 trigger,把桶中的副作用函数取出并执行,该副作用函数正在执行,还没执行完就要开始下一次执行,这样导致无限递归地调用自己,产生栈溢出。

  1. 增加 守卫条件
  2. 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
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 => {
		//  
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(fn => fn())
}
复制代码

4.7、调度执行

可调度:当 trigger 动作触发副作用函数重新执行时,有能力决定副作用执行的时机、次数以及方式。

effect 函数设计一个选项参数options,允许用户指定调度器scheduler。

  1. 在effect将options绑定到 effectFn.options = options
  2. 在trigger副作用执行之前判断有无scheduler
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 => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => {
    if (effectFn.options?.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}
复制代码

问题7:如何控制同一个值的执行次数,在vue中更新一个响应式数据,不会每次都会执行

const data = { foo: 1 }
effect(() => console.log(obj.foo))
obj.foo++
obj.foo++
// 会执行1,2,3
复制代码

如何不执行过度状态2,通过调度器很容易实现:

在这这之前需要了解宏任务和微任务:

由于js是单线程,js设计者把任务分为同步任务和异步任务,同步任务都在主线程上排队执行,前面任务没有执行完成,后面的任务会一直等待;异步任务则是挂在在一个任务队列里,等待主线程所有任务执行完成后,通知任务队列可以把可执行的任务放到主线程执行。异步任务放到主线程执行完后,又通知任务队列把下一个异步任务放到主线程中执行。这个过程一直持续,直到异步任务执行完成,这个持续重复的过程就叫Event loop。而一次循环就是一次tick 。

在任务队列中的异步任务又可以分为两种microtast(微任务)macrotask(宏任务)

microtast(微任务):Promise, process.nextTick, Object.observe, MutationObserver

macrotask(宏任务):script整体代码、setTimeout、 setInterval等

执行优先级上,先执行宏任务macrotask,再执行微任务mincrotask。

执行过程中需要注意的几点是:

  • 在一次event loop中,microtask在这一次循环中是一直取一直取,直到清空microtask队列,而macrotask则是一次循环取一次。
  • 如果执行事件循环的过程中又加入了异步任务,如果是macrotask,则放到macrotask末尾,等待下一轮循环再执行。如果是microtask,则放到本次event loop中的microtask任务末尾继续执行。直到microtask队列清空。

png

回到这一章,vue如何实现跳过中间状态,只执行一次:

/ 定义一个任务队列
const jobQueue = new Set()
// 建给一个任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷行队列
let isFlushing = true
function flushJob() {
  if (!isFlushing) return
  isFlushing = false
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

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

obj.foo++
obj.foo++
复制代码

连续对foo执行自增操作,会连续执行两次scheduler,同一副作用函数会被jobQueue.add(fn)两次,但是Set数据有去重能力,所以只会有一项

flushJob()也会执行两次,但是isFlushing,flushJob函数在一个事件循环内只会执行一次,在微任务中队列中执行一次,当微任务队列开始执行时,遍历jobQueue队列内的副作用函数。只有一个所以也只会执行一次。这也是vue 连续修改响应式数据但只会触发一次更新的逻辑。

4.8、计算属性 computed 和 lazy

问题8:effect的函数会立即执行

在options添加 lazy 属性,在effect中根据lazy属性值判断是否执行

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

const effectFn = effect(() => console.log(obj.foo),
  {
    lazy: true
  }
)
effectFn() // 手动执行副作用函数
复制代码

问题9: computed如何拿到副作用的值

  1. 将副作用函数当作getter传入到computed中
  2. 将fn的执行结果存储到一个变量res中
  3. 执行副作用函数的时候返回
function computed(getter) {
  const effectFn = effect(getter, { lazy: true })
  const obj = {
    get value() {
      return effectFn()
    }
  }
  return obj
}

const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value)
复制代码

问题10:如何对computed的值做缓存,避免重复计算

  1. 增加一个dirty变量,标识是否需要重新计算,false直接读上次缓存的值
  2. 当响应式数据变化时,再将dirty设置为true,重新调用effectFn计算
  3. 当响应的值在effect函数中读取,修改响应式数据副作用函数不执行,需要手动调用trigger/trick
function computed(getter) {
  // 缓存上一次计算的值
  let value
  // 是否需要重新计算
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler: function() {
      if (!dirty) {
        dirty = true
		// 当计算属性依赖响应书数据变化时,手动调用trigger触发
        trigger(obj, 'value')
      }
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
// 当读取value时,手动调用trick进行响应跟踪
      track(obj, 'value')
      return value
    }
  }
  return obj
}
复制代码

4.9、watch的实现

watch的本质:观察一个响应式数据,当数据发生改变时,通知并执行相应的回调函数。

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

副作用函数与响应式数据建立联系,当响应式数据变化时,会触发副作用函数重新执行,当副作用函数中有scheduler选项,当响应式数据变化时,会触发scheduler 调度函数执行,而非直接触发副作用函数执行

问题11: watch还可以接受一个getter函数,在getter 函数内部,用户可以指定该watch依赖哪些响应式数据,只有当这些数据发生改变,才会触发回调函数执行

function watch(source, cb) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse()
  }
  effect(() => getter()
  , {
    scheduler() {
      // 当 obj.foo的值发生
      cb()
    }
  })
}

function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value == null || seen.has(value)) return
  seen.add(value)
  for (const k in value) {
    traverse(value[k], seen)
  }
  return value
}
复制代码

问题12:如何拿到旧值和新值

  1. 使用lazy选项创建一个懒执行的effect,手动调用effectFn 函数得到返回值,即第一次执行得到的值oldValue
  2. 当数据发生变化触发scheduler调度函数执行时,会重新调用effectFn 函数得到新值newValue
  3. 接着将newValue,oldValue作为参数传递给回调函数
function watch(source, cb) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse()
  }
  let oldValue, newValue
  const effectFn = effect(() => getter()
  , {
    lazy: true,
    scheduler() {
      newValue = effectFn()
      cb(newValue, oldValue)
      oldValue = newValue
    }
  })
  oldValue = effectFn()
}
复制代码

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

watch的本质是对effect的二次封装,立即执行函数就是把scheduler手动执行一遍,把scheduler函数抽离成job,当options有immediate时手动调用job。

function watch(source, cb, options = {}) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse()
  }
  let oldValue, newValue
  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }
  const effectFn =
    effect(
      () => getter(),
      {
        lazy: true,
        scheduler: job
      })
  if (options.immediate) {
    job()
  } else  {
    oldValue = effectFn()
  }
}
复制代码

在除指定回调函数为immeditate时,还可以通过其他参数(flush)来指定回调函数的执行时机

flush为post:代表scheduler需要将副作用函数放到一个微任务中,并等待DOM更新结束后再执行,

4.11、过期的副作用

问题13: 更改响应式数据发送网络请求,返回数据快慢会影响最后的值,比如先发A请求,立马发送B请求,B请求先返回A请求后返回,最后的结果是A的返回值

watch设计了第三个参数onInvalidate,在watch内部每次检测到变更后,在副作用函数重新执行之前,会先调用通过onInvalidate函数注册的过期回调,在过期回调中将上一次的副作用标记为“过期”

总结

vue3响应式系统如何实现

通过Proxy监听对象属性的Get 和 Set 方法,在 get 中进行依赖收集track,使用WeakMap 配合 Map和Set的数据解构,在响应式与副作用函数之间建立联系。将目标对象作为key,Map()作为value存入到WeakMap中。而Map存储的则是具体对象属性的key值和副作用的Set集合。当改变对象的属性值时,会触发set方法trigger,这时会找到在Set集合中的副作用函数遍历执行。

连续多次修改响应式数据只会触发一次更新

通过一个微任务队列对任务进行缓存,首先定义了一个Set集合的任务队列,多次且连续的修改数据,也会多次执行scheduler函数,将副作用函数放入到Set集合,由于Set有去重功能,所以队列中只会有一项。并且在scheduler中会调用一个flushJob的方法刷新队列。通过一个isFlushing的标识,只有为true的时候才会执行,并且在Promise.resolve的微任务中遍历Set集合执行,执行完之后finally将标识改为false,回到下一次的更新前的状态。

computed如何实现

computed计算属性实际上是一个lazy的副作用函数,通过lazy的选项使得副作用函数可以懒执行,将方法getter作为参数传递给effect函数并且options.lazy设置为true,内部定义了一个obj对象,重写了get value的方法执行了副作用函数,最后返回整个obj对象,当计算属性中依赖的响应式数据发生变化,通过value拿到执行之后的结果值。为了避免多次计算设计一个缓存的开关 dirty ,当dirty 为真时才会计算,当数据发生改变时在set中会调用scheduler,这是将dirty设置为true,重新计算

watch如何实现

watch本质上利用了副作用函数重新执行时的可调度性schedluer,在scheduler中执行用户通过watch函数注册的回调函数即可,scheduler指的是当trigger动作触发副作用函数重新执行,在effect函数中增加的第二个options参数

猜你喜欢

转载自juejin.im/post/7079970135473127461