深入理解 Vue3 响应式原理

1,557 阅读15分钟

写前小述

关于自己的前端工作经历:目前大四,大三在字节实习四个月左右参加转正答辩,秋招参加多家公司面试成功上岸。在准备面试过程中,框架方面 Vue3 响应式是自己给自己划的重点。自己借助网上文章教程和源码搭配学习,并简单实现过 Vue3 Reactivity,只实现了主要功能,最后也能应付面试(其实面试问的也不多 x)。但总觉得自己掌握的不好,知识点零散,容易遗忘。思考了一下原因,自己在看这块代码的时候,更多的时候是去看别人的文章,没有主动学习,没有去思考代码为什么会这样实现。而根据费曼学习法,教给别人学习留存率才是最高的。

现在已经是2022年了,Vue3 Reactivity 甚至 Vue3 源码解析的文章课程都不胜枚。最近Vue3 也已经更名为core, Vue3 将成为默认版本。 现在自己发布 Vue3 Reactivity 源码解析文章未免显得有点落伍。

但本着利己利他的原则,还是想写这篇文章,在自己写文章的过程中,对”输出倒逼输入“也深有体会。 希望自己的第一篇对外文章能为自己的输出计划开个好头。

总述

Vue3 之所以看上去代码体量大,是因为要保证应用到现实业务中的稳定性和功能性,同时会添加一些利于开发的调试代码等。其实要实现一个简易的 Vue3 Reactivity 是很简单的,如果不考虑不同场景和边界条件的话。尤大也有一个亲自实现 mini-vue 的视频,大家感兴趣的可以去看一下。跟尤雨溪一起解读Vue3源码【中英字幕】- Vue Mastery哔哩哔哩bilibili

Vue3 有三个核心模块: reactivity module, render module, compiler module。Vue3 使用 monorepo 进行代码组织管理之后,reactivity 作为一个单独的模块,有利于我们能更集中的去了解 reactivity 的实现,不用去理会 runtime 和 render。

响应式其实是在强调一种自动更新,事物之间保持同步的思想。

例如,我们在代码中声明一个对象 target, 当target 更改时, 响应式系统可以自动的做一些什么,比如上传更改日志,来保持数据和日志的同步性。在 Vue3 响应式系统中会这样写:

import {reactive , effect} from 'vue';

const target = { info : 'vue3'}
const state = reactive(target)

effect(() => {
    console.log('info changed', state.info)  // 模拟上传更改日志函数
})

state.info = 'core'

在 Vue3 响应式系统中, 会将原对象 (target) 转化成响应式对象(state) ,之后都是对响应式对象进行操作;我们会把保持同步操作的函数,例如上传更改日志、页面渲染等函数,传递给 effect, 这样effect 就能知道函数使用了哪些值,从而实现在值更改的时候执行函数。我们把 effect(fn) 称作副作用函数。

在JavaScript 中,利用Proxy对操作进行拦截,执行副作用函数;同时结合Reflect完成默认行为,对原始对象正常更改,是实现响应式的基础。

要想实现这一个功能需要做两件事:

  • 追踪观察响应式对象
  • 响应式对象改变,执行副作用函数

整个流程如下:1. 初始化响应式对象 - 2. 副作用函数执行,读取响应式对象,建立响应式对象与副作用函数的依赖关系 - 3. 响应式对象改变,执行对应副作用函数。之后2与3循环。

接下来我们来看一下代码中是如何实现的

reactive

首先,我们来看一下 reactive 的实现,也就是完成第一步:初始化响应式对象。

顺带提一下 readonly , shallowReactive, shallowReadonlyreadonly 是把一个对象/响应式对象/ref 转化为只读对象,不可更改。 由于对象的属性可以嵌套, readonly, reactive的代理操作都是深层的。shallow 翻译为浅的, shallowReadonlyshallowReactive 对应的只读代理和响应式代理只会对对象的第一层属性有效。

以上, 我们发现在响应式系统中存在多种类型的对象,可以通过下列标识进行区分

export interface Target {
  [ReactiveFlags.SKIP]?: boolean  // 为true 可以跳过响应式操作
  [ReactiveFlags.IS_REACTIVE]?: boolean  // 为true 响应式对象
  [ReactiveFlags.IS_READONLY]?: boolean // 为true 只读对象
  [ReactiveFlags.RAW]?: any // 为true 原对象
}

在 reactive 函数会有一个判断,不允许 reactive(readonlyObj) 的行为。

if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
    return target
}

reactive 的核心就是得到一个 target 的 Proxy对象,在实例化 Proxy 时传入的handler对象能进行拦截操作,与effect 结合实现响应式。

function createReactiveObject(
    target,
    isReadOnly,
    baseHandlers,
    collectionHandlers,
    proxyMap
){
   //  只接受对象
   if(!isObject(target)){
      return target
   }
   
   // 如果target 已经是Proxy 直接返回, 但是允许 readonly(reactive)
   if (
      target[ReactiveFlags.RAW] &&
      !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
   ) {
      return target
   }
      
   const existingProxy = proxyMap.get(target)
   if (existingProxy) {
     return existingProxy
   }
   
   const targetType = getTargetType(target)
   if (targetType === TargetType.INVALID) {
     return target
   }
   
   // Proxy 实例化
   const proxy = new Proxy(
     target,
     targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
   )
   
   proxyMap.set(target, proxy)
   return proxy   
}

handler是实现拦截功能的入口。baseHandlers collectionHandlers 是为了区分不同类型的对象而编写的,主要分为COMMON COLLECTION 两大类。在实例化Proxy时,会判断target 的类型,从而传入不同的handler。 我们可以先只关注baseHandlers。

function targetTypeMap(rawType: string) {
  switch (rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION
    default:
      return TargetType.INVALID
  }
}

baseHandlers

接着我们来看一下 baseHandlers。

响应式对象主要对target 代理了get , set , deleteProperty , has , ownKey 五种拦截器,涵盖了常用的读取和修改对象属性值的场景。下面以get 和 set 为主来讲解。

get

get -> createGetter

function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {
    // 细节一
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    } else if (key === ReactiveFlags.IS_SHALLOW) {
      return shallow
    } else if (
      key === ReactiveFlags.RAW &&
      receiver ===
        (isReadonly
          ? shallow
            ? shallowReadonlyMap
            : readonlyMap
          : shallow
          ? shallowReactiveMap
          : reactiveMap
        ).get(target)
    ) {
      return target
    }
​
    const targetIsArray = isArray(target)
​
    // 数组方法拦截
    if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }
​
    // Reflect.get 完成默认操作,得到结果
    const res = Reflect.get(target, key, receiver)
​
    // 如果是非readonly, 就进行track
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }
​
    if (shallow) {
      return res
    }
​
    // 细节二:ref解构
    if (isRef(res)) {
      // ref unwrapping - does not apply for Array + integer key.
      const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
      console.log('ref',res,shouldUnwrap, !targetIsArray ,!isIntegerKey(key))
      return shouldUnwrap ? res.value : res
    }
​
    // 细节三:递归响应
    if (isObject(res)) {
      // Convert returned value into a proxy as well. we do the isObject check
      // here to avoid invalid value warning. Also need to lazy access readonly
      // and reactive here to avoid circular dependency.
      return isReadonly ? readonly(res) : reactive(res)
    }
​
    return res
  }
}

细节1: 为了实现 isReadonlyisReactiveisProxy这几个方法,会通过读取value [ReactiveFlags.IS_REACTIVE]value [ReactiveFlags.IS_REACTIVE] , value [ReactiveFlags.RAW]进入get 拦截操作, 通过isReadonly 参数返回结果。这样就不会在响应式对象添加额外的属性,实现的很巧妙。

细节2: 如果取出的值是ref, 就直接返回 ref.value。 具体原因可以看这个issue

细节3: 如果属性取值是对象, 就继续对取出的对象 readonly 或者是 reactive。这里和 Vue2 不同, Vue2 是在一开始就对对象进行递归追踪,在这里是有 get 属性取值,也就是属性值被 access 了,才对属性取值结果进行代理操作, 没有 access 的属性值,也就没有必要对其进行代理操作,这样性能更优。同样我们也可以注意到,如果是 shallow , 在取到第一层属性值之后就直接返回了,不会对深层属性进行代理操作。

track 函数的具体实现, 我们留在之后再来详解。我们现在只需要知道,当我们执行副作用函数时, 会获取响应式对象,触发 get 拦截,执行track函数。 在track 函数中,就能够建立响应式对象和副作用函数的依赖关系。

set

Set -> CreateSetter

function createSetter(shallow = false) {
  return function set(
    target,
    key,
    value,
    receiver
  ){
    let oldValue = target[key]
    if (isReadonly(oldValue) && isRef(oldValue) && !isRef(value)) {
      return false
    }

    // key 大于数组length 就表示新增
    const hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key)
    const result = Reflect.set(target, key, value, receiver)
    // don't trigger if target is something up in the prototype chain of original
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

当我们修改响应式对象时就会触发set 操作,执行trigger函数。在这里会注意到, 有多种trigger操作类型: set, add,clear,delete; 分别对应属性更改,添加,清除和删除四种操作。

trigger 函数留在之后详解,我们现在只需知道,在trigger函数中,会执行在track时收集到的副作用函数。

effect

接下来我们来看 effect。effect (fn,options) 接收函数 fn和options 对象作为参数。options 作为配置信息,也可以不传。

在不传options 的情况下,effect 会立即执行 fn , 读取响应式对象,触发响应式对象上的get 拦截操作,从而执行track 函数,建立依赖关系。

带入源码中来看:

export function effect(fn,options){
  if (fn.effect) {
    fn = fn.effect.fn
  }

  const _effect = new ReactiveEffect(fn)
  if (options) {
    extend(_effect, options)
    if (options.scope) recordEffectScope(_effect, options.scope)
  }
  if (!options || !options.lazy) {
    _effect.run()
  }
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}
export class ReactiveEffect {
  active = true
  deps = [] // effect的依赖数组

  constructor(fn,scheduler,scope) {
    recordEffectScope(this, scope)
  }

  run() {
      /** */
  }

  stop() {
    if (this.active) {
      cleanupEffect(this)
      if (this.onStop) {
        this.onStop()
      }
      this.active = false
    }
  }
}

在effect 中, 会通过 ReactiveEffect类 , 将 fn 转化有状态的 ReactiveEffect 实例,并实现了run和stop 两个方法。

在没有options 或者options.lazy 为false 的情况下, 执行 _effect.run , 设置activeEffect = this, 执行fn。

我们来具体看一下,ReactiveEffect 上的 run 方法:

下列的 run 方法是3.2 优化之前的版本,省去了位运算优化的部分,更容易理解。

run() {
        if (!this.active) {
            return this.fn()
        }
        if (!effectStack.includes(this)) {
            activeEffect = this
            try {
                effectStack.push(this)
                cleanupEffect(this)
                return this.fn()
            } finally {
                effectStack.pop()
                const n = effectStack.length
                activeEffect = n > 0 ? effectStack[n-1] :undefined
            }
        }  
    }

可以看到,执行run方法之后, 会将当前实例ReactiveEffect 设置为activeEffect , 添加到effectStack, 并执行 fn 方法,最后effectStack 出栈,更新activeEffect。

为什么要设置全局变量activeEffect 呢?

在设置了全局变量activeEffect之后, 会马上执行 fn, 在fn 中, 会获取响应式对象属性值 key - value, 触发了get 的拦截操作, 执行track 函数。

track

我们来看track函数做了什么。

ecport function track(){
​
 if (!isTracking()) {
    return
  }
  
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
​
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = createDep()))
  }
  
  trackEffects(dep)
}

track 函数中,会首先判断 isTracking ,如果不是,就直接返回。接着出现了两个Map结构, targetMap 和 depsMap 我们通过图示来展示他们之间的关系:

截屏2022-01-30 下午4.52.48.png

通过depsMap 获取到 key 对应的dep , 然后 trackEffects 函数会将activeEffect 放进dep 中, 因此这样就能将 key 与 fn 对应起来,构成依赖关系。

所以,如果我们不设置activeEffect , 我们就无法知道当前 key 所对应的 effect 是哪一个。

现在我们的第二步也做好了。现在我们来看第三步, 响应式对象改变,执行对应副作用函数。

trigger

state.info = 'core' 会触发set 拦截, 从而执行trigger 函数。

在trigger 函数中, 可以看到,会根据TriggerOpTypes 对deps 进行不同的操作, 最后会统一通过 triggerEffects 执行副作用函数。

export function trigger(
  target,type,key,newValue) {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }

  let deps = []
  if (type === TriggerOpTypes.CLEAR) {
    deps = [...depsMap.values()]
  } else if (key === 'length' && isArray(target)) {
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= newValue) {
        deps.push(dep)
      }
    })
  } else {
    // schedule runs for SET | ADD | DELETE
    if (key !== void 0) {
      deps.push(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE | Map.SET
    switch (type) {
      case TriggerOpTypes.ADD:
        if (!isArray(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        } else if (isIntegerKey(key)) {
          // new index added to array -> length changes
          deps.push(depsMap.get('length'))
        }
        break
      case TriggerOpTypes.DELETE:
        if (!isArray(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        break
      case TriggerOpTypes.SET:
        if (isMap(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }


  if (deps.length === 1) {
    if (deps[0]) {
        triggerEffects(deps[0])
    }
  } else {
    const effects = []
    for (const dep of deps) {
      if (dep) {
        effects.push(...dep)
      }
    }
  }
    
}

执行副作用函数又会触发track ,重新建立依赖关系,如此重复。

为什么要在每次执行副作用函数之前,清除副作用函数的依赖,也就是 cleanupEffect?

例如在这样一个场景下:

const ok = reactive({value:true})
const  msg = reactive({value: 'vue3'})
​
effect(() => {
    if(ok.value){
        console.log('msg value', msg.value)
    }else{
         console.log('false branch')
    }
    
})

首次执行effect, ok 和 msg 都会作为该effect的依赖, 此时会打印 msg 的值。

然后更改 ok.value = false , 执行trigger会再次执行effect.run,这时会清除effect之前的依赖。接着执行fn,effect 只会收集 ok 作为依赖。msg 已经不再是副作用函数的依赖。接着改变 msg. value,是不会再有effect执行。但是如果不清除,msg 还是 effect 的依赖,msg 改变,还是会执行effect, 从而走到 false brach 分钟中,是不符合预期的。因此在我们第二次执行副作用函数之前,需要先借助cleanupEffect清除依赖。

关于cleanupEffect 的实现

function cleanupEffect(effect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

我们可以回忆到在实例化 ReactiveEffect 时候也会初始化一个dep数组,在 track 的时候会建立 key 与 effect 的依赖关系 dep,这个依赖关系是双向的,我们也会向 ReactiveEffect 的deps数组中push dep。这样就能在cleanupEffect 当中,清除上次track建立的dep关系。

但其实effect 前后不一致的依赖的情况还是少见, 因此 Vue3.2 使用位运算进行了优化。

为什么需要一个全局变量 effectStatck ,并设计成栈结构。

主要是考虑到effect 嵌套的情况, 例如以下场景:

const counter = reactive({ 
    num1: 0, 
    num2: 0 
}) 
​
function logCount1() { 
    effect(logCount2) 
    console.log('num1:', counter.num1) 
} 
​
function logCount2() { 
    console.log('num2:', counter.num2) 
} 
​
effect(logCount) 
counter.num++

我们每次执行 effect 函数时,如果仅仅把 ReactiveEffect 赋值给 activeEffect,那么针对这种嵌套场景,执行完 effect(logCount2) 后,activeEffect 还是 effect(logCount2) ,这样接着访问 counter.num 的时候,num1 依赖收集对应的 effect 就是 effect(logCount2),但实际上num1 对应的 effect 是 effect(logCount1)。此时我们外部执行 count 函数修改 counter.num1 后执行的便不是 logCount1 函数,而是 logCount2 函数,最终输出的结果如下,不符合预期。

// bad
num2: 0 
num: 0 
num2: 0// expected
num2: 0 
num: 0 
num2: 0 
num: 1

因此针对嵌套 effect 的场景,我们不能简单地赋值 activeEffect,应该考虑到函数的执行本身就是一种入栈出栈操作,因此我们也可以设计一个 effectStack,这样每次进入 reactiveEffect 函数就先把它入栈,然后 activeEffect 指向这个 reactiveEffect 函数,接着在 fn 执行完毕后出栈,再把 activeEffect 指向 effectStack 最后一个元素,也就是外层 effect 函数对应的 reactiveEffect。

该示例参考: 拉勾教育 - 黄轶 - Vue.js 3.0 核心源码解析

但是在最新的Vue 中, 栈结构也进行了优化, 有兴趣的可以去看看。

数组处理

接下来我们来看看使用Proxy 处理数组是怎样的流程以及会遇到什么问题。

首先, 我们来看几个demo , 注意观察打印结果

const raw = [1, 2, 3, 4]
​
const result = new Proxy(raw, {
  get(target, key) {
    console.log('get',key, Reflect.get(target, key))
    return Reflect.get(target, key)
  },
​
  set(target, key,value) {
    console.log('set', key, value)
    return Reflect.set(target, key, value)
  }
})
​
result[0]
// 打印结果
// get 0
​
​
​
result.push(5)
// 打印结果
// get push f push {}
// get length 4
// set 4 5
// set length 4

我们可以看到, 当通过下标访问数组时, key 就是数组下标;当我们通过push 改变下标时,会有两次 get和 set。

因此我们带入响应式当中, 先暂时忽略当前代码

if (!isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
}
const raw = [1]
const result = reactive(raw)
​
​
effect(()=>{
    result.forEach((i)=>{
        console.log('假装渲染函数 触发数组收集依赖',i)
    })
})
​
result.push(2)

当首次执行effect 时, 其实会触发三次get , 其key 分别是 forEach,length,和下标 0 。 与push 同理。

因此我们最后收集的depsMap 就是这样:

截屏2022-01-30 下午4.57.41.png

当我们要进行push 的时候 , 其实在push 的前两次get 是不能进行track 的 因为此时的activeEffect 是 undefined 。然后是两次set , 我们在set 1 的时候发现key 是新增, 传给trigger add 的类型标识。

// 当key 大于 length 的时候说明是新增
const hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key)

接着到trigger 的逻辑。 进入 add 的分支, 新增的索引是没有对应的depsMap 的, 因此也不会有相应的dep。所以会使用 length 对应的 effect , 也就是我们刚刚在forEach 的时候track 得到的。 因此在set resulu[1] = 2 的时候, 会让 effect 重新执行,这样也就实现了 数组在使用push的方法 , 能感知变化, 实现响应式。

可能大家会有个疑问,为什么数组add 的情况,要使用length 对应的副作用函数。 其实这些数组方法 push、pop、shift、unshift 其实都是会改变数组长度的办法,会影响到遍历函数的结果。而 forEach、map、for 这些循环遍历函数 都会 get length,收集依赖 。从而我们以length 这个key 作为桥梁,来实现同步。

通常情况下,我们都是会遍历数组渲染页面的。

接下来我们来讨论为什么会有我们刚刚忽略的代码。

如果是非只读的数组, 会拦截两种数组方法,第一类是includesindexOflastIndexOf,第二类是 pushpopshiftunshiftsplice

我们先来看第二类数组方法。 为什么在能够实现响应式的同时还要拦截这些数组方法呢,

在这个issue 可以找到答案,我们可以在调试Vue 源码的时候,可以先将数组方法拦截的代码注释掉,打断点观察结果。

issue 中为我们展示了这样一个场景

const arr = reactive([])
​
watchEffect(()=>{
  arr.push(1)
})
​
watchEffect(()=>{
  arr.push(2)
})

我们可以发现,如果存在两个effect , 在两个effect 当中,如果对响应式数组进行push或者其他改变数组长度的方法,就会引起无限循环。

我们来稍微深究下,由于 watchEffect 在 runtime-core 当中,不在reactivity 当中,我就将watchEffect 换成 effect来说明。watchEffect 之所以无限循环还因为它涉及effect 的调度,替换后虽然不会无限循环,但还是可以看出混乱的结果。

const arr = reactive([])
​
// effect1
effect(()=>{
  arr.push(1)
}) 
​
// effect2
effect(()=>{
  arr.push(2)
})

简单分析下,在执行effect1时, 会有两次get 和 set , get push 收集effect1, get length 收集effect1 。在set arr[0] = 1 时,会加入length 对应的effct , 但由于activeEffect === effect1 就不会执行,set length 时会因为 hasChanged(value, oldValue) === false 也不执行。

在执行effect 2 是, 会有两次get 和 set , get push 收集effect2, get length 收集effect2,此时 lengh 对应的effect 有effect1 和 effect2。 在set arr[1] = 2时,会加入length 对应的effct , 在遍历effect 数组,执行effect 时便会再次执行effect1。

同理在执行effect1时,会再次执行effect2......

所以我们可以看到, 根本原因是因为在push时会get到lengh, 收集到依赖。 其实issue 中的用法本身也有点问题, effect 使用规则就是在effect 副作用函数不能去更改依赖的值。

我们来看拦截方法的具体实现:

;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
    instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
      pauseTracking()
      const res = (toRaw(this) as any)[key].apply(this, args)
      resetTracking()
      return res
    }
  })

关键就是 pauseTrackingresetTracking 。 在 pauseTracking 中设置shouldTrack为false , 就不会为length track 依赖。

因此在 set 时, 也不会触发到 push 方法所在的effect。在push之后,才会resetTracking 设置shouldTrack为true

而对于includesindexOflastIndexOf 三种方法, 由于它们会接受索引值作为参数,如果他们的执行在effect 包裹函数中,它们就需要在任一索引值改变或者长度改变时重新执行。因此使用for 循环+track,effect 便可以将 索引值和length 作为其依赖,追踪变化。

;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
    instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
      const arr = toRaw(this) as any
      for (let i = 0, l = this.length; i < l; i++) {
        track(arr, TrackOpTypes.GET, i + '')
      }
      // we run the method using the original args first (which may be reactive)
      const res = arr[key](...args)
      if (res === -1 || res === false) {
        // if that didn't work, run it again using raw values.
        return arr[key](...args.map(toRaw))
      } else {
        return res
      }
    }
  })

ref

ref(target) 接受一个内部值并返回一个响应式且可变的 ref 对象。ref 常常用在转换 string, number 这类基本类型的值上,由于reactive 只能用于转换对象,但如果把基本类型转换成对象再处理就会显得比较僵硬和麻烦。而ref可以解决这个问题。

来看一下ref的内部实现:

class RefImpl{
  _value = undefined
  _rawValue = undefined
  dep= undefined
  __v_isRef = true
​
  constructor(value,__v_isShallow) {
    this._rawValue = __v_isShallow ? value : toRaw(value)
    this._value = __v_isShallow ? value : toReactive(value)
  }
​
  get value() {
    trackRefValue(this)
    return this._value
  }
​
  set value(newVal) {
    newVal = this.__v_isShallow ? newVal : toRaw(newVal)
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      this._value = this.__v_isShallow ? newVal : toReactive(newVal)
      triggerRefValue(this, newVal)
    }
  }
}

Proxy 只能代替对象, 因此ref 便不再使用 Proxy , 而是选定 value 作为默认属性,结合 getter 和 setter 访问器属性,完成track 和 trigger 操作。

而ref 和 reactiveEffect 的依赖关系则是直接挂载在ref 实例的dep 属性上,没有再单独去构造一个Map。

computed

computed 函数计算属性, 用于依赖其他状态计算的时候。它接受 getter 函数并为 getter 返回的值返回一个不可变的响应式 ref 对象,或者,它可以使用一个带有 get 和 set 函数的对象来创建一个可写的 ref 对象。

首先来简单的看一下他的用法:

第一种场景:

const count = ref(1)
​
const plusOne = computed(() => count.value + 1)
​
​
conut.value++
count.value++
count.value++
​
​
console.log('plusOne.value', plusOne.value) // 5 ( 只会打印一次)
plusOne.value++ // error : 这是因为没有传入set 函数,所以不能直接对其更改

count.value 改变了三次,plusOne.value 打印了最后一次的结果 5

第二种场景:

const count = ref(1)
​
// plusOne -> computedRef
const plusOne = computed(() => count.value + 1) // computed(getter)
​
​
​
// computedEffect
effect(()=>{
    console.log('count value', plusOne.value) 
})
​
​
conut.value++
count.value++

第一次 effect 首次执行的时候, 打印出 pluOne.value 的值为2 。当 count 的值更改时,与plusOne 相关的 effect 也执行了,并打印出plusOne.value 的值为3。当 count的值再次更改时,与plusOne 相关的effect 同样执行,并打印出plusOne.value 的值为4.

我们可以看到computed 也是返回一个ref 。当我们连续改变count的值,getter 函数不是随之连续执行的。只有当我们通过 value 属性访问plusOne的时候,会执行getter 函数,计算出正确的值。 但是我们看到第二种情况, 由于plusOne 依赖count , 只要改变了count 的值,就会触发pluOne 的副作用函数。

因此我们可以猜想到 computed 的机制是: computed getter 函数只有在通过 value 属性访问的时候才会执行, 但 computed 依赖的值改变会触发 computed 的副作用函数,例如这里的count 改变时会触发plusOne 的副作用函数。

来看一下源码内部的实现

export class ComputedRefImpl{
  dep = undefined
  _value = undefined
  effect = undefined
  __v_isRef = true
  _dirty = true
​
  constructor(getter) {
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        this._dirty = true
        triggerRefValue(this)
      }
    })
    this.effect.computed = this
  }
​
  get value() {
    const self = toRaw(this)
    trackRefValue(self)
    if (self._dirty || !self._cacheable) {
      self._dirty = false
      self._value = self.effect.run()
    }
    return self._value
  }
​
  set value(newValue) {
    this._setter(newValue)
  }
  
}

我们可以看出, computed 其实就是一个改造过的ref。 其中实例上的dirty 表示脏值,当dirty 为true 时表示依赖的值更改过,需要重新执行getter 函数。 如果没有更改过,就直接用实例上的属性 _value 值。 相当于是一种缓存机制,不用重复去执行getter 函数。

回到刚刚提到的问题: 为什么在computed 依赖的值改变会触发 computed 的副作用函数?

我们结合场景二来分析, 我们把读取plusOne 的effect统一称为computedEffect 。其实在computed 实例初始化时, 会将getter函数 实例化 ReactiveEffect 。

在第一次获取computedRef(plusOne) 的值的时候,会去收集 computedEffect,并执行ReactiveEffect(getter) 函数。 在执行ReactiveEffect(getter) 函数中,会读取count的值,因此getter 函数也成为了 依赖值(count)的effect。

截屏2022-01-30 下午5.10.47.png

于是当count 进行更改时, 会进行trigger , 对于ReactiveEffect(getter) , 因为有 scheduler 参数, 所以会执行schedule 函数, 在schedule 函数中, 会将dirty 设为true , 除了设置dirty 之外, 还会去执行之前 computedRef 收集到的 computedEffect。

最后

解析 Vue3 Reactivity 原理就暂时写到这吧,希望对大家有帮助。

这是我第一次对外输出文章,欢迎大家评论建议。

Refs

  1. 拉勾教育 - Vue.js 3.0 核心源码解析
  2. 细说 Vue.js 3.2 关于响应式部分的优化
  3. Vue3 源码分析(1):响应式原理