查看原文
其他

我被 React 劫持了,很痛苦又离不开

前端之巅 2023-04-28


作者 | EmNudge 译者 | 平川 策划 | 褚杏娟  
前言

如果几年前我写的这篇文章,可能会被视为亵渎。但如今越来越多的人表达了对 React 的不满,我终于可以表达我的观点了。坦率地说,我不太喜欢 React,大多数人也是这样的想法,即使你现在还没有任何不满情绪。

我已经使用 React 很长时间了,但实际上我更喜欢其他替代方案。尽管我是一名“React 开发人员”,过去用它,现在用它,可能将来还会用它,我似乎无法完全摆脱它。也许你认为我会在一段时间后不再关注,但一旦看到其他选项,我就会感到困惑,不知道为什么自己仍然继续使用 React。

当 React 刚出现时,一切都显得如此优雅。组件代码注重 行为局部性。DOM 是内联的,就在事件处理程序旁边。代码看起来是模块化的,很整洁。

类组件

class MyComponent extends React.Component {
constructor() {
super();
this.setState({ num: 0 });
}

handleClick = () =>
this.setState({ num: this.state.num + 1});

render() {
return (
<button onClick={this.handleClick}>
Clicked {this.state.num} times!
</button>
)
}
}

随着教程的发布、著作的出版以及大师的诞生,React 以前所未有的速度建立起了自己的帝国。它提供了无与伦比的开发体验,但更好的是,容易理解。最初尝试 React 的是活跃分子、思想影响者以及我们中最为书呆子的人。这个库很快就流行起来了,这也不难理解,因为它的语法非常简单。

于是我们都加入了进来。然后,我们有了钩子(Hooks)!万岁!说实话,它们一开始让人感到有些陌生。但那种清新的感觉!React 变得更加优雅了!它使输入更快、迭代更快、阅读更容易。React 是正确的选择。

函数组件

const MyComponent = () => {
const [num, setNum] = useState(0);
const handleClick = () => setNum(num + 1);

return (
<button onClick={handleClick}>
Clicked {num} times!
</button>
)
}

几年过去了,我们开始注意到,有许多其他可选项出现了。每个月都会有新的名称出现。当时我们嗤之以鼻,认为这些新潮项目永远不会流行起来!我们认为 React 已经经过战斗考验,完美地满足了我们的需求,其他库没有留下任何空间。

然后人们开始说话过于积极,过于一致。感觉每个人都对他们的新东西感到满意,对旧东西感到不满意。但那些只是过眼云烟的潮流!从 2015 年发布到 2019 年的迭代版本,没有什么能像 React 那样经得起时间的考验,而且 React 仍在不断发展。

你知道的,React 感觉不再那么稳定了。我是说,或许它是最好的选择,但谁知道呢?我以为我们已经完成了迭代,但还是不断地被一些新变化所吸引。也许还有更好的方法,只是我还没看到!看看也无妨。

React 与 Hooks

尽管 Hooks 并不是 React 本身的必要组成部分,你仍然可以使用类组件,但 Hooks 已经成为了 React 生态系统中不可或缺的一部分。本文所提到的 React 模型指的就是“React Hooks”。

最近,我注意到人们对 React 生态系统向 Hooks 的平稳转换感到惊讶,因为现在每个人都一致认为 Hooks 带来了许多好处。然而,我还记得不久前在这个问题上曾有过激烈的争论。

我不是非要和那些反对 React Hooks 的人站在一起,但我确实认为他们的担忧是有一定道理的。React Hooks 存在于一个由类组件控制的环境中,为了实现这种转换,React 必须与类代码完全兼容,而事实也确实如此。这种兼容性,再加上 Hooks 所带来的可组合性和可读性的提升,使得整个行业采用 Hooks 的速度超出了预期,这也是 Hooks 带来的两项重要改进。

我坚信,“可组合性优于继承”,虽然不是所有人都同意这种观点,但我认为通过函数共享行为是对类继承的巨大改进。至于可读性,虽然代码的长度可能与易读性没有直接关系(例如 Regex、代码高尔夫),但 React Hooks 提高了代码的“行为局部性”。

这意味着,Hooks 可以减少查看那些比较小的组件的次数。事件监听器、状态转换和渲染输出等细节让人应接不暇,而 React Hooks 对此进行了改进。我发现,函数组件写起来更快,读起来也更容易。

可读性与复杂性

但乍看起来,可读性本身与复杂性并没有直接的联系(至少直觉上是这样)。Hooks 通过本地化行为降低了复杂性,但必要的抽象又增加了复杂性。

我经常想起 Amos 那句断章取义的话。

或者更确切地说,那个说法半真半假,它掩盖了这样一个事实:当你把某件事情变得简单时,其实是把复杂性转移到了别处。

Amos(简单就是个谎言)

当我们抽象复杂的系统时,不是在消除复杂性,而是在转移它。在我们的例子中,复杂系统不是 前端开发,而是 React。

Hooks 改变了我们的心理模型,它让我们考虑状态转换和同步,而不是生命周期。或者,至少它的目的是这样的。

componentDidMount → useEffect(func, [])
componentWillUnmount → useEffect(() => func, [])
componentDidUpdate → useEffect(func, [props])

这一举措有一些性能上的损失——这个问题可以通过 Hooks useMemouseCallback得到缓解。我并不是说在 Hooks 出现之前,React 中不存在记忆化(memoization)。它是存在的(React.memo())。我是说,由于本地化行为的改进,我们现在必须记住状态初始化和状态转换。

社区中经常会有关于 React 记忆化的讨论,而且与其他框架相比,这类讨论更多。在所有框架中,值缓存都很重要,但是 Hooks 将很多决策留给了组件作者,而不是核心库。

我们稍后会详细介绍。但在此之前,我想花点时间讨论一下 心理模型

不管是 React 的文档,还是 YouTube 上的视频,都经常提到这个模型,这也是实际上正在发生的事。或者说,至少存在一种更符合实际行为的心理模型,我认为这一点很重要。

更好的心理模型

在关于 React 的讨论中,经常提及“VDOM”这个术语。然而,Dan Abramov 似乎不喜欢它。我赞同他的看法,因为 VDOM 并不是 React 的决策因素,而是其结果。这一点在讨论直接差异时也很容易理解。

因此,我们应该将重点放在如何保持 React 组件的“纯净性”上,而不是关注 VDOM。一想到组件有状态,这个术语似乎立马就不合时宜了。因为状态似乎与纯函数的概念不符——对于给定的一组输入,无论调用多少次,无论以何种方式调用,输出都应该相同。

纯函数

// 纯函数
const getPlusOne = (num) => num + 1;

// 非纯函数
const getDateNow = () => Date.now();

关键在于理解 React 中的状态并不存储在组件中。

状态是另一种输入。

在 React 中,调用useState是另一种接收输入的方式。状态存在于 React VDOM/ 状态树中。组件有严格的调用顺序,useState将从提供的栈中弹出输入。

state & props = inputs

const Component = ({ color }) => {
const [num1] = useState(0); // receive next state argument
const [num2] = useState(0); // receive next state argument
const [num3] = useState(0); // receive next state argument

return <div>{num1} + {num2}</div>
}

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

这些信号将依次更新其组件 状态栈 并重新运行组件。给这个组件提供新的输入,它将产生一个特定的输出。

React 组件对 React 来说也可能是个黑盒,因为其内部行为是不可见的。相反,我们可以将组件本身视为反应式对象,而不是单个状态块。这也是为什么人们认为 React 的反应模型不是“细粒度”的原因。

因此,React 需要一种方法来避免在每次更新时重写整个 DOM。因此,它会在新的更新上运行一个差异对比过程,以判断哪个 DOM 节点需要更新。也许没有不同之处。也许全不相同。不检查就没法知道。

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

在 React 中,组件是真实的,而 DOM 不是。也许这就是为什么 React 对于 非 Web 渲染器 来说是一个很好的选择。我们可以使用 React 来绘制 UI 和更新,但把更新应用到 UI 的过程替换掉。

作为一名 Web 开发人员,这算不上是一个多大的好处。

发现陷阱

作为一名 React 新手,你最先遇到的障碍会是类似这样的东西。

死循环

function MyComponent() {
const [num, setNumber] = useState(42);

// 死循环
setNumber(n => n + 1);

return <div>{num}</div>
}

试图在组件的顶层进行状态更新将导致无限循环。状态更新会重新运行组件。这并不意味着 DOM 更新,但确实意味着另一个状态更新,状态更新将再次触发重新运行,重新运行将再次触发状态更新,再次触发重新运行,就这样不断进行下去。

可能你很快就会发现这个 Bug。像这样的无限循环并不难发现。

当你开始使用 React Context 并在父组件中发出更新信号时,情况会变得更加复杂,因为会出现级联渲染。这意味着一些组件可能需要重新挂载,导致状态更新被延迟数秒钟。这种情况很常见,值得单独写一篇文章来深入讨论如何修复 React 中的这个问题。不过在本文中,我并不打算探讨这个问题。

作为反应式对象的组件

我们将继续讨论作为反应式对象而不是状态存在的组件。这种模式有一些后果。

模拟表单组件

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

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

为了让你感到轻松一些,我对这个组件进行了大幅简化。然而,任何使用过 React 表单的人都知道,它们通常会更加复杂。

我曾经看到一些表单组件达到了 300 多行的代码量。这些组件涉及到状态转换、验证和错误视图等多个方面。但是,很多特性都是表单本身固有的,而不是 React 独有的。只是,React 往往会使事情变得更加复杂。

需要记住的是,组件是反应式的,而不是状态本身。当使用 受控输入 时,每次按键输入都会导致“重新渲染”,这意味着不管是否触及状态,都可能运行状态计算代码。

VDOM 可以解决所有问题!

这似乎是一种流行的 VDOM 误解。VDOM 的作用是避免不必要的 DOM 更新,而不是避免状态计算。

组件是一个函数,每次需要检查更新时,它都会重新运行。虽然可能没有触及 DOM,但代码仍然会被执行,而实际上它并不需要被执行。

设想有下面这样一个组件。

自定义输入封装器

const MyInput = ({ label, value, onInput, isError, errorText }) => {
const labelText = label ? toTitleCase(label) : 'Text Input';

return <>
<label>
<span>{labelText}</span>
<input value={value} onInput={onInput} />
</label>
{isError && <div className="error">{errorText}</div>}
<>;
}

我认为这个例子更加贴切。对于提供的标签输入,我们决定将它们转换为“首字母大写(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>;
}

现在,每次按键时,每个组件都会重新运行toTitleCaseuseState使得整个表单组件可以对任何状态变化做出反应!

噢,不!

我是说,这有问题吗?浏览器的速度非常快。硬件的速度也非常快。也许这不是问题。

好吧,直到它成了问题。

在不同的地方逐步增加计算不会造成太大的伤害。但一直这样做下去,最终就会变得缓慢。现在,你必须面对这样一个问题:性能问题的源头不止一个——哪都可能导致性能问题。解决这个问题所要做的工作会远远超出你的想象。

你是不是忘了 useMemo ?

啊,是的。那……

记忆化

在这个问题上,我无疑希望人们达成了共识。然而,有一篇支持记忆化的文章,就会有一篇反对记忆化的文章。

要知道,记忆化有性能成本。

Dan Abramov 反复指出,记忆化仍然会产生比较 props 的成本,并且在许多情况下,记忆化检查永远无法阻止重新渲染,因为组件总会接收新的 props。例如,可以看下 Dan 的推特讨论。

—— Mark Erikson(React 渲染行为(大部分)完整指南)

讨论提到了 React.memo(),这是 React 中一种略有不同的记忆化形式。

const MyInputMemoized = React.memo(MyInput);

记忆化整个组件可以使渲染级联不再检查它的子组件。把这个作为默认设置似乎很合理,但 React 团队似乎认为,比较 props 的性能成本超过了大规模渲染级联的平均性能成本。

我觉得那可能是错的。Mark 似乎同意这个观点。

这也使得排版看起来更加丑陋。我见过的大多数代码库都倾向于避免使用React.memo(),除非非常确定它可以显著提高性能。

另一个反对记忆化的论据是,当父代码编写不正确时,React.memo()很容易失效。

模拟的 React.memo() 示例

// 记忆化以防止重新渲染
const Child = React.memo(({ user }) => <div>{user.name}</div>);

function Parent2() {
const user = { name: 'John' };
// 无论如何重新渲染
return <Child user={user} />;
}

我们用速度最快的方式(浅相等)比较 props。每次重新渲染看起来都像一个新的 prop。由于重新渲染很常见,所以我们需要注意这一点。

这里,组件是反应式“原语”,我们可以通过 在组件中移动状态 来修复一些记忆化问题。

在创建一个产品时,我并不是很喜欢这种讨论。

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

对于 useMemo(),我们也有同样的性能考量。现在,成本变成了比较“依赖关系”,而不是 props。

模拟的记忆化示例

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]);

不要在上面的代码上花太多时间。那只是为了演示。

但是你有没有注意到,事情有些怪异?有两个单独的状态变换。一个是列表操作,另一个是在结果数据上调用某个函数。

我们不小心记忆化了过多内容!如果randomizer  变了会发生什么?重新运行整个函数?!我们应该写成下面这样。

恰当的记忆化

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 时间较长的人来说,我们所讨论的概念和心理模型可能不再需要解释。然而,对于大多数人来说,这些概念并不那么容易理解。

例如,组件在状态更新时会重新渲染,但这并不是显而易见的。状态的使用并没有对应的名称,那么它是如何保存的呢?

实际上,状态保存在 VDOM 栈上,这也解释了为什么组件的顺序很重要。状态是组件的输入,而状态突变是向树发送信号,由树再次调用函数来比较输出差异。你是否理解了这个过程呢?

随着时间的推移,你是否也注意到了这些问题呢?也许你读了一篇文章,看了一段视频,或者你比我聪明很多。

与其他同时代的替代方案相比,状态更新的复杂性在 React 开发中经常成为一个障碍。然而,这些必备的概念却往往是被作为 高级主题 来教授的。

在这方面,我希望新的 React 文档 会做出改变。我也希望人们能够认识到,有很多初学者更喜欢通过视频来获取信息,而不是冗长的教程。

修复 React

我想重新探讨下这个表单。

这种痛苦由来已久,以至于表单最佳实践出现了一些变化。不受控输入现在大行其道。

组件更新源于状态更新。受控输入会强制对每个表单交互进行状态更新。如果只是为了让表单可以做任何事,那么只需更新提交和验证步骤即可。

这种模式在 Formik 和 react-hook form 等表单库中得到了推广。我们可以将

纯 React 表单

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>

转换成 react-hook-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。

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

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

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

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

Jotai 示例

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 这样的东西。

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 时非常有用。在应用程序生命周期中,状态才是真正重要的。

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

reactive 数据存储(信号)

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}"`);

// 在应用程序中的某个地方
nameSignal.set('Jane')

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

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

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

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

reactive mini-framework

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 框架(如 Leptos 和 Sycamore)超过了 Angular 和 Slvelte。

问题总结

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

React 的功效学设计很差。

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

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

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

  1. 经过实战检验

    1. 大公司已经证明可以把它应用于生产。

    2. 当你看到使用特定技术的产品取得了成功时,就更容易做出决定。


  2. 生态成熟

    1. 技术上讲,是这样的,但是有一半的生态系统要么是作为普通库的 React 封装器而存在,要么是作为 React 缓解包而存在。

    2. 非凡的反应模型通常意味着插入 React 之外的第三方库更容易。

  3. 劳动力更充足

    1. 这一点似乎无法反驳。如果你想要一份工作,最好的选择就是 React。如果你想雇佣员工,最好的选择也是 React。

    2. 虽然我认为教授其他框架通常更容易,但只有当你有足够的时间和能力来培训工程师时,这才有意义。

  4. 还在演进

    1. 当“解决方案”就在眼前时,要做出改变很难。

    2. 几乎“全栈”都在演进,但每一个新产品都是作为 React 所有弊病的解决方案而出现。

  5. 很难离开

    1. 预期收益抵不上迁移成本。

    2. 它的反应模型非常独特,以至于迁移到另一个框架需要花费大量的时间,无法立即获得明显的改进。

    我现在的工作是 React 开发。我的下一份工作还将是 React 开发。再下一份也可能是。

    声明:本文为 InfoQ 翻译,未经许可禁止转载。

    原文链接:https://emnudge.dev/blog/react-hostage

欢迎参与讨论

3 月 17 日,React 正式发布了全新的 React 官方文档,官网的首选框架建议是 Next.js 或 Remixjs ,这引发了社区的积极讨论,你对此有何看法,欢迎留言。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存