你踩过 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.

相关推荐
树上有只程序猿21 分钟前
后端思维之高并发处理方案
前端
庸俗今天不摸鱼1 小时前
【万字总结】前端全方位性能优化指南(十)——自适应优化系统、遗传算法调参、Service Worker智能降级方案
前端·性能优化·webassembly
黄毛火烧雪下1 小时前
React Context API 用于在组件树中共享全局状态
前端·javascript·react.js
Apifox1 小时前
如何在 Apifox 中通过 CLI 运行包含云端数据库连接配置的测试场景
前端·后端·程序员
一张假钞1 小时前
Firefox默认在新标签页打开收藏栏链接
前端·firefox
高达可以过山车不行1 小时前
Firefox账号同步书签不一致(火狐浏览器书签同步不一致)
前端·firefox
m0_593758101 小时前
firefox 136.0.4版本离线安装MarkDown插件
前端·firefox
掘金一周1 小时前
金石焕新程 >> 瓜分万元现金大奖征文活动即将回归 | 掘金一周 4.3
前端·人工智能·后端
三翼鸟数字化技术团队2 小时前
Vue自定义指令最佳实践教程
前端·vue.js
Jasmin Tin Wei2 小时前
蓝桥杯 web 学海无涯(axios、ecahrts)版本二
前端·蓝桥杯