写在前面
当前笔记是在读书和写代码的同时做的记录,后续也未做整理,可能在某些方面写的比较简单。
更加详细的内容请直接查看项目代码
1. 实现目标
const obj = reactive({
name: '小明'
})
effect(() => {
document.querySelector('#name').innerText = obj.name
})
setTimeOut(() => {
obj.name = '小红'
}, 1000)
将原始数据通过reative
包裹,生成响应式数据,向effect
传递匿名函数并执行,通过拦截器搜集依赖,在一秒后更新响应式数据并再次触发匿名函数。
2. 基本实现
这里使用Vue 3 中使用的Proxy
let activeEffect = null
const effects = new Set() // 用于存储所有的响应函数
function reactive(data) {
return new Proxy(data, {
get(target, key) {
// 如果没有活跃函数,则说明该数据不需要响应式,直接返回
if (!activeEffect) {
return target[key]
}
effects.add(activeEffect) // 添加上当前活跃的fn
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
effects.forEach(fn => fn()) // 遍历所有的活跃函数,并执行
}
})
}
/**
* 存储当前函数
* 立即执行一次
*/
function effect(fn) {
activeEffect = fn
fn()
}
本次主要实现了最基本的响应式处理,但本次在get
函数中并没有对读取的key
做区分,使得数据中的所有key都会触发同一组响应函数。
区分读取的key
在上一版本的基础之上,我们加上对数据内key的区分。
创立一个Map
对象,使得每个key
都对应一个用于存储响应函数的Set
对象。
let activeEffect = null
const depsMap = new Map() // key => new Set(fn1、fn2、fn3)
function reactive(data) {
return new Proxy(data, {
get(target, key) {
if (!activeEffect) {
return target[key]
}
let deps = depsMap.get(key)
if (!deps) {
// 没有在类型桶中,则新建一个
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect) // 添加上当前存储的fn
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
const deps = depsMap.get(key)
deps && deps.forEach(fn => fn())
return true // 定义Set时要返回true,不然会报错 'set' on proxy: trap returned falsish for property ‘xxx’
}
})
}
function effect(fn) {
activeEffect = fn
fn()
}
区分data
由于reactive
函数并不是只调用一次,我们还要对原始数据进行区分。
创建WeakMap
对象,使得每个data
都对应我们之前创立的Map
。
bucket => new WeakMap()
|
├─ data1: new Map()
| |
| ├─ key1: new Set()
| | |
| | ├─ fn1()
| | |
| | └─ fn2()
| |
| └─ key2: new Set()
| |
| ├─ fn1()
| |
| └─ fn2()
|
└─ data2: new Map()
let activeEffect = null
const bucket = new WeakMap() // data => new Map()
function reactive(data) {
return new Proxy(data, {
get(target, key) {
if (!activeEffect) {
return target[key]
}
// 根据target从“桶”中取得depsMap, 它是一个Map类型: key -> effects
let depsMap = bucket.get(target)
if (!depsMap) {
// 如果不存在, 则创建一个新的Map与target关联
bucket.set(target, (depsMap = new Map()))
}
// 根据key从depsMap中取得effects,它是一个Set类型: effects
// 这里记录着当前key的所有副作用函数
let deps = depsMap.get(key)
if (!deps) {
// 没有,则新建一个Set,并且将其与key关联
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect) // 添加上当前存储的fn
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
// 根据target从“桶”中取得depsMap, 它是一个Map类型: key -> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据key从depsMap中取得effects,它是一个Set类型: effects
const deps = depsMap.get(key)
// 执行所有副作用函数
deps && deps.forEach(fn => fn())
return true // 定义Set时要返回true,不然会报错 'set' on proxy: trap returned falsish for property ‘xxx’
}
})
}
function effect(fn) {
activeEffect = fn
fn()
}
实现完成,最后将函数抽取整理一下即可。
Q: WeakMap、Map、Set的区别
首先 WeakMap
与 Map
相似,都是用来存储键值对的([object Object]: value
),但是 Map
存储的是对象,而 WeakMap
存储的是弱引用。
const map = new Map()
const weakMap = new WeakMap()
(() => {
const foo = {foo: 1}
const bar = {bar: 2}
map.set(foo, 1)
weakMap.set(bar, 1)
})()
在立即执行函数内部,我们创建了两个对象foo、bar,他们分别作为map
与weakMap
的key。当函数执行完成时,对于foo
来说,它仍然作为map的key被引用着,无法进行回收。对于bar
来说,weakMap的key是弱引用,它不影响回收器的工作,当函数执行完成,垃圾回收器就会把对象bar
从内存中移除。
Set
是 ES6 提供的新的数据结构,类似于数组,但是成员的值都是唯一的,没有重复的值
分支切换
首先需要明确分支切换的概念
const obj = reactive({
ok: true,
text: 'Hello, world'
})
effect(() => {
document.body.innerText = obj.ok ? obj.text : 'not'
})
理想状态下,当obj.ok
为true
时,我们修改obj.text
的值会触发副作用函数,而obj.ok
为false
时,页面只显示静态的'not',此时不以来其他字段,我们修改obj.text
的值时就不应该触发副作用函数。
然而,按照目前的代码写法,我们没法在obj.ok
修改时,将不再需要监听的字段解除其对应的副作用函数的绑定关系。
解法思路:在副作用函数执行之前,先把副函数与它所有有关系的字段解绑,之后在执行函数时仅针对本次用到的字段进行绑定。
我们先来看一下之前的的effect
以及 track
export function reactive(data) {
return new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
return true // 定义Set时要返回true,不然会报错 'set' on proxy: trap returned falsish for property ‘xxx’
}
})
}
function track(target, key) {
if (!activeEffect) {
return target[key]
}
// 根据target从“桶”中取得depsMap, 它是一个Map类型: key -> effects
let depsMap = bucket.get(target)
if (!depsMap) {
// 如果不存在, 则创建一个新的Map与target关联
bucket.set(target, (depsMap = new Map()))
}
// 根据key从depsMap中取得effects,它是一个Set类型: effects
// 这里记录着当前key的所有副作用函数
let deps = depsMap.get(key)
if (!deps) {
// 没有,则新建一个Set,并且将其与key关联
depsMap.set(key, (deps = new Set()))
}
// 添加上当前存储的fn
deps.add(activeEffect)
}
export function effect(fn) {
activeEffect = fn
fn()
}
每次effect
执行时,触发了get的参数就会将本次的effect
记录在自己的“桶”中,但如果我们要清除当前effect
触发过的参数绑定时,就不那么容易。
所以,我们需要在effect
执行时,将所有触发get的参数记录在effect
自己的“桶”中,这样就可以通过自己的“桶”中记录的参数,一一对应,清除参数“桶”中关于本次effect
的绑定。 (写的啰嗦)
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)
/**
* 将当前触发get的参数的“桶”关联到activeEffect的“桶”中
*/
activeEffect.deps.push(deps)
}
// 用一个全局变量存储当前激活的 effect 函数
export function effect(fn) {
/**
* 这里我们对传入的fn重新包装一下
* 声明 deps 属性,用来记录执行时触发get的参数
*/
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
/**
* 用来清理参数中关联的effect
*/
function cleanup(effectFn) {
/**
* ffectFn.deps记录了所有触发get的参数的deps
*
* 将这些deps中有关本次effect的属性删掉
*
* 现在这个参数就与effect没有关系了
*/
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
注意!
此时看似功能已经完成,但有一个致命的问题 => 无限循环
function trigger(target, key) {
// ...
const effects = depsMap.get(key)
effects && effects.forEach(effectFn => effectFn())
}
在trigger
中我们会循环调用参数的effect
集合,而effectFn
又会将自身从参数的以何种删除掉并重新执行,导致自身又被添加到参数的effect
集合中,但此时的forEach
还没有结束,又会重新执行effectFn
,如此反复。
- 方案一,在
forEach
结束时再执行effectFn
(抖机灵)
我们将effectFn
变成一个异步函数,
const effectFn = async () => {
await timeout(0)
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
fn()
}
利用Event Loop
的机制,将函数的调用时机放在下次任务中,这样就不会影响到forEach
的循环。
当然,这样会造成不必要的时间浪费。
- 方案二
const set = new Set([1])
set.forEach(i => {
set.delete(1)
set.add(1)
console.log('run')
})
我们将上述问题进行简化。要解决这个问题其实很简单,我们只需要构造另一个Set合集,遍历与操作对应不同的合集,这样就可以避免无限循环。
const set = new Set([1])
const newSet = new Set(set)
newSet.forEach(i => {
set.delete(1)
set.add(1)
console.log('run')
})
那么回到我们的问题, 在trigger
中,用同样的手段来避免无限循环。
function trigger(target, key) {
// ...
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => effectFn())
// effects && effects.forEach(effectFn => effectFn()) // 删除
}
嵌套的Effect
查看修改前的版本,请将
./core.js
替换为../03-1/core.js
首先我们来分析问题,当点击effect1 切换名称
的按钮时,控制栏打印出
effect1 小红
effect2 1
effect2 1
此时,执行了一次外部的effect
, 内部的effect
执行了两次。
原因: 当我们点击
effect1
的按钮时,会先触发bar的get
,造成bar对当前activeEffect
的收集,然而当前activeEffect
则是effect2
,因为我们再执行effect
后并没有做出及时清理,导致bar即搜集了外层effect
又收集了内层的effect
,遍历执行后导致内层effect
执行两次。
由于时嵌套的Effect
,所以我们要考虑当内部的effect
执行结束时,将activeEffect
还给外层。
let activeEffect = null
const effectStack = [] // 用来维护effect的函数栈
export function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
effectStack.push(effectFn) // 添加进栈
fn()
// 弹出
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] // 回滚到上一个effect
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
避免无限循环。
举个栗子
const data = reactive({
foo: 1
})
effect(() => {
data.foo++ // => data.foo = data.foo + 1
})
在这段代码中,由于在同一个副作用函数中即触发了foo
的收集(track
),也触发了foo
的修改(trigger
),这样就会造成无限循环。
因此,我们需要在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)
effectsToRun.forEach(effectFn => {
// 与当前的activeEffect相同,为避免无限循环,不触发执行
if (effectFn !== activeEffect) {
effectFn()
}
})
}
调度执行
本节主要讨论trigger
中副作用函数的触发时机、次数。
- 场景一
const data = reactive({
foo: 1
})
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('结束了')
这段代码输出如下:
1
2
'结束了'
现在我们要将trigger
中触发的副作用函数改至微任务中,使得输出变为:
1
'结束了'
2
代码实现如下:
// 我们在`effect`中添加一个`options`参数
export function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
effectStack.push(effectFn)
fn()
// 弹出
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将options添加到effectFn中
effectFn.options = options
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects)
effectsToRun.forEach(effectFn => {
// 与当前的activeEffect相同,为避免无限循环,不触发执行
if (effectFn !== activeEffect) {
// 这里进行判断,如果有调度器,则调度器执行,否则执行副作用函数
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
}
})
}
那么,在传入effect
时,需要手动编写对应需求的调度器代码。
effect(() => {
console.log(obj.foo)
}, {
scheduler(effectFn) {
setTimeout(effectFn, 0) // 将代码执行到下一个宏任务再执行
}
})
- 场景二
const data = reactive({
foo: 1
})
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
这段代码输出如下:
1
2
3
由于两次obj.foo++
是在同一个宏任务中连续执行的,而由此触发了两次副作用函数,输出了三次结果。如果我们只关心结果而不关心过程,那么三次打印操作时多余的,我们期望的打印结果是:
1
3
代码实现:
// 创建一个任务列表
const jobQueue = new Set()
const q = Promise.resolve() // 创建一个微任务环境
// 正在刷新队列
let isFlushing = false
function flushJob () {
// 如果正在刷新,不执行
if (isFlushing) return
isFlushing = true // 更改状态
q.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
// 当执行结束时,重置状态
isFlushing = false
})
}
effect(() => {
console.log(obj.foo)
}, {
scheduler(effectFn) {
jobQueue.add(effectFn) // 将当前副作用函数添加到待执行列表中
flushJob() // 调用执行
}
})
我们通过调度器,将所有副作用函数的执行环境放在微任务中(通过promise.resolve
实现),搜集宏任务中的触发的副作用函数,并将其加入到待执行队列中,并且由于Set
的特性,使得相同的副作用函数只会被加入一次。
computed && lazy
按照书中的顺序,我们先来讨论一下lazy
,即lazy
的effect
。
在某些场景下,我们可能并不希望effect
立即执行。在上文中,我们给effect
添加了options
的入参,所以我们可以通过在options
中添加lazy
来控制effect
的执行。
// 该effect不会立即执行
effect(() => {
console.log(obj.foo)
}, {
lazy: true
})
// 用一个全局变量存储当前激活的 effect 函数
export function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
effectStack.push(effectFn)
fn()
// 弹出
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.options = options
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 非lazy时,才会立即执行
if (!options.lazy) {
// 执行副作用函数
effectFn()
}
// lazy 属性下,将函数返回出去
return effectFn
}
在添加了lazy
属性后,effect
不会立即执行,而是将封装好的副作用函数返回,之后我们可以自行调用函数。
const effectFn = effect(
() => obj.foo + obj.bar,
{lazy: true}
)
const value = effectFn()
为了实现这种操作,我们还需要改造一下封装副作用函数的方法。
// ...
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数复制给 activeEffect
activeEffect = effectFn
effectStack.push(effectFn)
const res = fn() // 这里在执行完用户传入的函数后,将返回值赋值给 res
// 弹出
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res // 返回结果
}
// ...
至此,稍微有点computed
函数的影子了,我们先写一次computed
函数的大致样子。
const res = computed(() => {
return obj.foo + obj.bar
})
console.log(res.value)
为了实现这个效果,我们可以利用对象的get
字符计算出所需要的函数值。
function computed(getter) {
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
return effectFn()
}
}
return obj
}
至此我们大致实现了computed
,但是在每次访问value
属性时,都会重新执行一次effect
,做不到对值的缓存。
export function computed(getter) {
let value // 缓存上次计算的值
let dirty = true // 是否需要重新计算
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
// 将dirty设置为false, 那么下次取值时不会重新计算
dirty = false
}
return value
}
}
return obj
}
可以看到,我们对函数的结果进行了保存,在一次执行后,后续的取值不再重新计算。那么当函数中的值发生更改,触发了trigger
后,我们就需要重新执行函数,即修改dirty
的值,而修改的时机我们之前已经写好,就是scheduler
函数。
// ...
const effectFn = effect(getter, {
lazy: true,
scheduler() {
// 添加一个调度器,当数据改变触发了trigger时,就会调用`scheduler`函数,此时我们就将dirty改为true,在下次获取值时重新执行函数。
dirty = true
}
})
// ...
现在我们的计算属性还有一个缺陷。
const res = computed(() => {
return obj.foo + obj.bar
})
effect(() => {
console.log(res.value)
})
obj.foo++
我们在effect
中读取计算属性的值时,会触发一次副作用函数执行,但当obj
中的值发生改变时,副作用函数并没有再次执行。
分析问题原因,这个问题本质上还是一个典型的effect
嵌套。计算属性内部拥有自己的effect
,并且它是懒执行,只有当读取计算属性的值时才会执行。当计算属性内部的effect
执行时,它会将计算函数的effect
作为本次的activeEffect
被响应式函数进行依赖收集,当函数执行完成后则会把activeEffect
丢给外层的effect
,但是外层的effect
并没有被响应式数据收集。
解决思路,在读取计算属性后,我们需要将当前的结果值与外层的effect
进行关联,当结果值发生改变时,则会触发外层的effect
。那么我们需要手动调用track
、trigger
函数进行收集与响应。
function computed(getter) {
let value // 缓存上次计算的值
let dirty = true // 是否需要重新计算
const effectFn = effect(getter, {
lazy: true,
scheduler() {
// 添加一个调度器,当数据改变触发了trigger时,就会调用`scheduler`函数,此时我们就将dirty改为true,在下次获取值时重新执行函数。
dirty = true
// 当响应式数据发生改变,触发`obj`的绑定函数
trigger(obj, 'value')
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
// 将dirty设置为false, 那么下次取值时不会重新计算
dirty = false
}
/**
* effectFn 执行完毕,activeEffect已经跑给了外层的`effect`
*
* 我们将当前的obj.value 与外层的`effect`进行关联
*/
track(obj, 'value')
return value
}
}
return obj
}
至此,computed的实现完成。
写在最后
更完善的代码请看my-vue