多人协同在线表格简易实现
刀刀
8/8/2025
0 字
0 分钟
- 来源:三十的前端课;视频地址:前往学习
项目搭建与第三方库引入
多人协同在线表格项目使用到的技术栈是:Vue3 + NodeJS + luckysheet。
首先引入 luckysheet
,注意这里不能直接 import
导入使用,而是要找到它在 node_modules
内的 dist
资源,复制粘贴到 public
文件夹内,然后在 index.html
中引入。如果不知道引入哪些资源,可以查看其 官方文档。
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/css/pluginsCss.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/plugins.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/css/luckysheet.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/assets/iconfont/iconfont.css' />
<script src="https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/js/plugin.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/luckysheet.umd.js"></script>
引入成功后现在可以在 .vue
文件中使用了。
luckysheet使用
基础实现
在 .vue
组件中,首先需要创建一个 div
容器,设置对应的 id
名称,用于存放 luckysheet
表格。
在 onMounted
生命周期中,使用 luckysheet.create
方法创建 luckysheet
表格,并传入对应的配置项。具体有哪些配置项可以查看 官方文档。这里简单列几个本案例需要使用的。
<script setup>
onounted(() => {
luckysheet.create({
container: "onlineTable", // 容器id
title: "多人协同在线表格", // 表格名称
lang: "zh", // 语言设置为中文
showinfobar: false, // 隐藏头部信息栏
row: 10, // 初始化行数
// 鼠标右键点击菜单项配置
cellRightClickConfig: {
copy: false, // 禁止右键复制
copyAs: false, // 禁止右键复制为
paste: false, // 禁止右键粘贴
}
})
})
</script>
<template>
<div style="height: 100%;" id="onlineTable"></div>
</template>
[!INPORTANT] 标注 需要给这个
div
绑定高度样式,不然会缩成一坨。
单元格操作
luckysheet
提供了 getAllSheets
方法获取整个表格的数据信息,先来看看数据格式长啥样。
[
{
celldata: [{...}],
ch_width: 4560,
color: '',
config: {},
data: [],
index: 0,
jfgrid_select_save: [],
luckysheet_selection_range: [],
namme: 'Sheet1',
order: '0',
rh_height: 200,
scrollLeft: 0,
status: 0,
visibledatacolumn: [],
visibledatarow: [],
zoomRatio: 1
}
// ...
]
可以看出,它是一个数组的格式,每一个 Sheet
表是一个对象数据,重点看它的 data
,存放了当前表的数据。当前表有几行,就有多少项,如果有10行,就有10项数据。每一项数据是一个对象,是当前行对应的单元格。单元格对象每一个属性的含义可查看 官方文档单元格属性表。
Node实现后端
load接口获取数据
这就是整个文件的数据结构,了解数据结构后就知道如何和后端联调了。和后端联调的主要思路是通过 loadUrl
字段,赋值一个后端链接地址,luckysheet
会自动请求这个链接,获取数据并渲染到表格中。如果有文件,后端会读取文件,返回给前端;没有文件,则创建一个新的 json
文件,并以前端说的文件名命名。
const filename = 'test' // 文件名
onounted(() => {
luckysheet.create({
container: "onlineTable", // 容器id
title: "多人协同在线表格", // 表格名称
lang: "zh", // 语言设置为中文
showinfobar: false, // 隐藏头部信息栏
row: 10, // 初始化行数
gridKey: filename, // 后端要的文件名
loadUrl: 'http://127.0.0.1:8000/load', // 后端数据地址
// 鼠标右键点击菜单项配置
cellRightClickConfig: {
copy: false, // 禁止右键复制
copyAs: false, // 禁止右键复制为
paste: false, // 禁止右键粘贴
}
})
})
下面来模拟写一个后端接口,用于获取文件。
const express = require('express')
const cors = require('cors')
const path = require('path')
const fs = require('fs')
const app = express()
const port = 8000
app.use(cors())
app.use(express.json())
app.post('/load', (req, res) => {
const filename = req.body.gridKey; // 获取文件名
const _path = path.json(__dirname, '/' + filename + '.json') // 拼接文件路径
const _isExist = fs.existsSync(_path) // 判断文件是否存在
if (_isExist) {
let data = fs.readFileSync(_path)
res.send(data) // 返回文件数据
}
else {
const demopath = path.join(__dirname, './empty.json')
const data = fs.readFileSync(demopath)
fs.writeFileSync(path.join(__dirname, '/' + filename + '.json'), data)
res.send(data) // 返回文件数据
}
})
save接口保存数据
主要思路如下:
- 前端获取数据,发送给后端,并附带文件名称
- 删除原文件
- 以前端新数据重写为文件
前端点击了保存按钮后,调用前面提到的 getAllSheets
方法获取整个表格的数据,然后调用接口,发送给后端。
function sendExcel() {
let excelObj = luckysheet.getAllSheets()
axios({
url: 'http://127.0.0.1:8000/save',
method: 'post',
data: {
exceldata: excelObj,
filename
}
})
}
多人协同实现
luckysheet
提供了 cellUpdated
钩子函数,在线表格触发更新之后,会调用这个钩子函数,函数有四个参数:
row
:当前编辑的是第几行,索引从0开始col
:当前编辑的是第几列,索引从0开始before
:当前单元格编辑前的值after
:当前单元格编辑后的值
触发更新后调用 websocket
发送数据,后端接收到数据后,再广播给其他用户。
const nowSheet = 'Sheet1' // 当前编辑的表格
const filename = 'test' // 文件名
onounted(() => {
const ws = new WebSocket('ws://127.0.0.1:8000/ws?filename=' + filename)
luckysheet.create({
container: "onlineTable", // 容器id
title: "多人协同在线表格", // 表格名称
lang: "zh", // 语言设置为中文
showinfobar: false, // 隐藏头部信息栏
row: 10, // 初始化行数
gridKey: fileName, // 后端要的文件名
loadUrl: 'http://127.0.0.1:8000/load', // 后端数据地址
// 鼠标右键点击菜单项配置
cellRightClickConfig: {
copy: false, // 禁止右键复制
copyAs: false, // 禁止右键复制为
paste: false, // 禁止右键粘贴
},
hook: {
// 当前激活的表格被修改时触发
sheetActive() {
nowSheet = luckysheet.getSheetIndex()
},
// 单元格被修改时触发
cellUpdated(row, rol, before, after) {
ws.send(JSON.stringify({ row, rol, after, sheet: nowSheet, filename }))
}
}
})
})
后端那边主要做的操作如下:
- 根据文件名,读取文件
- 找到文件里对应的
Sheet
对象 - 修改对应
Sheet
的单元格数据 - 删除原文件,把修改后的文件写为新数据
- 遍历
ws
连接池 - 给所有当前在编辑文件的用户推送新数据
这里用 NodeJS 实现后端的逻辑。
const cors = require('cors')
const path = require('path')
const qs = require('qs')
const WebSocket = require('ws')
const server = new WebSocket.Server({ port: 8000 })
let sockets = []; // 连接池,存放所有连接的用户
server.on('connection', (ws, req) => {
const queryStr = req.url.split('?')[1] // 获取url参数
const query = qs.parse(queryStr) // 解析url参数
// 存储用户信息到连接池
ws.push({
socket: ws,
filename: query.f
})
// 监听消息
ws.on('message', (message) => {
const data = JSON.parse(message.toString()) // 用户发过来的信息
const _path = path.join(__dirname, '/' + filename + '.json') // 取出要修改的文件
let { row, rol, after, sheet, filename } = data // 解析用户发过来的信息字段
let res = JSON.parse(fs.readFileSync(_path)) // 读取文件
let sheetArr = res.find(item => {
// 找到当前用户编辑的表格
if (itemm.name === sheetName) {
return item
}
})
sheetArr.data[row][rol] = after // 修改对应单元格的数据
fs.unlinkSync(_path) // 删除原文件
fs.writeFileSync(_path, JSON.stringify(res)) // 写入新数据
// 遍历连接池,给所有当前在编辑文件的用户推送新数据
sockets.forEach(user => {
if (user.filename === filename) {
user.socket.send(JSON.stringify(res))
}
})
})
ws.on('close', () => {
sockets = sockets.filter(user => user.socket !== ws) // 关闭连接后,从连接池中移除
})
})
前端这边监听 ws
,有消息时可以拿到被修改的单元格是哪个 sheet
表格、哪一行、哪一列、最新的值是啥,然后调用 luckysheet.setCellValue
方法,把数据更新到表格里。
onounted(() => {
const ws = new WebSocket('ws://127.0.0.1:8000/ws?filename=' + filename)
ws.onmessage = (message) => {
const data = JSON.parse(message.data)
luckysheet.setCellValue(data.sheet, data.row, data.rol, data.after.v, {
isRefresh: true, // 是否刷新单元格
})
}
luckysheet.create({
container: "onlineTable", // 容器id
title: "多人协同在线表格", // 表格名称
lang: "zh", // 语言设置为中文
showinfobar: false, // 隐藏头部信息栏
row: 10, // 初始化行数
gridKey: fileName, // 后端要的文件名
loadUrl: 'http://127.0.0.1:8000/load', // 后端数据地址
// 鼠标右键点击菜单项配置
cellRightClickConfig: {
copy: false, // 禁止右键复制
copyAs: false, // 禁止右键复制为
paste: false, // 禁止右键粘贴
},
hook: {
// 当前激活的表格被修改时触发
sheetActive() {
nowSheet = luckysheet.getSheetIndex()
},
// 单元格被修改时触发
cellUpdated(row, rol, before, after) {
ws.send(JSON.stringify({ row, rol, after, sheet: nowSheet, filename }))
}
}
})
})
BUG修改
目前还剩下一个 bug
需要修改。就是当用户在编辑表格时,会发现一直在调用 cellUpdated
方法,这是因为 luckysheet
在编辑单元格时,会触发 cellUpdated
方法,调用 ws
发送数据,后端接收到数据后,再广播给其他用户,其他用户接收到数据后,会调用 cellUpdated
方法,这样就形成了死循环。
解决方法是,luckysheet
有一个 cellUpdateBefore
方法,在单元格被修改前触发,我们可以在这个方法里判断当前数据是否有没有被修改,没有的话返回 false
,阻止 cellUpdated
方法的调用。
onounted(() => {
const ws = new WebSocket('ws://127.0.0.1:8000/ws?filename=' + filename)
ws.onmessage = (message) => {
const data = JSON.parse(message.data)
luckysheet.setCellValue(data.sheet, data.row, data.rol, data.after.v, {
isRefresh: true, // 是否刷新单元格
})
}
luckysheet.create({
container: "onlineTable", // 容器id
title: "多人协同在线表格", // 表格名称
lang: "zh", // 语言设置为中文
showinfobar: false, // 隐藏头部信息栏
row: 10, // 初始化行数
gridKey: fileName, // 后端要的文件名
loadUrl: 'http://127.0.0.1:8000/load', // 后端数据地址
// 鼠标右键点击菜单项配置
cellRightClickConfig: {
copy: false, // 禁止右键复制
copyAs: false, // 禁止右键复制为
paste: false, // 禁止右键粘贴
},
hook: {
// 当前激活的表格被修改时触发
sheetActive() {
nowSheet = luckysheet.getSheetIndex()
},
// 单元格被修改前触发
cellUpdateBefore(row, rol, data) {
if (luckysheet.getCellValue(row, rol) === data) return false;
},
// 单元格被修改时触发
cellUpdated(row, rol, before, after) {
ws.send(JSON.stringify({ row, rol, after, sheet: nowSheet, filename }))
}
}
})
})
完整代码
<script setup>
const nowSheet = 'Sheet1' // 当前编辑的表格
const filename = 'test' // 文件名
onounted(() => {
const ws = new WebSocket('ws://127.0.0.1:8000/ws?filename=' + filename)
ws.onmessage = (message) => {
const data = JSON.parse(message.data)
luckysheet.setCellValue(data.sheet, data.row, data.rol, data.after.v, {
isRefresh: true, // 是否刷新单元格
})
}
luckysheet.create({
container: "onlineTable", // 容器id
title: "多人协同在线表格", // 表格名称
lang: "zh", // 语言设置为中文
showinfobar: false, // 隐藏头部信息栏
row: 10, // 初始化行数
gridKey: fileName, // 后端要的文件名
loadUrl: 'http://127.0.0.1:8000/load', // 后端数据地址
// 鼠标右键点击菜单项配置
cellRightClickConfig: {
copy: false, // 禁止右键复制
copyAs: false, // 禁止右键复制为
paste: false, // 禁止右键粘贴
},
hook: {
// 当前激活的表格被修改时触发
sheetActive() {
nowSheet = luckysheet.getSheetIndex()
},
// 单元格被修改前触发
cellUpdateBefore(row, rol, data) {
if (luckysheet.getCellValue(row, rol) === data) return false;
},
// 单元格被修改时触发
cellUpdated(row, rol, before, after) {
ws.send(JSON.stringify({ row, rol, after, sheet: nowSheet, filename }))
}
}
})
})
function sendExcel() {
let excelObj = luckysheet.getAllSheets()
axios({
url: 'http://127.0.0.1:8000/save',
method: 'post',
data: {
exceldata: excelObj,
filename
}
})
}
</script>
<template>
<div style="height: 100%;" id="onlineTable"></div>
</template>
const cors = require('cors')
const path = require('path')
const qs = require('qs')
const WebSocket = require('ws')
const fs = require('fs')
const app = express()
const port = 8000
app.use(cors())
app.use(express.json())
const server = new WebSocket.Server({ port: 8000 })
let sockets = []; // 连接池,存放所有连接的用户
app.post('/load', (req, res) => {
const filename = req.body.gridKey; // 获取文件名
const _path = path.json(__dirname, '/' + filename + '.json') // 拼接文件路径
const _isExist = fs.existsSync(_path) // 判断文件是否存在
if (_isExist) {
let data = fs.readFileSync(_path)
res.send(data) // 返回文件数据
}
else {
const demopath = path.join(__dirname, './empty.json')
const data = fs.readFileSync(demopath)
fs.writeFileSync(path.join(__dirname, '/' + filename + '.json'), data)
res.send(data) // 返回文件数据
}
})
server.on('connection', (ws, req) => {
const queryStr = req.url.split('?')[1] // 获取url参数
const query = qs.parse(queryStr) // 解析url参数
// 存储用户信息到连接池
ws.push({
socket: ws,
filename: query.f
})
// 监听消息
ws.on('message', (message) => {
const data = JSON.parse(message.toString()) // 用户发过来的信息
const _path = path.join(__dirname, '/' + filename + '.json') // 取出要修改的文件
let { row, rol, after, sheet, filename } = data // 解析用户发过来的信息字段
let res = JSON.parse(fs.readFileSync(_path)) // 读取文件
let sheetArr = res.find(item => {
// 找到当前用户编辑的表格
if (itemm.name === sheetName) {
return item
}
})
sheetArr.data[row][rol] = after // 修改对应单元格的数据
fs.unlinkSync(_path) // 删除原文件
fs.writeFileSync(_path, JSON.stringify(res)) // 写入新数据
// 遍历连接池,给所有当前在编辑文件的用户推送新数据
sockets.forEach(user => {
if (user.filename === filename) {
user.socket.send(JSON.stringify(res))
}
})
})
ws.on('close', () => {
sockets = sockets.filter(user => user.socket !== ws) // 关闭连接后,从连接池中移除
})
})