[译] 拖放库中 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索