文章目录
-
- 前言
- 问题背景
- [最终方案:纯 JavaScript 实现](#最终方案:纯 JavaScript 实现)
- 核心实现
-
- [1. 协同编辑管理器](#1. 协同编辑管理器)
- [2. WebSocket Provider(小程序版)](#2. WebSocket Provider(小程序版))
- [3. 服务器实现](#3. 服务器实现)
- [4. Vue 组件集成](#4. Vue 组件集成)
- 工作流程
- 性能优化
-
- [1. 增量更新](#1. 增量更新)
- [2. 批量操作](#2. 批量操作)
- [3. 断线重连](#3. 断线重连)
- 对比分析
-
- [Yjs 版本 vs 简化版本](#Yjs 版本 vs 简化版本)
- 适用场景
- 实战经验
-
- [1. 小程序 WebSocket 注意事项](#1. 小程序 WebSocket 注意事项)
- [2. IP 地址配置](#2. IP 地址配置)
- [3. 消息格式](#3. 消息格式)
- 未来优化方向
-
- [1. 数据持久化](#1. 数据持久化)
- [2. 权限控制](#2. 权限控制)
- [3. 操作历史](#3. 操作历史)
- [4. 更智能的冲突解决](#4. 更智能的冲突解决)
- 总结
- 参考资源
- 项目地址
- [✨ 简化版特点](#✨ 简化版特点)
-
- [1. 零依赖问题](#1. 零依赖问题)
- [2. 功能完整](#2. 功能完整)
- [3. 小程序优化](#3. 小程序优化)
- [🚀 使用方法](#🚀 使用方法)
- [🏗️ 架构设计](#🏗️ 架构设计)
- [📁 文件结构](#📁 文件结构)
- [🔧 核心组件](#🔧 核心组件)
-
- [1. SimpleCollaborativeManager](#1. SimpleCollaborativeManager)
- [2. SimpleWebSocketProvider](#2. SimpleWebSocketProvider)
- [3. 简化服务器](#3. 简化服务器)
- [🧪 测试方法](#🧪 测试方法)
-
- [1. 开发者工具测试](#1. 开发者工具测试)
- [2. 真机测试](#2. 真机测试)
- [3. 多设备测试](#3. 多设备测试)
- [🔍 优势对比](#🔍 优势对比)
- [🚨 注意事项](#🚨 注意事项)
-
- [1. 冲突处理](#1. 冲突处理)
- [2. 数据持久化](#2. 数据持久化)
- [3. 扩展性](#3. 扩展性)
- [🔮 未来优化](#🔮 未来优化)
-
- [1. 数据持久化](#1. 数据持久化)
- [2. 权限控制](#2. 权限控制)
- [3. 操作历史](#3. 操作历史)
- [📚 相关文档](#📚 相关文档)
- [🎉 总结](#🎉 总结)
前言
在开发小程序 Excel 协同编辑功能时,我遇到了一个棘手的问题:Yjs 库在小程序环境中无法运行,报错 crypto is not defined。经过多次尝试和重构,我最终放弃了 Yjs,使用纯 JavaScript 实现了一个功能完整的协同编辑方案。这篇文章记录了整个重构过程和技术细节。
问题背景
最初的技术选型
项目初期,我选择了业界流行的 Yjs 作为协同编辑的核心库:
typescript
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
const doc = new Y.Doc()
const provider = new WebsocketProvider('ws://localhost:1234', 'room', doc)
Yjs 基于 CRDT (Conflict-free Replicated Data Type) 算法,能够自动解决冲突,是协同编辑的理想选择。
遇到的问题
然而,在小程序环境中编译时,遇到了致命错误:
js
ReferenceError: crypto is not defined
at vendor.js [sm]:8084
问题根源:
- Yjs 依赖
lib0、y-protocols等库 - 这些库内部使用了 Node.js 的
crypto模块 - 小程序环境不支持 Node.js 的
crypto模块 - 即使使用 polyfill 也无法完全解决
尝试过的解决方案
我尝试了多种方案:
方案 1:Crypto Polyfill
typescript
// crypto-polyfill.ts
const cryptoPolyfill = {
getRandomValues: (array: Uint8Array) => {
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256)
}
return array
}
}
globalThis.crypto = cryptoPolyfill
结果:部分解决,但仍有其他依赖问题。
方案 2:移除有问题的依赖
json
{
"dependencies": {
"yjs": "^13.6.0"
// 移除 lib0, y-protocols, y-websocket
}
}
结果:Yjs 核心库仍然有 crypto 依赖。
方案 3:使用纯净版 Yjs
创建只使用 Yjs 核心 API 的版本,避免复杂的编码库。
结果:仍然无法完全避免 crypto 依赖。
最终方案:纯 JavaScript 实现
经过多次尝试,我决定放弃 Yjs,使用纯 JavaScript 实现协同编辑。
架构设计
js
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 客户端 A │ │ WebSocket │ │ 客户端 B │
│ │ │ 服务器 │ │ │
│ Collaborative │◄───────►│ 房间管理 │◄───────►│ Collaborative │
│ Manager │ │ 消息转发 │ │ Manager │
│ │ │ │ │ │
│ UI 组件 │ └─────────────────┘ │ UI 组件 │
└─────────────────┘ └─────────────────┘
数据结构设计
使用简单的 JSON 结构存储表格数据:
typescript
interface TableData {
cells: Record<string, CellData> // key: "row-col"
mergedCells: Array<{
startRow: number
startCol: number
endRow: number
endCol: number
}>
metadata: {
title: string
rowCount: number
colCount: number
updatedAt: number
}
}
interface CellData {
text: string
formula?: string
dataType?: 'text' | 'number' | 'date'
}
同步协议设计
定义简单的消息格式:
typescript
interface UpdateMessage {
type: 'cellUpdate' | 'insertRow' | 'deleteRow' |
'insertCol' | 'deleteCol' | 'mergeCells' |
'unmergeCells' | 'fullSync'
data: any
timestamp: number
clientId: string
}
核心实现
1. 协同编辑管理器
typescript
export class SimpleCollaborativeManager {
private tableData: TableData
private clientId: string
private updateCallbacks: Array<(data: TableData) => void> = []
constructor() {
this.clientId = this.generateClientId()
this.tableData = {
cells: {},
mergedCells: [],
metadata: {
title: '协同表格',
rowCount: 10,
colCount: 8,
updatedAt: Date.now()
}
}
}
// 设置单元格值
setCellValue(row: number, col: number, value: CellData): UpdateMessage {
const key = `${row}-${col}`
this.tableData.cells[key] = { ...value }
this.tableData.metadata.updatedAt = Date.now()
const updateMessage: UpdateMessage = {
type: 'cellUpdate',
data: { row, col, value },
timestamp: Date.now(),
clientId: this.clientId
}
this.notifyUpdate()
return updateMessage
}
// 应用远程更新
applyUpdate(message: UpdateMessage): void {
if (message.clientId === this.clientId) {
return // 忽略自己的更新
}
switch (message.type) {
case 'cellUpdate':
const { row, col, value } = message.data
const key = `${row}-${col}`
this.tableData.cells[key] = { ...value }
break
case 'insertRow':
this.insertRows(message.data.startRow, message.data.count)
break
// ... 其他操作类型
}
this.notifyUpdate()
}
// 通知更新
private notifyUpdate(): void {
this.updateCallbacks.forEach(callback => {
callback(this.getFullData())
})
}
}
2. WebSocket Provider(小程序版)
关键点:小程序中必须使用 uni.connectSocket 而不是原生 WebSocket:
typescript
export class SimpleWebSocketProvider {
private socketTask: any = null
connect(config: SimpleWebSocketConfig): void {
const url = `${config.serverUrl}/${config.roomName}`
// 使用 uni.connectSocket 而不是 new WebSocket()
this.socketTask = uni.connectSocket({
url: url,
success: () => {
console.log('WebSocket 连接请求发送成功')
}
})
// 监听连接打开
this.socketTask.onOpen(() => {
console.log('WebSocket 连接成功')
config.onStatusChange?.('connected')
this._requestFullSync()
})
// 监听消息
this.socketTask.onMessage((res: any) => {
this._handleMessage(res.data)
})
}
// 发送更新消息
sendUpdate(message: UpdateMessage): void {
this.socketTask.send({
data: JSON.stringify(message),
success: () => {
console.log('消息发送成功')
}
})
}
}
3. 服务器实现
使用纯 Node.js 实现,不依赖任何第三方库:
javascript
const WebSocket = require('ws')
const http = require('http')
// 存储所有房间的数据
const rooms = new Map()
const wss = new WebSocket.Server({ server })
wss.on('connection', (ws, req) => {
const roomName = req.url?.slice(1) || 'default'
const room = getRoomData(roomName)
room.clients.add(ws)
ws.on('message', (message) => {
const data = JSON.parse(message.toString())
if (data.type === 'requestSync') {
// 发送完整数据给请求的客户端
ws.send(JSON.stringify({
type: 'fullSync',
data: room.data,
timestamp: Date.now()
}))
} else {
// 应用更新到房间数据
applyUpdateToRoom(room, data)
// 广播给其他客户端
room.clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(message.toString())
}
})
}
})
})
4. Vue 组件集成
js
<script setup lang="ts">
import { SimpleCollaborativeManager } from '../utils/SimpleCollaborativeManager'
import { SimpleWebSocketProvider } from '../utils/SimpleWebSocketProvider'
const tableManager = new SimpleCollaborativeManager()
const wsProvider = new SimpleWebSocketProvider()
onMounted(() => {
// 监听表格管理器的更新
tableManager.onUpdate((data: TableData) => {
loadAllCells()
})
// 连接 WebSocket
wsProvider.connect({
serverUrl: 'ws://192.168.1.100:1234',
roomName: 'demo-sheet',
onMessage: (message: UpdateMessage) => {
tableManager.applyUpdate(message)
},
onFullSync: (data: TableData) => {
tableManager.setFullData(data)
}
})
})
// 编辑单元格
const finishEdit = () => {
const updateMessage = tableManager.setCellValue(row, col, {
text: editValue.value
})
// 发送更新到服务器
wsProvider.sendUpdate(updateMessage)
}
</script>
工作流程
完整的数据流
js
用户编辑单元格
↓
SimpleCollaborativeManager.setCellValue()
↓
生成 UpdateMessage
↓
SimpleWebSocketProvider.sendUpdate()
↓
服务器接收并广播
↓
其他客户端接收 UpdateMessage
↓
SimpleCollaborativeManager.applyUpdate()
↓
触发 onUpdate 回调
↓
UI 自动更新
冲突处理
虽然没有 CRDT 算法,但使用时间戳和客户端 ID 也能处理大部分冲突:
typescript
applyUpdate(message: UpdateMessage): void {
// 忽略自己的更新
if (message.clientId === this.clientId) {
return
}
// 使用时间戳判断优先级
const currentTimestamp = this.tableData.metadata.updatedAt
if (message.timestamp < currentTimestamp) {
console.warn('收到过期的更新,忽略')
return
}
// 应用更新...
}
性能优化
1. 增量更新
只传输变化的数据,不传输整个文档:
typescript
// 只发送单个单元格的更新
{
type: 'cellUpdate',
data: { row: 0, col: 0, value: { text: 'Hello' } }
}
// 而不是发送整个表格
2. 批量操作
将多个操作合并为一个消息:
typescript
const batchUpdate = {
type: 'batchUpdate',
operations: [
{ type: 'cellUpdate', data: {...} },
{ type: 'cellUpdate', data: {...} },
{ type: 'cellUpdate', data: {...} }
]
}
3. 断线重连
实现指数退避算法:
typescript
private _handleDisconnect(): void {
// 1s, 2s, 4s, 8s, 16s, 30s
const delay = Math.min(
Math.pow(2, this.reconnectAttempts) * 1000,
30000
)
setTimeout(() => {
this._connect()
}, delay)
}
对比分析
Yjs 版本 vs 简化版本
| 特性 | Yjs 版本 | 简化版本 |
|---|---|---|
| 包体积 | 大(~500KB) | 小(~50KB) |
| 编译速度 | 慢(复杂依赖) | 快(无依赖) |
| 小程序兼容性 | 差(crypto 问题) | 优(纯 JS) |
| 冲突解决 | CRDT 算法(完美) | 时间戳(够用) |
| 学习成本 | 高 | 低 |
| 功能完整性 | 完整 | 完整 |
| 实时性 | 优秀 | 优秀 |
| 离线支持 | 优秀 | 一般 |
适用场景
选择 Yjs 的场景:
- Web 应用(浏览器环境)
- 需要完美的冲突解决
- 需要离线编辑支持
- 复杂的协同编辑需求
选择简化版本的场景:
- 小程序环境
- 简单的协同编辑需求
- 对包体积敏感
- 需要快速开发和部署
实战经验
1. 小程序 WebSocket 注意事项
typescript
// ❌ 错误:小程序不支持原生 WebSocket
const ws = new WebSocket('ws://localhost:1234')
// ✅ 正确:使用 uni.connectSocket
const socketTask = uni.connectSocket({
url: 'ws://localhost:1234'
})
2. IP 地址配置
小程序不支持 localhost,必须使用局域网 IP:
typescript
// ❌ 错误
const serverUrl = 'ws://localhost:1234'
// ✅ 正确
const serverUrl = 'ws://192.168.1.100:1234'
3. 消息格式
使用 JSON 而不是二进制格式:
typescript
// ✅ 简单易调试
socketTask.send({
data: JSON.stringify(message)
})
// ❌ 小程序中处理二进制较复杂
socketTask.send({
data: new Uint8Array([...])
})
未来优化方向
1. 数据持久化
javascript
// 使用 Redis 存储房间数据
const redis = require('redis')
const client = redis.createClient()
await client.set(`room:${roomName}`, JSON.stringify(room.data))
2. 权限控制
typescript
interface UpdateMessage {
type: string
data: any
timestamp: number
clientId: string
userId?: string // 添加用户 ID
permissions?: string[] // 添加权限控制
}
3. 操作历史
typescript
interface HistoryEntry {
message: UpdateMessage
timestamp: number
canUndo: boolean
}
class HistoryManager {
private history: HistoryEntry[] = []
undo(): void {
const entry = this.history.pop()
if (entry?.canUndo) {
this.applyReverseOperation(entry.message)
}
}
}
4. 更智能的冲突解决
typescript
class ConflictResolver {
resolve(local: UpdateMessage, remote: UpdateMessage): UpdateMessage {
// 基于操作类型的冲突解决策略
if (local.type === 'cellUpdate' && remote.type === 'cellUpdate') {
// 使用时间戳或优先级
return local.timestamp > remote.timestamp ? local : remote
}
// ... 其他策略
}
}
总结
从 Yjs 到纯 JavaScript 的重构,虽然失去了 CRDT 算法的完美冲突解决能力,但获得了:
- ✅ 完美的小程序兼容性 - 彻底解决 crypto 问题
- ✅ 更小的包体积 - 从 500KB 减少到 50KB
- ✅ 更快的编译速度 - 无复杂依赖
- ✅ 更容易理解和维护 - 纯 JavaScript 实现
- ✅ 功能完整 - 支持所有核心协同编辑功能
对于大多数小程序协同编辑场景,这个简化版本已经足够使用。如果未来需要更复杂的功能,可以考虑:
- 在服务端使用 Yjs 处理冲突
- 客户端只负责 UI 和简单的数据同步
- 这样既能利用 Yjs 的优势,又能避免小程序兼容性问题
参考资源
项目地址
完整代码已开源,欢迎 Star 和 Fork:
关键词:小程序、协同编辑、Yjs、WebSocket、Vue3、uniapp、实时同步、CRDT
✨ 简化版特点
1. 零依赖问题
- ❌ 不使用 Yjs
- ❌ 不使用 lib0、y-protocols、y-websocket
- ❌ 不使用任何包含 crypto 的库
- ✅ 纯 JavaScript/TypeScript 实现
- ✅ 只依赖 Vue 3 和 uniapp
2. 功能完整
- ✅ 实时协同编辑
- ✅ 单元格编辑
- ✅ 插入/删除行列
- ✅ 合并/取消合并单元格
- ✅ 范围选择
- ✅ 断线重连
- ✅ 冲突处理
3. 小程序优化
- ✅ 纯 JSON 数据传输
- ✅ 简化的同步协议
- ✅ 更小的包体积
- ✅ 更快的编译速度
🚀 使用方法
快速启动
bash
# 双击运行(Windows)
start-simple.bat
手动启动
bash
# 1. 启动简化服务器
pnpm run simple-server
# 2. 编译小程序
pnpm run dev:mp-weixin
# 3. 修改 IP 地址(见下文)
# 4. 重新编译
pnpm run dev:mp-weixin
🏗️ 架构设计
数据结构
typescript
interface TableData {
cells: Record<string, CellData> // key: "row-col"
mergedCells: Array<{
startRow: number
startCol: number
endRow: number
endCol: number
}>
metadata: {
title: string
rowCount: number
colCount: number
updatedAt: number
}
}
同步协议
typescript
interface UpdateMessage {
type: 'cellUpdate' | 'insertRow' | 'deleteRow' | 'insertCol' | 'deleteCol' | 'mergeCells' | 'unmergeCells' | 'fullSync'
data: any
timestamp: number
clientId: string
}
工作流程
js
客户端 A 编辑
↓
SimpleCollaborativeManager.setCellValue()
↓
生成 UpdateMessage
↓
SimpleWebSocketProvider.sendUpdate()
↓
服务器接收并广播
↓
客户端 B 接收 UpdateMessage
↓
SimpleCollaborativeManager.applyUpdate()
↓
触发 onUpdate 回调
↓
UI 自动更新
📁 文件结构
js
src/
├── utils/
│ ├── SimpleCollaborativeManager.ts # 简化的协同编辑管理器
│ ├── SimpleWebSocketProvider.ts # 简化的 WebSocket Provider
│ ├── PureYjsManager.ts # 纯净 Yjs 管理器(备用)
│ └── PureWebSocketProvider.ts # 纯净 WebSocket Provider(备用)
├── components/
│ ├── SimpleTableMiniapp.vue # 小程序专用表格组件
│ └── SimpleTable.vue # 原版表格组件(H5 使用)
└── pages/index/index.vue # 页面入口
根目录/
├── simple-server.js # 简化服务器
├── server.js # 原版服务器(H5 使用)
├── start-simple.bat # 简化版启动脚本
└── package.json # 依赖配置
🔧 核心组件
1. SimpleCollaborativeManager
typescript
class SimpleCollaborativeManager {
// 不依赖 Yjs,使用简单的 JSON 数据结构
private tableData: TableData
// 核心方法
setCellValue(row, col, value): UpdateMessage
getCellValue(row, col): CellData
insertRows(startRow, count): UpdateMessage
deleteRows(startRow, count): UpdateMessage
mergeCells(range): UpdateMessage
// 协同方法
applyUpdate(message: UpdateMessage): void
getFullData(): TableData
setFullData(data: TableData): void
}
2. SimpleWebSocketProvider
typescript
class SimpleWebSocketProvider {
// 纯原生 WebSocket 实现
connect(config: SimpleWebSocketConfig): void
sendUpdate(message: UpdateMessage): void
sendFullData(data: TableData): void
// 事件处理
onMessage?: (message: UpdateMessage) => void
onFullSync?: (data: TableData) => void
onStatusChange?: (status) => void
}
3. 简化服务器
javascript
// simple-server.js
// 不依赖任何第三方库,纯 Node.js 实现
const WebSocket = require('ws')
const http = require('http')
// 存储房间数据
const rooms = new Map()
// 处理消息类型
// - requestSync: 请求完整同步
// - fullSync: 完整数据同步
// - cellUpdate, insertRow, etc: 增量更新
🧪 测试方法
1. 开发者工具测试
- 在微信开发者工具中运行小程序
- 同时在浏览器打开 H5 版本
- 两边同时编辑,验证同步
2. 真机测试
- 开发者工具中点击「预览」
- 手机扫码打开小程序
- 在开发者工具和手机上同时编辑
3. 多设备测试
- 多个手机扫码预览
- 验证多人协同编辑
- 测试断线重连功能
🔍 优势对比
| 特性 | Yjs 版本 | 简化版本 |
|---|---|---|
| 包体积 | 大(包含复杂依赖) | 小(纯 JS 实现) |
| 编译速度 | 慢(复杂依赖) | 快(简单结构) |
| 小程序兼容性 | 差(crypto 问题) | 优(无依赖问题) |
| 冲突解决 | CRDT 算法 | 时间戳 + 客户端 ID |
| 学习成本 | 高(需要理解 CRDT) | 低(简单 JSON) |
| 功能完整性 | 完整 | 完整 |
| 实时性 | 优秀 | 优秀 |
🚨 注意事项
1. 冲突处理
简化版使用时间戳和客户端 ID 来处理冲突,虽然不如 CRDT 算法完美,但对于大多数场景已经足够。
2. 数据持久化
当前版本数据存储在内存中,服务器重启后数据会丢失。生产环境建议添加数据库持久化。
3. 扩展性
如果需要更复杂的协同编辑功能(如富文本、公式计算),建议回到 Yjs 版本,并解决小程序兼容性问题。
🔮 未来优化
1. 数据持久化
javascript
// 添加 Redis 或数据库支持
const redis = require('redis')
const client = redis.createClient()
// 保存房间数据
await client.set(`room:${roomName}`, JSON.stringify(room.data))
2. 权限控制
typescript
interface UpdateMessage {
type: string
data: any
timestamp: number
clientId: string
userId?: string // 添加用户 ID
permissions?: string[] // 添加权限控制
}
3. 操作历史
typescript
interface HistoryEntry {
message: UpdateMessage
timestamp: number
canUndo: boolean
}
📚 相关文档
MINIAPP-GUIDE.md- 小程序运行指南FINAL-FIX.md- Yjs 版本修复指南blog-collaborative-editing.md- 技术博客
🎉 总结
简化版协同编辑解决方案:
- ✅ 彻底解决了小程序 crypto 问题
- ✅ 保持了完整的协同编辑功能
- ✅ 更适合小程序环境
- ✅ 更容易理解和维护
- ✅ 更小的包体积和更快的编译速度