You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 20:25:53