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

LeviDing發表於2018-04-03

頭圖由 James PadolseyUnsplash 拍攝

我為 React 寫了一個拖放庫 react-beautiful-dnd ?。Atlassian 建立這個庫的目的是為網站上的列表提供一種美觀且易於使用的拖放體驗。你可以閱讀介紹文件: 關於拖放的反思。這個庫完全通過狀態驅動 —— 使用者的輸入導致狀態改變,然後更新使用者看到的內容。這在概念上允許使用任何輸入型別進行拖動,但是太多狀態驅動拖動將會導致效能上的缺陷。?

我們最近釋出了 react-beautiful-dnd 的第四個版本 version 4,其中包含了大規模的效能提升

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

列表中的資料是基於具有 500 個可拖動卡片的配置,在開發版本中啟用儀表的情況下進行記錄的,開發版本及啟用儀表都會降低執行速度。但與此同時,我們使用了一臺效能卓越的機器用於這次記錄。確切的效能提升幅度會取決於資料集的大小,裝置效能等。

您看仔細了,我們看到有 99% 的效能提升 ?。由於這個庫已經經過了極致的優化,所以這些改進更加令人印象深刻。你可在大型列表示例大型皮膚示例這兩個例子中來感受效能提升的酸爽 ?。


在本部落格中,我將探討我們面臨的效能挑戰以及我們如何克服它們以獲得如此重要的結果。我將談論的解決方案非常適合我們的問題領域。有一些原則和技術將會出現 —— 但具體問題可能會在問題領域有所不同。

我在這篇部落格中描述的一些技術相當先進,其中大部分技術最好在 React 庫的邊界內使用,而不是直接在 React 應用程式中使用。

TLDR;

我們都很忙!這裡是這個部落格的一個非常高度的概述:

儘可能避免 render 呼叫。 另外以前探索的技術 (第一輪, 第二輪),我在這裡有一些新的認識:

  • 避免使用 props 來傳遞訊息
  • 呼叫 render 不是改變樣式的唯一方法
  • 避免離線工作
  • 如果可以的話,批量處理相關的 Redux 狀態更新

狀態管理

react-beautiful-dnd 的大部分狀態管理使用 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:拖動開始之前長時間停頓

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

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

當點選一個大列表中的卡片時,需要相當長的時間才能開始拖拽,在 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 資訊。鑑於此檢查是針對使用者輸入而進行的,並且沒有渲染影響,我們可以避開它。

拖曳開始前不再有很長的停頓

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

在擁有 500 個卡片的列表中進行拖動立刻就拖動了

通過使用上面介紹的技術,我們可以將在一個有 500 個可拖動卡片的拖動時間從 2.6 s 拖動到到 15 ms(在一個幀內),這是一個 99% 的減少 ?!

Problem 2:緩慢的位移

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

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

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

Issue 1:太多的運動

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

虛擬位移

我們現在只移動對使用者來說部分可見的東西,而不是移動使用者看不到的卡片。 因此完全不可見的卡片不會移動。這大大減少了我們在進入大列表時需要做的工作量,因為我們只需要 render 可見的可拖動卡片。

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

  • 如果卡片需要移動並且可見:移動卡片併為其運動新增動畫
  • 如果一個卡片需要移動但它不可見:不要移動它
  • 如果一個卡片需要移動並且可見,但是它之前的卡片需要移動但不可見:請移動它,但不要使其產生動畫。

因此我們只移動可見卡片,所以不管當前的列表有多大,從效能的角度看移動都沒有問題,因為我們只移動了使用者可見的卡片。

為什麼不使用虛擬列表?

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

一個來自 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>
    )
  }
}
複製程式碼

即時位移

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

在大的列表之間的平滑移動。

通過實施這些優化,我們可以減少在包含 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章