手写vue3响应式系统

lxf2023-03-14 10:54:01

vue3响应式原理

前面写过vue2响应式原理 想了解的可以去看下, 本章讲vue3的响应式原理,并对照着源码手写一下简化的vue3响应式系统。

Proxy

在 Vue.2x 中,使用 Object.defineProperty()对象进行监听。而在 Vue3.0 中,改用 Proxy 进行监听。Proxy 比起 Object.defineProperty() 有如下优势:

  1. 监听的是整个对象,可以监听属性的增删操作。
  2. 可以监听数组某个索引值的变化以及数组长度的变化。

心智负担

对比vue2的响应式系统,vue3的功能更强大,这也导致了其心智负担增加了。

  1. Proxy 并不是直接在原对象上操作,而是新建了一个代理对象用来监听,后果就是代码中同时存在原始对象和代理对象,于是vue3中新增了toRaw、markRaw等api。
  2. vue3新增了 readonly 只读代理和 shallowRefshallowReactiveshallowReadonly 浅层代理等 api,当然这应该算正向的好处,不过学习成本提高了,心智负担增加,
  3. 由于基本类型数据无法被Proxy代理,引入了refrefreactive两套响应式api,无形中也导致了使用心智负担增加。
  4. 由于es6的解构赋值会导致proxy的代理响应丢失,于是引入了toRefstoRef,理解和使用心智负担增加。
  5. 新增 副作用作用域(effectScope)的概念,用于批量操作effect。这也是好处,但是心智负担哈,也是提高了。

心智负担增加就意味着学习使用成本提高,接下我们就参照vue3的源码,目录文件名和方法名保持和源码一致,手写一个响应式系统,理解其中的原理,也方便大家读源码时理解源码。

reactive

作用: 返回一个对象的响应式代理。

简单实现理解原理不考虑 readonlyshallow

// src/reactivity/reactive.js
import { isObject } from '../utils/index.js'
import { mutableHandlers } from './baseHandlers.js'

export const ReactiveFlags = {
  IS_REACTIVE : "__v_isReactive", // 判断是否已被reactive的标识
  RAW : "__v_raw", // 挂载原始对象的字段
}

// 全局创建一个WeakMap存储 原始对象到代理对象的 映射 
export const reactiveMap = new WeakMap()

// reactive() 是向用户暴露的 API,它真正执行的是 createReactiveObject() 函数
// mutableHandlers 即针对普通对象和数组的get/set处理handlers
// 源码中这里还传了针对集合类型数据(Map,Set,WeakMap,WeakSet)代理处理的handlers,本文暂不处理这种情况,就暂时不传
export function reactive(target) {
  return createReactiveObject(target, reactiveMap, mutableHandlers)
}

export function createReactiveObject(target, proxyMap, baseHandlers) {
  // 非对象类数据,直接返回 target
  if (!isObject(target)) {
    return target
  }
  // 已经被响应式化的数据, 直接返回 target
  if (isReactive(target)) {
    return target
  }
    
  // proxyMap里存过的数据,直接查到返回
  if (proxyMap.has(target)) {
    return proxyMap.get(target)
  }

  // 创建代理数据,传入原始对象target,和对应的处理handlers
  const proxy = new Proxy(target, baseHandlers)

  // 将原始对象和代理对象存进WeakMap
  proxyMap.set(target, proxy)

  // 返回代理对象
  return proxy
  
}

// 根据标识判断是否是reactive对象
export function isReactive(value) {
  return !!value[ReactiveFlags.IS_REACTIVE]
}

这个函数就是判断了那些数据可以被响应式化,然后new Proxy实际处理代理响应的具体逻辑在 baseHandlers

// src/reactivity/baseHandlers.js
import { hasChanged, isObject } from '../utils/index.js'
import { track, trigger } from './effect.js'
import { reactive, ReactiveFlags, reactiveMap } from './reactive.js'

// 处理get
function createGetter() {
  return function get(target, key, receiver) {
    // key 为 __v_raw 即为获取原始对象,且代理对象和原始对象能对应上,返回target
    if (key === ReactiveFlags.RAW && receiver === reactiveMap.get(target)) {
      return target
    // key 为 __v_isReactive 即 判断当前数据是否为响应式数据,返回true
    } else if (key === ReactiveFlags.IS_REACTIVE) {
      return true
    }
      
    // 从原始对象中获取对应值
    const res = Reflect.get(target, key, receiver)

    // 收集依赖
    track(target, key)

    // 值如果是对象,则继续代理值,相比vue2的一开始就递归响应式化所有值,这里
    // 只在get用到的时候才代理,算是性能上的优化
    if (isObject(res)) {
      // 返回代理后的子值
      return reactive(res)
    }

    // 返回值
    return res
  }
}
// 处理set
function createSetter() {
  return function set(target, key, newValue, receiver) {
    const oldValue = target[key]
    // 给原始对象设置值
    const res = Reflect.set(target, key, newValue, receiver)
    // 新旧值发生改变即触发依赖更新
    if (hasChanged(oldValue, newValue)) {
      trigger(target, key)
    }
    return res
  }
}

const get = createGetter()
const set = createSetter()

// 这里只处理了,set get 源码中还有 deleteProperty has ownKeys
export const mutableHandlers = {
  get,
  set
}

以上就是代理get/set的 具体处理。

effect

effect直译就是 副作用 ,主要和响应式的对象结合使用,响应式对象更新触发effect更新,也可以理解为就是依赖,类似vue2中的watcher的概念。

// src/reactivity/effect.js
import { createDep } from "./dep.js"
import { recordEffectScope } from "./effectScope.js"

// 面向用户的effect函数, 接受更新函数fn,和配置参数options
export function effect(fn, options = {}) {
  
  // 创建effect实例
  const _effect = new ReactiveEffect(fn)

  // 未配置 lazy 的,自动运行一次 更新函数
  if (!options.lazy) {
    _effect.run()
  }

  // 把 _effect.run 这个方法返回
  // 让用户可以自行选择调用的时机(调用 fn)
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}

let activeEffect = undefined // 当前正在运行的effect
let shouldTrack = false // 是否可以收集依赖

// effect实现类
export class ReactiveEffect {

  parent = undefined // 父级effect,也即运行当前effect时的外层effect
  active = true // effect是否是激活状态
  deps = [] // effect对应的dep,即effect被哪些dep收集过
  onStop = undefined // 停止时触发的钩子

  constructor(fn, scheduler) {
    this.fn = fn
    this.scheduler = scheduler // 调度器, 先忽略
  }
  // 更新
  run() {
    // 非激活状态,执行 fn  但是不收集依赖
    if (!this.active) {
      return this.fn();
    }
      
    // 存储上一个 shouldTrack 和 activeEffect 的值
    const lastShouldTrack = shouldTrack
    this.parent = activeEffect
      
    // 执行 fn  收集依赖
    // 可以开始收集依赖了
    shouldTrack = true
    // 执行的时候将当前的 effect 赋值给全局的 activeEffect 
    // 利用全局属性来获取当前的 effect
    activeEffect = this
   
    try {
      // 执行用户传入的 fn
      return this.fn()
    } finally {
      // 将刚存的值赋值回去
      shouldTrack = lastShouldTrack
      activeEffect = this.parent
      this.parent = undefined
    }
  }

  // 停止
  stop() {
    if (this.active) {
      // 如果第一次执行 stop 后 active 就 false 了
      // 这是为了防止重复的调用,执行 stop 逻辑
      cleanupEffect(this)
      if (this.onStop) {
        this.onStop()
      }
    }
    this.active = false
  }

}

function cleanupEffect(effect) {
  // 找到所有依赖这个 effect 的响应式对象
  // 从这些响应式对象里面把 effect 给删除掉
  effect.deps.forEach((dep) => {
    dep.delete(effect);
  });

  effect.deps.length = 0;
}

上面的逻辑就是,调用effect创建ReactiveEffect 类即创建依赖。ReactiveEffect 执行run方法时设置,当前实例this到全局activeEffect 方便后面收集依赖。

既然依赖以及创建了,那么趁热打铁,我们再看下怎么收集依赖track和触发依赖更新trigger

// src/reactivity/effect.js

// 创建WeakMap映射,存储对应原始对象对应的key的依赖
// 二维的结构, 类似
// targetMap -> { target: depsMap }
// depsMap -> { key: dep }
const targetMap = new WeakMap()

// 收集依赖
export function track(target, key) {
  // 无法收集时直接返回
  if (!canTrack()) {
    return
  }
  // 根据target 找 depsMap,没有就新建一个,并存下当前target和新建的depsMap
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  // 再根据key 找 dep, 同样没有就新建一个,并存下当前key和新建的dep
  let dep = depsMap.get(key)
  if (!dep) {
    dep = createDep()
    depsMap.set(key, dep)
  }
    
  trackEffects(dep)
}
// dep收集当前正在运行的 effect,即全局的activeEffect,同时effect也存下这个dep
export function trackEffects(dep) {
  if (!dep.has(activeEffect)) {
    // 双向收集
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

// 触发依赖更新
export function trigger(target, key) {
  // 根据原始对象target找到对应的depsMap
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  // 根据depsMap找到对应的依赖器dep
  const dep = depsMap.get(key)
  
  // 如果存在dep则触发更新
  if (dep) {
    triggerEffects(dep)
  }
}

// 触发dep中收集的effect更新
export function triggerEffects(dep) {
  // 循环获取每一个effect
  for (const effect of dep) {
    // 存在调度函数则调用调度函数,让后续scheduler中的逻辑处理具体怎么更新
    if (effect.scheduler) {
      effect.scheduler()
    // 不存在调度函数,则直接调用run更新
    } else {
      effect.run()
    }
  }
}

// 判断是否可以收集依赖
export function canTrack() {
  return shouldTrack && activeEffect
}

// 创建dep, 利用Set去重,用于存储effect
export function createDep(effects) {
  return new Set(effects)
}

就是创建targetMap建立依赖和对应响应式数据的原始数据间的映射关系并存储 ,调用track 可以将当前正在运行的依赖activeEffect 收集到targetMap, 调用trigger可以根据原始对象和key到targetMap中找到收集的依赖effect并触发更新。

至此 响应式对象(reactive) 和 副作用函数(effect)均已完成,验证下:

// 创建响应式对象
const foo = reactive({
    a: 1
})
// 添加副作用函数
effect(() => {
    console.log('更新:' + foo.a)
})

// 打印 ---> 更新:1

// 修改响应式数据
foo.a = 2
foo.a = 3
// 打印 ---> 更新:2
// 打印 ---> 更新:3

ref

由于基本类型数据无法被Proxy代理,所以vue3引入了ref,接下来就是实现ref用于处理基本数值类型的响应式。

官网介绍ref(value) 接受一个内部值,返回一个响应式的、可更改的 ref 对象,此对象只有一个指向其内部值的属性 .value

具体实现

// src/reactivity/ref.js
import { hasChanged } from '../utils/index.js'
import { createDep } from './dep.js'
import { canTrack, trackEffects, triggerEffects } from './effect.js'
import { toReactive } from './reactive.js'

// 面向用户的ref函数
export function ref(value) {
  // 已经是ref数据的直接返回value
  if (isRef(value)) {
    return value
  }
  // 创建 RefImpl 实例 , ps: Impl 就是 类实现 的意思
  return new RefImpl(value)
}

// 直接通过 __v_isRef 标识判定是不是 ref 对象
export function isRef(value) {
  return !!(value && value.__v_isRef === true)
}

class RefImpl {

  __v_isRef = true // 标识
  dep // 依赖存储处

  constructor(value) {
    // 如果传进来的是对象则利用reactive对其进行响应式化 
    this._value = toReactive(value)
    this._rawValue = value // 存下原始值
  }

  // 调.value时触发get, 收集依赖并返回 _value
  get value() {
    trackRefValue(this)
    return this._value
  }
  // 设置.value值时
  set value(newValue) {
    if (hasChanged(newValue, this._rawValue)) { // 判断新旧值是否一致
      this._rawValue = newValue // 修改原始值
      this._value = toReactive(newValue) // 响应式化新值
      triggerRefValue(this) // 触发依赖更新
    }
  }
}

// 给ref收集依赖
export function trackRefValue(ref) {
  if (canTrack()) {
    // 方法定义位置见上面的 effect.js 
    // 调 trackEffects 收集 当前正在运行的 effect,到ref.dep上,没有dep则新建一个
    trackEffects(ref.dep || (ref.dep = createDep()))
  }
}

// 触发ref的依赖更新 方法定义位置见上面的 effect.js 
export function triggerRefValue(ref) {
  if (ref.dep) {
    // 方法定义位置见上面的 effect.js 
    // 触发ref.dep中收集的effect更新
    triggerEffects(ref.dep)
  }
}

// src/reactivity/reactive.js
// 如果是引用类型则进行响应式化返回代理后的对像, 否则返回原始值
export function toReactive(value) {
  return isObject(value) ? reactive(value) : value
}

可以看到ref本质上就是 新建一个RefImpl类 , 利用类的 get/set收集触发依赖,逻辑很简单。

验证下:

const foo = ref(1)

effect(() => {
    console.log('更新:' + foo.value)
})
// 打印 ---> 更新:1
foo.value = 2
foo.value = 3
// 打印 ---> 更新:2
// 打印 ---> 更新:3

toRefs、toRef

由于es6的解构赋值会导致proxy的代理响应丢失,于是引入了toRefstoRef

先看案例

const foo = reactive({
    a: {
        c: 1
    },
    b: 2
})

let { a, b } = foo

effect(() => {
    a.c + b
    console.log('更新')
})
// 首次打印 -> 更新

a.c = 2 // 打印 -> 更新
b = 3   // 未触发更新无打印
a = { c: 4 } // 未触发更新无打印

上述代码中,我们发现, 解构赋值,b 不会触发响应式a如果你访问其子属性时会触发响应式,a直接更改引用也不会触发响应式

这是为什么呢?

我们知道解构赋值,区分基本类型的赋值,和引用类型的赋值,

基本类型的赋值相当于按值传递引用类型的值就相当于按引用传递

基本类型的赋值

const {  b } = foo
b = 3   // b此时就是一个值3,和当前的foo已经没有联系了,所以你直接访问和修改b,触发不了foo的get/set,所以就不会导致更新

引用类型赋值

const { a } = foo
a.c = 3 // 由于a是对象是引用类型,赋值的只是引用,此时的a和foo.a公用一个引用,所以修改a.c就相当于修改foo.a.c,所以能触发foo的get/set,所以就会更新

再看最后一种直接修改引用

const { a } = foo
a = { c: 4 } // 此时foo.a虽然是引用类型,但是直接修改了a的引用,就同样导致了a和foo.a不沾边了,那么就和第一种情况一样了,同样不会导致更新

所以vue3引入了toRefs来解决这种问题,这样使用就能保证响应式。

const foo = reactive({
  a: {
      c: 1
  },
  b: 2
})

let { a, b } = toRefs(foo)

effect(() => {
  a.value.c + b.value // 值得一提的是,在vue3的template中不需要加.value,因为vue3编译的过程中会自动帮你处理,但是这里不行
  console.log('更新')
})
// 首次打印 -> 更新

b.value = 3   // 打印 -> 更新
a.value = { c: 4 } // 打印 -> 更新

可以看到,toRefs就是将foo对象的每个属性都转换成了是指向foo对象相应属性的 ref,因为是ref所以使用时要求必须用.value

实现:

// src/reactivity/ref.js

export function toRefs(object) {
  // 数组处理
  const ret = isArray(object) ? new Array(object.length) : {}
  for (const key in object) {
   // 将对象的每一项转换为 ref
    ret[key] = toRef(object, key)
  }
  return ret
}

export function toRef(object, key, defaultValue) {
  const val = object[key]
  // 本身是ref的不转换,否则转换成 ObjectRefImpl
  return isRef(val) ? val : new ObjectRefImpl(object, key, defaultValue)
}

class ObjectRefImpl {
  __v_isRef = true // 标识本对象是ref
  constructor(object, key, defaultValue) {
    // 赋值到内部变量
    this._object = object 
    this._key = key
    this._defaultValue = defaultValue
  }

  get value() {
    // 可以看到 调 .value 就是在 调 object[key]
    const val = this._object[this._key]
    return val === undefined ? this._defaultValue : val
  }

  set value(newVal) {
    // 同样 设置.value 就是在 设置 object[key]
    this._object[this._key] = newVal
  }
}

代码逻辑很简单,就是利用类的get/set 将 所有对.value的操作代理到原来的响应式对象上去,连依赖收集触发都是在原响应式对象中。

最后跑一次上面的使用案例,通过~

computed

接下来就是计算属性computed的实现了。

官网介绍: 接受一个 getter 函数,返回一个只读的响应式 ref 对象。该 ref 通过 .value 暴露 getter 函数的返回值。它也可以接受一个带有 getset 函数的对象来创建一个可写的 ref 对象。

两种用法,传 getter函数 只读或传 get/set配置对象可写

// src/reactivity/computed.js
import { isFunction, noop } from "../utils/index.js"
import { ReactiveEffect } from './effect.js'
import { trackRefValue, triggerRefValue } from "./ref.js"

// 面向用户的 computed 函数
export function computed(getterOrOptions) {

  let getter
  let setter

  // 传函数就代表只有 get 没有 set
  const onlyGetter = isFunction(getterOrOptions)

  if (onlyGetter) {
    getter = getterOrOptions
    setter = noop  // noop 即空函数 () => {}
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set || noop
  }

  // 最后返回 ComputedRefImpl 实例
  return new ComputedRefImpl(getter, setter)
}


class ComputedRefImpl {
  dep // 存储使用了computed值的effect
  effect // 当前的computed effect, 类似vue2中的computed watcher
  _value // getter计算出来的值
  __v_isRef = true // 标识是ref对象
  _dirty = true // 标识getter计算出来的值是否是脏的,是否需要重新计算,一开始肯定要计算的
  constructor(getter, setter) {
    this._getter = getter
    this._setter = setter
    // 创建computed effect,并传入调度函数,即computed关联的响应式对象变化时,触发computed effect更新,不直接更新而是走这个调度函数,具体可以见上面effect实现的章节
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        // 响应式对象变化后,将computed的_dirty置为true,代表computed的数据脏了
        this._dirty = true
        // 触发所以使用了computed值的effect,即存在this.dep里的effect更新
        triggerRefValue(this)
      }
    })
  }

  get value() {
    // 收集所有使用了本computed值的依赖effect, 具体实现见 ref.js
    trackRefValue(this)
    // 使用computed值时,发现数据已脏,就调本触发computed的effect的run方法即调this._getter重新计算computed 值
    // 只有在数据脏了,也就是其关联的响应式对象发生变化后才重新计算,这样就能保证在多次使用computed值时,不用多次计算,节省性能
    if (this._dirty) {
      this._dirty = false
      // effect.run和直接调this._getter的区别在,effect会将本computed effect设置为全局的 activeEffect, 方便执行this._getter过程中的响应式对象收集 本computed effect作为依赖,具体实现见上面 effect 章节
      this._value = this.effect.run() 
    }
    // 返回computed的值
    return this._value

  }
  // 这里就是调用用户传的set,走用户的逻辑
  set value(newValue) {
    this._setter(newValue)
  }
}

至此实现代码完成,验证下:

const foo = reactive({
    a: 1
})
const bar = computed(() => foo.a + 1)

effect(() => {
   console.log('更新:' + bar.value)
})

// 首次打印 -> 更新:2

foo.a = 2
// 打印 -> 更新:3

好的,运行没问题。我们分析下它的具体运作流程:

  1. 生成 foo 响应式对象
  2. 调用 computed 生成 bar 计算属性,此时还没计算 computed 的值,但初始化时其 _dirty 值为true
  3. 创建 模拟渲染effect,首次渲染时,将此模拟渲染effect 赋值到全局activeEffect,渲染运行中使用了 bar.value,触发其 get value , 将 全局activeEffect也就是此模拟渲染effect收集到 computed dep 里,此时_dirty 为 ture, 那么就调effect.run() 计算值,并将_ditry 置为 false,意味着值是干净的。effect.run运行的过程中,设置此effect也就是computed effect为全局activeEffect,接着调this._getter其中使用到了foo.a 触发其get,便将此时的全局activeEffect也就是此computed effect收集到foo.a的依赖中去,然后计算值,返回值给bar.value接着首次打印更新:2
  4. 运行foo.a = 2 ,修改响应式数据,触发其依赖更新,其中就包括刚刚被收集的computed effectcomputed effect 由于初始化时传了调度函数,所以更新就是调用这个调度函数,此时将_dirty 置为true,代表computed的值脏了。然后触发computed 收集的依赖,也就是这里使用了computed 值的模拟渲染effect 更新,更新过程中又用到了computed的值,触发其get value,由于此时_dirtytrue,那么就调 computed effecteffect.run() 重新计算值返回给模拟渲染effect使用,这样就完成了一次更新

watchEffect

watch,watchEffect并不在vue3的reactivity 中,在vue3的设计reactivity这个响应式系统是可以直接单独对外使用的。而watch,watchEffect可以看做是针对vue3运行时的一个特别的effect,所以他们的代码被放到了runtime-core包里。

接下来先实现下watchEffect

先看用法:立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。

const count = ref(0)

const stop = watchEffect((onCleanup) => {
    onCleanup(() => {
        // count.value发生变化时触发,首次执行时不触发,
        // 可用来注册清理回调。清理回调会在该副作用下一次执行前被调用,可以用来清理无效的副作用,例如等待中的异步请求
        console.log('onCleanup')
    })    
    console.log(count.value)
}, /* options */) // 可选的options配置参数,用来调整副作用的刷新时机或调试副作用的依赖,主要是设置侦听器将在组件渲染之前执行,还是同步还是之后执行的,本文由于只讲响应式原理,不涉及渲染相关所以就不实现了。

// 首次打印 -> 输出 0

count.value++
// 打印 -> oncleanup
// 打印 -> 输出 1

// 当不再需要此侦听器时,可以停止侦听器
stop()
// 打印 -> oncleanup

count.value++
// 不在打印

可以看到watchEffect 相比前面的effect 主要就是多了个 onCleanup 和 返回了stop

实现代码:

// src/runtime-core/apiWatch.js

// 面向用户的 watchEffect
export function watchEffect(effect) {
  return doWatch(effect, null)
}

// 具体实现 源码里 watch , watchEffect 都是通过这个函数实现的,这会先讲 watchEffect
export function doWatch(source, cb, options = {}) {

  // 用于创建 effect 的 getter,这里是在执行 effect.run 的时候,也就是更新的时候就会调用的
  let getter = () => {
      // 执行clean的时机
      if (cleanup) {
          cleanup()
      }
      source(onCleanup)
  }

  // cleanup 的作用是为了解决初始化的时候不调用 fn(用户传过来的 cleanup)
  // 第一次执行 watchEffect 的时候 onCleanup 会被调用 而这时候只需要把 fn 赋值给 cleanup 就可以
  // 当第二次执行 watchEffect 的时候就需要执行 fn 了 也就是 cleanup  
  let cleanup
  const onCleanup = (fn) => {
    // 当 effect stop 的时候也需要执行 cleanup 
    // 所以可以在 onStop 中直接执行 fn
    cleanup = effect.onStop = () => {
      fn()
    }
  }

  // 更新时执行
  const job = () => {
    if (!effect.active) return;
    effect.run()
  }

  // 创建 effect
  const effect = new ReactiveEffect(getter, job)

  // 首次执行
  effect.run()

  // 返回stop
  const unwatch = () => {
    effect.stop()
  }

  return unwatch
}

可以看到watcheEffect就是调用new ReactiveEffect生成一个特殊的effect,然后处理下onCleanupstop

最后可以拿上面的用法去测试下,这里就不写了。

watch

先看用法:

watch() 默认是懒侦听的,即仅在侦听源发生变化时才执行回调函数。

第一个参数是侦听器的。这个来源可以是以下几种:

  • 一个函数,返回一个值
  • 一个 ref
  • 一个响应式对象
  • ...或是由以上类型的值组成的数组

第二个参数是在发生变化时要调用的回调函数。这个回调函数接受三个参数:新值、旧值,以及一个用于注册副作用清理的回调函数。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。

const count = ref(0)

watch(count, (newVal, oldVal, onCleanup) => {
   onCleanup(() => {
        console.log('onCleanup')
    })    
    console.log(newVal.value)
}, {
    immediate: true,
})

watch和vue2中的$watch用法基本一致。

修改doWatch方法实现:

// src/runtime-core/apiWatch.js
import { ReactiveEffect } from "../reactivity/effect.js"
import { isReactive } from "../reactivity/reactive.js"
import { isRef } from "../reactivity/ref.js"
import { hasChanged, isFunction } from "../utils/index.js"

const INITIAL_WATCHER_VALUE = {}

// 面向用户的 watchEffect
export function watchEffect(effect) {
  return doWatch(effect, null)
}
// 面向用户的 watch
export function watch(source, cb, options) {
  return doWatch(source, cb, options)
}

// 具体实现
export function doWatch(source, cb, options = {}) {

  let getter

  // 根据 watch 传的source类型设置 getter 函数
  if (isRef(source)) {
    getter = () => source.value
  } else if (isReactive(source)) {
    getter = () => source
    options.deep = true
  } else if (isFunction(source)) {
    
    if (cb) {
      // watch
      getter = () => source()    
    } else {
      // watchEffect
      getter = () => {
        if (cleanup) {
          cleanup()
        }
        source(onCleanup)
      }
    }
  }

 
  let cleanup
  const onCleanup = (fn) => {
    cleanup = effect.onStop = () => {
      fn()
    }
  }

  // 初始化时的oldValue, 借助{}和任何值都不全等的原理,用于新旧值对比
  let oldValue = INITIAL_WATCHER_VALUE
  
 // 更新时执行
  const job = () => {
    if (!effect.active) return;
    if (cb) {
      // watch
      // 获取值,并进行依赖收集
      const newValue = effect.run()
      // 设置了deep或者值不同时,调用 cb
      if (options.deep || hasChanged(newValue, oldValue)) {
        if (cleanup) {
          cleanup()
        }
        cb(
            newValue, 
            // 一开始oldValue是undefined
            oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue, 
            onCleanup
        )
      }
    } else {
      // watchEffect
      effect.run()
    }
  }

  const effect = new ReactiveEffect(getter, job)

  if (cb) {
    // watch
    // 设置 immediate 即首次执行一次
    if (options.immediate) {
      job()
    } else {
      // 首次获取一次值,并进行依赖收集
      oldValue = effect.run()
    }
  } else {
    // watchEffect
    effect.run()
  }

  const unwatch = () => {
    effect.stop()
  }

  return unwatch
}

watch 的原理就是,首次获取一个值,并进行依赖收集,收集当前的 effect, 当值发现变动时,触发该 effect 更新,重新进行一次值计算,然后新旧值对比,发生变化则调用用户传的 cb

effectScope

最后讲讲effectScope副作用作用域这个vue3新增的概念,相信很多人对这个不太熟悉,其实原理并不复杂。

官网介绍: effectScope 创建一个 effect 作用域,可以捕获其中所创建的响应式副作用 (即计算属性和侦听器),这样捕获到的副作用可以一起处理。

示例:

const scope = effectScope()
const counter = ref(1)

scope.run(() => {
  const doubled = computed(() => counter.value * 2)

  watch(doubled, () => console.log(doubled.value))

  watchEffect(() => console.log('Count: ', doubled.value))
})

// 处理掉当前作用域内的所有 effect
scope.stop()

说白了就是,在 effectScope 的run里定义的 effect 都可以被effectScope收集并统一注销。

配套的还有onScopeDispose() 方法,用于在当前活跃的 effect 作用域上注册一个处理回调函数。当相关的 effect 作用域停止时会调用这个回调函数。

示例:

function onScopeDispose(fn: () => void): void

实现:

// src/recativity/effectScope.js

// 面向用户的 effectScope 方法
export function effectScope() {
  return new EffectScope()
}

let activeEffectScope // 全局变量用来存储当前正在运行的 effectScope

class EffectScope {

  _active = true  // 当前effectScope是否激活
  effects = [] // 存储当前effectScope收集到的effect
  cleanups = [] // 通过onScopeDispose注册的,作用域停止时回调函数存放处

  constructor() {}	

  // 对外获取激活状态用 active
  get active() {
    return this._active
  }
  
  // 调实例的run方法时,做收集effect动作
  run(fn) {
    if (this._active) {
      // 存一下外层的effectScope
      const lastEffectScope = activeEffectScope
      try {
        // 设置当前effectScope 为 全局的activeEffectScope
        activeEffectScope = this
        // 执行fn, 里面就会创建 effect, 创建的同时通过全局变量activeEffectScope将创建的effect收集起来, 所以这里需要 改下 effect 的创建逻辑,下面处理
        return fn()
      } finally {
        // 将外层的effectScope赋回去
        activeEffectScope = lastEffectScope
      }
    }
  }

  stop() {
    if (this._active) {
      // 依次销毁所收集的 effect 
      for (let i = 0; i < this.effects.length; i++) {
        this.effects[i].stop()
      }
      // 依次调用销毁时的回调
      for (let i = 0; i < this.cleanups.length; i++) {
        this.cleanups[i]()
      }
      // 设置当前作用域为非激活状态
      this._active = false
    }
  }
}

// 收集effect到effectScope的具体方法
export function recordEffectScope(effect, scope) {
  // 未传scope即是当前正在运行的 effectScope
  if (!scope) scope = activeEffectScope
  if (scope && scope.active) {
    // 收集
    scope.effects.push(effect)
  }
}
// 对外暴露的方法
export function getCurrentScope() {
  return activeEffectScope
}

// 给当前正在运行的 effectScope 添加,销毁时的回调
export function onScopeDispose(fn) {
  if (activeEffectScope) {
    activeEffectScope.cleanups.push(fn)
  } else {
    // 没有正在运行的 effectScope则提示
    console.warn(`onScopeDispose() is called when there is no active effect scope to be associated with.`)
  }
}
// src/reactivity/effect.js

export class ReactiveEffect {
  // 省略其他代码
  constructor(fn, scheduler, scope) {
    // 创建effect时,将该effect收集到 scope 中,没传 scope 即 当前正在运行的effectScope 即 activeEffectSccope
    recordEffectScope(this, scope)
  }

大致上就是,调用effectScoperun 方法时,现将当前effectScope设置到全局变量activeEffectScope,然后运行 run上传进来的fnfn 中创建的effect均在初始化时,就被收集到了activeEffectScope中也就是现在运行runeffectScope。最后调用其stop则可以根据收集到的effect一次性注销掉。

其实源码中effectScope还有是否是独立作用域的概念,如果是非独立的作用域,则一销毁大家收集的effect 同时销毁,这里就不实现了,有兴趣的可以自行阅读源码。

总结

就像上面说的,vue3的响应式系统相对vue2的,功能更强大的同时,心智负担也更大,想要完全理解也更难,这里我们手写了个简易的响应式系统,还有许多功能未能,包括数组length重复触发的问题,以及集合类型数据的响应式,has delete 的监听等等。有兴趣的可以自行阅读源码。

最后附上 代码地址