Luckysheet是一款开源的Excel编辑器,同时也支持协同编辑,官方案例是用java写的,现在我要用node.js实现一版简易的协同编辑功能。
先看效果:
7月7日 (1)
源码在这里:
一、客户端
从github fork Luckysheet源码,打包,然后引入到项目中
<!-- luck sheet -->
<link rel='stylesheet' href='./lucksheet/plugins/css/pluginsCss.css' />
<link rel='stylesheet' href='./lucksheet/plugins/plugins.css' />
<link rel='stylesheet' href='./lucksheet/css/luckysheet.css' />
<link rel='stylesheet' href='./lucksheet/assets/iconfont/iconfont.css' />
<script src="./lucksheet/plugins/js/plugin.js"></script>
<script src="./lucksheet/luckysheet.umd.js"></script>
初始化
var options = {
container: 'excelWrapper', //luckysheet为容器id
allowUpdate: true,
updateUrl: `${this.serverUrl}?fileId=${this.fileId}&userId=${this.userId}&userName=${this.userName}`,
loadUrl: `${this.serverUrl}/api/get-json/${this.fileId}`,
}
luckysheet.create(options)
根据官方文档描述,在初始化时,allowUpdate、updateUrl、loadUrl三个都传值,才可以开启Luckysheet的协同编辑功能,所以初始化时我分别赋值了webSocket地址和请求地址,updateUrl是协同消息的webSocket地址,loadUrl是加载数据的地址。
webSocket地址中传参fileId、userId、userName,这些在后台中会用到,作用是区分ws消息推送给哪些用户和编辑的是否是同一个文件。
监听表格更新
Luckysheet提供了一些钩子函数,在这里,我用到了updated 来监听表格的更新,用sheetActivate来监听sheet页的切换。因为当一个用户新增sheet页时,另一个用户虽然显示有新的sheet页,可是如果不点击进去,是无法实时更新到其他用户编辑内容的。所以需要监听切换sheet页后,需要获取存储在服务端的当前最新的数据,并刷新当前打开的sheet页。
var options = {
container: 'excelWrapper', //luckysheet为容器id
allowUpdate: true,
updateUrl: `${this.serverUrl}?fileId=${this.fileId}&userId=${this.userId}&userName=${this.userName}`,
loadUrl: `${this.serverUrl}/api/get-json/${this.fileId}`,
hook: {
updated () {
self_.upDateSheets()
},
sheetActivate() {
axios.post(`${self_.serverUrl}/api/get-json/${self_.fileId}`).then(res => {
luckysheet.updataSheet({ data: res.data, success: () => {}})
})
}
}
}
luckysheet.create(options)
至此,协同编辑前端核心代码就是这样
表格导入
用官方提供的插件luckyexcel就可以实现
LuckyExcel.transformExcelToLucky(file, (exportJson, luckysheetfile) => {
var options = {
container: 'excelWrapper', //luckysheet为容器id
data: exportJson.sheets,
title: exportJson.info.name,
userInfo: exportJson.info.name.creator,
}
luckysheet.create(options)
})
表格导出
使用第三方插件exceljs和file-saver可实现
import FileSaver from "file-saver";
const Excel = require("exceljs");
var setMerge = function (luckyMerge = {}, worksheet) {
const mergearr = Object.values(luckyMerge);
mergearr.forEach(function (elem) {
// elem格式:{r: 0, c: 0, rs: 1, cs: 2}
// 按开始行,开始列,结束行,结束列合并(相当于 K10:M12)
worksheet.mergeCells(
elem.r + 1,
elem.c + 1,
elem.r + elem.rs,
elem.c + elem.cs
);
});
};
var setBorder = function (luckyBorderInfo, worksheet) {
if (!Array.isArray(luckyBorderInfo)) return;
// console.log('luckyBorderInfo', luckyBorderInfo)
luckyBorderInfo.forEach(function (elem) {
// 现在只兼容到borderType 为range的情况
if (elem.rangeType === "range") {
const border = borderConvert(elem.borderType, elem.style, elem.color);
const rang = elem.range[0];
// console.log('range', rang)
const row = rang.row;
const column = rang.column;
for (let i = row[0] + 1; i < row[1] + 2; i++) {
for (let y = column[0] + 1; y < column[1] + 2; y++) {
worksheet.getCell(i, y).border = border;
}
}
}
if (elem.rangeType === "cell") {
const { col_index, row_index } = elem.value;
const borderData = Object.assign({}, elem.value);
delete borderData.col_index;
delete borderData.row_index;
const border = addborderToCell(borderData, row_index, col_index);
worksheet.getCell(row_index + 1, col_index + 1).border = border;
}
// console.log(rang.column_focus + 1, rang.row_focus + 1)
// worksheet.getCell(rang.row_focus + 1, rang.column_focus + 1).border = border
});
};
/**
* 转换日期为Excel序列号
* @param {string|Date} dateValue - 日期值
* @returns {number} Excel序列号
*/
const convertToExcelDate = (dateValue) => {
if (!dateValue) return null;
let date;
if (dateValue instanceof Date) {
date = dateValue;
} else if (typeof dateValue === "string") {
// 处理日期字符串,例如 "2025/1/20"
date = new Date(dateValue);
} else {
return dateValue; // 如果不是日期则返回原值
}
if (isNaN(date.getTime())) {
return dateValue; // 如果转换失败则返回原值
}
// Excel的起始日期是1900年1月1日
const start = new Date(1900, 0, 1);
const diff = date - start;
const oneDay = 1000 * 60 * 60 * 24;
const excelDate = diff / oneDay + 1;
return excelDate;
};
/**
* 判断是否为纯数字
* @param {string} value - 要判断的值
* @returns {boolean} 是否为纯数字
*/
const isNumeric = (value) => {
if (typeof value !== "string") return false;
return !isNaN(value) && !isNaN(parseFloat(value));
}
/**
* 设置样式和值
*/
const setStyleAndValue = (cellArr, worksheet) => {
if (!Array.isArray(cellArr)) return;
cellArr.forEach((row, rowid) => {
row.forEach((cell, columnid) => {
if (!cell) return;
const fill = fillConvert(cell.bg);
const font = fontConvert(
cell.ff,
cell.fc,
cell.bl,
cell.it,
cell.fs,
cell.cl,
cell.ul
);
const alignment = alignmentConvert(cell.vt, cell.ht, cell.tb, cell.tr);
let value = "";
if (cell.f) {
// 尝试新的公式处理方式
const formulaStr = cell.f.trim();
value = {
formula: formulaStr.startsWith('=') ? formulaStr.substring(1) : formulaStr,
result: cell.v,
// date1904: false // 添加这个属性试试
}
} else if (!cell.v && cell.ct && cell.ct.s) {
value = cell.ct.s.reduce((acc, curr) => acc + curr.v, "");
} else {
// 处理时间格式
if (
cell.ct &&
cell.ct.fa &&
(cell.ct.fa.includes("m") ||
cell.ct.fa.includes("d") ||
cell.ct.fa.includes("y"))
) {
// 如果是日期字符串,转换为Excel序列号
const excelDate = convertToExcelDate(cell.v);
if (excelDate !== null) {
value = excelDate;
worksheet.getCell(rowid + 1, columnid + 1).numFmt = 'yyyy-mm-dd';
} else {
value = cell.v;
}
} else {
value = cell.v;
}
}
if (value !== null) {
// 只在 value 不为 null 时设置单元格值
const letter = createCellPos(columnid);
const target = worksheet.getCell(letter + (rowid + 1));
if (Object.keys(fill).length > 0) {
target.fill = fill;
}
target.font = font;
target.alignment = alignment;
if (value !== undefined && value !== null) {
// 判断是否是数字 设置为数字格式
target.value = isNumeric(value) ? value * 1 : value;
// target.value = value
}
}
});
});
};
var setImages = function (imagesArr, worksheet, workbook) {
if (typeof imagesArr !== "object") return;
for (const key in imagesArr) {
// console.log(imagesArr[key]);
// 通过 base64 将图像添加到工作簿
const myBase64Image = imagesArr[key].src;
// 开始行 开始列 结束行 结束列
const start = { col: imagesArr[key].fromCol, row: imagesArr[key].fromRow };
const end = { col: imagesArr[key].toCol, row: imagesArr[key].toRow };
const imageId = workbook.addImage({
base64: myBase64Image,
extension: "png",
});
worksheet.addImage(imageId, {
tl: start,
br: end,
editAs: "oneCell",
});
}
};
var fillConvert = function (bg) {
if (!bg) {
return {};
}
// const bgc = bg.replace('#', '')
const fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: bg.replace("#", "") },
};
return fill;
};
var fontConvert = function (
ff = 0,
fc = "#000000",
bl = 0,
it = 0,
fs = 10,
cl = 0,
ul = 0
) {
// luckysheet:ff(样式), fc(颜色), bl(粗体), it(斜体), fs(大小), cl(删除线), ul(下划线)
const luckyToExcel = {
0: "微软雅黑",
1: "宋体(Song)",
2: "黑体(ST Heiti)",
3: "楷体(ST Kaiti)",
4: "仿宋(ST FangSong)",
5: "新宋体(ST Song)",
6: "华文新魏",
7: "华文行楷",
8: "华文隶书",
9: "Arial",
10: "Times New Roman ",
11: "Tahoma ",
12: "Verdana",
num2bl: function (num) {
return num !== 0;
},
};
// 出现Bug,导入的时候ff为luckyToExcel的val
let colorValue = fc;
if (fc.indexOf('rgb') !== -1) {
// 处理 rgb 格式
const rgb = fc.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const [r, g, b] = rgb;
colorValue = ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
}
} else {
// 处理十六进制格式
colorValue = fc.replace("#", "");
}
const font = {
name: typeof ff === "number" ? luckyToExcel[ff] : ff,
family: 1,
size: fs,
color: { argb: 'FF' + colorValue },
bold: luckyToExcel.num2bl(bl),
italic: luckyToExcel.num2bl(it),
underline: luckyToExcel.num2bl(ul),
strike: luckyToExcel.num2bl(cl),
};
return font;
};
var alignmentConvert = function (
vt = "default",
ht = "default",
tb = "default",
tr = "default"
) {
// luckysheet:vt(垂直), ht(水平), tb(换行), tr(旋转)
const luckyToExcel = {
vertical: {
0: "middle",
1: "top",
2: "bottom",
default: "top",
},
horizontal: {
0: "center",
1: "left",
2: "right",
default: "left",
},
wrapText: {
0: false,
1: false,
2: true,
default: false,
},
textRotation: {
0: 0,
1: 45,
2: -45,
3: "vertical",
4: 90,
5: -90,
default: 0,
},
};
const alignment = {
vertical: luckyToExcel.vertical[vt],
horizontal: luckyToExcel.horizontal[ht],
wrapText: luckyToExcel.wrapText[tb],
textRotation: luckyToExcel.textRotation[tr],
};
return alignment;
};
var borderConvert = function (borderType, style = 1, color = "#000") {
// 对应luckysheet的config中borderinfo的的参数
if (!borderType) {
return {};
}
const luckyToExcel = {
type: {
"border-all": "all",
"border-top": "top",
"border-right": "right",
"border-bottom": "bottom",
"border-left": "left",
},
style: {
0: "none",
1: "thin",
2: "hair",
3: "dotted",
4: "dashDot", // 'Dashed',
5: "dashDot",
6: "dashDotDot",
7: "double",
8: "medium",
9: "mediumDashed",
10: "mediumDashDot",
11: "mediumDashDotDot",
12: "slantDashDot",
13: "thick",
},
};
const template = {
style: luckyToExcel.style[style],
color: { argb: color.replace("#", "") },
};
const border = {};
if (luckyToExcel.type[borderType] === "all") {
border["top"] = template;
border["right"] = template;
border["bottom"] = template;
border["left"] = template;
} else {
border[luckyToExcel.type[borderType]] = template;
}
// console.log('border', border)
return border;
};
function addborderToCell(borders) {
const border = {};
const luckyExcel = {
type: {
l: "left",
r: "right",
b: "bottom",
t: "top",
},
style: {
0: "none",
1: "thin",
2: "hair",
3: "dotted",
4: "dashDot", // 'Dashed',
5: "dashDot",
6: "dashDotDot",
7: "double",
8: "medium",
9: "mediumDashed",
10: "mediumDashDot",
11: "mediumDashDotDot",
12: "slantDashDot",
13: "thick",
},
};
// console.log('borders', borders)
for (const bor in borders) {
// console.log(bor)
if (borders[bor].color.indexOf("rgb") === -1) {
border[luckyExcel.type[bor]] = {
style: luckyExcel.style[borders[bor].style],
color: { argb: borders[bor].color.replace("#", "") },
};
} else {
border[luckyExcel.type[bor]] = {
style: luckyExcel.style[borders[bor].style],
color: { argb: borders[bor].color },
};
}
}
return border;
}
function createCellPos(n) {
const ordA = "A".charCodeAt(0);
const ordZ = "Z".charCodeAt(0);
const len = ordZ - ordA + 1;
let s = "";
while (n >= 0) {
s = String.fromCharCode((n % len) + ordA) + s;
n = Math.floor(n / len) - 1;
}
return s;
}
/**
* 列宽
* @param columnWidth
* @param worksheet
*/
var setColumnWidth = function (columnWidth, worksheet) {
for (let key in columnWidth) {
worksheet.getColumn(parseInt(key) + 1).width = columnWidth[key] / 7.5;
}
};
/**
* 行高
* @param rowHeight
* @param worksheet
* @param excelType
*/
var setRowHeight = function (rowHeight, worksheet) {
for (let key in rowHeight) {
worksheet.getRow(parseInt(key) + 1).height = rowHeight[key] * 0.75;
}
};
export var exportExcel = async function (luckysheet, value, isDown = false) {
const workbook = new Excel.Workbook();
luckysheet.forEach(function (table) {
// 深拷贝数据以避免修改原始数据
const tableData = JSON.parse(JSON.stringify(table.data));
// 移除预处理公式的代码,让 Excel 自己处理公式格式
if (tableData.length === 0) return true;
const worksheet = workbook.addWorksheet(table.name);
const borderInfo = (table.config && table.config.borderInfo) || {};
const columnWidth = (table.config && table.config.columnlen) || {}; //列宽
const rowHeight = (table.config && table.config.rowlen) || {}; //行高
// 3. 设置单元格合并, 边框, 样式, 值, 图片
setColumnWidth(columnWidth, worksheet);
//行高设置50导出后在ms-excel中打开显示25,在wps-excel中打开显示50这个bug不会修复
setRowHeight(rowHeight, worksheet);
setMerge((table.config && table.config.merge) || {}, worksheet);
setBorder(borderInfo, worksheet);
setStyleAndValue(tableData, worksheet);
setImages(table.images, worksheet, workbook);
return true;
});
let data = await workbook.xlsx.writeBuffer();
const blob = new Blob([data], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8",
});
// 创建File对象
const file = new File([blob], `${value}`, {
type: blob.type,
lastModified: new Date().getTime(),
});
if (isDown) return file;
console.log("导出成功!");
FileSaver.saveAs(blob, `${value}`);
return workbook.xlsx.writeBuffer();
};
二、服务端
使用express进行开发
还记得前边写的,Luckysheet初始化时,updateUrl、loadUrl需要传参吗?
所以在服务端我们最主要就是开发两个接口:1、获取表格数据 2、websocket接口
在这里着重讲解websocket接口
获取表格数据接口
定义一个接口,可以返回Luckysheet所需格式即可
websocket接口
Luckysheet在协同编辑时,向后台推送的数据是经过pako压缩的,所以首先需要定义一个解压数据的方法
const pako = require('pako')
function unzip (str) {
let chartData = str.toString().split("").map((i) => i.charCodeAt(0));
let binData = new Uint8Array(chartData);
let data = '';
try {
data = pako.inflate(binData);
} catch (error) {}
return decodeURIComponent(
String.fromCharCode.apply(null, new Uint16Array(data))
)
}
通过查看Luckysheet源码(假如你看过源码),我们知道了推送ws消息Luckysheet需要如下格式的数据,现在我们定一个返回数据结构体的方法
function callbackdata(user, data, type) {
return JSON.stringify({
createTime: moment().format("YYYY-MM-DD HH:mm:ss"),
data,
id: user.id,
returnMessage: "success",
status: 0,
type,
username: user.name,
});
}
客户端在使用Luckysheet编辑表格时,会往服务端推送一些带有指令的数据
- 当 t===“mv” 时,表示用户光标
- 当 t==="v" 时,表示更新单元格
- 当 t==="sha"时,表示新增sheet页
- 还有很多,不再赘述。。
在这里最重要的就是 mv和v,简单做功能的话,我们只要处理他们两个就行。
当服务端通过ws获取到客户端推送的指令后,我们需要给推送给客户端的数据的type中赋值相应的编码即可。
还是通过Luckysheet源码可知:type === ‘3’时,Luckysheet会处理光标。当 type === ‘2’时,会处理数据更新。
所以我们再定义个指令响应的函数,为数据结构函数callbackdata赋值,并将数据推送给客户端:
function wshandle(user, data) {
this.send(callbackdata(user, data, JSON.parse(data).t === "mv" ? 3 : 2));
}
下面是服务端建立websocket时处理消息广播的代码,比较简单,就不多讲了:
const wss = new WebSocket.Server({ server });
wss.on('connection', (ws, req) => {
const userId = new URL(req.url, 'http://xxx.com').searchParams.get('userId');
const userName = new URL(req.url, 'http://xxx.com').searchParams.get('userName');
const fileId = new URL(req.url, 'http://xxx.com').searchParams.get('fileId');
ws.wid = userId;
ws.wname = userName;
ws.fileid = fileId;
ws.on('message', (data) => {
if (data !== 'rub') {
// 广播消息
wss.clients.forEach((conn) => {
if (conn.fileid !== ws.fileid) return; // 如果与我当前操作文件不一致,则不发送消息给你
if (conn.wid === ws.wid) return; // 如果我操作的是自己,则不发送消息给你
// 使得 this 指向当前连接对象 ,并且保证,操作对象始终是当前用户
try {
wshandle.call(conn, { id: ws.wid, name: ws.wname }, unzip(data));
} catch (error) {}
});
}
});
ws.on('close', () => {
console.log('WebSocket连接已关闭');
});
});