前端框架:性能與靈活性的取捨

大家好,我卡頌。

針對 「前端框架」,長期存在着各種紛爭。其中爭論比較大的是下面兩項:

比如,各大新興框架都會掏出benchmark證明自己優秀的運行時性能,在這些benchmarkReact通常是墊底的存在。

API設計上,Vue愛好者認爲:“更多的 API 約束了開發者,不會因爲團隊成員水平的差異造成代碼質量較大的差異”。

React愛好者則認爲:“Vue大量的API限制了靈活性,JSX yyds”。

上述討論歸根結底是框架 「性能」「靈活性」 的取捨。

本文將介紹一款名爲 legendapp[1] 的狀態管理庫,他與其他狀態管理庫設計理念上有很大不同。

React中合理使用legendapp,可以極大提升應用的運行時性能。

但本文的目的並不僅僅是 「介紹一個狀態管理庫」,而是與你一起感受**「隨着性能提高,框架靈活性發生的變化」**。

React 的性能優化

React性能確實不算太好,這是不爭的事實。原因在於React自頂向下的更新機制。

每次狀態更新,React都會從根組件開始深度優先遍歷整棵組件樹。

既然遍歷方式是固定的,那麼如何優化性能呢?答案是 「尋找遍歷時可以跳過的子樹」

什麼樣的子樹可以跳過遍歷呢?顯然是 「沒有發生變化的子樹」

React中,「變化」 主要由下面 3 個要素造成:

他們都可能改變UI,或者觸發useEffect

所以,一棵子樹中如果存在上述 3 個要素的改變,可能會發生變化,也就不能跳過遍歷。

「變化」 的角度,我們再來看看React中的性能優化 API,對於下面 2 個:

他們的本質是 —— 減少props的變化。

對於下面 2 個:

他們的本質是 —— 直接告訴React這個組件比較 prop 變化的方式由全等比較變爲淺比較了。

狀態管理庫能做的優化

瞭解了React的性能優化,我們再來看看狀態管理庫能爲**「性能優化」**做些什麼呢。

性能瓶頸主要發生在更新時,所以性能優化的方向主要有兩個:

Redux語境下的useSelector走的就是第一條路。

對於後一條路,「減少更新時遍歷的子樹」 通常意味着 「減少上文介紹的 3 要素的變化」

PS:黃玄開發的React Forget,是一個 「可以產生等效於 useMemo、useCallback 代碼的編譯器」,目的就是減少三要素中props的變化。

狀態管理庫在這方面能發揮的地方很有限,因爲不管狀態管理庫如何巧妙的封裝,也無法掩蓋 「他操作的其實是一個 React 狀態」 這一事實。

比如,雖然MobxReact帶來了 「細粒度更新」,但並不能帶來與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中包含名爲countstate,且每秒發生變化,則更新時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.useMemouseObservable返回的實際是個 「永遠不會變的值」

既然返回的不是state,那Counter組件中就不包含 3 要素(statepropscontext)中的任何一個,當然不會render了。

我們將這個思路推廣開,如果整個應用中所有狀態都通過useObservable定義,那不就意味着整個應用都不存在state,那麼更新時整棵組件樹不都能跳過了麼?

也就是說,legendappReact原有更新機制基礎上,實現了一套基於 「細粒度更新」 的完整更新流程,最大限度擺脫React的影響。

legendapp 的原理

接下來我們再聊聊legendapp狀態更新的實現。

在傳統的React例子中:

function Counter() {
  const [count, setCount] = useState(1)

  useInterval(() => {
    setCount(v => v + 1)
  }, 1000)

  return <div>Count: {count}</div>
}

count變化,造成Counter組件renderrendercount是新的值,所以返回的divcount是新的值。

而在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提高了運行時性能,但也引入瞭如ComputedShow等新的API

你是願意框架更靈活、有更多想象力,還是願意犧牲靈活性,獲得更高的性能?

這就是本文想表達的 「性能與易用性的取捨」

總結

用過Solid.js的同學會發現,引入legendappReactAPI上已經無限接近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