怎么用代码实现一个迷你响应式系统vue

寻技术 VUE 2023年12月25日 100

这篇文章主要讲解了“怎么用代码实现一个迷你响应式系统vue”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究和学习“怎么用代码实现一个迷你响应式系统vue”吧!

基本定义

什么是响应式系统?学术上的定义,我们就不细究了。通过纵观前端业界对响应系统的实现,其实,这个定义是很简单的。 无非是 - 一个系统,它能够对接入这个系统的 js 值的变化自动地做出反应的话,那么这个系统就可以称之为「响应式系统」。

基本要素

从上面的基本定义来看,响应式系统就包含两个基本的,必不可少的要素:

  • 被观察的值

  • 能够响应值发生变化的能力

「能被观察的值」在不同的 UI 库中叫法不一样。比如:

  • mobx 中称之为「observables」

  • solidjs 称之为「signal」

  • vue 称之为「ref」

  • recoil 称之为 「atom」

  • 还有称之为「subjects」或者「state」

不管你怎么叫,它终究还是一个能被观察的 「js 值」。显然, 原始的 js 值是没有响应性的,这里的「能被观察」正是需要我们自己去封装实现的。这里的实现的基本思路就是「包裹」。展开说,就是你想某个 js 值能被观察,那么它就必须被「某个东西」包裹住,然后与之配合,用户消费的是包裹后的产物而不是原始值。

实现「包裹」的方式不一样,那么最终提供给用户的 API 的风格就不一样。不同风格的 API 所带来的 DX 不同。比如,vue3 里面,它的响应式系统是基于浏览器的原生 API

Proxy
来实现值的包裹的。在这中技术方案下,用户使用原生的 js 值访问语法和赋值语法即可:
const proxyVal = new Proxy(originVal, {
    get(){},
    set(){}
});
// 读值
console.log(proxyVal);
// 写值
proxyVal = newVal;

跟 vue 不同,solidjs 自己实现了一套显式的读和写 API:

const [val, setVal] = createSignal(originVal);
// 读值
console.log(val());
// 写值
setVal(newVal)

以上是第一基本要素。第二个基本要素是,我们得有响应被观察值发生变化的能力。这种能力主要体现在当我们所消费的 js 值发生了变化后,我们要根据特定的上下文来做出对应的反应。js 值被消费的最常见的地方就是 js 语句。如果我们能让这个语句重新再执行一次,那么它就能拿到最新的值。这就是所谓的响应式。那如果能够让一个 js 语句再执行一遍呢?答案是:“把它放在函数里面,重新调用这个函数即可”。

上面所提到的「函数」就是函数式编程概念里面的「副作用」(effect)。还是老样子,同一个东西,不同的类库有不同的叫法。effect 又可以称之为:

  • reaction

  • consumer(值的消费者)

  • listener(值的监听者)

等等。一般而言,副作用是要被响应式系统接管起来的,等到被观察的 js 值发生变化的时候,我们再去调用它。从而实现了所谓的响应能力。这个用于接管的 API,不同的类库有不同的叫法:

  • createEffect

  • consume

  • addListener

  • subscribe

以上是对响应式系统的最基本的两个要素的阐述。下面,我们就从这个认知基础出发,循序渐进地用 60 行代码去实现一个迷你响应系统。为了提高逼格,我们沿用 solidjs 响应式系统所采用的相关术语。

代码实现

实现值的包裹

包裹 js 值的根本目的就是为了监听用户对这些值的「读」和「写」的两个动作:

function createSignal(value) {
  const getter = () => {
    console.log('我监听到读值了')
    return value;
  };
  const setter = (nextValue) => {
   console.log('我监听到写值了')
   value = nextValue;
  };
  return [getter, setter]; 
}
const [count, setCount] = createSignal(0)
//读
count()
// 我监听到读值了
//写
setCount(1)
// 我监听到写值了

可以说,我们的这种 API 设计改变了用户对 js 值的读写习惯,甚至可以说有点强迫性。很多人都不习惯读值的这种语法是一个函数调用。没办法,拿人手短,吃人嘴软,习惯就好(不就是多敲连两个字符吗?哈哈)。

通过这种带有一点强制意味的 API 设计,我们能够监听到用户对所观察值的读和写。

其实,上面的短短的几行代码是本次要实现的迷你型响应系统的奠基框架。因为,剩下要做的,我们就是不断往 setter 和 getter 的函数体里面堆砌代码,以实现响应式系统的基本功能。

订阅值的变化

用户对 js 值的消费一般是发生在语句中。为了重新执行这些语句,我们需要提供一个 API 给用户来将语句封装起来成为一个函数,然后把这个函数当做值存储起来,在未来的某个时刻由系统去调用这个函数。当然,顺应「语句」的语义,我们应该在将语句封装在函数里面之后,应该马上执行一次:

let effect
function createSignal(value) {
  const subscriptions = [];
  const getter = () => {
    subscriptions.push(effect)
    return value;
  };
  const setter = (nextValue) => {
     value = nextValue;
     for (const sub of subscriptions) {
        sub()
      }
  };
  return [getter, setter]; 
}
function createEffect(fn){
    effect = fn;
    fn()
}

至此,我们算是实现了响应系统的基本框架:

  • 一个可以帮助 js 值被观察的 API

  • 一个辅助用户创建 effect 的 API

熟悉设计模式的读者可以看出,这个框架的背后其实就是「订阅-发布模式」 - 系统在用户「读值」的时候去做订阅,在用户「写值」的时候去通知所有的订阅者(effect)

上面的代码看起来好像没问题。不信?我们测试一下:

代码片段1

const [count, setCount] = createSignal(0)
createEffect(()=> {
    console.log(`count: ${count()}`);
})
// 打印一次:count: 0
setCount(1)
// ?

在打问号的地方,我们期待它是打印一次

count: 1
。但是实际上它一直在打印,导致页面卡死了。看来,
setCount(1)
导致了无限循环调用了。仔细分析一下,我们会发现,导致无限循环调用的原因在于:
setCount(1)
会导致系统遍历
subscriptions
数组,去调用每一个 effect。而调用
effect()
又会产生一次读值。一旦读值,我们就会把当前全局变量
effect
push 到
subscriptions
数组。这就会导致了我们的
subscriptions
数组永远遍历不完。我们可以通过组合下面两个防守来解决这个问题:
  • 防止同一个 effect 被重复 push 到

    subscriptions
    数组里面了。
  • 先对

    subscriptions
    数组做浅拷贝,再遍历这个浅拷贝的数组。

修改后的代码如下:

function createSignal(value) {
  const subscriptions = [];
  const getter = () => {
    if(!subscriptions.includes(effect)){
        subscriptions.push(effect)
    }
    return value;
  };
  const setter = (nextValue) => {
     value = nextValue;
     for (const sub of [...subscriptions]) {
        sub()
      }
  };
  return [getter, setter]; 
}

我们再用上面「代码片段1」去测试一下,你会发现,结果是符合预期的,没有 bug。

小优化

细心的读者可能会注意到,其实上面的代码还是可以有优化的空间的 - 我们可以让它更精简和健壮。

用 Set 代替数组

首先我们看看这段防守代码:

if(!subscriptions.includes(effect)){
    subscriptions.push(effect)
}

这段代码的目的不言而喻,我们不希望

subscriptions
存在「重复的」effect。一提到去重相关的需求,我们得马上想到「自带去重功能的」,ES6 规范添加的新的数据结构 「Set」。于是,我们用 Set 来代替数组:
function createSignal(value) {
   const getter = () => {
    subscriptions.add(effect);
    return value;
  };
  const setter = (nextValue) => {
     value = nextValue;
      for (const sub of [...subscriptions]) {
        sub();
      }
  };
  return [getter, setter]; 
}

看来用上 Set 之后,我们的代码精简了不少,so far so good。

用 forEach 代替 for...of

这个优化真的很考验读者对 js 这门复杂语言的掌握程度。首先,你得知道

forEach
for...of
虽然都是用来遍历 Iterable 的数据结构,但是两者之间还是有很多不同的。其中的一个很大的不同体现在「是否支持在遍历中对源数据进行动态修改」。在这一点上,
forEach
是不支持的,而
for...of
是支持的。下面举个简单的例子进行说明: 首先
const a = [1,2,3];
a.forEach(i=> {
    if(i === 3){ a.push(4)}
    console.log(i)
})
// 1
// 2
// 3
console.log(a); // [1,2,3,4]
for(const i of a){
 if(i === 4){ a.push(5)}
    console.log(i)
}
// 1
// 2
// 3
// 4
// 5
console.log(a); // [1,2,3,4,5]

通过上面的对比,我们验证了上面提及的这两者的不同点:

forEach
不会对源数据的动态修改做出反应,而
for...of
则是相反。

当你知道

forEach
for...of
这一点区别后,结合我们实现响应系统的这个上下文,显然,我们这里更适合使用
forEach
来遍历 Set 这个数据结构。于是,我们修改代码,目前最终代码如下:
let effect
function createSignal(value) {
  const subscriptions = new Set();
  const getter = () => {
    subscriptions.add(effect)
    return value;
  };
  const setter = (nextValue) => {
     value = nextValue;
     [...subscriptions].forEach(sub=> sub())
  };
  return [getter, setter]; 
}
function createEffect(fn){
    effect = fn;
    fn()
}

到目前为止,我们就可以交差了。因为,如果用户「不乱用」的话,这个迷你响应系统是能够运行良好的。

何为「乱用」呢?好吧,让我们现在来思考一下:「万一用户嵌套式地创建 effect 呢?」

支持 effect 嵌套

好,我们基于上面的最新代码,用下面的代码测试一下:

代码片段2

const [count1, setCount1] = createSignal(0);
const [count2, setCount2] = createSignal(0);
createEffect(function count1Effect() { 
    console.log(`count1: ${count1()}`)
    createEffect(function count2Effect(){
        console.log(`count2: ${count2()}`)
    }) 
})
// count1: 0
// count2: 0
setCount1(1)
// count1: 1
// count2: 0
// count2: 0 // 多了一次打印,为什么?

setCount1(1)
之后,我们期待应该只打印两次:

count1: 1
count2: 0

实际上却是多了一次

count2: 0
,这一次打印是哪里来的?问题似乎出现在全局变量
effect
上 - 一旦
createEffect
嵌套调用了,那么,effect 的收集就发生了错乱。具体表现在,我们第一次调用
createEffect()
去创建 count1Effect 的时候,代码执行完毕后,此时全局变量
effect
指向 count2Effect。当我们调用
setCount1()
之后,我们就会通知 count1Effect,也就是调用
count1Effect()
。这次调用过程中,我们就会再次去收集 count1 的订阅者,此时订阅者却指向 count2Effect。好,这就是问题之所在。

针对这个问题,最简单的解决方法就是:调用完 effect 函数后,就释放了全局变量的占用,如下:

function createEffect(fn){
    effect = fn;
    fn();
    effect = null; // 新增这一行
}

同时,在收集 effect 函数地方加多一个防守:

function createSignal(value) {
  const subscriptions = new Set();
  const getter = () => {
    !!effect && subscriptions.add(effect) // 新增防守
    return value;
  };
  const setter = (nextValue) => {
     value = nextValue;
     [...subscriptions].forEach(sub=> sub())
  };
  return [getter, setter]; 
}

如此一来,就解决我们的问题。解决这个问题,还有另外一种解决方案 - 用「栈」的思想解决特定 js 值与所对应的 effect 的匹配问题。在这种方案中,我们将全局变量

effect
重命名为数组类型的
activeEffects
更符合语义:
let activeEffects = []; // 修改这一行
function createSignal(value) {
  const subscriptions = new Set();
  const getter = () => {
    const currentEffect = activeEffects[activeEffects.length - 1]; // 新增这一行
    subscriptions.add(currentEffect);
    return value;
  };
  const setter = (nextValue) => {
     value = nextValue;
     [...subscriptions].forEach(sub=> sub())
  };
  return [getter, setter]; 
}
function createEffect(fn){
    activeEffects.push(fn); // 新增这一行
    fn();
    activeEffects.pop(); // 新增这一行
}

同一个 effect 函数实例不被重复入队

细心的读者可能会发现,在代码片段2中,如果我们接着去设置

count2
的值的话,count2Effect 会被执行两次。实际上,我觉得它仅仅被执行一次是比较合理的。当然,在这个示例代码中,因为我们重复调用
createEffect()
时候传入是不同的,新的函数实例,因此被视为不同的 effect 也是理所当然的。但是万一用户在这种场景下(嵌套创建 effect)传递给我们的是同一个 effect 函数实例的引用,我们能做到 『当这个 effect 函数所依赖的响应值发生改变的时候,这个 effect 函数只被调用一次吗』?

答案是:“能”。而且我们目前已经误打误撞地实现了这个功能。请看上面「用 Set 代替 数组」的优化之后的结果:

subscriptions.add(effect);
。这句代码就通过 Set 数据结构自带的去重特性,防止在嵌套创建 effect 场景下,如果用户多次传入的是同一个 effect 函数实例引用,我们能够保证它在响应值的
subscriptions
中只会存在一个。因此,该 effect 函数只会被调用一次。

回到代码片段2中,如果我们想 count2Effect 函数只会被执行一次,那么我们该怎么做呢?答案是:“传递一个外部的函数实例引用”。比如这样:

const [count1, setCount1] = createSignal(0);
const [count2, setCount2] = createSignal(0);
function count2Effect(){
    console.log(`count2: ${count2()}`)
}
createEffect(function count1Effect() { 
    console.log(`count1: ${count1()}`)
    createEffect(count2Effect) 
})

小结

好了,到了这里,我们基本上可以交差了,因为我们已经实现了响应式系统的两个基本要素:

  • 实现值的包裹

  • 订阅值的变化

如果我们现在拿「代码片段2」去测试,现在的结果应该是符合我们的预期的。

提高响应的准确性

从更高的标准来看,目前为止,前面实现的迷你型响应系统还是比较粗糙的。其中的一个方面是:响应的准确性不高。下面我们着手来解决这个问题。

避免不必要的 rerun

如果读者朋友能细心去把玩和测试我们目前实现的代码,你会发现,如果你对同一个响应值多次设置同一个值的话,这个响应值所对应的 effect 都会被执行:

代码片段3

const [count1, setCount1] = createSignal(0);
createEffect(function count1Effect(){
    console.log(`count1: ${count1()}`)
}) 
setCount1(1)
// count1: 1
setCount1(1)
// count1: 1

从上面的测试示例,我们可以看出,被观察值没有发生变化,我们还是执行了 effect。这显然是不够准确的。解决这个问题也很简单,我们在设置新值之前,加一个相等性判断的防守 - 只有新值不等于旧值,我们才会设置新值。优化如下:

function createSignal(value) {
  // ......省略很多代码
  const setter = (nextValue) => {
     if(nextValue !== value){
         value = nextValue;
         [...subscriptions].forEach(sub=> sub())
     }
  };
  return [getter, setter]; 
}

或者,我们可以更进一步,把判断两个值是否相等的决策权交给用户。为了实现这个想法,我们可以让用户在创建响应值的时候传递个用于判断两个值是否相等的函数进来。如果用户没有传递,我们才使用

===
作为相等性判断的方法:
function createSignal(value, eqFn) {
  // ......省略很多代码
  const setter = (nextValue) => {
     let isChange
     if(typeof eqFn === 'function'){
         isChange = !eqFn(value, nextValue);
     }else {
         isChange = nextValue !== value
     }
     if(isChange){
         value = nextValue;
         [...subscriptions].forEach(sub=> sub())
     }
  };
  return [getter, setter]; 
}

经过上面的优化,我们再拿代码片段3去测试一下,结果是达到了我们的预期了: 第二次的

setCount1(1)
不会导致 effect 函数的执行。

动态的依赖管理

这里引入了「依赖管理」的概念。现在,我们先不讨论这个概念应该如何理解,而是看看下面这个示例代码:

代码片段4

const [count1, setCount1] = createSignal(0);
const [count2, setCount2] = createSignal(0);
const [flag, setFlag] = createSignal(true);
createEffect(function totalEffect(){
    if(flag()){
        console.log(`total : ${count1() + count2()}`);
    }else {
        console.log(`total : ${count1()}`);
    }
});
setCount1(1);
// total : 1 (第 1 次打印,符合预期)
setCount2(1);
// total : 2 (第 2 次打印,符合预期)
setFlag(false);
// total : 1 (第 3 次打印,符合预期)
setCount1(2);
// total : 2 (第 4 次打印,符合预期)
setCount2(2);
// total : 2 (第 5 次打印,不符合预期)

首先,我们得讨论一下,什么是「依赖」?「依赖」其实是在描述 「effect 函数」跟「响应值」之间的关系。现在如果有这样的观点:你「使用」了某个物品,我们就说你「依赖」这个物品。那么,在上面的示例代码中,

totalEffect()
使用了响应值
count1
count2
,我们就可以说,
totalEffect()
依赖(及物动词)了
count1
count2
。反过来我们也可以说,
count1
count2
totalEffect()
的依赖(名词)。这就是「依赖管理」中「依赖」的含义 - 取名词之义。

通过发散思维,我们不难发现,effect 函数会依赖多个响应值,一个响应值会被多个 effect 函数所依赖。effect 函数 与 响应值之间的关系是「N:N」的关系。而这种关系是会随着程序的执行发生动态变化的 - 之前依赖的响应值,也许现在就不依赖了。又或者添加之间没有的依赖项。就目前而言,我们还没实现依赖管理的动态化。回到本示例中,在

setFlag(false);
调用之前,我们的 totalEffect 是依赖两个响应值
count1
count2
。而在此之后,实际上它只依赖
count1
。但是,从第 5 次的打印来看,
setCount2(2)
还是通知到了
totalEffect()
。实际上,因为我
totalEffect()
并没有使用
count2
了,所以,我并不需要对
count2
值的改变做出响应。

那我们该如何实现 effect 函数跟响应值依赖关系的动态化管理呢?基本思路就是:我们需要在 effect 函数执行之前,先清空之前的依赖关系。然后,在本次执行完毕,构建一个新的依赖关系图。

就目前而言,某个响应值被哪些 effect 函数所依赖,这个关系是在创建响应值时候所闭包住的

subscriptions
数组中体现的。而一个 effect 函数所依赖了哪些响应值,这个依赖关系并没有数据结构来体现。所以,我们得先实现这个。我们要在创建 effect 的时候,为每一个 effect 函数创建一个与一一对应的依赖管理器,命名为
effectDependencyManager
:
function createEffect(fn, eqFn) {
  const effectDependencyManager = {
    dependencies: new Set(),
    run() {
      activeEffect = effectDependencyManager;
      fn(); // 执行的时候再重建新的依赖关系图
      activeEffect = null;
    }
  };
  effectDependencyManager.run();
}

然后在 effect 函数被收集到

subscriptions
数组的时候,也要把
subscriptions
数组放到
effectDependencyManager.dependencies
数组里面,以便于当 effect 函数不依赖某个响应值的时候,也能从该响应值的
subscriptions
数组反向找到自己,然后删除自己。
function createSignal(value, eqFn) {
  const subscriptions = new Set();
  const getter = () => {
    if (activeEffect) {
      activeEffect.dependencies.add(subscriptions);
      subscriptions.add(activeEffect);
    }
    return value;
  };
  // ......省略其他代码
}

上面已经提到了,为了动态更新一个 effect 函数跟其他响应值的依赖关系,我们需要在它的每个次执行前「先清除所有的依赖关系,然后再重新构建新的依赖图」。现在,就差「清除 effect 函数所有的依赖关系」这一步了。为了实现这一步,我们要实现一个

cleanup()
函数:
function cleanup(effectDependencyManager) {
  const deps = effectDependencyManager.dependencies;
  deps.forEach(sub=> sub.delete(effectDependencyManager))
  effectDependencyManager.dependencies = new Set();
 }

上面的代码意图已经很明确了。

cleanup()
函数要实现的就是遍历 effect 函数上一轮所依赖的响应值,然后从响应值的
subscriptions
数组中把自己删除掉。最后,清空
effectDependencyManager.dependencies
数组。

最后,我们在 effect 函数调用之前,调用一下这个

cleanup()
function createEffect(fn, eqFn) {
  const effectDependencyManager = {
    dependencies: [],
    run() {
      cleanup(effectDependencyManager);
      activeEffect = effectDependencyManager;
      fn(); // 执行的时候再重建新的依赖关系图
      activeEffect = null;
    }
  };
  effectDependencyManager.run();
}

我们再拿代码片段4来测试一下,现在的打印结果应该是符合我们得预期了 - 当我们调用

setFlag(false);
之后,我们实现了 totalEffect 的依赖关系图的动态更新。在新的依赖关系图中,我们已经不依赖响应值
count2
了。所以,当
count2
的值发生改变后,totalEffect 函数也不会被重新执行。

修复功能回退

当前,我们引入了新的数据结构

effectDependencyManager
。这会导致我们之前所已经实现的某个功能被回退掉了。哪个呢?答案是:“同一个 effect 函数实例不被重复入队”。

为什么?因为,现在我们添加到

subscriptions
集合的元素不再是用户传递进来的 effect 函数,而是经过我们包装后的依赖管理器
effectDependencyManager
。而这个依赖管理器每次在用户在调用
createEffect()
的时候都生成一个新的实例。这就导致了之前利用 Set 集合的天生去重能力就丧失掉了。所以,接下来,我们需要把这块的功能给补回来。首先,我们在
effectDependencyManager
身上新加一个属性,用它来保存用户传进来的函数实例引用:
function createEffect(fn) {
    const effectDependencyManager = {
        dependencies: new Set(),
        run() {
            // 在执行 effect 之前,清除上一次的依赖关系
            cleanup(effectDependencyManager);
            activeEffect = effectDependencyManager;
            // activeEffects.push(effectDependencyManager);
            fn();
            // 执行的时候再重建新的依赖关系图
            activeEffect = null;
        },
        origin: fn // 新增一行
    };
    effectDependencyManager.run();
}

其次,我们在把

effectDependencyManager
添加到响应值的
subscriptions
集合去之前,我们先做个手动的去重防守:
function createSignal(value, eqFn) {
    const subscriptions = new Set();
    const getter = ()=>{
        if (activeEffect) {
            const originEffects = []
            for (const effectManager of subscriptions) {
                originEffects.push(effectManager.origin)
            }
            const hadSubscribed = originEffects.includes(activeEffect.origin)
            if (!hadSubscribed) {
                activeEffect.dependencies.add(subscriptions);
                subscriptions.add(activeEffect);
            }
        }
        return value;
    }
    // ...省略其他代码
    return [getter, setter];
}

至此,我们把丢失的「同一个 effect 函数实例不被重复入队」功能补回来了。

附加特性

支持基于旧值来产生新值

换句话说,我们需要支持用户向响应值的 setter 传入函数来访问旧值,然后计算出要设置的值。用代码来说,即支持下面的 API 语法:

const [count1, setCount1] = createSignal(0);
setCount1(c=> c + 1);

实现这个特性很简单,我们判断用户传进来的

nextValue
值的类型,区别处理即可:
function createSignal(value, eqFn) {
    // ......省略其他代码
    const setter = (nextValue)=>{
        nextValue = typeof nextValue === 'function' ? nextValue(value) : nextValue;// 新增一行
        let isChange;
        if (typeof eqFn === 'function') {
            isChange = !eqFn(value, nextValue);
        } else {
            isChange = nextValue !== value
        }
        if (isChange) {
            value = nextValue;
            [...subscriptions].forEach(sub=>sub.run())
        }
    };
    return [getter, setter];
}

派生值/计算属性

计算属性(computed)也有很多叫法,它还可以称之为:

  • Derivations

  • Memos

  • pure computed

在这里我们沿用 solidjs 的叫法:

memo
。 这是一个很常见和广为接受的概念了。在这,我们一并实现它。其实,在我们当前这个框架上实现这个特性是比较简单的 - 本质上是对
createEffect
函数的二次封装:
function createMemo(fn){
    const [result, setResult] = createSingal();
    createEffect(()=> {
        setResult(fn())
    });
    return result;
}

你可以用下面的代码去测试一下:

const [count1, setCount1] = createSignal(0);
const [count2, setCount2] = createSignal(0);
const total = createMemo(() => count1() + count2());
createEffect(()=> {
  console.log(`total: ${total()}`)
});
// total: 0
setCount1(1);
// total: 1
setCount2(100);
// total: 101
关闭

用微信“扫一扫”