我被 React 绑架了
我与 React 的“相爱相杀”

序言

这篇文章放在几年前一定会招来公众的集体声讨。但最近大家逐渐开始对 React 有了不满情绪,我终于敢大胆说出我的观点。

与大多数人一样,我其实不太关注 React。

作为一名 React 开发者,我使用 React 已经很长时间了,久到我都想换个框架。我的上一家公司,现在的公司,很可能我的下一家公司,都使用 React。

React 刚出现时,所有内容都有很优雅。组件代码注重行为的局部性;DOM 是内联的,紧挨着其事件处理器;代码是模块化的,非常整洁。

随着教程的发布、相关书籍的出版以及业界大佬的出现,React 以前所未有的速度建立起了自己的商业版图。React 为开发者提供前所未有的开发体验。第一批使用 React 的是活跃于各种会议、热爱思考的开发者以及“技术发烧友”。这个库很快就在开发者中流行起来,因为它的语法非常简单。

后来大家都开始使用它。

我们都获得了赠送的 Hook!刚开始,我们不知道怎么使用 Hook,但我们很快就被 Hook 带来的清晰度和优雅征服,打字更快!迭代更快!更易阅读!React 真绝!

随着时间的推移,出现了很多替代 React 的新产品,这些产品每月都如雨后春笋般出现,但我们依然信赖经过无数次试炼的 React,对这些新产品嗤之以鼻!React 满足了我们的所有需求,没有给其他产品留下一丝儿空间。

但现在的 React 似乎没有以前那么稳定了。所以我想,是不是已经出现了比 React 更好用的产品?不妨试试新产品?


React & Hooks

使用 Hook 导致了很多传统 JS 中根本不会出现的问题——难以优化;在运行时语义方面投入太多,导致其难以向多使用编译器的方向发展。
Evan You
2023 年 2 月 4 日

尽管 Hook 并不等同于 React(你仍然可以使用类组件),但是 Hook 已经成了 React 开发的固定组成部分。本文提到的 React 模型就是“React Hooks”。

一些人惊讶于 React 生态系统向 Hook 的平稳转换,因为大家都一致认为 Hook 带来了很多好处。但我记得不久前针对这个问题还有很多分歧。

我并非完全支持抵制 Hook 的人,但我认为他们的很多担忧很有道理。React Hook 存在于类组件控制的环境中。要实现转换,React 必须与类代码完全兼容!

React 的兼容性,再加上 Hook 带来的可组合性与可读性的优化,使得整个行业以高于预期的速度采用了 Hook。

Hook 确实改进了可组合性和可读性。另外,虽然这种观点没有得到广泛认同,但我坚定的认为 “可组合性高于继承性”,我认为通过函数共享行为是对类继承的极大改进。至于可读性,虽然代码长度可能与可读性没有直接关系(见 Regex代码高尔夫),但 React Hook 改善了“行为的局部性”。

Hook 可以减少查看较小组件中的次数。事件监听器、状态转换和渲染输出等让眼睛应接不暇,React Hook 在这些方面有所改进。另外,函数组件写起来更快,也更易阅读。


可读性与复杂性

可读性本身与复杂性并没有直接的关系,Hook 通过本地化的行为降低了复杂性,但必要的抽象又增加了复杂性。

我经常想到 Amos 这句断章取义的话。

确切来说,这种说法掩盖了这样一个事实:当你试图把某件事情变简单时,你是在把复杂性转移到其他地方。
——Amos (“简单”是一句谎言)

我们对复杂的系统进行抽象化时,并没有消除复杂性,而是将复杂性转移。对于我们来说,复杂的系统不是前端开发,而是 React Hooks 改变了我们的心理模型,我们开始考虑状态转换和同步,而非生命周期。

这种改变对性能产生了一些负面影响,不过这些负面影响通过 Hooks useMemouseCallback 来缓解。我不是说在 Hook 之前,React 没有备忘功能,React 有备忘功能 (React.memo())。我的意思是,由于本地化行为的优化,我们现在必须对状态初始化和转换进行备忘。

社区中对 React 备忘的讨论比其他框架要多得多。值缓存对所有框架都很重要,但 Hook 将很多决策留给了组件作者,而非核心库。

稍后我们会进一步讨论这个问题。在此之前,我想花点时间讨论一下心理模型。

React 文档和 YouTube 视频中都经常出现这种模型,实际行为也与之符合。换句话说,至少存在一个符合实际行为的心理模型。


更好的心理模型

在 React 的讨论中,经常出现“VDOM”这个词。Dan Abramov 似乎并不喜欢它,我赞同他的看法,React VDOM 不应该是我们的关注点。

VDOM 只是 React 的一个结果,而非其原因,这在讨论直接差异时很容易理解。

我们应该将关注点放在 React 组件的“纯净性”上。

当我们想起组件有状态时,这个术语似乎立刻就不一样了。状态似乎违背了纯函数的概念。对于一组给定的输入,无论调用多少次或以何种方式,都会产生相同的输出。

诀窍在于理解 React 的状态不是存储在组件上。

状态是另一种输入

在 React 中,调用 useState 是另一种接收输入的方式。状态存在于 React VDOM/state-tree 上;组件以一种非常有序的方式被调用,useState 会从提供的堆栈中弹出输入。

stateprops 都是输入。调用 setState 是向 React 内部发送信号,不是直接更改。

这些信号会依次更新其组件状态栈,并重新运行一个组件,该组件收到新的输入后会产生一定的输出。

对于 React 来说,React 组件就像一个黑盒子。

它的内部行为无法查看。我们可以将组件本身视为反应式对象,而非单个的状态片段。

这就是人们没有将 React 的反应性模型描述为“细粒度”的原因。

因此,React 需要一种无需在每次更新时重写整个 DOM 的方法——在新的更新上运行一个差异对比过程,确定需要更新的 DOM 节点。

也许没有需要更新的 DOM 节点;也许全部都要更新;不检查就无法知道。

这就是 React。渲染器和调节器之间的关系是一种“纯组件”行为。状态和 DOM 更新之间缺乏直接的联系。

在 React 中,组件是真实的,DOM 则不是。

也许这就是 React 适合非网页渲染器的原因。我们可以用 React 来绘制 UI 和更新,但把更新应用于 UI 的过程换掉。

作为一名 web 开发者,这并不是一个很强大的优点。


寻找陷阱

React 新手最常遇到的就是这些障碍。

在一个组件的顶层进行状态更新将导致无限循环。状态更新会重新运行组件,不是 DOM 的更新,而是另一个状态更新,这将触发另一个重新运行,而重新运行又会触发一个状态更新,如此反复,永不停止。

你很快就会发现这个错误,这不难发现。

当你开始使用 React Context 并开始在父组件中发出更新信号时,事情就更加复杂了。因为会出现渲染级联。有可能一个组件获取了一些数据,一些组件重新挂载,然后你再次运行状态更新时就会出现几秒钟的延迟。

这种情况非常常见,完全可以写一篇文章来详述,但本文我不打算过多延伸。


作为反应对象的组件

我们继续讨论组件作为反应式对象而不是状态存在的问题。这种模式会产生下列后果。

为了便于大家理解,我已经尽量对这个组件进行了简化,只要在 React 中处理过表单的人都知道,它们通常要复杂得多。

我经常看到 300 多行代码的表单组件。

这种组件涉及状态转换、验证和错误视图等方面。其中很多问题都是表单所固有的,不是 React 独有。 但是,React 会增加其复杂程度。

组件是反应性的,不是状态。当使用受控输入时,我们在每一次输入按键都会引起“重新渲染”。因此,无论这些状态是否被触及,都可能运行状态计算代码。

但 VDOM 可以解决所有这些问题!

这似乎是一个对 VDOM 的普遍误解!VDOM 可以避免不必要的 DOM 更新,但不能避免状态计算。

组件是一个函数,每次需要检查更新时,都会被重新运行。虽然 DOM 本身可能未被触及,但代码正在运行,而这些代码本不需要运行。

假设有下面这个组件。

我有一个更实际的例子。我们决定修复提供给我们的标签输入,将它们转化为“Title Case”。

目前很好。我决定不对任何内容进行备忘,因为这个计算很简单。

但如果情况发生变化呢?

如果 toTitleCase 越来越复杂呢?随着时间的推移,我们慢慢增加功能,创造出最终的 Title Caser™️!

const MyForm = () => {
  const [text1, setText1] = useState('');
  const [text2, setText2] = useState('');
  const [text3, setText3] = useState('');

  return <form>
    <MyInput value={text1} onInput={e => setText1(e.currentTarget.value)} />
    <MyInput value={text2} onInput={e => setText2(e.currentTarget.value)} />
    <MyInput value={text3} onInput={e => setText3(e.currentTarget.value)} />
  </form>;
}

每次按键时,我们在每个组件中都重新运行 toTitleCase。使用 useState 后,整个表单组件对其任何状态的变化都会作出反应。

这是个问题吗?浏览器非常快。硬件也非常快。也许这不是问题。

但以后可能会成为问题。

在不同的位置逐步增加计算,不会造成太大的伤害。但一直这样做,就会造成一种迟缓的体验。现在必须面对的问题是:造成性能问题的原因不止一个,它无处不在。解决这个问题需要非常大的工作量。

是不是忘记了 useMemo?


备忘

我当然希望大家在这个问题上都达成了共识,然而,反对备忘的人依然很多。

备忘有性能成本。

Dan Abramov 多次指出,备忘仍然会产生比较道具的成本,而且多数情况下,备忘检查无法避免重新渲染,因为组件总是在收到新的道具。请看 Dan 的这条推文:
——Mark Erikson - React 渲染行为完整指南

评论提到了 React.memo(),说它是 React 中的一种略微不同的备忘形式。

const MyInputMemoized = React.memo(MyInput);

对整个组件进行备忘,可以避免渲染的级联检查其子代。这似乎是一个合理的默认值,但 React 团队似乎认为,比较道具的性能成本超过了大规模渲染级联平均性能成本。

这一点我持反对意见。Mark 似乎同意这个观点

这也让排版看起来更加难看。我看过的大多数代码库都避免使用 React.memo(),除非绝对确定它能显著改善性能。

反对备忘的另一个原因是,当父级代码写得不正确时,React.memo() 很容易失效。

// Memoifying to prevent re-renders
const Child = React.memo(({ user }) => <div>{user.name}</div>);

function Parent2() {
  const user = { name: 'John' };
  // re-renders anyway
  return <Child user={user} />;
}

我们以尽可能最快的方式(浅平等)比较道具。每次重新渲染时都会出现一个新的道具。重新渲染很常见,我们要意识到这一点。

组件是反应性“基元”,我们可以通过在组件中移动状态来解决一些备忘问题。

在创造产品时,我并不喜欢这种讨论。

我说的是 useMemo(),不是 React.memo()

我们来浅谈一下这个问题。

我们对 useMemo() 有相同的性能考量。现在是在比较“依赖”的成本,而非道具。

const value = useMemo(() => {
  const items = dataList
    .map(item => [item, placeMap.get(item)])
    .filter(([item, place]) => itemSet.has(place));

  return pickItem(items, randomizer);
}, [dataList, placeMap, itemSet, pickItem, randomizer]);

不用在代码上花费太多时间。

但你是否注意到一些奇怪的事情?有 2 个离散状态的转换。一个是列表操作;另一个是对结果数据调用一些函数。

我们不小心备忘了太多内容!如果随机器发生变化会出现什么情况?重新运行整个函数,如下:

const items = useMemo(() => {
  return dataList
    .map(item => [item, placeMap.get(item)])
    .filter(([item, place]) => itemSet.has(place))
}, [dataList, placeMap, itemSet]);

const value = useMemo(() => {
  return pickItem(items, randomizer)
}, [items, pickItem, randomizer]);

现在我们的值更具体了,变为 randomizer 不会重新运行 .map.filter,只会修改对 pickItem 的调用。

有救了!?

在进行列表操作时,我习惯自动备忘数据,但我不确定这是否是正确的做法。

这种备忘的最大问题在于有些不美观。我不知道是否该称它为“代码味”(我以前读到过),但它会使代码更难读。

备忘在有些时候会有帮助,前提是在组件的使用和组成上要谨慎。

缓存并不是 React 独有的复杂领域,但我们经常要手动处理很多次。

备忘解决了问题,但要考虑何时何地进行备忘,这会让人感到沮丧,它的有效性很差。

教学

我很关注这个话题。多年来,我一直把研究编程教学法作为爱好。我一直在关注这个问题:

“如何最有效地传达编程概念?”

虽然目前我还不知道怎么有效的传达编程概念,但我知道哪些传达方式是无效的。

过去,React 一直描述为一种简单的组件系统,其状态与 UI 相连并随时间更新。

我有幸给不少人讲解过 React,他们对框架、React 或一般的编码都比较陌生。我发现,React 并不容易,而模糊的教材更增加了它的难度。

如果你已经使用 React 很长时间,可能不需要去学习这些概念和心理模型,但这些对大多数人来说,并不容易理解。

组件会在状态更新时重新渲染,这并不明显。

这是怎么运行的?每个状态的使用没有对应的名字,怎么记住呢?

状态被保存在 VDOM 的一个堆栈中,这也解释了为什么组件的排序很重要。且状态是组件的输入,状态突变是对状态树的信号,状态树会再次调用函数来比较输出差异,你知道以上整个过程吗?

你是随着时间的推移发现的吗?还是读了一篇文章,看了一段视频?

与其替代品相比,React 将状态更新的复杂性作为开发中的一个障碍。

而这些必要的材料大多是作为补充高级课题来教授。

我希望新的 React 文档能改变这种情况。我也希望人们能意识到很多初学者喜欢从视频中获取信息,而不是长篇大论的教程。


修复 React

回看上文关于表格的讨论。

这个问题已经持续了很久,所以表单的最佳实践出现了一些变化。不受控输入是当前的流行趋势。

组件的更新源于状态更新。受控输入会在每次表单交互时强制进行状态更新。如果只是让表单做事情,只需更新提交和验证步骤。

这种模式已经在 Formikreact-hook-form 等表单库中普及。我们可以将:

const [firstName, setFirstName] = useState('');

const onSubmit = data => console.log(data);

return <form onSubmit={handleSubmit(onSubmit)}>
  <Input 
    name="firstName" 
    value={firstName} 
    onInput={e => setFirstName(e.currentTarget.value)} 
  />
</form>

转为:

const { control, handleSubmit } = useForm({
  defaultValues: { firstName: '' }
});

const onSubmit = data => console.log(data);

return <form onSubmit={handleSubmit(onSubmit)}>
  <Controller
    name="firstName"
    control={control}
    render={({ field }) => <Input {...field} />}
  />
</form>

我们增加了一些复杂性,但是我们帮助解决了状态更新对组件影响过大的问题。

但这引出了一个有趣的问题。当审视 React 的生态系统时,我们会发现很多库都是为了修复 React 的缺点而存在的。

当你看到一个库号称速度提升 100 倍并改进了功效学设计时,它们所做的就是避开 React。

Tanner Linsley
@tannerlinsley
讽刺的是,我为 React 构建库时,并没有真正使用 useState、useReducer 等 Hook。
在 react 之外管理状态的最大好处(和陷阱)是,你可以完全控制组件何时应该重新渲染。
2022 年 3 月 18 日

顺便说一下,我并不反对这个观点。看着 UI 渲染器生态系统如此不知疲倦地工作,一边使用它,又一边躲着它真是太有意思了。

关于状态讨论,有几个朋友加入我们,包括 react-redux@xstate/reactZustandJotaiRecoil 等。

状态讨论通常会让人感到沮丧,因为它们通常会掩盖某种形式的 React Context。我们必须按照 React 的规则来触发 UI 更新,所以前面提到的所有库都有某种形式的级联渲染效果。

React 组件不能直接共享状态。因为状态存在于树上,我们只能间接地访问这棵树,所以我们必须爬上爬下,而不是从一个树枝跳到另一个树枝。在这个过程中,我们会接触到本不需要的东西。

const countAtom = atom(0);
const doubleCountAtom = atom(get => get(countAtom) * 2);

const MyComponent = () => {
  const [count, setCount] = useAtom(countAtom);
  const doubleCount = useAtomValue(doubleCountAtom);

  return <button onClick={() => setCount(count + 1)}>
    {count} x 2 = {doubleCountAtom}
  </button>;
}

我们已经使用 Jotai 巧妙地设置了一些派生状态,但将其插入 React 意味着我们回到了基于组件的反应性。

你可以为 React 添加“细粒度”的反应式系统,不需要做太多修改。


细粒度反应

框架集成的细粒度反应性是什么样子的?可能是 Solid.js

function Counter() {
  const [count, setCount] = createSignal(0);

  setInterval(() => setCount(count() + 1), 1000);

  return <div>Count: {count()}</div>;
}

在讨论 React 时提到 Solid 很有趣,因为它的 API 看起来与 React 非常像。主要的不同是我们不需要像这样在 useEffect 中封装代码。

在 React 中,这类代码会导致一个严重的 Bug,即每秒创建一个新的 setInterval 调用。

对于非基于组件的反应式框架,组件之间的区别就消失了。它们在设置和生成 UI 时非常有用。在应用程序生命周期中,状态才是真正重要的。

PreactVueAngularMarkoSolidSlvelte 等框架都采用了某种形式的细粒度反应。它们称之为信号、存储或可观察量。语义差异可能很重要,但我将把这个概念称为信号。

const [headerEl, divEl, spanEl] = getEls();

const nameSignal = signal('John');

nameSignal.subscribe(name => headerEl.textContent = `Name: ${name}`);
nameSignal.subscribe(name => divEl.textContent = name);
nameSignal.subscribe(name => spanEl.textContent = `"${name}"`);

// somewhere in our application
nameSignal.set('Jane') 

这个例子包含了信号——可以感知自身“订阅者”的状态片段。当我们改变这个状态值时,信号将通过传递进来的函数“通知”它的订阅者有一个更新。

在执行更新之前,我们不需要参照最高状态树来比较 UI 输出差异。我们可以直接将状态连接到 UI 更改。

信号也可以通知其他信号。计算状态机仍然存在,只是具备了非常好的功效学设计。

使用反应式原语,你可以在一个小时内构建出自己的框架,并且代码比使用其他反应式模型要好得多。

const num1 = signal(0), num2 = signal(0);
const total = computed(() => num1.value + num2.value);

const inputEl1 = create('input').bind('value', num1);
const inputEl2 = create('input').bind('value', num2);
const outputEl = create('input').bind('textContent', total);

get('body').append(inputEl1, ' + ', inputEl2, ' = ', outputEl);

就像 Rustaceans 反对任何没有内存安全的新语言一样,我反对任何没有信号的新框架。

我们在 WASM 战争中也发现了类似的战斗。Yew 是最突出的 Rust 前端框架,但它依赖于类似 React 的方法。它的性能仅略优于 React,而基于信号的 Rust 框架(如 LeptosSycamore)超过了 Angular 和 Slvelte。


结论

虽说如此,但我认为只看框架基准测试是不够的。

React 的效果很差。

使用 React 比使用 Slvelte 更容易出错。当然,超优化的 React 只比其他框架差一点点,但我不写超优化的代码。因此,在实践中,我看到的 React 代码往往每个文件都有十几个性能问题,出于理智,我们忽略了这些问题。

React 刚发布时非常棒!但现在,我们有了更好的选择,客观情况基本就是这样。虽然随着时间的推移会有所改进,但我不认为 React 会从根本上改变它的工作机制,让它再次变得可以忍受。

那么,为什么我们还在使用 React 呢?

1. 因为它经过了现实的考验!

  • 很多大公司已经证明它的有效性!
  • 当你看到使用特定技术的成功产品时,更容易做出决定。

2. 不断进化的生态系统

  • 技术上讲,是这样的,但是有一半的生态系统要么是作为普通库的 React 封装器而存在,要么是作为 React 缓解包而存在。
  • 不同的反应性模型意味着通常更容易插入 React 之外的第三方库。

3. 更充足的劳动力

  • 这一点很难反驳。如果你想要一份工作,最好的选择是 React。如果你想招聘,最好的选择也是 React。
  • 尽管我认为其他的框架更简单,但培训工程师需要时间和带宽。

4. 它在不断发展进化

  • 当“解决方案”就在眼前时,要做出改变很难。
  • 几乎“全栈”都在演进,但每一个新产品都是作为 React 所有弊病的解决方案而出现。

5. 很难离开

  • 预期收益抵不上迁移成本。
  • 它的反应性模型非常独特,迁移到另一个框架需要花费大量的时间,而且改进效果并不明显。

因此,我现在的工作是 React 开发,我的下一份工作也会是 React 开发。之后的工作很可能还是。


原文作者:EmNudge
原文链接:https://emnudge.dev/blog/react-hostage
推荐阅读
相关专栏
前端与跨平台
90 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。