大家好我是小瑜,本章是通过阅读Vue.js设计与实现,将比较精炼的部分写成笔记。 里面很多设计非常巧妙,以为是很难的源码,现在通过作者的需分拆解,发现其实原理并不是特别难。可能这是本书的开篇,所以难度并没有深入。
片头总结,设计桶数据结构中略有难度,得画图总结,方便理解。收获很大,又变强了。
响应式的基本实现
把存储副作用的容器想象成一个桶,当触发读取操作的时候,就往桶里面添加,当触发set的时候,就执行桶里面所有的依赖
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
<style></style>
</head>
<body>
<script type="module">
// 存储副作用函数的桶
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.innerHTML = obj.text;
}
// 执行副作用函数
effect();
// 1秒后修改响应式数据
setTimeout(() => {
console.log(bucket);
obj.text = "hello Vue3";
}, 1000);
// 以上函数存在的缺陷
// 例如外面直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活
// 副作用函数的名字可以任意取, 甚至是一个匿名函数
// 因此需要想办法去掉这种硬编码的机制
</script>
</body>
</html>
这里虽然实现了一个基础的响应式,但是还是存在一些缺陷
例如
- 外面直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活
- 副作用函数的名字可以任意取, 甚至是一个匿名函数,因此需要想办法去掉这种硬编码的机制
2. 取消硬编码
我们硬编码了副作用函数的名字(effect),导致一旦副作用
函数的名字不叫 effect,那么这段代码就不能正确地工作了。而我们
希望的是,哪怕副作用函数是一个匿名函数,也能够被正确地收集到
“桶”中。为了实现这一点,我们需要提供一个用来注册副作用函数的
机制
可以用一个全局变量存储被注册的副作用函数
当effect被触发的时候,就将函数赋值给activeEffect
这样就可以结果,不论什么名字的函数都可以正常执行
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
<style></style>
</head>
<body>
<script type="module">
// 上一节硬编码了一个副作用函数effect,如果不叫这个名称则无法正常执行
// 而希望的是,不论是什么名字的函数都可以正常执行
// 所以需要提供一个用来注册副作用函数的机制
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect函数用来注册副作用函数
function effect(fn) {
// 当调用effect 注册副作用那函数xiq时,将副作用函数fn赋值给全局变量activeEffect
activeEffect = fn;
// 立即执行副作用函数
fn();
}
// 响应式
const data = { text: "hello world" };
const bucket = new Set();
const obj = new Proxy(data, {
get(target, key) {
if (activeEffect) {
console.log(target, "@@target");
bucket.add(activeEffect);
}
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
bucket.forEach((fn) => fn());
return true;
},
});
// 测试 , 例如在响应式数据obj上设置一个不存在的属性时
// 可以按照如下的方式使用effect函数
effect(() => {
console.log("effect run"); // 打印两次
document.body.innerHTML = obj.text;
});
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.noExist = "hello Vue3";
}, 1000);
// 下一节需要解决的问题
// 在匿名副作用函数中兵没有读取obj.notExist 并没有与副作用建立建立响应联系
// 因此,定时器内的语句的执行不应该触发匿名副作用函数重新执行,但是这里却执行了,所以这是不正确的,
// 为了解决这个问题, 我们需要重新设计桶的数据结构
</script>
</body>
</html>
3. 重新设计桶的数据结构
在第二节中,在延时器中设置了一个 obj.noExist = "hello Vue3" 的操作
即使 data 中没有定义 obj.noExist 属性,但是依然触发了effect的运行,这显然是不正确的,所以需要重新设计桶的数据结构
1. 创建WeakMap容器
例如 响应式对象为
const target = { text: 'hello' }
这里依然需要创建一个桶来存储effect,这里使用WeakMap, 使用原因是
-
创建一个WeakMap对象 key只可以是对象,value是可以任意类型
-
这时候将target 作为 bucket 的key ,value 则是 一个新的Map对象
-
WeakMap 是对key的弱引用,使用完成,垃圾回收机制会回收内存,不会导致内存泄漏
-
如果用的是Map,则对key是强引用,不会销毁内存,容易内容泄漏
const bucket = new WeakMap()
2. 将对象作为Key 存储到WeakMap容器
bucket.set(target, new Map())
时候的数据就是
WeakMap(1) = {
{ text: 'hello' }: {}(Map = 这个Map 取个名字就叫 depsMap)
}
3. depsMap.set( key,new Set() )
- 给 depsMap 添加一个set 也就是一个不重复的数组
- 这里的key就是对象中的key 也就是这里的text
Map(1) = {
{ text: 'hello' }: {
[ {key:'text', value: [] } ],
}
}
4. new Set().add( effect )
给set数组添加副作用函数
此时的数据结构就是
Map(1) = {
{ text: 'hello' }: {
[ {key:'text', value: [ ()=> effect ] } ],
}
}
5. 数据关系
6. 完整代码
let activeEffect
// effect函数用来注册副作用函数
export function effect(fn) {
// 当调用effect 注册副作用那函数时,将副作用函数fn赋值给全局变量activeEffect
activeEffect = fn
// 立即执行副作用函数
fn()
}
// 响应式
const data = { text: "hello world" }
const bucket = new WeakMap()
/**
* 1. 如果没有activeEffect 直接return
* 2. 根据target, 在桶中获取这个Map 这个Map的类型是 key(target)=>effect => {text:'hello world'} => effect
* 3. 如果在桶里面没有找到对应的target,那么就需要新建一个(depsMap),使得target与bucket进行关联,关联关系还是key(target)=>effect => {text:'hello world'} => effect
* 4. 再根据key从depsMap中获取deps,他是一个set类型,里面存储的就是对应的副作用函数
*/
export let obj = new Proxy(data, {
get(target, key) {
// 1. 没有activeEffect,直接return
if (!activeEffect) return target[key]
/// 2. 根据target 从 桶中取得depsMap,它也是一个Map类型:key=>effects
let depsMap = bucket.get(target)
// 3. 如果不存在 depsMap,那么新建一个Map,并与target关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
console.log(bucket, "@bucket")
}
// 4. 再根据key从depsMap中取得deps,他是一个set类型
// 里面存储着所有与当前key相关联的副作用函数: effects
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到桶里
deps.add(activeEffect)
// console.log(bucket, "@@bucket")
return target[key]
},
set(target, key, newVal) {
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())
return true
},
})
7. 测试查看效果
这里定义两个延时器,看没有定义的属性,是否还会被触发依赖
通过运行查看, obj.noExist = "hello Vue3" obj.noExist = "hello Vue3 = ss" 都不会触发 effect run
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
<style></style>
</head>
<body>
<script type="module">
import { effect, obj } from "./02-重新设计桶数据结构.js"
effect(() => {
console.log("effect run")
document.body.innerHTML = obj.text
})
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.noExist = "hello Vue3"
}, 1000)
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.noExist = "hello Vue3 = ss"
}, 2000)
</script>
</body>
</html>
4. 抽离 收集和触发依赖函数
就是将代理中的get 和set函数进行抽离封装
let activeEffect
export function effect(fn) {
activeEffect = fn
fn()
}
const bucket = new WeakMap()
const data = { text: "hello Vue3", ok: true }
export const obj = new Proxy(data, {
get(target, key) {
console.log("????")
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
return true
},
})
/**
* 收集依赖
*/
function track(target, key) {
if (!activeEffect) return
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)
console.log(bucket, "开始读")
}
/**
* 触发依赖
*/
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach((fn) => fn())
}
5. 条件分支减少不必要effect
1. 什么是条件分支?
假设定义了一段响应式数据,并触发依赖执行
在逻辑中添加了三元表达式,就是条件分支
const data = { text:'hello', ok:true }
effect(() => {
document.body.innerHTML = obj.ok ? obj.text : "not"
})
2. 要解决什么问题?
按照下面的例子运行函数
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
<style></style>
</head>
<body>
<script type="module">
import { effect, obj } from "./index.js"
let ss = "not"
effect(() => {
// 当判断条件不成立的时候,应该不会触发副作用函数
document.body.innerHTML = obj.ok ? obj.text : "not"
})
setTimeout(() => {
obj.ok = false
}, 1000)
setTimeout(() => {
obj.text = "两秒后再次修改"
}, 2000)
setTimeout(() => {
obj.text = "三秒后再次修改"
}, 3000)
</script>
</body>
</html>
......... 省略
/**
* 收集依赖
*/
function track(target, key) {
if (!activeEffect) return
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)
console.log(bucket, "开始读") // 打上日志
}
通过日志,看到即使三元运行的代码条件分支修改为了false,所以是页面始终是渲染not,但是每次都需要去触发收集依赖并执行
*所以要解决的就是,当条件不成立,或者条件并非需要触发响应式才能渲染页面的逻辑。这些操作都不需要进行依赖收集
分支切换可能会产生遗留的副作用函数。拿上面这段代码来说,字段 obj.ok 的初始值为 true,这时会读取字段 obj.text 的值,
所以当 effectFn 函数执行时会触发字段 obj.ok 和字段 obj.text。这两个属性的读取操作,此时副作用函数 effectFn 与响应式数据之
间建立的联系如下:
3. 解决因为条件分支的条件改变且不应该触发的effect重复触发
要解决这个问题其实很简单,在触发依赖前,将所有的依赖设置为空,然后在去触发依赖,这样每次触发依赖都会删除上一次遗留的依赖
- 创建一个空数组用来存储所有的相关依赖的集合
function effect(fn) {
// 解决条件分支变化导致的非必要副作用函数执行
const effectFn = () => {
// 调用cleanup函数,完成清除工作
cleanup(effectFn)
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}
- 在收集依赖时,将关联的依赖进行存储到 effectFn.deps
/**
* 收集依赖
*/
function track(target, key) {
if (!activeEffect) return
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)
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps)
console.log(deps, "开始读")
}
- 删除依赖
function cleanup(fn) {
// fn,就是当前要执行的副作用函数
fn &&
fn.deps.forEach((item) => {
// 此时的 item 是 new Set();
item.delete(fn) // 将他从集合中删除掉
})
fn.deps.length = 0 // 然后清空这个数组
}
解决死循环问题
以下代码会有死循环问题
effect(() => {
console.log(obj.text)
})
产生的原因是因为删除了上一次的依赖后,又重新set了一遍,重新触发依赖,并且由于Set 原本的问题,导致死循环
语言规范中对此有明确的说明:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,
如果此时 forEach 遍历没有结束,那么该值会重新被访问。因此,上面的代码会无限执行。解决办法很简单,我们可以构造另外一个 Set
集合并遍历它。
/**
* 触发依赖
*/
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
// const
// effects && effects.forEach((fn) => fn())
// 解决cleanup死循环问题
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach((effectFn) => effectFn()) // 新增
}
现在再去将ok修改为false,就不再会去触发依赖的收集了