談一談爲什麼我對 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