mirror of
https://gitee.com/ant-design/ant-design.git
synced 2024-11-30 02:59:04 +08:00
docs: Blog virtual table (#44526)
* docs: virtual table * docs: fix other blog style * docs: more
This commit is contained in:
parent
639c0c2505
commit
659054ee49
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { TinyColor } from '@ctrl/tinycolor';
|
||||
import { css, Global } from '@emotion/react';
|
||||
import React from 'react';
|
||||
import { useTheme } from 'antd-style';
|
||||
|
||||
const GlobalStyle: React.FC = () => {
|
||||
@ -26,6 +26,8 @@ const GlobalStyle: React.FC = () => {
|
||||
.markdown img {
|
||||
max-width: calc(100% - 32px);
|
||||
max-height: 100%;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.markdown p > img {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Space, Table, Typography, Switch } from 'antd';
|
||||
import { Space, Switch, Table, Typography } from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
|
||||
interface RecordType {
|
||||
@ -43,10 +43,16 @@ const columns: TableProps<RecordType>['columns'] = [
|
||||
title: 'Age',
|
||||
dataIndex: 'age',
|
||||
width: 100,
|
||||
onCell: (record) => ({
|
||||
colSpan: record.id % 4 === 0 ? 2 : 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Address 1',
|
||||
dataIndex: 'address1',
|
||||
onCell: (record) => ({
|
||||
colSpan: record.id % 4 === 0 ? 0 : 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Address 2',
|
||||
@ -99,53 +105,55 @@ const App = () => {
|
||||
|
||||
return {
|
||||
columnWidth: 48,
|
||||
expandedRowRender: (record) => <p style={{ margin: 0 }}>{record.address1}</p>,
|
||||
expandedRowRender: (record) => <p style={{ margin: 0 }}>🎉 Expanded {record.address1}</p>,
|
||||
rowExpandable: (record) => record.id % 2 === 0,
|
||||
};
|
||||
}, [expanded]);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={bordered}
|
||||
onChange={() => setBordered(!bordered)}
|
||||
checkedChildren="Bordered"
|
||||
unCheckedChildren="Bordered"
|
||||
/>
|
||||
<Switch
|
||||
checked={expanded}
|
||||
onChange={() => setExpanded(!expanded)}
|
||||
checkedChildren="Expandable"
|
||||
unCheckedChildren="Expandable"
|
||||
/>
|
||||
<Switch
|
||||
checked={empty}
|
||||
onChange={() => setEmpty(!empty)}
|
||||
checkedChildren="Empty"
|
||||
unCheckedChildren="Empty"
|
||||
<div style={{ padding: 64 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={bordered}
|
||||
onChange={() => setBordered(!bordered)}
|
||||
checkedChildren="Bordered"
|
||||
unCheckedChildren="Bordered"
|
||||
/>
|
||||
<Switch
|
||||
checked={expanded}
|
||||
onChange={() => setExpanded(!expanded)}
|
||||
checkedChildren="Expandable"
|
||||
unCheckedChildren="Expandable"
|
||||
/>
|
||||
<Switch
|
||||
checked={empty}
|
||||
onChange={() => setEmpty(!empty)}
|
||||
checkedChildren="Empty"
|
||||
unCheckedChildren="Empty"
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
bordered={bordered}
|
||||
virtual
|
||||
columns={mergedColumns}
|
||||
scroll={{ x: 2000, y: 400 }}
|
||||
rowKey="id"
|
||||
dataSource={empty ? [] : data}
|
||||
pagination={false}
|
||||
rowSelection={
|
||||
expanded
|
||||
? undefined
|
||||
: {
|
||||
type: 'radio',
|
||||
columnWidth: 48,
|
||||
}
|
||||
}
|
||||
expandable={expandableProps}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
bordered={bordered}
|
||||
virtual
|
||||
columns={mergedColumns}
|
||||
scroll={{ x: 2500, y: 400 }}
|
||||
rowKey="id"
|
||||
dataSource={empty ? [] : data}
|
||||
pagination={false}
|
||||
rowSelection={
|
||||
expanded
|
||||
? undefined
|
||||
: {
|
||||
type: 'radio',
|
||||
columnWidth: 48,
|
||||
}
|
||||
}
|
||||
expandable={expandableProps}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
import type { Component } from 'react';
|
||||
import React from 'react';
|
||||
import HolderOutlined from '@ant-design/icons/HolderOutlined';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSMotionProps } from 'rc-motion';
|
||||
import type { BasicDataNode, TreeProps as RcTreeProps } from 'rc-tree';
|
||||
import RcTree from 'rc-tree';
|
||||
import type { DataNode, Key } from 'rc-tree/lib/interface';
|
||||
import type { Component } from 'react';
|
||||
import React from 'react';
|
||||
import type { CSSMotionProps } from 'rc-motion';
|
||||
|
||||
import initCollapseMotion from '../_util/motion';
|
||||
import { ConfigContext } from '../config-provider';
|
||||
import useStyle from './style';
|
||||
|
@ -10,7 +10,7 @@ In the Tree or similar components (such as TreeSelect, Cascader), needs check fu
|
||||
|
||||
Before we start, let's establish a consensus. That is, when a node is `disabled`, it cannot be clicked `checked`. Then we take the following Tree structure as an example:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*eMq8S7Pq0lQAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*eMq8S7Pq0lQAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
Next, we check the root node `parent 1`, and analyze the similarities and differences of different check transmission strategies.
|
||||
|
||||
@ -18,31 +18,31 @@ Next, we check the root node `parent 1`, and analyze the similarities and differ
|
||||
|
||||
This is the most intuitive strategy, all nodes will be checked:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*QQp-R4EMteAAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*QQp-R4EMteAAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
You can immediately see the problem with this strategy, we mentioned earlier that `disabled` nodes are not allowed to be `checked`. But when the parent node is not `disabled`, its child nodes will be forcibly checked. This will cause the `disabled` node to "can" be checked, which is obviously unreasonable.
|
||||
|
||||
### All checkable nodes are checked
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*BzrZRbT1gCEAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*BzrZRbT1gCEAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
From the checkbox interaction, it looks good, but it's not intuitive. After `parent 1` is checked, `leaf 2` is checked by conduction. But the middle node `parent 1-0` is not checked. At some deep enough level, this strategy can cause the user to be unaware that a check has been propagated:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*3mHLQZvTgWsAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*3mHLQZvTgWsAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
When there is no scrolling, the user can't realize that the upper `disabled` is not checked, but the top is checked:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*xTqPQbdX6B0AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*xTqPQbdX6B0AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
### Check only reachable checkable nodes
|
||||
|
||||
This is also the current strategy of antd, when a node is checked, it will propagate upwards and downwards from the node until `disabled` stops. When there are multiple `disabled` in the node, they will each check the status management:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*EIK0Rbq92CMAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*EIK0Rbq92CMAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
Conversely check `leaf 2`, it will not conduct:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*Ytr9SrJUvD4AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*Ytr9SrJUvD4AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
The advantage of this strategy is that users can clearly see the selection process. Compared with the previous strategy, users only need a small area to understand the check logic in the scrolling scene.
|
||||
|
||||
@ -54,19 +54,19 @@ Note: We only introduce simple conduction logic here. Please refer to [actual co
|
||||
|
||||
When a node is checked, we will add `key` to `checkedKeys`. We iterate over each `key` in the new `checkedKeys` for conduction checks. The first step will be conduction from top to bottom (in the example below we check `0-0`):
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*30UnR60SSD8AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*30UnR60SSD8AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
We record the current node `0-0` and the transmitted `0-0-0` and `0-0-1`:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*jo7wQZVX9S0AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*jo7wQZVX9S0AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
In the second step, we will conduct upwards from this node:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*k5hoSKM1OMYAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*k5hoSKM1OMYAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
Similarly, record the node `0` that was passed on:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*yqBETbq8ugQAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*yqBETbq8ugQAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
When the parent node is checked, the parent node of the parent node may also be checked, so we need to continue to conduct upward until the root node or `disabled` node.
|
||||
|
||||
|
@ -10,7 +10,7 @@ author: zombieJ
|
||||
|
||||
在开始之前,我们先确定一个共识。那就是当一个节点被 `disabled` 后,它是不能被点击 `checked` 的。接着我们以如下 Tree 结构做示例:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*eMq8S7Pq0lQAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*eMq8S7Pq0lQAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
接下来我们勾选根节点 `parent 1`,并分析一下不同的勾选传导策略的异同。
|
||||
|
||||
@ -18,31 +18,31 @@ author: zombieJ
|
||||
|
||||
这是最直观的一种策略,所有的节点都会被勾选:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*QQp-R4EMteAAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*QQp-R4EMteAAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
你立刻就会发现这种策略的问题所在,我们前面提到过 `disabled` 节点不允许被 `checked`。但是当父节点不是 `disabled` 时,它的子节点会被强制勾选。这样就会导致 `disabled` 节点“可以”被勾选,显然不合理。
|
||||
|
||||
### 所有的可勾选节点都被勾选
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*BzrZRbT1gCEAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*BzrZRbT1gCEAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
从勾选交互看,它看起来不错,但是并不符合直觉。`parent 1` 勾选后,`leaf 2` 被传导勾选。但是其中间节点 `parent 1-0` 却没有勾选。在一些足够深的层级下,这种策略会导致用户并不知道勾选被传导出去了:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*3mHLQZvTgWsAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*3mHLQZvTgWsAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
没有滚动时,用户并不能意识到上层 `disabled` 都没有被勾选时,而最上面被勾选了:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*xTqPQbdX6B0AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*xTqPQbdX6B0AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
### 只勾选可触达的可勾选节点
|
||||
|
||||
这也是 antd 现在的策略,当节点被勾选时,它会从节点起向上向下传导,直到 `disabled` 停止。节点中存在多个 `disabled` 时会各自进行勾选状态管理:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*EIK0Rbq92CMAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*EIK0Rbq92CMAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
反过来勾选 `leaf 2`,也不会传导:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*Ytr9SrJUvD4AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*Ytr9SrJUvD4AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
这种策略的好处是,用户可以清晰的看到勾选的传导过程。相对于上一个策略,在滚动场景用户只需要很小的区域就能理解勾选逻辑。
|
||||
|
||||
@ -54,19 +54,19 @@ author: zombieJ
|
||||
|
||||
当勾选节点后,我们会将 `key` 加入到 `checkedKeys` 中。我们会遍历新的 `checkedKeys` 中的每个 `key` 进行传导检查。第一步会自上而下进行传导(下图示例我们勾选 `0-0`):
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*30UnR60SSD8AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*30UnR60SSD8AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
我们将当前节点 `0-0` 和被传导的 `0-0-0`与 `0-0-1` 记录下来:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*jo7wQZVX9S0AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*jo7wQZVX9S0AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
第二步,我们会从该节点向上传导:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*k5hoSKM1OMYAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*k5hoSKM1OMYAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
同样的,将被传导的节点 `0` 记录下来:
|
||||
|
||||
![Tree](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*yqBETbq8ugQAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Tree" height="300" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*yqBETbq8ugQAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
当父节点被传导勾选时,该父节点的父节点勾选也可能会被传导,所以我们需要继续向上传导,直到 根节点 或者 `disabled` 节点为止。
|
||||
|
||||
|
@ -6,11 +6,11 @@ author: zombieJ
|
||||
|
||||
We often encounter the need for pop-up elements when developing, such as the Select drop-down box, or the Modal component. When it is directly rendered under the current node, it may be clipped by the `overflow: hidden` of the parent node:
|
||||
|
||||
![Overflow](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*Noh-TYJ0BdcAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Overflow" height="200" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*Noh-TYJ0BdcAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
Therefore we render it under `body` by default in Ant Design, but this will bring new problems. Since they are not under the same container, when the user scrolls the screen, they will find that the popup layer does not follow the scrolling:
|
||||
|
||||
![Scroll](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*d44KQqkTX90AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Scroll" height="370" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*d44KQqkTX90AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
To solve this problem, we provide the `getContainer` property, which allows users to customize the rendered container. The `getContainer` method will be called when the component is mounted, returning a container node, and the component will be rendered under this node through `createPortal`.
|
||||
|
||||
|
@ -6,11 +6,11 @@ author: zombieJ
|
||||
|
||||
在网页开发中,我们时常会遇到弹出元素的需求,比如 Select 的下拉框、或者是 Modal 组件。直接将其渲染到当前节点下时,可能会被父节点的 `overflow: hidden` 裁剪掉:
|
||||
|
||||
![Overflow](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*Noh-TYJ0BdcAAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Overflow" height="200" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*Noh-TYJ0BdcAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
因而在 Ant Design 中,我们默认将其渲染到 `body` 下,但是这又会带来新的问题。由于不在同一个容器下,当用户滚动屏幕时会发现弹出层并未跟随滚动:
|
||||
|
||||
![Scroll](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*d44KQqkTX90AAAAAAAAAAAAADrJ8AQ/original)
|
||||
<img alt="Scroll" height="370" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*d44KQqkTX90AAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
为了解决这个问题,我们提供了 `getContainer` 属性,让用户可以自定义渲染的容器。`getContainer` 方法会在组件挂载时调用,返回一个容器节点,组件会通过 `createPortal` 渲染到这个节点下。
|
||||
|
||||
|
161
docs/blog/virtual-table.en-US.md
Normal file
161
docs/blog/virtual-table.en-US.md
Normal file
@ -0,0 +1,161 @@
|
||||
---
|
||||
title: Virtual Table is here!
|
||||
date: 2023-08-29
|
||||
author: zombieJ
|
||||
---
|
||||
|
||||
## Preface
|
||||
|
||||
In v4, we added a custom `components` example for Table, which replaces the default `<tbody>` with `components.body` to achieve virtual scrolling. But many developers feedback that the virtual table in the Demo has many functions that cannot be implemented. For example, fixed columns, merged rows and columns, expandable rows, etc.
|
||||
|
||||
So we proposed [[RFC] StaticTable for fast perf & virtual scroll support](https://github.com/ant-design/ant-design/discussions/41500) in v5. The RFC expects to provide a high-performance Table.StaticTable, which will support virtual scrolling by default. But as the development progressed, we eventually decided to implement StaticTable on the underlying `rc-table`, and on the antd side, we only need to enable it with `<Table virtual />`.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Table supports virtual scrolling by setting the `virtual` prop. At the same time, the original Table's functions except `components.body` can be used normally:
|
||||
|
||||
```tsx
|
||||
<Table virtual scroll={{ x: 2000, y: 500 }} {...otherProps} />
|
||||
```
|
||||
|
||||
### Fixed columns
|
||||
|
||||
![Fixed Columns](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*V2FcS7ZAReMAAAAAAAAAAAAADrJ8AQ/original)
|
||||
|
||||
### Expandable
|
||||
|
||||
![Expandable](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*nd61R4YsknsAAAAAAAAAAAAADrJ8AQ/original)
|
||||
|
||||
### RowSpan & ColSpan
|
||||
|
||||
![Rowspan & Colspan](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*DYkYQo8tU6sAAAAAAAAAAAAADrJ8AQ/original)
|
||||
|
||||
You can visit the [virtual list](/components/table#components-table-demo-virtual-list) example to experience it.
|
||||
|
||||
## Some details
|
||||
|
||||
Table in antd internally uses the `rc-table` component. Our virtual scrolling feature also reuses the `components` property mentioned above. Replace the middle `<tbody>` with `rc-virtual-list`, which is widely used in various virtual scrolling scenarios of antd like Select and Tree. `rc-virtual-list` itself does not support horizontal scrolling, so we also added horizontal scrolling support for it in this refactoring.
|
||||
|
||||
### Fixed columns
|
||||
|
||||
In v4, we refactored the fixed columns of Table into `position: sticky`. This CSS allows you to fix an element at a certain position when scrolling. So as to avoid the need to render an extra Table in v3 to achieve the fixed position effect:
|
||||
|
||||
<img alt="Sticky" height="279" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*WNoMQKQwX-YAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
For overlapping fixed columns, you only need to configure different offsets:
|
||||
|
||||
<img alt="Stack Sticky" height="279" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*HSW-S40yR_wAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
We can also use this feature in virtual scrolling. Just reuse the `sticky` style to achieve the effect of fixed columns. `rc-virtual-list` only needs to provide horizontal scrolling, and does not need to care about the implementation of fixed columns.
|
||||
|
||||
### Expandable
|
||||
|
||||
We will flatten the tree structure of `dataSource` through `useFlattenRecords` in `rc-table`, so as to support developers' custom virtual scrolling capabilities. Thanks to [@crawler-django](https://github.com/react-component/table/pull/619) for his contribution at that time, so we don't need to implement the flattening logic again.
|
||||
|
||||
But in testing, we found a strange phenomenon. When the table is rendered for the first time or re-render, there will be a very large lag. When debugging, it comes from the `useFlattenRecords` hook. It's strange that test code itself does not use the expandable tree function. It was found that there were a lot of GC operations in `useFlattenRecords`. And these operations are caused by a piece of inconspicuous code:
|
||||
|
||||
```tsx
|
||||
// Fake code. Not used in real word
|
||||
function flatten<T extends { children?: T[] }>(data: T[] = []) {
|
||||
let tmpList: T[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
const record = data[i];
|
||||
tmpList = [...tmpList, record, ...flatten(record.children)];
|
||||
}
|
||||
|
||||
return tmpList;
|
||||
}
|
||||
```
|
||||
|
||||
When traversing, although `children` is empty and only enters recursion once. But when looping through each Record, a temporary empty array will be created. But when `dataSource` data is huge, they will continue to trigger GC to clean up these temporary arrays. So we added logic to avoid unnecessary consumption:
|
||||
|
||||
```tsx
|
||||
// Fake code. Not used in real word
|
||||
function flatten<T extends { children?: T[] }>(data: T[] = [], list: T[] = []) {
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
const record = data[i];
|
||||
list.push(record);
|
||||
flatten(record.children, list);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
```
|
||||
|
||||
### RowSpan & ColSpan
|
||||
|
||||
If you are familiar with the implementation of Table, you will know that row and column merging is achieved through `rowSpan` and `colSpan`. In virtual scrolling, since not all nodes are rendered, there will be cases where the rows and columns to be rendered do not exist:
|
||||
|
||||
<img alt="RowSpan" height="400" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*S94JSpL-9rUAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
To render this content, we need to calculate the `rowSpan` and `colSpan` of all Records in the current visible area. And this calculation process is very complicated. We need to traverse all Records and calculate the `rowSpan` and `colSpan` of each Record. Obviously, this is a very time-consuming operation, and when the `rowSpan` data is too far away from the visible area, the amount of content it needs to render will also be very large:
|
||||
|
||||
<img alt="Huge Size" height="600" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*52qqQ7nmv9kAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
Maybe you will think of whether we can calculate the `rowSpan` in advance and then get these data when scrolling. This is actually not possible. The row and column data is provided by `onCell`, and calculating `onCell` every time when rendering the parent node will cause huge performance loss:
|
||||
|
||||
```jsx
|
||||
const Demo = () => {
|
||||
const [spanCount, setSpanCount] = useState(3);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
dataIndex: 'group',
|
||||
onCell: (_, index) => ({
|
||||
rowSpan: index % spanCount === 0 ? spanCount : 0,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// WOW!
|
||||
React.useEffect(() => {
|
||||
setSpanCount(5);
|
||||
}, []);
|
||||
|
||||
return <Table columns={columns} {...props} />;
|
||||
};
|
||||
```
|
||||
|
||||
Thus, even if we count the `rowSpan` data and render the rows outside the screen, it is still not enough. It may appear that `rowSpan` alternates:
|
||||
|
||||
<img alt="Multiple Row Span" height="700" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*NbckQIUqTIUAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
And for the worst case, all rows have `rowSpan` with other rows, then the amount of content we need to render will be the entire `dataSource`. That is, virtual scrolling is no longer virtual. Therefore, we need to clip it to render only the `rowSpan` in the visible area, and remove the irrelevant parts outside the screen:
|
||||
|
||||
<img alt="cut off" height="500" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*QxM9SJ1mlAIAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
So, think backwards. We only need to start from the visible area. Then get the Records affected by `rowSpan` up and down. Then only render the `cell` that provides `rowSpan`:
|
||||
|
||||
<img alt="fully cut off" height="400" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*_LBQRKPhLaIAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
`rc-virtual-list` provides the `extraRender` method, which will provide the row number currently rendered in virtual scrolling. We only need to execute `onCell` on each Record in this range to get the `rowSpan` and `colSpan` information of each `cell`. So we can know whether the current row has `rowSpan`:
|
||||
|
||||
```tsx
|
||||
// Fake code. Not used in real word
|
||||
const extraRender = ({ start, end }) => {
|
||||
// Start record
|
||||
const startRecord = flattenData[start];
|
||||
columns.forEach((col) => {
|
||||
// `rowSpan` === 0 means upper record has `rowSpan`
|
||||
const { rowSpan } = col.onCell(startRecord, start);
|
||||
});
|
||||
|
||||
// End record
|
||||
const endRecord = flattenData[end];
|
||||
columns.forEach((col) => {
|
||||
// `rowSpan` > 1 means it should extend to next records
|
||||
const { rowSpan } = col.onCell(endRecord, end);
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
You should note that, in the actual collection process, we will record all `cell` with `rowSpan` in the range instead of just the head and tail Record. Then render these `cell` through the `extraRender` method (and skip rendering for `cell` affected by `rowSpan` in the original Record). This ensures the correctness of `rowSpan`.
|
||||
|
||||
Of course, this implementation is based on the assumption that `rowSpan > 1` and `rowSpan = 0` will appear. It does not support the case where `rowSpan` is used to squeeze to the lower level, but for data tables, this is enough.
|
||||
|
||||
## Finally
|
||||
|
||||
Virtual scrolling is a very complex feature, and there are many factors to consider. But we believe that it is worth spending this effort, and developers no longer need to choose between functionality and performance. Instead, you can have both. However, it should be noted that since we have implemented virtual scrolling through `components.body`, developers cannot override the `body` part of the component.
|
||||
|
||||
That's all.
|
161
docs/blog/virtual-table.zh-CN.md
Normal file
161
docs/blog/virtual-table.zh-CN.md
Normal file
@ -0,0 +1,161 @@
|
||||
---
|
||||
title: 虚拟表格来了!
|
||||
date: 2023-08-29
|
||||
author: zombieJ
|
||||
---
|
||||
|
||||
## 前言
|
||||
|
||||
在 v4 时期,我们为 Table 添加了一个自定义 `components` 的示例,通过 `components.body` 替换默认的 `<tbody>`,实现虚拟滚动的效果。但是很多开发者反馈 Demo 中的虚拟表格有很多功能无法实现。例如 固定列、合并行列、展开行 等等。
|
||||
|
||||
所以在 v5 中,我们提出了 [[RFC] StaticTable for fast perf & virtual scroll support](https://github.com/ant-design/ant-design/discussions/41500)。该 RFC 期望提供一个高性能的 Table.StaticTable,它会默认支持虚拟滚动。但是随着开发进行,我们最终决定 StaticTable 在底层 `rc-table` 上实现,而在 antd 侧则只需要通过 `<Table virtual />` 即可开启。
|
||||
|
||||
## 太长不看
|
||||
|
||||
Table 通过 `virtual` 属性即可开启虚拟滚动能力。同时,原 Table 的功能(除自定义 `components.body` 外)都能正常使用:
|
||||
|
||||
```tsx
|
||||
<Table virtual scroll={{ x: 2000, y: 500 }} {...otherProps} />
|
||||
```
|
||||
|
||||
### 固定列
|
||||
|
||||
![Fixed Columns](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*V2FcS7ZAReMAAAAAAAAAAAAADrJ8AQ/original)
|
||||
|
||||
### 可展开
|
||||
|
||||
![Expandable](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*nd61R4YsknsAAAAAAAAAAAAADrJ8AQ/original)
|
||||
|
||||
### 行列组合
|
||||
|
||||
![Rowspan & Colspan](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*DYkYQo8tU6sAAAAAAAAAAAAADrJ8AQ/original)
|
||||
|
||||
你可以直接访问 [虚拟列表](/components/table#components-table-demo-virtual-list) 示例进行体验。
|
||||
|
||||
## 一些细节
|
||||
|
||||
antd 的 Table 底层使用了 `rc-table` 组件,我们的虚拟滚动功能也是复用了上文提到的 `components` 属性。将中间的 `<tbody>` 替换为 `rc-virtual-list`,该组件广泛应用于 antd 的各个虚拟滚动场景中。例如 Select、Tree 都可以见到它的身影。而 `rc-virtual-list` 本身并不支持横向滚动能力,因而我们在这次改造中,也为其添加了横向滚动的支持。
|
||||
|
||||
### 固定列
|
||||
|
||||
在 v4 时期,我们便将 Table 的固定列改造成了 `position: sticky` 实现。该 CSS 允许你在滚动时,将元素固定在某个位置。从而避免 v3 时期需要额外渲染一份 Table 用于实现固定位置的效果:
|
||||
|
||||
<img alt="Sticky" height="279" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*WNoMQKQwX-YAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
对于叠加固定,只需要配置不同的偏移量即可:
|
||||
|
||||
<img alt="Stack Sticky" height="279" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*HSW-S40yR_wAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
在虚拟滚动中,我们同样可以利用该特性。直接复用 `sticky` 样式,即可实现固定列的效果。而 `rc-virtual-list` 需要付出的仅仅是提供横向滚动,而不需要关心固定列的实现。
|
||||
|
||||
### 可展开
|
||||
|
||||
在 `rc-table` 中,我们会将 `dataSource` 通过 `useFlattenRecords` 将树状结构打平,从而支持开发者自定义的虚拟滚动能力。感谢 [@crawler-django](https://github.com/react-component/table/pull/619) 当年的贡献,因而我们这次并不需要再实现一次打平逻辑。
|
||||
|
||||
但是在测试时,我们发现一个奇怪的现象。表格在首次、再次渲染时,会有非常大的卡顿。在进行断点时,它来自于 `useFlattenRecords` hooks。而测试的代码本身并没有使用可展开树的功能,于是我们对其进行了排查。发现在 `useFlattenRecords` 中,存在大量的 GC 操作。而这些操作是由于一段不起眼的代码引起的:
|
||||
|
||||
```tsx
|
||||
// Fake code. Not used in real word
|
||||
function flatten<T extends { children?: T[] }>(data: T[] = []) {
|
||||
let tmpList: T[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
const record = data[i];
|
||||
tmpList = [...tmpList, record, ...flatten(record.children)];
|
||||
}
|
||||
|
||||
return tmpList;
|
||||
}
|
||||
```
|
||||
|
||||
在遍历过程中,虽然 `children` 为空只会进入一次递归。但是在循环每个 Record 时都会创建一次临时的空数组。但是当 `dataSource` 数据巨大时,它们会不断触发 GC 清理这些临时数组。因而我们通过改造添加逻辑以避免不必要的消耗:
|
||||
|
||||
```tsx
|
||||
// Fake code. Not used in real word
|
||||
function flatten<T extends { children?: T[] }>(data: T[] = [], list: T[] = []) {
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
const record = data[i];
|
||||
list.push(record);
|
||||
flatten(record.children, list);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
```
|
||||
|
||||
### 行列组合
|
||||
|
||||
如果你对 Table 的实现有所了解,那么你会知道行列合并是通过 `rowSpan` 和 `colSpan` 实现的。而在虚拟滚动中,由于并不是所有节点都是渲染,所以会出现需要渲染的行列并不存在的情况:
|
||||
|
||||
<img alt="RowSpan" height="400" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*S94JSpL-9rUAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
为了渲染该项内容,我们就需要计算出当前屏幕区域内的所有 Record 相关的 `rowSpan` 和 `colSpan`。而这个计算过程是非常复杂的,我们需要遍历所有的 Record,计算出每个 Record 的 `rowSpan` 和 `colSpan`。显然这是一个非常耗时的操作,而且当 `rowSpan` 数据距离可见区域过远时,它需要额外渲染的内容量也会非常大:
|
||||
|
||||
<img alt="Huge Size" height="600" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*52qqQ7nmv9kAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
对于 `rowSpan`,你可能会想到我们是否可以提前把 `rowSpan` 计算好,然后在滚动时获取这些数据。这其实是不行的,行列数据由 `onCell` 提供,而在父节点渲染时都全部计算一次 `onCell` 会产生巨量的性能损耗:
|
||||
|
||||
```jsx
|
||||
const Demo = () => {
|
||||
const [spanCount, setSpanCount] = useState(3);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
dataIndex: 'group',
|
||||
onCell: (_, index) => ({
|
||||
rowSpan: index % spanCount === 0 ? spanCount : 0,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// WOW!
|
||||
React.useEffect(() => {
|
||||
setSpanCount(5);
|
||||
}, []);
|
||||
|
||||
return <Table columns={columns} {...props} />;
|
||||
};
|
||||
```
|
||||
|
||||
此外,即便我们统计了 `rowSpan` 的数据,并且将屏幕外的行也进行渲染仍然不够。它可能会出现 `rowSpan` 交替的情况:
|
||||
|
||||
<img alt="Multiple Row Span" height="700" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*NbckQIUqTIUAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
假设出现最糟的情况,所有的行都和其他的行有 `rowSpan`,那么我们需要渲染的内容量将会是整个 `dataSource`。即虚拟滚动不再虚拟。因而,我们需要对其进行裁剪,只渲染可见区域内的 `rowSpan`,而将屏幕外的无关部分去除:
|
||||
|
||||
<img alt="cut off" height="500" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*QxM9SJ1mlAIAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
所以,反向思考。我们只需要从可见区域出发。然后向上、向下获取被 `rowSpan` 影响到的 Record。然后仅对提供 `rowSpan` 的 `cell` 进行渲染即可:
|
||||
|
||||
<img alt="fully cut off" height="400" src="https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*_LBQRKPhLaIAAAAAAAAAAAAADrJ8AQ/original" />
|
||||
|
||||
`rc-virtual-list` 提供了 `extraRender` 方法,它会提供当前虚拟滚动中所渲染的行号。我们只需要对这个范围内的 Record 执行一次 `onCell` 获取每个 `cell` 的 `rowSpan` 和 `colSpan` 信息。既可以知道当前行是否存在对外的 `rowSpan` 依赖:
|
||||
|
||||
```tsx
|
||||
// Fake code. Not used in real word
|
||||
const extraRender = ({ start, end }) => {
|
||||
// Start record
|
||||
const startRecord = flattenData[start];
|
||||
columns.forEach((col) => {
|
||||
// `rowSpan` === 0 means upper record has `rowSpan`
|
||||
const { rowSpan } = col.onCell(startRecord, start);
|
||||
});
|
||||
|
||||
// End record
|
||||
const endRecord = flattenData[end];
|
||||
columns.forEach((col) => {
|
||||
// `rowSpan` > 1 means it should extend to next records
|
||||
const { rowSpan } = col.onCell(endRecord, end);
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
需要注意的是,在实际收集过程中,我们会将整个范围有 `rowSpan` 的 `cell` 都记录下来而不仅仅是头尾的 Record。然后通过 `extraRender` 方法将这些 `cell` 渲染出来(而原本的 Record 对于被 `rowSpan` 影响到的 `cell` 则跳过渲染)。这样就可以保证 `rowSpan` 的正确性了。
|
||||
|
||||
当然,这种实现是基于 `rowSpan > 1` 和 `rowSpan = 0` 会匹配出现的假设。它不支持 `rowSpan` 用于挤压至下层的情况,但是对于数据表格而言,这已经足够了。
|
||||
|
||||
## 总结
|
||||
|
||||
虚拟滚动是一个非常复杂的功能,它需要考虑的因素非常多。但是我们相信花费这些精力是值得的,开发者不用再在功能和性能之间做取舍。而是可以同时拥有两者。不过需要注意的是,由于我们是通过 `components.body` 进行了虚拟滚动支持。这也意味着开发者不能覆盖 `body` 部分的组件。
|
||||
|
||||
以上。
|
Loading…
Reference in New Issue
Block a user