前 Meta 前端工程師:我又來罵 React 了!
作者是前 Facebook/Meta 前端工程師,也是近 4000 顆星 react-native-webrtc 的作者,現在 Google 用 AngularDart 開發前端。
本文摘自 Andy Lee:React 設計缺陷:缺失 useDerivedState 與 useChildState?
沒錯又是我,我又來罵 React 了 ,沒有啦,真理不辯不明,我攻擊他人的 “想法”,我也完全接受他人攻擊我的 “想法”(人身攻擊的就別了,太 low),什麼面子什麼大佬,對我來說沒意義,唯一有意義的是享受思考。
至於我的想法是不是原創,我只能說我常常是完全靠自己思考出了一個新想法,才發現早有幾千幾萬星的 library 甚至 framework,沒辦法這世界太捲了,尤其前端工程更卷 ,但我還是挺欣喜,時常還是能原創想出一些從來沒見過的思考。
背景
回到本文的主題,先說背景,
-
大家還記得在 Class 組件時代 Controlled vs Uncontrolled 組件的衝突麼?
-
大家知道 React 18 在嚴格模式下 useEffect 會呼叫兩次,導致社羣炸鍋麼?
-
Fetch-on-render vs Render-as-you-fetch 的權衡,並且 React 推薦的 Render-as-you-fetch 非常難實作。
-
並沒有一個官方 Hook 去對應 derived state,甚至在 Class 組件使用 getDerivedStateFromProps 本來就是 buggy 的
直接講結論,這些都是因爲 React 原生沒有支援 useDerivedState 這種概念的 API,並且要最優雅的使 useDerivedState 可以運作,還是必須導入 Subscribable 可訂閱的 state(記得我之前寫的 React Turbo 麼?),但這次是相對薄薄一層。
範例
有 defaultValue 的 Input 組件
function Input({ defaultValue }: { defaultValue: Signal<string> }) {
const [value, setValue] = useDerivedState<string>(
set => set(defaultValue()),
[defaultValue]
);
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
function Form() {
const [email, setEmail, emailSignal] = useSignal("a123");
return <form><Input defaultValue={emailSignal} /></form>
}
不需要編譯的 Fetch on setState
function Page({pageId}: { pageId: Signal<string> }) {
const [result] = useDerivedState<Result>(
async (set) => {
const id = pageId();
set({ loading: true, data: null, id });
const data = await fetch("api/" + id);
set((result) => (result.id !== id) ? result : { loading: false, data, id };
},
[pageId]
);
const {loading, data} = result;
return <div>{loading ? "Loading..." : data }</div>;
}
function App() {
const [pageId, setPageId, pageIdSignal] = useSignal("");
return pageId ? <Page pageId={pageIdSignal} /> : <Home>;
}
首先以上例子使用 useSignal 和 useDerivedState 新的 Hook 都已實作可運作。
useSignal
const [state, setState, signal] = useSignal(123);
中的 state, setState 和 useState 完全一致、沒有任何差別,而 signal 是一個可以被訂閱的東西。
signal.subscribe(() => {});
setState 發生時,signal.subscribe(callback) 中的 callback 會同步呼叫,重點是,同步。
useDerivedState
const [derived, setDerived] = useDerivedState(
(set) => {},
[signal1, signal2]
);
useDerivedState 不過就只是訂閱 signal.subscribe 罷了,在 signal 被 setState 時,會同步呼叫 callback,並帶有一個 set 參數,方便修改這個 derivedState。
回到以上兩個例子,都是完全沒 bug 的:
-
defaultValue Input:無論是 getDerivedStateFromProps,或在 useEffect 甚至 render 去 setState,都是有 bug 的,尤其 hook 方法會造成 double render。而 useDerivedState 不會造成 double render。
-
Page:Fetch on render 的問題在於:fetch 時機被延遲,以及會 double render。而我這個實作嚴格來說是 Fetch on setState,在 pageId 變化時,無論是 fetch 時機和 render 次數,都是跟 Render-as-you-fetch 同等性能。
Fetch waterfalls
defaultValue Input 的問題已經用 useDerivedState 完美解決。
然而我的 Fetch on setState 在 Page 組件已經 render 過後,在切換到不同 pageId 時運作的很完美,然而在第一次 render(比如從 Home 到 Page),依然會有 Fetch waterfalls 的問題。
Waterfall 的問題相當難解決,react-query、useSWR、Apollo 其實都是存在 Waterfall,社羣中真正的解決方案,要嘛是需要編譯(Relay),要嘛是非純前端(Remix),Nextjs 在第一頁面後也都有 Waterfall。
那要怎麼不用編譯工具也純前端的解決 Waterfall 呢?這時真的要出大招了。
function Page({loading, data}) {
return <div>{loading ? "Loading..." : data }</div>;
}
Page.useState = ({pageId}: { pageId: Signal<string> }) => {
// 整段代碼基本沒變
const [result] = useDerivedState<Result>(
async (set) => {
const id = pageId();
if (!id) return; // 加了
set({ loading: true, data: null, id });
const data = await fetch("api/" + id);
set((result) => (result.id !== id) ? result : { loading: false, data, id };
},
[pageId]
);
const {loading, data} = result;
return {loading, data};
}
function App() {
const [pageId, setPageId, pageIdSignal] = useSignal("");
const pageState = Page.useState({pageId})
return pageId ? <Page {...pageState} /> : <Home>;
}
嚴格來說,這個做法不過是 Lifting State Up 提升 State(即 Fully controlled 組件)這個官方解法,然而我把一個子組件 Page 的 state hooks 提取成一個獨立函數成爲 custom hook,然後到父組件 App 直接呼叫,再注入回去的做法,我認爲是前所未見的,至少我沒查到其他人這麼做,希望我是世界上第一個 。
這個 design pattern,姑且稱爲 useChildState,更好的點在於,官方的 Fully controlled 是在父組件耦合 (coupling) 了子組件。而 useChildState 是解耦的(decoupling),App 父組件完全不知道 Page 子組件有哪些 states,App 只是 Page.useState,然後{...pageState},夠解耦了吧?不管你 Page.useState 裏要塞多少東西都沒差,App 完全不管,甚至還有在 Page 裏 nested 更多子組件的玩法。
這時候還是需要感謝讚歎一下 React Hook 的發明,useChildState 這個 pattern,在 Class 組件是基本做不到的,而 Hook 卻能夠輕鬆優雅的把狀態和渲染分開了!我說 Hook 和 Dependency Injection 的發明同等地位沒有人反對吧。
總結
大致上整個解決方案就是如上所敘,主要是
-
React 必須要提供真正的 useDerivedState,這個 Hook 大大有用處,爲了要實現這個 Hook,又必須要有 Subscribable 可訂閱的 state,如上所說 useSignal(但我更希望是整合在目前的 useState)
-
useChildState 的 pattern 設計模式,能分開 state 和 render。因爲 render 是滯後的,甚至在 concurrent mode 是可被打斷的。分開 state 和 render 是關鍵,因爲 render 是很耗性能的,一堆 createElement 以及 vDOM diff,然而 useState 等純 value 計算並不耗能。(如果 React 再改設計成純 value 也耗能的話,我只能說 React 趕緊被掃進垃圾堆吧)
附錄
對於 derived state 的官方解法如:
- 用 ref、
- 上述的 Fully controlled、
- useEffect 去 sync state,我沒有展開說,但我完全清楚其概念,但它們都有缺陷,useDerivedState 纔是真正解方。
對了,useDerivedState 似乎 Jotai 是說有支援,但我用 Jotai 怎麼寫都寫不出我期望的 useDerivedState,所以還是自己稍微實作了。
State management
另外對於上述棘手問題,就連官方都會推諉給一個解決方案,即是用 global state management library,然而這是非常不負責任的說法。
我就問,難道我爲了控制一個 input 的 default value,我也要把這個 input 的 value 存到 redux 的 state tree 裏麼?query result 放入 redux 的話,那等同於所有組件的主要 state 都要放入 redux,也就是組件 tree 和 global state tree 要一比一關聯,這多反人性用過的都知道。
最重要的是 global state 破壞了組件的 self-contained 特性,render 哪些 UI 或許還是解耦的,但 state 卻耦合在 global state。
Side effect
我這邊稍微勘誤一下,useEffect 跑兩次雖然讓社羣很不爽,但某種程度上來說,這是對的做法,因爲 useEffect 其實是 useSynchronizeImperativeStuff,就算 useEffect 不跑兩次,side effect 放在 useEffect 也不太好,會造成的問題即上述 double render 和 fetch on render。
side effect 到底該放在哪呢?這需要開另一篇文章討論,不過其實有一個前提問題是 TMD 什麼東西是被定義爲 side effect。
我先給個小結論,fetch data 和 reset default value 壓根就不是 side effect,他們只不過就是一個 set 的動作罷了,只不過這個 set 的動作通常是比較遙遠(在子組件裏),所以父組件不好控制,所以大家試圖用 useEffect 來 trigger。這也就是爲什麼 useDerivedState 可以完美解決。
性能
最後關於 useChildState 的性能問題,它的性能是和官方提倡的 Fully controlled 一樣 “差”,沒有比較差,但還是有點不爽,解決方案呢?React Turbo。
剛剛測了一下初始化 1000 個 useState(),花了 1ms,比如說有 100 個頂層 Route,每個 Route 平均 5 層,每層級兩個 API calls,總共 1000 狀態,用 useChildState 等於這 1000 個狀態都直接堆到最上層的 App 組件,需要耗時 1ms,還可以接受?
再說有 100 個頂層 Route 也會 Code splitting 吧?(遇性能不決就推給 Code splitting),這 1ms 主要是在 initial app load 和轉換 url 會消耗,一般使用者打字什麼的,url 不改變是沒有關係。本來 fetch data 就要花個 50ms 以上,1ms 沒有影響的。(頂層組件要 React.memo 包)
作者:Andy Lee
https://www.zhihu.com/people/dikfiell
關注「大前端技術之路」加星標,提升前端技能
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/4V_VPHsGJiOPnnDHXIoMvA