前言
最近做一個矩陣圖編輯器需求,其實就是一個表格編輯器。
主要要求
- 表頭支持多層級行列合并,抽象出來也就是多棵樹組成的表頭
- 表體行單元格隨表頭葉子節(jié)點的變化去做diff聯(lián)動
其他業(yè)務(wù)就不細述了。
具體UI
先看UI圖效果



從圖中可以看出單元格是通過 + - icon不斷去增加刪除的,有增加同級的、下級的,這樣就類似一棵樹了,而且樹可以有多棵。
下面的圖可能更加清晰,有1、2、3 三棵樹。

那么第一個問題來了,在不斷增加單元格的過程中,怎么去保持每棵樹的行、列同步呢?,比如1單元格在不斷增加子單元格的時候,1這個單元格要實現(xiàn)列合并,同時2這個單元格也要不斷實現(xiàn)行合并。
要解決這個問題,需要分步驟:
第一步:按行去一行行渲染單元格
第二步:計算哪些單元格需要行合并、哪些單元格需要列合并
那么第一步,我們定義一個最簡單的樹的數(shù)據(jù)結(jié)構(gòu)為:
type IdType = string
interface TreeItem {
id: IdType;
name: string; // 名稱
level: number; // 層級
children: TreeItem[]
}
其中l(wèi)evel 層級用來記錄該節(jié)點處于樹的第幾層,而這也是用來確定該節(jié)點在第幾行渲染,level從0開始,根節(jié)點1是0。1-1、1-2的level是1;而1-1-1,1-1-2的level是2如此。我們需要寫一個方法把樹轉(zhuǎn)成二維數(shù)組,然后通過循環(huán)二維數(shù)組去渲染單元格:,期待的二維數(shù)組的結(jié)構(gòu)如下,注意這里用二維數(shù)組也有一個取巧的地方,那就是數(shù)組arr的index 與 level相對應(yīng)
arr = [
[{id, name: 1, level: 0}, {id, name: 2: level: 0}],
[{id, name: 1-1, level: 1}, {id, name: 1-2: level: 1}]
[{id, name: 1-1-1, level: 2}, {id, name: 1-1-2, level: 2}]
]
寫一個轉(zhuǎn)換方法
// 樹轉(zhuǎn)換層級數(shù)組
function generateLevelArray(treeData: TreeItem[]) {
const arr = [];
treeForEach(treeData, (item) => {
if (!Array.isArray(arr[item.level])) {
arr[item.level] = [];
}
arr[item.level].push(item);
});
return arr;
}
這里的treeForEach是一個深度遍歷樹節(jié)點的方法。遍歷樹的方法有深度優(yōu)先遍歷與廣度優(yōu)先遍歷。這里用深度的原因是廣度優(yōu)先遍歷過程中無法傳遞當(dāng)前節(jié)點的parent;而深度優(yōu)先遍歷是通過遞歸方式,可以傳遞parent,省心省力完成后面的一些特色需求,所以一般沒啥特殊要求的話還是深度優(yōu)先遍歷比較好。
// 是否有子節(jié)點,一般用來判斷是否是葉子節(jié)點
export function hasChildren(item: TreeItem): boolean {
return Array.isArray(item.children) && item.children.length > 0;
}
// 深度遍歷樹執(zhí)行回調(diào)函數(shù)
export function treeForEach(tree: TreeItem[], cb: Function, parent?: TreeItem): void {
const isFunction = typeof cb === 'function';
if (!isFunction) return;
tree.forEach((item, index) => {
const hasChild = hasChildren(item);
cb(item, index, parent);
if (hasChild) {
treeForEach(item.children, cb, item);
}
});
}
通過樹轉(zhuǎn)換成數(shù)組方法,我們遍歷二維數(shù)組就能生成下面的表格,(先忽略“工作內(nèi)容”這個單元格)

這與我們的實際需求相差甚遠:

所以我們上面說的第二個步驟要實現(xiàn)。
再次通過觀察實際效果,我們可以發(fā)現(xiàn)1單元格的列合并數(shù)量,其實等于它下面的葉子節(jié)點數(shù)量(沒有后代的節(jié)點是葉子節(jié)點),注意這里說的是葉子節(jié)點,不是兒子節(jié)點。1單元格下面的葉子節(jié)點是1-1-1、1-1-2、1-2 。由此得出列合并的規(guī)律就是:
- 單元格不是葉子節(jié)點,因為葉子節(jié)點不需要合并
- 找出該單元格下面的葉子節(jié)點數(shù)量
那么行合并怎么算呢?比如2單元格是要三行合并成一行。通過觀察1-2、2這兩個單元格,我們也可以得到行合并數(shù)量的規(guī)律:
- 單元格必須為葉子節(jié)點,且它所在的level不是最大的層級level
- 找到該單元格的層級level與最大level的差距, 即等于二維數(shù)組的長度減去當(dāng)前的層級: arr.length - currentLevel
由此我們可以得到一個表頭二維數(shù)組的computed
const columnTree: Ref<TreeItem[]> = ref([]);
const tableHeadData = computed(() => {
const levelArray = generateLevelArray(columnTree.value);
const tableHeadData: Array<Array<TableHeadCell>> = [];
const maxLevel = levelArray.length - 1; // 最大層級
levelArray.forEach((level) => {
const row: Array<TableHeadCell> = [];
level.forEach((treeItem: TreeItem) => {
if (hasChildren(treeItem)) {
// 非葉子節(jié)點列合并
const item: TableHeadCell = {
id: treeItem.id,
name: treeItem.name,
level: treeItem.level,
colspan: getLeafNumber([treeItem]),
}
row.push(item);
} else if (treeItem.level !== maxLevel) {
// 非末端葉子節(jié)點需要行合并
const rowspan = maxLevel - treeItem.level + 1;
const item: TableHeadCell = {
id: treeItem.id,
name: treeItem.name,
level: treeItem.level,
rowspan,
}
row.push(item);
} else {
// 末端葉子節(jié)點 啥也不干
const item: TableHeadCell = {
id: treeItem.id,
name: treeItem.name,
level: treeItem.level,
}
row.push(item);
}
});
tableHeadData.push(row);
});
const firstCell: TableHeadCell = { id: FIRST_CELL_ID, name: '工作內(nèi)容', rowspan: maxLevel + 1, level: 0 };
if (tableHeadData[0]) {
tableHeadData[0].unshift(firstCell);
} else {
tableHeadData[0] = [firstCell]
}
return tableHeadData;
})
// 獲取葉子節(jié)點數(shù)量來實現(xiàn)列合并
export function getLeafNumber(tree: TreeItem[]): number {
let num = 0;
treeForEach(tree, (item: TreeItem) => {
if (!hasChildren(item)) {
num++;
}
});
return num;
}
這樣子就實現(xiàn)我們的行合并、列合并的效果。

咋一看好像沒啥問題,但是實際上卻有嚴(yán)重的性能問題!問題就出在getLeafNumber這里,這個方法是獲取節(jié)點下面有幾個葉子節(jié)點的。單獨算一個節(jié)點的下面的葉子節(jié)點數(shù)量是完全沒有問題的,但是如果你遍歷一棵樹的過程中,每個節(jié)點都去算其下有幾個葉子節(jié)點,這實際上是存在巨大的重復(fù)工作的,比如1節(jié)點的葉子數(shù)量應(yīng)該等于1-1、1-2兩者的葉子節(jié)點之和,而不是1節(jié)點算一遍、1-1、1-2自己又算一遍。
遍歷一顆樹,分別計算每個節(jié)點下面有幾個葉子節(jié)點,最好的時間復(fù)雜度應(yīng)該是O(n),怎么實現(xiàn)呢?
這里還是用到了遞歸方式,上面也說了每個節(jié)點的葉子節(jié)點數(shù)量等于它下面的兒子的葉子節(jié)點數(shù)量之和,直到該節(jié)點是葉子節(jié)點才退出遞歸。
// 記錄每個節(jié)點下面有幾個葉子節(jié)點
export function getLeafNumber(tree: TreeItem) {
function deep(node: TreeItem) {
if (!hasChildren(node)) {
return 1 // 葉子節(jié)點直接返回本身1個數(shù)量
}
// 否則有children 去計算其下的children的葉子數(shù)量之和
let num = 0;
node.children.forEach(item => {
num += deep(item);
})
return num;
}
return deep(tree);
}
參照基本邏輯實現(xiàn)了一版,仔細看這里還是有重復(fù)計算的問題,缺失了計算結(jié)果緩存,所以我們需要一個map來緩存計算過的節(jié)點結(jié)果,優(yōu)化版:
// 記錄每個節(jié)點下面有幾個葉子節(jié)點
export function getLeafNumberMap(tree: TreeItem): NodeLeafNumberMap {
const nodeLeafNumberMap: NodeLeafNumberMap = Object.create(null);
function deep(node: TreeItem) {
if (!hasChildren(node)) {
nodeLeafNumberMap[node.id] = 1;
return nodeLeafNumberMap[node.id];
}
// 有children 有l(wèi)eaf
if (Object.hasOwn(nodeLeafNumberMap, node.id)) return nodeLeafNumberMap[node.id];
// 有children 沒leaf
let num = 0;
node.children.forEach(item => {
num += deep(item);
})
nodeLeafNumberMap[node.id] = num;
return num;
}
deep(tree);
return nodeLeafNumberMap;
}
有了nodeLeafNumberMap知道每個節(jié)點的葉子數(shù)量那就好辦了,上面的表頭二維數(shù)組的computed可以改成下面優(yōu)化版, 去掉getLeafNumber方法,改成 leafNumberMap[treeItem.id]
const tableHeadData = computed(() => {
const leafNumberMapList = columnTree.value.map(tree => getLeafNumberMap(tree));
const leafNumberMap = leafNumberMapList.reduce((prevMap, currentMap) => ({ ...prevMap, ...currentMap }), Object.create(null))
const levelArray = generateLevelArray(columnTree.value);
const tableHeadData: Array<Array<TableHeadCell>> = [];
const maxLevel = levelArray.length - 1; // 最大層級
levelArray.forEach((level) => {
const row: Array<TableHeadCell> = [];
level.forEach((treeItem: TreeItem) => {
if (hasChildren(treeItem)) {
// 非葉子節(jié)點列合并
const item: TableHeadCell = {
id: treeItem.id,
name: treeItem.name,
level: treeItem.level,
colspan: leafNumberMap[treeItem.id],
}
row.push(item);
} else if (treeItem.level !== maxLevel) {
// 非末端葉子節(jié)點需要行合并
const rowspan = maxLevel - treeItem.level + 1;
const item: TableHeadCell = {
id: treeItem.id,
name: treeItem.name,
level: treeItem.level,
rowspan,
}
row.push(item);
} else {
// 末端葉子節(jié)點 啥也不干
const item: TableHeadCell = {
id: treeItem.id,
name: treeItem.name,
level: treeItem.level,
}
row.push(item);
}
});
tableHeadData.push(row);
});
const firstCell: TableHeadCell = { id: FIRST_CELL_ID, name: '工作內(nèi)容', rowspan: maxLevel + 1, level: 0 };
if (tableHeadData[0]) {
tableHeadData[0].unshift(firstCell);
} else {
tableHeadData[0] = [firstCell]
}
return tableHeadData;
})
表格導(dǎo)出到excel
另外一個需求就是支持導(dǎo)出到excel, 我采用 exceljs 這個庫來導(dǎo)出。我覺得這是整個表格編輯器最難的地方,難點就是要拿到表頭的單元格行列位置信息與合并信息。其中導(dǎo)出的單元格位置信息數(shù)據(jù)結(jié)構(gòu)為
// sheet表格中單元格位置信息
export type SheetCellPosition = {
c: number; // 列位置
r: number // 行位置
cs: number // 列合并數(shù)
rs: number // 行合并數(shù)
}
其中每個單元格的 r \ rs \ cs 信息都可以輕松拿到,r = node.level; rs = node.rowspan; cs = node.colspan, 上面的二維數(shù)組都有,唯獨這個列信息c 沒有;這個列位置信息不是二維數(shù)組的每個單元格的index坐標(biāo)信息,而是要包含列合并后的位置信息,總結(jié)就是
當(dāng)前單元格的c = 同行的前一個單元格的c + cs
看到這里有人可能會想:既然這樣子那還不簡單?對二維數(shù)組的每一行進行遍歷,根據(jù)上面的公式不就能知道每個單元格的c信息了嗎?
確實我一開始也是這么做的,導(dǎo)出后發(fā)現(xiàn)有些單元格就不符合預(yù)期了,原因是第一行的單元格可以這么做得到單元格的對應(yīng)信息沒問題,回到上面的例子中

二維數(shù)組簡略后應(yīng)該是這樣子:
[
[{name: '1'}, {name: '2'}, {name: 3}],
[{name: '1-1'}, {name: '1-2'},{name: '3-1'}, {name: '3-2'}],
[{name: '1-1-1'}, {name: '1-1-2'}],
]
從第二行開始,仔細觀察,你會發(fā)現(xiàn) ‘3-1’ 的c信息并不能依靠同行前面的'1-2'的c信息和cs得到,因為從圖中可以看到它們之間還隔著一個“2”單元格的距離,所以不能簡單遍歷二維數(shù)組就能得到每個單元格的列位置信息也就是c信息。
那要怎么才能得到呢?
能不能記錄不同行的信息也就是上面的“2”單元格的信息,用到的時候再加上?可以這么想,但是中間可能不僅僅是“2”一個單元格,實際導(dǎo)出過程中存在可能隔著好幾個單元格都有的情況,而且隔著的這些單元格可能分別分布在不同的行里面!這么一想就感覺太復(fù)雜了,難度系數(shù)劇增!
現(xiàn)在我們整理一下思路,要知道當(dāng)前單元格的列位置信息c,需要知道:
- 同行的前面的位置信息c + cs
- 類似“2”單元格這種不同行的一個或者多個單元格信息
而第2點很難計算出來,那么有沒有代替方法呢?
終于經(jīng)過一段時間的摸索之后我發(fā)現(xiàn)其實要知道“3-1”的位置不用第2點也行,我們只要知道“3”單元格這個位置信息就行了!因為第2點的信息太難計算,無論它隔著幾個單元格,“3”單元格這個信息我們是可以輕易得到的,因為“3”,“3-1”在同一顆樹上且是父子節(jié)點!
那么我們就可以通過遍歷樹的方式, 而且計算公式還是:
當(dāng)前單元格的c = 同層的前一個單元格的c + cs
不過要注意,當(dāng) “當(dāng)前單元格” 是同層的第一個節(jié)點的時候,它的列位置信息其實 === 父節(jié)點的列位置信息!
這樣子就能通過深度遍歷樹得到所有單元格的正確的列位置信息了!
而且恰好第一個子節(jié)點的信息依賴父節(jié)點的信息,一次遍歷即可計算出所有的節(jié)點信息,這就是我前面說的深度優(yōu)先遍歷算法省心省力完成的特色需求!
想通了就好辦了,代碼如下:
// 設(shè)置每個單元格的colIndex
function setCellColIndex(treeData: TreeItem[], positionMap: SheetCellPositionMap): SheetCellPositionMap {
// 獲取單元格的列位置
function getCellColIndex(list: TreeItem[], itemIndex: number, parentColIndex: number = 0): number {
let currentIndex = itemIndex - 1
let currentItem: TreeItem
let currentItemPosition: SheetCellPosition
let c = parentColIndex;
while (currentIndex >= 0) {
currentItem = list[currentIndex];
currentItemPosition = positionMap[currentItem.id]
if (currentItemPosition.c !== 0) {
return currentItemPosition.c + currentItemPosition.cs
} else {
c = c + currentItemPosition.cs
}
currentIndex--;
}
return c;
}
treeForEach(treeData, (item, index, parent) => {
if (!parent) {
// 沒有parent就是根節(jié)點
positionMap[item.id].c = getCellColIndex(treeData, index)
} else {
// 非第一行數(shù)據(jù),先找parent.c再找自身的index和累計的cs
const parentColIndex = positionMap[parent.id].c;
// 第一個子節(jié)點就要基于父節(jié)點的colIndex來加,后面的子節(jié)點就直接拿前面兄弟節(jié)點的colIndex + cs
positionMap[item.id].c = getCellColIndex(parent.children, index, parentColIndex);
}
})
return positionMap;
}
其中的positionMap 是一個記錄了 每個節(jié)點的 c 、r、 rs、 cs信息的map如:
positionMap = {
'id1': {
c: 0,
cs: 2,
r: 2,
rs: 3
},
'id2': {
c: 0,
cs: 3,
r: 1,
rs: 2
}
}
通過上面的setCellColIndex方法就能補全positionMap 的每個節(jié)點的c信息。同時我們注意到同層節(jié)點的后一個節(jié)點的結(jié)果 依賴前一個節(jié)點的結(jié)果,這其實有點類似我另外一篇文章 斐波那契數(shù)列解法有感: 遞歸+緩存 or 動態(tài)規(guī)劃里面提到的場景。而在這里我其實用到的是“遞歸+緩存”的思想,而不是動態(tài)規(guī)劃,因為這里是要計算所有節(jié)點的信息,動態(tài)規(guī)劃只適于求某個節(jié)點的信息,動態(tài)規(guī)劃在這里就不是最優(yōu)解了。
最后
感謝你仔細的閱讀,希望你可以從中獲得一些感悟與共鳴,以上如有不對,不煩指出,不勝感激。