递归组件里面的数据状态不共享
递归组件里面的数据状态不共享,所以需要用 pinia 之类的东西去共享数据。
菜鸟这里想了一下,要是状态共享就完了,就真的牵一发动全身了,很难排查问题。
js
<script setup>
import { useProjectCenterActiveObjStore } from "@/store/modules/projectCenterActiveObj";
defineProps({
asideData: {
type: Array,
default: () => []
},
layers: {
type: Number,
default: 0
}
});
const useActiveObj = useProjectCenterActiveObjStore();
const setActiveObj = (item) => {
useActiveObj.setActiveObj(item);
};
// 设置激活的button为普通样式,非激活的就是plain样式
const getPlain = (id) => {
if (useActiveObj.activeObj.id === id) {
return false;
}
return true;
};
const getClass = (layers) => {
if (layers === 0) {
return `ml-[${layers * 20}px] font-bold w-full`;
}
return `ml-[${layers * 20}px] w-[120px]`;
};
</script>
<template>
<div>
<div v-for="item in asideData" :key="item.id">
<template v-if="item.children">
<el-tag type="info" size="small" :class="getClass(layers)">{{ item.title }} </el-tag>
<aside-view :aside-data="item.children" :layers="layers + 1"></aside-view>
</template>
<el-button
v-else
type="primary"
size="small"
:plain="getPlain(item.id)"
:data-id="item.id"
:class="`ml-[${layers * 20}px] w-[120px]`"
@click="setActiveObj(item)"
>{{ item.title }}</el-button
>
</div>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-tag--small) {
height: 24px;
}
</style>
v-model 和 update事件 的书写顺序可能会影响结果

菜鸟在修改项目代码时发现,如果按照红色的写,虽然确实会触发更新函数 excute,但是请求后端使用的数据还是老的;如果用绿色的,则请求的数据是新的,这也是组件使用的一个坑!
这里和上篇文章写的:element plus 使用细节 (二) 中的 change事件里面不要访问v-model的数据 还有点差别。
这里的是vue自带的更新方法,上篇文章是element自己封装的change方法,所以上篇文章就算书写顺序是绿色的,也不行!
组件设计
这些都是菜鸟最近在工作中,深有体会的一些设计规范,可以减少后期返工,不能因为工期就不考虑设计!!!
我们公司另一位大佬说了很好的一句话:
封装不应该封装 UI功能(不仅包括样式和组件,还包括"渲染结构的描述方式",如 template / JSX / JS 配置,这些都属于 UI 层,不应该被当作业务封装),因为组件库已经做得很好了,需要封装的是业务重复的功能,以及UI组件没有做的功能!
组件设计 - 表格列
表格最好通过 JS 配置进行统一管理,而不是在 UI 中写死。每一列应具备独立的配置(如字段、顺序、显隐等)。
原因是:写死在模板中的表格结构,本质是"不可变结构",一旦涉及列顺序调整、权限控制、个性化配置,就会导致大量重复修改甚至重构。
这样设计的好处是:
- 可以灵活调整列顺序,而不需要修改模板结构
- 支持用户个性化配置(如拖拽排序、显示/隐藏列)
- 降低后续维护成本,避免频繁改动 UI 代码
- 更容易做持久化(如本地存储或服务端保存用户配置)
本质上是将"表格结构"从"视图层"抽离为"数据配置驱动",提高扩展性和可维护性。
但是没有设计是完全灵活的,一个组件只要被大量引用,"牵一发动全身"是不可避免的,这是复用带来的天然代价。
设计只能减少负担,并不能解决,除非你一个界面就写死一个,不进行复用,但是这样又会导致要修改的时候需要同时修改N个文件!
注意
菜鸟感觉这个是提取组件提取早了,项目未真正成型前,就应该 "一个界面就写死一个",等后面基本不会改变后再来抽取,过早的抽取反而适得其反!
这里是菜鸟推荐的思路
- 前期直接 "一个界面就写死一个" js 文件渲染 table
- 后期基本不用改动的时候,提取公用列的 js
- 如果后期之后还有修改,就按照如下封装一个函数
公用列,提取成js
js
export function createColumns(config = {}) {
const {
override = {}, // 替换
order = [], // 排序
exclude = [], // 删除
append = [] // 新增
} = config
let cols = [
......公用列
]
// 1. 删除
cols = cols.filter(col => !exclude.includes(col.key))
// 2. 覆盖
cols = cols.map(col => ({
...col,
...(override[col.key] || {})
}))
// 3. 添加新列
cols = [...cols, ...append]
// 4. 排序
if (order.length) {
const orderMap = new Map(order.map((k, i) => [k, i]))
cols.sort((a, b) => {
const aIndex = orderMap.has(a.key) ? orderMap.get(a.key) : Infinity
const bIndex = orderMap.has(b.key) ? orderMap.get(b.key) : Infinity
return aIndex - bIndex
})
}
return cols
}
注意
其实JS也有不好的地方,就是不够直观,特别是很多提取出去之后,再进行组合,那就真的没法看!
所以最好的就是自定义列的顺序,一开始就后端一起做,要么就按照下面的进行封装!
自定义列的顺序(纯前端)
我们之前做的时候,没想到会有列的需求变化,直接写死了UI,然后之前文章提到过的那位大佬,又提前封装了组件,导致列被重复引入,且位置很多,所以改一个可能会引发很多变化!
为了解决这个问题,大佬封装了两个组件(推荐封装) ,可以直接套在el-table上实现自定义列的功能,思路很好,可以给大家参考一下!
注意
这个还是不够完善,暂时对单列不合并的表格是支持比较好的,其他就不知道了!
实现如图:


代码如下
ElTableTools.vue
html
<script setup>
import { createEventHook } from "@vueuse/core";
import {
collectNestedList,
GetColsType,
SetColsType,
traverseNodes
} from "@/components/TablePlugins/common/common.js";
import { cloneDeep, filter, map, includes, flatMapDeep } from "lodash-es";
import { match } from "ts-pattern";
import { useDialogEditClassStyle } from "@/hooks/useDialogEditClassStyle.js";
const getCols = createEventHook("getCols");
const setCols = createEventHook("setCols");
provide(GetColsType, getCols);
provide(SetColsType, setCols);
const props = defineProps({
conflictSourcePageKey: {
type: String,
default: ""
}
});
const sourcePageKey = props.conflictSourcePageKey || unref(inject("sourcePageKey") || ref(""));
const localStorageKey = computed(
() => `${sourcePageKey}`
);
const backupCols = ref([]);
const cols = ref([]); // 真实的提交列,如果用户提交会保存,否者会回滚
const currentCols = ref([]); // 当前正在使用的列,数据会被直接修改
// 当检测到用户目前的列配置发送变化的时候
getCols.on(async (_cols) => {
cols.value = collectNestedList(_cols, {
collectFn({ node }) {
return {
...node,
isShow: true
};
}
});
backupCols.value = cloneDeep(cols.value);
currentCols.value = cloneDeep(cols.value);
await nextTick();
// 尝试加载用户之前存储的配置在服务端或者浏览器本地,之后合并配置
loadColumnsConfig({ isTriggerSetCols: true });
});
const dialogVisible = ref(false);
function handleConfirm() {
cols.value = cloneDeep(unref(currentCols));
const checkedKeys = new Set(unref(treeRef).getCheckedKeys());
triggerSetCols(checkedKeys);
saveColumnsConfig(unref(cols));
dialogVisible.value = false;
}
function triggerSetCols(checkedKeys) {
// 通知 ColsManager 进行更新
const filteredCols = collectNestedList(unref(currentCols), {
shouldCollect({ isLeaf, node, transformedChildren }) {
if (isLeaf) {
return checkedKeys.has(node.id);
}
return transformedChildren.length;
}
});
setCols.trigger(filteredCols);
}
function handleCancel() {
handleBeforeClosed(() => {
dialogVisible.value = false;
});
}
function handleClickCheck(data, { checkedNodes }) {
const isShow = checkedNodes.includes(data);
traverseNodes([data], {
before({ node }) {
node.isShow = isShow;
}
});
}
function handleOpenColsConfig() {
dialogVisible.value = true;
setCurrentColsChecked();
}
async function setCurrentColsChecked() {
await nextTick();
const checkedNodes = [];
traverseNodes(unref(currentCols), {
before({ node, isLeaf }) {
if (isLeaf && node.isShow) {
checkedNodes.push(node);
}
}
});
unref(treeRef).setCheckedNodes(checkedNodes, false);
}
// 递归获取树的所有id
function getAllIds(tree, childrenKey = "children") {
return flatMapDeep(tree, (node) => {
const children = node[childrenKey];
const childIds = children ? getAllIds(children, childrenKey) : [];
return [node.id, ...childIds];
});
}
const toTriggerSetCols = () => {
const labels = map(filter(unref(currentCols), "isShow"), "label");
const checkedKeys = new Set(
getAllIds(filter(unref(currentCols), (item) => includes(labels, item.label)))
);
triggerSetCols(checkedKeys);
};
async function loadColumnsConfig({ isTriggerSetCols }) {
// 尝试从 localStorage 加载用户的列配置,并与当前的列进行合并,最后触发 setCols 进行更新
const localColsConfig = JSON.parse(localStorage.getItem(unref(localStorageKey)) || "[]");
if (!Array.isArray(localColsConfig) || localColsConfig.length === 0) {
if (isTriggerSetCols) {
toTriggerSetCols();
}
return;
}
const sourceCols = [...unref(currentCols)];
// 由于缺少唯一标识符,只能通过label来匹配,所以先构建一个label到列配置的映射表,方便后续匹配和属性更新
const configMap = new Map(localColsConfig.map((c) => [c.label, c]));
const matchedNodesMap = new Map();
const unmatchedNodesWithIndex = [];
// 1. 更新属性并识别匹配/不匹配的列
sourceCols.forEach((node, index) => {
const { label } = node;
const configItem = configMap.get(label);
if (configItem) {
const { fixed, width, realWidth, isShow } = configItem;
Object.assign(node, { fixed, width, realWidth, isShow });
matchedNodesMap.set(label, node);
} else {
unmatchedNodesWithIndex.push({ node, index });
}
});
// 2. 根据 localColsConfig 的顺序排列匹配到的列 (舍弃不在 currentCols 中的列)
const orderedMatchedNodes = [];
localColsConfig.forEach((configItem) => {
const node = matchedNodesMap.get(configItem.label);
if (node) {
orderedMatchedNodes.push(node);
matchedNodesMap.delete(configItem.label); // 防止重复
}
});
// 3. 将不在 localColsConfig 中的列插入回原来的位置 (保持位置不变)
const finalCols = [...orderedMatchedNodes];
// 按原索引排序以确保插入位置正确
unmatchedNodesWithIndex.sort((a, b) => a.index - b.index);
unmatchedNodesWithIndex.forEach(({ node, index }) => {
const insertPos = Math.min(index, finalCols.length);
finalCols.splice(insertPos, 0, node);
});
currentCols.value = finalCols;
await nextTick();
if (isTriggerSetCols) {
toTriggerSetCols();
}
}
function saveColumnsConfig(cols) {
// Save entire tree which include propName width fixed and isShow props
localStorage.setItem(unref(localStorageKey), JSON.stringify(unref(cols)));
}
function resetColumnsConfig() {
currentCols.value = cloneDeep(unref(backupCols));
setCurrentColsChecked();
}
const treeRef = ref();
function allowDrop(draggingNode, dropNode, type) {
return match(type)
.with("inner", () => {
return false;
})
.with("prev", "next", () => {
return draggingNode.level === dropNode.level;
})
.exhaustive();
}
const fixedOpts = [
{ label: "左固定", value: "left" },
{ label: "右固定", value: "right" },
{ label: "不固定", value: false }
];
function getWidth(data) {
return data.width ?? data.realWidth;
}
function changeWidth(data, nv) {
data.width = nv;
}
function handleBeforeClosed(done) {
// currentCols.value = cloneDeep(cols.value);
loadColumnsConfig({ isTriggerSetCols: false });
done();
}
const { dialogProps, cardProps, footerProps } = useDialogEditClassStyle();
function closeDialog() {}
</script>
<template>
<div class="mb-2 mr-1 flex justify-end">
<slot name="left"></slot>
<el-tooltip content="自定义列配置">
<el-button icon="menu" circle @click="handleOpenColsConfig" />
</el-tooltip>
<slot name="right"></slot>
</div>
<el-dialog
:before-close="handleBeforeClosed"
append-to-body
v-bind="dialogProps"
width="700"
v-model="dialogVisible"
@closed="closeDialog"
>
<el-card v-bind="cardProps">
<template #header>
<div class="flex justify-between">
<el-tag type="primary">配置表格列</el-tag>
</div>
</template>
<div>
<section class="flex flex-col gap-y-3">
<el-tree
ref="treeRef"
@check="handleClickCheck"
show-checkbox
:data="currentCols"
draggable
default-expand-all
node-key="id"
:allow-drop="allowDrop"
>
<template #default="{ node, data }">
<div class="flex w-full items-center gap-x-2">
<div class="flex-1">{{ node.label }}</div>
<div class="w-[130px]">
<el-input-number
controls-position="right"
@click.stop
:model-value="getWidth(data)"
@change="(nv) => changeWidth(data, nv)"
v-if="node.isLeaf"
size="small"
/>
<div class="grid place-items-center" v-else>-</div>
</div>
<div class="w-44">
<el-segmented
v-if="node.level === 1"
v-model="data.fixed"
:options="fixedOpts"
size="small"
/>
<div class="grid place-items-center" v-else>-</div>
</div>
</div>
</template>
</el-tree>
</section>
<div v-bind="footerProps">
<el-button plain @click="resetColumnsConfig">重置</el-button>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleConfirm()">保存</el-button>
</div>
</div>
</el-card>
</el-dialog>
<div class="table-tools-content-body">
<slot name="default"></slot>
</div>
</template>
<style scoped lang="scss">
.table-tools-content-body {
// flex: 1;
display: flex;
flex-direction: column;
/* 允许父元素收缩,不被内容撑高 */
min-height: 0;
}
</style>
common.js
js
import { noop } from "lodash-es";
export const GetColsType = Symbol("getCols");
export const SetColsType = Symbol("setCols");
export function traverseNodes(
nodes,
{ before = noop, after = noop, childrenName = "children" } = {}
) {
function _traverseNodes(nodes, level) {
if (!Array.isArray(nodes)) {
return;
}
for (const node of nodes) {
const children = Reflect.get(node, childrenName);
const isLeaf = !children;
before({ node, isLeaf, level });
_traverseNodes(children, level + 1);
after({ node, isLeaf, level });
}
}
_traverseNodes(nodes, 0);
}
export function collectNestedList(
list,
{ childrenName = "children", collectFn, shouldCollect } = {}
) {
collectFn ??= ({ node }) => node;
shouldCollect ??= (..._args) => true;
function _collectNestedList(cols, level, parent) {
if (!Array.isArray(cols)) {
return null;
}
const res = [];
for (const col of cols) {
const { [childrenName]: children } = col;
const isLeaf = !children;
const transformedChildren = _collectNestedList(children, level + 1, col);
const transformedNode = collectFn({
node: {
...col,
[childrenName]: transformedChildren
},
isLeaf,
level,
parent
});
if (shouldCollect({ isLeaf, node: col, level, parent, transformedChildren })) {
res.push(transformedNode);
}
}
return res;
}
return _collectNestedList(list, 0, null);
}
useDialogEditClassStyle.js
js
export function useDialogEditClassStyle() {
const dialogProps = ref({
showClose: false,
headerClass: "p-0",
footerClass: "p-0"
});
const cardProps = ref({
headerClass: "!py-3",
bodyClass: "!pb-0",
shadow: "never"
});
const footerProps = ref({
className: "flex h-16 shrink-0 items-center justify-end rounded "
});
return {
dialogProps,
cardProps,
footerProps
};
}
ElColumnsManager.vue
html
<script lang="jsx" setup>
import { getCurrentInstance, onBeforeUnmount, onMounted } from "vue";
import { createEventHook, watchDebounced } from "@vueuse/core";
import { cloneDeep, isNil, omitBy } from "lodash-es";
import { match, P } from "ts-pattern";
import {
collectNestedList,
GetColsType,
SetColsType
} from "@/components/TablePlugins/common/common.js";
const props = defineProps({
/**
* 最后的渲染时机用户可能修改某些配置
* @param {[]} columns
* @returns {[]}
*/
columnsRewrite: {
type: Function,
default: (columns) => columns
}
});
const instance = getCurrentInstance();
const owner = computed(() => {
let parent = instance.parent;
while (parent && !parent.tableId) {
parent = parent.parent;
}
return parent;
});
const renderTableCtx = ref();
function getRenderTableCtx() {
const { store } = unref(owner);
renderTableCtx.value = {
_self: unref(owner),
store
};
}
onMounted(getRenderTableCtx);
const refTableRef = ref();
const cols = ref([]); // origin cols from slots
const currentCols = ref([]); // would be edited by user
const getCols = inject(GetColsType, createEventHook("_getCols"));
const setCols = inject(SetColsType, createEventHook("_setCols"));
setCols?.on((cols) => {
console.log("on set cols", cols);
currentCols.value = cols;
});
function emitColsChange() {
function transformCols(cols) {
return collectNestedList(cols, {
collectFn({ node }) {
return {
...node
};
}
});
}
getCols?.trigger(transformCols(unref(cols)));
}
const cellIndexMap = shallowRef(new Map());
// 监听 refTableRef 的 columns变化,在某些情况下,特别是表格列是动态加载的情况下,此类监听会循环触发很多次
// 修改为根据label, property, width, fixed四个维度缓存表格列配置,以此来判断表格是否发生变动
let virtualTableLocalMark = [];
watchDebounced(
() => {
const { columns } = unref(refTableRef) ?? {};
return columns;
},
(columns) => {
const currentVirtualTableLocalMark = [];
// 根据 label, property, width, fixed 四个维度缓存表格列配置
columns.map((item) => {
const { label, property, width, fixed } = item;
currentVirtualTableLocalMark.push([label, property, width, fixed].join("-"));
});
if (virtualTableLocalMark.join(",") !== currentVirtualTableLocalMark.join(",")) {
virtualTableLocalMark = cloneDeep(currentVirtualTableLocalMark);
cols.value = currentCols.value = columns;
// console.log(unref(cols));
cellIndexMap.value = calcCellIndex(unref(cols));
// console.log(cellIndexMap.value);
emitColsChange();
}
},
{
deep: true,
debounce: 1
}
);
function calcCellIndex(cols) {
let curCellIndex = 0;
const cellIndexMap = new Map();
function traverse(cols) {
if (!Array.isArray(cols)) {
return;
}
for (const col of cols) {
if (!col.children) {
// leaf node
cellIndexMap.set(col.id, curCellIndex);
curCellIndex += 1;
}
// before()
traverse(col.children);
// after()
}
}
traverse(cols);
return cellIndexMap;
}
function RenderCellWrapper({ renderCell, row, $index, column }) {
const cellIndex = unref(cellIndexMap).get(column.id);
const { _self, store } = unref(renderTableCtx);
// bug:第一次执行的时候可能还没有拿到row,这时不应该渲染
if (Object.keys(row).length === 0) {
return h("div");
}
const data = {
$index,
cellIndex, // 分析el源码并调试观察不难看出这个相当于tree的子节点开始来数的
column,
expanded: false,
row,
store,
_self
};
return renderCell(data).children; // 避免渲染两次 div.cell
}
function RenderColumns({ columns }) {
if (!unref(renderTableCtx)) {
return;
}
return columns.map((column) => {
const {
property,
label,
width,
minWidth,
maxWidth,
children,
renderCell,
fixed,
renderHeader,
...rest
} = column;
const props = omitNil({ prop: property, label, width, minWidth, maxWidth, fixed });
// 为每一列提供唯一的 key,避免 VDOM 基于位置复用导致列属性(如 filters、filterMethod)串列
return (
<el-table-column key={column.id ?? property ?? label} {...Object.assign({}, props, rest)}>
{{
header: ({ column, $index }) =>
renderHeader({
$index,
column,
...rest,
...unref(renderTableCtx)
}),
default: ({ row, $index }) =>
match(children)
.with(P.array(P._), () => <RenderColumns columns={children} />)
.otherwise(() => <RenderCellWrapper {...{ row, $index, renderCell, column }} />)
}}
</el-table-column>
);
});
}
function omitNil(obj) {
return omitBy(obj, isNil);
}
const finalRenderColumns = computed(() => {
const { columnsRewrite } = props;
return columnsRewrite(unref(currentCols));
});
onBeforeUnmount(() => {
virtualTableLocalMark = [];
});
</script>
<template>
<RenderColumns :columns="finalRenderColumns" />
<el-table ref="refTableRef" :data="[]" class="hidden">
<slot name="default"></slot>
</el-table>
</template>
使用方式
js
<ElTableTools>
<el-table>
......el-table-column
<ElColumnsManager>
......需要排序的el-table-column
</ElColumnsManager>
......el-table-column
</el-table>
</ElTableTools>