一、响应式数据与副作用函数
- 什么是响应式数据?
- 什么是副作用函数?
- 副作用函数在vue中起什么作用?
- vue中的副作用函数有哪些?
以上是读本章节我的疑问。
什么是副作用函数,什么是响应式数据书中是这样说的,如下:
副作用函数指的是会产生副作用的函数,如下面的代码所示:
function effect() {
document.body.innerText = 'hello vue3'
}
当 effect 函数执行时,它会设置 body 的文本内容,但除了 effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也就是说,effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面的代码所示:
// 全局变量
let val = 1
function effect() {
val = 2 // 修改全局变量,产生副作用
}
什么是响应式数据呢,结合副作用函数来说:
理解了什么是副作用函数,再来说说什么是响应式数据。假设在一个副作用函数中读取了某个对象的属性:
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
如上面的代码所示,副作用函数 effect 会设置 body 元素的 innerText 属性,其值为obj.text,当 obj.text 的值发生变化时,我们希望副作用函数 effect 会重新执行:
obj.text = 'hello vue3' // 修改 obj.text 的值,同时希望副作用函数会重新执行
这句代码修改了字段 obj.text 的值,我们希望当值变化后,副作用函数自动重新执行,如果能实现这个目标,那么对象 obj 就是响应式数据。但很明显,以上面的代码来看,我们还做不到这一点,因为 obj 是一个普通对象,当我们修改它的值时,除了值本身发生变化之外,不会有任何其他反应。
副作用函数在vue中起什么作用呢:
当某个状态改变时,副作用函数可以自动执行,使得视图可以随之更新。例如响应式数据改变页面对应的dom更新。在组件加载时,可以在onMounted函数中发送 API 请求,并将获取的数据存储在ref或者reactive响应式状态中,进而更新视图。或者在侦听器watch中我们写的回调函数也被认为是副作用函数,监听数据改变,数据变化回调函数从新执行。
vue中的副作用函数有哪些呢:
主要集中在以下几个方面:
- watch侦听器
- 生命周期钩子(如 mounted和onMounted)
- nextTick 函数
- ……
二、响应式数据的基本实现
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
观察这段代码:
当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作;
当修改 obj.text 的值时,会触发字段 obj.text 的设置操作。
如果我们能拦截一个对象的读取和设置操作,事情就变得简单了,当读取字段 obj.text 时,我们可以把副作用函数 effect 存储到一个“桶”里,如图所示。
接着,当设置 obj.text 时,再把副作用函数 effect 从“桶”里取出并执行即可,如图所示。
接下来我们就根据如上思路,采用 Proxy 来实现:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 effect 添加到存储副作用函数的桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
bucket.forEach(fn => fn())
// 返回 true 代表设置操作成功
return true
}
})
// 副作用函数
function effect() {
document.body.innerText = obj.text
}
// 执行副作用函数,触发读取
effect()
// 1 秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
</script>
</body>
</html>
首先,我们创建了一个用于存储副作用函数的桶 bucket,它是Set 类型。接着定义原始数据 data,obj 是原始数据的代理对象,我们分别设置了 get 和 set 拦截函数,用于拦截读取和设置操作。当读取属性时将副作用函数 effect 添加到桶里,即bucket.add(effect),然后返回属性值;当设置属性值时先更新原始数据,再将副作用函数从桶里取出并重新执行,这样我们就实现了响应式数据。但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活。副作用函数的名字可以任意取,我们完全可以把副作用函数命名为myEffect,甚至是一个匿名函数,因此我们要想办法去掉这种硬编码的机制。这里大家只需要理解响应式数据的基本实现和工作原理即可。
三、设计一个完善的响应系统
先看代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
// 存储副作用函数的桶
const bucket = new WeakMap()
// 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
trigger(target, key)
}
})
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
effect(
// 一个匿名的副作用函数
() => {
document.body.innerText = obj.text
}
)
// 1 秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3'
obj.notExist = 'hello vue3'
}, 1000)
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return target[key]
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key)
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
}
function trigger(target, key, newVal) {
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
</script>
</body>
</html>
对以上代码做一个解读:
定义了一个全局变量 activeEffect,初始值是 undefined,它的作用是存储被注册的副作用函数。接着重新定义了 effect 函数,它变成了一个用来注册副作用函数的函数,effect 函数接收一个参数 fn,即要注册的副作用函数。我们使用一个匿名的副作用函数作为 effect 函数的参数。当 effect 函数执行时,首先会把匿名的副作用函数 fn 赋值给全局变量 activeEffect。接着执行被注册的匿名副作用函数 fn,这将会触发响应式数据 obj.text 的读取操作,进而触发代理对象 Proxy 的 get 拦截函数。由于副作用函数已经存储到了 activeEffect 中,所以在 get 拦截函数内应该把 activeEffect 收集到“桶”中,这样响应系统就不依赖副作用函数的名字了。
可以看到,匿名副作用函数内部读取了字段 obj.text 的值,于是匿名副作用函数与字段 obj.text 之间会建立响应联系。如果obj.text 的值改变,那么读取了obj.text的值的函数会从新执行。
但如果在响应式数据 obj 上设置一个不存在的属性时,例如添加一个notExist属性,这时会触发Proxy的set方法并从新执行副作用函数,字段 obj.notExist 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用函数重新执行。也就是在实际项目开发中我们在一个响应式数据中添加一个新字段,那么不应该导致页面的从新渲染和dom更新。为了解决这个问题,我们需要重新设计“桶”的数据结构。
不应该直接使用Set 数据结构作为存储副作用函数的“桶”。导致上述问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构,而不能简单地使用一个 Set 类型的数据作为“桶”了。
那应该设计怎样的数据结构呢?在回答这个问题之前,我们需要先仔细观察下面的代码:
effect(function effectFn() {
document.body.innerText = obj.text
})
//在这段代码中存在三个角色:
//被操作(读取)的代理对象 obj;
//被操作(读取)的字段名 text;
//使用 effect 函数注册的副作用函数 effectFn。
//在Proxy的get方法中储存副作用函数时
//如果用 target 来表示一个代理对象所代理的原始对象
//用 key 来表示被操作的字段名
//用 effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:
target
└── key
└── effectFn
//1.如果有两个副作用函数同时读取同一个对象的属性值:
effect(function effectFn1() {
obj.text
})
effect(function effectFn2() {
obj.text
})
//那么关系如下:
target
└── text
└── effectFn1
└── effectFn2
//2.如果一个副作用函数中读取了同一个对象的两个不同属性:
effect(function effectFn() {
obj.text1
obj.text2
})
//那么关系如下:
target
└── text1
└── effectFn
└── text2
└── effectFn
//3.如果在不同的副作用函数中读取了两个不同对象的不同属性:
effect(function effectFn1() {
obj1.text1
})
effect(function effectFn2() {
obj2.text2
})
//那么关系如下:
target1
└── text1
└── effectFn1
target2
└── text2
└── effectFn2
总之,这其实就是一个树型数据结构。这个联系建立起来之后,就是被代理的响应式对象在设置某个字段时,只会从新触发被设置的这个字段对应的副作用函数,如果字段没被读取过,那么这个字段就不会收集到捅中,也就无论这个字段如何改变都不会触发任何副作用函数的执行。
解读一下track函数和trigger函数
track函数在get读取拦截时被执行,接收了get的参数,用于追踪,里面封装了把副作用函数收集到“桶”里的这部分逻辑。那是如何收集的呢?观察代码使用了WeakMap、Map 和Set:WeakMap 由 target --> Map 构成; Map 由 key --> Set 构成。其中 WeakMap 的键是原始对象 target,WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。它们的关系如图 所示。
trigger函数在set设置拦截是被执行,接收了set的参数,用于触发,里面封装了触发副作用函数重新执行的逻辑。
四、分支切换与 cleanup
在使用vue开发项目时项目时,会经常在模板中写一些三元表达式如:
<template>
<div>{{ obj.ok ? obj.text : 'not' }}</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
let obj = reactive({
ok : true,
text:'hello world'
})
</script>
思考:执行代码页面显示的是'hello world',当obj.ok变为false时,此时会触发vue的render函数和diff算法从新执行,且更新视图,此时页面会显示'not'。想一下此时让obj.text的值无论如何变化还会触发vue的render函数和diff算法从新执行吗???答案是不会的。
在vue中是这样类似的处理方式,先看代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
// 存储副作用函数的桶
// const bucket = new Set()
const bucket = new WeakMap()
// 原始数据
const data = { ok: true, text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶里取出并执行
trigger(target, key)
}
})
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
// activeEffect = fn
// // 执行副作用函数
// fn()
const effectFn = () => {
//调用 cleanup 函数完成清除工作
cleanup(effectFn)
activeEffect = effectFn
fn()
}
effectFn.deps = [] //函数也是对象可以为函数添加属性
effectFn()
}
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i]
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn)
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0
}
effect(
// 一个匿名的副作用函数
() => {
// document.body.innerText = obj.text
document.body.innerText = obj.ok ? obj.text : 'not'
}
)
// 1 秒后修改响应式数据
setTimeout(() => {
obj.ok = false
obj.text = 'hello vue3'
}, 1000)
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key)
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
//把当前激活的副作用函数添加到依赖集合 deps 中
//deps就是一个与当前副作用函数存在联系的依赖集合
//将其添加到 activeEffect.deps数组中
activeEffect.deps.push(deps)
}
function trigger(target, key, newVal) {
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
//此时桶中储存了两个副作用函数 但是只做 obj.ok = false这一个设置操作 所以其中一个副作用函数是多余的
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
const effectsToRun = new Set(effects)
effectsToRun && effectsToRun.forEach(effectFn => effectFn())
// effects&&effects.forEach(effectFn => effectFn())
}
</script>
</body>
</html>
对以上代码做一个解读:
首先,我们需要明确分支切换的定义,如下面的代码所示: