news 2026/9/21 19:12:28

Handsontable 服务端数据实战:用 Django REST Framework 实现分页、排序、过滤与批量 CRUD 数据网格

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Handsontable 服务端数据实战:用 Django REST Framework 实现分页、排序、过滤与批量 CRUD 数据网格
  • 前端
  • UI组件

【免费下载链接】handsontable

JavaScript 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插件接入 Django REST Framework(DRF)后端:分页、排序、过滤全部在服务端完成,前端网格展示结果并把所有编辑操作回写 API。读者将掌握dataProviderfetchRows/onRowsCreate/onRowsUpdate/onRowsRemove完整协议、DRF 端的分页形状映射与批量@action设计,以及 Django CSRF 在前端 fetch 场景下的透明处理方案。

难度:中级 | 预计耗时:约 30 分钟 | 后端环境:Python 3.11+、Django 4+、Django REST Framework 3.14+ 本教程对应的完整可运行示例位于仓库的 docs/content/recipes/data-management/server-side-django 目录下(server/为 Django 后端源码,javascript/react/angular/为各框架前端示例)。

构建目标:一个员工通讯录网格

本教程最终交付一个"员工通讯录"数据网格,它具备以下能力:

  • 从 DRF API 逐页加载数据(分页在服务端完成)
  • 点击任意列头,在服务端按该列排序
  • 按列值在服务端过滤行
  • 通过批量 API 端点创建、更新、删除行
  • 透明处理 Django 的 CSRF 防护

为什么需要服务端数据模式

当数据量达到数万甚至数十万行时,把全部数据一次性塞进浏览器既不现实也不高效。Handsontable 的dataProvider插件正是为此设计:它把网格的"数据源"从本地数组切换为外部服务端。

从源码结构看,dataProvider 插件 的核心约定是:

  • rowId指定标识行的字段名;
  • fetchRows(queryParameters, options)负责拉取{ page, pageSize, sort, filters }对应的数据,返回{ rows, totalRows }
  • onRowsCreate/onRowsUpdate/onRowsRemove三个回调把网格上的增、改、删操作转发给服务端。

dataProvider配置为完整的服务端配置(上述键全部存在且合法)时,Handsontable 通过fetchRows加载行、通过回调执行变更,并且内部钩子hasExternalDataSource返回true,使 Filters、Pagination 等插件把网格当作服务端驱动模式处理(见 dataProvider.ts)。此外,fetchRows收到的AbortSignal会传递给底层fetch(),从而在用户连续排序/过滤时取消过期请求(见 dataProvider.ts)。

本教程把 Django 作为这个服务端数据源,走通完整链路。

架构总览

浏览器 (Vite dev server, :5173) │ /api/* 由 Vite 代理转发 ▼ Django REST Framework (:8000,运行在 Docker 内) │ ▼ PostgreSQL(Docker 容器)
  • 前端:Vite 开发服务器 + Handsontable +dataProvider插件
  • 后端:Django + DRF + PostgreSQL,全部跑在 Docker Compose 中
  • 开发期通过 Vite 代理/api/*http://localhost:8000,浏览器与 Django 同源,CSRF Cookie 无需额外 CORS 配置即可访问

开始之前

需要安装:

  • Docker 与 Docker Compose
  • Node.js 与 npm

无需本地 Python 环境——Django 后端和 PostgreSQL 数据库都运行在 Docker 容器内。

Step 1 — 搭建 Django 应用

运行bash setup.sh(或make setup)即可完成:脚本通过 Docker Compose 构建并启动 PostgreSQL 与 Django、执行数据库迁移、写入 50 条员工种子数据,并在http://localhost:5173打开 Vite 开发服务器。

Django 项目使用一个独立的employees应用来承载员工模块。

为什么单独建一个 app?Django 的 app 是自包含的模块。把员工模型、序列化器和视图集中在一个 app 内,便于独立扩展与测试。

settings.py中注册 app 与所需第三方包(完整片段见 server/settings-snippet.py):

# settings.py -- 只列出与本教程相关的增量配置 INSTALLED_APPS = [ # ... 你已有的 apps ... "rest_framework", "corsheaders", # django-cors-headers "django_filters", # django-filter(可选,用于 DjangoFilterBackend) "employees", # 本项目 app ] MIDDLEWARE = [ # CorsMiddleware 必须放在 CommonMiddleware 之前 "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", # ... 其余 middleware ... ] # --- CORS --- # 允许来自前端开发服务器的请求。 # 生产环境请限制为你的真实域名。 CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", # Vite dev server "http://localhost:3000", # Create React App / Next.js dev server "https://your-production-domain.com", ] # 如果前端需要携带凭证(Cookie、Authorization 头),还要设置: # CORS_ALLOW_CREDENTIALS = True # --- Django REST Framework --- REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": "employees.pagination.EmployeePagination", "PAGE_SIZE": 10, "DEFAULT_FILTER_BACKENDS": [ "rest_framework.filters.OrderingFilter", "rest_framework.filters.SearchFilter", # 如需按字段精确匹配可启用 DjangoFilterBackend: # "django_filters.rest_framework.DjangoFilterBackend", ], }

Step 2 — 定义 Employee 模型

employees/models.py中创建模型(完整代码见 server/models.py):

from django.db import models class Employee(models.Model): """员工通讯录演示模型。 字段与 Handsontable 前端的列定义一一对应: id 由 Django 自动生成,用作前端 rowId。 """ DEPARTMENT_CHOICES = [ ("Engineering", "Engineering"), ("Marketing", "Marketing"), ("Sales", "Sales"), ("HR", "HR"), ("Finance", "Finance"), ("Operations", "Operations"), ] first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) department = models.CharField(max_length=100, choices=DEPARTMENT_CHOICES) role = models.CharField(max_length=100) # DecimalField 避免货币金额的浮点舍入误差 salary = models.DecimalField(max_digits=10, decimal_places=2) class Meta: # 默认按姓氏排序,保证网格首次展示顺序可预期 ordering = ["last_name", "first_name"] def __str__(self): return f"{self.first_name} {self.last_name} ({self.department})"

设计要点:

  • DecimalField以定点数存储薪资,避免浮点数舍入误差——这对货币值至关重要;
  • Meta.ordering设置默认查询顺序,用户点击列头排序时由视图中的排序逻辑覆盖(详见 Step 6);
  • Django 自动添加id主键,它将成为前端rowId的值。

创建数据库表,执行迁移:

python manage.py makemigrations employees python manage.py migrate

Step 3 — 填充种子数据

创建种子命令文件employees/management/commands/seed.py(完整代码见 server/seed_command.py)。该文件位于标准的 management 命令目录结构中:

employees/management/__init__.py (空文件) employees/management/commands/__init__.py (空文件) employees/management/commands/seed.py (本命令)

运行:

python manage.py seed

命令会插入 50 条贴近现实的员工记录(覆盖 Engineering、Marketing、Sales、HR、Finance、Operations 六个部门)。它会在插入前检查Employee.objects.exists(),因此重复运行不会产生重复数据——已存在数据时输出 "Database already seeded -- skipping." 并直接返回。

Step 4 — 编写序列化器

创建employees/serializers.py(完整代码见 server/serializers.py):

from rest_framework import serializers from .models import Employee class EmployeeSerializer(serializers.ModelSerializer): """Employee 模型的序列化器。 ModelSerializer 会自动从模型定义生成字段与校验规则, 无需手动声明字段。`id` 字段只读,作为前端 rowId。 """ class Meta: model = Employee fields = ["id", "first_name", "last_name", "department", "role", "salary"] read_only_fields = ["id"]

设计要点:

  • ModelSerializer自动检查模型并生成字段定义与校验规则;
  • id为只读:数据库负责分配主键,前端新建行时无需也不允许提交id
  • fields列表决定了 API 响应中出现哪些列,也就决定了 Handsontable 接收哪些列——前后端列集合由此保持一致。

Step 5 — 配置分页

创建employees/pagination.py(完整代码见 server/pagination.py):

from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response class EmployeePagination(PageNumberPagination): page_size = 10 page_size_query_param = "pageSize" # 与 Handsontable 默认参数名一致 max_page_size = 100 def get_paginated_response(self, data): """返回 dataProvider 期望的响应形状:{ rows: [...], totalRows: N }""" return Response( { "rows": data, "totalRows": self.page.paginator.count, } ) def get_paginated_response_schema(self, schema): return { "type": "object", "properties": { "totalRows": {"type": "integer"}, "rows": schema, }, }

为什么要自定义分页类?

DRF 默认的响应形状是{ count, next, previous, results },而 Handsontable 的dataProvider期望{ rows, totalRows }。通过重写get_paginated_response在服务端完成形状映射,前端fetchRows回调里就可以直接return res.json(),无需任何额外转换。

为什么设置page_size_query_param = 'pageSize'

Handsontable 会自动发送?pageSize=10。DRF 默认的查询参数名是page_size,将其改为pageSize后 DRF 可以直接读取 Handsontable 发出的值,fetchRows中就不需要做任何 URL 参数翻译。

Step 6 — 编写 ViewSet

创建employees/views.py,这是整个后端最核心的部分(完整代码见 server/views.py)。关键包括排序翻译、过滤翻译和三个批量 CRUD 动作。

排序翻译

Handsontable 发送?sort[prop]=salary&sort[order]=desc。在get_queryset中完成翻译:

sort_prop = self.request.query_params.get('sort[prop]') sort_order = self.request.query_params.get('sort[order]', 'asc') if sort_prop and sort_prop in ALLOWED_ORDERING_FIELDS: prefix = '' if sort_order == 'asc' else '-' queryset = queryset.order_by(f'{prefix}{sort_prop}')

原理拆解:

  1. 读取 Handsontable 的sort[prop]sort[order]参数;
  2. 降序时在字段名前加-前缀(Django ORM 约定);
  3. 直接调用queryset.order_by()ALLOWED_ORDERING_FIELDS是一个白名单集合({'first_name', 'last_name', 'department', 'role', 'salary'}),只有白名单内的字段才允许进入order_by,从而阻止通过任意字段名进行的 ORM 注入。

过滤翻译

dataProvider把过滤条件编码为单个filters查询参数,其中是 JSON 数组,每个元素是一个DataProviderFilterColumn对象:

[ { "prop": "department", "operation": "conjunction", "conditions": [{ "name": "eq", "args": ["Engineering"] }] } ]

后端用json.loads()解码并构造 DjangoQ对象。从源码看,完整翻译逻辑如下(server/views.py):

filters_json = self.request.query_params.get('filters') if filters_json: try: filter_cols = json.loads(filters_json) q = Q() for col in filter_cols: prop = col.get('prop', '') operation = col.get('operation', 'conjunction') conditions = col.get('conditions') or [] if prop not in ALLOWED_ORDERING_FIELDS: continue # ... 逐个条件构造 Q 对象 ... # 列内条件按 operation(conjunction=AND / disjunction=OR)组合 # 列与列之间始终用 AND 组合 queryset = queryset.filter(q) except (json.JSONDecodeError, TypeError, KeyError): pass

原理拆解:

  • json.loads(filters_json)把单个filters参数解码为列过滤对象列表;
  • 每个列对象包含propoperationconjunction= AND,disjunction= OR)和conditions列表;
  • 同一列内的条件按operation组合,不同列之间始终以 AND 组合;
  • eq/neq:数值字段(salary)用exact(DecimalField 拒绝iexact),文本字段用iexact
  • empty/not_empty:文本字段同时处理null与空字符串两种情况,数值字段则用isnull判断;
  • prop同样经过ALLOWED_ORDERING_FIELDS白名单校验,防止 ORM 注入。

源码中还定义了一张条件名到 Django ORM 查找后缀的映射表(server/views.py),供过滤翻译复用:

Handsontable 条件名Django ORM 查找是否取反
containsicontains
not_containsicontains
begins_withistartswith
ends_withiendswith
gtegte
ltelte
gtgt
ltlt

批量 CRUD 端点

标准 REST 约定是单资源端点(POST /employees/DELETE /employees/{id}/),但 Handsontable 的dataProvider会把所有变更以数组形式放进一次请求。DRF 的@action装饰器可以优雅地解决这个问题,无需手工编写额外 URL 路由。三个动作的完整实现见 server/views.py:

@action(detail=False, methods=['post'], url_path='create-rows') @transaction.atomic def create_rows(self, request): """POST /api/employees/create-rows/ 载荷形状:{ rowsAmount: 2 }""" rows_amount = max(1, int(request.data.get('rowsAmount') or 1)) employees = Employee.objects.bulk_create([ Employee(first_name='', last_name='', department='', role='', salary=0) for _ in range(rows_amount) ]) serializer = EmployeeSerializer(employees, many=True) return Response(serializer.data, status=201) @action(detail=False, methods=['patch'], url_path='update-rows') @transaction.atomic def update_rows(self, request): """PATCH /api/employees/update-rows/ 载荷形状:[{ id: 7, changes: { salary: 102000 } }, ...]""" updated = [] for row in request.data: employee = Employee.objects.get(pk=row['id']) serializer = EmployeeSerializer(employee, data=row['changes'], partial=True) serializer.is_valid(raise_exception=True) serializer.save() updated.append(serializer.data) return Response(updated) @action(detail=False, methods=['delete'], url_path='remove-rows') def remove_rows(self, request): """DELETE /api/employees/remove-rows/ 载荷形状:[3, 7, 14]""" deleted_count, _ = Employee.objects.filter(pk__in=request.data).delete() return Response({'deleted': deleted_count})

原理拆解:

  • detail=False把动作注册在列表 URL(/api/employees/)下,而不是详情 URL(/api/employees/{id}/);
  • create_rows从请求读取rowsAmount,用bulk_create一条 SQL 语句中插入指定数量的空行,并把带新id的行返回给前端,让dataProvider更新内部行映射;
  • update_rowspartial=True允许只更新部分字段(row['changes']),不必提交全部字段;
  • filter(pk__in=ids).delete()用一条 SQL 语句删除多行;
  • 两个写动作都加了@transaction.atomic,保证批量操作的事务性。

为什么不逐行调用标准DELETE /api/employees/{id}/删除 N 行需要 N 次请求;一次批量请求更快,大幅减少网络往返。

Step 7 — 注册 URL

创建employees/urls.py(完整代码见 server/urls.py):

from django.urls import include, path from rest_framework.routers import DefaultRouter from .views import EmployeeViewSet router = DefaultRouter() router.register(r"employees", EmployeeViewSet, basename="employee") urlpatterns = [ path("api/", include(router.urls)), ]

DefaultRouter自动生成 ViewSet 的全部标准与自定义动作路由:

方法路径对应动作
GET/api/employees/list()(分页列表)
POST/api/employees/create()(单行创建;批量请用create-rows/
GET/api/employees/{id}/retrieve()
PUT / PATCH/api/employees/{id}/update()/partial_update()
DELETE/api/employees/{id}/destroy()
POST/api/employees/create-rows/create_rows(批量创建)
PATCH/api/employees/update-rows/update_rows(批量更新)
DELETE/api/employees/remove-rows/remove_rows(批量删除)

在项目根urls.py中引入:

from django.urls import include, path urlpatterns = [ path('', include('employees.urls')), ]

浏览器访问http://localhost:8000/api/可以查看 DRF 自动生成的 API 路由清单。

Step 8 — 配置 CORS

浏览器默认会拦截跨域请求。需要安装django-cors-headers,并在settings.py中把它加入INSTALLED_APPSMIDDLEWARE(配置见 server/settings-snippet.py)。

为什么CorsMiddleware必须放在CommonMiddleware之前?CorsMiddleware需要在 Django 的路由逻辑处理预检OPTIONS请求之前拦截它。如果放在CommonMiddleware之后,预检响应可能缺失 CORS 头,导致浏览器拒绝跨域请求。

生产环境注意事项:把开发服务器的来源替换为真实生产域名。绝不要在生产环境设置CORS_ALLOW_ALL_ORIGINS = True

Step 9 — 前端处理 CSRF

Django 会保护所有变更类端点。它在每个响应中设置csrftokencookie,前端需要读取该 token 并在每次POSTPATCHDELETE请求中携带X-CSRFToken请求头,否则 Django 返回 403 Forbidden。getCsrfToken的实现见 Step 11 的代码文件:

function getCsrfToken() { return document.cookie .split('; ') .find((row) => row.startsWith('csrftoken=')) ?.split('=')[1]; }

为什么用 cookie 而不是隐藏表单字段?Handsontable 使用fetch()而非 HTML 表单提交。从 cookie 读取 token(SameSite + CSRF 双提交模式)对任何 JavaScript HTTP 客户端都通用,无需修改服务端模板。

Step 10 — 构建 fetchRows 的 URL

dataProvider会以{ page, pageSize, sort, filters }对象调用fetchRowsbuildUrl把它转换为 DRF 能理解的查询串(完整实现见 javascript/example1.js):

const API_BASE = '/api/employees/'; function buildUrl({ page, pageSize, sort, filters }) { const params = new URLSearchParams(); params.set('page', page); params.set('pageSize', pageSize); if (sort?.prop) { params.set('sort[prop]', sort.prop); params.set('sort[order]', sort.order ?? 'asc'); } // 把完整过滤载荷编码为 JSON 字符串,Django 用 json.loads() 解析嵌套 conditions if (filters?.length) { params.set('filters', JSON.stringify(filters)); } return `${API_BASE}?${params.toString()}`; }

原理拆解:

  • pagepageSize原样发送。因为 Step 5 中设置了page_size_query_param = 'pageSize',DRF 直接读取pageSize
  • sort拆分为sort[prop]sort[order],Django 视图读取两个参数并直接调用queryset.order_by()(见 Step 6);
  • filtersJSON.stringify序列化。dataProvider传递完整的DataProviderFilterColumn数组(含operation和嵌套的conditions),Django 用json.loads()解码;
  • API_BASE使用相对路径(/api/employees/)。开发环境下 Vite 把/api/*代理到http://localhost:8000,浏览器与 Django 同源,CSRF cookie 无需额外 CORS 配置即可访问。

Step 11 — 初始化 Handsontable

后端与 Vite 开发服务器运行后(bash setup.sh),打开http://localhost:5173即可看到网格。Django API 运行在 Docker 内的http://localhost:8000,Vite 把所有/api/*请求代理过去。完整前端代码见:

  • JavaScript: javascript/example1.js
  • TypeScript: javascript/example1.ts
  • React: react/example1.jsx
  • Angular: angular/example1.ts + angular/example1.html

核心初始化代码(JavaScript 版,其他框架封装方式略有差异但dataProvider配置一致):

import Handsontable from 'handsontable/base'; import { registerPlugin, DataProvider, ContextMenu, Dialog, DropdownMenu, Filters, ColumnSorting, Pagination, EmptyDataState, Notification, } from 'handsontable/plugins'; import { registerAllCellTypes } from 'handsontable/cellTypes'; registerPlugin(DataProvider); registerPlugin(ContextMenu); registerPlugin(Dialog); registerPlugin(DropdownMenu); registerPlugin(Filters); registerPlugin(ColumnSorting); registerPlugin(Pagination); registerPlugin(EmptyDataState); registerPlugin(Notification); registerAllCellTypes(); const container = document.querySelector('#example1'); let removeConfirmed = false; const hot = new Handsontable(container, { dataProvider: { rowId: 'id', fetchRows: async ({ page, pageSize, sort, filters }, { signal }) => { const url = buildUrl({ page, pageSize, sort, filters }); const res = await fetch(url, { signal }); if (!res.ok) { throw new Error(`Fetch failed: ${res.status}`); } // pagination.py 已把 DRF 的 { count, results } 映射为 // { rows, totalRows },因此可以直接返回 res.json() return res.json(); }, onRowsCreate: async ({ rowsAmount }) => { const res = await fetch(`${API_BASE}create-rows/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify({ rowsAmount }), }); if (!res.ok) throw new Error(`Create failed: ${res.status}`); const data = await res.json(); return data; // 返回服务端分配的新行 id,供 dataProvider 更新内部行映射 }, onRowsUpdate: async (rows) => { const res = await fetch(`${API_BASE}update-rows/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify(rows), }); if (!res.ok) throw new Error(`Update failed: ${res.status}`); }, onRowsRemove: async (rowIds) => { const res = await fetch(`${API_BASE}remove-rows/`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(), }, body: JSON.stringify(rowIds), }); if (!res.ok) throw new Error(`Delete failed: ${res.status}`); }, }, beforeRowsMutation(operation, payload) { // 删除前弹出确认对话框;用户确认后重新发起删除 if (operation === 'remove' && !removeConfirmed) { const count = payload.rowsRemove.length; const notification = hot.getPlugin('notification'); const id = notification.showMessage({ variant: 'warning', title: 'Delete rows', message: `Delete ${count} row${count !== 1 ? 's' : ''}? This cannot be undone.`, duration: 0, actions: [ { label: 'Delete', type: 'primary', callback: () => { notification.hide(id); removeConfirmed = true; hot.getPlugin('dataProvider').removeRows(payload.rowsRemove).finally(() => { removeConfirmed = false; }); }, }, { label: 'Cancel', type: 'secondary', callback: () => notification.hide(id), }, ], }); return false; } }, pagination: { pageSize: 10 }, columnSorting: true, filters: true, dropdownMenu: ['filter_by_condition', 'filter_action_bar'], contextMenu: true, emptyDataState: true, notification: true, dialog: true, colHeaders: ['First Name', 'Last Name', 'Department', 'Role', 'Salary'], columns: [ { data: 'first_name', type: 'text' }, { data: 'last_name', type: 'text' }, { data: 'department', type: 'text' }, { data: 'role', type: 'text' }, { data: 'salary', type: 'numeric', numericFormat: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } }, ], rowHeaders: true, height: 400, width: '100%', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation', });

关键选项说明

选项作用
rowId: 'id'告诉dataProvider哪个字段标识一行,必须与序列化器字段名一致
fetchRows中的{ signal }AbortSignal传给fetch(),用户在上一响应返回前排序/过滤时可取消进行中的请求
onRowsCreatereturn res.json()返回服务端响应,dataProvider据此用服务端分配的id更新内部行映射
pagination: { pageSize: 10 }启用分页工具栏,dataProvider自动把当前页与每页条数传给fetchRows
columnSorting: true启用列头点击排序,排序状态变化时传给fetchRows
filters: true启用列过滤 UI,生效条件变化时传给fetchRows
emptyDataState: truefetchRows返回零行时(例如过滤无匹配)显示友好插画
notification: truefetchRows或变更回调抛错时自动弹出错误提示;拉取失败附带Refetch重试按钮
beforeRowsMutation在变更发送前拦截操作,此处用于弹出删除确认对话框,确认后重新发起删除
contextMenu: true启用右键菜单,包含 "Insert row above / below" 与 "Remove row"

完整工作流:一次请求如何走通

  1. 首次加载dataProvider调用fetchRows({ page: 1, pageSize: 10 }),视图返回前 10 行与总行数;
  2. 用户点击列头columnSorting更新排序状态,dataProvider携带sort: { prop: 'salary', order: 'desc' }再次调用fetchRows,Django 视图翻译为?ordering=-salary交给OrderingFilter
  3. 用户应用列过滤:过滤 UI 更新条件列表,dataProvider携带filters数组调用fetchRows,Django 视图用json.loads()解码filtersJSON 串,并为每列的conditions构造 DjangoQ对象;
  4. 用户翻到第 2 页dataProvider调用fetchRows({ page: 2, pageSize: 10, ... })
  5. 用户编辑单元格dataProvider收集该行所有变更单元格,调用onRowsUpdate发送[{ id: 7, salary: 102000 }]update-rows端点执行局部更新;
  6. 用户新增行dataProvider携带新行值调用onRowsCreatecreate-rows端点插入行并返回带id的行,dataProvider更新内部映射,后续编辑使用正确的 id;
  7. 用户删除行dataProvider携带所选行 id 调用onRowsRemoveremove-rows端点用一条 SQL 语句删除所有匹配行。

核心要点回顾

  • DRF 默认响应形状({ count, results })与dataProvider期望({ rows, totalRows })不同:在自定义分页类中重写get_paginated_response在服务端完成形状映射;
  • 设置page_size_query_param = 'pageSize',让 DRF 直接读取 Handsontable 的参数名;
  • get_queryset中读取sort[prop]sort[order]并直接调用queryset.order_by(),用ALLOWED_ORDERING_FIELDS白名单防止 ORM 注入;
  • filters参数作为 JSON 字符串接收并用json.loads()解码,每条是{ prop, operation, conditions: [{ name, args }] }——列内条件用 AND 或 OR 组合,列间始终用 AND;
  • 用 DRF@action端点实现批量 CRUD,而非单资源 REST 路由;
  • csrftokencookie 读取 Django CSRF token,并在所有变更请求中携带X-CSRFToken头;
  • CorsMiddleware放在CommonMiddleware之前,确保预检请求获得 CORS 头。

后续深入阅读

  • Server-side data 文档(dataProvider完整 API 参考)
  • Rows pagination 指南(行分页)
  • Column filter 指南(列过滤)
  • Rows sorting 指南(行排序)
  • 前端
  • UI组件

【免费下载链接】handsontable

JavaScript 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
点击查看免费下载

相关推荐

上一篇:AndPermission中的责任链模式:权限检查器设计
下一篇:告别因子选择困境:逐步回归与正则化的量化实战指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/21 19:12:14

3个方案对比:卡点视频生成技术图解原理

3个方案对比:卡点视频生成技术图解原理 别再去翻那几百页的官方文档了,真的,没人有那个耐心。想搞懂 卡点视频 怎么在代码里实现,盯着 FFmpeg 或者 MoviePy 的英文 API 看,眼睛都花了还是抓不住重点。这时候,你需要的是 图解原理 ,不是枯燥的文字堆砌。…

作者头像 李华
网站建设 2026/9/21 19:12:07

vstart下载避坑指南:3步搞定环境配置,告别报错焦虑

vstart下载避坑指南:3步搞定环境配置,告别报错焦虑 刚接触移动端开发或尝试配置本地调试环境时,你是不是也遇到过这种情况?终端里刷出一长串红色的 StackTrace,满屏的 NullPointerException 或者 Connection Refused…

作者头像 李华
网站建设 2026/9/21 19:12:04

一文搞懂360手机拦截:5步搞定开发环境配置

一文搞懂360手机拦截:5步搞定开发环境配置 刚写完几个 if-else 和 for 循环,觉得自己挺牛,结果一动手想搭个能跑起来的小项目,瞬间懵圈:依赖怎么装?端口冲突怎么解?报错红字满屏飞。这就是很多初学者的通病: 学会语法却不知怎么搭项目…

作者头像 李华
网站建设 2026/9/21 19:11:45

3招搞定jdwb高频考点 源码解析助你一次通过

3招搞定jdwb高频考点 源码解析助你一次通过 官方文档动辄几百页,读了一半就困,重点抓不住是常态。别慌,我把 jdwb 的核心逻辑拆碎了,结合 源码解析 给你划重点。咱们不背死书,只讲面试和考试里真正爱考的点。 考点梳理:别在无效知识上浪费时间 很多刚接触 jdwb…

作者头像 李华
网站建设 2026/9/21 19:11:45

空调内循环源码解析:3步搞定从教程到落地的实战项目

空调内循环源码解析:3步搞定从教程到落地的实战项目 看了一堆教程还是不会写项目,是不是觉得代码复制粘贴都跑不通? 别再死磕文档了,直接上手拆解真实场景的【空调内循环】逻辑。 这篇【源码解析】带你从零搭建一个可运行的状态机,彻底搞懂业务闭环。 项目目标与痛点直击…

作者头像 李华
网站建设 2026/9/21 19:11:40

面试必考异常心电图处理逻辑与实战项目避坑指南

面试必考异常心电图处理逻辑与实战项目避坑指南 学会语法却不知怎么搭项目,这是很多初学者最头疼的问题。 在医疗信息化或物联网设备开发的 实战项目 中, 异常心电图 数据的清洗与识别往往是核心难点。 面试官最爱问的不是基础语法,而是你如何处理这些充满噪声、干扰的脏数据。…

作者头像 李华