你踩过 react 生态的 signal 坑吗?且看 helux 如何应对

前言

helux 是一个集atomsignal依赖追踪为一体,支持细粒度响应式更新的状态引擎,兼容所有类 react 库,包括 react18。

本文将重点介绍react生态里各方signal的具体实现,并探讨背后隐藏的坑导致的可用性问题,同时也会给出heluxsignal实现的优势,并教会你使用signal 写出0 hookreact 状态组件。

signal 热潮

signal 在2023 年掀起了一股热潮,其实诸多框架很早就开始为其布局,Vue、Solid.js、Preact、Svelte 都在不同时的引入了signal,某种程度来说 vue 甚至可以算是 signal 的鼻祖,Angular作者发文Signal是前端框架的未来后,在2023年末的Angular 17里引入了signal实现,可见大家对signal是多么的盼望。

我们观察如下Angularsignal代码示例

ts 复制代码
import { signal, computed, effect } from '@angular/core';

export class SignalExample {
  count = signal(1);

  // Get (Same in Template || Typescript)
  getCount = () => this.count();

  // Setters
  reset = () => this.count.set(1);
  increment = () => this.count.update((c) => c + 1);

  // Computed
  doubled = computed(() => this.count() * 2);

  // Effects
  logCount = effect(() => console.log(this.doubled()));
}

发现保持了signal里通用概念,即状态修改派生副作用都存在,大家能很快上手,只是细节实现不一样,例如angular这里的修改基于setupdate,获取通过get函数。

接下来我们将只针对react生态里的signal揭示可能存在的使用问题。

不知angularsveltesolid是否存在如下问题,欢迎阅读本文的读者提出对应观点或给出示例。

signal 本质

为何前端对signal念念不忘,究其原因就是它可让视图的数据依赖可锁定到最小范围,以便实现点对点更新,即dom粒度的更新

既然要感知到视图的数据依赖,那么就存在依赖收集前置行为,而不是依赖比较后置行为。

上图里的浅灰色部分的compare deps即react.memo的比较函数

ts 复制代码
react.memo(Comp, ( prevProps, currProps )=> boolean)

白色部分的compare deps即现在signal里读取行为产生的值比较(读取行为让框架感知到了当前视图的依赖,下一刻可以用来做比较)

ts 复制代码
// 类似 solid 的设计
const [ val, setVal ] = createSignal(someVal);

// 视图调用 val()
<div>{val()}</div>

所以我们可将signal看做是一种支持依赖收集的包装数据,数据的读依赖可以和各种副作用绑定起来(例如render函数,派生函数,观察函数等),写行为则是找到这些副作用函数再去执行一遍。

哎喂?这不就是mobx或者vue一直都在做的事么,换了个名词signal再来一遍.....

react 里的 signal 三方实现

既然signal这么美好,为什么react不原生支持,这也是很多人质问的点,react核心团队的 Andrew Clark对此做了答复

我们可能会在 React 中添加一个类似 Signals 的基元,但我并不认为这是一个编写 UI 代码的好方法。它对性能来说是很好的。但我更喜欢 React 的模式,在这种模式下,你每次都会假装重新创建所有的内容。我们的计划是使用一个编译器来实现与之相当的性能

这里面还存在一个问题就是react内部接受的不可变数据,以便做高效的diff比较,而signal强调的数据数据可变,以便精确知道变化的细节,但其实这不是阻碍在react里实现signal的核心因素,因为我们只需要在可变mutable和不可变immutable之间搭一个桥梁,基于mutable库改写数据,生成快照数据喂给react就解决此问题了。

react保持克制只做自己核心的部分,是社区生态强大的重要原因,所以很多三方库开始为react带来signal体验,这里我们重点列举mobx-react@preact/signals-react两个库,来看看他们存在的问题。

mobx-react

注意,如果你认可上面总结的signal本质的话,mobx-react也算是一种signal的实现

是不是很意外,原来很早很早react生态里就有了 signal 实现,只是当时大家还没吹热 signal 这个概念。

mobx-react和现有signal库的差异点在于,在函数组件大行其道的当下,任然需要显式地突出观察节点,即包装普通组件为可观察组件,再配合钩子函数才能感知变化

javascript 复制代码
import { makeAutoObservable } from "mobx"
import { observer, useObservable } from "mobx-react-lite"

// 定义 store
class Store {
  count = 0

  constructor() {
    makeAutoObservable(this);
  }
  
  add(){
    this.count += 1
  },
}

// 实例化 store
const mySotre = new Store();

const App = observer(() => {
  // 使用 store
  const store = useObservable(mySotre);
  return <button onClick={() => ++store.count}>{store.count}</button>
});

示例参考自mobx官网react集成

这种使用体验落后于其他不需要显式突出观察,api完全走函数式风格的signal实现库。

preact/signals-react

preact 自身实现了 signal后,也发布了 @preact/signals-react 包来为react带来signal能力,但是当我认真体验后,唯一的感受是:这可能只适合写hello world.....

我们按照官网demo写出来一个简单的示例是这样的:

ts 复制代码
import { signal, computed } from "@preact/signals-react";

// 定义 signal
const count = signal(0);
// 派生 signal
const comput = computed(() => count.value * 5);
// 修改 signal
const increment = () => {
  count.value++;
};

export default function App() {
  return (
    <div className="App">
      <h1>Count -> {count}</h1>
      <h1>Commp -> {comput}</h1>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

跑起来的确没问题,但是我们的实际开发项目不可能是count这种简单结构数据,以及加和减这种逻辑。

坑1:没有可变修改

我稍微把数据调复杂一点,代码变为如下:

ts 复制代码
const user = signal({a:{b:1}, name: 'test'});
const comput = computed(() => user.v.a.b + 1);

const increment = () => {
    user.value.a.b++;
};

这样的代码就不能正常驱动视图重新渲染,所以我必须改为

diff 复制代码
user.value.a.b++;
+ user.value = { ...user.value };

坑2: 渲染粒度是信号本身

signals-react的渲染粒度是信号本身,即信号自身改变后,任何引用信号的组件都会被重写渲染,对于复杂对象也是一样的结果,就导致可能只改了对象的部分数据节点A,而只使用了部分数据节点B的组件也会被重渲染。

代码如下,每个组件可以包含了一个 <h3>{Date.now()}</h3>,如果变化了表示当前组件被触发重渲染了

示例代码见App3.tsx文件

ts 复制代码
import { signal } from "@preact/signals-react";

const person = signal({
  firstName: "John",
  lastName: "Doe",
});

function FirstName() {
  return (
    <div>
      <h1>firstName: {person.value.firstName}</h1>
      <h3>{Date.now()}</h3>
    </div>
  );
}

function LastName() {
  return (
    <div>
      <h1>lastName: {person.value.lastName}</h1>
      <h3>{Date.now()}</h3>
    </div>
  );
}

const change = (e) => {
  person.value.firstName = e.target.value;
  person.value = { ...person.value };
};

export default function App() {
  return (
    <div className="App">
      <FirstName />
      <LastName />
      <input onInput={change} />
    </div>
  );
}

输入input,渲染结果如下,我们只修改了firstName,结果FirstNameLastName组件均触发重渲染

坑3: 糟糕的细粒度更新开发体验

可是官网明明宣称支持细粒度更新的啊,为什么坑2 的示例触发了全部使用方渲染呢,经过仔细阅读官网,发现上述例子改为真正的细粒度渲染还需要进一步拆分signal,所以代码需要优化为signalsignal的模式

diff 复制代码
// signal 定义
const person = signal({
++  firstName: "John",
--  firstName: signal("John"),
++  lastName: "Doe",
--  lastName: signal("Doe"),
});

// 修改方式
-- const change = (e) => {
--  person.value.firstName = e.target.value;
--  person.value = { ...person.value };
--};
++ const change = (e) => {
++  person.value.firstName.value = e.target.value;
++};

// 组件使用
-- <p>{person.value.firstName}</p>
++ <p>{person.value.firstName.value}</p>

写完这样一段代码后,我的内心感受是这样的

如果我们真的在项目铺开来这样使用signal的话,将陷入signal拆分的海洋里,以及漫天.value的暴雨中....

helux 带你进入真正的signal世界

helux引入signal设计时,明确了要完成以下几个目标,才能让signalreact真正可用、好用。

纯粹的函数式

相比mobx-react使用class创建 store,helux彻底拥抱函数式,有更强的可组合性

ts 复制代码
import { atom, share, derive, watch, watchEffect } from 'helux';

// primitive atom
const [ numAtom, setNum ] = atom(1);
const result = derive(()=> numAtom.val + 1); // { val: 2 }
watch(()=> console.log(`numAtom changed`), ()=>[numAtom]);
// or
watchEffect(()=> console.log(`numAtom changed ${numAtom.val}`))
setNum(100); // 驱动 derive watch watchEffect

// object atom
const [ state, setState ] = share({a:1, b:2});
const result = derive(()=> state.a + 1);
watch(()=> console.log(`state.a changed`), ()=>[state.a]);
// or
watchEffect(()=> console.log(`state.a ${state.a}`))
setState(draft=>{draft.a=100}); // 驱动 derive watch watchEffect

足够简单的api

组件里使用 observer 配合 useObservable 才能驱动函数组件,只需要useAtom即可

ts 复制代码
function Demo1() {
  cosnt [ num ] = useAtom(numAtom); // 自动拆箱 { val: T }
  // or useAtom(state);
  return (
    <div>{num}</div>
  );
}

function Demo2() {
  const [ state ] = useAtom(state);
  return (
    <div>{state.a}</div>
  );
}

依赖是细粒度的,对象逐步展开依赖就会逐步缩小

less 复制代码
// 依赖仅仅是 state.a,只有 state.a 变化才会引起重渲染
<div>{state.a}</div>

如果不需要驱动这个组件重新渲染,只需要使用$符号包括即可,此时组件都不需要useAtom,实现 0 hook 编码。

javascript 复制代码
import { $ } from 'helux';

function Demo(){
  // 仅div内部这一小块区域会重渲染
  <div>{$(state.a)}</div>
}

没有任何心智负担的使用信号对象

我们可以把对象构建得足够复杂,也可以像使用原生json对象使用信号对象,不需要signalsignal的诡异操作。

ts 复制代码
atom(()=>({
	a:1,
	b: {  .... }
	c : { e: { list: [ ... ] } }
}));

强悍的依赖追踪性能

基于limu的强悍性能,可以无所畏惧的跟踪到任意深度节点的依赖,helux默认对数组只跟踪到下标位置,下标里的对象属性继续展开后的依赖就不再收集,避免超大数组产生过多的跟踪,但如果你愿意花费额外的内存空间收集所有子节点依赖,调整收集策略即可完成。

例如下面这个例子里,修改数组任意一个子节点的某个子属性,只会触发目标节点更新,甚至我们完全克隆了一份新的set回去也没有触发更新,因为helux内部在比较新值和上一刻的快照是否相等,相等则跳过更新。

示例见list依赖,本链接里还包含了其他简单示例

ts 复制代码
import React from "react";
import { sharex } from "helux";
import { MarkUpdate, Entry } from "./comps";

const stateFn = () => ({
  list: [
    { name: "one1", age: 1 },
    { name: "one2", age: 2 },
    { name: "one3", age: 3 },
  ],
});
// stopArrDep=false,支持数组子项依赖收集
const { useState, reactive } = sharex(stateFn, { stopArrDep: false });

// 更新其中一个子项,触发更新
function updateListItem() {
  reactive.list[1].name = `${Date.now()}`;
}

// 新克隆一个更新回去,不触发更新,因为具体值没变
function updateAllListItem() {
  reactive.list = JSON.parse(JSON.stringify(reactive.list));
}

// 新克隆一个并修改其中一个,仅触发list[0]更新
function updateAllListItemAndChangeOne() {
  const list = JSON.parse(JSON.stringify(reactive.list));
  list[1].name = `${Date.now()}`;
  reactive.list = list;
}

const ListItem = React.memo(
  function (props: any) {
    // arrDep = false 数组自身依赖忽略
    const [state, , info] = useState({ arrDep: false });
    return (
      <MarkUpdate info={info}>name: {state.list[props.idx].name}</MarkUpdate>
    );
  },
  () => true
);

function List() {
  const [state] = useState();
  return (
    <div>
      {state.list.map((item, idx) => (
        <ListItem key={idx} idx={idx} />
      ))}
    </div>
  );

更新效果如下,可观察色块的变化确定是否被更新

结语

其他框架的signal体验效果暂无验证,本文只针对react生态中的signal相关作品做对比,helux处处未体现signal,但当你使用atom创建出来共享转态时,处处都在收集读取行为产生的信号(derivewatchuseAtom), 这些都是内置的,你只管像操作普通json一样操作携带信号功能的atom共享状态即可,这或许才是我们再react世界里需要的面向开发者编码友好,面向用户运行高效的signal实现。

友链:

❤️ 你的小星星是我们开源最大的精神动力,欢迎关注以下项目:

helux 集atom、signal、依赖追踪为一体,支持细粒度响应更新的状态引擎

limu 最快的不可变数据js操作库.

hel-micro 工具链无关的运行时模块联邦sdk.

相关推荐
燃先生._.23 分钟前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖1 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235241 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240252 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar2 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人3 小时前
前端知识补充—CSS
前端·css
GISer_Jing3 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245523 小时前
吉利前端、AI面试
前端·面试·职场和发展
理想不理想v3 小时前
webpack最基础的配置
前端·webpack·node.js