VUE设计解析(2vs3 响应式对比)

104 阅读5分钟

前言:这篇文章只从架构层面分析,主要是思想方面。

以前写过一些具体带代码得分析,现在这一篇我们只从架构层面分析,不涉及细小的细节,这一篇主要是响应式架构的分析。

涉及到的一些解析,我们先用文字描述逻辑,然后再转换成代码实现

这个地址里有vue2.6的源代码,里面是好早之前写的,有部分错误的地方,主题思想还是看本篇把 segmentfault.com/a/119000004…

形成响应式的基本条件

在这里我们思考一下,形成响应式需要几个条件(这里我们不考虑具体的细节,只是大体的逻辑)。

  1. 首先我们需要劫持数据属性的访问和设置,也就是get和set。
  2. 其次我们需要在某些函数中访问劫持数据的属性,来触发get,然后在get里我们需要直接或者间接的把这些函数存起来,所以在实现劫持的时候对象每一个属性节点应该都有一个数组,比如叫属性节点Dep,然后把这些函数放到属性节点Dep中。
  3. 然后在对应的劫持数据属性改变时会触发set,set触发时需要找到属性节点Dep,然后把它里面的函数执行一遍。
  4. 某些函数我们其实可以认为是watch和计算属性回调函数和组件render函数。

这会基本响应式已经形成,还有个小问题就是属性节点Dep会有一些额外冗余存储的函数,例如obj是响应式劫持对象,在某些函数中三元表达式 obj.ok ? obj.text : 'not'中ok为true时ok和text属性节点Dep都会收集这个函数,在ok变更时false时,这会只需要ok这个节点收集就够了,那现在情况就是text中还有,有冗余函数,它需要清掉,那上面的条件需要再做补充,如下:

  1. 上面所说的某些函数的每个函数都会有一个函数Dep,这个函数被放到了哪个属性节点Dep中,对应的就会把这个属性节点Dep放到函数Dep中,形成一个双向关联。
  2. 然后在这个函数执行之后,重新收集依赖之后,这个函数Dep里存放的一定得是准确的属性节点Dep,没有任何冗余的依赖。
  3. 所以在函数执行之前一定还有这样一段逻辑,循环函数Dep中所有的属性节点Dep,然后把当前函数从属性节点Dep中清空,然后函数Dep清空为空数组(因为现在已经没有属性dep存储它了)。
  4. 然后当前函数执行又会触发get重新准确的把当前函数添加到对应的属性节点Dep上。

这里说的步骤3、4处理冗余依赖这一步 Vue.js设计与实现霍春阳 是这样做的,vue2源代码处理是另一种方式,我们下面说。

好,那接下来我们来看看vue2和vue3是如何做的?

vue2响应式的设计

那在这里我们把其他的源代码设计都抛开,我们关注核心重点形成响应式的这块,其重点就是源代码里的4步,这四步按顺序执行。

  1. initData()用来生成observe,也就是对data进行劫持get和set,在劫持时创建了const dep = new Dep()用来收集watcher观察者。
  2. initComputed()初始化计算属性,每个计算属性都会创建一个计算属性的watcher,wathcer的evaluate属性回调函数最终会调用计算属性对应的函数,并且标记缓存,然后再对计算属性进行劫持,重点是计算属性的劫持get函数也就是源码内的computedGetter函数,下面详细解释。
  3. initWatch()初始化用户侦听器,watch属性,每watch的属性都会创建一个用户侦听器的watcher,wathcer的run属性回调函数最终会调用用户侦听器传入的对应的函数,然后根据immediate是否初始化执行用户侦听器所传入的函数。
  4. 最后初始化的watcher是渲染watcher,渲染watcher对应的函数是 vm._update(vm._render(), hydrating) 也就是组件的render函数,用来更新组件,watcher的run属性回调函数也会调用它。

在这里initWatch用户侦听器在初始化的时候,例如watch: { 'person.name': function... } ,默认会解析'person.name',并且触发,也就是说如果watch的key它是data的属性那就会触发data的属性get劫持函数,然后像我们最上面说的一样被对应属性节点Dep收集,如果watcher的key是计算属性那就会触发计算属性的get,也就是computedGetter,这个函数我们放到下面说,因为在render函数中也会触发计算属性的get,我们继续说用户侦听器,也就是说用户侦听器观察者watcher一初始化,也就意味着被 属性节点Dep 收集走了。

接下来我们继续走流程

在watcher这个class中有一个get函数实现:

  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps()
    }
    return value
  }

这个this.getter就是我们对应的用户侦听器回调、计算属性回调、组件的render函数,而在最后初始化渲染watcher时,会默认调用一下上面这个get,也就是说会默认执行组件的render函数生成vnode并且挂载到界面上。

而在执行render函数的时候,例如模板里我们会用到计算属性也就是说会触发计算属性的get也就是computedGetter函数,这个函数实现是这样的:

function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }

      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }

那函数里这个watcher就是我们对应的 计算属性节点的watcher实例,dirty表示缓存是否失效,默认是true,所以默认会执行evaluate,而evaluate内部会执行上面的贴的get代码,并且标记dirty为false标记缓存生效。、

执行get代码里面有pushTarget和popTarget需要注意,先讲this.getter然后再说这俩函数把,在这里this.getter也就是计算属性的回调函数,我们一般在这个回调中 会去拿vue的data里的某个属性,例如函数体中this.message.split('').reverse().join('') ,继而去触发data的属性的get劫持函数,然后这个 属性节点Dep数组会收集当前的watcher观察者,当前的观察者是计算属性的watcher,也就意味着当前计算属性watcher被属性节点所收集。

然后这个get执行完毕,中间的pushTarget和popTarget函数我们来看一下:

// Dep.target 用来存放目前正在使用的watcher
// 全局唯一,并且一次也只能有一个watcher被使用
// The current target watcher being evaluated.
// This is globally unique because only one watcher
// can be evaluated at a time.
Dep.target = null
const targetStack = []
// 入栈并将当前 watcher 赋值给 Dep.target
// 父子组件嵌套的时候先把父组件对应的 watcher 入栈,
// 再去处理子组件的 watcher,子组件的处理完毕后,再把父组件对应的 watcher 出栈,继续操作
export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  // 出栈操作
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

那这里维持了一个watcher的栈,从上面看渲染watcher触发的get和计算属性触发的get实际是嵌套的,包括我们上面说的用户侦听器watcher的get和计算属性触发的get也都是嵌套的,所以这个watcher的栈和这个嵌套关系也是对应的,至于它做什么用我们来看一下。

上面说到计算属性被data的属性节点dep收集走了,如果在这里结束的话,那意味着没法渲染,因为对应的渲染watcher没有被属性节点dep收集走,而我们最上面说了函数Dep会收集属性节点dep,在这里我们可以认为watcher的dep收集了属性节点dep,那我们就可以拿到计算属性所有相关的属性节点dep,也就是知道它被哪些data的属性节点收集了,然后再把渲染watcher放到data相关的属性节点dep中,这样对应data的属性节点dep中就收集了,计算属性watcher和渲染watcher,这也是计算属性的get computedGetter做的事情。

收集watcher这个事情再上面这个例子中发生了两次,在get收集时我们应该收集对应的watcher,所以需要有一个函数调用和watcher栈的对应关系,而对应关系的维护就是通过pushTarget和popTarge做的。

到这里get的触发 观察者的收集基本就完毕了,然后值得更改就是触发了data属性得set函数,然后从dep中取出watcher,然后执行update,计算属性标记dirty为true缓存失效,然后其他的用户侦听器watcher和渲染watcher执行queueWatcher,也就是nextTick函数,nextTick就是异步更新了,异步更新我们后续专门分一篇来讲。

那异步更新中更新 用户侦听器watcher和渲染watcher时实际是执行的watcher的run函数,run函数内部也会执行上面所说的get函数,这会关注一个重点cleanupDeps函数,这个函数作用就是用来修正watcher的dep依赖项,并且修正属性节点dep上watcher观察者依赖,这一块和我们最开始自己思考实现是不一样的,我们来看下它的实现:

  /**
   * Clean up for dependency collection.
   */
  cleanupDeps () {
    let i = this.deps.length
    while (i--) {
      const dep = this.deps[i]
      if (!this.newDepIds.has(dep.id)) {
        dep.removeSub(this)
      }
    }
    let tmp = this.depIds
    this.depIds = this.newDepIds
    this.newDepIds = tmp
    this.newDepIds.clear()
    tmp = this.deps
    this.deps = this.newDeps
    this.newDeps = tmp
    this.newDeps.length = 0
  }

这里的this是指当前的watcher实例,它这里有一个deps一个newDeps,也就是说一个是旧的一个是新的,新的是刚执行完收集的,那新的一定是准确的,所以这里主要就是看看新的deps里是否包含旧的dep项,如果不包含证明新一轮函数里没有用到这个旧的属性节点项,所以我们需要把当前的watcher观察者从 这个属性节点dep中去掉,防止不必要的更新,然后再把newDeps变成当前的deps,然后newDeps重置为默认值一个空的set数据结构,其实中心思想和我们上面自己思考的类似。

到这里vue2响应式就结束了,下面我们来看vue3.

vue3响应式的设计

vue3的响应式解析主要是借助了 Vue设计与实现(霍春阳) 这本书,也比较推荐这本书,这里面作者对框架的设计思路有描述,这本书的作者也是vue的开发维护人员之一。

vue3响应式由defineProperty改为了proxy代理,但其实形成响应式的基本条件及思想是没有变化的,只是数据存储结构,及具体实现上有了变化。

我借用书里的一张图来表示,我们最上面说的观察者最终的存储是什么样子的,最终是存到哪里了。

image.png

从上面这个图可以看出构建数据结构的方式,我们分别使用了 WeakMap、Map 和 Set

  1. WeakMap 由 data --> Map 构成;
  2. Map 由 key --> Set 构成。

data其实就是我们proxy代理之前的原始数据,用WeakMap存储是因为,WeakMap对key是弱引用,一旦data没了其他引用,也就是说没其他地方用了,就会被释放掉,被垃圾回收器回收。

vue2用defineProperty递归循环属性,而在每个属性劫持的函数里会有dep,由此可见2的dep是存在函数闭包里的,2的dep存储节点类似一个树的结构,因为对象结构有点类似树,而3我们看到了data对应的是个map,前面说了每个属性节点都有一个Dep,所以map里的key是就是属性节点,而值是一个Set结构,这个值也就是我们的属性dep,从这一点我们能看到各个属性节点的dep存储实际被拍平了。

而图里的effectFn副作用函数,它是什么,我们做一个类比的话,它就好比我们vue2里的 watch用户侦听器和计算属性的回调函数、组件的render函数。

那接下来我们来模拟实现一个简易版的vue3的响应式,按我们最开始的思考,那我们要有几步:

  1. 需要对一个对象用proxy进行劫持,触发get时给在对应的map里key是属性,然后存到对应的Set数据结构Dep里,触发set劫持时给从Set数据结构里把所有回调拿出来执行。
  2. 需要定义一个effect函数来执行图里传入的effectFn函数,来触发我们劫持的get函数。
  3. 然后我们的effectFn副作用函数上应该有一个函数Dep,存储属性节点Dep也就是上图中的那个Set数据结构依赖集合,条件就是我们上面说的那样,Set依赖集合一旦存储了effectFn副作用函数,就需要把这个Set依赖集合反存储到函数Dep上,形成一个双向关联。
  4. 然后在执行副作用effectFn函数收集依赖之前,我们循环函数Dep中所有的属性节点Dep Set数据结构,然后把当前函数从属性节点Dep Set数据结构中清空,然后函数Dep清空为空数组(副作用effectFn函数执行完之后触发get劫持会重新收集准确的新的依赖)。
  5. 然后我们需要实现一个类似上面vue2里的targetStack的函数栈,用来属性节点Set收集依赖函数时保证,函数关系不会错乱,这个函数栈所面对的处理场景下面会讲。
  6. 最后是处理一些错误边界限制。

上面这几步做完了就相当于基础的响应式函数完成了,然后computed和watch函数都是在它的基础上做实现。

那下面我们来开始做具体实现:


        const data = {
            ok: true,
            text: 'hello word',
            num: 1
        }

        //桶
        const bucket = new WeakMap()

        // 当前副作用函数
        let activeEffect

        //副作用函数栈 存储的和函数执行栈对应 用来处理组件嵌套
        const targetStack = []

        //收集依赖的函数
        function track(target, key) {
            //不存在副作用函数 不是通过effectFn访问的 直接退出收集函数
            if (!activeEffect) return
            // 从WeakMap桶里取出 对应的弱引用的map
            let depsMap = bucket.get(target)
            if (!depsMap) {
                // 首次不存在时 初始化 并且做添加操作
                bucket.set(target, (depsMap = new Map()))
            }
            let effectsDep = depsMap.get(key)
            if (!effectsDep) {
                // 首次访问不存在时 初始化 并添加
                depsMap.set(key, (effectsDep = new Set()))
            }

            // 收集副作用函数到dep
            effectsDep.add(activeEffect)

            // 函数dep反收集 属性节点dep
            activeEffect.dep.push(effectsDep)
        }

        //触发更新的函数
        function trigger(target, key) {
            const depsMap = bucket.get(target)
            if (depsMap) {
                const effectsDep = depsMap.get(key)
                //有可能设置未被收集的属性所以加一个判断空
                const effectsToRun = new Set()

                effectsDep &&
                    effectsDep.forEach((fn) => {
                        if (fn !== activeEffect) {
                            // 防止无限循环  例如副作用函数里 出现了 obj++ 相当与obj+=1
                            // 读取和设置操作是在同一个副作用函数内进行的。
                            // 此时无论是 track 时收集的副作用函数,还是 trigger 时要触发执行的副作用函数,都是activeEffect
                            // 所以找出不是这个的执行
                            effectsToRun.add(fn)
                        }
                    })
                effectsToRun.forEach((effectFn) => effectFn()) //循环依赖函数执行
            }
        }

        const obj = new Proxy(data, {
            get(target, key, receiver) {
                track(target, key)
                return Reflect.get(target, key, receiver)
            },
            set(target, key, receiver) {
                Reflect.set(target, key, receiver)
                trigger(target, key)
                return true
            }
        })

        // 清空函数dep里的属性节点dep 依赖等
        function cleanup(fn) {
            // 循环相关的属性节点dep
            fn.dep.forEach((dep) => {
                dep.delete(fn) //属性节点dep删除对应的副作用
            })
            // 清空函数dep方便重新收集
            fn.dep = []
        }

        function effect(fn) {
            //副作用函数 我们自己再包装一层
            function effectFn() {
                cleanup(effectFn)
                 //做个副作用函数栈处理  保证函数堆栈和它是对应的  get收集时保证不会收集错  解决组件嵌套
                targetStack.push(effectFn)
                activeEffect = effectFn
                fn()
                targetStack.pop()
                activeEffect = targetStack[targetStack.length - 1]
            }
            // 函数dep
            effectFn.dep = []
            effectFn()
        }

        effect(() => {
            console.log(obj.ok ? obj.text : 'not')

        })

        obj.ok = false
        obj.text = 'sdasdas'

这里按上面写的实现了一个基础的响应式,上面targetStack栈相关的是为了处理组件嵌套,收集的activeEffect不正确,如下

   effect(() => {
            console.log(obj.text)
            effect(() => {
                console.log(obj.ok)
            })
   })

然后我们在这个基础上实现一下computed和watcher:

分析computed函数的需求并实现

  1. 计算属性是我们用的时候,才触发那个传入的副作用函数,也就是说它是懒加载的,不是默认一上来就执行的,而我们用的时候触发这个副作用函数,证明我们需要拿到它(副作用函数)。
  2. 我们需要获取到计算属性传入的那个函数的返回值。
  3. 计算属性需要有缓存,computed函数内部需要有个属性标记缓存是否生效。
  4. 计算属性传入的副作用函数里,所使用的响应式属性变更时, 我们的副作用函数不执行(理由参考1),需要把缓存属性标记为失效。

然后我们依次来看需要对我们最开始的响应式做哪些更改:

  1. effect函数支持传入第二个参数,传一个配置options对象过去,例如里面有个lazy属性,在有lazy属性时,默认不一上来就执行。
  2. effect函数需要把副作用函数effectFn返回出去,让外面决定什么时候调用。
  3. 副作用函数把传入的那个函数额返回值return出来。
  4. computed函数内部需要有个dirty属性来标记缓存是不是有效。
  5. 而我们实现上面computed需求4,需要改下trigger函数,其实需求4的本质就是需要把副作用函数的执行时机交出来,在外面确定是否执行要怎么执行,而不是在trigger里直接执行,那我们在options对象再多传一个函数进去,scheduler属性,它是一个函数,我们把options挂到effectF上,然后在trigger中effectsDep里的副作用函数依次执行时,判断副作用函数上是否有scheduler,有的话调用它并且把副作用函数传出去,scheduler(effectFn),这样就把控制权交出去了。

代码实现:

      //触发更新的函数
        function trigger(target, key) {
            const depsMap = bucket.get(target)
            if (depsMap) {
                const effectsDep = depsMap.get(key)
                //有可能设置未被收集的属性所以加一个判断空
                const effectsToRun = new Set()

                effectsDep &&
                    effectsDep.forEach((fn) => {
                        if (fn !== activeEffect) {
                            // 防止无限循环  例如副作用函数里 出现了 obj++ 相当与obj+=1
                            // 读取和设置操作是在同一个副作用函数内进行的。
                            // 此时无论是 track 时收集的副作用函数,还是 trigger 时要触发执行的副作用函数,都是activeEffect
                            // 所以找出不是这个的执行
                            effectsToRun.add(fn)
                        }
                    })
                effectsToRun.forEach((effectFn) => {
                    if (effectFn.options.scheduler) { // 如果由外部调度器的话 把调度权交出去
                        effectFn.options.scheduler()
                    } else {
                        effectFn()
                    }
                }) //循环依赖函数执行
            }
        }
        
        
       function effect(fn, options = {}) {
            //副作用函数 我们自己再包装一层
            function effectFn() {
                cleanup(effectFn)
                //做个副作用函数栈处理  保证函数堆栈和它是对应的  get收集时保证不会收集错  解决组件嵌套
                targetStack.push(effectFn)
                activeEffect = effectFn
                // 那下返回值 计算属性时需要用
                const res = fn()
                targetStack.pop()
                activeEffect = targetStack[targetStack.length - 1]
                return res
            }
            // 函数dep
            effectFn.dep = []

            // 配置存起来 方便trigger函数用
            effectFn.options = options

            //没有lazy时再执行
            if (!options.lazy) {
                effectFn()
            }

            // 返回副作用函数 lazy为true时 由外界决定什么时候执行
            return effectFn
        }
        
        
        
        function computed(getter) {
            // 默认计算属性的值时脏的
            let dirty = true

            // 形成一个响应式 懒加载的
            const effectFn = effect(getter, {
                lazy: true,
                scheduler(fn) { // 传入调度器 让这个响应式把调度时机交出来
                    dirty = true //标记缓存失效
                    // 虽然这里把副作用函数fn传了出来  但是我们的副作用函数 是在实际使用的时候调用的 下面  所以这里是不需要调用的
                    // 只需要把缓存设置为失效 让下面能调用就可以
                }
            })
            // 缓存值
            let value
            const obj = {
                get value() {
                    if (dirty) { //缓存脏了 需要更新
                        value = effectFn() // 在实际用的时候再 执行副作用 重新获取值
                        dirty = false //标记缓存生效
                    }
                    // dirty为false 时 证明缓存是有效的 不需要更新
                    return value
                }
            }
            return obj

        }


const test = computed(() => obj.text)

实现计算属性我们改了这三个函数的实现,现在还有一点小问题,就是如果计算属性被其他副作用函数引用了,例如:

        const test = computed(() => obj.text)
        effect(() => {
            console.log(test.value)
        })

我们看上面的computed函数实现,返回的obj的get函数,实际是并没有调用track来收集 当前副作用函数作为依赖的(上例test没有收集effect的参数函数),所以在计算属性的scheduler被调用时(上面例子text变更时),也就没法调用它更新,要解决也很简单,我们在obj触发get时,手动用track函数来收集一下当前的副作用函数作为依赖(上例手动收集effect的参数函数,默认调用effect时,会activeEffect = effectFn,然后track会收集activeEffect),然后scheduler中手动用trigger触发,所以代码如下:

        function computed(getter) {
            // 默认计算属性的值时脏的
            let dirty = true

            // 形成一个响应式 懒加载的
            const effectFn = effect(getter, {
                lazy: true,
                scheduler(fn) { // 传入调度器 让这个响应式把调度时机交出来
                    dirty = true //标记缓存失效
                    // 虽然这里把副作用函数fn传了出来  但是我们的副作用函数 是在实际使用的时候调用的 下面  所以这里是不需要调用的
                    // 只需要把缓存设置为失效 让下面能调用就可以

                    trigger(obj, 'value')

                }
            })
            // 缓存值
            let value
            const obj = {
                get value() {
                    if (dirty) { //缓存脏了 需要更新
                        value = effectFn() // 在实际用的时候再 执行副作用 重新获取值
                        dirty = false //标记缓存生效
                    }

                    track(obj, 'value')
                    // dirty为false 时 证明缓存是有效的 不需要更新
                    return value
                }
            }
            return obj

        }

这样计算属性就实现了。

分析watch函数的需求并实现

  1. watch函数有三个参数,第一个参数source可以是一个函数,也可以是一个响应式proxy对象,所以我们实现它时需要区分一下,如果要是函数那就直接给effect函数传递,如果是对象那我们需要定义一个函数,在这个函数里递归去获取所有属性触发这个对象的get让它收集依赖。
  2. 第二个参数cb是一个函数,cb函数是我们指定的source对应的响应式变更后,需要执行参数二cb,所以我们watch里的effect也需要有个scheduler把调用时机交出来,然后scheduler中调用cb。
  3. cb中接受新值和旧值,所以内部需要定义俩变量来存储,然后effect的lazy也是true,因为cb调用需要获取副作用函数的值。
  4. 第四个参数是个options配置,immediate来决定cb是否默认执行。
  5. 不管immediate是true还是false,初始都需要执行一下副作用函数,来让属性节点把副作用函数收集走

代码实现:

        function traverse(source, seen = new Set()) {
            //因为是递归循环对象 所以不是对象 或者不存在 或者已经递归过的 情况都返回
            if (typeof source !== 'object' || source === null || seen.has(source)) return

            for (const key in source) {
                seen.add(source[key])
                traverse(source[key], seen)
            }
            // 最后返回一下 因为watch副作用函数需要 调用副作用函数获取新旧值  source
            return source
        }

        function watch(source, cb, options = {}) {

            let getter
            if (typeof source === 'function') {
                //函数的话直接用
                getter = source
            } else {
                //对象的话 递归循环 触发get
                getter = () => traverse(source)
            }

            // watch回调的新旧值
            let oldValue, newValue
            function runCb() {
                //执行回调
                newValue = effectFn()
                cb(oldValue, newValue)
                oldValue = newValue
            }
            // 创建延迟的effect
            const effectFn = effect(() => getter(), {
                lazy: true,
                scheduler() {
                    //拿出控制权 在监听的属性变化后 执行cb
                    runCb()
                }
            })
            // 默认是否执行
            if (options.immediate) {
                runCb()
            } else {
                // 默认需要执行一下effectFn 让属性节点把effectFn副作用函数收集走 上面runCb中会调用effectFn
                oldValue = effectFn()
            }

        }
        
        watch(() => obj.ok, (oldValue, newValue) => {
            console.log(oldValue, newValue)
        })

我们的watch按上面的分析做出来是这样的。

到这里我们实现的是最简易版本的响应式,代理里只有get和set,例如删除属性劫持的deleteProperty和其他结构例如map和set、数组等等 后续的其他属性劫持就不在这里展开说了,我们这里过的是基础的响应式逻辑。

上面我们proxy代理是固定的一个对象,现在我们这里再实现一下ref和reactive:

        function reactive(data) {
            return new Proxy(data, {
                get(target, key, receiver) {

                    track(target, key)
                    const res = Reflect.get(target, key, receiver) // 拿到当前值 因为代理只能代理当前这一层
                    //判断取出来的是不是对象 如果是的话用reactive 再包一层 把他里面的也劫持上 返回
                    //为了了处理obj.xx.xx这种情况
                    if (res && typeof res === 'object') {
                        return reactive(res)
                    }
                    return res
                },
                set(target, key, receiver) {
                    const res = Reflect.set(target, key, receiver)
                    trigger(target, key)
                    return res
                }
            })
        }

        function ref(val) {
            const wrapper = {
                value: val
            }
            Object.defineProperty(wrapper, '_v_isef', { //加个标记区分是ref还是reactive 不然分不出来了
                value: true
            })
            return reactive(wrapper)
        }
        
        
        const datas = ref({ ll: '111' })
        watch(() => datas.value.ll, (oldValue, newValue) => {
            console.log(oldValue, newValue)
        })

然后我们我们结合最简单的renderer使用就是这样的,我把完整代码贴一下:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div id="app"></div>
    <script>


        //桶
        const bucket = new WeakMap()

        // 当前副作用函数
        let activeEffect

        //副作用函数栈 存储的和函数执行栈对应 用来处理组件嵌套
        const targetStack = []

        //收集依赖的函数
        function track(target, key) {
            //不存在副作用函数 不是通过effectFn访问的 直接退出收集函数
            if (!activeEffect) return
            // 从WeakMap桶里取出 对应的弱引用的map
            let depsMap = bucket.get(target)
            if (!depsMap) {
                // 首次不存在时 初始化 并且做添加操作
                bucket.set(target, (depsMap = new Map()))
            }
            let effectsDep = depsMap.get(key)
            if (!effectsDep) {
                // 首次访问不存在时 初始化 并添加
                depsMap.set(key, (effectsDep = new Set()))
            }

            // 收集副作用函数到dep
            effectsDep.add(activeEffect)

            // 函数dep反收集 属性节点dep
            activeEffect.dep.push(effectsDep)
        }

        //触发更新的函数
        function trigger(target, key) {
            const depsMap = bucket.get(target)
            if (depsMap) {
                const effectsDep = depsMap.get(key)
                //有可能设置未被收集的属性所以加一个判断空
                const effectsToRun = new Set()

                effectsDep &&
                    effectsDep.forEach((fn) => {
                        if (fn !== activeEffect) {
                            // 防止无限循环  例如副作用函数里 出现了 obj++ 相当与obj+=1
                            // 读取和设置操作是在同一个副作用函数内进行的。
                            // 此时无论是 track 时收集的副作用函数,还是 trigger 时要触发执行的副作用函数,都是activeEffect
                            // 所以找出不是这个的执行
                            effectsToRun.add(fn)
                        }
                    })
                effectsToRun.forEach((effectFn) => {
                    if (effectFn.options.scheduler) { // 如果由外部调度器的话 把调度权交出去
                        effectFn.options.scheduler()
                    } else {
                        effectFn()
                    }
                }) //循环依赖函数执行
            }
        }


        function reactive(data) {
            return new Proxy(data, {
                get(target, key, receiver) {

                    track(target, key)
                    const res = Reflect.get(target, key, receiver) // 拿到当前值 因为代理只能代理当前这一层
                    //判断取出来的是不是对象 如果是的话用reactive 再包一层 把他里面的也劫持上 返回
                    //为了了处理obj.xx.xx这种情况
                    if (res && typeof res === 'object') {
                        return reactive(res)
                    }
                    return res
                },
                set(target, key, receiver) {
                    const res = Reflect.set(target, key, receiver)
                    trigger(target, key)
                    return res
                }
            })
        }

        function ref(val) {
            const wrapper = {
                value: val
            }
            Object.defineProperty(wrapper, '_v_isef', { //加个标记区分是ref还是reactive 不然分不出来了
                value: true
            })
            return reactive(wrapper)
        }

        // 清空函数dep里的属性节点dep 依赖等
        function cleanup(fn) {
            // 循环相关的属性节点dep
            fn.dep.forEach((dep) => {
                dep.delete(fn) //属性节点dep删除对应的副作用
            })
            // 清空函数dep方便重新收集
            fn.dep = []
        }

        function effect(fn, options = {}) {
            //副作用函数 我们自己再包装一层
            function effectFn() {
                cleanup(effectFn)
                //做个副作用函数栈处理  保证函数堆栈和它是对应的  get收集时保证不会收集错  解决组件嵌套
                targetStack.push(effectFn)
                activeEffect = effectFn
                // 那下返回值 计算属性时需要用
                const res = fn()
                targetStack.pop()
                activeEffect = targetStack[targetStack.length - 1]
                return res
            }
            // 函数dep
            effectFn.dep = []

            // 配置存起来 方便trigger函数用
            effectFn.options = options

            //没有lazy时再执行
            if (!options.lazy) {
                effectFn()
            }

            // 返回副作用函数 lazy为true时 由外界决定什么时候执行
            return effectFn
        }

        // effect(() => {
        //     console.log(obj.ok ? obj.text : 'not')

        // })


        function computed(getter) {
            // 默认计算属性的值时脏的
            let dirty = true

            // 形成一个响应式 懒加载的
            const effectFn = effect(getter, {
                lazy: true,
                scheduler(fn) { // 传入调度器 让这个响应式把调度时机交出来
                    dirty = true //标记缓存失效
                    // 虽然这里把副作用函数fn传了出来  但是我们的副作用函数 是在实际使用的时候调用的 下面  所以这里是不需要调用的
                    // 只需要把缓存设置为失效 让下面能调用就可以

                    trigger(obj, 'value')

                }
            })
            // 缓存值
            let value
            const obj = {
                get value() {
                    if (dirty) { //缓存脏了 需要更新
                        value = effectFn() // 在实际用的时候再 执行副作用 重新获取值
                        dirty = false //标记缓存生效
                    }

                    track(obj, 'value')
                    // dirty为false 时 证明缓存是有效的 不需要更新
                    return value
                }
            }
            return obj

        }

        function traverse(source, seen = new Set()) {
            //因为是递归循环对象 所以不是对象 或者不存在 或者已经递归过的 情况都返回
            if (typeof source !== 'object' || source === null || seen.has(source)) return

            for (const key in source) {
                seen.add(source[key])
                traverse(source[key], seen)
            }
            // 最后返回一下 因为watch副作用函数需要 调用副作用函数获取新旧值  source
            return source
        }

        function watch(source, cb, options = {}) {

            let getter
            if (typeof source === 'function') {
                //函数的话直接用
                getter = source
            } else {
                //对象的话 递归循环 触发get
                getter = () => traverse(source)
            }

            // watch回调的新旧值
            let oldValue, newValue
            function runCb() {
                //执行回调
                newValue = effectFn()
                cb(oldValue, newValue)
                oldValue = newValue
            }
            // 创建延迟的effect
            const effectFn = effect(() => getter(), {
                lazy: true,
                scheduler() {
                    //拿出控制权 在监听的属性变化后 执行cb
                    runCb()
                }
            })
            // 默认是否执行
            if (options.immediate) {
                runCb()
            } else {
                // 默认需要执行一下effectFn 让属性节点把effectFn副作用函数收集走 上面runCb中会调用effectFn
                oldValue = effectFn()
            }

        }


        function renderer(domString, container) {
            container.innerHTML = domString
        }

        const data = ref('你好啊')


        effect(() => {
            renderer(`<h1>Hello ${data.value}</h1>`, document.getElementById('app'))
        })



    </script>
</body>

</html>

到这里vue2和3的响应式对比就完结了,其实主题设计思想还是我们最开始的思考,变化的是语法糖,如果看过的同学有不同的看法,可以留言讨论。