资讯动态

Handsontable + Spring Boot 3:用 dataProvider 插件实现服务端分页、排序与过滤

发布时间:2026/9/21 18:51:55 来源:尧图企业网站定制
前端UI组件【免费下载链接】handsontableJavaScript Data Grid / Data Table with a Spreadsheet Look Feel. Works with React, Angular, and Vue. Supported by the Handsontable team ⚡项目地址https://gitcode.com/gh_mirrors/ha/handsontable点击查看免费下载导读本文是一份完整的实战教程教你用 Handsontable 的dataProvider插件对接一个 Spring Boot 3 后端构建一个浏览器永远只加载当前页数据的产品目录数据网格。你将学会REST API 的分页参数如何与 Spring Data 的PageRequest互相换算、服务端排序与过滤如何实现、行级增删改如何持久化到 JPA 管理的数据库以及beforeRowsMutation、notification、emptyDataState等dataProvider配套能力如何让网格体验接近原生表格。读完你就能把这个前后端联调模式复用到自己的项目中。本教程对应的完整示例位于仓库的 docs/content/recipes/data-management/server-side-spring 目录前端示例在javascript/、react/、angular/子目录后端 Java 源码在server/子目录。本文所有结论均可对照该目录下的源码验证。难度中级耗时约 45 分钟技术栈Spring Boot 3.3、Spring Data JPA、PostgreSQL 16、Flyway、HandsontabledataProvider你将构建什么一个产品目录数据网格具备以下能力每次翻页都从 Spring Boot REST API 拉取对应页的数据排序和过滤在服务端完成——浏览器永远不会加载全量数据集通过专用端点创建、更新、删除行把 Handsontable 的1 起始页码转换为 Spring Data 的0 起始PageRequest把 Spring Data 的Page响应映射为 Handsontable 期望的{ rows, totalRows }结构启动时向数据库播种 55 条产品数据开始前的准备已安装 Docker 与 Docker ComposeNode.js 18 或更高版本npm 9 或更高版本对 Spring Boot 与 JPA 有基本了解一个已具备dataProvider插件的 Handsontable 项目第 1 步创建 Spring Boot 项目用 Spring Initializr 生成一个包含所需依赖的新项目curl https://start.spring.io/starter.zip \ -d dependenciesweb,data-jpa,flyway,postgresql \ -d typemaven-project \ -d languagejava \ -d bootVersion3.3.5 \ -d javaVersion21 \ -d groupIdcom.example \ -d artifactIdproducts \ -d nameproducts \ -o products.zip unzip products.zip -d products或者在已有的pom.xml中添加以下依赖dependencies !-- REST endpoints -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- JPA Hibernate ORM -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency !-- PostgreSQL JDBC driver -- dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId scoperuntime/scope /dependency !-- Flyway -- manages schema migrations -- dependency groupIdorg.flywaydb/groupId artifactIdflyway-core/artifactId /dependency dependency groupIdorg.flywaydb/groupId artifactIdflyway-database-postgresql/artifactId /dependency /dependencies依赖说明spring-boot-starter-web提供内嵌 Tomcat 服务器与RestController支持。spring-boot-starter-data-jpa引入 Hibernate 与 Spring Data 仓库抽象。postgresql作用域为runtime——它只提供 JDBC 驱动编译期不需要。flyway-core与flyway-database-postgresql通过版本化 SQL 迁移文件管理建表而不是依赖 Hibernate 的ddl-auto。注意flyway-database-postgresql是 Flyway 6.5 之后针对 PostgreSQL 的独立模块官方驱动在该模块中。第 2 步配置数据库创建或更新src/main/resources/application.properties完整配置见 server/application.properties# PostgreSQL datasource -- override DATABASE_URL/DB_USERNAME/DB_PASSWORD via environment variables spring.datasource.url${DATABASE_URL:jdbc:postgresql://localhost:5432/products} spring.datasource.username${DB_USERNAME:postgres} spring.datasource.password${DB_PASSWORD:postgres} spring.datasource.driver-class-nameorg.postgresql.Driver # Let Hibernate validate the schema -- Flyway owns DDL creation. spring.jpa.database-platformorg.hibernate.dialect.PostgreSQLDialect spring.jpa.hibernate.ddl-autovalidate # Flyway runs migrations from src/main/resources/db/migration before the app starts. spring.flyway.enabledtrue spring.flyway.locationsclasspath:db/migration配置说明数据源 URL、用户名和密码从环境变量DATABASE_URL、DB_USERNAME、DB_PASSWORD读取并带有合理的本地默认值。在 Docker Compose 环境中会自动注入这些变量。ddl-autovalidate让 Hibernate 在启动时校验数据库 schema 与实体映射是否一致但从不修改数据库。所有 DDL 变更都由 Flyway 负责。flyway.enabledtrue让 Spring Boot 在应用上下文完成启动前先执行src/main/resources/db/migration下待执行的迁移。首次运行时V1__create_products_table.sql会创建products表。第 3 步创建 Product 实体完整实体见 server/Product.javaEntity Table(name products) public class Product { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false) private String name; Column(nullable false, unique true) private String sku; private String category; /** Stored with two decimal places -- matches the numeric column in Handsontable. */ Column(precision 10, scale 2) private BigDecimal price; private Integer stock; }要点Entity与Table(name products)告诉 JPA 把这个类映射到products表。Id与GeneratedValue(strategy GenerationType.IDENTITY)配置自增主键。生成的id值就是前端通过dataProvider.rowId: id引用的行标识。Column(nullable false)在name、sku上强制执行数据库级非空约束sku额外带有unique true保证 SKU 唯一后面createRows生成占位行时正是利用这一点生成 UUID 派生 SKU。Column(precision 10, scale 2)让price以两位小数存储与前端列定义里的numeric单元格类型对应前端还配套了locale: en-US与numericFormat见 javascript/example1.js。为什么实体要尽量精简每个字段都直接对应网格展示的一列。只添加网格需要的字段能让 API 响应体更小、映射代码更简洁。第 4 步添加仓库接口完整代码见 server/ProductRepository.javapublic interface ProductRepository extends JpaRepositoryProduct, Long, JpaSpecificationExecutorProduct { }要点JpaRepositoryProduct, Long提供save、findById、deleteAllById、count等方法——不用写任何 SQL 即可满足全部 CRUD 需求。JpaSpecificationExecutorProduct增加findAll(Specification, Pageable)重载。这是ProductService中应用服务端过滤的关键方法动态过滤条件被编译为 JPA 谓词predicate。第 5 步播种数据库完整代码见 server/DataInitializer.javaConfiguration public class DataInitializer { Bean CommandLineRunner seedDatabase(ProductRepository repository) { return args - { if (repository.count() 0) { repository.saveAll(List.of( product(Laptop Pro 15, SKU-0001, Electronics, new BigDecimal(1299.99), 45), product(Wireless Keyboard, SKU-0002, Accessories, new BigDecimal(89.99), 120), // ... 共 55 行 product(Anti-Glare Screen Wipes, SKU-0055, Accessories, new BigDecimal(6.99), 800) )); } }; } private Product product(String name, String sku, String category, BigDecimal price, int stock) { // 构造 Product 并 set 各字段 } }要点CommandLineRunner是 Spring Boot 回调在应用上下文启动后执行。用Bean方法返回它即自动注册。if (repository.count() 0)守卫防止测试期间该 bean 被多次执行时插入重复数据。repository.saveAll(List.of(...))一次性批量插入全部 55 行而不是执行 55 条独立 INSERT。为什么是 55 行默认pagination.pageSize是 1055 行正好构成 6 页让分页控件从第一次加载起就可见、有意义。第 6 步构建 Service 层完整代码见 server/ProductService.java。这是后端集成的核心负责在 Handsontable 的数据模型与 Spring Data 的抽象之间做翻译。页码换算Pageable pageable PageRequest.of(page - 1, pageSize, sort);Handsontable 发送的第一页是page: 1而 Spring Data 的PageRequest.of()期望 0 起始下标。减去 1 是唯一的换算点——其余代码全部使用 Spring 的模型。排序映射Sort.Direction direction desc.equalsIgnoreCase(sortOrder) ? Sort.Direction.DESC : Sort.Direction.ASC; return Sort.by(direction, sortProp);Handsontable 发送{ prop: price, order: desc }。Service 把order转换为Sort.Direction枚举并构造Sort对象。ALLOWED_COLUMNS白名单id, name, sku, category, price, stock会拒绝任何非已知列名的sortProp值从而防止 SQL 注入sortProp缺失或不在白名单时回退为Sort.by(ASC, id)默认排序。过滤条件反序列化ListMapString, Object filters objectMapper.readValue( filtersJson, new TypeReference() {} );Handsontable 把过滤条件作为单个查询参数里的 JSON 数组发送例如[{column:category,value:Electronics}]。Controller 以原始String接收Service 用 Jackson 的ObjectMapper反序列化。每个条目对匹配列生成一个LIKE谓词predicates.add(builder.like( builder.lower(root.get(column).as(String.class)), % value.toLowerCase() % ));注意过滤是大小写不敏感的列值与输入值都转小写并且同样受ALLOWED_COLUMNS白名单约束——不在白名单的列被静默忽略。若过滤 JSON 解析失败buildFilters捕获异常并返回Specification.where(null)即不过滤保证畸形输入不会击穿接口。完整的findAll调用链是findAll(page, pageSize, sortProp, sortOrder, filtersJson)→buildSort(...)→PageRequest.of(page-1, pageSize, sort)→buildFilters(...)→repository.findAll(spec, pageable)。响应结构映射response.put(rows, result.getContent()); response.put(totalRows, result.getTotalElements());Spring Data 的PageProduct包含content行列表、totalElements总数和分页元数据。Handsontable 只需要rows和totalRows所以 Service 只抽取这两个值丢弃其余元数据。事务边界类级Transactional注解让每个公开方法都包裹在单个数据库事务中。updateRows或removeRows内部任何一步抛异常整个操作自动回滚。findAll用Transactional(readOnly true)覆盖允许 Hibernate 在读取时跳过脏检查dirty-checking提升只读路径性能。三种变更操作createRows(CreateRowsPayload payload)按rowsAmount创建若干空行占位nameNew Product、由 UUID 派生的唯一 SKUSKU-NEW- UUID...、category为 Uncategorized、price与stock为 0最后saveAll批量落库。updateRows(ListUpdateRowPayload rows)对每个条目按id查找实体只更新changes映射里出现的列——例如用户只改了pricename/sku等字段保持不动。removeRows(ListLong rowIds)直接调用repository.deleteAllById(rowIds)执行单条批量 DELETE避免 N1 查询。第 7 步创建 REST Controller完整代码见 server/ProductController.java。RestController组合了Controller与ResponseBody所有方法返回值自动序列化为 JSON。RequestMapping(/api/products)是四个端点的统一基路径。GetMapping方法对可选参数使用RequestParam(required false)缺省参数时 Spring 返回null由 Service 的空值判断兜底page与pageSize则用defaultValue 1/10提供默认值。PostMapping、PatchMapping、DeleteMapping方法以RequestBody接收载荷返回200 OK无响应体。Handsontable 在变更类响应上只检查 HTTP 状态码是否非错误。端点一览HTTP 方法路径Handsontable 回调GET/api/productsfetchRowsPOST/api/products/create-rowsonRowsCreatePATCH/api/products/update-rowsonRowsUpdateDELETE/api/products/remove-rowsonRowsRemove两个请求 DTO 也很关键CreateRowsPayload.java 承载{ position: above|below, referenceRowId, rowsAmount }referenceRowId为null时表示在数据集末尾插入UpdateRowPayload.java 承载{ id, changes }数组其中changes只含用户改动过的列。第 8 步配置 CORS完整代码见 server/CorsConfig.javaConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry .addMapping(/api/**) .allowedOrigins(*) .allowedMethods(GET, POST, PATCH, DELETE); } }要点WebMvcConfigurer是 Spring MVC 回调接口实现addCorsMappings是全局配置 CORS 的惯用方式无需在每个 Controller 上加注解。allowedOrigins(*)对本教程的本地开发场景是安全的。生产环境必须把*替换为确切的前端源例如https://your-app.com防止跨站请求滥用。显式列出allowedMethods让 CORS 头保持最小化——只放行 Handsontable 回调会用到的四个 HTTP 方法。第 9 步对接 Handsontable用bash setup.sh或make setup启动后端与 Vite 开发服务器然后打开http://localhost:5173。后端在 Docker 内运行于http://localhost:8080Vite 把所有/api/*请求代理给后端因此浏览器端无需再做 CORS 配置。完整前端示例按框架分目录存放JavaScriptjavascript/example1.js另有 TypeScript 版本 javascript/example1.tsReactreact/example1.jsx另有 TSX 版本 react/example1.tsxAngularangular/example1.ts 与 angular/example1.html下面以 JavaScript 版本为例讲解关键配置网格初始化选项见 javascript/example1.jsconst hot new Handsontable(container, { columns: [ { data: id, title: ID, readOnly: true, width: 60 }, { data: name, title: Name, width: 200 }, { data: sku, title: SKU, width: 120 }, { data: category, title: Category, width: 130 }, { data: price, title: Price, type: numeric, locale: en-US, numericFormat: { minimumFractionDigits: 2, maximumFractionDigits: 2 }, width: 100, }, { data: stock, title: Stock, type: numeric, width: 80 }, ], colHeaders: true, rowHeaders: true, height: 450, width: 100%, columnSorting: true, filters: true, dropdownMenu: true, contextMenu: true, pagination: { pageSize: 10 }, emptyDataState: true, notification: true, dataProvider: { rowId: id, fetchRows: async ({ page, pageSize, sort, filters }, { signal }) { /* ... */ }, onRowsCreate: async (payload) { /* ... */ }, onRowsUpdate: async (rows) { /* ... */ }, onRowsRemove: async (rowIds) { /* ... */ }, }, beforeRowsMutation(operation, payload) { /* ... */ }, licenseKey: non-commercial-and-evaluation, });buildUrl辅助函数function buildUrl(base, params) { const url new URL(base, window.location.origin); for (const [key, value] of Object.entries(params)) { if (value ! undefined value ! null) { url.searchParams.set(key, String(value)); } } return url.toString(); }buildUrl为fetchRows组装查询串。它跳过undefined和null值这样可选参数sortProp、sortOrder、filters只在真正设置时才追加到 URL——如果直接把undefined传给URLSearchParams.set()会拼出字面量字符串undefined而不是省略该参数。fetchRows用户在翻页、排序或应用过滤条件时Handsontable 都会调用fetchRows。它负责把 Handsontable 的参数形态映射为 Spring Boot 的查询参数名sortProp、sortOrder。把filters数组序列化为 JSON 字符串——Controller 以String查询参数接收Service 再用 Jackson 反序列化。把AbortSignal传给fetch浏览器可取消在途请求例如用户快速连跳两页时前一个请求会被中止。响应非 ok 时抛出异常配合notification: true自动弹出错误提示。返回{ rows, totalRows }——Handsontable 用totalRows计算总页数。onRowsCreate、onRowsUpdate、onRowsRemoveonRowsCreate必须返回服务端创建的行数组含服务端分配的id值。Handsontable 用返回的行更新内部行映射保证后续更新、删除引用到正确的主键同时展示一条包含生成 ID 的 Row added 成功通知。示例代码里通过hot.getPlugin(notification).showMessage({ variant: success, title: Row added, message: Created: (id: N), duration: 3000 })实现。单元格编辑通过onRowsUpdate触发改动会立即乐观显示在网格中。发送给服务端的每个元素是{ id, changes }changes只包含用户修改过的列ProductService.updateRows()会精确应用这些改动。如果服务端返回非 2xx 或任意回调抛异常Handsontable 会回滚值并触发afterRowsMutationError钩子。onRowsRemove发送与dataProvider.rowId匹配的id数组。Controller 将其反序列化为ListLong并交给repository.deleteAllById()批量删除。beforeRowsMutationbeforeRowsMutation在任意 create/update/remove 操作前触发。返回false即取消该操作——onRowsRemove不会被调用服务端也不会删除任何行。由于beforeRowsMutation是同步的且严格检查 false返回值你不能在其中使用window.confirm()或任何异步对话框。替代方案是用notification.showMessage()的variant: warning配合两个 action 按钮。第一次尝试直接返回false取消用户点Delete后再通过hot.getPlugin(dataProvider).removeRows(rowsRemove)重新发起删除removeConfirmed标志让第二次调用直接放行、不再重复询问。完整实现见 javascript/example1.js 的beforeRowsMutation块。notification: true与emptyDataState: truenotification: true启用内置错误提示。fetchRows或变更回调抛错、或服务端返回非 2xx 时Handsontable 显示可关闭的错误消息抓取失败还会附带一个Refetch动作按钮点击后重新调用fetchRows。emptyDataState: true在当前过滤组合返回零行时显示占位提示而不是留白。contextMenu: true启用右键菜单提供 Insert row above / below 与 Remove row 条目。dataProvider插件本身在仓库 handsontable/src/plugins/dataProvider 下有完整实现与测试包括 fetchData.spec.js、createRows.spec.js、updateRows.spec.js、removeRows.spec.js 等单测以及 hooks/afterRowsMutationError.spec.js、hooks/beforeRowsMutation.spec.js 等钩子测试可作为理解各回调行为边界的参考。完整流程一次请求的生命周期首次加载Handsontable 以page: 1、pageSize: 10、无排序、无过滤调用fetchRows。服务端接收GET /api/products?page1pageSize10Service 换算PageRequest.of(0, 10, Sort.by(ASC, id))——页码减 1。Spring Data 查询SELECT * FROM products ORDER BY id ASC LIMIT 10。响应映射返回{ rows: [...10 条产品...], totalRows: 55 }给网格。用户按价格降序排序Handsontable 以sort: { prop: price, order: desc }调用fetchRows。服务端接收GET /api/products?page1pageSize10sortProppricesortOrderdescService 构建Sort.by(DESC, price)并创建新的PageRequest。用户应用过滤Handsontable 以filters: [{ column: category, value: Electronics }]调用fetchRows。服务端接收GET /api/products?...filters[{column:category,value:Electronics}]Service 反序列化Jackson 把 JSON 字符串解析为ListMapString, Object转成 JPALIKE %electronics%谓词。用户编辑单元格Handsontable 以[{ id: 4, changes: { price: 599.00 } }]调用onRowsUpdate。服务端接收PATCH /api/products/update-rows——Service 按 ID 找到产品只更新price字段。用户插入一行右键选择Insert row belowonRowsCreate携带{ position: below, referenceRowId: 4, rowsAmount: 1 }触发。Spring 创建空行并返回dataProvider更新内部行映射Handsontable 显示 Row added 成功通知。用户删除行选中两行后选择Remove rows。beforeRowsMutation拦截操作、返回false显示带Delete/Cancel按钮的警告通知点击Delete后onRowsRemove携带[4, 7]触发Spring 删除这两行。你学到了什么如何把 Handsontable 的 1 起始页码换算为 Spring Data 的 0 起始PageRequest.of(page - 1, pageSize, sort)。如何把 Spring Data 的PageT响应映射为dataProvider插件期望的{ rows, totalRows }结构。如何用列白名单防止通过sortProp查询参数进行 SQL 注入。如何用 JacksonObjectMapper从单个查询参数反序列化 Handsontable 的 JSON 过滤数组。如何用JpaSpecificationExecutor在不写原生 SQL 的情况下应用动态LIKE谓词。如何在读路径用Transactional(readOnly true)、写路径用Transactional界定正确的事务边界。如何用WebMvcConfigurer配置 CORS让浏览器从不同源访问 Spring Boot API。notification: true与emptyDataState: true如何在服务端缓慢或无结果时改善用户体验。后续步骤把 H2 换成持久化数据库PostgreSQL、MySQL在application.properties中更换数据源并把ddl-auto改为validate本教程的 application.properties 已按此模式配置。在 Controller DTO 上加Valid并定义 Bean Validation 约束如name上的NotBlank、price上的Positive让用户保存非法数据时返回结构化错误响应。用 Spring Security 保护 API变更类端点要求认证同时保持GET /api/products公开可访问。对比 Laravel 教程——同一个 Handsontable 前端对接 PHP 后端使用相同的端点形态。对比 Symfony 教程——同一个 Handsontable 前端对接 PHP/Symfony 后端使用相同的端点形态。对比 ASP.NET Core 教程——同一个 Handsontable 前端对接 .NET 后端用 EF Core 替代 Spring Data JPA。深入阅读 dataProvider 插件源码 与它的单元测试进一步了解分页、排序、过滤与变更钩子的底层实现相关测试还包括 plugins/pagination.spec.js、plugins/columnSorting.spec.js、plugins/filters.spec.js。赞分享前端UI组件【免费下载链接】handsontableJavaScript Data Grid / Data Table with a Spreadsheet Look Feel. Works with React, Angular, and Vue. Supported by the Handsontable team ⚡项目地址https://gitcode.com/gh_mirrors/ha/handsontable点击查看免费下载相关推荐10分钟搞定黑苹果配置OpCore Simplify图形化工具完全指南10分钟搞定黑苹果配置OpCore Simplify图形化工具完全指南 你是否曾经因为复杂的黑苹果配置而望而却步面对繁琐的OpenCore配置文件、硬件兼容前端UI组件freeCodeCamp 每日编程挑战实现 camelCase 转 snake_case 的函数Challenge 114 全解析freeCodeCamp 每日编程挑战实现 camelCase 转 snake_case 的函数Challenge 114 全解析 本文围绕 freeCo前端UI组件KaTrain围棋AI训练平台从智能对弈到棋力突破的完整指南KaTrain围棋AI训练平台从智能对弈到棋力突破的完整指南 想要通过人工智能技术系统性地提升围棋水平KaTrain作为基于KataGo引擎的专业训练平台前端UI组件上一篇Labelbox Python SDK v6.8.0 版本解析增强关系标注与目录切片功能下一篇终极指南如何为API添加自定义HTTP响应头创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价