小程序协同编辑实战:从 Yjs 到纯 JavaScript 的重构之路

文章目录

    • 前言
    • 问题背景
    • [最终方案:纯 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

问题根源:

  1. Yjs 依赖 lib0y-protocols 等库
  2. 这些库内部使用了 Node.js 的 crypto 模块
  3. 小程序环境不支持 Node.js 的 crypto 模块
  4. 即使使用 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 算法的完美冲突解决能力,但获得了:

  1. 完美的小程序兼容性 - 彻底解决 crypto 问题
  2. 更小的包体积 - 从 500KB 减少到 50KB
  3. 更快的编译速度 - 无复杂依赖
  4. 更容易理解和维护 - 纯 JavaScript 实现
  5. 功能完整 - 支持所有核心协同编辑功能

对于大多数小程序协同编辑场景,这个简化版本已经足够使用。如果未来需要更复杂的功能,可以考虑:

  • 在服务端使用 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. 开发者工具测试

  1. 在微信开发者工具中运行小程序
  2. 同时在浏览器打开 H5 版本
  3. 两边同时编辑,验证同步

2. 真机测试

  1. 开发者工具中点击「预览」
  2. 手机扫码打开小程序
  3. 在开发者工具和手机上同时编辑

3. 多设备测试

  1. 多个手机扫码预览
  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 问题
  • ✅ 保持了完整的协同编辑功能
  • ✅ 更适合小程序环境
  • ✅ 更容易理解和维护
  • ✅ 更小的包体积和更快的编译速度
相关推荐
....4922 小时前
修复 Element Plus (Vue3) 输入框获取焦点时边框消失的问题
javascript·vue.js·ecmascript
jingling5552 小时前
无需重新安装APK | uni-app 热更新技术实战
前端·javascript·前端框架·uni-app·node.js
遇见小美好y2 小时前
uniapp 实现向下追加数据功能
前端·javascript·uni-app
wuhen_n2 小时前
数据缓存策略:让我们的应用“快如闪电”
前端·javascript·vue.js
wuhen_n2 小时前
自定义指令:为 DOM 操作提供高效的抽象入口
前端·javascript·vue.js
Pu_Nine_92 小时前
深入理解 ES6 Map 数据结构:从理论到实战应用
前端·javascript·数据结构·es6
minglie13 小时前
mqtt接入事件回调测试
前端·javascript
Cg136269159743 小时前
js引入方式
前端·javascript·ajax
zhaoyin19943 小时前
JavaScript面试题笔记
java·javascript·笔记