前言
前些天看到Luckysheet支持协同编辑Excel,正符合我们协同项目的一部分,故而想进一步完善协同文章,但是遇到了一下困难,特此做声明哈,若侵权,请联系我删除文章!

若侵犯版权、个人隐私,请联系删除哈!!!(我可不想踩缝纫机)
Luckysheet ,一款纯前端类似excel的在线表格,功能强大、配置简单、完全开源。当然,也原生支持协同,下面,我们针对协同部分做详细讲解。官网使用的是Java,也有协同的Demo,我就不说了,下面用 Node 实现协同,完整的样例如下,我们开始吧

Luckysheet 基础使用
引入依赖
CDN
<script src="https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/js/plugin.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luckysheet/dist/luckysheet.umd.js"></script>
本地打包
Luckysheet: Luckysheet ,一款纯前端类似excel的在线表格,功能强大、配置简单、完全开源。

https://gitee.com/mengshukeji/Luckysheet
官网建议我们在上网址下载完整的包,这样,我们得到的是luckysheet的源码,可以进行二次开发。很重要哈,最后我们也会这样做。

npm i --s // 执行 npm 命令,进行依赖包的下载
npm run build // 执行打包命令(二次开发是需要修改源码的)
把dist包放到自己的项目中,我已经更名了哈:

然后,index.html 直接引入这个地址的文件就行了(二开一定是引这个地址哈)。
<script src="./luckysheet/dist/plugins/js/plugin.js"></script>
<script src="./luckysheet/dist/luckysheet.umd.js"></script>
这个方式建议大家都试试,二次开发一定是这个方式哈!
npm
如果大家觉得不用二开,就是用原生的功能 ,那直接使用 npm 下载就行了。
npm i luckysheet
<script src="./node_modules/luckysheet/dist/plugins/js/plugin.js"></script>
<script src="./node_modules/luckysheet/dist/luckysheet.umd.js"></script>
初始化
指定容器
创建表格
onMounted(() => {
// 初始化表格
var options = {
container: "luckysheet", //luckysheet为容器id
};
luckysheet.create(options);
});

这样就已经是一个完善的表格编辑器了,支持函数、图表、填充等多项功能。
协同编辑

因此,我们分别配置这几个参数:
loadUrl
配置loadUrl接口地址,加载所有工作表的配置,并包含当前页单元格数据,与loadSheetUrl配合使用。参数为gridKey(表格主键)
$.post(loadurl, {"gridKey" : server.gridKey}, function (d) {})
源码写法如上,因此,我们需要创建一个 post请求的地址:

编辑
app.use("/excel", excelRouter); // 添加公共前缀
配置 loadUrl,加了 baseURL是做了请求代理哈
allowUpdate: true,
loadUrl: "/baseURL/excel",
接口要求返回以下数据,我们直接复制,然后返回:
"[
//status为1的sheet页,重点是需要提供初始化的数据celldata
{
"name": "Cell",
"index": "sheet_01",
"order": 0,
"status": 1,
"celldata": [{"r":0,"c":0,"v":{"v":1,"m":"1","ct":{"fa":"General","t":"n"}}}]
},
//其他status为0的sheet页,无需提供celldata,只需要配置项即可
{
"name": "Data",
"index": "sheet_02",
"order": 1,
"status": 0
},
{
"name": "Picture",
"index": "sheet_03",
"order": 2,
"status": 0
}
]"
本例中,只返回一个sheet表,初始化 0 0 单元格内容为 ‘默认数据’
router.post("/", (req, res, next) => {
// console.log("lucySheet");
let sheetData = [
//status为1的sheet页,重点是需要提供初始化的数据celldata
{
name: "Cell",
index: "sheet_01",
order: 0,
status: 1,
celldata: [
{
r: 0,
c: 0,
v: { v: "默认数据", m: "111", ct: { fa: "General", t: "n" } },
},
],
},
];
res.json(JSON.stringify(sheetData));
});

编辑

编辑
updateUrl
操作表格后,实时保存数据的websocket地址,此接口也是共享编辑的接口地址。注意,发送给后端的数据默认是经过pako压缩过后的。后台拿到数据需要先解压。通过共享编辑功能,可以实现Luckysheet实时保存数据和多人同步数据,每一次操作都会发送不同的参数到后台
因此,我们需要初始化一个 ws 连接:
module.exports = () => {
console.log("等待初始化 WS 服务...");
// 搭建ws服务器
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 9000 });
console.log(" WS 服务初始化成功,连接地址:ws://localhost:9000");
wss.on("connection", (ws, req) => {
console.log("用户连接");
});
};

打开控制台,可以看到连接成功的提示,我们可以一下源码是怎么处理的:

编辑
除了看到输出语句外,我们更应该关注一个 send 事件,因为 websocket 是通过send 发送数据的,还有的是pako.gzip()压缩。因此,服务端监听 message 获取数据:

至此,我们可以获取一些基础信息:
- 每次操作都会发送 send 事件;
- 每次发送的数据都经过 pako.gzip 压缩
- node 获取的都是 buffer 数据
也就是这样,我也不知道如何进行下去了,就加了官方的微信,就发生了篇头的那张截图。但是革命还在继续。加了官网微信群,特此感谢【小李飞刀刀】的指导。

解析Buffer
const pako = require("pako");
/**
* @DESC 导出解压方法
* @param { string } str
* @returns
*/
exports.unzip = (str) => {
let chartData = str
.toString()
.split("")
.map((i) => i.charCodeAt(0));
let binData = new Uint8Array(chartData);
let data = pako.inflate(binData);
return decodeURIComponent(
String.fromCharCode.apply(null, new Uint16Array(data))
);
};

编辑
得到上图,就知道该怎么办了吧,映射的是用户的所有操作哈。需要添加用户标记
let id = Math.random().toString().split(".")[1].slice(0, 3);
// 需要添加自定义属性
ws.wid = id;
ws.wname = "user_" + id;
处理用户光标
我们一定要看源码是如何处理的哈,官网文档并没有那么详细:

因此,同步光标的时候,我们应该发送type =3 的数据,我们封装ws的事件响应中心:
// wss.clients 所有的客户端
wss.clients.forEach((conn) => {
// 不发送给自己
if (conn.wid === ws.wid) return;
// 使得 this 指向当前连接对象
wshandle.call(conn, unzip(data));
});

我们还没做数据同步哈,因此数据没有显示,不影响,先显示用户光标。
同步数据
/**
* ws 事件响应中心
* 根据不同的事件,返回不同的数据
* type 1 成功/失败
* type 2 更新数据
* type 3 用户光标
* type 4 批量处理数据
*/
function wshandle(data) {
// 表示用户移动鼠标 实际是需要根据指令实现不同的响应的,但是这里统一做 更新数据
this.send(callbackdata.call(this, data, JSON.parse(data).t === "mv" ? 3 : 2));
}

至此,协同好像已经实现了,但是还没完。
用户退出
源码中需要返回 {message ,id} 两个数据,因此直接封装 退出函数:

编辑
/**
* 用户退出
*/
function exit() {
this.send(JSON.stringify({ message: "用户退出", id: this.wid }));
}
监听ws close 事件:
ws.on("close", (ws) => {
try {
// 实现用户退出
wss.clients.forEach((conn) => {
if (conn.wid === ws.wid) return;
// 使得 this 指向当前连接对象
exit.call(conn);
});
} catch (error) {
console.log(error);
}
});

BUG修复

不知道大家发现没有,当多人协作时,我们的用户id 是错的,原因是我们move时,传的参数不对:

// 使得 this 指向当前连接对象 ,并且保证,操作对象始终是当前用户
wshandle.call(conn, { id: ws.wid, name: ws.wname }, unzip(data));
// 表示用户移动鼠标 实际是需要根据指令实现不同的响应的,但是这里统一做 更新数据
// 手动传输 user
this.send(callbackdata(user, data, JSON.parse(data).t === "mv" ? 3 : 2));
// function callback:
return JSON.stringify({
createTime: dayjs().format("YYYYMMHH mm:hh:ss"),
data,
id: user.id,
returnMessage: "success",
status: 0,
type,
username: user.name,
});
数据库存储
全量存储
表格操作完成后,使用luckysheet.getAllSheets()方法获取到全部的工作表数据,全部发送到后台存储。

协同存储
协同存储就是用户的每次操作,都会触发 websocket,因此,我们直接在websocket中调用控制层,实现数据的更新,举例说明:
[
{
"data":[], // 每个工作表参数组成的一维数组
"name": "Cell", //工作表名称
"color": "", //工作表颜色
"index": 0, //工作表索引
"status": 1, //激活状态
"order": 0, //工作表的下标
"hide": 0,//是否隐藏
"row": 36, //行数
"column": 18, //列数
"defaultRowHeight": 19, //自定义行高
"defaultColWidth": 73, //自定义列宽
"celldata": [], //初始化使用的单元格数据
"config": {
"merge":{}, //合并单元格
"rowlen":{}, //表格行高
"columnlen":{}, //表格列宽
"rowhidden":{}, //隐藏行
"colhidden":{}, //隐藏列
"borderInfo":{}, //边框
"authority":{}, //工作表保护
},
"scrollLeft": 0, //左右滚动条位置
"scrollTop": 315, //上下滚动条位置
"luckysheet_select_save": [], //选中的区域
"calcChain": [],//公式链
"isPivotTable":false,//是否数据透视表
"pivotTable":{},//数据透视表设置
"filter_select": {},//筛选范围
"filter": null,//筛选配置
"luckysheet_alternateformat_save": [], //交替颜色
"luckysheet_alternateformat_save_modelCustom": [], //自定义交替颜色
"luckysheet_conditionformat_save": {},//条件格式
"frozen": {}, //冻结行列配置
"chart": [], //图表配置
"zoomRatio":1, // 缩放比例
"image":[], //图片
"showGridLines": 1, //是否显示网格线
"dataVerification":{} //数据验证配置
},
// ... 其他 sheet 页数据与上类似
]
上是整个sheet的配置项,数据库表可以根据这个来构建,数据表单独分开、样式表也单独分开,还有基础配置表:


这样就不用存储很多无效的数据,能实现对某一条数据的精确控制与存储,节省数据库存储空间。
文件导入
两种方式实现哈,先隐藏默认,然后自定定位实现添加按钮,或者根据配置项实现配置
/deep/.luckysheet_info_detail_save,
/deep/.luckysheet_info_detail_update {
display: none;
}

npm i luckyexcel
绑定了一个 input ref='importFileRef'
const importFileHandle = (e) => {
let { files } = e.target;
LuckyExcel.transformExcelToLucky(files[0], (exportJson, luckysheetfile) => {
luckysheet.create({
container: "luckysheet", // luckysheet is the container id
data: exportJson.sheets,
title: exportJson.info.name,
userInfo: exportJson.info.name.creator,
});
// 清空
importFileRef.value.value = "";
});
};

但是这样会丢失协同性:
// 文件导入
const importFileHandle = (e) => {
let { files } = e.target;
LuckyExcel.transformExcelToLucky(files[0], (exportJson, luckysheetfile) => {
// 【会丢失协同性】
// luckysheet.create({
// container: "luckysheet", // luckysheet is the container id
// data: exportJson.sheets,
// title: exportJson.info.name,
// userInfo: exportJson.info.name.creator,
// });
let { info, sheets } = exportJson;
luckysheet.setWorkbookName(info.name);
sheets.forEach((sheet) => {
// sheet 便是每一个 sheet 页,需要根据实际的数量动态创建
luckysheet.setSheetAdd({
sheetObject: sheet,
});
});
// 清空
importFileRef.value.value = "";
});
};

文件导出
npm i exceljs file-saver
import Excel from "exceljs";
import FileSaver from "file-saver";
import { ElMessage } from "element-plus";
export const exportExcel = async (name, luckysheet) => {
// 获取 buffer
let buffer = await getBuffer(luckysheet);
download(name, buffer);
};
/**
* 使用 fileSaver 进行文件保存操作
* @param {Buffer} buffer
*/
function download(name, buffer) {
try {
const blob = new Blob([buffer], {
type: "application/vnd.ms-excel;charset=utf-8",
});
FileSaver.saveAs(blob, `${name}.xlsx`);
ElMessage.success("文件导出成功");
} catch (error) {
ElMessage.error("文件导出失败");
}
}
/**
*
* @param { Array as luckysheet.getluckysheetfile() } luckysheet
* @returns
*/
async function getBuffer(luckysheet) {
// 参数为luckysheet.getluckysheetfile()获取的对象
// 1.创建工作簿,可以为工作簿添加属性
const workbook = new Excel.Workbook();
// 2.创建表格,第二个参数可以配置创建什么样的工作表
luckysheet.every(function (table) {
if (table.data.length === 0) return true;
const worksheet = workbook.addWorksheet(table.name);
// 3.设置单元格合并,设置单元格边框,设置单元格样式,设置值
setStyleAndValue(table.data, worksheet);
setMerge(table.config.merge, worksheet);
setBorder(table.config.borderInfo, worksheet);
return true;
});
// 4.写入 buffer
const buffer = await workbook.xlsx.writeBuffer();
return buffer;
}
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的情况
// console.log('ele', elem)
if (elem.rangeType === "range") {
let border = borderConvert(elem.borderType, elem.style, elem.color);
let rang = elem.range[0];
// console.log('range', rang)
let row = rang.row;
let column = rang.column;
for (let i = row[0] + 1; i < row[1] + 2; i++) {
for (let y = column[0] + 1; y < column1 2 y worksheet.getcelli y.border='border;' if elem.rangetype='== "cell")' col_index: 2 row_index: 1 b: color: d0d4e3 style: 1 const col_index row_index const borderdata='Object.assign({},' elem.value delete borderdata.col_index delete borderdata.row_index let border='addborderToCell(borderData,' row_index col_index console.logbordre border borderdata worksheet.getcellrow_index 1 col_index 1.border='border;' console.lograng.column_focus 1 rang.row_focus 1 worksheet.getcellrang.row_focus 1 rang.column_focus 1.border='border' var setstyleandvalue='function' cellarr worksheet if array.isarraycellarr return cellarr.foreachfunction row rowid const dbrow='worksheet.getRow(rowid+1);' 1.2 dbrow.height='luckysheet.getRowHeight([rowid])[rowid]*1.2;' row.everyfunction cell columnid if rowid='= 0)' const dobcol='worksheet.getColumn(columnid' 1 8 dobcol.width='luckysheet.getColumnWidth([columnid])[columnid]' 8 if cell return true let bg='cell.bg' ffffff white bg='bg' ffff00 : bg.replace let fill='{' type: pattern pattern: solid fgcolor: argb: bg let font='fontConvert(' cell.ff cell.fc cell.bl cell.it cell.fs cell.cl cell.ul let alignment='alignmentConvert(cell.vt,' cell.ht cell.tb cell.tr let value if cell.f value='{' formula: cell.f result: cell.v else if cell.v cell.ct cell.ct.s xlsxlsxcell.vcell.ct.s value='cell.ct.s[0].v'> {
value += arr.v;
});
} else {
value = cell.v;
}
// style 填入到_value中可以实现填充色
let letter = createCellPos(columnid);
let target = worksheet.getCell(letter + (rowid + 1));
// console.log('1233', letter + (rowid + 1))
for (const key in fill) {
target.fill = fill;
break;
}
target.font = font;
target.alignment = alignment;
target.value = value;
return true;
});
});
};
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 ? false : true;
},
};
// 出现Bug,导入的时候ff为luckyToExcel的val
//设置字体颜色
fc = fc === "red" ? "FFFF0000" : fc.replace("#", "");
let font = {
name: typeof ff === "number" ? luckyToExcel[ff] : ff,
family: 1,
size: fs,
color: { argb: fc },
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,
},
};
let 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",
},
};
let template = {
style: luckyToExcel.style[style],
color: { argb: color.replace("#", "") },
};
let 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, row_index, col_index) {
let 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) {
let ordA = "A".charCodeAt(0);
let ordZ = "Z".charCodeAt(0);
let len = ordZ - ordA + 1;
let s = "";
while (n >= 0) {
s = String.fromCharCode((n % len) + ordA) + s;
n = Math.floor(n / len) - 1;
}
return s;
}

关联文件
在excel协同的时候,还需要跟我们quill编辑器类似,绑定fileid:
updateUrl:
"ws://localhost:9000?fileid=" +
router.currentRoute.value.params.fileid, // 实现传参,
二开实现websocket的关闭连接:
// 源码中 server.js 添加方法
closeWebSocket: function () {
let _this = this;
if ("WebSocket" in window) {
_this.websocket.close();
} else console.error("## closeWebSocket", locale().websocket.support);
},
global.api(api.js 文件)
/**
* 导出 websocket 的关闭方法:
* luckysheet.wsclose() 进行调用
*/
export function wsclose() {
console.log('调用自定义方法 server.closeWebSocket()')
server.closeWebSocket();
}
重新打包,在需要的地方进行调用:

但是每次关闭连接后,都会alert,把这个关了:


与文件关联后,不是同一个文件的不能协同编辑。
总结
到此,功能都已经开发完了。还是那句话哈:
如果侵权了,请联系删除!
如果侵权了,请联系删除!
如果侵权了,请联系删除!
****对luckysheet的协同做一下总结吧:
- 对pako压缩数据进行解析,这是第一个难点;
- 数据存储按照分布式存储会更快;这里是结合着 loadUrl的哈,后端返回保存后的数据进行渲染;
- luckyexcel 进行文件导入;
- exceljs file-saver 实现文件导出;
- 对源码进行二次开发,实现手动关闭 websocket 连接;
- 还有很多细节哈,大家根据需要可以自行定义,有问题欢迎留言讨论。
作者:朴shu
链接:
https://juejin.cn/post/7298170736480485376