JackWang

V1

2023/03/04阅读:20主题:默认主题

SolidJS 是如何实现响应式的?

作为现代前端框架中的一员,SolidJS 为开发者提供了极细粒度的响应式系统。

在我看来,响应式系统是现代前端框架的基础,没有响应式系统,意味着开发者需要花费额外的精力去处理数据的读写,而不能将有限的精力集中在视图本身。

本文将深入讲解 SolidJS 中实现响应式系统的方式,带大家探索 SolidJS 的响应式系统实现,一起动手实现 SolidJS 的响应式系统。

学习路径

要想深入探索 SolidJS 的响应式系统实现,不可避免地需要克隆代码仓库到本地,从源码的角度来学习。但是大多数情况下,直接上手读源码是一件投入产出比非常低的事情,为了了解一个简单的知识点,通常需要花费几个星期甚至数月的时间。

事实上,源码阅读也符合帕累托法则(又称二八效应/犹太法则),意思就是源码中 80% 的代码是在做各种优化,亦或是处理各种边界情况,而真正实现 Feature 的核心代码往往只有 20% 甚至更少。

因此在本文中,我不会带大家直接上手源码,而是通过循序渐进的形式,从核心响应式原语的表现入手,一步一步带大家探索 SolidJS 的响应式系统实现,并抽丝剥茧,一起实现简易的 SolidJS 响应式系统。

核心响应式原语

Signals

Signals 是 SolidJS 的响应式系统中最主要的部分,通常由 gettersettervalue 组成。在学术上称其为 Signals,在一些框架中也被称为 Observables(MobX[1])、Atoms(Recoil[2])、Refs(Vue.js[3])等。

从 SolidJS 的官方文档中我们已经了解了 Signals 如何创建,如何获取 value 和修改 value

const [count, setCount] = createSignal(0);

console.log(count()); // 0

setCount(5);
console.log(count()); // 5

不难看出,核心是 createSignal,它创建了一个响应式数据,并返回了响应式数据的 gettersetter

现在一起动手来实现一下:

const createSignal = (value) => {
  // 获取 Signal value
  const getter = () => {
    return value;
  };
  // 修改 Signal value
  const setter = (newValue) => {
    value = newValue;
  };
  return [getter, setter];
};

非常简单,通过闭包将 value 保存,当调用 setter 修改 value 时,会修改闭包中的 value,当调用 getter 获取 value 时,又会返回闭包中的 value

但是只实现 Signals 的创建、获取和修改还不够,因为 gettersetter 在这里都是显式调用,要实现响应式还有一个非常关键的能力——通知依赖了 Signals 的地方更新 Signal value

要实现通知依赖了 Signals 的地方更新 Signal value,就涉及到 SolidJS 中的另一个核心响应式原语——Effects。

Effects

响应式数据创建出来是需要被消费的,而 Effects 的作用就是消费响应式数据,同时执行一些存在副作用的代码。在 SolidJS 和 Recoil[4] 中称其为 Effects,在一些框架中也被称为 Reactions/Autoruns(MobX[5])、Watches(Vue.js[6])等。

最经典的例子就是打日志:

const [count, setCount] = createSignal(0);

// 当 count 发生变化时
// 打印当前 count value
createEffect(() => {
  console.log('count is', count());
});

不难看出,核心是 createEffect,它创建了一个具有副作用的函数,当副作用函数中依赖的响应式数据发生变化时,自动执行副作用。

结合上文中提到的 Signals 的行为,是否有些熟悉?这就是观察者模式,响应式数据为被观察者,而副作用函数为观察者。

要实现 createEffect 的能力,不可避免地需要结合 createSignal 来实现,当 Signal getter 被触发时,将其所在的副作用函数存储起来,待 Signal setter 被触发后,将所有的副作用函数一次性都取出来并执行。

现在一起动手来实现一下:

const observers = [];

const getCurrentObserver = () => {
  return observers[observers.length - 1];
};

const createSignal = (value) => {
  const subscribers = new Set();
  // 获取 Signal value
  const getter = () => {
    // 获取此次 Signal getter 调用时的 Observer
    const currentObserver = getCurrentObserver();
    if (currentObserver) {
      // 如果此次 Signal getter 调用时的 Observer 存在
      // 则将其存储至当前 Signal 的 subscribers
      // 订阅当前 Signal 的变化
      subscribers.add(currentObserver);
    }
    return value;
  };
  // 修改 Signal value
  const setter = (newValue) => {
    value = newValue;
    // 将所有订阅了当前 Signal 变化的 Observer 一次性都取出来并执行
    subscribers.forEach((subscriber) => subscriber());
  };
  return [getter, setter];
};

const createEffect = (effect) => {
  const execute = () => {
    // 无论是否在副作用函数中调用了 Signal getter
    // 先假设副作用函数为某个 Signal 的 Observer
    // 将其存储至 observers 中
    observers.push(execute);
    try {
      // 执行副作用函数
      // 若副作用函数确实为某个 Signal 的 Observer(即副作用函数中调用了 Signal getter)
      // 则在 Signal getter 中会将 execute 存储至内部的 subscribers 中
      // 否则不执行任何操作
      effect();
    } finally {
      // 删除副作用函数
      observers.pop();
    }
  };
  // 副作用函数立即执行
  execute();
};

稍微有点复杂,纯粹使用文字和代码进行描述非常枯燥,因此我给大家做了一个动画,通过动画帮助大家来理解这个过程:

How does the SolidJS reactivity system work[7]

可能部分同学会发现了一个稍显奇怪的地方:为什么 Signal subscribers 使用的是 Set,而 observers 却是一个数组?

原因在于,每次将 Signal subscribers 取出来并执行的过程中会再一次触发 Signal getter,导致同一个 Observer 被第二次存储到 observers,然后被添加到 Signal subscribers 中。这个行为意味着每执行一个副作用函数,Signal subscribers 的长度就会翻倍一次,因此需要使用 Set 过滤掉相同的 Observer。

Memos

使用函数包裹 Signals 进行运算并返回的操作被称为 Signals 的派生:

const [count, setCount] = createSignal(0);
// double 为 count 的派生值
const double = () => count() * 2;

而 Memos 也属于 Signals 的派生,与直接使用函数包裹 Signals 进行运算不同,Memos 会将运算结果进行缓存,直到它所依赖的 Signals 更新。在 SolidJS 中称其为 Memos,在一些框架中也被称为 Computeds(MobX[8]/Vue.js[9])、Pure Computeds(KnockoutJS[10]) 等。

在生产环境中,往往有一些非常消耗性能的操作,会长时间占用主线程,导致应用卡顿。举个非常形象的例子:

function Counter() {
  const [count, setCount] = createSignal(10);
  const fib = () => {
    console.count('fibonacci run');
    return fibonacci(count());
  };

  createEffect(() => {
    Array(50)
      .fill(fib)
      .forEach((l) => l());
  });

  return (
    <div>
      <h2>You can see the logs in the console.</h2>
      <button onClick={() => setCount(count() + 1)}>count fibonacci</button>
    </div>
  );
}

不难看出,这段代码就是用来计算斐波那契数列的,如果直接使用函数包裹 count(),你会发现控制台日志中,fib 函数被调用了 50 次,且每次点击按钮都会再次被调用 50 次。

但是如果使用 createMemo 包裹,则 fib 函数只会被调用一次,且每次点击按钮也只会被调用一次。

现在一起动手来实现一下:

const createMemo = (memo) => {
  const [_value, _setValue] = createSignal();

  createEffect(() => {
    _setValue(memo());
  });

  return _value;
};

非常简单,仅仅只是 createSignalcreateEffect 的组合,为什么?

结合上文中提到的 Effects 的行为,当 Effects 所依赖的 Signals 更新时,Effects 会自动执行,而这个自动执行的行为也正是 Memos 所需要的,再创建一个内部的 Signals 对派生值进行缓存,就实现了 Memos 的效果。

但是,真正跟着我们动手操作的同学会发现一个问题:即使 Signal value 没有任何变化,仅仅只是调用了 Signal setter,Memos 也会重新执行一次。显然,这个行为是不符合预期的,那么问题出在哪呢?

回到上文中的 createSignal 中,不难发现,setter 函数无论传入什么值,都会通知所有的 Observer 更新。

既然如此,修改一下 setter 的逻辑,加入 newValuevalue 的比较逻辑,当 newValuevalue 的值不一致时才通知 Observers 更新:

const setter = (newValue) => {
  if (value !== newValue) {
    value = newValue;
    subscribers.forEach((subscriber) => subscriber());
  }
};

至此,Memos 的能力完美实现。

Playground

本文中涉及到的所有代码,均充分注释,并可直接在 StackBlitz 中在线编辑和预览:

implement-createsignal-by-myself[11]

总结

本文深入讲解了 SolidJS 中实现响应式的方式,带大家动手实现了 SolidJS 核心响应式原语 Signal、Memo、Effect,同时也抛出了一个观点:响应式是现代前端框架的基础。

为什么说响应式是现代前端框架的基础?

在我看来,前端开发的本质就是响应用户操作,这样的行为模式本质上与观察者模式/发布订阅模式十分相似,开发者会在页面上编写各种各样的监听器以响应用户操作。这意味着要想简单高效地开发,就需要一个响应式系统对页面数据做自动化处理,减少开发者分散在数据流控制上的精力,让开发者能够更加专注于用户体验本身。

拓展学习

在 SolidJS 早期的几个版本中,响应式系统直接基于 S.js 进行封装实现,感兴趣的同学可以尝试使用 S.js 构建一个具有响应式数据能力的原生 Web 应用。

adamhaile/S[12]

直到现在,SolidJS 的响应式系统中的核心代码仍然与 S.js 基本一致。

参考资料

响应式编程 - 维基百科,自由的百科[13]

响应式编程(Reactive Programming)介绍 - 知乎[14]

Finding Fine-Grained Reactive Programming - JavaScript inDepth[15]

A Hands-on Introduction to Fine-Grained Reactivity - DEV Community[16]

参考资料

[1]

MobX: https://mobx.js.org/observable-state.html

[2]

Recoil: https://recoiljs.org/docs/basic-tutorial/atoms

[3]

Vue.js: https://vuejs.org/guide/essentials/reactivity-fundamentals.html

[4]

Recoil: https://recoiljs.org/docs/guides/atom-effects

[5]

MobX: https://mobx.js.org/reactions.html

[6]

Vue.js: https://vuejs.org/guide/essentials/watchers.html

[7]

How does the SolidJS reactivity system work: https://bytedance.feishu.cn/space/api/box/stream/download/asynccode/?code=ZDFjMzA4ZGE4YzViY2IwNzNlZWZiMTJmN2E2YjJmMDNfSHZVYW5oVks1SlZucTVWNGxqZHRaVUNPS2Q3ajZxc01fVG9rZW46Ym94Y255YWJIWTBiYldBbWxKWTBVZDRyVUhmXzE2Nzc5NDE3NzY6MTY3Nzk0NTM3Nl9WNA

[8]

MobX: https://mobx.js.org/computeds.html

[9]

Vue.js: https://vuejs.org/guide/essentials/computed.html

[10]

KnockoutJS: https://knockoutjs.com/documentation/computed-pure.html

[11]

implement-createsignal-by-myself: https://stackblitz.com/edit/implement-createsignal-by-myself?file=src%2FCustom.jsx

[12]

adamhaile/S: https://github.com/adamhaile/S

[13]

响应式编程 - 维基百科,自由的百科: https://zh.m.wikipedia.org/zh-hans/%E5%93%8D%E5%BA%94%E5%BC%8F%E7%BC%96%E7%A8%8B

[14]

响应式编程(Reactive Programming)介绍 - 知乎: https://zhuanlan.zhihu.com/p/27678951

[15]

Finding Fine-Grained Reactive Programming - JavaScript inDepth: https://indepth.dev/posts/1269/finding-fine-grained-reactive-programming

[16]

A Hands-on Introduction to Fine-Grained Reactivity - DEV Community: https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf

分类:

前端

标签:

前端

作者介绍

JackWang
V1

公众号:J4ck W4ng @抖音