React的慢與快:優化React應用實戰

玄學醬發表於2017-10-17
本文講的是React 的慢與快:優化 React 應用實戰,

React 是慢的。我的意思是,任何中等規模的 React 應用都是慢的。但是在開始找備選方案之前,你應該明白任何中等規模的 Angular 或 Ember 應用也是慢的。好訊息是:如果你在乎效能,使 React 應用變得超級快則相當容易。這篇文章就是案例。

衡量 React 效能

我說的 “慢” 到底是什麼意思?舉個例子。

我正在為 admin-on-rest 這個開源專案工作,它使用 material-ui 和 Redux 為任一 REST API 提供一個 admin 使用者圖形介面。這個應用已經有一個資料頁,在一個表格中展示一系列記錄。當使用者改變排列順序,導航到下一個頁面,或者做結果篩選,這個介面的響應式做的我不夠滿意。接下來的截圖是重新整理放慢了 5x 的結果。

Datagrid refresh

來看看發生了什麼,我在 URL 裡插入一個 ?react_perf。自 React 15.4,可以通過這個屬性啟用 元件 Profiling。等待初始化資料頁載入完畢。在 Chrome 開發者工具開啟 Timeline 選項卡,點選 “Record” 按鈕,並單擊表頭更新排列順序。一旦資料更新,再次點選 “Record” 按鈕停止記錄,Chrome 會在 “User Timing” 標籤下展示一個黃色的火焰圖。

Initial flamegraph

如果你從未見過火焰圖,看起來會有點嚇人,但它其實非常易於使用。這個 “User Timing” 圖顯示的是每個元件佔用的時間。它隱藏了 React 內部花費的時間(這部分時間是你無法優化的),所以這圖使你專注優化你的應用。這個 Timeline 顯示的是不同階段的視窗截圖,這就能聚焦到點選表頭時對應的時間點情況。

Initial flamegraph zoomed

似乎在點選排序按鈕後,甚至在拿到 REST 資料 之前 就已經重新渲染,我的應用就重新渲染了 <List> 元件。這個過程花費了超過 500ms。這個應用僅僅更新了表頭的排序 icon,和在資料表之上展示灰色遮罩表明資料仍在傳輸。

另外,這個應用花了半秒鐘提供點選的視覺反饋。500ms 絕對是可感知的 – UI 專家如是說,當視覺層改變低於 100ms 時,使用者感知才是瞬時的。這一可覺察的變更即是我所說的 ”慢“。

為何而更新?

根據上述火焰圖,你會看到許多小的凹陷。那不是一個好標誌。這意味著許多元件被重繪了。火焰圖顯示,<Datagrid> 元件更新花費了最多時間。為什麼在獲取到新資料之前應用會重繪整個資料表呢?讓我們來深入探討。

要理解重繪的原因,通常要藉助在 render 函式裡新增 console.log() 語句完成。因為函式式的元件,你可以使用如下的單行高階元件(HOC):

// in src/log.js
const log = BaseComponent => props => {
    console.log(`Rendering ${BaseComponent.name}`);
    return <BaseComponent {...props} />;
}
export default log;

// in src/MyComponent.js
import log from `./log`;
export default log(MyComponent);

小提示:另一值得一提的 React 效能工具是 why-did-you-update。這個 npm 包在 React 基礎上打了一個補丁,當一個元件基於相同 props 重繪時會打出 console 警告。說明:輸出十分冗長,並且在函式式元件中不起作用。

在這個例子中,當使用者點選列的標題,應用觸發一個 action 來改變 state:此列的排序 [currentSort] 被更新。這個 state 的改變觸發了 <List> 頁的重繪,反過來造成了整個 <Datagrid> 元件的重繪。在點選排序按鈕後,我們希望 datagrid 表頭能夠立刻被重繪,作為使用者行為的反饋。

使得 React 應用遲緩的通常不是單個慢的元件(在火焰圖中反映為一個大的區塊)。大多數時候,使 React 應用變慢的是許多元件無用的重繪。 你也許曾讀到,React 虛擬 DOM 超級快的言論。那是真的,但在一箇中等規模的應用中,全量重繪容易造成成百的元件重繪。甚至最快的虛擬 DOM 模板引擎也不能使這一過程低於 16ms。

切割元件即優化

這是 <Datagrid> 元件的 render() 方法:

// in Datagrid.js
render() {
    const { resource, children, ids, data, currentSort } = this.props;
    return (
        <table>
            <thead>
                <tr>
                    {React.Children.map(children, (field, index) => (
                        <DatagridHeaderCell key={index} field={field} currentSort={currentSort} updateSort={this.updateSort}
                        />
                    ))}
                </tr>
            </thead>
            <tbody>
                {ids.map(id => (
                    <tr key={id}>
                        {React.Children.map(children, (field, index) => (
                            <DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
                        ))}
                    </tr>
                ))}
            </tbody>
        </table>
    );
}

這看起來是一個非常簡單的 datagrid 的實現,然而這 非常低效。每個 <DatagridCell> 呼叫會渲染至少兩到三個元件。正如你在初次介面截圖裡看到的,這個表有 7 列,11 行,即 7x11x3 = 231 個元件會重新渲染。僅僅是 currentSort 的改變時,這簡直是浪費時間。雖然在虛擬 DOM 沒有更新的情況下,React 不會更新真實DOM,所有元件的處理也會耗費 500ms。

為了避免無用的表體渲染,第一步就是把它 抽取 出來:

// in Datagrid.js
render() {
    const { resource, children, ids, data, currentSort } = this.props;
    return (
        <table>
            <thead>
                <tr>
                    {React.Children.map(children, (field, index) => (
                        <DatagridHeaderCell key={index} field={field} currentSort={currentSort} updateSort={this.updateSort}
                        />
                    ))}
                </tr>
            </thead>
            <DatagridBody resource={resource} ids={ids} data={data}>
                {children}
            </DatagridBody>
            </table>
        );
    );
}

通過抽取表體邏輯,我建立了新的 <DatagridBody> 元件:

// in DatagridBody.js
import React from `react`;

const DatagridBody = ({ resource, ids, data, children }) => (
    <tbody>
        {ids.map(id => (
            <tr key={id}>
                {React.Children.map(children, (field, index) => (
                    <DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
                ))}
            </tr>
        ))}
    </tbody>
);

export default DatagridBody;

抽取表體對效能上毫無影響,但它反映了一條優化之路。龐大的,通用的元件優化起來有難度。小的,單一職責的元件更容易處理。

shouldComponentUpdate

React 文件 裡對於避免無用的重繪有非常明確的方法:shouldComponentUpdate()。預設的,React 一直重繪 元件到虛擬 DOM 中。換句話說,作為開發者,在那種情況下,檢查 props 沒有改變的元件和跳過繪製都是你的工作。

以上述 <DatagridBody> 元件為例,除非 props 改變,否則 body 就不應該重繪。

所以元件應該如下:

import React, { Component } from `react`;

class DatagridBody extends Component {
    shouldComponentUpdate(nextProps) {
        return (nextProps.ids !== this.props.ids
             || nextProps.data !== this.props.data);
    }

    render() {
        const { resource, ids, data, children } = this.props;
        return (
            <tbody>
                {ids.map(id => (
                    <tr key={id}>
                        {React.Children.map(children, (field, index) => (
                            <DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
                        ))}
                    </tr>
                ))}
            </tbody>
        );
    }
}

export default DatagridBody;

小提示:相比手工實現 shouldComponentUpdate() 方法,我可以繼承 React 的 PureComponent 而不是 Component。這個元件會用嚴格對等(===)對比所有的 props,並且僅當 任一 props 變更時重繪。但是我知道在例子的上下文中 resource 和children 不會變更,所以無需檢查他們的對等性。

有了這一優化,點選表頭後,<Datagrid> 元件的重繪會跳過表體及其全部 231 個元件。這會將 500ms 的更新時間減少到 60ms。網路效能提高超過 400ms!

Optimized flamegraph

小提示:別被火焰圖的寬度騙了,比前一個火焰圖而言,它放大了。這幅火焰圖顯示的效能絕對是最好的!

shouldComponentUpdate 優化在圖中去掉了許多凹坑,並減少了整體渲染時間。我會用同樣的方法避免更多的重繪(例如:避免重繪 sidebar,操作按鈕,沒有變化的表頭和頁碼)。一個小時的工作之後, 點選表頭的列後,整個頁面的渲染時間僅僅是 100ms。那相當快了 – 即使仍然存在優化空間。

新增一個 shouldComponentUpdate 方法也許似乎很麻煩,但如果你真的在乎效能,你所寫的大多陣列件都應該加上。

別哪裡都加上 shouldComponentUpdate – 在簡單元件上執行 shouldComponentUpdate 方法有時比僅渲染元件要耗時。也別在應用的早期使用 – 這將過早地進行優化。但隨著應用的壯大,你會發現元件上的效能瓶頸,此時才新增shouldComponentUpdate 邏輯保持快速地執行。

重組

我不是很滿意之前在 <DatagridBody> 上的改造:由於使用了 shouldComponentUpdate,我不得不改造成簡單的基於類的函式式元件。這增加了許多行程式碼,每一行程式碼都要耗費精力 – 去寫,除錯和維護。

幸運的是,得益於 recompose,你能夠在高階元件(HOC)上實現 shouldComponentUpdate 的邏輯。它是一個 React 的函式式工具,提供 pure() 高階例項。

// in DatagridBody.js
import React from `react`;
import pure from `recompose/pure`;

const DatagridBody = ({ resource, ids, data, children }) => (
    <tbody>
        {ids.map(id => (
            <tr key={id}>
                {React.Children.map(children, (field, index) => (
                    <DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
                ))}
            </tr>
        ))}
    </tbody>
);

export default pure(DatagridBody);

這段程式碼與上述的初始實現僅有的差異是:我匯出了 pure(DatagridBody) 而非 DatagridBodypure 就像PureComponent,但是沒有額外的類别範本。

當使用 recompose 的 shouldUpdate() 而不是 pure() 的時候,我甚至可以更加具體,只瞄準我知道可能改變的 props:

// in DatagridBody.js
import React from `react`;
import shouldUpdate from `recompose/shouldUpdate`;

const DatagridBody = ({ resource, ids, data, children }) => (
    ...
);

const checkPropsChange = (props, nextProps) =>
    (nextProps.ids !== this.props.ids
  || nextProps.data !== this.props.data);

export default shouldUpdate(checkPropsChange)(DatagridBody);

checkPropsChange 是純函式,我甚至可以匯出做單元測試。

recompose 庫提供了更多 HOC 的效能優化方案,例如 onlyUpdateForKeys(),這個方法所做的檢查,與我自己寫的checkPropsChange 那類檢查完全相同。

// in DatagridBody.js
import React from `react`;
import onlyUpdateForKeys from `recompose/onlyUpdateForKeys`;

const DatagridBody = ({ resource, ids, data, children }) => (
    ...
);

export default onlyUpdateForKeys([`ids`, `data`])(DatagridBody);

強烈推薦 recompose 庫,除了能優化效能,它能幫助你以函式和可測的方式抽取資料獲取邏輯,HOC 組合和進行 props 操作。

Redux

如果你正在使用 Redux 管理應用的 state (我也推薦這一方式),那麼 connected 元件已經是純元件了。不需要新增 HOC。只要記住一旦其中一個 props 改變了,connected 元件就會重繪 – 這也包括了所有子元件。因此即使你在頁面元件上使用 Redux,你也應該在渲染樹的深層用 pure() 或 shouldUpdate()

並且,當心 Redux 用嚴格模式對比 props。因為 Redux 將 state 繫結到元件的 props 上,如果你修改 state 上的一個物件,Redux 的 props 對比會錯過它。這也是為什麼你必須在 reducer 中用 不可變性原則

舉個例子,在 admin-on-rest 中,點選表頭 dispatch 一個 SET_SORT action。監聽這個 action 的 reducer 必須 替換 state 中的 object,而不是 更新 他們。

// in listReducer.js
export const SORT_ASC = `ASC`;
export const SORT_DESC = `DESC`;

const initialState = {
    sort: `id`,
    order: SORT_DESC,
    page: 1,
    perPage: 25,
    filter: {},
};

export default (previousState = initialState, { type, payload }) => {
    switch (type) {
    case SET_SORT:
        if (payload === previousState.sort) {
            // inverse sort order
            return {
                ...previousState,
                order: oppositeOrder(previousState.order),
                page: 1,
            };
        }
        // replace sort field
        return {
            ...previousState,
            sort: payload,
            order: SORT_ASC,
            page: 1,
        };

    // ...

    default:
        return previousState;
    }
};

還是這個 reducer,當 Redux 用 `===` 檢查到變化時,它發現 state 物件的不同,然後重繪 datagrid。但是我們修改 state 的話,Redux 將會忽略 state 的改變並錯誤地跳過重繪:

// don`t do this at home
export default (previousState = initialState, { type, payload }) => {
    switch (type) {
    case SET_SORT:
        if (payload === previousState.sort) {
            // never do this
            previousState.order = oppositeOrder(previousState.order);
            return previousState;
        }
        // never do that either
        previousState.sort = payload;
        previousState.order = SORT_ASC;
        previousState.page = 1;
        return previousState;

    // ...

    default:
        return previousState;
    }
};

為了不可變的 reducer,其他開發者喜歡用同樣來自 Facebook 的 immutable.js。我覺得這沒必要,因為 ES6 解構賦值使得有選擇地替換元件屬性十分容易。另外,Immutable 也很笨重(60kB),所以在你的專案中新增它之前請三思。

重新選擇

為了防止(Redux 中)無用的繪製 connected 元件,你必須確保 mapStateToProps 方法每次呼叫不會返回新的物件。

以 admin-on-rest 中的 <List> 元件為例。它用以下程式碼從 state 中為當前 resource 獲取一系列記錄(如:帖子,評論等):

// in List.js
import React from `react`;
import { connect } from `react-redux`;

const List = (props) => ...

const mapStateToProps = (state, props) => {
    const resourceState = state.admin[props.resource];
    return {
        ids: resourceState.list.ids,
        data: Object.keys(resourceState.data)
            .filter(id => resourceState.list.ids.includes(id))
            .map(id => resourceState.data[id])
            .reduce((data, record) => {
                data[record.id] = record;
                return data;
            }, {}),
    };
};

export default connect(mapStateToProps)(List);

state 包含了一個陣列,是以前獲取的記錄,以 resource 做索引。舉例,state.admin.posts.data 包含了一系列帖子:

{
    23: { id: 23, title: "Hello, World", /* ... */ },
    45: { id: 45, title: "Lorem Ipsum", /* ... */ },
    67: { id: 67, title: "Sic dolor amet", /* ... */ },
}

mapStateToProps 方法篩選 state 物件,只返回在 list 中展示的部分。如下所示:

{
    23: { id: 23, title: "Hello, World", /* ... */ },
    67: { id: 67, title: "Sic dolor amet", /* ... */ },
}

問題是每次 mapStateToProps 執行,它會返回一個新的物件,即使底層物件沒有被改變。結果,<List> 元件每次都會重繪,即使只有 state 的一部分改變了 – date 或 ids 改變造成 id 改變。

Reselect 通過備忘錄模式解決這個問題。相比在 mapStateToProps 中直接計算 props,從 reselect 中用 selector 如果輸入沒有變化,則返回相同的輸出。

import React from `react`;
import { connect } from `react-redux`;
import { createSelector } from `reselect`

const List = (props) => ...

const idsSelector = (state, props) => state.admin[props.resource].ids
const dataSelector = (state, props) => state.admin[props.resource].data

const filteredDataSelector = createSelector(
  idsSelector,
  dataSelector
  (ids, data) => Object.keys(data)
      .filter(id => ids.includes(id))
      .map(id => data[id])
      .reduce((data, record) => {
          data[record.id] = record;
          return data;
      }, {})
)

const mapStateToProps = (state, props) => {
    const resourceState = state.admin[props.resource];
    return {
        ids: idsSelector(state, props),
        data: filteredDataSelector(state, props),
    };
};

export default connect(mapStateToProps)(List);

現在 <List> 元件僅在 state 的子集改變時重繪。

作為重組問題,reselect selector 是純函式,易於測試和組合。它是為 Redux connected 元件編寫 selector 的最佳方式。

當心 JSX 中的物件字面量

當你的元件變得更 “純” 時,你開始檢測導致無用重繪壞模式。最常見的是 JSX 中物件字面量的使用,我更喜歡稱之為 “臭名昭著的 {{“。請允許我舉例說明:

import React from `react`;
import MyTableComponent from `./MyTableComponent`;

const Datagrid = (props) => (
    <MyTableComponent style={{ marginTop: 10 }}>
        ...
    </MyTableComponent>
)

每次 <Datagrid> 元件重繪,<MyTableComponent> 元件的 style 屬性都會得到一個新值。所以即使 <MyTableComponent>是純的,每次 <Datagrid> 重繪時它也會跟著重繪。事實上,每次把物件字面量當做屬性值傳遞到子元件時,你就打破了純函式。解法很簡單:

import React from `react`;
import MyTableComponent from `./MyTableComponent`;

const tableStyle = { marginTop: 10 };
const Datagrid = (props) => (
    <MyTableComponent style={tableStyle}>
        ...
    </MyTableComponent>
)

這看起來很基礎,但是我見過太多次這個錯誤,因而生成了檢測臭名昭著的 {{ 的敏銳直覺。我把他們一律替換成常量。

另一個常用來劫持純函式的 suspect 是 React.cloneElement()。如果你把 prop 值作為第二引數傳入方法,每次渲染就會生成一個帶新 props 的新 clone 元件。

// bad
const MyComponent = (props) => <div>{React.cloneElement(Foo, { bar: 1 })}</div>;

// good
const additionalProps = { bar: 1 };
const MyComponent = (props) => <div>{React.cloneElement(Foo, additionalProps)}</div>;

material-ui 已經困擾了我一段時間,舉例如下:

import { CardActions } from `material-ui/Card`;
import { CreateButton, RefreshButton } from `admin-on-rest`;

const Toolbar = ({ basePath, refresh }) => (
    <CardActions>
        <CreateButton basePath={basePath} />
        <RefreshButton refresh={refresh} />
    </CardActions>
);

export default Toolbar;

儘管 <CreateButton> 是純函式,但每次 <Toolbar> 繪製它也會繪製。那是因為 material-ui 的 <CardActions> 新增了一個特殊 style,為了使第一個子節點適應 margin – 它用了一個物件字面量來做這件事。所以 <CreateButton> 每次都收到不同的style 屬性。我用 recompose 的 onlyUpdateForKeys() HOC 解決了這個問題。

// in Toolbar.js
import onlyUpdateForKeys from `recompose/onlyUpdateForKeys`;

const Toolbar = ({ basePath, refresh }) => (
    ...
);

export default onlyUpdateForKeys([`basePath`, `refresh`])(Toolbar);

結論

還有許多可以使 React 應用更快的方法(使用 keys、懶載入重路由、react-addons-perf 包、使用 ServiceWorkers 快取應用狀態、使用同構等等),但正確實現 shouldComponentUpdate 是第一步 – 也是最有用的。

React 預設是不快的,但是無論是什麼規模的應用,它都提供了許多工具來加速。這也許是違反直覺的,尤其自從許多框架提供了 React 的替代品,它們聲稱比 React 快 n 倍。但 React 把開發者的體驗放在了效能之前。這也是為什麼用 React 開發大型應用是個愉快的體驗,沒有驚嚇,只有不變的實現速度。

只要記住,每隔一段時間 profile 你的應用,讓出一些時間在必要的地方新增一些 pure() 呼叫。別一開始就做優化,別花費過多時間在每個元件的過度優化上 – 除非你是在移動端。記住在不同裝置進行測試,讓使用者對應用的響應式有良好印象。





原文釋出時間為:2017年4月10日

本文來自雲棲社群合作伙伴掘金,瞭解相關資訊可以關注掘金網站。


相關文章