阅读视图

发现新文章,点击刷新页面。

花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案

花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案

我知道「React 孝子」很多,先别急着骂,看完再说

当你还在 useState 的闭包陷阱里跟 React 斗智斗勇,在 useEffect 依赖数组里当人肉编译器,在 Zustand 的 selector 里写到怀疑人生 —— 哥们,该换个活法了。

Signal,来自 Preact,本质就是 Vue 那套响应式的 React 版:对象引用读写,依赖自动追踪,不用你手动喂。接入 @preact/signals-react 之后你就会发现,原来「现代化」三个字可以这么写,而不是靠 React 那套「设计哲学」自嗨。


我知道「React 孝子」很多,每次讨论时他们总搬出两套话:

  1. 「React 手动挡、Vue 自动挡,老司机都是手动挡。」
    纯纯的逻辑谬误。开车的手动/自动和写代码的「要不要亲手管依赖」根本不是同一回事;把「控制欲」包装成「专业」是偷换概念。这比喻的荒谬程度,不亚于班主任那句「一个人浪费一分钟,全班四十个人就浪费四十分钟」 —— 时间不能那样线性叠给全班,框架优劣也不能用「手动/自动」一个轴判死刑。

  2. 「既然你这么讨厌 React,为什么还要用?」
    因为出现的早、生态繁荣,所以你必然要接触到。任何第三方前端库,第一个兼容目标都是 React; AI First 时代更狠

这些 AI 工具一打开,全是 React。用 React 不等于认同它每一处设计 —— 历史包袱和网络效应摆在那儿,换框架成本极高,而 AI 生成代码几乎全是 React,正反馈循环只会越滚越大。所以「讨厌」和「在用」可以同时成立,不矛盾。


不可否认的是,JSX 是划时代的

React 真正贡献的是:组件化、声明式 UI、单向数据流成了行业共识,后面 Vue、Svelte 的模板或语法糖,都是在「React 确立的范式」上做改进。

我写这篇不是为了当传教士,也不想一味骂街 —— 我只想安安静静分享自己的心得,讲述如何解决这些问题,顺便帮到被闭包和依赖数组折磨的人。

批评 useState 的闭包和 useEffect 的依赖数组,不等于否定整个 React;而是说这一块设计得反人类,值得用 Signal 之类的方式补上。


而且关于 React、Vue 我是有发言权的,两边源码都翻过一些,React 的很多坑我都自己填过

不是晒仓库,而是说明:我既在 React 里干活,又亲手绕过它的坑。所以下面聊 Signal 和状态管理,是在 React 生态里怎么活得更像人的实操结论,不是跟风捧新玩意儿,也不是键盘党嘴炮。


一、闭包陷阱:React 的「设计哲学」有多可笑

useState 有个祖传问题:setState 是异步的,你刚 set 完,下一秒换个函数读,拿到的还是旧值。逻辑一拆分,后面的函数永远活在「上一个渲染周期」的梦里。

// 问题:fn2 拿不到最新 count
const [count, setCount] = useState(0)

const fn1 = () => {
  setCount(count + 1)
  fn2() // count 仍是旧值,惊不惊喜?
}
const fn2 = () => {
  console.log(count) // 0,意不意外?
}

const handleXx = () => {
  fn1()
  fn2() // 这辈子都拿不到最新值
}

React 官方会说:这是「调度」「可预测性」「避免半成品 UI」 —— 翻译成人话就是:我让你拿不到你就是拿不到,你得按我的规矩来。一旦逻辑拆分、跨模块复用,你就得跟闭包斗智斗勇,写一堆 useCallbackuseRef 来擦屁股。这叫设计哲学?这叫甩锅给开发者。

Signal 不跟你玩这套。状态放在对象里,.value 读就是当前值,写就是立刻生效。没有快照,没有「下一次渲染才更新」,读到的永远是实时的。

import { signal } from '@preact/signals-react'

const count = signal(0)

const fn1 = () => {
  count.value += 1
  fn2() // 此时 count.value 已经是 1 了
}
const fn2 = () => {
  console.log(count.value) // 1,终于像个正常人该有的行为
}

暂时还得用 useState?行,用 useGetState 救个急,setCount.getLatest() 直接拿最新值,不用再传什么回调了。

// 救急方案:useGetState
import { useGetState } from 'hooks'

const [count, setCount] = useGetState(0)
const fn2 = () => {
  console.log(setCount.getLatest()) // 1
}

源码:github.com/beixiyo/rea…


二、useEffect 依赖数组:人肉编译器,你当定了

useEffect 的依赖数组,堪称 React 开发者的噩梦:漏写一个,闭包拿旧值;

多写一个,effect 跑成陀螺。复杂对象还得自己 useMemo 包一层,不然每次都是「新引用」,依赖数组形同虚设。

Signal 的 effectuseSignalEffect 直接自动追踪你在回调里读了哪些 signal,变了才跑。跟 Vue 的 watchEffect 一样,该有的智商它都有。

import { effect, signal } from '@preact/signals-react'

const count = signal(0)
const name = signal('Jane')

effect(() => {
  console.log(count.value, name.value)
  return () => console.log('cleanup')
})

不用写依赖数组,不用纠结「这个到底该不该塞进 deps」,不用当人肉依赖分析器。省下来的脑子,干点别的不好吗?


三、Signal API 简洁,没有废话

Signal 的 API 就四个:

  • signal(initial):建状态
  • computed(fn):派生
  • effect(fn):副作用
  • batch(fn):批量写,effect 只跑一次

没有 Action、Reducer、Slice,没有 Store 配置,没有中间件链。想用就写,写完就算。相比之下,下面那些「当红」库,个个都是模板代码生产器。

1. 渲染优化:直接传 signal 到 JSX,跳过组件重渲染

这是 Signal 最香的用法之一。count.value 会建立订阅,signal 一变组件就重渲染;但直接把 signal 丢进 JSX,Preact 会绑定到 DOM 文本节点,更新时只改 DOM,不触发组件重渲染。

const countOptimized = signal(0)

// ❌ 未优化:读 .value 会订阅,每次变化都重渲染
const UnoptimizedDisplay = memo(() => {
  useSignals()
  return <strong>{ countOptimized.value }</strong>  // 变色 = 重渲染
})

// ✅ 优化:直接传 signal,跳过 VDOM,只更新 DOM 文本
const OptimizedDisplay = memo(() => {
  useSignals()
  return (
    <strong><>{ countOptimized }</></strong>  // 不变色 = 无重渲染
  )
})

PixPin_2026-02-02_10-03-36.webp

点击 +1 时,未优化侧背景色会变(重渲染),优化侧可能不变色(直接改 DOM)。源码见 github.com/beixiyo/rea…

2. computed:派生状态,自动缓存

import { signal, computed } from '@preact/signals-react'

const count = signal(0)
const doubled = computed(() => count.value * 2)

// doubled.value 随 count 变,依赖自动追踪,不用写 useMemo 依赖数组

3. signals 原生 effect(推荐、最省脑)

import { signal, effect } from '@preact/signals-react'

export const count = signal(0)
export const doubled = signal(0)

// ✅ 自动依赖收集,不写依赖数组
effect(() => {
  doubled.value = count.value * 2
})

特点一句话说明:

  • 依赖是谁,运行时自动追踪
  • 读了 count.value,就只依赖 count
  • 重构安全,删代码=删依赖

4. Hook 版:useSignalEffect(组件内用)

适合必须写在组件里的副作用(比如依赖 props / 生命周期)。

import { signal, useSignalEffect } from '@preact/signals-react'

const count = signal(0)

export function Counter() {
  // ✅ 和 effect 一样:不用依赖数组
  useSignalEffect(() => {
    console.log('count changed:', count.value)
  })

  return (
    <button onClick={() => count.value++}>
      +1
    </button>
  )
}

你可以把它理解为:

useEffect + 自动依赖收集 + 无 deps

5. 对照:React useEffect 等价写法(反例)

useEffect(() => {
  console.log('count changed:', count)
}, [count]) // ❌ 人肉维护依赖

问题不在“能不能用”,而在:

  • 依赖要人想
  • 重构容易漏
  • 逻辑一复杂就开始糊 deps

在线体验

这是我部署的 Signal 示例 Demo,里面涵盖了几乎所有 API 用法,我就不啰嗦了,需要可以在线体验。

image.png

如果背景色变化代表重新渲染了


四、其他状态管理库:一个比一个离谱

1. Zustand:Selector 写到手酸,中间件叠成屎山

github.com/pmndrs/zust…

Zustand 本身不算复杂,但要按需订阅避免多余渲染?对不起,每个字段自己写 selector 去吧:

const useCount = () => useStore(state => state.count)
const useName = () => useStore(state => state.name)
// 字段一多,selector 写到腱鞘炎

更离谱的是中间件。persist、devtools、immer 一层套一层,套完就是这坨:

export const useCounterStore = create<typeof initState>()(immer(
  devtools(
    persist(
      () => initState,
      {
        name: 'counter',
        storage: createJSONStorage(() => sessionStorage),
        partialize: state => Object.fromEntries(
          Object.entries(state).filter(([key]) => !key.startsWith('user'))
        ),
      }
    ),
    { enabled: true }
  )
))

你觉得还能看?那是因为我特意格式化过了。真实项目里中间件一多,括号套括号,缩进套缩进,可读性直接归零。

这种层层嵌套的写法,纯纯一坨。你能确保每个同事代码都像我一样写得这么「讲究」?不能的话,这就是定时炸弹。


2. Jotai:原子化挺好,但 useAtom 要写吐了

github.com/pmndrs/jota…

Jotai 的原子化思路我认可,细粒度订阅、按需更新,没问题。但原版用法有个致命问题:每个 atom 都得单独 useAtom / useAtomValue,组件顶上一排 hook,字段一多直接爆炸。

const [count, setCount] = useAtom(countAtom)
const [name, setName] = useAtom(nameAtom)
const [age, setAge] = useAtom(ageAtom)
// 再来十个字段?继续往上叠呗

而且为了性能你得保证原子性,基础属性都得拆成独立 atom,每个组件每个属性都得来一遍 useAtom,繁琐到令人发指。

解决办法

我自己封装了 jotaiTool github.com/beixiyo/rea…

通过 createUseAtoms 传入 atom 对象,自动生成 useAtomsgetAtomsuseResetcreateReset 等,按需订阅、类型安全、组件外也能读写。算是给 Jotai 提供了点语法糖。

实现原理是通过 Proxy 返回,并且确保细粒度订阅和类型安全,支持 Reset 等高级特性。所有功能均以测试

我部属到了 CloudFlare,在线体验到 react-tool-70q.pages.dev/jotaiTest

如果背景色变化代表重新渲染了

image.png

示例:定义 atom 对象后调用 createUseAtoms,在组件里用 useAtoms() 拿到一个代理对象,直接读属性、写属性或调 setXxx,下划线开头的 key 会被自动过滤。

image.png

更多用法(含按需 selector、useReset、createReset)见仓库内 github.com/beixiyo/rea…

即便如此,你还是得理解 atom、selector、store 这一堆概念。Signal 呢?一个对象,.value 读写,完事。


3. Recoil:Jotai 的低配版,还要自己写 key

recoiljs.org/docs/introd…

和 Jotai 思路差不多,但每个 atom 都得手写唯一 key,既啰嗦又容易冲突。Recoil 还要你自己想字符串。太蠢了,不说了。


4. Valtio:曾经的最爱,但是有些问题无法解决

valtio.dev/

Valtio 用起来是真的爽,proxy 一包,改属性自动更新,Vue 那味。但有两个硬伤直接劝退:

  1. snap 返回 readonly,类型别扭得要死,和「改完直接用」的习惯完全不搭,类型安全天天跟你打架。
  2. input 组件有 bug,必须开 sync 模式才能正常用。底层和 React 的批量更新八字不合,这都能出问题,我也是服了。

5. Redux:沉浸式屎山,LSP 都救不了

redux.js.org/

Redux 的「单向数据流」「可预测」「规范化」 —— 翻译过来就是:写一堆 Action、Reducer、Slice、Middleware,模板代码堆成山。实现成本高到离谱,还衍生出了 Redux Toolkit 这个「简化版」屎山。懂的都懂。

最离谱的是找代码。你想找「用户列表」的数据定义在哪?Ctrl+点击、F12 跳转、LSP 智能导航 —— 通通没用。你只能看到一堆 Action、Reducer、Slice,真正的数据定义藏得跟谍战片一样。写这个库的人是不是不知道什么叫 LSP? 找代码纯靠全局搜索是吧?什么年代了,开发体验还停留在文本搜索时代,真是没救了。


五、React:2026 年了,还缺这缺那

React 19.2 终于上了 Keep-Alive,这么多年来难得干了件人事。但问题是:2025 年才上。这么基础的能力,社区自己 hack 了多少年?现在才官方支持,早干嘛去了。

目前 React 生态还缺啥:

  1. 官方好用的状态管理:Signal 这种「对象引用 + 自动依赖」才是现代前端的该有的样子。useState 的闭包陷阱、useEffect 的依赖数组,早该被扫进历史垃圾堆了。别让开发者再写屎山了。

  2. 官方 Router:第三方路由全都没 Keep-Alive。复杂应用只能自己造轮子,恶心到家了。所以我只能自己写一个 github.com/beixiyo/rea…

    内置页面缓存(Keep-Alive),LRU 策略 + include/exclude 白名单,再也不用切个 Tab 回来表单全丢。顺带还有全局守卫 beforeEach/afterEach、Vue 风格中间件、全局 navigate/replace/back,API 简洁无废话。

代码示例:github.com/beixiyo/rea…

import { lazy } from 'react'
import { RouterProvider, createBrowserRouter } from '@jl-org/react-router'

const router = createBrowserRouter({
  routes: [
    { path: '/', component: lazy(() => import('./views/home')) },
    {
      path: '/dashboard',
      component: lazy(() => import('./views/dashboard')),
      meta: { title: 'Dashboard', requiresAuth: true },
    },
    { path: '/list', component: lazy(() => import('./views/list')) },
  ],
  options: {
    // 页面缓存:只缓存指定路径,最多 5 个页面,LRU 淘汰
    cache: {
      limit: 5,
      include: ['/', '/dashboard', '/list'],  // 白名单,也可用 RegExp
    },
    beforeEach: async (to, _from, next) => {
      if (to.meta?.requiresAuth && !getUser()) {
        next('/login')
        return
      }
      next()
    },
    afterEach: (to) => {
      document.title = (typeof to.meta?.title === 'string' ? to.meta.title : 'App')
    },
  },
})
  1. 官方动画方案:还好有 Framer Motion 兜底。没有 Motion,有几个人知道 React 卸载动画咋写?官方文档有教吗?没有。全靠社区自救。

六、Signal 使用指南:安装与 Babel

1. 安装

pnpm i @preact/signals-react

跑业务用这四个 API 就够了:signalcomputedeffectbatch。若想少写一层订阅代码(见下文),再装 Babel 插件:

pnpm i @preact/signals-react-transform -D

2. Babel 插件:做了什么、和 React Compiler 冲突、不用时怎么写

Babel 做了什么

@preact/signals-react-transform 会在编译阶段扫描组件内对 signal.value读取,自动插入「订阅」逻辑。结果是:在 JSX 里写 count.value 时,不用 在组件里再调 useSignals(),组件也会在 signal 变化时正确重渲染。
换句话说,Babel 帮你把「谁在用这个 signal」分析好了,并注入订阅,所以你可以直接写:

const count = signal(0)
function Counter() {
  return <p>{count.value}</p>   // 不用 useSignals(),照样响应更新
}

官方文档是这么描述规则的:

  • 函数是组件吗?

  • 如果是的话,这个组件会使用信号吗?

  • 如果一个函数名称大写(例如函数 MyComponent() {})且包含 JSX,则称该函数为组件。

  • 如果函数的主体包含一个成员表达式引用 .value(即某某的值 ),我们假设它是信号。

如果你的函数/组件符合这些条件,这个插件会对它进行转换。

如果没有,就会被放任不管。

如果你有一个函数使用信号但不符合这些条件(例如手动调用 createElement 而不是使用 JSX),你可以添加带有字符串 @useSignals 的注释,指示该插件转换该函数。

你也可以手动选择不转换函数,方法是添加带有字符串 @noUseSignals 的注释。


和 React Compiler 冲突

同一份文件里,不能 同时启用 signals-react-transformbabel-plugin-react-compiler,否则响应式会乱(见 preactjs/signals#652)。

至于这个 issue 我是怎么找的,那当然不是人肉搜索,而是靠 AI + Bash + CLI

比如 Github 提供了 MCPgh CLI(Github CLI) 命令行工具,可以让你查找代码和 issue 等,下面再介绍如何使用 gh,这里先看效果

ai2.png

解决做法是按路径分流:例如只对 views/signals/ 下的文件用 transform,其它文件只用 react-compiler:

// vite.config.ts
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    react({
      babel: (id) => {
        const isSignals = /[\\/]views[\\/]signals[\\/]/.test(id)
        return {
          plugins: isSignals
            ? [['module:@preact/signals-react-transform']]
            : ['babel-plugin-react-compiler'],
        }
      },
    }),
  ],
})

不用 Babel 时代码怎么写

不装、或不用 signals-react-transform 时,在消费 signal 的组件里必须显式调用 useSignals(),否则读 count.value 不会建立订阅,界面不会更新:

import { useSignals } from '@preact/signals-react/runtime'
import { signal } from '@preact/signals-react'

const count = signal(0)

function Counter() {
  useSignals()   // 必须写:让组件订阅用到的 signal
  return <p>Value: {count.value}</p>
}

同时用 Signals 和 React Compiler 时,也只能用这种「手写 useSignals()」的方式,不能在同一文件上开 Babel transform。


七、gh CLI(Github CLI),让你的 AI 掌握整个 Github

GitHub CLI 是一个命令行工具,用于在终端中直接与 GitHub 交互。适合让 LLM 通过命令行查阅和阅读仓库内容,节省 MCP Token 消耗。

安装

# Windows (使用 winget 或 scoop)
# 或者 Github Releases 下载
winget install --id GitHub.cli
# 或
scoop install gh

# macOS
brew install gh

# Linux
# 参考:https://github.com/cli/cli/blob/trunk/docs/install_linux.md#debian
(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& sudo mkdir -p -m 755 /etc/apt/sources.list.d \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh -y

身份验证:

gh auth login              # 交互式登录
gh auth status             # 查看认证状态
gh auth refresh            # 刷新 token

仓库信息

# 建议优先使用 JSON 输出,避免 README 导致输出过长
gh repo view {owner}/{repo} --json name,description,primaryLanguage,stargazerCount,url

文件内容(最常用)

# 读取文件内容(需解码)
gh api "repos/{owner}/{repo}/contents/{path/to/file}" --jq '.content' | base64 -d

# 读取 README
gh api "repos/{owner}/{repo}/readme" --jq '.content' | base64 -d

# 列出目录(仅显示名称,节省 Token)
gh api "repos/{owner}/{repo}/contents/{path}" --jq '.[].name'

分支和提交

# 列出所有分支名称
gh api "repos/{owner}/{repo}/branches" --jq '.[].name'

# 获取最近 3 条提交记录(格式化输出)
gh api "repos/{owner}/{repo}/commits?per_page=3" --jq '.[] | {sha: .sha[0:7], message: .commit.message, author: .commit.author.name}'

Issue 和 PR

# 列表显示(带限制)
gh issue list --repo {owner}/{repo} --limit 5
gh pr list --repo {owner}/{repo} --limit 5

# 搜索特定关键词的 Issue
gh issue list --repo {owner}/{repo} --search "{keyword}" --limit 5

# 获取 PR 详情(JSON)
gh api "repos/{owner}/{repo}/pulls/{number}" --jq '{title, body, state, html_url}'

通用 API

gh api {endpoint} --jq '.field'              # 任意 API + jq 过滤
gh api -X POST {endpoint} -f key=value       # POST 请求

使用场景

  1. 快速查阅仓库结构:先用 gh repo view --json ... 了解概况,再用 gh api .../contents --jq '.[].name' 浏览根目录
  2. 深度查看代码:定位文件后,使用 gh api .../contents/path --jq '.content' | base64 -d 读取
  3. 排查历史:使用 gh api .../commits?per_page=5 查看最近变更
❌