R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

对 react-dnd、immutable-js 与树形结构的优化

注意:本文发布于 2206 天前,文章中的一些内容可能已经过时。

背景

最近一直在做一个低配版网页编辑器(面单编辑器),这是我刚入职的时候从同事那儿接手的一个 React 项目,项目中使用了 react-dnd 作为拖拽库,Store 中的信息都是 Immutable 对象。按理说应该效率挺高的,于是我就一直专注于业务需求,这周就差不多写完了。

如果一切顺利的话就不会有这篇文章了,起因是前天我手贱,搞了 256 个元素进去,别说拖拽了,选中一个元素都得卡上一秒左右……这怎么行,赶紧祭出之前摸索出来的 Performance 优化大法。

由于支持 JSON 格式的导入导出,于是我就导出了个测试用例(512 个元素),这样就不用每次都重新画了:

0

火焰图的局限性

在 Chrome 等浏览器的 F12 工具中,可以记录页面的性能,并生成“火焰图”或者“调用树”。之前我在调试自己写的函数时用它来找性能瓶颈还是挺顺手的,然而现在这是个 React 项目,拖拽元素时记录下来的火焰图长这样:

1

kd

这啥?这特么又是啥?算了算了我还是从代码角度来优化吧……

对 react-dnd 的优化

我发现 react-dnd 的原理大概是创建了一个 Drag Source,而且我发现每次的 ID 都不一样,那应该是每次都创建了一个新元素吧。从网上搜了一下发现 react-dnd 的 Drag Source 中有个叫 options 的参数,但全程只在 这儿 出现了一次:

options: Optional. A plain object. If some of the props to your component are not scalar (that is, are not primitive values or functions), specifying a custom arePropsEqual(props, otherProps) function inside the options object can improve the performance. Unless you have performance problems, don't worry about it.

大概的意思就是可以将其设置为 { arePropsEqual: (props, otherProps) => returns a boolean } 来判断是否需要重新创建,有点类似于 React 中的 shouldComponentUpdate

有了这个思路,应该很简单了。但是由于项目使用了 immutable-js,而 Drag Source 是个展示型组件,所以是经过了 toJS 处理的,也就是说每次获取到的元素信息都不一样。我一开始想在 toJS 时保留 Immutable 对象的 hashCode,以便后续用来判断是否是同一元素,但没想到 hashCode 居然这么容易冲突:focus + lock 时的组件与 !focus + !lock 时的组件计算出来的 hashCode 是一样的!最后我用了一个损招:传入一个 originalElement,这是 toJS 之前的原始对象,然后使用 equals(otherProps) 作为判断相等的依据。虽然比较丑,但加了这个优化之后,dragStartdrop 的时候基本不卡了。

减少绘图区的更新

仔细看代码之后发现,绘图区和左边的图层列表使用到的树结构,是我通过 Store 中的一维数组生成的(大概类似于“通过 parent 列表生成一棵树”),而且在生成树的过程中给原数据添加了一个 children 字段,因此每次生成的都是一个新数据。考虑到如果要在 Store 中维护一棵树实在是不划算,因此我决定给绘图区的元素添加 shouldComponentUpdate。这时候讨厌的 toJS 问题又来了。我只能新写一个组件:

import { Component } from 'react';
import PropTypes from 'prop-types';

export default class UpdateJudge extends Component {
    static propTypes = {
        children: PropTypes.element.isRequired,
        element: PropTypes.object.isRequired
    }
    shouldComponentUpdate(nextProps) {
        return !this.props.element.equals(nextProps.element);
    }
    render() {
        return this.props.children;
    }
}
<UpdateJudge element={element}>
    <CanvasElement someProps>{children}</CanvasElement>
</UpdateJudge>

通过对前后的 element 使用 equals 来比较是否相等,若不相等则更新组件。由于 element 里面带了 children 字段,因此如果子孙被修改了,这个元素是可以被更新的。

添加了这个优化之后,单个操作的时间降到了不到 500 毫秒。

合并对 Store 的修改

我发现对于一些特定的操作,例如在没有选中元素的情况下拖动一个元素,预期的效果是元素将会移动并且被选中。我当时为了方便,是这么写的:

// get move offset
const { x: offsetX, y: offsetY } = monitor.getDifferenceFromInitialOffset();
// focus this element if no focused element
if (!focusedElements.filter(item => item.id === id).length) {
    focusElements([id]);
}
// move currently focused element
moveElements({ offsetX, offsetY });

于是这会对 Store 产生两次修改,每次都要花 500 毫秒更新界面……解决的方法挺简单,只需要稍微改一下即可:

const { x: offsetX, y: offsetY } = monitor.getDifferenceFromInitialOffset();
const id = monitor.getItem().id;
moveElements({ offsetX, offsetY, id });

然后在 MOVE_ELEMENTS 的 Reducer 里面特判一下:

  • 如果没有传入 id(使用方向键来移动),则移动目前选中的元素;
  • 如果目前选中的元素包含了 id,则移动目前选中的元素;
  • 否则(传入了 id 但目前选中的元素没有包含它),则移动并选中 id 对应的元素。

这样比较符合人们的思维,最关键的是,只需要对 Store 做一次修改啦!

对 Ant Design 中 Tree 组件的优化

我一开始想使用与绘图区相同的优化方法来优化左边的图层列表,但列表使用了 Ant Design 中的 Tree 组件,而且我需要它的拖拽等功能,因此不能简单的通过 element 是否改变来设置 shouldComponentUpdate(否则当 element 没变时左边就跟卡死了一样)。

其实吧,因为这是 Ant Design 库中的组件,所以我不好直接改它的代码;由于树结构是需要在 render 中递归生成,而不是像 Input 组件可以传入一个 defaultValue,因此这其实算一个受控组件,没法通过让内部 State 与外部 Store 分离的方式来优化。一时间我有点陷入江局。

最后我决定在 Ant Design 的 TreeNode 外面包一层自己的组件 ImmutableTreeNode,大概是这样:

import React, { Component } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';

import { Tree } from 'antd';

export default class ImmutableTreeNode extends Component {
  static propTypes = {
    children: ImmutablePropTypes.list,
    childrenToCheck: ImmutablePropTypes.list
  }
  shouldComponentUpdate(nextProps) {
    for (const key in nextProps) {
        if (typeof nextProps[key] === 'undefined') {
            continue;
        }
        // whether two prop are equal, for both Immutable and js vars
        if (nextProps[key] !== null && nextProps[key].equals) {
            if (!nextProps[key].equals(this.props[key])) {
                // console.log(key, 'changed');
                return true;
            }
        } else if (nextProps[key] !== this.props[key]) {
            // console.log(key, 'changed');
            return true;
        }
    }
    return false;
  }
  render() {
    return <Tree.TreeNode {...this.props}>{this.props.children}</Tree.TreeNode>;
  }
}
renderTreeNodes(nodes) {
    // ....
    return (
        <ImmutableTreeNode
            icon={<IconList someProps />}
            key={id}
            title={id}
            type={type}
            // 'parentId' is for drag-and-drop
            // when 'dropToGap', use this prop to calculate its parent
            parentId={parent}
            // the following 4 props are not used by TreeNode, but they're
            // keys for ImmutableTreeNode to perform shouldComponentUpdate
            // visible={visible}
            // locked={locked}
            // focus={focus}
            // childrenToCheck={children || null}
        >
            {children ? this.renderTreeNodes(children) : null}
        </ImmutableTreeNode>
    );
}

结果发现还不如不优化……加了两句 console 输出后发现,每次的 icon 属性都是新的!所以大家引以为戒吧,能不从 Props 传 JSX 就尽量不去这么做。可这是 Ant Design 啊,我也只好在我的组件中忽略对 icon 的检查。

其实,icon 的更新条件就是里面的 someProps(与 elementvisiblelockedfocus 相关)被修改,因此直接将这三个属性挂到了 ImmutableTreeNode 上。此外如果 element 的子孙变了,这个组件显然也是要更新的,还好这不是个展示组件,没有用 toJS,于是直接将 children 也作为 Props 挂了上去。

尾声

最终的性能监视显示,在页面中有 512 个元素的情况下,对单个元素做操作,所有的更新只需要一百多毫秒,其中绘图区更新只需要二十多毫秒,图层列表也只需要几十毫秒,已经可以满足日常需求了。

顺便说一下在优化过程中,我一开始是在几个组件的 componentWillUpdatecomponentDidUpdate 钩子中使用了 console.timeconsole.timeEnd 来统计时间,但后来看到网上有人说 React 可以与 Chrome 的性能监视配合,显示一个很好的火焰图,但不是 Main 那个,而是上面的 User Timing,于是我看到了这样一幕:

2

脑子里只剩一个字:哇……

哦对了,整张图的跨度是 173.5 毫秒,意味着你只要手速眼速别像我一样快,别扔五百多个元素上去,基本是不怎么卡的。

P.S. 我问了一下产品,我们有没有什么场景需要在一个页面上画一百个元素?产品说,应该不会超过五十个,尼玛,一百个元素我们画着也蛋疼啊!

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3078 天