From acb0e5f16ecf0932ab9d9ac7aeb4635b71d9c941 Mon Sep 17 00:00:00 2001 From: jqroom Date: Tue, 26 Aug 2025 18:03:37 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E5=9B=BA=E5=AE=9A=E8=A1=A8=E5=A4=B4=E5=92=8C=E5=B7=A6?= =?UTF-8?q?=E5=88=97demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/demo.tsx | 7 ++- src/packages/table/demos/h5/demo15.tsx | 77 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/packages/table/demos/h5/demo15.tsx diff --git a/src/packages/table/demo.tsx b/src/packages/table/demo.tsx index d65c0ce752..46a969eaa1 100644 --- a/src/packages/table/demo.tsx +++ b/src/packages/table/demo.tsx @@ -14,6 +14,7 @@ import Demo11 from './demos/h5/demo11' import Demo12 from './demos/h5/demo12' import Demo13 from './demos/h5/demo13' import Demo14 from './demos/h5/demo14' +import Demo15 from './demos/h5/demo15' const TableDemo = () => { const [translated] = useTranslate({ @@ -31,6 +32,7 @@ const TableDemo = () => { stickyHeader: '固定表头', stickyLeftColumn: '固定左列', stickyRightColumn: '固定右列', + stickyBothColumns: '同时固定表头和左列', customRow: '自定义行', }, 'en-US': { @@ -47,7 +49,8 @@ const TableDemo = () => { sorterIcon: 'Supports Replacing Sorting ICON', stickyHeader: 'Sticky Header', stickyLeftColumn: 'Sticky Left Column', - stickyRightColumn: 'Sticky Rright Column', + stickyRightColumn: 'Sticky Right Column', + stickyBothColumns: 'Sticky Both Header And Left Column', customRow: 'Custom Row', }, }) @@ -80,6 +83,8 @@ const TableDemo = () => {

{translated.stickyRightColumn}

+

{translated.stickyBothColumns}

+

{translated.customRow}

diff --git a/src/packages/table/demos/h5/demo15.tsx b/src/packages/table/demos/h5/demo15.tsx new file mode 100644 index 0000000000..e68d46a1ca --- /dev/null +++ b/src/packages/table/demos/h5/demo15.tsx @@ -0,0 +1,77 @@ +import { Table, TableColumnProps } from '@nutui/nutui-react' +import React, { useState } from 'react' + +const Demo15 = () => { + const [data] = useState([ + { + name: 'Tom', + gender: '男', + record: '小学', + birthday: '2010-01-01', + age: 10, + }, + { + name: 'Lucy', + gender: '女', + record: '本科', + birthday: '2000-01-01', + age: 30, + }, + { + name: 'Jack', + gender: '男', + record: '高中', + birthday: '2020-01-01', + age: 4, + }, + { + name: 'Sara', + gender: '女', + record: '高中', + birthday: '2020-01-01', + age: 6, + }, + { + name: 'Frank', + gender: '男', + record: '幼儿园', + birthday: '2020-01-01', + age: 3, + }, + ]) + + const [columnsStickHeaderLeft] = useState>([ + { + title: '姓名', + key: 'name', + align: 'center', + fixed: 'left', + width: 100, + }, + { + title: '性别', + key: 'gender', + }, + { + title: '学历', + key: 'record', + }, + { + title: '生日', + key: 'birthday', + }, + { + title: '年龄', + key: 'age', + }, + ]) + + return ( + + ) +} +export default Demo15 From 061938734563714728f78d77481857f8edf47525 Mon Sep 17 00:00:00 2001 From: jqroom Date: Thu, 28 Aug 2025 11:09:22 +0800 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=E4=B8=B4=E6=97=B6=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modes/rules-4k79ih5s/agentDefinition.md | 37 +++++++ .../rules-4k79ih5s/customInstructions.md | 0 .joycode/prompt.json | 1 + src/packages/table/demos/h5/demo9.tsx | 37 ++++--- src/packages/table/table.tsx | 99 ++++++++++++++++--- src/types/spec/table/base.ts | 9 +- 6 files changed, 156 insertions(+), 27 deletions(-) create mode 100644 .joycode/modes/rules-4k79ih5s/agentDefinition.md create mode 100644 .joycode/modes/rules-4k79ih5s/customInstructions.md create mode 100644 .joycode/prompt.json diff --git a/.joycode/modes/rules-4k79ih5s/agentDefinition.md b/.joycode/modes/rules-4k79ih5s/agentDefinition.md new file mode 100644 index 0000000000..b5b3132f23 --- /dev/null +++ b/.joycode/modes/rules-4k79ih5s/agentDefinition.md @@ -0,0 +1,37 @@ +You are a Senior Front-End Developer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. + +- Follow the user’s requirements carefully & to the letter. +- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail. +- Confirm, then write code! +- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines . +- Focus on easy and readability code, over being performant. +- Fully implement all requested functionality. +- Leave NO todo’s, placeholders or missing pieces. +- Ensure code is complete! Verify thoroughly finalised. +- Include all required imports, and ensure proper naming of key components. +- Be concise Minimize any other prose. +- If you think there might not be a correct answer, you say so. +- If you do not know the answer, say so, instead of guessing. + +### Coding Environment + +The user asks questions about the following coding languages: + +- ReactJS +- NextJS +- JavaScript +- TypeScript +- TailwindCSS +- HTML +- CSS + +### Code Implementation Guidelines + +Follow these rules when you write code: + +- Use early returns whenever possible to make the code more readable. +- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags. +- Use “class:” instead of the tertiary operator in class tags whenever possible. +- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown. +- Implement accessibility features on elements. For example, a tag should have a tabindex=“0”, aria-label, on:click, and on:keydown, and similar attributes. +- Use consts instead of functions, for example, “const toggle = () =>”. Also, define a type if possible. diff --git a/.joycode/modes/rules-4k79ih5s/customInstructions.md b/.joycode/modes/rules-4k79ih5s/customInstructions.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.joycode/prompt.json b/.joycode/prompt.json new file mode 100644 index 0000000000..f806bc0197 --- /dev/null +++ b/.joycode/prompt.json @@ -0,0 +1 @@ +[{"label":"一键安装环境","name":"Install","description":"专注于解决工作空间环境问题","prompt":"你是一位专门从事解决工作空间环境问题的全栈工程师和DevOps专家,你的主要任务是帮助用户诊断、修复和配置当前工作空间`/Users/jiangqi147/github/nutui-react`的开发环境。\n\n## 核心职责\n\n### 1. 环境检测与诊断\n- 自动扫描工作空间中的项目文件(package.json, requirements.txt, pom.xml, Gemfile, go.mod等)\n- 识别项目所需的运行环境和依赖\n- 检测当前系统已安装的环境版本\n- 分析环境配置冲突和兼容性问题\n\n### 2. 主流环境支持\n**Node.js生态系统:**\n- 检测和安装Node.js(如果用户没要求推荐LTS版本)\n- 配置npm/yarn/pnpm包管理器\n- 处理node_modules依赖问题\n- 解决版本冲突和权限问题\n\n**Python生态系统:**\n- 安装Python(2.x/3.x版本管理)\n- 配置pip包管理器和虚拟环境(venv/conda)\n- 处理requirements.txt依赖安装\n- 解决Python路径和模块导入问题\n\n**Java生态系统:**\n- 安装和配置JDK/JRE(版本选择和JAVA_HOME设置)\n- 配置Maven/Gradle构建工具\n- 处理依赖下载和仓库配置\n- 解决类路径和编译问题\n\n**其他主流环境:**\n- Go语言环境配置\n- Ruby和Rails环境\n- PHP和Composer\n- .NET Core环境\n- Docker容器化环境\n\n### 3. 项目启动与运行\n- 分析项目启动脚本和配置文件\n- 提供标准化的启动命令\n- 配置开发服务器和热重载\n- 设置环境变量和配置文件\n- 处理端口冲突和服务依赖\n\n### 4. 问题解决策略\n- 提供跨平台解决方案(Windows/macOS/Linux)\n- 给出详细的安装步骤和命令\n- 提供多种安装方式选择(官方安装器/包管理器/容器化)\n- 预防常见错误和最佳实践建议\n- 提供环境验证和测试方法\n\n### 5. 交互方式\n- 首先询问用户的操作系统和项目类型\n- 逐步引导用户完成环境配置\n- 提供可复制的命令和脚本\n- 在每个步骤后确认执行结果\n- 遇到问题时提供多种备选方案\n\n## 工作流程\n1. **环境扫描**:分析工作空间文件结构,识别项目类型\n2. **需求评估**:确定所需的运行环境和版本要求\n3. **现状检查**:检测当前已安装的环境和工具\n4. **差距分析**:对比需求与现状,列出缺失项\n5. **安装指导**:提供详细的安装和配置步骤\n6. **验证测试**:确保环境配置正确可用\n7. **项目启动**:协助用户成功启动项目\n\n请始终保持耐心和专业,用通俗易懂的语言解释技术概念,并在每个关键步骤提供清晰的指导。现在请开始分析当前工作空间的环境需求。\n"}] \ No newline at end of file diff --git a/src/packages/table/demos/h5/demo9.tsx b/src/packages/table/demos/h5/demo9.tsx index aaf32ce379..5200385b90 100644 --- a/src/packages/table/demos/h5/demo9.tsx +++ b/src/packages/table/demos/h5/demo9.tsx @@ -1,15 +1,12 @@ import React, { useState } from 'react' -import { Table, Toast } from '@nutui/nutui-react' +import { + SortStateType, + Table, + TableColumnProps, + Toast, +} from '@nutui/nutui-react' +import { ArrowDown, ArrowUp } from '@nutui/icons-react' -interface TableColumnProps { - key: string - title?: string - align?: string - sorter?: ((a: any, b: any) => number) | boolean | string - render?: (rowData: any, rowIndex: number) => string | React.ReactNode - fixed?: 'left' | 'right' - width?: number -} const Demo9 = () => { const [data] = useState([ { @@ -50,14 +47,26 @@ const Demo9 = () => { { title: '年龄', key: 'age', - sorter: (row1: any, row2: any) => { - return row1.age - row2.age + sorterIcon: (currentSortState) => { + if (currentSortState === null) + return ( + + ) + if (currentSortState === 'asc') + return + if (currentSortState === 'desc') + return }, + sorter: (row1: any, row2: any) => row1.age - row2.age, }, ]) - const handleSorter = (item: TableColumnProps, data: Array) => { - Toast.show(`${JSON.stringify(item)}`) + const handleSorter = ( + item: TableColumnProps, + sortedData?: Array, + sortState?: SortStateType + ) => { + Toast.show(`${item.title} 排序状态:${sortState || '不排序'}`) } return ( diff --git a/src/packages/table/table.tsx b/src/packages/table/table.tsx index 37985cf55a..5d141163b8 100644 --- a/src/packages/table/table.tsx +++ b/src/packages/table/table.tsx @@ -5,7 +5,7 @@ import { useConfig, useRtl } from '@/packages/configprovider' import { ComponentDefaults } from '@/utils/typings' import { usePropsValue } from '@/hooks/use-props-value' import { useTableSticky } from './utils' -import { TableColumnProps, WebTableProps } from '@/types' +import { SortStateType, TableColumnProps, WebTableProps } from '@/types' const defaultProps = { ...ComponentDefaults, @@ -42,7 +42,7 @@ export const Table: FunctionComponent< ...defaultProps, ...props, } - const sortedMapping = useRef<{ [key: string]: boolean }>({}) + const sortedMapping = useRef<{ [key: string]: SortStateType }>({}) const [innerValue, setValue] = usePropsValue({ defaultValue: data, finalValue: [], @@ -65,19 +65,59 @@ export const Table: FunctionComponent< const cls = classNames(classPrefix, className) const handleSorterClick = (item: TableColumnProps) => { - if (item.sorter && !sortedMapping.current[item.key]) { + if (!item.sorter) return + + // 获取当前排序状态,如果不存在则默认为 null(不排序) + const currentSortState = sortedMapping.current[item.key] || null + + // 根据当前状态确定下一个状态:null -> asc -> desc -> null + let nextSortState: 'asc' | 'desc' | null + if (currentSortState === null) { + nextSortState = 'asc' // 默认不排序 -> 升序 + } else if (currentSortState === 'asc') { + nextSortState = 'desc' // 升序 -> 降序 + } else { + nextSortState = null // 降序 -> 不排序 + } + + // 更新排序状态 + sortedMapping.current[item.key] = nextSortState + + // 根据排序状态执行相应的排序操作 + if (nextSortState === null) { + // 不排序,恢复原始数据 + setValue(data) + onSort && onSort(item) + } else { const copied = [...innerValue] if (typeof item.sorter === 'function') { - copied.sort(item.sorter as (a: any, b: any) => number) + // 使用自定义排序函数 + if (nextSortState === 'asc') { + copied.sort(item.sorter as (a: any, b: any) => number) + } else { + // 降序:交换排序函数的参数顺序 + copied.sort( + (a, b) => -(item.sorter as (a: any, b: any) => number)(a, b) + ) + } } else if (item.sorter === 'default') { - copied.sort() + // 默认排序 + if (nextSortState === 'asc') { + copied.sort() + } else { + copied.sort().reverse() + } + } else if (item.sorter === true) { + // 简单排序,根据列的 key 值进行排序 + const key = item.key + if (nextSortState === 'asc') { + copied.sort((a, b) => (a[key] > b[key] ? 1 : -1)) + } else { + copied.sort((a, b) => (a[key] > b[key] ? -1 : 1)) + } } - sortedMapping.current[item.key] = true setValue(copied, true) - onSort && onSort(item, copied) - } else { - sortedMapping.current[item.key] = false - setValue(data) + onSort && onSort(item, copied, nextSortState) } } @@ -94,6 +134,42 @@ export const Table: FunctionComponent< const renderHeadCells = () => { return columns.map((item, index) => { + // 获取当前列的排序状态 + const currentSortState = sortedMapping.current[item.key] || null + + // 根据排序状态决定是否显示图标以及显示什么图标 + const renderSorterIcon = () => { + if (!item.sorter) return null + + // 如果列提供了自定义的排序图标函数,优先使用 + if (item.sorterIcon) { + return item.sorterIcon(currentSortState) + } + + // 如果提供了全局的排序图标,使用全局图标 + if (sorterIcon) { + return sorterIcon + } + + // 默认图标逻辑:根据排序状态显示不同的图标 + if (currentSortState === 'asc') { + // 升序状态 + return ( + + ) + } + if (currentSortState === 'desc') { + // 降序状态 + return + } + // 未排序状态 - 显示较淡的图标 + return + } + return (
{item.title}  - {item.sorter && - (sorterIcon || )} + {item.sorter && renderSorterIcon()}
) }) diff --git a/src/types/spec/table/base.ts b/src/types/spec/table/base.ts index a60538a9bb..0cc6faa78b 100644 --- a/src/types/spec/table/base.ts +++ b/src/types/spec/table/base.ts @@ -6,12 +6,15 @@ export interface TableColumnProps { key: string title?: string align?: string + sorterIcon?: (currentSortState: SortStateType) => ReactNode sorter?: ((a: any, b: any) => number) | boolean | string render?: (rowData: any, rowIndex: number) => string | ReactNode fixed?: PositionX width?: number } +export type SortStateType = 'asc' | 'desc' | null + export interface BaseTable extends BaseProps { columns: Array data: Array @@ -20,6 +23,10 @@ export interface BaseTable extends BaseProps { striped?: boolean noData?: ReactNode sorterIcon?: ReactNode - onSort?: (column: TableColumnProps, sortedData: Array) => void + onSort?: ( + column: TableColumnProps, + sortedData?: Array, + sortState?: SortStateType + ) => void showHeader?: boolean } From 20e3830007366179d880854be21f0c5d78369c5c Mon Sep 17 00:00:00 2001 From: jqroom Date: Thu, 28 Aug 2025 14:20:09 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=E4=B8=B4=E6=97=B6=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/demo.tsx | 5 + src/packages/table/demos/h5/demo-virtual.tsx | 122 +++++ src/packages/table/index.virtual.ts | 14 + src/packages/table/table-virtual.tsx | 479 +++++++++++++++++++ src/packages/table/virtual-scroll.ts | 125 +++++ 5 files changed, 745 insertions(+) create mode 100644 src/packages/table/demos/h5/demo-virtual.tsx create mode 100644 src/packages/table/index.virtual.ts create mode 100644 src/packages/table/table-virtual.tsx create mode 100644 src/packages/table/virtual-scroll.ts diff --git a/src/packages/table/demo.tsx b/src/packages/table/demo.tsx index 46a969eaa1..a525df1e53 100644 --- a/src/packages/table/demo.tsx +++ b/src/packages/table/demo.tsx @@ -15,6 +15,7 @@ import Demo12 from './demos/h5/demo12' import Demo13 from './demos/h5/demo13' import Demo14 from './demos/h5/demo14' import Demo15 from './demos/h5/demo15' +import DemoVirtual from './demos/h5/demo-virtual' const TableDemo = () => { const [translated] = useTranslate({ @@ -34,6 +35,7 @@ const TableDemo = () => { stickyRightColumn: '固定右列', stickyBothColumns: '同时固定表头和左列', customRow: '自定义行', + virtual: '虚拟滚动', }, 'en-US': { basic: 'Basic Usage', @@ -52,6 +54,7 @@ const TableDemo = () => { stickyRightColumn: 'Sticky Right Column', stickyBothColumns: 'Sticky Both Header And Left Column', customRow: 'Custom Row', + virtual: 'virtual scroll', }, }) @@ -87,6 +90,8 @@ const TableDemo = () => {

{translated.customRow}

+

{translated.virtual}

+ ) } diff --git a/src/packages/table/demos/h5/demo-virtual.tsx b/src/packages/table/demos/h5/demo-virtual.tsx new file mode 100644 index 0000000000..c66ba60d59 --- /dev/null +++ b/src/packages/table/demos/h5/demo-virtual.tsx @@ -0,0 +1,122 @@ +import React, { useState, useRef } from 'react' +import TableVirtual, { + TableColumnProps, + VirtualTableRef, +} from '../../index.virtual' + +// 定义数据项接口 +interface DataItem { + name: string + record: string + age: number +} + +const DemoVirtual = () => { + // 创建表格引用 + const tableRef = useRef(null) + + // 生成大量数据 + const generateData = (count: number): DataItem[] => { + const data: DataItem[] = [] + for (let i = 0; i < count; i++) { + data.push({ + name: `Name ${i}`, + record: ['小学', '初中', '高中', '大专', '本科'][i % 5], + age: Math.floor(Math.random() * 50) + 10, + }) + } + return data + } + + // 定义列配置 + const [columns] = useState([ + { + title: 'ID', + key: 'id', + render: (_record: any, index: number) => { + return index + 1 + }, + }, + { + title: '姓名', + key: 'name', + }, + { + title: '学历', + key: 'record', + }, + { + title: '年龄', + key: 'age', + sorter: (a: DataItem, b: DataItem) => a.age - b.age, + }, + ]) + + // 使用状态管理数据 + const [data, setData] = useState(generateData(1000)) + + // 更新数据的方法 + const updateData = (count: number) => { + setData(generateData(count)) + } + + // 滚动到指定行的方法 + const handleScrollToRow = (index: number) => { + if (tableRef.current) { + tableRef.current.scrollToIndex(index) + } + } + + return ( +
+

普通表格 (无虚拟滚动)

+ + +
+ + + +
+ +
+ + + +
+ +

虚拟滚动表格 (1000条数据)

+ +
+ ) +} + +export default DemoVirtual diff --git a/src/packages/table/index.virtual.ts b/src/packages/table/index.virtual.ts new file mode 100644 index 0000000000..c70e1fa198 --- /dev/null +++ b/src/packages/table/index.virtual.ts @@ -0,0 +1,14 @@ +import { + TableVirtual, + VirtualTableRef, + VirtualTableProps, +} from './table-virtual' +import { useVirtualScroll } from './virtual-scroll' +import { TableColumnProps } from '@/types/spec/table/base' + +// 导出TableVirtual组件,使其能够接收正确的props +const TableVirtualWrapper = TableVirtual + +export { useVirtualScroll } +export type { TableColumnProps, VirtualTableRef, VirtualTableProps } +export default TableVirtualWrapper diff --git a/src/packages/table/table-virtual.tsx b/src/packages/table/table-virtual.tsx new file mode 100644 index 0000000000..d886947590 --- /dev/null +++ b/src/packages/table/table-virtual.tsx @@ -0,0 +1,479 @@ +import React, { + useEffect, + useRef, + useState, + useImperativeHandle, + forwardRef, + ForwardRefRenderFunction, +} from 'react' +import classNames from 'classnames' +import { ArrowDown } from '@nutui/icons-react' +import { useConfig, useRtl } from '@/packages/configprovider' +import { ComponentDefaults } from '@/utils/typings' +import { usePropsValue } from '@/hooks/use-props-value' +import { useTableSticky } from './utils' +import { useVirtualScroll } from './virtual-scroll' +import { SortStateType, TableColumnProps, WebTableProps } from '@/types' + +export interface VirtualTableProps extends Omit { + // 是否启用虚拟滚动 + virtual?: boolean + // 表格可视区域高度 + height?: number + // 每行高度 + rowHeight?: number + // 预加载的行数 + overscan?: number + // 滚动到指定索引的方法 + scrollToIndex?: (index: number) => void + // 覆盖WebTableProps中的bordered,使其可选 + bordered?: boolean +} + +// 定义组件引用类型 +export interface VirtualTableRef { + // 滚动到指定索引的方法 + scrollToIndex: (index: number) => void +} + +const defaultProps = { + ...ComponentDefaults, + columns: [], + data: [], + bordered: true, + striped: false, + noData: '', + sorterIcon: null, + showHeader: true, + virtual: false, + height: 300, + rowHeight: 40, + overscan: 5, +} as VirtualTableProps + +// 使用ForwardRefRenderFunction来定义组件,以便支持ref转发 +const TableVirtualComponent: ForwardRefRenderFunction< + VirtualTableRef, + VirtualTableProps +> = (props, ref) => { + const { locale } = useConfig() + const rtl = useRtl() + defaultProps.noData = locale.noData + + const { + children, + className, + style, + columns, + data, + bordered, + summary, + striped, + noData, + sorterIcon, + showHeader, + onSort, + virtual, + height, + rowHeight, + overscan, + scrollToIndex, + ...rest + } = { + ...defaultProps, + ...props, + } + const sortedMapping = useRef<{ [key: string]: SortStateType }>({}) + const [innerValue, setValue] = usePropsValue({ + defaultValue: data, + finalValue: [], + }) + const { + isSticky, + stickyLeftWidth, + stickyRightWidth, + getStickyClass, + getStickyStyle, + } = useTableSticky(columns, rtl) + + // 表头高度 + const [headerHeight, setHeaderHeight] = useState(rowHeight) + const headerRef = useRef(null) + + // 计算表头高度 + useEffect(() => { + if (headerRef.current && showHeader) { + setHeaderHeight(headerRef.current.getBoundingClientRect().height) + } else if (!showHeader) { + setHeaderHeight(0) + } + }, [showHeader, headerRef.current]) + + // 虚拟滚动相关 + const { + visibleRange, + totalHeight, + offsetY, + onScroll, + containerRef, + scrollTo, + } = useVirtualScroll({ + total: innerValue.length, + viewportHeight: (height || 300) - (showHeader ? headerHeight || 0 : 0), + itemHeight: rowHeight || 40, + overscan: overscan || 5, + }) + + // 使用useImperativeHandle暴露方法给外部 + useImperativeHandle( + ref, + () => ({ + scrollToIndex: scrollTo, + }), + [scrollTo] + ) + + // 当数据变化时,更新内部值 + useEffect(() => { + setValue(data) + + // 当数据变化时,如果启用了虚拟滚动,需要重新计算虚拟滚动状态 + if (virtual && containerRef.current) { + // 保存当前滚动位置 + const currentScrollTop = containerRef.current.scrollTop + + // 延迟一帧,确保内部状态已更新 + requestAnimationFrame(() => { + // 确保容器引用仍然有效 + if (containerRef.current) { + // 手动触发滚动事件以更新虚拟滚动状态 + const scrollEvent = new UIEvent('scroll', { + bubbles: true, + }) + containerRef.current.dispatchEvent(scrollEvent) + + // 如果数据量变化较大,可能需要再次触发滚动事件 + if (data.length !== innerValue.length) { + setTimeout(() => { + if (containerRef.current) { + const secondScrollEvent = new UIEvent('scroll', { + bubbles: true, + }) + containerRef.current.dispatchEvent(secondScrollEvent) + } + }, 50) + } + } + }) + } + }, [data, virtual, innerValue.length]) + + // 当表头高度变化时,更新虚拟滚动状态 + useEffect(() => { + if (virtual && containerRef.current && showHeader) { + // 触发一次滚动事件,以更新虚拟滚动状态 + const scrollEvent = new UIEvent('scroll', { bubbles: true }) + containerRef.current.dispatchEvent(scrollEvent) + } + }, [headerHeight, virtual, showHeader]) + + // 将scrollTo方法暴露给外部(兼容旧的API) + useEffect(() => { + if (scrollToIndex && virtual && typeof scrollToIndex === 'function') { + // 直接将内部的scrollTo方法赋值给外部的scrollToIndex + // 这样外部可以通过ref.current.scrollToIndex(index)来调用 + scrollToIndex(scrollTo as any) + } + }, [scrollToIndex, virtual, scrollTo]) + + const classPrefix = 'nut-table' + const headerClassPrefix = `${classPrefix}-main-head-tr` + const bodyClassPrefix = `${classPrefix}-main-body-tr` + const cls = classNames(classPrefix, className) + + const handleSorterClick = (item: TableColumnProps) => { + if (!item.sorter) return + + // 获取当前排序状态,如果不存在则默认为 null(不排序) + const currentSortState = sortedMapping.current[item.key] || null + + // 根据当前状态确定下一个状态:null -> asc -> desc -> null + let nextSortState: 'asc' | 'desc' | null + if (currentSortState === null) { + nextSortState = 'asc' // 默认不排序 -> 升序 + } else if (currentSortState === 'asc') { + nextSortState = 'desc' // 升序 -> 降序 + } else { + nextSortState = null // 降序 -> 不排序 + } + + // 更新排序状态 + sortedMapping.current[item.key] = nextSortState + + // 根据排序状态执行相应的排序操作 + if (nextSortState === null) { + // 不排序,恢复原始数据 + setValue(data) + onSort && onSort(item) + } else { + const copied = [...innerValue] + if (typeof item.sorter === 'function') { + // 使用自定义排序函数 + if (nextSortState === 'asc') { + copied.sort(item.sorter as (a: any, b: any) => number) + } else { + // 降序:交换排序函数的参数顺序 + copied.sort( + (a, b) => -(item.sorter as (a: any, b: any) => number)(a, b) + ) + } + } else if (item.sorter === 'default') { + // 默认排序 + if (nextSortState === 'asc') { + copied.sort() + } else { + copied.sort().reverse() + } + } else if (item.sorter === true) { + // 简单排序,根据列的 key 值进行排序 + const key = item.key + if (nextSortState === 'asc') { + copied.sort((a, b) => (a[key] > b[key] ? 1 : -1)) + } else { + copied.sort((a, b) => (a[key] > b[key] ? -1 : 1)) + } + } + setValue(copied, true) + onSort && onSort(item, copied, nextSortState) + } + } + + const cellClasses = (item: TableColumnProps) => { + return { + [`${headerClassPrefix}-border`]: bordered, + [`${headerClassPrefix}-align${item.align ? item.align : ''}`]: true, + } + } + + const getColumnItem = (value: string): TableColumnProps => { + return columns.filter((item: TableColumnProps) => item.key === value)[0] + } + + const renderHeadCells = () => { + return columns.map((item: TableColumnProps, index: number) => { + // 获取当前列的排序状态 + const currentSortState = sortedMapping.current[item.key] || null + + // 根据排序状态决定是否显示图标以及显示什么图标 + const renderSorterIcon = () => { + if (!item.sorter) return null + + // 如果列提供了自定义的排序图标函数,优先使用 + if (item.sorterIcon) { + return item.sorterIcon(currentSortState) + } + + // 如果提供了全局的排序图标,使用全局图标 + if (sorterIcon) { + return sorterIcon + } + + // 默认图标逻辑:根据排序状态显示不同的图标 + if (currentSortState === 'asc') { + // 升序状态 + return ( + + ) + } + if (currentSortState === 'desc') { + // 降序状态 + return + } + // 未排序状态 - 显示较淡的图标 + return + } + + return ( +
handleSorterClick(item)} + style={getStickyStyle(item.key)} + > + {item.title}  + {item.sorter && renderSorterIcon()} +
+ ) + }) + } + + const sortDataItem = () => { + return columns.map((column: TableColumnProps) => { + return [column.key, column.render] as [ + string, + ((item: any, index: number) => React.ReactNode) | undefined, + ] + }) + } + + const renderBodyTds = (item: any, rowIndex: number) => { + return sortDataItem().map( + ([value, render]: [ + string, + ((item: any, index: number) => React.ReactNode) | undefined, + ]) => { + return ( +
+ {typeof item[value] === 'function' || + typeof render === 'function' ? ( +
{render ? render(item, rowIndex) : item[value](item)}
+ ) : ( + item[value] + )} +
+ ) + } + ) + } + + const renderBodyTrs = () => { + // 如果启用了虚拟滚动,只渲染可视区域内的行 + const dataToRender = virtual + ? innerValue.slice( + Math.min(visibleRange[0], innerValue.length - 1), + Math.min(visibleRange[1] + 1, innerValue.length) + ) + : innerValue + + // 如果没有数据要渲染,返回空数组 + if (dataToRender.length === 0) { + return [] + } + + return dataToRender + .map((item: any, index: number) => { + // 计算实际行索引(用于虚拟滚动) + const actualIndex = virtual ? visibleRange[0] + index : index + + // 确保item存在且是一个有效的对象 + if (!item || typeof item !== 'object') { + console.warn('Invalid item in table data:', item) + return null + } + + const inner = renderBodyTds(item, actualIndex) + const { rowRender } = item + if (rowRender && typeof rowRender === 'function') { + return rowRender(item, actualIndex, { inner }) + } + return ( +
+ {inner} +
+ ) + }) + .filter(Boolean) // 过滤掉无效的行 + } + + return ( +
+
+
onScroll(e) : undefined} + style={ + virtual + ? { + height: height || 300, + maxHeight: height || 300, + overflow: 'auto', + position: 'relative', + } + : {} + } + > + {showHeader && ( +
+
{renderHeadCells()}
+
+ )} +
+ {virtual && ( +
+
+ {renderBodyTrs()} +
+
+ )} + {!virtual && renderBodyTrs()} +
+
+
+ {isSticky ? ( + <> +
+
+ + ) : null} + {(summary || innerValue.length === 0) && ( +
{summary || noData}
+ )} +
+ ) +} + +// 使用forwardRef包装组件,以便支持ref转发 +export const TableVirtual = forwardRef( + TableVirtualComponent +) + +TableVirtual.displayName = 'NutTableVirtual' diff --git a/src/packages/table/virtual-scroll.ts b/src/packages/table/virtual-scroll.ts new file mode 100644 index 0000000000..14fc16c1b8 --- /dev/null +++ b/src/packages/table/virtual-scroll.ts @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from 'react' + +export interface VirtualScrollOptions { + // 数据总条数 + total: number + // 可视区域高度 + viewportHeight: number + // 每行高度 + itemHeight: number + // 预加载的行数(可视区域外上下额外渲染的行数) + overscan?: number +} + +export interface VirtualScrollResult { + // 可视区域内的行索引范围 + visibleRange: [number, number] + // 容器总高度 + totalHeight: number + // 当前滚动位置的偏移量 + offsetY: number + // 滚动事件处理函数 + onScroll: (event: React.UIEvent) => void + // 容器引用 + containerRef: React.RefObject + // 滚动到指定索引的方法 + scrollTo: (index: number) => void +} + +/** + * 虚拟滚动Hook + * @param options 虚拟滚动配置选项 + * @returns 虚拟滚动状态和方法 + */ +export function useVirtualScroll( + options: VirtualScrollOptions +): VirtualScrollResult { + const { total, viewportHeight, itemHeight, overscan = 5 } = options + + // 计算总高度 + const totalHeight = total * itemHeight + + // 容器引用 + const containerRef = useRef(null) + + // 当前滚动位置 + const [scrollTop, setScrollTop] = useState(0) + + // 使用ref保存当前的滚动位置,以便在滚动事件处理函数中访问最新值 + const scrollTopRef = useRef(scrollTop) + scrollTopRef.current = scrollTop + + // 使用ref保存当前的数据总量,以便在滚动事件处理函数中访问最新值 + const totalRef = useRef(total) + totalRef.current = total + + // 使用useEffect监听total变化,确保数据更新时重新计算 + useEffect(() => { + // 当数据总量变化时,如果当前滚动位置超出了新的总高度,则调整滚动位置 + if (containerRef.current && scrollTop > totalHeight) { + // 如果当前滚动位置超出了新的总高度,则滚动到顶部 + containerRef.current.scrollTop = 0 + setScrollTop(0) + } else if (containerRef.current) { + // 即使滚动位置没有超出范围,也触发一次滚动事件,确保可视区域正确更新 + const scrollEvent = new UIEvent('scroll', { bubbles: true }) + containerRef.current.dispatchEvent(scrollEvent) + } + }, [total, totalHeight, scrollTop]) + + // 计算可视区域内的行索引范围 + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan) + const endIndex = Math.min( + total - 1, + Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscan + ) + + // 计算偏移量 + const offsetY = startIndex * itemHeight + + // 使用防抖优化滚动事件处理 + const scrollTimerRef = useRef(null) + + // 滚动事件处理函数 + const onScroll = (event: React.UIEvent) => { + console.log('scroll', event) + const { scrollTop } = event.target as HTMLDivElement + + // 立即更新滚动位置,确保渲染不延迟 + setScrollTop(scrollTop) + + // 清除之前的定时器 + if (scrollTimerRef.current !== null) { + window.clearTimeout(scrollTimerRef.current) + } + + // 设置新的定时器,在滚动停止后再次触发更新 + scrollTimerRef.current = window.setTimeout(() => { + // 确保使用最新的滚动位置 + const currentScrollTop = (event.target as HTMLDivElement).scrollTop + if (currentScrollTop !== scrollTopRef.current) { + setScrollTop(currentScrollTop) + } + scrollTimerRef.current = null + }, 100) // 100ms的防抖延迟 + } + + // 手动设置滚动位置的方法 + const scrollTo = (index: number) => { + if (containerRef.current) { + const targetIndex = Math.min(Math.max(0, index), totalRef.current - 1) // 确保不会滚动到超出范围的位置 + const targetScrollTop = targetIndex * itemHeight + containerRef.current.scrollTop = targetScrollTop + setScrollTop(targetScrollTop) // 立即更新状态,确保渲染不延迟 + } + } + + return { + visibleRange: [startIndex, endIndex], + totalHeight, + offsetY, + onScroll, + containerRef, + scrollTo, + } +} From 0e47a9c9aeecce40b37e492b355f60e66e498f5f Mon Sep 17 00:00:00 2001 From: jqroom Date: Thu, 28 Aug 2025 20:47:28 +0800 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E9=AB=98=E5=BA=A6=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/demos/h5/demo-virtual.tsx | 46 +++++- src/packages/table/table-virtual.tsx | 50 +++++-- src/packages/table/table.scss | 11 ++ src/packages/table/virtual-scroll.ts | 148 +++++++++++++++++-- 4 files changed, 223 insertions(+), 32 deletions(-) diff --git a/src/packages/table/demos/h5/demo-virtual.tsx b/src/packages/table/demos/h5/demo-virtual.tsx index c66ba60d59..b054b59a52 100644 --- a/src/packages/table/demos/h5/demo-virtual.tsx +++ b/src/packages/table/demos/h5/demo-virtual.tsx @@ -9,6 +9,7 @@ interface DataItem { name: string record: string age: number + description?: string // 添加描述字段,用于测试不同高度的行 } const DemoVirtual = () => { @@ -19,10 +20,21 @@ const DemoVirtual = () => { const generateData = (count: number): DataItem[] => { const data: DataItem[] = [] for (let i = 0; i < count; i++) { + // 为部分行添加不同长度的描述,以测试动态高度 + let description + if (i % 3 === 0) { + description = `这是一段较长的描述文本,用于测试动态高度。行号: ${i}. 这段文字会导致行高增加。` + } else if (i % 5 === 0) { + description = `这是一段非常非常长的描述文本,它会占用多行空间。这是为了测试虚拟滚动表格在处理不同高度的行时的表现。行号: ${i}. 我们希望表格能够正确计算每行的实际高度,并且在滚动时保持良好的性能和用户体验。` + } else { + description = undefined + } + data.push({ name: `Name ${i}`, record: ['小学', '初中', '高中', '大专', '本科'][i % 5], age: Math.floor(Math.random() * 50) + 10, + description, }) } return data @@ -50,6 +62,26 @@ const DemoVirtual = () => { key: 'age', sorter: (a: DataItem, b: DataItem) => a.age - b.age, }, + { + title: '描述', + key: 'description', + render: (record: DataItem) => { + return record.description ? ( +
+ {record.description} +
+ ) : ( + '-' + ) + }, + }, ]) // 使用状态管理数据 @@ -104,7 +136,7 @@ const DemoVirtual = () => {
-

虚拟滚动表格 (1000条数据)

+

虚拟滚动表格 (固定高度)

{ overscan={10} bordered /> + +

虚拟滚动表格 (动态高度)

+
) } diff --git a/src/packages/table/table-virtual.tsx b/src/packages/table/table-virtual.tsx index d886947590..adfa8a068a 100644 --- a/src/packages/table/table-virtual.tsx +++ b/src/packages/table/table-virtual.tsx @@ -28,6 +28,8 @@ export interface VirtualTableProps extends Omit { scrollToIndex?: (index: number) => void // 覆盖WebTableProps中的bordered,使其可选 bordered?: boolean + // 是否启用动态高度(如果为true,将尝试获取每行的实际高度) + dynamicHeight?: boolean } // 定义组件引用类型 @@ -49,6 +51,7 @@ const defaultProps = { height: 300, rowHeight: 40, overscan: 5, + dynamicHeight: false, } as VirtualTableProps // 使用ForwardRefRenderFunction来定义组件,以便支持ref转发 @@ -78,6 +81,7 @@ const TableVirtualComponent: ForwardRefRenderFunction< rowHeight, overscan, scrollToIndex, + dynamicHeight, ...rest } = { ...defaultProps, @@ -117,11 +121,14 @@ const TableVirtualComponent: ForwardRefRenderFunction< onScroll, containerRef, scrollTo, + getRowRef, + updateItemHeight, } = useVirtualScroll({ total: innerValue.length, viewportHeight: (height || 300) - (showHeader ? headerHeight || 0 : 0), itemHeight: rowHeight || 40, overscan: overscan || 5, + dynamicHeight: dynamicHeight || false, }) // 使用useImperativeHandle暴露方法给外部 @@ -380,10 +387,22 @@ const TableVirtualComponent: ForwardRefRenderFunction< const inner = renderBodyTds(item, actualIndex) const { rowRender } = item if (rowRender && typeof rowRender === 'function') { - return rowRender(item, actualIndex, { inner }) + const renderedRow = rowRender(item, actualIndex, { inner }) + // 如果自定义渲染函数返回的是React元素,我们需要添加ref + if (React.isValidElement(renderedRow) && dynamicHeight) { + return React.cloneElement(renderedRow, { + // @ts-ignore + ref: getRowRef(actualIndex), + }) + } + return renderedRow } return ( -
+
{inner}
) @@ -427,25 +446,24 @@ const TableVirtualComponent: ForwardRefRenderFunction<
{renderHeadCells()}
)} -
+
{virtual && (
-
- {renderBodyTrs()} -
+ {renderBodyTrs()}
)} {!virtual && renderBodyTrs()} diff --git a/src/packages/table/table.scss b/src/packages/table/table.scss index 07221de69a..63b719062e 100644 --- a/src/packages/table/table.scss +++ b/src/packages/table/table.scss @@ -49,6 +49,17 @@ } } + &-virtual { + .nut-table-main-head { + &-tr { + &-th { + background-color: #fff; + z-index: 1; + } + } + } + } + &-head, &-body { background: inherit; diff --git a/src/packages/table/virtual-scroll.ts b/src/packages/table/virtual-scroll.ts index 14fc16c1b8..93ca0e4449 100644 --- a/src/packages/table/virtual-scroll.ts +++ b/src/packages/table/virtual-scroll.ts @@ -5,10 +5,12 @@ export interface VirtualScrollOptions { total: number // 可视区域高度 viewportHeight: number - // 每行高度 + // 每行默认高度(当无法获取实际高度时使用) itemHeight: number // 预加载的行数(可视区域外上下额外渲染的行数) overscan?: number + // 是否启用动态高度(如果为true,将尝试获取每行的实际高度) + dynamicHeight?: boolean } export interface VirtualScrollResult { @@ -24,6 +26,10 @@ export interface VirtualScrollResult { containerRef: React.RefObject // 滚动到指定索引的方法 scrollTo: (index: number) => void + // 更新指定行高度的方法 + updateItemHeight: (index: number, height: number) => void + // 获取行元素引用的方法 + getRowRef: (index: number) => (element: HTMLElement | null) => void } /** @@ -34,10 +40,34 @@ export interface VirtualScrollResult { export function useVirtualScroll( options: VirtualScrollOptions ): VirtualScrollResult { - const { total, viewportHeight, itemHeight, overscan = 5 } = options + const { + total, + viewportHeight, + itemHeight, + overscan = 5, + dynamicHeight = false, + } = options + + // 高度缓存,用于存储每行的实际高度 + const [heightCache, setHeightCache] = useState>({}) + + // 行元素引用缓存 + const rowRefs = useRef>({}) + + // 计算总高度(考虑动态高度) + const calculateTotalHeight = () => { + if (!dynamicHeight) { + return total * itemHeight + } + + let height = 0 + for (let i = 0; i < total; i++) { + height += heightCache[i] || itemHeight + } + return height + } - // 计算总高度 - const totalHeight = total * itemHeight + const totalHeight = calculateTotalHeight() // 容器引用 const containerRef = useRef(null) @@ -67,15 +97,68 @@ export function useVirtualScroll( } }, [total, totalHeight, scrollTop]) - // 计算可视区域内的行索引范围 - const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan) - const endIndex = Math.min( - total - 1, - Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscan - ) + // 计算可视区域内的行索引范围(考虑动态高度) + const calculateVisibleRange = () => { + if (!dynamicHeight) { + // 固定高度的简单计算 + const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan) + const end = Math.min( + total - 1, + Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscan + ) + return [start, end] as [number, number] + } + + // 动态高度的计算 + let currentHeight = 0 + let startIndex = 0 + let endIndex = 0 + + // 找到起始索引 + for (let i = 0; i < total; i++) { + const rowHeight = heightCache[i] || itemHeight + if (currentHeight + rowHeight > scrollTop) { + startIndex = Math.max(0, i - overscan) + break + } + currentHeight += rowHeight + } + + // 找到结束索引 + currentHeight = 0 + for (let i = 0; i < total; i++) { + const rowHeight = heightCache[i] || itemHeight + currentHeight += rowHeight + if (currentHeight > scrollTop + viewportHeight) { + endIndex = Math.min(total - 1, i + overscan) + break + } + + // 如果到达最后一行,设置结束索引为最后一行 + if (i === total - 1) { + endIndex = total - 1 + } + } + + return [startIndex, endIndex] as [number, number] + } + + const visibleRange = calculateVisibleRange() - // 计算偏移量 - const offsetY = startIndex * itemHeight + // 计算偏移量(考虑动态高度) + const calculateOffsetY = () => { + if (!dynamicHeight) { + return visibleRange[0] * itemHeight + } + + let offset = 0 + for (let i = 0; i < visibleRange[0]; i++) { + offset += heightCache[i] || itemHeight + } + return offset + } + + const offsetY = calculateOffsetY() // 使用防抖优化滚动事件处理 const scrollTimerRef = useRef(null) @@ -104,22 +187,57 @@ export function useVirtualScroll( }, 100) // 100ms的防抖延迟 } - // 手动设置滚动位置的方法 + // 手动设置滚动位置的方法(考虑动态高度) const scrollTo = (index: number) => { if (containerRef.current) { const targetIndex = Math.min(Math.max(0, index), totalRef.current - 1) // 确保不会滚动到超出范围的位置 - const targetScrollTop = targetIndex * itemHeight + + let targetScrollTop = 0 + if (!dynamicHeight) { + targetScrollTop = targetIndex * itemHeight + } else { + // 计算目标位置的滚动偏移 + for (let i = 0; i < targetIndex; i++) { + targetScrollTop += heightCache[i] || itemHeight + } + } + containerRef.current.scrollTop = targetScrollTop setScrollTop(targetScrollTop) // 立即更新状态,确保渲染不延迟 } } + // 更新指定行高度的方法 + const updateItemHeight = (index: number, height: number) => { + if (heightCache[index] !== height) { + setHeightCache((prev) => ({ + ...prev, + [index]: height, + })) + } + } + + // 获取行元素引用的方法 + const getRowRef = (index: number) => (element: HTMLElement | null) => { + if (element && dynamicHeight) { + rowRefs.current[index] = element + + // 如果高度发生变化,更新高度缓存 + const currentHeight = element.getBoundingClientRect().height + if (heightCache[index] !== currentHeight) { + updateItemHeight(index, currentHeight) + } + } + } + return { - visibleRange: [startIndex, endIndex], + visibleRange, totalHeight, offsetY, onScroll, containerRef, scrollTo, + updateItemHeight, + getRowRef, } } From 5292c85697b69c11fa5c2687b68d0a82f3a5b2a8 Mon Sep 17 00:00:00 2001 From: jqroom Date: Thu, 28 Aug 2025 21:01:34 +0800 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E6=BB=9A=E5=8A=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/table-virtual.tsx | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/packages/table/table-virtual.tsx b/src/packages/table/table-virtual.tsx index adfa8a068a..eb63e426a3 100644 --- a/src/packages/table/table-virtual.tsx +++ b/src/packages/table/table-virtual.tsx @@ -418,9 +418,18 @@ const TableVirtualComponent: ForwardRefRenderFunction< isSticky ? `${classPrefix}-wrapper-sticky` : '' }` )} + ref={virtual ? containerRef : undefined} + onScroll={virtual ? (e) => onScroll(e) : undefined} style={{ ...style, - ...(virtual ? { height: height || 300 } : {}), + ...(virtual + ? { + height: height || 300, + maxHeight: height || 300, + overflow: 'auto', + position: 'relative', + } + : {}), }} >
onScroll(e) : undefined} - style={ - virtual - ? { - height: height || 300, - maxHeight: height || 300, - overflow: 'auto', - position: 'relative', - } - : {} - } > {showHeader && (
From 4bb8e7056a9ebabe9b4c36f8d04c3c322004a312 Mon Sep 17 00:00:00 2001 From: jqroom Date: Thu, 28 Aug 2025 21:13:02 +0800 Subject: [PATCH 06/16] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/table-virtual.tsx | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/packages/table/table-virtual.tsx b/src/packages/table/table-virtual.tsx index eb63e426a3..4ff8872e72 100644 --- a/src/packages/table/table-virtual.tsx +++ b/src/packages/table/table-virtual.tsx @@ -437,6 +437,10 @@ const TableVirtualComponent: ForwardRefRenderFunction< [`${classPrefix}-main-striped`]: striped, [`${classPrefix}-main-virtual`]: virtual, })} + style={{ + height: totalHeight, + position: 'relative', + }} > {showHeader && (
@@ -446,24 +450,14 @@ const TableVirtualComponent: ForwardRefRenderFunction<
- {virtual && ( -
- {renderBodyTrs()} -
- )} - {!virtual && renderBodyTrs()} + {renderBodyTrs()}
From 80d9a74fbdc3a966a5b65335a8f00789e628f7bb Mon Sep 17 00:00:00 2001 From: jqroom Date: Fri, 29 Aug 2025 11:50:11 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6=E8=AE=BE=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/demos/h5/demo-virtual.tsx | 8 +++- src/packages/table/table-virtual.tsx | 44 +++++++++++++------- src/packages/table/table.scss | 2 + 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/packages/table/demos/h5/demo-virtual.tsx b/src/packages/table/demos/h5/demo-virtual.tsx index b054b59a52..05a942d9b3 100644 --- a/src/packages/table/demos/h5/demo-virtual.tsx +++ b/src/packages/table/demos/h5/demo-virtual.tsx @@ -45,6 +45,7 @@ const DemoVirtual = () => { { title: 'ID', key: 'id', + width: 50, render: (_record: any, index: number) => { return index + 1 }, @@ -52,14 +53,17 @@ const DemoVirtual = () => { { title: '姓名', key: 'name', + width: 80, }, { title: '学历', key: 'record', + width: 60, }, { title: '年龄', key: 'age', + width: 70, sorter: (a: DataItem, b: DataItem) => a.age - b.age, }, { @@ -136,7 +140,7 @@ const DemoVirtual = () => {
-

虚拟滚动表格 (固定高度)

+ {/*

虚拟滚动表格 (固定高度)

{ rowHeight={40} overscan={10} bordered - /> + /> */}

虚拟滚动表格 (动态高度)

handleSorterClick(item)} - style={getStickyStyle(item.key)} + style={{ + ...getStickyStyle(item.key), + width: item.width, + }} > {item.title}  {item.sorter && renderSorterIcon()} @@ -324,18 +327,20 @@ const TableVirtualComponent: ForwardRefRenderFunction< const sortDataItem = () => { return columns.map((column: TableColumnProps) => { - return [column.key, column.render] as [ + return [column.key, column.render, column.width] as [ string, ((item: any, index: number) => React.ReactNode) | undefined, + number, ] }) } const renderBodyTds = (item: any, rowIndex: number) => { return sortDataItem().map( - ([value, render]: [ + ([value, render, width]: [ string, ((item: any, index: number) => React.ReactNode) | undefined, + number, ]) => { return (
{typeof item[value] === 'function' || typeof render === 'function' ? ( @@ -437,10 +445,6 @@ const TableVirtualComponent: ForwardRefRenderFunction< [`${classPrefix}-main-striped`]: striped, [`${classPrefix}-main-virtual`]: virtual, })} - style={{ - height: totalHeight, - position: 'relative', - }} > {showHeader && (
@@ -450,14 +454,26 @@ const TableVirtualComponent: ForwardRefRenderFunction<
- {renderBodyTrs()} + {virtual && ( +
+ {renderBodyTrs()} +
+ )} + {!virtual && renderBodyTrs()}
diff --git a/src/packages/table/table.scss b/src/packages/table/table.scss index 63b719062e..c8e4652c2d 100644 --- a/src/packages/table/table.scss +++ b/src/packages/table/table.scss @@ -75,6 +75,7 @@ } &-th { + box-sizing: border-box; display: table-cell; padding: $table-cols-padding; table-layout: fixed; @@ -93,6 +94,7 @@ } &-td { + box-sizing: border-box; display: table-cell; padding: $table-cols-padding; table-layout: fixed; From 2810d646447c5bb3c9067248d3af3ee209c11450 Mon Sep 17 00:00:00 2001 From: jqroom Date: Fri, 29 Aug 2025 14:01:16 +0800 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Edemo=E6=A1=88?= =?UTF-8?q?=E4=BE=8B=EF=BC=8C=E8=B0=83=E6=95=B4=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/demo.tsx | 5 + .../table/demos/h5/demo-no-virtual.tsx | 99 +++++++++++++++++++ src/packages/table/demos/h5/demo-virtual.tsx | 70 ++----------- src/packages/table/table-virtual.tsx | 4 +- src/packages/table/table.scss | 13 +-- src/styles/variables-jmapp.scss | 1 + src/styles/variables.scss | 1 + 7 files changed, 121 insertions(+), 72 deletions(-) create mode 100644 src/packages/table/demos/h5/demo-no-virtual.tsx diff --git a/src/packages/table/demo.tsx b/src/packages/table/demo.tsx index a525df1e53..bdbcce51f8 100644 --- a/src/packages/table/demo.tsx +++ b/src/packages/table/demo.tsx @@ -15,6 +15,7 @@ import Demo12 from './demos/h5/demo12' import Demo13 from './demos/h5/demo13' import Demo14 from './demos/h5/demo14' import Demo15 from './demos/h5/demo15' +import DemoNoVirtual from './demos/h5/demo-no-virtual' import DemoVirtual from './demos/h5/demo-virtual' const TableDemo = () => { @@ -35,6 +36,7 @@ const TableDemo = () => { stickyRightColumn: '固定右列', stickyBothColumns: '同时固定表头和左列', customRow: '自定义行', + noVirtual: '普通表格', virtual: '虚拟滚动', }, 'en-US': { @@ -54,6 +56,7 @@ const TableDemo = () => { stickyRightColumn: 'Sticky Right Column', stickyBothColumns: 'Sticky Both Header And Left Column', customRow: 'Custom Row', + noVirtual: 'no virtual scroll', virtual: 'virtual scroll', }, }) @@ -90,6 +93,8 @@ const TableDemo = () => {

{translated.customRow}

+

{translated.noVirtual}

+

{translated.virtual}

diff --git a/src/packages/table/demos/h5/demo-no-virtual.tsx b/src/packages/table/demos/h5/demo-no-virtual.tsx new file mode 100644 index 0000000000..7dea93956f --- /dev/null +++ b/src/packages/table/demos/h5/demo-no-virtual.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react' +import TableVirtual, { TableColumnProps } from '../../index.virtual' + +// 定义数据项接口 +interface DataItem { + name: string + record: string + age: number + description?: string // 添加描述字段,用于测试不同高度的行 +} + +const DemoNoVirtual = () => { + // 生成大量数据 + const generateData = (count: number): DataItem[] => { + const data: DataItem[] = [] + for (let i = 0; i < count; i++) { + // 为部分行添加不同长度的描述,以测试动态高度 + let description + if (i % 3 === 0) { + description = `这是一段较长的描述文本,用于测试动态高度。行号: ${i}. 这段文字会导致行高增加。` + } else if (i % 5 === 0) { + description = `这是一段非常非常长的描述文本,它会占用多行空间。这是为了测试虚拟滚动表格在处理不同高度的行时的表现。行号: ${i}. 我们希望表格能够正确计算每行的实际高度,并且在滚动时保持良好的性能和用户体验。` + } else { + description = undefined + } + + data.push({ + name: `Name ${i}`, + record: ['小学', '初中', '高中', '大专', '本科'][i % 5], + age: Math.floor(Math.random() * 50) + 10, + description, + }) + } + return data + } + + // 定义列配置 + const [columns] = useState([ + { + title: 'ID', + key: 'id', + width: 50, + render: (_record: any, index: number) => { + return index + 1 + }, + }, + { + title: '姓名', + key: 'name', + width: 80, + }, + { + title: '学历', + key: 'record', + width: 60, + }, + { + title: '年龄', + key: 'age', + width: 70, + sorter: (a: DataItem, b: DataItem) => a.age - b.age, + }, + { + title: '描述', + key: 'description', + render: (record: DataItem) => { + return record.description ? ( +
+ {record.description} +
+ ) : ( + '-' + ) + }, + }, + ]) + + // 使用状态管理数据 + const [data] = useState(generateData(10)) + + return ( + + ) +} + +export default DemoNoVirtual diff --git a/src/packages/table/demos/h5/demo-virtual.tsx b/src/packages/table/demos/h5/demo-virtual.tsx index 05a942d9b3..4b2a49824d 100644 --- a/src/packages/table/demos/h5/demo-virtual.tsx +++ b/src/packages/table/demos/h5/demo-virtual.tsx @@ -104,66 +104,16 @@ const DemoVirtual = () => { } return ( -
-

普通表格 (无虚拟滚动)

- - -
- - - -
- -
- - - -
- - {/*

虚拟滚动表格 (固定高度)

- */} - -

虚拟滚动表格 (动态高度)

- -
+ ) } diff --git a/src/packages/table/table-virtual.tsx b/src/packages/table/table-virtual.tsx index b476517575..ffa2fe1008 100644 --- a/src/packages/table/table-virtual.tsx +++ b/src/packages/table/table-virtual.tsx @@ -437,7 +437,9 @@ const TableVirtualComponent: ForwardRefRenderFunction< overflow: 'auto', position: 'relative', } - : {}), + : { + height: height || 300, + }), }} >
Date: Fri, 29 Aug 2025 14:38:53 +0800 Subject: [PATCH 09/16] =?UTF-8?q?style:=20=E6=A0=B7=E5=BC=8F=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/demos/h5/demo-no-virtual.tsx | 13 ++++--------- src/packages/table/demos/h5/demo-virtual.tsx | 3 +++ src/packages/table/table.scss | 6 ++++++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/packages/table/demos/h5/demo-no-virtual.tsx b/src/packages/table/demos/h5/demo-no-virtual.tsx index 7dea93956f..b4a3ccfccb 100644 --- a/src/packages/table/demos/h5/demo-no-virtual.tsx +++ b/src/packages/table/demos/h5/demo-no-virtual.tsx @@ -40,6 +40,7 @@ const DemoNoVirtual = () => { title: 'ID', key: 'id', width: 50, + fixed: 'left', render: (_record: any, index: number) => { return index + 1 }, @@ -63,6 +64,8 @@ const DemoNoVirtual = () => { { title: '描述', key: 'description', + width: 100, + fixed: 'right', render: (record: DataItem) => { return record.description ? (
{ // 使用状态管理数据 const [data] = useState(generateData(10)) - return ( - - ) + return } export default DemoNoVirtual diff --git a/src/packages/table/demos/h5/demo-virtual.tsx b/src/packages/table/demos/h5/demo-virtual.tsx index 4b2a49824d..f29eeec5b3 100644 --- a/src/packages/table/demos/h5/demo-virtual.tsx +++ b/src/packages/table/demos/h5/demo-virtual.tsx @@ -46,6 +46,7 @@ const DemoVirtual = () => { title: 'ID', key: 'id', width: 50, + fixed: 'left', render: (_record: any, index: number) => { return index + 1 }, @@ -69,6 +70,8 @@ const DemoVirtual = () => { { title: '描述', key: 'description', + width: 100, + fixed: 'right', render: (record: DataItem) => { return record.description ? (
div { + background-color: $color-background-overlay; + } + } + &-head, &-body { background: inherit; From 25c46337c36c9b8f508e5346965ec7f5b5364a71 Mon Sep 17 00:00:00 2001 From: jqroom Date: Fri, 29 Aug 2025 15:51:35 +0800 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/table-virtual.tsx | 5 +++++ src/packages/table/table.scss | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/packages/table/table-virtual.tsx b/src/packages/table/table-virtual.tsx index ffa2fe1008..8006337d15 100644 --- a/src/packages/table/table-virtual.tsx +++ b/src/packages/table/table-virtual.tsx @@ -470,6 +470,11 @@ const TableVirtualComponent: ForwardRefRenderFunction< transform: `translateY(${offsetY}px)`, display: 'table-row', width: '100%', + willChange: 'transform', // 提示浏览器该元素会频繁变换,优化渲染性能 + backfaceVisibility: 'hidden', // 防止渲染闪烁 + WebkitBackfaceVisibility: 'hidden', // Safari 兼容 + transition: 'transform 0.05s ease-out', // 添加平滑过渡效果,减少闪动 + zIndex: 1, // 确保内容在正确的层级 }} > {renderBodyTrs()} diff --git a/src/packages/table/table.scss b/src/packages/table/table.scss index b874e9780b..998d43be31 100644 --- a/src/packages/table/table.scss +++ b/src/packages/table/table.scss @@ -78,7 +78,7 @@ position: sticky; top: 0; background-color: $table-th-bg-color; - z-index: 1; + z-index: 2; &.nut-table-fixed-left, &.nut-table-fixed-right { From d32f67cf00bd11482b91ae30711b42dd87e28575 Mon Sep 17 00:00:00 2001 From: jqroom Date: Fri, 29 Aug 2025 16:27:06 +0800 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20=E8=A7=A3=E5=86=B3=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E6=B8=B2=E6=9F=93=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/virtual-scroll.ts | 78 ++++++++++++++++++---------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/src/packages/table/virtual-scroll.ts b/src/packages/table/virtual-scroll.ts index 93ca0e4449..454cfdccff 100644 --- a/src/packages/table/virtual-scroll.ts +++ b/src/packages/table/virtual-scroll.ts @@ -72,12 +72,10 @@ export function useVirtualScroll( // 容器引用 const containerRef = useRef(null) - // 当前滚动位置 - const [scrollTop, setScrollTop] = useState(0) - - // 使用ref保存当前的滚动位置,以便在滚动事件处理函数中访问最新值 - const scrollTopRef = useRef(scrollTop) - scrollTopRef.current = scrollTop + // 当前滚动位置 - 使用ref而不是state来避免不必要的重渲染 + const scrollTopRef = useRef(0) + // 创建一个状态,但仅用于触发重新渲染,不直接用于计算 + const [scrollTopState, setScrollTopState] = useState(0) // 使用ref保存当前的数据总量,以便在滚动事件处理函数中访问最新值 const totalRef = useRef(total) @@ -86,25 +84,31 @@ export function useVirtualScroll( // 使用useEffect监听total变化,确保数据更新时重新计算 useEffect(() => { // 当数据总量变化时,如果当前滚动位置超出了新的总高度,则调整滚动位置 - if (containerRef.current && scrollTop > totalHeight) { + if (containerRef.current && scrollTopRef.current > totalHeight) { // 如果当前滚动位置超出了新的总高度,则滚动到顶部 containerRef.current.scrollTop = 0 - setScrollTop(0) + scrollTopRef.current = 0 + setScrollTopState(0) } else if (containerRef.current) { // 即使滚动位置没有超出范围,也触发一次滚动事件,确保可视区域正确更新 const scrollEvent = new UIEvent('scroll', { bubbles: true }) containerRef.current.dispatchEvent(scrollEvent) } - }, [total, totalHeight, scrollTop]) + }, [total, totalHeight]) // 计算可视区域内的行索引范围(考虑动态高度) const calculateVisibleRange = () => { + const currentScrollTop = scrollTopRef.current + if (!dynamicHeight) { // 固定高度的简单计算 - const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan) + const start = Math.max( + 0, + Math.floor(currentScrollTop / itemHeight) - overscan + ) const end = Math.min( total - 1, - Math.ceil((scrollTop + viewportHeight) / itemHeight) + overscan + Math.ceil((currentScrollTop + viewportHeight) / itemHeight) + overscan ) return [start, end] as [number, number] } @@ -117,7 +121,7 @@ export function useVirtualScroll( // 找到起始索引 for (let i = 0; i < total; i++) { const rowHeight = heightCache[i] || itemHeight - if (currentHeight + rowHeight > scrollTop) { + if (currentHeight + rowHeight > currentScrollTop) { startIndex = Math.max(0, i - overscan) break } @@ -129,7 +133,7 @@ export function useVirtualScroll( for (let i = 0; i < total; i++) { const rowHeight = heightCache[i] || itemHeight currentHeight += rowHeight - if (currentHeight > scrollTop + viewportHeight) { + if (currentHeight > currentScrollTop + viewportHeight) { endIndex = Math.min(total - 1, i + overscan) break } @@ -164,27 +168,39 @@ export function useVirtualScroll( const scrollTimerRef = useRef(null) // 滚动事件处理函数 + // 使用requestAnimationFrame进行滚动处理,避免过多的状态更新 const onScroll = (event: React.UIEvent) => { - console.log('scroll', event) - const { scrollTop } = event.target as HTMLDivElement + // 获取当前滚动位置 + const scrollContainer = event.target as HTMLDivElement + const currentScrollTop = scrollContainer.scrollTop + + // 如果滚动位置与当前引用值相同,则不更新 + if (currentScrollTop === scrollTopRef.current) { + return + } - // 立即更新滚动位置,确保渲染不延迟 - setScrollTop(scrollTop) + // 立即更新ref值,这不会触发重新渲染 + scrollTopRef.current = currentScrollTop - // 清除之前的定时器 + // 清除之前的RAF请求 if (scrollTimerRef.current !== null) { - window.clearTimeout(scrollTimerRef.current) + window.cancelAnimationFrame(scrollTimerRef.current as number) } - // 设置新的定时器,在滚动停止后再次触发更新 - scrollTimerRef.current = window.setTimeout(() => { - // 确保使用最新的滚动位置 - const currentScrollTop = (event.target as HTMLDivElement).scrollTop - if (currentScrollTop !== scrollTopRef.current) { - setScrollTop(currentScrollTop) - } + // 使用requestAnimationFrame来优化渲染性能 + scrollTimerRef.current = window.requestAnimationFrame(() => { + // 触发状态更新以重新渲染可视区域 + // 使用函数形式的setState,确保我们总是基于最新状态更新 + setScrollTopState((prev) => { + // 只有当滚动位置真正变化时才更新状态 + if (Math.abs(prev - scrollTopRef.current) > 1) { + return scrollTopRef.current + } + return prev + }) + scrollTimerRef.current = null - }, 100) // 100ms的防抖延迟 + }) } // 手动设置滚动位置的方法(考虑动态高度) @@ -202,8 +218,14 @@ export function useVirtualScroll( } } + // 设置DOM元素的滚动位置 containerRef.current.scrollTop = targetScrollTop - setScrollTop(targetScrollTop) // 立即更新状态,确保渲染不延迟 + + // 更新ref值 + scrollTopRef.current = targetScrollTop + + // 触发状态更新以重新渲染 + setScrollTopState(targetScrollTop) } } From 62b9d4329a80f475ed8a610eea6de148482ddaaf Mon Sep 17 00:00:00 2001 From: jqroom Date: Fri, 29 Aug 2025 16:32:55 +0800 Subject: [PATCH 12/16] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/table-virtual.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/packages/table/table-virtual.tsx b/src/packages/table/table-virtual.tsx index 8006337d15..a9138ed743 100644 --- a/src/packages/table/table-virtual.tsx +++ b/src/packages/table/table-virtual.tsx @@ -473,7 +473,6 @@ const TableVirtualComponent: ForwardRefRenderFunction< willChange: 'transform', // 提示浏览器该元素会频繁变换,优化渲染性能 backfaceVisibility: 'hidden', // 防止渲染闪烁 WebkitBackfaceVisibility: 'hidden', // Safari 兼容 - transition: 'transform 0.05s ease-out', // 添加平滑过渡效果,减少闪动 zIndex: 1, // 确保内容在正确的层级 }} > From 2116b98eacd30eb0b28c5e0a8e83670975c6acdd Mon Sep 17 00:00:00 2001 From: jqroom Date: Fri, 29 Aug 2025 16:54:06 +0800 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E6=BB=9A=E5=8A=A8=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/hooks.ts | 111 ++++++++++++++++++++ src/packages/table/table-row.tsx | 148 +++++++++++++++++++++++++++ src/packages/table/table-virtual.tsx | 111 +++++++++----------- src/packages/table/virtual-scroll.ts | 53 +++++----- 4 files changed, 332 insertions(+), 91 deletions(-) create mode 100644 src/packages/table/hooks.ts create mode 100644 src/packages/table/table-row.tsx diff --git a/src/packages/table/hooks.ts b/src/packages/table/hooks.ts new file mode 100644 index 0000000000..bfc1a0724a --- /dev/null +++ b/src/packages/table/hooks.ts @@ -0,0 +1,111 @@ +import { useCallback, useRef } from 'react' + +/** + * 节流Hook - 限制函数调用频率 + * @param fn 需要节流的函数 + * @param delay 节流延迟时间(毫秒) + * @param options 配置选项 + * @returns 节流处理后的函数 + */ +export function useThrottle any>( + fn: T, + delay: number = 16, // 默认约60fps + options: { leading?: boolean; trailing?: boolean } = {} +): T { + const { leading = true, trailing = true } = options + const lastCallTime = useRef(0) + const timer = useRef(null) + const lastArgs = useRef([]) + + // 清除定时器 + const clearTimer = () => { + if (timer.current !== null) { + window.cancelAnimationFrame(timer.current) + timer.current = null + } + } + + return useCallback( + (...args: Parameters) => { + const now = Date.now() + const elapsed = now - lastCallTime.current + lastArgs.current = args + + // 重置上次调用时间 + function resetLastCallTime() { + lastCallTime.current = now + } + + // 如果是第一次调用或者已经超过延迟时间 + if (elapsed > delay) { + clearTimer() + + if (leading) { + resetLastCallTime() + fn(...args) + } else if (trailing) { + timer.current = window.requestAnimationFrame(() => { + resetLastCallTime() + fn(...lastArgs.current) + timer.current = null + }) + } + } else if (trailing && timer.current === null) { + // 设置定时器,确保最后一次调用也能执行 + timer.current = window.requestAnimationFrame(() => { + resetLastCallTime() + fn(...lastArgs.current) + timer.current = null + }) + } + }, + [fn, delay, leading, trailing] + ) as T +} + +/** + * 防抖Hook - 延迟函数调用直到停止触发一段时间后 + * @param fn 需要防抖的函数 + * @param delay 防抖延迟时间(毫秒) + * @param options 配置选项 + * @returns 防抖处理后的函数 + */ +export function useDebounce any>( + fn: T, + delay: number = 300, + options: { leading?: boolean; trailing?: boolean } = {} +): T { + const { leading = false, trailing = true } = options + const timer = useRef(null) + const isLeadingCalled = useRef(false) + + return useCallback( + (...args: Parameters) => { + const invokeLeading = leading && !isLeadingCalled.current + + // 清除之前的定时器 + if (timer.current !== null) { + window.clearTimeout(timer.current) + timer.current = null + } + + // 如果是第一次调用并且启用了leading选项 + if (invokeLeading) { + isLeadingCalled.current = true + fn(...args) + } + + // 设置新的定时器 + if (trailing || !leading) { + timer.current = window.setTimeout(() => { + if (trailing) { + fn(...args) + } + isLeadingCalled.current = false + timer.current = null + }, delay) + } + }, + [fn, delay, leading, trailing] + ) as T +} diff --git a/src/packages/table/table-row.tsx b/src/packages/table/table-row.tsx new file mode 100644 index 0000000000..119288846c --- /dev/null +++ b/src/packages/table/table-row.tsx @@ -0,0 +1,148 @@ +import React, { memo } from 'react' +import classNames from 'classnames' +import { TableColumnProps } from '@/types' + +interface TableRowProps { + // 行数据 + item: any + // 行索引 + rowIndex: number + // 行类名前缀 + bodyClassPrefix: string + // 单元格类名计算函数 + cellClasses: (item: TableColumnProps) => Record + // 获取粘性类名 + getStickyClass: ( + key: string + ) => Record | undefined + // 获取粘性样式 + getStickyStyle: (key: string) => Record | undefined + // 获取列配置 + getColumnItem: (value: string) => TableColumnProps + // 列数据项 + sortDataItem: () => [ + string, + ((item: any, index: number) => React.ReactNode) | undefined, + number, + ][] + // 是否启用动态高度 + dynamicHeight?: boolean + // 获取行引用的方法 + getRowRef?: (index: number) => (element: HTMLElement | null) => void +} + +/** + * 表格行组件 - 使用React.memo优化性能 + * 只有当行数据或相关属性变化时才会重新渲染 + */ +const TableRow: React.FC = ({ + item, + rowIndex, + bodyClassPrefix, + cellClasses, + getStickyClass, + getStickyStyle, + getColumnItem, + sortDataItem, + dynamicHeight, + getRowRef, +}) => { + // 渲染单元格 + const renderBodyTds = () => { + return sortDataItem().map( + ([value, render, width]: [ + string, + ((item: any, index: number) => React.ReactNode) | undefined, + number, + ]) => { + return ( +
+ {typeof item[value] === 'function' || + typeof render === 'function' ? ( +
{render ? render(item, rowIndex) : item[value](item)}
+ ) : ( + item[value] + )} +
+ ) + } + ) + } + + // 处理自定义行渲染 + const { rowRender } = item + if (rowRender && typeof rowRender === 'function') { + const inner = renderBodyTds() + const renderedRow = rowRender(item, rowIndex, { inner }) + + // 如果自定义渲染函数返回的是React元素,我们需要添加ref + if (React.isValidElement(renderedRow) && dynamicHeight && getRowRef) { + return React.cloneElement(renderedRow, { + // @ts-ignore + ref: getRowRef(rowIndex), + }) + } + return renderedRow + } + + // 标准行渲染 + return ( +
+ {renderBodyTds()} +
+ ) +} + +// 使用React.memo包装组件,避免不必要的重渲染 +// 只有当props发生变化时,组件才会重新渲染 +export default memo(TableRow, (prevProps, nextProps) => { + // 如果行索引不同,需要重新渲染 + if (prevProps.rowIndex !== nextProps.rowIndex) { + return false + } + + // 如果行数据引用不同,需要进一步比较 + if (prevProps.item !== nextProps.item) { + // 简单比较对象的键值是否相同 + // 注意:这是一个浅比较,对于复杂嵌套对象可能需要更深入的比较 + const prevKeys = Object.keys(prevProps.item) + const nextKeys = Object.keys(nextProps.item) + + if (prevKeys.length !== nextKeys.length) { + return false + } + + // 比较每个键的值 + for (const key of prevKeys) { + if (prevProps.item[key] !== nextProps.item[key]) { + return false + } + } + } + + // 其他props变化也需要重新渲染 + if ( + prevProps.bodyClassPrefix !== nextProps.bodyClassPrefix || + prevProps.dynamicHeight !== nextProps.dynamicHeight + ) { + return false + } + + // 如果所有比较都通过,则认为组件不需要重新渲染 + return true +}) diff --git a/src/packages/table/table-virtual.tsx b/src/packages/table/table-virtual.tsx index a9138ed743..14ab3f9069 100644 --- a/src/packages/table/table-virtual.tsx +++ b/src/packages/table/table-virtual.tsx @@ -5,6 +5,7 @@ import React, { useImperativeHandle, forwardRef, ForwardRefRenderFunction, + useCallback, } from 'react' import classNames from 'classnames' import { ArrowDown } from '@nutui/icons-react' @@ -13,6 +14,7 @@ import { ComponentDefaults } from '@/utils/typings' import { usePropsValue } from '@/hooks/use-props-value' import { useTableSticky } from './utils' import { useVirtualScroll } from './virtual-scroll' +import TableRow from './table-row' import { SortStateType, TableColumnProps, WebTableProps } from '@/types' export interface VirtualTableProps extends Omit { @@ -255,16 +257,22 @@ const TableVirtualComponent: ForwardRefRenderFunction< } } - const cellClasses = (item: TableColumnProps) => { - return { - [`${headerClassPrefix}-border`]: bordered, - [`${headerClassPrefix}-align${item.align ? item.align : ''}`]: true, - } - } + const cellClasses = useCallback( + (item: TableColumnProps) => { + return { + [`${headerClassPrefix}-border`]: bordered, + [`${headerClassPrefix}-align${item.align ? item.align : ''}`]: true, + } + }, + [headerClassPrefix, bordered] + ) - const getColumnItem = (value: string): TableColumnProps => { - return columns.filter((item: TableColumnProps) => item.key === value)[0] - } + const getColumnItem = useCallback( + (value: string): TableColumnProps => { + return columns.filter((item: TableColumnProps) => item.key === value)[0] + }, + [columns] + ) const renderHeadCells = () => { return columns.map((item: TableColumnProps, index: number) => { @@ -325,7 +333,7 @@ const TableVirtualComponent: ForwardRefRenderFunction< }) } - const sortDataItem = () => { + const sortDataItem = useCallback(() => { return columns.map((column: TableColumnProps) => { return [column.key, column.render, column.width] as [ string, @@ -333,41 +341,10 @@ const TableVirtualComponent: ForwardRefRenderFunction< number, ] }) - } + }, [columns]) - const renderBodyTds = (item: any, rowIndex: number) => { - return sortDataItem().map( - ([value, render, width]: [ - string, - ((item: any, index: number) => React.ReactNode) | undefined, - number, - ]) => { - return ( -
- {typeof item[value] === 'function' || - typeof render === 'function' ? ( -
{render ? render(item, rowIndex) : item[value](item)}
- ) : ( - item[value] - )} -
- ) - } - ) - } - - const renderBodyTrs = () => { + // 使用useCallback优化renderBodyTrs函数,避免不必要的重新创建 + const renderBodyTrs = useCallback(() => { // 如果启用了虚拟滚动,只渲染可视区域内的行 const dataToRender = virtual ? innerValue.slice( @@ -392,31 +369,37 @@ const TableVirtualComponent: ForwardRefRenderFunction< return null } - const inner = renderBodyTds(item, actualIndex) - const { rowRender } = item - if (rowRender && typeof rowRender === 'function') { - const renderedRow = rowRender(item, actualIndex, { inner }) - // 如果自定义渲染函数返回的是React元素,我们需要添加ref - if (React.isValidElement(renderedRow) && dynamicHeight) { - return React.cloneElement(renderedRow, { - // @ts-ignore - ref: getRowRef(actualIndex), - }) - } - return renderedRow - } + // 使用记忆化的TableRow组件,减少不必要的重渲染 return ( -
- {inner} -
+ item={item} + rowIndex={actualIndex} + bodyClassPrefix={bodyClassPrefix} + cellClasses={cellClasses} + getStickyClass={getStickyClass} + getStickyStyle={getStickyStyle} + getColumnItem={getColumnItem} + sortDataItem={sortDataItem} + dynamicHeight={dynamicHeight} + getRowRef={dynamicHeight ? getRowRef : undefined} + /> ) }) .filter(Boolean) // 过滤掉无效的行 - } + }, [ + virtual, + innerValue, + visibleRange, + bodyClassPrefix, + cellClasses, + getStickyClass, + getStickyStyle, + getColumnItem, + sortDataItem, + dynamicHeight, + getRowRef, + ]) return (
diff --git a/src/packages/table/virtual-scroll.ts b/src/packages/table/virtual-scroll.ts index 454cfdccff..be63f10f6f 100644 --- a/src/packages/table/virtual-scroll.ts +++ b/src/packages/table/virtual-scroll.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { useThrottle } from './hooks' export interface VirtualScrollOptions { // 数据总条数 @@ -164,16 +165,8 @@ export function useVirtualScroll( const offsetY = calculateOffsetY() - // 使用防抖优化滚动事件处理 - const scrollTimerRef = useRef(null) - - // 滚动事件处理函数 - // 使用requestAnimationFrame进行滚动处理,避免过多的状态更新 - const onScroll = (event: React.UIEvent) => { - // 获取当前滚动位置 - const scrollContainer = event.target as HTMLDivElement - const currentScrollTop = scrollContainer.scrollTop - + // 滚动事件处理函数 - 使用节流优化 + const handleScrollUpdate = (currentScrollTop: number) => { // 如果滚动位置与当前引用值相同,则不更新 if (currentScrollTop === scrollTopRef.current) { return @@ -182,25 +175,31 @@ export function useVirtualScroll( // 立即更新ref值,这不会触发重新渲染 scrollTopRef.current = currentScrollTop - // 清除之前的RAF请求 - if (scrollTimerRef.current !== null) { - window.cancelAnimationFrame(scrollTimerRef.current as number) - } + // 触发状态更新以重新渲染可视区域 + // 使用函数形式的setState,确保我们总是基于最新状态更新 + setScrollTopState((prev) => { + // 只有当滚动位置真正变化时才更新状态 + if (Math.abs(prev - scrollTopRef.current) > 1) { + return scrollTopRef.current + } + return prev + }) + } - // 使用requestAnimationFrame来优化渲染性能 - scrollTimerRef.current = window.requestAnimationFrame(() => { - // 触发状态更新以重新渲染可视区域 - // 使用函数形式的setState,确保我们总是基于最新状态更新 - setScrollTopState((prev) => { - // 只有当滚动位置真正变化时才更新状态 - if (Math.abs(prev - scrollTopRef.current) > 1) { - return scrollTopRef.current - } - return prev - }) + // 使用节流优化滚动事件处理,在快速滚动时降低更新频率 + const throttledScrollHandler = useThrottle(handleScrollUpdate, 16, { + leading: true, + trailing: true, + }) - scrollTimerRef.current = null - }) + // 滚动事件处理函数 + const onScroll = (event: React.UIEvent) => { + // 获取当前滚动位置 + const scrollContainer = event.target as HTMLDivElement + const currentScrollTop = scrollContainer.scrollTop + + // 使用节流函数处理滚动更新 + throttledScrollHandler(currentScrollTop) } // 手动设置滚动位置的方法(考虑动态高度) From 83a111ccab6605b6a9b1707cee7c81db30d4ca72 Mon Sep 17 00:00:00 2001 From: jqroom Date: Fri, 29 Aug 2025 17:13:28 +0800 Subject: [PATCH 14/16] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/index.virtual.ts | 8 +++++--- src/packages/table/{ => virtual}/hooks.ts | 0 src/packages/table/virtual/index.ts | 8 ++++++++ src/packages/table/{ => virtual}/table-row.tsx | 0 src/packages/table/{ => virtual}/table-virtual.tsx | 2 +- src/packages/table/{ => virtual}/virtual-scroll.ts | 0 6 files changed, 14 insertions(+), 4 deletions(-) rename src/packages/table/{ => virtual}/hooks.ts (100%) create mode 100644 src/packages/table/virtual/index.ts rename src/packages/table/{ => virtual}/table-row.tsx (100%) rename src/packages/table/{ => virtual}/table-virtual.tsx (99%) rename src/packages/table/{ => virtual}/virtual-scroll.ts (100%) diff --git a/src/packages/table/index.virtual.ts b/src/packages/table/index.virtual.ts index c70e1fa198..84838d5355 100644 --- a/src/packages/table/index.virtual.ts +++ b/src/packages/table/index.virtual.ts @@ -2,13 +2,15 @@ import { TableVirtual, VirtualTableRef, VirtualTableProps, -} from './table-virtual' -import { useVirtualScroll } from './virtual-scroll' + useVirtualScroll, + useThrottle, + useDebounce, +} from './virtual' import { TableColumnProps } from '@/types/spec/table/base' // 导出TableVirtual组件,使其能够接收正确的props const TableVirtualWrapper = TableVirtual -export { useVirtualScroll } +export { useVirtualScroll, useThrottle, useDebounce } export type { TableColumnProps, VirtualTableRef, VirtualTableProps } export default TableVirtualWrapper diff --git a/src/packages/table/hooks.ts b/src/packages/table/virtual/hooks.ts similarity index 100% rename from src/packages/table/hooks.ts rename to src/packages/table/virtual/hooks.ts diff --git a/src/packages/table/virtual/index.ts b/src/packages/table/virtual/index.ts new file mode 100644 index 0000000000..7797ad064b --- /dev/null +++ b/src/packages/table/virtual/index.ts @@ -0,0 +1,8 @@ +export { TableVirtual } from './table-virtual' +export type { VirtualTableProps, VirtualTableRef } from './table-virtual' +export { useVirtualScroll } from './virtual-scroll' +export type { + VirtualScrollOptions, + VirtualScrollResult, +} from './virtual-scroll' +export { useThrottle, useDebounce } from './hooks' diff --git a/src/packages/table/table-row.tsx b/src/packages/table/virtual/table-row.tsx similarity index 100% rename from src/packages/table/table-row.tsx rename to src/packages/table/virtual/table-row.tsx diff --git a/src/packages/table/table-virtual.tsx b/src/packages/table/virtual/table-virtual.tsx similarity index 99% rename from src/packages/table/table-virtual.tsx rename to src/packages/table/virtual/table-virtual.tsx index 14ab3f9069..3f2332c490 100644 --- a/src/packages/table/table-virtual.tsx +++ b/src/packages/table/virtual/table-virtual.tsx @@ -12,7 +12,7 @@ import { ArrowDown } from '@nutui/icons-react' import { useConfig, useRtl } from '@/packages/configprovider' import { ComponentDefaults } from '@/utils/typings' import { usePropsValue } from '@/hooks/use-props-value' -import { useTableSticky } from './utils' +import { useTableSticky } from '../utils' import { useVirtualScroll } from './virtual-scroll' import TableRow from './table-row' import { SortStateType, TableColumnProps, WebTableProps } from '@/types' diff --git a/src/packages/table/virtual-scroll.ts b/src/packages/table/virtual/virtual-scroll.ts similarity index 100% rename from src/packages/table/virtual-scroll.ts rename to src/packages/table/virtual/virtual-scroll.ts From beba08ea817ae92cb13cfdfa651849b6079f19ca Mon Sep 17 00:00:00 2001 From: jqroom Date: Fri, 29 Aug 2025 17:25:13 +0800 Subject: [PATCH 15/16] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/doc.en-US.md | 79 +++++++++++++++++++++++++++++++++ src/packages/table/doc.md | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/src/packages/table/doc.en-US.md b/src/packages/table/doc.en-US.md index fab4466089..e69e4dd5e8 100644 --- a/src/packages/table/doc.en-US.md +++ b/src/packages/table/doc.en-US.md @@ -114,6 +114,26 @@ import { Table } from '@nutui/nutui-react' ::: +### Virtual Scrolling + +When the table has a large amount of data, virtual scrolling can be used to optimize performance. + +:::demo + + + +::: + +### Virtual Scrolling + Dynamic Row Height + +Supports dynamic calculation of row height, suitable for scenarios where row content height is not fixed. + +:::demo + + + +::: + ## Table ### Props @@ -142,6 +162,65 @@ import { Table } from '@nutui/nutui-react' | width | Column width | `number` | `auto` | | fixed | Fixed position | `left` \| `right` | `-` | +## TableVirtual + +### Import + +```tsx +import { TableVirtual } from '@nutui/nutui-react' +``` + +### Props + +Inherits all properties of the Table component, and adds the following properties: + +| Property | Description | Type | Default Value | +| --- | --- | --- | --- | +| virtual | Whether to enable virtual scrolling | `boolean` | `false` | +| height | Table viewport height | `number` | `300` | +| rowHeight | Height of each row | `number` | `40` | +| overscan | Number of rows to preload | `number` | `5` | +| dynamicHeight | Whether to enable dynamic height (if true, will try to get the actual height of each row) | `boolean` | `false` | + +### VirtualTableRef + +| Property | Description | Type | +| --- | --- | --- | +| scrollToIndex | Method to scroll to a specific index | `(index: number) => void` | + +### Example + +```tsx +import React, { useRef } from 'react' +import { TableVirtual } from '@nutui/nutui-react' +import type { VirtualTableRef } from '@nutui/nutui-react' + +const VirtualTableExample = () => { + const tableRef = useRef(null) + + // Scroll to specific row + const scrollToRow = (index: number) => { + tableRef.current?.scrollToIndex(index) + } + + return ( + + ) +} + +VirtualTableExample.displayName = 'VirtualTableExample' + +export default VirtualTableExample +``` + ## Theme Customization ### Style Variables diff --git a/src/packages/table/doc.md b/src/packages/table/doc.md index 188d136f57..8503389794 100644 --- a/src/packages/table/doc.md +++ b/src/packages/table/doc.md @@ -114,6 +114,26 @@ import { Table } from '@nutui/nutui-react' ::: +### 虚拟滚动 + +当表格数据量较大时,可以使用虚拟滚动来优化性能。 + +:::demo + + + +::: + +### 虚拟滚动 + 动态行高 + +支持动态计算行高,适用于行内容高度不固定的场景。 + +:::demo + + + +::: + ## Table ### Props @@ -142,6 +162,65 @@ import { Table } from '@nutui/nutui-react' | width | 列宽度 | `number` | `auto` | | fixed | 固定位置 | `left` \| `right` | `-` | +## TableVirtual + +### 引入 + +```tsx +import { TableVirtual } from '@nutui/nutui-react' +``` + +### Props + +继承 Table 组件的所有属性,并新增以下属性: + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| virtual | 是否启用虚拟滚动 | `boolean` | `false` | +| height | 表格可视区域高度 | `number` | `300` | +| rowHeight | 每行高度 | `number` | `40` | +| overscan | 预加载的行数 | `number` | `5` | +| dynamicHeight | 是否启用动态高度(如果为true,将尝试获取每行的实际高度) | `boolean` | `false` | + +### VirtualTableRef + +| 属性 | 说明 | 类型 | +| --- | --- | --- | +| scrollToIndex | 滚动到指定索引的方法 | `(index: number) => void` | + +### 示例 + +```tsx +import React, { useRef } from 'react' +import { TableVirtual } from '@nutui/nutui-react' +import type { VirtualTableRef } from '@nutui/nutui-react' + +const VirtualTableExample = () => { + const tableRef = useRef(null) + + // 滚动到指定行 + const scrollToRow = (index: number) => { + tableRef.current?.scrollToIndex(index) + } + + return ( + + ) +} + +VirtualTableExample.displayName = 'VirtualTableExample' + +export default VirtualTableExample +``` + ## 主题定制 ### 样式变量 From 1178a17707e3ec7e77ee8d50e70d14ac265fd241 Mon Sep 17 00:00:00 2001 From: jqroom Date: Fri, 29 Aug 2025 17:37:33 +0800 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/packages/table/doc.en-US.md | 10 +++++----- src/packages/table/doc.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/packages/table/doc.en-US.md b/src/packages/table/doc.en-US.md index e69e4dd5e8..073b36bf91 100644 --- a/src/packages/table/doc.en-US.md +++ b/src/packages/table/doc.en-US.md @@ -114,23 +114,23 @@ import { Table } from '@nutui/nutui-react' ::: -### Virtual Scrolling +### Disable Virtual Scrolling -When the table has a large amount of data, virtual scrolling can be used to optimize performance. +Virtual scrolling table without enabling virtual scrolling, used as a regular table. :::demo - + ::: ### Virtual Scrolling + Dynamic Row Height -Supports dynamic calculation of row height, suitable for scenarios where row content height is not fixed. +When the table has a large amount of data, virtual scrolling can be used to optimize performance. Supports dynamic calculation of row height, suitable for scenarios where row content height is not fixed. :::demo - + ::: diff --git a/src/packages/table/doc.md b/src/packages/table/doc.md index 8503389794..ef1ae1388f 100644 --- a/src/packages/table/doc.md +++ b/src/packages/table/doc.md @@ -114,23 +114,23 @@ import { Table } from '@nutui/nutui-react' ::: -### 虚拟滚动 +### 不开启虚拟滚动 -当表格数据量较大时,可以使用虚拟滚动来优化性能。 +虚拟滚动表格不开启虚拟滚动,当普通表格使用。 :::demo - + ::: ### 虚拟滚动 + 动态行高 -支持动态计算行高,适用于行内容高度不固定的场景。 +当表格数据量较大时,可以使用虚拟滚动来优化性能。支持动态计算行高,适用于行内容高度不固定的场景。 :::demo - + :::