Notes:浅谈响应式设计

45 阅读11分钟

原文是《Vue.js设计与实现》(霍春阳) 第四章:响应系统的作用和实现

1.effect函数

响应式数据就是通过js改变数据内容时dom中的文本也会随之改变。很容易想到要去拦截对象的读取和设置操作,在ES2015+中,可以使用Proxy代理对象来实现。

const data = {
  text: 'zs'
}
const obj = new Proxy(data,{
  get(target,key){
    return target[key]
  },
  set(target,key,newVal){
    target[key] = newVal
    document.body.innerText = newVal
  }
})
​
document.body.innerText = 'xia'

以上的代码就实现了一个非常简单的相应式数据,可以看到document.body.innerText这行代码重复了两次,并且这行代码代表了数据更改后会发生的变化,由此引出了effect函数(副作用函数)的概念。

由此,以上的代码可以改写为:

const data = {
  text: 'zs'
}
const obj = new Proxy(data,{
  get(target,key){
    return target[key]
  },
  set(target,key,newVal){
    target[key] = newVal
    effect()
  }
})
​
function effect(){
  document.body.innerText = 'xia'
}
​
effect()

上面的对象中只有一个属性,代码看起来是在正常运行的,如果现在有两个属性了,我们更改其中任意一个属性都会触发相同的effect函数,这和我们的预期显然是不一样的,因此我们需要对不同的属性收集(track)不同的effect,并在对应属性发生改变时触发(trigger)这个effect。这里引入了一个bucket(桶),用来存放所有的effect。

上面的代码还存在一个问题就是在set函数中硬编码了副作用函数(effect)的函数名,如果之后副作用函数不叫effect,则上面的代码无法正常工作。

下面的代码对以上两个问题进行了修正。第一,上面分析过要对不同对象的不同属性的effect函数收集,因此 bucket中的值的类型设置为weakmap(target,map(key,set(effect))),分别存储对象名、属性、副作用函数。第二,增加一个全局变量activeEffect,用来存储被注册的副作用函数。

const data = {
  text: 'zs'
}
const obj = new Proxy(data,{
  get(target,key){
    track(target,key)
    return target[key]
  },
  set(target,key,newVal){
    target[key] = newVal
    trigger(target,key)
  }
})
​
const bucket = new WeakMap()
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)
}
​
function trigger(target,key) {
  const depsMap = bucket.get(target)
  if ( !depsMap ) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn());
}
let activeEffect
function effect(fn){
  activeEffect = fn
  fn()
}
​
effect(()=>{
  document.body.innerText = obj.text
})
2.分支切换

当副作用函数中是一个关于obj的三元表达式时,也就是根据obj.ok的值的不同会执行不同的代码分支,如下:

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

以上代码上,当obj.ok的值为true时,ok和text属性都会被读取和并且收集到依赖中,当ok的属性变为false后,document.body.innerText的值将显示为not。理想情况下,此时text的值如何改变document.body.innerText的值都不会改变,也确实如此。可是text的值的改变,会触发副作用函数,即使未更新dom,这种触发,有些浪费。

解决这个问题的思路就是,每次副作用函数执行时,先把它从所有与之相关联的函数集合中删除,因此需要知道那些依赖集合中含有它。为此,在effect函数中定义了一个effectFn函数,并为每个effectFn函数添加了一个deps属性,用来存储包含依赖当前副作用函数的依赖集合。

let activeEffect
function effect(fn,options={}){
  const effectFn = () => {
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []//activeEffect.deps[]存储依赖集合
  effectFn()
}
​
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)
  activeEffect.deps.push(deps) //完成了依赖集合收集
}

有了这个联系后,就可以在每次副作用函数执行性,根据effectFn.deps获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除。

let activeEffect
function effect(fn,options={}){
  const effectFn = () => {
    cleanUp(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = []//activeEffect.deps[]存储依赖集合
  effectFn()
}
​
function cleanUp(effectFn){
  for(let i = 0 ; i < effectFn.deps.length;i++){
    const deps = effectFn.deps[i] // deps set集合
    deps.delete(effectFn)
  }
  effectFn.deps.length = 0
}

至此,可以避免副作用函数产生的遗留了,但目前运行代码会导致无限循环执行,因为在trigger函数中,遍历effect函数集合,会调用cleanUp清楚,但副作用函数的执行会导致其(副作用函数)重新被收集到集合中,因此,对于effects集合的遍历就回一直进行。解决的办法就是新增一个Set集合并遍历它。

function trigger(target,key) {
  const depsMap = bucket.get(target)
  if ( !depsMap ) return
  const effects = depsMap.get(key)//effects、activeEffect、track(add)都指向同一个地址
  //在调用foreach遍历set集合时,如果一个值已经被访问过了,但该值给删除并重新添加到集合中,且foreach未借结束,会被重新访问
  effects && effects.forEach(fn => fn());
}
​
​
function trigger(target,key) {
  const depsMap = bucket.get(target)
  if ( !depsMap ) return
  const effects = depsMap.get(key)
  //effects && effects.forEach(fn => fn());//这个循环无法退出
  const effectsToRun = new Set(effects)
  effectsToRun.forEach( effectFn => effectFn())
}
3.effect嵌套和effect栈

在一个effect函数中传入了另一个effect函数,就发生了effect函数的嵌套。

effect(()=>{
  console.log('effectFn1执行')
  effect(()=>{
    console.log('effectFn2执行')
    console.log(obj.bar)
  })
  console.log(obj.foo)
})

在上面的代码中,effectFn1内部嵌套了effectFn2,很明显,effectFn1的执行会导致effectFn2的执行,其中,在effectFn1中访问了obj的foo属性,在effectFn2中访问了obj的bar属性,理想情况下,我们希望修改bar会触发effectFn2执行,修改foo会触发effectFn1执行。

但在实际运行时,我们会发现在修改foo的时候,effectFn1没有运行,反而是运行了effectFn2。原因我们使用同一个activeEffect来存储effect函数注册的副作用函数,意味着同一时刻,activeEffect函数所存储的副作用函数只能有一个,当副作用函数发生嵌套时,内层的副作用函数的执行会覆盖activeEffect的值。

为解决这个问题,需要引入一个effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。

const effectStack = []
function effect(fn,options={}){
  const effectFn = () => {
    cleanUp(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length-1]
  }
  effectFn.deps = []
  effectFn()
}
4.避免无限循环
effect(() => obj.num++)

在以上代码中,同一个副作用函数中,既读取了num(track),又设置了num(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 &&effects.forEach( fn => {
    if ( fn !== activeEffect ) {
      effectsToRun.add(fn)
    }
  })
  effectsToRun.forEach( effectFn => effectFn())
}
5.调度执行

调度执行,指的是trigger有能力决定触发副作用函数执行的时机、次数以及方式。

时机
const data = { foo: 1}
const obj = new Proxy(/*...*/)
effect(()=> cosnole.log(obj.foo))
obj.foo++
console.log('结束了')

以上代码输出的顺序分别是1、2、‘结束了’,若要在不改变代码结构的情况调整输出顺序,例如,将输出顺序修改为1、‘结束了’、2,就需要响应系统支持调度。

为此,我们可以为effect函数设计一个选项式参数options,允许用户指定调度器。

effect(()=>{/*...*/},{
  scheduler(fn){
    //...
  }
})

我们需要再effect函数内部把options选项挂载到对应的副作用函数上,在trigger函数触发副作用函数执行时,就可以直接调用用户传递的副作用函数,从而把控制权交给用户。

​
function effect(fn,options={}){
  const effectFn = () => {
    cleanUp(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length-1]
  }
  effectFn.deps = []
  effectFn.options = options
  effectFn()
}
​
function trigger(target,key) {
  const depsMap = bucket.get(target)
  if ( !depsMap ) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  effects &&effects.forEach( fn => {
    if ( fn !== activeEffect ) {
      effectsToRun.add(fn)
    }
  })
  effectsToRun.forEach( effectFn => {
    if ( effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    }else {
      effectFn()
    }
  })
}

添加好后,要实现上上述的需求,就可以直接在scheduler函数中将fn放到一个宏任务队列中执行

effect(()=>{/*...*/},{
  scheduler(fn){
    setTimeOut(fn)
  }
})
次数
const data = { foo: 1}
const obj = new Proxy(/*...*/)
effect(()=> cosnole.log(obj.foo))
obj.foo++
obj.foo++

在上述代码中,输入的值分别是1、2、3,显然,2是一个过渡状态,我们关心的是最终结果(3),而不包含过渡状态,基于调度器,可以比较容易实现这个功能。

const jobQueue = new Set()
const p = Promise.resolve()
let isFlushing = false
function flushJOb(){
  if ( isFlushing ) return
  isFlushing = true
  p.then(()=>{
    jobQueue.forEach(job => job())
  }).finally(()=>{
    isFlushing = false
  })
}
​
effect(()=>{/*...*/},{
  scheduler(fn){
    jobQueue(fn)//每次trigger加入一个effect函数到微任务队列中
    flushJob()
  }
})
​
obj.num++
obj.num++
6.计算属性和lazy

在某些场景下,我们不希望effect函数立即执行,而是在需要的时候才执行。就比如计算属性,在依赖更新的时候才执行,此时,我们可以通过在options中添加lazy,当lazy为true时,就不立即执行effect函数。

function effect(fn,options={}){
  const effectFn = () => {
    cleanUp(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length-1]
  }
  effectFn.deps = []
  effectFn.options = options
  //只有lazy:flase 才执行
  if( !options.lazy ) {
      effectFn()
  }
}

那当lazy为true时,什么时候执行effect函数呢?显然,以上代码无法执行effectFn函数,因此需要将effectFn函数返回,去手动调用副作用执行函数。

并且计算属性我们是想要获取一个返回值的,而当前的effect函数显然不能满足这个要求,因此我们需要做一些修改,返回用户传入getter函数的返回值。

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.deps = []
  effectFn.options = options
  if (!options.lazy) {
    effectFn()
  }
  return effectFn;//当调用effect时能拿到对应的effectFn
}
​
function computed(getter){
  const effectFn = effect(getter,{
    lazy:true
  })
  const computedObj = {
    get value(){
      return effectFn()
    }
  }
  return computedObj
}

以上代码只做到了在读取时计算,并没有做到真正的缓存值,每次访问都会导致effectFn重新计算。

为了实现对值的缓存功能,我们添加一个val变量,用来缓存上一次计算得到的值,并增加一个dirty标志,用来表示是否需要重新计算值,默认dirty为true,当计算一次后就将dirty设置为false,在trigger中dirty又重新设置为true。

function computed(getter){
  let val
  let dirty = true
  const effectFn = effect(getter,{
    lazy:true,
    scheduler(){
      dirty = true
    }
  })
  const computedObj = {
    get value(){
      if ( dirty ) {
        val =  effectFn()
        dirty = false
      }
      return val
    }
  }
  return computedObj
}

以上的代码已经实现computed的功能,但还有一个缺陷,就是当在另一个effect中读取计算属性的值时,修改依赖,并不会触发副作用函数的渲染。

effect(()=>{
  computed(()=>obj.foo+obj.bar)
})

究其原因,对于计算属性的getter函数(effectFn)来说,它里面访问响应式数据computed内部的effect收集为依赖,而当把计算属性用于另一个effect时,就会发生effect嵌套,外层的effect不会被内层effect中的响应式数据收集。解决的办法是手动调用trac函数和trigger函数进行追踪和触发。

function computed(getter){
  let val
  let dirty = true
  const effectFn = effect(getter,{
    lazy:true,
    scheduler(){
      dirty = true
      trigger(computedObj,'value')
    }
  })
  const computedObj = {
    get value(){
      if ( dirty ) {
        val =  effectFn()
        dirty = false
      }
      track(computedObj,'value')
      return val
    }
  }
  return computedObj
}
7. watch实现原理

watch就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数,实现本质是利用了effect以及options.scheduler选项。

function watch(source,cb) {
  effect(()=>source.foo,{
    scheduler(cb){
      cb()
    }
  })
}

上面的代码中硬编码了source.foo,为了使得watch具有通用性,需要封装一个读取操作。

watch除了可以观测响应式数据,还可以接受一个getter函数,在getter函数内部,用户可以指定watch依赖哪些响应式数据。

watch的回调函数中可以获取新值和旧值,我们可以利用lazy选项创建一个懒执行effect,最开始手动调用effectFn函数的到一个旧值,并在之后scheduler函数执行时替换旧值。

function watch(source,cb){
  let getter
  if ( typeof source !== 'function') {
    getter = () => traverse(source)
  }else {
    getter = source
  }
  let oldV,newV
  const effectFn = effect(() => getter(),{
    lazy:true,
    scheduler(){
      newV = effectFn()
      cb(newV,oldV)
      oldV = newV
    }
  })
  oldV = effectFn()
}
​
//递归读取对象中的值
function traverse(value,seen = new Set()) {
  if ( typeof value !== 'object' || value === null || seen.has(value)) return
  seen.add(value)
  //{a:{b:{,d:{}}}}
  for (const key in value) {
    traverse(value[key],seen)
  }
  return value
}

watch有两个特性:一是立即执行的回调函数,二是回调函数执行的时机。

立即执行的和后续执行没有太大的差别,我们可以为watch传入一个immediate参数控制是否立即执行,同时把scheduler调度函数封装为一个函数。

function watch(source,cb,options={}){
  let getter
  if ( typeof source !== 'function') {
    getter = () => traverse(source)
  }else {
    getter = source
  }
  let oldV,newV 
  const job = () => {
    newV = effectFn()
    cb(newV,oldV)
    oldV = newV
  }
  
  const effectFn = effect(() => getter(),{
    lazy:true,
    scheduler:job
    //vue中通过flush来执行调度函数执行的时机
    // scheduler: () => {
    //   if (options.flush === 'post') {
    //     const p = Promise.resolve()
    //     p.then(job)
    //   }else {
    //     job()
    //   }
    // }
  })
  if ( options.imediate ) {
    job()
  }else {
    oldV = effectFn() 
  }
}

当副作用函数为异步函数时,连续多次修改obj则可能会发生竞态问题,即第二次的结果先于第一次返回,最终则显示为第一次(过期)的结果,因此,我们需要一个让副作用过期的手段。在watch内部每次检测到变更后,副作用函数执行之前,先调用我们通过onInvalidate函数注册的过期回调。

function watch(source,cb){
  let getter
  if ( typeof source !== 'function') {
    getter = () => traverse(source)
  }else {
    getter = source
  }
​
  let oldV,newV
​
  let cleanUp
​
  function onInvalidate(fn){
    cleanUp = fn
  }
    
  const job = () => {
    newV = effectFn()
    if ( cleanUp ) {
      cleanUp()
    }
    cb(newV,oldV,onInvalidate)
    oldV = newV
  }
  const effectFn = effect(() => getter(),{
    lazy:true,
    scheduler: () => {
      if (options.flush === 'post') {
        const p = Promise.resolve()
        p.then(job)
      }else {
        job()
      }
    }
  })
  
  if ( options.imediate ) {
    job()
  }else {
    oldV = effectFn() 
  }
}
​
watch(obj,async(newv,oldv,onInvalidate) => {
  let expired = false
​
  onInvalidate(()=>{
    expired = true
  })
​
  const res = await fetch('')
​
  if (!expired) finalData = res
})