携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 2 天,点击查看活动详情
前言
本文是细说 Vue3 响应式原理的下篇,阅读前请确保你已阅读过上篇 reactive 与 watch 实现
本文将实现 ref 与 computed 的功能,并分析其中的一些细节优化
预备函数
接下来会复用一些在上篇已经实现的函数,在这里列举出来,不作讲解。
工具函数与变量
const isArray = Array.isArray
const isObject = (val) => val !== null && typeof val === 'object'
let activeEffect // 存储激活的响应式依赖
ReactiveEffect - 响应式依赖类
class ReactiveEffect {
constructor(fn, scheduler) {
this.fn = fn // 收集依赖的函数
this.scheduler = scheduler // 调度程序
this.deps = [] // 存储所有用到此 effect 的dep
this.parent = undefined // 用于保存前一个 activeEffect
}
// 收集依赖
run() {
try {
// 保存之前的 effect
this.parent = activeEffect
activeEffect = this
return this.fn()
} finally {
// 处理完毕 恢复之前的 effect
activeEffect = this.parent
this.parent = undefined
}
}
// 停止作用,从所有dep中删除此effect
stop() {
const { deps } = this
for (let i = 0; i < deps.length; i++) {
deps[i].delete(this)
}
deps.length = 0
}
}
trackEffects - 添加依赖
function trackEffects(dep) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
triggerEffects - 执行依赖
function triggerEffects(dep) {
for (const effect of dep) {
effect.scheduler()
}
}
createDep - 创建依赖集合
后续会在其中进行细节优化
function createDep(effects) {
const dep = new Set(effects)
return dep
}
toRaw - 获取代理的原对象
function toRaw(observed) {
const raw = observed && observed['__v_raw']
return raw ? toRaw(raw) : observed
}
watch - 监视函数
为了方便讲解与测试,我们使用基础版的 watch 函数,后续会针对 ref 和 computed 扩展其功能。
function watch(getter, callback) {
let oldValue
const job = () => {
const newValue = effect.run()
callback(newValue, oldValue)
oldValue = newValue
}
const effect = new ReactiveEffect(getter, job)
oldValue = effect.run()
// 返回一个可以取消监听的函数
return () => {
effect.stop()
}
}
reactive - 对象响应式
同样为了方便,我们只采用基础版的 reactive 功能,获取会因 ref 和 computed 的加入扩展 getter 与 setter
const targetMap = new WeakMap()
const get = createGetter()
const set = createSetter()
function createGetter() {
return function (target, key) {
if (key === '__v_raw') {
return target // 供 toRaw 获取原对象
}
track(target, key) // 收集对象依赖,具体代码见上篇
const res = Reflect.get(target, key)
return reactive(res) // 处理多层对象
}
}
function createSetter() {
return function (target, key, value) {
let oldValue = target[key]
const result = Reflect.set(target, key, value)
if (value !== oldValue) {
trigger(target, key) // 触发对象依赖,具体代码见上篇
}
return result
}
}
const handler = { get, set }
const reactiveMap = new WeakMap()
function reactive(obj) {
if (!isObject(obj)) return obj
let p = reactiveMap.get(obj)
if (!p) {
p = new Proxy(obj, handler)
reactiveMap.set(obj, p)
}
return p
}
实现逻辑
与基于 Proxy 的 reactive 不同,ref 和 computed 都是基于 Object.defineProperty 实现的,我们在使用的时候,也都是访问修改它们的 value 属性。
在源码中,ref 和 computed 分别对应一个类,在类中定义 value 属性的 getter 与 setter,来实现响应式功能。而且与其有关的响应式依赖并不存入全局的 targetMap,而是存在各自的 dep 属性中。
实现 ref
基础功能
我们先实现一个基础版的 ref,只需在 getter 中跟踪依赖,在 setter 中触发依赖就好。
function ref(value) {
return new RefImpl(value)
}
// ref类
class RefImpl {
constructor(value) {
this.dep = createDep()
this._value = value // 老值
}
get value() {
trackRefValue(this) // 收集依赖
return this._value
}
set value(newVal) {
if (newVal !== this._value) {
this._value = newVal
triggerRefValue(this) // 触发依赖
}
}
}
// 收集 ref 依赖
function trackRefValue(ref) {
if (activeEffect) {
trackEffects(ref.dep) // 添加依赖
}
}
// 触发 ref 依赖
function triggerRefValue(ref) {
if (ref.dep) {
triggerEffects(ref.dep) // 执行依赖
}
}
// 测试
const num = ref(1)
watch(
() => num.value,
(now, pre) => {
console.log(`num从${pre}变成了${now}`)
}
)
num.value++ // num从1变成了2
num.value++ // num从2变成了3
逻辑图:
ref 与 watch 的交互
我们平时监听 ref,并不会这样子书写 watch(() => rev.value, callback),而是直接将 ref 对象传过去 watch(ref, callback),所以我们扩展一下 watch 的功能,使其允许直接传 ref
我们要如何知道传入的 getter 是不是一个 ref 呢?其实很简单,只要给 ref 对象设置一个特殊的属性就好了。
class RefImpl {
constructor(value) {
this.dep = createDep()
this._value = value // 老值
}
……
}
// 判断是否为 ref
function isRef(r) {
return r && r.__v_isRef
}
然后我们在 watch 中做一下修改,当传入的 getter 是一个ref时,自动改为 () => ref.value 的形式
function watch(getter, callback) {
if (isRef(getter)) { // 处理 ref
getter = () => getter.value
}
……
}
然后 watch 还允许使用传一个 ref 数组,我们也实现一下
function watch(getter, callback) {
if (isRef(getter)) { // 处理ref
getter = () => getter.value
} else if (isArray(getter)) { // 处理 ref 数组
const arr = getter
getter = () => arr.map((ref) => ref.value)
}
……
}
ref 与 reactive 的交互
我们知道,当我们向 ref 传入一个对象时,该对象会被 reactive 代理
而对象被 reactive 封装后因为对象被代理后返回的是新对象,原对象地址不同,所以牵扯到对象的比较,我们只比较它们的原对象是否同地址。
class RefImpl {
constructor(value) {
this.__v_isRef = true
this.dep = createDep()
this._rawValue = toRaw(value) // 原值
this._value = reactive(value) // 老值 如果是对象,使用reactive代理
}
get value() {
trackRefValue(this) // 收集依赖
return this._value
}
set value(newVal) {
newVal = toRaw(newVal) // 转化成原值比较
if (newVal !== this._rawValue) {
this._rawValue = newVal
this._value = reactive(newVal)
triggerRefValue(this) // 触发依赖
}
}
}
当 ref 的数据不是对象时,_value 与 _rawValue 始终相等
至此,ref 算是实现完了,还有一点小尾巴放在 computed 中实现更为合适
实现 computed
用法与目标
我们根据基础的用法开始实现,大家用 computed 更多时候应该传递一个 getter 函数吧,然后当其中用到的响应式数据发生改变,再次访问 computed 的话,其值就会更新
像下面这样:
const num = ref(1)
const double = computed(() => {
console.log('计算属性更新了')
return num.value * 2
})
console.log(double.value) // 2 计算属性更新了
num.value++
num.value++
console.log(double.value) // 6 计算属性更新了
getter 函数并不是在你更改响应式数据的时候执行,而是当你再次访问的时候,才会重新执行获取新值
基础功能
我们先只实现上面那个简单的例子
我们发现,想要实现 computed 的自动更新,需要收集其中的依赖,这点与 watch 很像。我们可以仿照 watch 中的做法,区别在于其调度函数暂时还没有内容
所以一个基础般的 computed 就做好了
function computed(getter) {
return new ComputedRefImpl(getter)
}
// computed 类
class ComputedRefImpl {
constructor(getter) {
this.effect = new ReactiveEffect(getter, () => {})
}
get value() {
return this.effect.run()
}
}
const num = ref(1)
const double = computed(() => num.value * 2)
console.log(double.value) // 2
num.value++
console.log(double.value) // 4
插入响应式
基础版的 computed 实现了 value 的自动更新,但是并不能像 ref 一样被响应式依赖,所以我们为其插入响应式功能。
conputed 收集和触发依赖的逻辑和 ref 是一样的,可以复用之前的 trackRefValue 与 triggerRefValue 函数
class ComputedRefImpl {
constructor(getter) {
this.dep = createDep()
this.effect = new ReactiveEffect(getter, () => {
triggerRefValue(this) // 触发依赖
})
}
get value() {
trackRefValue(this) // 收集依赖
return this.effect.run()
}
}
// 举例测试
const num = ref(1)
const double = computed(() => {
return num.value * 2
})
// 监听double
watch(
() => double.value,
(now, pre) => {
console.log(`double从${pre}变成了${now}`)
}
)
num.value++ // double从2变成了4
num.value++ // double从4变成了6
来张逻辑图:
在上面的流程中,创建了两次 ReactiveEffect,图中使用的是 watch 创建的响应式依赖,存储在 conputed 的 dep 属性中,使用的是 computed 创建的响应式依赖,存储在 ref 的 dep 属性中。
当 ref 的 value 改变后,触发 的调度函数,然后在其中执行 ,再执行 的调度函数,将提示信息输出到控制台。
数据缓存
我们知道,computed 的数据是有缓存性的,只有其依赖的响应式数据改变了,才会重新执行 getter 获取新值
像下面这样
const num = ref(1)
const double = computed(() => {
console.log('double获取了新值')
return num.value * 2
})
double.value // double获取了新值
double.value
double.value
num.value++
double.value // double获取了新值
我们为 conputed 对象增加两个属性,_value 缓存值,_dirty 标记缓存是否可用。只有在缓存失效(数据脏了)的时候,才去调用 getter
class ComputedRefImpl {
constructor(getter) {
this.dep = createDep()
this._value = undefined // 缓存值
this._dirty = true // 缓存是否已失效
this.effect = new ReactiveEffect(getter, () => {
// 执行调度函数说明其依赖的响应值已改变,缓存脏了
if (!this._dirty) {
this._dirty = true // 下次取值需要重新计算
triggerRefValue(this)
}
})
}
get value() {
trackRefValue(this)
// 如果缓存不可以,才重新计算
if (this._dirty) {
this._dirty = false
this._value = this.effect.run()
}
return this._value
}
}
可写的计算属性
我们在调用 computed 时,传参可以不是一个 getter 函数,而是一个具有 get 和 set 函数属性的对象,来创建一个可写的计算属性
实现也很简单,在 computed 中判断一下是不是函数,如果不是那就取对象的 get/set 来创建可写计算属性
function computed(getterOrOptions) {
let getter
let setter
// 判断是否为函数
const onlyGetter = typeof getterOrOptions === 'function'
if (onlyGetter) {
getter = getterOrOptions
setter = () => {}
} else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}
return new ComputedRefImpl(getter, setter)
}
而往 ComputedRefImpl 写值的时候,调用一下 setter 就行。一般可写的计算属性,其 setter 中会改变依赖源(比如说是ref)的数值,然后触发 ref 的依赖,进而改变 computed 的值,再触发其自身的依赖……就是重复前文插入响应式一节的流程
class ComputedRefImpl {
constructor(getter, setter) {
this.setter = setter
……
}
set value(newValue) {
this.setter(newValue)
}
……
}
conputed 与 reactive 的交互
我们平时开发中,应该经常会像下面这样,在 reactive 中使用 computed
const num = ref(1)
const obj = reactive({
double: computed(() => num.value * 2),
})
console.log(obj.double) // 2
按理来说 obj.double 应该是个 ComputedRefImpl 示例,而在我们却能直接获取其 value
这就涉及到 reactive 中对 Ref 对象的解包
实现起来很简单,首先在 ComputedRefImpl 示例加上 __v_isRef 标志
class ComputedRefImpl {
constructor(getter, setter) {
this.__v_isRef = true
……
}
}
然后在 reactive 的 getter 与 setter 中,判断是否为 Ref 对象,如果是的话就进行解包处理。
function createGetter() {
return function (target, key) {
if (key === '__v_raw') {
return target
}
track(target, key)
const res = Reflect.get(target, key)
// 如果是 Ref 对象,解包
if (isRef(res)) return res.value
return reactive(res) // 将返回值用reactive包裹
}
}
function createSetter() {
return function (target, key, value) {
let oldValue = target[key]
// 如果旧值是 Ref 对象且新值不是 Ref 对象,依赖就交给 Ref 触发
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
}
const result = Reflect.set(target, key, value)
if (value !== oldValue) {
trigger(target, key)
}
return result
}
}
需要注意,在 reactive 读写 Ref 对象时,虽然不会触发依赖(改为触发 Ref 的依赖),但依旧会收集依赖到 targetMap 中,因为该属性可能被赋值一个新的 Ref 对象,此时就要触发之前收集到的依赖
画个图吧
至此 Vue3 响应式的四大金刚就完成了,市面上的所有教程都就止步于此了
但这里不是,我还要分析源码中的优化细节,让你们在面试中能聊出和别人不一样的东西
细节优化
懒创建
先说个简单的,在 ref 和 computed 对象中,他们 dep 属性的创建操作被移动到了 trackRefValue 函数中
class RefImpl {
constructor(value) {
this.dep = undefined
……
}
}
class ComputedRefImpl {
constructor(getter, setter) {
this.dep = undefined
……
}
}
function trackRefValue(ref) {
if (shouldTrack && activeEffect) {
ref = toRaw(ref)
trackEffects(ref.dep || (ref.dep = createDep()))
}
}
就是在使用前省了集合的创建,聊胜于无吧。
Vue 中还有很多这样的细节,比如渲染器、实例上下文,都是到用的时候才去创建
依赖的清除
接下来的应该都是市面上没人讲过的东西,反正我看源码的时候没搜到相关资料,思考了半天。
先说解决了什么问题吧,看下面这个测试:
const needPositive = ref(true)
const positiveNum = ref(1) // 正数
const negativeNum = ref(-1) // 负数
const num = computed(() => {
if (needPositive.value) {
console.log(`计算属性更新了,值为${positiveNum.value}`)
return positiveNum.value
} else {
console.log(`计算属性更新了,值为${negativeNum.value}`)
return negativeNum.value
}
})
num.value // 计算属性更新了,值为1
positiveNum.value++
num.value // 计算属性更新了,值为2
negativeNum.value--
num.value // (无输出,negativeNum没有被跟踪)
needPositive.value = false
num.value // 计算属性更新了,值为-2
positiveNum.value++
num.value // 计算属性更新了,值为-2 (positiveNum依旧被跟踪!)
negativeNum.value--
num.value // 计算属性更新了,值为-3
发现问题了吗,上面的示例中,计算属性 num 已经不依赖 positiveNum 了,但因为之前收集过它的依赖,在其修改后,又会使下次访问 num 时 getter 函数重新调用,进而执行所有依赖此计算属性的调度函数,即使数据没有改变
所以,我们不仅要保证计算属性能跟踪其内部的所有响应式依赖,还要保证在某些响应式数据无法触及时,清除它们的的依赖
暴力全清
先从简单粗暴的方法来,每次执行 getter 前都把上次收集到的所有依赖清除掉,在执行函数时重新收集依赖,不就能保证每一个依赖都确实会导致数据改变吗
那要如何清除呢?其实在上篇已经实现了,只是当时不是重点,一笔带过没有细说。
还记得 ReactiveEffect 示例中的 deps 属性吗?里面存有所有用到此依赖的 dep,我们的依赖都是双向添加的,你在使用此依赖的同时,我也存有你的 dep 集合
function trackEffects(dep) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
当时清除依赖的操作我们实现在 stop 方法中,此时要将其抽离成一个函数,每次收集依赖前都调用,实现如下:
class ReactiveEffect {
constructor(fn, scheduler) {
this.fn = fn // 收集依赖的函数
this.scheduler = scheduler // 调度程序
this.deps = [] // 存储所有用到此 effect 的dep
this.parent = undefined // 用于保存前一个 activeEffect
}
run() {
try {
// 保存之前的 effect
this.parent = activeEffect
activeEffect = this
cleanupEffect(this) // 清除上次收集到的依赖
return this.fn() // 重新收集依赖
} finally {
// 处理完毕 恢复之前的 effect
activeEffect = this.parent
this.parent = undefined
}
}
stop() {
// 从所有使用此实例的dep中清除此实例
cleanupEffect(this)
}
}
// 从deps删除依赖
function cleanupEffect(effect) {
const { deps } = effect
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
}
deps.length = 0
}
逻辑可能有点绕,带领大家分析一遍示例
- 第一次访问的时候,运行 getter 函数,只读取了
positiveNum和needPositive,所以只将 effect 添加进它们的 dep 集合中。由于是双向收集,所以它们的 dep 已被添加进了num的 deps 数组中;
- 然后修改
positiveNum,由于其 dep 集合中包含num的 effect,执行调度函数,标记缓存已脏,需重新计算; - 之后又访问,重新执行 getter,控制台打印输出。虽然中间清除了一遍依赖,但访问的数据没有变化,又将依赖添了回来;
- 后来修改了
negativeNum由于其没有被依赖,并不会导致缓存失效 - 转折点在于修改了
needPositive,触发依赖,标记缓存已失效,但此时依赖关系依旧是上图的情况 - 但是在下一次访问
num的时候,清空了之前的了之前的依赖,断开了 dep、deps、effect 之间的连接,这次代码执行和上次不同,读取了negativeNum,依赖关系变为下图的情况
- 最后就是常规的改值读取,验证了
positiveNum已不被依赖
标记清除
每次都暴力全清肯定是个笨方法嘛,我们其实只需要清除这次访问中没有使用到的响应式依赖
所以我们将本次使用到的依赖打上标记,访问完对比一下,将没有访问的依赖清除掉,这部分实现还是较难理解的,我先将代码展示出来,然后画图讲解把
标记就打在dep集合上,createDep 函数不止是创建一个集合这么简单,它还为集合添加了两个属性,值都是数字类型
const createDep = (effects) => {
const dep = new Set(effects)
dep.w = 0 // 是否已被收集
dep.n = 0 // 在新的一轮访问中是否仍被收集
return dep
}
定义一些变量和方法,验证某个 dep 是否已被收集/仍被收集
let effectTrackDepth = 0 // 当前响应式依赖的嵌套层数
let trackOpBit = 1 // 用于比较的变量
const wasTracked = (dep) => (dep.w & trackOpBit) > 0 // 检查dep是否已被收集
const newTracked = (dep) => (dep.n & trackOpBit) > 0 // 检查dep在新的一轮收集中是否仍被收集
在收集依赖时给 dep 打上标记
function trackEffects(dep) {
let shouldTrack = false
if (!newTracked(dep)) { // 如果不是新的依赖
dep.n |= trackOpBit // 标记此dep新的一轮收集中仍被收集(新收集)
shouldTrack = !wasTracked(dep)
}
if (shouldTrack) {
// 双向添加
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
}
在 ReactiveEffect.run 中标记已收集的依赖和清除未收集的依赖
class ReactiveEffect {
constructor(fn, scheduler = null) {
this.fn = fn // 收集依赖的函数
this.scheduler = scheduler // 更新函数
this.deps = [] // 存储所有用到此 effect 的dep
this.parent = undefined // 用于保存前一个 activeEffect
}
run() {
try {
this.parent = activeEffect
activeEffect = this
// 将deps中的所有dep 标记已收集,标记符号位一个二进制数
trackOpBit = 1 << ++effectTrackDepth
const { deps } = this
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].w |= trackOpBit // 设置w属性,标记为已收集
}
}
return this.fn()
} finally {
const { deps } = this
if (deps.length) {
let length = 0
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]
// 如果这个dep已收集但本次循环并没有被收集,清除它
if (wasTracked(dep) && !newTracked(dep)) {
dep.delete(this)
} else {
// 正常循环,覆盖已删除的dep
deps[length++] = dep
}
// 清除dep上的标记
dep.w &= ~trackOpBit
dep.n &= ~trackOpBit
}
deps.length = length // 清除末尾的dep
}
// 复位全局的标记符号
trackOpBit = 1 << --effectTrackDepth
activeEffect = this.parent
this.parent = undefined
}
}
stop() {
cleanupEffect(this)
}
}
接下来带着大家跟着实例走一遍流程
第一次访问,依赖收集,所有依赖都是新的,访问到的依赖都被双向收集,下图是收集过程时的中间状态,在 trackEffects 函数中将 dep.n 置 1
然后收集完成,标记复位
下一次收集是在修改了 positiveNum 之后,在 ReactiveEffect.run 将 dep.w 置 1,在 trackEffects 函数中将 dep.n 置 1
然后尝试清除依赖,发现所有已收集的依赖在这一轮依旧被收集,无依赖被删除
在下一轮,修改了 needPositive,还是在 ReactiveEffect.run 将 dep.w 置 1,在 trackEffects 函数中将 dep.n 置 1,但这次结果可不一样了
清除依赖时,发现 positiveNum 的 dep 已收集,但本轮并未收集,说明是一个用不到的依赖,将其清除,与计算属性断开了连接。
然后复位,成为下图的情况
最后就是验证操作,无需分析了。
总结下来就是这几条:
- dep.w 表示当前依赖已被收集
- dep.n 表示当前依赖在本轮仍被收集
- dep.w 且 !dep.n 的即将被清除
只是因为 Vue 采取的是位标记,可能有点绕
位标记
在这里聊聊为什么依赖清除采用位标记,最直接的原因就是位运算块,还节省空间,处理多层嵌套的依赖收集也很方便。
比如当两个 ReactiveEffect 依赖收集嵌套运行时,dep.w/dep.n 有三种可能取值
- 1:表示被外层的 ReactiveEffect 所依赖
- 2:表示被内层的 ReactiveEffect 所依赖
- 3:表示被内层和外层的 ReactiveEffect 同时依赖
在 Vue 中计算属性的嵌套是经常发生的,如果不采取位标记的话,就要耗费大量的空间和性能来确定当前 dep 的状态
位标记也是有极限的,正数最多31位,所以最多可以标记30层的嵌套,如果真有超过30层的嵌套,就使用暴力清除
1 << 30 // 1073741824
1 << 31 // -2147483648
至此,Vue 响应式的四大金刚都讲完了,这不值得你点个赞?
结语
如果喜欢或有所帮助的话,希望能点赞关注,鼓励一下作者。
如果文章有不正确或存疑的地方,欢迎评论指出。