谈一谈为什么我对 React 越来越失望

全栈前端精选

共 14068字,需浏览 29分钟

 ·

2022-11-25 06:32

大厂技术  高级前端  精选文章

点击上方 全站前端精选,关注公众号

回复1,加入高级前段交流群


作者 | François Zaninotto
译者 | 张卫滨
策划 | 闫园园

亲爱的 React.js:

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

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

You Were The One

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

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

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

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

Heroes Of New Forms

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

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

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

import React, { useState } from 'react';export default () => {    const [a, setA] = useState(1);    const [b, setB] = useState(2);    function handleChangeA(event) {        setA(+event.target.value);    }    function handleChangeB(event) {        setB(+event.target.value);    }    return (        <div>            <input type="number" value={a} onChange={handleChangeA} />            <input type="number" value={b} onChange={handleChangeB} />            <p>                {a} + {b} = {a + b}            </p>        </div>    );};

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

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

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

<script>    let a = 1;    let b = 2;</script><input type="number" bind:value={a}><input type="number" bind:value={b}><p>{a} + {b} = {a + b}</p>
You're Too Context Sensitive

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

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

// Reduxconst name = useSelector(state => state.user.name);// React contextconst { name } = useContext(UserContext);

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

// 这种写法看上去非常疯狂,但是我们别无选择export const CoreAdminContext = props => {    const {        authProvider,        basename,        dataProvider,        i18nProvider,        store,        children,        history,        queryClient,    } = props;    return (        <AuthContext.Provider value={authProvider}>            <DataProviderContext.Provider value={dataProvider}>                <StoreContextProvider value={store}>                    <QueryClientProvider client={queryClient}>                        <AdminRouter history={history} basename={basename}>                            <I18nContextProvider value={i18nProvider}>                                <NotificationContextProvider>                                    <ResourceDefinitionContextProvider>                                        {children}                                    </ResourceDefinitionContextProvider>                                </NotificationContextProvider>                            </I18nContextProvider>                        </AdminRouter>                    </QueryClientProvider>                </StoreContextProvider>            </DataProviderContext.Provider>        </AuthContext.Provider>    );};

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

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

// from https://react-hook-form.com/advanced-usage/#FormProviderPerformanceconst NestedInput = memo(    ({ register, formState: { isDirty } }) => (        <div>            <input {...register('test')} />            {isDirty && <p>This field is dirty</p>}        </div>    ),    (prevProps, nextProps) =>        prevProps.formState.isDirty === nextProps.formState.isDirty,);export const NestedInputContainer = ({ children }) => {    const methods = useFormContext();    return <NestedInput {...methods} />;};

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

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

I Want None Of This

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

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

转发 ref 本可以非常简单:

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

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

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

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

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

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

The Butterfly (use) Effect

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

// 使用生命周期回调class MyComponent {    componentWillUnmount: () => {        // 执行某些操作    };}// 使用useEffectconst MyComponent = () => {    useEffect(() => {        return () => {            // 执行某些操作        };    }, []);};

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

  }, []);

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

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

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

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

const handleClick = useCallback(    async event => {        event.persist();        const type =            typeof rowClick === 'function'                ? await rowClick(id, resource, record)                : rowClick;        if (type === false || type == null) {            return;        }        if (['edit', 'show'].includes(type)) {            navigate(createPath({ resource, id, type }));            return;        }        if (type === 'expand') {            handleToggleExpand(event);            return;        }        if (type === 'toggleSelection') {            handleToggleSelection(event);            return;        }        navigate(type);    },    [        // 天啊,真不想这么做        rowClick,        id,        resource,        record,        navigate,        createPath,        handleToggleExpand,        handleToggleSelection,    ],);

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

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

function Counter() {    const [count, setCount] = useState(0);    const handleClick = useCallback(() => {        setCount(count => count + 1);    }, [setCount]);    useEffect(() => {        const id = setInterval(() => {            setCount(count => count + 1);        }, 1000);        return () => clearInterval(id);    }, [setCount]);    useEffect(() => {        console.log('The count is now', count);    }, [count]);    return <button onClick={handleClick}>Click Me</button>;}

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

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

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

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

Makeup Your Mind

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

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

function subscribe(callback) {    window.addEventListener('online', callback);    window.addEventListener('offline', callback);    return () => {        window.removeEventListener('online', callback);        window.removeEventListener('offline', callback);    };}function useOnlineStatus() {    return useSyncExternalStore(        subscribe, // 只要传递相同的函数,React不会解除订阅        () => navigator.onLine, // 如何获取客户端的值        () => true, // 如何获取服务器的值    );}

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

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

Strict Machine

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

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

const Inspector = ({ isVisible }) => {    if (!isVisible) {        // 尽早离开        return null;    }    useEffect(() => {        // 注册事件处理器        return () => {            // 解除事件处理器        };    }, []);    return <div>...</div>;}

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

const Inspector = ({ isVisible }) => {    useEffect(() => {        if (!isVisible) {            return;        }        // 注册事件处理器        return () => {            // 解除事件处理器        };    }, [isVisible]);    if (!isVisible) {        // 不像前文那样,进入之后立即离开        return null;    }    return <div>...</div>;};

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

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

You've Been Gone Too Long

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

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

class ContextMenu extends React.Component {    state = {        visible: false,    };    render() {        return (            <canvas                ref="canvas"                className="DrawReflect"                onMouseDown={this.startDrawing}            />        );    }    startDrawing(e) {        console.log(            e.clientX - e.target.offsetLeft,            e.clientY - e.target.offsetTop,        );    }    drawPen(cursorX, cursorY) {        // Just for showing drawing information in a label        this.context.updateDrawInfo({            cursorX: cursorX,            cursorY: cursorY,            drawingNow: true,        });        // Draw something        const canvas = this.refs.canvas;        const canvasContext = canvas.getContext('2d');        canvasContext.beginPath();        canvasContext.arc(            cursorX,            cursorY /* start position */,            1 /* radius */,            0 /* start angle */,            2 * Math.PI /* end angle */,        );        canvasContext.stroke();    }}

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


至于你的官方文档,仍然建议使用 componentDidMount 和 componentWillUnmount 而不是 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 有讨厌的陷阱:

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

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

I Can't Quit You Baby、

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

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

这就是生态系统。

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

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

我依赖你。

Call Me Maybe

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

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

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

前端 社群



下方加 Nealyang 好友回复「 加群」即可。



如果你觉得这篇内容对你有帮助,我想请你帮我2个小忙:

1. 点个「在看」,让更多人也能看到这篇文章

点赞和在看就是最大的支持

浏览 28
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报