一个界面设计,让你再也不用头疼table懒加载不能动态增删子节点!

587 阅读2分钟

接上一篇文章:

  1. element plus 使用细节

菜鸟最近和大佬在做的系统里面,有很多子结构的数据展示,大家肯定第一个想到的是 el-tree,但是这里的父子结构都是有表头的,所以只能使用el-table的树形数据了。但菜鸟和大佬都很头疼的点就在于 element plus 的 el-table 不能在使用懒加载的同时,对子节点里面的数据进行动态的增删。

el-table树形结构的痛点

1、子结构的表头和父结构不一样,那么有的就会空起来

image.png

为什么table懒加载不能手动增删子节点?

image.png

菜鸟点击删除后,有两种办法可以刷新(新增、编辑同理):

1、刷新整个界面 location.reload(),修改子节点就算请求父节点数据也不行,但是这样的话用户体验就不是很好,每次删除、编辑等,都需要重新展开界面!(父节点修改可以直接再次请求父节点数据即可,不用刷新界面)

2、只刷新子节点,但是这个是很难做到的

2这里 知道如何实现的jym,可以 指点江山,激昂文字

大佬的做法

我们公司大佬是在去看的el-table的源码知道了数据的展示结构,发现其数据是分为两个保存的,所以我们修改的不会影响到子节点!

如果想修改子节点,具体代码见

大佬的代码

// 格式化数据
const createProductType = (r) => {
  return {
    ...r,
    children: null,
    hasChildren: false,
    id: r.outsourceCompanyProductId
  };
};

// 新增子节点
const onHandleAddProductType = async (row) => {
  const { id: outsourceCompanyId, companyName } = row;
  const { value: productName, isValid = true } = await ElMessageBox.prompt(
    "请输入需要添加的产品类型名称",
    `为公司 ${companyName} 来添加产品类型`,
    {
      confirmButtonText: "确定",
      cancelButtonText: "取消",
      inputPattern: /[\u4e00-\u9fa5\w]{2,}/,
      inputErrorMessage: "产品类型名称需要至少2位"
    }
  ).catch(() => {
    // 省略了.then
    return { isValid: false };
  });

  if (!isValid) {
    return;
  }

  // 发送新增请求请求
  ……
  
  // 重新请求数据
  const {
    data: { records }
  } = await listOutsourceCompanyProduct({
    outsourceCompanyId,
    pageNum: 1,
    pageSize: 1e3,
    offShelf: 0
  });

  // 添加子节点重点代码
  const treeData = unref(table).store.states.treeData.value;
  //  对于lazy node 并且已经展开的情况下
  if (Reflect.has(treeData, row.id) && treeData[row.id].loaded) {
    if (treeData[row.id].children.length === 0) {
      //  对于清空掉了节点,之后重新添加的情况,需要重置加载状态
      treeData[row.id].expanded = false;
      treeData[row.id].lazy = true;
      treeData[row.id].loaded = false;
      unref(table).store.loadOrToggle(row);
      return;
    }
    const newest = createProductType(records.at(-1)); // 最新添加的在最后一个
    treeData[row.id].children.push(newest.id);
    unref(table).store.states.lazyTreeNodeMap.value[row.id].push(newest);
  } else {
    row.children = records.map(createProductType);
  }
};

// 删除子节点
const onHandleTakeOff = async (row) => {
  const { companyName, productName, outsourceCompanyId, outsourceCompanyProductId } = row;
  const isValid = await promise2bool(
    ElMessageBox.confirm(
      `确定要下架  ${companyName ? `${companyName} 外包公司` : `${productName} 产品`} ,下架后可以在历史外包公司信息中查看下架记录`,
      `下架${companyName ? "外包公司" : "产品"}`,
      {
        confirmButtonText: "确定下架",
        cancelButtonText: "考虑一下",
        type: "warning"
      }
    )
  );

  if (!isValid) {
    return;
  }

  try {
    if (companyName) {
      await updateOutsourceCompany({
        outsourceCompanyId,
        offShelf: 1
      });
    } else {
      await updateOutsourceCompanyProduct({
        outsourceCompanyProductId,
        offShelf: 1
      });
    }

    // 删除子节点主要代码
    const treeData = unref(table).store.states.treeData.value;
    if (companyName) {
      //  下架公司
      state.value.records = removeNodeFromForest(unref(state).records, row.id);
    } else {
      //  下架公司的产品
      const companyTypes = unref(table).store.states.lazyTreeNodeMap.value[row.outsourceCompanyId];
      if (!companyTypes) {
        state.value.records = removeNodeFromForest(
          unref(state).records,
          row.outsourceCompanyProductId,
          "outsourceCompanyProductId"
        );
        return;
      }

      const idx = companyTypes.findIndex((v) => v.id === row.id);
      companyTypes.splice(idx, 1);
      const treeDataIdx = treeData[row.outsourceCompanyId].children.indexOf(row.id);
      treeData[row.outsourceCompanyId].children.splice(treeDataIdx, 1);
    }
  } catch (e) {
    console.log(e);
  } finally {
    takenOffCompanyListDrawerRef.value.resetQuery();
  }

  //  remove node from forest -- 删除子节点主要代码
  function removeNodeFromForest(nodes, id, removeBy = "id") {
    const removeNode = (node) => {
      if (node[removeBy] === id) {
        return false;
      }
      if (node.children) {
        node.children = node.children.filter(removeNode);
      }
      return true;
    };
    return nodes.filter(removeNode);
  }
};

注意

  1. 大佬说了,这个不是正统的解法,因为直接修改了table的内部隐藏属性或者方法

  2. 这里的内部属性或者方法是可能发生变化的,不同的element plus版本,可能存的变量名不一样,所以这里只提供思路(读者如果发现这个不生效,可能是变量不一样了,需要自行寻找)—— 本代码适用 “大于等于2.10版本”

  3. 这个有点强关联了,只能使用js的弹窗方式,要是用组件的弹窗,就很难获取到row,需要自己缓存起来(见下方我的代码)!

我的代码

<script setup>
const queryParams = {
  pageNum: 1,
  pageSize: 10,
  queryFirstProductType: true
};

// 表单数据
let fristProductType = ref([]);
let total = ref(0);
// 获取一级
const getList = async () => {
  const res = await listproductType(queryParams);
  if (+res.code === 200) {
    fristProductType.value = res.data.records;
    total.value = res.data.total;
    // 循环给每个产品添加一个children属性 --》 这里是因为后端已经按照新的界面设计返回了,所以这里要自己加
    fristProductType.value.forEach((item) => {
      item.hasChildren = true;
    });
  }
};

const load = async (row, treeNode, resolve) => {
  resolve(await getSecondProductType(row.productTypeId));
};

// 获取二级
const getSecondProductType = async (parentId) => {
  const res = await listproductType({
    pageNum: 1,
    pageSize: 1e3,
    parentId
  });
  if (res.code === 200) {
    return res.data.records;
  }
};

onMounted(() => {
  getList();
});

// 新增
let tableEL = ref();
// 更新需要的id
let fristValue = ref("");
// 缓存点击的行
let clickRow = ref({});
const onHandleAddProduct = (row) => {
  fristValue.value = row.productTypeId;
  SecondShow.value = true;
  clickRow.value = row;
};

// 编辑
let FristaddShow = ref(false);
let SecondShow = ref(false);
let EditData = ref({});
const onHandleEditProduct = (row) => {
  if (!row.process) {
    FristaddShow.value = true;
  } else {
    SecondShow.value = true;
  }
  EditData.value = row;
};

// 关闭新增/编辑 -- bool: true 一级 false 二级
const addClose = async (bool = true) => {
  FristaddShow.value = false;
  SecondShow.value = false;
  EditData.value = {};
  if (bool) {
    // 父节点
    await getList();
  } else {
    const treeData = unref(tableEL).store.states.treeData.value;
    let records = await getSecondProductType(fristValue.value);
    //  对于lazy node 并且已经展开的情况下
    if (Reflect.has(treeData, fristValue.value) && treeData[fristValue.value].loaded) {
      if (treeData[fristValue.value].children.length === 0) {
        // 对于清空掉了节点,之后重新添加的情况,需要重置加载状态
        // 大佬试了很多属性,才发现是这些属性影响了加载
        treeData[fristValue.value].expanded = false;
        treeData[fristValue.value].lazy = true;
        treeData[fristValue.value].loaded = false;
        unref(tableEL).store.loadOrToggle(clickRow.value);
        return;
      }
      // 假设最新添加的在最后一个,取最后一个
      const newest = createProductType(records.at(-1));
      treeData[fristValue.value].children.push(newest.id);
      unref(tableEL).store.states.lazyTreeNodeMap.value[fristValue.value].push(newest);
    } else {
      // 未展开的情况
      clickRow.value.children = records.map(createProductType);
    }
  }
};

// 格式化数据
const createProductType = (r) => {
  return {
    ...r,
    children: null,
    hasChildren: false,
    id: r.outsourceCompanyProductId
  };
};

// 删除
const onHandleDeleteProduct = async (row) => {
  const res = await delproductType(row.productTypeId);
  if (+res.code === 200) {
    ElMessage({
      message: "删除成功",
      type: "success"
    });
  } else {
    ElMessage({
      message: res.message,
      type: "error"
    });
  }
  if (!row.process) {
    // 删除父节点
    await getList();
  } else {
    // 这里的xxxid需要修改为自己的
    const treeData = unref(tableEL).store.states.treeData.value;
    const companyTypes = unref(tableEL).store.states.lazyTreeNodeMap.value[row.parentId];

    fristProductType.value = removeNodeFromForest(
      fristProductType.value,
      row.productTypeId,
      "productTypeId"
    );

    const idx = companyTypes.findIndex((v) => v.productTypeId === row.productTypeId);
    companyTypes.splice(idx, 1);
    const treeDataIdx = treeData[row.parentId].children.indexOf(row.productTypeId);
    treeData[row.parentId].children.splice(treeDataIdx, 1);
  }
};

// 删除节点方法
const removeNodeFromForest = (nodes, id, removeBy = "id") => {
  const removeNode = (node) => {
    if (node[removeBy] === id) {
      return false;
    }
    if (node.children) {
      node.children = node.children.filter(removeNode);
    }
    return true;
  };
  return nodes.filter(removeNode);
};
</script>

<template>
  <el-button @click="FristaddShow = true">新增一级分类</el-button>
  <el-table
    ref="tableEL"
    row-key="productTypeId"
    :data="fristProductType"
    lazy
    :load="load"
    :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
  >
    <el-table-column prop="productTypeName" label="产品名称">
      <template #default="{ row }">
        <el-tag>{{ row.productTypeName }}</el-tag>
      </template>
    </el-table-column>
    <el-table-column prop="process" label="适配流程" />
    <el-table-column prop="remark" label="释义" />
    <el-table-column label="操作" fixed="right" align="center" width="180" prop="operator">
      <template #default="{ row }">
        <div>
          // 子节点不显示
          <el-button
            v-if="!row.process"
            @click.stop="() => onHandleAddProduct(row)"
            icon="plus"
            circle
            type="primary"
            plain
          ></el-button>
          <el-button
            @click.stop="() => onHandleEditProduct(row)"
            icon="edit"
            circle
            type="primary"
            plain
          ></el-button>
          <el-button
            @click.stop="() => onHandleDeleteProduct(row)"
            icon="delete"
            circle
            type="danger"
            plain
          ></el-button>
        </div>
      </template>
    </el-table-column>
  </el-table>

  <pagination
    v-show="total > 0"
    :total="total"
    v-model:page="queryParams.pageNum"
    v-model:limit="queryParams.pageSize"
    @pagination="getList"
  />

  <!-- 一级新增/编辑 -->
  <addorEditFrist
    v-if="FristaddShow"
    :add-visible="FristaddShow"
    :FormData="EditData"
    @close-event="addClose(true)"
  />

  <!-- 二级新增/编辑 -->
  <addorEditSecond
    v-if="SecondShow"
    :add-visible="SecondShow"
    :FormData="EditData"
    :parent-id="fristValue"
    @close-event="addClose(false)"
  />
</template>

注意

上述这个菜鸟在使用的过程中,还是发现了bug,就是先新增一个子节点之后,再来修改其中任意一个,那么修改不生效,且会把新增的那个再加一次,刷新后重新打开就是好的!

解决上面的问题

上面的问题出现的原因就是没有对新增和编辑单独进行处理,这里需要进行区分一下!

这里菜鸟就直接改了子组件传参了,只是一个demo,所以就简单写了个判断,上面的addClose就不用传参了,都是子组件传了!

修改后我的代码

// 关闭新增/编辑 -- bool: true 一级 false 二级
const addClose = async (bool = true, type) => {
  FristaddShow.value = false;
  SecondShow.value = false;
  EditData.value = {};
  if (bool) {
    await getList();
  } else {
    const treeData = unref(tableEL).store.states.treeData.value;
    let records = await getSecondProductType(fristValue.value);
    
    if (type === "add") {
      //  对于lazy node 并且已经展开的情况下
      if (Reflect.has(treeData, fristValue.value) && treeData[fristValue.value].loaded) {
        if (treeData[fristValue.value].children.length === 0) {
          //  对于清空掉了节点,之后重新添加的情况,需要重置加载状态
          // 大佬试了很多属性,才发现是这些属性影响了加载
          treeData[fristValue.value].expanded = false;
          treeData[fristValue.value].lazy = true;
          treeData[fristValue.value].loaded = false;
          unref(tableEL).store.loadOrToggle(clickRow.value);
          return;
        }
        // 假设最新添加的在最后一个,取最后一个
        const newest = createProductType(records.at(-1));
        treeData[fristValue.value].children.push(newest.id);
        unref(tableEL).store.states.lazyTreeNodeMap.value[fristValue.value].push(newest);
      } else {
        // 未展开的情况
        clickRow.value.children = records.map(createProductType);
      }
    } else {
      //  对于lazy node 并且已经展开的情况下
      if (Reflect.has(treeData, fristValue.value) && treeData[fristValue.value].loaded) {
        if (treeData[fristValue.value].children.length === 0) {
          //  对于清空掉了节点,之后重新添加的情况,需要重置加载状态
          // 大佬试了很多属性,才发现是这些属性影响了加载
          treeData[fristValue.value].expanded = false;
          treeData[fristValue.value].lazy = true;
          treeData[fristValue.value].loaded = false;
          unref(tableEL).store.loadOrToggle(clickRow.value);
          return;
        }
        unref(tableEL).store.states.lazyTreeNodeMap.value[fristValue.value] =
          records.map(createProductType);
      } else {
        // 未展开的情况
        clickRow.value.children = records.map(createProductType);
      }
    }
  }
};

菜鸟试了一下,这个treeData[fristValue.value].expanded = false注释了也没啥影响,有兴趣的读者可以试试看!

有图有真相

额,掘金太low了,上传不了gif图,只要上传gif就一直显示保存中,所以只能各位读者自己尝试了!

微信图片_20250513102039.png

见沸点:juejin.cn/pin/7503453…

通过界面设计,避免上述问题

界面

image.png

这样设计,就可以分为两个table,避免了上面描述的问题,且界面结构也很清晰!

难点:table的单选实现 + el-card滚动

这里table的单选实现,是大佬想到的,使用的是el-radio-group;还有一个难点就是要实现el-card里面的滚动,需要将el-card设置为flex,然后里面的内容用定位和flex-1实现(一般不自动滚动都是元素的父元素没有高度,导致没办法计算)!

<script setup>
import { listproductType, delproductType } from "@/api/order/productType";
import addorEditFrist from "./comps/addorEditFrist.vue";
import addorEditSecond from "./comps/addorEditSecond.vue";
import { ElMessage } from "element-plus";

const params = {
  pageNum: 1,
  pageSize: 1e3
};

// 一级
let fristProductType = ref([]);
let fristValue = ref(""); // 选中值
let FristaddShow = ref(false);
onMounted(async () => {
  await getFristProductType();
});
// 获取一级列表
const getFristProductType = async () => {
  const { data } = await listproductType({ ...params, queryFirstProductType: true });
  fristProductType.value = data.records;
};
// 点击一级
const onHandleTableRowClick = async (row) => {
  fristValue.value = row.productTypeId;
  await getSecondProductType();
  canaddsecond.value = true;
};
// 编辑
let FristEditData = ref({});
const onHandleEditFristProduct = (row) => {
  FristaddShow.value = true;
  FristEditData.value = row;
};

// 二级
let secondProductType = ref([]);
let canaddsecond = ref(false); // 是否可以新增二级
let SecondShow = ref(false);
// 获取二级列表
const getSecondProductType = async () => {
  const { data } = await listproductType({ ...params, parentId: fristValue.value });
  secondProductType.value = data.records;
};
// 编辑
let secondEditData = ref({});
const onHandleEditSecondProduct = (row) => {
  SecondShow.value = true;
  secondEditData.value = row;
};

// 关闭新增/编辑 -- bool: true 一级 false 二级
const addClose = async (bool = true) => {
  FristaddShow.value = false;
  SecondShow.value = false;
  FristEditData.value = {};
  secondEditData.value = {};
  await (bool ? getFristProductType() : getSecondProductType());
};
// 删除
const onHandleDeleteProduct = async (row, bool = true) => {
  const res = await delproductType(row.productTypeId);
  console.log(res);
  if (+res.code === 200) {
    ElMessage({
      message: "删除成功",
      type: "success"
    });
  } else {
    ElMessage({
      message: res.message,
      type: "error"
    });
  }

  await (bool ? getFristProductType() : getSecondProductType());
};
</script>

<template>
  <div class="grid h-full grid-cols-2 gap-4 pb-4">
    <el-card class="flex flex-col">
      <template #header>
        <div class="flex items-center justify-between">
          <el-tag>一级产品类型</el-tag>
          <el-button type="primary" icon="circle-plus" @click="FristaddShow = true"
            >新增一级产品类型</el-button
          >
        </div>
      </template>
      <el-radio-group class="table-expand-container size-full" v-model="fristValue">
        <el-table
          class="table-expand-content size-full"
          :data="fristProductType"
          @row-click="onHandleTableRowClick"
        >
          <el-table-column prop="selection" fixed="left" width="55">
            <template #default="{ row }">
              <div class="flex justify-center">
                <el-radio :value="row.productTypeId"></el-radio>
              </div>
            </template>
          </el-table-column>
          <el-table-column prop="productTypeName" label="产品名称">
            <template #default="{ row }">
              <el-tag>{{ row.productTypeName }}</el-tag>
            </template>
          </el-table-column>
          <el-table-column prop="remark" label="释义" />
          <el-table-column label="操作" fixed="right" align="center" width="140" prop="operator">
            <template #default="{ row }">
              <div>
                <el-button
                  @click.stop="() => onHandleEditFristProduct(row)"
                  icon="edit"
                  circle
                  type="primary"
                  plain
                ></el-button>
                <el-button
                  @click.stop="() => onHandleDeleteProduct(row)"
                  icon="delete"
                  circle
                  type="danger"
                  plain
                ></el-button>
              </div>
            </template>
          </el-table-column>
        </el-table>
      </el-radio-group>
    </el-card>

    <el-card>
      <template #header>
        <div class="flex items-center justify-between">
          <el-tag>二级产品类型</el-tag>
          <el-button
            type="primary"
            icon="circle-plus"
            :disabled="!canaddsecond"
            @click="SecondShow = true"
            >新增二级产品类型</el-button
          >
        </div>
      </template>
      <el-table v-show="canaddsecond" :data="secondProductType">
        <el-table-column prop="productTypeName" label="产品名称">
          <template #default="{ row }">
            <el-tag>{{ row.productTypeName }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="process" label="适配流程" />
        <el-table-column prop="remark" label="释义" />
        <el-table-column label="操作" fixed="right" align="center" width="140" prop="operator">
          <template #default="{ row }">
            <div>
              <el-button
                @click.stop="() => onHandleEditSecondProduct(row)"
                icon="edit"
                circle
                type="primary"
                plain
              ></el-button>
              <el-button
                @click.stop="() => onHandleDeleteProduct(row, false)"
                icon="delete"
                circle
                type="danger"
                plain
              ></el-button>
            </div>
          </template>
        </el-table-column>
      </el-table>
    </el-card>

    <!-- 一级新增/编辑 -->
    <addorEditFrist
      v-if="FristaddShow"
      :add-visible="FristaddShow"
      :FormData="FristEditData"
      @close-event="addClose"
    />

    <!-- 二级新增/编辑 -->
    <addorEditSecond
      v-if="SecondShow"
      :add-visible="SecondShow"
      :FormData="secondEditData"
      :parent-id="fristValue"
      @close-event="addClose(false)"
    />
  </div>
</template>

<style lang="scss" scoped>
:deep(.el-card__body) {
  padding: 0 !important;
  flex: 1;
  position: relative;
}
</style>

css

@tailwind components;
@tailwind utilities;

@layer utilities {
  .table-expand-container {
    @apply relative flex-1;
  }

  .table-expand-content {
    @apply absolute inset-0 h-full w-full;
  }
}

菜鸟感觉其他就比较简单了,就是点击左侧就请求子节点即可,如果有更多层,感觉这个界面也可以实现,但是就是更加复杂!

jym有什么更好的办法可以评论区一起讨论,欢迎大家指点江山,激昂文字

难点:数据竞争

这里其实还有一个问题:如果用户点击比较快,但是有的请求比较慢,那么可能会出现后点击的那个,返回的却是先点击的数据!

这里菜鸟使用的是大佬封装的一个函数

/**
 * @description 包装后的函数只执行最新的操作
 * @param asyncIterFn
 * @param debugInfo
 * @returns {function(...[*]): Promise<*|{value: null, done: boolean}>}
 */
export function latestDecorator(asyncIterFn, { debugInfo = false } = {}) {
  //  闭包的上下文参数
  let latestToken = 0;

  return async function (...args) {
    latestToken += 1;
    const currentToken = latestToken;
    if (debugInfo) {
      console.log(`start >>> currentToken=${currentToken}`);
    }

    const ag = asyncIterFn(...args);
    return await spawn(ag, {
      afterYield() {
        //  当前的token和最新的不一致的时候杀掉协程
        if (currentToken !== latestToken) {
          if (debugInfo) {
            console.log(`currentToken=${currentToken}, latestToken=${latestToken}`);
          }
          return true;
        }
      }
    });
  };
}

/**
 * @description 运行一个协程,可以插入各种钩子
 * @param ag - async generator
 * @param afterYield - 返回true则终止迭代,如果返回false或者不返回的情况则继续迭代
 */
async function spawn(ag, { afterYield = () => false } = {}) {
  let res = {
    value: null,
    done: false
  };
  while ((res = await ag.next(res.value))) {
    if (res.done) {
      break;
    }

    // 用户自定义的逻辑在每次协程的yield交还控制权的时候检查,这里类似于OS的功能 --> 大佬说这里其实可以优化,但是他暂时没想好返回什么
    if (afterYield()) {
      break;
    }
  }
}

这个的思路是

用户点击的顺序肯定是没问题的,所以只保留最后点击的那一个请求的数据,其实还是发了好几个请求!

使用比较简单,直接将上面的getSecondProductType改成类似异步迭代的写法即可!

// 获取二级列表
const getSecondProductType = latestDecorator(async function* () {
  const { data } = yield await listproductType({ ...params, parentId: fristValue.value });
  secondProductType.value = data.records;
});