- 原文地址:Dragging React performance forward
- 原文做者:Alex Reardon
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:hexiang
- 校對者:wznonstop,zephyrJS
頭圖由 James Padolsey 在 Unsplash 拍攝css
我爲 React 寫了一個拖放庫 react-beautiful-dnd 🎉。Atlassian 建立這個庫的目的是爲網站上的列表提供一種美觀且易於使用的拖放體驗。你能夠閱讀介紹文檔: 關於拖放的反思。這個庫徹底經過狀態驅動 —— 用戶的輸入致使狀態改變,而後更新用戶看到的內容。這在概念上容許使用任何輸入類型進行拖動,可是太多狀態驅動拖動將會致使性能上的缺陷。🦑html
咱們最近發佈了 react-beautiful-dnd 的第四個版本 version 4
,其中包含了大規模的性能提高。前端
列表中的數據是基於具備 500 個可拖動卡片的配置,在開發版本中啓用儀表的狀況下進行記錄的,開發版本及啓用儀表都會下降運行速度。但與此同時,咱們使用了一臺性能卓越的機器用於此次記錄。確切的性能提高幅度會取決於數據集的大小,設備性能等。react
您看仔細了,咱們看到有 99% 的性能提高 🤘。因爲這個庫已經通過了極致的優化,因此這些改進更加使人印象深入。你可在大型列表示例或大型面板示例這兩個例子中來感覺性能提高的酸爽 😎。android
在本博客中,我將探討咱們面臨的性能挑戰以及咱們如何克服它們以得到如此重要的結果。我將談論的解決方案很是適合咱們的問題領域。有一些原則和技術將會出現 —— 但具體問題可能會在問題領域有所不一樣。ios
我在這篇博客中描述的一些技術至關先進,其中大部分技術最好在 React 庫的邊界內使用,而不是直接在 React 應用程序中使用。git
咱們都很忙!這裏是這個博客的一個很是高度的概述:github
儘量避免 render
調用。 另外之前探索的技術 (第一輪, 第二輪),我在這裏有一些新的認識:算法
render
不是改變樣式的惟一方法react-beautiful-dnd 的大部分狀態管理使用 Redux。這是一個實現細節,庫的使用者可使用任何他們喜歡的狀態管理工具。本博客中的許多具體內容都針對 Redux 應用程序 —— 然而,有一些技術是通用的。爲了可以向不熟悉 Redux 的人解釋清楚,下面是一些相關術語的說明:redux
context
中,因此被鏈接的組件能夠被註冊去更新。若是你感興趣,這是一些來自 Dan Abramov 的關於這些概念更詳細的信息。
做爲通常規則,您應該儘量避免調用組件的 render() 函數,render
調用代價很大,有如下緣由:
render
函數調用的進程很費資源Reconciliation 是 React 構建一顆新樹的過程,而後用當前的視圖(虛擬 DOM)來進行 調和,根據須要執行實際的 DOM 更新。reconciliation 過程在調用一個 render
後被觸發。
render
函數的 processing 和 reconciliation 在規模上是代價很大的。 若是你有 100 個或者 10000 個組件,你可能不但願每一個組件在每次更新時都協調一個 store
中的共享狀態。理想狀況下,只有須要更新的組件纔會調用它的 render
函數。對於咱們每秒 60 次更新(60 fps)的拖放,這尤爲如此。
我在前兩篇博客 (第一輪, 第二輪) 中探討了避免沒必要要的 render
調用的技巧,React 文檔關於這個問題的敘述也討論了這個主題。就像全部東西都有一個平衡點同樣,若是你太過刻意地避免渲染,你可能會引入大量潛在的冗餘記憶檢查。 這個話題已經在其餘地方討論過了,因此我不會在這裏詳細討論。
除了渲染成本以外,當使用 Redux 時,鏈接的組件越多,您就須要在每次更新時運行更多的狀態查詢 (mapStateToProps
) 和記憶檢查。我在 round 2 blog 中詳細討論了與 Redux 相關的狀態查詢,選擇器和備忘錄。
注意從鼠標下的圓圈出現到被選卡片變綠時的時間差。
當點擊一個大列表中的卡片時,須要至關長的時間才能開始拖拽,在 500 個卡片的列表中這是 2.6 s 😢!對於那些指望拖放交互是即時的用戶來講,這是一個糟糕的體驗。 讓咱們來看看發生了什麼,以及咱們用來解決問題的一些技巧。
爲了執行拖動,咱們將全部相關組件的尺寸(座標,大小,邊距等)的快照放入到咱們的 state 和拖動的開始處。而後,咱們會在拖動過程當中使用這些信息來計算須要移動的內容。 咱們來看看咱們如何完成這個初始快照:
state
發出請求 request
。request
並查看他們是否須要發佈任何內容。shouldPublish
屬性。publish
回調來發布維度好的,因此這裏有一些痛點:
- 當咱們開始拖動時,咱們在
state
上發起了一個request
。- 關聯維度發佈組件讀取此請求並查看他們是否須要發佈任何內容
此時,每一個關聯的維度發佈者都須要針對 store 執行檢查,以查看他們是否須要請求維度。不理想,但並不可怕。讓咱們繼續
- 若是他們須要發佈,他們會在未鏈接的維度發佈者上設置一個
shouldPublish
屬性
咱們過去使用 shouldPublish
屬性來傳遞消息給組件來執行一個動做。不幸的是,這樣作會有一個反作用,它會致使組件進行 render,從而引起該組件自己及其子組件的調和。當你在衆多組件上執行這個操做時,代價昂貴。
- 未鏈接的維度發佈者從 DOM 收集維度並使用
publish
回調來發布維度
事情會變得更糟。首先,咱們會當即從 DOM 讀取不少維度,這可能須要一些時間。從那裏每一個維度發佈者將單獨 publish
一個維度。 這些維度會被存儲到狀態中。這種 state
的變化會觸發 store 的訂閱,從而致使步驟二中的關聯組件狀態查詢和記憶檢查被執行。它還會致使應用程序中的其餘鏈接組件相似地運行冗餘檢查。所以,每當未鏈接的維度發佈者發佈維度時,將致使全部其餘鏈接組件的冗餘工做。這是一個 O(n²) 算法 - 更糟!哎。
爲了解決這些問題,咱們建立了一個新角色來管理維度收集流程:dimension marshal
(維度元帥)。如下是新的維度發佈的工做方式:
拖動工做以前:
dimension marshal
,而後把它放到了 context
中。context
中讀取 dimension marshal
,並向 dimension marshal
註冊本身。Dimension 發佈者再也不直接監聽 store。 所以,不存在更多未鏈接的維度發佈者。拖動工做開始:
state
發出 request
。dimension marshal
接收 request
並直接向所需維度發佈者請求關鍵維度(拖動卡片及其容器)以便開始拖動。 這些發佈到 store 就能夠開始拖動。dimension marshal
將在下一個幀中異步請求全部其餘 dimension publishers 的 dimensions。這樣作會分割從 DOM 中收集維度的成本,並將維度(下一步)發佈到單獨的幀中。dimension marshal
執行全部收集維度的批量 publish
。在這一點上,state 是徹底混合的,它只須要三幀。這種方法的其餘性能優點:
由於 dimension marshal
知道系統中的全部 ID
和 index
,因此它能夠直接請求任何維度 O(1)
。這也使其可以決定如何以及什麼時候收集和發佈維度。 之前,咱們有一個單獨的 shouldPublish
信息,它對一切都當即進行迴應。dimension marshal
在調整這部分生命週期的性能方面給了咱們很大的靈活性。若是須要,咱們甚至能夠根據設備性能實施不一樣的收集算法。
咱們經過如下方式改進了維度收集的性能:
當一個拖動開始的時候,咱們須要應用一些樣式到每個 Draggable
(例如 pointer-events: none;
)。爲此咱們應用了一個行內樣式。爲了應用行內樣式咱們須要 render
每個 Draggable
。當用戶試圖開始拖動時,這可能會致使潛在的在 100 個可拖動卡片上調用 render
,這會致使 500 個卡片耗費 350 ms。
那麼,咱們將如何去更新這些樣式而不會產生 render
?
對於全部 Draggable
組件,咱們如今應用共享數據屬性(例如 data-react-beautiful-dnd-draggable
)。data
屬性歷來沒有改變過。 可是,咱們經過咱們在頁面 head
建立的共享樣式元素動態地更改應用於這些數據屬性的樣式。
這是一個簡單的例子:
// 建立一個新的樣式元素
const el = document.createElement('style');
el.type = 'text/css';
// 將它添加到頁面的頭部
const head = document.querySelector('head');
head.appendChild(el);
// 在未來的某個時刻,咱們能夠徹底從新定義樣式元素的所有內容
const setStyle = (newStyles) => {
el.innerHTML = newStyles;
};
// 咱們能夠在生命週期的某個時間點應用一些樣式
setStyle(`
[data-react-beautiful-dnd-drag-handle] {
cursor: grab;
}
`);
// 另外一個時刻能夠改變這些樣式
setStyle(`
body {
cursor: grabbing;
}
[data-react-beautiful-dnd-drag-handle] {
point-events: none;
}
[data-react-beautiful-dnd-draggable] {
transition: transform 0.2s ease;
}
`);
複製代碼
若是你感興趣,你能夠看看咱們怎麼實施它的。
在拖拽生命週期的不一樣時間點上,咱們從新定義了樣式規則自己的內容。 您一般會經過切換 class
來改變元素的樣式。 可是,經過使用定義動態樣式,咱們能夠避免應用新的 class
去 render
任何須要渲染的組件。
咱們使用 data
屬性而不是 class
使這個庫對於開發者更容易使用,他們不須要合併咱們提供的 class
和他們本身的 class
。
使用這種技術,咱們還可以優化拖放生命週期中的其餘階段。 咱們如今能夠更新卡片的樣式,而無需 render
它們。
注意:您能夠經過建立預置樣式規則集,而後更改 body
上的 class
來激活不一樣的規則集來實現相似的技術。然而,經過使用咱們的動態方法,咱們能夠避免在 body
上添加 class
es。並容許咱們隨着時間的推移使用具備不一樣值的規則集,而不只僅是固定的。
不要懼怕,data
屬性的選擇器性能很好,與 render
性能差異很大。
當一個拖動開始時,咱們也在 Draggable
上調用 render
來將 canLift
prop 更新爲 false
。這用於防止在拖動生命週期中的特定時間開始新的拖動。咱們須要這個 prop ,由於有一些鍵盤鼠標的組合輸入可讓用戶在已經拖動一些東西的期間開始另外一些東西的拖動。咱們仍然真的須要這個 canLift
檢查 —— 可是咱們怎麼作到這一點,而無需在全部的 Draggables
上調用 render
?
咱們沒有經過 render
更新每一個 Draggable
的 props 來阻止拖動的發生,而是在 context
中添加了 canLift
函數。該函數可以從 store 中得到當前狀態並執行所需的檢查。經過這種方式,咱們可以執行相同的檢查,但無需更新 Draggable
的 props。
此代碼大大簡化,但它說明了這種方法:
import React from 'react';
import PropTypes from 'prop-types';
import createStore from './create-store';
class Wrapper extends React.Component {
// 把 canLiftFn 放置在 context 上
static childContextTypes = {
canLiftFn: PropTypes.func.isRequired,
}
getChildContext(): Context {
return {
canLiftFn: this.canLift,
};
}
componentWillMount() {
this.store = createStore();
}
canLift = () => {
// 在這個位置咱們能夠進入 store
// 因此咱們能夠執行所需的檢查
return this.store.getState().canDrag;
}
// ...
}
class DraggableHandle extends React.Component {
static contextTypes = {
canLiftFn: PropTypes.func.isRequired,
}
// 咱們能夠用它來檢查咱們是否被容許開始拖拽
canStartDrag() {
return this.context.canLiftFn();
}
// ...
}
複製代碼
很明顯,你只想很是謹慎地作到這一點。可是,咱們發現它是一種很是有用的方法,能夠在不更新 props 的狀況下向組件提供 store 信息。鑑於此檢查是針對用戶輸入而進行的,而且沒有渲染影響,咱們能夠避開它。
在擁有 500 個卡片的列表中進行拖動馬上就拖動了
經過使用上面介紹的技術,咱們能夠將在一個有 500 個可拖動卡片的拖動時間從 2.6 s 拖動到到 15 ms(在一個幀內),這是一個 99% 的減小 😍!。
移動大量卡片時幀速降低。
從一個大列表移動到另外一個列表時,幀速率顯著降低。 當有 500 個可拖動卡片時,移入新列表將花費大約 350 ms。
react-beautiful-dnd 的核心設計特徵之一是卡片在發生拖拽時會天然地移出其它卡片的方式。可是,當您進入新列表時,您一般能夠一次取代大量卡片。 若是您移動到列表的頂部,則需移動下整個列表中的全部內容才能騰出空間。離線的 CSS 變化自己代價不大。然而,與 Draggables
溝通,經過 render
來告訴他們移動出去的方式,對於同時處理大量卡片來講是很昂貴的。
咱們如今只移動對用戶來講部分可見的東西,而不是移動用戶看不到的卡片。 所以徹底不可見的卡片不會移動。這大大減小了咱們在進入大列表時須要作的工做量,由於咱們只須要 render
可見的可拖動卡片。
當檢測可見的內容時,咱們須要考慮當前的瀏覽器視口以及滾動容器(帶有本身滾動條的元素)。一旦用戶滾動,咱們會根據如今可見的內容更新位移。在用戶滾動時,確保這種位移看起來正確,有一些複雜。他們不該該知道咱們沒有移動那些看不見的卡片。如下是咱們提出的一些規則,以建立在用戶看起來是正確的體驗。
所以咱們只移動可見卡片,因此無論當前的列表有多大,從性能的角度看移動都沒有問題,由於咱們只移動了用戶可見的卡片。
一個來自 react-virtualized 的擁有 10000 卡片的虛擬列表。
避免離屏工做是一項艱鉅的任務,您使用的技術將根據您的應用程序而有所不一樣。咱們但願避免在拖放交互過程當中移動和動畫顯示不可見的已掛載元素。這與避免徹底使用諸如 react-virtualized 之類的某種虛擬化解決方案渲染離屏組件徹底不一樣。虛擬化是使人驚奇的,可是增長了代碼庫的複雜性。它也打破了一些原生的瀏覽器功能,如打印和查找(command / control + f
)。咱們的決定是爲 React 應用程序提供卓越的性能,即便它們不使用虛擬化列表。這使得添加美觀,高性能的拖放操做變得很是簡單,並且只需不多的開銷便可將其拖放到現有的應用程序中。也就是說,咱們也計劃支持 supporting virtualised lists - 所以開發者能夠選擇是否要使用虛擬化列表減小大型列表 render
時間。 若是您有包含 1000 個卡片的列表,這將很是有用。
當用戶拖動 Droppable
列表時,咱們經過更新 isDraggingOver
屬性讓用戶知道。可是,這樣作會致使 Droppable
的 render
- 這反過來會致使其全部子項 render
- 多是 100 個 Draggable
卡片!
爲了不這種狀況,咱們針對 react-beautiful-dnd 的使用者,建立了性能優化的建議建議文檔,以免渲染不須要渲染的 Droppable
的子元素。庫自己並不控制 Droppable
的子元素的渲染,因此咱們能作的最好的是提供一個建議的優化。 這個建議容許用戶在拖拽時設置 Droppable
,同時避免在其全部子項上調用 render
。
import React, { Component } from 'react';
class Student extends Component<{ student: Person }> {
render() {
// 渲染一個可拖動的元素
}
}
class InnerList extends Component<{ students: Person[] }> {
// 若是子列表沒有改變就不要從新渲染
shouldComponentUpdate(nextProps: Props) {
if(this.props.students === nextProps.students) {
return false;
}
return true;
}
// 你也不能夠作你本身的 shouldComponentUpdate 檢查,
// 只能繼承自 React.PureComponent
render() {
return this.props.students.map((student: Person) => (
<Student student={student} />
))
}
}
class Students extends Component {
render() {
return (
<Droppable droppableId="list">
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
<div
ref={provided.innerRef}
style={{ backgroundColor: provided.isDragging ? 'green' : 'lightblue' }}
>
<InnerList students={this.props.students} />
{provided.placeholder}
</div>
)}
</Droppable>
)
}
}
複製代碼
在大的列表之間的平滑移動。
經過實施這些優化,咱們能夠減小在包含 500 個卡片的列表之間移動的時間,這些卡片的位移時間從 380 ms 減小到 8 ms 每幀!這是另外一個 99% 的減小。
這種優化並非針對 React 的 - 但在處理有序列表時很是有用
在 react-beautiful-dnd 中咱們常用數組去存儲有序的數據。可是,咱們也但願快速查找此數據以檢索條目,或查看條目是否存在。一般你須要作一個 array.prototype.find
或相似的方法來從列表中獲取條目。 若是這樣的操做過於頻繁,對於龐大的數組來講可能會是場災難。
有不少技術和工具來解決這個問題(包括 normalizr)。一種經常使用的方法是將數據存儲在一個 Object
映射中,並有一個 id
數組來維護順序。若是您須要按期查看列表中的值,這是一個很是棒的優化,而且能夠加快速度。
咱們作了一些不一樣的事情。咱們用 memoize-one
(只記住最新參數的記憶函數) 去建立懶 Object
映射來進行實時地按需查找。這個想法是你建立一個接受 Array
參數並返回一個 Object
映射的函數。若是屢次將相同的數組傳遞給該函數,則返回以前計算的 Object
映射。 若是數組更改,則從新計算映射。 這使您擁有一張當即查找表,而無需按期從新計算或者須要將其明確存儲在 state
中。
const getIdMap = memoizeOne((array) => {
return array.reduce((previous, current) => {
previous[current.id] = array[current];
return previous;
}, {});
});
const foo = { id: 'foo' };
const bar = { id: 'bar' };
// 咱們喜歡的有序結構
const ordered = [ foo, bar ];
// 懶惰地計算出快速查找的映射
const map1 = getMap(ordered);
map1['foo'] === foo; // true
map1['bar'] === bar; // true
map1['baz'] === undefined; // true
const map2 = getMap(ordered);
// 像以前同樣返回相同的映射 - 不須要從新計算
const map1 === map2;
複製代碼
使用查找表大大加快了拖動動做,咱們在每次更新(系統中的 O(n²)
)時檢查每一個鏈接的 Draggable
組件中是否存在某個卡片。經過使用這種方法,咱們能夠根據狀態變化計算一個 Object
映射,並讓鏈接的 Draggable
組件使用共享映射進行 O(1)
查找。
我但願你發現這個博客頗有用,能夠考慮一些能夠應用於本身的庫和應用程序的優化。看看 react-beautiful-dnd,也能夠試着玩一下咱們的示例。
感謝 Jared Crowe 和 Sean Curtis 提供優化幫助,Daniel Kerris,Jared Crowe,Marcin Szczepanski,Jed Watson,Cameron Fletcher,James Kyle,Ali Chamas 和其餘 Atlassian 人將博客放在一塊兒。
我在 React Sydney 發表了一篇關於這個博客的主要觀點的演講。
YouTube 視頻連接:這兒
在 React Sydney 上優化 React 性能。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。