大家好,我是 Echa,最近網上掀起了一波吐槽 React 的熱潮,不知道你做何感想呢?
親愛的 React ,我們在一起快 10 年了,我們一起走過了很長一段路,但事情逐漸變得有點失控了,我們需要談談。
對你一見鍾情
當我最開始和 JavaScript 相遇時,我並不是一開始就喜歡這個語言。在你出現之前,我對 jQuery、Backbone.js 和 Angular.js 有過很長的學習經歷。我知道我可以從這些 JavaScript 框架中得到些什麼:更好的 UI、更高的生產力和更流暢的開發人員體驗。但也有不得不不斷改變我思考代碼的方式來匹配框架的思維方式所帶來的挫敗感。
當我剛開始遇到你時,我剛剛結束了和 Angular.js 的長期關係。我已經被 watch 和 digest 折騰累了,更不用說 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>
);
};
當我與你之間出現性能問題的時候,大多數情況下都是由上下文引起的,我別無選擇,只能對它拆分。
我不想使用 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} />;
};
已經 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 。相反,我必須給所有的效果添加一個條件,以便它們在 isVisible 為 false 時提前離開:
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 = {
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 實現了拖拽功能。它還有許多沒解決的 issues ,開發更新的頻率也很低。也許是因為它仍然是基於類組件的 — 當代碼庫使用的方案太舊的時候,是很難吸引貢獻者的。
至於你的官方文檔,仍然在建議使用 componentDidMount、componentWillUnmount 而不是 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 不是 JavaScript,SolidJS 有一些令人討厭的陷阱,比如:
// 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
如果你有任何想法,歡迎在留言區和我留言,如果這篇文章幫助到了你,歡迎點讚和關注。