{T}

手写响应式

手写一个迷你的 Vue 框架,实现 Vue3 的主要渲染和更新逻辑

响应式

Vue3 的组件之间是通过响应式机制来通知的,响应式机制可以自动收集系统中数据的依赖,并且在修改数据之后自动执行更新,极大提高开发的效率

img

响应式机制的主要功能就是,把普通的 JavaScript 对象封装成为响应式对象,拦截数据的获取和修改操作,实现依赖数据的自动化更新

最简单的响应式模型可以通过 reactive 或者 ref 函数,把数据包裹成响应式对象,并且通过 effect 函数注册回调函数,然后在数据修改之后,响应式地通知 effect 去执行回调函数即可

Vue 的响应式是可以独立在其他平台使用的。比如可以新建 test.js,使用下面的代码在 node 环境中使用 Vue 响应。以 reactive 为例,使用 reactive 包裹 JavaScript 对象之后,每一次对响应式对象 counter 的修改,都会执行 effect 内部注册的函数

javascript
const {effect, reactive} = require('@vue/reactivity')

let dummy
const counter = reactive({ num1: 1, num2: 2 })

effect(() => {
  dummy = counter.num1 + counter.num2
  console.log(dummy)// 每次 counter.num1 修改都会打印日志
})

setInterval(()=>{
  counter.num1++
},1000)

使用 reactive 把普通的 JavaScript 对象包裹成响应式数据了。在 effect 中获取 counter.num1counter.num2 的时候,就会触发 counter 的 get 拦截函数;get 函数,会把当前的 effect 函数注册到一个全局的依赖地图中去。这样 counter.num1 在修改的时候,就会触发 set 拦截函数,去依赖地图中找到注册的 effect 函数,然后执行。

img

reactive

进入 src/reactivity 目录中新建 reactive.spec.js,测试 reactive 的功能,能够在响应式数据 ret 更新之后,执行 effect 中注册的函数:

javascript
import { effect } from '../effect'
import { reactive } from '../reactive'

describe('测试响应式', () => {
  test('reactive 基本使用', () => {
    const ret = reactive({ num: 0 })
    let val
    effect(() => {
      val = ret.num
    })
    expect(val).toBe(0)
    ret.num++
    expect(val).toBe(1)
    ret.num = 10
    expect(val).toBe(10)
  })
})

reactive 是通过 ES6 中的 Proxy 特性实现的属性拦截,所以在 reactive 函数中我们直接返回 newProxy 即可:

javascript
export function reactive(target) {
  if (typeof target!=='object') {
    console.warn(`reactive  ${target} 必须是一个对象`);
    return target
  }

  return new Proxy(target, mutableHandlers);
}

下一步我们需要实现的就是 Proxy 中的处理方法 mutableHandles

这里会把 Proxy 的代理配置抽离出来单独维护,是因为,其实 Vue3 中除了 reactive 还有很多别的函数需要实现,比如只读的响应式数据、浅层代理的响应式数据等,并且 reactive 中针对 ES6 的代理也需要单独的处理。这里我们只处理 js 中对象的代理设置

javascript
const proxy = new Proxy(target, mutableHandlers)

mutableHandles

mutableHandles 要做的事就是配置 Proxy 的拦截函数,这里我们只拦截 get 和 set 操作,进入到 baseHandlers.js 文件中。使用 createGetter 和 createSetters 来创建 set 和 get 函数,mutableHandles 就是配置了 set 和 get 的对象返回

  • get 中直接返回读取的数据,这里的 Reflect.get 和 target[key] 实现的结果是一致的;并且返回值是对象的话,还会嵌套执行 reactive,并且调用 track 函数收集依赖
  • set 中调用 trigger 函数,执行 track 收集的依赖
javascript
const get = createGetter();
const set = createSetter();

function createGetter(shallow = false) {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    track(target, "get", key)
    if (isObject(res)) {
      // 值也是对象的话,需要嵌套调用reactive
      // res就是target[key]
      // 浅层代理,不需要嵌套
      return shallow ? res : reactive(res)
    }
    return res
  }
}

function createSetter() {
  return function set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    // 在触发 set 的时候进行触发依赖
    trigger(target, "set", key)
    return result
  }
}

export const mutableHandles = {
  get,
  set,
};

先看 get 的关键部分,track 函数是怎么完成依赖收集的

track

具体写代码之前,把依赖收集和执行的原理我们梳理清楚,看下面的示意图:

img

在 track 函数中,我们可以使用一个巨大的 tragetMap 去存储依赖关系。map 的 key 是我们要代理的 target 对象,值还是一个 depsMap,存储这每一个 key 依赖的函数,每一个 key 都可以依赖多个 effect。上面的代码执行完成,depsMap 中就有了 num1 和 num2 两个依赖。

而依赖地图的格式,用代码描述如下:

javascript
targetMap = {
  target: {
  key1: [回调函数1,回调函数2],
    key2: [回调函数3,回调函数4],
    },
    target1: {
    key3: [回调函数5]
  }  
}

好,有了大的设计思路,我们来进行具体的实现,在 reactive 下新建 effect.js。

由于 target 是对象,所以必须得用 map 才可以把 target 作为 key 来管理数据,每次操作之前需要做非空的判断。最终把 activeEffect 存储在集合之中:

javascript
const targetMap = new WeakMap()

export function track(target, type, key) {

  // console.log(`触发 track -> target: ${target} type:${type} key:${key}`)

  // 1. 先基于 target 找到对应的 dep
  // 如果是第一次的话,那么就需要初始化
  // {
  //   target1: {//depsmap
  //     key:[effect1,effect2]
  //   }
  // }
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    // 初始化 depsMap 的逻辑
    // depsMap = new Map()
    // targetMap.set(target, depsMap)
    // 上面两行可以简写成下面的
    targetMap.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
  }
  if (!deps.has(activeEffect) && activeEffect) {
    // 防止重复注册
    deps.add(activeEffect)
  }
  depsMap.set(key, deps)
}

get 中关键的收集依赖的 track 函数我们已经讲完了,继续看 set 中关键的 trigger 函数。

trigger

有了上面 targetMap 的实现机制,trigger 函数实现的思路就是从 targetMap 中,根据 target 和 key 找到对应的依赖函数集合 deps,然后遍历 deps 执行依赖函数

javascript
export function trigger(target, type, key) {
  // console.log(`触发 trigger -> target:  type:${type} key:${key}`)
  // 从targetMap中找到触发的函数,执行他
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // 没找到依赖
    return
  }
  const deps = depsMap.get(key)
  if (!deps) {
    return
  }
  deps.forEach((effectFn) => {

    if (effectFn.scheduler) {
      effectFn.scheduler()
    } else {
      effectFn()
    }
  })
}

可以看到执行的是 effect 的 scheduler 或者 run 函数,这是因为我们需要在 effect 函数中把依赖函数进行包装,并对依赖函数的执行时机进行控制,这是一个小的设计点。

effect

然后我们来实现 effect 函数。

下面的代码中,我们把传递进来的 fn 函数通过 effectFn 函数包裹执行,在 effectFn 函数内部,把函数赋值给全局变量 activeEffect;然后执行 fn() 的时候,就会触发响应式对象的 get 函数,get 函数内部就会把 activeEffect 存储到依赖地图中,完成依赖的收集:

javascript
export function effect(fn, options = {}) {
  // effect嵌套,通过队列管理
  const effectFn = () => {
    try {
      activeEffect = effectFn
      //fn执行的时候,内部读取响应式数据的时候,就能在get配置里读取到activeEffect
      return fn()
    } finally {
      activeEffect = null
    }
  }
  if (!options.lazy) {
    //没有配置lazy 直接执行
    effectFn()
  }
  effectFn.scheduler = options.scheduler // 调度时机 watchEffect回用到
  return effectFn
  
}

effect 传递的函数,比如可以通过传递 lazy 和 scheduler 来控制函数执行的时机,默认是同步执行。

scheduler 存在的意义就是我们可以手动控制函数执行的时机,方便应对一些性能优化的场景,比如数据在一次交互中可能会被修改很多次,我们不想每次修改都重新执行依次 effect 函数,而是合并最终的状态之后,最后统一修改一次。

scheduler 怎么用你可以看下面的代码,我们使用数组管理传递的执行任务,最后使用 Promise.resolve 只执行最后一次,这也是 Vue 中 watchEffect 函数的大致原理。

javascript
const obj = reactive({ count: 1 })
effect(() => {
  console.log(obj.count)
}, {
  // 指定调度器为 queueJob
  scheduler: queueJob
})
// 调度器实现
const queue: Function[] = []
let isFlushing = false
function queueJob(job: () => void) {
  if (!isFlushing) {
    isFlushing = true
    Promise.resolve().then(() => {
      let fn
      while(fn = queue.shift()) {
        fn()
      }
    })
  }
}

好了,绕了这么一大圈终于执行完了函数,估计你也看出来了封装了很多层。

之所以封装这么多层就是因为,Vue 的响应式本身有很多的横向扩展,除了响应式的封装,还有只读的拦截、浅层数据的拦截等等,这样,响应式系统本身也变得更加灵活和易于扩展,我们自己在设计公用函数的时候也可以借鉴类似的思路。

ref 函数

有了 track 和 trigger 的逻辑之后,我们用 ref 函数实现就变得非常简单了。

ref 的执行逻辑要比 reactive 要简单一些,不需要使用 Proxy 代理语法,直接使用对象语法的 getter 和 setter 配置,监听 value 属性即可。

看下面的实现,在 ref 函数返回的对象中,对象的 get value 方法,使用 track 函数去收集依赖,set value 方法中使用 trigger 函数去触发函数的执行。

javascript
export function ref(val) {
  if (isRef(val)) {
    return val
  }
  return new RefImpl(val)
}
export function isRef(val) {
  return !!(val && val.__isRef)
}

// ref就是利用面向对象的getter和setters进行track和trigget
class RefImpl {
  constructor(val) {
    this.__isRef = true
    this._val = convert(val)
  }
  get value() {
    track(this, 'value')
    return this._val
  }

  set value(val) {
    if (val !== this._val) {
      this._val = convert(val)
      trigger(this, 'value')
    }
  }
}

// ref也可以支持复杂数据结构
function convert(val) {
  return isObject(val) ? reactive(val) : val
}

ref 函数实现的相对简单很多,只是利用面向对象的 getter 和 setter 拦截了 value 属性的读写,这也是为什么我们需要操作 ref 对象的 value 属性的原因。

值得一提的是,ref 也可以包裹复杂的数据结构,内部会直接调用 reactive 来实现,这也解决了大部分同学对 ref 和 reactive 使用时机的疑惑,现在你可以全部都用 ref 函数,ref 内部会帮你调用 reactive。

computed

Vue 中的 computed 计算属性也是一种特殊的 effect 函数,我们可以新建 computed.spec.js 来测试 computed 函数的功能,computed 可以传递一个函数或者对象,实现计算属性的读取和修改。比如说可以这么用:

javascript
import {  ref } from '../ref'
import {  reactive } from '../reactive'
import { computed } from '../computed'


describe('computed测试',()=>{
  it('computed基本使用',()=>{
    const ret = reactive({ count: 1 })
    const num = ref(2)
    const sum = computed(() => num.value + ret.count)
    expect(sum.value).toBe(3)

    ret.count++
    expect(sum.value).toBe(4)
    num.value = 10
    expect(sum.value).toBe(12)
  })
  it('computed属性修改',()=>{
    const author = ref('大圣')
    const course = ref('玩转Vue3')
    const title = computed({
      get(){
        return author.value+":"+course.value
      },
      set(val){
        [author.value,course.value] = val.split(':')
      }
    })
    expect(title.value).toBe('大圣:玩转Vue3')

    author.value="winter"
    course.value="重学前端"
    expect(title.value).toBe('winter:重学前端')
    //计算属性赋值
    title.value = '王争:数据结构与算法之美'
    expect(author.value).toBe('王争')
    expect(course.value).toBe('数据结构与算法之美')

  })
})

怎么实现呢?我们新建 computed 函数,看下面的代码,我们拦截 computed 的 value 属性,并且定制了 effect 的 lazy 和 scheduler 配置,computed 注册的函数就不会直接执行,而是要通过 scheduler 函数中对 _dirty 属性决定是否执行。

javascript
export function computed(getterOrOptions) {
  // getterOrOptions可以是函数,也可以是一个对象,支持get和set
  // 还记得清单应用里的全选checkbox就是一个对象配置的computed
  let getter, setter
  if (typeof getterOrOptions === 'function') {
    getter = getterOrOptions
    setter = () => {
      console.warn('计算属性不能修改')
    }
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  return new ComputedRefImpl(getter, setter)
}
class ComputedRefImpl {
  constructor(getter, setter) {
    this._setter = setter
    this._val = undefined
    this._dirty = true
    // computed就是一个特殊的effect,设置lazy和执行时机
    this.effect = effect(getter, {
      lazy: true,
      scheduler: () => {
        if (!this._dirty) {
          this._dirty = true
          trigger(this, 'value')
        }
      },
    })
  }
  get value() {
    track(this, 'value')
    if (this._dirty) {
      this._dirty = false
      this._val = this.effect()
    }
    return this._val
  }
  set value(val) {
    this._setter(val)
  }
}

总结

  • 响应式的主要功能就是可以把普通的 JavaScript 对象封装成为响应式对象,在读取数据的时候通过 track 收集函数的依赖关系,把整个对象和 effect 注册函数的依赖关系全部存储在一个依赖图中
  • 定义的 dependsMap 是一个巨大的 Map 数据,effect 函数内部读取的数据都会存储在 dependsMap 中,数据在修改的时候,通过查询 dependsMap,获得需要执行的函数,再去执行即可
  • dependsMap 中存储的也不是直接存储 effect 中传递的函数,而是包装了一层对象对这个函数的执行实际进行管理,内部可以通过 active 管理执行状态,还可以通过全局变量 shouldTrack 控制监听状态,并且执行的方式也是判断 scheduler 和 run 方法,实现了对性能的提升
  • 我们在日常项目开发中也可以借鉴响应式的处理思路,使用通知的机制,来调用具体数据的操作和更新逻辑,灵活使用 effect、ref、reactive 等函数把常见的操作全部变成响应式数据处理,会极大的提高我们开发的体验和效率