City Background
Article cover

React 带来的生死疲劳

很长时间没写 React 了,最近因为期末大作业需要做一个报告,我搞得稍微花哨了一点,用 React 做了个 PPT。动画嘛,再配上 Canvas,纯拿 React 写大概率是要吃瘪的,特别是好久不碰手有点生了。于是就想着写个笔记给自己看,也给有需要的朋友留一份的资料。

当然,在这里我要叠个甲,我知道你们前端圈有圣战的传统。尽管本文看起来像是在咬赛博打火机,但实际上我没有要参战的意思,我就是个老老实实写代码的人。你觉得我哪里说得不对,以你为准,我是傻逼,傻逼不在乎,不要在我的地盘上闹。

React 是什么

这是一个很大的话题,如果你去面试一些前端开发岗位的话,你的面试官出于某种怪异的优越感可能会要求你解析实现原理、讲源代码细节以体现他的学识是渊博的。但是真的干过活的你也应当认同,重要的不是那些八股,而是心智模型。这是很多 React 开发者,哪怕是非常有经验的开发者也常搞不明白的事情。

React 的核心心智模型包含两部分,React 内部环境:State + vDOM,React 外部的一大堆平台特异的东西。以 Web 来说,最常见的就是 DOM Object。

React 在做的事情是把二者桥接在一起,具体而言,React 通过 Reference 的方式把 vDOM 内部的实体和外部环境进行绑定,开发者通过 Effect(副作用)把 React 内部的状态发送给外部环境;外部环境通过异步回调(比如 Event Callback,异步信息流)的方式把数据发回来。

React 的角色比较像是一个八爪鱼,抓着环境里面的东西,通过绑定的方式收发信息。基于此,你得到了 UI = f(state)。

没了。

在这样的心智模型下,你会发现很多常规开发实践离谱至极。比如,有开发者把 Effect 理解成了「当 X 变量发生变化时,执行某个函数」的工具。但实际上,这个理解当中有两个错误的地方。首先,X 不是一个一般的变量,而是「某个状态」,因为不被 React 管理的状态无法稳定地触发 vDOM 的重渲染;其次,不是「执行某个函数」,而是很具体地「把 React 内部的信息同步到外部环境」,即「不纯的」副作用。

有很多不太懂的开发者,会监听某个 Prop 发生变化,然后刷新 Component 内部的某个状态,来做「状态同步」。你大概率会因为同步时序的问题 Fuck Up,正确的做法是在函数内部做状态派生。

再比如,一个经典难搞的事情是在 React 里面操作 Canvas,通常伴随着一大堆尺寸超大时序不明的 Effect,绕来绕去最后彻底调试到崩溃。这里面比较 Tricky 的事情是, Canvas 自己里面有一个自己的状态机,你对 Canvas 的所有操作其实都是副作用,跟 React 没半毛钱关系。所以最佳实践是把它们全都丢到 React 外面,只留两组接口做通讯, Effect 往 Canvas 同步,管理器存在 Ref 里,接收 Canvas 的 Reference,发送事件。

react-three-fiberreact-konva 帮你把这事情做了,如果你没有用库的话(实际上我个人挺讨厌这种库的)就得自己把对应的开发范式理干净。

说到 Reference,也有开发者会通过把 State 和 Callback 打包成 Reference 暴露给父级组件,父级组件再通过 useRef 去子组件内部挖宝,进而达成做所谓的「反向沟通」。这其实也是一种明确的反范式,因为 Reference 在定义上就不是这么个东西。而且更进一步的,在 React 的大概念下根本不应该有「反向数据流」这件事情,从下往上传的那个东西只应该是事件回调。

SSR 是个怪主意

我没说它是个坏主意,我说它是个怪主意。

SSR 的好处之一是避免数据来回在客户端和服务器上打乒乓球,服务器端上做好 HTML 渲染一次性发给客户端再「水合状态」就好了。

如果我们回顾「React 是什么」这个讨论,就会发现这里有一个「很怪」的地方:React 的渲染涉及到一个「内部环境」和一个「外部环境」,内部环境就是用 JavaScript 写的 React 本身,这没什么问题,但是如果你想要在服务器端完成一次「和浏览器里面一样的渲染结果」,那么你就同样需要准备一份等效的「外部环境」。同时,为了确保客户端的 React 能把 DOM 和各种「外部环境」对应起来,你得确保所有的「外部环境」都是可以被序列化的。

That’s how things fuck up.

比较常见的例子是「同构 Fetch」,在服务器侧模拟一个长得一模一样的 Fetch API 来达成「数据抓取」的目的。

但是,这个世界上有很多东西你是没办法确保服务端和客户端能保持一致行为的。比如:

  • 浏览器 API (window, document, localStorage)
  • 用户设备信息(屏幕尺寸、触摸支持、User Agent)
  • 时间相关的值(时间戳、日期、Date.now())
  • 随机数 (Math.random())
  • Canvas 状态、WebGL 上下文
  • WebSocket 连接、定时器

此外,你的确可以不在客户端和服务器上打乒乓,但是按照那些「同态 Fetch」实现,变成了 SSR 服务器和后端服务器打乒乓。当然你也可以做成「非对称的」,服务器端直接调 RPC 把数据拉回来,但是这就意味着你要写两套,刺激吧。

而且跑在 Node 上的 SSR 性能还是挺拉的,不光性能差劲,还得有一大堆内存很大的服务器,因为 SSR 服务器需要的运行时 Node.js 是一个非常吃内存的东西。

这个时候有「经验」的开发者会辩称:我们有 Edge Worker 啊!为什么 SSR 的 Runtime 一定要跑在 Node.js 上,一个裸的 V8 不好吗!

但惨烈的现实是,Edge Worker 的 Runner 是一家一个样,每家都有自己的实现,自己的 SDK、自己的 Adapter。尽管有 TC55 这个机构在管理标准,但你看看那贫瘠的标准文档就知道它能创造的「标准化方案」到底有多少「标准」可言。

另外,当你把渲染迁移到边缘服务器上的时候,就要想办法解决它们怎么跟数据中心通信的问题,省下来的性能指标又被通信给追回来了。你或许会继续说,我们可以用分布式数据库呀。

面多了加水水多了加面一顿操作猛如虎,回头再看你要解决的竟然是一个「前端渲染性能」的问题,高达都自愧不如。

数据(状态)归属的边界问题

让我们坐上时光机回到二十年前, JSP、PHP 和 ASP 大行其道的年代,几乎所有动态内容全都是由服务器产生的。后来也有一些 Fancy 的东西,后来的 Ruby on Rails、Django,也都在用这个思路,纯纯的 Server Side Rendering。Wow, Amazing!

它们的工作流极其简单且统一:请求打进来、查库、填模板、返回 HTML 字符串、扔给浏览器做渲染。

但为什么我们在讨论 React 的 SSR 时会觉得它「怪」,而讨论 PHP 的时候却觉得它「正常」?

原因我们刚才讨论过了,传统方案可没打算在服务器上模拟一个浏览器。它不需要 window,不需要 document,更不需要什么扭曲的「同构 Fetch」。它在服务器端完成的任务只有一件:把数据变成 HTML。

它不需要「水合」,因为在传统模式下,HTML 发到浏览器那一刻,它的使命就完成了。如果你想要交互,你得写一段独立的 JS 脚本,通过 document.querySelector 强行去抓取 DOM 元素,然后手动给它绑定事件。

在这个话语体系下,React 内部环境(State + vDOM)和外部环境(DOM)的那个「八爪鱼」是不存在的。服务器生产文档、浏览器消费文档、JS 脚本在文档上打补丁。

服务器和客户端之间有一个很明确的默契,谁是数据的权威来源(Source of Truth),谁就对这段数据负责。

在传统栈里,状态的流动是单向的。当你点击一个按钮触发页面刷新时,客户端的所有状态被瞬间清空,所有权力交还给服务器。服务器重新查库,决定现在的 UI 应该长什么样,然后再一次性地把结论(HTML)推给浏览器。

这里没有所谓的「状态同步」,没有「同构」,也没有「水合」。因为权威来源只有一个,那就是服务器。

在近十年我们开始面对的全新问题是:我们现在想要的是「应用(App)」,基于「文档(Document)」的解决方案已经支撑不了如此庞大的复杂度了。

我们想要毫秒级的响应,想要在断网时依然能打字,想要在拖拽一个元素时页面不需要刷新。这意味着,我们必须在客户端建立一套自己的状态管理系统。于是,权威来源发生了分裂:一部分状态在服务器(数据库),一部分状态在客户端(内存)。

这时候,React 的 SSR 这种「怪主意」就登场了。它试图通过一套极其复杂的机制,在服务器上模拟一个客户端的运行时,试图让服务器在发送 HTML 之前,先「预演」一遍客户端的状态逻辑。它想在同一个代码库里,让一份逻辑同时在两个截然不同的权威来源(服务器和浏览器)之间无缝切换。

你可能会问,有没有办法让服务器和客户端各自负责自己的权威来源?这是一个很好的想法,值得继续讨论。

这个做法充分的尊重了客户端和服务器拥有不同内外部环境的事实:服务器的端的外部环境是数据库,内部数据是查询运算结果;客户端的外部环境是浏览器,内部环境是决定 UI 的数据。通常对于单个请求,服务器端的内部环境并不会因为外部的异步事件而发生什么变化,它基本上是 One Shot 的 [1],但是客户端需要对连续的事件做出响应,做刷新变更,所以它得是一个动态变化的状态。

恭喜你!你发现了 Server Component!

Server Component 更怪了

React 针对这个观察,给出的答案是把它们缝在一起,用 use client 或 use server 来标注一段数据的权威来源。

而且正如我们前面讲的,服务器环境下数据通常是 One Shot 的,所以你不能用 Hook,不能有状态。所以如果你真的意识到内外部环境的问题,再来看整个系统设计是 make sense 的。而且我也觉得 Server Component 这个路子挺好的,但是搞不明白为什么在 React 的宏大叙事下面会被搞成这么一团乱糟糟的玩意。

乍一看,它们之间可以互相嵌套,让一个很复杂的体系被调和得很圆融。但这只是一个表面看上去和平的做法,广告片、发布会、震惊体大 V、拥趸和优越逼没有告诉你它带来的庞大心智负担。

传什么和怎么传

如果你在一个普通的 React 应用里写代码,从父组件传一个 Prop 给子组件,心智模型是把一段数据在浏览器内部简单传递下去。你可以传字符串、对象,也可以传一个回调函数,甚至传一个带有各种内部方法的 Class 实例。一切都非常自然。

但在 Server Component 和 Client Component 混用的世界里,传递这件事情开始变得微妙,而是一道横亘在互联网两端的网络物理边界

当你在 Server Component 里嵌套一个 Client Component 并试图传递 Props 时,这些 Props 必须穿越网络,这意味着它们必须是可序列化的。你不能传函数,不能传复杂的实例对象,不能传任何不能被 JSON.stringify(或者更精确地说,React 自己那套 Flight 协议的序列化器)理解的东西。

你的代码看起来是在同一个文件系统下,甚至在一个组件树里愉快地嵌套着。但实际上,这是一个精神分裂的八爪鱼。它的上半身在服务器上,下半身在用户的浏览器里。

作为开发者,你现在被迫在大脑里同时运行两个完全独立的运行时。你每敲下一行代码,都得在脑子里做一个判断:我现在的上下文是在服务器还是客户端?这个组件能不能用 Hook?那个数据能不能越过边界?

你以为自己少写了几个 API Endpoint,但实际上你只是把原来写在路由层的清晰的接口契约,变成了散落在整个代码库里隐式、隐晦且极易出错的 'use client' 边界。API 并没有消失,它只是被框架强行塞进了组件树的缝隙里。

庞大的心智负担与成本权衡

让我们算一笔账。

Server Component 最大的卖点之一,是把沉重的依赖(比如一个好几 MB 的语法高亮库、或者巨型 Markdown 解析器)留在服务器上,以此缩减发送给客户端的 JS Bundle 体积。

看着不错,但为了这点收益,代价是什么?

首先,你把单一的模块图(Module Graph)硬生生劈成了三个:Server 模块图、Client 模块图,以及它们之间共享的模块图。你不能像以前那样轻易地重构代码,因为把一个纯函数从一个文件挪到另一个文件,可能会无意中跨越那条隐形的序列化边界,然后在一堆不知所云的编译报错中抓耳挠腮。

其次,调试变成了一场噩梦。以前页面坏了,你要么查浏览器的 Network 看看接口是不是挂了,要么看 Console 里的报错。现在呢?错误可能发生在服务端渲染 Server Component 时,可能发生在 Flight 协议把这棵树序列化成二进制和字符串混合体的过程中,也可能发生在客户端拿到这坨乱码重新 Hydrate 拼接 DOM 时。

为了所谓的「更好的用户体验」,开发团队付出了几何倍数增长的工程复杂度。更荒谬的是,绝大多数业务线上的 CRUD 后台、Dashboard 或是简单的内容展示站,可能根本不需要这种极端的优化。你花费数周时间去解决那些因为 RSC 带来的边界问题、第三方库不兼容问题,原本你只需要用普通的 React 配合一个简单的骨架屏、渐进式披露动画就能做到 95% 的效果。

JavaScript 的原罪与傲慢

这可能是 Server Component 最让人不舒服的地方:它把你的后端技术栈彻底锁死在了 JavaScript 上。

纯粹个人观点:我倾向于 By default 反对把 Node.js 当后端的行为,除非你能充分地 justify 它的必要性,比如做一个简单的博客(笑)。原因很简单,众所周知地摸爬滚打了这么多年 Node.js 的后端生态依然一坨狗屎连个能打的 ORM 都没有。当然如果你的任务简单到只需要一个 Edge Runtime 不需要 Node,而且也没有什么复杂的数据库通信工作,那就另当别论了,比如做一个博客(笑)。

如果采用传统的「前端 React + 后端 API」的模式,你的后端可以是极其高效的 Go,可以是写爬虫和 AI 无缝衔接的 Python,可以是生态稳如老狗的 Java。后端团队想怎么玩怎么玩,前端只需要关心数据结构。

但是 Server Component 的运行前提是什么?是你的服务器必须能够执行 React 组件,并且能够生成那套专有的 Flight 协议格式。这就要求你的服务端必须跑一个 Node.js 或者 Edge V8 运行时。

如果你的数据和核心业务逻辑在 Go 微服务里怎么办?你可能得在前端和后端之间再垫一个中间层。原本客户端直连后端的简单架构,变成了三层。

你不仅多维护了一个运行时,而且再次踩中了我们之前聊过的钉耙:「SSR 服务器和后端服务器打乒乓球」。

更要命的是安全问题。既然 Flight 是一种在服务端和客户端之间传递组件指令和数据的序列化格式,那么它就要背上所有反序列化带来的麻烦。详情参考 Vercel 跟 React 手拉手搞出来的那一串满分 CVE:因为要在客户端触发 Server Action 并传递复杂状态,底层协议对 Payload 的校验一旦出现百密一疏,黑客就能通过一个被构造的恶意数据包在你的 Node.js 服务器上执行任意代码。一方面在 JS 上搞出这种事情我真的是完全不意外,毕竟那可是 JS;此外,反序列化数据是一回事,直接反序列化业务逻辑那就是另外一回事了,在目前的实现下,尽管没有直接传递可执行代码,但 Server Component 所使用的协议已经不再是单纯的数据传输,它同样传递了组件结构、引用关系以及调用能力,变成了一种很罕见的复合描述。这种设计在工程感受上具备了某种业务逻辑的色彩,从而模糊了数据与逻辑之间的边界,也天然地带有更多的安全隐患。

跟着钱走:利益的纠葛

面对如此高昂的心智成本和荒谬的架构束缚,你可能会疑惑:为什么社区里有那么多人、那么多大 V 在狂热地推销它?为什么这一切的一切把它渲染得像是前端开发的救赎一般?

这就不得不提这套技术背后的商业逻辑了。

React 团队在 Meta(Facebook)内部诞生了这个想法,但 Meta 有自己独特而庞大的独有问题。

We don't really use SSR at Facebook, which is why this has come last.

by Andrew Clark, May 2017

所以,React 团队需要一个富有经验的勇者来落地这个实验。猜猜被选召的婊子是谁呀!啊哈!原来是邪恶黑色三角形 Vercel 呀!

不要误会,我并不是在宣扬阴谋论,这完全是正当且合理的商业决策。但是你得看清楚 Vercel 的商业模式是什么:他们是一家卖服务器和边缘计算资源(Serverless/Edge Functions)的云服务商。

如果大家都去写纯 CSR 的单页应用(SPA)或者静态站点生成(SSG),编译完就是一堆 HTML/CSS/JS 静态文件。你完全可以把它们扔到 GitHub Pages、Cloudflare Pages 或者是廉价亲民又实惠的 AWS S3 上,根本不需要消耗多少昂贵的服务器算力。

但这怎么行?如果不烧 CPU,怎么卖算力?

Server Component 和 Server Action,在做的事情是将那些本来可以静态化、或者本来在客户端浏览器(花的是用户的电费)里计算的东西,强制拉回到了服务器上执行。你每一次交互,每一次页面流式更新,都在消耗 Serverless 计算时间。

啧。

把 SSR 和 RSC 作为框架的「默认选项」强推给所有开发者,将极大地增加开发者对计算资源的需求。整个生态被这套叙事裹挟着,开发者们为了解决一个并不普遍存在的首屏加载时间问题,心甘情愿地重写代码库,踩无数的兼容性大坑,最终把自己的项目部署到了按请求计费的云函数上。

如果这事情不是由 Vercel 做,React 团队能够更多地承担开源维护者的责任,把通信协议做成一个受 RFC 机制管理的标准,有一个委员会对它负责的话,事情可能会变成另外一种样貌。哪怕在 React 侧分析源代码,对 Server Component 抽取成标准模板序列,生成类似 Protobuf 的共享协议交给后端,后端对着这个东西填数据,配合 SSG 也能避免整个生态全都被绑死在 JavaScript 上的悲剧。

哦,对了,二十年前,PHP 的年代,这东西被称作「颗粒化」。对部分 HTML 的渲染结果直接就地缓存。PHP 在拼完整 HTML 的时候如果发现缓存被命中了就直接读硬盘,省了查数据库的时间和过模板的时间。而且跟 Server Component 一样,输出的都是没状态不可交互的东西,唯一的差别可能只在这里面能不能再嵌套动态交互内容。但你说这是不能被 Server Component 之外方案解决的吗?显然不是。

我一直主张 server 的东西就老老实实的用 Server 端的 Toolkit 写,如果 Linter 上能要求这部分代码必须有一个特殊前缀做标记就更好了。遇到动态的东西 Client 侧明确给出几个 Slot 再往里面填,而不是这种连汤狗不涝的写成一大堆看似公平均质的玩意。燃鹅 React 在这方面做得相当没有肩膀:

it's not a stable format today because it's not clear if many people would use one and we want to leave room to improve the format as new optimization opportunities arise.

Sophie Alpert

我真心期望社区里面的野生实现能把这坨最麻烦的大便清理干净,类似 XHP-JS 很明显更加务实一些,但可惜这项目的坟头草已经万丈高了。好在基于 Golang 的 Strike,以及基于 Rust 的 rari 都还健在。可惜还没搞出什么大名堂。

毕竟你看,无需在服务器端全量模拟浏览器环境看着就很版本答案。因为静态信息碎片没有状态,可以直接吐出静态渲染结果,可能在 React Compiler 上面再稍加点魔法就可以直接建立一种跳过水合的机制进一步做透明的性能加速,看着也挺美好的。至少这样,一切又回到了二十年前的思路,质朴但工作。

Again,Server Component 这个思路的确解决了很棘手的问题。但是我对 React 和 Vercel 手拉手给出的方案并不满意,因为我从整个方案里没有看到作为生态方应有的,一个很负责任的态度。

社区的碎片化

React 18 对整个生态来了一场巨大的血洗,重灾区是 CSS-in-JS 领域。在 React 18 之前,我们习惯了在运行时动态注入样式的方案。我个人最喜欢的是 Styletron 和 Griffel。它们逻辑简单:渲染组件的过程中就生成样式,并且构造出样式表。它满足了 React 把一切都放进 JS 的哲学,一切都非常流畅和自然。

但当 Fiber 架构和并发模式(Concurrent Mode)登场后,这个路子完全走不通了。并发模式的核心是「可中断渲染」:React 可能会开始渲染一个组件,然后中途停下来处理高优任务,甚至直接丢弃这次渲染结果重新开始。

这近乎给动态 CSS-in-JS 判了死刑。样式的注入顺序决定了 CSS 的优先级,而当渲染过程变得不可预测、可以被中断或重复执行时,样式的注入顺序就彻底乱了。

为了适配这套架构,很多库不得不进行破坏性的重写,或者引入极其复杂的补丁。也有大量中小型方案直接死掉了,或者变成了「僵尸库」,它们还能跑,但没办法支持 React 的新特性。

活下来的是可以静态分析的方案:你以为你在写 JS,但是你被迫进入一种脑裂的状态,不再能把样式看成是 JS 的一部分,恰如 SSR 和 Server Component 搞出来的那一出。

或者拥抱 Tailwind 吧我的朋友!我可爱死在 class 列表里面写 CSS 啦!欢庆新世界!

呕。

相信你可以理解,这种碎片化势必会蔓延到整个组件库生态。

以前,一个前端开发者在挑选 UI 库时,考量因素是:它的设计美学如何?跟我的设计师作品相性好吗?API 是否直观?

但现在,除了上述问题之外,你脑袋里面还得多一大堆评估标准,这个库支持 RSC 吗?它能跑在 Server Component 里吗?它在流式 SSR 下会引起水合报错吗?

一个库可能在传统的客户端 React 里表现完美,但在 RSC 环境下就得被套上无数层 'use client' 才能勉强运行;或者它支持流式渲染,但不支持部分并发特性。

一个怪东西从东方冉冉升起:React 内部出现了一套非官方的、碎片化的「兼容性矩阵」。浏览器领域有一个 caniuse.com,让我们能清晰地知道某个 API 在哪个版本可用。但在 React 的世界里,没有一个官方的、统一的 React 版本。你只能在各种碎片化的博客、 GitHub Issue 和技术圈名媛那里得到一些答案。BTW,我认识的技术很强的人审美都很烂,最后基本都会给你推荐死人白系列组件,相信你的设计师看见这些备选之后会产生想把你的脑袋拧下来的冲动。

这种碎片化在某种程度上可以被理解成复杂度的转嫁。

React 团队为了解决 Facebook 规模下那 1% 的极端性能问题,引入了 Fiber、并发模式、 Flight 协议和 RSC。但这些能力并不是通过一个简单的开关来开启的,它们潜移默化地改变了整个框架的运行假设。

有趣的是,还记得前面我们讲的吗?Facebook 用的 React 和开源的 React 不是一个东西,它们自己有自己内部的独到解决方案。

结果就是那些致力于构建通用工具的库作者,必须为了适配这 1% 的极端场景,去重写 100% 的代码。而作为最终使用者的开发者,在享受那点微乎其微的性能提升之前,得先在无数的兼容性大坑里反复横跳。

我们以为自己在拥抱「未来」,但实际上,我们是在用整个社区的工程稳定性,为少数几个巨头公司的极端用例买单。

这就是这场「架构革命」留给我们的遗产:一个被劈成碎片、充满隐形边界、且需要通过极其复杂的心理建设才能上手的新世界。而这一切都被包装成了「现代化前端开发」的必经之路。

疲劳

总结而言,我想破头也没想明白这些问题:

  1. Concurrent Mode 的可中断渲染在单线程环境下能带来什么实质收益,它的真实适用场景是什么?
  2. 既然 Web Worker 可以承担计算任务、虚拟化可以解决 DOM 规模问题,Fiber 架构的巨大复杂度代价是否合理?
  3. SSR 的核心价值主张(SEO、性能、首屏体验)是否经得起推敲,还是在大多数场景下 SSG + CSR 已经足够?
  4. React 团队推动 SSR 和 RSC 的方向,在多大程度上是技术判断,在多大程度上受到了 Meta 内部无法实验、Vercel 商业利益介入这两个非技术因素的影响?
  5. React 生态的复杂度增长是否与实际收益相称,还是整个社区为一套主要服务于少数规模场景的架构承担了不必要的成本?

在很长一段时间里,我对 React 的热爱源于它的简单。那套哲学极其优雅,把复杂的 DOM 操作抽象成了纯粹的映射关系以及几条基本原则。这套教义最迷人的地方在于,你不需要成为一个编译器专家或底层架构师,只要把这套哲学记在心里,遵循最佳实践,你大概率能写出质量上乘的代码。

但这种优雅是有代价的。因为哲学不能被 Linter 量化,它要求开发者在写每一行代码时,脑袋都必须处于一个「时刻开机」的状态。尽管依赖数组这种简单的东西 Linter 会帮你看,但是更复杂的数据流管理、状态设计是 Linter 帮不了你的。很多脑袋不开机的人经常会搞出时序问题、需要逆向传输数据的情况,这也突出了以哲学为核心的 React 本身的巨大门槛。

虽然很累,但先前我能说服自己。它逼我把业务逻辑想清楚,逼我理顺数据的流动方向。只要我遵循这套哲学,我的工程就是可维护的。在这种语境下,我的心智负担是用来对抗业务复杂度的,而不是用来对抗工具本身的。而且 React Compiler 是我看到的为数不多的好文明,它真的能让开发者从其哲学的心智负担中解脱一丢丢。

这也是为什么我曾经盛赞 CRA。因为它代表了一种极其珍贵的克制:它告诉开发者,打包工具的复杂度不应该是你面对的任务。我看不到任何魔改 Webpack 配置能解决的实际业务问题,除了让开发者产生一种「我掌控了底层」的虚假成就感和优越感。

但现在这种疲劳变成了一种被愚弄的疲劳。

为了解决 Facebook 或 Wix 那种 1% 的极端规模问题。为了让一个在边缘服务器上跑的 Node.js 运行时能稍微快那么一点点。为了让 Vercel 的计算资源账单能再多跳几个数字,我被迫要去处理我根本不在乎的问题。

它强迫我们去面对一个被劈成碎片的生态,去学习一套为了补救「水合原罪」而设计的补丁方案。它试图用一个统一的框架,去强行抹平「服务器权威状态」与「客户端持久状态」之间不可调和的差异。它不承认边界的存在,反而试图通过增加复杂度来掩盖边界。

长久以来 React 一直把自己定位成为一个「库」,并且把「库」不能解决的问题甩出去交给社区解决,整个社区对此也有默契,在我看来这是让整个 React 社区生机勃勃的重要因素。但是它现在开始跳出一开始的圈圈,开始染指「框架」层面的东西:React 尝试变成神。它期待它的运行时能接管一切,期待它的模型能解决从简单博客到巨型协作工具的所有问题。但很可惜,我是人,生态里的开发者们也都是人。

看着这出闹剧,我只觉得累。我厌倦了在这种「为了优化而优化」的叙事中寻找意义。

所以,我跳车去 Svelte 了,以前一起玩 React 的朋友有跳车到 Solid.js 的,有跳车到 Preact 的,也有跳车到 HTMX 的。这些库或者框架都能更加坦诚的面对自己的边界,不去尝试解决所有问题,在我看来这是一种美德。Shopify 也在搞 Remix V3 了,作为吃瓜看戏的普通民众,我也很期待他们能搞出什么名堂。

隔壁的 Astro 也香气扑鼻,明确的把不需要水合的东西给划出去,是一个更聪明务实的解法。沿着这种思路继续往下走,在未来 Bundle Size、First Contentful Paint 等令人头疼的问题或许能有更加轻盈的处理方案。

我没有很能接受「那又怎么说主义」,自己的宗教回答不了问题的时候就去攻击其他工具不够好。谁都知道家家都有本难念的经,上述所有方案也都有自己的麻烦,或许本文当中提到的各种坑它们多少也都有,但至少没有像 React 一样让我「累得那么均匀且无死角」。

当然,为了阻止战火燃烧我们也可以再揪一个罪人出来,比如看起来就没有怎么在上班的 WhatWG。这帮人 在 Web 从 Document 变成 Web 的这波浪潮里它的确是干了不少人事,但为了解决眼下以 Web 框架战争为主的这一大堆破事,Web Component 很明显不是版本答案,而且差得有点多,而且零人在乎。

真是一个令人悲伤的故事。

没有任何一个解决方案能成为永恒。JS 生态的有趣之处就在于它的低门槛,并造就了轮子井喷的宏伟奇观。每天都有百万个轮子出现,每秒钟都有人在尝试定义下一个「标准」。在这个高速 Trigger 的时代,我们不需要一个试图解决一切的「神」。只需要在下一个问题冒出来的时候,能有一个造轮子的白痴恰好命中红心,给我们一个简单、好用、不累人的工具。

而现在,我对 React 最后的期待只有,不要变成像 Windows 11 一样被各方利益裹挟的纯粹垃圾(此处响起女高音咏唱 Copilot 的赞歌)。

最后,我想给那些在社交媒体上狂热推销的「圣战者」们一个建议:如果你在做的东西只是一个死人白、毫无美学的个人博客,那么你根本没有资格谈论这些新潮玩意带来的所谓「好处」。因为你从未触碰到过那些真正需要这些武器的战场,你只是在用最昂贵的重型武器,去砍一颗路边的杂草,然后对着那堆被震碎的碎片赞叹不已。

我真的不知道 Dan 在面对这一团狼藉脱口说出「You may not like it, but React is basically Haskell」时脑子里面究竟在想什么,或许这就是神的境界吧。


  1. 我知道有另外一种玩法,放弃在客户端镜像一份状态的幻想,直接让服务器驱动 DOM,像是 HTMX 或 Phoenix LiveView,但我总觉得这玩法太癫了。 ↩︎

Comments

Loading animation

Loading comments...