
古老的 Angular
年轻的时候,我曾用 Angular.JS 赚钱。当时,那是一项非常出色的技术。绝对是当时最大的 JS 框架,最重要的是,那可能是 Web 开发第一次有了“框架”。在那之前,用的都是“库”,所以它是第一个,不仅提供一套函数让你使用,还提供给你一个构建 Web 应用的实际框架。
但事物总是相对的,Angular 之所以好是因为它之前的技术不够好。当时,我们还有其他的 SPA 框架,比如 Backbone 和 Knockout,但它们没有产生太大的影响。不,Angular 真正击败的敌人是 jQuery。
尽管 jQuery 只是 HTML DOM API 的一个封装(不得不承认当时非常粗糙),但它仍然成了构建复杂 Web 应用的事实标准。它的工作原理相当简单:你在 JS 中手动或通过命令创建 HTML 元素,然后修改它们,移动它们,做任何你需要做的事情,使网站可以像应用程序一样交互。
对于简单应用来说,这完全没问题,但如果应用比较大,维护就会变成一场噩梦。当时就开始发生这样的事情了。你真不能责怪 jQuery,只能责怪现代用户的需求,他们需要无处不在的交互性。所以开发人员被迫继续使用 jQuery,尽管它已经不再适合这项工作了。
然后 Angular 出现了,一切都解决了。你可以专注于编写 UI 和应用逻辑,而不是手动组装 HTML 的各个部分。它确实是一个改变游戏规则的框架(不再是一个库),你终于有一个合适的工具来创建大型交互式应用了。它有一些神奇的东西:
A) 组件。这个命名很奇怪,实际上,应该称它们为“指令(directives)”,但无论如何,你可以定义一个简单的 HTML 和 JS 文件组合,作为 UI 的一个组成部分,然后在应用的多个地方重用它。
B) 双向绑定。你定义一个变量,每当它改变时,UI 中的所有地方都会更新。这很有效。后来,人们开始抱怨这种全向数据流不好,所以有人推动使用单向(自上而下)绑定,这在技术上听起来更好,但实际上让一切变得更复杂,并引发了一场讨论,最终,如今的我们不得不使用 Redux。
在我的第一份工作中,我正好参与了一项将一个庞大且难以管理的 jQuery 应用重写为 Angular 应用的工作。不管是过程还是最终结果都相当好。
然而,不好的是,几年后我们不得不用 Angular 2 重写同样的 UI,我只是庆幸自己离开那家公司足够早,不然他们会让我用 React 第三次重写它。
加入 React 开发
后来,我确实有机会学习 React,甚至在一两个项目中专业地使用它了。
我仍然记得第一眼看到它时的新鲜感。当时,对比的是 Angular 2——它彻底重写了先前的版本,但样板代码翻倍了。开箱即用的 TypeScript、单向绑定、响应式/可观察模式,它们本身都是好东西,但太复杂了:开发慢、构建慢、运行慢。
React 重回简单之路,获得了人们的支持。有一段时间,React 保持了简单性,变得越来越受欢迎,成了开发 SPA 的首选库。
是的,我们现在又用了“库”这个词,为的是显示它实际上有多简单。但你没法只用一个库来构建一个复杂的应用。你需要几个库来处理应用的所有问题,你还需要一些代码结构。React“自带啤酒”的方法意味着你基本上是自己构建了一个框架,并带着它所有的缺点。
最终的结果是,没有两个 React 应用是一样的。每个人都有一个定制的“框架”,由从互联网上随机找到的库构建而成。
我当时不幸参与的应用都让我有同样的想法——即使是 Angular 2 也会比这好。JSX“核心”似乎总是看起来很坚固,但它周围的一切都是一团糟。
所以我退出了,去写一些 Java 后端,我相信这说明了一切。
就在我以为我退出了的时候…
就在我自以为已经退出了的时候,我最近又回到了 React。
当然,这只是一个业余项目,所以我没有把它当一个严肃的生产应用来对待。但即便如此,这次经历不仅证实了我的低预期没问题,而且还大大拉低了我的预期。React 让人抓狂,不知道为什么没有人谈论它。
架构、组件、状态
首先,让我们从 React 为你选定的架构开始。正如前文提到的,React 只是一个库,所以它不会强迫你做任何事,但是,JSX 的隐含限制使得一些模式自然而然地浮现出来。很久以前,我们谈论过 MVC、MVVM、MVP,它们都是同一主题的不同变体,那么 React 是哪一个呢?我认为都不是,我认为这是一种新的范式——我们可以称之为“基于组件的架构”。
乍一看,一切都是合乎逻辑的。你有组件,你构建了一个自上而下的组件树,然后砰的一声,你的应用就完成了。React 施展了一些内部魔法,确保与你提供给它的数据保持同步。这足够简单了。
但在这个过程中,它有时候表现得比它应该有的样子更聪明。对于一个简单的“UI 库”来说,React 确实有很多复杂的术语。而且对于一个与“函数式编程”无关的库来说,它里面确实有很多函数式编程的命名。
让我们从状态开始。如果你有一个自上而下的组件树,那么逻辑上,你会希望将状态从上往下传递。但在实践中,由于小组件非常多、非常混乱,所以你需要花费大量的时间和代码来连接各种数据片段,以便将它们放置在需要的地方。
这个问题通过使用 React 钩子将状态“侧载(sideloading)”到组件中得到了解决。对此,我还没有听到有人抱怨过,但你们是认真的吗?你们是在说任何组件都可以使用任何部分的应用状态吗?更糟糕的是,任何组件都可以发起状态更改,然后可以在任何其他组件中更新。
这怎么可能通过代码审查?基本上,你使用的是一个全局变量,只是状态修改规则更复杂。它们甚至不是规则,而只是一种仪式,因为没有什么能真的阻止你从任何地方修改状态。人们真的认为,如果你给某样东西起一个聪明的名字,比如 reducer,它突然就变成了好的架构吗?
所以,如果自上而下和侧载方法都不行,那么这个问题的解决方案是什么?我真不知道。事实上,我唯一能想到的是:如果我们不能很好地解决这个问题,那么也许整个“组件架构”就是一个错误,我们就不应该称之为优秀设计的典范,并停止创新。也许,这次我们真的需要另一个 JS 框架,尝试一些更好的东西。
React 钩子
接下来我们讨论下 React 钩子。不可否认它们很有用,但它们的存在至今仍让我头疼不已。
人们如何把组件说成是 “纯函数”,却又把钩子说成是组件内部的一个有状态的小黑盒。鉴于组件的可组合性,它更像是一层又一层套在一起的有状态的小黑盒。
这一点就不说了,我主要想吐槽下 useEffect。我们看一个很简单的“副作用”。你改变了状态,然后你需要做一些外部操作,比如将结果发送给一个 API。理论上,这种将“重要的应用程序内容”和“副作用”分开的做法是有道理的。但在实践中,你能像这样干净利落地分开吗?
首先,我最不满意的是 useEffect 被用来“在组件挂载后运行某物”。我理解,当 React 从类迁移到钩子时,这是最接近 componentDidMount 的替代品,但是拜托,这无论如何都是一种很不规范的做法。
使用一个“副作用”钩子来初始化组件?好吧,如果你必须从那里进行 API 调用,我会同意那是副作用。但是那个 API 调用,它也会设置状态。所以,一个完全无害的“副作用”钩子实际上管理了组件的状态。为什么没有人谈论这有多疯狂?
而且,如果想依赖那个状态并在之后做一些事情,你就要定义另一个 useEffect,并且依赖于第一个钩子的设置。

这段代码来自最近被几千万美元收购的一家公司的一个生产应用。我稍微修改了一下,使用了更简单的 House 和 Cat 实体,而不是实际的内容。但请看一下,试着解析这段代码的执行顺序。当你准备好了,可以看下下图给出的答案:

像这样的一连串的状态变化,本来用简单的命令式代码就可以实现,现在却分散在两个异步函数中,唯一能提示执行顺序的是每个函数底部的“依赖关系数组”。而实际上,你在心里是从下到上解析它的。
我记得,当初人们因为 then 方法而嫌 JavaScript promises 笨重,甚至在此之前我们还有“回调地狱”——但任何东西都比现在这个要好。
我知道,这些问题可以通过以下方式解决:a) 将它们移到一个单独的文件中(这只是在隐藏问题);或者 b) 可以使用 Redux 或其他方法(这方面我没有足够的经验,并不是很确定)。
“模式”
所有这些加在一起看上去很丑,也违背了 React 在其“Hello world”示例中承诺的简单性。但等等,我还没说完。有个熟人写过一篇博文,标题是“最常见的React设计模式”。我不知道自己在期待什么,但对于这些模式的复杂性以及要弄清楚其机制所需花费的脑力,我感到非常震惊——所有这些只是为了在屏幕上显示一个项目列表。
最令人震惊的是:文章并没有承认这一点。所有这些复杂性都被认为是理所当然的。显然,人们真的就是这样构建他们的 UI,而且没有人对此感到惊讶。
这好像还不够,有些人甚至去写“CSS-in-JS”,并以此获取报酬。我同意,JSX 最初的“关注点分离”并不是“文件分离”,将 HTML 和 JS 写在同一个文件中实际上没有问题。但是把 CSS 也放进去,并且使其成为强类型的?这是不是太过分了?
为什么
我们不能只是说 React 疯了,然后就不管了。作为理性的灵长类动物,我相信我们可以做得更好。我们可以尝试理解它。
我想起了我的第一份工作,想起了 “jQuery 迁移 ”项目中的一位同事。他是一位经验丰富的后端工程师,也是一位架构师,总的来说,是一个在软件领域非常受人尊敬的人。
关于他,我印象最深的不是他提供的技术解决方案,而是他对我们的前端工作的判断。比如,看着我们的 Angular 应用,他会说类似这样的话——你们这里到底是要做什么?为什么非得搞得这么复杂?
这并不是说我们很差劲——我们也是一个对软件毫不含糊的团队。只是在当时,以一个传统的后台开发人员的眼光来看,整个 Angular 的设置看起来简直是疯了。
如今,我大概和他当时的年纪一样大,我在这里写一篇博文,讲述 React 有多么疯狂。我想,有些事情是不可避免的。
但让我们更上一层楼,试着理解下为什么会这样。
首先,我认为我们都会同意,大多数 Web 应用本来就不应该成为 Web 应用。人们即使不需要 SPA,也会选择 SPA,他们以后可能需要,所以显然,从一开始就选择 SPA 花费不会太多。
但我想说的是,事实上,这种做法确实会让你付出代价。我们如此深陷于“默认 SPA”的方式,以至于我们忘记了替代方案有多简单。拥有一个简单的服务器端渲染页面比考虑一下 React 都要简单许多。没有 API 通信开销,前端非常轻量级,UI 代码可以是强类型的(如果后端是强类型的),你可以在整个技术栈上进行重构,什么都加载得更快,你可以更好地进行缓存,因为有些组件是高度静态的,对所有用户来说都一样,所以只需要渲染一次,诸如此类。
确实,你失去了在产品经理心血来潮时提供复杂交互逻辑的灵活性。但可能这也不全对,因为我敢打赌,你可以用简单的 JavaScript 进行“渐进式增强”,直到很长时间后才真正需要添加 React 状态管理的复杂性。
我说的是,我们使用 React 只是因为我们以前使用过它。这也难怪,惰性是一剂猛药,但这仍然无法解释为什么这段代码最终会复杂到难以想象的地步。
令人惊讶的是,这个问题的答案让我停止了对 React 的抨击,走向了相反的方向,不仅为 React,也为 Angular 和 jQuery 以及它们之前的一切辩护了起来。我认为,代码之所以糟糕,是因为开发一个交互式 UI,其中任何组件都可以更新任何其他组件,这简直是在软件开发中能做的最复杂的事情之一了。
想想你在日常生活中使用的任何其他系统。厨房水槽有冷热两个输入,一个输出。厨房搅拌机或电钻可能有一个或两个按钮,但无论你做什么,都只影响可旋转部分的行为。烤箱可能有三、四、五个旋钮,并且可能有同样数量的输出,这听起来已经很危险了。
相比之下,交互式 WebUI 可能有无限数量的输入以及无限数量的输出。你怎么能指望它有“干净的代码”呢?
因此,关于 React 的这番长篇大论,其实根本不是 React 的错。也不是 Angular 和 jQuery 的错。简单来说,无论你选择哪种技术,都不可避免地会在构建响应式用户界面时那不可能实现的复杂性下崩溃。
如何解决这个问题?
我不够聪明,也没有深入研究,真的解决不了这个问题,但我可以提出一些想法。如果我们采用这种输入/输出的心理模型,将网页视为一个实际的东西,那么我们也许可以从减少它的输入和输出数量入手。
在输入方面,我说的是:“去减少按钮的数量”,这可能无法强制执行。但毫无疑问,功能越少,代码库就越容易管理。
这很简单,不值得一提,是吗?产品经理知道吗?增加三个按钮会比增加两个多 5%的 Bug,并且,未来在那个屏幕上开展的设计和实现工作复杂度会增加 30%。没有人度量这些事情,但我相信那可能是真的。
如果我告诉你需要在后端添加 Redis,你会告诉我“不,我们需要控制技术复杂性”——如果产品经理要求添加一个应用范围的全局过滤器,可以从任何地方应用于任何东西,你就只会低下头,编写一些人们将来要花费 10 年时间去设法摆脱的怪物,为什么?
简而言之——请不要再增加那么多按钮了,我求你了。我知道,你甚至可以疯狂地移除一些?
然而,在输出方面,情况就有点不同了。写这篇文章的时候我意识到,基本上,服务器端渲染的页面就是将页面减少到一个输出。不管你与它做任何交互,它都只是重建整个页面。具有讽刺意味的是,这意味着移除 FP(函数式编程)风格的 React,服务器端渲染的页面实际上成了状态的一个纯函数。没有前端状态 = 简洁性大获全胜,如果你能负担得起的话。
不可避免地,当你确实需要在服务器端渲染的“应用”中添加一些脚本逻辑时,明智的做法也许是只在最必要的地方添加,越少越好。
关于这一点,我认为“交互岛”这个名字不错。我谷歌了一下,结果发现这个名已经被用了。不过,那篇文章还提到了 Preact、SSR、清单文件,所以我不确定我们的看法是否一致。人们会把一切都搞得过于复杂。
但我确实相信,我们如今有足够的带宽来加载一个小型 React 应用,它只渲染一个位于传统服务器端渲染页面里的交互岛。我觉得这种组合不会那么糟糕,但我还没有尝试过,在我的下一个项目里,我可能会尝试一下。
所以,我未经测试的拥有干净且可维护前端代码的方法是:全部在服务器上渲染,只在你真正需要的地方插入 React 或任何其他东西。
它真的不可能比现在更糟了。
原文链接:
评论