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

Vue3表格点击client_txid二次排序触发insertBefore报错的原因与解决

问题

在Vue3+TypeScript项目中实现表格排序功能,点击client_txid表头可完成升序排序,但再次点击该表头切换排序方向时,控制台抛出错误:

Uncaught (in promise) TypeError: Cannot read properties of null (reading 'insertBefore')

完整代码如下:

<script setup lang="ts">
  import { ref, computed, watch } from 'vue'

  const data = ref([
    {
      header: {
        id_file: '11',
        id_client: '0',
        client_txid: '01TF 49608',
        spa_status: 'A',
      },
      try_log: [
        {
          hac_id: '184',
          exec_date: '2024-08-05T20:32:25.579Z',
          api_env: 'PRODUCCION_REAL',
        },
        {
          hac_id: '168',
          exec_date: '2024-08-05T18:54:57.535Z',
          api_env: 'PRODUCCION_REAL',
        },
      ],
    },
    {
      header: {
        id_file: '11',
        id_client: '0',
        client_txid: '01MN 177338',
        spa_status: 'TIMEOUT',
      },
      try_log: [
        {
          hac_id: '184',
          exec_date: '2024-08-05T20:32:25.579Z',
          api_env: 'PRODUCCION_REAL',
        },
        {
          hac_id: '168',
          exec_date: '2024-08-05T18:54:57.535Z',
          api_env: 'PRODUCCION_REAL',
        },
      ],
    },
    {
      header: {
        id_file: '11',
        id_client: '0',
        client_txid: '01MN 177338',
        spa_status: 'PAGADA',
      },
      try_log: [
        {
          hac_id: '167',
          exec_date: '2024-08-05T18:54:55.780Z',
          api_env: 'PRODUCCION_REAL',
        },
      ],
    },
    {
      header: {
        id_file: '500',
        id_client: '850',
        client_txid: 'TRX-VE4586-T3',
        spa_status: '',
      },
      try_log: [
        {
          hac_id: '166',
          exec_date: '2024-08-05T18:54:51.719Z',
          api_env: 'PRODUCCION_REAL',
        },
      ],
    },
  ])

  const page = ref(1)
  const itemsPerPage = ref(10)
  const sortBy = ref<string>('')
  const sortDirection = ref<string>('asc')
  const filteredData = ref(data.value)

  const filters = ref({
    search: '',
    id_file: '',
  })

  const normalizeValue = (value: any) => {
    if (typeof value === 'string') {
      return value.toLowerCase()
    }
    if (typeof value === 'number' || typeof value === 'boolean') {
      return value.toString()
    }
    return value
  }

  function sortTable(column: string) {

    if (sortBy.value === column) {
      sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
    } else {
      sortBy.value = column
      sortDirection.value = 'asc'
    }

    const modifier = sortDirection.value === 'asc' ? 1 : -1

    const sortedData = [...filteredData.value].sort((a, b) => {
      const aValue = normalizeValue(a.header[column] ?? ''); 
      const bValue = normalizeValue(b.header[column] ?? '');

      if (!aValue && !bValue) return 0; // Both null, no change in order
      if (!aValue) return 1; // a is null, move b before a (descending)
      if (!bValue) return -1; // b is null, move a before b (ascending)

      // Existing comparison logic
      if (aValue < bValue) return -1 * modifier;
      if (aValue > bValue) return 1 * modifier;

      return 0
    })

    filteredData.value = sortedData
  }

  watch(
    filters,
    () => {
      page.value = 1
      filteredData.value = data.value.filter((item) => {
        return Object.values(item.header).some((value) =>
          String(value).toLowerCase().includes(filters.value.search.toLowerCase())
        )
      })
    },
    { immediate: true }
  )

  const paginatedData = computed(() => {
    if (itemsPerPage.value === 0) return filteredData.value
    const start = (page.value - 1) * itemsPerPage.value
    const end = start + itemsPerPage.value
    return filteredData.value.slice(start, end)
  })


  watch(filters, () => {
    page.value = 1
  })

</script>

<template>
  <div>
    <div class="list-view-toolbar">
      <div class="search-field">
        <VField>
          <VControl
            icon="feather:search"
            style="margin: 0"
          >
            <input
              v-model="filters.search"
              class="input custom-text-filter"
              placeholder="Search..."
            >
          </VControl>
        </VField>
      </div>
    </div>
  </div>
  <div class="datatable-wrapper">
    <div
      class="table-container"
      style="overflow: auto"
    >
      <table class="table datatable-table is-fullwidth">
        <thead>
          <tr>
            <th
              class="col-estatus"
              align="center"
              @click="sortTable('id_client')"
            >
              id_client
              <span v-if="sortBy === 'id_client'">
                <i
                  v-if="sortDirection === 'asc'"
                  class="iconify"
                  data-icon="feather:chevron-up"
                  aria-hidden="true"
                />
                <i
                  v-else
                  class="iconify"
                  data-icon="feather:chevron-down"
                  aria-hidden="true"
                />
              </span>
            </th>
            <th
              class="col-estatus"
              align="center"
              @click="sortTable('client_txid')"
            >
              client_txid
              <span v-if="sortBy === 'client_txid'">
                <i
                  v-if="sortDirection === 'asc'"
                  class="iconify"
                  data-icon="feather:chevron-up"
                  aria-hidden="true"
                />
                <i
                  v-else
                  class="iconify"
                  data-icon="feather:chevron-down"
                  aria-hidden="true"
                />
              </span>
            </th>
            <th
              class="col-estatus"
              align="center"
              @click="sortTable('id_file')"
            >
              id_file
              <span v-if="sortBy === 'id_file'">
                <i
                  v-if="sortDirection === 'asc'"
                  class="iconify"
                  data-icon="feather:chevron-up"
                  aria-hidden="true"
                />
                <i
                  v-else
                  class="iconify"
                  data-icon="feather:chevron-down"
                  aria-hidden="true"
                />
              </span>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr
            v-for="(item, index) in paginatedData"
            :key="index"
          >
            <td
              class="col-id-cliente"
              align="center"
            >
              {{ item.header.id_client }}
            </td>
            <td
              class="col-id-beneficiario"
              align="center"
            >
              {{ item.header.client_txid }}
            </td>
            <td
              class="col-id-cliente"
              align="center"
            >
              {{ item.header.id_file }}
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>
报错原因
  • 行key使用索引引发DOM复用异常:表格行的:key绑定了index,排序后数据顺序改变,Vue会复用原有DOM节点,但索引对应的内容已变化,结合Iconify动态图标组件,DOM更新时会出现节点引用丢失,触发insertBefore的null错误。
  • 直接修改filteredData导致响应式更新冲突:每次排序直接替换filteredData.value,分页计算与排序操作的响应式更新队列冲突,引发DOM操作目标节点不存在的问题。
修复方法

1. 替换行key为唯一标识

将表格行的:key从index改为数据项的唯一组合值,确保Vue能准确识别每个行节点:

<tr
  v-for="(item, index) in paginatedData"
  :key="`${item.header.id_file}-${item.header.client_txid}`"
>

2. 用计算属性处理排序逻辑,避免直接修改filteredData

重构排序逻辑为计算属性,让响应式更新更稳定:

// 新增排序后的计算属性
const sortedAndFilteredData = computed(() => {
  let result = [...filteredData.value];
  if (sortBy.value) {
    const modifier = sortDirection.value === 'asc' ? 1 : -1;
    result.sort((a, b) => {
      const aValue = normalizeValue(a.header[sortBy.value] ?? ''); 
      const bValue = normalizeValue(b.header[sortBy.value] ?? '');

      if (!aValue && !bValue) return 0;
      if (!aValue) return 1;
      if (!bValue) return -1;

      if (aValue < bValue) return -1 * modifier;
      if (aValue > bValue) return 1 * modifier;
      return 0;
    });
  }
  return result;
});

// 修改分页计算属性依赖排序后的数据
const paginatedData = computed(() => {
  if (itemsPerPage.value === 0) return sortedAndFilteredData.value;
  const start = (page.value - 1) * itemsPerPage.value;
  const end = start + itemsPerPage.value;
  return sortedAndFilteredData.value.slice(start, end);
});

// 简化sortTable函数,仅更新排序状态
function sortTable(column: string) {
  if (sortBy.value === column) {
    sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
  } else {
    sortBy.value = column;
    sortDirection.value = 'asc';
  }
}

3. 合并重复的watch监听

删除重复的filters监听,保留一个即可:

watch(
  filters,
  () => {
    page.value = 1;
    filteredData.value = data.value.filter((item) => {
      return Object.values(item.header).some((value) =>
        String(value).toLowerCase().includes(filters.value.search.toLowerCase())
      );
    });
  },
  { immediate: true }
);

内容的提问来源于stack exchange,提问作者alex

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 09:27:02