Laravel12+Vue+Inertia+useVueTable:preserveState开启时表格无响应问题
问题:Laravel 12 + Inertia + TanStack Vue Table 排序状态与数据响应性冲突
问题描述
使用Laravel 12 + Vue启动套件 + shadcn-vue开发基于Inertia的响应式数据表时,遇到以下矛盾:
- 关闭
preserveState:表格数据能正确响应Inertia返回的新数据,但表头的排序状态箭头会丢失 - 开启
preserveState: true:排序箭头显示正常,但表格数据不会更新(尽管Inertia请求已返回正确排序后的数据集)
当前实现代码
<script setup lang="ts"> import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import AppLayout from '@/layouts/AppLayout.vue'; import IndexLayout from '@/layouts/IndexLayout.vue'; import { valueUpdater } from '@/lib/utils'; import { BreadcrumbItem, Can, Pagination, Permission } from '@/types'; import { Head, router } from '@inertiajs/vue3'; import { ColumnDef, ColumnFiltersState, ExpandedState, FlexRender, getCoreRowModel, getFilteredRowModel, SortingState, useVueTable, } from '@tanstack/vue-table'; import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-vue-next'; import { h, ref } from 'vue'; import DropdownAction from './partials/DropdownAction.vue'; interface Props { can: Can; filters: object; permissions: Pagination<Permission>; } const props = defineProps<Props>(); // const model = defineModel<Props>(); const breadcrumbs: BreadcrumbItem[] = [ { title: 'Permisos', href: '/dashboard', }, ]; const columns: ColumnDef<Permission>[] = [ { id: 'select', header: ({ table }) => h(Checkbox, { modelValue: table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'), 'onUpdate:modelValue': (value: any) => table.toggleAllPageRowsSelected(!!value), ariaLabel: 'Select all', }), cell: ({ row }) => h(Checkbox, { modelValue: row.getIsSelected(), 'onUpdate:modelValue': (value: any) => row.toggleSelected(!!value), ariaLabel: 'Select row', }), enableSorting: false, enableHiding: false, }, { accessorKey: 'name', header: ({ column, table }) => { const isSorted = column.getIsSorted(); const isSortedDesc = column.getIsSorted() === 'desc'; return h(DropdownMenu, () => [ h(DropdownMenuTrigger, { asChild: true }, () => [ h(Button, { variant: 'outline', class: 'ml-auto' }, () => [ 'Nombre', isSorted ? isSortedDesc ? h(ChevronDown, { class: 'ml-2 h-4 w-4' }) : h(ChevronUp, { class: 'ml-2 h-4 w-4' }) : h(ChevronsUpDown, { class: 'ml-2 h-4 w-4' }), ]), ]), h(DropdownMenuContent, { align: 'start' }, () => [ h( DropdownMenuCheckboxItem, { key: `${column.id}Up`, checked: isSorted && !isSortedDesc, onSelect: () => column.toggleSorting(false) }, () => ['Ordenar ASC'], ), h( DropdownMenuCheckboxItem, { key: `${column.id}Down`, checked: isSorted && isSortedDesc, onSelect: () => column.toggleSorting(true) }, () => ['Ordenar DESC'], ), h(DropdownMenuCheckboxItem, { key: `${column.id}Clr`, checked: false, onSelect: () => table.setSorting(() => <SortingState>[]) }, () => [ 'Restablecer', ]), ]), ]); }, cell: ({ row }) => h('div', row.getValue('name')), }, { accessorKey: 'description', header: ({ column, table }) => { const isSorted = column.getIsSorted(); const isSortedDesc = column.getIsSorted() === 'desc'; return h(DropdownMenu, () => [ h(DropdownMenuTrigger, { asChild: true }, () => [ h(Button, { variant: 'outline', class: 'ml-auto' }, () => [ 'Descripción', isSorted ? isSortedDesc ? h(ChevronDown, { class: 'ml-2 h-4 w-4' }) : h(ChevronUp, { class: 'ml-2 h-4 w-4' }) : h(ChevronsUpDown, { class: 'ml-2 h-4 w-4' }), ]), ]), h(DropdownMenuContent, { align: 'start' }, () => [ h( DropdownMenuCheckboxItem, { key: `${column.id}Up`, checked: isSorted && !isSortedDesc, onSelect: () => column.toggleSorting(false) }, () => ['Ordenar ASC'], ), h( DropdownMenuCheckboxItem, { key: `${column.id}Down`, checked: isSorted && isSortedDesc, onSelect: () => column.toggleSorting(true) }, () => ['Ordenar DESC'], ), h(DropdownMenuCheckboxItem, { key: `${column.id}Clr`, checked: false, onSelect: () => table.setSorting(() => <SortingState>[]) }, () => [ 'Restablecer', ]), ]), ]); }, cell: ({ row }) => h('div', row.getValue('description')), }, { id: 'actions', enableHiding: false, cell: ({ row }) => { const permission = row.original; const can = props.can; return h(DropdownAction, { permission, can, onExpand: row.toggleExpanded, }); }, }, ]; const sorting = ref<SortingState>([]); const columnFilters = ref<ColumnFiltersState>([]); const globalFilter = ref(''); const rowSelection = ref({}); const expanded = ref<ExpandedState>({}); function handleSortingChange(item: any) { if (typeof item === 'function') { const sortValue = item(); const sortBy = sortValue[0]?.id ? sortValue[0].id : '' ; const sortDirection = sortBy ? sortValue[0]?.desc ? 'desc' : 'asc' : ''; const data: {[index: string]: any} = {}; data[sortBy] = sortDirection; router.visit(route('permissions.index'), { data, only: ['permissions'], preserveScroll: true, preserveState: true, onSuccess: (page) => { console.log(page); sorting.value = sortValue; // model.value = page.props.permissions.data; } }); } } const table = useVueTable({ data: props.permissions.data, columns, getCoreRowModel: getCoreRowModel(), manualPagination: true, manualSorting: true, enableSortingRemoval: true, pageCount: props.permissions.per_page, getFilteredRowModel: getFilteredRowModel(), onSortingChange: (updaterOrValue) => handleSortingChange(updaterOrValue), onColumnFiltersChange: (updaterOrValue) => valueUpdater(updaterOrValue, columnFilters), onGlobalFilterChange: (updaterOrValue) => valueUpdater(updaterOrValue, globalFilter), onRowSelectionChange: (updaterOrValue) => valueUpdater(updaterOrValue, rowSelection), state: { get sorting() { return sorting.value; }, get columnFilters() { return columnFilters.value; }, get globalFilter() { return globalFilter.value; }, get rowSelection() { return rowSelection.value; }, get expanded() { return expanded.value; }, }, }); </script> <template> <AppLayout :breadcrumbs="breadcrumbs"> <Head title="Permisos" /> <IndexLayout title="Permisos"> <div class="w-full"> <div class="flex items-center py-4"> <Input class="max-w-sm" placeholder="Filter emails..." :model-value="globalFilter ?? ''" @update:model-value="(value) => (globalFilter = String(value))" /> <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="outline" class="ml-auto"> Columns <ChevronDown class="ml-2 h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuCheckboxItem v-for="column in table.getAllColumns().filter((column) => column.getCanHide())" :key="column.id" class="capitalize" :model-value="column.getIsVisible()" @update:model-value=" (value: any) => { column.toggleVisibility(!!value); } " > {{ column.id }} </DropdownMenuCheckboxItem> </DropdownMenuContent> </DropdownMenu> </div> <div class="rounded-md border"> <Table> <TableHeader> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id"> <TableHead v-for="header in headerGroup.headers" :key="header.id"> <FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" /> </TableHead> </TableRow> </TableHeader> <TableBody> <template v-if="table.getRowModel().rows?.length"> <template v-for="row in table.getRowModel().rows" :key="row.id"> <TableRow :data-state="row.getIsSelected() && 'selected'"> <TableCell v-for="cell in row.getVisibleCells()" :key="cell.id"> <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" /> </TableCell> </TableRow> <TableRow v-if="row.getIsExpanded()"> <TableCell :colspan="row.getAllCells().length"> {{ JSON.stringify(row.original) }} </TableCell> </TableRow> </template> </template> <TableRow v-else> <TableCell :colspan="columns.length" class="h-24 text-center"> No results. </TableCell> </TableRow> </TableBody> </Table> </div> <div class="flex items-center justify-end space-x-2 py-4"> <div class="flex-1 text-sm text-muted-foreground"> {{ table.getFilteredSelectedRowModel().rows.length }} de {{ table.getFilteredRowModel().rows.length }} fila(s) seleccionadas. </div> <div class="space-x-2"> <Button variant="outline" size="sm" :disabled="!table.getCanPreviousPage()" @click="table.previousPage()"> Anterior </Button> <Button variant="outline" size="sm" :disabled="!table.getCanNextPage()" @click="table.nextPage()"> Siguiente </Button> </div> </div> </div> </IndexLayout> </AppLayout> </template>
解决方案
核心问题在于开启preserveState后,组件没有正确响应Inertia更新后的props数据,同时排序状态需要保留。以下是修复步骤:
1. 替换props为defineModel实现双向绑定
Vue 3.4+的defineModel可以自动处理props的响应式更新,替换原来的defineProps:
// 移除原来的props定义 // const props = defineProps<Props>(); const model = defineModel<Props>();
2. 更新表格数据来源
将table实例的data指向model.value.permissions.data,确保数据更新时表格能感知到:
const table = useVueTable({ // 替换props为model.value data: model.value.permissions.data, columns, getCoreRowModel: getCoreRowModel(), manualPagination: true, manualSorting: true, enableSortingRemoval: true, // 更新pageCount为model.value的数据 pageCount: model.value.permissions.per_page, getFilteredRowModel: getFilteredRowModel(), onSortingChange: (updaterOrValue) => handleSortingChange(updaterOrValue), onColumnFiltersChange: (updaterOrValue) => valueUpdater(updaterOrValue, columnFilters), onGlobalFilterChange: (updaterOrValue) => valueUpdater(updaterOrValue, globalFilter), onRowSelectionChange: (updaterOrValue) => valueUpdater(updaterOrValue, rowSelection), state: { get sorting() { return sorting.value; }, get columnFilters() { return columnFilters.value; }, get globalFilter() { return globalFilter.value; }, get rowSelection() { return rowSelection.value; }, get expanded() { return expanded.value; }, }, });
3. 优化handleSortingChange逻辑
移除onSuccess中手动设置sorting.value的代码,因为preserveState已经保留了前端排序状态,同时确保所有使用props的地方替换为model.value:
function handleSortingChange(item: any) { if (typeof item === 'function') { const sortValue = item(); const sortBy = sortValue[0]?.id ? sortValue[0].id : '' ; const sortDirection = sortBy ? sortValue[0]?.desc ? 'desc' : 'asc' : ''; const data: {[index: string]: any} = {}; data[sortBy] = sortDirection; router.visit(route('permissions.index'), { data, only: ['permissions'], preserveScroll: true, preserveState: true, onSuccess: (page) => { console.log(page); // 移除手动设置sorting的代码 // sorting.value = sortValue; // 不需要手动更新model,defineModel会自动同步props } }); } }
4. 替换模板中所有props引用
将模板里的props.can、props.permissions等替换为model.value.can、model.value.permissions,比如:
<!-- 示例:替换DropdownAction的can属性 --> <DropdownAction :permission="permission" :can="model.value.can" :onExpand="row.toggleExpanded" />
5. 确保表格响应数据变化
由于model.value是响应式的,当Inertia更新props后,model.value.permissions.data会自动更新,表格会重新渲染数据。同时preserveState: true保留了排序状态,表头箭头不会丢失。
关键原理
preserveState: true保留了前端组件的状态(包括排序状态),避免页面刷新导致箭头丢失defineModel实现了props的双向绑定,确保Inertia返回的新数据能自动同步到组件中,让表格响应式更新
内容的提问来源于stack exchange,提问作者Dovahkiin
相关产品推荐
相关产品推荐

