接上一篇文章:
菜鸟最近和大佬在做的系统里面,有很多子结构的数据展示,大家肯定第一个想到的是 el-tree,但是这里的父子结构都是有表头的,所以只能使用el-table的树形数据了。但菜鸟和大佬都很头疼的点就在于 element plus 的 el-table 不能在使用懒加载的同时,对子节点里面的数据进行动态的增删。
el-table树形结构的痛点
1、子结构的表头和父结构不一样,那么有的就会空起来
为什么table懒加载不能手动增删子节点?
菜鸟点击删除后,有两种办法可以刷新(新增、编辑同理):
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);
}
};
注意
大佬说了,这个不是正统的解法,因为直接修改了
table的内部隐藏属性或者方法!这里的内部属性或者方法是可能发生变化的,不同的element plus版本,可能存的变量名不一样,所以这里只提供思路(读者如果发现这个不生效,可能是变量不一样了,需要自行寻找)—— 本代码适用 “大于等于2.10版本”
这个有点强关联了,只能使用
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就一直显示保存中,所以只能各位读者自己尝试了!
通过界面设计,避免上述问题
界面
这样设计,就可以分为两个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;
});