【譯】針對 Airbnb 清單頁的 React 性能優化
原文地址:React Performance Fixes on Airbnb Listing Pages
原文作者:Joe Lencioni譯文出自:掘金翻譯計劃本文永久鏈接:https://github.com/xitu/gold-miner/blob/master/TODO/recent-web-performance-fixes-on-airbnb-listing-pages.md譯者:木羽 zwwill校對者:tvChan, atuooo(史金煒)
簡要:可能在某些領域存在一些觸手可及的性能優化點,雖不常見但依然很重要。
我們一直在努力把 http://airbnb.com 的核心預訂流程遷移到一個使用 React Router 和 Hypernova 技術的服務端渲染的單頁應用。年初,我們推出了登陸頁面,搜索結果告訴我們很成功。我們的下一步是將清單詳情頁擴展到單頁應用程序里去。
http://airbnb.com 的清單詳情頁: https://www.airbnb.com/rooms/8357
這是您在確定預訂清單時所訪問的頁面。在整個搜索過程中,您可能會多次訪問該頁面以查看不同的清單。這是 airbnb 網站訪問量最大同時也是最重要的頁面之一,因此,我們必須做好每一個細節。
作為遷移到我們的單頁應用的一部分,我希望能排查出所有影響清單頁交互性能的遺留問題(例如,滾動、點擊、輸入)。讓頁面啟動更快並且延遲更短,這符合我們的目標,而且這會讓使用我們網站的人們有更好的體驗。
通過解析、修復、再解析的流程,我們極大地提高了這個關鍵頁的交互性能,使得預訂體驗更加順暢,更令人滿意。在這篇文章中,您將了解到我用來解析這個頁面的技術,用來優化它的工具,以及在解析結果給出的火焰圖表中感受優化的效果。
方法
這些配置項通過Chrome的性能工具被記錄下來:
- 打開隱身窗口(這樣我的瀏覽器擴展工具不會干擾我的解析)。
- 使用
?react_perf
在查詢字元串中進行配置訪問本地開發頁面(啟用 React 的 User Timing 注釋,並禁用一些會使頁面變慢的 dev-only 功能,例如 axe-core) - 點擊 record 按鈕 ??
- 操作頁面(如:滾動,點擊,打字)
- 再次點擊 record 按鈕 ??,分析結果
通常情況下,我推薦在移動設備上進行解析以了解在較慢的設備上的用戶體驗,比如 Moto C Plus,或者 CPU 速度設置為 6x 減速。然而,由於這些問題已經足夠嚴重了,以至於即使是在沒有節流的情況下,在我的高性能筆記本電腦上結果表現也是明顯得糟糕。
初始化渲染
在我開始優化這個頁面時,我注意到控制台上有一個警告:??
webpack-internal:///36:36 Warning: React attempted to reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting. React injected new markup to compensate which works but you have lost many of the benefits of server rendering. Instead, figure out why the markup being generated is different on the client or server: (client) ut-placeholder-label screen-reader-only" (server) ut-placeholder-label" data-reactid="628" n
這是可怕的 客戶端/服務端 不匹配問題,當伺服器渲染不同於客戶端初始化渲染時發生。這會迫使你的 Web 瀏覽器執行那些在使用伺服器渲染時不應該做的工作,所以每當發生這種情況時 React 就會給出這樣的提醒 ? 。
不過,錯誤信息並沒有明確地表明底發生了什麼,或者可能的原因是什麼,但確實給了我們一些線索。?? 我注意到一些看起來像 CSS 類的文本,所以我在終端里輸入下面的命令:
~/airbnb ??? ag ut-placeholder-labelnapp/assets/javascripts/components/o2/PlaceholderLabel.jsxn85: input-placeholder-label: true,nnapp/assets/stylesheets/p1/search/_SearchForm.scssn77: .input-placeholder-label {n321:.input-placeholder-label,nnspec/javascripts/components/o2/PlaceholderLabel_spec.jsxn25: const placeholderContainer = wrapper.find(.input-placeholder-label); n
很快地我將搜索範圍縮小到了 o2/PlaceHolderLabel.jsx
這個文件,一個在頂部渲染的搜索組件。
事實上,我們使用了一些特徵檢測,以確保在舊瀏覽器(如 IE)中可以看到 placeholder
,如果在當前的瀏覽器中不支持 placeholder
,則會以不同的方式呈現 input
。特徵檢測是正確的方法(與用戶代理嗅探相反),但是由於在伺服器渲染時沒有瀏覽器檢測功能,導致伺服器總是會渲染一些額外的內容,而不是大多數瀏覽器將呈現的內容。
這不僅降低了性能,還導致了一些額外的標籤被渲染出來,然後每次再從頁面上刪除。真難伺候!我把渲染的內容轉化為 React 的 state,並將其設置到 componentDidMount
,直到客戶端渲染時才呈現。這完美的解決了問題。
我重新運行了一遍 profiler 發現,<SummaryContainer>
在 mounting 後立刻更新。
Redux 連接的 SummaryContainer 重繪消耗了 101.64 ms
更新後會重新渲染一個 <BreadcrumbList>
、兩個 <ListingTitles>
和一個 <SummaryIconRow>
組件,但是他們前後並沒有任何區別,所以我們可以通過使用 React.PureComponent
使這三個組件的渲染得到顯著的優化。方法很簡單,如下
export default class SummaryIconRow extends React.Component {n ...n}n
改成這樣:
export default class SummaryIconRow extends React.PureComponent {n ...n}n
接下來,我們可以看到 <BookIt>
在頁面初始載入時也發生了重新渲染的操作。根據火焰圖可以看出,大部分時間都消耗在渲染 <GuestPickerTrigger>
和 <GuestCountFilter>
組件上。
有趣的是,除非用戶操作,這些組件基本是不可見的 ?? 。
解決這個問題的方法是在不需要的時候不渲染這些組件。這加快了初始化的渲染,清除了一些不必要的重繪。?? 如果我們進一步地進行優化,增加更多 PureComponents,那麼初始化渲染會變得更快。
來回滾動
通常我們會在清單頁面上做一些平滑滾動的效果,但在滾動時效果並不理想。?? 當動畫沒有達到平滑的 60 fps(每秒幀),甚至是 120 fps,人們通常會感到不舒服也不會滿意。滾動是一種特殊的動畫,是你的手指動作的直接反饋,所以它比其他動畫更加敏感。
稍微分析一下後,我發現我們在滾動事件處理機制中做了很多不必要的 React 組件的重繪!看起來真的很糟糕:
我可以使用 React.PureComponent
轉化 <Amenity>
、<BookItPriceHeader>
和 <StickyNavigationController>
這三個組件來解決絕大部分問題。這大大降低了頁面重繪的成本。雖然我們還沒能達到 60 fps(每秒幀數),但已經很接近了。
另外還有一些可以優化的部分。展開火焰圖表,我們可以看到,<StickyNavigationController>
也產生了耗時的重繪。如果我們細看他的組件堆棧信息,可以發現四個相似的模塊。
<StickyNavigationController>
是清單頁面頂部的一個部分,當我們不同部分間滾動時,它會聯動高亮您當前所在的位置。火焰圖表中的每一塊都對應著常駐導航的四個鏈接之一。並且,當我們在兩個部分間滾動時,會高亮不同的鏈接,所以有些鏈接是需要重繪的,就像下圖顯示的那樣。
現在,我注意到我們這裡有四個鏈接,在狀態切換時改變外觀的只有兩個,但在我們的火焰圖表中顯示,四個鏈接每都做了重繪操作。這是因為我們的 <NavigationAnchors>
組件每次切換渲染時都創建一個新的方法作為參數傳遞給 <NavigationAnchor>
,這違背了我們純組件的優化原則。
const anchors = React.Children.map(children, (child, index) => { n return React.cloneElement(child, {n selected: activeAnchorIndex === index,n onPress(event) { onAnchorPress(index, event); },n });n});n
我們可以通過確保 <NavigationAnchor>
每次被 <NavigationAnchors>
渲染時接收到的都是同一個 function 來解決這個問題。
const anchors = React.Children.map(children, (child, index) => { n return React.cloneElement(child, {n selected: activeAnchorIndex === index,n index,n onPress: this.handlePress,n });n});n
接下來是 <NavigationAnchor>
:
class NavigationAnchor extends React.Component {n constructor(props) {n super(props);n this.handlePress = this.handlePress.bind(this);n }nn handlePress(event) {n this.props.onPress(this.props.index, event);n }nn render() {n ...n }n}n
在優化後的解析中我們可以看到,只有兩個鏈接被重繪,事半功倍!並且,如果我們這裡有更多的鏈接塊,那麼渲染的工作量將不再增加。
Dounan Shi 再 Flexport 一直在維護 Reflective Bind,這是供你用來做這類優化的 Babel 插件。這個項目還處於起步階段,還不足以正式發布,但我已經對它未來的可能性感到興奮了。
繼續看 Performance 記錄的 Main 面板,我注意到我們有一個非常可疑的模塊 handleScroll
,每次滾動事件都會消耗 19ms。如果我們要達到 60 fps 就只有 16ms 的渲染時間,這明顯超出太多。
罪魁禍首的好像是 onLeaveWithTracking
內的某個部分。通過代碼排查,問題定位到了 <EngagementWrapper>
。然後在看看他的調用棧,發現大部分的時間消耗在了 React setState
,但奇怪的是,我們並沒有發現期間有產生任何的重繪。
深入挖掘 <EngagementWrapper>
,我注意到,我們使用了 React 的 state 跟蹤了實例上的一些信息。
this.state = { inViewport: false }; n
然而,在渲染的流程中我們從來沒有使用過這個 state,也沒有監聽它的變化來做重繪,也就是說,我們做了無用功。將所有 React 的此類 state 用法轉換為簡單的實例變數可以讓這些滾動動畫更流暢。
this.inViewport = false; n
我還注意到,<AboutThisListingContainer>
的重繪導致了組件 <Amenities>
高消耗且多餘的重繪。
最終確認是我們使用的高階組件 withExperiments
來幫助我們進行實驗所造成的。HOC 每次都會創建一個新的對象作為參數傳遞給子組件,整個流程都沒有做任何優化。
render() {n ...n const finalExperiments = {n ...experiments,n ...this.state.experiments,n };n return (n <WrappedComponentn {...otherProps}n experiments={finalExperiments}n />n );n}n
我通過引入 reselect 來修復這個問題,他可以緩存上一次的結果以便在連續的渲染中保持相同的引用。
const getExperiments = createSelector(n ({ experimentsFromProps }) => experimentsFromProps,n ({ experimentsFromState }) => experimentsFromState,n (experimentsFromProps, experimentsFromState) => ({n ...experimentsFromProps,n ...experimentsFromState,n }),n);n...nrender() {n ...n const finalExperiments = getExperiments({n experimentsFromProps: experiments,n experimentsFromState: this.state.experiments,n });n return (n <WrappedComponentn {...otherProps}n experiments={finalExperiments}n />n );n}n
問題的第二個部分也是相似的。我們使用了 getFilteredAmenities
方法將一個數組作為第一個參數,並返回該數組的過濾版本,類似於:
function getFilteredAmenities(amenities) {n return amenities.filter(shouldDisplayAmenity);n}n
雖然看上去沒什麼問題,但是每次運行即使結果相同也會創建一個新的數組實例,這使得即使是很單純的組件也會重複的接收這個數組。我同樣是通過引入 reselect
緩存這個過濾器來解決這個問題。??
可能還有更多的優化空間,(比如 CSS containment),不過現在看起來已經很好了。
點擊操作
更多地體驗過這個頁面後,我明顯得感覺到在點擊「Helpful」按鈕時存在延時問題。
我的直覺告訴我,點擊這個按鈕導致頁面上的所有評論都被重新渲染了。看一看火焰圖表,和我預計的一樣:
在這兩個地方引入 React.PureComponent
之後,我們讓頁面的更新更高效。
鍵盤操作
再回到之前的客戶端/服務端不匹配的老問題上,我注意到,在這個輸入框里打字確實有反應遲鈍的感覺。
分析後發現,每次按鍵操作都會造成整個評論區頭部的重繪。這是在逗我嗎???
為了解決這個問題,我把頭部的一部分提取出來做為組件,以便我可以把它做成一個 React.PureComponent
,然後再把這個幾個 React.PureComponent
分散在構建樹上。這使得每次按鍵操作就只能重繪需要重繪的組件了,也就是 input
。
我們學到了什麼?
- 我們希望頁面可以啟動得更快延遲更短
- 這意味著我們需要關注不僅僅是頁面交互時間,還需要對頁面上的交互進行剖析,比如滾動、點擊和鍵盤事件。
React.PureComponent
和reselect
在我們 React 應用的性能優化工具中是非常有用的兩個工具。- 當實例變數這種輕量級的工具可以完美地滿足你的需求時,就不要使用像 React state 這種重量級的工具了。
- 雖然 React 很強大,但有時編寫代碼來優化你的應用反而更容易。
- 培養分析、優化、再分析的習慣。
如果你喜歡做性能優化,那就加入我們吧,我們正在尋找才華橫溢、對一切都很好奇的你。我們知道,Airbnb 還有大優化的空間,如果你發現了一些我們可能感興趣的事,亦或者只是想和我聊聊天,你可以在 Twitter 上找到我 @lencioni。
著重感謝 Thai Nguyen 在 review 代碼和清單頁遷移到單頁應用的過程中作出的貢獻。?? 得以實施主要得感謝 Chrome DevTools 團隊,這些性能可視化的工具實在是太棒了!另外 Netflix 是第二項優化的功臣。
感謝 Adam Neary。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。
推薦閱讀:
※記一次冷雨寒風中的UWA優化日(內附技術PPT)
※Unity優化技巧(上)
※Android應用內存泄露分析、改善經驗總結
※關於Unity渲染優化,你可能遇到這些問題
※#每天一個小目標#Unity技術分享(九)