前言:
以下就是vue响应式的核心
1、reactive:数据劫持
javascript
import { isObject } from '@vue/shared'
import { track, trigger } from './effect'
// 缓存代理结果:保证了同一个对象返回相同的代理结果,用weakMap的原因是它的属性可以是object,且不存在垃圾回收的问题
const reactiveMap = new WeakMap()
// 常量:实现将代理后的对象再次传入reactive中,不进行再一次的代理
const enum ReactiveFlags {
IS_REACTIVE = '__v_isReactive'
}
// 被Proxy包裹后,每次读取属性时都会先通过这个方法
const mutableHandlers = {
get(target, key, receiver) {
// 1. 在经过get劫持后,如果访问到的key就是ReactiveFlags.IS_REACTIVE,就说明被代理的对象,又被传进来了,所以直接返回true
if (key === ReactiveFlags.IS_REACTIVE) return true
// 2. 使用reflect的原因是通过改变this指向来保证对象中的每一个属性都能够被依赖收集(详细看下面)
const res = Reflect.get(target, key, receiver)
// 3. 进行依赖收集逻辑
track(target, key)
// 4. 判断如果res是一个对象,则进行递归代理。保证对象内部的对象也被Proxy
if(isObject(res)){
return reactive(res);
}
return res
},
set(target, key, value, receiver) {
let oldValue = target[key]
Reflect.set(target, key, value, receiver)
// 1.新旧值不一样的时候,触发更新逻辑
if (oldValue !== value) {
trigger(target, key, value, oldValue)
}
return true
}
}
export function reactive(target) {
// 1. 先判断target是不是个对象,reactive只能处理对象类型的数据
if (!isObject(target)) return
// 2. 如果能够从从缓存中读取,则直接返回
const existingProxy = reactiveMap.get(target)
if(existingProxy) return existingProxy
// 3. 如果被代理后的对象,又被传入进来了,那么应该将这个被代理的对象直接返回,而不是再代理一次
// 第一次没被代理过if里面是undefined,
// 第二次target被代理过了,并且target[ReactiveFlags.IS_REACTIVE]是个取值操作所以会走上面get逻辑返回true
if (target[ReactiveFlags.IS_REACTIVE]) return target
// 4. 没有缓存过,就使用proxy进行代理
const proxy = new Proxy(target, mutableHandlers)
// 5. 缓存proxy结果
reactiveMap.set(target, proxy)
return proxy
}
2、effect:实现依赖收集和触发更新
javascript
// 1. 当前正在执行的effect
// 通过一个变量让effect和reactive之间成功建立了联系。实现了依赖收集,就是reactive中值发生改变了,自动执行相应effect函数的功能
export let activeEffect = undefined
// 声明清理effect的一个方法,在每次依赖收集前进行调用
function cleanupEffect(effect) {
const { deps } = effect; // 清理effect
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect);
}
effect.deps.length = 0;
}
// 编写ReactiveEffect类
class ReactiveEffect {
// 2. 设置一个父节点的标识:来解决effect嵌套调用的问题
parent = undefined
// 定义一个依赖数组,保存着一个effect对应了哪些依赖
deps = []
// 表示当前处于激活态,要进行依赖收集
active = true
// 将scheduler挂载effect实例上,保证当依赖发生变化的时候,我们可以执行自己的逻辑。
constructor(public fn, public scheduler) { }
run() {
// 失活态默认调用run的时候,只是重新执行传入的函数,并不会发生依赖收集
if (!this.active) {
return this.fn()
}
try {
// 1-1.设置正在运行的是当前effect
activeEffect = this
// 清理上一次依赖收集
cleanupEffect(this)
// 执行传入的函数
return this.fn()
} finally {
activeEffect = this.parent // 2-1. 执行完当前effect之后,还原activeEffect为当前effect的父节点
this.parent = undefined // 2-2. 重置父节点标记
}
}
// 声明stop方法
stop() {
if (this.active) {
// 失活就停止依赖收集
this.active = false
cleanupEffect(this)
}
}
}
// 我们创建一个响应式effect导出,并且让effect首先默认执行
// effect是底层方法, 很多方法都是基于它进行封装的。
export function effect(fn, options: any = { }) {
const _effect = new ReactiveEffect(fn, options.scheduler)
_effect.run()
// 给effect添加一个返回值,通过这个返回值可以调用_effect实例中的stop和run等方法
// 手动调用_effect中的stop可以停止依赖收集、手动调用_effect的run方法就类似于Vue中的forceUpdate,可以强制刷新组件
const runner = _effect.run.bind(_effect)
runner.effect = _effect
return runner
}
// track函数实现了依赖收集的逻辑
// targetMap的key是整个对象,value是一个map结构。
// map结构的key是属性,value是set结构,存储和属性对应的一个effect
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中记录所有依赖,后续便于清理(多对多联系建立)
}
}
}
// trigger实现触发更新的逻辑
export function trigger(target, key, newValue, oldValue) {
// 通过对象找到对应属性,让这个属性对应的effect重新执行
const depsMap = targetMap.get(target) // 获取对应的映射表
if (!depsMap) return
const dep = depsMap.get(key) // 属性对应的所有effect集合,是个set
// 进行一次拷贝,防止自己删除元素的同时,自己添加,造成死循环
const effects = [...dep]
effects && effects.forEach(effect => {
// 执行每个effect中的run方法;正在执行的effect,不要多次执行,防止死循环
if (effect !== activeEffect) {
// 如果用户传入了scheduler,那么就执行用户自定义逻辑,否则还是执行run逻辑
if(effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
})
}
一、Vue2和Vue3的对比
这里我们不得不先提及一下Vue2的响应式原理,说句现实的话,面试的时候,肯定会一起问的,那么如果能够将两者结合在一起,进行有条理的对比分析回答,那么绝对是一个亮眼的加分项。
1、响应式原理对比
Vue2不足:
- 在使用Vue2的时候,进行数据劫持使用的是Object.defineproperty,需要对我们data中定义的所有属性进行重写,从而添加getter和setter,正是因为了这一步,所以导致,如果data中定义的属性过多,性能就会变差。
- 在写项目的时候,有的时候会碰到需要新增或删除属性的操作,那么直接新增/删除,就无法监控变化,所以需要通过一些api比如 <math xmlns="http://www.w3.org/1998/Math/MathML"> s e t , set, </math>set,delete进行实现,其实原理上还是使用了Object.defineproperty进行了数据劫持。
- 针对数组的处理,没有使用Object.defineproperty进行数据劫持,因为如果给一个很长的数组的每一项,都添加getter和setter,那多来几个数组,就崩掉了,而且日常开发中我们通过数组索引进行修改数组的操作比较少。所以Vue2的方式就是采用重写了一些常用的数组方法比如unshift,shift,push,pop,splice,sort,reverse这七个方法,来解决数组数据响应式的问题。
Vue3改进:
- Vue3使用了Proxy来实现了响应式数据变化,从而从根本上解决了上述问题,逻辑也简化了好多。
2、写法区别对比
- 在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在开发比较复杂的页面中,书写起来显得非常方便。
二、reactivity模块的基本使用
老规矩,我们先简单的看下,这个模块的使用方法,然后再来一步一步,简单实现里边的方法。打开上篇文章创建好的项目,在项目根目录,我们执行pnpm install vue -w,先用一下Vue3官方提供的方法,看看是啥效果。安装好后,我们通过node_modules文件夹找到@vue/reactivity/dist/reactivity.esm-browser.js这个文件,通过文件名字我们就能看出来,这个是esModule可以放在浏览器中运行的。把这个文件复制一份,直接放在我们自己reactivity/dist目录下,然后修改reactivity/dist/index.html的代码如下:
xml
<!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 会自动执行进行页面渲染。effect中使用reactive对象的时候,会进行依赖收集,reactive对象属性变化的时候,effect会重新执行
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方法是底层方法,项目中用不到非常正常,但是watch,watchEffect总该用过吧?嘿嘿,没错,都是基于effect进行了封装从而实现的,别急,我们在下边的文章中会娓娓道来。
三、开始实现reactivity模块中的方法
首先我们在shared中添加一个新方法:
javascript
// 用来判断是不是一个对象
export const isObject = value => {
return value != null && typeof value === 'object'
}
之后,我们在reactivity/src目录下,新建reactive.ts文件,(用来1. 写reactive的主逻辑):
1、实现reactive的基本主逻辑:
javascript
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
}
2、使用Reflect的原因:
我们用最简单的代码,写了reactive的核心逻辑,从代码中也看到,reactive中只能处理对象类型的数据。还有一点,细心的朋友可能会发现,在get和set中,使用了Reflect的get,set方法,那为什么不直接用target[key]呢,效果不是一样的么?看起来是这样,但是在一些情况下,就能看到明显的问题。我们先举个例子:
javascript
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属性。
javascript
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
3、实现传入同一个对象,返回相同的代理结果:
经过这个小插曲,我们回到reactive代码中。虽然核心逻辑写好了,但是我们要考虑一些小问题,比如在下方代码中,如果用Vue3官方源码来执行,那么如果对于同一个对象进行多次代理,都应该返回同一个代理,结果为true,但是在我们目前的代码中,没有过这个判断,只要在reactive中传入一个对象,就进行new Proxy()生成一个新的代理,所以结果为false,这样肯定是不合理的。
ini
import { reactive } from 'vue'
const obj = { name: 'zhangsan' }
let proxy1 = reactive(obj)
let proxy2 = reactive(obj)
console.log(proxy1 === proxy2)
那么应该如何做到如果传入同一个对象,就返回相同的代理结果呢?其实想一想大致的思路就有了,没错,需要有个缓存表,来记录每次传入的对象是不是重复了,如果重复,就返回已经存在的代理对象。那应该用什么缓存呢?没错,就是用WeekMap,好处就是它的key能存放object类型的数据,而且不存在垃圾回收的问题,我们来补充完整逻辑吧!
javascript
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
}
4、实现将代理后的对象再次传入reactive中,不进行再一次的代理:
这时候,我们再引入自己的reactive,执行刚才那段测试代码,发现console.log(proxy1 === proxy2)
返回的就是true。这个问题解决了,但是新的问题又来了,还是回到刚才那个测试代码,这次将代理后的对象,再次传入到reactive中。在源码中返回的结果依旧是true,但是在我们的代码中,因为传入被代理后的对象,又是一个新的对象,所以会再次被代理。那么,我们怎么才能够判断这种情况呢?
ini
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采用了一个比较巧妙的方法来解决这个问题,第一次看可能会有些绕,所以最好多看几遍代码,或在浏览器中进行断点调试。
javascript
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 // 保证target[ReactiveFlags.IS_REACTIVE]为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 // 第一次没被代理过不走上面的get逻辑,第二次被代理过了走上面get逻辑返回true
// 没有缓存过,就使用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方法
1、effect的主逻辑
javascript
// 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()
}
2、实现reactive中值发生改变了,自动执行相应effect函数的功能(也就是实现怎么进行依赖收集):
那么,effect的最基本架子,就搭起来了。接下来是一个很关键的步骤,effect是怎么和reactive建立起联系,产生关联的呢?换句话讲,当我们定义的reactive变量中的值发生变化了,是怎么执行相应effect的函数呢?有些朋友自然而然就想到了依赖收集、触发更新这两个词,别急,我们一步一步来分析,其实建立联系用到了一个很巧妙的方法,那就是导出一个变量,那么这个变量就代表着effect的实例,从reactive模块中再导入这个变量,那么就相当于建立起了联系,我们看具体代码:
javascript
// 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模块中导入这个变量,简单的调试看下结果:
javascript
// reactivity/src/reactive.ts
import { activeEffect } from './effect'
...
get() {
...
console.log(activeEffect)
...
}
...
多余的代码不写了,为了清晰,我们只写调试代码。刷新页面,我们可以看到,在执行effect方法中传入的函数时,因为我们在函数中使用到了reactive定义的变量,所以可以清楚地看到activeEffect被成功的打印了出来,至此,effect和reactive之间成功建立了联系。后续所有的代码都是建立在这条之上的。
3、每次执行effect方法的时候,activeEffect都为当前的effect
有聪明的小伙伴可能有疑问了,那如果我们在index.html中,调用了2次或多次effect函数,按现在的代码不就有问题了么,因为run了多次之后,或者在effect外部又改变了reactive定义变量的值,那activeEffect不就乱套了么?没错,所以我们要保证,每次执行effect方法的时候,activeEffect都为当前的effect,解决方法也很简单,我们再添加几行代码:
javascript
// 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()
}
4、解决effect嵌套调用的问题
我们继续,那么问题又来了,如果按照现在我们的effect中的代码,如果在使用effect方法的时候,进行了嵌套调用,那activeEffect就会出bug了,什么意思呢?我们改变一下index.html中的代码,然后稍加分析。
xml
<!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进行取值的时候,effect和reactive之间的联系就断了(没有被依赖收集),而想正确建立联系,那么此时的activeEffect就应该是outer effect,怎么去做呢?这种嵌套的关系,是不是很像树形结构?树型结构的特点就是有父节点和子节点,所以,我们只需要标记父子关系即可:
javascript
// 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中被监听到。那么接下来,我们便可以写之前常提到的依赖收集和触发更新了。我们发现,reactive和effect方法,其实是多对多的关系,即一个reactive中的属性,可以在多个effect方法中使用,而一个effect方法中,又可以使用多个reactive中的属性。
所以,我们之前常说的依赖收集,其实可以理解为,使用我们自己定义的一个名叫track的方法,在get中收集每个响应式属性对应的effect方法,让这个属性和effect产生关联;而触发更新,则是使用我们自己定义的trigger方法,在set中触发更新的逻辑,执行每个响应式属性所对应的effect方法。
那么我们首先在reactive文件中,导入并且调用这两个方法,之后,我们再去effect文件中实现这两个方法:
5、实现依赖收集和触发更新
vbnet
// 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中再实现这两个方法:
javascript
// 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这种结构,targetMap的key是整个对象,value是一个map结构,map结构的key是属性,value是set结构,存储和属性对应的一个个effect,如果还是不清楚,那么可以将targetMap打印在控制台中。
关于第8步骤trigger中,在循环调用effect.run方法前,会有一个防止死循环的判断,这是啥意思呢?我们简单解释一下,如果在index.html中,这样调用effect方法的话:
ini
effect(() => {
// 每次修改state.name都是新的随机数
state.name = Math.random()
app.innerHTML = state.name + ':' + state.age
})
很明显,上述代码就变成了死循环,因为当state.name的值发生变化后,就会触发更新,又执行了effect方法,而在执行effect方法的时候,又因为重新改变了state.name的值,所以就又会触发effect方法,就成了无线递归的死循环代码。所以,我们这边要加一个判断,表明如果当前正在执行的effect如果和activeEffect不相同的时候,才去执行,这样,就不会造成自己调用自己,死循环的结果。
到这里,我们的代码依旧有些小问题可以优化,我们来看一个比较有意思的场景,改变index.html中的代码:
xml
<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行,顺序是:
arduino
页面刷新
// 1秒后
页面刷新
// 又过了1秒后
name被修改了
页面刷新
那么问题来了,name被修改后,不应该又触发一次页面刷新的逻辑,因为此时flag已经变为了false,按理来说依赖收集应该只收集flag和age,所以当改变name的时候,不会触发更新。我们再梳理下当前代码,依赖收集和触发更新的流程:一开始effect会直接执行,所以会直接输出页面刷新,此时依赖收集的属性有flag和name,过了1秒钟,flag改为了false,所以又会触发页面更新,此时依赖收集的是flag和age(注意,name的依赖收集依旧存在,没有被清理掉,问题就出在这),又过了1秒钟,打印了name被修改了,但是因为此时name的依赖收集依旧存在,在改了name的值后,依旧触发了effect函数,所以紧接着就打印了页面刷新。
看到这,是不是就知道问题所在和怎么去解决呢?没错,就是在进行下次依赖收集之前,要把之前的依赖收集先进行清空,这样,就不会存在上边这种,明明没有收集name的依赖,但是当改变name的值后,页面依旧触发更新的情况了。
javascript
// 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的代码就逐渐接近尾声了。我们加把劲,继续来!
那么有一种很常见的场景,当我们代理的对象,内部又有很多对象,那这些对象就不会被代理,比如:
php
const obj = reactive({
name: '张三',
info: {
age: 18,
sex: '男'
}
})
那么这时候,我们就需要进行递归代理,方法也很简单,在reactive.ts文件中get最后添加几行代码即可:
kotlin
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文件来继续看:
javascript
// 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文件,来尝试用下这个功能,不然只说概念,没有场景,很难理解。
xml
<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稍加改造,然后根据我们想要的数据结构,来反向推断代码应该如何写,我们想要的结果是这样:
xml
<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方法中的第一个回调逻辑,这样就达到了当依赖发生变化的时候,我们可以执行自己的逻辑。想要的效果很明确了,那我们来完善下逻辑吧!
javascript
// 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增加的内容,有点眼熟的感觉呢?没错,这种写法像极了watch和watchEffect,类似于第一个参数是观察的属性,第二个参数是执行的回调。那么剩下的内容,就是我们下篇文章要说的了,面试中也经常会问到watch,computed是如何实现的呢?且听下回分解~
作者:柠檬soda水
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。