我最近在给一个新项目确定技术栈。
UI 这一项,我原本很自然地写成了:
shadcn/ui(Radix UI)
2026/8/31大约 8 分钟
我最近在给一个新项目确定技术栈。
UI 这一项,我原本很自然地写成了:
shadcn/ui(Radix UI)
由于有大量的爬虫配置需要调试,下载运行后端代码过于笨重,于是使用和 VS Code 同样的编辑器 Monaco Editor 加上 WebSocket 实现在线调试。
对 Monaco Editor 提供的 API 进行一些基础的封装以便提供外部使用,这里有一点需要注意的是读取值 (getValue) 和赋值 (setValue) 的时候需要使用 toRaw 函数对 editor 包裹一层,不然页面会卡死。
根据一个 Vue 创建的代理返回其原始对象。这是一个可以用于临时读取而不引起代理访问/跟踪开销,或是写入而不触发更改的特殊方法。不建议保存对原始对象的持久引用,请谨慎使用。
function toRaw<T>(_proxy_: T): T
业务需要需要导出合并多个表格,为此写了这个工具类,简化了 ExcelJS 原有的函数操作,并能够根据数据结构直接合并表头,省去不少针对每个表格定制合并的心力。该工具类 ExcelJSuu 提供了一系列操作 Excel 的方法,包括构建表头、构建 sheet 二维数据结构、构建基本带边框的表格、合并单元格、设置宽高、设置样式等。
先观察一下 sheetjs !merges 函数合并所需要的数据
workSheet['!merges'] = [ { s: { r: 0, c: 0 }, e: { r: 0, c: 0 } } ];
业务需要,做了个前端导出,由于有部分数据长度不固定,所以合并范围需要动态计算,为了能够支持修改样式,使用了国产的 ExcelJS。
基于国产 ExcelJS 封装的一个工具类,封装简化原有的函数操作,并将使用方式向 sheetjs 靠拢,保持 API 使用一致。
export class ExcelJSuu {
constructor(public worksheet: Worksheet) {}
calcHeaders = calcHeaders;
flatCells = flatCells;
flatColumns = flatColumns;
ColumnCount = 0;
/**
*根据间隔构造排及其合并数据
*
* @param {string[]} row
* @param {number} interval
* @return {*} {{ row: string[][]; merges: Range[] }}
* @memberof ExcelJSuu
*/
buildRowAndMergesByInterval(
row: string[],
interval: number
): { row: string[][]; merges: Range[] } {
const newRow = row.reduce((pre, cur) => {
if (pre.length + 1 == row.length * interval) {
pre.push(cur);
} else {
pre.push(...[cur, ...new Array(interval)]);
}
return pre;
}, []);
const merges = rangeArray(1, newRow.length)
.map((num, i) => {
if (i % (interval + 1) === 0) {
return [1, num, 1, num + interval];
}
return [];
})
.filter((e) => e.length !== 0) as Range[];
return {
row: newRow,
merges,
};
}
/**
*构建表头
*
* @param {IUuColumn[]} columns
* @return {*} {string[][]}
* @memberof ExcelJSuu
*/
buildHeadersAndMerges(columns: IUuColumn[]): {
headers: string[][];
merges: Range[];
} {
const m = calcHeaders(columns);
const mgs = new Array(...m.values()).map(
(e) => [e.top, e.left, e.bottom, e.right] as Range
);
return {
headers: flatCells(m),
merges: mgs,
};
}
/**
*构建 sheet 二维数据结构
*
* @param {IUuTableColumnsType} flatColumns
* @param {any[]} tableData
* @return {*} {string[][]}
* @memberof ExcelJSuu
*/
buildSheet(columns: IUuTableColumnsType, tableData: any[]): string[][] {
const flats = flatColumns(columns);
return tableData.reduce((pre, cur) => {
const arr = [];
flats.forEach((column) => {
arr.push(cur[column.dataIndex]);
});
pre.push(arr);
return pre;
}, []);
}
/**
*根据数据构建基本带边框的表格
*
* @param {IUuTableColumnsType} columns
* @param {any[]} tableData
* @return {*} {string[][]}
* @memberof ExcelJSuu
*/
buildBaseSheet(
columns: IUuTableColumnsType,
tableData: any[],
autoMerge = true
): string[][] {
const headersAndMgs = this.buildHeadersAndMerges(columns);
const sheet = this.buildSheet(columns, tableData);
sheet.unshift(...headersAndMgs.headers);
this.worksheet.addRows(sheet);
autoMerge && this.merges(headersAndMgs.merges);
this.setWidthByColumns(columns);
this.setDefaultStyleByColumn(1, this.worksheet.columnCount);
return sheet;
}
/**
*合并单元格
*
* @param {MergeCells} v
* @memberof ExcelJSuu
*/
merges(
v: ([top: number, left: number, bottom: number, right: number] | number[])[]
): void {
v.forEach((item) => {
this.worksheet.mergeCells(
item as [top: number, left: number, bottom: number, right: number]
);
});
}
/**
*根据 columns 的 width 属性调整表格宽度
*
* @param {IUuTableColumnsType} columns
* @memberof ExcelJSuu
*/
setWidthByColumns(columns: IUuTableColumnsType) {
const flats = flatColumns(columns);
flats.forEach((column, index) => {
this.worksheet.getColumn(index + 1).width = column.width / 5;
});
}
/**
*设置指定范围填充
*
* @param {number} top
* @param {number} left
* @param {number} bottom
* @param {number} right
* @param {Fill} fill
* @memberof ExcelJSuu
*/
setFillByRange(
top: number,
left: number,
bottom: number,
right: number,
fill: Fill
) {
const rows = this.worksheet.getRows(top, bottom);
rangeArray(left, right).forEach((num) => {
rows.forEach((row) => {
row.getCell(num).fill = fill;
});
});
}
/**
*设置默认样式 边框 居中
*
* @param {number} start
* @param {number} end
* @param {object} style
* @memberof ExcelJSuu
*/
setDefaultStyleByColumn(start: number, end: number, style?: Partial<Style>) {
rangeArray(start, end).forEach((num) => {
const column = this.worksheet.getColumn(num);
column.eachCell(function (cell, rowNumber) {
cell.border = {
top: { style: "thin", color: { argb: "ff000000" } },
left: { style: "thin", color: { argb: "ff000000" } },
bottom: { style: "thin", color: { argb: "ff000000" } },
right: { style: "thin", color: { argb: "ff000000" } },
};
cell.alignment = { vertical: "middle", horizontal: "center" };
for (const key in style) {
cell[key] = style[key];
}
});
});
}
/**
*设置宽高
*
* @param {number} start
* @param {number} end
* @param {number} width
* @param {number} height
* @memberof ExcelJSuu
*/
setWidthAndHeight(start: number, end: number, width: number, height: number) {
rangeArray(start, end).forEach((num) => {
const column = this.worksheet.getColumn(num);
column.width = width;
});
const rows = this.worksheet.getRows(start, end);
rows.forEach((row) => {
row.height = height;
});
}
/**
*获取当前范围的列
*
* @param {number} start
* @param {number} end
* @return {*}
* @memberof ExcelJSuu
*/
getRangeByColumn(start: number, end: number) {
return rangeArray(start, end).flatMap((num) => {
const column = this.worksheet.getColumn(num);
return [column];
});
}
/**
*根据列来设置当前范围的样式
*
* @param {number} start
* @param {number} end
* @param {object} [style]
* @memberof ExcelJSuu
*/
setStyleInViewByColumn(
start: number,
end: number,
style?: Partial<Style>
): void {
rangeArray(start, end).forEach((num) => {
const column = this.worksheet.getColumn(num);
column.eachCell(function (cell, rowNumber) {
for (const key in style) {
cell[key] = style[key];
}
});
});
}
/**
*写入 XLSX 文件
*
* @param {Workbook} workbook
* @memberof ExcelJSuu
*/
async writeXLSXFile(
workbook: Workbook,
filename = this.worksheet.name
): Promise<void> {
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/octet-stream" });
openDownloadDialog(blob, filename);
}
/**
*将 row 插入工作簿流 不会破坏原有的结构 并返回新的工作簿
*
* @param {Workbook} workbook
* @param {number} pos
* @param {any[]} values
* @return {*} {Promise<Workbook>}
* @memberof ExcelJSuu
*/
async insertRowWithBuffer(
workbook: Workbook,
pos: number,
values: any[]
): Promise<Workbook> {
const buffer = await workbook.xlsx.writeBuffer();
const newWb = await workbook.xlsx.load(buffer);
const worksheet = newWb.worksheets.find(
(e) => e.name === this.worksheet.name
);
if (!worksheet) {
console.warn("找不到工作表");
return;
}
worksheet.insertRow(pos, values);
return newWb;
}
}
业务需要,做了个前端导出,使用了 XLSX,由于有部分数据长度不固定,所以合并范围需要动态计算。
后面需要调整样式,发现 XLSX 开源版本不支持,切换到国产 ExcelJS,并基于此封装了一个工具类
ExcelJSuu, 封装简化了插件提供的函数,并可以根据页面渲染所需的相同树形数据结构直接
计算出合并范围,最后封装完整后只需要直接传入表头和表格数据即可。
/**
*
* @param queryHeader 第一排表头数组
*/
async function exportTable(queryHeader = tableHeader1.value) {
const header1 = queryHeader ?? [
"学校",
"考试",
"考试时间",
"年级",
"班级",
"科目",
"考试总分",
];
const header2 = ["学号", "姓名", "班级"];
const dynamic = ["分数", "联考排名", "学校排名", "班级排名"];
const headers: [
headerRow1: string[],
headerRow2: string[],
headerRow2: string[]
] = [[], [...header2], new Array(header2.length + 1)];
const [headerRow1, headerRow2, headerRow3] = headers;
const params = deepCopy(showScoreParams.value);
params.limit = 100000000;
const { items } = await api.Internal.ScoreAnalysis.GetScoreRanking_PostAsync(
params,
[]
);
if (items?.length == 0) return;
const [firstItem] = items;
firstItem.scoreDetail
.map((e) => e.subjectName)
.forEach((subjectName) => {
headerRow2.push(...[subjectName, ...new Array(dynamic.length - 1)]);
});
const interval = Math.floor(headerRow2.length / header1.length) - 1; // 间隔
header1.reduce((pre, cur) => {
// pre.push(...[cur, ...new Array(interval)]);
headerRow1.push(...[cur, ...new Array(interval)]);
return pre;
}, []);
for (let i = 0; i < firstItem.scoreDetail.length; i++) {
headerRow3.push(...dynamic);
}
const sheet = items.reduce((pre, cur) => {
const {
studentName,
internalStudentNumber,
schoolName,
className,
examName,
} = cur.info;
const row: (string | number)[] = [
internalStudentNumber,
studentName,
className,
];
cur.scoreDetail.forEach((item) => {
const { subjectName, score, unionExamRanking, ranking, classRanking } =
item;
row.push(...[score, unionExamRanking, ranking, classRanking]);
});
pre.push(row);
return pre;
}, []);
sheet.unshift(...headers);
const ws = utils.aoa_to_sheet(sheet);
/**
* 构造固定列,无定数列表头所合并行列坐标范围
*/
const headerRow1MG = rangeArray(0, headerRow1.length - 1).flatMap(
(num, i) => {
if (i % (interval + 1) === 0) {
return {
s: { c: num, r: 0 },
e: { c: num + interval, r: 0 },
};
}
return [];
}
);
const headerRow2MG = rangeArray(0, header2.length - 1).map((num) => ({
s: { c: num, r: 1 },
e: { c: num, r: 2 },
}));
const headerRow2DynamicMG = rangeArray(
header2.length,
headerRow2.length
).flatMap((num, i) => {
if (i % dynamic.length === 0) {
return {
s: { c: num, r: 1 },
e: { c: num + dynamic.length - 1, r: 1 },
};
}
return [];
});
ws["!merges"] = [
...headerRow1MG,
...headerRow2MG.concat(headerRow2DynamicMG),
];
ws["!cols"] = headerRow3.map((e) => ({ wpx: 120 }));
ws["!rows"] = headerRow3.map((e) => ({ hpx: 32 }));
const wb = utils.book_new();
utils.book_append_sheet(wb, ws, "显示成绩");
writeFile(wb, "学生成绩信息.xlsx");
}
我司有一个阅卷系统,涉及到了阅卷打分,需要写一个涂鸦组件,经过 SVG 和 Canvas 的对比使用了 SVG(后续增加需求开发小程序端,由于小程序不支持 SVG 画图,使用了 canvas)。
当时写这个组件的时候考虑到可能会多个项目使用,于是是作为一个可 npm 安装的独立组件来开发的。
"files": [
"lib"
],
"main": "./lib/ch2-lib.umd.ts",
"module": "./lib/ch2-lib.es.ts",
"types": "./lib/types/packages/index.d.ts",
"exports": {
".": {
"import": "./lib/ch2-lib.es.ts",
"require": "./lib/ch2-lib.umd.ts"
},
"./lib/style.css": {
"import": "./lib/style.css",
"require": "./lib/style.css"
}
},