淺談React性能優化的方向

本文來源於公司內部的一次閃電分享,稍做潤色分享出來。主要討論 React 性能優化的主要方向和一些小技巧。若是你以爲能夠,請多點贊,鼓勵我寫出更精彩的文章🙏。html

文章首發於掘金react

React 渲染性能優化的三個方向,其實也適用於其餘軟件開發領域,這三個方向分別是:git

  • 減小計算的量。 -> 對應到 React 中就是減小渲染的節點 或者 下降組件渲染的複雜度
  • 利用緩存。-> 對應到 React 中就是如何避免從新渲染,利用函數式編程的 memo 方式來避免組件從新渲染
  • 精確從新計算的範圍。 對應到 React 中就是綁定組件和狀態關係, 精確判斷更新的'時機'和'範圍'. 只從新渲染'髒'的組件,或者說下降渲染範圍

目錄github

減小渲染的節點/下降渲染計算量(複雜度)

首先從計算的量上下功夫,減小節點渲染的數量或者下降渲染的計算量能夠顯著的提升組件渲染性能。緩存

0️⃣ 不要在渲染函數都進行沒必要要的計算

好比不要在渲染函數(render)中進行數組排序、數據轉換、訂閱事件、建立事件處理器等等. 渲染函數中不該該放置太多反作用安全

1️⃣ 減小沒必要要的嵌套

咱們團隊是重度的 styled-components 用戶,其實大部分狀況下咱們都不須要這個玩意,好比純靜態的樣式規則,以及須要重度性能優化的場景。除了性能問題,另一個困擾咱們的是它帶來的節點嵌套地獄(如上圖)。性能優化

因此咱們須要理性地選擇一些工具,好比使用原生的 CSS,減小 React 運行時的負擔.

通常沒必要要的節點嵌套都是濫用高階組件/RenderProps 致使的。因此仍是那句話‘只有在必要時才使用 xxx’。 有不少種方式來代替高階組件/RenderProps,例如優先使用 props、React Hooks

2️⃣ 虛擬列表

虛擬列表是常見的‘長列表'和'複雜組件樹'優化方式,它優化的本質就是減小渲染的節點。

虛擬列表只渲染當前視口可見元素:

虛擬列表渲染性能對比:

虛擬列表經常使用於如下組件場景:

  • 無限滾動列表,grid, 表格,下拉列表,spreadsheets
  • 無限切換的日曆或輪播圖
  • 大數據量或無限嵌套的樹
  • 聊天窗,數據流(feed), 時間軸
  • 等等

相關組件方案:

擴展:

3️⃣ 惰性渲染

惰性渲染的初衷本質上和虛表同樣,也就是說咱們只在必要時纔去渲染對應的節點

舉個典型的例子,咱們經常使用 Tab 組件,咱們沒有必要一開始就將全部 Tab 的 panel 都渲染出來,而是等到該 Tab 被激活時纔去惰性渲染。

還有不少場景會用到惰性渲染,例如樹形選擇器,模態彈窗,下拉列表,摺疊組件等等。

這裏就不舉具體的代碼例子了,留給讀者去思考.

4️⃣ 選擇合適的樣式方案

如圖(圖片來源於THE PERFORMANCE OF STYLED REACT COMPONENTS), 這個圖片是17年的了,可是大抵的趨勢仍是這樣。

因此在樣式運行時性能方面大概能夠總結爲:CSS > 大部分CSS-in-js > inline style


避免從新渲染

減小沒必要要的從新渲染也是 React 組件性能優化的重要方向. 爲了不沒必要要的組件從新渲染須要在作到兩點:

  1. 保證組件純粹性。即控制組件的反作用,若是組件有反作用則沒法安全地緩存渲染結果
  2. 經過shouldComponentUpdate生命週期函數來比對 state 和 props, 肯定是否要從新渲染。對於函數組件可使用React.memo包裝

另外這些措施也能夠幫助你更容易地優化組件從新渲染:

0️⃣ 簡化 props

① 若是一個組件的 props 太複雜通常意味着這個組件已經違背了‘單一職責’,首先應該嘗試對組件進行拆解.
② 另外複雜的 props 也會變得難以維護, 好比會影響shallowCompare效率, 還會讓組件的變更變得難以預測和調試.

下面是一個典型的例子, 爲了判斷列表項是否處於激活狀態,這裏傳入了一個當前激活的 id:

這是一個很是糟糕的設計,一旦激活 id 變更,全部列表項都會從新刷新. 更好的解決辦法是使用相似actived這樣的布爾值 prop. actived 如今只有兩種變更狀況,也就是說激活 id 的變更,最多隻有兩個組件須要從新渲染.

簡化的 props 更容易理解, 且能夠提升組件緩存的命中率

1️⃣ 不變的事件處理器

避免使用箭頭函數形式的事件處理器, 例如:

<ComplexComponent onClick={evt => onClick(evt.id)} otherProps={values} />

假設 ComplexComponent 是一個複雜的 PureComponent, 這裏使用箭頭函數,其實每次渲染時都會建立一個新的事件處理器,這會致使 ComplexComponent 始終會被從新渲染.

更好的方式是使用實例方法:

class MyComponent extends Component {
  render() {
    <ComplexComponent onClick={this.handleClick} otherProps={values} />;
  }
  handleClick = () => {
    /*...*/
  };
}

② 即便如今使用hooks,我依然會使用useCallback來包裝事件處理器,儘可能給下級組件暴露一個靜態的函數:

const handleClick = useCallback(() => {
  /*...*/
}, []);

return <ComplexComponent onClick={handleClick} otherProps={values} />;

可是若是useCallback依賴於不少狀態,你的useCallback可能會變成這樣:

const handleClick = useCallback(() => {
  /*...*/
  // 🤭
}, [foo, bar, baz, bazz, bazzzz]);

這種寫法實在讓人難以接受,這時候誰還管什麼函數式非函數式的。我是這樣處理的:

function useRefProps<T>(props: T) {
  const ref = useRef < T > props;
  // 每次渲染更新props
  useEffect(() => {
    ref.current = props;
  });
}

function MyComp(props) {
  const propsRef = useRefProps(props);

  // 如今handleClick是始終不變的
  const handleClick = useCallback(() => {
    const { foo, bar, baz, bazz, bazzzz } = propsRef.current;
    // do something
  }, []);
}

設計更方便處理的 Event Props. 有時候咱們會被逼的不得不使用箭頭函數來做爲事件處理器:

<List>
  {list.map(i => (
    <Item key={i.id} onClick={() => handleDelete(i.id)} value={i.value} />
  ))}
</List>

上面的 onClick 是一個糟糕的實現,它沒有攜帶任何信息來標識事件來源,因此這裏只能使用閉包形式,更好的設計多是這樣的:

// onClick傳遞事件來源信息
const handleDelete = useCallback((id: string) => {
  /*刪除操做*/
}, []);

return (
  <List>
    {list.map(i => (
      <Item key={i.id} id={i.id} onClick={handleDelete} value={i.value} />
    ))}
  </List>
);

若是是第三方組件或者 DOM 組件呢? 實在不行,看能不能傳遞data-*屬性:

const handleDelete = useCallback(event => {
  const id = event.dataset.id;
  /*刪除操做*/
}, []);

return (
  <ul>
    {list.map(i => (
      <li key={i.id} data-id={i.id} onClick={handleDelete} value={i.value} />
    ))}
  </ul>
);

2️⃣ 不可變數據

不可變數據可讓狀態變得可預測,也讓 shouldComponentUpdate '淺比較'變得更可靠和高效. 筆者在React 組件設計實踐總結 04 - 組件的思惟介紹過不可變數據,有興趣讀者能夠看看.

相關的工具備Immutable.jsImmer、immutability-helper 以及 seamless-immutable。

3️⃣ 簡化 state

不是全部狀態都應該放在組件的 state 中. 例如緩存數據。按照個人原則是:若是須要組件響應它的變更, 或者須要渲染到視圖中的數據才應該放到 state 中。這樣能夠避免沒必要要的數據變更致使組件從新渲染.

4️⃣ 使用 recompose 精細化比對

儘管 hooks 出來後,recompose 宣稱再也不更新了,但仍是不影響咱們使用 recompose 來控制shouldComponentUpdate方法, 好比它提供瞭如下方法來精細控制應該比較哪些 props:

/* 至關於React.memo */
 pure()
 /* 自定義比較 */
 shouldUpdate(test: (props: Object, nextProps: Object) => boolean): HigherOrderComponent
 /* 只比較指定key */
 onlyUpdateForKeys( propKeys: Array<string>): HigherOrderComponent

其實還能夠再擴展一下,好比omitUpdateForKeys忽略比對某些 key.

精細化渲染

所謂精細化渲染指的是只有一個數據來源致使組件從新渲染, 好比說 A 只依賴於 a 數據,那麼只有在 a 數據變更時才渲染 A, 其餘狀態變化不該該影響組件 A。

Vue 和 Mobx 宣稱本身性能好的一部分緣由是它們的'響應式系統', 它容許咱們定義一些‘響應式數據’,當這些響應數據變更時,依賴這些響應式數據視圖就會從新渲染. 來看看 Vue 官方是如何描述的:

0️⃣ 響應式數據的精細化渲染

大部分狀況下,響應式數據能夠實現視圖精細化的渲染, 但它仍是不能避免開發者寫出低效的程序. 本質上仍是由於組件違背‘單一職責’.

舉個例子,如今有一個 MyComponent 組件,依賴於 A、B、C 三個數據源,來構建一個 vdom 樹。如今的問題是什麼呢?如今只要 A、B、C 任意一個變更,那麼 MyComponent 整個就會從新渲染:

更好的作法是讓組件的職責更單一,精細化地依賴響應式數據,或者說對響應式數據進行‘隔離’. 以下圖, A、B、C 都抽取各自的組件中了,如今 A 變更只會渲染 A 組件自己,而不會影響父組件和 B、C 組件:

舉一個典型的例子,列表渲染:

import React from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react-lite';

const initialList = [];
for (let i = 0; i < 10; i++) {
  initialList.push({ id: i, name: `name-${i}`, value: 0 });
}

const store = observable({
  list: initialList,
});

export const List = observer(() => {
  const list = store.list;
  console.log('List渲染');
  return (
    <div className="list-container">
      <ul>
        {list.map((i, idx) => (
          <div className="list-item" key={i.id}>
            {/* 假設這是一個複雜的組件 */}
            {console.log('render', i.id)}
            <span className="list-item-name">{i.name} </span>
            <span className="list-item-value">{i.value} </span>
            <button
              className="list-item-increment"
              onClick={() => {
                i.value++;
                console.log('遞增');
              }}
            >
              遞增
            </button>
            <button
              className="list-item-increment"
              onClick={() => {
                if (idx < list.length - 1) {
                  console.log('移位');
                  let t = list[idx];
                  list[idx] = list[idx + 1];
                  list[idx + 1] = t;
                }
              }}
            >
              下移
            </button>
          </div>
        ))}
      </ul>
    </div>
  );
});

上述的例子是存在性能問題的,單個 list-item 的遞增和移位都會致使整個列表的從新渲染:

緣由大概能猜出來吧? 對於 Vue 或者 Mobx 來講,一個組件的渲染函數就是一個依賴收集的上下文。上面 List 組件渲染函數內'訪問'了全部的列表項數據,那麼 Vue 或 Mobx 就會認爲你這個組件依賴於全部的列表項,這樣就致使,只要任意一個列表項的屬性值變更就會從新渲染整個 List 組件。

解決辦法也很簡單,就是將數據隔離抽取到單一職責的組件中。對於 Vue 或 Mobx 來講,越細粒度的組件,能夠收穫更高的性能優化效果:

export const ListItem = observer(props => {
  const { item, onShiftDown } = props;
  return (
    <div className="list-item">
      {console.log('render', item.id)}
      {/* 假設這是一個複雜的組件 */}
      <span className="list-item-name">{item.name} </span>
      <span className="list-item-value">{item.value} </span>
      <button
        className="list-item-increment"
        onClick={() => {
          item.value++;
          console.log('遞增');
        }}
      >
        遞增
      </button>
      <button className="list-item-increment" onClick={() => onShiftDown(item)}>
        下移
      </button>
    </div>
  );
});

export const List = observer(() => {
  const list = store.list;
  const handleShiftDown = useCallback(item => {
    const idx = list.findIndex(i => i.id === item.id);
    if (idx !== -1 && idx < list.length - 1) {
      console.log('移位');
      let t = list[idx];
      list[idx] = list[idx + 1];
      list[idx + 1] = t;
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  console.log('List 渲染');

  return (
    <div className="list-container">
      <ul>
        {list.map((i, idx) => (
          <ListItem key={i.id} item={i} onShiftDown={handleShiftDown} />
        ))}
      </ul>
    </div>
  );
});

效果很明顯, list-item 遞增只會從新渲染自己; 而移位只會從新渲染 List, 由於列表項沒有變更, 因此下級 list-item 也不須要從新渲染:

1️⃣ 不要濫用 Context

其實 Context 的用法和響應式數據正好相反。筆者也看過很多濫用 Context API 的例子, 說到底仍是沒有處理好‘狀態的做用域問題’.

首先要理解 Context API 的更新特色,它是能夠穿透React.memo或者shouldComponentUpdate的比對的,也就是說,一旦 Context 的 Value 變更,全部依賴該 Context 的組件會所有 forceUpdate.

這個和 Mobx 和 Vue 的響應式系統不一樣,Context API 並不能細粒度地檢測哪些組件依賴哪些狀態,因此說上節提到的‘精細化渲染’組件模式,在 Context 這裏就成爲了‘反模式’.

總結一下使用 Context API 要遵循一下原則:

  • 明確狀態做用域, Context 只放置必要的,關鍵的,被大多數組件所共享的狀態。比較典型的是鑑權狀態

    舉一個簡單的例子:

    擴展:Context其實有個實驗性或者說非公開的選項observedBits, 能夠用於控制ContextConsumer是否須要更新. 詳細能夠看這篇文章<ObservedBits: React Context的祕密功能>. 不過不推薦在實際項目中使用,並且這個API也比較難用,不如直接上mobx。

  • 粗粒度地訂閱 Context

    以下圖. 細粒度的 Context 訂閱會致使沒必要要的從新渲染, 因此這裏推薦粗粒度的訂閱. 好比在父級訂閱 Context,而後再經過 props 傳遞給下級。

另外程墨 Morgan 在避免 React Context 致使的重複渲染一文中也提到 ContextAPI 的一個陷阱:

<Context.Provider
  value={{ theme: this.state.theme, switchTheme: this.switchTheme }}
>
  <div className="App">
    <Header />
    <Content />
  </div>
</Context.Provider>

上面的組件會在 state 變化時從新渲染整個組件樹,至於爲何留給讀者去思考。

因此咱們通常都不會裸露地使用 Context.Provider, 而是封裝爲獨立的 Provider 組件:

export function ThemeProvider(props) {
  const [theme, switchTheme] = useState(redTheme);
  return (
    <Context.Provider value={{ theme, switchTheme }}>
      {props.children}
    </Context.Provider>
  );
}

// 順便暴露useTheme, 讓外部必須直接使用Context
export function useTheme() {
  return useContext(Context);
}

如今 theme 變更就不會從新渲染整個組件樹,由於 props.children 由外部傳遞進來,並無發生變更。

其實上面的代碼還有另一個比較難發現的陷阱(官方文檔也有提到):

export function ThemeProvider(props) {
  const [theme, switchTheme] = useState(redTheme);
  return (
    {/* 👇 💣這裏每一次渲染ThemeProvider, 都會建立一個新的value(即便theme和switchTheme沒有變更),
        從而致使強制渲染全部依賴該Context的組件 */}
    <Context.Provider value={{ theme, switchTheme }}>
      {props.children}
    </Context.Provider>
  );
}

因此傳遞給 Context 的 value 最好作一下緩存:

export function ThemeProvider(props) {
  const [theme, switchTheme] = useState(redTheme);
  const value = useMemo(() => ({ theme, switchTheme }), [theme]);
  return <Context.Provider value={value}>{props.children}</Context.Provider>;
}

擴展

相關文章
相關標籤/搜索