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