React 的慢與快:優(yōu)化 React 應(yīng)用實戰(zhàn)

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

衡量 React 性能

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

我正在為 admin-on-rest 這個開源項目工作,它使用 material-uiRedux 為任一 REST API 提供一個 admin 用戶圖形界面。這個應(yīng)用已經(jīng)有一個數(shù)據(jù)頁,在一個表格中展示一系列記錄。當(dāng)用戶改變排列順序,導(dǎo)航到下一個頁面,或者做結(jié)果篩選,這個界面的響應(yīng)式做的我不夠滿意。接下來的截屏是刷新放慢了 5x 的結(jié)果。

Datagrid refresh
Datagrid refresh

來看看發(fā)生了什么,我在 URL 里插入一個 ?react_perf。自 React 15.4,可以通過這個屬性啟用 組件 Profiling。等待初始化數(shù)據(jù)頁加載完畢。在 Chrome 開發(fā)者工具打開 Timeline 選項卡,點擊 "Record" 按鈕,并單擊表頭更新排列順序。一旦數(shù)據(jù)更新,再次點擊 "Record" 按鈕停止記錄,Chrome 會在 "User Timing" 標(biāo)簽下展示一個黃色的火焰圖。

Initial flamegraph
Initial flamegraph

如果你從未見過火焰圖,看起來會有點嚇人,但它其實非常易于使用。這個 "User Timing" 圖顯示的是每個組件占用的時間。它隱藏了 React 內(nèi)部花費的時間(這部分時間是你無法優(yōu)化的),所以這圖使你專注優(yōu)化你的應(yīng)用。這個 Timeline 顯示的是不同階段的窗口截屏,這就能聚焦到點擊表頭時對應(yīng)的時間點情況。

Initial flamegraph zoomed
Initial flamegraph zoomed

似乎在點擊排序按鈕后,甚至在拿到 REST 數(shù)據(jù) 之前 就已經(jīng)重新渲染,我的應(yīng)用就重新渲染了 <List> 組件。這個過程花費了超過 500ms。這個應(yīng)用僅僅更新了表頭的排序 icon,和在數(shù)據(jù)表之上展示灰色遮罩表明數(shù)據(jù)仍在傳輸。

另外,這個應(yīng)用花了半秒鐘提供點擊的視覺反饋。500ms 絕對是可感知的 - UI 專家如是說,當(dāng)視覺層改變低于 100ms 時,用戶感知才是瞬時的。這一可覺察的變更即是我所說的 ”慢“。

為何而更新?

根據(jù)上述火焰圖,你會看到許多小的凹陷。那不是一個好標(biāo)志。這意味著許多組件被重繪了。火焰圖顯示,<Datagrid> 組件更新花費了最多時間。為什么在獲取到新數(shù)據(jù)之前應(yīng)用會重繪整個數(shù)據(jù)表呢?讓我們來深入探討。

要理解重繪的原因,通常要借助在 render 函數(shù)里添加 console.log() 語句完成。因為函數(shù)式的組件,你可以使用如下的單行高階組件(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 基礎(chǔ)上打了一個補丁,當(dāng)一個組件基于相同 props 重繪時會打出 console 警告。說明:輸出十分冗長,并且在函數(shù)式組件中不起作用。

在這個例子中,當(dāng)用戶點擊列的標(biāo)題,應(yīng)用觸發(fā)一個 action 來改變 state:此列的排序 [currentSort] 被更新。這個 state 的改變觸發(fā)了 <List> 頁的重繪,反過來造成了整個 <Datagrid> 組件的重繪。在點擊排序按鈕后,我們希望 datagrid 表頭能夠立刻被重繪,作為用戶行為的反饋。

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

切割組件即優(yōu)化

這是 <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 的實現(xiàn),然而這 非常低效。每個 <DatagridCell> 調(diào)用會渲染至少兩到三個組件。正如你在初次界面截圖里看到的,這個表有 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>
        );
    );
}

通過抽取表體邏輯,我創(chuàng)建了新的 <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;

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

shouldComponentUpdate

React 文檔 里對于避免無用的重繪有非常明確的方法:shouldComponentUpdate()。默認(rèn)的,React 一直重繪 組件到虛擬 DOM 中。換句話說,作為開發(fā)者,在那種情況下,檢查 props 沒有改變的組件和跳過繪制都是你的工作。

以上述 <DatagridBody> 組件為例,除非 props 改變,否則 body 就不應(yīng)該重繪。

所以組件應(yīng)該如下:

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;

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

有了這一優(yōu)化,點擊表頭后,<Datagrid> 組件的重繪會跳過表體及其全部 231 個組件。這會將 500ms 的更新時間減少到 60ms。網(wǎng)絡(luò)性能提高超過 400ms!

Optimized flamegraph
Optimized flamegraph

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

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

添加一個 shouldComponentUpdate 方法也許似乎很麻煩,但如果你真的在乎性能,你所寫的大多數(shù)組件都應(yīng)該加上。

別哪里都加上 shouldComponentUpdate - 在簡單組件上執(zhí)行 shouldComponentUpdate 方法有時比僅渲染組件要耗時。也別在應(yīng)用的早期使用 - 這將過早地進(jìn)行優(yōu)化。但隨著應(yīng)用的壯大,你會發(fā)現(xiàn)組件上的性能瓶頸,此時才添加 shouldComponentUpdate 邏輯保持快速地運行。

重組

我不是很滿意之前在 <DatagridBody> 上的改造:由于使用了 shouldComponentUpdate,我不得不改造成簡單的基于類的函數(shù)式組件。這增加了許多行代碼,每一行代碼都要耗費精力 - 去寫,調(diào)試和維護(hù)。

幸運的是,得益于 recompose,你能夠在高階組件(HOC)上實現(xiàn) shouldComponentUpdate 的邏輯。它是一個 React 的函數(shù)式工具,提供 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);

這段代碼與上述的初始實現(xiàn)僅有的差異是:我導(dǎo)出了 pure(DatagridBody) 而非 DatagridBody。pure 就像 PureComponent,但是沒有額外的類模板。

當(dāng)使用 recomposeshouldUpdate() 而不是 pure() 的時候,我甚至可以更加具體,只瞄準(zhǔn)我知道可能改變的 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 是純函數(shù),我甚至可以導(dǎo)出做單元測試。

recompose 庫提供了更多 HOC 的性能優(yōu)化方案,例如 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 庫,除了能優(yōu)化性能,它能幫助你以函數(shù)和可測的方式抽取數(shù)據(jù)獲取邏輯,HOC 組合和進(jìn)行 props 操作。

Redux

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

并且,當(dāng)心 Redux 用嚴(yán)格模式對比 props。因為 Redux 將 state 綁定到組件的 props 上,如果你修改 state 上的一個對象,Redux 的 props 對比會錯過它。這也是為什么你必須在 reducer 中用 不可變性原則

舉個栗子,在 admin-on-rest 中,點擊表頭 dispatch 一個 SET_SORT action。監(jiān)聽這個 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,當(dāng) Redux 用 '===' 檢查到變化時,它發(fā)現(xiàn) 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,其他開發(fā)者喜歡用同樣來自 Facebook 的 immutable.js。我覺得這沒必要,因為 ES6 解構(gòu)賦值使得有選擇地替換組件屬性十分容易。另外,Immutable 也很笨重(60kB),所以在你的項目中添加它之前請三思。

重新選擇

為了防止(Redux 中)無用的繪制 connected 組件,你必須確保 mapStateToProps 方法每次調(diào)用不會返回新的對象。

以 admin-on-rest 中的 <List> 組件為例。它用以下代碼從 state 中為當(dāng)前 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 包含了一個數(shù)組,是以前獲取的記錄,以 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 執(zhí)行,它會返回一個新的對象,即使底層對象沒有被改變。結(jié)果,<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);

現(xiàn)在 <List> 組件僅在 state 的子集改變時重繪。

作為重組問題,reselect selector 是純函數(shù),易于測試和組合。它是為 Redux connected 組件編寫 selector 的最佳方式。

當(dāng)心 JSX 中的對象字面量

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

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

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

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

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

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

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

另一個常用來劫持純函數(shù)的 suspect 是 React.cloneElement()。如果你把 prop 值作為第二參數(shù)傳入方法,每次渲染就會生成一個帶新 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 已經(jīng)困擾了我一段時間,舉例如下:

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> 是純函數(shù),但每次 <Toolbar> 繪制它也會繪制。那是因為 material-ui 的 <CardActions> 添加了一個特殊 style,為了使第一個子節(jié)點適應(yīng) margin - 它用了一個對象字面量來做這件事。所以 <CreateButton> 每次都收到不同的 style 屬性。我用 recompose 的 onlyUpdateForKeys() HOC 解決了這個問題。

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

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

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

結(jié)論

還有許多可以使 React 應(yīng)用更快的方法(使用 keys、懶加載重路由、react-addons-perf 包、使用 ServiceWorkers 緩存應(yīng)用狀態(tài)、使用同構(gòu)等等),但正確實現(xiàn) shouldComponentUpdate 是第一步 - 也是最有用的。

React 默認(rèn)是不快的,但是無論是什么規(guī)模的應(yīng)用,它都提供了許多工具來加速。這也許是違反直覺的,尤其自從許多框架提供了 React 的替代品,它們聲稱比 React 快 n 倍。但 React 把開發(fā)者的體驗放在了性能之前。這也是為什么用 React 開發(fā)大型應(yīng)用是個愉快的體驗,沒有驚嚇,只有不變的實現(xiàn)速度。

只要記住,每隔一段時間 profile 你的應(yīng)用,讓出一些時間在必要的地方添加一些 pure() 調(diào)用。別一開始就做優(yōu)化,別花費過多時間在每個組件的過度優(yōu)化上 - 除非你是在移動端。記住在不同設(shè)備進(jìn)行測試,讓用戶對應(yīng)用的響應(yīng)式有良好印象。


掘金翻譯計劃 是一個翻譯優(yōu)質(zhì)互聯(lián)網(wǎng)技術(shù)文章的社區(qū),文章來源為 掘金 上的英文分享文章。內(nèi)容覆蓋 AndroidiOS、React前端、后端產(chǎn)品、設(shè)計 等領(lǐng)域,想要查看更多優(yōu)質(zhì)譯文請持續(xù)關(guān)注 掘金翻譯計劃。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容