Yjs与Vue3的完美结合:手把手教你实现实时协同任务列表
Yjs与Vue3的完美结合:手把手教你实现实时协同任务列表
在当今远程协作成为常态的背景下,实时协同功能已经从"锦上添花"变成了"必不可少"。想象一下,你的团队正在使用一个任务管理工具,每个人都能即时看到其他人的更新,无需刷新页面,没有数据冲突——这就是CRDT技术带来的魔法。本文将带你用Vue3和Yjs构建这样一个系统,从零开始实现一个真正实时的协作式任务列表。
1. 环境准备与项目初始化
首先确保你的开发环境已经安装了Node.js(建议16.x或更高版本)和npm/yarn。我们将使用Vite来快速搭建Vue3项目,这是目前最轻量高效的Vue开发工具链。
npm create vite@latest yjs-vue-todo --template vue-ts cd yjs-vue-todo npm install yjs @syncedstore/core y-webrtc安装完成后,项目结构应该如下所示:
yjs-vue-todo/ ├── node_modules/ ├── public/ ├── src/ │ ├── assets/ │ ├── components/ │ ├── App.vue │ └── main.ts ├── package.json └── vite.config.ts我们需要额外创建一个store.ts文件来存放我们的协同状态逻辑:
// src/store.ts import { syncedStore, getYjsDoc } from "@syncedstore/core"; import { WebrtcProvider } from "y-webrtc"; type TodoItem = { title: string; completed: boolean; createdAt: number; }; export const store = syncedStore({ todos: [] as TodoItem[], room: "default-room" }); const doc = getYjsDoc(store); export const webrtcProvider = new WebrtcProvider("yjs-vue-todo-room", doc);2. 核心协同逻辑实现
Yjs的核心在于它的CRDT(Conflict-Free Replicated Data Type)算法。简单来说,这种数据结构允许多个用户同时编辑同一份数据,而无需担心冲突问题。当网络延迟或离线编辑发生时,所有更改最终会收敛到一致状态。
让我们深入看看如何将这一机制应用到我们的任务列表中:
2.1 状态管理与Vue集成
SyncedStore是Yjs的一个上层抽象,它让我们可以用更直观的方式操作共享状态。在Vue中启用绑定支持:
// src/main.ts import { createApp } from 'vue' import App from './App.vue' import { enableVueBindings } from '@syncedstore/core'; import * as Vue from 'vue'; enableVueBindings(Vue); createApp(App).mount('#app')现在,我们可以在组件中直接使用这个共享store:
<!-- src/App.vue --> <script setup lang="ts"> import { store } from './store' import { ref } from 'vue' const newTodo = ref('') const addTodo = () => { if (!newTodo.value.trim()) return store.todos.push({ title: newTodo.value.trim(), completed: false, createdAt: Date.now() }) newTodo.value = '' } const toggleTodo = (index: number) => { store.todos[index].completed = !store.todos[index].completed } const removeTodo = (index: number) => { store.todos.splice(index, 1) } </script>2.2 WebRTC对等连接配置
Y-webrtc使用WebRTC技术建立点对点连接,这意味着数据可以直接在客户端之间传输,而不必经过中央服务器。这既提高了速度,又增强了隐私性。
// 在store.ts中扩展配置 export const webrtcProvider = new WebrtcProvider("yjs-vue-todo-room", doc, { signaling: ['wss://signaling.yjs.dev'], password: 'optional-room-password', awareness: { // 可以添加用户状态信息 user: { name: 'Anonymous', color: '#000000' } } })提示:在生产环境中,你可能需要自建信令服务器以获得更好的稳定性。Yjs官方提供了信令服务器的Docker镜像。
3. 构建任务列表UI
现在让我们创建一个功能完整且美观的任务列表界面。我们将使用Vue的组合式API和TypeScript来确保类型安全。
<template> <div class="todo-app"> <h1>协同任务列表</h1> <div class="input-area"> <input v-model="newTodo" @keyup.enter="addTodo" placeholder="输入新任务..." /> <button @click="addTodo">添加</button> </div> <div class="filter-controls"> <button :class="{ active: filter === 'all' }" @click="filter = 'all'" >全部</button> <button :class="{ active: filter === 'active' }" @click="filter = 'active'" >未完成</button> <button :class="{ active: filter === 'completed' }" @click="filter = 'completed'" >已完成</button> </div> <ul class="todo-list"> <li v-for="(todo, index) in filteredTodos" :key="todo.createdAt" :class="{ completed: todo.completed }" > <input type="checkbox" :checked="todo.completed" @change="toggleTodo(index)" /> <span>{{ todo.title }}</span> <button @click="removeTodo(index)">删除</button> </li> </ul> <div class="stats"> <span>{{ activeCount }} 项待完成</span> <button v-if="completedCount > 0" @click="clearCompleted" >清除已完成</button> </div> </div> </template> <script setup lang="ts"> import { store } from './store' import { ref, computed } from 'vue' const newTodo = ref('') const filter = ref<'all' | 'active' | 'completed'>('all') const filteredTodos = computed(() => { switch (filter.value) { case 'active': return store.todos.filter(t => !t.completed) case 'completed': return store.todos.filter(t => t.completed) default: return [...store.todos].sort((a, b) => a.createdAt - b.createdAt) } }) const activeCount = computed(() => store.todos.filter(t => !t.completed).length) const completedCount = computed(() => store.todos.filter(t => t.completed).length) const clearCompleted = () => { const completedIndexes = store.todos .map((t, i) => t.completed ? i : -1) .filter(i => i !== -1) .reverse() completedIndexes.forEach(i => store.todos.splice(i, 1)) } </script> <style scoped> /* 添加适当的样式 */ .todo-app { max-width: 600px; margin: 0 auto; padding: 20px; } .todo-list { list-style: none; padding: 0; } .todo-list li { display: flex; align-items: center; padding: 10px; border-bottom: 1px solid #eee; } .todo-list li.completed span { text-decoration: line-through; color: #999; } .input-area { display: flex; margin-bottom: 20px; } .input-area input { flex-grow: 1; padding: 8px; margin-right: 10px; } .filter-controls { margin-bottom: 20px; } .filter-controls button { margin-right: 5px; background: none; border: 1px solid #ddd; padding: 5px 10px; cursor: pointer; } .filter-controls button.active { background: #eee; } .stats { margin-top: 20px; color: #666; font-size: 0.9em; display: flex; justify-content: space-between; } </style>4. 高级功能与优化
一个真正的生产级协同应用需要考虑更多细节。让我们添加一些增强功能:
4.1 用户状态感知
Yjs的Awareness API可以让我们知道谁正在编辑文档:
// 在store.ts中添加 export const awareness = webrtcProvider.awareness awareness.setLocalStateField('user', { name: prompt('请输入你的名字') || '匿名用户', color: `#${Math.floor(Math.random()*16777215).toString(16)}` })然后在组件中显示在线用户:
<template> <div class="online-users"> <h3>在线用户 ({{ onlineUsers.length }})</h3> <div v-for="user in onlineUsers" :key="user.id" class="user-badge" :style="{ backgroundColor: user.state.user.color }" > {{ user.state.user.name }} </div> </div> </template> <script setup lang="ts"> import { awareness } from './store' import { ref, onMounted, onUnmounted } from 'vue' const onlineUsers = ref<{id: number, state: any}[]>([]) const updateUsers = () => { onlineUsers.value = Array.from(awareness.getStates().entries()) .map(([id, state]) => ({ id, state })) } onMounted(() => { awareness.on('change', updateUsers) updateUsers() }) onUnmounted(() => { awareness.off('change', updateUsers) }) </script> <style scoped> .online-users { margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; } .user-badge { display: inline-block; padding: 3px 8px; margin: 0 5px 5px 0; border-radius: 3px; color: white; font-size: 0.8em; } </style>4.2 离线支持与数据持久化
Yjs天生支持离线编辑,但我们需要添加本地持久化来防止数据丢失:
// 在store.ts中添加 import * as Y from 'yjs' import { IndexeddbPersistence } from 'y-indexeddb' // 添加IndexedDB持久化 export const persistence = new IndexeddbPersistence('yjs-vue-todo', doc) persistence.whenSynced.then(() => { console.log('数据已从本地存储加载') })4.3 冲突解决与历史记录
Yjs内置了撤销/重做功能,我们可以轻松实现:
// 在store.ts中添加 import { UndoManager } from 'yjs' // 为todos数组创建撤销管理器 export const undoManager = new UndoManager(doc.getArray('todos'), { captureTimeout: 500 // 将500ms内的操作合并为一个撤销步骤 })然后在组件中添加对应的控制按钮:
<template> <div class="undo-controls"> <button @click="undo" :disabled="!canUndo">撤销</button> <button @click="redo" :disabled="!canRedo">重做</button> </div> </template> <script setup lang="ts"> import { undoManager } from './store' import { ref, onMounted, onUnmounted } from 'vue' const canUndo = ref(false) const canRedo = ref(false) const updateButtons = () => { canUndo.value = undoManager.undoStack.length > 0 canRedo.value = undoManager.redoStack.length > 0 } const undo = () => { undoManager.undo() updateButtons() } const redo = () => { undoManager.redo() updateButtons() } onMounted(() => { undoManager.on('stack-item-added', updateButtons) undoManager.on('stack-item-popped', updateButtons) updateButtons() }) onUnmounted(() => { undoManager.off('stack-item-added', updateButtons) undoManager.off('stack-item-popped', updateButtons) }) </script>5. 部署与性能优化
当应用准备上线时,我们需要考虑一些性能和安全方面的优化:
5.1 生产环境配置
// 修改WebRTC提供者配置 export const webrtcProvider = new WebrtcProvider(process.env.VITE_ROOM_NAME || 'default-room', doc, { signaling: process.env.VITE_SIGNALING_SERVER ? [process.env.VITE_SIGNALING_SERVER] : ['wss://signaling.yjs.dev'], password: process.env.VITE_ROOM_PASSWORD, maxConns: 20, // 限制每个客户端的连接数 filterBcConns: true // 过滤掉浏览器扩展的连接 })5.2 性能监控
添加性能监控可以帮助我们发现潜在问题:
// 监控文档更新频率 setInterval(() => { const stats = { updateCount: doc.store.updates.length, gcSize: doc.gcSize, memoryUsed: doc.memoryUsed } console.log('文档统计:', stats) }, 10000)5.3 安全性增强
对于敏感应用,可以考虑添加额外的安全层:
// 加密文档内容 import { encrypt, decrypt } from 'yjs-encryption' const encryptionKey = 'your-secret-key' // 在发送更新前加密 provider.on('update', update => { const encrypted = encrypt(update, encryptionKey) // 发送加密后的更新 }) // 接收更新时解密 provider.on('message', message => { const decrypted = decrypt(message, encryptionKey) Y.applyUpdate(doc, decrypted) })