花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案
我知道「React 孝子」很多,先别急着骂,看完再说
当你还在 useState 的闭包陷阱里跟 React 斗智斗勇,在 useEffect 依赖数组里当人肉编译器,在 Zustand 的 selector 里写到怀疑人生 —— 哥们,该换个活法了。
Signal,来自 Preact,本质就是 Vue 那套响应式的 React 版:对象引用读写,依赖自动追踪,不用你手动喂。接入 @preact/signals-react 之后你就会发现,原来「现代化」三个字可以这么写,而不是靠 React 那套「设计哲学」自嗨。
我知道「React 孝子」很多,每次讨论时他们总搬出两套话:
-
「React 手动挡、Vue 自动挡,老司机都是手动挡。」
纯纯的逻辑谬误。开车的手动/自动和写代码的「要不要亲手管依赖」根本不是同一回事;把「控制欲」包装成「专业」是偷换概念。这比喻的荒谬程度,不亚于班主任那句「一个人浪费一分钟,全班四十个人就浪费四十分钟」 —— 时间不能那样线性叠给全班,框架优劣也不能用「手动/自动」一个轴判死刑。
-
「既然你这么讨厌 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」 —— 翻译成人话就是:我让你拿不到你就是拿不到,你得按我的规矩来。一旦逻辑拆分、跨模块复用,你就得跟闭包斗智斗勇,写一堆 useCallback、useRef 来擦屁股。这叫设计哲学?这叫甩锅给开发者。
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 的 effect 和 useSignalEffect 直接自动追踪你在回调里读了哪些 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> // 不变色 = 无重渲染
)
})

点击 +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 用法,我就不啰嗦了,需要可以在线体验。

如果背景色变化代表重新渲染了
四、其他状态管理库:一个比一个离谱
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 对象,自动生成 useAtoms、getAtoms、useReset、createReset 等,按需订阅、类型安全、组件外也能读写。算是给 Jotai 提供了点语法糖。
实现原理是通过 Proxy 返回,并且确保细粒度订阅和类型安全,支持 Reset 等高级特性。所有功能均以测试
我部属到了 CloudFlare,在线体验到 react-tool-70q.pages.dev/jotaiTest
如果背景色变化代表重新渲染了

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

更多用法(含按需 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 那味。但有两个硬伤直接劝退:
-
snap 返回 readonly,类型别扭得要死,和「改完直接用」的习惯完全不搭,类型安全天天跟你打架。
-
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 生态还缺啥:
-
官方好用的状态管理:Signal 这种「对象引用 + 自动依赖」才是现代前端的该有的样子。useState 的闭包陷阱、useEffect 的依赖数组,早该被扫进历史垃圾堆了。别让开发者再写屎山了。
-
官方 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')
},
},
})
-
官方动画方案:还好有 Framer Motion 兜底。没有 Motion,有几个人知道 React 卸载动画咋写?官方文档有教吗?没有。全靠社区自救。
六、Signal 使用指南:安装与 Babel
1. 安装
pnpm i @preact/signals-react
跑业务用这四个 API 就够了:signal、computed、effect、batch。若想少写一层订阅代码(见下文),再装 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(),照样响应更新
}
官方文档是这么描述规则的:
如果你的函数/组件符合这些条件,这个插件会对它进行转换。
如果没有,就会被放任不管。
如果你有一个函数使用信号但不符合这些条件(例如手动调用 createElement 而不是使用 JSX),你可以添加带有字符串 @useSignals 的注释,指示该插件转换该函数。
你也可以手动选择不转换函数,方法是添加带有字符串 @noUseSignals 的注释。
和 React Compiler 冲突
同一份文件里,不能 同时启用 signals-react-transform 和 babel-plugin-react-compiler,否则响应式会乱(见 preactjs/signals#652)。
至于这个 issue 我是怎么找的,那当然不是人肉搜索,而是靠 AI + Bash + CLI
比如 Github 提供了 MCP 和 gh CLI(Github CLI) 命令行工具,可以让你查找代码和 issue 等,下面再介绍如何使用 gh,这里先看效果

解决做法是按路径分流:例如只对 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 请求
使用场景
-
快速查阅仓库结构:先用
gh repo view --json ... 了解概况,再用 gh api .../contents --jq '.[].name' 浏览根目录
-
深度查看代码:定位文件后,使用
gh api .../contents/path --jq '.content' | base64 -d 读取
-
排查历史:使用
gh api .../commits?per_page=5 查看最近变更