前言
当我在前端工作一年后,我终于有时间开始学习原理方面的知识。并看了如下的的技术书籍。看了许多文章。尤其在diff算法上面花了大量的精力。然而工作上面消耗的时间太多。日常的加班到家后就已经9点多了。外加我还需要准备学历方面的考试。即使将周末也用来学习。进步依然缓慢。
现在工作两年了。考试也快要进入尾声。便又有时间去学习。当我在回忆起关于Vue原理时,突然发现我连基础的响应式都说不明白。而之前我所以为的响应式,却仅仅只是Proxy、Reflect这些API而已。当我再次将响应式原理从懵懵懂懂到现在的理解,又过了近半个月。
我中午12点下班,大概梳理一下今天要学习的任务,12.10开始吃饭,12.30 - 1.45 期间用于学习。如果晚上要加班,则 6.00 - 7.00 用于学习。如果不加班,早些回家,则一天的学习时间在 3-4 小时。而周末两天,平均一天的学习时间也在 3 - 5 小时。而这个五一,接近三分之一的时间都在这篇文章上了。所以写出这篇文章的统合时间也不会低于 60 个小时。
当然,一方面因为并不简单,另一点则是我并不聪明。
我已经尽力写了,但如果看的人觉得看完后没有学到什么,那我很抱歉了。
关于这篇文章要讲些什么
本篇文章就是想要讲明白,vue3 的 响应式是如何实现的。也就是为什么修改了数据 a,则依赖了 a 的数据也会跟着改变。
//原生js
let a = 5;
let res = a + 10;
a = 10;
console.log(res);
// 可以想到,这个res的值并不会因为 对a的重新赋值而改变
// 而响应式则是 res 的值也会跟着改变。
上面的 res 并不会随着 a 的改变而改变。如果想要res的值一直是最新的,则就需要在a的值改变时,立即重新计算 a+10,并将值重新赋值给 res,此时就需要对 a 的值进行监听。
监听数据的方式
大概说一下
vue2使用 js的 Object.defineProperty() 实现对数据操作时的拦截,对数据操作时,如读取或修改时,该api可以立即知道。【Object.defineProperty() :MDN链接】 ,但vue3却使用js的new Proxy() 实现【构造函数Proxy :MDN链接】 。
其中一个原因是 Proxy 可以实现一个对象的几乎所有操作,如:新增删除、包括对 Map 数据结构的操作,而Object.defineProperty 只能拦截对象已有属性的操作、也无法拦截对 Map的操作。
上面大致介绍了文章的相关信息,下面将通过 计算属性 来引入响应式。
响应式之 computed
计算属性是响应式的一种表现方式,当其依赖的值发生变化时,该值在读取时,也会相应地发生变化。 我们先看下 computed 的简单的使用方式。
<template>
{{ getSum }}
</template>
<script setup lang="ts">
const obj = reactive({
a:1,
b:2,
})
const getSum = computed(()=> obj.a + obj.b )
</script>
监听 响应式数据
上面的computed功能,当 obj.a 或 obj.b 的值发生变化时,则getSum的值也会自动更新。本质上是 obj.a 或 obj.b 的值发生了变化,会触发 ()=> obj.a + obj.b 这个函数执行,当然就可以获取到最新的值了。但和obj 是一个响应式数据有关,如果 obj 只是一个普通的对象,则无法实现。
<template>
{{ getSum }}
</template>
<script setup lang="ts">
const obj = {
a:1,
b:2,
}
const getSum = computed(()=> obj.a + obj.b )
</script>
这种方式,无论 obj.a 或 obj.b 如何改变,getSum 都不会去计算它们的 值之和。
懒加载
看一下上面的第二个功能,当在 template 写入 {{ getSum }} 时,代表了对 getSum 计算属性的读取,如果在 template 中并没有读取 getSum,如下面:
<template>
</template>
<script setup lang="ts">
const obj = reactive({
a:1,
b:2,
})
const getSum = computed(()=> obj.a + obj.b )
</script>
这种方式,getSum 此时并不知道 obj.a + obj.b 的值是多少,因为它还没有开始计算,只有读取该数据时,它才计算数据,这就是懒加载。
缓存
其实当 计算属性 的核心内部已经实现时,缓存也就很简单了。 可以想一下,在工作中,一般自己去实现一个缓存功能,类似下面:
let a = 10;
function createCacheGetValue(){
//cache 为缓存值
let cache;
let prevA;
return function (){
//判断a的值是否变化,若无变化,则直接返回 cache;
if(prevA !== a){
cache = a * 10;
prevA = a;
}
return cache;
}
}
const getValue = createCacheGetValue();
console.log(getValue());
用个变量记一下计算出的结果,当依赖的数据没有发生变化,则返回 缓存值,就可以了。
监听 计算属性
上面实现的不仅是 computed 监听了 响应式数据,还实现了对 computed 的监听,也就是,响应式数据的改变影响了 computed 的值,computed 的改变也影响了依赖了 computed 的值的数据。
其实这是一种嵌套的关系 数据 ——>依赖了 computed数据 ——> 依赖了 响应式数据
当然了,上面是通过template 标签中的 {{ }} 插值语法 实现对 计算属性 监听的。
为了表达的更清楚一些,再看下下面
const getSum = computed(()=> obj.a + obj.b )
function fn(){
console.log(getSum.value)
}
fn();
这个 console.log(getSum.value) 只会在 最开始fn() 调用时,会执行一次,之后无论 getSum 的值如何改变,都不会再执行 console.log(getSum.value) 。此时计算属性的值虽然已经改变了,但我们是并不知道的,只有当 打印其值时,发现值已经不同了,我们才能得知。
如果在 template 中的{{ }} 插值语法上,也是像上面一样,只在最初时获取到 计算属性的值,而之后无论 计算属性的值如何改变, {{ }} 插值语法 展示的都是初始化时的值,则就有问题了。 所以 {{ }} 插值语法 其实是对 computed 做了监听,所以才可以实时更新它的值。
那么,接下来就是主要想讲明白,上面这三点是如何实现的 [监听 响应式数据、懒加载、监听 计算属性],并说一下为什么 computed 只能监听响应式数据,最后顺带说一下缓存。
当然了,性能优化是少不了的了。
一些注意点
由于以下展示的代码是纯 js 的,所以并不会看到 template 标签,也就不会有 {{ }} 插值语法了。但会以一个函数的方式模拟 {{ }}, 即 vue 响应式原理中使用的一个函数 effect 。
下面的 getSum 用了 .value 的方式,是因为在 js 中就是这样读取的,只是 {{ }} 插值语法做了处理,不需要写 .value
//我们用这种方式模拟 {{ }} ,当 getSum的值发生变化时,
//下面的回调函数 ()=>{ getSum } 将执行,并获取到最新的值
effect(()=>{
getSum.value
})
注:由于 template 的 {{ }} 插值语法是对 其中的响应式数据持续的追踪,所以 {{ getSum }} 就是一直在读取 getSum 的值,如果 getSum 的值变了,将会立即在 template 中展示出来,而我用来模拟 {{ }} 的 effect函数,也具有同样的功能,它是对 getSum的持续监听,只要 getSum 的值改变,下面的 getSum.value 就是最新的值,我们可以把它展示到页面上,或者打印出来。
effect(()=>{
document.body.innerText = getSum.value;
console.log('我是getSum.value',getSum.value);
})
上面就是基本的介绍了,下面开始了。
离不开的话题 --- Proxy 代理
proxy 可以实现对一个对象的代理,当对代理对象进行一些操作时,proxy 则可以拦截操作,并让使用者知道。
// 原数据
const data = {
a: 1,
b: 2,
}
let res = data.a + data.b;
data.a = 10;
console.log( res) // 3
上面这个是不用 proxy,即使data.a 改变了,res 的值依然没有变。
// 原数据
const data = {
a: 1,
b: 2,
}
// 使用 new Proxy 对 data代理
//之后的操作都要使用 obj,而不是data
// 目前 obj 的数据与 data 一致
const obj = new Proxy(data, {
// 当读取 obj的属性时,如 console.log(obj.a)
//则会进入 get方法中, target 为 obj本身,key为当前读取的属性 a
get(target, key) {
// target[key] 则是读取该属性的值,并返回
// 则外部可以读取到值了
return target[key];
},
set(target, key, newVal) {
target[key] = newVal;
// 当 执行 obj.a = 10 时,set 方法就会被调用
// newVal 此时为 10,此时重新为 res 赋值
res = target.a + target.b;
}
})
let res = obj.a + obj.b;
obj.a = 10;
console.log(res) // 12
上面就是通过 proxy 对 data 对象的代理,代理后的对象是 obj,此后对 obj 的读取、修改操作都会被 get、set所拦截,当在 set 中更新 res的值。则此后只要 obj.a 或 obj.b 发生了变化,则 res 一定是最新的值了。
但上面的数据仅是一个 obj 代理对象, a 与 b 两个属性而已,数据量大就需要考虑 可维护性 了。也就是需要一个地方可以查看所代理的对象有哪些,每个对象又有哪些属性,这些属性又影响着怎样的操作。
所以,首先要创建一个 对象用于存储这些数据。
存储数据的 桶 结构 --- bucket
我们现在想要实现对很多数据的拦截,并有一个地方去记录下这个被拦截的对象与属性,则需要一个 对象,结构如下:
{
// 最外层存储不同的对象
obj:{
// 第二层 则是不同的属性
'a':...,
'b':...
},
obj2:{
'a':...,
'b':...
},
obj3:{
'a':...,
'c':...
}
}
上面便是需要的结构。
可以看到,上面的通过 obj 作为对象的 key,而常规的对象只能以 字符串 作为key,所以该 桶结构 需要用 Map 实现。
还要注意一点,在使用vue时,每个组件都有其 生命周期 ,如 挂载、卸载等。在组件卸载时,组件内部声明的值将会被销毁(如 const name = ref('Nancy')。若没有外部变量引用其值,之后便会被垃圾回收,该值占用的内存将会被释放,用于存储其它数据。当组件重新被挂载时,内部的值将会重新被创建,分配内存,并通过变量引用其值。
若在组件创建时,响应式数据在 桶中 存储了数据,则就应该在组件销毁时,也同时将该组件中使用到的响应式数据,从 bucket 中清除。而 WeakMap 可以自动实现这个功能。
WeakMap 也支持将 对象作为 key,但当该对象被销毁时,该值也会被垃圾回收WeakMap:MDN链接
所以用于存储 数据的结构是 WeakMap
const bucket = new WeakMap();
再来看一下上面的数据结构
{
obj:{
'a':...,
}
}
obj 是响应式对象,a为属性,可是a的值应该是什么呢?
我们回到一开始要实现的功能,即 当 obj.a 或 obj.b 改变时,let res = obj.a + obj.b ,这个res我们也希望它会跟着改变。可是想要res改变,就需要将 obj.a + obj.b 重新计算,不管怎样,想要获取新的值,这个计算是不能少的。也就是说,属性 a 的值改变,就必然要重新执行 res = obj.a + obj.b;
所以a的值是一个函数,用于当a改变时,直接调用该函数,则res的值就是新的了。
{
obj:{
'a':function(){res = obj.a + obj.b},
}
}
由于 依赖 obj.a 的数据可能不止 res 一个,可能有很多的值 都是通过 obj.a 计算而来的,所以 a 的值要改变一下,变成一个 Set() 数据结构,用于存储多个 函数。
先实现简单的响应式
先整合一下上面的内容。
- 原数据通过 proxy 代理
- 代理过的数据,为管理其响应式状态,将一并存储在一个 bucket 桶中,桶 是通过 new WeakMap() 实现的。
- bucket 的数据格式为 二层嵌套:外层是存储对象,其值为该对象的多个属性。内层为属性,其值为 Set()格式,存储依赖该属性的 函数。
目前已经有了一个 bucket,我们可以向其存储 对象-属性-依赖该属性的函数。存储后的目的是在该属性的值改变时,调用这个函数,这样就可以更新数据了。
因为在属性改变时,调用函数,所以调用函数是在 set 拦截器中实现的,如:
const obj = new Proxy(data,{
set(target,key,newVal){
// 获取外层对象的值,值为多个属性
let objMap = bucket.get(target);
if (!objMap) return;
// 获取属性的值,值为 new Set()格式,其中存储了多个函数
let propSet = objMap.get(key);
// 执行这些函数
propSet && propSet.forEach(fn => fn())
}
})
问题是何时向 bucket 中存储数据呢? 看一下下面的例子。
let getSum;
getSum = obj.a + obj.b;
上面这个 getSum 的值来源于 a与b。当a或b改变时,都应该执行 getSum = obj.a + obj.b,所以应该将这个操作作为函数,添加入 bucket 中。而当对 obj.a 或 obj.b 进行读取时,此时将 getSum = obj.a + obj.b; 分别存入 a与b的 Set中,便可以在修改时,获取函数并调用了。
副作用函数 & effect
我们先封装一个函数,用于将 要存储桶中的函数保存和执行。
介绍一下副作用函数:当一个函数修改了外部的变量,由于外部的变量有可能在其它地方也使用到,则这个函数在执行时,就会产生 副作用。因为它的执行,可能会引起其它地方的变化,这样的函数就称为 副作用函数。
之后所提及的 副作用函数 是fn参数,也是最后入桶的 副作用函数
// 用于存储 函数,在get中再将其添加入桶。
let activeEffect;
// 这个地方有两个 副作用函数
// 一个是 effect,一个是 fn
function effect(fn){
activeEffect = fn;
// 初始化时调用一下。
fn();
}
// 使用方式
let getSum;
effect(()=>{
getSum = obj.a + obj.b;
})
由于设计的 bucket 结构的主要任务是存储 对象-属性-依赖该属性的函数,而这里的核心是 依赖该属性的函数,在当前场景下就是 ()=>{ getSum = obj.a + obj.b; } 这个函数,由于这个函数改变了外部变量 getSum, 所以也称为副作用函数。
也就是说这个bucket的主要作用就是将 副作用函数存储到对应属性中,而在该属性修改时再调用这个属性下的副作用函数,便可以实现更新了。
所以 上面的 effect 函数, 目的就是将 ()=>{ getSum = obj.a + obj.b; } 保存下来,在get拦截器中添加入 bucket。而activeEffect 就是暂时保存 这个函数的,因为get拦截器与 effect 处于不同时间触发,所以就需要临时变量保存函数了。
我们先来实现一下上面所讲到的功能,看效果如何
// 原数据
const data = {
a: 1,
b: 2,
}
// 桶,用于存储 对象-属性-依赖该属性的函数
const bucket = new WeakMap()
// 用于临时保存 副作用函数
let activeEffect;
// 接收 副作用函数 fn,将其 保存进 activeEffect中等待使用,并立即执行。
function effect(fn) {
activeEffect = fn;
//立即执行
fn();
}
// 通过 proxy 代理 data
const obj = new Proxy(data, {
// get 拦截器,当读取 obj 的属性时,get 方法会被调用
get(target, key) {
if (!activeEffect) return;
// 由于这部分属于创建,所以先获取对象的值
// 如果没有值,则初始化值为 new Map()
let objMap = bucket.get(target);
objMap || bucket.set(target, objMap = new Map());
// 通过对象的值,获取对应属性值
// 如果没有属性值,则初始化值为 new Set();
let propSet = objMap.get(key);
propSet || objMap.set(key, propSet = new Set());
// 将临时保存的 activeEffect 添加入桶;
propSet.add(activeEffect);
return target[key];
},
// set 拦截器,当修改 obj 的属性时,set 方法会被调用
set(target, key, newVal) {
target[key] = newVal;
//获取对象的值
let objMap = bucket.get(target);
if (!objMap) return;
// 获取属性的值
let propSet = objMap.get(key);
// 执行该属性下的 函数
propSet && propSet.forEach(fn => fn())
}
})
let res;
// 在调用时,因为会立即执行,所以 res 的值会进行初始化
effect(() => {
res = obj.a + obj.b; // 3
})
obj.a = 10;
console.log(res) // 12
obj.b = 10;
console.log(res) // 12 // 桶,用于存储 依赖数据
上面已经实现了 当 obj.a 或 obj.b 属性变化时,res的值也会自动更新。
上面代码的实现逻辑如下:
解释effect函数
调用 effect 函数,传入参数 () => { res = obj.a + obj.b; }, 在 effect 中,该参数会被保存到 activeEffect 中,然后被调用。
此时activeEffect的值为 () => { res = obj.a + obj.b; }
解释 副作用函数 入桶与执行
在 get 拦截器中,对activeEffect的值 () => { res = obj.a + obj.b; } 进行入桶操作
let objMap = bucket.get(target);
objMap || bucket.set(target, objMap = new Map());
// 通过对象的值,获取对应属性值
// 如果没有属性值,则初始化值为 new Set();
let propSet = objMap.get(key);
propSet || objMap.set(key, propSet = new Set());
// 将临时保存的 activeEffect 添加入桶;
propSet.add(activeEffect);
由于effect中立即执行传入的参数,所以 () => { res = obj.a + obj.b; } 会先执行一次,则就会分别读取 obj.a 和 obj.b ,在get拦截器中会进入两次,一次是 属性a的入桶操作,一次是属性b的入桶操作。所以操作后的 bucket 数据如下:
{
obj:{
'a':[() => { res = obj.a + obj.b; }],
'b':[() => { res = obj.a + obj.b; }]
}
}
上面对a和b都存入了 函数,则当执行 obj.a = 10;时,便会进入 set 拦截器中,然后从 bucket 中取出函数执行。
//获取对象的值
let objMap = bucket.get(target);
if (!objMap) return;
// 获取属性的值
let propSet = objMap.get(key);
// 执行该属性下的 函数
propSet && propSet.forEach(fn => fn())
最后的 fn() 就是执行函数。所以res的值就是最新的了。
封装 track、trigger 函数
上面的set与get中关于 桶 的操作最好抽出来,以防止proxy 中代码量太多,也符合 单一职责的设计模式,将一个大的功能,拆分成 多个小功能,一个功能只专注做一件事。
将 get 中的关于桶的操作封装成 track 函数,起名叫 track 该函数可以追踪读取属性操作,并添加入桶。
// track 的意思有追踪
function track(target,key){
// 该函数的目的是为了将 副作用函数 添加到桶中,既然没有副作用函数,当然就 return 了
if(!activeEffect) return;
// 依旧是老样子,在多个对象中,找到要操作的对象
let objMap = bucket.get(target);
// 如果是第一次操作该对象,则是新增逻辑,则为该对象分配一个数据空间
objMap||bucket.set(target,objMap = new Map());
// 逻辑同上,在多个属性中,找到要操作的属性
const propSet = objMap.get(key);
// 如果是第一次操作该属性,则是新增逻辑,则为该属性分配一个数据空间
propSet||objMap.set(key,propSet = new Set());
// 最后,将副作用函数添加进该属性的桶中
propSet.add(activeEffect);
}
将 set 中的关于桶的操作封装成 trigger 函数。叫 trigger 是因为 它的核心作用是触发副作用函数的重新执行。
// trigger 有 触发的意思
function trigger(target,key){
// 获取要操作的对象值
const objMap = bucket.get(target);
if(!objMap) return ;
// 获取该对象中要操作的属性值
// 此时值为 Set() 类型,存储着 副作用函数。
const propSet = objMap.get(key);
// 执行 副作用函数。
propSet && propSet.forEach(fn=>fn());
}
虽然目前还没有实现 computed ,但也算实现了 对响应式数据的监听,从而自动的更改了依赖其数据的数据。所以我要讲的第一点监听响应式数据算是实现了,那么下一点就是:懒加载。
懒加载
在实现 懒加载 功能前,还是想先再说说 effect函数。
effect 函数是用来注册 副作用函数的,被注册的副作用函数 会在get拦截器中添加到桶中对应的属性下,在 该属性的set 拦截器中被取出执行。
在Vue中,不仅 Computed 是通过 effect 函数实现的,而且 watch 也是通过 effect 实现的。对Vue3熟悉的朋友应该知道一个 WatchEffect api,它也是通过 effect 函数实现的。
所以在对 effect函数 扩展时,需要注意兼容。如 懒加载功能应该是支持配置的,而不是固定的。
那么想实现懒加载,先看下想要实现的需求具体是什么。
// 用于临时保存 副作用函数
let activeEffect;
// 接收 副作用函数 fn,将其 保存进 activeEffect中等待使用,并立即执行。
function effect(fn) {
activeEffect = fn;
//立即执行
fn();
}
let res;
// 之前的这里,因为在 effect 函数中是直接调用了参数 fn
// 所以是立即执行,所以只需要不立即调用就可以了
effect(() => {
res = obj.a + obj.b; // 3
})
我们需要对 effect 函数做一些改变,使其可以支持立即调用,也可以支持延迟调用。
传入配置项
将 副作用函数 作为第一个参数,第二个参数为 options 配置项
// 以下只展示 与 懒加载相关代码
const data = {
a: 1,
b: 2,
}
const obj = new Proxy(data, {
...
})
function effect(fn, options = {}) {
activeEffect = fn;
// 如果 lazy 为 true,则不调用fn
options.lazy || fn();
// 并将 fn 返回,由外部决定何时调用
return fn;
}
// 接收返回的 函数
const fn = effect(() => {
res = obj.a + obj.b;
},{
lazy:true
})
// 此时,由于不立即调用,则 res 为 undefined;
// 而fn为更新函数,只要调用它,就可以随时更新 res 的值了
obj.a = 10;
fn()
console.log(res) // 12
上面的 fn 在每次依赖数据改变后,都要手动调用,这显然不是我们想要的结果,但可以借此 封装一个 computed 函数,在内部自动调用。
computed 的实现
我们将通过 目前的 effect函数 封装 computed,但需要几个步骤,如下:
修改使用 effect 的方式
之前的 effect 调用方式
// 这是之前的使用方式
// res = obj.a + obj.b; 被硬编码在第一个参数中
// 返回的fn,就是 传入的第一个参数本身
const fn = effect(() => {
res = obj.a + obj.b;
},{
lazy:true
})
首先,我们要去除 res,从而不对任何值做修改,而是将 fn本身作为一个值
// fn 为 () => obj.a + obj.b 函数本身,调用后就是 obj.a + obj.b 相加的值
const fn = effect(() => obj.a + obj.b ,{
lazy:true
})
computed 函数
上面消除了 res 的硬编码,但 () => obj.a + obj.b 也是硬编码,如果下次要计算其它的值呢? effect 函数为底层函数,并不希望用户直接使用。而高级api comptued 向使用者暴露。所以将通过 computed 接收 一个函数 作为参数,传入给effect。
// 将 () => obj.a + obj.b 去除,变为 getter 参数
function computed(getter) {
const fn = effect(
getter, {
lazy: true
})
// 此时 fn() 就是 getter () => obj.a + obj.b 的返回值了
// 在读取 value 属性时,将 fn() 的值返回出去
const obj = {
get value() {
return fn()
}
}
return obj;
}
const getSum = computed(() => obj.a + obj.b)
console.log(getSum);
在上面,我们实现了 computed 函数,并在读取value属性时,才开始调用fn,从而计算新值。已经实现了 懒加载 功能。
不过要介绍一下 get value 这个功能 getter:MDN链接
// 在属性前加上 get 字段,该属性就可以用方法的方式返回一个值
const obj = {
get name() {
return "Nancy";
},
};
// 在读取该属性时,获取到的就是 name方法的返回值了
console.log(obj.name); // Nancy
接下来,该到 缓存 功能了。
缓存
cache 与 dirty
之前说到,缓存就是用两个变量。 一个变量将值保存,一个变量用来判断是否要获取新值。如果依赖的值已经改变了,但还用之前计算的值返回,当然就有问题了。接下来就开始实现了。
function computed(getter) {
// 缓存值
let cache;
// 缓存是否是脏数据,若脏,则更新缓存
let dirty = true;
const fn = effect(
getter, {
lazy: true
})
const obj = {
get value() {
// 缓存脏时,则更新缓存
if (dirty) {
// 重新计算值,并更新缓存
cache = fn();
// 缓存值 不脏了
dirty = false;
}
return cache;
}
}
return obj;
}
上面通过 dirty 变量,判断缓存是否是脏数据,若脏,则更新缓存。
缓存是更新了,dirty 为false了。但什么时候数据又会变为脏数据呢?
调度执行 之 scheduler调度器
在这个例子中 getSum = computed(()=>obj.a + obj.b); 如果 getSum 一直是读取缓存的,那么当 obj.a 或 obj.b 变化时,缓存则需要更新,而更新缓存是通过 dirty 字段 判断的。
所以需要在 obj.a 或 obj.b 改变时,需要将 dirty 变为 true。
属性值的改变在 set 拦截器中,但不能直接添加 dirty = true,为了灵活性,依然要通过 配置项传入。
function computed(getter) {
let cache;
let dirty = true;
const fn = effect(
getter, {
lazy: true,
// 我们将 dirty = true 装在一个函数中
// 作为配置传递过去
scheduler() {
dirty = true;
}
})
const obj = {
get value() {
if (dirty) {
cache = fn();
dirty = false;
}
return cache;
}
}
return obj;
}
我们将 dirty = true 放在 调度器 scheduler 中,作为函数传递到 effect函数中。但问题是,effect 函数 与 set 拦截器完全是两个 作用域。 在set 中 无法获取到scheduler。
解决的方法是,由于bucket 主要操作的 副作用函数,所以每个属性也是与其 副作用函数 相关联,我们可以将 scheduler 绑定到其 副作用函数身上,当在 该属性的 set 拦截器中,就可以 通过 副作用函数,获取到 scheduler 并执行了。
function effect(fn, options = {}) {
// 我们 将 options 绑定到 fn 函数本身
fn.options = options;
activeEffect = fn;
options.lazy || fn();
return fn;
}
const obj = new Proxy(data, {
set(target, key, newVal) {
target[key] = newVal;
trigger(target, key,)
}
})
function trigger(target, key) {
let objMap = bucket.get(target);
if (!objMap) return;
let propSet = objMap.get(key);
// 之前这里是这样执行的,直接调用
// propSet && propSet.forEach(fn=>fn());
// 现在做一个判断,
// 有 scheduler 则调用 scheduler,没有则调用 fn
propSet.forEach(fn => {
const { scheduler } = fn.options;
scheduler ? scheduler(fn) : fn();
})
// 此处的 scheduler 便是 ()=>{ dirty = true; }
}
上面通过在 effect 中传入的参数 fn 身上绑定第二个参数 options,实现了 scheduler 的传递,从而在 set 拦截器中成功执行了 调度器。
目前的缓存功能已经实现。接下来就是,监听计算属性
effect 监听计算属性
之前说过, 我们对计算属性监听并不使用 template 标签中的 {{ getSum }} 这种插值语法实现,而是用 effect 模拟(其实插值语法也和effect函数有关)。
监听的方式如下:
const res = computed(() => obj.a + obj.b)
effect(() => {
console.log(res.value, '改变发生了')
})
setTimeout(() => {
obj.b = 15
}, 1000);
我本指望着直接成功了,但 res.value 的值为 3,也就是初始化时的值,而不是 16。
此时,由于 console.log(res.value, '改变发生了') 是写在 effect函数 中,当 obj.a 或 obj.b 变化时, console.log(res.value, '改变发生了') 这句代码便会执行,但 res.value 的值一直没有变。
activeEffect 导致的问题
上面的问题是因为 activeEffect ,activeEffect是在 effect函数中,通过 activeEffect = fn 绑定的值。也就是说,只要调用了 effect 函数,便会 将 fn 赋值给 activeEffect。
我们看一下代码,调用了几个 effect函数
// 其实这里也调用了 effect 函数
const res = computed(() => obj.a + obj.b)
//这里调用了,此时 activeEffect = () => { console.log(res.value, '改变发生了')}
effect(() => {
console.log(res.value, '改变发生了')
})
setTimeout(() => {
obj.b = 15
}, 1000);
这里是上面的 computed 的实现 , 其中也调用了 effect函数, 而这时 activeEffect = () => obj.a + obj.b
function computed(getter) {
// 缓存值
let cache;
// 缓存是否是脏数据,若脏,则更新缓存
let dirty = true;
// 这里调用了 effect ,
// activeEffect = getter
const fn = effect(
getter, {
lazy: true,
scheduler() {
dirty = true;
}
})
const obj = {
get value() {
// 缓存脏时,则更新缓存
if (dirty) {
// 重新计算值,并更新缓存
cache = fn();
// 缓存值 不脏了
dirty = false;
}
return cache;
}
}
return obj;
}
注意上面的执行顺序 ,先执行了 computed 的 effect ,此时 activeEffect = () => obj.a + obj.b 。之后执行了第二个 effect ,此时 activeEffect = () => { console.log(res.value, '改变发生了')} 。
当读取 obj.a 或 obj.b 的值时,添加入桶的是 () => { console.log(res.value, '改变发生了')}
此时桶结构是这样的
{
obj:{
'a':[() => { console.log(res.value, '改变发生了')}],
'b':[() => { console.log(res.value, '改变发生了')}]
}
}
所以在 a 或 b的值改变时,执行的也是上面这样的函数,而不是 obj.a + obj.b ,则res 的值自然不会变了。而且这里的 一个问题还在于 effect 发生了嵌套,也是导致 activeEffect 出错的原因。
解决方法则是 允许添加多个 activeEffect
对一的副作用函数
我们将创建一个 effect 栈来实现对 fn 的存储 , 并要修改一下 effect 函数了
let activeEffect;
// 用于存储多个 fn
const effectStack = []
function effect(fn, options = {}) {
// 创建副作用函数包装器
const effectFn = () => {
// 将当前副作用函数设置为活动的
activeEffect = effectFn;
// 将当前副作用函数压入栈中,解决嵌套effect问题
effectStack.push(effectFn);
// 执行原始函数并获取结果
let res = fn();
// 执行完毕后从栈中弹出
effectStack.pop();
// 恢复之前的activeEffect
activeEffect = effectStack[effectStack.length - 1];
// 返回函数执行结果
return res;
}
// 将options挂载到effectFn上,方便后续使用
effectFn.options = options;
// 如果不是懒执行模式,则立即执行副作用函数
options.lazy || effectFn();
// 返回副作用函数
return effectFn;
}
上面将之前的 fn 函数,现在换成了 effectFn 函数,目的是要对 fn 函数执行前后,执行 栈的操作。
也就是 当 activeEffect = () => obj.a + obj.b 时,由于 computed 是懒加载,所以此时并不会读取 obj.a 和 obj.b ,就无法将 副作用函数 收集到 bucket 中了。现在 我们在 effectFn 函数中,将 activeEffect 赋值两次,第一次是当前,第二次则是恢复之前的。
所以 当 activeEffect 被赋值为 () => { console.log(res.value, '改变发生了')} 时,还会通过 activeEffect = effectStack[effectStack.length - 1]; 恢复成之前的activeEffect ,就可以正确收集 副作用函数了。
修改 computed
想要实现 在 effect中跟踪 计算属性 ,还需要对 computed 做如下修改:
function computed(getter) {
let cache;
let dirty = true;
const fn = effect(
getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true;
// 更新缓存时,手动触发 obj.value 的修改操作
// 从而执行其 副作用函数
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
cache = fn();
dirty = false;
}
// 读取 value时,手动执行 入桶操作
track(obj, 'value')
return cache;
}
}
return obj;
}
我们从开始到现在,其实都围绕着核心逻辑去实现响应式。就是读取数据时,将副作用函数添加到桶中,修改依赖数据时,从桶中取出副作用函数并执行。
但这两个操作都是在 proxy中实现了,普通的对象并不会触发 拦截器。当我们对 obj 操作时可以生效,是因为它是个响应式数据,对它的操作也都会将 副作用函数 收集到 bucket 中。
但我们读取 res.value 时,在bucket中并不存在 关于对象 res ,也不存在 value属性,更不存在相关的 副作用函数,所以在 effect 中监听 计算属性 并不会生效。
但我们通过上面添加的两行代码,就可以实现了。
我们在对 res. value 读取时,对obj. value 做入桶操作,在修改时也对 obj. value 的副作用函数 取出执行。就可以了。
反正我们需要的就是 桶与副作用函数。其实这两行代码中的 并不需要是obj,任何一个对象都可以,是不是响应式也无所谓,我们只是需要 bucket 的数据。
结语:
其实才写了一半
其实上面看似实现了响应式,计算属性。但其实还是有问题的,包括数据冗余、无限递归导致的内存溢出,而且关于 watch 还并没讲。
我原本打算将响应式讲完,但内容已经太多了,我担心纯技术文章,没人愿意看这么多,所以暂且停下吧。
其实有不少人慢慢觉得掘金远离了初衷,技术文章不受爱,流量文章却霸榜。自己力量薄弱,能力也有很大的不足,但仍希望自己可以通过努力,为技术派,加一些力。