为什么 Antd 的 Table 这么慢慢慢慢慢?!

3921次阅读  |  发布于2年以前

基本每过几个月,市面上就会出现一个新的 Table 组件库。并且拿 Ant Design 的 Table 做一次对比,长此往复。但是有趣的是,往往无论 Table 组件库如何翻新换代。React 下总是难以出现一款让所有人都满意的 Table。今天,我们简单聊聊,为什么 Ant Design 的 Table 这么“慢”。有的是历史问题,而有的则是无奈之举。

万物皆可缓存,吗?

在 v4 初期,我们重构了 Table。alpha 的 Table 通过 memo 极致缓存了所有行列数据,基本上来说你怎么折腾。只要data不变,Table 就不会重新渲染。这很符合大部分场景下的期待,数据没有变当然展示也不用变。但是往往就会有一些 case 会 break 掉这个直觉:

const [count, setCount] = React.useState(0);

const columns = [
  { render: () => count },
];

return <Table columns={columns} {...} />

真实的业务中,有时 Table Cell 不是纯粹的。开发者可以通过render额外处理一些展示。比如这个例子中,我们简单的将render设置为外部的 statecount。这个时候,虽然data没有变化,但是 Table 还是需要跟随count做重新渲染。

这种与外部 state 交互的代码很常见,比如说我希望用户点击某一个单元格的时候它可以编辑。所以那个行列可以编辑我存在 state 里。那么这个时候,Table 就不能假设 data 可以推导出 memo 唯一性。那么,是不是columns相同,且data相同,那么闭包不会更新我们就可以认为数据不会变呢?当然也是不行的。

let globalValue = 0;

const columns = [{
  render: () => globalValue;
}];

export default () => <Table columns={columns} />;

按照useMemo看,columns没有变化。但是render照样可以被改。这个时候,聪明的小伙伴们肯定会想到那我加个和 effect 相似的deps如何?这样如果deps变化了,Table 说明需要重新渲染,反之就不需要。很可惜,业务是变化多端的。当你在处理遗留代码时,很难发现因为自己新加的逻辑没有添加到deps而导致 Table 没有更新。而知识诅咒会使的越聪明的人越难发现这点。他们会更趋向于去阅读源码然后寻找为什么 Table 没有更新。直到找到后,才发现原来这个就存在于文档之上,然后写一篇文章痛斥为什么 Table 要这么过渡设计。

因而在 antd 中,为了让 Table 元素可以 memo。我们提供了一个更原子的shouldCellUpdate方法。它更贴近渲染,而不是根据deps来做决定。这样即便聪明人,也能一眼看出是什么导致 cell 被 memo 了。

万物皆可虚拟,吗?

接着,老生常谈。为什么 Table 不像其他组件一样提供默认的虚拟滚动能力。说真的,我觉得这是 Table 的基础能力。但是它同样也是一个容易埋坑的东东。一个很常见的例子,就是可编辑表格。

在市面上,90% 的 Form 组件都会根据节点自动注册、卸载字段,从而使得开发者不用去关心什么时候字段出现了。它们总是按照预期的收集数据并且提供给开发者。

但是当你支持虚拟表格后,问题便来了。我们知道,虚拟滚动的亮点在于只渲染看得到的部分从而节约大量的节点渲染性能。但是对于 Form 而言,没有渲染的行列也就不会有节点,那么这个字段对于 Form 而言是不存在的。也就是说,当用户辛辛苦苦编辑完一堆内容并提交后。Form 其实只收集了短短几行的数据,其他内容付之东流。(antd 的 Form 可以通过 getFieldsValue(true)收集所有包括卸载的数据,但是这也能收集到开发者真的不想要的数据)

不同于 Tree、Select 之类的组件,他们的虚拟滚动部分更偏向于数据展示,因而基本不用担心开发者在虚拟滚动中加入副作用。Table 更加灵活,因而如果是可编辑表格往往开发者需要做更耦合的实现来支持虚拟化。

除此之外,还有一些常见的问题。比如超长单元格在滚动到之前不知道它的宽度,因而滚动的时候会突然遇到跳跃的情况。又或者干脆固定宽度失去 Table 原生自适应的能力等等。另外,虚拟滚动对于跨行截断单元格的无障碍支持也会有问题。所以在 antd 中,提供了一个 Demo 关于自行实现虚拟化的功能而没有做成内置的方法。

当然,我还是希望 Table 可以支持虚拟化。只是没有在在想到最好的解法下并不适合立刻动手,以免等到有最优解时又成了历史债务。

万物皆可 Break,吗?

在维护大型组件库时,最让人头疼的就是 breaking change。如果你是 antd 的老用户的话,你肯定记得我们有个很蛋疼的 API 叫做feildNames。是的,它拼错了。于是当我们修正为fieldNames后,feildNames仍然保留到整个 major 版本结束。

很多时候,组件库原本的能力是不够的。为此还需要更多的“洞”来支持自定义操作。比如 antd 里常见的xxxRender系列。Table 也是,如果你观察过,你会发现 Table 的“洞”多如牛毛。基本上所有部件都可以自定义。而随着“洞”的增加,一些原本的 API 又显得很不合理。举个例子。Table 的columnsrender方法你可以返回节点。同时你也可以返回额外的节点props:

const columns = [{
  render: () => ({
    children: 'Hello World',
    rowSpan: 2,
    colSpan: 2,
  });
}];

但是后来,我们又提供了一个onCell方法,你也可以这么写:

const columns = [{
  render: () => 'Hello World',
  onCell: () => ({
    rowSpan: 2,
    colSpan: 2,
  });
}];

从开发者角度看,或许这两个差不多。而且原本的render似乎更内聚一些。但是在实际执行时,它们完全不同。render返回 props 会导致子节点必然被执行一次。而onCell可以无关子节点,而只获取 props。后者可以更好的做渲染优化。举一个例子,Hover 行的时候我们会高亮这一行。但是如果这一行是横跨两行的时候,Hover 应该将两行都高亮(而原生 CSS 则很难做到,因而我们需要动态添加className来实现这个效果):

而无论在动态添加className还是获取当前 cell 的rowSpan时,我们都依赖于 props 。通过render时,则必然需要调渲染 子节点 方法,而在调用渲染 子节点 方法就不能假设没有副作用,因而我们需要将这次的渲染如实反馈到页面上。而后者onCell则不会有这个问题。

因而在最新版本,我们把所有示例都替换成了onCell版本。同时提示用户render返回 props 下个大版本将被废弃。

最后

你会发现,解决某一个单一问题时往往很容易。我们可以针对自己的场景做到极致的优化。但是当业务场景的增加,你的组件库需要考虑的不单单是性能问题。一个更重要的点就是不能因为过渡优化而埋坑。在 Table 上,则是非常典型的取舍问题。因而这是为什么你看到了shouldCellUpdate而不是自动去 memo。我曾经和一些 Table 的开发者交流过上面的 edge case,但是往往讨论的最终结果就是这个解决了我的问题就行了,所以我不用 antd Table。人人都想要银弹,但是没有银弹。世间充满了不完美。

此外,除了上面几个点之外。Table 在维护过程中也有时候会引入一些真性能问题。比如 context 导致的 rerender、Resizable 导致的 rerender 等等等等。这些遇到就会修掉。本身就是 Table 的 BUG,没什么好说的。以上。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8