談一談為什么我對 React 越來越失望
大廠技術 高級前端 精選文章
點擊上方 全站前端精選,關注公眾號
回復1,加入高級前段交流群
親愛的 React.js:
我們在一起已經(jīng)快十年了,我們攜手走過了漫長的旅程。但是,事情正在變得越來越糟糕,我們真的需要談談了。
這確實有點令人尷尬,我知道,沒人愿意進行這樣的談話,所以我就以歌曲的形式來進行表達吧。(作者的每一個標題都是一首英文歌的名稱,在此我們不做翻譯——譯者注)
我并不是 JS 方面的新手。在遇到你之前,我已經(jīng)和 jQuery、Backbone.js 以及 Angular.js 打過很久的交道。我知道可以從 JavaScript 框架中得到什么:更好的用戶界面,更高的生產(chǎn)力,以及更流暢的開發(fā)體驗。但是,這也意味著我不得不改變我對代碼的思考方式,以匹配框架的思維模式,這會帶來一定的挫敗感。
當我遇見你的時候,我剛剛結束了與 Angular.js 的一段長期感情。我已經(jīng)被它的 watch 和 digest 搞得焦頭爛額,更不用提 scope 了。我正在尋找不會讓我感到如此痛苦的東西。
我對你一見鐘情。相對于其他的方案,你的單向數(shù)據(jù)綁定讓我感到驚艷。我之前遇到的數(shù)據(jù)同步和性能等一系列問題在你身上根本就不存在。你純粹基于 JavaScript,而不是在 HTML 元素中以字符串的形式進行笨拙的表述。你擁有“聲明式組件”,它實在太迷人了,吸引了所有人的目光。

當然,你并不易于相處。為了與你保持和諧,我不得不改變自己的編碼習慣,但這都是值得的。最初,我對你非常滿意,以至于我一直向所有的人介紹你。
當我開始要求你處理表單的時候,事情就開始變得不對勁了。在 vanilla JS 中,處理表單和輸入域是很困難的,但是在 React 中,則是難上加難。
首先,開發(fā)人員必須在受控和非受控輸入之間做出選擇。兩者各有其缺點,在一些極端情況下都有缺陷。但是,歸根到底我們?yōu)槭裁匆獜闹羞M行選擇呢?兩種形式都要難道不好嗎?!
“推薦”方式是使用受控組件,但它超級繁瑣。如下顯示了實現(xiàn)一個加法功能的表單需要的代碼。
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= onChange={handleChangeB} /><p>{a} + = {a + b}</p></div>);};
如果只有兩種方式的話,我還會很開心。但是,構建一個真正的表單需要默認值、檢驗、輸入依賴和錯誤信息等功能,這需要大量的代碼,所以我不得不使用第三方框架。這些框架各有各的毛病。
當使用 Redux 的時候,Redux-form 看上去是一個很自然的選擇,但后來它的主要開發(fā)人員放棄了它,然后建立了 React-final-form,這個框架全是未解決的缺陷,而且其主要的開發(fā)人員又放棄了它。所以,我又看了一下 Formik,它很流行,但它是一個重量級的框架,大型表單運行緩慢并且特性有限。所以,我決定使用 React-hook-form,它很快,但是有隱藏的缺陷,而且其文檔就像迷宮一樣。
在使用 React 構建表單多年之后,我依然努力使用易讀的代碼為用戶提供強大的用戶體驗。當我看到 Svelte 是如何處理表單的時候,我瞬間覺得我一直被錯誤的抽象所羈絆。請看下面這個執(zhí)行加法功能的表單。
<script>let a = 1;let b = 2;</script><input type="number" bind:value={a}><input type="number" bind:value=><p>{a} + = {a + b}</p>
我們見面不久之后,你就向我介紹了你的小寵物 Redux。沒有它,你什么都做不了。起初我并不介意,因為它確實很可愛。但是,后來我意識到所有的一切都在圍繞它來構建。而且,在構建框架的時候,它讓我的生活變得更加困難,其他的開發(fā)人員很難使用現(xiàn)有 reducer 來調(diào)整應用。
似乎你也注意到了這一點,于是決定擺脫 Redux,轉(zhuǎn)而使用自己的 useContext。只不過,useContext 缺少了 Redux 的一個關鍵特性,那就是響應上下文中局部變更的能力。在性能上,二者是不能同日而語的。
// Reduxconst name = useSelector(state => state.user.name);// React contextconst { name } = useContext(UserContext);
在第一個樣例中,該組件只會在用戶名發(fā)生變化的時候進行重新渲染。但是在第二個樣例中,當用戶的任何部分發(fā)生變更都會導致重新渲染。這一點很重要,以至于我們不得不拆分上下文以避免不必要的重新渲染。
// 這種寫法看上去非常瘋狂,但是我們別無選擇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>);};

當我遇到性能問題的時候,大多數(shù)情況都是因為龐大的上下文,我別無選擇,只能對其進行拆分。
我不想使用 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} />;};
都已經(jīng)十年了,這個缺陷依然還存在。我想問一下,提供一個 useContextSelector 能有多難呢?
你當然意識到了這一點。但你在顧左右而言他,即便大家都知道這是你最重要的性能瓶頸。
你跟我說,我不應該直接訪問 DOM,這都是為我好。我從來不認為 DOM 是多臟的東西,但是它卻讓你坐立不安,所以我就聽你的了。現(xiàn)在,按照你的要求,我不得不使用 ref。
但是 ref 這東西很快就像病毒一樣四處傳播。大多數(shù)時候,當某個組件使用 ref 的時候,它會將其傳遞到子組件中,如果第二個組件是 React 組件,它必須要將 ref 轉(zhuǎn)發(fā)至另一個組件,以此類推,直到樹中的某個組件渲染 HTML 元素為止。所以,代碼中到處都是轉(zhuǎn)發(fā) ref 的代碼,降低了代碼的易讀性。
轉(zhuǎn)發(fā) ref 本可以非常簡單:
const MyComponent = props => <div ref={props.ref}>Hello, {props.name}!</div>;但是,這不行,這太簡單了,于是你發(fā)明了 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 節(jié)點,還等價于函數(shù)組件的 this。換句話說,“不觸發(fā)重新渲染的狀態(tài)”。按照我的經(jīng)驗,每次我不得不使用 ref 的時候,都是因為你,因為你那詭異的 useEffect API。也就是說,ref 是你創(chuàng)造出來的問題的解決方案。
說到 useEffect,我本人對它有一個疑問。我承認它是優(yōu)雅的創(chuàng)新,它在一個統(tǒng)一的 API 中,涵蓋了掛載、卸載和更新事件。但是,這怎么能算是進步呢?
// 使用生命周期回調(diào)class MyComponent {componentWillUnmount: () => {// 執(zhí)行某些操作};}// 使用useEffectconst MyComponent = () => {useEffect(() => {return () => {// 執(zhí)行某些操作};}, []);};
看,就這一行代碼就反應了我對 useEffect 的憂慮。
}, []);我看到我的代碼中到處都是這種難以理解的格式,而這些都是因為 useEffect。另外,你還強迫我跟蹤依賴,比如這段代碼:
// 如果沒有數(shù)據(jù)的話,對頁面進行變更useEffect(() => {if (query.page <= 0 ||(!isFetching && query.page > 1 && data?.length === 0)) {// 查詢不存在的頁數(shù)時,將頁數(shù)設置為1queryModifiers.setPage(1);return;}if (total == null) {return;}const totalPages = Math.ceil(total / query.perPage) || 1;if (!isFetching && query.page > totalPages) {// 查詢范圍之外的頁數(shù)時,將頁數(shù)設置為最后一頁// 這種情況會在刪除最后一頁的最后一條數(shù)據(jù)時出現(xiàn)queryModifiers.setPage(totalPages);}}, [isFetching, query.page, query.perPage, data, queryModifiers, total]);
看到最后一行了嗎?我必須在依賴數(shù)組中包含所有的反應式變量(reactive variable)。我以前還認為對于支持垃圾收集的所有語言來說,引用計數(shù)是一項原生提供的功能,但是并非如此,我必須對依賴關系進行微觀管理,因為你不知道該怎樣進行處理。

而且,在很多情況下,其中的某項依賴是我創(chuàng)建的函數(shù)。因為你沒有區(qū)分變量和函數(shù),我必須通過 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,],);
如果一個簡單組件有多個事件處理器和生命周期回調(diào)的話,代碼瞬間就會變得亂七八糟,因為我必須要管理這個像地獄似的依賴關系。所有的這一切都是因為你決定一個組件可以執(zhí)行任意多次。
舉例來說,如果我想要實現(xiàn)一個計數(shù)器,每過一秒以及用戶每次點擊按鈕時,它都會增加,我必須這樣實現(xiàn):
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 頁的文章。我必須說,那是一篇非常棒的文檔。但是,如果一個庫需要翻閱幾十頁文檔才能正確使用它,這難道不正是它設計得不好的一個標志嗎?
既然我們已經(jīng)談到了 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, // 只要傳遞相同的函數(shù),React不會解除訂閱() => navigator.onLine, // 如何獲取客戶端的值() => true, // 如何獲取服務器的值);}
但這對我來講,這就是狗尾續(xù)貂。如果反應式 effect 更易于使用的話,我們根本沒有必要增加其他的 hook。
換句話說,隨著時間的推移,除了不斷增加核心 API 之外,你別無選擇。對于像我這樣要維護巨大代碼庫的人來說,這種持續(xù)的 API 膨脹是一個噩夢??吹侥忝刻焱恐ǚ郏@反過來就是在不斷提醒你,想想你在試圖掩飾些什么呢。
你的 hook 是一個很好的主意,但它們是有成本的。這就是 Hook 規(guī)則。它們很難記,難以付諸實踐。但是,它們迫使我們必須在不必要的代碼上耗費時間。
例如,我有一個“inspector”組件,終端用戶可以將它拖來拖去。用戶也可以隱藏它。當隱藏時,inspector 組件不會渲染任何東西。所以,定義組件時我希望“盡早離開”,避免無謂的注冊事件監(jiān)聽器。
const Inspector = ({ isVisible }) => {if (!isVisible) {// 盡早離開return null;}useEffect(() => {// 注冊事件處理器return () => {// 解除事件處理器};}, []);return <div>...</div>;}
但是,這樣是不行的,因為這違反了 Hook 規(guī)則,useEffect hook 是否執(zhí)行取決于 props。所以,我必須在所有的 effect 上添加一個條件,使其能夠在 isVisible 屬性為 false 時盡早離開:
const Inspector = ({ isVisible }) => {useEffect(() => {if (!isVisible) {return;}// 注冊事件處理器return () => {// 解除事件處理器};}, [isVisible]);if (!isVisible) {// 不像前文那樣,進入之后立即離開return null;}return <div>...</div>;};
因此,所有的 effect 在它們的依賴關系中都要有 isVisible 屬性,并且可能會頻繁運行(這會損害性能)。我知道,我應該創(chuàng)建一個中間組件,如果 isVisible 為 false,就不渲染。但我憑什么要這樣做呢?這只是 Hook 規(guī)則妨礙我的一個例子,我還有很多其他的例子。這樣帶來的后果就是,我的 React 代碼庫中有很大一部分都是用來滿足 Hook 規(guī)則的。
Hook 規(guī)則是實現(xiàn)細節(jié)導致的結果,也就是你為 hook 所選擇的實現(xiàn)。但是,它并非必須要這樣。
你從 2013 年就開始存在了,而且盡可能地保持了向后兼容性。為此我要感謝你,這也是我能夠與你構建一個龐大代碼庫的原因之一。但是,這種向后兼容性是有代價的,文檔和社區(qū)資源往好了說是過時的,往壞了說就是有誤導性的。
例如,當我在 StackOverflow 上搜索“React mouse position”時,第一個結果建議使用如下的解決方案,而這個解決方案在一個世紀前就已經(jīng)過時了:
class ContextMenu extends React.Component {state = {visible: false,};render() {return (<canvasref="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 labelthis.context.updateDrawInfo({cursorX: cursorX,cursorY: cursorY,drawingNow: true,});// Draw somethingconst 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();}}

至于你的官方文檔,仍然建議使用 componentDidMount 和 componentWillUnmount 而不是 useEffect。在過去的兩年里,核心團隊一直在開發(fā)一個新的版本,稱為 Beta docs。但是他們依然沒有做好最后的準備。
總而言之,向 hook 的漫長遷移仍未完成,而且它在社區(qū)中產(chǎn)生了明顯的分裂現(xiàn)象。新的開發(fā)者努力在 React 生態(tài)系統(tǒng)中找到自己的方向,而老的開發(fā)者則努力跟上最新的發(fā)展。
起初,你的父親 Facebook 看起來特別酷。Facebook 想要“讓人們更緊密地聯(lián)系在一起”。每當我登錄 Facebook 時,都會遇到一些新朋友
但后來事情就變得很混亂了。Facebook 加入了一個操縱人群的計劃。他們發(fā)明了“假新聞”的概念。他們未經(jīng)同意就開始保留每個人的檔案。訪問 Facebook 變得很可怕,以至于幾年前我刪除了自己的賬戶。
我知道,不能讓孩子為父母的行為負責。但你仍然和它生活在一起。他們資助你的發(fā)展。他們是你最大的用戶。你依賴他們。如果有一天,他們因為自己的行為而倒下,你就會和他們一起倒下。
其他主要的 JS 框架已經(jīng)能夠從它們的父母那里掙脫出來,變得變得獨立,并加入了 The OpenJS Foundation 基金會。Node.js、Electron、webpack、lodash、eslint,甚至 Jest 現(xiàn)在都是由一些公司和個人集體資助的。既然它們可以,你也可以。但你沒有。你被你的父親困住了,為什么呢?

你和我有相同的生活目的,也就是幫助開發(fā)者建立更好的用戶界面。我正在用 React-admin 實現(xiàn)這一點。所以我理解你面臨的挑戰(zhàn),以及你必須做出的權衡。你并不容易,可能正在解決大量我甚至不知道的問題。
但我發(fā)現(xiàn)自己正在不斷地隱藏你的缺陷。當我談到你的時候,我從不會提及上述問題,我假裝我們是一對偉大的夫婦,生活沒有陰云。在 react-admin 中,我引入了 API,消除了直接與你打交道的麻煩。當人們抱怨 react-admin 時,我盡力解決他們的問題,但大多數(shù)時候,它們都是你的問題。作為框架的開發(fā)者,我也位于第一線,比其他人能夠更早看到所有的問題。
我看了其他的框架,他們有自己的缺陷,比如 Svelte 不是 JavaScript,SolidJS 有討厭的陷阱:
// 這可以在SolidJS中運行const BlueText = props => <span style="color: blue">{props.text}</span>;// 這無法在SolidJS中運行const BlueText = ({ text }) => <span style="color: blue">{text}</span>;
但它們沒有你身上的缺陷。那些讓我有時想哭的缺陷,那些經(jīng)過多年處理后變得非常煩人的缺陷,那些讓我想嘗試其他新框架的缺陷。相比之下,所有其他框架都令人耳目一新。
問題在于,我無法離開你。
首先,我喜歡你的朋友們。MUI、Remix、react-query、react-testing-library、react-table... 當我和它們在一起的時候,總是能做出美妙的成果。它們使我成為更好的開發(fā)者,它們使我成為更好的人。要離開你,我就必須離開它們。
這就是生態(tài)系統(tǒng)。
我不能否認,你有最好的社區(qū)和最好的第三方模塊。但坦率地說,令人遺憾的是,開發(fā)者選擇你不是因為你的品質(zhì),而是因為你的生態(tài)系統(tǒng)的品質(zhì)。
第二,我在你身上投資了太多。我已經(jīng)用你建立了一個巨大的代碼庫,遷移到其他框架會讓我感到崩潰。我已經(jīng)圍繞你建立了自己的商業(yè)模式,讓我能夠以可持續(xù)的方式開發(fā)開源軟件。
我依賴你。
我對我的感受一直很坦誠。現(xiàn)在,我希望你也能這樣做。你是否有計劃解決我上面列出的問題,如果是的話,在什么時候做呢?你對像我這樣的庫開發(fā)人員有什么看法?我是否應該忘記你,轉(zhuǎn)而去嘗試其他框架,還是應該呆在一起,為保持我們的關系而繼續(xù)努力?
我們下一步要走向何方?你能告訴我嗎?

后續(xù):React 的開發(fā)人員在推特上對作者的這些問題進行了答復,React 承認這些問題,并致力于解決和完善,但這似乎不是一朝一夕能夠完成的。
前端 社群
下方加 Nealyang 好友回復「 加群」即可。
如果你覺得這篇內(nèi)容對你有幫助,我想請你幫我2個小忙:
1. 點個「在看」,讓更多人也能看到這篇文章
下方加 Nealyang 好友回復「 加群」即可。
如果你覺得這篇內(nèi)容對你有幫助,我想請你幫我2個小忙:
點贊和在看就是最大的支持
