1. 为什么我要用 Codex 重做在线协作画板
在线协作画板这个需求,看起来简单,真做起来坑不少。核心检索词就三个:Codex、Canvas、WebSocket 实时同步。它本质上是一个「多人共享一块画布」的 Web 应用,适合谁?适合想练手实时协作的前端、想给团队做白板工具的全栈、以及想体验 AI 驱动开发流程的独立开发者。
我之前的做法是手写 Canvas 绘图逻辑,再单独接一套 WebSocket,结果两边状态对不上:本地画完一笔,远端延迟半秒才出现;断线重连后画布直接空白;撤销只撤自己那部分,别人看到的还是旧的。这些问题的根因不是代码写错,而是「绘图状态」和「同步状态」没有统一抽象。
这次我换了个思路:把 Codex 当成一个能听懂架构描述的结对工程师,先让它把绘图引擎、历史管理、WebSocket 客户端三块拆清楚,再逐块生成。实测下来,只要提示词里把「图层用离屏 Canvas」「操作记录用于撤销和同步」这两点讲明白,Codex 生成的骨架基本能直接跑。
下面这套流程,从项目初始化到双端联调,每一步都有可复制的命令和配置。你跟着做,大概两三个小时能跑通一个能多人同时画线的版本。
2. TaoToken 前置:给 Codex 配一个稳定的模型入口
Codex 本身是命令行工具,但它背后要调模型。如果你直接用官方额度,跑这种多轮生成的项目很容易中途限流。我的做法是先把模型入口统一到 TaoToken,这样 Codex、Claude Code、以及后面可能接的 Agent 都走同一个 Key,省得来回切。
TaoToken 在这里的角色是「模型 API 聚合入口」,不是编辑器替代品。你仍然在本地用 Codex 写代码,只是把请求发到 TaoToken 的兼容端点。官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数。
操作顺序是这样:先注册账号,进控制台创建 API Key,然后把 Key 配到环境变量里。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,API Keys 管理页是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。如果你后面要长期跑编码任务,可以看下 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
配置环境变量,Linux/macOS 用:
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"Windows PowerShell:
$env:TAOTOKEN_API_KEY="sk-你的key" $env:TAOTOKEN_BASE_URL="https://taotoken.net/api"配完之后,Codex 的请求就会走这个入口。如果你用的是 Claude Code 那套,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,ClaudeCodeAnthropic 相关配置在 https://taotoken.net/claudecode-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode-anthropic&utm_campaign=rewrite 。想先验证模型通不通,可以直接用模型对话页试一句:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 。
注意:环境变量配好后,新开一个终端窗口再跑 Codex,否则读不到。
3. 可复制配置:项目骨架与 Codex 提示词
3.1 环境检查与项目初始化
先确认本地环境。Node 要 20 以上,pnpm 8 以上,Codex 用最新版:
node -v pnpm -v codex --version然后建项目。我习惯前后端分目录,前端 Vue 3 + Vite,后端 Express + ws:
mkdir collaborative-whiteboard && cd collaborative-whiteboard pnpm create vite client --template vue cd client pnpm add element-plus @element-plus/icons-vue pinia axios pnpm add -D sass cd .. mkdir server && cd server pnpm init pnpm add express ws uuid cors pnpm add -D nodemon cd ..目录结构大概长这样:
collaborative-whiteboard/ ├── client/ │ ├── src/ │ │ ├── components/ │ │ │ ├── ToolBar.vue │ │ │ ├── LayerPanel.vue │ │ │ └── Canvas.vue │ │ ├── composables/ │ │ │ ├── useCanvas.js │ │ │ ├── useHistory.js │ │ │ └── useWebSocket.js │ │ ├── App.vue │ │ └── main.js │ └── package.json ├── server/ │ ├── app.js │ ├── ws-handler.js │ └── package.json └── README.md3.2 给 Codex 的绘图引擎提示词
这一步是整个项目的核心。提示词要讲清楚三件事:图层怎么管、笔迹怎么平滑、操作记录怎么产生。
请实现一个 Canvas 绘图引擎类 DrawingEngine。要求: 1. 管理多个图层,每个图层一个离屏 Canvas 2. 支持画笔自由绘制,用二次贝塞尔曲线平滑 3. 支持矩形、圆形、直线、箭头 4. 支持文字插入 5. 支持橡皮擦,用 destination-out 模式 6. 每次绘制产生一个操作记录,用于撤销和 WebSocket 同步 7. 用 requestAnimationFrame 优化渲染Codex 生成的核心结构是这样的:构造函数里维护layers数组,每个 layer 包含一个离屏 canvas 和它的 ctx;_bindEvents绑定鼠标和触摸事件;_onPointerDown开始绘制,_onPointerMove过程中用quadraticCurveTo平滑,_onPointerUp结束时生成 operation 对象并触发onOperation回调。
关键代码片段:
_drawSmoothLine(point) { const ctx = this.activeLayer.ctx const points = this.currentPath const len = points.length if (len < 3) { ctx.lineTo(point.x, point.y) ctx.stroke() return } const p2 = points[len - 2] const midX = (p2.x + point.x) / 2 const midY = (p2.y + point.y) / 2 ctx.quadraticCurveTo(p2.x, p2.y, midX, midY) ctx.stroke() }onOperation这个回调是后面 WebSocket 同步的入口,一定要留出来。
3.3 WebSocket 消息协议配置
前后端要约定好消息格式,否则联调时对不上。我用的是这套:
| 消息类型 | 方向 | 字段 | 说明 |
|---|---|---|---|
| join | 客户端→服务端 | roomId, nickname | 加入房间 |
| joined | 服务端→客户端 | userId, users, operations | 加入成功,返回历史操作 |
| draw | 双向 | operation | 绘制操作广播 |
| cursor | 双向 | cursor | 光标位置同步 |
| clear | 双向 | userId | 清空画布 |
| user_joined | 服务端→客户端 | userId, nickname, users | 有人加入 |
| user_left | 服务端→客户端 | userId, users | 有人离开 |
服务端用ws库,按房间管理连接。核心逻辑是RoomManager类,维护roomsMap,每个房间有users和operations。新用户加入时,把最近 100 条操作发给他,这样画布状态能追上。
joinRoom(roomId, userId, ws, nickname) { const room = this.getOrCreateRoom(roomId) room.users.set(userId, { ws, nickname, cursor: { x: 0, y: 0 } }) return room }心跳检测每 30 秒一次,用ws.ping(),超时就terminate(),避免僵尸连接占着房间。
3.4 前端 WebSocket 客户端与断线重连
前端用useWebSocket组合式函数封装。断线重连用指数退避,第一次 2 秒,第二次 4 秒,最多 30 秒,重试 10 次后停止。
function attemptReconnect() { if (reconnectAttempts >= maxReconnectAttempts) return reconnectAttempts++ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000) reconnectTimer = setTimeout(() => connect(), delay) }重连成功后要重新joinRoom,否则服务端不知道你是谁。这一点很容易漏,我踩过坑:断线重连后画布能画,但别人看不到,就是因为没重新加入房间。
4. 验证请求:双端联调与同步延迟测试
4.1 启动服务
后端先跑起来:
cd server npx nodemon app.js看到在线画板后端已启动: http://localhost:3002和WebSocket 地址: ws://localhost:3002/ws就对了。
前端另开一个终端:
cd client pnpm dev浏览器打开http://localhost:5173,状态栏显示「已连接」说明 WebSocket 通了。
4.2 双端联调动作
开两个浏览器窗口,都访问http://localhost:5173,在工具栏输入同一个房间号room-001,分别点「加入」。然后:
第一个窗口画一笔,第二个窗口应该立刻出现同样的笔迹。第二个窗口移动鼠标,第一个窗口能看到一个带昵称标签的彩色光标在动。第一个窗口点清空,第二个窗口画布同步清空。
如果笔迹出现但光标不动,检查cursor消息有没有发出去;如果光标动了但笔迹不同步,检查draw消息里的 operation 结构是否一致。
4.3 同步延迟验证
打开浏览器开发者工具的 Network 面板,筛选 WS,看draw消息的时间戳。本地环境下,从发送到接收一般在 10 到 30 毫秒。如果超过 100 毫秒,检查是不是每次mousemove都发了消息——那样会刷屏。正确做法是用rafThrottle把光标同步限制到每帧一次。
export function rafThrottle(fn) { let rafId = null return function (...args) { if (rafId) return rafId = requestAnimationFrame(() => { fn.apply(this, args) rafId = null }) } }5. 本篇常见错排查
5.1 画布空白或笔迹不显示
最常见的原因是_composeLayers没被调用。每次绘制结束、图层切换、可见性变化后都要调一次。另外检查离屏 canvas 的宽高是否和主 canvas 一致,不一致会导致绘制偏移。
5.2 WebSocket 连接失败
先看后端有没有启动,再看端口是不是 3002。如果前端报WebSocket connection to 'ws://localhost:3002/ws' failed,检查app.js里WebSocketServer的path配置是不是/ws。Nginx 部署时还要加proxy_set_header Upgrade $http_upgrade和Connection "upgrade",否则握手失败。
5.3 断线重连后画布不同步
重连后必须重新发join消息,服务端才会把你加回房间并返回历史操作。如果只重连不重新加入,你画的东西服务端不知道发给谁。
5.4 撤销只影响本地
撤销是基于图层快照的,HistoryManager保存的是每个图层的 DataURL。如果撤销后远端没变化,说明撤销操作没有通过 WebSocket 广播。简单做法是撤销后发一条clear加全量重放,复杂做法是发undo消息让远端也执行撤销。我建议先用全量重放,稳定后再优化。
5.5 多人同时画同一区域冲突
Canvas 本身没有冲突检测,后画的覆盖先画的。如果业务需要,可以在 operation 里加layerId和timestamp,服务端按时间排序后重放。但大多数协作白板场景不需要这么严格,先跑通再说。
6. 继续往下走:从能跑到好用
跑通上面这套之后,你手里已经有一个能多人实时画线的版本了。接下来可以做的:加图层面板拖拽排序、加导出 PNG/JPG、加房间列表、加操作历史回放。这些都可以继续用 Codex 生成,提示词里把「基于现有 DrawingEngine 扩展」讲清楚就行。
如果你在接入或排障时卡住,优先看 API Keys 和接入文档:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 和 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。想先验证模型响应是否正常,用模型对话页最快:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 。长期跑编码和 Agent 任务的话,Coding Plan 更划算:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。
最后一个实用技巧:Codex 生成大段代码后,别急着全盘接受。先跑一遍,把报错贴回去让它修,通常两三轮就能稳定。我试过把_composeLayers里棋盘格背景去掉,性能立刻好一截——这种小优化,AI 不会主动提,得你自己判断。