一、基本概念:响应式数据和副作用函数
前言:
本篇文章是《Vue.js设计与实现》第 4 章 响应系统的作用与实现笔记,其中的代码和图片部分来源于本书,用于记录学习收获并且分享。
1.副作用函数
副作用函数:在执行过程中会影响到其他函数的执行(产生副作用)。 比如设置body文本的内容:
function effect() {
document.body.innerText = 'hello vue3'
}
当body文本内容被改变时,可能会有其它使用到body文本的函数会受到影响。
2.响应式数据
响应式数据:在副作用函数中使用的的值,当其变化时,可以触发副作用函数重新执行。若此时改变obj.text
的值副作用函数effect()
能够自动重新执行,则其为响应式数据。
const obj = { text: 'hello world' }
function effect() {
document.body.innerText = obj.text
}
二、响应式数据的基本实现
1.思路:
通过对副作用函数和响应式数据的了解可知,为了实现响应式,我们需要:
1.在数据被读取时,保存相对应的副作用函数。
2.在数据变化时,将保存的副作用函数取出并执行。
2.如何对数据的读取和设置操作进行拦截呢?
Vue2中,使用的是Object.defineProperty
函数实现的。Vue3则采用了ES6中新增的代理对象Proxy实现。
3.Proxy:
proxy的基础用法如下:
let p = new Proxy(target, handler);
target
:被代理的目标对象。handler
:一个对象,定义了代理对象的行为。
为了拦截数据的读取和变化操作,我们需要在handler对象中进行相应的定义:
get(target, property, receiver)
:拦截对象属性的读取操作。set(target, property, value, receiver)
:拦截对象属性的设置操作。
其中:
target
为被代理的对象
property
为被代理对象的属性名
receiver
为代理后的proxy实例,这里为p
具体使用为:
let p = new Proxy(data, {
// 拦截读取操作
get(target, key) {
...
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
...
}
})
4.简单实现:
将数据使用Proxy
代理,并在拦截函数中进行副作用函数的存取操作,为了避免重复的收集副作用函数,使用Set
数据类型存储。
// 定义存储副作用函数的容器
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())
}
})
三、完善
以上实现的响应式在实际情况下还会存在一些问题:
1.副作用函数是通过硬编码来实现的,但实际情况下副作用函数名不一定是effect;
2.在以上情况下实现的响应式会使得整个对象数据和副作用函数建立联系,而我们通常只需要数据中的某些值与其建立联系;
3.分支切换过程中情况没有被考虑;
4.副作用函数如果发生嵌套要如何处理;
5.副作用函数中可能会导致无限递归循环的操作;
1.使用一个函数去注册副作用函数
为了避免硬编码的问题,需要设置一个用于注册副作用函数的方法:
let activeEffect
function effect(fn) {
activeEffect = fn
fn()
}
首先定义了一个全局变量activeEffect
,用于存储被激活的副作用函数。副作用函数fn
则作为effect
的参数传入,并被赋值给activeEffect
,在响应式数据的读取操作发生时收集activeEffect即可。
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
...
bucket.add(activeEffect)
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
...
}
})
2.副作用函数与响应式数据的字段而不是整个响应式数据对象建立联系
先前我们已经使用Proxy
拦截对应操作让原始数据data
与副作用函数建立了联系,使其变成了响应式数据obj。
但是在执行如下操作时会发现存在问题:
effect(() => {
console.log('effect run')
document.body.innerText = obj.text
})
setTimeout(() => {
obj.text2 = 'hello vue3'
}, 1000)
理想情况下,effect应该只会在注册副作用函数时运行一次,即打印一个'effect run'
但实际上给obj设置一个新属性值text2
仍会触发副作用函数执行。这是我们所不希望的,在响应式数据obj
中只有text
和副作用函数有关,那么联系也应该仅存在于两者之间。
为了解决这个问题,我们需要修改存储副作用函数的数据结构,让响应式数据的目标字段和副作用函数建立联系
通过观察副作用函数可以发现,可以建立如下联系:
target
└── key
└── effectFn
对于这种树形结构的数据关系,我们使用如下数据结构进行存储
target
和key
使用WeakMap存储,键为target
值为一个Map
实例。使用WeakMap存储的原因是因为WeakMap的键所引用的对象不存在的情况下会通过垃圾回收从内存中移除。这样当target
不再被使用时可以被回收,可以防止内存溢出。
key
和effectFn
的关系则使用Map
存储。
接着我们需要调整拦截数据读取的过程。为了逻辑拆分将拦截的方法单独封装到track
函数中,触发的方法放在trigger
中。
/ 原始数据
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)
}
})
function track(target, key) {
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
这样只有在和副作用函数建立了联系的那个属性值obj[key]
变化时才会触发副作用函数执行。
3.分支切换和cleanp函数
副作用函数中存在条件语句时,当数据的值不同时会执行不同分支的代码,可能会导致不需要的副作用函数遗留
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */ })
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : 'not'
})
如上所示,当obj.ok
为true
时obj.text
会被读取,此时会建立如下联系:
data
└── ok
└── effectFn
└── text
└── effectFn
当
obj.ok
为false
时obj.text
和副作用函数之间已经没有联系,此时理想情况下联系应该如下:
data
└── ok
└── effectFn
为了解决这个问题,我们需要在每次副作用函数执行之前先清除所有和副作用函数有关的依赖集合,从而起到一种类似刷新的效果。
为了实现这个效果,前提是需要在副作用函数中能够拿到所有和他有关的依赖集合。
1.在effectFn上定义一个deps数组用于存储依赖集合
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
2.在trace函数中将从key到effect的Map实例存入deps中
function track(target, key) {
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
3.实现一个cleanup函数,并在每次副作用函数执行前使用cleanup清除与其相关依赖
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
let activeEffect
function effect(fn) {
const effectFn = () => {
//调用cleanup清除依赖关系
cleanup(effectFn)
activeEffect = effectFn
fn()
}
effectFn.deps = []
effectFn()
}
4.会导致无限循环的问题
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return 04 const effects = depsMap.get(key)
//问题出在这里
effects && effects.forEach(fn => fn())
}
当响应式数据被设置时,trigger
函数被调用,副作用函数执行,cleanup
会被调用以清除和其有关的依赖集合。然后副作用函数被继续执行,其重新被收集到了依赖集合之中,从而产生了如下的效果,导致无限循环。
const set = new Set([1])
const newSet = new Set(set)
newSet.forEach(item => {
set.delete(1)
set.add(1)
})
解决办法:构造另一个Set集合遍历 trigger函数被修改如下
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effectsToRun.forEach(effectFn => effectFn())
}
通过重新构造了effectsToRun
而不是直接遍历effects
,避免了无限循环。
4.嵌套的effect
在Vue中effect是会出现嵌套的情况的,Vue的渲染函数就是在effect中执行的,当组件之间发生嵌套时,effect也就发生了嵌套
// Bar 组件
const Bar = {
render() { /* ... */ },
}
// Foo 组件渲染了 Bar 组件
const Foo = {
render() {
return // jsx 语法
}
}
effect(() => {
Foo.render()
// 嵌套
effect(() => {
Bar.render()
})
})
目前实现的响应式是并不支持effect嵌套的:
// 原始数据
const data = { foo: true, bar: true }
// 代理对象
const obj = new Proxy(data, { /* ... */ })
// 全局变量
let temp1, temp2
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
// 在 effectFn2 中读取 obj.bar 属性
temp2 = obj.bar
})
// 在 effectFn1 中读取 obj.foo 属性
temp1 = obj.foo
})
理想情况下当修改obj.foo
时effectFn1
会执行,effectFn2
由于嵌套在effectFn1
里面也会执行,而修改obj.bar
时只有 effectFn2
会被触发.
实际情况 当修改obj.foo
时,输出为
'effectFn1 执行'
'effectFn2 执行'
'effectFn2 执行'
第一次和第二次是初始情况下effetc
执行的正常值,但是第三次的执行却有问题:修改obj.foo
的值effectFn2
执行了effectFn1
并没有执行.
原因:
通过activeEffect
来存储副作用函数,同一时间只能有一个副作用函数被存储,当发生嵌套时内层的副作用函数会覆盖activeEffect
的值,这时不管内外层副作用函数的数据都只能收集到内层副作用函数
解决方法
使用一个栈去存储副作用函数,副作用函数执行时入栈,执行完后出栈,并使得activeEffect
一直指向栈顶的副作用函数。
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并还原 activeEffect 为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
如上所示effectFn2执行完毕后其出栈,effectFn1执行时activeEffect变成了effectFn1
5.自增引起的无限递归循环
当副作用函数中有如下语句时,会产生栈溢出
effect(() => {
obj.foo = obj.foo + 1
})
原因在于:obj.foo
被读取触发track
,此时其被加1,又触发了trigger
操作,副作用函数再次执行,但是此时本次操作尚未结束,下一次副作用函数的执行开始了,由此产生了栈溢出。
解决办法: trigger
触发时做一个判断,若activeEffect
等于当前正在执行的副作用函数,则不继续执行。
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
}