談一談爲什麼我對 React 越來越失望

作者 | 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 的一個關鍵特性,那就是響應上下文中局部變更的能力。在性能上,二者是不能同日而語的。

// 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>
    );
};

當我遇到性能問題的時候,大多數情況都是因爲龐大的上下文,我別無選擇,只能對其進行拆分。

我不想使用 useMemo 或 useCallback。因爲重新渲染的問題是你造成的,而不是我。但是,你卻強迫我這樣做。請看一下,理想情況下我是如何構建一個簡單而快速的表單的吧:

// 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} />;
};

都已經十年了,這個缺陷依然還存在。我想問一下,提供一個 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: () => {
        // 執行某些操作
    };
}
// 使用useEffect
const 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"
                class
                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>{props.text}</span>;
// 這無法在SolidJS中運行
const BlueText = ({ text }) => <span>{text}</span>;

但它們沒有你身上的缺陷。那些讓我有時想哭的缺陷,那些經過多年處理後變得非常煩人的缺陷,那些讓我想嘗試其他新框架的缺陷。相比之下,所有其他框架都令人耳目一新。

I Can't Quit You Baby、

問題在於,我無法離開你。

首先,我喜歡你的朋友們。MUI、Remix、react-query、react-testing-library、react-table... 當我和它們在一起的時候,總是能做出美妙的成果。它們使我成爲更好的開發者,它們使我成爲更好的人。要離開你,我就必須離開它們。

這就是生態系統。

我不能否認,你有最好的社區和最好的第三方模塊。但坦率地說,令人遺憾的是,開發者選擇你不是因爲你的品質,而是因爲你的生態系統的品質。

第二,我在你身上投資了太多。我已經用你建立了一個巨大的代碼庫,遷移到其他框架會讓我感到崩潰。我已經圍繞你建立了自己的商業模式,讓我能夠以可持續的方式開發開源軟件。

我依賴你。

Call Me Maybe

我對我的感受一直很坦誠。現在,我希望你也能這樣做。你是否有計劃解決我上面列出的問題,如果是的話,在什麼時候做呢?你對像我這樣的庫開發人員有什麼看法?我是否應該忘記你,轉而去嘗試其他框架,還是應該呆在一起,爲保持我們的關係而繼續努力?

我們下一步要走向何方?你能告訴我嗎?

後續:React 的開發人員在推特上對作者的這些問題進行了答覆,React 承認這些問題,並致力於解決和完善,但這似乎不是一朝一夕能夠完成的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/BAa3UJg_g2TyqK0dDvZf4w