likes
comments
collection
share

从零开始学习Vue3源码 ——— (二)Vue3响应式原理(上)

作者站长头像
站长
· 阅读数 23

前言

学完本篇文章(上下篇),你能掌握:

  • Vue3的响应式原理(上篇)
  • reactiveeffect方法的实现(上篇)
  • computedwatch方法的实现(下篇)
  • ref方法的实现(下篇)

所以么,还是干货满满,能够对Vue3的响应式系统有着比较深刻的理解。

Vue2和Vue3的对比

这里我们不得不先提及一下Vue2的响应式原理,说句现实的话,面试的时候,肯定会一起问的,那么如果能够将两者结合在一起,进行有条理的对比分析回答,那么绝对是一个亮眼的加分项。

响应式原理对比

Vue2不足:

  • 在使用Vue2的时候,进行数据劫持使用的是Object.defineproperty,需要对我们data中定义的所有属性进行重写,从而添加gettersetter,正是因为了这一步,所以导致,如果data中定义的属性过多,性能就会变差。
  • 在写项目的时候,有的时候会碰到需要新增或删除属性的操作,那么直接新增/删除,就无法监控变化,所以需要通过一些api比如$set$delete进行实现,其实原理上还是使用了Object.defineproperty进行了数据劫持。
  • 针对数组的处理,没有使用Object.defineproperty进行数据劫持,因为如果给一个很长的数组的每一项,都添加gettersetter,那多来几个数组,就崩掉了,而且日常开发中我们通过数组索引进行修改数组的操作比较少。所以Vue2的方式就是采用重写了一些常用的数组方法比如unshift,shift,push,pop,splice,sort,reverse这七个方法,来解决数组数据响应式的问题。

Vue3改进:

  • Vue3使用了Proxy来实现了响应式数据变化,从而从根本上解决了上述问题,逻辑也简化了好多。

写法区别对比

  • Vue2中使用的是OptionsAPI,我们在写代码的时候,如果页面比较复杂,那么可能就会在data中定义很多属性,methods中定义很多方法,那么相关的逻辑就不在同一块地方,我们在找代码的时候,就可能比较累,鼠标滚轮或者触摸板来回上下翻找。Vue3使用了CompositionAPI,可以把某一块逻辑,单独写在一起,解决了这种反复横跳的问题。
  • Vue2中所有的属性都是通过this来进行访问的,this的指向一直是JS中很恶心的问题,一不小心就搞不清this的指向,代码就会出问题。Vue3直接干掉了this
  • Vue2中,很多没有使用的方法或者属性,都会被打包,并且全局的API都可以在Vue对象上访问到。比如我们在Computed中,定义了3个值,但是页面中只用到了1个,那么依旧会把这3个Computed值全部都打包。Vue3使用的CompositionAPI,对tree-shaking非常友好,代码压缩后的体积也就更小。
  • Vue2中的mixins可以实现相同逻辑复用,抽离到一个mixin文件中,但是会有数据来源不明确的问题,命名上也会产生冲突。而Vue3使用CompositionAPI,提取公共逻辑可以抽成单独的hooks,非常方便,避免了之前的问题。

当然,在简单的页面中,我们依旧可以使用OptionsAPI,就是Vue2的写法。CompositionAPI在开发比较复杂的页面中,书写起来显得非常方便。我们本篇文章要学习的就是Vue3中的reactivity模块,那么这个模块中包含了很多我们使用的API,比如computed,reactive,ref,effect等。

reactivity模块的基本使用

老规矩,我们先简单的看下,这个模块的使用方法,然后再来一步一步,简单实现里边的方法。打开上篇文章创建好的项目,在项目根目录,我们执行pnpm install vue -w,先用一下Vue3官方提供的方法,看看是啥效果。安装好后,我们通过node_modules文件夹找到@vue/reactivity/dist/reactivity.esm-browser.js这个文件,通过文件名字我们就能看出来,这个是esModule可以放在浏览器中运行的。把这个文件复制一份,直接放在我们自己reactivity/dist目录下,然后修改reactivity/dist/index.html的代码如下:

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

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div id="app">
  </div>
  <script type="module">
    import { effect, reactive } from './reactivity.esm-browser.js'
    const state = reactive({ name: '张三', age: 18 })
    effect(() => {
      app.innerHTML = state.name + ': ' + state.age
    })
    setTimeout(() => {
      state.name = '李四'
    }, 2000)
  </script>
</body>

</html>

我们这里介绍上述代码中的两个API,第一个就是我们熟知的reactive,没错,在项目中如果想定义一个响应式对象的话,就把对象传进reactive中就好了。

那么effect又是啥呢?如果我们只是写业务,其实很难用到这个方法,但effect确是一个非常重要的方法(又叫副作用函数),执行effect就会渲染页面,所以渲染页面的核心离不开effect方法。

一句话,reactive方法会将对象变成proxy对象,effect中使用reactive对象的时候,会进行依赖收集,等之后reactive对象中的属性发生变化的时候,会重新执行effect函数。

我们在浏览器中执行上边的代码,会发现过了2秒后,我们只是将state.name赋值成了李四,但是页面也重新被渲染了,名字从张三变成了李四。等看完本篇文章的代码后,可以回过头来再来理解上边的那句话。

有人可能有些疑问了,reactive我在项目中确实有用到过,但是这个effect方法,在项目中根本没用到过啊,甚至听都没听说过,没错,effect方法是底层方法,项目中用不到非常正常,但是watchwatchEffect总该用过吧?嘿嘿,没错,都是基于effect进行了封装从而实现的,别急,我们在下边的文章中会娓娓道来。

开始实现reactivity模块中的方法

编写reactive方法

首先我们在shared中添加一个新方法:

// 用来判断是不是一个对象
export const isObject = value => {
  return value != null && typeof value === 'object'
}

之后,我们在reactivity/src目录下,新建reactive.ts文件,用来写reactive的主逻辑:

import { isObject } from '@vue/shared'
const mutableHandlers =  {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    Reflect.set(target, key ,value, receiver)
    // 严格模式下如果不返回true就会报错
    return true
  }
}
export function reactive(target) {
  // 先判断target是不是个对象,reactive只能处理对象类型的数据
  if (!isObject(target)) return
  const proxy = new Proxy(target, mutableHandlers)
  return proxy
}

我们用最简单的代码,写了reactive的核心逻辑,从代码中也看到,reactive中只能处理对象类型的数据。还有一点,细心的朋友可能会发现,在getset中,使用了Reflectgetset方法,那为什么不直接用target[key]呢,效果不是一样的么?看起来是这样,但是在一些情况下,就能看到明显的问题。我们先举个例子:

let obj = {
  name: 'zhangsan',
  get nickName{
    return 'nickName:' + this.name
  }
}
let proxyObj = new Proxy(obj, {
  get(target, key, receiver) {
    console.log('收集依赖:', key)
    return target[key]
  }
})
// 进行取值操作
console.log(proxyObj.nickName)

上述代码中,是一个很简单的代理,如果我们在页面中,使用了proxyObj.nickName这个取值代码,那么根据相应逻辑,执行代码打印的结果就是:

收集依赖: nickName
nickName:zhangsan

那么很明显的问题就是,obj中的name属性,没有被依赖收集,那么如果在后续操作中,我们对proxyObj.name = 'xxxxxx'进行赋值了,因为没有被依赖收集到,所以虽然数据变化了,但是页面视图却并没有同步发生变化。说到底还是因为this指向的原因,当前this指向了obj,而我们希望这个this指向被代理后的proxyObj,这样才能够将name属性也收集到,那么所以,我们此时应该使用Reflect,来使this正确的指向被代理后的proxyObj属性。

let obj = {
  name: 'zhangsan',
  get nickName() {
    return 'nickName:' + this.name
  }
}
let proxyObj = new Proxy(obj, {
  get(target, key, receiver) {
    console.log('收集依赖:', key)
    return Reflect.get(target, key, receiver)
  }
})
// 进行取值操作
console.log(proxyObj.nickName)

经过此番修改,我们再执行代码,会发现,诶name属性也被成功的进行依赖收集了,达到了我们的预期.这就是为什么这里要使用Reflect的原因啦。

收集依赖: nickName
收集依赖: name
nickName:zhangsan

经过这个小插曲,我们回到reactive代码中。虽然核心逻辑写好了,但是我们要考虑一些小问题,比如在下方代码中,如果用Vue3官方源码来执行,那么如果对于同一个对象进行多次代理,都应该返回同一个代理,结果为true,但是在我们目前的代码中,没有过这个判断,只要在reactive中传入一个对象,就进行new Proxy()生成一个新的代理,所以结果为false,这样肯定是不合理的。

import { reactive } from 'vue'
const obj = { name: 'zhangsan' }
let proxy1 = reactive(obj)
let proxy2 = reactive(obj)
console.log(proxy1 === proxy2)

那么应该如何做到如果传入同一个对象,就返回相同的代理结果呢?其实想一想大致的思路就有了,没错,需要有个缓存表,来记录每次传入的对象是不是重复了,如果重复,就返回已经存在的代理对象。那应该用什么缓存呢?没错,就是用WeekMap,好处就是它的key能存放object类型的数据,而且不存在垃圾回收的问题,我们来补充完整逻辑吧!

import { isObject } from '@vue/shared'
// 1.我们利用WeakMap,来定义一个缓存表
const reactiveMap = new WeakMap()
const mutableHandlers =  {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    Reflect.set(target, key ,value, receiver)
    // 严格模式下如果不返回true就会报错
    return true
  }
}
export function reactive(target) {
  // 先判断target是不是个对象,reactive只能处理对象类型的数据
  if (!isObject(target)) return
  // 2.先从缓存表中读取代理结果,如果能找到,就直接返回
  const existingProxy = reactiveMap.get(target)
  if(existingProxy) return existingProxy
  // 没有缓存过就正常new Proxy()
  const proxy = new Proxy(target, mutableHandlers)
  // 代理后,在缓存表中缓存结果
  reactiveMap.set(target, proxy)
  return proxy
}

这时候,我们再引入自己的reactive,执行刚才那段测试代码,发现console.log(proxy1 === proxy2)返回的就是true。这个问题解决了,但是新的问题又来了,还是回到刚才那个测试代码,这次将代理后的对象,再次传入到reactive中。在源码中返回的结果依旧是true,但是在我们的代码中,因为传入被代理后的对象,又是一个新的对象,所以会再次被代理。那么,我们怎么才能够判断这种情况呢?

import { reactive } from 'vue'
const obj = { name: 'zhangsan' }
let proxy1 = reactive(obj)
let proxy2 = reactive(proxy1)
console.log(proxy1 === proxy2)

很多人第一反应就是我判断传入的值是不是proxy不就完事了,首先,并没有什么好的办法,判断传入的值是一个proxy代理后的对象,其次,如果用户自己new Proxy()生成了一个代理的对象,那么凭啥不让人家传入reactive中呢?之所以要做上文和现在这两点优化,是因为同一个对象,或同一个对象经过代理后的结果,多次传入reactive中后不会被再次进行代理,提高了效率。

这里,新版本的Vue3采用了一个比较巧妙的方法来解决这个问题,第一次看可能会有些绕,所以最好多看几遍代码,或在浏览器中进行断点调试。

import { isObject } from '@vue/shared'

const reactiveMap = new WeakMap()
const enum ReactiveFlags {
  IS_REACTIVE = '__v_isReactive'
}
const mutableHandlers =  {
  get(target, key, receiver) {
    // 2.在经过get劫持后,如果访问到的key就是ReactiveFlags.IS_REACTIVE,就说明被代理的对象,又被传进来了,所以直接返回true
    if (key === ReactiveFlags.IS_REACTIVE) return true
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    Reflect.set(target, key ,value, receiver)
    // 严格模式下如果不返回true就会报错
    return true
  }
}

export function reactive(target) {
  // 先判断target是不是个对象,reactive只能处理对象类型的数据
  if (!isObject(target)) return
  // 如果能够从从缓存中读取,则直接返回
  const existingProxy = reactiveMap.get(target)
  if(existingProxy) return existingProxy
  // 1.如果被代理后的对象,又被传入进来了,那么应该将这个被代理的对象直接返回,而不是再代理一次
  if (target[ReactiveFlags.IS_REACTIVE]) return target
  // 没有缓存过,就使用proxy进行代理
  const proxy = new Proxy(target, mutableHandlers)
  // 缓存proxy结果
  reactiveMap.set(target, proxy)
  return proxy
}

其实就是增加了一个常量枚举值,那么在Vue3内部,这些常量都是以__v开头的,IS_REACTIVE这个常量就代表着是否是一个已经被代理的reactive对象。新增的代码非常简洁,我们简单过一遍整体的流程。

首先,当一个普通对象第一次被传入进reactive中的时候,target[ReactiveFlags.IS_REACTIVE]肯定是undefined,这个毫无疑问,返回的值我们称为proxy1。注意重点来了,当我们再次将proxy1传入到reactive中的时候,因为proxy1已经是一个被代理的对象了,所以在经过if(target[ReactiveFlags.IS_REACTIVE]) return target这行代码的时候,因为target[ReactiveFlags.IS_REACTIVE]是一个取值操作,所以就会命中get中的逻辑,也就是命中这行代码if (key === ReactiveFlags.IS_REACTIVE) return true,返回了true,因为返回了true,所以根据后边的逻辑,就直接return target,将proxy1自己直接返回了。

好好品味一下这段逻辑,非常的巧妙。到这里,reactive的核心内容我们已经完成了,那么还有一些其他的方法,和细节,我们这里就不再多说,之后分析源码的时候,如果遇到再去讲解分析。

编写effect方法

// reactivity/src/effect.ts 文件

// 2.编写ReactiveEffect类
class ReactiveEffect {
  constructor(public fn) { }
  run() {
    // 执行传入的函数
    return this.fn()
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

那么,effect的最基本架子,就搭起来了。接下来是一个很关键的步骤,effect是怎么和reactive建立起联系,产生关联的呢?换句话讲,当我们定义的reactive变量中的值发生变化了,是怎么执行相应effect的函数呢?有些朋友自然而然就想到了依赖收集、触发更新这两个词,别急,我们一步一步来分析,其实建立联系用到了一个很巧妙的方法,那就是导出一个变量,那么这个变量就代表着effect的实例,从reactive模块中再导入这个变量,那么就相当于建立起了联系,我们看具体代码:

// reactivity/src/effect.ts 文件

// 3、当前正在执行的effect
export let activeEffect = undefined
// 2.编写ReactiveEffect类
class ReactiveEffect {
  constructor(public fn) { }
  run() {
    // 4.设置正在运行的是当前effect
    activeEffect = this
    // 执行传入的函数
    return this.fn()
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

没错,就是这两行简单的代码,其实就解释了依赖收集,是怎么收集的。我们可以先在reactive模块中导入这个变量,简单的调试看下结果:

// reactivity/src/reactive.ts
import { activeEffect } from './effect'
...
 get() {
   ...
   console.log(activeEffect)
   ...
 }
...

多余的代码不写了,为了清晰,我们只写调试代码。刷新页面,我们可以看到,在执行effect方法中传入的函数时,因为我们在函数中使用到了reactive定义的变量,所以可以清楚地看到activeEffect被成功的打印了出来,至此,effectreactive之间成功建立了联系。后续所有的代码都是建立在这条之上的。

有聪明的小伙伴可能有疑问了,那如果我们在index.html中,调用了2次或多次effect函数,按现在的代码不就有问题了么,因为run了多次之后,或者在effect外部又改变了reactive定义变量的值,那activeEffect不就乱套了么?没错,所以我们要保证,每次执行effect方法的时候,activeEffect都为当前的effect,解决方法也很简单,我们再添加几行代码:

// reactivity/src/effect.ts 文件

// 3、当前正在执行的effect
export let activeEffect = undefined
// 2.编写ReactiveEffect类
class ReactiveEffect {
  constructor(public fn) { }
  run() {
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5.在执行完传入的函数后,将activeEffect置空,这样做还有个好处就是,如果在effect方法外部使用
      // 了reactive定义的变量,那么就不会被监听到,因为此时activeEffect已经被置为null了
      activeEffect = null
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

我们继续,那么问题又来了,如果按照现在我们的effect中的代码,如果在使用effect方法的时候,进行了嵌套调用,那activeEffect就会出bug了,什么意思呢?我们改变一下index.html中的代码,然后稍加分析。

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

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div id="app">
  </div>
  <script type="module">
    import { effect, reactive } from './reactivity.esm-browser.js'
    const state = reactive({ name: '张三', age: 18 })
    effect(() => {
      app.innerHTML = state.name + ': ' + state.age
      effect(() => {
        app.innerHTML = state.name
      })
      app.innerHTML = state.age
    })
  </script>
</body>
</html>

我们仔细分析下嵌套部分的代码:当调用外部的effect方法时,activeEffect为外部的effect,我们这里简称outer effect,紧接着,又调用了内部的effect方法,那么按照我们现有的effect逻辑,此时activeEffect又会变为内部的effect,我们简称inner effect,注意,此时我们内部的effect执行完毕后,按照现有逻辑,activeEffect会清空变为null,但是此时外部的effect并没有执行完毕,还剩一句app.innerHTML = state.age代码没有执行,没错,这就有问题了,当前的activeEffect因为被清空重置为null了,所以当对state.age进行取值的时候,effectreactive之间的联系就断了(没有被依赖收集),而想正确简历联系,那么此时的activeEffect就应该是outer effect,怎么去做呢?这种嵌套的关系,是不是很像树形结构?树型结构的特点就是有父节点和子节点,所以,我们只需要标记父子关系即可:

// reactivity/src/effect.ts 文件

// 3、当前正在执行的effect
export let activeEffect = undefined
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  constructor(public fn) { }
  run() {
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

这下,按照上边的逻辑,我们再分析下嵌套逻辑,就能跑的通了,所以属性发生变化的时候,都可以在reactive中的get中被监听到。那么接下来,我们便可以写之前常提到的依赖收集和触发更新了。我们发现,reactiveeffect方法,其实是多对多的关系,即一个reactive中的属性,可以在多个effect方法中使用,而一个effect方法中,又可以使用多个reactive中的属性。

所以,我们之前常说的依赖收集,其实可以理解为,使用我们自己定义的一个名叫track的方法,在get中收集每个响应式属性对应的effect方法,让这个属性和effect产生关联;而触发更新,则是使用我们自己定义的trigger方法,在set中触发更新的逻辑,执行每个响应式属性所对应的effect方法。

那么我们首先在reactive文件中,导入并且调用这两个方法,之后,我们再去effect文件中实现这两个方法:

// reactivity/src/reactive.ts 文件
import { track, trigger } from './effect'
...
const mutableHandlers = {
  get(target, key, receiver) {
    if (key === ReactiveFlags.IS_REACTIVE) return true
    const res = Reflect.get(target, key, receiver)
    // 1. 进行依赖收集逻辑
    track(target, key)
    return res
  },
  set(target, key, value, receiver) {
    let oldValue = target[key]
    Reflect.set(target, key, value, receiver)
    // 2.新旧值不一样的时候,触发更新逻辑
    if (oldValue !== value) {
      trigger(target, key, value, oldValue)
    }
    return true
  }
}
...

接下来,我们在effect中再实现这两个方法:

// reactivity/src/effect.ts 文件

// 3、当前正在执行的effect
export let activeEffect = undefined
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  // 定义一个依赖数组,保存着一个effect对应了哪些依赖
  deps = []
  constructor(public fn) { }
  run() {
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

// 7. 实现依赖收集的逻辑
// 记录依赖关系
const targetMap = new WeakMap()
export function track(target, key) {
  // 只有在effect方法中改变了reactive对象,才会被进行依赖收集,因为此时activeEffect不是undefined
  if (activeEffect) {
    // 首先在targetMap中获取target
    let depsMap = targetMap.get(target)
    // 如果没有,就新建一个映射表,这里使用Map是因为之后的key可能是字符串
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    // 如果有映射表,就查找有没有当前的属性
    let dep = depsMap.get(key)
    // 如果没有这个属性,就使用Set添加一个集合
    if (!dep) {
      depsMap.set(key, (dep = new Set()))
    }
    // 判断如果没有的话,再去添加
    let shouldTrack = !dep.has(activeEffect)
    if (shouldTrack) {
      // 在同一个effect中,如果多次使用同一属性,那么就不需要多次进行依赖收集
      dep.add(activeEffect)
      activeEffect.deps.push(dep) // 在effect中记录所有依赖,后续便于清理(多对多联系建立)
    }
  }
}

// 8. 实现触发更新
export function trigger(target, key, newValue, oldValue) {
  // 通过对象找到对应属性,让这个属性对应的effect重新执行
  const depsMap = targetMap.get(target) // 获取对应的映射表
  if (!depsMap) return
  const dep = depsMap.get(key) // 属性对应的所有effect集合,是个set
  dep && dep.forEach(effect => {
    // 执行每个effect中的run方法;正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) effect.run()
  })
}

那么注意,此时的数据结构,很可能会让人很晕乎,我们稍作解释:此时的targetMap大致上应该是长这个样子的(注意,key是对象):{{name: 'xxx', age: xxx}: {'name': [dep]}},也就是weakMap : map : set这种结构,targetMapkey是整个对象,value是一个map结构,map结构的key属性valueset结构,存储和属性对应的一个个effect,如果还是不清楚,那么可以将targetMap打印在控制台中。

关于第8步骤trigger中,在循环调用effect.run方法前,会有一个防止死循环的判断,这是啥意思呢?我们简单解释一下,如果在index.html中,这样调用effect方法的话:

effect(() => {
  // 每次修改state.name都是新的随机数
  state.name = Math.random()
  app.innerHTML = state.name + ':' + state.age
})

很明显,上述代码就变成了死循环,因为当state.name的值发生变化后,就会触发更新,又执行了effect方法,而在执行effect方法的时候,又因为重新改变了state.name的值,所以就又会触发effect方法,就成了无线递归的死循环代码。所以,我们这边要加一个判断,表明如果当前正在执行的effect如果和activeEffect不相同的时候,才去执行,这样,就不会造成自己调用自己,死循环的结果。

到这里,我们的代码依旧有些小问题可以优化,我们来看一个比较有意思的场景,改变index.html中的代码:

<script>
  ...
  const state = reactive({ name: '张三', age: 18, flag: true })
  effect(() => {
    console.log('页面刷新')
    app.innerHTML = state.flag ? state.name : state.age
  })
  setTimeout(() => {
    state.flag = false
    setTimeout(() => {
      console.log('name被修改了')
      state.name = '李四'
    })
  }, 1000)
</script>

我们在浏览器中执行这个代码,会发现页面过了1秒,变为了18,控制台的结果却打印了4行,顺序是:

页面刷新
// 1秒后
页面刷新
// 又过了1秒后
name被修改了
页面刷新

那么问题来了,name被修改后,不应该又触发一次页面刷新的逻辑,因为此时flag已经变为了false,按理来说依赖收集应该只收集flagage,所以当改变name的时候,不会触发更新。我们再梳理下当前代码,依赖收集和触发更新的流程:一开始effect会直接执行,所以会直接输出页面刷新,此时依赖收集的属性有flagname,过了1秒钟,flag改为了false,所以又会触发页面更新,此时依赖收集的是flagage(注意,name的依赖收集依旧存在,没有被清理掉,问题就出在这),又过了1秒钟,打印了name被修改了,但是因为此时name的依赖收集依旧存在,在改了name的值后,依旧触发了effect函数,所以紧接着就打印了页面刷新

看到这,是不是就知道问题所在和怎么去解决呢?没错,就是在进行下次依赖收集之前,要把之前的依赖收集先进行清空,这样,就不会存在上边这种,明明没有收集name的依赖,但是当改变name的值后,页面依旧触发更新的情况了。

// reactivity/src/effect.ts 文件

// 3. 当前正在执行的effect
export let activeEffect = undefined
// 9-1. 声明清理effect的一个方法,在每次依赖收集前进行调用
function cleanupEffect(effect) {
  const { deps } = effect; // 清理effect
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect);
  }
  effect.deps.length = 0;
}
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  // 定义一个依赖数组,保存着一个effect对应了哪些依赖
  deps = []
  constructor(public fn) { }
  run() {
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 9-2. 清理上一次依赖收集
      cleanupEffect(this)
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

// 7. 实现依赖收集的逻辑
// 记录依赖关系
const targetMap = new WeakMap()
export function track(target, key) {
  // 只有在effect方法中改变了reactive对象,才会被进行依赖收集,因为此时activeEffect不是undefined
  if (activeEffect) {
    // 首先在targetMap中获取target
    let depsMap = targetMap.get(target)
    // 如果没有,就新建一个映射表,这里使用Map是因为之后的key可能是字符串
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    // 如果有映射表,就查找有没有当前的属性
    let dep = depsMap.get(key)
    // 如果没有这个属性,就使用Set添加一个集合
    if (!dep) {
      depsMap.set(key, (dep = new Set()))
    }
    // 判断如果没有的话,再去添加
    let shouldTrack = !dep.has(activeEffect)
    if (shouldTrack) {
      // 在同一个effect中,如果多次使用同一属性,那么就不需要多次进行依赖收集
      dep.add(activeEffect)
      activeEffect.deps.push(dep) // 在effect中记录所有依赖,后续便于清理(多对多联系建立)
    }
  }
}

// 8. 实现触发更新
export function trigger(target, key, newValue, oldValue) {
  // 通过对象找到对应属性,让这个属性对应的effect重新执行
  const depsMap = targetMap.get(target) // 获取对应的映射表
  if (!depsMap) return
  const dep = depsMap.get(key) // 属性对应的所有effect集合,是个set
  // 9-3 进行一次拷贝,防止自己删除元素的同时,自己添加,造成死循环
  const effects = [...dep]
  effects && effects.forEach(effect => {
    // 执行每个effect中的run方法;正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) effect.run()
  })
}

我们看9-1步骤,那么这步就是用到了我们之前定义的deps = []这个存放当前activeEffect对应了哪些依赖(set结构)。找到后清理掉所有的effect,再进行下一次的依赖收集,这样就不会造成类似于"缓存"的问题。那么在9-3步骤,为什么要进行一次拷贝呢?其实很简单,在一个循环中,同时对effect进行了添加和删除操作,刚删完元素,就又添加了新元素,那岂不是循环就成了死循环,一直跳不出来了么,所以,解决的方法就是进行一次拷贝,删除和运行分开进行,就不会有死循环的问题了。

经过我们一步步的完善,那么effect的代码就逐渐接近尾声了。我们加把劲,继续来!

那么有一种很常见的场景,当我们代理的对象,内部又有很多对象,那这些对象就不会被代理,比如:

const obj = reactive({
  name: '张三',
  info: {
    age: 18,
    sex: '男'
  }
})

那么这时候,我们就需要进行递归代理,方法也很简单,在reactive.ts文件中get最后添加几行代码即可:

  get(target, key, receiver) {
    ......
   if (key === ReactiveFlags.IS_REACTIVE) return true
    const res = Reflect.get(target, key, receiver)
    track(target, key)
    // 判断如果res是一个对象,则进行递归代理
    if(isObject(res)){
        return reactive(res);
    }
    return res
  },

接下来我们增加实例的2个方法。对于effect方法,其实是有一个返回值的,那么我们拿到这返回值,通过调用里边的方法,可以手动进行执行effect中的run方法,和停止依赖收集的stop方法,我们首先来实现拿到返回值进行手动调用(类似于Vue中的forceUpdate,可以强制刷新组件),其实原理非常简单,就把new ReactiveEffect(fn)这个结果,当成返回值不就好了么,没错,不过有些细节,我们通过完善effect.ts文件来继续看:

// reactivity/src/effect.ts 文件

// 3. 当前正在执行的effect
export let activeEffect = undefined
// 9-1. 声明清理effect的一个方法,在每次依赖收集前进行调用
function cleanupEffect(effect) {
  const { deps } = effect; // 清理effect
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect);
  }
  effect.deps.length = 0;
}
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  // 定义一个依赖数组,保存着一个effect对应了哪些依赖
  deps = []
  // 11-1. 表示当前处于激活态,要进行依赖收集
  active = true
  constructor(public fn) { }
  run() {
    // 11-3. 失活态默认调用run的时候,只是重新执行传入的函数,并不会发生依赖收集
    if (!this.active) {
      return this.fn()
    }
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 9-2. 清理上一次依赖收集
      cleanupEffect(this)
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
  // 11-2. 声明stop方法
  stop() {
    if (this.active) {
      // 失活就停止依赖收集
      this.active = false
      cleanupEffect(this)
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
  // 10. 给effect添加一个返回值,通过这个返回值可以调用_effect实例中的stop和run等方法
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}

// 7. 实现依赖收集的逻辑
// 记录依赖关系
const targetMap = new WeakMap()
export function track(target, key) {
  // 只有在effect方法中改变了reactive对象,才会被进行依赖收集,因为此时activeEffect不是undefined
  if (activeEffect) {
    // 首先在targetMap中获取target
    let depsMap = targetMap.get(target)
    // 如果没有,就新建一个映射表,这里使用Map是因为之后的key可能是字符串
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    // 如果有映射表,就查找有没有当前的属性
    let dep = depsMap.get(key)
    // 如果没有这个属性,就使用Set添加一个集合
    if (!dep) {
      depsMap.set(key, (dep = new Set()))
    }
    // 判断如果没有的话,再去添加
    let shouldTrack = !dep.has(activeEffect)
    if (shouldTrack) {
      // 在同一个effect中,如果多次使用同一属性,那么就不需要多次进行依赖收集
      dep.add(activeEffect)
      activeEffect.deps.push(dep) // 在effect中记录所有依赖,后续便于清理(多对多联系建立)
    }
  }
}

// 8. 实现触发更新
export function trigger(target, key, newValue, oldValue) {
  // 通过对象找到对应属性,让这个属性对应的effect重新执行
  const depsMap = targetMap.get(target) // 获取对应的映射表
  if (!depsMap) return
  const dep = depsMap.get(key) // 属性对应的所有effect集合,是个set
  // 9-3 进行一次拷贝,防止自己删除元素的同时,自己添加,造成死循环
  const effects = [...dep]
  effects && effects.forEach(effect => {
    // 执行每个effect中的run方法;正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) effect.run()
  })
}

我们直接看步骤10,这样写的好处就是const runner = effect(() => { console.log('页面刷新') app.innerHTML = state.name }),在通过上述方式拿到了返回值runner后,我们可以手动执行runner()方法,或runner.effect.run()方法,进行手动刷新页面,我们通过修改index.html文件,来尝试用下这个功能,不然只说概念,没有场景,很难理解。

<script>
 import { effect, reactive } from './reactivity.js'
    const state = reactive({ name: '张三', age: 18, flag: true })
    let a = '李四'
    const runner = effect(() => {
      app.innerHTML = state.name + a
    })
    setTimeout(() => {
      a = '王五'
      runner()
    }, 1000)
</script>

通过上边的代码,我们执行后发现,页面在1秒钟后,还是发生了改变,虽然我们只是在定时器里边改了变量a的值,但是因为我们进行了手动触发effect.run()方法,所以页面还是会更新的。那么我们继续看什么叫做停止依赖收集。看步骤11-1~11-3,非常明确,如果调用了stop方法,那么就会停止所有的依赖收集,并且就算之后进行了手动调用runner.run()方法,因为步骤11-3,所以也只是会再次调用effect中传入的函数,并不会进行依赖收集和触发更新。

到这里,effect就接近尾声了,那么为了和下篇文章进行接轨,我们再讲最后的一个优化点。上文提到了,我们可以手动执行runner()或runner.effect.run()方法进行页面的强制更新,但是这个runner方法,我们现在是写在effect方法之外的地方,能不能想个办法,将这个逻辑放在effect方法中呢?我们对index.html稍加改造,然后根据我们想要的数据结构,来反向推断代码应该如何写,我们想要的结果是这样:

<script>
    import { effect, reactive } from './reactivity.js'
    const state = reactive({ name: '张三', age: 18 })
    const runner = effect(() => {
      app.innerHTML = state.name
      console.log('我执行啦')
    }, {
      scheduler: () => {
        setTimeout(() => {
          console.log('页面重新刷新了')
          runner()
        }, 1000)
      }
    })
    setTimeout(() => {
      state.name = '王五'
      console.log('名字改变了')
    }, 1000)
</script>

我们给effect方法,提供第二个参数,参数中有一个scheduler属性,这个属性就对应着我们刚才定时器中的逻辑。我们期望的结果是,过了1秒钟,state.name = '王五'发生改变后,触发的是我们effect方法中第二个参数中的scheduler对应的逻辑,而不是effect方法中的第一个回调逻辑,这样就达到了当依赖发生变化的时候,我们可以执行自己的逻辑。想要的效果很明确了,那我们来完善下逻辑吧!

// reactivity/src/effect.ts 文件

// 3. 当前正在执行的effect
export let activeEffect = undefined
// 9-1. 声明清理effect的一个方法,在每次依赖收集前进行调用
function cleanupEffect(effect) {
  const { deps } = effect; // 清理effect
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect);
  }
  effect.deps.length = 0;
}
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  // 定义一个依赖数组,保存着一个effect对应了哪些依赖
  deps = []
  // 11-1. 表示当前处于激活态,要进行依赖收集
  active = true
  // 12-2. 将scheduler挂载effect实例上
  constructor(public fn, public scheduler) { }
  run() {
    // 11-3. 失活态默认调用run的时候,只是重新执行传入的函数,并不会发生依赖收集
    if (!this.active) {
      return this.fn()
    }
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 9-2. 清理上一次依赖收集
      cleanupEffect(this)
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
  // 11-2. 声明stop方法
  stop() {
    if (this.active) {
      // 失活就停止依赖收集
      this.active = false
      cleanupEffect(this)
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn, options: any = {}) {
  // 12-1. 添加options.scheduler的传参
  const _effect = new ReactiveEffect(fn, options.scheduler)
  _effect.run()
  // 10. 给effect添加一个返回值,通过这个返回值可以调用_effect实例中的stop和run等方法
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}

// 7. 实现依赖收集的逻辑
// 记录依赖关系
const targetMap = new WeakMap()
export function track(target, key) {
  // 只有在effect方法中改变了reactive对象,才会被进行依赖收集,因为此时activeEffect不是undefined
  if (activeEffect) {
    // 首先在targetMap中获取target
    let depsMap = targetMap.get(target)
    // 如果没有,就新建一个映射表,这里使用Map是因为之后的key可能是字符串
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    // 如果有映射表,就查找有没有当前的属性
    let dep = depsMap.get(key)
    // 如果没有这个属性,就使用Set添加一个集合
    if (!dep) {
      depsMap.set(key, (dep = new Set()))
    }
    // 判断如果没有的话,再去添加
    let shouldTrack = !dep.has(activeEffect)
    if (shouldTrack) {
      // 在同一个effect中,如果多次使用同一属性,那么就不需要多次进行依赖收集
      dep.add(activeEffect)
      activeEffect.deps.push(dep) // 在effect中记录所有依赖,后续便于清理(多对多联系建立)
    }
  }
}

// 8. 实现触发更新
export function trigger(target, key, newValue, oldValue) {
  // 通过对象找到对应属性,让这个属性对应的effect重新执行
  const depsMap = targetMap.get(target) // 获取对应的映射表
  if (!depsMap) return
  const dep = depsMap.get(key) // 属性对应的所有effect集合,是个set
  // 9-3 进行一次拷贝,防止自己删除元素的同时,自己添加,造成死循环
  const effects = [...dep]
  effects && effects.forEach(effect => {
    // 执行每个effect中的run方法;正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) {
      // 12-3. 如果用户传入了scheduler,那么就执行用户自定义逻辑,否则还是执行run逻辑
      if(effect.scheduler) {
        effect.scheduler()
      }else {
        effect.run()
      }
    }
  })
}

通过12-1~12-3的这三个步骤,我们不难理解,只需要在trigger方法中,也就是触发的时候通过判断是否传入了options.scheduler属性,来执行我们自己定义的scheduler函数逻辑或者是执行默认的effect.run方法。到此,我们的effect.ts文件可以说是暂时写完了。

结语

呼,长舒一口气。聪明的你,有没有发现,最后effect增加的内容,有点眼熟的感觉呢?没错,这种写法像极了watchwatchEffect,类似于第一个参数是观察的属性,第二个参数是执行的回调。那么剩下的内容,就是我们下篇文章要说的了,面试中也经常会问到watchcomputed是如何实现的呢?且听下回分解~