前言

在上一篇在实战项目中邂逅Vue3.0的分享中,我们了解并学习了 Vue3 的新特性,以及对比了在实战项目开发中相比 Vue2 的开发优势,也同时零零碎碎的了解了一些源码的实现方式,那么今天这篇文章就让我们系统的学习下 Vue3 的响应式源码实现,包括它常用的一些响应式 api ,如ref, shallowRef, toRef, toRefs, reactive, readonly, shallowReactive, shallowReadonly, effect, computed的代码编写技巧,以及最核心的依赖收集派发更新
顺便提一句,在 Vue3 源码中,使用 TypeScript做类型校验,那么我们为了兼顾所有同学对核心源码的理解成本和阅读成本,基本抛掉ts的类型校验,来学习 Vue3 响应式核心源码。

项目结构

  1. ├──packages
  2. ├── compiler-core # 与平台无关的编译器核心
  3. ├── compiler-dom # 针对浏览器的编译模块
  4. ├── compiler-sfc # 针对 Vue 单文件解析
  5. ├── compiler-ssr # 针对服务端渲染的编译模块
  6. ├── reactivity # 响应式系统
  7. ├── runtime-core # 与平台无关的运行时核心
  8. ├── runtime-dom # 针对浏览器的运行时。包括 DOM API, 属性,事件处理等
  9. ├── vue # 完整版本,包括运行时和编译器
  10. ├── shared # 多个包之间共享的内容
  11. ├── ...

Vue3 是使用 Monorepo 的方式管理所有的模块,可以看到,模块拆分的非常清晰,模块相对独立,我们可以单独引用 reactivity 这个模块,也可以引用 compiler-sfc 在我们自己开发的 plugin 中去使用它,例如编译template模版的 vue-loader。
接下来我们就正式开始这篇文章的主题,分析 Vue3 的响应式源码,重点关注的就是packages中的reactivity这个模块包。这里建议大家最好提前clone一份 Vue3 的整个源码项目到本地,参照着去学习~

响应式模块的目录结构

  1. src
  2. ├─baseHandlers.ts # 处理数组、对象的getter/setter
  3. ├─collectionHandlers.ts # 处理Set/Map等的getter/setter
  4. ├─computed.ts # 计算属性computed的逻辑在这里
  5. ├─effect.ts # 依赖收集和派发更新的核心
  6. ├─index.ts # 响应式 api 统一导出文件
  7. ├─operations.ts # 操作行为声明文件,如:get/set/add
  8. ├─reactive.ts # 处理引用类型的响应式逻辑在这里(proxy)
  9. ref.ts # 处理普通类型的响应式逻辑在这里

响应式之reactive

首先从它的入口文件index.ts中可以看到,所有对外暴露的 api 都在这里统一导出。

  1. // index.ts
  2. export {
  3. reactive,
  4. readonly,
  5. shallowReactive,
  6. shallowReadonly
  7. } from './reactive'
  8. export {
  9. effect
  10. } from './effect'
  11. export {
  12. ref,
  13. shallowRef,
  14. toRef,
  15. toRefs
  16. } from './ref'
  17. export {
  18. computed
  19. } from './computed'

那么,我们先来看下reactive, readonly, shallowReactive, shallowReadonly这几个响应式 api。

reactive, readonly, shallowReactive, shallowReadonly源码分析

关于这几个响应式 api 的用法, 大家不了解的话可以看下Vue官网(opens new window),这里简单介绍下它们之间的区别:

  • reactive 代理的数据是深度的
  • readonly 代理的数据是只读的,永远不可更改
  • shallowReactive 只有代理数据的第一层是响应式的
  • shallowReadonly 只有代理数据的第一层是只读的

这几个 api 都是在 reactive.ts 这个文件中声明并向外暴露的,如下:

  1. export function reactive (target) {
  2. return createReactiveObject(target, false, mutableHandlers)
  3. }
  4. export function readonly (target) {
  5. return createReactiveObject(target, true, readonlyHandlers)
  6. }
  7. export function shallowReactive (target) {
  8. return createReactiveObject(target, false, shallowReactiveHandlers)
  9. }
  10. export function shallowReadonly (target) {
  11. return createReactiveObject(target, true, shallowReadonlyHandlers)
  12. }

可以看到,vue源码在这里是创建了一个createReactiveObject函数通过传入不同参数来调用实现不同的处理逻辑。所以createReactiveObject这个函数才是数据代理(proxy)实现响应式的核心。

  1. const reactiveMap = new WeakMap() // 响应式缓存表
  2. const readonlyMap = new WeakMap() // 只读式缓存表
  3. // 响应式函数(核心)
  4. export function createReactiveObject (target, isReadonly, baseHandlers) {
  5. if (!isObject(target)) {
  6. return target
  7. }
  8. const proxyMap = isReadonly ? readonlyMap : reactiveMap
  9. const existingProxy = proxyMap.get(target)
  10. if (existingProxy) {
  11. return existingProxy
  12. }
  13. const proxy = new Proxy(target, baseHandlers)
  14. proxyMap.set(target, proxy)
  15. return proxy
  16. }
  1. 这个函数首先判断,代理的数据源是否是对象类型,因为 ES6 的 proxy 只能代理引用类型的数据,所以这里判断如果不是引用类型,就直接返回原数据。
  2. 接着根据是否是只读数据区分出不同的缓存表进行缓存操作。这里作者认为已经代理过的数据,没必要进行二次代理,也就是说同一份数据被多次代理,都应该是一份数据,所以这里用到WeakMap来缓存已经代理过的数据源。
  3. 最后这个函数的返回值就是一个被代理过后的数据实例。
    1. return new Proxy(target, baseHandlers)
    上面代码可以看出,具体数据的get, set操作都是分发在不同的参数列表中的, 这样我们就可以把数据get, set操作的逻辑主要集中在下面这几个导入的对象中:
    1. import {
    2. mutableHandlers,
    3. readonlyHandlers,
    4. shallowReactiveHandlers,
    5. shallowReadonlyHandlers
    6. } from './baseHandlers'

    数据getter

    由此,找到baseHandlers.ts,我们首先来看数据的getter操作: ```css import { extend, isObject, isArray, isIntegerKey, hasOwn } from ‘@vue/shared’ import { TrackOpTypes, TriggerOpTypes } from ‘./operations’ import { reactive, readonly } from ‘./reactive’

// 是不是只读的,只读的set会进行警告 // 是不是深度的 // getter 拦截获取 function createGetter (shallow = false, isReadonly = false) { return function get(target, key, receiver) { const res = Reflect.get(target, key, receiver)

  1. if (!isReadonly) {
  2. // 不是只读,说明数据可以改,数据变化触发视图更新 (依赖收集)
  3. console.log('~~~~~这里进行依赖收集~~~~~')
  4. track(target, TrackOpTypes.GET, key)
  5. }
  6. // 浅代理 (暴露原始值)
  7. if (shallow) {
  8. return res
  9. }
  10. if (isObject(res)) {
  11. // 懒代理,递归
  12. return isReadonly ? readonly(res) : reactive(res)
  13. }
  14. return res

} } const get = createGetter() const shallowGet = createGetter(true, false) const readonlyGet = createGetter(false, true) const shallowReadonlyGet = createGetter(true, true)

export const mutableHandlers = { get }

export const readonlyHandlers = { get: readonlyGet }

export const shallowReactiveHandlers = { get: shallowGet }

export const shallowReadonlyHandlers = { get: shallowReadonlyGet }

  1. 可以看到,作者这里通过函数柯里化的方式创建了createGetter函数,分别将其对应的返回逻辑赋值给不同的get函数,对应如下:
  2. - get 函数 -----> reactive 响应式函数的get
  3. - shallowGet 函数 -----> shallowReactive 响应式函数的get
  4. - readonlyGet 函数 -----> readonly 响应式函数的get
  5. - shallowReadonlyGet 函数 -----> shallowReadonly 响应式函数的get
  6. 这样对应好了之后,就是分析这个createGetter函数里实现的主要逻辑了<br />其实createGetter函数做的主要事情就是拦截数据进行**依赖收集**,什么情况会进行依赖收集,往下看分析:
  7. 1. 首先通过Reflect.get()方法进行取值操作后存到一个常量中,以便我们需要对深度代理的数据进行递归
  8. 1. 判断条件如果**不是只读**类型,那么说明数据是可以随便更改的,数据变化触发视图更新,如何更新,这个条件里就要开始进行依赖收集了
  9. 1. 接下来如果是“浅代理”,那么直接返回取出来的原始值即可
  10. 1. 如果数据源的属性值也是引用类型,那么进行递归代理,和第一步呼应
  11. 到这里,我们先对每个 api 的数据getter进行一个简单的测试:
  12. - reactive的数据getter:
  13. ```css
  14. <script src='../node_modules/@vue/reactivity/dist/reactivity.global.js'></script>
  15. <script>
  16. const { reactive } = VueReactivity
  17. const obj = {
  18. name: 'secoo',
  19. age: {
  20. n: 13
  21. }
  22. }
  23. const p = reactive(obj)
  24. console.log(p);
  25. console.log(p.age);
  26. </script>

打印如下: Vue3响应式源码分析 - 图1

  • readonly的数据getter:

    1. <script src='../node_modules/@vue/reactivity/dist/reactivity.global.js'></script>
    2. <script>
    3. const { readonly } = VueReactivity
    4. const obj = {
    5. name: 'secoo',
    6. age: {
    7. n: 13
    8. }
    9. }
    10. const p = readonly(obj)
    11. console.log(p);
    12. console.log(p.age);
    13. </script>

    打印如下: Vue3响应式源码分析 - 图2

  • shallowReactive的数据getter:

    1. <script src='../node_modules/@vue/reactivity/dist/reactivity.global.js'></script>
    2. <script>
    3. const { shallowReactive } = VueReactivity
    4. const obj = {
    5. name: 'secoo',
    6. age: {
    7. n: 13
    8. }
    9. }
    10. const p = shallowReactive(obj)
    11. console.log(p);
    12. console.log(p.age);
    13. </script>

    打印如下: Vue3响应式源码分析 - 图3

  • shallowReadonly的数据getter:

    1. <script src='../node_modules/@vue/reactivity/dist/reactivity.global.js'></script>
    2. <script>
    3. const { shallowReadonly } = VueReactivity
    4. const obj = {
    5. name: 'secoo',
    6. age: {
    7. n: 13
    8. }
    9. }
    10. const p = shallowReadonly(obj)
    11. console.log(p);
    12. console.log(p.age);
    13. </script>

    打印如下: Vue3响应式源码分析 - 图4

    小结

    经过上述测试,大家也可以清楚的看到这几个 api 的区别了吧,这里小结归纳:
    只有数据是被代理并且可以更改的情况下进行依赖收集

    数据setter

    接着,我们来看数据的setter操作:
    同样的,和创建createGetter一样使用闭包的方式来创建createSetter函数,如下:

    1. // setter 拦截设置
    2. function createSetter (shallow = false) {
    3. return function set(target, key, value, receiver) {
    4. const oldValue = target[key]
    5. const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key)
    6. const result = Reflect.set(target, key, value, receiver)
    7. // 数据变化,通知对应属性的effect去重新执行
    8. if (!hadKey) { // 新增
    9. console.log('~~~~~数据变化-新增,通知对应属性的effect去重新执行~~~~~')
    10. trigger(target, TriggerOpTypes.ADD, key, value)
    11. } else { // 修改
    12. console.log('~~~~~数据变化-修改,通知对应属性的effect去重新执行~~~~~')
    13. trigger(target, TriggerOpTypes.SET, key, value, oldValue)
    14. }
    15. return result
    16. }
    17. }
    18. const set = createSetter()
    19. const shallowSet = createSetter(true)

    可以看到,这里声明了“深代理”的setter操作set函数和“浅代理”的setter操作shallowSet函数,因为只读类型不涉及到数据的更改响应变化。
    那么接着重点分析createSetter函数的逻辑
    其实createSetter函数做的主要事情就是劫持数据的变化状态去通知对应属性的effect去重新执行
    需要注意的是,这里数据的变化状态分为已有属性的修改和属性新增两种情况,分析如下:

  1. 通过isArray判断数据target是否为数组,并且key是否为整型数值,说明这种情况的数据是数组,索引key和数据target的length判断是否为新增或已有属性,并把数组的自有属性length的变化触发过滤掉

进行数组数据的操作,不管是用数组的方法,还是直接更改数组的索引值,索引值会发生变化,内部的length也会发生变化,这样我们本来新增或修改一个值,结果会导致两次响应更新,其中一次就是length触发的,所以作者就通过isIntegerKey(key)规避掉length这种

  1. 对象数据操作就比较简单了,直接通过对象原型上的hasOwnProperty方法来判断是否为新增或已有属性
  2. 这样我们就可以明确数组和对象的数据变化状态了,通过不同的操作行为去进行trigger操作,就是我们说的触发更新了~

createSetter函数分析完了,我们来看下reactive, readonly, shallowReactive, shallowReadonly这几个 api 分别对应的setter设置:

  1. export const mutableHandlers = {
  2. get,
  3. set
  4. }
  5. export const readonlyHandlers = {
  6. get: readonlyGet,
  7. set(target, key) {
  8. console.warn(
  9. `Set operation on key "${String(key)}" failed: target is readonly. ------ erwin `,
  10. target
  11. )
  12. return true
  13. }
  14. }
  15. export const shallowReactiveHandlers = {
  16. get: shallowGet,
  17. set: shallowSet
  18. }
  19. export const shallowReadonlyHandlers = extend(
  20. {},
  21. readonlyHandlers,
  22. {
  23. get: shallowReadonlyGet
  24. }
  25. )

可以看到,如果是只读的set数据处理,则直接控制台打印警告⚠️
到这里,我们就大体分析完了reactive, readonly, shallowReactive, shallowReadonly这几个 api 分别对数据的劫持情况
我们依然对每个 api 的数据setter进行一个简单的测试:

  • reactive的数据setter:

    1. <script src='../node_modules/@vue/reactivity/dist/reactivity.global.js'></script>
    2. <script>
    3. const { reactive } = VueReactivity
    4. const obj = {
    5. name: 'secoo',
    6. age: {
    7. n: 13
    8. }
    9. }
    10. const p = reactive(obj)
    11. p.age.n = 14
    12. console.log(p);
    13. console.log(p.age.n);
    14. </script>

    打印如下: Vue3响应式源码分析 - 图5

  • readonly的数据setter:

    1. <script src='../node_modules/@vue/reactivity/dist/reactivity.global.js'></script>
    2. <script>
    3. const { readonly } = VueReactivity
    4. const obj = {
    5. name: 'secoo',
    6. age: {
    7. n: 13
    8. }
    9. }
    10. const p = readonly(obj)
    11. p.age.n = 14
    12. console.log(p);
    13. console.log(p.age.n);
    14. </script>

    打印如下: Vue3响应式源码分析 - 图6

  • shallowReactive的数据setter:

    1. <script src='../node_modules/@vue/reactivity/dist/reactivity.global.js'></script>
    2. <script>
    3. const { shallowReactive } = VueReactivity
    4. const obj = {
    5. name: 'secoo',
    6. age: {
    7. n: 13
    8. }
    9. }
    10. const p = shallowReactive(obj)
    11. p.name = 'sec'
    12. p.pos = '北京市'
    13. console.log(p);
    14. console.log(p.name);
    15. console.log(p.age);
    16. </script>

    打印如下: Vue3响应式源码分析 - 图7

  • shallowReadonly的数据setter:

    1. <script src='../node_modules/@vue/reactivity/dist/reactivity.global.js'></script>
    2. <script>
    3. const { shallowReadonly } = VueReactivity
    4. const obj = {
    5. name: 'secoo',
    6. age: {
    7. n: 13
    8. }
    9. }
    10. const p = shallowReadonly(obj)
    11. p.name = 'sec'
    12. console.log(p.name, 'name');
    13. p.age.n = 14
    14. console.log(p.age.n, 'age-n');
    15. </script>

    打印如下: Vue3响应式源码分析 - 图8

    小结

    经过上述测试,这里小结归纳:
    只有数据是被代理并且可以更改的情况下进行触发更新

    响应式之effect

    在 Vue3 的响应式源码中,依赖收集和派发更新操作都是基于一个叫effect函数来实现的,所以我们有必要在分析依赖收集和派发更新的源码实现之前先了解下这个effect函数。这个函数的实现是在effect.ts文件中 ```css export function effect(fn, options:any = {}) { const effect = createReactiveEffect(fn, options)

    if (!options.lazy) { effect() // 响应式的effect默认会执行一次 }

    return effect }

let uid = 0 let activeEffect // 存储当前运行的effect(全局变量) let effectStack = [] // effect执行栈 function createReactiveEffect(fn, options) { const effect = function reactiveEffect() { if (!effectStack.includes(effect)) { try { effectStack.push(effect) activeEffect = effect return fn() } finally { effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } } }

effect.id = uid++ // 用于区分effect的标识 effect._isEffect = true // 是否是响应式的effect effect.raw = fn // 保留effect对应的原函数 effect.options = options // 自定义属性存到effect

return effect }

  1. 首先看到,导出一个入口函数effect,接收两个参数,分别是fnoptions,内部返回了一个主要逻辑执行的函数createReactiveEffect,判断条件是optionslazy属性不存在,那么先会默认执行一次这个函数,由此得知,这里的createReactiveEffect返回了一个函数。接着来分析createReactiveEffect函数的主要逻辑实现<br />其实这个函数主要做了两件事
  2. 1. 全局变量储存当前运行的effect
  3. 存到全局变量上是为了在track函数中可以收集到对应属性运行的effect
  4. 1. 通过栈的“先进后出”来保证当前运行的effect是正确的
  5. 通过举例来了解作者这里使用栈的作用
  6. ```css
  7. effect(() => {
  8. console.log(data.name)
  9. effect(() => {
  10. console.log(data.age)
  11. })
  12. console.log(data.pos)
  13. })

如上代码,可以看到第一层的effect(暂定为effect1)的回调函数中分别取了data.name 和 data.pos,第二层的effect(暂定为effect2)取了data.age
这里大家思考一个问题,effect2执行完后,data.pos对应的当前的effect是哪个?
如果没有执行栈的操作,那么这里的data.pos对应的当前的effect会是effect2,因为effect2执行完了并没有出栈,那么当前运行的activeEffect变量中保存的就是最新运行的effect,因此,作者这里运用栈的操作将执行完后的函数出栈,重新赋值变量activeEffect为栈中最后一项,这样就保证了属性依赖的effect就不会有错乱的情况了。
到这里,我们基本了解了effect函数的作用,接下来就开始重点分析实现依赖收集的track方法派发更新的trigger方法

依赖收集的track

  1. // 让对象中的某个属性,收集它当前对应的effect
  2. const targetMap = new WeakMap()
  3. export function track(target, type, key) { // 需要获取到当前运行的effect
  4. if (activeEffect === undefined) return
  5. let depsMap = targetMap.get(target)
  6. if (!depsMap) {
  7. targetMap.set(target, (depsMap = new Map))
  8. }
  9. let dep = depsMap.get(key)
  10. if (!dep) {
  11. depsMap.set(key, (dep = new Set))
  12. }
  13. if (!dep.has(activeEffect)) {
  14. dep.add(activeEffect)
  15. }
  16. }

上面的代码,我们通过这张图来表达可能更加直观: Vue3响应式源码分析 - 图9
其实通过图可以看出一个关系,这个关系是一个一对多对多的关系
看完这张图,再来分析上述track收集依赖的思路:

  1. 首先定义一个总的数据表targetMap,为弱引用类型的WeakMap。其key是数据源target,value是depsMap,这个depsMap用于管理当前target中每个key的dep,也就是副作用依赖。
  2. 第一次尝试去获取target对应的depsMap,获取不到的话,targetMap.set(target, (depsMap = new Map)), 设置并初始化声明depaMap
  3. 第一次尝试去获取key对应的dep,获取不到的话,同样地,depsMap.set(key, (dep = new Set)),设置并初始化声明dep
  4. 最后将当前运行的activeEffect推入到dep中, 进行依赖收集

打印测试如下: Vue3响应式源码分析 - 图10

派发更新的trigger

在讲解这个部分前,我们先来看几个问题,如下:
问题1:

  1. <body>
  2. <div id="app"></div>
  3. <script src='../node_modules//@vue/reactivity/dist/reactivity.global.js'></script>
  4. <script>
  5. const { effect, reactive } = VueReactivity
  6. const obj = reactive({
  7. name: 'secoo',
  8. age: 13,
  9. opt: 'hhhh',
  10. arr: [1, 2, 3]
  11. })
  12. effect(() => {
  13. app.innerHTML = `寺库 name: ${obj.name}, 数组length${obj.arr.length}, 数组第三项的值:${obj.arr[2]}`
  14. })
  15. setTimeout(() => {
  16. obj.arr.length = 1
  17. }, 3000)
  18. </script>
  19. </body>

上述代码可以看到,我们在3秒后让obj的属性arr的length变为了1,在effect的回调函数中length属性被get,不难理解,数据变化,对应的视图数据更新,但是通过索引arr[2]这样的方式去取值,在length变为1后,这个数据应该发生更新吗?
问题2:

  1. <body>
  2. <div id="app"></div>
  3. <script src='../node_modules//@vue/reactivity/dist/reactivity.global.js'></script>
  4. <script>
  5. const { effect, reactive } = VueReactivity
  6. const obj = reactive({
  7. name: 'secoo',
  8. age: 13,
  9. opt: 'hhhh',
  10. arr: [1, 2, 3]
  11. })
  12. effect(() => {
  13. app.innerHTML = `寺库 name: ${obj.name}, 数组:${obj.arr}`
  14. })
  15. setTimeout(() => {
  16. obj.arr[10] = 10
  17. }, 3000)
  18. </script>
  19. </body>

和上一个问题不一样之处就在于,在effect回调函数中直接取的是整个数组,3秒后,我们将数组的索引10的位置赋值为了一个数字10,请问,视图的数据应该更新吗?如何更新?
可以看到我们并没有依赖数组数据变化的属性,只是增加了索引,怎么让视图触发更新呢?大家可以先思考一下
这也是 Vue3 中的派发更新在处理数组数据这部分做的一些hack手段,源码实现:

  1. // 派发更新
  2. export function trigger(target, type, key?, newValue?, oldValue?) {
  3. const depsMap = targetMap.get(target)
  4. // 没有过依赖收集,不做任何操作
  5. if (!depsMap) return
  6. const effects = new Set()
  7. const add = (effectToAdd) => {
  8. if (effectToAdd) { // 可能是新增属性 -----> undefined
  9. effectToAdd.forEach(effect => {
  10. effects.add(effect)
  11. });
  12. }
  13. }
  14. // 判断修改的是否是数组的长度
  15. if (key === 'length' && isArray(target)) { // 严格判断,排除类数组
  16. depsMap.forEach((dep, key) => {
  17. // 1. 如果length有被依赖,数据变化视图需要更新
  18. // 2. 如果被依赖的key 大于 修改的length,数据变化视图需要更新
  19. if (key === 'length' || key > newValue) {
  20. console.log('~~~~~触发更新~~~~~')
  21. add(dep)
  22. }
  23. })
  24. } else {
  25. if (key !== void 0) {
  26. console.log('~~~~~触发更新~~~~~')
  27. add(depsMap.get(key))
  28. }
  29. // 如果添加了数组的索引,触发视图更新
  30. switch(type) {
  31. case TriggerOpTypes.ADD:
  32. if (isArray(target) && isIntegerKey(key)) {
  33. console.log('~~~~~触发更新~~~~~')
  34. add(depsMap.get('length'))
  35. }
  36. }
  37. }
  38. effects.forEach((effect: any) => effect())
  39. }

分析如下:
对于问题1:
作者这里通过判断数据target是否是数组并且修改的key是length的话,那么遍历depsMap对应的key和dep依赖表,进而再判断

  1. depsMap中的key有没有属性length被依赖,

也就是有没有直接这样取length属性,obj.arr.length

  1. 或者被依赖的key 大于 修改的length,

也就是说你这样改的length,obj.arr.length = 1, 这样改后,这里的newValue就是更改的值1,依赖的arr[2]的key为2,这样就是大于的情况,那么改完了数组的长度变了,原来的索引值肯定取不到就是undefined,页面需要更新这个变化,反之,改的数组长度大于这个依赖的key, 就不需要对应的视图更新了。
这种length的变化就将对应的依赖表遍历推到effects中,方便之后顺序执行
对于问题2:
作者是通过switch判断type为ADD行为的话,接着判断数组索引条件,这种情况一定是增加数组索引的操作,那么就将key为length属性的副作用依赖推入到effects中去触发执行
上面代码也看到了,页面内容数据直接取的是整个数组,这里内部会取.length属性,所以作者这里手动将key为length属性的副作用依赖推入到effects中去实现响应式更新的

小结

对于数组这种数据的操作,大家也看到了,确实要考虑很多种情况,所以这里触发更新的条件判断比较繁琐
对于对象操作,那么就会简单很多,只需判断key若存在,就取key对应的dep中保存的副作用依赖推入到effects中去执行就可以了
综上总结:

  1. effect中所有的属性,都会收集effect
  2. 当这个属性值发生变化,会重新执行effect

当然内部源码还处理了Set, Map这种类型数据的更新,这里就略过了,我们了解了最常用的数组和对象的实现思路,其它实现思路大同小异,大家有兴趣自己可以对照源码调试了解下。接下来,我们学习ref和toRefs的响应式原理

响应式之ref

这部分我们来学习有关ref, shallowRef, toRef, toRefs有关 API 的源码实现,有关这几个 API 的用法,大家不了解的话可以看下Vue官网(opens new window)
首先大家可以思考一个问题,为什么会有ref这个api?
大家都知道,proxy只能代理对象类型的数据,那么普通类型数据怎么实现响应式呢,所以这里作者就实现了一个ref API专门来处理对普通类型数据的响应式,它的底层是借助Object.defineProperty来实现的。当然,ref也是可以处理对象类型数据的响应式,只不过底层还是通过reactive来转换的。具体实现看下面分析

ref, shallowRef源码分析

刚刚也说到,ref主要是针对普通类型数据的响应式,那么对于普通类型数据的响应式转换,在 Vue3 中是基于 ES6的类的属性访问器实现的,ref相关的逻辑都在ref.ts中:

  1. export function ref(value) {
  2. return createRef(value)
  3. }
  4. export function shallowRef(value) {
  5. return createRef(value, true)
  6. }
  7. const convert = (val) => isObject(val) ? reactive(val) : val
  8. class RefImpl {
  9. // 声明了一个变量,并没有赋值(实例上新增属性,必须有提前声明这一步,ts要求)
  10. private _value
  11. // 产生的实例上会自动添加__v_isRef属性
  12. public readonly __v_isRef = true
  13. // 参数中前面增加修饰符,表示此属性会自动放到实例上
  14. constructor(public _rawValue, public _shallow) {
  15. this._value = _shallow ? _rawValue : convert(_rawValue)
  16. }
  17. // 类的属性访问器
  18. get value() {
  19. // 收集依赖
  20. track(this, TrackOpTypes.GET, 'value')
  21. return this._value
  22. }
  23. set value(newVal) {
  24. // 派发更新
  25. if (hasChanged(newVal, this._rawValue)) {
  26. this._rawValue = newVal // 更新老值为新值
  27. this._value = this._shallow ? newVal : convert(newVal)
  28. trigger(this, TriggerOpTypes.SET, 'value', newVal)
  29. }
  30. }
  31. }
  32. function createRef(rawValue, shallow = false) {
  33. return new RefImpl(rawValue, shallow)
  34. }

可以看到,同样是使用函数柯里化的方式,通过声明createRef函数接收不同的参数来实现的ref和shallowRef这两个api, 在内部通过_shallow判断是否通过而convert方法做reactive的数据类型转换,而在createRef函数内部返回的是一个RefImpl实例,这个实例就是通过class类来实现的
在RefImpl实例类中,主要是通过getter/setter将普通类型的值包装成{value: ‘你定义的数据’}这样的格式,所以我们修改值的话需要.value的方式,并且在getter的时候track收集依赖,在setter的时候trigger派发更新。

toRef, toRefs源码分析

  1. class ObjectRefImpl {
  2. public readonly __v_isRef = true
  3. constructor(public _object, public _key) {}
  4. get value () {
  5. return this._object[this._key]
  6. }
  7. set value (newVal) {
  8. this._object[this._key] = newVal
  9. }
  10. }
  11. export function toRef(object, key) {
  12. return new ObjectRefImpl(object, key)
  13. }
  14. export function toRefs(object) {
  15. const ret = isArray(object) ? new Array(object.length) : {}
  16. for(const key in object) {
  17. ret[key] = toRef(object, key)
  18. }
  19. return ret
  20. }

可以看到,toRef的内部返回一个ObjectRefImpl实例,参数是引用类型的数据源和对应的key,在ObjectRefImpl实例类中本质上还是通过类的属性访问器,通过getter将属性取值包装成{value: ‘’}格式,通过setter设置.value的方式改值。
toRefs是基于toRef实现的,toRef只能转换一个值,而toRefs是可以转换一组值,内部是通过遍历数据源,循环去调toRef方法,这样就把所有的值全部转换成了ref类型的数据了
举个🌰:

  1. const obj = reactive({
  2. name: 'sec',
  3. age: 13
  4. })
  5. const o = toRefs(obj)
  6. console.log(o);

打印如下:
Vue3响应式源码分析 - 图11

#响应式之computed

这部分我们来学习computed的实现原理,它的基本功能和用法和 Vue2 一样,同样支持两种写法。不同与 Vue2 的是,Vue3 的计算属性是具有依赖收集和派发更新的机制的。它的底层也是通过Object.defineProperty实现的
现在让我们来看看computed在 Vue3 中的实现逻辑

  1. export function computed(getterOrOptions) {
  2. let getter;
  3. let setter;
  4. if (isFunction(getterOrOptions)) {
  5. getter = getterOrOptions
  6. setter = () => {
  7. console.warn('Write operation failed: computed value is readonly -----')
  8. }
  9. } else {
  10. getter = getterOrOptions.get
  11. setter = getterOrOptions.set
  12. }
  13. return new ComputedRefImpl(
  14. getter,
  15. setter,
  16. isFunction(getterOrOptions) || !getterOrOptions.set
  17. )
  18. }

可以看到,声明的computed函数接收一个getterOrOptions的参数,内部首先会判断这个参数是否是函数,

  1. 如果是函数的话就直接赋值给getter变量,setter为一个自定义函数,输出警告,因为参数是函数这种用法,值(实例)是不具有更改功能的。
  2. 如果是对象的话,就直接取对象的get方法赋值给getter,set方法赋值给setter
  3. 最后内部返回一个ComputedRefImpl实例,将对应的参数传入

接下来,我们就重点看下这个ComputedRefImpl实例类中的实现逻辑

  1. class ComputedRefImpl {
  2. public _value
  3. private _dirty = true // 缓存标识,默认是不取缓存的
  4. public effect
  5. private __v_isRef = true
  6. constructor(getter, public _setter, isReadonly) {
  7. this.effect = effect(getter, {
  8. lazy: true, // 默认不执行
  9. scheduler: () => {
  10. if (!this._dirty) {
  11. this._dirty = true
  12. trigger(this, TriggerOpTypes.SET, 'value')
  13. }
  14. }
  15. })
  16. }
  17. get value () {
  18. if (this._dirty) {
  19. this._value = this.effect()
  20. this._dirty = false
  21. }
  22. track(this, TrackOpTypes.GET, 'value')
  23. return this._value
  24. }
  25. set value (newValue) {
  26. this._setter(newValue) // 执行用户传入的set方法
  27. }
  28. }

ComputedRefImpl实例类主要做了3件事,我们分别通过示例代码来进行分析:

  1. 缓存值

    1. const { reactive, computed, effect } = VueReactivity
    2. const state = reactive({ name: 'sec' })
    3. const c = computed(() => {
    4. console.log('~~~runner~~~');
    5. return state.name
    6. })
    7. console.log(c.value);
    8. console.log(c.value);

    打印如下: Vue3响应式源码分析 - 图12
    可以看到,我们取了两次值c.value,但是runner只执行了一次,说明第二次取的是缓存的值
    源码实现: 取值会走getter,缓存是通过_dirty来做标识的,默认为true不取缓存,执行自身实例上的effect方法后返回用户传入的返回值给_value,同时把_dirty置为false,返回_value,这样下次再取值的话就不会走effect重新赋值,就达到了缓存的效果

  2. 值变了,需要重新取最新值

    1. const { reactive, ref, computed, effect } = VueReactivity
    2. const state = reactive({ name: 'sec' })
    3. const c = computed(() => {
    4. console.log('~~~runner~~~');
    5. return state.name
    6. })
    7. console.log(c.value);
    8. setTimeout(() => {
    9. state.name = 'secoooo'
    10. console.log(c.value);
    11. }, 3000)

    打印如下: Vue3响应式源码分析 - 图13
    可以看到,第一次取name值为sec,在3秒后,我们把name值改为了secoooo,再次取值就是最新值
    源码实现: 我们改值的话会执行对应的trigger方法,trigger方法中会拿到副作用依赖去依次执行,判断如果有scheduler属性的话,那么去执行scheduler

    1. effects.forEach((effect: any) => {
    2. if (effect.options.scheduler) {
    3. effect.options.scheduler(effect)
    4. } else {
    5. effect()
    6. }
    7. })

    这样的话就将_dirty置为了true,这样下次取值的话重新执行effect,获取最新值

  3. 计算属性的依赖收集和派发更新

    1. const { reactive, ref, computed, effect } = VueReactivity
    2. const state = reactive({ name: 'sec' })
    3. const c = computed(() => {
    4. console.log('~~~runner~~~');
    5. return state.name
    6. })
    7. console.log(c.value);
    8. effect(() => {
    9. console.log(c.value, 'effect');
    10. })
    11. state.name = 'secooooo'

    打印如下: Vue3响应式源码分析 - 图14
    可以看到,我们在effect中取computed的返回值,随后将值更改了,可以重新更新最新值
    源码实现: 我们在effect中并没有依赖state的name属性,那是如何做到触发更新的呢,内部是通过在取值的时候,将自身实例作为依赖进行收集,在改值的时候调用了trigger方法去实现effect的执行,达到触发更新的效果。

    总结 🎉

    到这里,我们基本把 Vue3 的核心响应式系统源码学习完了,这样大家在用法上不仅用的更加熟练,同时做到知其然,也知其所以然。