【从零实现Vue3】2、开始实现@vue/reactivity

542 阅读5分钟

本篇文章对应的提交:
提交链接:[feat]implement simple effect() and reactive() · smallnine9/mini-vue@9960307 (github.com)

  flowchart LR
    compiler-sfc["@vue/compiler-sfc"]
    compiler-dom["@vue/compiler-dom"]
    compiler-core["@vue/compiler-core"]
    vue["vue"]
    runtime-dom["@vue/runtime-dom"]
    runtime-core["@vue/runtime-core"]
    reactivity["@vue/reactivity"]

    subgraph "Runtime Packages"
      runtime-dom --> runtime-core
      runtime-core --> reactivity
    end

    subgraph "Compiler Packages"
      compiler-sfc --> compiler-core
      compiler-sfc --> compiler-dom
      compiler-dom --> compiler-core
    end

    vue ---> compiler-dom
    vue --> runtime-dom

上图是Vue官方给出的源码结构图,整个vue分成了两大部分,每个部分里面又各自分成了三个小部分。 在编译部分中,最核心的模块是compiler-core,实现了编译模块的最核心功能,complier-dom依赖于compiler-core,实现了将template解析成render函数的功能,compiler-sfc依赖于compiler-dom和compiler-core,实现了编译单文件组件的功能 在运行时部分中,最核心的代码便是reactivity,实现了最基本的响应式接口,runtime-core在reactivity的基础上,实现了Vue的核心逻辑:创建Vnode,收集依赖,渲染节点、更新节点等,runtime-dom实现了暴露给用户的接口,例如createApp函数

响应式框架,最核心的当然是实现一个响应式数据,我们从最简单的例子开始入手:

   const obj1 = {
        name: 'obj1'
   }

我们希望将上面这个对象改造成一个响应式的对象,当我们改变obj1.name的时候,会触发某些操作

过去,在Vue2中,使用的是Object.defineProperty这个方法,而在Vue3中,使用了Proxy这一特性 Vue3提供了reactive()这一方法,将一个对象转换成响应式对象

以TDD的角度去思考,我们首先要写一个怎样的测试?

   import { reactive } from '...'
   describe('reactive' () =>{
       it('happy path', () => {
           const obj1 = {
                name: 'obj1'
           }
           const obj1Reactive = reactive(obj1)
           expect(obj1Reactive).not.toBe(obj1)
           expect(obj1Reactive.name).toBe('obj1')
       })
   })
})

这个测试是一个happy path(不考虑特殊情况,只考虑最简单的场景)

那我们如何确定这是一个响应式对象呢?很简单,当我们改变obj1Reactive中的name属性时,会触发某个函数,那我们就能确定了。 所以,我们还需要提供一个注册副作用函数的方法,这个方法被称为effect,我们再来写effect的测试:

    import { effect, reactive } from '...'
    describe('effect' () => {
        it('happy path', () => {
           const obj1 = {
                name: 'obj1'
           }
           const obj1Reactive = reactive(obj1)
           let newName = ''
           effect(() => {
               newName = obj1Reactive.name
           })
           expect(newName).toBe('obj1')
           obj1Reactive.name = 'new ojb1'
           expect(newName).toBe('new obj1)
        })
    })

我们写好了测试,现在就要写逻辑代码了。

实现reactive函数,我们使用Proxy拦截的,是get 和 set这两个基本操作:

    function reactive(obj) {
        const objProxy = new Proxy(obj, {
            get (target, key) {
                ...
            }
            set (target, key , value) {
                ...
            }
        })
    }
    

get 和 set 是js语言本身的两个基本操作,我们使用Proxy,就相当于在这个基本操作的基础上,做一些我们自己想要的操作

那首先,我们要保证get 和 set 这两个基本操作原本的功能生效:

    get(target, key) {
        return Reflect.get(target, key)
    }
    
    set(target, key, value) {
        Reflect.set(target,key,value)
    }

这里我们使用了另一个js语言特性: Reflect(反射) 使用Reflect的理由,解释起来比较复杂,我们先从最简单的角度来说明:通过Reflect,我们可以不关心set或get的基本操作是如何实现的,只需要简单地调用Reflect[基本操作名] 就可以简单地复原基本操作的功能

走到这一步,我们现在需要考虑的是如何实现响应式操作了

实现响应式,一共分成两步:收集依赖 和 触发依赖

收集依赖,实际上就是建立响应式数据和副作用函数的联系,我们很自然地能想到哈希表这种数据结构,而当响应式数据发生改变时,副作用函数可以自动执行

而触发依赖,则是将某个响应式数据对应的所有副作用函数的取出来,依次执行。

    get(target, key) {
        track(target, key)
        return Reflect.get(target, key)
    }
    
    set(target, key, value) {
        trigger(target, key)
        Reflect.set(target,key,value)
    }

我们让track表示收集依赖的函数,tigger表示触发依赖

实现track,根据上面的解释,我们很自然地想到要使用哈希表这一数据结构来建立对应关系 那么哈希表的key是什么呢?我们肯定不会只定义一个响应式数据,所以每一个响应式数据结构体,都有自己的副作用函数,所以target(原始对象)将作为第一层的key,那么value是什么呢?难道value就可以直接是副作用函数集合吗?不是的,因为我们修改target的某个属性时,我们希望只触发跟这个属性相关的副作用函数,那么,单纯的一层哈希表不满足我们的需求,我们需要设置两层哈希表结构: key(target) - value(map) (target.key) - value(effect副作用函数)

不过,最外层的哈希表,我们要用比较特殊的哈希表:WeakMap(),这是因为,WeakMap对键值的引用是弱引用,如果我们使用Map, 那么target永远不会被内存回收,而使用WeakMap(),当没有其他对target的引用时,垃圾回收程序就会将target回收,释放内存。

所以,track函数我们可以这样实现:

    const targetMap = new WeakMap()
    export function track(target: any, key: String | Symbol) {
      let depsMap = targetMap.get(target)
      if(!depsMap) {
        targetMap.set(target, depsMap = new Map())
      }
      let dep = depsMap.get(key)
      if(!dep) {
        depsMap.set(key, dep = new Set())
      }
      //将副作用函数推入dep集合中
    }

还剩下一个问题,副作用函数从哪里获得?

我们需要提供一个副作用函数的注册机制effect(): 当我们注册一个副作用函数时,这个副作用函数能够被track函数捕捉到 如何实现这个机制呢? 我们需要借助全局变量

    let activeEffect = null // 表示当前待收集的副作用函数
    export function effect(fn) {
        activeEffect = fn
        fn()
        activeEffect = null
    }
    let activeEffect = null // 表示当前待收集的副作用函数
    class ReactiveEffect {
        private _fn: Function  // typescript语法
        constructor(fn) {
            this._fn = fn
        }
        run() {
            activeEffect = this
            this._fn()
            activeEffect = null
        }
    }
    // 为了适应未来的新需求的改动,我们使用面向对象的思想重构effect函数
    let activeEffect = null // 表示当前待收集的副作用函数
    export function effect(fn) {
        const _effect = new ReactiveEffect(fn)
        _effect.run()
    }
    

此时,我们的track函数也可以完善了:

    const targetMap = new WeakMap()
    export function track(target: any, key: String | Symbol) {
      let depsMap = targetMap.get(target)
      if(!depsMap) {
        targetMap.set(target, depsMap = new Map())
      }
      let dep = depsMap.get(key)
      if(!dep) {
        depsMap.set(key, dep = new Set())
      }
      if(activeEffect) {
          dep.add(activeEffect)
      }
    }

而trigger函数就很简单了,就是从哈希表里取副作用函数,然后依次执行:

export function trigger(target, key) {
  const depMap = targetMap.get(target)
  if(!depMap) {
    return
  }
  const dep = depMap.get(key)
  if(dep) {
    dep.forEach(effect => {
      effect.run()
    })
  }
}