前些天看到Luckysheet支持协同编辑Excel,正符合我们协同项目的一部分,故而想进一步完善协同文章,但是遇到了一下困难,特此做声明哈,若侵权,请联系我删除文章!
若侵犯版权、个人隐私,请联系删除哈!!!(我可不想踩缝纫机)
Luckysheet ,一款纯前端类似excel的在线表格,功能强大、配置简单、完全开源。当然,也原生支持协同,下面,我们针对协同部分做详细讲解。官网使用的是Java,也有协同的Demo,我就不说了,下面用 Node 实现协同,完整的样例如下,我们开始吧
Luckysheet 基础使用引入依赖CDN<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/css/pluginsCss.css' /><link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/plugins.css' /><link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/css/luckysheet.css' /><link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/assets/iconfont/iconfont.css' /><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 直接引入这个地址的文件就行了(二开一定是引这个地址哈)。
<!-- 引入 luck Sheet 二次开发地址 就是你刚才 build 的那个 dist 包 --> <link rel='stylesheet' href='./luckysheet/dist/plugins/css/pluginsCss.css' /> <link rel='stylesheet' href='./luckysheet/dist/plugins/plugins.css' /> <link rel='stylesheet' href='./luckysheet/dist/css/luckysheet.css' /> <link rel='stylesheet' href='./luckysheet/dist/assets/iconfont/iconfont.css' /> <script src="./luckysheet/dist/plugins/js/plugin.js"></script> <script src="./luckysheet/dist/luckysheet.umd.js"></script>这个方式建议大家都试试,二次开发一定是这个方式哈!
npm如果大家觉得不用二开,就是用原生的功能 ,那直接使用 npm 下载就行了。
npm i luckysheet
<link rel='stylesheet' href='./node_modules/luckysheet/dist/plugins/css/pluginsCss.css' /> <link rel='stylesheet' href='./node_modules/luckysheet/dist/plugins/plugins.css' /> <link rel='stylesheet' href='./node_modules/luckysheet/dist/css/luckysheet.css' /> <link rel='stylesheet' href='./node_modules/luckysheet/dist/assets/iconfont/iconfont.css' /> <script src="./node_modules/luckysheet/dist/plugins/js/plugin.js"></script> <script src="./node_modules/luckysheet/dist/luckysheet.umd.js"></script>初始化指定容器<div id="luckysheet" style="margin:0px;padding:0px;position:absolute;width:100%;height:100%;left: 0px;top: 0px;"></div>创建表格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 数据也就是这样,我也不知道如何进行下去了,就加了官方的微信,就发生了篇头的那张截图。但是革命还在继续。加了官网微信群,特此感谢【小李飞刀刀】的指导。
解析Bufferconst 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));// 表示用户移动鼠标 实际是需要根据指令实现不同的响应的,但是这里统一做 更新数据// 手动传输 userthis.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 < column[1] + 2; y++) { worksheet.getCell(i, y).border = border; } } } if (elem.rangeType === "cell") { // col_index: 2 // row_index: 1 // b: { // color: '#d0d4e3' // style: 1 // } const { col_index, row_index } = elem.value; const borderData = Object.assign({}, elem.value); delete borderData.col_index; delete borderData.row_index; let border = addborderToCell(borderData, row_index, col_index); // console.log('bordre', border, borderData) 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 });};var setStyleAndValue = function (cellArr, worksheet) { if (!Array.isArray(cellArr)) { return; } cellArr.forEach(function (row, rowid) { // const dbrow = worksheet.getRow(rowid+1); // //设置单元格行高,默认乘以1.2倍 // dbrow.height=luckysheet.getRowHeight([rowid])[rowid]*1.2; row.every(function (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 === "yellow" ? "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) { // xls转为xlsx之后,内部存在不同的格式,都会进到富文本里,即值不存在与cell.v,而是存在于cell.ct.s之后 // value = cell.ct.s[0].v cell.ct.s.forEach((arr) => { 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