03_Vue3设计与实现之响应系统——其他特性

242 阅读10分钟

书接上篇,实现了响应系统之后,接下来学习写与响应系统相关的特性:调度执行计算属性watch

调度执行

1. 什么是调度执行

  • 调度执行实质上就是把代码的控制权交给用户,控制trigger触发副作用函数执行的时机及次数,看下面一段代码,通过之前实现的响应系统,应该打印出来什么内容?
const data = { foo: 1 }
const obj = new Proxy(data, handler)
effect(() => {
  console.log(obj.foo)
})
obj.foo++
console.log('结束了')

// 打印结果
// 1
// 2
// 结束了
  • 那我们想要实现延迟执行副作用函数应该怎么做呢,也就是上述代码实现先通过get收集依赖,set后面的代码按顺序执行,最终再去执行收集的副作用函数,使上面的代码打印出1,2,结束了

2. 如何实现调度执行

  • 这就需要通过调度器scheduler来实现,调度器实际上就是一个函数,通过先执行调度器函数并且把副作用函数作为参数暴露出去,从而把控制权交给用户
  • 实现:给effect函数增加一个options,options中支持传递scheduler,trigger执行副作用函数之前先判断是否存在scheduler,存在就执行scheduler并把副作用函数传递出去
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.options = options // 新增 给副作用函数添加options
  effectFn.deps = []
  effectFn()
}

const trigger = (target, key) => {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  if (!effects) return
  const effectsToRun = new Set()
  effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn) // 新增 如果存在scheduler就执行他并把副作用函数暴露给用户
    } else {
      effectFn()
    }
  })
}

const data = { foo: 1 }
const obj = new Proxy(data, handler)
effect(
  () => {
    console.log(obj.foo)
  },
  {
    scheduler(fn) {
      setTimeout(fn)
    }
  }
)
obj.foo++
console.log('结束了')

3. 跳过过渡状态

  • 多次修改同一个属性时不用关心中间某一次的结果,只需要最终的结果即可
  • 即上述代码执行两次obj.foo++跳过第一次执行,只打印出初始状态1和最终状态3
  • 实现
    • 创建一个待执行任务队列jobQueue、一个是否正在执行的标志isFlushing
    • 调用effect时会先执行一遍副作用函数,打印出1
    • 修改obj.foo时,会执行trigger调度器函数,把副作用函数压入任务队列中,再去刷新任务队列
const jobQueue = new Set() // 待执行任务队列
const isFlushing = false // 是否正在执行
const p = Promise.resolve() // 一个微任务
function flushJob() {
  if (isFlushing) return
  isFlushing = true
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finnaly(() => {
    isFlushing = false
  })
}

const data = { foo: 1 }
const obj = new Proxy(data, handler)
effect(
  () => {
    console.log(obj.foo)
  },
  {
    scheduler(fn) {
      jobQueue.add(fn) // 新增 当副作用函数增加到任务队列中
      flushJob() // 新增 刷新任务队列
    }
  }
)
obj.foo++
obj.foo++

// 打印结果
// 1
// 3
  • 这里有个问题,第一次刷新任务栈的时候isFlushing为false,但是却没有执行p.then,这是因为p.then属于微任务,而当前执行栈中正在执行flushJob,先执行完执行栈的内容才会将微任务队列中最后一个任务拿出来放到执行栈中执行,即副作用函数在一个事件循环内只会执行一次

计算属性

1. lazy执行的副作用函数

  • 通过options传递一个lazy参数,只有非lazy的副作用才执行,同时返回副作用函数,这样就可以实现手动执行副作用函数
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.options = options
  effectFn.deps = []
  if(!options.lazy) { // 新增 非lazy才执行
    effectFn()
  }
  return effectFn // 新增 返回副作用函数
}

const data = { foo: 1 }
const obj = new Proxy(data, handler)
const effectFn = effect(
  () => {
    console.log(obj.foo)
  },
  {
    lazy: true // 新增 表示不用执行副作用函数
  }
)
effectFn()

2. 计算属性的get

  • 先来看vue3中副作用函数的使用方式,computed接受一个function,不指定get和set方法时默认为get,也就是只读模式
const data = { foo: 1, bar: 1 }
const obj = new Proxy(data, handler)

// 1. 只读 只传递getter
computed(() => { return obj.foo + obj.bar })
// 2. 可写 传递getter+setter
computed(() => {
  get() {
    return obj.foo + 1
  },
  set(val) {
    obj.foo = val - 1
  }
})
  • 如何通过上面lazy执行的副作用函数来实现计算属性呢?
    • 首先需要得到副作用函数执行的结果,上一步得到的只是副作用函数,并没有得到副作用执行后的结果,实际上fn才是真正的副作用函数
    • 实现一个computed需要接受一个getter,先将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 // 新增 将副作用函数执行后得到的结果return出去
  }
  effectFn.options = options
  effectFn.deps = []
  if(!options.lazy) {
    effectFn()
  }
  return effectFn
}

const computed = function(getter) {
  const effectFn = effect(getter, { lazy: true })
  const obj = {
    get value() {
      return effectFn()
    }
  }
  return obj
}

const data = { foo: 1 }
const obj = new Proxy(data, handler)
const fooRes = computed(() => obj.foo + 1)
obj.foo++
console.log(fooRes.value);

3. 计算属性缓存

  • 上述代码已经实现了基本的计算属性,但是计算属性还有个非常重要的特点:结果缓存,即依赖的值不变时不去重新执行副作用函数
  • 新增value用来保存上次计算得到的结果,dirty用来判断是否需要重新计算,这里通过闭包实现缓存
    • effectFn是一个函数,它在调用effect(getter, {...})时创建。该函数会记住创建它时的词法作用域,其中包含局部变量value和dirty
    • obj对象的get value()访问器方法也是一个闭包。它在定义时捕获了value和dirty变量,并在obj对象返回给调用者之后,仍然可以访问和操作这些变量
    • effectFnget value()方法都在 computed 函数内部定义,并在computed函数返回后继续存在。由于 JavaScript 的闭包特性,这些函数能够记住并访问computed函数的局部变量value和dirty,即使computed函数已经执行完毕
  • 还存在一个问题:下面代码三次均打印出来的是2,在依赖的值修改时应该去重新获取计算属性的值,也就是dirty需要变为true,通过scheduler来实现每次值修改dirty为true,从而执行副作用函数
const computed = function(getter) {
  let value // 新增 缓存结果
  let dirty = true // 新增 是否需要重新计算
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true // 新增 依赖值修改重置dirty为true
    }
  })
  const obj = {
    get value() {
      if (dirty) { // 新增 需要重新计算把此次计算得到的值缓存起来
        value = effectFn()
        dirty = false
      }
      return value
    }
  }
  return obj
}

const data = { foo: 1 }
const obj = new Proxy(data, handler)
const fooRes = computed(() => obj.foo + 1)
console.log(fooRes.value)
console.log(fooRes.value)
obj.foo++
console.log(fooRes.value)

4. 计算属性的effect嵌套

  • 再来看一个场景,计算属性有懒执行的effect,只有读取计算属性时才执行计算属性的effect,计算属性依赖的响应式数据只会把computed内部的effect收集为依赖。而当把计算属性用于另外一个effect时,就会发生effect嵌套,外层的effect不会被内层effect中的响应式数据收集
  • bucke中此时只收集了computed内部的effect,外部的effect没有被收集到,所以修改计算属性依赖的响应式数据不会执行外部的effect

image.png

const data = { foo: 1 }
const obj = new Proxy(data, handler)
const fooRes = computed(() => obj.foo + 1) // 内层的effect
effect(() => { // 外层的effect
  console.log(fooRes.value)
})

obj.foo++

// 打印结果
// 2
  • 解决方法:当读取计算属性的值时,手动调用track函数进行追踪,当计算属性依赖的响应式数据发生变化时,手动调用trigger函数触发响应
  • bucket中收集了两个依赖,一个是computed内部的effect,另一个是在读取computed值的时候所收集的计算属性返回以obj作为target,以value作为键值所收集的effect

image.png

const computed = function(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
      trigger(obj, 'value') // 新增 设置值的时候手动触发trigger
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      track(obj, 'value') // 新增 读取值的时候手动触发track
      return value
    }
  }
  return obj
}

// 打印结果
// 2
// 3

5. 计算属性的set

  • 上面代码已经能够实现get操作,接下来实现下set操作,set操作要实现的是修改计算属性的值,计算属性所依赖的响应式数据也随之修改
  • 修改computed函数的参数,使其接受一个options
    • 如果options是个函数就默认只传入了get
    • 如果options是个对象,则表明传入的是get和set
    • 执行set时把新值传递出去,修改响应式数据的值
const computed = function(options) {
  let value
  let dirty = true

  let getter, setter

  if (typeof options === 'function') {
    getter = options
    setter = undefined
  } else if (typeof options === 'object' && options !== null) {
    getter = options.get
    setter = options.set
  }

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
    }
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    },
    set value(newValue) {
      if (setter) {
        setter(newValue)
      }
    }
  }

  return obj
}

// 示例用法
const obj = { foo: 1 }

const fooRes = computed({
  get() {
    return obj.foo + 1
  },
  set(val) {
    obj.foo = val - 1
  }
})

console.log(fooRes.value) // 输出 2
obj.foo = 2
console.log(fooRes.value) // 输出 3
fooRes.value = 5
console.log(obj.foo) // 输出 4

watch

1. watch与effect的关系

  • watch的用法
    • 参数一
      • ref
      • 响应式对象
      • getter函数
      • 多个数据源组成的数组
    • 参数二
      • 回调函数接受newVal(修改后的值),oldVal(修改前的值)
    • 参数三
      • 配置包含deep,immediate,once
// 单个 ref
watch(x, (newX) => { console.log(`x is ${newX}`) })
// getter 函数
watch( () => x.value + y.value, (sum) => { console.log(`sum of x + y is: ${sum}`) } )
// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => { console.log(`x is ${newX} and y is ${newY}`) })
  • watch实际上就是利用了effect的scheduler,当响应式数据修改时会先触发scheduler,scheduler中先执行watch传入的回调函数
// source.foo修改就先执行watch的回调函数
const watch = (source, cb) => {
  effect(
    () => source.foo,
    {
    scheduler() {
      cb()
    }
  })
}

2. watch实现

  • 首先要做的是收集依赖,封装了traverse函数用来
  • 根据watch第一个参数的类型,按类型来分别设置副作用函数
  • 回调函数接受新值和旧值,这里通过之前的lazy来实现,先手动调用副作用函数得到旧值,再在响应式数据发生变化时的scheduler中调用副作用函数得到新值,最后赋值完成变更
const traverse = (value, seen = new Set()) => {
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  seen.add(value)
  for (const key in value) {
    traverse(value[key], seen)
  }
  return value
}

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

  let oldValue, newValue
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler() {
        newValue = effectFn()
        cb(newValue, oldValue)
        oldValue = newValue
      }
    }
  )
  oldValue = effectFn()
}

const data = { foo: 1 }
const obj = new Proxy(data, handler)

watch(() => obj.foo,
  function (newVal, oldVal) {
    console.log(newVal, oldVal);
  }
)
obj.foo++

3. 立即执行的watch

  • immediate:是否需要立即执行,默认情况下只有响应式数据发生变化时才会执行watch,传递immediate后,会在收集依赖时就执行一遍副作用函数
  • 实现:将之前的scheduler抽离出来,传递一个额外的参数options,需要立即执行时传递immediate为true,有immediate时就执行一次收集依赖,没有就赋初始值给旧值
const watch = (source, cb, options = {}) => {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  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()
  }
}
  • flush:指定调用时机
    • pre:默认值
    • post:异步延迟执行
    • sync:同步执行
  • 实现:先来看post的实现方式,创建一个微任务,利用事件循环将此次watch回调函数放到DOM更新之后执行
const watch = (source, cb, options = {}) => {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    getter = () => traverse(source)
  }

  let oldValue, newValue
  const job = () => {
    newValue = effectFn()
    cb(newValue, oldValue)
    oldValue = newValue
  }
  const effectFn = effect(
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        if (options.flush === 'post') { // post异步执行
          const p = Promise.resolve()
          p.then(job)
        } else {
          job() // sync同步执行
        }
      }
    }
  )
  if (options.immediate) {
    job()
  } else {
    oldValue = effectFn()
  }
}