[关闭]
@levinzhang 2022-09-25T10:40:51.000000Z 字数 10934 阅读 485

React: 我爱你,但是你越来越让我失望了

摘要

React是一个有着近十年历史的优秀框架,但是在不断的版本迭代中,它也积累了不少的问题,比如表单处理、DOM访问、Hook规则等,作者对这些影响开发效率和性能的问题进行了形象化的阐述。


本文最初发表于Marmelab网站,由InfoQ中文站翻译分享。

亲爱的React.js

我们在一起已经快十年了,我们携手走过了漫长的旅程。但是,事情正在变得越来越糟糕,我们真的需要谈谈了。

这确实有点令人尴尬,我知道,没人愿意进行这样的谈话,所以我就以歌曲的形式来进行表达吧。(作者的每一个标题都是一首英文歌的名称,在此我们不做翻译——译者注)

You Were The One

我并不是JS方面的新手。在遇到你之前,我已经和jQuery、Backbone.js以及Angular.js打过很久的交道。我知道可以从JavaScript框架中得到什么:更好的用户界面,更高的生产力,以及更流畅的开发体验。但是,这也意味着我不得不改变我对代码的思考方式,以匹配框架的思维模式,这会带来一定的挫败感。

当我遇见你的时候,我刚刚结束了与Angular.js的一段长期感情。我已经被它的watchdigest搞得焦头烂额,更不用提scope了。我正在寻找不会让我感到如此痛苦的东西。

我对你一见钟情。相对于其他的方案,你的单向数据绑定让我感到惊艳。我之前遇到的数据同步和性能等一系列问题在你身上根本就不存在。你纯粹基于JavaScript,而不是在HTML元素中以字符串的形式进行笨拙的表述。你拥有“声明式组件”,它实在太迷人了,吸引了所有人的目光。

当然,你并不易于相处。为了与你保持和谐,我不得不改变自己的编码习惯,但这都是值得的。最初,我对你非常满意,以至于我一直向所有的人介绍你。

Heroes Of New Forms

当我开始要求你处理表单的时候,事情就开始变得不对劲了。在vanilla JS中,处理表单和输入域是很困难的,但是在React中,则是难上加难。

首先,开发人员必须在受控和非受控输入之间做出选择。两者各有其缺点,在一些极端情况下都有缺陷。但是,归根到底我们为什么要从中进行选择呢?两种形式都要难道不好吗?!

“推荐”方式是使用受控组件,但它超级繁琐。如下显示了实现一个加法功能的表单需要的代码。

  1. import React, { useState } from 'react';
  2. export default () => {
  3. const [a, setA] = useState(1);
  4. const [b, setB] = useState(2);
  5. function handleChangeA(event) {
  6. setA(+event.target.value);
  7. }
  8. function handleChangeB(event) {
  9. setB(+event.target.value);
  10. }
  11. return (
  12. <div>
  13. <input type="number" value={a} onChange={handleChangeA} />
  14. <input type="number" value={b} onChange={handleChangeB} />
  15. <p>
  16. {a} + {b} = {a + b}
  17. </p>
  18. </div>
  19. );
  20. };

如果只有两种方式的话,我还会很开心。但是,构建一个真正的表单需要默认值、检验、输入依赖和错误信息等功能,这需要大量的代码,所以我不得不使用第三方框架。这些框架各有各的毛病。

当使用Redux的时候,Redux-form看上去是一个很自然的选择,但后来它的主要开发人员放弃了它,然后建立了React-final-form,这个框架全是未解决的缺陷,而且其主要的开发人员又放弃了它。所以,我又看了一下Formik,它很流行,但它是一个重量级的框架,大型表单运行缓慢并且特性有限。所以,我决定使用React-hook-form,它很快,但是有隐藏的缺陷,而且其文档就像迷宫一样。

在使用React构建表单多年之后,我依然努力使用易读的代码为用户提供强大的用户体验。当我看到Svelte是如何处理表单的时候,我瞬间觉得我一直被错误的抽象所羁绊。请看下面这个执行加法功能的表单。

  1. <script>
  2. let a = 1;
  3. let b = 2;
  4. </script>
  5. <input type="number" bind:value={a}>
  6. <input type="number" bind:value={b}>
  7. <p>{a} + {b} = {a + b}</p>

You're Too Context Sensitive

我们见面不久之后,你就向我介绍了你的小宠物Redux。没有它,你什么都做不了。起初我并不介意,因为它确实很可爱。但是,后来我意识到所有的一切都在围绕它来构建。而且,在构建框架的时候,它让我的生活变得更加困难,其他的开发人员很难使用现有reducer来调整应用。

似乎你也注意到了这一点,于是决定摆脱Redux,转而使用自己的useContext。只不过,useContext缺少了Redux的一个关键特性,那就是响应上下文中局部变更的能力。在性能上,二者是不能同日而语的。

  1. // Redux
  2. const name = useSelector(state => state.user.name);
  3. // React context
  4. const { name } = useContext(UserContext);

在第一个样例中,该组件只会在用户名发生变化的时候进行重新渲染。但是在第二个样例中,当用户的任何部分发生变更都会导致重新渲染。这一点很重要,以至于我们不得不拆分上下文以避免不必要的重新渲染。

  1. // 这种写法看上去非常疯狂,但是我们别无选择
  2. export const CoreAdminContext = props => {
  3. const {
  4. authProvider,
  5. basename,
  6. dataProvider,
  7. i18nProvider,
  8. store,
  9. children,
  10. history,
  11. queryClient,
  12. } = props;
  13. return (
  14. <AuthContext.Provider value={authProvider}>
  15. <DataProviderContext.Provider value={dataProvider}>
  16. <StoreContextProvider value={store}>
  17. <QueryClientProvider client={queryClient}>
  18. <AdminRouter history={history} basename={basename}>
  19. <I18nContextProvider value={i18nProvider}>
  20. <NotificationContextProvider>
  21. <ResourceDefinitionContextProvider>
  22. {children}
  23. </ResourceDefinitionContextProvider>
  24. </NotificationContextProvider>
  25. </I18nContextProvider>
  26. </AdminRouter>
  27. </QueryClientProvider>
  28. </StoreContextProvider>
  29. </DataProviderContext.Provider>
  30. </AuthContext.Provider>
  31. );
  32. };

当我遇到性能问题的时候,大多数情况都是因为庞大的上下文,我别无选择,只能对其进行拆分。

我不想使用useMemouseCallback。因为重新渲染的问题是你造成的,而不是我。但是,你却强迫我这样做。请看一下,理想情况下我是如何构建一个简单而快速的表单的吧:

  1. // from https://react-hook-form.com/advanced-usage/#FormProviderPerformance
  2. const NestedInput = memo(
  3. ({ register, formState: { isDirty } }) => (
  4. <div>
  5. <input {...register('test')} />
  6. {isDirty && <p>This field is dirty</p>}
  7. </div>
  8. ),
  9. (prevProps, nextProps) =>
  10. prevProps.formState.isDirty === nextProps.formState.isDirty,
  11. );
  12. export const NestedInputContainer = ({ children }) => {
  13. const methods = useFormContext();
  14. return <NestedInput {...methods} />;
  15. };

都已经十年了,这个缺陷依然还存在。我想问一下,提供一个useContextSelector能有多难呢?

你当然意识到了这一点。但你在顾左右而言他,即便大家都知道这是你最重要的性能瓶颈。

I Want None Of This

你跟我说,我不应该直接访问DOM,这都是为我好。我从来不认为DOM是多脏的东西,但是它却让你坐立不安,所以我就听你的了。现在,按照你的要求,我不得不使用ref

但是ref这东西很快就像病毒一样四处传播。大多数时候,当某个组件使用ref的时候,它会将其传递到子组件中,如果第二个组件是React组件,它必须要将ref转发至另一个组件,以此类推,直到树中的某个组件渲染HTML元素为止。所以,代码中到处都是转发ref的代码,降低了代码的易读性。

转发ref本可以非常简单:

  1. const MyComponent = props => <div ref={props.ref}>Hello, {props.name}!</div>;

但是,这不行,这太简单了,于是你发明了react.forwardRef这个可恶的玩意儿。

  1. const MyComponent = React.forwardRef((props, ref) => (
  2. <div ref={ref}>Hello, {props.name}!</div>
  3. ));

你可能会问,为什么这么难呢?这是因为我们无法使用forwardRef构建一个通用组件(在Typescript语言下)

  1. // 我该如何使用forwardRef呢?
  2. const MyComponent = <T>(props: <ComponentProps<T>) => (
  3. <div ref={/* pass ref here */}>Hello, {props.name}!</div>
  4. );

此外,你认为ref不仅仅适用于DOM节点,还等价于函数组件的this。换句话说,“不触发重新渲染的状态”。按照我的经验,每次我不得不使用ref的时候,都是因为你,因为你那诡异的useEffect API。也就是说,ref是你创造出来的问题的解决方案。

The Butterfly (use) Effect

说到useEffect,我本人对它有一个疑问。我承认它是优雅的创新,它在一个统一的API中,涵盖了挂载、卸载和更新事件。但是,这怎么能算是进步呢?

  1. // 使用生命周期回调
  2. class MyComponent {
  3. componentWillUnmount: () => {
  4. // 执行某些操作
  5. };
  6. }
  7. // 使用useEffect
  8. const MyComponent = () => {
  9. useEffect(() => {
  10. return () => {
  11. // 执行某些操作
  12. };
  13. }, []);
  14. };

看,就这一行代码就反应了我对useEffect的忧虑。

  1. }, []);

我看到我的代码中到处都是这种难以理解的格式,而这些都是因为useEffect。另外,你还强迫我跟踪依赖,比如这段代码:

  1. // 如果没有数据的话,对页面进行变更
  2. useEffect(() => {
  3. if (
  4. query.page <= 0 ||
  5. (!isFetching && query.page > 1 && data?.length === 0)
  6. ) {
  7. // 查询不存在的页数时,将页数设置为1
  8. queryModifiers.setPage(1);
  9. return;
  10. }
  11. if (total == null) {
  12. return;
  13. }
  14. const totalPages = Math.ceil(total / query.perPage) || 1;
  15. if (!isFetching && query.page > totalPages) {
  16. // 查询范围之外的页数时,将页数设置为最后一页
  17. // 这种情况会在删除最后一页的最后一条数据时出现
  18. queryModifiers.setPage(totalPages);
  19. }
  20. }, [isFetching, query.page, query.perPage, data, queryModifiers, total]);

看到最后一行了吗?我必须在依赖数组中包含所有的反应式变量(reactive variable)。我以前还认为对于支持垃圾收集的所有语言来说,引用计数是一项原生提供的功能,但是并非如此,我必须对依赖关系进行微观管理,因为你不知道该怎样进行处理。

而且,在很多情况下,其中的某项依赖是我创建的函数。因为你没有区分变量和函数,我必须通过useCallback告诉你,防止进行重新渲染。同样的结果,同样诡异的方法签名:

  1. const handleClick = useCallback(
  2. async event => {
  3. event.persist();
  4. const type =
  5. typeof rowClick === 'function'
  6. ? await rowClick(id, resource, record)
  7. : rowClick;
  8. if (type === false || type == null) {
  9. return;
  10. }
  11. if (['edit', 'show'].includes(type)) {
  12. navigate(createPath({ resource, id, type }));
  13. return;
  14. }
  15. if (type === 'expand') {
  16. handleToggleExpand(event);
  17. return;
  18. }
  19. if (type === 'toggleSelection') {
  20. handleToggleSelection(event);
  21. return;
  22. }
  23. navigate(type);
  24. },
  25. [
  26. // 天啊,真不想这么做
  27. rowClick,
  28. id,
  29. resource,
  30. record,
  31. navigate,
  32. createPath,
  33. handleToggleExpand,
  34. handleToggleSelection,
  35. ],
  36. );

如果一个简单组件有多个事件处理器和生命周期回调的话,代码瞬间就会变得乱七八糟,因为我必须要管理这个像地狱似的依赖关系。所有的这一切都是因为你决定一个组件可以执行任意多次。

举例来说,如果我想要实现一个计数器,每过一秒以及用户每次点击按钮时,它都会增加,我必须这样实现:

  1. function Counter() {
  2. const [count, setCount] = useState(0);
  3. const handleClick = useCallback(() => {
  4. setCount(count => count + 1);
  5. }, [setCount]);
  6. useEffect(() => {
  7. const id = setInterval(() => {
  8. setCount(count => count + 1);
  9. }, 1000);
  10. return () => clearInterval(id);
  11. }, [setCount]);
  12. useEffect(() => {
  13. console.log('The count is now', count);
  14. }, [count]);
  15. return <button onClick={handleClick}>Click Me</button>;
  16. }

如果我能知道如何跟踪依赖的话,那么代码就可以简化成这个样子:

  1. function Counter() {
  2. const [count, setCount] = createSignal(0);
  3. const handleClick = () => setCount(count() + 1);
  4. const timer = setInterval(() => setCount(count() + 1), 1000);
  5. onCleanup(() => clearInterval(timer));
  6. createEffect(() => {
  7. console.log('The count is now', count());
  8. });
  9. return <button onClick={handleClick}>Click Me</button>;
  10. }

实际上,上面就是合法的Solid.js代码。

最后,想要高效地使用useEffect需要阅读一篇53页的文章。我必须说,那是一篇非常棒的文档。但是,如果一个库需要翻阅几十页文档才能正确使用它,这难道不正是它设计得不好的一个标志吗?

Makeup Your Mind

既然我们已经谈到了useEffect这个糟糕的抽象概念,你确实也在尝试改善它,并提出了useEventuseInsertionEffectuseDeferredValueuseSyncWithExternalStore以及其他吸引眼球的东西。

它们确实使你变得更漂亮了:

  1. function subscribe(callback) {
  2. window.addEventListener('online', callback);
  3. window.addEventListener('offline', callback);
  4. return () => {
  5. window.removeEventListener('online', callback);
  6. window.removeEventListener('offline', callback);
  7. };
  8. }
  9. function useOnlineStatus() {
  10. return useSyncExternalStore(
  11. subscribe, // 只要传递相同的函数,React不会解除订阅
  12. () => navigator.onLine, // 如何获取客户端的值
  13. () => true, // 如何获取服务器的值
  14. );
  15. }

但这对我来讲,这就是狗尾续貂。如果反应式effect更易于使用的话,我们根本没有必要增加其他的hook。

换句话说,随着时间的推移,除了不断增加核心API之外,你别无选择。对于像我这样要维护巨大代码库的人来说,这种持续的API膨胀是一个噩梦。看到你每天涂脂抹粉,这反过来就是在不断提醒你,想想你在试图掩饰些什么呢。

Strict Machine

你的hook是一个很好的主意,但它们是有成本的。这就是Hook规则。它们很难记,难以付诸实践。但是,它们迫使我们必须在不必要的代码上耗费时间。

例如,我有一个“inspector”组件,终端用户可以将它拖来拖去。用户也可以隐藏它。当隐藏时,inspector组件不会渲染任何东西。所以,定义组件时我希望“尽早离开”,避免无谓的注册事件监听器。

  1. const Inspector = ({ isVisible }) => {
  2. if (!isVisible) {
  3. // 尽早离开
  4. return null;
  5. }
  6. useEffect(() => {
  7. // 注册事件处理器
  8. return () => {
  9. // 解除事件处理器
  10. };
  11. }, []);
  12. return <div>...</div>;
  13. };

但是,这样是不行的,因为这违反了Hook规则,useEffect hook是否执行取决于props。所以,我必须在所有的effect上添加一个条件,使其能够在isVisible属性为false时尽早离开:

  1. const Inspector = ({ isVisible }) => {
  2. useEffect(() => {
  3. if (!isVisible) {
  4. return;
  5. }
  6. // 注册事件处理器
  7. return () => {
  8. // 解除事件处理器
  9. };
  10. }, [isVisible]);
  11. if (!isVisible) {
  12. // 不像前文那样,进入之后立即离开
  13. return null;
  14. }
  15. return <div>...</div>;
  16. };

因此,所有的effect在它们的依赖关系中都要有isVisible属性,并且可能会频繁运行(这会损害性能)。我知道,我应该创建一个中间组件,如果isVisible为false,就不渲染。但我凭什么要这样做呢?这只是Hook规则妨碍我的一个例子,我还有很多其他的例子。这样带来的后果就是,我的React代码库中有很大一部分都是用来满足Hook规则的。

Hook规则是实现细节导致的结果,也就是你为hook所选择的实现。但是,它并非必须要这样。

You've Been Gone Too Long

你从2013年就开始存在了,而且尽可能地保持了向后兼容性。为此我要感谢你,这也是我能够与你构建一个庞大代码库的原因之一。但是,这种向后兼容性是有代价的,文档和社区资源往好了说是过时的,往坏了说就是有误导性的。

例如,当我在StackOverflow上搜索“React mouse position”时,第一个结果建议使用如下的解决方案,而这个解决方案在一个世纪前就已经过时了:

  1. class ContextMenu extends React.Component {
  2. state = {
  3. visible: false,
  4. };
  5. render() {
  6. return (
  7. <canvas
  8. ref="canvas"
  9. className="DrawReflect"
  10. onMouseDown={this.startDrawing}
  11. />
  12. );
  13. }
  14. startDrawing(e) {
  15. console.log(
  16. e.clientX - e.target.offsetLeft,
  17. e.clientY - e.target.offsetTop,
  18. );
  19. }
  20. drawPen(cursorX, cursorY) {
  21. // Just for showing drawing information in a label
  22. this.context.updateDrawInfo({
  23. cursorX: cursorX,
  24. cursorY: cursorY,
  25. drawingNow: true,
  26. });
  27. // Draw something
  28. const canvas = this.refs.canvas;
  29. const canvasContext = canvas.getContext('2d');
  30. canvasContext.beginPath();
  31. canvasContext.arc(
  32. cursorX,
  33. cursorY /* start position */,
  34. 1 /* radius */,
  35. 0 /* start angle */,
  36. 2 * Math.PI /* end angle */,
  37. );
  38. canvasContext.stroke();
  39. }
  40. }

当我为某个特定的React特性寻找npm包的时候,我经常会找到语法陈旧、过时的废弃包。以react-draggable为例。它是用React实现拖放的事实标准。它有许多未解决的问题,而且开发活跃性很低。可能这是因为它仍然是基于类组件的,当代码库如此老旧时,很难吸引贡献者。

至于你的官方文档,仍然建议使用 componentDidMountcomponentWillUnmount而不是useEffect在过去的两年里,核心团队一直在开发一个新的版本,称为Beta docs。但是他们依然没有做好最后的准备。

总而言之,向hook的漫长迁移仍未完成,而且它在社区中产生了明显的分裂现象。新的开发者努力在React生态系统中找到自己的方向,而老的开发者则努力跟上最新的发展。

Family Affair

起初,你的父亲Facebook看起来特别酷。Facebook想要“让人们更紧密地联系在一起”。每当我登录Facebook时,都会遇到一些新朋友

但后来事情就变得很混乱了。Facebook加入了一个操纵人群的计划。他们发明了“假新闻”的概念。他们未经同意就开始保留每个人的档案。访问Facebook变得很可怕,以至于几年前我删除了自己的账户

我知道,不能让孩子为父母的行为负责。但你仍然和它生活在一起。他们资助你的发展。他们是你最大的用户。你依赖他们。如果有一天,他们因为自己的行为而倒下,你就会和他们一起倒下。

其他主要的JS框架已经能够从它们的父母那里挣脱出来,变得变得独立,并加入了The OpenJS Foundation基金会。Node.js、Electron、webpack、lodash、eslint,甚至Jest现在都是由一些公司和个人集体资助的。既然它们可以,你也可以。但你没有。你被你的父亲困住了,为什么呢?

It's Not Me, It's You

你和我有相同的生活目的,也就是帮助开发者建立更好的用户界面。我正在用React-admin实现这一点。所以我理解你面临的挑战,以及你必须做出的权衡。你并不容易,可能正在解决大量我甚至不知道的问题。

但我发现自己正在不断地隐藏你的缺陷。当我谈到你的时候,我从不会提及上述问题,我假装我们是一对伟大的夫妇,生活没有阴云。在react-admin中,我引入了API,消除了直接与你打交道的麻烦。当人们抱怨react-admin时,我尽力解决他们的问题,但大多数时候,它们都是你的问题。作为框架的开发者,我也位于第一线,比其他人能够更早看到所有的问题。

我看了其他的框架,他们有自己的缺陷,比如Svelte不是JavaScript,SolidJS有讨厌的陷阱:

  1. // 这可以在SolidJS中运行
  2. const BlueText = props => <span style="color: blue">{props.text}</span>;
  3. // 这无法在SolidJS中运行
  4. const BlueText = ({ text }) => <span style="color: blue">{text}</span>;

但它们没有你身上的缺陷。那些让我有时想哭的缺陷,那些经过多年处理后变得非常烦人的缺陷,那些让我想尝试其他新框架的缺陷。相比之下,所有其他框架都令人耳目一新。

I Can't Quit You Baby

问题在于,我无法离开你。

首先,我喜欢你的朋友们。MUIRemixreact-queryreact-testing-libraryreact-table... 当我和它们在一起的时候,总是能做出美妙的成果。它们使我成为更好的开发者,它们使我成为更好的人。要离开你,我就必须离开它们。

这就是生态系统。

我不能否认,你有最好的社区和最好的第三方模块。但坦率地说,令人遗憾的是,开发者选择你不是因为你的品质,而是因为你的生态系统的品质。

第二,我在你身上投资了太多。我已经用你建立了一个巨大的代码库,迁移到其他框架会让我感到崩溃。我已经围绕你建立了自己的商业模式,让我能够以可持续的方式开发开源软件。

我依赖你。

Call Me Maybe

我对我的感受一直很坦诚。现在,我希望你也能这样做。你是否有计划解决我上面列出的问题,如果是的话,在什么时候做呢?你对像我这样的库开发人员有什么看法?我是否应该忘记你,转而去尝试其他框架,还是应该呆在一起,为保持我们的关系而继续努力?

我们下一步要走向何方?你能告诉我吗?

后续:React的开发人员在推特上对作者的这些问题进行了答复,React承认这些问题,并致力于解决和完善,但这似乎不是一朝一夕能够完成的。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注