前言
若你想了解虚拟 DOM 的部分,可见 Vuejs设计与实现 —— 为什么需要虚拟 DOM
Vuejs 三大核心模块:
- Compiler 模块:涉及 AST 抽象语法树的内容,再通过 generate 将 AST 生成渲染函数等
- Runtime 模块:也称为 Renderer 模块,将虚拟 DOM 生成真实 DOM 元素,并渲染到浏览器上
- Reactivity 模块:响应式系统,将 JavaScript 对象代理为数据模型,而当你修改数据模型时,视图会进行更新
其中响应式系统是 Vuejs 中的重要组成部分,相信这一部分大家都是深有体会的,下面就尝试实现响应式系统,目的是为了更好的了解响应式系统的设计与实现的过程。
响应式数据 & 副作用函数
什么是副作用函数?
顾名思义,副作用函数指的就是会产生 副作用 的 函数。
其中的 函数 不难理解,那么 副作用 是什么呢?
下面举个栗子,存在如下两个函数(具体作用看注释):
// 设置 body 中文本内容 function setTextForBody(text = 'hello vue3'){ document.body.innerText = text } // 获取 body 中的文本并输出 function getTextFromBody(){ console.log("document.body = ", document.body.innerText) } 复制代码
当函数 setTextForBody
函数执行时,会将页面中 body
的内容默认设置为 'hello vue3'
,而 getTextFromBody
函数是负责获取 body
的文本内容。那么当 setTextForBody
调用时传入的参数不同,会导致 getTextFromBody
函数中获取到的内容也会发生改变,甚至是在其他函数中也有类似的设置或读取 body
中文本内容的操作,都会 受到直接或间接的影响,那么就可以称这个函数(这里是 setTextForBody
函数)产生了 副作用(effect)。
什么是响应式数据?
请看下面的栗子:
// 初始数据 const data = { text: 'hello world' } // 副作用函数 function effect(){ document.body.innerText = data.text } // 修改数据 setTimeout(() => { data.text = 'hello vue3' }, 3000); 复制代码
如上代码中,副作用函数 effect
会设置 body
的文本内容为数据 data
对象中的 text
属性,而 setTimeout
则负责 3s 后将 data.text
的值进行修改。
期望的是,当代码执行 data.text = 'xxx'
的代码时,副作用函数 effect
可以自动执行,而省略手动调用的过程,那如果能实现这个目标,那么就可以将 data
对象称为响应式数据。
响应式数据的基本实现
通过上面的代码,不难发现(毕竟代码量很少):
- 当副作用函数
effect
执行时,会通过data.text
进行 读取操作 - 当需要修改
text
字段值时,会通过data.text = xxx
进行 设置操作其中 读取操作 和 设置操作 正好对应 JavaScript 中的 getter 和 setter,在 Vue.js 2 中采用的是Object.defineProperty
函数进行实现,而在 Vue.js 3 中已经转向Proxy
的实现方式。
基本思路
- 将原始数据进行代理,实现 getter 和 setter 函数
- 当执行副作用 effect 函数时,会触发对应数据的 getter 函数,此时将这个 effect 函数保存到容器 bucket 中,等待在未来某时刻执行
- 当执行 data.text = xxx 操作时,会触发对应数据的 setter 函数,此时从容器 bucket 中取出所有 effect 函数并执行它们
// 存储副作用函数的容器 const bucket = new Set() // 原始数据 const rawData = { text: 'hello world' } // 副作用函数 function effect(){ document.body.innerText = proxyData.text } // 响应式函数 function reactive(target){ return new Proxy(target, { get(target, key){ // 保存副作用函数 effect bucket.add(effect) // 返回访问的值 return target[key] }, set(target, key, newValue){ // 设置新值 target[key] = newValue // 从容器 bucket 中取出 effect 函数并执行 bucket.forEach(fn => fn()) // 表示设置成功 return true } }) } // 对原始数据进行代理 const proxyData = reactive(rawData) 复制代码
现在可以使用下面的代码来进行测试:
// 初始化执行,触发 getter 函数,收集 effect effect() // 2s 后对 proxyData.text 进行修改 setTimeout(() => { console.log('定时器执行,触发修改') proxyData.text = 'hello vue3' }, 2000) 复制代码
得到的结果如下:
完善响应式系统
明确副目标对象和作用函数关系
存在缺陷
上面通过硬编码的形式进行的实现,存在着如下的缺陷:
- 强制副作用的函数名为 effect,这会导致一旦产生副作用的函数名不是 effect,那么上述的代码实现就无法达到预期的效果
- 最佳实现 应该是哪怕副作用函数是匿名的,也能被正确的进行收集到容器中,从而在未来某个班时刻被执行
- 仅仅使用 set 数据结构作为副作用函数的容器,会导致 副作用函数和被操作目标的字段之间无法建立明确的关系
- 目前的实现是将所有的副作用函数全部放到同一个容器中进行存储,导致的结果就是一旦某个被操作的目标字段进行更新操作,这会导致容器中的所有的副作用(即使是无关的)全部都会执行一遍
- 最佳实现 应该是只执行和当前被操作目标中具体字段相关的副作用函数,可以是一个或多个
完善思路
- 由于期望的副作用函数可以是任意形式的函数,因此需要一个全局变量 activeEffect 存储当前被注册的副作用函数
- 为了 副作用函数和被操作目标的字段之间建立明确的关系,需要使用 WeakMap、Map、Set 重构对应的数据结构
- 通过 WeakMap 创建依赖容器 bucket,它的键是对应不同的被操作目标对象,它的值的类型是一个 Map 对象,这个 Map 对象的键是对应被操作目标对象中的不同字段,其中每个字段对应的值是 Set 数据结构,里面可以存储多个对应的副作用函数
- 可以通过下图进行辅助理解
【关于数据结构选择上的解释】:使用 WeakMap 用来存储不同的被操作目标对象,是因为 WeakMap 中的键是弱引用的,简单的说一旦外部环境没有对这个目标对象的引用,那么垃圾回收机制可以正常进行回收;而 Map 的键属于强引用,即便外部没有对目标对象的引用,但这个 Map 本身的键也会被认为是对目标对象的引用,因此会导致垃圾回收无法正常进行。
具体代码实现
// 存储副作用函数 const bucket = new WeakMap() // 用于存储被注册的副作用函数 let activeEffect = null // 用于接收并注册副作用函数 function effect(fn) { // 保存 fn activeEffect = fn // 执行 fn 函数,目的是初始化执行和触发 get 拦截 fn() } // 响应式数据 function reactive(target) { return new Proxy(target, { get(target, key) { console.log("get =", key, Reflect.get(target, key)); // 没有注册副作用函数,直接返回数据 if (!activeEffect) return Reflect.get(target, key) track(target, key) return Reflect.get(target, key) }, set(target, key, newVal) { console.log("set ", key, newVal); target[key] = newVal trigger(target, key) return Reflect.set(target, key, newVal) } }) } // 收集依赖 function track(target, key) { // 从 bucket 获取 depsMap 的依赖关系 let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, (depsMap = new Map())) } // 从 depsMap 获取 deps 集合 let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) } // 触发依赖 function trigger(target, key) { // 获取对应的 depsMap const depsMap = bucket.get(target) if (!depsMap) return // 获取对应的 deps const deps = depsMap.get(key) // 执行相应的 effect deps && deps.forEach(effect => effect()) } 复制代码
动态清除无用副作用函数
存在缺陷
若执行下面的测试代码,那么会产生 遗留的副作用函数依赖:
// 获得响应式数据 const data = reactive({ text: 'hello world...', ok: true }) // 注册副作用函数 effect(() => { console.log('effect running ...') document.body.innerText = data.ok ? data.text : 'not ok' }) 复制代码
- 当初始化
ok = true
时执行,会产生依赖关系为: - 当发生修改操作
ok = false
后,此时会执行对应副作用函数,同时意味着data.text
字段将不会再被访问到,理想情况是此时data.text
字段所对应的副作用函数依赖应该要被清除
完善思路
- 每次副作用函数执行时,将副作用函数从所有与之有关联的依赖集合中进行删除
- 当副作用函数执行完毕后,又会产生新的依赖关系,但这个新的依赖关系就不会包含遗留的副作用函数