前端框架:性能與靈活性的取捨
大家好,我卡頌。
針對 「前端框架」,長期存在着各種紛爭。其中爭論比較大的是下面兩項:
-
性能之爭
-
API 設計之爭
比如,各大新興框架都會掏出benchmark
證明自己優秀的運行時性能,在這些benchmark
中React
通常是墊底的存在。
在API
設計上,Vue
愛好者認爲:“更多的 API 約束了開發者,不會因爲團隊成員水平的差異造成代碼質量較大的差異”。
而React
愛好者則認爲:“Vue
大量的API
限制了靈活性,JSX
yyds”。
上述討論歸根結底是框架 「性能」 與 「靈活性」 的取捨。
本文將介紹一款名爲 legendapp[1] 的狀態管理庫,他與其他狀態管理庫設計理念上有很大不同。
在React
中合理使用legendapp
,可以極大提升應用的運行時性能。
但本文的目的並不僅僅是 「介紹一個狀態管理庫」,而是與你一起感受**「隨着性能提高,框架靈活性發生的變化」**。
React 的性能優化
React
性能確實不算太好,這是不爭的事實。原因在於React
自頂向下的更新機制。
每次狀態更新,React
都會從根組件開始深度優先遍歷整棵組件樹。
既然遍歷方式是固定的,那麼如何優化性能呢?答案是 「尋找遍歷時可以跳過的子樹」。
什麼樣的子樹可以跳過遍歷呢?顯然是 「沒有發生變化的子樹」。
在React
中,「變化」 主要由下面 3 個要素造成:
-
state
-
props
-
context
他們都可能改變UI
,或者觸發useEffect
。
所以,一棵子樹中如果存在上述 3 個要素的改變,可能會發生變化,也就不能跳過遍歷。
從 「變化」 的角度,我們再來看看React
中的性能優化 API,對於下面 2 個:
-
useMemo
-
useCallback
他們的本質是 —— 減少props
的變化。
對於下面 2 個:
-
PureComponent
-
React.memo
他們的本質是 —— 直接告訴React
這個組件比較 prop 變化的方式由全等比較變爲淺比較了。
狀態管理庫能做的優化
瞭解了React
的性能優化,我們再來看看狀態管理庫能爲**「性能優化」**做些什麼呢。
性能瓶頸主要發生在更新時,所以性能優化的方向主要有兩個:
-
減少不必要的更新
-
減少每次更新時要遍歷的子樹
像Redux
語境下的useSelector
走的就是第一條路。
對於後一條路,「減少更新時遍歷的子樹」 通常意味着 「減少上文介紹的 3 要素的變化」。
PS:黃玄開發的
React Forget
,是一個 「可以產生等效於 useMemo、useCallback 代碼的編譯器」,目的就是減少三要素中props
的變化。
狀態管理庫在這方面能發揮的地方很有限,因爲不管狀態管理庫如何巧妙的封裝,也無法掩蓋 「他操作的其實是一個 React 狀態」 這一事實。
比如,雖然Mobx
爲React
帶來了 「細粒度更新」,但並不能帶來與Vue
中 「細粒度更新」 相匹配的性能,因爲Mobx
最終觸發的是自頂向下的更新。
legendapp 的思路
本文要介紹的legendapp
也走的是第二條路,但他的理念蠻特別的 —— 如果減少 3 要素的數量,那不就能減少 3 要素的變化麼?
舉個極端的例子,如果一個龐大的應用中一個狀態都沒有,那更新時整棵組件樹都能被跳過。
下面是個Hook
實現的計數器例子,useInterval
每秒觸發一次回調,回調中會觸發更新:
function Counter() {
const [count, setCount] = useState(1)
useInterval(() => {
setCount(v => v + 1)
}, 1000)
return <div>Count: {count}</div>
}
根據 3 要素法則,Counter
中包含名爲count
的state
,且每秒發生變化,則更新時Counter
不會被跳過(表現爲Counter
每秒都會render
)。
下面是使用legendapp
改造的例子:
function Counter() {
const count = useObservable(1)
useInterval(() => {
count.set(v => v + 1)
}, 1000)
return <div>Count: {count}</div>
}
在這個例子中,使用legendapp
提供的useObservable
方法定義狀態count
。
Counter
只會render
一次,後續即使count
變化,Counter
也不會render
。
在線 Demo[2]
這是如何辦到的呢?
在legendapp
源碼中,useObservable
方法代碼如下:
function useObservable(initialValue) {
return React.useMemo(() => {
// ...一套類似Vue的細粒度更新機制
}, []);
}
通過包裹依賴項爲空的React.useMemo
,useObservable
返回的實際是個 「永遠不會變的值」。
既然返回的不是state
,那Counter
組件中就不包含 3 要素(state
、props
、context
)中的任何一個,當然不會render
了。
我們將這個思路推廣開,如果整個應用中所有狀態都通過useObservable
定義,那不就意味着整個應用都不存在state
,那麼更新時整棵組件樹不都能跳過了麼?
也就是說,legendapp
在React
原有更新機制基礎上,實現了一套基於 「細粒度更新」 的完整更新流程,最大限度擺脫React
的影響。
legendapp 的原理
接下來我們再聊聊legendapp
狀態更新的實現。
在傳統的React
例子中:
function Counter() {
const [count, setCount] = useState(1)
useInterval(() => {
setCount(v => v + 1)
}, 1000)
return <div>Count: {count}</div>
}
count
變化,造成Counter
組件render
,render
時count
是新的值,所以返回的div
中count
是新的值。
而在legendapp
例子中,Counter
只會render
一次,count
如何更新呢?
function Counter() {
const count = useObservable(1)
useInterval(() => {
count.set(v => v + 1)
}, 1000)
return <div>Count: {count}</div>
}
實際上,useObservable
返回的count
並不是一個數字,而是一個叫做Text
的組件:
const Text = React.memo(function ({ data }) {
// 省略內部實現
});
在Text
組件中,會監聽count
的變化。
當count
變化後,會通過內部定義的useReducer
觸發一次React
更新。
雖然React
的更新是自頂向下遍歷整棵組件樹,但是整個應用中只有Text
組件中存在狀態且發生變化,所以除Text
組件外其他子樹都會被跳過。
性能與易用性的取捨
現在我們知道在legendapp
中文本節點如何更新。
但JSX
非常靈活,除了文本節點,還有比如:
- 條件語句
如:
isShow ? <A/> : <B/>
- 自定義屬性
如:
<div className={isFocus ? 'text-blue' : ''}></div>
這些形式的變化該如何監聽,並觸發更新呢?
爲此,legendapp
提供了自定義組件Computed
:
<Computed>
<span
className={showChild.get() ? 'text-blue' : ''}
>
{showChild.get() ? 'true' : 'false'}
</span>
</Computed>
對應的React
語句:
<span className={showChild ? 'text-blue' : ''}>
{showChild ? 'true' : 'false'}
</span>
Computed
相當於一個容器,會監聽children
中的狀態變化,並觸發React
更新。
文本節點對應的Text
組件可以類比爲**「被 Computed 包裹的文本內容」**:
<Computed>{文本內容}</Computed>
除此之外,還有些更具語意化的標籤(本質都是Computed
的封裝),比如用於條件語句的Show
:
<Show if={showChild}>
<div>Child element</div>
</Show>
對應的React
語句:
{showChild && (
<div>Child element</div>
)}
還有用於數組遍歷的<For/>
組件等。
到這一步你應該發現了,雖然我們利用legendapp
提高了運行時性能,但也引入瞭如Computed
、Show
等新的API
。
你是願意框架更靈活、有更多想象力,還是願意犧牲靈活性,獲得更高的性能?
這就是本文想表達的 「性能與易用性的取捨」。
總結
用過Solid.js
的同學會發現,引入legendapp
的React
在API
上已經無限接近Solid.js
了。
事實上,當Solid.js
選擇結合React
與 「細粒度更新」,並在性能上作出優化的那一刻起,就決定了他的最終形態就是如此。
legendapp
+ React
已經在運行時做到了很高的性能,如果想進一步優化,一個可行的方向是 「編譯時優化」。
如果朝着這個路子繼續前進,在不捨棄 「虛擬 DOM」 的情況下,就會與Vue3
無限接近。
如果更極端點,捨棄了 「虛擬 DOM」,那麼就會與Svelte
無限接近。
每個框架都在性能與靈活性上作出了取捨,以討好他們的目標受衆。
參考資料
[1] legendapp: https://www.legendapp.com/open-source/state/hooks/
[2] 在線 Demo: https://codesandbox.io/s/legend-state-primitives-140tmg
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/eGvN21Jd2lEA146c9fDPFg