[譯] 拖放庫中 React 性能的優化

頭圖由 James PadolseyUnsplash 拍攝css

我爲 React 寫了一個拖放庫 react-beautiful-dnd 🎉。Atlassian 建立這個庫的目的是爲網站上的列表提供一種美觀且易於使用的拖放體驗。你能夠閱讀介紹文檔: 關於拖放的反思。這個庫徹底經過狀態驅動 —— 用戶的輸入致使狀態改變,而後更新用戶看到的內容。這在概念上容許使用任何輸入類型進行拖動,可是太多狀態驅動拖動將會致使性能上的缺陷。🦑html

咱們最近發佈了 react-beautiful-dnd 的第四個版本 version 4,其中包含了大規模的性能提高前端

列表中的數據是基於具備 500 個可拖動卡片的配置,在開發版本中啓用儀表的狀況下進行記錄的,開發版本及啓用儀表都會下降運行速度。但與此同時,咱們使用了一臺性能卓越的機器用於此次記錄。確切的性能提高幅度會取決於數據集的大小,設備性能等。react

您看仔細了,咱們看到有 99% 的性能提高 🤘。因爲這個庫已經通過了極致的優化,因此這些改進更加使人印象深入。你可在大型列表示例大型面板示例這兩個例子中來感覺性能提高的酸爽 😎。android


在本博客中,我將探討咱們面臨的性能挑戰以及咱們如何克服它們以得到如此重要的結果。我將談論的解決方案很是適合咱們的問題領域。有一些原則和技術將會出現 —— 但具體問題可能會在問題領域有所不一樣。ios

我在這篇博客中描述的一些技術至關先進,其中大部分技術最好在 React 庫的邊界內使用,而不是直接在 React 應用程序中使用。git

TLDR;

咱們都很忙!這裏是這個博客的一個很是高度的概述:github

儘量避免 render 調用。 另外之前探索的技術 (第一輪, 第二輪),我在這裏有一些新的認識:算法

  • 避免使用 props 來傳遞消息
  • 調用 render 不是改變樣式的惟一方法
  • 避免離線工做
  • 若是能夠的話,批量處理相關的 Redux 狀態更新

狀態管理

react-beautiful-dnd 的大部分狀態管理使用 Redux。這是一個實現細節,庫的使用者可使用任何他們喜歡的狀態管理工具。本博客中的許多具體內容都針對 Redux 應用程序 —— 然而,有一些技術是通用的。爲了可以向不熟悉 Redux 的人解釋清楚,下面是一些相關術語的說明:redux

  • store: 一個全局的狀態容器  —  一般放在 context 中,因此被鏈接的組件能夠被註冊去更新。
  • 被鏈接的組件: 直接註冊到 store 的組件. 他們的責任是響應 store 中的狀態更新並將 props 傳遞給未鏈接的組件。這些一般被稱爲智能或者容器組件
  • 未鏈接的組件: 未鏈接到 Redux 的組件。他們一般被鏈接到 store 的組件包裹,接收來自 state 的 props。這些一般被稱爲笨拙或者展現組件

若是你感興趣,這是一些來自 Dan Abramov 的關於這些概念更詳細的信息

第一個原則

Snipaste_2018-03-10_19-58-28.png

做爲通常規則,您應該儘量避免調用組件的 render() 函數,render 調用代價很大,有如下緣由:

  • render 函數調用的進程很費資源
  • Reconciliation

Reconciliation 是 React 構建一顆新樹的過程,而後用當前的視圖(虛擬 DOM)來進行 調和,根據須要執行實際的 DOM 更新。reconciliation 過程在調用一個 render 後被觸發。

render 函數的 processing 和 reconciliation 在規模上是代價很大的。 若是你有 100 個或者 10000 個組件,你可能不但願每一個組件在每次更新時都協調一個 store 中的共享狀態。理想狀況下,只有須要更新的組件纔會調用它的 render 函數。對於咱們每秒 60 次更新(60 fps)的拖放,這尤爲如此。

我在前兩篇博客 (第一輪, 第二輪) 中探討了避免沒必要要的 render 調用的技巧,React 文檔關於這個問題的敘述也討論了這個主題。就像全部東西都有一個平衡點同樣,若是你太過刻意地避免渲染,你可能會引入大量潛在的冗餘記憶檢查。 這個話題已經在其餘地方討論過了,因此我不會在這裏詳細討論。

除了渲染成本以外,當使用 Redux 時,鏈接的組件越多,您就須要在每次更新時運行更多的狀態查詢 (mapStateToProps) 和記憶檢查。我在 round 2 blog 中詳細討論了與 Redux 相關的狀態查詢,選擇器和備忘錄。

Problem 1:拖動開始以前長時間停頓

注意從鼠標下的圓圈出現到被選卡片變綠時的時間差。

當點擊一個大列表中的卡片時,須要至關長的時間才能開始拖拽,在 500 個卡片的列表中這是 2.6 s 😢!對於那些指望拖放交互是即時的用戶來講,這是一個糟糕的體驗。 讓咱們來看看發生了什麼,以及咱們用來解決問題的一些技巧。

Issue 1:原生維度的發佈

爲了執行拖動,咱們將全部相關組件的尺寸(座標,大小,邊距等)的快照放入到咱們的 state 和拖動的開始處。而後,咱們會在拖動過程當中使用這些信息來計算須要移動的內容。 咱們來看看咱們如何完成這個初始快照:

  1. 當咱們開始拖動時,咱們對 state 發出請求 request
  2. 關聯維度發佈組件讀取此 request 並查看他們是否須要發佈任何內容。
  3. 若是他們須要發佈,他們會在未鏈接維度的發佈者上設置一個 shouldPublish 屬性。
  4. 未鏈接的維度發佈者從 DOM 收集維度並使用 publish 回調來發布維度

好的,因此這裏有一些痛點:

  1. 當咱們開始拖動時,咱們在 state 上發起了一個 request
  2. 關聯維度發佈組件讀取此請求並查看他們是否須要發佈任何內容

此時,每一個關聯的維度發佈者都須要針對 store 執行檢查,以查看他們是否須要請求維度。不理想,但並不可怕。讓咱們繼續

  1. 若是他們須要發佈,他們會在未鏈接的維度發佈者上設置一個 shouldPublish 屬性

咱們過去使用 shouldPublish 屬性來傳遞消息給組件來執行一個動做。不幸的是,這樣作會有一個反作用,它會致使組件進行 render,從而引起該組件自己及其子組件的調和。當你在衆多組件上執行這個操做時,代價昂貴。

  1. 未鏈接的維度發佈者從 DOM 收集維度並使用 publish 回調來發布維度

事情會變得更糟。首先,咱們會當即從 DOM 讀取不少維度,這可能須要一些時間。從那裏每一個維度發佈者將單獨 publish 一個維度。 這些維度會被存儲到狀態中。這種 state 的變化會觸發 store 的訂閱,從而致使步驟二中的關聯組件狀態查詢和記憶檢查被執行。它還會致使應用程序中的其餘鏈接組件相似地運行冗餘檢查。所以,每當未鏈接的維度發佈者發佈維度時,將致使全部其餘鏈接組件的冗餘工做。這是一個 O(n²) 算法 - 更糟!哎。

The dimension marshal

爲了解決這些問題,咱們建立了一個新角色來管理維度收集流程:dimension marshal(維度元帥)。如下是新的維度發佈的工做方式:

拖動工做以前:

  1. 咱們建立一個 dimension marshal,而後把它放到了 context 中。
  2. 當維度發佈者加載到 DOM 中時,它會從 context 中讀取 dimension marshal ,並向 dimension marshal 註冊本身。Dimension 發佈者再也不直接監聽 store。 所以,不存在更多未鏈接的維度發佈者。

拖動工做開始:

  1. 當咱們開始拖動時,咱們對 state 發出 request
  2. dimension marshal 接收 request 並直接向所需維度發佈者請求關鍵維度(拖動卡片及其容器)以便開始拖動。 這些發佈到 store 就能夠開始拖動。
  3. 而後,dimension marshal 將在下一個幀中異步請求全部其餘 dimension publishers 的 dimensions。這樣作會分割從 DOM 中收集維度的成本,並將維度(下一步)發佈到單獨的幀中。
  4. 在另外一個幀中,dimension marshal 執行全部收集維度的批量 publish。在這一點上,state 是徹底混合的,它只須要三幀。

這種方法的其餘性能優點:

  • 更少的狀態更新致使全部鏈接組件的工做量減小
  • 沒有更多的鏈接維度發佈者,這意味着在這些組件中完成的處理再也不須要發生。

由於 dimension marshal 知道系統中的全部 IDindex,因此它能夠直接請求任何維度 O(1)。這也使其可以決定如何以及什麼時候收集和發佈維度。 之前,咱們有一個單獨的 shouldPublish 信息,它對一切都當即進行迴應。dimension marshal 在調整這部分生命週期的性能方面給了咱們很大的靈活性。若是須要,咱們甚至能夠根據設備性能實施不一樣的收集算法。

總結

咱們經過如下方式改進了維度收集的性能:

  • 不使用 props 傳遞沒有明顯更新的消息。
  • 將工做分解爲多個幀。
  • 跨多個組件批量更新狀態。

Issue 2:樣式更新

當一個拖動開始的時候,咱們須要應用一些樣式到每個 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 來改變元素的樣式。 可是,經過使用定義動態樣式,咱們能夠避免應用新的 classrender 任何須要渲染的組件。

咱們使用 data 屬性而不是 class 使這個庫對於開發者更容易使用,他們不須要合併咱們提供的 class 和他們本身的 class

使用這種技術,咱們還可以優化拖放生命週期中的其餘階段。 咱們如今能夠更新卡片的樣式,而無需 render 它們。

注意:您能夠經過建立預置樣式規則集,而後更改 body上的 class 來激活不一樣的規則集來實現相似的技術。然而,經過使用咱們的動態方法,咱們能夠避免在 body 上添加 classes。並容許咱們隨着時間的推移使用具備不一樣值的規則集,而不只僅是固定的。

不要懼怕,data 屬性的選擇器性能很好,與 render 性能差異很大。

Issue 3:阻止不須要的拖動

當一個拖動開始時,咱們也在 Draggable 上調用 render 來將 canLift prop 更新爲 false。這用於防止在拖動生命週期中的特定時間開始新的拖動。咱們須要這個 prop ,由於有一些鍵盤鼠標的組合輸入可讓用戶在已經拖動一些東西的期間開始另外一些東西的拖動。咱們仍然真的須要這個 canLift 檢查 —— 可是咱們怎麼作到這一點,而無需在全部的 Draggables上調用 render

與 State 結合的 context 函數

咱們沒有經過 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% 的減小 😍!

Problem 2:緩慢的位移

移動大量卡片時幀速降低。

從一個大列表移動到另外一個列表時,幀速率顯著降低。 當有 500 個可拖動卡片時,移入新列表將花費大約 350 ms。

Issue 1:太多的運動

react-beautiful-dnd 的核心設計特徵之一是卡片在發生拖拽時會天然地移出其它卡片的方式。可是,當您進入新列表時,您一般能夠一次取代大量卡片。 若是您移動到列表的頂部,則需移動下整個列表中的全部內容才能騰出空間。離線的 CSS 變化自己代價不大。然而,與 Draggables 溝通,經過 render 來告訴他們移動出去的方式,對於同時處理大量卡片來講是很昂貴的。

虛擬位移

咱們如今只移動對用戶來講部分可見的東西,而不是移動用戶看不到的卡片。 所以徹底不可見的卡片不會移動。這大大減小了咱們在進入大列表時須要作的工做量,由於咱們只須要 render 可見的可拖動卡片。

當檢測可見的內容時,咱們須要考慮當前的瀏覽器視口以及滾動容器(帶有本身滾動條的元素)。一旦用戶滾動,咱們會根據如今可見的內容更新位移。在用戶滾動時,確保這種位移看起來正確,有一些複雜。他們不該該知道咱們沒有移動那些看不見的卡片。如下是咱們提出的一些規則,以建立在用戶看起來是正確的體驗。

  • 若是卡片須要移動而且可見:移動卡片併爲其運動添加動畫
  • 若是一個卡片須要移動但它不可見:不要移動它
  • 若是一個卡片須要移動而且可見,可是它以前的卡片須要移動但不可見:請移動它,但不要使其產生動畫。

所以咱們只移動可見卡片,因此無論當前的列表有多大,從性能的角度看移動都沒有問題,由於咱們只移動了用戶可見的卡片。

爲何不使用虛擬列表?

一個來自 react-virtualized 的擁有 10000 卡片的虛擬列表。

避免離屏工做是一項艱鉅的任務,您使用的技術將根據您的應用程序而有所不一樣。咱們但願避免在拖放交互過程當中移動和動畫顯示不可見的已掛載元素。這與避免徹底使用諸如 react-virtualized 之類的某種虛擬化解決方案渲染離屏組件徹底不一樣。虛擬化是使人驚奇的,可是增長了代碼庫的複雜性。它也打破了一些原生的瀏覽器功能,如打印和查找(command / control + f)。咱們的決定是爲 React 應用程序提供卓越的性能,即便它們不使用虛擬化列表。這使得添加美觀,高性能的拖放操做變得很是簡單,並且只需不多的開銷便可將其拖放到現有的應用程序中。也就是說,咱們也計劃支持 supporting virtualised lists - 所以開發者能夠選擇是否要使用虛擬化列表減小大型列表 render 時間。 若是您有包含 1000 個卡片的列表,這將很是有用。

Issue 2:可放棄的更新

當用戶拖動 Droppable 列表時,咱們經過更新 isDraggingOver 屬性讓用戶知道。可是,這樣作會致使 Droppablerender - 這反過來會致使其全部子項 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% 的減小

Other:查找表

這種優化並非針對 React 的 - 但在處理有序列表時很是有用

在 react-beautiful-dnd 中咱們常用數組去存儲有序的數據。可是,咱們也但願快速查找此數據以檢索條目,或查看條目是否存在。一般你須要作一個 array.prototype.find 或相似的方法來從列表中獲取條目。 若是這樣的操做過於頻繁,對於龐大的數組來講可能會是場災難。

Snipaste_2018-03-10_20-03-13.png

有不少技術和工具來解決這個問題(包括 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 CroweSean Curtis 提供優化幫助,Daniel KerrisJared CroweMarcin SzczepanskiJed WatsonCameron FletcherJames Kyle,Ali Chamas 和其餘 Atlassian 人將博客放在一塊兒。

記錄

我在 React Sydney 發表了一篇關於這個博客的主要觀點的演講。

YouTube 視頻連接:這兒

在 React Sydney 上優化 React 性能。

感謝 Marcin Szczepanski.


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索