Standing on the shoulders of giants to see vue3 Chapter 4 The role and implementation of the responsive system

Standing on the shoulders of giants and watching Vue, the design and implementation of Vue from Huo Chunyang. The author interprets the role and implementation of the vue responsive system step by step in the form of questions. Let's follow the article and appreciate the author's flowing ideas and ingenious design. This is a series, and it is also a record after reading it. I hope that I can communicate and learn with everyone through the form of the article.

4.1-4.2, reactive data and side effect functions

Side effect function: The execution of a function will directly or indirectly affect the execution of other functions.

Basic implementation of responsive data:

  • First create a bucket for storing side effect functions const buket = new Set();
  • Set get and set interception functions on data through Proxy to intercept read and set operations
  • When reading the tree, add the side effect function effect to the bucket, bucket.add(effect), and then return its attribute value;
  • When setting the property value, first update the original data, then take the side effect function out of the bucket and execute it again;
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. Design a perfect response system

Problem 1: Side-effect function names can be dynamically collected, even for anonymous functions

  1. Define a global variable to store the registered function let activeEffect
  2. Define the effect function, take the function to be executed as a parameter, assign it to activeEffect and execute it, unify the name of the side effect function

Question 2: Setting a non-existent property in the reactive data obj will also trigger the side effect function, without establishing a clear relationship between the side effect function and the target field being manipulated. Setting a property or reading a property will place side effects in the bucket and execute side effects functions.

  1. Deconstructing data with WeakMap as bucket const bucket = new WeakMap()
  2. WeakMap consists of target —> Map; Map consists of key —> Set; Set stores side-effect functions related to 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)
复制代码

Question 3: Why use WeakMap instead of Map

The key of WeakMap is a weak reference, which does not affect the work of the garbage collector. There is no reference to the target object. At this time, the garbage collection period will complete the recovery task. If Map is used instead, the user code has no reference to the target, and the target will not. Recycling, causing a memory overflow.

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参数

Guess you like

Origin juejin.im/post/7079970135473127461