Vue.js设计于实现 - 响应式(三)

发布于:2025-08-11 ⋅ 阅读:(20) ⋅ 点赞:(0)

副作用函数和响应式数据

  • 副作用函数

再次说明一下,主要读取或修改了公共变量的都是副作用函数
例如

function effect() {
  document.body.innerText = '123'
}
let val = 1
function effect2() {
  val = 2
}
  • 响应式数据

如果在一个副作用函数中读取或修改了某个对象的属性值,那么我们希望当值变化后,副作用函数自动重新执行,实现该目标,这个对象就是响应式对象

响应式数据基本实现

拦截一个对象的读取和设置操作,当读取字段时,将副作用函数存入一个“桶”中,当这个字段改变时,再执行副作用函数

// 存储副作用函数的桶
const bucket = new Set()
const data = {
    text: 'hello world'
}
const obj = new Proxy(data, {
    get(target, key) {
        // 收集依赖
        bucket.add(effect)
        // 返回属性值
        return target[key]
    },
    set(target, key, value) {
        target[key] = value
        // 触发副作用函数
        bucket.forEach(fn => fn())
        // 返回 true 表示设置成功
        return true
    }
})
  • 实现注册副作用函数

上面的内容只是一个最小型的响应式结构,实际上我们的副作用函数不会都叫effect,那么如何实现不论如何命名函数都能收集依赖呢,这里采用一个全局变量activeEffect来保存

let activeEffect
function effect (fn) {
  activeEffect = fn
  fn()
}
// 我们执行自己的函数时,使用effect调用
const changeBody = () => {
  document.body.innerText = obj.text
}
effect(changeBody)

那么getter的代码就要改为

get(target, key) {
    // 收集依赖
    if(activeEffect) {
      bucket.add(activeEffect)
    }
    // 返回属性值
    return target[key]
},
  • 实现副作用函数与对象的具体属性关联

在上面的内容中,当我们修改obj中的其他属性,并且该属性不在副作用函数中时,副作用函数依然会执行,因此要重新设计“桶”结构,添加映射关系

			-- 键 -- [函数]
对象   -- 键 -- [函数]
			-- 键 -- [函数]
			
weakMap: {
	obj1: Map1,
	obj2: Map2
}
Map1: {
	key1: [ fn1, fn2 ],
	key2: [ fn1 ]
}
const data = { text: 'hello world' }

// 临时保存副作用函数
let activeEffect
function effect (fn) {
  activeEffect = fn
  fn()
}
// 存放依赖的桶
const bucket = new WeakMap()
const obj = new Proxy(data, {
  get (target, key) {
    // 触发依赖收集
    track(target, key)
    return target[key]
  },
  set (target, key, value) {
    target[key] = value
    // 触发副作用函数
    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()))
  }
  deps.add(activeEffect)
}
function trigger (target, key) {
  // 获取obj[key]执行副作用函数
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

这里使用weakMap不使用Map的原因是,weakMap对key是弱引用,一旦key被垃圾回收,则对应的键和值就访问不到了,避免内存溢出

  • 实现cleanup

如果副作用函数是一个三元表达式

function fn () {
	obj.ok ? obj.text : ''
}

那么当ok为false时,为了避免不必要的更新,我们需要清除obj.text的依赖收集,变为true时再重新依赖

解决方法为,在每次副作用函数执行前,将其从相关联的依赖集合中移除,后续执行过程中不读取该属性就不会进行收集

我们要重新设计副作用函数effect,在内部定义了新的effectFn函数,并为其添加deps属性,用来存储当前副作用函数的依赖集合

let activeEffect
function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn
    fn()
  }
  // 用来存储当前副作用函数的依赖集合
  effectFn.deps = []
  effectFn()
}

在track中收集副作用函数对应的键 对应的函数集合

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()))
  }
  deps.add(activeEffect)
  // 将对应键的对应函数集合存入副作用函数的deps数组中
  activeEffect.deps.push(deps)
}

这样在副作用函数执行前遍历deps,删除其中包含自身的,即可实现移除

function cleanup(effectFn) {
  for(let i = 0; i < effectFn.deps.length; i++) {
    // deps为Set集合
    const deps = effectFn.deps[i]
    // 删除自身
    deps.delete(effectFn)
  }
  // 重置effectFn.deps数组
  effectFn.deps.length = 0
}
let activeEffect
function effect(fn) {
  const effectFn = () => {
    // 调用清除
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  // 用来存储当前副作用函数的依赖集合
  effectFn.deps = []
  effectFn()
}

此时运行代码会无限循环,原因是trigger使用了forEach遍历,在语言规范中,当forEach遍历Set集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么该值会被重新访问
即这个代码会无限循环

const set = new Set([1])
set.forEach(item => {
  set.delete(1)
  set.add(1)
  console.log('这段代码会无限循环')
})

因此要修改trigger

function trigger(target, key) {
  const depsMap = buckect.get(target)
  if(!depsMap) return
  const effects = depsMap.get(key)
  // 创建中间变量用于执行
  const effectToRun = new Set(effects) 
  effectToRun.forEach(effectFn => effectFn())
}

目前的代码为

const data = { text: 'hello world' }

// 清除副作用函数
function cleanup(effectFn){
  for(let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]
    // 删除掉所有Set中的自身
    deps.delete(effectFn)
  }
  // 清空副作用函数的依赖集合
  effectFn.deps.length = 0
}

// 临时保存副作用函数
let activeEffect
function effect (fn) {
  const effectFn = () => {
    // 执行副作用函数前删除集合中的自身
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  // 存储当前副作用函数依赖集合
  effectFn.deps = []
  effectFn()
}
// 存放依赖的桶
const bucket = new WeakMap()
const obj = new Proxy(data, {
  get (target, key) {
    // 触发依赖收集
    track(target, key)
    return target[key]
  },
  set (target, key, value) {
    target[key] = value
    // 触发副作用函数
    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()))
  }
  deps.add(activeEffect)
  // 将当前副作用函数存入依赖集合
  activeEffect.deps.push(deps)
}
function trigger (target, key) {
  // 获取obj[key]执行副作用函数
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  // 创建中间变量避免无限循环
  const effectsToRun = new Set(effects)
  effectsToRun.forEach(effectFn => effectFn())
  // effects && effects.forEach(fn => fn())
}

嵌套的effect

为什么需要支持effect嵌套
组件的render函数其实就是在effect中执行的

// Foo组件
const Foo = {
    render() {
        retunr /*...*/
    }
}
effect(() => {
    Foo.render()
})

那么当组件嵌套时,就会产生effect嵌套

我们之前的代码使用全局变量activeEffect来存储副作用函数,当遇到嵌套的effect时,外层副作用函数会被内层覆盖

为了解决这个问题,我们新增一个effectStack副作用函数栈,在副作用函数执行时入栈,执行完毕后再弹出,并且activeEffect永远指向栈顶(即新推入栈的函数)从而避免相互影响

let activeEffect
const effectStack = [] // 栈
function effect (fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    // 执行前入栈
    effectStack.push(effectFn)
    fn()
    // 执行完毕后出栈
    effectStack.pop()
    // 指向当前栈顶
    activeEffect = effectStackp[effectStack.length - 1]
  }
  effectFn.deps = []
  effectFn()
}

避免无限递归

当我们的副作用函数为

effect(() => {
  obj.foo = obj.foo + 1
})

此时我们先读取obj.foo,触发track,加1后赋值触发trigger,触发后又要更新执行该函数,于是会无限递归调用自己

所以要在trigger触发执行副作用函数前判断和当前正在执行的副作用函数是否相同,相同则不执行

function trigger (target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  // 判断和当前正在执行的副作用函数是否相同,不相同的才添加到effectsToRun
  effects && effects.forEach(effectFn => {
      if(effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
      }
  })
  effectsToRun.forEach(effectFn => effectFn())
}

调度执行

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

  • 例如
const data = { foo: 1 }
const obj = new Proxy(data, {/**/})
effect(() => {
    console.log(obj.foo)
})
obj.foo++
console.log('finish')
// 执行结果为 1 2 finish

如果用户想要改变执行顺序,比如实现执行结果为1 finish 2,就只能修改代码顺序,但是我们想在不调整代码的前提下实现需求

我们给effect新增一个参数options

function effect (fn, options = {}) {
  const effectFn = () => {
	//...
  }
  effectFn.options = options
  //...
}

effect(
  () => {
    console.log(obj.foo)
  },
  // options     
  {
    // 调度器scheduler是一个函数
    scheduler(fn) {
      // 比如此处我像将副作用函数放到宏任务队列执行
      setTimeout(fn)
    }
  }
)

trigger中触发

function trigger (target, key) {
  // ...
  effectsToRun.forEach(effectFn => {
    // 副作用函数存在调度器,则将副作用函数放在调度器中执行
    if(effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

此时就实现了我们最开始的需求,执行结果为1 finish 2

  • 再比如
effect(() => {
  console.log(obj.foo)
})
obj.foo++
obj.foo++

正常执行会输出1 2 3,但我们不关心过程,只想看到最终结果1 3,即连续触发多次响应式但只执行一次更新

也可以通过调度器实现

// 定义一个任务队列
const jobQueue = new Set()
// 创建一个Promise实例,用他将一个任务添加到微任务队列
const p = Promise.resolve()
// 当前数据是否正在刷新队列
let isFlushing = false
function flushJob() {
  if(isFlushing) return
  isFlushing = true
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    // 结束后重置
    isFlushing = false
  })
}

effect(() => {
    console.log(obj.foo)
  },
  {
    scheduler(fn) {
      // 每次调度时,将副作用函数添加到队列
      jobQueue.add(fn)
      // 调用刷新队列
      flushJob()
    }
  }
)

连续对obj.foo进行两次自增,同步执行两次scheduler调度,jobQueue中添加了两次fn,但是由于jobQueueSet,所以还是只有一项;flushJob也会同步执行两次,但是由于isFlushing标志位存在,flushJob实际在一个事件循环中只执行了一次,当微任务队列开始执行时,遍历jobQueue并执行里面的副作用函数,所以只会执行一次,并且由于是在微任务队列才开始执行,此时obj.foo已经变为了3