跳到主要内容

What can I say?

· 阅读需 16 分钟
Mike

缺陷

react 是个好框架吗?无疑是的 但在我看来他有一些问题。 作为一个工具,他设置了太多限制,让使用者顾虑太多。 作为同行,同样是完成页面渲染的任务,vue、solidjs 等,不太需要使用者注意太多,定义好视图,注册好事件,在定义下状态,使用固定的生命周期规范即可,页面就如你所愿了; 但对 react 来说,没那么简单:

  • 你要按照我的规定定义状态
  • 我们是纯函数,生命周期是什么?
  • 你想读状态?先看文档吧
  • 你想改状态?不好意思,我不记得你改了两次
  • 你想在渲染时改状态?不好意思,有副作用
  • 等等

综上,如果是一个新接触的 react 使用者,一定会被这些奇怪的规范坑,当查文档、问 AI 后恍然大悟:原来都是 react 的设计在坑我 所以说,作为辛苦开发挣点血汗钱的小前端来说,居然在用一个带刺的犁耕地,时不时被扎一下,真是不爽 这一点来说 react 的设计无疑是费力的

根源: 技术大拿的自嗨 不知大家有没有这个经历,在我们刚刚学习前端的时候,碰到过自诩清高的人说 react 更好,其他框架是智力低的人学的 在讨论react 相关技术的博客上,“心智模型”、“代数效应”、“副作用”等词汇频繁出现,甚至是这辈子第一次听说 更明显的,在提出 react 缺点、框架比较的讨论上,一定有人站在 react 这一边人身攻击贬低别人 以上就说明一个问题,鄙视链 已经存在,而且拥护 react 的人已经站在了顶端,一个神已经诞生了,神是没有缺点的,只有人理解不了神 一旦你发现了什么,就是你水平低,你自己的问题

例子

我们先从最常用的举例

setState

在 函数组件时代,只能用 setState定义组建状态 但这个 setState 真是让人费解 setState 是异步的,set 后我想读取一下,抱歉,我还没来得及更新 react 说:这是「调度」「可预测性」「避免半成品 UI」 但我寻思我一个写代码的,刚 set 的再 get 怎么就不行了?这不是欺负老实人吗

你得 自己存储新的 state,使用时自己传过去,要么就等到下一周期

useEffect

这个 API 可谓是 react 的灵魂了 官方的定义是每次 render 后根据 deps 判断是否执行 也就是说他并非简单的生命周期的含义,怕我们不懂甚至还有专门的课程告诉我们什么时候不该用 但我想说,所谓的pure、hook 等概念让这个 api 成为了问题发生器 规范告诉我们用到的状态都要加到 deps 里,否则依赖收集不到下次 render 会有闭包陷阱 但需求告诉我我不能把用过的 state 加到 deps 里,因为一旦我加了,这个细枝末节的依赖会导致我的清理回调过早执行

肯定是我用的不对 记住这个典型场景的定义:我需要用到这个值,但不希望他的变化触发 effect 我们可以想到另一个 api:useRef useRef 突破了render 的限制,就像一个函数外的全局变量,恰好能满足需求

const useLastRef = (value) => {
const ref = useRef(value);
// 为什么有这个 effect,还是经典原因,react 不允许在 render 阶段进行有副作用的更新,即 render 阶段不要给任何 react 相关变量赋值,有副作用的操作都要在 useEffect 里进行且要加上依赖
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}

const useLastCallback = (value) => {
const ref = useLastRef(value);
return useCallback((...args) => ref.current(...args), [ref]);
}

function Comp({date}) => {
const lastDate = useLastRef(date);
// 例如 我们只是要在这个组件的初始化时同时初始化一个连接,假如父组件传入一个每秒更新的 date,那么显然不可能把 date 加入依赖数组导致每一秒初始化一下连接
useEffect(() => {
connect.then((res) => message.success('connect success date is ' + lastDate.current'))
return () => {
disconnect();
}
}, []);
}

在 react 19 后,新增了一个 API:useEffectEvent

还有就是官方告诉我们,不是任何关于状态的操作都要使用 useEffect,反而推荐能不用就不用,用户和事件触发的都放到事件处理函数里,即在组件内定义的普通函数,只有组件自发和一些内部的数据传递才不得不需要用 useEffect

useSyncExternalStore

useSyncExternalStore(简称 uSES)是 React 18 引入的一个非常底层、甚至带点“补救”色彩的 Hook。

如果说 Fiber 和 Concurrent Mode(并发模式)是 React 引以为傲的超跑引擎,那么 useSyncExternalStore 就是为了防止这辆超跑把第三方状态库(Zustand, Redux, Valtio 等)甩出车道而专门设计的**“安全锁”**。

要彻底弄懂它,我们必须先理解它试图解决的那个极其恐怖的并发灾难:UI 撕裂(Tearing)

1. 灾难前传:为什么传统的 useEffect 订阅失效了?

在 React 18 之前,我们如果在 React 里订阅一个外部数据(比如 window.innerWidth 或者 Redux Store),通常是这么写的:

// ❌ React 18 并发模式下的反模式
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);

useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);

return width;
}

并发模式下的灾难推演: 假设你有一个包含 1000 个子组件的超长列表,每个子组件都在读取这个 width

  1. 渲染开始: React 开始并发渲染,处理了前 500 个组件。此时 width 是 1000px。
  2. 被迫让出: 时间切片(5ms)用完,React 暂停渲染,把主线程还给浏览器。
  3. 外部突变: 就在这停顿的几毫秒里,用户猛地拖拽了浏览器窗口,width 变成了 800px!外部 Store(window 对象)已经变了。
  4. 恢复渲染: React 醒来,继续渲染剩下的 500 个组件。但此时它读取到的 width 变成了 800px。
  5. UI 撕裂发生: 屏幕上同时出现了 1000px 样式的上半部分,和 800px 样式的下半部分。同一个状态在同一次 Render 中出现了两个不同的值。 这对 UI 框架来说是致命的。

2. 救场英雄:useSyncExternalStore 的 API 设计

为了解决这个问题,React 团队推出了这个名字极长的 Hook。它要求状态库作者必须把控制权交还给 React。

它的 API 只有三个参数:

const state = useSyncExternalStore(
subscribe, // 1. 订阅函数:告诉 React 数据变了怎么通知它
getSnapshot, // 2. 快照函数:告诉 React 怎么获取当前最新的、不可变的值
getServerSnapshot // 3. (可选) SSR 时的初始值
);

还是拿 window.innerWidth 举例,用 uSES 重写后是这样的:

// ✅ 现代 React 订阅外部状态的绝对标准
function subscribe(callback) {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}

function getSnapshot() {
return window.innerWidth;
}

function useWindowWidth() {
return useSyncExternalStore(subscribe, getSnapshot);
}

3. 底层魔法:它是如何防止撕裂的?

既然它叫 useSyncExternalStore,这里的 Sync (同步) 就是整个魔法的核心。它不仅是一个订阅工具,更是一个并发打断器

当你在并发渲染中使用它时,React 底层会做非常严苛的检查:

  1. React 开始渲染,读取了 getSnapshot() 的值。
  2. 渲染过程中,组件树被暂停,主线程让出。
  3. 就在这时,外部数据变了(触发了 subscribe 绑定的 callback)。
  4. React 醒来准备继续渲染,但在继续之前,它会偷偷再调用一次 getSnapshot()
  5. 发现不一致! React 发现当前拿到的快照和渲染上半场拿到的快照不一样。
  6. 极其暴力的“Sync”干预: React 会立刻判定:“这次并发渲染已经被污染了,草稿作废!” 接着,React 会放弃并发模式,强制切换为同步(Synchronous)模式,锁死主线程,用最新的快照值,从头到尾一口气把整个组件树重新渲染一遍。

4. 为什么状态管理库(Zustand/Valtio/Redux)全靠它续命?

在 React 18 刚发布时,几乎所有的第三方状态库都炸锅了,因为它们自己维护的 Store 都是“外部(External)”的,天然不受 React 并发调度的管控。

  • Zustand: 它的核心依然是一个原生的 JS 闭包变量。当你调用 useStore 时,底层其实就是调用了 useSyncExternalStore(store.subscribe, store.getState)
  • Valtio: Valtio 的 Proxy 负责突变,克隆出冻结的快照交给 getSnapshot,然后通过 uSES 把这个快照极其安全地注入到 React 的渲染流中。
  • Redux Toolkit: useSelector 底层同样全面重构,接入了 uSES。

总结

useSyncExternalStore 是 React 官方对并发模式副作用的一次“妥协与修补”。

React 承认:“我管不了外面的世界(DOM、第三方状态库)。如果外面的世界变了,为了防止我的 UI 撕裂,我只能放弃并发的优雅,退化回暴力的同步渲染来保证正确性。”

作为普通开发者,你基本不需要手写这个 Hook。但理解它,你就彻底懂了现代 React 状态库的底层架构,以及 React 在面对极其复杂的并发调度时,那道用来保底的最后防线。

并发模式的代价

React 的 Concurrent Mode(并发模式)可以说是前端工程史上最疯狂的架构实验之一。它试图在单线程的 JavaScript 环境里,用应用层的代码去模拟操作系统的“线程调度”。

这种“逆天而行”的架构虽然带来了极致的理论响应速度,但也彻底打开了潘多拉魔盒,把极其复杂的“并发锁”、“内存逃逸”等概念强加给了普通的前端开发者。

除了前面提到的 UI 撕裂 (Tearing)防撕裂 Hook (useSyncExternalStore) 之外,并发模式还带来了以下几个极其折磨人的底层问题:

1. useRef 的突变污染陷阱 (The Mutation Trap)

在非并发时代,只要你在 Render 函数里同步修改了 useRef,虽然官方不推荐,但通常“跑起来没问题”,因为渲染是一气呵成的。但在并发模式下,这是致命的

灾难推演: 假设你在 Render 阶段写了 ref.current = ref.current + 1 来记录组件渲染的次数。

  1. React 开始低优先级渲染组件 A,执行到了这一行,ref.current 变成了 2。
  2. 突然,高优先级的用户点击事件进来了,React 决定打断并废弃这次组件 A 的渲染。
  3. 陷阱来了: 渲染虽然被废弃了,但 ref.current 是一个脱离了 React 状态快照的普通 JS 引用!它的突变是不可逆的。它已经变成了 2。
  4. 当 React 再次重新渲染组件 A 时,ref.current 又加了 1,变成了 3。最终页面虽然只渲染成功了一次,但 Ref 的值却莫名其妙变成了 3,导致与外部系统(如第三方图表、视频播放器实例)彻底脱节。

2. 开发环境的“双重调用”噩梦 (Strict Mode Double Invocation)

如果你升级到 React 18,你会发现一个让人血压飙升的现象:在开发环境下,所有的 useEffect 都会莫名其妙地执行两次! (挂载 -> 卸载 -> 再次挂载)。

  • 开发者的痛苦: 如果你的 Effect 里是发请求、埋点,你会看到 Network 面板里发了两次请求。很多人为了解决这个问题,搞出了各种奇技淫巧(比如用 useRef 记录是否请求过),导致代码越来越丑。
  • React 团队的苦衷: 这是他们故意的。并发模式下,React 未来会引入 Offscreen API(类似 Vue 的 Keep-Alive)。React 可能会在后台预渲染组件(挂载),发现用户没切过来,就暂停它(卸载),等切过来再恢复(再次挂载)。React 团队为了强迫开发者写出完美的、支持随时中断和恢复的清理函数(Cleanup Function),干脆在开发环境模拟了这种极端情况。
  • 结论: 框架为了未来架构的“政治正确”,把压力和不适感直接转嫁给了当下的开发者。

3. 被废弃渲染带来的 GC (垃圾回收) 惩罚

并发模式最大的卖点是:遇到高优先级任务,直接丢弃低优先级任务。

  • 隐形代价: 被丢弃的低优先级任务并不是真的“没发生过”。CPU 已经辛辛苦苦计算了那棵庞大的 Fiber 树(workInProgress tree),并且分配了大量的内存。
  • GC 风暴: 当它被无情抛弃时,这棵庞大的内存树变成了无主之地。JavaScript 引擎的垃圾回收器(GC)必须花费额外的算力去清理这些垃圾。
  • 结论: 如果你的应用充满了大量复杂的低优先级运算和高频的用户输入,并发模式不仅没有让你变快,反而可能因为频繁的打断和疯狂的 GC 导致浏览器卡顿。也就是所谓的“为了不卡顿而引入了新的卡顿”。

4. useMemo 失去“记忆”的语义不确定性

在以前,很多开发者把 useMemo 当作一个绝对可靠的缓存:只要依赖不变,它的值和内存地址就绝对不变。

  • 并发模式下的反转: React 官方在 18 的文档里明确警告:useMemo 只是一个性能优化的“提示(Hint)”,而不是语义上的“保证(Guarantee)”。
  • 为什么? 因为在并发模式下,如果内存吃紧,或者某次渲染被打断,React 内部完全有权利擅自清空 useMemo 的缓存,并在下次强行重新计算。
  • 引发的 Bug: 如果你把 useMemo 的结果作为 useEffect 的依赖项,一旦 React 在底层擅自清空了缓存导致重新计算出新对象(引用地址改变),你的 useEffect 就会在意料之外被意外触发,引发连锁反应。

5. Suspense 的“瀑布流与请求竞态”放大效应

并发模式重度依赖 <Suspense> 来处理异步。但如果你不使用 Next.js 等元框架帮你做服务端预取(Prefetching),而是在客户端通过 Suspense 加载数据,并发打断会让请求变得不可控。

  • 当组件准备渲染,触发了抛出 Promise 的请求逻辑。
  • 突然因为高优先级更新,渲染被打断,组件树被抛弃。
  • 此时你的网络请求还在飞(浪费带宽),但它的结果已经没人要了。当下一次渲染重新启动时,如果没有极其严密的缓存层(如 React Query),它可能会再次发起一个完全相同的请求

过度设计

对于所有的前端框架或者库来说,要做的事情就一件:把用户写的 js 渲染成页面上的 html-css-js 为了完成这个任务,vue 使用了模版语法定义视图,组件化完成了组件实例和渲染,vdom 完成了 html 转化和组件更新,响应式数据完成了数据驱动页面 solidjs 使用了 JSX 定义视图,组件化完成了逻辑的初次挂载(组件函数仅运行一次),预编译机制完成了真实 DOM 的生成与精准绑定(无 VDOM),**细粒度响应式(Signals)**完成了数据到具体 DOM 节点的直接驱动页面。 angular 使用了带有结构化指令的模版语法定义视图,基于类的面向对象与装饰器完成了组件实例和渲染,**增量 DOM(Ivy 渲染引擎)**完成了 html 转化和组件更新,全局脏检查机制(Zone.js)及现代 Signals 完成了数据驱动页面。 svelte 使用了增强型 HTML 模版语法定义视图,组件化在编译阶段转化为了高度优化的独立模块,AOT(运行前)编译机制直接生成了操作真实 DOM 的原生代码(无 VDOM),**编译器劫持赋值语句(或现代 Runes)**完成了零运行时开销的数据驱动页面。 在学习这几个框架的是现时我能感觉到他们的设计是渐进的,是顺其自然的,想组件化就用类或封装结构来包装一个前端意义上的组件,想响应式就用响应式数据,都是单纯的把数据和调用关联起来

而 react 则是一个宏大到无以复加的存在,并发模式下无时无刻的 workLoop,组件化却没有为组件封装,反而搞了一套 fiber 来代替组件,hook 更是披着“纯函数”的外衣,底层却死板地依赖着极其脆弱的执行顺序链表。它将原本该由框架底层去解决的“响应式依赖收集”彻底甩锅,强迫开发者在无处不在的闭包陷阱中如履薄冰,用繁琐的依赖数组和满篇的 useMemo/useCallback 去手动修补这套宏大调度系统所带来的性能与逻辑窟窿。

生态问题

生态完整、占有率高一直是 react 的骄傲,但我想说,这个生态里有很多缺陷

  • router keep alive
  • 状态管理大乱斗
  • 表单处理的繁琐与割裂
  • 异步请求与 useEffect 的陷阱
    • 官方极其强烈地推荐你放弃手写 useEffect 发请求,直接拥抱 SWR 或 React Query 这样的库
  • CSS 方案的撕裂
  • 官方好像更喜欢Next.js

总结

推崇者们说:React 的骄傲在于“你想怎么做都行”

但我们对着需求文档才发现:“你必须自己决定怎么做”

开发到一半又发现:”不得不这么做“

额外的

应该有很多人听说过 preact

这个项目太幽默了,把 react 改造成了 vue/solid

没有 hook、也没有了 view=f(props) 的 react 哲学

性能有了质的提升,更新变成了 dom 节点级,不需要开发者管理依赖,这完全就是解决了 react 最大的两个缺陷

但 大家似乎不太喜欢他,因为这样一来 react 就不是 react 了,完全被夺舍了,如果用 preact,不如用 vue/solid,毕竟 react 缺点很多但还是前端家族中最闪耀的孩子