查看原文
其他

React 我爱你,但你太让我失望了

The following article is from code秘密花园 Author ConardLi译

亲爱的 React ,我们在一起快 10 年了,我们一起走过了很长一段路,但事情逐渐变得有点失控了,我们需要谈谈。

对你一见钟情

当我最开始和 JavaScript 相遇时,我并不是一开始就喜欢这个语言。在你出现之前,我对 jQuery、Backbone.jsAngular.js 有过很长的学习经历。我知道我可以从这些 JavaScript 框架中得到些什么:更好的 UI、更高的生产力和更流畅的开发人员体验。但也有不得不不断改变我思考代码的方式来匹配框架的思维方式所带来的挫败感。

当我刚开始遇到你时,我刚刚结束了和 Angular.js 的长期关系。我已经被 watchdigest 折腾累了,更不用说 scope 了。我一直在寻找不会让我感到痛苦的东西。

这就是一见钟情。与我当时所知道的相比,你的单向数据绑定是如此令人耳目一新。我在数据同步和性能方面遇到的一整套问题在你们那里根本不存在。你是纯粹的 JavaScript ,而不是在 HTML 元素中表示为字符串的拙劣模仿。你的 “声明性组件” 太漂亮了,以至于每个人都一直注视着你。

你不是那种很容易相处的人。为了和你相处,我不得不改变我的一些编程习惯,但我认为这是值得的!一开始,我和你在一起很开心,所以我一直跟大家讲述关于你的事。

处理表单太费劲了

当我让你处理表单的时候,事情就开始变得奇怪了。在原生JS中,表单和用户输入就是很难处理的。但是有了 React 之后,我感觉更困难了...

首先,开发者必须在 受控输入非受控输入 之间做出选择。在一些极端情况下,这两种方法都有缺点和 Bug 。但为什么我一开始就要做出选择呢?

“推荐的”方式,控制组件,是超级冗长的。这是我需要一个附加形式的代码:

受控组件的推荐写法非常冗长,比如这是一段关于表单处理的代码:

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,充满了未修复的 bug,核心开发者也放弃了;
  • Formik,现在挺流行的,但重了,处理大型表单速度很慢,功能也很有限;
  • React-hook-form,速度很快,但有很多隐藏的 Bug,并且文档写的很差。

使用 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>

你对上下文太敏感了

我们第一次见面后不久,你就把你的小跟班 Redux 介绍给了我,没有它你哪儿也去不了。一开始我并不介意,因为它还挺可爱的。但后来我意识到,整个世界都在围着它转。同时,这也增加了构建框架的难度 — 其他开发者无法轻易地使用现有的 reducer 来调整程序。

但是你也注意到了这一点,于是决定放弃 Redux 转而使用你自己的 useContext 。只是 useContext 缺少了 Redux 的一个关键特性:对上下文部分的变化做出反应的能力。这两者在性能上还是有点差距的:

// Redux
const name = useSelector(state => state.user.name);
// React context
const { 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>

    );
};

当我与你之间出现性能问题的时候,大多数情况下都是由上下文引起的,我别无选择,只能对它拆分。

我不想使用 useMemouseCallback 。一些重复的渲染是你的问题,不是我的,但你却要强迫我这么做???

看一下我应该怎么写才能构建出一个性能比较好的表单组件:

// from https://react-hook-form.com/advanced-usage/#FormProviderPerformance
const 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} />;
};

已经 10 年了,你还是有这样的缺陷。提供一个 useContextSelector 有多难?

你当然也知道这一点。但是你正在寻找其他的解决方案,即使这可能是你最重要的性能瓶颈。

https://github.com/reactjs/rfcs/pull/118

我不想要这些

你已经向我解释过了,我不应该直接访问 DOM 节点,是未了我自己好。我从来没有想过 DOM 是肮脏的,但因为它会对你产生一些影响,我就不再去直接访问它了。现在我按你的要求使用 refs  。

https://en.reactjs.org/docs/refs-and-the-dom.html

但是这个 ref 的东西像病毒一样传播。大多数时候,当组件使用 ref 时,它会将其传递给子组件。如果第二个组件是 React 组件,它必须将 ref 传递给另一个组件,依此类推,直到树中的一个组件最终渲染 HTML 元素。所以代码库最终会到处传递 refs,从而降低了代码的可读性。

转发 refs 可以像这样简单:

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.

https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref

// how am I supposed to forwardRef to this?
const MyComponent = <T>(props: <ComponentProps<T>) => (
    <div ref={/* pass ref here */}>Hello, {props.name}!</div>
);

此外,你已经确定 refs 不仅是 DOM 节点,它们和函数组件的引用是等价的。或者也可以说是 “不触发重新渲染的状态”。以我的经验,每次我不得不使用这样的 ref,都是因为你的 useEffectAPI 太奇怪了。换句话说,refs 是你创建的问题的解决方案。

飘忽不定的 (use) Effect

说到 useEffect,我个人对它有一些意见。我承认这是一个优雅的创新,它在一个统一的 API 中涵盖了挂载、卸载和更新事件,但这也能算进步吗?

// 使用生命周期
class MyComponent {
    componentWillUnmount() => {
        // do something
    };
}

// 使用 useEffect
const MyComponent = () => {
    useEffect(() => {
        return () => {
            // do something
        };
    }, []);
};

你看,这行代码就代表了我对你的 useEffect 的失望:

    }, []);

我在我的代码中,到处都会看到这种神秘符号的嵌套,而它们都是因为 useEffect 。另外,你强迫我跟踪依赖关系,就像下面的代码:

// 如果没有数据,就改变页面
useEffect(() => {
    if (
        query.page <= 0 ||
        (!isFetching && query.page > 1 && data?.length === 0)
    ) {
        // 查询一个不存在的页面,设置 page 为 1
        queryModifiers.setPage(1);
        return;
    }
    if (total == null) {
        return;
    }
    const totalPages = Math.ceil(total / query.perPage) || 1;
    if (!isFetching && query.page > totalPages) {
        // 查询超出边界的页面,将 page 设置为现有的最后一个页面
        // 在删除最后一页的最后一个元素时发生
        queryModifiers.setPage(totalPages);
    }
}, [isFetching, query.page, query.perPage, data, queryModifiers, total]);

看到最后一行了吗?我必须确保在依赖数组中包含所有的响应变量。而且我认为引用计数是所有带有垃圾回收器的语言的原生特性。但是不行,我必须自己对依赖项进行细粒度的管理,因为你不知道该怎么做。

很多时候,这些依赖项之一是我自己创建的函数。因为你不会区分变量和函数,我必须用 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);
    },
    [
        // oh god, please no
        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! ConardLi</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 页的论文。

https://overreacted.io/a-complete-guide-to-useeffect/

我必须说,这是一个了不起的文档。但是如果一个库需要我翻几十页才能把它用好,这不就是说明它自己设计的不好吗?

不断膨胀的核心 API

因为我们已经讨论了 useEffect 这个有漏洞的抽象,所以你已经尝试了改进它。你已经向我介绍了 useEvent、useInsertionEffect、useDeferredValue、useyncwithexternalstore 和其他噱头。

它们确实让你看起来很漂亮:

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 won't resubscribe for as long as you pass the same function
        () => navigator.onLine, // How to get the value on the client
        () => true// How to get the value on the server
    );
}

但对我来说,这就是给猪身上涂口红。如果响应式 effects 更容易使用,你就不需要这些其他的钩子了。

换句话说:除了随着时间的推移不断增长核心 API 之外,你没有其他解决方案。对于像我这样必须维护庞大代码库的人来说,这种持续的 API 膨胀是一场噩梦。看到你每天化的妆越来越浓,会不断提醒你想要刻意隐藏的东西。

严格的限制

你的 Hooks 是个好创意,但它们是有代价的。而这个成本就是 Hooks of Hooks

https://reactjs.org/docs/hooks-rules.html

它们不容易记住,也不容易付诸实践。但是它们迫使我在不需要的代码上花费时间。

例如,我有一个可以由用户拖动的“调试器”组件。用户还可以隐藏调试器。隐藏时,调试器组件不渲染任何内容。所以我很想“早点离开”,避免白白注册事件监听器。

const Inspector = ({ isVisible }) => {
    if (!isVisible) {
        // leave early
        return null;
    }
    useEffect(() => {
        // Register event listeners
        return () => {
            // Unregister event listeners
        };
    }, []);
    return <div>...</div>;
};

但是不行,这是违反 Hooks 规则的,因为 useEffect 可能执行,也可能不执行,这取决于 props 。相反,我必须给所有的效果添加一个条件,以便它们在 isVisiblefalse 时提前离开:

const Inspector = ({ isVisible }) => {
    useEffect(() => {
        if (!isVisible) {
            return;
        }
        // Register event listeners
        return () => {
            // Unregister event listeners
        };
    }, [isVisible]);

    if (!isVisible) {
        // leave not so early
        return null;
    }
    return <div>...</div>;
};

因此,所有 effects 的依赖项中都包含 isVisible props,并且可能会过于频繁地运行(可能会损耗性能)。我知道,我应该创建一个中间组件,如果 isVisible 是假的,那什么都不渲染。但是我为啥要这么做呢?这只是 “Hooks规则” 阻碍我的一个例子 - 然而还有很多其他的例子。所以这导致我的 React 代码库的很大一部分代码都是用来满足 Hooks 规则的。

而这一切,都是因为你选择的 Hooks 的实现方式导致的,肯定还有更好的方式。

你已经离开太久了

2013 年以来,你一直强调尽可能长时间地保持向后兼容。我对此表示很感谢 — 这也是我能够和你一起开发一个庞大的代码仓库的原因之一。但这种向后兼容是有代价的:一些文档和社区资源往好了说是过时的,往坏了说是有误导性的。

比如,当我在 StackOverflow 上搜索 “React mouse position” 时,第一个结果是这个解决方案,这在很久之前就已经过时了:

class ContextMenu extends React.Component {
    state = {
        visiblefalse,
    };

    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,
            drawingNowtrue,
        });

        // 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 实现了拖拽功能。它还有许多没解决的 issues ,开发更新的频率也很低。也许是因为它仍然是基于类组件的 — 当代码库使用的方案太旧的时候,是很难吸引贡献者的。

至于你的官方文档,仍然在建议使用 componentDidMountcomponentWillUnmount 而不是 useEffect 。在过去的两年里,你的核心团队一直在开发一个名为 Beta docs 的新版本,但似乎还是没准备好正式对外开放。

总而言之,向 hooks 的长期迁移还没有结束,它在社区中产生了明显的碎片化。新开发者努力在 React 生态系统中找到自己的方式,而老开发者则一直在努力跟上最新的发展。

家庭影响

起初,你父母的 Facebook 看起来超级酷。Facebook 的宗旨是 让人们更紧密地联系在一起 !每当我拜访你的父母时,我都会结识新朋友。

但后来事情变得一团糟了,你的父母参加了一个人群操纵计划。

https://en.wikipedia.org/wiki/Facebook%E2%80%93Cambridge_Analytica_data_scandal

他们发明了“假新闻”的概念,并开始在未经用户同意的情况下保存每个人的文件。拜访你的父母变得很可怕 — 以至于几年前我已经删除了自己的 Facebook 帐户。

我知道 - 你不能让孩子为父母的行为负责,但你仍然要坚持和他们住在一起,因为你需要他们资助你的发展,他们也是你最大的用户,你依赖他们。如果有一天,他们因为他们的行为而跌倒了,你会和他们一起跌倒。

其他一些主要的 JS 框架已经能够摆脱父辈的束缚。他们都独立起来了,并加入了一个名为 The OpenJS Foundation 的基金会。

https://openjsf.org/

Node.js、Electron、webpack、lodash、eslint 甚至 Jest 现在都开始由公司和个人集体资助了。既然他们可以,你也可以,但你没有,你被父母困住了,为什么?

不是我,是你

你和我的人生目标是一样的:帮助开发者构建更好的 UI。我正在使用 react-admin 来开发。

https://marmelab.com/react-admin/

所以我理解你们面临的困难,以及你们必须做出的权衡。你的工作不容易,你可能正在解决很多我都不知道的问题。

但我发现自己总是在试图掩盖你的一些缺点。当我谈到你的时候,我从来没有提到过上面的问题  - 我还一直在假装我们是很好的一对。在 react-admin 中,我引入了一些 API,免去了与你直接打交道的麻烦。当人们抱怨  react-admin 的时候,我会尽我所能解决他们的问题 — 但大多数时候,他们对你都有意见。作为一名框架开发者,我也站在第一线,我会比别人先发现所有的问题。

我看过一些其他框架,它们也有自己的缺陷 — 比如 slvelte 不是 JavaScriptSolidJS 有一些令人讨厌的陷阱,比如:

// this works in SolidJS
const BlueText = props => <span style="color: blue">{props.text}</span>;

// this doesn't work in SolidJS
const BlueText = ({ text }) => <span style="color: blue">{text}</span>;

但他们没有你那些有时候让我想哭的缺点,在与这些缺点打了很多年交道以后,它们变得让我很恼怒。让我想尝试一些别的东西,相比之下,所有其他的框架都是新鲜的。

我不能放弃你宝贝

问题是我不能离开你。

首先,我爱你的朋友。MUI、Remix、react-query、react-testing-library、react-table ... 当我和这些人在一起时,我总是能做一些令人惊奇的事情。他们让我成为一个更好的开发者,我不能离开你而不离开他们。

我不能否认你们拥有最好的社区和最好的第三方模块。但老实说,很遗憾开发者选择你不是因为你的素质,而是因为你的生态系统的素质。

其次,我在你身上投入了太多。我已经和你一起构建了一个巨大的代码库,如果我还没疯,就不可能再迁移到另一个框架。我已经围绕你建立了一个企业,让我能够以可持续的方式开发开源软件。

我依赖你。

方便的话请联系我

我对自己的感受非常坦诚,现在我希望你也这样做。

你打算解决我上面列出的几点问题吗?

如果是,什么时候呢?

你如何看待像我这样的三方库开发者?

我应该忘记你,然后去做点别的事情吗?

还是我们应该呆在一起,并努力维持我们的关系?

我们的下一步是什么呢?你告诉我。

最后

本文译自:https://marmelab.com/blog/2022/09/20/react-i-love-you.html




往期推荐

我的学习小圈子

首次公开!我快速做项目的技巧

HTTPS 就一定安全?我不信

谁说没实习经历,就不能进大厂了!

Excel 在 Java 中还能这么操作 ?

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

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