305 KiB
Opentu 编码规则详解
本文档包含项目中积累的具体编码规则和常见错误案例。这是 CLAUDE.md 的详细补充,当需要具体的实现指导时参考本文档。
注意:本文档由 CLAUDE.md 拆分而来,包含详细的错误示例和解决方案。基础编码规范请参考
docs/CODING_STANDARDS.md。
目录
- 架构与重构规范
- 文件命名规范
- TypeScript 规范
- React 组件规范
- CSS/SCSS 规范
- Service Worker 规范
- 缓存与存储规范
- API 与任务处理规范
- Plait 插件规范
- UI 交互规范
- E2E 测试规范
- 数据安全规范
架构与重构规范
循环依赖防回归
场景: 新增共享类型、服务单例、插件 transform、通用 UI、工具 registry 或 barrel export 时
核心原则:
- 类型、常量、元数据只能依赖更底层的纯模块
- 通用 UI 不直接 import 全量业务服务,优先接收回调或使用窄 helper
- service 之间需要互访时,优先用 runtime bridge,而不是静态双向 import
- 插件的纯 transforms/API 与 React 渲染组件分离
- 从 barrel import 前先确认 barrel 没有注册副作用或重组件 re-export
提交前至少跑:
NPM_TOKEN=${NPM_TOKEN:-dummy} pnpm check:cycles
NPM_TOKEN=${NPM_TOKEN:-dummy} pnpm check:cycles:types
详细经验见 docs/CYCLE_DEPENDENCY_LESSONS.md。
避免过度设计和不必要的抽象层
场景: 在重构或添加新功能时,避免引入不必要的架构模式
核心原则:
- 优先使用简单的 interface + service 模式
- 只在有明确需求时才添加抽象层
- 遵循 YAGNI 原则(You Aren't Gonna Need It)
❌ 错误示例 - 过度使用 DDD 模式:
// 错误:为简单的任务管理引入过多抽象层
// domain/task/task.ts (487 行)
export class Task {
private constructor(...) {} // 私有构造函数
static create(...) {} // 工厂方法
start() {} // 状态转换方法
complete() {}
fail() {}
// ... 50+ 个方法
}
// domain/task/task-repository.ts
interface TaskRepository {
findById(id: string): Promise<Task | null>;
save(task: Task): Promise<void>;
}
// infrastructure/persistence/indexeddb-task-repository.ts
class IndexedDBTaskRepository implements TaskRepository {
// 只是包装了现有的 taskStorageReader/Writer
}
// infrastructure/execution/execution-strategy.ts
interface TaskExecutionStrategy {
execute(task: Task): Promise<TaskResult>;
}
// infrastructure/execution/sw-execution-strategy.ts
class SWExecutionStrategy implements TaskExecutionStrategy {
// 包装了一个简单的条件判断
}
// 结果:30 个新文件,3000+ 行代码,但没有解决实际问题
✅ 正确示例 - 保持简单:
// types/task.types.ts - 简单的接口定义
export interface Task {
id: string;
type: TaskType;
status: TaskStatus;
params: GenerationParams;
// ... 其他字段
}
// services/task-queue-service.ts - 直接的服务实现
export class TaskQueueService {
async createTask(params: GenerationParams): Promise<Task> {
const task = { /* ... */ };
await taskStorageWriter.saveTask(task);
return task;
}
async executeTask(task: Task): Promise<void> {
// 简单的条件判断,不需要 Strategy 模式
if (shouldUseSWTaskQueue()) {
await swTaskQueueService.createTask(task.type, task.params);
} else {
await taskQueueService.createTask(task.type, task.params);
}
}
}
// 结果:清晰、简单、易维护
判断是否需要抽象层的标准:
-
Repository 模式:
- ❌ 不需要:只有一种存储实现(如只用 IndexedDB)
- ✅ 需要:需要支持多种存储后端(IndexedDB、LocalStorage、远程 API)
-
Strategy 模式:
- ❌ 不需要:只是简单的 if/else 判断(如
if (shouldUseSW)) - ✅ 需要:有 3+ 种算法需要动态切换,且逻辑复杂
- ❌ 不需要:只是简单的 if/else 判断(如
-
Factory 模式:
- ❌ 不需要:对象创建逻辑简单(如
{ id, type, status }) - ✅ 需要:创建逻辑复杂,需要根据类型创建不同的对象
- ❌ 不需要:对象创建逻辑简单(如
-
Aggregate Root:
- ❌ 不需要:简单的数据结构,状态转换逻辑简单
- ✅ 需要:复杂的业务规则,需要保证不变性约束
原因:
- 过度设计会增加代码量和维护成本
- 抽象层会增加理解难度和调试复杂度
- 简单的问题应该用简单的方案解决
- 只在有明确需求时才引入复杂模式
删除未使用的"基础设施"代码
场景: 创建了"为未来准备"的代码,但实际上没有被使用
核心原则:
- 代码应该解决当前的实际问题
- 未使用的代码应该删除,不要"为未来预留"
- 如果未来真的需要,可以从 Git 历史恢复
❌ 错误示例 - 创建未使用的事件系统:
// services/app-events.ts - 创建了统一事件总线
export function publishEvent(event: AppEvent): void { }
export function subscribeToEventType<T>(type: T, handler: Function) { }
// types/events.types.ts - 定义了完整的事件类型
export interface TaskCreatedEvent extends BaseEvent { }
export interface TaskCompletedEvent extends BaseEvent { }
// ... 10+ 个事件类型
// 问题:没有任何代码使用这些文件
// grep -r "from.*app-events" 返回 0 个结果
✅ 正确做法 - 删除未使用的代码:
# 检查是否有代码使用
grep -r "from.*app-events" packages/drawnix/src
# 如果没有使用,直接删除
rm src/services/app-events.ts
rm src/types/shared/events.types.ts
# 如果未来需要,可以从 Git 历史恢复
git log --all --full-history -- "**/app-events.ts"
判断是否应该保留代码的标准:
-
有实际使用:
- ✅ 保留:至少有 1 处代码导入并使用
- ❌ 删除:没有任何地方使用
-
解决实际问题:
- ✅ 保留:解决了当前存在的问题
- ❌ 删除:只是"可能有用"的基础设施
-
维护成本:
- ✅ 保留:维护成本低,不会成为负担
- ❌ 删除:需要持续维护,但没有实际价值
原因:
- 未使用的代码会增加维护负担
- 会让新开发者困惑("这个是干什么的?")
- Git 历史可以保存所有删除的代码
- YAGNI 原则:不要为未来可能的需求编写代码
重构前先问"解决什么问题"
场景: 在进行架构重构时,应该先明确要解决的实际问题
核心原则:
- 重构应该解决具体的痛点,而不是追求"更好的架构"
- 先列出当前的实际问题,再设计解决方案
- 验证重构是否真的解决了问题
❌ 错误示例 - 为了架构而重构:
// 问题描述:"代码不够优雅,需要引入 DDD"
// 实际情况:代码工作正常,没有明确的痛点
// 重构方案:
// - 添加 domain 层(18 个文件)
// - 添加 application 层(3 个文件)
// - 添加 infrastructure 层(9 个文件)
// 结果:
// - 新增 30 个文件,3000+ 行代码
// - 原有问题没有解决
// - 引入了新的复杂度
✅ 正确示例 - 针对问题重构:
// 问题 1:类型定义在 3 处重复,需要手动同步
// 解决方案:创建共享类型文件
// 结果:删除 2 处重复定义,统一到 1 个文件
// 问题 2:DDD 层增加了 30 个文件但没有解决实际问题
// 解决方案:删除 DDD 层,回归简单的 interface + service
// 结果:删除 30 个文件,减少 3000 行代码
// 验证:
// - 类型定义只有 1 处 ✓
// - 代码量减少 ✓
// - 构建和测试通过 ✓
重构前的检查清单:
-
明确问题:
- 列出当前存在的具体问题
- 问题是否真的影响开发效率或代码质量
- 问题是否频繁出现
-
评估方案:
- 方案是否直接解决问题
- 方案是否引入新的复杂度
- 方案的成本是否合理
-
验证结果:
- 原有问题是否解决
- 是否引入新问题
- 代码是否更简单(文件数、行数)
原因:
- 架构模式不是目的,解决问题才是
- 好的架构应该让代码更简单,而不是更复杂
- 重构应该有明确的收益,而不是"看起来更好"
文件命名规范
- 组件:
PascalCase.tsx(如ImageCropPopup.tsx) - Hooks:
camelCase.ts(如useImageCrop.ts) - 工具:
kebab-case.ts(如image-utils.ts) - 类型:
kebab-case.types.ts(如image-crop.types.ts) - 常量:
UPPER_SNAKE_CASE.ts(如STORAGE_KEYS.ts)
TypeScript 规范
- 对象类型使用
interface,联合类型使用type - 所有组件 Props 必须有类型定义
- 避免使用
any,使用具体类型或泛型
元组类型 vs 数组类型
场景: 当函数参数期望固定长度的元组(如 [Point, Point])时
❌ 错误示例:
// 错误:使用数组类型,TypeScript 无法确定长度
const points: [number, number][] = [
[x1, y1],
[x2, y2],
];
// 类型错误:类型"[number, number][]"不能赋给类型"[Point, Point]"
// 目标仅允许 2 个元素,但源中的元素可能不够
createShape(board, points, shapeType);
✅ 正确示例:
// 正确:显式声明为元组类型
const points: [[number, number], [number, number]] = [
[x1, y1],
[x2, y2],
];
createShape(board, points, shapeType);
原因: [T, T][] 表示"T 的二元组的数组(长度不定)",而 [[T, T], [T, T]] 表示"恰好包含两个 T 二元组的元组"。当 API 期望固定数量的点(如矩形的左上角和右下角)时,必须使用精确的元组类型,否则 TypeScript 无法保证数组长度符合要求。
扩展外部库的枚举类型
场景: 需要在外部库的枚举(如 @plait/common 的 StrokeStyle)基础上添加新值时
❌ 错误示例:
// 错误:直接修改外部库的枚举(无法做到)或使用魔术字符串
import { StrokeStyle } from '@plait/common';
// 无法向 StrokeStyle 添加 'hollow' 值
// 使用字符串字面量会导致类型不兼容
const strokeStyle = 'hollow'; // ❌ 类型不匹配
setStrokeStyle(board, strokeStyle); // 错误:类型 'string' 不能赋给 StrokeStyle
✅ 正确示例:
// 正确:创建扩展类型,同时保持与原始枚举的兼容性
import { StrokeStyle } from '@plait/common';
// 1. 使用联合类型扩展
export type FreehandStrokeStyle = StrokeStyle | 'hollow';
// 2. 创建同名常量对象,合并原始枚举值
export const FreehandStrokeStyle = {
...StrokeStyle,
hollow: 'hollow' as const,
};
// 使用时可以访问所有值
const style1 = FreehandStrokeStyle.solid; // ✅ 原始值
const style2 = FreehandStrokeStyle.hollow; // ✅ 扩展值
// 函数参数使用扩展类型
export const setFreehandStrokeStyle = (
board: PlaitBoard,
strokeStyle: FreehandStrokeStyle // ✅ 接受原始值和扩展值
) => { ... };
原因: TypeScript 的枚举是封闭的,无法在外部添加新成员。通过 "类型 + 同名常量对象" 模式,可以:1) 保持与原始枚举的完全兼容;2) 类型安全地添加新值;3) 在运行时和编译时都能正确使用。这是扩展第三方库类型的标准模式。
Blob 对象的 MIME 类型获取
场景: 处理 File | Blob 联合类型时获取文件的 MIME 类型
❌ 错误示例:
// 错误:假设只有 File 有 type 属性,Blob 时使用默认值
async function addAsset(file: File | Blob) {
const mimeType = file instanceof File
? file.type
: 'application/octet-stream'; // ❌ 忽略了 Blob.type
// 如果 Blob 是通过 new Blob([data], { type: 'image/png' }) 创建的
// 这里会错误地返回 'application/octet-stream'
}
✅ 正确示例:
// 正确:Blob 也有 type 属性,优先使用
async function addAsset(file: File | Blob) {
const mimeType = file instanceof File
? file.type
: (file.type || 'application/octet-stream'); // ✅ 先检查 Blob.type
}
// 或更简洁的写法(File 继承自 Blob,都有 type)
async function addAsset(file: File | Blob) {
const mimeType = file.type || 'application/octet-stream';
}
原因: Blob 构造函数支持通过 options.type 设置 MIME 类型,如 new Blob([data], { type: 'image/png' })。在处理从 ZIP 解压的文件、Canvas 导出的图片等场景时,传入的是带有正确 type 的 Blob 对象。如果忽略 Blob.type,会导致文件类型验证失败。
Import 语句必须放在文件顶部
场景: 添加新的 import 语句时
❌ 错误示例:
interface MyInterface {
name: string;
}
const MAX_SIZE = 100;
// 错误:import 在变量声明之后
import { someUtil } from './utils';
✅ 正确示例:
import { someUtil } from './utils';
interface MyInterface {
name: string;
}
const MAX_SIZE = 100;
原因: ESLint 规则 import/first 要求所有 import 语句必须放在模块最顶部(JSDoc 注释之后),位于任何变量声明、类型定义或其他代码之前。这样做便于快速了解模块的依赖关系,保持代码结构清晰。
类型可推断时移除显式类型注解
场景: 变量直接赋值为字面量时
❌ 错误示例:
// 错误:类型可从字面量推断,显式声明是冗余的
private isEnabled: boolean = false;
private count: number = 0;
private name: string = 'default';
✅ 正确示例:
// 正确:让 TypeScript 自动推断类型
private isEnabled = false;
private count = 0;
private name = 'default';
// 注意:联合类型或复杂类型仍需显式声明
private status: 'pending' | 'done' = 'pending';
private config: Config | null = null;
原因: ESLint 规则 @typescript-eslint/no-inferrable-types 要求移除可从初始值推断的冗余类型注解,保持代码简洁。当变量赋值为 false、true、数字或字符串字面量时,TypeScript 能自动推断类型。
枚举(enum)不能使用 import type 导入
场景: 当枚举既作为类型又作为运行时值使用时
❌ 错误示例:
// 错误:使用 type-only import 导入枚举
import type { TaskType, TaskStatus } from './types';
// 运行时错误:TaskType is not defined
const config = {
[TaskType.IMAGE]: 10000, // ❌ TaskType 在运行时不存在
[TaskType.VIDEO]: 20000,
};
✅ 正确示例:
// 正确:枚举作为值使用时,必须使用普通 import
import { TaskType } from './types';
// 纯类型可以继续使用 import type
import type { TaskStatus, TaskError } from './types';
const config = {
[TaskType.IMAGE]: 10000, // ✅ TaskType 在运行时可用
[TaskType.VIDEO]: 20000,
};
原因: TypeScript 的 import type 在编译时会被完全移除,不会产生任何运行时代码。而 enum 在 TypeScript 中既是类型也是值(会编译为 JavaScript 对象),当代码中使用枚举成员作为对象键或进行比较时,需要运行时存在该值。如果使用 import type 导入枚举,运行时会抛出 ReferenceError: xxx is not defined。
禁止空 catch 块(必须有日志)
场景: 捕获异常后需要记录或处理
❌ 错误示例:
// 错误:静默吞掉错误,调试困难
try {
await swChannelClient.getTask(taskId);
} catch {
return; // 什么都不做
}
// 错误:catch 块为空
try {
await someOperation();
} catch {
// 静默忽略错误
}
✅ 正确示例:
// 正确:至少记录 debug 级别日志
try {
await swChannelClient.getTask(taskId);
} catch (error) {
console.debug('[SWTaskQueue] getTask failed for', taskId, error);
return;
}
// 正确:预期的错误用 warn,严重错误用 error
try {
await criticalOperation();
} catch (error) {
console.warn('[ModuleName] Operation failed:', error);
return fallbackValue;
}
原因: 空 catch 块会静默吞掉错误,导致问题难以排查。即使是预期的错误(如网络超时),也应记录 debug 级别日志,便于调试。使用 console.debug 可在生产环境中隐藏,但开发时能看到。
Service Worker 与主线程模块不共享
场景: 需要在 Service Worker 和主线程中使用相同逻辑时
❌ 错误示例:
// apps/web/src/sw/index.ts
// 错误:SW 中直接导入主线程包
import { sanitizeObject } from '@drawnix/drawnix';
// 会导致打包体积膨胀或循环依赖
✅ 正确示例:
// 正确:SW 和主线程各自维护独立模块
// 主线程版本:packages/drawnix/src/utils/sanitize-utils.ts
export function sanitizeObject(data: unknown): unknown { ... }
// SW 版本:apps/web/src/sw/task-queue/utils/sanitize-utils.ts
export function sanitizeObject(obj: unknown): unknown { ... }
原因: Service Worker 和主线程是完全隔离的执行环境,有各自独立的打包入口。SW 无法直接 import @drawnix/drawnix 包,否则会将整个主线程代码打包进 SW,导致体积膨胀。相同逻辑需要在两个环境中分别维护独立的模块副本。
相关文件:
- 主线程:
packages/drawnix/src/utils/sanitize-utils.ts - Service Worker:
apps/web/src/sw/task-queue/utils/sanitize-utils.ts
模块循环依赖导致 TDZ 错误
场景: 多个服务模块相互导入,形成循环依赖链,导致 ES 模块初始化时的 TDZ (Temporal Dead Zone) 错误
❌ 错误示例:
// task-queue/index.ts - 导入 sw-task-queue-service
import { swTaskQueueService } from '../sw-task-queue-service';
export function shouldUseSWTaskQueue() { ... }
export { swTaskQueueService };
// sw-task-queue-service.ts - 导入 sw-channel/client
import { swChannelClient } from './sw-channel';
export const swTaskQueueService = SWTaskQueueService.getInstance();
// sw-channel/client.ts - 导入 task-queue(循环!)
import { shouldUseSWTaskQueue } from '../task-queue'; // ❌ 循环依赖
export const swChannelClient = SWChannelClient.getInstance();
// 结果:Uncaught ReferenceError: Cannot access 'swChannelClient' before initialization
// chat-workflow-client.ts
const checkAndSubscribe = () => {
// ❌ ES 模块环境不支持 CommonJS require
const { swChannelClient: client } = require('./client');
};
// 结果:Uncaught ReferenceError: require is not defined
✅ 正确示例:
// 1. 将共享函数提取到独立的隔离模块
// task-queue/sw-detection.ts - 不导入任何服务模块
export function shouldUseSWTaskQueue(): boolean {
if (typeof navigator === 'undefined') return false;
if (!('serviceWorker' in navigator)) return false;
return true;
}
// 2. 服务文件从隔离模块导入
// sw-channel/client.ts
import { shouldUseSWTaskQueue } from '../task-queue/sw-detection'; // ✅ 无循环
// 3. 使用服务注册表延迟服务访问
// task-queue/service-registry.ts - 不导入任何服务模块
export const serviceRegistry = {
swTaskQueueService: null,
legacyTaskQueueService: null,
};
export function registerService(name, service) {
serviceRegistry[name] = service;
}
// 4. 服务创建后注册到注册表
// sw-task-queue-service.ts
export const swTaskQueueService = SWTaskQueueService.getInstance();
import { registerService } from './task-queue/service-registry';
registerService('swTaskQueueService', swTaskQueueService);
// 5. 入口文件使用 Proxy 延迟访问
// task-queue/index.ts
import { serviceRegistry } from './service-registry';
export const taskQueueService = new Proxy({}, {
get(_target, prop) {
const service = shouldUseSWTaskQueue()
? serviceRegistry.swTaskQueueService
: serviceRegistry.legacyTaskQueueService;
return service[prop];
},
});
// 6. 单例构造函数中延迟访问其他模块
// sw-task-queue-service.ts
private constructor() {
// ✅ 使用 queueMicrotask 延迟,等待其他模块初始化完成
queueMicrotask(() => {
this.setupSWClientHandlers(); // swChannelClient 此时已初始化
});
}
原因: ES 模块是静态解析的,循环依赖会导致某些模块在被访问时尚未完成初始化。解决方案是将共享代码提取到独立模块(打破依赖链),并使用延迟访问模式(Proxy、queueMicrotask)。
相关文件:
packages/drawnix/src/services/task-queue/sw-detection.ts- 隔离的检测函数packages/drawnix/src/services/task-queue/service-registry.ts- 服务注册表packages/drawnix/src/services/task-queue/index.ts- 使用 Proxy 的入口
Service Worker 枚举值使用小写
场景: 读取 SW 任务队列数据(如 sw-task-queue 数据库)进行过滤时
❌ 错误示例:
// sw-debug 或其他外部模块读取 SW 数据
const TaskStatus = {
COMPLETED: 'COMPLETED', // ❌ 大写
};
const TaskType = {
IMAGE: 'IMAGE', // ❌ 大写
VIDEO: 'VIDEO',
};
// 过滤已完成任务 - 永远匹配不到!
const completedTasks = tasks.filter(
task => task.status === TaskStatus.COMPLETED // 实际数据是 'completed'
);
✅ 正确示例:
// 正确:使用小写,与 SW 定义保持一致
const TaskStatus = {
COMPLETED: 'completed', // ✅ 小写
};
const TaskType = {
IMAGE: 'image', // ✅ 小写
VIDEO: 'video',
};
// 正确匹配
const completedTasks = tasks.filter(
task => task.status === TaskStatus.COMPLETED
);
原因: SW 内部的枚举定义使用小写值(见 apps/web/src/sw/task-queue/types.ts),读取 SW 数据时必须使用相同的值进行匹配。大小写不一致会导致过滤或比较失败,但不会报错,难以调试。
相关文件:
apps/web/src/sw/task-queue/types.ts- SW 枚举定义
共享模块与统一配置模式
场景: 多个功能模块有相似逻辑但细节不同时(如宫格图和灵感图的拆分)
❌ 错误示例:
// image-splitter.ts - 重复的去白边逻辑
function splitGrid(imageUrl: string) {
const borders = trimBorders(imageData, 0.5, 0.15);
// ...
}
// photo-wall-splitter.ts - 几乎相同的逻辑
function splitPhotoWall(imageUrl: string) {
const borders = trimBorders(imageData, 0.5, 0.15);
// ...
}
✅ 正确示例:
// image-split-core.ts - 核心模块,统一配置
export type TrimMode = 'strict' | 'normal' | 'none';
export function getTrimParams(trimMode: TrimMode) {
switch (trimMode) {
case 'strict': return { borderRatio: 1.0, maxTrimRatio: 0.05 };
case 'normal': return { borderRatio: 0.95, maxTrimRatio: 0.05 };
case 'none': return null;
}
}
// image-splitter.ts - 使用配置区分行为
const trimMode: TrimMode = isStandardGrid ? 'strict' : 'normal';
const params = getTrimParams(trimMode);
if (params) {
borders = trimBorders(imageData, params.borderRatio, params.maxTrimRatio);
}
原因:
- 避免代码重复,便于统一维护
- 通过配置类型区分行为,而非复制代码
- 核心模块命名规范:
*-core.ts(如image-split-core.ts)
相关文件:
packages/drawnix/src/utils/image-split-core.ts- 图片拆分核心模块
工具函数组织与导入
场景: 项目中有通用工具函数需要在多个包之间共享
❌ 错误示例:
// packages/drawnix/src/utils/common.ts
// 错误:在业务包中二次导出通用函数
import { generateId, sanitizeObject } from '@aitu/utils';
export { generateId, sanitizeObject }; // ❌ 二次导出
// packages/drawnix/src/services/some-service.ts
// 错误:从业务包导入通用函数
import { generateId } from '../utils/common'; // ❌ 间接导入
✅ 正确示例:
// packages/drawnix/src/services/some-service.ts
// 正确:直接从 @aitu/utils 导入
import { generateId, sanitizeObject } from '@aitu/utils'; // ✅ 直接导入
// packages/drawnix/src/utils/common.ts
// 正确:只保留业务特有的函数
import { IS_APPLE, PlaitBoard, toImage } from '@plait/core';
export const safeToImage = async (board: PlaitBoard, options = {}) => {
// ✅ Plait 导出特有兜底:临时保护内部图片 fetch 失败
return toImage(board, options);
};
export const boardToImage = (board: PlaitBoard) => {
return safeToImage(board, { fillStyle: 'transparent' });
};
原因:
- 二次导出会造成依赖链混乱,增加包体积
- 修改工具函数时难以追踪所有使用位置
- 直接导入语义更清晰,IDE 跳转更准确
@aitu/utils 包含的通用函数:
- ID 生成:
generateId(),generateUUID() - 日期格式化:
formatDate(),formatDuration(),formatFileSize() - DOM 操作:
download(),copyToClipboard() - 安全工具:
sanitizeObject(),sanitizeUrl(),sanitizeRequestBody(),getSafeErrorMessage() - 字符串处理:
truncate(),capitalize(),toKebabCase() - 异步工具:
debounce(),throttle() - Blob 转换:
blobToBase64(),pureBase64ToBlob(),dataUrlToBlob(),blobToDataUrl() - 设备信息:
getDeviceId(),getDeviceName(),getDeviceType() - 格式化:
formatSize(),formatDurationMs(),formatPercent(),formatRelativeTime() - IndexedDB:
openIndexedDB(),getById(),getAll(),getAllWithCursor(),put(),deleteById()
避免不必要的函数包装/别名
场景: 导入工具函数后,创建了一个简单包装函数
❌ 错误示例:
import { truncate, sanitizeRequestBody as sanitizeBody } from '@aitu/utils';
// 错误:不必要的包装,没有添加任何功能
const truncateText = (text: string, maxLength: number) => truncate(text, maxLength);
const sanitizeRequestBody = sanitizeBody;
// 使用
log.prompt = truncateText(prompt, 2000);
log.body = sanitizeRequestBody(body);
✅ 正确示例:
import { truncate, sanitizeRequestBody } from '@aitu/utils';
// 正确:直接使用导入的函数
log.prompt = truncate(prompt, 2000);
log.body = sanitizeRequestBody(body);
原因:
- 不必要的包装增加了代码量和阅读负担
- 在调用链中增加了一层,调试时更难追踪
- 如果确实需要别名,使用
import { x as y }语法即可
React 组件规范
- 使用函数组件和 Hooks
- 使用
React.memo优化重渲染 - 事件处理器使用
useCallback包装 - Hook 顺序:状态 hooks → 副作用 hooks → 事件处理器 → 渲染逻辑
useCallback 定义顺序必须在 useEffect 依赖之前
场景: 当 useEffect 的依赖数组引用某个 useCallback 定义的函数时
❌ 错误示例:
// 错误:handleResetView 在 useEffect 依赖中被引用,但定义在 useEffect 之后
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === '0') {
handleResetView(); // 引用了后面才定义的函数
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleResetView]); // ❌ 运行时错误: Cannot access 'handleResetView' before initialization
const handleResetView = useCallback(() => {
// 重置逻辑
}, []);
✅ 正确示例:
// 正确:被依赖的 useCallback 必须在 useEffect 之前定义
const handleResetView = useCallback(() => {
// 重置逻辑
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === '0') {
handleResetView();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleResetView]); // ✅ 正常工作
原因: JavaScript 的 const 声明有暂时性死区(TDZ),在声明语句执行前访问会抛出 ReferenceError。useEffect 的依赖数组在组件首次渲染时就会被读取,此时如果被依赖的函数还未定义,就会报错。
Hover 延迟操作需要正确的计时器清理
场景: 实现 hover 延迟展开/显示等交互效果时(如工具栏 Popover 延迟展开)
❌ 错误示例:
// 错误:没有清理计时器,可能导致内存泄漏和意外行为
const [open, setOpen] = useState(false);
<div
onPointerEnter={() => {
setTimeout(() => setOpen(true), 300); // 计时器没有被追踪
}}
>
✅ 正确示例:
// 正确:使用 ref 追踪计时器,在离开和卸载时清理
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearHoverTimeout = useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
}, []);
// 组件卸载时清理
useEffect(() => {
return () => clearHoverTimeout();
}, [clearHoverTimeout]);
<div
onPointerEnter={() => {
clearHoverTimeout(); // 先清除之前的计时器
hoverTimeoutRef.current = setTimeout(() => setOpen(true), 300);
}}
onPointerLeave={() => {
clearHoverTimeout(); // 离开时取消延迟操作
}}
onPointerDown={() => {
clearHoverTimeout(); // 点击时立即响应,取消延迟
setOpen(true);
}}
>
关键点:
- 使用
useRef存储计时器 ID(不用 state,避免不必要的重渲染) onPointerLeave清除计时器(用户离开后取消待执行的操作)onPointerDown清除计时器(点击时立即响应,不等待延迟)useEffect清理函数确保组件卸载时清除计时器
单击/双击区分场景的计时器清理
场景: 使用 setTimeout 延迟单击操作以区分单击和双击时
❌ 错误示例:
// 错误:没有在组件卸载时清理计时器
const clickTimerRef = useRef<NodeJS.Timeout | null>(null);
// 单击延迟处理
onClick={() => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
}
clickTimerRef.current = setTimeout(() => {
handleSingleClick(); // 组件卸载后仍可能执行,导致 state 更新到已卸载组件
}, 200);
}}
// 双击取消单击
onDoubleClick={() => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
handleDoubleClick();
}}
// ⚠️ 缺少 useEffect 清理!
#### 优先使用项目已有的工具函数
**场景**: 需要使用 debounce、throttle 等常见工具函数时
❌ **错误示例**:
```typescript
// 错误:在组件内部自己实现 debounce
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: Parameters<T>) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
}) as T;
}
✅ 正确示例:
const clickTimerRef = useRef<NodeJS.Timeout | null>(null);
// 组件卸载时清理计时器
useEffect(() => {
return () => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
};
}, []);
// 单击延迟处理
onClick={() => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
}
clickTimerRef.current = setTimeout(() => {
handleSingleClick();
}, 200);
}}
// 双击取消单击
onDoubleClick={() => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
handleDoubleClick();
}}
原因: 如果用户在计时器等待期间导航离开页面(组件卸载),计时器回调仍会执行,可能导致:
- 内存泄漏(闭包引用已卸载组件的状态)
- React 警告:"Can't perform a React state update on an unmounted component"
- stale callback 访问过期的 props/state // 正确:用项目的 @aitu/utils 包 import { debounce } from '@aitu/utils';
**可用的工具函数来源**:
- `@aitu/utils`: `debounce`、`throttle` 等项目共享工具函数
**原因**: 重复实现常见工具函数会增加代码体积,且可能存在边界情况处理不完善的问题。项目已有的工具函数经过测试和优化,应优先使用。
#### 滑块等连续输入控件的更新策略
**场景**: 滑块拖动时触发昂贵操作(如 SVG pattern 重新生成、Canvas 重绘)
❌ **错误示例**:
```typescript
// 错误 1:每次滑块变化都立即触发外部回调,导致频繁重绘和抖动
const handleSliderChange = (value: number) => {
setConfig({ ...config, scale: value });
onChange?.({ ...config, scale: value }); // 每次都触发,造成性能问题
};
// 错误 2:使用 debounce(防抖),用户停止拖动后才更新,响应迟钝
const debouncedOnChange = useMemo(
() => debounce((config) => onChange?.(config), 150),
[onChange]
);
✅ 正确示例:
// 正确:使用 throttle(节流),定时触发更新,平衡响应性和性能
import { throttle } from '@aitu/utils';
// 节流版本的外部回调
const throttledOnChange = useMemo(
() => throttle((newConfig: Config) => {
onChange?.(newConfig);
}, 100), // 100ms 节流
[onChange]
);
// 滑块专用的更新函数:立即更新 UI,节流触发外部回调
const updateConfigThrottled = useCallback(
(updates: Partial<Config>) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig); // 立即更新 UI
throttledOnChange(newConfig); // 节流触发外部回调
},
[config, throttledOnChange]
);
<input
type="range"
onChange={(e) => updateConfigThrottled({ scale: Number(e.target.value) })}
/>
关键点:
- 内部状态 (
setConfig) 立即更新,保证滑块 UI 的即时响应 - 外部回调 (
onChange) 使用throttle(节流),减少昂贵操作的执行频率 - 防抖 vs 节流: 防抖等用户停止操作后才触发(适合搜索框);节流定时触发(适合滑块)
- 节流时间根据操作开销选择:轻量操作 50-100ms,重量操作(SVG/Canvas)100-200ms
- 使用
useMemo包装 throttle 函数,避免每次渲染创建新实例
React Context 回调中必须使用函数式更新
场景: 在 Context 提供的回调函数(如 openDialog, closeDialog)中更新状态时
❌ 错误示例:
// 错误:使用闭包中的 context.appState,可能是过期的引用
const closeDialog = (dialogType: DialogType) => {
const newOpenDialogTypes = new Set(context.appState.openDialogTypes);
newOpenDialogTypes.delete(dialogType);
context.setAppState({
...context.appState, // 闭包中的旧状态!
openDialogTypes: newOpenDialogTypes,
});
};
// 问题场景:
// 1. 打开弹窗 A:openDialogTypes = { A }
// 2. 打开弹窗 B:openDialogTypes = { A, B }
// 3. 关闭弹窗 A 时,closeDialog 中的 context.appState 可能仍是 { A }
// 4. 结果:openDialogTypes 变成 {},弹窗 B 也被关闭了!
✅ 正确示例:
// 正确:使用函数式更新,确保始终使用最新的状态
const closeDialog = (dialogType: DialogType) => {
context.setAppState((prevState) => {
const newOpenDialogTypes = new Set(prevState.openDialogTypes);
newOpenDialogTypes.delete(dialogType);
return {
...prevState,
openDialogTypes: newOpenDialogTypes,
};
});
};
// 同样适用于 openDialog
const openDialog = (dialogType: DialogType) => {
context.setAppState((prevState) => {
const newOpenDialogTypes = new Set(prevState.openDialogTypes);
newOpenDialogTypes.add(dialogType);
return {
...prevState,
openDialogTypes: newOpenDialogTypes,
};
});
};
原因:
- Context 的回调函数可能被旧的事件处理器或 useCallback 缓存调用
- 闭包中的
context.appState是创建回调时的快照,不是最新状态 - 函数式更新
setState(prev => ...)保证prev始终是最新状态 - 这个问题在多个弹窗/抽屉同时打开时特别容易出现
模式切换时的状态同步问题
场景: 当 UI 组件(如 Toolbar)需要触发模式切换时,直接调用底层的 setMode 可能导致相关状态不同步
❌ 错误示例:
// ViewerToolbar.tsx - 直接调用 setMode
<button onClick={() => onModeChange('edit')}>
编辑
</button>
// UnifiedMediaViewer.tsx - 传递底层 setMode
<ViewerToolbar
onModeChange={actions.setMode} // 错误:只改变模式,不设置 editingItem
/>
// 结果:模式变成 'edit',但 editingItem 仍为 null
// 后续保存时 editingItem 为 null,无法正确覆盖原图
✅ 正确示例:
// UnifiedMediaViewer.tsx - 创建包装函数
const handleModeChange = useCallback((newMode: ViewerMode) => {
if (newMode === 'edit') {
// 进入编辑模式时,同时设置相关状态
const currentItem = items[currentIndex];
if (currentItem && currentItem.type === 'image') {
updateEditingItem(currentItem); // 同步更新 editingItem
}
}
actions.setMode(newMode);
}, [items, currentIndex, actions, updateEditingItem]);
<ViewerToolbar
onModeChange={handleModeChange} // 正确:使用包装函数
/>
原因:
- 当多个状态需要联动更新时,直接暴露底层的单一状态更新函数容易导致状态不一致
- 应该封装成一个函数,确保所有相关状态同步更新
- 这在模式切换、打开/关闭弹窗等场景中尤其重要
传递 React 组件作为 prop 时必须实例化
场景: 将 React 组件作为 icon 或其他 prop 传递给子组件时
❌ 错误示例:
// 错误:传递组件函数本身,而不是 JSX 实例
import { BackgroundColorIcon } from './icons';
const icon = !hexColor ? BackgroundColorIcon : undefined;
// 子组件中渲染时:
// {icon} → React 警告 "Functions are not valid as a React child"
✅ 正确示例:
// 正确:传递 JSX 实例
import { BackgroundColorIcon } from './icons';
const icon = !hexColor ? <BackgroundColorIcon /> : undefined;
// 子组件中渲染时:
// {icon} → 正常渲染
原因: React 组件本质上是函数,直接将函数作为子元素传递会导致 React 警告。需要调用组件(<Component />)生成 JSX 元素后再传递。
内联 style 的 undefined 值会覆盖 CSS 类样式
场景: 当需要条件性地应用内联样式,同时使用 CSS 类作为备选样式时
❌ 错误示例:
// 错误:style 对象中的 undefined 值会覆盖 CSS 类的 background
<label
className={classNames('fill-label', { 'color-mixed': fill === undefined })}
style={{
background: fill ? fill : undefined, // undefined 会覆盖 .color-mixed 的 background
}}
/>
✅ 正确示例:
// 正确:当需要 CSS 类生效时,不传递 style 对象
<label
className={classNames('fill-label', { 'color-mixed': fill === undefined })}
style={
fill === undefined
? undefined // 不设置 style,让 CSS 类的 background 生效
: { background: fill }
}
/>
原因: React 的内联 style 优先级高于 CSS 类。即使 background: undefined,React 仍会在元素上设置空的 style 属性,这可能干扰 CSS 类的样式应用。当需要 CSS 类完全控制样式时,应该不传递 style 对象(style={undefined})。
使用 ResizeObserver 实现组件级别的响应式布局
场景: 当组件位于可调整大小的侧边栏、抽屉或面板中时,使用基于视口宽度的媒体查询 (@media) 无法准确反映组件的实际可用空间。
❌ 错误示例:
// 仅依赖视口宽度的媒体查询
@media (max-width: 1200px) {
.task-item {
grid-template-areas: "preview prompt" "info info";
}
}
✅ 正确示例:
// TaskItem.tsx
const [isCompactLayout, setIsCompactLayout] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// 根据组件实际宽度切换布局
setIsCompactLayout(entry.contentRect.width < 500);
}
});
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, []);
return (
<div ref={containerRef} className={classNames('task-item', { 'task-item--compact': isCompactLayout })}>
{/* ... */}
</div>
);
原因: 本项目大量使用可拖拽调整宽度的抽屉(如任务队列、聊天侧栏)。组件的布局应取决于其父容器的宽度,而非整个浏览器的宽度。ResizeObserver 提供了精确的容器级别响应式控制。
避免在子组件中重写布局样式以保持 Grid 一致性
场景: 当多个组件(如 TaskQueuePanel 和 DialogTaskList)复用同一个基础组件(如 TaskItem)时。
❌ 错误示例:
// dialog-task-list.scss
.dialog-task-list {
.task-item {
// ❌ 错误:在外部强行修改基础组件的布局
display: flex;
flex-direction: row;
// ... 大量覆盖样式
}
}
✅ 正确示例:
// dialog-task-list.scss
.dialog-task-list {
.task-item {
// ✅ 正确:只调整尺寸和细节,复用基础组件自带的响应式布局
padding: 10px;
&__preview-wrapper { width: 100px; }
}
}
原因: 基础组件(如 TaskItem)已经包含了完善的响应式 Grid 布局逻辑。在子组件容器中强行覆盖布局(如从 Grid 改为 Flex)会导致维护困难、布局不一致,并破坏基础组件原有的响应式能力。应优先通过微调尺寸或传递 Props 让基础组件自我调整。
跨 React Root 状态共享:useSyncExternalStore
场景: 需要从主应用向 Plait 文本组件(react-text 的 Text)传递状态时(如搜索高亮关键词)
Plait 框架通过 createRoot 将每个文本组件渲染在独立的 React 树中(见 react-board/src/plugins/with-react.tsx),React Context 无法穿透这些独立 Root。
❌ 错误示例:
// ❌ 错误:在主应用提供 Context,期望 Text 组件能消费
// 主应用
<SearchContext.Provider value={query}>
<Board /> {/* Board 内的文本通过 createRoot 渲染,无法访问此 Context */}
</SearchContext.Provider>
// react-text/text.tsx
const query = useContext(SearchContext); // 永远是 undefined
✅ 正确示例:
// ✅ 正确:使用 useSyncExternalStore + 模块级 store 实现跨 Root 状态共享
// search-highlight.ts
let searchQuery = '';
const listeners = new Set<() => void>();
export function setSearchHighlightQuery(query: string) {
if (searchQuery === query) return;
searchQuery = query;
listeners.forEach(listener => listener());
}
export function useSearchHighlightQuery(): string {
return useSyncExternalStore(
(cb) => { listeners.add(cb); return () => listeners.delete(cb); },
() => searchQuery,
() => searchQuery,
);
}
// text.tsx - 在独立 React Root 中也能正确订阅
const query = useSearchHighlightQuery(); // ✅ 正常工作
原因: createRoot 创建的 React 树是完全独立的,不共享父级 Context。useSyncExternalStore 订阅模块级全局 store,不依赖 React 树结构,因此能跨 Root 工作。此模式适用于所有需要从主应用向 Plait 文本组件传递状态的场景。
RxJS 事件传递对象引用导致 React.memo 失效
场景: Service 层通过 RxJS Subject 推送任务更新事件,多个 React 组件通过不同方式消费(增量替换 vs 全量快照),但 TaskItem 共用同一个 React.memo 自定义比较
❌ 错误示例:
// Service: 原地修改对象再 emit
onProgress: (progress) => {
const localTask = this.tasks.get(taskId);
localTask.progress = progress; // 原地修改
localTask.updatedAt = Date.now();
this.emitEvent('taskUpdated', localTask); // 传递同一引用
}
// Hook A(增量替换):event.task 与数组中旧对象是同一引用
setTasks(prev => prev.map(t => t.id === event.task.id ? event.task : t));
// React.memo: prev.task === next.task(同一对象)→ 不渲染
// Hook B(全量快照):getAllTasks() 返回 Map 中的原始对象
setTasks(taskQueueService.getAllTasks());
// React.memo: prev.task.progress === next.task.progress(同一对象,值已被改过)→ 不渲染
✅ 正确示例:
// Service: 创建新对象存入 Map 再 emit
onProgress: (progress) => {
const localTask = this.tasks.get(taskId);
const updatedTask = { ...localTask, progress, updatedAt: Date.now() };
this.tasks.set(taskId, updatedTask); // 新对象存入 Map
this.emitEvent('taskUpdated', updatedTask); // 新引用
}
// emitEvent 中额外浅拷贝(双保险)
private emitEvent(type, task) {
this.taskUpdates$.next({ type, task: { ...task }, timestamp: Date.now() });
}
原因: React.memo 的自定义比较函数比较的是属性值,但如果 prev.task 和 next.task 指向同一个被原地修改的对象,所有属性比较都会返回 true(值已经被改过了)。必须确保每次更新都产生新的对象引用,让 prev.task.progress !== next.task.progress 成立。
CSS/SCSS 规范
- 使用 BEM 命名规范
- 优先使用设计系统 CSS 变量
- 属性顺序:定位 → 盒模型 → 外观 → 排版 → 动画
使用 box-shadow 替代 border 实现不影响尺寸的边框
场景: 当需要边框不影响元素实际尺寸时(如裁剪框、选区框等精确定位场景)
❌ 错误示例:
// 边框画在元素内部,会占用元素空间
// 导致可视边界比实际区域大 2px
.crop-area {
position: absolute;
border: 2px solid #4f46e5;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
}
✅ 正确示例:
// 使用 box-shadow 模拟边框,边框画在元素外部
// 元素内边缘即为实际边界
.crop-area {
position: absolute;
// 第一个 shadow 是边框,第二个是遮罩
box-shadow: 0 0 0 2px #4f46e5, 0 0 0 9999px rgba(0, 0, 0, 0.6);
}
原因: CSS border 默认使用 box-sizing: content-box,边框会画在元素边界内部。对于裁剪框等需要精确对应实际区域的场景,使用 box-shadow: 0 0 0 Npx color 可以模拟边框,且边框画在元素外部,不影响元素尺寸。
绝对定位子元素需要正确的父容器设置
场景: 在容器内添加绝对定位的浮层/预览框等元素时
❌ 错误示例:
.container {
// 缺少 position: relative,子元素的绝对定位相对于更上层的定位元素
overflow: hidden; // 会裁切溢出的绝对定位子元素
.floating-preview {
position: absolute;
right: 100%; // 想要显示在容器左侧
// 结果:1) 定位参照物可能不对 2) 被 overflow: hidden 裁切掉
}
}
✅ 正确示例:
.container {
position: relative; // 作为绝对定位子元素的参照物
overflow: visible; // 允许子元素溢出显示
.floating-preview {
position: absolute;
right: 100%; // 正确显示在容器左侧
}
}
检查清单:
- 父容器需要
position: relative(或其他非 static 的定位) - 如果子元素需要溢出显示,父容器需要
overflow: visible - 多层嵌套时,确认绝对定位的参照元素是正确的
原因: position: absolute 的元素相对于最近的非 static 定位祖先元素定位。如果父容器没有设置定位,子元素会相对于更上层的元素定位,导致位置错误。同时 overflow: hidden 会裁切超出容器边界的内容,包括绝对定位的子元素。
移动端固定定位元素需要考虑工具栏遮挡
场景: 移动端页面底部或顶部的固定定位元素(输入框、提示条等)需要避开左侧工具栏
❌ 错误示例:
// 直接居中,没有考虑左侧工具栏
.ai-input-bar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
}
@media (max-width: 640px) {
.ai-input-bar {
// 移动端仍然直接居中,会被工具栏遮挡
bottom: 16px;
}
}
✅ 正确示例:
.ai-input-bar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
}
@media (max-width: 640px) {
.ai-input-bar {
bottom: 16px;
// 考虑左侧工具栏宽度 (48px),偏移居中点
left: calc(50% + 24px); // 工具栏宽度的一半
max-width: calc(100% - 60px); // 左侧工具栏 + 右侧边距
}
}
检查清单:
- 移动端 (
@media max-width: 640px/768px) 的固定定位元素 - 是否会与左侧 unified-toolbar (48px) 重叠
- 是否会与右上角缩放控件重叠
- 使用
$toolbar-width变量而非硬编码数值
相关变量: $toolbar-width: 48px (定义在 styles/_common-variables.scss)
移动端触控需要 touch 事件实现 hover 效果
场景: 桌面端的 hover 预览/提示在移动端没有效果,需要添加 touch 事件支持
❌ 错误示例:
// 只有鼠标事件,移动端触控没有预览效果
<canvas
onMouseEnter={() => setPreviewVisible(true)}
onMouseLeave={() => setPreviewVisible(false)}
onMouseMove={(e) => updatePreviewPosition(e)}
/>
✅ 正确示例:
// 添加触控状态追踪
const isTouchingRef = useRef(false);
const handleTouchStart = (e: React.TouchEvent) => {
isTouchingRef.current = true;
const touch = e.touches[0];
updatePreviewPosition(touch.clientX, touch.clientY);
setPreviewVisible(true);
};
const handleTouchMove = (e: React.TouchEvent) => {
const touch = e.touches[0];
updatePreviewPosition(touch.clientX, touch.clientY);
// 触控移动时始终显示预览
setPreviewVisible(true);
};
const handleTouchEnd = () => {
isTouchingRef.current = false;
// 延迟隐藏,让用户看到最终位置
setTimeout(() => {
if (!isTouchingRef.current) {
setPreviewVisible(false);
}
}, 500);
};
<canvas
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
/>
注意事项:
- 触控时会同时触发
pointerdown,可能导致拖拽状态与预览状态冲突 - 使用
isTouchingRef区分移动端触控和桌面端鼠标拖拽 - 触控结束后延迟隐藏预览,给用户时间查看结果
- Canvas 元素需要设置
touch-action: none防止默认滚动行为
TDesign CSS 变量优先级问题
场景: 在组件样式中使用 TDesign CSS 变量(如 --td-brand-color-light)时,发现颜色不生效或显示为默认颜色。
❌ 错误示例:
// 在组件 scss 中使用 CSS 变量
.project-drawer-node__row {
&--selected {
background: var(--td-brand-color-light); // 可能被 TDesign 内部样式覆盖
&:hover {
background: var(--td-brand-color-light-hover); // 不生效
}
}
}
✅ 正确示例:
// 直接使用具体颜色值
.project-drawer-node__row {
&--selected {
background: rgba(243, 156, 18, 0.12); // 品牌橙金色 12% 透明度
&:hover {
background: rgba(243, 156, 18, 0.18); // 18% 透明度
}
}
}
原因: TDesign 组件内部可能在更具体的选择器中定义了同名 CSS 变量的值,导致 :root 中的全局定义被覆盖。在组件级样式中直接使用具体颜色值可以确保样式正确生效。
适用场景:
- 组件内的选中/高亮/激活状态背景色
- 需要使用品牌色变体的场景(如透明度变化)
- 任何发现 CSS 变量不生效的情况
弹出菜单被父容器 overflow 截断
场景: 在设置了 overflow: hidden/auto 的容器内渲染右键菜单、下拉菜单等弹出层时,菜单被截断。
❌ 错误示例:
// 菜单直接渲染在组件内部,被父容器 overflow 截断
return (
<div className="drawer-content" style={{ overflow: 'auto' }}>
{/* 内容 */}
{contextMenu && (
<div
className="context-menu"
style={{ position: 'fixed', left: x, top: y }}
>
{/* 菜单项 */}
</div>
)}
</div>
);
✅ 正确示例:
import { createPortal } from 'react-dom';
return (
<div className="drawer-content" style={{ overflow: 'auto' }}>
{/* 内容 */}
{/* 使用 Portal 渲染到 body,避免被截断 */}
{contextMenu && createPortal(
<div
className="context-menu"
style={{ position: 'fixed', left: x, top: y, zIndex: 10000 }}
>
{/* 菜单项 */}
</div>,
document.body
)}
</div>
);
原因: 即使使用 position: fixed,如果元素在 DOM 树中位于设置了 overflow: hidden 的容器内部,该容器仍然会裁切超出其边界的内容。使用 createPortal 将弹出层渲染到 document.body,使其脱离原有容器的裁切上下文。
适用场景:
- 右键菜单
- 自定义下拉菜单
- 悬浮预览框
- 任何需要显示在容器边界外的浮层
Git 提交规范
- 格式:
<type>(<scope>): <subject> - 类型:
feat,fix,docs,style,refactor,test,chore,perf,ci
重要规则
- UI 框架: 使用 TDesign React,配置浅色主题
- Tooltips: 始终使用
theme='light' - 品牌色一致性: 覆盖第三方组件(如 TDesign Tag)的默认颜色以符合 Opentu 品牌视觉
- 示例: 处理中状态使用蓝紫色系 (
#5A4FCF) - CSS:
.t-tag--theme-primary { background-color: rgba(90, 79, 207, 0.08); color: #5A4FCF; }
- 示例: 处理中状态使用蓝紫色系 (
- 文件大小限制: 单个文件不超过 500 行
- 文档语言: 规格文档使用中文
- 概念术语一致性: 使用
/docs/CONCEPTS.md中定义的标准术语
TDesign Dropdown 的 popupProps 透传
场景: 需要监听 TDesign Dropdown 组件的显示/隐藏状态变化时
❌ 错误示例:
// 错误:Dropdown 没有直接的 onVisibleChange 属性
<Dropdown
options={options}
trigger="context-menu"
onVisibleChange={(visible) => { // ❌ 类型错误
setMenuOpen(visible);
}}
>
<Button>触发器</Button>
</Dropdown>
✅ 正确示例:
// 正确:通过 popupProps 透传给底层 Popup 组件
<Dropdown
options={options}
trigger="context-menu"
popupProps={{
onVisibleChange: (visible) => {
setMenuOpen(visible);
}
}}
>
<Button>触发器</Button>
</Dropdown>
原因: TDesign 的 Dropdown 组件没有直接暴露 onVisibleChange 属性,需要通过 popupProps 透传给底层的 Popup 组件。这是 TDesign 的组合式 API 设计,很多底层能力需要通过 xxxProps 透传。
强制重绘使用 void 前缀
场景: 需要触发浏览器强制重绘(reflow)以确保 CSS 变更立即生效时
❌ 错误示例:
// ESLint 报错:Expected an assignment or function call and instead saw an expression
element.offsetHeight;
✅ 正确示例:
// 使用 void 运算符明确表示故意丢弃返回值
void element.offsetHeight;
原因: 读取 offsetHeight 属性会触发浏览器回流(reflow),这是一种常见的强制重绘技巧。但 ESLint 会报错因为这是一个"无用"的表达式。添加 void 运算符可以:
- 消除 ESLint 警告
- 明确表示我们的意图是触发副作用而非使用返回值
常见使用场景:
- CSS 动画开始前重置状态
- 确保 transition 属性变更后立即生效
- 在设置初始样式和应用动画样式之间强制重绘
项目概念文档维护
场景: 添加新功能、新类型或新概念时
规则: 项目使用 /docs/CONCEPTS.md 作为核心术语和概念的权威定义。添加新功能时应检查并更新概念文档。
需要更新概念文档的情况:
- 引入新的类型定义(如新的 TaskType、AssetSource 等)
- 添加新的 React Context
- 创建新的 Service 或核心服务
- 添加新的 MCP 工具
- 引入新的数据流模式
- 添加新的虚拟路径前缀
概念文档结构:
- 术语表:中英文对照、定义、关键文件
- 架构分层:应用层、核心库、适配层
- 数据流:AI 生成流程、素材库数据流
- 状态管理:Context 和持久化存储
- 命名规范:文件、变量、事件
参考: 查看 /docs/CONCEPTS.md 获取完整的术语定义和概念说明
navigator.storage.estimate() 返回浏览器配额而非磁盘空间
场景: 需要获取用户设备存储空间信息时
❌ 错误示例:
// 错误:误以为 quota 是实际磁盘剩余空间
const estimate = await navigator.storage.estimate();
const diskFreeSpace = estimate.quota; // ❌ 这不是磁盘剩余空间!
console.log(`磁盘剩余: ${diskFreeSpace / 1024 / 1024 / 1024} GB`);
// 可能显示 500+ GB,但实际磁盘只剩 10GB
✅ 正确示例:
// 正确理解:quota 是浏览器分配给该站点的配额上限
const estimate = await navigator.storage.estimate();
const usage = estimate.usage || 0; // 该站点已使用的存储
const quota = estimate.quota || 0; // 浏览器分配的配额(通常是磁盘空间的某个比例)
const usagePercent = quota > 0 ? (usage / quota) * 100 : 0;
// 只用于判断站点存储使用率,不用于显示磁盘空间
if (usagePercent > 80) {
console.warn('站点存储使用率较高');
}
原因: navigator.storage.estimate() 返回的 quota 是浏览器为该源(origin)分配的存储配额,通常是磁盘可用空间的某个比例(如 50%),而非实际磁盘剩余空间。向用户展示这个值会造成误解。Web API 无法直接获取真实的磁盘剩余空间。
异步初始化模式
场景: 使用 settingsManager 或其他需要异步初始化的服务时
❌ 错误示例:
async initialize(): Promise<boolean> {
const settings = geminiSettings.get(); // 可能返回加密的 JSON!
await swTaskQueueClient.initialize({ apiKey: settings.apiKey });
}
✅ 正确示例:
async initialize(): Promise<boolean> {
await settingsManager.waitForInitialization(); // 等待解密完成
const settings = geminiSettings.get(); // 现在返回解密后的值
await swTaskQueueClient.initialize({ apiKey: settings.apiKey });
}
原因: settingsManager 使用异步方法 decryptSensitiveDataForLoading() 解密敏感数据(如 API Key)。如果在解密完成前调用 geminiSettings.get(),会返回加密的 JSON 对象而不是解密后的字符串,导致 API 请求失败。
跨层数据转换必须传递所有字段
场景: 在主线程和 Service Worker 之间传递数据对象时,需要将内部类型转换为传输格式
❌ 错误示例:
// 错误:转换时漏掉了 options 字段
const swWorkflow = {
id: legacyWorkflow.id,
name: legacyWorkflow.name,
steps: legacyWorkflow.steps.map(step => ({
id: step.id,
mcp: step.mcp,
args: step.args,
description: step.description,
status: step.status,
// 漏掉了 step.options!导致批量信息(batchId 等)丢失
})),
};
✅ 正确示例:
// 正确:显式传递所有字段,包括可选字段
const swWorkflow = {
id: legacyWorkflow.id,
name: legacyWorkflow.name,
steps: legacyWorkflow.steps.map(step => ({
id: step.id,
mcp: step.mcp,
args: step.args,
description: step.description,
status: step.status,
options: step.options, // 包含 batchId, batchIndex, batchTotal 等
})),
};
常见遗漏的字段:
options- 批量参数、执行模式等metadata- 元数据信息context- 上下文信息- 任何
?:可选字段
原因: 跨层通信时,如果源类型有可选字段,在转换时很容易遗漏。这会导致功能静默失败(如批量生成只执行第一个),且很难排查。建议在转换函数中显式列出所有字段,或使用 TypeScript 的类型检查确保字段完整。
Service Worker 初始化时序
场景: 提交工作流到 Service Worker 执行前
❌ 错误示例:
// 错误:直接提交工作流,SW 可能还未初始化
const submitToSW = async (workflow) => {
await workflowSubmissionService.submit(swWorkflow);
// 如果 SW 的 workflowHandler 未初始化,工作流会被暂存
// 步骤状态永远停留在 pending("待开始")
};
✅ 正确示例:
// 正确:先确保 SW 已初始化
const submitToSW = async (workflow) => {
// 确保 SW 任务队列已初始化(发送 TASK_QUEUE_INIT 消息)
const { swTaskQueueService } = await import('../services/sw-task-queue-service');
await swTaskQueueService.initialize();
await workflowSubmissionService.submit(swWorkflow);
};
原因: Service Worker 的 workflowHandler 需要收到 TASK_QUEUE_INIT 消息后才会初始化。如果在 SW 初始化前提交工作流,消息会被暂存到 pendingWorkflowMessages,等待配置到达。若配置永远不到达(如 swTaskQueueService.initialize() 未被调用),工作流就永远不会开始执行,步骤状态保持 pending。
Service Worker 初始化统一入口
场景: 需要确保 SW 客户端已初始化并配置好后再进行 API 调用
❌ 错误示例:
// 错误:在各处重复 SW 初始化逻辑
async function callSWApi() {
if (shouldUseSWTaskQueue()) {
if (!swChannelClient.isInitialized()) {
const settings = geminiSettings.get();
if (settings.apiKey && settings.baseUrl) {
await settingsManager.waitForInitialization();
await swChannelClient.initialize();
await swChannelClient.init({
geminiConfig: { apiKey: settings.apiKey, baseUrl: settings.baseUrl },
videoConfig: { baseUrl: settings.baseUrl },
});
}
}
// 使用 SW...
}
}
✅ 正确示例:
// 正确:使用统一入口
async function callSWApi() {
const useSW = await swChannelClient.ensureReady();
if (useSW) {
// 使用 SW...
} else {
// 降级到主线程...
}
}
统一入口说明:
swChannelClient.ensureReady(): 统一的异步初始化入口,内部处理 URL 参数检查、设置验证、初始化和配置swTaskQueueService.initialize(): 任务队列服务初始化,内部调用ensureReady()+ 设置 visibility 监听器shouldUseSWTaskQueue(): 同步检查函数,仅用于判断模式,不进行初始化
原因: 分散的 SW 初始化逻辑会导致代码重复、维护困难,且容易遗漏某些检查步骤。使用统一入口可以确保所有初始化逻辑集中管理,便于维护和调试。
Service Worker 更新提示在开发模式下被跳过
场景: 在 localhost 本地测试 Service Worker 更新提示功能
现象: 修改代码并构建后,在 localhost 环境下看不到版本更新提示
原因: 项目在开发模式下(localhost 或 127.0.0.1)会自动跳过更新提示,直接激活新的 Service Worker。
// apps/web/src/main.tsx 中的逻辑
const isDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
if (isDevelopment) {
// 开发模式:直接跳过 waiting,不显示提示
newWorker.postMessage({ type: 'SKIP_WAITING' });
} else {
// 生产模式:显示更新提示
window.dispatchEvent(new CustomEvent('sw-update-available', { ... }));
}
}
测试方法:
- 在控制台手动触发更新提示(仅测试 UI):
window.__debugTriggerUpdate('0.5.10')
-
部署到生产环境测试: 只有在非 localhost 环境下才会显示更新提示
-
正确的版本升级流程:
pnpm run version:patch # 升级版本号
pnpm run build:web # 重新构建
# 部署到生产环境后会触发更新提示
注意:
- Service Worker 更新检测是基于
sw.js文件内容的字节级比较 - 只修改
version.json不会触发 SW 更新,必须修改sw.js内容 - 版本号通过
__APP_VERSION__变量注入到sw.js中
SW RPC Handler 必须等待数据恢复完成
场景: Service Worker 中的 RPC handler 需要访问从 IndexedDB 恢复的数据(如任务列表)
❌ 错误示例:
// 错误:RPC handler 直接访问数据,可能在 IndexedDB 恢复完成前被调用
private handleTaskListPaginated(data: { page: number; pageSize: number }) {
const allTasks = this.taskQueue?.getAllTasks() || [];
// 如果 restoreFromStorage() 还在执行,allTasks 可能是空的或不完整的
return { tasks: allTasks.slice(start, end), total: allTasks.length };
}
✅ 正确示例:
// 正确:先等待 IndexedDB 数据恢复完成
private async handleTaskListPaginated(data: { page: number; pageSize: number }) {
// 确保 IndexedDB 数据已恢复
await this.taskQueue?.waitForStorageRestore();
const allTasks = this.taskQueue?.getAllTasks() || [];
return { tasks: allTasks.slice(start, end), total: allTasks.length };
}
// SWTaskQueue 中提供等待方法
class SWTaskQueue {
private storageRestorePromise: Promise<void> | null = null;
async waitForStorageRestore(): Promise<void> {
if (this.storageRestorePromise) {
await this.storageRestorePromise;
}
}
}
现象: 页面刷新后,任务列表显示不完整(如 67 条任务只显示 2 条),因为 RPC 响应在数据恢复完成前就返回了。
配置同步到 IndexedDB 供 SW 读取
场景: Service Worker 需要读取用户配置(如 API Key、baseUrl),但 SW 无法访问 localStorage
问题: 之前的架构依赖 postMessage 传递配置给 SW,当通信不畅时 SW 可能拿不到配置导致任务失败。
❌ 错误示例:
// 错误:SW 依赖 postMessage 获取配置
// 主线程
await swChannelClient.init({
geminiConfig: { apiKey, baseUrl },
videoConfig: { baseUrl },
});
// SW 中
private async handleInit(data: { geminiConfig; videoConfig }) {
this.geminiConfig = data.geminiConfig;
// 如果 postMessage 失败,SW 就没有配置
}
✅ 正确示例:
// 正确:配置同步到 IndexedDB,SW 直接读取
// 1. 主线程 SettingsManager 同步配置到 IndexedDB
private async saveToStorage(): Promise<void> {
localStorage.setItem(DRAWNIX_SETTINGS_KEY, settingsJson);
// 必须 await:首次输入 API Key 后若立即提交工作流,SW 需能读到最新配置
await this.syncToIndexedDB();
}
private async syncToIndexedDB(): Promise<void> {
const geminiConfig = {
apiKey: this.settings.gemini.apiKey,
baseUrl: this.settings.gemini.baseUrl,
// ...
};
await configIndexedDBWriter.saveConfig(geminiConfig, videoConfig);
}
// 2. SW 从 IndexedDB 读取配置
const geminiConfig = await taskQueueStorage.getConfig<GeminiConfig>('gemini');
const videoConfig = await taskQueueStorage.getConfig<VideoAPIConfig>('video');
数据流:
用户修改设置 → localStorage + IndexedDB(同时写入)
SW 需要配置 → 直接读取 IndexedDB → 始终可用
关键点:
configIndexedDBWriter使用与 SW 相同的数据库名 (sw-task-queue) 和存储名 (config)- 初始化时自动同步,老用户的 localStorage 配置会自动迁移到 IndexedDB
- RPC
init()不再需要传递配置参数(可选,用于兼容旧版本) - 任务/工作流仍保留配置快照,用于恢复场景
原因: Service Worker 启动后会异步从 IndexedDB 恢复数据(restoreFromStorage()),这个过程可能需要一定时间。如果 RPC handler 在恢复完成前被调用,访问的数据是不完整的。需要通过 Promise 机制确保数据恢复完成后再返回响应。
SW 可用性检测需统一(channel + ping)
场景: 决定是否使用 SW 执行工作流时,需与 WorkflowSubmissionService 使用相同的检测逻辑
问题: 仅检查 navigator.serviceWorker.controller 会误判。controller 存在不代表 duplex channel 已就绪,提交时会超时后降级,WorkZone 卡在「待开始」。
❌ 错误示例:
// 错误:仅检查 controller
function checkSWAvailable(): boolean {
return !!(navigator.serviceWorker?.controller);
}
// 若 channel 未初始化,submitWorkflow 会超时,降级路径可能仍用 SW 执行器导致二次超时
✅ 正确示例:
// 正确:检查 swChannelClient.isInitialized() 且 ping 成功
async function checkSWChannelAvailable(): Promise<boolean> {
if (!swChannelClient.isInitialized()) return false;
return await Promise.race([
swChannelClient.ping(),
new Promise<boolean>((r) => setTimeout(() => r(false), 1500)),
]);
}
原因: controller 存在时 duplex channel 可能仍在初始化或 SW 正在修复 IndexedDB。需统一使用 channel + ping 检测,避免走 SW 路径后超时降级。
降级路径强制使用主线程执行器
场景: workflow 提交到 SW 超时后,使用 MainThreadWorkflowEngine 降级执行时
问题: WorkflowEngine 内部用 executorFactory.getExecutor(),当 ping 仍成功时会返回 SW 执行器,导致 generate_image/generate_video 再次走 SW 并超时,WorkZone 一直「待开始」。
❌ 错误示例:
// 错误:降级时仍用 executorFactory(可能返回 SW 执行器)
this.fallbackEngine = new MainThreadWorkflowEngine({
onEvent: (e) => this.handleFallbackEngineEvent(e),
executeMainThreadTool: async (name, args) => { /* ... */ },
});
// WorkflowEngine 内部: const executor = await executorFactory.getExecutor();
// → 返回 SW 执行器 → 再次超时
✅ 正确示例:
// 正确:强制使用主线程执行器
this.fallbackEngine = new MainThreadWorkflowEngine({
onEvent: (e) => this.handleFallbackEngineEvent(e),
forceFallbackExecutor: true,
executeMainThreadTool: async (name, args) => { /* ... */ },
});
// WorkflowEngine 内部: const executor = this.options.forceFallbackExecutor
// ? executorFactory.getFallbackExecutor()
// : await executorFactory.getExecutor();
原因: 降级路径应完全在主线程执行,直接调用 API,不依赖 SW 或 IndexedDB 就绪。
降级路径工作流工具集一致性
场景: 工作流降级到主线程执行时,WorkflowEngine 的 executeToolStep 用 switch 按 step.mcp 分发。SW 侧(如 workflow-executor、swCapabilitiesHandler)支持的工具,主线程分支也必须能路由,否则会落入 default 抛出 Unknown tool: xxx。
❌ 错误示例:
// 错误:SW 的 handler 支持 generate_grid_image,但 engine 的 switch 没有对应 case
// sw-capabilities/handler.ts 有 case 'generate_grid_image'
// workflow-engine/engine.ts 只有 generate_image、generate_video、ai_analyze、insert_*...
// → generate_grid_image 落入 default,抛 Unknown tool: generate_grid_image
switch (step.mcp) {
case 'generate_image': // ...
case 'generate_video': // ...
case 'ai_analyze': // ...
case 'insert_mermaid':
case 'canvas_insert': // ...
default:
throw new Error(`Unknown tool: ${step.mcp}`);
}
✅ 正确示例:
// 正确:与 DelegatedOperationType / swCapabilitiesHandler.execute 支持的工具集一致
// 凡由主线程 handler 执行的工具,都走 executeMainThreadTool 分支
case 'generate_grid_image':
case 'generate_inspiration_board':
case 'split_image':
case 'generate_long_video':
case 'insert_mermaid':
case 'insert_mindmap':
case 'insert_svg':
case 'canvas_insert':
case 'insert_to_canvas': {
if (!this.options.executeMainThreadTool) {
throw new Error(`No main thread tool executor configured for: ${step.mcp}`);
}
const toolResult = await this.options.executeMainThreadTool(step.mcp, step.args);
// ...
}
原因: 降级路径功能一致性要求主线程与 SW 支持相同工具集。新增 DelegatedOperation 类型或在 handler 增加 case 时,需同步在 WorkflowEngine.executeToolStep 中增加对应 case 并路由到 executeMainThreadTool。
相关文件:
packages/drawnix/src/services/sw-capabilities/types.ts- DelegatedOperationTypepackages/drawnix/src/services/sw-capabilities/handler.ts- execute switchpackages/drawnix/src/services/workflow-engine/engine.ts- executeToolStep switch
同页面禁止创建多个 ServiceWorkerChannel
场景: 主线程需要与 SW 通信的多个模块(如 swChannelClient、FetchRelayClient)各自调用 ServiceWorkerChannel.createFromPage()。
问题: SW 端 enableGlobalRouting 按 event.source.id(SW client ID)路由消息。同一页面的所有 createFromPage() 共享同一个 clientId。SW 端 ensureChannel(clientId) 只为第一个连接创建 channel 和 subscribeMap,后续连接复用同一个 server-side channel。这导致 RPC 响应可能路由到错误的 client-side channel,channel.call() 永远不 resolve。
❌ 错误示例:
// FetchRelayClient 创建独立 channel — 与 swChannelClient 冲突!
class FetchRelayClient {
private channel: ServiceWorkerChannel | null = null;
async doInitialize(): Promise<boolean> {
// 创建第二个 channel,但 SW 只有一个 server-side channel
this.channel = await ServiceWorkerChannel.createFromPage({ timeout: 120000 });
return true;
}
async fetch(url: string, init: RequestInit): Promise<Response> {
// channel.call() 永远不 resolve — 响应去了 swChannelClient 的 channel
const response = await this.channel!.call('fetchRelay:start', { url, ...init });
// ...
}
}
✅ 正确示例:
// FetchRelayClient 复用 swChannelClient 的 channel
class FetchRelayClient {
private swChannelClientRef: { getChannel: () => ServiceWorkerChannel | null } | null = null;
private getChannel(): ServiceWorkerChannel | null {
return this.swChannelClientRef?.getChannel() ?? null;
}
async initialize(): Promise<boolean> {
const { swChannelClient } = await import('../sw-channel');
this.swChannelClientRef = swChannelClient;
if (!swChannelClient.isInitialized()) {
await swChannelClient.initializeChannel();
}
return !!swChannelClient.getChannel();
}
async fetch(url: string, init: RequestInit): Promise<Response> {
const channel = this.getChannel();
if (!channel) return this.directFetch(url, init); // 降级
const response = await channel.call('fetchRelay:start', { url, ...init });
// ...
}
}
原因: enableGlobalRouting 按 clientId 路由。同一浏览器 tab 的所有 createFromPage() 共享同一个 clientId(来自 event.source.id)。SW 端只为每个 clientId 维护一个 server-side channel 和一套 subscribeMap。第二个 createFromPage() 的握手消息被已有 channel 处理,导致路由混乱。
相关文件:
packages/drawnix/src/services/sw-channel/client.ts- swChannelClient(唯一的 createFromPage 调用点)packages/drawnix/src/services/fetch-relay/client.ts- FetchRelayClient(复用 swChannelClient.getChannel())apps/web/src/sw/task-queue/channel-manager.ts- enableGlobalRouting + ensureChannel
Fetch Relay 初始化超时保护
场景: fetchRelayClient.initialize() 在热路径中被调用(如 FallbackMediaExecutor.generateImage()、swTaskQueueService.doInitialize())。内部 ServiceWorkerChannel.createFromPage() 默认超时 120 秒,如果 SW 存在但响应慢(如正在 activate),整个热路径会被阻塞 2 分钟。
❌ 错误示例:
// 错误:await 可能阻塞 120 秒
await fetchRelayClient.initialize();
const relayResponse = await fetchRelayClient.fetch(url, { ... });
✅ 正确示例:
// 正确:3 秒超时保护,超时后 isInitialized() 返回 false,
// fetchRelayClient.fetch() 内部自动降级到 directFetch
const relayReady = await Promise.race([
fetchRelayClient.initialize(),
new Promise<boolean>((r) => setTimeout(() => r(false), 3000)),
]);
console.debug('[XXX] Fetch Relay ready:', relayReady);
// fetch() 内部会检查 isInitialized(),不可用时自动降级
const relayResponse = await fetchRelayClient.fetch(url, { ... });
原因: fetchRelayClient.initialize() 创建的 ServiceWorkerChannel 需要与 SW 完成握手。SW 在 activating、更新或忙碌时可能延迟响应。在图片生成、任务初始化等热路径中,2 分钟阻塞会导致用户感知请求未发出。
补充:同理,recoverFetchRelayResults() 在 doInitialize() 中也需加超时,否则会阻塞任务恢复流程(handleInterruptedTasks 和 startPolling 无法执行)。
相关文件:
packages/drawnix/src/services/fetch-relay/client.ts- FetchRelayClientpackages/drawnix/src/services/media-executor/fallback-executor.ts- generateImagepackages/drawnix/src/services/sw-task-queue-service.ts- doInitialize / recoverFetchRelayResults
模块迁移时接口定义必须逐字段对比
场景: 将模块从 SW 迁移到主线程(或反向迁移)时,在新模块中重新定义了 interface,但遗漏了某些字段。
❌ 错误示例:
// SW 版本的 llm-api-logger.ts 有完整定义
interface LLMApiLog {
id: string;
// ... 基础字段 ...
hasReferenceImages?: boolean;
referenceImageCount?: number;
referenceImages?: LLMReferenceImage[]; // ← SW 版本有这个字段
}
// 主线程版本迁移时遗漏了 referenceImages
interface LLMApiLog {
id: string;
// ... 基础字段 ...
hasReferenceImages?: boolean;
referenceImageCount?: number;
// ❌ referenceImages 字段丢失!类型定义缺失导致调用方无法传入数据
}
✅ 正确示例:
// 主线程版本必须与 SW 版本逐字段对比
interface LLMApiLog {
id: string;
// ... 基础字段 ...
hasReferenceImages?: boolean;
referenceImageCount?: number;
referenceImages?: LLMReferenceImage[]; // ✅ 保留所有字段
}
原因: 模块迁移时,不仅调用方传参需要完整,类型定义本身也必须包含所有字段。如果 interface 缺少字段,即使调用方想传数据也无法传入(TypeScript 严格对象字面量检查会报错)。迁移后应逐字段对比新旧 interface,确保功能完整性。本次案例中,主线程 LLMApiLog 遗漏 referenceImages 导致 debug 面板参考图始终显示"加载中..."。
相关文件:
packages/drawnix/src/services/media-executor/llm-api-logger.ts- 主线程版本apps/web/src/sw/task-queue/llm-api-logger.ts- SW 版本
模型参数偏好必须按模型作用域存储
场景: 图片、视频、音频生成表单需要记忆不同模型的用户参数偏好。
核心原则:
- 优先使用
selectionKey作为偏好作用域键,缺失时再回退modelId - 不同供应商来源的同名模型不能共享参数
- 回填优先级必须是:任务参数 / 外部显式初始化 > 模型偏好 > 模型默认值
- 回填后必须重新做兼容性过滤,丢弃当前模型不支持的参数
❌ 错误示例:
const key = `prefs:${model.id}`;
localStorage.setItem(key, JSON.stringify(params));
// provider-a::gemini-2.5-flash
// provider-b::gemini-2.5-flash
// 最终会读写同一个 key,导致参数串用
✅ 正确示例:
const scopeKey = model.selectionKey || model.id;
scopedPreferences[scopeKey] = sanitizeSelectedParams(model.id, params);
const restored =
explicitTaskParams ??
scopedPreferences[scopeKey] ??
getModelDefaultParams(model.id);
原因: 运行时模型发现后,同名模型可能来自不同供应商,参数能力并不完全相同。只按 modelId 记忆会让用户在切换供应商后看到错误回填,最终演变成隐蔽状态 bug。
模型默认值与选择器排序的三层配置
场景: 修改默认模型或调整模型选择器中的厂商/模型排序。
三层配置:
DEFAULT_*_MODEL_ID(model-config.ts):代码级默认模型 ID,影响无用户设置时的兜底值DEFAULT_SETTINGS(settings-manager.ts):设置面板"默认方案"的预设值,新用户首次看到的默认模型,文本模型优先复用getDefaultTextModel(),避免默认值双写漂移BUILT_IN_MODEL_RECOMMENDATION_SCORES(model-config.ts):模型在选择器内的排序权重,分数越高排越前
厂商 Tab 排序: 由 DISCOVERY_VENDOR_ORDER(ModelVendorBrand.tsx)控制,与模型数组顺序无关。
下线内置模型: 注释或移除 TEXT_MODELS / CHAT_MODELS 中的模型时,同步处理 BUILT_IN_MODEL_RECOMMENDATION_SCORES 和默认模型常量;否则模型可能仍通过排序权重、旧聊天清单或默认设置入口残留。
隐藏模型: 不在选择器默认展示但保留参数定义的模型放入 HIDDEN_VIDEO_MODELS,ALL_MODELS 不包含它们,getStaticModelConfig 单独兜底查找。
❌ 错误示例:
// 只改了常量,忘了改 settings-manager 的默认方案
export const DEFAULT_VIDEO_MODEL_ID = 'seedance-1.5-pro';
// settings-manager.ts 里仍然是 videoModelName: 'veo3.1'
// → 设置面板"默认方案"显示旧模型
// 只改了数组顺序,以为能影响选择器排序
const BUILT_IN_VIDEO_MODELS = [kling, seedance, veo, ...];
// → 实际排序由 recommendedScore 和 DISCOVERY_VENDOR_ORDER 决定
✅ 正确示例:
// 1. 改常量
export const DEFAULT_VIDEO_MODEL_ID = 'seedance-1.5-pro';
// 2. 同步改 settings-manager 默认方案
const DEFAULT_SETTINGS = {
gemini: {
videoModelName: 'seedance-1.5-pro',
textModelName: getDefaultTextModel(),
},
};
// 3. 给模型加 recommendedScore 控制选择器内排序
'seedance-1.5-pro': 97,
// 4. 调整 DISCOVERY_VENDOR_ORDER 控制厂商 Tab 顺序
// OpenAI 要在 DeepSeek 前面时,把 ModelVendor.GPT 放到 ModelVendor.DEEPSEEK 前
export const DISCOVERY_VENDOR_ORDER = [ModelVendor.GPT, ModelVendor.DEEPSEEK];
// 5. 更新默认文本模型时,让默认设置引用统一默认函数
export const DEFAULT_TEXT_MODEL_ID = 'gpt-5.5';
中断任务延迟判定
场景: 页面刷新后,handleInterruptedTasks() 处理仍处于 processing 状态的任务。如果图片生成通过 Fetch Relay 代理,SW 可能仍在执行 fetch(尚未完成),此时 recoverFetchRelayResults() 找不到结果。
❌ 错误示例:
// 错误:立即标记失败,但 SW 可能几秒后就完成了
private async handleInterruptedTasks(): Promise<void> {
for (const task of processingTasks) {
if (!task.remoteId) {
await taskStorageWriter.failTask(task.id, { code: 'INTERRUPTED', message: '...' });
}
}
}
✅ 正确示例:
// 正确:延迟 15 秒后再次尝试 Fetch Relay 恢复,仍无法恢复才标记失败
private async handleInterruptedTasks(): Promise<void> {
for (const task of processingTasks) {
if (!task.remoteId) {
this.delayedInterruptCheck(task); // 15 秒后再检查
}
}
}
private delayedInterruptCheck(task: Task): void {
setTimeout(async () => {
const current = this.tasks.get(task.id);
if (!current || current.status !== TaskStatus.PROCESSING) return;
await this.recoverFetchRelayResults(); // 再试一次
// 仍然 processing → 标记失败
}, 15000);
}
原因: Fetch Relay 的 SW 端是异步执行的,图片生成 API 通常需要 5-30 秒。如果立即标记失败,SW 完成的结果会被丢弃,用户看到失败但实际上请求成功了。
IndexedDB 主线程读取前检查 store 是否存在
场景: 主线程 StorageReader(如 WorkflowStorageReader)从 IndexedDB 读取数据时
问题: object store 由 SW 在 onupgradeneeded 中创建。首次加载或 SW 尚未初始化时 store 可能不存在,直接 transaction(storeName) 会抛 NotFoundError。
❌ 错误示例:
async getWorkflow(id: string): Promise<Workflow | null> {
const db = await this.getDB();
const swWorkflow = await this.getById(WORKFLOWS_STORE, id); // store 不存在时抛 NotFoundError
return swWorkflow ? convert(swWorkflow) : null;
}
✅ 正确示例:
async getWorkflow(id: string): Promise<Workflow | null> {
const db = await this.getDB();
if (!db.objectStoreNames.contains(WORKFLOWS_STORE)) return null;
const swWorkflow = await this.getById(WORKFLOWS_STORE, id);
return swWorkflow ? convert(swWorkflow) : null;
}
原因: 主线程用 indexedDB.open(dbName) 不加版本,不会触发 onupgradeneeded,无法创建 store。需在读取前检查 db.objectStoreNames.contains(storeName)。
降级模式持久化失败不阻塞执行
场景: MainThreadWorkflowEngine 或 workflowStorageWriter 在降级模式下写入 IndexedDB 时
问题: IndexedDB store 可能不存在,saveWorkflow 失败会 reject,导致工作流执行中断,用户看不到请求发出。
❌ 错误示例:
async saveWorkflow(workflow: Workflow): Promise<void> {
const db = await this.getDB();
if (!db.objectStoreNames.contains(WORKFLOWS_STORE)) {
reject(new Error('Store not found')); // 导致 WorkflowEngine 捕获异常并标记失败
}
// ...
}
✅ 正确示例:
async saveWorkflow(workflow: Workflow): Promise<void> {
try {
const db = await this.getDB();
if (!db.objectStoreNames.contains(WORKFLOWS_STORE)) return;
// ... 写入
} catch {
// 静默跳过:降级模式不依赖持久化,优先保证 API 调用执行
}
}
原因: 降级模式下用户最关心的是请求能发出,持久化是次要的。失败时 resolve 而非 reject,不阻塞工作流执行。
主线程任务数据必须通过 RPC 持久化到 SW
场景: 从云端同步或恢复任务数据到本地时
❌ 错误示例:
// 错误:只添加到本地内存,页面刷新后数据丢失
async restoreTasks(tasks: Task[]): Promise<void> {
for (const task of tasks) {
this.tasks.set(task.id, task); // 只存到内存
this.emitEvent('taskCreated', task);
}
}
✅ 正确示例:
// 正确:通过 RPC 调用 SW 的 importTasks 方法持久化到 IndexedDB
async restoreTasks(tasks: Task[]): Promise<void> {
const tasksToRestore = tasks.filter(task => !this.tasks.has(task.id));
if (tasksToRestore.length === 0) return;
// 转换为 SWTask 格式
const swTasks = tasksToRestore.map(task => this.convertTaskToSWTask(task));
// 调用 SW 的 importTasks RPC 持久化到 IndexedDB
const result = await swChannelClient.importTasks(swTasks);
if (result.success) {
// 持久化成功后才添加到本地内存
for (const task of tasksToRestore) {
this.tasks.set(task.id, task);
this.emitEvent('taskCreated', task);
}
}
}
现象: 云端同步任务后显示成功,但页面刷新后任务列表为空。
原因: swTaskQueueService 的本地 tasks Map 只是内存状态,不会自动持久化。SW 的 IndexedDB 才是任务数据的持久化存储。必须通过 RPC 调用 SW 端的方法才能将数据保存到 IndexedDB。
相关文件:
- 主线程:
packages/drawnix/src/services/sw-task-queue-service.ts - SW 端:
apps/web/src/sw/task-queue/channel-manager.ts(TASK_IMPORTRPC) - SW 存储:
apps/web/src/sw/task-queue/storage.ts
远程同步的任务不应恢复执行
场景: 多设备同步场景下,避免远程同步过来的任务被错误地恢复执行
❌ 错误示例:
// 错误:SW 重启后会恢复所有 PROCESSING/PENDING 状态的任务,包括远程同步的
private shouldResumeTask(task: SWTask): boolean {
if (task.status === TaskStatus.PROCESSING) {
return true; // 会恢复远程同步的任务,导致重复调用大模型接口
}
return false;
}
✅ 正确示例:
// 正确:检查 syncedFromRemote 标记,跳过远程同步的任务
private shouldResumeTask(task: SWTask): boolean {
// 远程同步的任务不应恢复执行(避免多设备重复执行)
if (task.syncedFromRemote) {
return false;
}
if (task.status === TaskStatus.PROCESSING) {
return true;
}
return false;
}
现象: 用户在设备 A 创建任务并同步到云端,设备 B 同步后页面刷新,任务被重新执行,导致重复调用大模型接口(产生额外费用)。
原因: SW 重启后会调用 restoreFromStorage() 恢复任务,并对 PROCESSING/PENDING 状态的任务调用 resumeTaskExecution()。远程同步的任务虽然通常是 COMPLETED 状态,但为了防止边界情况,需要通过 syncedFromRemote 标记明确区分本地创建和远程同步的任务。
实现要点:
Task和SWTask接口添加syncedFromRemote?: boolean字段task-sync-service.ts的compactToTask()设置syncedFromRemote: true- SW 的
shouldResumeTask()检查此标记并返回false - 数据转换函数(
convertTaskToStorageFormat、convertTaskToSWTask)传递此字段
相关文件:
- 主线程类型:
packages/drawnix/src/types/shared/core.types.ts - SW 类型:
apps/web/src/sw/task-queue/types.ts - 任务恢复:
apps/web/src/sw/task-queue/queue.ts(shouldResumeTask) - 同步服务:
packages/drawnix/src/services/github-sync/task-sync-service.ts
PostMessage 日志由调试模式完全控制
场景: Service Worker 与主线程之间的通讯日志记录
关键原则: PostMessage 日志记录必须完全由调试模式控制,避免影响未开启调试模式的应用性能。
✅ 正确实现:
// 1. postmessage-logger.ts 中的日志记录检查
function shouldLogMessage(messageType: string): boolean {
// 调试模式未启用时,立即返回 false,不进行任何记录操作
if (!isDebugModeActive()) {
return false;
}
return !EXCLUDED_MESSAGE_TYPES.includes(messageType);
}
// 2. message-bus.ts 中的日志记录
export function sendToClient(client: Client, message: unknown): void {
// Only attempt to log if debug mode is enabled
let logId = '';
if (isPostMessageLoggerDebugMode()) {
const messageType = (message as { type?: string })?.type || 'unknown';
logId = logSentMessage(messageType, message, client.id);
}
client.postMessage(message);
// ... 仅在调试模式启用时广播日志
}
// 3. Service Worker 中的日志记录
sw.addEventListener('message', (event: ExtendableMessageEvent) => {
// Log received message only if debug mode is enabled
let logId = '';
if (isPostMessageLoggerDebugMode()) {
logId = logReceivedMessage(messageType, event.data, clientId);
}
// ... 处理消息
});
// 4. 调试模式切换时的内存清理
export function setPostMessageLoggerDebugMode(enabled: boolean): void {
const wasEnabled = debugModeEnabled;
debugModeEnabled = enabled;
if (!enabled && wasEnabled) {
// 从启用变为禁用时,立即清空日志,释放内存
logs.length = 0;
pendingRequests.clear();
}
}
性能影响:
- 调试关闭: 零日志记录开销,零内存占用,应用运行不受影响
- 调试启用: 完整的日志记录,实时显示在调试面板,可接受的性能开销仅在调试时产生
相关文件:
docs/SW_DEBUG_POSTMESSAGE_LOGGING.md- 完整的实现文档apps/web/src/sw/task-queue/postmessage-logger.ts- 日志记录模块apps/web/src/sw/task-queue/utils/message-bus.ts- 消息总线模块apps/web/public/sw-debug.html- 调试面板界面
重复提交检测应由 UI 层处理
场景: 实现防重复提交功能时
❌ 错误示例:
// 错误:在服务层基于参数哈希进行去重
class TaskQueueService {
private recentSubmissions: Map<string, number>;
createTask(params: GenerationParams, type: TaskType): Task {
const paramsHash = generateParamsHash(params, type);
// 服务层拦截"相同参数"的任务
if (this.isDuplicateSubmission(paramsHash)) {
throw new Error('Duplicate submission detected');
}
this.recentSubmissions.set(paramsHash, Date.now());
// ... 创建任务
}
private isDuplicateSubmission(hash: string): boolean {
const lastSubmission = this.recentSubmissions.get(hash);
return lastSubmission && Date.now() - lastSubmission < 5000;
}
}
✅ 正确示例:
// 正确:服务层只检查 taskId 重复(防止同一任务被提交两次)
class TaskQueueService {
createTask(params: GenerationParams, type: TaskType): Task {
const taskId = generateTaskId(); // UUID v4,每次不同
if (this.tasks.has(taskId)) {
console.warn(`Task ${taskId} already exists`);
return;
}
// ... 创建任务,不做参数去重
}
}
// UI 层通过按钮防抖和状态管理处理重复提交
const AIInputBar = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (isSubmitting) return; // 防止重复点击
setIsSubmitting(true);
try {
await taskQueueService.createTask(params, type);
} finally {
// 使用冷却时间防止快速连续提交
setTimeout(() => setIsSubmitting(false), 1000);
}
};
};
原因:
- 用户意图不同: 用户连续提交相同参数可能是故意的(想生成多张相同提示词的图片)
- 去重规则复杂: "相同参数"的定义不清晰(图片 base64 是否算相同?时间戳呢?)
- 职责分离: 防重复点击是 UI 交互问题,应由 UI 层解决
- 调试困难: 服务层拦截导致的错误不易排查,用户不知道为什么提交失败
API 请求禁止重试
场景: 实现 API 调用(图片生成、视频生成、聊天等)时
❌ 错误示例:
// 错误:添加重试逻辑
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(apiUrl, options);
if (response.ok) return response.json();
} catch (error) {
if (attempt < maxRetries - 1) {
await sleep(retryDelay);
continue;
}
throw error;
}
}
✅ 正确示例:
// 正确:直接请求,失败则抛出错误
const response = await fetch(apiUrl, options);
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
throw error;
}
return response.json();
禁止重试的请求类型:
- AI 生成 API(图片、视频、角色)
- 聊天 API
- 任务队列中的任务执行
- Service Worker 中的 fetch 请求
原因:
- AI 生成请求成本高(时间和费用),重试会导致重复消耗
- 失败通常是由于内容策略、配额限制或 API 问题,重试无法解决
- 用户可以手动重试失败的任务
- 重试会延长错误反馈时间,影响用户体验
新画布功能必须作为 Plait 插件实现
场景: 需要在画布上添加新的交互功能(如激光笔、新画笔类型、标注工具等)
❌ 错误做法:使用独立 React 组件 + SVG overlay
// 错误:独立 React 组件,坐标系与画布不一致
const LaserPointer: React.FC = ({ active, board }) => {
const svgRef = useRef<SVGSVGElement>(null);
// 手动监听 pointer 事件,自己计算坐标
const handlePointerMove = (e: React.PointerEvent) => {
const rect = svgRef.current!.getBoundingClientRect();
// 屏幕坐标,未经过画布缩放/平移变换 → 笔迹位置错误
trailRef.current.push({ x: e.clientX - rect.left, y: e.clientY - rect.top });
};
return <svg className="overlay" onPointerMove={handlePointerMove}>...</svg>;
};
✅ 正确做法:实现为 withXxx 插件,复用框架基础设施
// 正确:作为 Plait 插件,复用 Generator/Smoother/坐标变换
export const withLaserPointer = (board: PlaitBoard) => {
const { pointerDown, pointerMove, pointerUp } = board;
const generator = new FreehandGenerator(board); // 复用已有渲染器
const smoother = new FreehandSmoother(); // 复用已有平滑算法
board.pointerMove = (event: PointerEvent) => {
if (isDrawing) {
const smoothed = smoother.process([event.x, event.y]);
// 使用框架标准坐标变换,与画布缩放/平移一致
const point = toViewBoxPoint(board, toHostPoint(board, smoothed[0], smoothed[1]));
points.push(point);
generator.processDrawing(element, PlaitBoard.getElementTopHost(board));
return;
}
pointerMove(event);
};
return board;
};
原因:
- 独立 SVG overlay 有自己的坐标系,不跟随画布缩放/平移,导致笔迹位置错误
- overlay 需要
stopPropagation拦截事件,与画布交互冲突 - 无法复用 FreehandGenerator(SVG 渲染)、FreehandSmoother(点平滑)等已有能力
- 插件方式通过
board.pointerDown/Move/Up链自然融入事件流
工具互斥通过 board.pointer 管理
场景: 添加新的工具类型(如激光笔),需要与其他工具(选择、手型、画笔)互斥
❌ 错误做法:使用独立布尔状态
// 错误:独立状态,需要手动维护互斥
interface DrawnixState {
laserPointerActive?: boolean; // 独立状态
}
// 激活激光笔时,不会自动停用其他工具
updateAppState({ laserPointerActive: true });
// 需要额外逻辑处理互斥
const [laserActive, setLaserActive] = useState(false);
useEffect(() => {
if (appState.laserPointerActive) {
setLaserActive(true);
updateState(prev => ({ ...prev, laserPointerActive: false }));
}
}, [appState.laserPointerActive]);
✅ 正确做法:加入枚举,使用 board.pointer 系统
// 正确:加入 FreehandShape 枚举
export enum FreehandShape {
feltTipPen = 'feltTipPen',
eraser = 'eraser',
laserPointer = 'laserPointer', // 新增
}
// 切换工具时,通过框架统一管理
BoardTransforms.updatePointerType(board, FreehandShape.laserPointer);
updateAppState({ pointer: FreehandShape.laserPointer });
// board.pointer 是单值,自动与其他工具互斥
// 判断激活状态
const isActive = board.pointer === FreehandShape.laserPointer;
原因:
board.pointer是单值,设置新工具自动停用旧工具,无需手动管理互斥- 工具栏 UI 通过
board.pointer === xxx自动反映选中状态 - 快捷键切换(V/H/L/E 等)只需更新 pointer 一处
- 插件中通过
PlaitBoard.isPointer(board, xxx)判断是否激活,逻辑清晰
Pointer 双更新:board 和 React context 必须同步
场景: 切换或恢复工具 pointer 时,只更新了 Plait board 的 pointer 而忘记更新 React context(appState.pointer),导致 UI 状态残留(如橡皮擦工具栏在退出幻灯片后仍然显示)
❌ 错误示例:
// 退出幻灯片时只恢复了 board pointer
const handleClose = () => {
BoardTransforms.updatePointerType(board, savedPointer);
// 遗漏:React context 中的 appState.pointer 仍是 FreehandShape.eraser
// 橡皮擦工具栏判断 appState.pointer === FreehandShape.eraser 仍为 true
};
✅ 正确示例:
// 同时更新 board 和 React context
const handleClose = () => {
BoardTransforms.updatePointerType(board, savedPointer);
setPointer(savedPointer); // 同步更新 React context
};
原因: Pointer 状态存在于两层:Plait board(board.pointer)和 React context(appState.pointer)。UI 组件(如橡皮擦工具栏)依赖 React context 判断显隐,只更新 board 不会触发 React 重渲染,导致 UI 与实际状态不一致。任何修改 pointer 的地方都必须同时调用 BoardTransforms.updatePointerType 和 setPointer。
Plait 选中状态渲染触发
场景: 在异步回调(如 setTimeout)中使用 addSelectedElement 选中元素时
❌ 错误示例:
// 错误:addSelectedElement 只更新 WeakMap 缓存,不触发渲染
setTimeout(() => {
const element = board.children.find(el => el.id === elementId);
clearSelectedElement(board);
addSelectedElement(board, element); // 选中状态已更新,但 UI 不会刷新
BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
}, 50);
✅ 正确示例:
// 正确:使用 Transforms.setNode 触发 board.apply() 从而触发渲染
setTimeout(() => {
const elementIndex = board.children.findIndex(el => el.id === elementId);
const element = elementIndex >= 0 ? board.children[elementIndex] : null;
if (element) {
clearSelectedElement(board);
addSelectedElement(board, element);
BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
// 设置临时属性触发渲染,然后立即删除
Transforms.setNode(board, { _forceRender: Date.now() } as any, [elementIndex]);
Transforms.setNode(board, { _forceRender: undefined } as any, [elementIndex]);
}
}, 50);
原因: Plait 的 addSelectedElement 只是将元素存入 BOARD_TO_SELECTED_ELEMENT WeakMap 缓存,不会触发任何渲染。在同步流程中(如 insertElement 内部),Transforms.insertNode 已经触发了 board.apply() 和渲染链,所以选中状态能正常显示。但在异步回调中单独调用时,需要手动触发一次 board.apply() 来刷新渲染。Transforms.setNode 会调用 board.apply(),从而触发完整的渲染链。
插入元素后选中需通过 ID 查找实际引用
场景: 使用 Transforms.insertNode 插入元素后,需要选中该元素
❌ 错误示例:
// 错误:直接使用传入的对象调用 addSelectedElement
const newElement = { id: idCreator(), type: 'pen', ... };
Transforms.insertNode(board, newElement, [board.children.length]);
clearSelectedElement(board);
addSelectedElement(board, newElement); // 可能报错:Unable to find the path for Plait node
✅ 正确示例:
// 正确:通过 ID 从 board.children 中查找实际插入的元素
const newElement = { id: idCreator(), type: 'pen', ... };
Transforms.insertNode(board, newElement, [board.children.length]);
// 查找实际插入到 board 中的元素引用
const insertedElement = board.children.find(child => child.id === newElement.id);
if (insertedElement) {
clearSelectedElement(board);
addSelectedElement(board, insertedElement);
}
原因: Transforms.insertNode 插入元素时,Plait 可能会对元素进行处理或创建新的引用。addSelectedElement 内部会调用 findPath 查找元素路径,如果传入的对象引用与 board.children 中的不一致,会导致 "Unable to find the path for Plait node" 错误。
异步任务幂等性检查应检查存在性而非完成状态
场景: 实现防止任务重复执行的检查逻辑时(如页面刷新后恢复任务)
❌ 错误示例:
// 错误:只检查 completed 状态,会导致 in_progress 的任务被重复执行
async checkProcessedRequest(requestId: string): Promise<boolean> {
const result = await db.get('requests', requestId);
// 用户刷新页面时,in_progress 的任务会被再次执行!
if (result && result.status === 'completed' && result.response) {
return true;
}
return false;
}
### Plait API 函数签名注意事项
**场景**: 调用 Plait 的工具函数(如 `getRectangleByElements`)时
❌ **错误示例**:
```typescript
// 错误:漏掉 board 参数,导致 elements.forEach is not a function 错误
const elementRect = getRectangleByElements([element], false);
// getRectangleByElements 的第一个参数是 board,不是 elements!
✅ 正确示例:
// 正确:检查任务是否存在,无论状态如何
async checkProcessedRequest(requestId: string): Promise<boolean> {
const result = await db.get('requests', requestId);
// 存在即返回 true,防止重复执行
if (result) {
return true;
}
return false;
}
原因:
- 当任务状态为
in_progress时,说明任务已经开始执行 - 如果只检查
completed状态,用户刷新页面后会导致同一任务被重复执行 - 正确的做法是检查任务记录是否存在,存在即视为"已处理"
- 这符合幂等性原则:同一请求多次执行应该得到相同结果
适用场景:
- Service Worker 恢复任务
- 页面刷新后的任务续接
- 分布式系统中的请求去重 // 正确:board 作为第一个参数 const elementRect = getRectangleByElements(board, [element], false);
**常见的需要 board 参数的 Plait 函数**:
- `getRectangleByElements(board, elements, includePadding)`
- `getSelectedElements(board)`
- `PlaitElement.getElementG(element)` - 注意这个不需要 board
**原因**: Plait 的大多数工具函数需要 board 作为上下文,用于访问视口、缩放比例等信息。漏掉 board 参数会导致运行时错误,且错误信息可能难以理解(如将 elements 数组误认为 board 对象导致的方法调用错误)。
### 交互插件必须覆盖所有元素类型
**场景**: 橡皮擦、选择、拖拽等交互插件只处理了部分元素类型,新增元素类型后遗漏更新
❌ **错误示例**:
```typescript
// 橡皮擦只处理 Freehand 元素,Frame/Image/Video 完全无法擦除
const checkAndMarkFreehandElementsForDeletion = (point: Point) => {
const freehandElements = board.children.filter((element) =>
Freehand.isFreehand(element)
) as Freehand[];
// 只遍历 freehand,其他元素类型被忽略
};
✅ 正确示例:
// 遍历所有 children,按类型分别做命中检测
const checkAndMarkElementsForDeletion = (point: Point) => {
board.children.forEach((element) => {
if (elementsToDelete.has(element.id)) return;
let hit = false;
if (Freehand.isFreehand(element)) {
hit = isHitFreehandWithRadius(board, element, viewBoxPoint, hitRadius);
} else if (isErasableRectElement(element)) {
hit = isHitRectElement(element, viewBoxPoint, hitRadius);
}
if (hit) {
PlaitElement.getElementG(element).style.opacity = '0.2';
elementsToDelete.add(element.id);
}
});
};
原因: 新增元素类型(如 Frame、Video)后,所有交互插件(橡皮擦、选择、碰撞检测等)都需要更新以支持新类型。仅过滤特定类型会导致其他类型的元素无法被交互。
createPortal 到 document.body 会脱离 Plait React Context
场景: 在 createPortal(... document.body) 渲染的组件中使用 useBoard() 等 Plait hooks
❌ 错误示例:
// FrameSlideshow 通过 createPortal 渲染到 document.body
// 内部使用 PencilSettingsToolbar,它调用 useBoard()
// 报错:The `useBoard` hook must be used inside the <Plait> component's context
return createPortal(
<PencilSettingsToolbar />, // 内部调用 useBoard() → 报错
document.body
);
✅ 正确示例:
// 通过 props 传递 board,直接调用 Plait API
const FrameSlideshow: React.FC<{ board: PlaitBoard }> = ({ board }) => {
const settings = getFreehandSettings(board);
const handleColorChange = (color: string) => {
setFreehandStrokeColor(board, color);
};
// 内联实现设置面板,不依赖 useBoard()
};
原因: createPortal 渲染到 document.body 会脱离 <Plait> 组件树,React Context 无法穿透。必须通过 props 传递 board 并直接调用 Plait API。
蒙层挖洞的 pointer-events 设置
场景: 全屏蒙层遮住画布,中间留出"窗口"区域允许交互(如幻灯片播放)
❌ 错误示例:
// 蒙层容器设置 pointer-events: auto,阻挡了窗口区域的画布交互
.frame-slideshow__mask {
pointer-events: auto; // 整个容器拦截事件,画布无法交互
}
✅ 正确示例:
// 容器透传事件,只有实际遮挡区域拦截
.frame-slideshow__mask {
pointer-events: none; // 容器不拦截
}
.frame-slideshow__mask-block {
pointer-events: auto; // 只有四块遮挡区域拦截事件
}
原因: 蒙层容器覆盖整个视口,如果设置 pointer-events: auto 会阻挡中间"窗口"区域的画布事件。正确做法是容器 pointer-events: none,仅四块实际遮挡的 div 设置 pointer-events: auto。
禁止自动删除用户数据
场景: 添加定时清理、自动裁剪、过期删除等"优化"逻辑时
❌ 错误示例:
// 错误:自动删除超过 24 小时的已完成任务
async restoreFromStorage() {
// ... 恢复任务
taskQueueStorage.cleanupOldTasks(); // 会删除素材库依赖的任务数据!
}
// 错误:创建新会话时自动删除旧会话
const createSession = async () => {
if (sessions.length >= MAX_SESSIONS) {
await pruneOldSessions(MAX_SESSIONS); // 会删除用户的聊天历史!
}
};
// 错误:定期清理"过期"的工作流数据
setInterval(() => cleanupOldWorkflows(), 24 * 60 * 60 * 1000);
✅ 正确示例:
// 正确:不自动删除任务数据
async restoreFromStorage() {
// ... 恢复任务
// NOTE: 不调用 cleanupOldTasks(),任务数据是素材库的数据来源
}
// 正确:不限制会话数量,让用户手动管理
const createSession = async () => {
const newSession = await chatStorageService.createSession();
// 不自动删除旧会话,用户可以手动删除
};
// 正确:只清理临时数据,不清理用户数据
setInterval(() => {
cleanupRecentSubmissions(); // ✅ 清理内存中的去重缓存(临时数据)
cleanupStaleRequests(); // ✅ 清理过期的请求状态(临时数据)
}, 60000);
可以自动清理的数据:
- 内存中的临时状态(去重缓存、请求状态、锁)
- 追踪事件缓存(临时数据)
- 存储空间不足时的 LRU 缓存淘汰(用户会收到提示)
禁止自动清理的数据:
- 任务数据(素材库依赖)
- 聊天会话和消息
- 工作流数据
- 用户上传的素材
- 项目和画板数据
原因: 本项目的素材库通过 taskQueueService.getTasksByStatus(COMPLETED) 获取 AI 生成的素材。如果自动删除已完成的任务,素材库就无法展示这些 AI 生成的图片/视频。类似地,聊天历史、工作流数据都是用户的重要数据,不应被自动删除。
类服务的 setInterval 必须保存 ID 并提供 destroy 方法
场景: 在类(Service、Manager、Client)中使用 setInterval 进行定期任务(如清理、监控、心跳)
❌ 错误示例:
class RequestManager {
constructor() {
// 错误:没有保存 interval ID,无法清理
setInterval(() => {
this.cleanupExpiredRequests();
}, 60000);
}
// 没有 destroy 方法!
}
class DuplexClient {
private startPerformanceMonitoring(): void {
// 错误:interval 一旦创建就永远运行
setInterval(() => {
this.updatePerformanceMetrics();
}, 5000);
}
}
✅ 正确示例:
class RequestManager {
private cleanupTimerId: ReturnType<typeof setInterval> | null = null;
constructor() {
this.startCleanupTimer();
}
private startCleanupTimer(): void {
if (this.cleanupTimerId) {
clearInterval(this.cleanupTimerId);
}
this.cleanupTimerId = setInterval(() => {
this.cleanupExpiredRequests();
}, 60000);
}
destroy(): void {
if (this.cleanupTimerId) {
clearInterval(this.cleanupTimerId);
this.cleanupTimerId = null;
}
// 清理其他资源...
}
}
检查清单:
- 每个
setInterval调用都保存返回的 ID 到类成员变量 - 类必须提供
destroy()方法用于清理定时器 - 重复调用启动方法时先清理旧定时器
- 单例模式的类在重新获取实例前也需要清理
原因: 类服务通常是单例或长期存在的,但在某些场景下(如热更新、测试、页面切换)需要销毁重建。未清理的 setInterval 会导致:
- 内存泄漏(闭包持有整个类实例)
- 定时器累积(每次创建新实例都增加一个定时器)
- 回调执行在已销毁的实例上
Map/Set 需要清理机制防止无限增长
场景: 使用 Map 或 Set 缓存数据(如工作流、请求、会话)
❌ 错误示例:
class WorkflowService {
private workflows: Map<string, Workflow> = new Map();
submit(workflow: Workflow): void {
this.workflows.set(workflow.id, workflow);
// 只有 set,没有 delete!
}
handleCompleted(workflowId: string): void {
const workflow = this.workflows.get(workflowId);
workflow.status = 'completed';
// 完成后没有从 Map 中移除,导致无限增长
}
}
✅ 正确示例:
// 清理延迟:完成后保留 5 分钟供查询
const CLEANUP_DELAY = 5 * 60 * 1000;
class WorkflowService {
private workflows: Map<string, Workflow> = new Map();
private cleanupTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
handleCompleted(workflowId: string): void {
const workflow = this.workflows.get(workflowId);
if (workflow) {
workflow.status = 'completed';
}
// 调度延迟清理
this.scheduleCleanup(workflowId);
}
handleFailed(workflowId: string, error: string): void {
const workflow = this.workflows.get(workflowId);
if (workflow) {
workflow.status = 'failed';
workflow.error = error;
}
this.scheduleCleanup(workflowId);
}
private scheduleCleanup(workflowId: string): void {
// 清除已有的清理定时器
const existingTimer = this.cleanupTimers.get(workflowId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
this.workflows.delete(workflowId);
this.cleanupTimers.delete(workflowId);
}, CLEANUP_DELAY);
this.cleanupTimers.set(workflowId, timer);
}
}
常见需要清理的 Map/Set:
workflows- 工作流完成/失败后pendingRequests- 请求超时或完成后sessions- 会话过期后subscriptions- 取消订阅后batches- 批处理完成后
原因: 没有清理机制的 Map/Set 会随着使用不断增长,最终导致内存溢出。即使单个条目很小,长期积累也会消耗大量内存。应该在数据不再需要时(完成、失败、超时、取消)及时清理。
不要绕过封装函数直接调用底层 API
场景: 项目中有封装好的函数处理额外逻辑(如日志记录、状态追踪、错误处理)
❌ 错误示例:
// 错误:直接调用 postMessage,绕过了日志记录系统
async sendToFocused(message: Message): Promise<boolean> {
const focusedClient = await this.findFocusedClient();
if (focusedClient) {
focusedClient.postMessage(message); // 绕过了 sendToClient 的日志记录
return true;
}
return false;
}
✅ 正确示例:
// 正确:使用封装函数,确保日志被记录
async sendToFocused(message: Message): Promise<boolean> {
const focusedClient = await this.findFocusedClient();
if (focusedClient) {
sendToClient(focusedClient, message); // 通过封装函数发送,会记录日志
return true;
}
return false;
}
常见场景:
sendToClient()vs 直接client.postMessage()fetchWithRetry()vs 直接fetch()logError()vs 直接console.error()cacheService.set()vs 直接localStorage.setItem()
原因: 封装函数通常包含重要的附加逻辑(日志记录、错误处理、监控上报等)。直接调用底层 API 会绕过这些逻辑,导致功能不完整或难以调试。在添加新代码时,应检查是否有现成的封装函数可用。
页面卸载时必须清理所有定时器资源
场景: 页面使用多个 setInterval 进行定时任务(如心跳、监控、轮询)
❌ 错误示例:
// 启动多个定时器
const heartbeatTimer = setInterval(sendHeartbeat, 5000);
startMemoryMonitoring(); // 内部也创建了 memoryMonitorInterval
// 卸载时只清理了部分定时器
window.addEventListener('beforeunload', () => {
clearInterval(heartbeatTimer);
// 遗漏了 memoryMonitorInterval!
});
✅ 正确示例:
// 启动多个定时器
const heartbeatTimer = setInterval(sendHeartbeat, 5000);
startMemoryMonitoring();
// 卸载时清理所有定时器
window.addEventListener('beforeunload', () => {
clearInterval(heartbeatTimer);
stopMemoryMonitoring(); // 确保清理所有定时器
});
检查清单:
- 列出页面中所有的
setInterval调用 - 确保
beforeunload或unload事件中清理每一个定时器 - 封装在函数中的定时器需要提供对应的
stop函数 - 考虑使用统一的资源管理器来追踪所有需要清理的资源
原因: 遗漏的定时器会在页面卸载后继续运行(特别是在 SPA 或 iframe 场景),导致:
- 资源泄漏(回调函数持有的闭包无法释放)
- 不必要的 CPU 占用
- 可能访问已销毁的 DOM 或状态
调试日志清理规范
场景: 开发功能时添加 console.log 调试日志
❌ 错误示例:
// 开发时添加了大量调试日志,提交时忘记删除
function handleClick(event: PointerEvent) {
console.log('[MyComponent] handleClick:', event);
console.log('[MyComponent] current state:', state);
// 业务逻辑...
console.log('[MyComponent] result:', result);
}
✅ 正确示例:
// 1. 提交前删除所有 console.log 或将其注释掉
function handleClick(event: PointerEvent) {
// 业务逻辑...
}
// 2. 使用分级日志记录高价值调试信息
function complexFunction() {
// console.info('[System] Initializing component'); // 高级生命周期事件
// console.debug('[Debug] Trace data:', data); // 详细数据追踪
// 业务逻辑...
}
原因: 调试日志会污染控制台输出,影响生产环境的日志分析,也会增加代码体积。开发时可以自由添加日志,但提交前必须清理。如果某些日志对生产调试有价值,应使用注释形式保留或使用分级的 console.debug/info (但需确保不会导致性能问题)。
Exceptions:
console.error/console.warn用于记录真正的错误/警告是允许的- 带有
[DEBUG]前缀且通过环境变量控制的日志可以保留 - 关键系统启动或成功标志日志 (如
Initialized successfully) 推荐保留一份但需保持简洁。
同步数据格式一致性
场景: 解析远程 Gist 中的同步数据文件时
❌ 错误示例:
// 错误:直接把 tasks.json 当数组解析
if (gist.files['tasks.json']) {
const tasksData = await parseGistFile(gist.files['tasks.json']);
if (Array.isArray(tasksData)) {
remoteTasks = tasksData; // 永远不会执行!
}
}
// 错误:查找 .drawnix 文件
for (const [fileName] of Object.entries(gist.files)) {
if (fileName.endsWith('.drawnix')) { // 实际格式是 board_*.json
remoteBoards.push(fileName);
}
}
✅ 正确示例:
// 正确:tasks.json 结构是 { completedTasks: Task[] }
if (gist.files['tasks.json']) {
const tasksData = await parseGistFile(gist.files['tasks.json']);
if (tasksData && Array.isArray(tasksData.completedTasks)) {
remoteTasks = tasksData.completedTasks;
}
}
// 正确:画板文件格式是 board_{id}.json
for (const [fileName] of Object.entries(gist.files)) {
if (fileName.startsWith('board_') && fileName.endsWith('.json')) {
const boardId = fileName.replace('board_', '').replace('.json', '');
remoteBoards.push({ id: boardId, fileName });
}
}
同步数据格式参考:
| 文件名 | 结构 |
|---|---|
tasks.json |
{ completedTasks: Task[] } |
board_{id}.json |
Board 对象 |
workspace.json |
{ folders, boardMetadata, currentBoardId } |
master-index.json |
{ boards: Record<id, BoardSyncInfo>, devices, ... } |
media_{hash}.json |
{ url, mimeType, data, size } |
原因: 同步数据有特定的结构,直接解析会导致数据无法正确读取。调试面板和应用代码必须使用相同的格式。
同步模块日志规范
场景: 在 github-sync/* 目录下的同步相关模块中记录日志
❌ 错误示例:
// sync-engine.ts
async pullFromRemote(): Promise<SyncResult> {
console.log('[SyncEngine] pullFromRemote START');
console.log('[SyncEngine] Downloading media 10/12:', url);
console.error('[SyncEngine] Failed to download:', error);
}
✅ 正确示例:
// sync-engine.ts
import { logInfo, logDebug, logSuccess, logWarning, logError } from './sync-log-service';
async pullFromRemote(): Promise<SyncResult> {
logInfo('pullFromRemote START');
logDebug('Downloading media', { current: 10, total: 12, url });
logError('Failed to download', error instanceof Error ? error : new Error(String(error)));
}
统一日志 API:
| 函数 | 用途 | 参数 |
|---|---|---|
logDebug(message, data?) |
详细调试信息 | message: string, data?: object |
logInfo(message, data?) |
一般流程信息 | message: string, data?: object |
logSuccess(message, data?) |
操作成功 | message: string, data?: object |
logWarning(message, data?) |
警告信息 | message: string, data?: object |
logError(message, error) |
错误信息 | message: string, error: Error |
原因:
- 同步模块的日志需要在
/sw-debug.html?tab=gist调试面板中查看,便于问题定位 console.log会污染浏览器控制台,且无法持久化和结构化查询- 统一日志服务支持:内存缓存 + IndexedDB 持久化 + 结构化查询 + 自动清理
注意事项:
logError的第二个参数必须是Error对象,不能是字符串- 使用结构化
data对象而非字符串拼接,便于后续查询和分析 sync类别的日志始终记录(不受 debugMode 影响),便于问题诊断
Z-Index 管理规范
规范文档: 参考 docs/Z_INDEX_GUIDE.md 获取完整规范
核心原则:
- 使用预定义的层级常量,禁止硬编码魔术数字
- TypeScript: 从
constants/z-index.ts导入Z_INDEX - SCSS: 从
styles/z-index.scss导入并使用$z-*变量或z()函数
层级结构 (每层预留100单位):
Layer 0 (0-999): Base & Canvas Internal
Layer 1 (1000-1999): Canvas Elements & Decorations
Layer 2 (2000-2999): Toolbars (unified-toolbar: 2000, popovers: 3000)
Layer 3 (3000-3999): Popovers & Tooltips
Layer 4 (4000-4999): Drawers & Panels (task-queue, chat-drawer)
Layer 5 (5000-5999): Modals & Dialogs (AI dialogs: 5100+)
Layer 6 (6000-6999): Notifications (active-task-warning: 6000)
Layer 7 (7000-7999): Auth Dialogs
Layer 8 (8000-8999): Image Viewer
Layer 9 (9000+): Critical Overlays (loading, system-error)
使用示例:
// TypeScript/TSX
import { Z_INDEX } from '@/constants/z-index';
<Rnd style={{ zIndex: Z_INDEX.DIALOG_AI_IMAGE }}>
// SCSS
@import 'styles/z-index';
.my-toolbar {
z-index: $z-unified-toolbar; // 或 z-index: z('unified-toolbar');
}
禁止事项:
- ❌ 禁止使用随意的数字 (如 9999, 10000, 10001)
- ❌ 禁止在同一层级随意 +1/-1
- ❌ 临时修复必须在完成后转换为规范用法
媒体 URL 处理规范(避免 CSP 和生命周期问题)
场景: 需要在画布中引用动态生成的图片/视频(如合并图片、AI 生成结果)
❌ 错误示例 1: 使用 data: URL
// 错误:data: URL 会被 CSP 的 connect-src 阻止 fetch
const dataUrl = canvas.toDataURL('image/png');
DrawTransforms.insertImage(board, { url: dataUrl, ... });
// @plait/core 的 convertImageToBase64 会对所有 URL 发起 fetch
// 生产环境 CSP connect-src 不包含 data: 会报错!
❌ 错误示例 2: 使用 blob: URL
// 错误:blob: URL 在页面刷新后失效
const blob = await fetch(imageUrl).then(r => r.blob());
const blobUrl = URL.createObjectURL(blob);
DrawTransforms.insertImage(board, { url: blobUrl, ... });
// 页面刷新后,blob: URL 失效,图片无法显示!
✅ 正确示例: 使用虚拟路径 + Service Worker 拦截
// 1. 生成 Blob 并缓存到 Cache API
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => b ? resolve(b) : reject(new Error('Failed')), 'image/png');
});
// 2. 使用虚拟路径 URL(由 Service Worker 拦截返回缓存内容)
const taskId = `merged-image-${Date.now()}`;
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
const cacheKey = `${location.origin}${stableUrl}`;
// 3. 缓存到 Cache API
await unifiedCacheService.cacheMediaFromBlob(cacheKey, blob, 'image', { taskId });
// 4. 使用虚拟路径插入图片
DrawTransforms.insertImage(board, { url: stableUrl, ... });
虚拟路径规范:
- 统一前缀:
/__aitu_cache__/ - 图片路径:
/__aitu_cache__/image/{taskId}.{ext} - 视频路径:
/__aitu_cache__/video/{taskId}.{ext} - Service Worker 通过路径或扩展名区分类型
原因:
data: URL被 CSP 的connect-src阻止(生产环境)blob: URL生命周期与页面绑定,刷新后失效- 虚拟路径 + Cache API 持久化,刷新后仍可访问
虚拟路径 URL 匹配规范
场景: 需要根据素材 URL 查找或删除画布中的元素时(如删除素材时同步删除画布元素)
❌ 错误示例: 使用精确匹配或 startsWith
// 错误:素材 URL 可能是完整 URL,画布元素可能是相对路径
function isCacheUrl(url: string): boolean {
return url.startsWith('/__aitu_cache__/'); // 无法匹配 http://localhost/__aitu_cache__/...
}
function findElement(assetUrl: string) {
return board.children.find(el => el.url === assetUrl); // 精确匹配会失败
}
// 素材 URL: http://localhost:7200/__aitu_cache__/image/xxx.png
// 元素 URL: /__aitu_cache__/image/xxx.png
// 结果:无法匹配!
✅ 正确示例: 提取路径部分进行匹配
const CACHE_URL_PREFIX = '/__aitu_cache__/';
// 检查是否为缓存 URL(支持完整 URL 和相对路径)
function isCacheUrl(url: string): boolean {
return url.includes(CACHE_URL_PREFIX); // ✅ 使用 includes
}
// 提取缓存路径部分用于匹配
function extractCachePath(url: string): string | null {
const cacheIndex = url.indexOf(CACHE_URL_PREFIX);
if (cacheIndex === -1) return null;
return url.slice(cacheIndex); // 返回 /__aitu_cache__/... 部分
}
// 匹配时使用路径部分比较
function findElements(assetUrl: string) {
const targetPath = extractCachePath(assetUrl);
return board.children.filter(el => {
const elPath = extractCachePath(el.url);
return el.url === assetUrl || (targetPath && elPath && targetPath === elPath);
});
}
原因:
- 素材存储时可能使用完整 URL(含 origin)
- 画布元素可能使用相对路径(由 Service Worker 拦截)
- 同一资源的两种 URL 形式必须能相互匹配
Cache API 缓存 key 一致性规范
场景: 主线程缓存媒体到 Cache API,Service Worker 需要读取该缓存
❌ 错误示例: 使用 location.origin 拼接完整 URL
// 主线程缓存时
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
const cacheKey = `${location.origin}${stableUrl}`; // http://localhost:7200/...
await cache.put(cacheKey, response);
// SW 读取时(代理场景下 origin 不同)
const cacheKey = request.url; // https://ai-tu.netlify.app/...
const cached = await cache.match(cacheKey); // ❌ 找不到!
✅ 正确示例: 使用相对路径作为缓存 key + 多 key 回退查找
// 主线程缓存时 - 使用相对路径
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
const cacheKey = stableUrl; // /__aitu_cache__/image/xxx.png
await cache.put(cacheKey, response);
// SW 读取时 - 优先完整 URL,回退到相对路径
let cachedResponse = await cache.match(request.url); // 完整 URL
if (!cachedResponse) {
cachedResponse = await cache.match(url.pathname); // 相对路径回退
}
原因:
- 使用
location.origin会在代理场景下导致 key 不一致(本地 vs 线上域名) - 推荐使用相对路径作为缓存 key,确保一致性
- SW 端采用多 key 回退策略,兼容历史数据和不同场景
相对路径 URL 解析规范
场景: 需要从 URL 中提取文件扩展名、路径等信息时(如下载文件时确定文件名)
❌ 错误示例: 直接使用 new URL() 解析
// 错误:相对路径无法被 new URL() 解析,会抛异常
function getFileExtension(url: string): string {
try {
const urlPath = new URL(url).pathname; // ❌ 相对路径会抛 TypeError
const ext = urlPath.substring(urlPath.lastIndexOf('.') + 1);
return ext;
} catch {
return 'bin'; // 回退到错误的扩展名
}
}
// 下载合并图片时:
// url = '/__aitu_cache__/image/merged-image-xxx.png'
// 结果:下载文件扩展名变成 .bin
✅ 正确示例: 先判断是否为相对路径
function getFileExtension(url: string): string {
try {
let urlPath: string;
// 相对路径直接使用,不需要 URL 解析
if (url.startsWith('/') || !url.includes('://')) {
urlPath = url;
} else {
urlPath = new URL(url).pathname;
}
const lastDotIndex = urlPath.lastIndexOf('.');
if (lastDotIndex > 0 && lastDotIndex < urlPath.length - 1) {
return urlPath.substring(lastDotIndex + 1).toLowerCase();
}
} catch {
// URL 解析失败
}
return 'bin';
}
原因:
new URL(path)要求完整 URL 或提供 base URL,相对路径会抛TypeError: Invalid URL- 虚拟路径如
/__aitu_cache__/xxx是相对路径,需要特殊处理 - 判断
startsWith('/')或不包含://可以识别相对路径
Service Worker 架构设计:避免复杂的往返通信
场景: 设计需要 Service Worker 执行的工具或任务时
❌ 错误示例: 复杂的往返通信架构
// 错误:ai_analyze 被设计为需要主线程执行,但内部又通过 SW 发起 chat 请求
// 形成了复杂的往返通信链,页面刷新时容易断链
// 流程:
// 1. 主线程发起工作流 → SW
// 2. SW 发现 ai_analyze 需要主线程执行
// 3. SW → 主线程 (MAIN_THREAD_TOOL_REQUEST)
// 4. 主线程执行 ai_analyze,调用 agentExecutor
// 5. agentExecutor 调用 callApiStreamViaSW
// 6. 主线程 → SW (CHAT_START) ← 又回到 SW!
// 7. SW 执行 chat,通过 MessageChannel 返回结果
// 8. 主线程收到结果,发送 MAIN_THREAD_TOOL_RESPONSE
// 9. SW 继续工作流
// 问题:刷新页面时,步骤 6-8 的通信链会断裂,导致工作流卡住
export function requiresMainThread(toolName: string): boolean {
const delegatedTools = [
'ai_analyze', // ❌ 内部又调用 SW,不应该委托给主线程
// ...
];
return delegatedTools.includes(toolName);
}
✅ 正确示例: 简化架构,避免往返通信
// 正确:如果操作最终在 SW 中执行,就应该直接在 SW 中实现
// 简化后的流程:
// 1. 主线程发起工作流 → SW
// 2. SW 直接执行 ai_analyze(不委托给主线程)
// 3. SW 内部调用 chat API
// 4. SW 解析结果,添加后续步骤
// 5. SW 继续执行后续步骤
// 在 SW 中注册工具,直接执行
export const swMCPTools: Map<string, SWMCPTool> = new Map([
['generate_image', generateImageTool],
['generate_video', generateVideoTool],
['ai_analyze', aiAnalyzeTool], // ✅ 直接在 SW 执行
]);
// 从委托列表中移除
export function requiresMainThread(toolName: string): boolean {
const delegatedTools = [
'canvas_insert', // 需要 DOM 操作,必须在主线程
'insert_mermaid', // 需要渲染,必须在主线程
// 'ai_analyze' - 不再委托,直接在 SW 执行
];
return delegatedTools.includes(toolName);
}
原因:
- 复杂的往返通信增加了故障点,页面刷新时容易断链
- Service Worker 是独立于页面的后台进程,刷新不影响 SW 执行
- 如果工具最终依赖 SW 执行(如 chat API),就应该直接在 SW 中实现
- 只有真正需要 DOM/Canvas 操作的工具才应该委托给主线程
判断标准: 工具是否真正需要主线程
- ✅ 需要委托:DOM 操作、Canvas 渲染、获取用户输入
- ❌ 不需要委托:纯 API 调用、数据处理、文件操作
Service Worker 更新注意: 修改 SW 代码后需要重新加载才能生效:
- Chrome DevTools → Application → Service Workers → 点击 "Update"
- 或关闭所有使用该 SW 的标签页,重新打开
Service Worker 更新后禁止自动刷新页面
场景: Service Worker 更新检测和页面刷新逻辑
❌ 错误示例:
// 错误:收到 SW 更新消息后自动刷新页面
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'SW_UPDATED') {
window.location.reload(); // 自动刷新会打断用户操作!
}
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload(); // 自动刷新会打断用户操作!
});
✅ 正确示例:
// 正确:使用标志位,只有用户确认后才刷新
let userConfirmedUpgrade = false;
// 监听 SW_UPDATED 消息
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'SW_UPDATED') {
// 只有用户主动确认升级后才刷新页面
if (!userConfirmedUpgrade) {
return; // 跳过自动刷新
}
setTimeout(() => window.location.reload(), 1000);
}
});
// 监听 controller 变化
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!userConfirmedUpgrade) {
return; // 跳过自动刷新
}
setTimeout(() => window.location.reload(), 1000);
});
// 监听用户确认升级事件
window.addEventListener('user-confirmed-upgrade', () => {
userConfirmedUpgrade = true;
// 触发 SW 跳过等待
pendingWorker?.postMessage({ type: 'SKIP_WAITING' });
});
原因:
- 自动刷新会打断用户正在进行的操作(编辑、生成任务等)
- 用户可能有未保存的工作,强制刷新会导致数据丢失
- 应该显示更新提示,让用户选择合适的时机刷新
相关文件:
apps/web/src/main.tsx- Service Worker 注册和更新逻辑components/version-update/version-update-prompt.tsx- 版本更新提示组件
postmessage-duplex 库使用规范 (v1.1.0)
场景: 使用 postmessage-duplex 库实现主线程与 Service Worker 的双工通信时
通信模式选择
| 模式 | 方法 | 用途 | 特点 |
|---|---|---|---|
| RPC | call() |
请求-响应 | 需要等待响应,有超时 |
| 广播 | broadcast() + onBroadcast() |
单向通知 | fire-and-forget,无响应 |
❌ 错误示例 1 - 单向通知使用 publish:
// 错误:publish 需要响应,用于单向通知会导致超时警告
channel.publish('task:status', { taskId, status }); // 等待响应超时
✅ 正确示例 1 - 使用 broadcast:
// 正确:单向通知使用 broadcast(fire-and-forget)
channel.broadcast('task:status', { taskId, status }); // 不等待响应
❌ 错误示例 2 - 接收广播使用 subscribe:
// 错误:subscribe 用于 RPC,接收广播应使用 onBroadcast
channel.subscribe('task:status', (response) => {
handleStatus(response.data); // 广播消息不会触发
return { ack: true };
});
✅ 正确示例 2 - 使用 onBroadcast:
// 正确:使用 onBroadcast 接收广播消息
channel.onBroadcast('task:status', ({ data }) => {
if (data) {
handleStatus(data as TaskStatusEvent); // data 直接是广播数据
}
// 不需要 return,这是单向的
});
客户端初始化
❌ 错误示例 3 - 未启用自动重连:
// 错误:SW 更新后连接断开,需要手动处理
const channel = await ServiceWorkerChannel.createFromPage({
timeout: 30000,
});
✅ 正确示例 3 - 启用 autoReconnect:
// 正确:启用自动重连,SW 更新时自动恢复连接
const channel = await ServiceWorkerChannel.createFromPage({
timeout: 30000,
autoReconnect: true,
log: { log: () => {}, warn: () => {}, error: () => {} }, // 禁用内部日志
} as any); // log 不在 PageChannelOptions 类型中,需要 as any
SW 端配置
❌ 错误示例 4 - 手动处理连接:
// 错误(v1.0.0 模式):手动监听 SW_CHANNEL_CONNECT
self.addEventListener('message', (event) => {
if (event.data?.type === 'SW_CHANNEL_CONNECT') {
// 手动创建 channel...
}
});
✅ 正确示例 4 - 使用 enableGlobalRouting:
// 正确(v1.1.0):使用 enableGlobalRouting 自动管理 channel
ServiceWorkerChannel.enableGlobalRouting((clientId, event) => {
const channel = ensureChannel(clientId); // 按需创建 channel
channel.handleMessage(event); // 处理消息
});
function ensureChannel(clientId: string) {
if (!channels.has(clientId)) {
const channel = ServiceWorkerChannel.createFromWorker(clientId, {
timeout: 30000,
subscribeMap: createRpcHandlers(),
log: { log: () => {}, warn: () => {}, error: () => {} },
});
channels.set(clientId, channel);
}
return channels.get(clientId);
}
isReady 检查
❌ 错误示例 5 - isReady 布尔值检查:
// 错误:isReady 返回数字 0/1,不是布尔值
if (this.channel?.isReady === true) { // 永远 false!isReady 是 1
return true;
}
✅ 正确示例 5:
// 正确:使用 truthy 检查
if (!!this.channel?.isReady) { // 0 → false, 1 → true
return true;
}
原因:
- v1.1.0 引入 broadcast 模式:
broadcast()+onBroadcast()用于单向通知,不需要响应,避免超时 - enableGlobalRouting 自动管理:不再需要手动处理
SW_CHANNEL_CONNECT,库自动路由消息到正确的 channel - autoReconnect 处理 SW 更新:SW 更新时自动重新建立连接,无需页面刷新
- log 选项禁用日志:
PageChannelOptions类型不含log,需要as any绕过类型检查 - isReady 返回数字:0/1 而非布尔值,使用
!!转换
任务队列双服务同步
场景: 项目中有两个任务队列服务,需要正确同步数据
| 服务 | 位置 | 用途 |
|---|---|---|
taskQueueService |
本地内存 | UI 组件状态(useTaskQueue hook) |
swTaskQueueService |
SW 通信 | 持久化任务数据 |
❌ 错误示例:
// 错误:只从本地服务读取,首次渲染时可能为空
export function useTaskQueue() {
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
setTasks(taskQueueService.getAllTasks()); // 可能为空!
}, []);
}
✅ 正确示例:
// 正确:渲染时直接从 IndexedDB 加载数据
export function useTaskQueue() {
const [tasks, setTasks] = useState<Task[]>([]);
const syncAttempted = useRef(false);
useEffect(() => {
setTasks(taskQueueService.getAllTasks());
}, []);
// 渲染时从 IndexedDB 加载
useEffect(() => {
if (syncAttempted.current) return;
syncAttempted.current = true;
const loadTasks = async () => {
// 直接从 IndexedDB 读取任务
const storedTasks = await taskStorageReader.getAllTasks();
if (storedTasks.length > 0) {
taskQueueService.restoreTasks(storedTasks);
}
};
loadTasks();
}, []);
}
原因:
taskQueueService是纯内存服务,页面刷新后数据丢失- 任务数据持久化在 IndexedDB 中,直接通过
taskStorageReader读取 - 避免 postMessage RPC 的 1MB 大小限制和通信不稳定性
- 响应数据格式可能有多种嵌套结构,需要灵活解析而非假设单一格式
相关文件:
packages/drawnix/src/services/sw-channel/client.ts- 主应用的 SW 通道客户端apps/web/src/sw/task-queue/channel-manager.ts- SW 端的通道管理器apps/web/public/sw-debug/duplex-client.js- 调试页面的 duplex 客户端
任务队列入口红点需区分状态与未读
场景: 工具栏任务队列图标需要提示失败任务或生成中任务时
❌ 错误示例:
// 错误:把历史失败任务当成红点条件,失败任务不清理就会永久亮
const showFailedDot =
failedTasks.length > 0 && activeTasks.length === 0;
✅ 正确示例:
// 正确:生成中是实时状态;失败红点是未查看通知
const latestFailedAt = failedTasks.reduce(
(latest, task) =>
Math.max(latest, task.completedAt || task.updatedAt || task.createdAt || 0),
0
);
const showFailedDot =
latestFailedAt > acknowledgedFailedAt && activeTasks.length === 0;
useEffect(() => {
if (taskPanelExpanded && latestFailedAt > acknowledgedFailedAt) {
setAcknowledgedFailedAt(latestFailedAt);
}
}, [acknowledgedFailedAt, latestFailedAt, taskPanelExpanded]);
原因:
failed是任务终态,可能长期保留在 IndexedDB/队列历史中,不能直接等价于“未读”- 用户打开任务队列已经完成查看动作,应清除红点;之后只有新失败或失败任务更新时间晚于确认点才重新提示
- 数字 Badge/绿色点可表达“正在生成”这种持续状态;红点更适合表达“有新情况待查看”
相关文件:
packages/drawnix/src/components/toolbar/bottom-actions-section.tsx- 任务队列入口图标和红点逻辑
降级模式(?sw=0)下任务必须自动执行
场景: 用户通过 ?sw=0 URL 参数禁用 Service Worker,使用降级模式
❌ 错误示例:
// 错误:降级模式下只创建任务,没有执行
class TaskQueueService {
createTask(params: GenerationParams, type: TaskType): Task {
const task = { id: generateTaskId(), type, status: TaskStatus.PROCESSING, ... };
this.tasks.set(task.id, task);
this.persistTask(task); // 保存到 IndexedDB
this.emitEvent('taskCreated', task);
return task; // 任务被创建但永远不会执行!
}
}
✅ 正确示例:
// 正确:创建任务后自动触发执行
class TaskQueueService {
createTask(params: GenerationParams, type: TaskType): Task {
const task = { id: generateTaskId(), type, status: TaskStatus.PROCESSING, ... };
this.tasks.set(task.id, task);
this.persistTask(task);
this.emitEvent('taskCreated', task);
// 自动执行任务(fire-and-forget)
this.executeTask(task).catch((error) => {
console.error('[TaskQueueService] Task execution error:', error);
});
return task;
}
private async executeTask(task: Task): Promise<void> {
const executor = await executorFactory.getExecutor();
// 根据任务类型执行
if (task.type === TaskType.IMAGE) {
await executor.generateImage({ taskId: task.id, prompt: task.params.prompt, ... });
} else if (task.type === TaskType.VIDEO) {
await executor.generateVideo({ taskId: task.id, prompt: task.params.prompt, ... });
}
// 轮询等待完成
const result = await waitForTaskCompletion(task.id, { timeout: 10 * 60 * 1000 });
// 更新状态
...
}
}
原因:
- SW 模式下,任务由 SW 自动执行(在
submitToSW中) - 降级模式下,任务只保存到 IndexedDB,没有后台进程执行它们
legacyTaskQueueService必须在createTask后自动调用executeTask- 同样,
retryTask也需要在重置状态后触发执行
相关文件:
packages/drawnix/src/services/task-queue-service.ts- 降级模式任务服务packages/drawnix/src/services/media-executor/factory.ts- 执行器工厂packages/drawnix/src/services/media-executor/fallback-executor.ts- 降级执行器
画布生成锚点重试必须区分生成失败和后处理失败
场景: 画布上的图片生成锚点显示“生成失败 / Failed to fetch”,用户第一次点击“重试”。
❌ 错误示例:
// 错误:任务已 completed,但后处理失败;只重新插入旧结果,不会重新发生成请求
if (task.status === TaskStatus.COMPLETED) {
workflowCompletionService.clearTask(task.id);
handleTaskCompleted(task);
}
✅ 正确示例:
// 正确:后处理失败但按钮语义是“重试生成”时,显式允许 completed 任务重新生成
const shouldRegenerateCompletedTask =
task.status === TaskStatus.COMPLETED &&
postProcessingStatus === 'failed';
if (task.status === TaskStatus.FAILED || shouldRegenerateCompletedTask) {
workflowCompletionService.clearTask(task.id);
taskQueueService.retryTask(task.id, { allowCompleted: shouldRegenerateCompletedTask });
}
原因:
- 生成锚点的
failed可能来自任务失败,也可能来自取图、显影、插入画布等后处理失败 - “重试”按钮承诺重新触发生成时,不能只清理后处理状态或复用旧结果
- 对
COMPLETED任务开放重试必须显式传参,并清空旧result / insertedToCanvas / completedAt,避免 UI 继续读取旧失败态
相关文件:
packages/drawnix/src/hooks/useAutoInsertToCanvas.ts- 生成锚点重试事件处理packages/drawnix/src/services/task-queue-service.ts- 任务重试状态重置与执行packages/drawnix/src/components/image-generation-anchor/ImageGenerationAnchorContent.tsx- 生成锚点重试入口
创建图片/视频任务及执行时须传递 referenceImages
场景: MCP 工具(如 image-generation.ts、video-generation.ts)通过 taskQueueService.createTask 创建任务,降级模式下由 executeTask 调用 executor.generateImage / generateVideo 发起请求
❌ 错误示例:
// 错误:createTask 只传 uploadedImages,不传 referenceImages
const task = taskQueueService.createTask(
{
prompt,
size: size || '1x1',
uploadedImages: uploadedImages?.length ? uploadedImages : undefined,
model,
// 缺少 referenceImages → task.params.referenceImages 为空
},
TaskType.IMAGE
);
// 错误:executeTask 调用 executor 时不传 referenceImages
await executor.generateImage({
taskId: task.id,
prompt: task.params.prompt,
model: task.params.model,
size: task.params.size,
// 缺少 referenceImages → 请求体不带参考图
});
✅ 正确示例:
// 正确:createTask 同时传入 referenceImages 与 uploadedImages
const task = taskQueueService.createTask(
{
prompt,
size: size || '1x1',
uploadedImages: uploadedImages?.length ? uploadedImages : undefined,
referenceImages: referenceImages?.length ? referenceImages : undefined,
model,
},
TaskType.IMAGE
);
// 正确:executeTask 从 task.params 取出 referenceImages 传给 executor
await executor.generateImage({
taskId: task.id,
prompt: task.params.prompt,
model: task.params.model,
size: task.params.size,
referenceImages: task.params.referenceImages as string[] | undefined,
uploadedImages: task.params.uploadedImages as Array<{ url?: string }> | undefined,
});
原因:
- 执行器(含 fallback)依赖
params.referenceImages或params.inputReference构建请求体;仅传uploadedImages时,executor 可从uploadedImages提取 URL,但task.params中必须至少有一种来源(referenceImages 或 uploadedImages),且 createTask 与 executeTask 两处都要一致传递 - 视频任务同理:createTask 传
referenceImages,executeTask 传referenceImages或inputReference,否则 sw=0 下 Form Data 不会包含input_reference
相关文件:
packages/drawnix/src/mcp/tools/image-generation.ts- 图片任务 createTaskpackages/drawnix/src/mcp/tools/video-generation.ts- 视频任务 createTaskpackages/drawnix/src/services/task-queue-service.ts- 降级 executeTask 调用 executorpackages/drawnix/src/services/sw-task-queue-service.ts- SW 降级 executeWithFallback
设置保存后需要主动更新 Service Worker 配置
场景: 用户在设置面板修改配置(如 API Key、流式请求开关)并保存后
❌ 错误示例:
// 错误:只保存到本地存储,不更新运行中的 SW 配置
const handleSave = async () => {
geminiSettings.set({
apiKey,
baseUrl,
imageStreamEnabled, // 新增的配置
});
// SW 使用的仍是初始化时的旧配置!
};
✅ 正确示例:
// 正确:保存后同时更新 SW 配置
const handleSave = async () => {
// 1. 保存到本地存储
geminiSettings.set({
apiKey,
baseUrl,
imageStreamEnabled,
});
// 2. 主动推送配置到运行中的 SW
swTaskQueueClient.updateConfig({
geminiConfig: {
apiKey,
baseUrl,
imageStreamEnabled,
},
});
};
原因:
- Service Worker 在初始化时接收配置(通过
TASK_QUEUE_INIT消息) - 之后 SW 使用内存中的配置,不会重新读取本地存储
- 如果用户修改设置后不调用
updateConfig(),SW 继续使用旧配置 - 这会导致用户开启的功能(如流式请求)看似保存成功但实际未生效
通信协议:
// 主线程 → Service Worker
swTaskQueueClient.updateConfig({
geminiConfig: { ... }, // 可选
videoConfig: { ... }, // 可选
});
// SW 内部处理
case 'TASK_QUEUE_UPDATE_CONFIG':
Object.assign(this.geminiConfig, data.geminiConfig);
Object.assign(this.videoConfig, data.videoConfig);
break;
Service Worker 内部处理虚拟路径 URL
场景: 在 Service Worker 内部需要获取 /__aitu_cache__/ 或 /asset-library/ 等虚拟路径的资源时
❌ 错误示例: 使用 fetch 获取虚拟路径
// 错误:SW 内部的 fetch 不会触发 SW 的 fetch 事件拦截
async function processReferenceImage(url: string) {
if (url.startsWith('/__aitu_cache__/')) {
const response = await fetch(url); // ❌ 这个请求不会被 SW 拦截!
const blob = await response.blob(); // 会失败或返回 404
return blobToBase64(blob);
}
}
✅ 正确示例: 直接从 Cache API 获取
// 正确:直接从 Cache API 获取,绕过 fetch
async function processReferenceImage(url: string) {
if (url.startsWith('/__aitu_cache__/')) {
const cache = await caches.open(IMAGE_CACHE_NAME);
// 缓存 key 是完整 URL(包含 origin)
const cacheKey = `${self.location.origin}${url}`;
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const blob = await cachedResponse.blob();
return blobToBase64(blob);
}
}
}
原因:
- Service Worker 的 fetch 事件只拦截来自页面(客户端)的请求
- SW 内部发起的 fetch 请求不会触发自身的 fetch 事件(避免无限循环)
- 因此必须直接从 Cache API 获取,而不是通过 fetch
- 注意缓存 key 是完整 URL,需要用
self.location.origin构造
Service Worker 中 opaque 响应的处理
场景: 使用 no-cors 模式获取外部图片时,会返回 opaque 响应
❌ 错误示例:
// 错误:只检查 status !== 0,会把 opaque 响应当作失败
for (let options of fetchOptions) {
response = await fetch(currentUrl, options);
if (response && response.status !== 0) {
break; // opaque 响应 status === 0,会被跳过!
}
}
// 错误:尝试读取 opaque 响应的 body
if (response.type === 'opaque') {
const blob = await response.blob(); // blob 是空的!
const corsResponse = new Response(blob, { ... }); // 创建的是空响应
await cache.put(request, corsResponse); // 缓存了空响应
}
✅ 正确示例:
// 正确:同时检查 status 和 type
for (let options of fetchOptions) {
response = await fetch(currentUrl, options);
// opaque 响应 status === 0 但 type === 'opaque',应该接受
if (response && (response.status !== 0 || response.type === 'opaque')) {
break;
}
}
// 正确:opaque 响应无法缓存,直接返回给浏览器
if (response.type === 'opaque') {
// 标记域名,后续请求跳过 SW
markCorsFailedDomain(hostname);
// 直接返回,依赖浏览器 disk cache
return response;
}
原因:
no-cors模式返回的 opaque 响应,status始终是0,type是'opaque'- opaque 响应的
body是安全锁定的,无法读取(返回空 Blob) - 浏览器可以用 opaque 响应显示图片,但 SW 无法读取或有效缓存
- 对于 CORS 配置错误的服务器,应该依赖浏览器的 disk cache
Cache API 返回前必须验证响应有效性
场景: 从 Cache API 返回缓存的响应时
❌ 错误示例:
// 错误:直接返回缓存,没有验证内容是否有效
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse; // 可能是之前错误缓存的空响应!
}
✅ 正确示例:
const cachedResponse = await cache.match(request);
if (cachedResponse) {
const responseClone = cachedResponse.clone();
const blob = await responseClone.blob();
// 检查 blob 是否为空
if (blob.size === 0) {
console.warn('检测到空缓存,删除并重新获取');
await cache.delete(request);
// 继续执行网络请求逻辑...
} else {
// 缓存有效,返回响应
return cachedResponse;
}
}
原因:
- 之前的代码 bug(如尝试缓存 opaque 响应的空 body)可能导致空响应被缓存
- 返回空响应会导致图片无法显示,用户体验差
- 在返回前验证
blob.size > 0可以自动修复历史问题 - 删除无效缓存后重新获取,确保用户看到正确的内容
Cache.put() 会消费 Response body,无法重复使用
场景: 需要将同一个 Response 对象缓存到多个不同的 key 时
❌ 错误示例:
// 错误:Cache.put() 会消费 Response 的 body,后续无法 clone
const response = new Response(blob, {
headers: { 'Content-Type': 'image/jpeg' },
});
// 第一次 put 消费了 body
await thumbCache.put(cacheKey1, response);
// 第二次 clone 会失败:Response body is already used
await thumbCache.put(cacheKey2, response.clone()); // ❌ TypeError
✅ 正确示例:
// 正确:为每个缓存 key 创建独立的 Response 对象
const createResponse = () => new Response(blob, {
headers: { 'Content-Type': 'image/jpeg' },
});
// 每个 put 使用独立的 Response 对象
await thumbCache.put(cacheKey1, createResponse());
await thumbCache.put(cacheKey2, createResponse());
await thumbCache.put(cacheKey3, createResponse());
原因:
Cache.put()方法会读取并消费 Response 的 body stream- 一旦 body 被消费,就无法再次读取或 clone
- Response 对象本身很轻量(只是包装 Blob),为每个 key 创建新对象是安全的
- 使用工厂函数
createResponse()可以方便地创建多个独立实例
fetchOptions 优先级:优先尝试可缓存的模式
场景: 在 Service Worker 中获取外部图片时,需要尝试多种 fetch 模式
❌ 错误示例:
// 错误:no-cors 模式优先,会导致 opaque 响应无法缓存
let fetchOptions = [
{ mode: 'no-cors' }, // ❌ 优先尝试,但无法缓存
{ mode: 'cors' }, // 可缓存,但优先级低
];
✅ 正确示例:
// 正确:优先尝试 cors 模式(可缓存),最后才尝试 no-cors
let fetchOptions = [
{ mode: 'cors' }, // ✅ 优先尝试,可以缓存
{ mode: 'no-cors' }, // 最后备选,无法缓存但可以绕过 CORS
];
原因:
cors模式返回的响应可以被 Service Worker 读取和缓存no-cors模式返回的opaque响应无法读取 body,无法有效缓存- 优先尝试可缓存的模式可以提高后续请求的命中率
- 只有在 cors 模式失败时才降级到 no-cors 模式
CDN 响应必须多重验证后才能缓存
场景: Service Worker 从 CDN 获取静态资源并缓存时
❌ 错误示例:
// 错误:只检查 response.ok,可能缓存 CDN 返回的 HTML 错误页面
const response = await fetch(cdnUrl);
if (response.ok) {
cache.put(request, response.clone());
return response; // 可能是 404 页面被当作 JS 执行!
}
✅ 正确示例:
const response = await fetch(cdnUrl);
if (response.ok) {
// 1. Content-Type 验证
const contentType = response.headers.get('Content-Type') || '';
const isValidType = contentType.includes('javascript') ||
contentType.includes('css') ||
contentType.includes('json');
if (!isValidType) continue; // 尝试下一个源
// 2. Content-Length 验证(排除空响应)
const length = parseInt(response.headers.get('Content-Length') || '0', 10);
if (length > 0 && length < 50) continue;
// 3. 内容采样验证(检测 HTML 错误页面)
const sample = await response.clone().text().then(t => t.slice(0, 200));
if (sample.includes('<!DOCTYPE') || sample.includes('Not Found')) {
continue; // CDN 返回了 HTML 错误页面
}
cache.put(request, response.clone());
return response;
}
原因:
- CDN 可能返回 404 但 HTTP 状态码仍是 200(某些 CDN 的行为)
- npm 包不存在时,CDN 返回 HTML 错误页面
- 错误页面被当作 JS 执行会导致 React 多实例冲突,应用崩溃
- 多重验证确保只缓存真正有效的资源
CDN 请求应设置短超时快速回退
场景: Service Worker 实现 CDN 优先加载策略时
❌ 错误示例:
// 错误:超时太长,CDN 回源慢时用户等待时间过长
const CDN_CONFIG = {
fetchTimeout: 10000, // 10 秒超时
};
✅ 正确示例:
// 正确:短超时,CDN 缓存命中很快(<200ms),超时说明在回源
const CDN_CONFIG = {
fetchTimeout: 1500, // 1.5 秒超时,快速回退到服务器
};
原因:
- CDN 缓存命中通常 < 200ms,1.5s 足够
- CDN 回源(首次请求)可能需要 3-5 秒,等待太久影响用户体验
- 短超时后快速回退到服务器,保证首次加载速度
- 用户请求会触发 CDN 缓存,后续访问自动加速
工作流恢复时 UI 与 SW 状态不一致的处理
场景: 页面刷新后,UI 从 IndexedDB/本地存储恢复的状态可能与 SW 端的真实状态不一致
❌ 错误示例:
// 错误:检测到终态但有运行中步骤时,直接标记为失败
if (isTerminalStatus && hasRunningSteps) {
hasClaimedRef.current = true; // 标记为已 claim
onWorkflowStateChange?.(workflowId, 'failed', '工作流已结束');
return; // 不再尝试从 SW 获取真实状态
}
// 后续即使 SW 更新了状态,也因为 hasClaimedRef.current = true 而跳过 claim
✅ 正确示例:
// 正确:不一致状态也触发 claim,从 SW 获取真实状态
const isInconsistentState = isTerminalStatus && hasRunningSteps;
const needsClaim = isActiveByStatus || isActiveBySteps || isInconsistentState;
if (isInconsistentState) {
console.log('Inconsistent state detected, will claim from SW');
// 不要在这里标记为 claimed,让 claim 逻辑去处理
}
// claim 逻辑会等待 swChannelClient 初始化,然后从 SW 获取真实状态
if (needsClaim && !hasClaimedRef.current) {
hasClaimedRef.current = true;
const result = await swChannelClient.claimWorkflow(workflowId);
// 根据 SW 返回的真实状态更新 UI
}
原因:
- 页面刷新时,UI 状态可能来自旧的 IndexedDB 数据
- SW 端可能已经更新了工作流状态(如从
failed变为running) - 如果直接根据本地状态判断,会导致用户看到错误的"失败"状态
- 正确做法是检测不一致状态后,从 SW 获取真实状态再更新 UI
SW 重发 Tool Request 需延迟等待主线程 Handler 准备
场景: 页面刷新后,SW 在工作流 claim 时重发 pending tool request
❌ 错误示例:
// channel-manager.ts - handleWorkflowClaim 中
if ((workflow.status === 'running') && hasPendingToolRequest) {
// 错误:立即重发,但主线程的 handler 可能还没注册
this.resendPendingToolRequestsForWorkflow(workflowId);
}
// 结果:
// 1. SW 发送 publish('workflow:toolRequest', {...})
// 2. 主线程还没完成 registerToolRequestHandler()
// 3. publish 返回 undefined/null
// 4. SW 误判为"超时",工作流失败
✅ 正确示例:
// 正确:延迟重发,等待主线程 handler 准备好
if ((workflow.status === 'running') && hasPendingToolRequest) {
console.log(`Will resend pending tool requests after delay`);
// 延迟 500ms,给主线程时间完成 registerToolRequestHandler
setTimeout(() => {
console.log(`Resending pending tool requests (delayed)`);
this.resendPendingToolRequestsForWorkflow(workflowId);
}, 500);
}
原因:
- 页面刷新后,组件挂载顺序不确定
WorkZoneContent的 claim 可能在WorkflowSubmissionService.registerToolRequestHandler()完成前执行- SW 的
publish()方法需要主线程已经subscribe()才能收到响应 - 500ms 延迟给主线程足够时间完成初始化(通常 200ms 内完成)
初始化时序:
页面刷新
↓
WorkflowSubmissionService.init() WorkZoneContent.useEffect()
↓ ↓
registerToolRequestHandler() swChannelClient.claimWorkflow()
(等待 swChannelClient 初始化) ↓
↓ SW 收到 claim
swChannelClient.isInitialized() ↓
↓ SW 重发 tool request ← 可能此时 handler 还没准备好!
subscribe('workflow:toolRequest')
SW 错误处理链必须保持完整
场景: 在 SW 执行链中需要传递特殊错误属性(如 isAwaitingClient)时
❌ 错误示例:
// workflow-executor.ts - executeStep 中
try {
await this.requestMainThreadTool(workflowId, stepId, toolName, args);
} catch (error) {
// 错误:所有错误都标记为 failed
step.status = 'failed';
step.error = error.message;
}
// 然后在 executeWorkflow 循环中
if (step.status === 'failed') {
// 错误:创建新的 Error 对象,丢失了 isAwaitingClient 属性
throw new Error(`Step ${step.id} failed: ${step.error}`);
}
// 最后在 catch 块中
catch (error) {
// error.message = "Step xxx failed: AWAITING_CLIENT:insert_mermaid"
// 但 error.isAwaitingClient 是 undefined!
if (error?.isAwaitingClient) { // 永远为 false
// 不会进入这个分支
}
workflow.status = 'failed'; // 错误地标记为失败
}
✅ 正确示例:
// workflow-executor.ts - executeStep 中
try {
await this.requestMainThreadTool(workflowId, stepId, toolName, args);
} catch (error) {
// 正确:检测特殊错误类型,保持原始属性
if (error?.isAwaitingClient || error?.message?.startsWith('AWAITING_CLIENT:')) {
// 保持 step 为 running 状态,重新抛出原始错误
step.status = 'running';
throw error; // 保留 isAwaitingClient 属性
}
// 其他错误才标记为 failed
step.status = 'failed';
step.error = error.message;
}
// 在 catch 块中
catch (error) {
// error.isAwaitingClient 现在是 true
if (error?.isAwaitingClient) {
console.log('Workflow waiting for client to reconnect');
workflow.status = 'running'; // 保持 running 状态
return;
}
workflow.status = 'failed';
}
原因:
- JavaScript 错误对象可以有自定义属性(如
error.isAwaitingClient = true) - 在中间层创建新的
Error对象会丢失这些属性 - 如果需要在上层检查特殊错误类型,必须重新抛出原始错误
- 或者在检查时同时检查
error.message内容作为备选
LLM API 日志必须记录异步任务的 remoteId
场景: 异步任务(视频生成、异步图片生成如 nbp4k-async)需要记录远程任务 ID 便于调试和任务恢复追踪
❌ 错误示例:
// failLLMApiLog 参数类型不支持 remoteId
export function failLLMApiLog(
logId: string,
params: {
httpStatus?: number;
duration: number;
errorMessage: string;
responseBody?: string;
// 缺少 remoteId 字段
}
): void { ... }
// 调用时 remoteId 被忽略或导致类型错误
failLLMApiLog(logId, {
duration,
errorMessage: msg,
remoteId: taskRemoteId, // ❌ 类型错误
});
✅ 正确示例:
// 参数类型支持 remoteId
export function failLLMApiLog(
logId: string,
params: {
httpStatus?: number;
duration: number;
errorMessage: string;
responseBody?: string;
remoteId?: string; // ✅ 支持异步任务 ID
}
): void {
const log = memoryLogs.find(l => l.id === logId);
if (log) {
// ... 其他字段
if (params.remoteId) log.remoteId = params.remoteId; // ✅ 记录 remoteId
updateLogInDB(log);
}
}
// completeLLMApiLog 同样需要支持
export function completeLLMApiLog(
logId: string,
params: {
// ... 其他字段
remoteId?: string; // ✅ 成功时也记录 remoteId
}
): void { ... }
原因:
- 异步任务通过
remoteId标识后端任务,是恢复轮询的关键信息 - 调试时需要通过
remoteId追踪任务在后端的状态 LLMApiLog接口已定义remoteId字段,日志函数参数也需要支持
Service Worker 静态资源回退应尝试所有版本缓存
场景: 用户使用旧版本 HTML,但服务器已部署新版本删除了旧静态资源
❌ 错误示例:
// 错误:只尝试当前版本缓存,服务器 404 时直接返回错误
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const response = await fetch(request);
if (!response.ok) {
return new Response('Not found', { status: 404 });
}
✅ 正确示例:
// 正确:服务器返回 4xx/5xx 或 HTML 回退时,尝试所有版本缓存
const response = await fetch(request);
// 检测服务器返回 HTML 错误页面(SPA 404 回退)
const contentType = response.headers.get('Content-Type');
const isHtmlFallback = response.ok && contentType?.includes('text/html') &&
request.destination === 'script';
// 服务器错误或 HTML 回退时,尝试旧版本缓存
if (response.status >= 400 || isHtmlFallback) {
const allCacheNames = await caches.keys();
for (const cacheName of allCacheNames) {
if (cacheName.startsWith('drawnix-static-v')) {
const oldCache = await caches.open(cacheName);
const oldResponse = await oldCache.match(request);
if (oldResponse) {
console.log(`Found resource in ${cacheName}`);
return oldResponse;
}
}
}
}
原因:
- 用户可能缓存了旧版本 HTML,但新部署删除了旧静态资源
- 旧 HTML 请求旧资源,服务器返回 404 或 HTML 错误页面
- 尝试旧版本缓存可以找到用户需要的资源,避免白屏
- 这是 PWA 的重要容错机制,确保版本升级平滑过渡
图像处理工具复用规范
场景: 需要对图片进行边框检测、去白边、裁剪等处理时
核心工具文件: utils/image-border-utils.ts
可用的公共方法:
| 方法 | 用途 | 返回值 |
|---|---|---|
trimCanvasWhiteAndTransparentBorder |
去除 Canvas 白边和透明边 | HTMLCanvasElement |
trimCanvasWhiteAndTransparentBorderWithInfo |
去除边框并返回偏移信息 | { canvas, left, top, trimmedWidth, trimmedHeight, wasTrimmed } |
trimImageWhiteAndTransparentBorder |
去除图片 URL 的白边 | Promise<string> (data URL) |
trimCanvasBorders |
去除 Canvas 边框(灰色+白色) | HTMLCanvasElement | null |
removeWhiteBorder |
去除图片白边(激进模式) | Promise<string> |
❌ 错误示例:
// 错误:在组件中重复实现去白边逻辑
const trimWhiteBorder = (canvas: HTMLCanvasElement) => {
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// ... 50+ 行重复代码
};
✅ 正确示例:
// 正确:使用公共工具方法
import {
trimCanvasWhiteAndTransparentBorder,
trimCanvasWhiteAndTransparentBorderWithInfo,
trimImageWhiteAndTransparentBorder
} from '../utils/image-border-utils';
// 只需要裁剪后的 Canvas
const trimmedCanvas = trimCanvasWhiteAndTransparentBorder(canvas);
// 需要知道裁剪偏移量(如计算插入位置)
const { canvas: trimmedCanvas, left, top } = trimCanvasWhiteAndTransparentBorderWithInfo(canvas);
// 处理图片 URL
const trimmedUrl = await trimImageWhiteAndTransparentBorder(imageDataUrl);
使用场景:
- 合并图片后去白边 →
trimCanvasWhiteAndTransparentBorderWithInfo(需要偏移量) - 生成预览图去白边 →
trimImageWhiteAndTransparentBorder(异步处理 URL) - 图片分割时去边框 →
trimCanvasBorders(检测灰色+白色)
原因: 图像处理逻辑(像素遍历、边界检测)容易出错且代码量大。使用统一的公共方法可以:
- 避免重复代码
- 确保一致的处理行为
- 便于统一优化和修复 bug
SSH 远程执行复杂 Shell 命令应使用 base64 编码
场景: 通过 SSH 在远程服务器执行包含引号、变量替换等复杂 shell 脚本时
❌ 错误示例:
// 错误:多层引号转义导致 shell 语法错误
const remoteCommand = `bash -c '
VERSION=$(tar -xzf ${uploadsDir}/${tarName} -O web/version.json 2>/dev/null | grep '"'"'"version"'"'"' | sed '"'"'s/.*"version": "\\([^"]*\\)".*/\1/'"'"')
if [ -z "$VERSION" ]; then
echo "无法读取版本号"
exit 1
fi
// ... 更多命令
'`;
// 错误:/bin/sh: -c: line 1: unexpected EOF while looking for matching `)'
✅ 正确示例:
// 正确:使用 base64 编码避免引号转义问题
const extractScript = `VERSION=$(tar -xzf ${uploadsDir}/${tarName} -O web/version.json 2>/dev/null | grep '"version"' | sed 's/.*"version": "\\([^"]*\\)".*/\\1/')
if [ -z "$VERSION" ]; then
echo "无法读取版本号"
exit 1
fi
// ... 更多命令`;
// 将脚本编码为 base64,避免引号转义问题
const encodedScript = Buffer.from(extractScript).toString('base64');
const remoteCommand = `echo ${encodedScript} | base64 -d | bash`;
sshCommand += ` ${config.DEPLOY_USER}@${config.DEPLOY_HOST} "${remoteCommand}"`;
原因:
- SSH 命令需要经过多层引号转义(Node.js 字符串 → SSH 命令 → shell 执行),复杂的引号嵌套容易导致语法错误
- base64 编码将脚本转换为纯 ASCII 字符串,避免了所有引号转义问题
- 远程服务器通过
base64 -d解码后执行,保持脚本原始格式
适用场景:
- 通过 SSH 执行多行 shell 脚本
- 脚本中包含引号、变量替换、管道等复杂语法
- 需要避免引号转义导致的语法错误
验证命令
修改代码后必须执行以下验证命令:
# 类型检查 (以 drawnix 为例)
cd packages/drawnix && npx tsc --noEmit
# 代码规范
pnpm nx lint drawnix
# 单元测试
pnpm nx test drawnix
# 构建验证
pnpm run build
CSS !important 覆盖 JavaScript 动态样式
场景: 需要通过 JavaScript 动态设置元素样式(如光标、颜色、尺寸),但 CSS 中存在 !important 规则
❌ 错误示例:
// SCSS 中使用 !important 固定光标样式
.plait-board-container {
&.pointer-eraser {
.board-host-svg {
cursor: url('data:image/svg+xml;base64,...') 10 10, crosshair !important;
}
}
}
// JavaScript 动态设置光标被 CSS !important 覆盖,无效
function applyCursorStyle(board: PlaitBoard, size: number) {
const hostSvg = document.querySelector('.board-host-svg');
hostSvg.style.cursor = generateCursorSvg(size); // 被 !important 覆盖!
}
✅ 正确示例:
// SCSS 中不使用 !important,或完全移除静态规则
.plait-board-container {
// 光标由 JavaScript 动态设置(usePencilCursor hook)
// 不再使用固定大小的 CSS 光标
}
// JavaScript 动态设置光标正常生效
function applyCursorStyle(board: PlaitBoard, size: number) {
const hostSvg = document.querySelector('.board-host-svg');
hostSvg.style.cursor = generateCursorSvg(size); // 正常生效
}
原因: CSS 的 !important 规则优先级高于 JavaScript 设置的内联样式。当需要动态控制样式时(如根据用户设置调整光标大小),必须移除 CSS 中的 !important 规则,否则 JavaScript 的样式设置会被完全覆盖。
检查方法: 如果 JavaScript 设置的样式不生效,在浏览器开发者工具中检查元素样式,查看是否有 !important 规则覆盖。
Freehand 元素属性设置需要自定义 callback
场景: 修改 Freehand(手绘线条)元素的属性(如 strokeStyle、strokeColor)时
❌ 错误示例:
// 错误:直接使用 PropertyTransforms,Freehand 元素可能不被正确处理
const setStrokeStyle = (style: StrokeStyle) => {
PropertyTransforms.setStrokeStyle(board, style, { getMemorizeKey });
};
✅ 正确示例:
// 正确:使用 callback 确保所有选中元素都被处理
export const setStrokeStyle = (board: PlaitBoard, strokeStyle: StrokeStyle) => {
PropertyTransforms.setStrokeStyle(board, strokeStyle, {
getMemorizeKey,
callback: (element: PlaitElement, path: Path) => {
Transforms.setNode(board, { strokeStyle }, path);
},
});
};
原因: PropertyTransforms 的默认行为可能不会处理所有类型的元素(如自定义的 Freehand 元素)。通过提供 callback 函数,可以确保对所有选中的元素执行属性设置操作。颜色设置(setStrokeColor、setFillColor)也使用了相同的模式。
错误 3: 第三方窗口/弹窗组件破坏 React 事件委托
场景: 使用 WinBox.js 或其他直接操作 DOM 的第三方窗口库包装 React 组件时
❌ 错误示例:
// 错误:使用 mount 选项将 React 渲染的 DOM 移动到外部,会破坏 React 的事件冒泡链
new WinBox({
mount: containerRef.current, // 导致 onClick/onDoubleClick 无响应
// ...
});
✅ 正确示例:
// 正确:使用 React.createPortal 将内容渲染到第三方组件提供的 DOM 容器中
const WinBoxWindow = ({ children }) => {
const [contentRef, setContentRef] = useState<HTMLElement | null>(null);
useEffect(() => {
const winbox = new WinBox({
oncreate: () => {
setContentRef(winbox.body); // 获取 WinBox 提供的容器
}
});
}, []);
return contentRef ? createPortal(children, contentRef) : null;
};
原因: React 使用事件委托机制在 root 节点监听事件。如果第三方库通过 appendChild 等原生 API 将 DOM 节点移出 React 的 root 树,事件将无法正常冒泡到 React 的监听器。createPortal 允许在物理上移动 DOM 的同时,在逻辑上保持 React 的组件树和事件流完整。
错误 4: 筛选逻辑中“全部”选项处理不当
场景: 实现带有“全部(ALL)”选项的多重过滤逻辑时
❌ 错误示例:
// 错误:未处理 undefined 情况,导致多条件组合时结果意外为空
const matchesType = filters.activeType === 'ALL' || asset.type === filters.activeType;
// 如果 activeType 是 undefined (初始状态),(undefined === 'ALL') 为 false,逻辑失效
✅ 正确示例:
// 正确:显式处理 undefined 和 'ALL',确保逻辑鲁棒
const matchesType =
!filters.activeType ||
filters.activeType === 'ALL' ||
asset.type === filters.activeType;
原因: 初始状态或重置状态下,筛选变量可能是 undefined 或 null。在进行比较前必须先进行存在性检查,否则会导致筛选结果不符合预期(通常表现为只有单独筛选有效,组合筛选失效)。
错误 5: 动态缩放网格布局出现间隙或重叠
场景: 实现支持用户调整元素显示尺寸(放大/缩小)的网格列表时
❌ 错误示例:
// 错误:使用 Flex 布局配合动态计算的百分比宽度,容易产生像素计算偏差
.grid-row {
display: flex;
.item {
width: 18.523%; // 计算出的宽度,容易在右侧留下缝隙
}
}
✅ 正确示例:
// 正确:使用 CSS Grid 布局配合 1fr,确保完美平铺和对齐
.grid-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); // 或动态设置列数
gap: 16px;
.item {
width: 100%;
height: 100%;
}
}
原因: Flex 布局在处理非整数像素的列宽时,浏览器舍入误差会导致右侧出现白边或元素重叠。CSS Grid 的 1fr 单元由浏览器底层引擎处理自动分配,能确保每一列都精准对齐容器边界,尤其适合需要频繁变动尺寸的素材预览场景。
错误 6: UI 元素高度不统一导致视觉错位
场景: 搜索框、按钮、已选计数条等多个组件并排排列时
❌ 错误示例:
.search-input { height: 36px; }
.action-button { height: 32px; }
// 导致并排排列时基准线不齐,视觉凌乱
✅ 正确示例:
// 正确:统一锁定核心高度(如 32px),并在组件库样式上使用 !important 覆盖
.t-input { height: 32px !important; }
.t-button { height: 32px !important; }
.counter-tag { height: 32px; display: flex; align-items: center; }
原因: “素雅”和“专业”感来自于严格的视觉对齐。在紧凑的工具栏布局中,即便只有 2-4px 的高度差也会被用户感知。应选定一个标准高度并强制执行,消除视觉噪音。
错误 7: 后台清理任务过度记录日志
场景: Service Worker 或后台定时器定期清理过期日志、缓存或任务时
❌ 错误示例:
// 错误:逐条记录清理项,导致控制台瞬间被淹没
expiredLogs.forEach(log => console.log(`Deleted expired log: ${log.id}`));
✅ 正确示例:
// 正确:仅记录清理结果摘要
if (deletedCount > 0) {
// console.log(`Service Worker: 清理了 ${deletedCount} 条过期控制台日志`);
}
原因: 后台任务通常是用户无感知的,过度记录调试信息会干扰正常开发。应汇总结果并优先使用分级日志(推荐注释掉或仅在调试模式显示)。
错误 8: 点击外部关闭下拉菜单使用透明遮罩层
场景: 实现自定义下拉菜单、弹出面板等需要"点击外部关闭"功能时
❌ 错误示例:
// 错误:使用透明遮罩层检测点击,在复杂 z-index 场景下会失效
{isOpen && (
<>
<div
className="dropdown-overlay" // position: fixed; z-index: 999
onClick={() => setIsOpen(false)}
/>
<div className="dropdown-menu" style={{ zIndex: 1000 }}>
{/* 菜单内容 */}
</div>
</>
)}
// 问题:页面上其他高 z-index 元素(工具栏、弹窗等)会遮挡遮罩层,
// 导致点击这些区域无法触发关闭
✅ 正确示例:
// 正确:使用全局 document 事件监听,不受 z-index 影响
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 检查点击是否在下拉组件内部
if (target.closest('.dropdown-menu')) return;
// 点击在外部,关闭下拉
setIsOpen(false);
};
// 使用 mousedown 响应更快
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// 组件只渲染下拉菜单,无需遮罩层
{isOpen && (
<div className="dropdown-menu">
{/* 菜单内容 */}
</div>
)}
原因: 透明遮罩层方案依赖正确的 z-index 层级,在有多个浮层组件的复杂页面中容易失效。全局 document 事件监听在事件捕获阶段工作,不受 DOM 层级和 z-index 影响,是更可靠的方案。同时代码也更简洁,无需维护额外的遮罩层元素和样式。
错误 9: 传递给第三方库的回调无法获取最新 React state
场景: 将 useCallback 创建的回调函数传递给第三方库(如 WinBox 的 addControl、图表库的事件处理器等)时
❌ 错误示例:
// 错误:回调中直接使用 state,第三方库保存的是旧回调引用
const [splitSide, setSplitSide] = useState<'left' | 'right' | null>(null);
const handleSplit = useCallback(() => {
// splitSide 永远是创建回调时的值(通常是初始值 null)
if (splitSide === 'right') {
doSomething(); // 永远不会执行!
}
}, [splitSide]); // 即使加了依赖,第三方库保存的仍是旧回调
useEffect(() => {
winbox.addControl({ click: handleSplit }); // WinBox 保存了这个引用
}, []);
✅ 正确示例:
// 正确:使用 ref 保存状态,回调中读取 ref.current 获取最新值
const [splitSide, _setSplitSide] = useState<'left' | 'right' | null>(null);
const splitSideRef = useRef<'left' | 'right' | null>(null);
// 同步更新 state 和 ref
const setSplitSide = useCallback((side: 'left' | 'right' | null) => {
_setSplitSide(side);
splitSideRef.current = side;
}, []);
const handleSplit = useCallback(() => {
// 使用 ref 获取最新值
const currentSplitSide = splitSideRef.current;
if (currentSplitSide === 'right') {
doSomething(); // 正确执行
}
}, []); // 依赖数组可以为空,因为读取的是 ref
useEffect(() => {
winbox.addControl({ click: handleSplit });
}, []);
原因: 第三方库(如 WinBox、ECharts、D3 等)在初始化时保存回调函数的引用,之后不会自动更新。当 React 重新渲染创建新的 useCallback 实例时,第三方库内部保存的仍然是旧引用。旧回调中的闭包捕获的是创建时的 state 值,导致永远获取不到最新状态。使用 useRef 保存状态可以绕过闭包问题,因为 ref 对象本身不变,只是 .current 属性的值在变化。
错误 10: 独立的 React 树缺少上下文环境
场景: 在使用 createRoot 或 render 手动挂载组件(如画布元素 ToolGenerator、WorkZone 或第三方窗口内部)时
❌ 错误示例:
// 错误:直接渲染组件,导致新 React 树与主应用树脱节,无法访问全局 Context
const root = createRoot(container);
root.render(<MyComponent />);
// 报错:Uncaught Error: useI18n must be used within I18nProvider
✅ 正确示例:
// 正确:使用项目提供的提供者包装器,重新注入必要的上下文
import { ToolProviderWrapper } from '../toolbox-drawer/ToolProviderWrapper';
const root = createRoot(container);
root.render(
<ToolProviderWrapper board={board}>
<MyComponent />
</ToolProviderWrapper>
);
原因: 独立的 React 树不会继承父级树的 Context。在 Opentu 中,画布元素是通过 SVG foreignObject 独立挂载的,必须通过 ToolProviderWrapper 显式重新提供 I18nProvider、AssetProvider、WorkflowProvider 和 DrawnixContext 等核心上下文,才能保证内部组件功能正常。
错误 11: 获取第三方组件位置使用其内部属性而非 DOM API
场景: 需要获取第三方弹窗/组件的屏幕位置进行坐标转换时(如 WinBox、Modal 等)
❌ 错误示例:
// 错误:使用 WinBox 的内部属性,可能与实际视口坐标不一致
const wb = winboxRef.current;
const rect = {
x: wb.x, // 可能是相对于 root 容器的坐标
y: wb.y, // 不一定等于视口坐标
width: wb.width,
height: wb.height,
};
// 与 getBoundingClientRect() 的坐标系不匹配,导致位置计算偏差
✅ 正确示例:
// 正确:使用 DOM 的 getBoundingClientRect() 获取准确的视口坐标
const wbWindow = wb.window as HTMLElement;
const domRect = wbWindow.getBoundingClientRect();
const rect = {
x: domRect.left, // 相对于视口的 X 坐标
y: domRect.top, // 相对于视口的 Y 坐标
width: domRect.width,
height: domRect.height,
};
// 与其他元素的 getBoundingClientRect() 使用相同坐标系,计算准确
原因: 第三方组件库(如 WinBox、Dialog 等)的内部位置属性可能使用不同的坐标系统(相对于 root 容器、相对于父元素等),与浏览器的视口坐标不一致。而 getBoundingClientRect() 始终返回元素相对于视口的准确位置,是进行坐标转换的可靠来源。当需要将一个元素的位置映射到另一个坐标系(如画布坐标)时,应统一使用 getBoundingClientRect() 获取两者的视口坐标,再进行转换。
错误 12: CustomEvent 传递硬编码占位符而非实际值
场景: 使用 CustomEvent 在组件/模块间传递数据时
❌ 错误示例:
// 错误:使用硬编码占位符,UI 会显示 "vnew" 而非实际版本号
window.dispatchEvent(new CustomEvent('sw-update-available', {
detail: { version: 'new' } // ❌ 硬编码的占位符
}));
// 结果:UI 显示 "新版本 vnew 已就绪"
✅ 正确示例:
// 正确:先获取实际值再传递
fetch(`/version.json?t=${Date.now()}`)
.then(res => res.ok ? res.json() : null)
.then(data => {
window.dispatchEvent(new CustomEvent('sw-update-available', {
detail: { version: data?.version || 'unknown' } // ✅ 实际版本号
}));
})
.catch(() => {
window.dispatchEvent(new CustomEvent('sw-update-available', {
detail: { version: 'unknown' } // ✅ 明确的回退值
}));
});
// 结果:UI 显示 "新版本 v0.5.35 已就绪"
原因: CustomEvent 的 detail 数据会直接被消费者使用。如果传递硬编码的占位符(如 'new'、'loading'),接收方无法区分这是占位符还是真实数据,导致 UI 显示错误。应该先获取实际数据再发送事件,或使用明确的回退值(如 'unknown')并在 UI 中特殊处理。
错误 13: 嵌套滚动容器中 scroll 事件绑定错误的元素
场景: 在有多层可滚动容器(如 SideDrawer > VirtualTaskList)的嵌套布局中实现滚动相关功能(如回到顶部按钮)
❌ 错误示例:
// 错误:直接在组件自身的容器上监听 scroll,但实际滚动可能发生在外层容器
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
container?.addEventListener('scroll', handleScroll); // 可能永远不触发!
}, []);
// 问题:
// - 外层容器 (.side-drawer__content) 设置了 overflow-y: auto
// - 内层容器 (.virtual-task-list-scrollarea) 也设置了 overflow: auto
// - 实际滚动可能发生在外层,内层的 scroll 事件永远不触发
✅ 正确示例:
// 正确:向上查找实际的滚动容器
const findScrollContainer = (element: HTMLElement | null): HTMLElement | null => {
let current = element;
while (current) {
const style = getComputedStyle(current);
const overflowY = style.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') &&
current.scrollHeight > current.clientHeight) {
return current;
}
current = current.parentElement;
}
return null;
};
useEffect(() => {
const container = containerRef.current;
const actualScrollContainer = findScrollContainer(container);
actualScrollContainer?.addEventListener('scroll', handleScroll); // ✅ 正确的容器
}, []);
原因: 当存在嵌套的 overflow: auto/scroll 容器时,滚动行为取决于哪个容器的内容先溢出。如果外层容器先溢出,滚动事件会在外层触发,内层容器上的监听器永远不会被调用。必须动态查找实际发生滚动的容器。
错误 14: 在动态高度容器中使用 absolute 定位固定元素
场景: 在可滚动列表底部放置固定按钮(如回到顶部、加载更多)
❌ 错误示例:
// 错误:使用 position: absolute,按钮会被推到内容底部产生空白
<div style={{ height: '100%', position: 'relative' }}>
<div style={{ overflow: 'auto' }}>
{/* 长列表内容 */}
</div>
<Button
style={{ position: 'absolute', bottom: 16 }} // ❌ 会被推到内容底部!
/>
</div>
// 问题:如果父容器的 height: 100% 没有生效(嵌套 flex 布局常见),
// 容器高度会被内容撑开,按钮定位在这个很高的容器底部,产生大量空白
✅ 正确示例:
// 正确:使用 position: fixed + 动态计算位置
const [buttonPosition, setButtonPosition] = useState<{ left: number; bottom: number } | null>(null);
const updateButtonPosition = (scrollContainer: HTMLElement) => {
const rect = scrollContainer.getBoundingClientRect();
setButtonPosition({
left: rect.left + scrollContainer.clientWidth / 2,
bottom: window.innerHeight - rect.bottom + 16,
});
};
<Button
style={{
position: 'fixed',
left: buttonPosition.left,
bottom: buttonPosition.bottom,
transform: 'translateX(-50%)',
}}
/>
原因: position: absolute; bottom: X 是相对于最近的定位父元素的底部。如果该父元素的高度被内容撑开(而非受限于视口),按钮会出现在内容底部而非视口底部。使用 position: fixed 相对于视口定位,配合动态计算可以正确放置按钮。
错误 15: ResizeObserver 监听错误的容器宽度
场景: 在嵌套组件(如弹窗内的任务列表)中使用 ResizeObserver 检测宽度以切换响应式布局
❌ 错误示例:
// 错误:只监听组件自身容器,但实际宽度由外层容器决定
useEffect(() => {
const container = containerRef.current;
const resizeObserver = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
setIsCompact(width < 500); // 可能检测到错误的宽度!
});
resizeObserver.observe(container);
}, []);
// 问题:组件容器可能设置了 flex: 1 或 100%,
// 实际宽度由外层的抽屉/弹窗决定,应该监听外层容器
✅ 正确示例:
// 正确:查找并监听正确的父容器
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// 查找合适的父容器(优先级:抽屉 > 弹窗 > 自身)
const drawerContent = container.closest('.side-drawer__content') as HTMLElement;
const dialogTaskList = container.closest('.dialog-task-list') as HTMLElement;
const dialogBody = container.closest('.t-dialog__body') as HTMLElement;
const targetElement = drawerContent || dialogTaskList || dialogBody || container;
const resizeObserver = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
setIsCompact(width < 500); // ✅ 监听正确的容器
});
resizeObserver.observe(targetElement);
}, []);
原因: 在嵌套布局中,组件自身的容器宽度可能由 CSS(如 flex: 1、width: 100%)动态计算。ResizeObserver 应该监听决定实际可视宽度的外层容器(如抽屉内容区域、弹窗主体),才能正确触发响应式布局切换。
错误 16: 垂直按钮列表中的 Tooltip 遮挡相邻元素
场景: 当多个按钮垂直紧密排列时,默认的上方气泡(Tooltip)会遮挡上方的按钮。
❌ 错误示例:
<div className="actions-list">
<Tooltip content="操作1">
<Button icon={<Icon1 />} />
</Tooltip>
<Tooltip content="操作2">
<Button icon={<Icon2 />} />
</Tooltip>
</div>
✅ 正确示例:
<div className="actions-list">
<Tooltip content="操作1" placement="left" theme="light">
<Button icon={<Icon1 />} />
</Tooltip>
<Tooltip content="操作2" placement="left" theme="light">
<Button icon={<Icon2 />} />
</Tooltip>
</div>
原因: 默认的 top 弹出位置会覆盖紧邻上方的交互元素。改用 left 或 right 弹出可以避开按钮排列轴向,确保所有按钮都可被顺畅点击。
错误 17: 基于过滤结果动态生成筛选按钮
场景: 实现分类筛选功能时,分类列表不应随当前选择而缩小。
❌ 错误示例:
// 错误:分类按钮列表随 filteredTools 变化
const categories = useMemo(() => {
return Array.from(new Set(filteredTools.map(t => t.category)));
}, [filteredTools]);
✅ 正确示例:
// 正确:分类按钮始终包含所有可用选项
const allCategories = useMemo(() => {
return Array.from(new Set(allAvailableTools.map(t => t.category)));
}, [allAvailableTools]);
原因: 如果筛选按钮是根据当前显示的结果动态生成的,一旦用户选定了一个分类,其它分类按钮就会因为结果中不存在而消失,导致用户无法直接切换到其它分类。
错误 18: 抽屉组件 z-index 硬编码过低导致被工具栏遮挡
场景: 侧边抽屉开启后,左边缘被工具栏覆盖,导致内边距看起来不对称或部分内容不可见。
❌ 错误示例:
<BaseDrawer zIndex={12} ... /> // z-index 太低,会被 2000 级的工具栏挡住
✅ 正确示例:
<BaseDrawer ... /> // 使用默认规范定义的 z-index (4030)
原因: 项目中的 z-index 有严格的分层规范(参见 docs/Z_INDEX_GUIDE.md)。工具栏位于 2000 层,而抽屉应该位于 4000 层及以上。硬编码低层级会破坏预留宽度的视觉预期。
错误 19: 初始化时重要元素不可见未自动滚动
场景: 在高度受限的可滚动容器中,重要的操作按钮(如 AI 生成按钮)可能位于可视区域外,用户不知道功能存在。
❌ 错误示例:
// 错误:不检查重要元素是否可见
const ToolbarContainer = () => {
return (
<div className="scrollable-toolbar">
<HandButton />
<SelectButton />
{/* ... 更多按钮 */}
<AIImageButton /> {/* 屏幕小时可能不可见 */}
<AIVideoButton />
</div>
);
};
✅ 正确示例:
const ToolbarContainer = () => {
const scrollableRef = useRef<HTMLDivElement>(null);
const hasScrolledToAI = useRef(false); // 防止重复执行
useEffect(() => {
// 只执行一次,避免死循环
if (hasScrolledToAI.current) return;
const scrollable = scrollableRef.current;
if (!scrollable) return;
const checkAndScroll = () => {
hasScrolledToAI.current = true; // 标记为已执行
// 查找目标按钮
const targetButton = scrollable.querySelector<HTMLElement>('[data-button-id="ai-image"]');
if (!targetButton) return;
// 检测是否可见
const scrollableRect = scrollable.getBoundingClientRect();
const buttonRect = targetButton.getBoundingClientRect();
const isVisible = buttonRect.bottom <= scrollableRect.bottom &&
buttonRect.top >= scrollableRect.top;
// 不可见时滚动(检查高度 > 0 避免极端情况)
if (!isVisible && scrollableRect.height > 0) {
scrollable.scrollTop += buttonRect.top - scrollableRect.top;
}
};
// 延迟执行,确保 DOM 渲染完成
const timeoutId = setTimeout(() => {
requestAnimationFrame(checkAndScroll);
}, 100);
return () => clearTimeout(timeoutId);
}, []);
return (
<div ref={scrollableRef} className="scrollable-toolbar">
{/* 按钮需要添加 data-button-id 属性以便定位 */}
<HandButton />
<SelectButton />
<AIImageButton data-button-id="ai-image" />
<AIVideoButton data-button-id="ai-video" />
</div>
);
};
防止死循环的关键点:
- 使用
ref标志确保只执行一次 - 在执行前立即设置标志,而非执行后
- 检查容器高度 > 0,避免容器未渲染时的极端情况
- 不在滚动失败时重试
原因: 屏幕尺寸多样化,重要功能按钮可能因容器高度不足而不可见。初始化时自动滚动到这些元素可以提升功能发现率,但必须确保只执行一次以避免死循环。
交互规范: 三段式循环排序模式
场景: 实现包含正序、逆序且需要支持恢复默认状态的排序按钮时。
❌ 错误示例: 使用多个独立按钮分别代表正序和逆序,或者简单的二段式切换(无法方便地回到默认排序)。
✅ 正确示例:
使用单个按钮循环切换:正序 -> 逆序 -> 恢复默认排序(如日期降序)。
const handleSortClick = () => {
if (currentSort === group.options.asc) {
setFilters({ sortBy: group.options.desc }); // 切换到逆序
} else if (currentSort === group.options.desc) {
setFilters({ sortBy: 'DATE_DESC' }); // 恢复默认
} else {
setFilters({ sortBy: group.options.asc }); // 切换到正序
}
};
原因: 这种模式在节省 UI 空间的同时,能让用户在有限的点击次数内触达所有排序状态,且逻辑闭环。
样式规范: 筛选岛(Island)组件的间距与对齐
场景: 在紧凑的水平排列筛选组中显示图标和计数标签时。
❌ 错误示例:
.filter-option {
width: 32px; // 固定宽度在数字变长时会溢出
.count {
position: absolute; // 绝对定位容易导致与图标重叠
right: 2px;
}
}
✅ 正确示例:
.filter-option {
padding: 0 8px; // 使用 padding 适应不同宽度的数字
display: flex;
align-items: center;
gap: 4px; // 为图标和计数预留固定间距
.count {
font-size: 11px; // 保持精致感
}
}
原因: 筛选组通常包含数量反馈,个位数和多位数占用的空间不同。使用弹性布局(Flex + gap)能确保在任何数据状态下 UI 都是对齐且易读的。
UI 设计原则: 以影代框(Shadows Over Borders)
场景: 为面板、卡片或容器定义边界时。
❌ 错误示例:
.container {
border: 1px solid var(--td-component-border);
}
✅ 正确示例:
.container {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
原因: 过多的物理线条(Border)会增加页面的视觉复杂度和“噪音”,产生生硬的切割感。改用弥散的弱阴影(Shadow)可以自然地体现层级关系,使界面显得更加通透、轻量且具有现代感。
样式规范: 消除容器与首个子元素边距叠加
场景: 容器有内边距(Padding)且首个子元素有上外边距(Margin-top)时,会导致顶部间距过大。
❌ 错误示例:
.container { padding: 16px; }
.title { margin-top: 16px; } // 结果顶部间距变成了 32px
✅ 正确示例:
.container { padding: 16px; }
.title { margin-top: 16px; }
.title:first-child { margin-top: 0; } // 或在容器内指定首个标题 margin-top: 4px
原因: 避免 Padding 与 Margin 的视觉累加,确保界面排版符合设计的网格预期。
UI 设计原则: 批量操作按钮视觉层级
场景: 设计包含多个操作按钮的批量操作栏(如任务队列、文件管理器)时。
❌ 错误示例:
// 所有按钮都使用主题色,视觉噪音过大
<div className="batch-actions">
<Button theme="primary">重试 (3)</Button>
<Button theme="primary">删除 (6)</Button>
<Button theme="primary">导出 (6)</Button>
</div>
✅ 正确示例:
// 主要正向操作使用主题色,危险/次要操作弱化
<div className="batch-actions">
<Button theme="primary">重试 (3)</Button> {/* 主要正向操作 - 主题色实心 */}
<Button variant="text" theme="default">删除 (6)</Button> {/* 危险操作 - 弱化为文字按钮 */}
</div>
原因:
- 过多的主题色按钮会造成视觉疲劳,用户难以快速识别主要操作
- 危险操作(如删除)应该弱化,避免用户误操作
- 按钮层级建议:主要正向操作 > 次要操作 > 危险操作
- 视觉层级:
theme="primary"实心 >variant="outline"描边 >variant="text"文字
性能指南
- 使用
React.lazy对大型组件进行代码分割 - 对图片实现懒加载和预加载
- 避免在 render 中创建新对象/函数
- 对长列表考虑使用虚拟化
CPU 密集型循环需要 yield 让出主线程
场景: 在异步函数中执行大量同步操作(JSON.stringify、加密、checksum 计算等)时,即使函数是 async 的,循环内部如果没有 await 点,仍然会连续占用主线程,导致页面卡顿。
❌ 错误示例:
// 错误:虽然是 async 函数,但循环内部没有 yield 点
async function pushToRemote() {
// 收集数据
const localData = await collectSyncData();
// 这个循环会连续执行,阻塞主线程
for (const [boardId, board] of localData.boards) {
const boardJson = JSON.stringify(board); // 同步操作
const encrypted = await cryptoService.encrypt(boardJson); // 虽然有 await,但每次 await 之间的 JSON.stringify 会累积
filesToUpdate[boardId] = encrypted;
}
// 用户可能看到页面卡顿、事件处理器超时警告
}
✅ 正确示例:
import { yieldToMain } from '@aitu/utils';
async function pushToRemote() {
const localData = await collectSyncData();
let processedCount = 0;
for (const [boardId, board] of localData.boards) {
const boardJson = JSON.stringify(board);
const encrypted = await cryptoService.encrypt(boardJson);
filesToUpdate[boardId] = encrypted;
// 每处理 3 个画板让出主线程,避免 UI 阻塞
processedCount++;
if (processedCount % 3 === 0) {
await yieldToMain();
}
}
}
// yieldToMain 的实现:
export const yieldToMain = (): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, 0));
};
原因:
- JavaScript 是单线程的,async/await 只是让代码看起来同步,但仍然在主线程执行
- 大量
JSON.stringify()、加密计算等同步操作会连续占用主线程 - 浏览器无法处理用户输入、渲染更新,导致
[Violation] 'mouseup' handler took 606ms等警告 yieldToMain()通过setTimeout(0)将后续执行推迟到下一个事件循环,让浏览器有机会处理待处理事件
最佳实践:
- 每 3-5 个耗时操作后调用一次
await yieldToMain() - 可以使用
@aitu/utils中的processBatched()函数自动处理分批 - 适用场景:同步、备份、批量导入导出、大量数据处理
参考文件:
packages/utils/src/async/index.ts-yieldToMain和processBatched工具函数packages/drawnix/src/services/github-sync/sync-engine.ts- 同步引擎中的使用示例
预缓存配置规范
场景: 使用 Service Worker 预缓存静态资源时,需要合理配置排除列表
❌ 错误示例:
// vite.config.ts - 错误:没有排除调试工具和大型资源
function precacheManifestPlugin(): Plugin {
const EXCLUDE_PATTERNS = [
/stats\.html$/,
/\.map$/,
];
// 扫描所有目录,包括 sw-debug/、product_showcase/ 等
scanDir(outDir); // ❌ 会将所有文件加入预缓存
}
✅ 正确示例:
// vite.config.ts - 正确:明确排除非核心资源
function precacheManifestPlugin(): Plugin {
const EXCLUDE_PATTERNS = [
/stats\.html$/,
/\.map$/,
/sw-debug\.html$/, // ✅ 排除调试面板入口
];
// 跳过不需要预缓存的目录
const SKIP_DIRECTORIES = [
'product_showcase', // 大型展示资源
'help_tooltips', // 帮助提示图片
'sw-debug', // 调试面板(仅在访问时加载)
];
if (entry.isDirectory()) {
if (!SKIP_DIRECTORIES.includes(entry.name)) {
scanDir(fullPath, relativePath);
}
}
}
应该排除的资源类型:
- 调试工具 - 如
sw-debug/,仅开发/排查时访问 - 大型展示资源 - 如
product_showcase/,非核心功能 - 帮助/文档资源 - 如
help_tooltips/,按需加载即可 - Source Maps - 生产环境不需要预缓存
原因: 预缓存会在主应用启动时由 Service Worker 下载所有列表中的文件。如果包含非核心资源,会:
- 增加首次加载时间和带宽消耗
- 占用用户设备存储空间
- 影响主应用的启动性能和用户体验
统计与监控上报旁路化
场景: 使用 PostHog、Sentry、Web Vitals、Page Report 等统计/监控上报时,若在首屏或用户操作路径上同步执行初始化、脱敏、capture,会抢主线程与网络,导致 LCP/INP 变差,甚至触发 413 等错误。
❌ 错误示例:
// 错误:首屏 1s 后同步初始化监控,与主流程抢 CPU
setTimeout(() => {
initWebVitals();
initPageReport();
}, 1000);
// 错误:在 track 内同步脱敏 + capture,阻塞调用方
track(eventName: string, eventData?: Record<string, any>) {
const sanitized = sanitizeObject(eventData);
window.posthog.capture(eventName, sanitized);
}
// 错误:在 Web Vitals 回调栈里直接上报,占用性能回调时间
function reportWebVitals(metric: Metric) {
analytics.track('$web_vitals', eventProperties); // 同步
}
✅ 正确示例:
// 正确:用 requestIdleCallback 在空闲时初始化(无则 setTimeout 兜底)
const scheduleMonitoring = () => {
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(initMonitoring, { timeout: 3000 });
} else {
setTimeout(initMonitoring, 3000);
}
};
scheduleMonitoring();
// 正确:脱敏与 capture 在 requestIdleCallback/setTimeout 中执行,调用方同步返回
track(eventName: string, eventData?: Record<string, any>) {
const doTrack = () => {
try {
const sanitized = eventData ? sanitizeObject(eventData) : undefined;
window.posthog!.capture(eventName, sanitized);
} catch (e) {
console.debug('[Analytics] track failed', e);
}
};
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(doTrack, { timeout: 2000 });
} else {
setTimeout(doTrack, 0);
}
}
// 正确:先延后再上报,不阻塞 CWV 回调栈
function reportWebVitals(metric: Metric) {
setTimeout(() => analytics.track('$web_vitals', eventProperties), 0);
}
原因:
- 统计上报是旁路逻辑,不得影响首屏与交互性能
- 初始化延后到空闲可避免与主流程争抢;track 内延后执行脱敏与网络请求,调用方不阻塞
- 失败只 log(如 console.debug),不向上 throw,避免影响主流程
参考: apps/web/src/main.tsx(统计 init)、packages/drawnix/src/utils/posthog-analytics.ts(track 旁路)、packages/drawnix/src/services/web-vitals-service.ts(CWV 延后上报)
安全指南
- 验证和清理所有用户输入
- 永远不要硬编码敏感信息(API keys 等)
- 对 API 调用使用适当的错误处理
- 在日志中过滤敏感信息
上报工具敏感信息过滤
场景: 使用 PostHog、Sentry 等上报工具时,需要确保不泄露 API Key 等敏感信息
❌ 错误示例:
// Sentry: 启用自动 PII 收集
Sentry.init({
sendDefaultPii: true, // 会收集 IP 地址等
// 没有 beforeSend 过滤
});
// PostHog: 直接传递未过滤的数据
window.posthog.capture(eventName, eventData); // eventData 可能包含敏感信息
✅ 正确示例:
// Sentry: 禁用 PII,添加 beforeSend 过滤
import { sanitizeObject, sanitizeUrl } from '@drawnix/drawnix';
Sentry.init({
sendDefaultPii: false,
beforeSend(event) {
if (event.extra) event.extra = sanitizeObject(event.extra);
if (event.request?.url) event.request.url = sanitizeUrl(event.request.url);
return event;
},
});
// PostHog: 使用 sanitizeObject 过滤敏感字段
const sanitizedData = sanitizeObject(eventData);
window.posthog.capture(eventName, sanitizedData);
敏感字段列表: apikey, api_key, password, token, secret, authorization, bearer, credential
相关工具模块:
- 主线程:
packages/drawnix/src/utils/sanitize-utils.ts - Service Worker:
apps/web/src/sw/task-queue/utils/sanitize-utils.ts
Console 日志安全打印
场景: 使用 console.error/warn 记录错误时
❌ 错误示例:
// 错误:直接打印完整 error 对象,可能包含敏感信息
try {
await loadConfig();
} catch (error) {
console.error('Failed to load config:', error); // error 可能包含 API Key
}
✅ 正确示例:
import { getSafeErrorMessage } from '@drawnix/drawnix';
try {
await loadConfig();
} catch (error) {
// 只记录错误类型,不记录详细信息
console.error('Failed to load config:', getSafeErrorMessage(error));
}
// getSafeErrorMessage 实现:
function getSafeErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.name || 'Error';
}
return 'Unknown error';
}
原因:
- 错误对象可能包含敏感的请求/响应数据
- API 错误可能在 message 中包含 API Key 或其他敏感信息
- 生产环境的 console 日志可能被监控工具收集
敏感信息模板变量安全处理
场景: 工具 URL 或配置中包含敏感信息(如 apiKey)时
❌ 错误示例:
// 错误:在插入画布时就替换模板变量,导致实际 apiKey 被存储
const executeToolInsert = (tool: ToolDefinition) => {
const { url } = processToolUrl(tool.url); // 替换 ${apiKey} 为实际值
ToolTransforms.insertTool(board, tool.id, url, ...); // 存储了实际 apiKey!
};
// 问题:导出/备份时会泄露敏感信息
✅ 正确示例:
// 正确:存储原始模板 URL,渲染时才替换
const executeToolInsert = (tool: ToolDefinition) => {
// 存储原始模板 URL(如 https://api.com?key=${apiKey})
ToolTransforms.insertTool(board, tool.id, tool.url, ...);
};
// 在渲染 iframe 时动态替换
private createIframe(element: PlaitTool): HTMLIFrameElement {
const { url: processedUrl } = processToolUrl(element.url);
iframe.src = processedUrl;
// 保存原始模板 URL,用于设置变化时重新替换
(iframe as any).__templateUrl = element.url;
}
// 监听设置变化,动态刷新 iframe
window.addEventListener('gemini-settings-changed', () => {
this.refreshTemplateIframes();
});
原因:
- 敏感信息(如 apiKey)应该使用模板变量形式(如
${apiKey})存储在数据中 - 只在渲染时动态替换为实际值
- 这样可以确保导出/备份时不会泄露敏感信息
- 用户更新设置后,已打开的工具可以自动刷新使用新的配置
配置对象返回深拷贝防止意外修改
场景: 设置管理器返回配置对象后,外部代码(如脱敏函数)修改返回值
❌ 错误示例:
// 错误:getSetting 返回直接引用
class SettingsManager {
private settings: Record<string, any> = {};
public getSetting<T>(path: string): T {
const keys = path.split('.');
let value = this.settings;
for (const key of keys) {
value = value?.[key];
}
return value as T; // 返回直接引用!
}
}
// 使用时:外部代码修改了返回值
const config = settingsManager.getSetting('gemini');
sanitizeObject(config); // sanitizeObject 修改了 config.apiKey 为 '[REDACTED]'
// 现在全局设置中的 apiKey 也变成了 '[REDACTED]'!
✅ 正确示例:
// 正确:返回深拷贝
class SettingsManager {
private settings: Record<string, any> = {};
public getSetting<T>(path: string): T {
const keys = path.split('.');
let value = this.settings;
for (const key of keys) {
value = value?.[key];
}
// 返回深拷贝,防止外部代码修改返回值影响原始设置
if (value && typeof value === 'object') {
return JSON.parse(JSON.stringify(value)) as T;
}
return value as T;
}
}
// 使用时:外部代码修改的是副本,不影响原始设置
const config = settingsManager.getSetting('gemini');
sanitizeObject(config); // 修改的是副本
// 全局设置中的 apiKey 保持不变
原因:
sanitizeObject等脱敏函数会修改传入的对象(如将apiKey替换为[REDACTED])- 如果返回直接引用,脱敏函数会意外修改全局配置
- 这会导致后续 API 调用使用被脱敏的值(如
"[REDACTED]"或"{key}")而失败 - 返回深拷贝确保全局配置不会被外部代码意外修改
部署脚本安全实践
场景: 创建部署脚本(上传文件、执行远程命令等)时
❌ 错误示例:
// 错误:在代码中硬编码密码
const password = 'my-secret-password';
const sshCommand = `sshpass -p "${password}" ssh user@host`;
// 错误:.env 文件未在 .gitignore 中,可能被提交到 Git
// .env 文件包含敏感信息但被提交了
// 错误:使用密码认证,密码会出现在进程列表中
const scpCommand = `sshpass -p "${config.DEPLOY_SSH_PASSWORD}" scp ...`;
✅ 正确示例:
// 正确:从 .env 文件读取配置(确保 .env 在 .gitignore 中)
const config = loadEnvConfig(); // 从 .env 读取
// 正确:优先使用 SSH 密钥认证
if (config.DEPLOY_SSH_KEY) {
sshCommand = `ssh -i "${sshKeyPath}" ...`;
} else if (config.DEPLOY_SSH_PASSWORD) {
// 如果必须使用密码,使用环境变量而不是命令行参数
process.env.SSHPASS = config.DEPLOY_SSH_PASSWORD;
sshCommand = 'sshpass -e ssh ...'; // -e 从环境变量读取
}
// 正确:配置免密 sudo,而不是在脚本中传递 sudo 密码
// 在服务器上:sudo visudo
// 添加:username ALL=(ALL) NOPASSWD: /bin/cp, /usr/sbin/nginx
安全最佳实践:
-
SSH 密钥认证(强烈推荐):
- 生成密钥对:
ssh-keygen -t ed25519 - 将公钥添加到服务器:
ssh-copy-id user@host - 在
.env中配置:DEPLOY_SSH_KEY=~/.ssh/id_ed25519
- 生成密钥对:
-
.env 文件管理:
- ✅ 确保
.env在.gitignore中 - ✅ 创建
.env.example作为模板(不包含真实密码) - ❌ 永远不要将
.env提交到版本控制
- ✅ 确保
-
Sudo 权限:
- ✅ 配置免密 sudo(更安全):
sudo visudo添加NOPASSWD规则 - ⚠️ 如果必须使用密码,使用
sudo -S从标准输入读取(但仍不安全)
- ✅ 配置免密 sudo(更安全):
-
密码传递:
- ❌ 避免在命令行中传递密码(
sshpass -p "password") - ✅ 使用环境变量:
sshpass -e从SSHPASS环境变量读取 - ✅ 优先使用 SSH 密钥,完全避免密码
- ❌ 避免在命令行中传递密码(
原因:
- 密码在命令行参数中会出现在进程列表中(
ps aux),容易被其他用户看到 .env文件如果被提交到 Git,所有敏感信息都会泄露- 使用 SSH 密钥认证更安全,且不需要每次输入密码
- 免密 sudo 避免了在脚本中存储 sudo 密码的风险
检查清单:
.env文件在.gitignore中- 创建了
.env.example模板文件 - 脚本中没有硬编码的密码或服务器地址
- 优先使用 SSH 密钥认证
- 配置了免密 sudo(如果需要)
API 轮询与任务恢复规则
场景: 视频生成等需要轮询的 API 调用,以及页面刷新后的任务恢复
错误 1: 轮询时不区分业务失败和网络错误
❌ 错误示例:
// 所有错误都重试 - 错误!业务失败不应重试
while (attempts < maxAttempts) {
try {
const response = await fetch(`${baseUrl}/videos/${videoId}`);
const data = await response.json();
if (data.status === 'failed') {
throw new Error(data.error.message); // 这个错误会被 catch 重试
}
} catch (err) {
// 所有错误都重试 - 业务失败也会重试!
consecutiveErrors++;
await sleep(backoffInterval);
}
}
✅ 正确示例:
// 区分业务失败和网络错误
class VideoGenerationFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'VideoGenerationFailedError';
}
}
while (attempts < maxAttempts) {
try {
const response = await fetch(`${baseUrl}/videos/${videoId}`);
const data = await response.json();
if (data.status === 'failed') {
// 使用特殊错误类型,不应重试
throw new VideoGenerationFailedError(data.error.message);
}
} catch (err) {
// 业务失败直接抛出,不重试
if (err instanceof VideoGenerationFailedError) {
throw err;
}
// 只有网络错误才重试
consecutiveErrors++;
await sleep(backoffInterval);
}
}
原因: 业务失败(如 generation_failed、INVALID_ARGUMENT)是 API 明确返回的错误,重试也不会成功,只会浪费时间。网络错误是临时的,重试可能成功。
错误 2: 页面刷新后自动恢复所有失败任务
❌ 错误示例(黑名单 - 太宽松):
// 排除少数已知失败,其余全部恢复 - 错误!
const isDefinitiveFailure = (task: Task): boolean => {
const msg = task.error?.message?.toLowerCase() || '';
return msg.includes('prohibited') || msg.includes('content policy');
};
failedTasks.forEach(task => {
if (!isDefinitiveFailure(task)) {
taskService.updateStatus(task.id, 'processing'); // 服务端真正失败的也被恢复了!
}
});
✅ 正确示例(白名单 - 只恢复客户端中断):
// 白名单:只恢复因页面刷新/客户端中断导致的失败
const RECOVERABLE_ERROR_CODES = new Set([
'INTERRUPTED', // 页面刷新中断
'INTERRUPTED_DURING_SUBMISSION', // 提交过程中中断
'RESUME_FAILED', // 上次恢复尝试失败
]);
failedTasks.forEach(task => {
const errorCode = task.error?.code || '';
if (!RECOVERABLE_ERROR_CODES.has(errorCode)) return; // 服务端失败不恢复
taskService.updateStatus(task.id, 'processing');
});
原因:
- 黑名单遗漏:无法穷举所有服务端错误类型,新增的错误类型会被误恢复
- 白名单安全:只有我们自己设置的中断错误码才恢复,服务端返回的任何失败都保持终态
- failed 是终态:用户看到失败后刷新页面,不应该看到任务又变成进行中
错误 3: 计费任务重试时重复调用生成接口
场景: 视频生成、角色提取等长耗时且按次计费的异步任务。
❌ 错误示例:
// 错误:无论是否已有 remoteId,重试都重新提交生成请求
async retryTask(task) {
task.status = 'pending';
// 重新进入流程,导致重新调用 POST /videos
this.processQueue();
}
✅ 正确示例:
// 正确:如果已有 remoteId,直接进入轮询阶段,跳过提交
async executeTask(task) {
if (task.remoteId && (task.type === 'video' || task.type === 'character')) {
task.executionPhase = 'polling';
return this.executeResume(task, task.remoteId);
}
// 正常提交逻辑...
}
原因: AI 厂商的生成接口通常较贵。一旦任务 ID (remoteId) 已成功返回,该任务就在云端排队生成。此时任何重试或恢复操作都应仅限于查询进度,严禁再次点击生成接口导致重复扣费和资源浪费。
错误 4: 异步任务 ID 找回逻辑不完整
场景: 任务提交成功但在 remoteId 保存到数据库前发生页面刷新或 Service Worker 重启。
❌ 错误示例:
// 错误:仅检查已完成的任务结果
async resumeTask(task) {
if (!task.remoteId) {
// 如果还没完成,就直接报错提示无法恢复
const successLog = await findSuccessLog(task.id);
if (!successLog) throw new Error('无法恢复');
}
}
✅ 正确示例:
// 正确:通过 API 日志系统尝试找回丢失的任务 ID(哪怕任务还没完成)
async resumeTask(task) {
if (!task.remoteId) {
// 从日志中找回 remoteId 或解析响应体
const latestLog = await findLatestLogByTaskId(task.id);
const recoveredId = latestLog?.remoteId || parseIdFromBody(latestLog?.responseBody);
if (recoveredId) {
task.remoteId = recoveredId;
return this.resumePolling(task); // 继续轮询进度
}
}
}
原因: 状态更新的持久化可能因崩溃而丢失。利用独立的日志系统记录每一次 API 响应,可以在主状态丢失时找回关键的任务 ID,实现任务进度的无缝衔接。
任务恢复决策表
| 任务类型 | 错误码 | 是否自动恢复 | 原因 |
|---|---|---|---|
| 视频/异步图片 | INTERRUPTED |
✅ 是 | 页面刷新中断,查询状态不扣费 |
| 视频/异步图片 | INTERRUPTED_DURING_SUBMISSION |
✅ 是 | 提交中断,可能已在后台执行 |
| 视频/异步图片 | RESUME_FAILED |
✅ 是 | 上次恢复失败,值得重试 |
| 任何类型 | 服务端返回的错误 | ❌ 否 | 业务失败重试也不会成功 |
| 同步图片 | 任何错误 | ❌ 否 | 每次调用都扣费 |
错误 5: fire-and-forget 写入后立即异步读取导致竞态
场景: 内存状态更新后通过 fire-and-forget 持久化到 IndexedDB,另一个模块随后从 IndexedDB 读取
❌ 错误示例:
// useTaskStorage: 恢复任务状态(内存同步更新 + IndexedDB 异步写入)
taskQueueService.updateTaskStatus(task.id, 'processing'); // 内存立即更新,persistTask() fire-and-forget
// fallback-executor: 从 IndexedDB 读取(可能读到旧状态!)
const tasks = await taskStorageReader.getAllTasks({ status: 'processing' });
// tasks 可能为空 — IndexedDB 写入尚未完成
✅ 正确示例:
// 从内存读取,始终是最新状态
const allTasks = taskQueueService.getAllTasks();
fallbackExecutor.resumePendingTasks(onUpdate, allTasks);
// resumePendingTasks 优先使用传入的内存数据
async resumePendingTasks(onUpdate, tasksFromMemory?: Task[]) {
const tasks = tasksFromMemory
? tasksFromMemory.filter(t => t.status === 'processing')
: await taskStorageReader.getAllTasks({ status: 'processing' }); // fallback
}
原因: persistTask() 是 fire-and-forget(不 await),IndexedDB 写入有延迟。如果另一个模块紧接着从 IndexedDB 读取,可能读到写入前的旧数据。内存中的状态是同步更新的,应优先使用。
错误 6: useEffect 中初始对账遍历所有任务导致死循环
场景: useEffect 内遍历任务列表执行对账,对账触发状态更新,依赖项变化导致 useEffect 重跑
❌ 错误示例:
useEffect(() => {
// 初始对账:处理所有终态任务
const allTasks = taskQueueService.getAllTasks();
for (const task of allTasks) {
processTaskEvent(task); // 可能触发 workflowControl.updateStep → 状态更新 → 重渲染
}
const sub = taskQueueService.observe().subscribe(handleEvent);
return () => sub.unsubscribe();
}, [workflowControl]); // workflowControl 每次渲染都是新引用 → 死循环!
✅ 正确示例:
const reconciliationDoneRef = useRef(false);
useEffect(() => {
// 用 ref 确保初始对账只执行一次
if (!reconciliationDoneRef.current) {
reconciliationDoneRef.current = true;
const allTasks = taskQueueService.getAllTasks();
for (const task of allTasks) {
processTaskEvent(task);
}
}
const sub = taskQueueService.observe().subscribe(handleEvent);
return () => sub.unsubscribe();
}, [workflowControl]);
原因: 初始对账中的 processTaskEvent 可能触发状态更新(如 workflowControl.updateStep),导致组件重渲染。如果 workflowControl 未被 useMemo 包裹,每次渲染都是新引用,useEffect 会重新执行,形成死循环。用 ref 守卫确保对账逻辑只跑一次。
生产代码禁止保留调试日志
场景: 开发调试时添加的 console.log 语句未在提交前清理
❌ 错误示例:
// 调试日志遗留在生产代码中
const handleZoomPercentClick = useCallback(() => {
console.log('[ViewNavigation] Zoom percent clicked, current state:', zoomMenuOpen);
setZoomMenuOpen((prev) => !prev);
}, [zoomMenuOpen]);
// Popover 中的调试日志
<Popover
onOpenChange={(open) => {
console.log('[Popover] onOpenChange:', open);
setZoomMenuOpen(open);
}}
>
✅ 正确示例:
// 清理调试日志,保持代码简洁
const handleZoomPercentClick = useCallback(() => {
setZoomMenuOpen((prev) => !prev);
}, []);
// 直接传递 setter 函数
<Popover onOpenChange={setZoomMenuOpen}>
原因:
- 调试日志会污染用户控制台,影响体验
- 暴露内部实现细节,存在安全隐患
- 增加打包体积和运行时开销
- 代码 Review 时容易被忽略,形成技术债
例外情况:
console.error/console.warn用于记录真正的错误/警告是允许的- 带有
[DEBUG]前缀且通过环境变量控制的日志可以保留
组件空状态不应简单返回 null
场景: 组件在没有数据时需要决定是否渲染
❌ 错误示例:
// 错误:没有历史记录时直接隐藏整个组件,用户看不到预设提示词
const PromptHistoryPopover = () => {
const { history } = usePromptHistory();
// 没有历史记录就不显示按钮
if (history.length === 0) {
return null;
}
return (
<button>提示词</button>
// ...
);
};
✅ 正确示例:
// 正确:即使没有历史记录也显示按钮,展示预设提示词
const PromptHistoryPopover = () => {
const { history } = usePromptHistory();
const presetPrompts = getPresetPrompts();
// 合并历史记录和预设提示词
const allPrompts = [...history, ...presetPrompts];
// 按钮始终显示
return (
<button>提示词</button>
// 面板中显示历史 + 预设
);
};
原因:
- 组件的核心功能(如提示词选择)不应该依赖于是否有历史数据
- 预设内容为新用户提供了引导,提升首次使用体验
- 隐藏入口会让用户不知道功能存在
文案应考虑所有使用场景
场景: 为组件、按钮、标题等编写文案时
❌ 错误示例:
// 错误:标题"历史提示词"在没有历史记录时不贴切
<PromptListPanel
title={language === 'zh' ? '历史提示词' : 'Prompt History'}
items={promptItems} // 可能包含历史记录 + 预设提示词
/>
✅ 正确示例:
// 正确:使用更通用的标题"提示词"
<PromptListPanel
title={language === 'zh' ? '提示词' : 'Prompts'}
items={promptItems}
/>
原因:
- 文案过于具体会在某些场景下显得不准确
- 通用的文案能适应更多使用场景(有/无历史记录)
- 避免后续因场景变化而频繁修改文案
UI 重构时必须保持信息完整性
场景: 重构 UI 样式(如简化布局、统一风格)时
❌ 错误示例:
// 重构前:显示完整的性能信息
entry.innerHTML = `
<span class="log-perf">⚡ 任务时长: ${duration}ms | FPS: ${fps}</span>
<span class="log-memory">📊 ${usedMB} MB / ${limitMB} MB (${percent}%)</span>
`;
// 重构后:为了"简化"只显示时长徽章,丢失了 FPS 和内存信息
let perfBadge = '';
if (log.performance?.longTaskDuration) {
perfBadge = `<span class="log-duration">${duration}ms</span>`;
}
// ❌ FPS、内存信息没有了!
✅ 正确示例:
// 重构后:样式简化但信息完整
let perfText = '';
if (log.performance) {
const parts = [];
if (log.performance.longTaskDuration) {
parts.push(`任务时长: ${log.performance.longTaskDuration.toFixed(0)}ms`);
}
if (log.performance.fps !== undefined) {
parts.push(`FPS: ${log.performance.fps}`);
}
perfText = parts.join(' | ');
}
// ✅ 所有原有信息都保留
检查清单:
- 重构前列出所有显示的信息项
- 重构后逐一核对是否都有展示
- 用真实数据测试,确认信息完整
原因: 样式重构的目的是优化视觉呈现,而不是删减功能。用户依赖这些信息进行问题诊断,丢失信息会影响使用体验。
日志/数据保留应优先保留问题记录
场景: 实现日志、任务历史等有容量上限的列表时
❌ 错误示例:
// 简单 FIFO,新日志进来就删除最旧的
state.logs.unshift(newLog);
if (state.logs.length > MAX_LOGS) {
state.logs.pop(); // ❌ 可能删掉重要的错误日志
}
✅ 正确示例:
// 优先保留问题记录
function isProblemLog(log) {
if (log.status >= 400 || log.error) return true; // 错误请求
if (log.duration >= 1000) return true; // 慢请求
return false;
}
function trimLogsWithPriority(maxLogs) {
// 分类
const bookmarked = logs.filter(l => isBookmarked(l.id));
const problems = logs.filter(l => !isBookmarked(l.id) && isProblemLog(l));
const normal = logs.filter(l => !isBookmarked(l.id) && !isProblemLog(l));
// 优先保留:收藏 > 问题 > 正常
const mustKeep = bookmarked.length + problems.length;
if (mustKeep >= maxLogs) {
state.logs = [...bookmarked, ...problems.slice(0, maxLogs - bookmarked.length)];
} else {
state.logs = [...bookmarked, ...problems, ...normal.slice(0, maxLogs - mustKeep)];
}
}
保留优先级:
- 用户收藏/标记的记录
- 错误记录(状态码 >= 400、有 error 字段)
- 慢请求(耗时 >= 1s)
- 正常记录
原因: 正常请求通常不需要回溯,而问题请求是排查问题的关键依据。如果问题请求被正常请求挤掉,会大大增加问题定位难度。
批量加载与单个加载方法必须保持逻辑一致
场景: 存在 loadAll*() 和 load*() 两种加载方法时
❌ 错误示例:
// loadBoard 有迁移逻辑
async loadBoard(id: string): Promise<Board | null> {
const board = await this.getBoardsStore().getItem(id);
if (board?.elements) {
await migrateElementsBase64Urls(board.elements); // ✅ 有迁移
}
return board;
}
// loadAllBoards 缺少迁移逻辑
async loadAllBoards(): Promise<Board[]> {
const boards: Board[] = [];
await this.getBoardsStore().iterate((value) => {
boards.push(value); // ❌ 没有迁移!
});
return boards;
}
// 问题:应用初始化用 loadAllBoards(),迁移逻辑永远不会执行
✅ 正确示例:
async loadAllBoards(): Promise<Board[]> {
const boards: Board[] = [];
await this.getBoardsStore().iterate((value) => {
if (value.elements) {
value.elements = migrateElementsFillData(value.elements);
}
boards.push(value);
});
// 迁移 Base64 图片 URL(与 loadBoard 保持一致)
for (const board of boards) {
if (board.elements) {
const migrated = await migrateElementsBase64Urls(board.elements);
if (migrated) await this.saveBoard(board);
}
}
return boards;
}
原因: 应用初始化通常使用批量加载方法(loadAll*),而开发时可能只在单个加载方法中添加新逻辑。这会导致新逻辑在实际运行时永远不会执行。
IndexedDB 元数据必须验证 Cache Storage 实际数据
场景: IndexedDB 存储元数据,Cache Storage 存储实际 Blob 数据
❌ 错误示例:
// 只从 IndexedDB 读取元数据,不验证 Cache Storage
async getAllAssets(): Promise<Asset[]> {
const keys = await this.store.keys();
return Promise.all(keys.map(async key => {
const stored = await this.store.getItem(key);
return storedAssetToAsset(stored); // ❌ 不验证实际数据是否存在
}));
}
// 问题:IndexedDB 有记录但 Cache Storage 数据被清理,导致 404
✅ 正确示例:
async getAllAssets(): Promise<Asset[]> {
// 先获取 Cache Storage 中的有效 URL
const cache = await caches.open('drawnix-images');
const validUrls = new Set(
(await cache.keys()).map(req => new URL(req.url).pathname)
);
const keys = await this.store.keys();
return Promise.all(keys.map(async key => {
const stored = await this.store.getItem(key);
// 验证 Cache Storage 中有实际数据
if (stored.url.startsWith('/asset-library/')) {
if (!validUrls.has(stored.url)) {
console.warn('Asset not in Cache Storage, skipping:', stored.url);
return null; // ✅ 跳过无效资源
}
}
return storedAssetToAsset(stored);
}));
}
原因:
- IndexedDB 和 Cache Storage 是独立的存储机制
- Cache Storage 可能被浏览器清理(存储压力时)
- 如果不验证,会显示实际无法加载的资源,导致 404 错误
本地缓存图片只存 Cache Storage,不存 IndexedDB
场景: 缓存本地生成的图片(如分割图片、Base64 迁移、合并图片)
❌ 错误示例:
// 本地图片也存入 IndexedDB 元数据
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
await unifiedCacheService.cacheMediaFromBlob(stableUrl, blob, 'image', { taskId });
// 问题:IndexedDB 会堆积大量不需要的元数据
✅ 正确示例:
// 本地图片只存 Cache Storage
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
await unifiedCacheService.cacheToCacheStorageOnly(stableUrl, blob);
// ✅ 只存实际数据,不存元数据
适用场景:
- ✅ 只存 Cache Storage:分割图片、Base64 迁移图片、合并图片
- ✅ 同时存 Cache Storage + IndexedDB:AI 生成图片、本地上传素材
原因:
- 本地图片不需要在素材库单独显示(它们只是画布元素的缓存)
- 减少 IndexedDB 存储压力
- 避免 IndexedDB 和 Cache Storage 数据不一致
错误: 将图标组件作为 React 子元素直接渲染
场景: 在 ToolButton 或类似组件的 icon 属性中传递图标时,或在 JSX 中使用三元表达式选择图标时。
❌ 错误示例:
// 报错:Functions are not valid as a React child
<ToolButton icon={MediaLibraryIcon} />
// 三元表达式中也是错误的
<button>{locked ? LockIcon : UnlockIcon}</button>
✅ 正确示例:
// 正确:实例化组件为 React 元素
<ToolButton icon={<MediaLibraryIcon />} />
// 三元表达式中也要实例化
<button>{locked ? <LockIcon /> : <UnlockIcon />}</button>
原因: icon 属性通常被直接渲染(如 {props.icon})。在 React 中,你可以渲染元素(Element),但不能直接渲染组件函数(Component Function)。将组件改为函数式组件(React.FC)后,必须使用 JSX 语法 <Icon /> 来实例化。
常见出错位置: popup-toolbar.tsx、size-input.tsx、link-button.tsx、app-menu-items.tsx 等使用图标的组件。
图标组件规范: 使用 React.FC 支持 size 属性
场景: 定义或更新 icons.tsx 中的图标时。
❌ 错误示例:
export const MyIcon = createIcon(<svg>...</svg>);
✅ 正确示例:
export const MyIcon: React.FC<React.SVGProps<SVGSVGElement> & { size?: number }> = ({ size = 24, ...props }) => (
<svg width={size} height={size} {...props}>...</svg>
);
原因: 统一使用 React.FC 定义图标组件,可以方便地通过 size 属性控制尺寸,同时通过解构 {...props} 支持透传 className、style 等 SVG 标准属性,增强了图标的灵活性和一致性。
错误: CSS 全局规则覆盖 SVG 特定颜色
场景: 为图标设置特定品牌色(如 AI 工具的玫红/橙色)时。
❌ 错误示例:
// scss 文件中
.tool-icon svg {
stroke: currentColor !important; // 覆盖了所有内联 stroke 属性
}
✅ 正确示例:
// icons.tsx 中
<path d="..." stroke="#E91E63" /> // 在路径级别设置颜色,避免被全局 CSS 轻易覆盖
原因: 全局的 stroke: currentColor 规则会强制图标跟随文字颜色,导致 AI 生成等需要强调色的图标变成灰色。应移除这类过于激进的全局样式,或在图标内部路径上显式指定颜色。
错误: 筛选逻辑中“全部 (ALL)”选项导致结果为空
场景: 实现带有“全部 (ALL)”选项的素材库筛选逻辑时。
❌ 错误示例:
const matchesSource = filters.activeSource === 'ALL' || asset.source === filters.activeSource;
// 如果 filters.activeSource 初始值为 undefined,(undefined === 'ALL') 为 false,结果为空
✅ 正确示例:
const matchesSource = !filters.activeSource || filters.activeSource === 'ALL' || asset.source === filters.activeSource;
原因: 初始状态下筛选变量可能为 undefined。进行逻辑判断时,必须同时考虑 undefined、null 和 'ALL' 这几种代表”不筛选”的情况,否则会导致筛选结果意外为空。
API 错误字段类型安全
场景: 处理外部 API 返回的 error 字段时,error 可能是字符串也可能是对象
❌ 错误示例:
// API 返回:{ error: { code: “generation_failed”, message: “[403]...” } }
const data = await response.json();
throw new Error(data.error || data.message || 'Failed');
// data.error 是对象 → new Error({...}) → error.message = “[object Object]”
✅ 正确示例:
const data = await response.json();
const errMsg = typeof data.error === 'string'
? data.error
: (data.error?.message || data.message || 'Failed');
const error = new Error(errMsg);
if (typeof data.error === 'object' && data.error?.code) {
(error as any).code = data.error.code;
}
throw error;
原因: new Error(value) 内部调用 String(value),对象会变成 ”[object Object]”。API 的 error 字段格式不统一(有的返回字符串,有的返回 { code, message } 对象),必须用 typeof 区分处理。
数值范围转换规则
场景: 回调链中传递进度/百分比等数值时,必须明确每一层的值域范围并保持一致
❌ 错误示例:
// pollVideoStatus 回调返回 0-1 范围
onProgress(apiProgress / 100); // 100 → 1.0
// 调用方误以为是 0-100 范围,乘以 0.8
options?.onProgress?.({ progress: 10 + progress * 0.8 });
// 结果:10 + 1.0 * 0.8 = 10.8,而非预期的 90
✅ 正确示例:
// 明确注释值域范围
onProgress(apiProgress / 100); // 输出 0-1
// 正确映射:0-1 → 10-90 需乘以 80
// progress 是 0-1 范围,映射到 10-90:10 + (0~1) * 80
options?.onProgress?.({ progress: 10 + progress * 80 });
// 结果:10 + 1.0 * 80 = 90 ✓
原因: 回调链跨多层传递数值时,0-1 和 0-100 两种范围容易混淆。每个回调的输入/输出值域必须用注释明确标注,映射公式应能通过边界值(0 和 max)验证正确性。
Plait 插件规范
自定义组件 onContextChanged 必须处理 viewport 变化
场景: 创建自定义 Plait 组件(继承 PlaitPluginElementComponent)时,选择框(蓝色虚线 + 控制点)在缩放/平移画布后与元素偏移。
❌ 错误示例:
// 错误:onContextChanged 没有处理 viewport 变化
onContextChanged(
value: PlaitPluginElementContext<MyElement, PlaitBoard>,
previous: PlaitPluginElementContext<MyElement, PlaitBoard>
): void {
if (value.element !== previous.element || value.hasThemeChanged) {
this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
} else {
const needUpdate = value.selected !== previous.selected;
if (needUpdate || value.selected) {
this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
}
}
// 问题:缩放/平移画布后,选择框位置不更新,与元素产生偏移
}
✅ 正确示例:
// 正确:检测 viewport 变化并重绘选择框(参考 ToolComponent)
onContextChanged(
value: PlaitPluginElementContext<MyElement, PlaitBoard>,
previous: PlaitPluginElementContext<MyElement, PlaitBoard>
): void {
const viewportChanged =
value.board.viewport.zoom !== previous.board.viewport.zoom ||
value.board.viewport.offsetX !== previous.board.viewport.offsetX ||
value.board.viewport.offsetY !== previous.board.viewport.offsetY;
if (value.element !== previous.element || value.hasThemeChanged) {
this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
} else if (viewportChanged && value.selected) {
// viewport 改变且元素被选中时,更新选择框位置
this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
} else {
const needUpdate = value.selected !== previous.selected;
if (needUpdate || value.selected) {
this.activeGenerator.processDrawing(this.element, PlaitBoard.getActiveHost(this.board), { selected: this.selected });
}
}
}
原因: 选择框渲染在 board-active-svg(独立 SVG,无 viewBox),坐标通过 toActiveRectangleFromViewBoxRectangle 实时计算。而元素渲染在 board-host-svg(有 viewBox 自动映射坐标)。当 viewport 变化时,host-svg 中的元素位置自动调整,但 active-svg 上的选择框必须手动重新计算坐标才能对齐。ToolComponent 已正确实现了此模式。
UI 交互规范
媒体预览统一使用公共组件
场景: 需要实现图片/视频预览功能时(如任务列表、生成结果预览等)。
❌ 错误示例:
// 自定义 Dialog 实现预览
<Dialog visible={previewVisible} header="图片预览" width="90vw">
<div className="preview-container">
<Button icon={<ChevronLeftIcon />} onClick={handlePrevious} />
<img src={previewUrl} />
<Button icon={<ChevronRightIcon />} onClick={handleNext} />
</div>
</Dialog>
✅ 正确示例:
import { UnifiedMediaViewer, type MediaItem } from '../shared/media-preview';
<UnifiedMediaViewer
visible={previewVisible}
items={mediaItems}
initialIndex={previewIndex}
onClose={handleClose}
showThumbnails={true}
/>
原因: 项目已有功能完善的 UnifiedMediaViewer 公共组件,支持:
- 单图预览、对比预览、编辑模式
- 缩略图导航栏
- 键盘快捷键(左右箭头、Escape)
- 缩放、拖拽、全屏
- 视频同步播放
自定义实现会导致功能不一致、代码重复,且缺失公共组件已有的增强功能。
媒体封面/占位统一使用公共组件
场景: 音频封面、音频缩略图、播放器封面等媒体入口需要处理无图、404 和加载失败。
❌ 错误示例:
{previewImageUrl && !loadFailed ? (
<img src={previewImageUrl} onError={() => setLoadFailed(true)} />
) : (
<div className="audio-fallback">
<Music4 />
</div>
)}
✅ 正确示例:
<AudioCover
src={previewImageUrl}
fallbackSrc={posterUrl}
alt={title || 'Audio cover'}
imageClassName="player__cover-image"
fallbackClassName="player__cover-fallback"
/>
规则:
- 媒体封面加载失败兜底逻辑要收敛到公共组件,禁止在播放器、画布节点、预览器里重复维护一套
onError + useState - 默认封面视觉必须统一,后续改默认图时只改一处
- 若存在
thumbnailUrl -> 原图 URL的回退链路,应由公共组件统一支持 - 业务组件只负责尺寸、布局、圆角样式,不负责重复实现失败状态机
原因:
- 音频相关展示入口分散,重复实现很容易出现“有的地方有占位,有的地方白屏/裂图”
- 公共组件可以统一失败行为、减少重复代码,并降低后续视觉调整成本
生成结果缩略图使用 contain 完整展示
场景: 展示 AI 生成的图片/视频缩略图时(任务队列、生成历史、预览缩略图等)。
❌ 错误示例:
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover; // 裁切图片,可能丢失重要内容
}
✅ 正确示例:
.thumbnail img {
width: 100%;
height: 100%;
object-fit: contain; // 完整展示图片
}
原因: AI 生成的图片内容完整性很重要,用户需要看到完整的生成结果才能判断质量。使用 cover 会裁切图片边缘,可能导致:
- 宫格图部分格子被裁切
- 竖版/横版图片重要内容被裁切
- 用户无法准确评估生成效果
例外情况: 以下场景可以使用 cover:
- 用户上传的参考图片(用户已知图片内容)
- 角色头像(圆形需要填充)
- 聊天消息中的图片
小图应提供 hover 大图预览
场景: 展示缩略图(尤其是 AI 生成结果的小图)时。
❌ 错误示例:
// 只有小图,没有预览
<div className="thumbnail">
<img src={image.url} alt={image.name} />
</div>
✅ 正确示例:
const [hoveredImage, setHoveredImage] = useState<{ url: string; x: number; y: number } | null>(null);
<div
className="thumbnail"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setHoveredImage({ url: image.url, x: rect.left + rect.width / 2, y: rect.top - 10 });
}}
onMouseLeave={() => setHoveredImage(null)}
>
<img src={image.url} alt={image.name} />
</div>
{/* Hover 预览通过 Portal 渲染到 body */}
{hoveredImage && ReactDOM.createPortal(
<div
className="hover-preview"
style={{ left: hoveredImage.x, top: hoveredImage.y, transform: 'translate(-50%, -100%)' }}
>
<img src={hoveredImage.url} alt="Preview" />
</div>,
document.body
)}
原因: 缩略图尺寸较小,用户难以判断图片细节。提供 hover 大图预览可以:
- 快速查看图片细节,无需点击打开预览弹窗
- 提升用户体验,减少操作步骤
- 方便用户快速对比多张图片
Tooltip 样式统一规范
场景: 在项目中使用 TDesign 的 Tooltip 组件时。
❌ 错误示例:
<Tooltip content="提示文字">
<Button icon={<Icon />} />
</Tooltip>
✅ 正确示例:
<Tooltip content="提示文字" theme="light" showArrow={false}>
<Button icon={<Icon />} />
</Tooltip>
原因: 为了保持项目视觉风格的高度统一,所有 Tooltip 必须使用 theme="light"(白底黑字)。同时,为了界面更简洁,推荐在图标按钮或紧凑列表项上使用 showArrow={false} 隐藏箭头。
高层级容器中的 Tooltip 遮挡问题
场景: 在使用 createPortal 渲染的弹窗、下拉菜单或设置了极高 zIndex 的容器内部使用 Tooltip 时。
❌ 错误示例:
// 在 zIndex: 10000 的下拉菜单中
<Tooltip content="状态提示">
<div className="status-dot" />
</Tooltip>
// 结果:Tooltip 被挡在下拉菜单下面,看不见
✅ 正确示例:
<Tooltip content="状态提示" theme="light" zIndex={20000}>
<div className="status-dot" />
</Tooltip>
原因: 项目中部分浮层(如模型选择下拉)使用了 createPortal 且 zIndex 达到 10000。默认层级的 Tooltip 会被遮挡。在这种情况下,必须显式将 Tooltip 的 zIndex 提升到更高(如 20000)以确保可见。
信号/状态展示的量化表意
场景: 展示模型健康度、网络信号等需要量化感知的状态时。
❌ 错误示例: 使用单一圆点或方块,仅靠颜色区分。用户难以感知“程度”的差异。
✅ 正确示例: 使用“信号格”或“进度条”设计,配合颜色变化。
- 3 格绿色:极佳
- 2 格橙色:一般
- 1 格红色:极差
原因: 相比单一的圆点,信号格能更直观地传达“量”的概念,符合用户的直觉认知(如手机信号、WiFi 强度)。
可点击容器模式:扩大交互区域
场景: 当 checkbox、按钮等小型交互元素嵌套在容器中时,用户期望点击整个容器都能触发操作。
❌ 错误示例:
// 只有点击 checkbox 本身才能触发
<div className="selection-info">
<Checkbox checked={isAllSelected} onChange={toggleSelectAll} />
<span>{selectedCount}</span>
</div>
.selection-info {
// 没有任何点击相关样式
}
✅ 正确示例:
// 点击整个容器都能触发
<div
className="selection-info"
onClick={toggleSelectAll}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSelectAll();
}
}}
>
<Checkbox checked={isAllSelected} /> {/* 移除 onChange,由父容器处理 */}
<span>{selectedCount}</span>
</div>
.selection-info {
cursor: pointer;
user-select: none;
&:active {
background: #cbd5e1; // 按压反馈
}
.t-checkbox {
pointer-events: none; // 禁用子元素直接点击,让父容器统一处理
}
}
关键点:
- 父容器添加
onClick、role="button"、tabIndex={0}和键盘支持 - 子元素使用
pointer-events: none禁用直接点击 - 添加
cursor: pointer和:active反馈
Shift 连选时防止文本被选中
场景: 实现列表/网格的多选功能时,用户使用 Shift 键连选会触发浏览器默认的文本选择行为。
❌ 错误示例:
.list-item {
cursor: pointer;
// 没有禁用文本选择,Shift+Click 时文字会被选中高亮
}
// 用户 Shift+Click 连选时,列表项的文字被蓝色高亮选中
const handleClick = (id: string, event: React.MouseEvent) => {
if (event.shiftKey) {
// 执行连选逻辑
selectRange(lastSelectedId, id);
}
};
✅ 正确示例:
.list-item {
cursor: pointer;
user-select: none; // 防止 Shift 连选时文本被选中
}
// Shift+Click 时只执行连选逻辑,不会选中文字
const handleClick = (id: string, event: React.MouseEvent) => {
if (event.shiftKey && lastSelectedId) {
selectRange(lastSelectedId, id);
return;
}
toggleSelection(id);
lastSelectedIdRef.current = id;
};
原因: 浏览器默认行为是 Shift+Click 选中两次点击之间的所有文本。在实现自定义多选功能时,需要通过 user-select: none 禁用这一行为,否则用户会看到文本被选中的蓝色高亮,影响交互体验。
筛选与选中状态联动
场景: 实现带筛选功能的列表选择时,选中状态应与筛选结果联动。
❌ 错误示例:
// 选中数量始终显示总选中数,不考虑筛选
<span>{selectedAssetIds.size}</span>
// 删除按钮也基于总选中数
<Button disabled={selectedAssetIds.size === 0} />
// 删除操作删除所有选中项,包括不在当前筛选结果中的
const handleDelete = () => {
deleteAssets(Array.from(selectedAssetIds));
};
✅ 正确示例:
// 计算当前筛选结果中被选中的数量
const filteredSelectedCount = useMemo(() => {
return filteredResult.assets.filter(asset => selectedAssetIds.has(asset.id)).length;
}, [filteredResult.assets, selectedAssetIds]);
// 显示筛选后的选中数量
<span>{filteredSelectedCount}</span>
// 按钮状态基于筛选后的选中数量
<Button disabled={filteredSelectedCount === 0} />
// 操作只影响当前筛选结果中被选中的项
const handleDelete = () => {
const filteredSelectedAssets = filteredResult.assets.filter(a => selectedAssetIds.has(a.id));
deleteAssets(filteredSelectedAssets.map(a => a.id));
};
核心原则:
- 显示:选中计数基于筛选后的结果
- 全选:只选中/取消当前筛选结果
- 操作:删除、下载等只影响筛选后被选中的项
- 按钮状态:disabled 基于筛选后的选中数量
全局组件配色统一
场景: 项目使用第三方 UI 库(如 TDesign)时,需要统一覆盖组件样式以符合品牌规范。
❌ 错误示例:
// 在多个组件文件中分散覆盖
// AssetItem.scss
.asset-item .t-checkbox.t-is-checked .t-checkbox__input {
background: $brand-orange;
}
// MediaLibraryGrid.scss
.media-library-grid .t-checkbox.t-is-checked .t-checkbox__input {
background: $brand-orange;
}
// OtherComponent.scss
.other .t-checkbox.t-is-checked .t-checkbox__input {
background: $brand-orange; // 重复代码,且容易遗漏
}
✅ 正确示例:
// 在 tdesign-theme.scss 中集中覆盖
/* 全局 Checkbox 样式覆盖 - 橙色背景 + 白色勾选图标 */
.t-checkbox {
&.t-is-checked,
&.t-is-indeterminate {
.t-checkbox__input {
background-color: var(--td-brand-color) !important;
border-color: var(--td-brand-color) !important;
}
}
.t-checkbox__input {
&::after {
border-color: #fff !important; // 确保勾选图标为白色
}
}
}
最佳实践:
- 在
styles/tdesign-theme.scss中集中管理所有第三方组件的品牌色覆盖 - 使用 CSS 变量(如
--td-brand-color)保持一致性 - 组件级别的样式文件只处理布局和特殊场景,不重复颜色定义
- 确保 checked、indeterminate、hover、active 等所有状态都被覆盖
React 加载状态规范
避免 Suspense 导致的布局抖动
场景: 使用 React.lazy 和 Suspense 加载组件时,如果 fallback 占位符的高度与加载后的真实内容差异巨大,会导致页面布局发生剧烈的跳动。
❌ 错误示例:
// 错误:fallback 只有 16px 高,加载后内容有 500px 高
<Suspense fallback={<div className="spinner" />}>
<ChatMessagesArea />
</Suspense>
✅ 正确示例:
// 正确:使用撑满容器或固定高度的 fallback
<Suspense fallback={
<div className="loading-container--full">
<div className="spinner" />
</div>
}>
<ChatMessagesArea />
</Suspense>
// SCSS
.loading-container--full {
flex: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
原因: 布局抖动(Layout Shift)严重影响用户体验和视觉稳定性。Fallback 应尽可能模拟加载后的布局尺寸。
API 与任务处理规范
优先使用结构化数据而非字符串解析
场景: 在 UI 组件中展示复杂的业务数据(如 AI 生成消息中的模型、参数、上下文)时。
❌ 错误示例:
// 错误:通过解析拼接后的字符串来提取参数
const metaTags = textContent.split('\n').filter(line => line.startsWith('模型:'));
✅ 正确示例:
// 正确:在数据模型中直接存储结构化对象
interface ChatMessage {
id: string;
content: string;
aiContext?: {
model: string;
params: Record<string, any>;
};
}
// UI 渲染时优先读取结构化数据
const model = message.aiContext?.model || parseFallback(message.content);
原因: 字符串解析极其脆弱,容易因文案微调、语言切换或历史数据格式不一而失效。结构化数据是唯一可靠的真相来源。
日志记录应反映实际发送的数据
场景: 在调用外部 API 前记录请求参数用于调试(如 /sw-debug.html 的 LLM API 日志),数据在发送前经过了处理(如图片裁剪、压缩)。
❌ 错误示例:
// 错误:在数据处理前收集日志信息
const referenceImageInfos = await Promise.all(
refUrls.map(url => getImageInfo(url)) // 获取原始图片信息
);
// 后续处理会改变图片
for (const url of refUrls) {
let blob = await fetchImage(url);
blob = await cropImageToAspectRatio(blob, targetWidth, targetHeight); // 裁剪
formData.append('input_reference', blob);
}
// 日志记录的是裁剪前的尺寸,与实际发送的不符!
startLLMApiLog({ referenceImages: referenceImageInfos });
✅ 正确示例:
// 正确:在数据处理后收集日志信息
const referenceImageInfos: ImageInfo[] = [];
for (const url of refUrls) {
let blob = await fetchImage(url);
blob = await cropImageToAspectRatio(blob, targetWidth, targetHeight); // 裁剪
// 获取处理后的图片信息用于日志
const info = await getImageInfo(blob);
referenceImageInfos.push(info);
formData.append('input_reference', blob);
}
// 日志记录的是实际发送的数据
startLLMApiLog({ referenceImages: referenceImageInfos });
原因: 调试日志的价值在于准确记录实际发送给 API 的数据。如果日志记录的是处理前的数据,当 API 返回错误(如"图片尺寸不匹配")时,日志显示的尺寸与实际不符,会严重误导排查方向。
外部 API 调用频率控制
场景: 调用外部服务的低频刷新接口(如每 5 分钟刷新一次的状态接口),多个组件可能同时触发请求。
❌ 错误示例:
// 错误:直接导出函数,每次调用都发起请求
export async function fetchHealthData(): Promise<Data[]> {
const response = await fetch(API_URL);
return response.json();
}
// 多个组件同时调用会产生重复请求
// ComponentA: fetchHealthData()
// ComponentB: fetchHealthData() // 同时发起第二个请求
✅ 正确示例:
// 正确:使用单例控制调用频率和并发
class HealthDataFetcher {
private static instance: HealthDataFetcher;
private cachedData: Data[] = [];
private lastFetchTime = 0;
private pendingFetch: Promise<Data[]> | null = null;
static getInstance() {
if (!this.instance) this.instance = new HealthDataFetcher();
return this.instance;
}
async fetch(force = false): Promise<Data[]> {
// 1. 检查最小调用间隔(如 1 分钟)
if (!force && Date.now() - this.lastFetchTime < 60_000) {
return this.cachedData;
}
// 2. 复用进行中的请求(防并发)
if (this.pendingFetch) return this.pendingFetch;
// 3. 发起新请求
this.pendingFetch = this.doFetch();
try { return await this.pendingFetch; }
finally { this.pendingFetch = null; }
}
}
export const healthDataFetcher = HealthDataFetcher.getInstance();
原因: 外部接口数据通常有刷新周期(如 5 分钟),在刷新周期内重复请求是浪费。单例模式可以:1) 设置最小调用间隔避免频繁请求;2) 复用进行中的 Promise 防止并发请求;3) 统一管理缓存,所有调用方共享数据。
第三方统计与 Session Replay 对主线程与请求体的影响
场景: 接入第三方统计/监控(如 PostHog)或开启 Session Replay(如 rrweb)时。
要点:
- 开启 Session Replay 前需评估:主线程开销(wheel、mousemove、setInterval 等)、单次上报体体积(易触发 413 Content Too Large)。
- 默认建议关闭或按采样开启;若开启,需配置限流、单批大小与 413 错误处理(如 on_xhr_error / on_request_error 中识别 413 后丢弃或有限重试并打 debug 日志),避免无限重试加重主线程与网络负担。
- 统计上报须遵循「统计上报旁路化」:初始化与上报在 requestIdleCallback 或 setTimeout 中执行,不在主路径上做脱敏与网络请求;失败静默不向上抛。
❌ 错误示例:
// 错误:默认开启 Session Recording,导致 wheel/setInterval 卡顿与 413
posthog.init('phc_xxx', {
api_host: 'https://us.i.posthog.com',
// 未设置 disable_session_recording,rrweb 默认录制
});
✅ 正确示例:
// 正确:默认关闭 Session Recording,并处理 413 避免重试风暴
posthog.init('phc_xxx', {
api_host: 'https://us.i.posthog.com',
disable_session_recording: true,
rate_limiting: { events_per_second: 10, events_burst_limit: 30 },
on_xhr_error: function(err) {
if (err && (err.statusCode ?? err.status) === 413) {
console.debug('[PostHog] 413 Content Too Large, batch dropped');
}
},
});
原因: Session Replay(rrweb)在滚轮、鼠标移动和定时器中录制 DOM/输入,会直接占用主线程并产生大量数据,单次 flush 易超服务端限制导致 413;413 响应常不带 CORS 头,浏览器会先报 CORS。关闭或按采样开启、并处理 413 可消除卡顿与报错。
无效配置下的数据不应被持久化或执行
场景: 用户在未配置 API Key 时创建了任务,后来配置了 API Key,这些旧任务不应被执行。
❌ 错误示例:
// 错误:initialize 时直接恢复所有 PENDING 任务
async initialize(config: Config): Promise<void> {
this.config = config;
this.initialized = true;
// 恢复并执行所有 PENDING 任务(包括无效配置时创建的)
for (const task of this.tasks.values()) {
if (task.status === TaskStatus.PENDING) {
this.executeTask(task); // ❌ 执行了"孤儿任务"
}
}
}
✅ 正确示例:
// 正确:首次初始化时清除无效配置下创建的任务
private hadSavedConfig = false;
async restoreFromStorage(): Promise<void> {
const { config } = await storage.loadConfig();
if (config) {
this.hadSavedConfig = true; // 标记有保存的配置
}
}
async initialize(config: Config): Promise<void> {
// 首次初始化时清除"孤儿任务"
if (!this.hadSavedConfig) {
for (const task of this.tasks.values()) {
if (task.status === TaskStatus.PENDING) {
await storage.deleteTask(task.id); // ✅ 清除无效任务
}
}
}
this.hadSavedConfig = true;
// ... 继续正常初始化
}
原因: 无效配置(如缺少 API Key)下创建的任务是"孤儿数据",不应在后续有效配置时被执行。通过 hadSavedConfig 标志区分"首次初始化"和"恢复已有配置",确保只有在有效配置下创建的任务才会被执行。
模块导入规范
同名模块的全局状态隔离问题
场景: 项目中存在多个同名模块(如 canvas-insertion.ts),各自维护独立的全局变量(如 boardRef)。
❌ 错误示例:
// MediaViewport.tsx - 错误:从 mcp/tools 导入
import { quickInsert } from '../../../mcp/tools/canvas-insertion';
// 但 boardRef 是在 services/canvas-operations 版本中被设置的
// 导致 "画布未初始化" 错误
✅ 正确示例:
// MediaViewport.tsx - 正确:从 services/canvas-operations 导入
import { quickInsert } from '../../../services/canvas-operations';
// 与 AIInputBar.tsx 中 setCanvasBoard 设置的是同一个 boardRef
原因: 项目中 mcp/tools/canvas-insertion.ts 和 services/canvas-operations/canvas-insertion.ts 是两个独立模块,各自有独立的 boardRef 变量。AIInputBar 只设置了 services 版本的 boardRef,所以必须从 services/canvas-operations 导入才能正确访问已初始化的 board。
坐标变换场景的一致性处理
翻转状态下的鼠标交互
场景: 当元素支持翻转(flipH/flipV)或旋转时,基于鼠标位移计算的逻辑(如拖拽裁剪框、调整大小)需要根据变换状态调整方向。
❌ 错误示例:
// 错误:未考虑翻转状态,翻转后拖拽方向和鼠标移动方向相反
const handleMouseMove = (e: MouseEvent) => {
const deltaX = (e.clientX - dragStart.x) / scale;
const deltaY = (e.clientY - dragStart.y) / scale;
// 直接使用 delta,翻转后方向错误
newCrop.x = initialCrop.x + deltaX;
newCrop.y = initialCrop.y + deltaY;
};
✅ 正确示例:
// 正确:根据翻转状态调整 delta 方向
const handleMouseMove = (e: MouseEvent) => {
let deltaX = (e.clientX - dragStart.x) / scale;
let deltaY = (e.clientY - dragStart.y) / scale;
// 翻转后需要反转 delta 方向
if (flipH) deltaX = -deltaX;
if (flipV) deltaY = -deltaY;
newCrop.x = initialCrop.x + deltaX;
newCrop.y = initialCrop.y + deltaY;
};
原因: 图片翻转后,视觉上的坐标系发生了变化。水平翻转后鼠标向右移动在视觉上是向左,垂直翻转后鼠标向下移动在视觉上是向上。
翻转状态下的 cursor 样式
场景: 调整大小的控制点需要显示正确的 cursor 方向指示。
❌ 错误示例:
// 错误:cursor 样式写死在 CSS 中,翻转后方向不对
<div className="handle--nw" /> // cursor: nw-resize(固定)
✅ 正确示例:
// 正确:根据翻转状态动态计算 cursor
const getCursorForHandle = (handle: string): string => {
let adjusted = handle;
if (flipH) adjusted = adjusted.replace('w', 'e').replace('e', 'w');
if (flipV) adjusted = adjusted.replace('n', 's').replace('s', 'n');
return `${adjusted}-resize`;
};
<div
className="handle--nw"
style={{ cursor: getCursorForHandle('nw') }}
/>
原因: 翻转后控制点的视觉位置改变了,例如原本在左上角的 nw 控制点,水平翻转后在视觉上变成了右上角,cursor 应该显示为 ne-resize 而非 nw-resize。
UI 图标库规范
验证 TDesign 图标库导出名称
场景: 使用 tdesign-icons-react 库中的图标时。
❌ 错误示例:
import { RobotIcon, NumberIcon } from 'tdesign-icons-react';
// 错误:这两个图标在库中并不存在,会导致运行时报错
✅ 正确示例:
import { ServiceIcon, BulletpointIcon } from 'tdesign-icons-react';
// 正确:使用库中实际存在的相近图标
原因: tdesign-icons-react 的图标导出名称有时与直觉不符(例如没有 RobotIcon 而是 ServiceIcon)。在引入新图标前务必通过 IDE 补全功能验证其存在。
React 加载状态规范
避免 Suspense 导致的布局抖动
场景: 使用 React.lazy 和 Suspense 加载组件时,如果 fallback 高度与实际内容差异巨大,会导致页面跳动。
❌ 错误示例:
// 错误:fallback 只有一行文字高度,加载后容器瞬间撑开
<Suspense fallback={<div>加载中...</div>}>
<ChatMessagesArea />
</Suspense>
✅ 正确示例:
// 正确:fallback 撑满容器或具有固定高度
<Suspense fallback={<div className="chat-loading--full"><Spinner /></div>}>
<ChatMessagesArea />
</Suspense>
// CSS
.chat-loading--full {
flex: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
原因: 布局抖动(Layout Shift)严重影响用户体验,通过为加载状态预留空间可以保持视觉稳定性。
API 与任务处理规范
优先使用结构化数据而非字符串解析
场景: 在 UI 层展示复杂信息(如 AI 生成参数)时。
❌ 错误示例:
// 错误:通过正则或 split 解析拼接好的显示文本
const parts = textContent.split(' 模型: ');
const modelId = parts[1]?.trim();
✅ 正确示例:
// 正确:在数据源头保留结构化 Context
const userChatMsg = {
role: 'user',
textContent: '...',
aiContext: context, // 存储原始对象
};
// UI 直接读取
const modelId = chatMessage.aiContext?.model?.id;
原因: 字符串解析极其脆弱,格式微调会导致解析失败;结构化数据提供类型安全且更易维护。
LLM 响应格式兼容性
场景: 解析 AI 模型返回的工作流 JSON 时,不同模型返回格式可能不同。
❌ 错误示例:
// 错误:只处理标准格式,忽略其他可能的格式
function parseToolCalls(response: string) {
const parsed = JSON.parse(response);
// 只处理 {"content": "...", "next": [...]} 格式
return parsed.next.map(item => ({...}));
}
✅ 正确示例:
// 正确:支持多种 AI 返回格式
function parseToolCalls(response: string): ToolCall[] {
// 格式 1: {"content": "分析完成", "next": [{"mcp": "...", "args": {...}}]}
const workflowJson = parseWorkflowJson(response);
if (workflowJson?.next.length > 0) {
return workflowJson.next.map(...);
}
// 格式 2: {"content": "[{\"mcp\": \"...\", \"args\": {...}}]"}
// content 字段本身是 JSON 数组字符串
if (next.length === 0 && parsed.content?.trim().startsWith('[')) {
const contentParsed = JSON.parse(parsed.content);
if (Array.isArray(contentParsed)) next = contentParsed;
}
// 格式 3: [{"mcp": "...", "args": {...}}] 直接数组
if (cleaned.trim().startsWith('[')) {
const parsed = JSON.parse(cleaned);
if (Array.isArray(parsed)) return parsed.map(...);
}
// 格式 4: 支持 name/mcp 和 arguments/args 字段名
const name = parsed.name || parsed.mcp;
const args = parsed.arguments || parsed.args || parsed.params;
return toolCalls;
}
原因: 不同 LLM 模型(GPT、Gemini、DeepSeek 等)对同一 prompt 可能返回不同格式的 JSON,必须兼容多种格式以确保工作流正确执行。
工作流状态同步使用事件驱动 + 初始对账
场景: 在 UI 组件中同步工作流执行状态时。
❌ 错误示例:
// 错误:轮询 IndexedDB 同步状态(多条同步路径互相竞争导致状态闪烁)
class WorkflowStatusSyncService {
subscribe(workflowId: string, callback: StatusChangeCallback): () => void {
// 每秒从 IndexedDB 读取 → 与事件驱动路径竞争 → 状态覆盖
}
}
✅ 正确示例:
// 正确:统一事件驱动路径 + 挂载时一次性对账
// TaskQueueService → RxJS → useTaskWorkflowSync → WorkflowContext + WorkZone + ChatDrawer
useEffect(() => {
// 初始对账:处理挂载前已完成的终态任务
const allTasks = taskQueueService.getAllTasks();
for (const task of allTasks) {
if (task.status === 'completed' || task.status === 'failed') {
processTaskEvent({ type: 'taskUpdated', task, timestamp: Date.now() });
}
}
// 订阅后续实时事件
const sub = taskQueueService.observeTaskUpdates().subscribe(processTaskEvent);
return () => sub.unsubscribe();
}, []);
原因:
- 单一事件驱动路径消除多路径竞争导致的状态闪烁
- 初始对账替代轮询,只执行一次,覆盖挂载前错过的终态事件
- TaskQueueService 内存 Map 是唯一数据源,无需从 IndexedDB 拉取
UI 图标库规范
验证 TDesign 图标库导出名称
场景: 使用 tdesign-icons-react 引入新图标时。
❌ 错误示例:
import { NumberIcon, RobotIcon } from 'tdesign-icons-react';
// ❌ 报错:这些名称在库中不存在,导致应用崩溃
✅ 正确示例:
import { BulletpointIcon, ServiceIcon } from 'tdesign-icons-react';
// ✅ 使用前先验证库中实际存在的导出名称
原因: TDesign 图标库命名不一定符合直觉,使用不存在的导出名会触发 SyntaxError 导致白屏。
E2E 测试规范
Playwright 元素选择器精度
场景: 选择工具栏按钮时,需要获取完整的可点击区域而非内部小元素。
❌ 错误示例:
// 错误:getByRole('radio') 选择 13x13px 的 input 元素
const toolBtn = toolbar.locator('div').filter({
has: page.getByRole('radio', { name: /画笔/ })
});
// 实际选中的是内部的 radio input,而非外层按钮容器
✅ 正确示例:
// 正确:使用 label 选择器获取完整的按钮容器 (40x36px)
const toolBtn = toolbar.locator('label').filter({
has: page.getByRole('radio', { name: /画笔/ })
});
原因: getByRole('radio') 会匹配到隐藏的 input 元素,其尺寸通常很小。使用 label 选择器可以获取实际的可点击区域。
CSS 定位避免内容截断
场景: 为元素添加标签/提示文字时,定位方式可能导致内容被截断。
❌ 错误示例:
/* 错误:使用 right + transform 定位,容易被父容器截断 */
.label {
position: absolute;
right: -10px;
transform: translate(100%, -50%);
}
✅ 正确示例:
/* 正确:使用 left: 100% + margin,内容不会被截断 */
.label {
position: absolute;
left: 100%;
margin-left: 8px;
transform: translateY(-50%);
}
原因: right 配合 transform: translate(100%) 会使元素超出父容器边界,如果父容器有 overflow: hidden 则内容被截断。
避免过度复杂的自动化系统
场景: 设计自动化工具(如 GIF 录制、截图生成)时的架构决策。
❌ 错误做法:
- 设计复杂的 DSL 系统(JSON 定义 + 执行器 + 时间戳裁剪)
- 录制一个长视频然后按时间点裁剪多个 GIF
- 尝试用一套配置生成所有内容
✅ 正确做法:
- 每个 GIF 使用独立的测试录制
- 简单的命令行参数控制(如
--trim 2.4) - 一个命令完成一个任务(如
pnpm manual:gif:mindmap)
原因:
- 长视频裁剪会带来"旧信息污染"(前一个操作残留影响后一个)
- DSL 元素选择器难以处理动态 UI 的时序问题
- 简单方案更易调试和维护,复杂系统的调试成本远超收益
定期清理未使用代码
场景: 功能开发过程中会产生实验性代码和辅助文件。
检查项:
# 查看未跟踪文件
git status --short | grep "^??"
# 检查文件是否被导入
grep -r "from.*filename" apps/ packages/
常见可清理的文件:
- 未使用的 fixture 文件(如
test-data.ts) - 重复功能的测试文件(如多个 visual spec 覆盖相同功能)
- 错误创建的目录结构
- 过时的文档(引用已删除代码的 md 文件)
原因: 未清理的代码会增加维护负担,误导后续开发者,也会增加 CI 执行时间。
Clipper 布尔运算结果处理
场景: 使用 clipper-lib 进行多边形布尔运算(合并、减去、相交等)后,需要区分外环和孔洞。
❌ 错误做法:
// 错误:依赖面积符号判断外环/孔洞
const outerRing = pathsWithArea.find(p => p.signedArea > 0);
const holes = pathsWithArea.filter(p => p.signedArea < 0);
✅ 正确做法:
// 正确:用面积大小判断,最大的是外环,其他是孔洞
const sortedByArea = [...pathsWithArea].sort((a, b) => b.absArea - a.absArea);
const outerRing = sortedByArea[0]; // 面积最大的是外环
const holes = sortedByArea.slice(1); // 其他都是孔洞
原因:
- Clipper 返回的路径方向(顺时针/逆时针)取决于坐标系(Y 轴向上还是向下)
- 在不同环境下,面积符号可能相反,导致孔洞被错误识别为外环
- 面积大小是稳定的判断依据:外环总是包含所有孔洞,因此面积最大
相关文件: packages/drawnix/src/transforms/precise-erase.ts, packages/drawnix/src/transforms/boolean.ts
Slate-React Leaf 组件 DOM 结构必须保持稳定
场景: 在 Slate-React 的 Leaf 组件中实现文本样式(如下划线、删除线)时
❌ 错误做法:
// 错误:根据条件动态切换 HTML 标签和 CSS 实现方式
const Leaf = ({ children, leaf, attributes }) => {
const hasCustomStyle = leaf['text-decoration-style'];
// 当样式变化时,DOM 结构会从 <u>...</u> 变成 <span style={...}>...</span>
if (leaf.underlined && !hasCustomStyle) {
children = <u>{children}</u>; // 有时用标签
}
const style = {};
if (leaf.underlined && hasCustomStyle) {
style.textDecoration = 'underline'; // 有时用 CSS
}
return <span style={style} {...attributes}>{children}</span>;
};
// 报错:Cannot resolve a DOM node from Slate node
✅ 正确做法:
// 正确:始终使用同一种方式实现,保持 DOM 结构稳定
const Leaf = ({ children, leaf, attributes }) => {
const style: CSSProperties = {};
// 统一使用 CSS 实现,不使用 <u>、<s> 等标签
if (leaf.underlined || leaf.strikethrough) {
const decorations: string[] = [];
if (leaf.underlined) decorations.push('underline');
if (leaf.strikethrough) decorations.push('line-through');
style.textDecoration = decorations.join(' ');
if (leaf['text-decoration-style']) {
style.textDecorationStyle = leaf['text-decoration-style'];
}
}
return <span style={style} {...attributes}>{children}</span>;
};
原因:
- Slate-React 依赖稳定的 DOM 结构来追踪编辑器节点与 DOM 节点的映射关系
- 当根据样式条件动态切换 HTML 标签(
<u>)和 CSS(text-decoration)时,DOM 结构会发生变化 - 这会导致 Slate 无法找到对应的 DOM 节点,抛出 "Cannot resolve a DOM node from Slate node" 错误
- 解决方案是选择一种实现方式并始终使用,推荐使用 CSS 因为它更灵活(支持自定义样式和颜色)
相关文件: packages/react-text/src/text.tsx
postmessage-duplex RPC 消息大小限制
场景: 使用 postmessage-duplex 进行 SW 与主线程通信,查询大量数据时
❌ 错误示例:
// 错误:一次性返回所有日志,可能超过 1MB 限制
async function getAllLogs() {
const logs = await db.getAll(); // 可能有上千条,每条包含大字段
return { logs }; // Message size exceeds limit (1048576 bytes)
}
// 错误:前端分页,但后端返回全量数据
async function getLogsPaginated(page, pageSize) {
const allLogs = await db.getAll();
return { logs: allLogs.slice((page-1)*pageSize, page*pageSize) };
}
✅ 正确示例:
// 正确:后端分页 + 精简数据
async function getLogsPaginated(page = 1, pageSize = 20, filter = {}) {
let logs = await db.getAll();
// 1. 先过滤(在分页前)
if (filter.type) logs = logs.filter(l => l.type === filter.type);
if (filter.status) logs = logs.filter(l => l.status === filter.status);
// 2. 计算分页信息
const total = logs.length;
const totalPages = Math.ceil(total / pageSize);
// 3. 分页
const pagedLogs = logs.slice((page-1)*pageSize, page*pageSize);
// 4. 精简数据,去掉大字段
const compactLogs = pagedLogs.map(log => ({
id: log.id,
timestamp: log.timestamp,
status: log.status,
// 截断长文本
prompt: log.prompt?.substring(0, 200),
// 不传输 requestBody、responseBody 等大字段
}));
return { logs: compactLogs, total, page, pageSize, totalPages };
}
原因:
- postmessage-duplex 默认消息大小限制为 1MB(1048576 bytes)
- 日志数据通常包含大字段(请求体、响应体、图片 base64 等),100 条日志可能超过 10MB
- 解决方案:后端分页 + 精简数据,每页 20 条左右,去掉大字段
- 如需完整数据,提供单条查询接口,按需获取
相关文件:
apps/web/src/sw/task-queue/llm-api-logger.ts-getLLMApiLogsPaginatedapps/web/src/sw/task-queue/channel-manager.ts-handleDebugGetLLMApiLogs
主线程直接读取 IndexedDB 优于 RPC
场景: 主线程需要从 Service Worker 读取存储在 IndexedDB 中的数据(任务列表、工作流状态等)
❌ 错误示例:
// 错误:通过 RPC 从 SW 读取 IndexedDB 数据
async function getAllTasks() {
// 问题:
// 1. postMessage 有 1MB 大小限制,大数据量会失败
// 2. RPC 可能超时(SW 更新、连接断开等)
// 3. 需要复杂的分页和重试逻辑
return await swChannelClient.listTasksPaginated({ offset: 0, limit: 1000 });
}
✅ 正确示例:
// 正确:主线程直接读取 IndexedDB
// 创建专用的 storage reader(如 task-storage-reader.ts)
class TaskStorageReader {
async getAllTasks(): Promise<Task[]> {
const db = await this.getDB();
const store = db.transaction('tasks', 'readonly').objectStore('tasks');
const tasks = await store.getAll();
return tasks.map(convertSWTaskToTask);
}
}
// 使用时,RPC 作为 fallback
async function getAllTasks() {
if (await taskStorageReader.isAvailable()) {
return await taskStorageReader.getAllTasks(); // 直接读取
}
// Fallback to RPC(仅在 IndexedDB 不可用时)
return await swChannelClient.listTasksPaginated(...);
}
原因:
- IndexedDB 是浏览器共享的,主线程和 SW 都可以直接访问
- 直接读取避免了 postMessage 的 1MB 大小限制
- 直接读取更稳定,不受 SW 连接状态影响
- 写操作仍需通过 SW,以保持数据一致性和触发业务逻辑
适用场景:
| 操作类型 | 推荐方式 | 原因 |
|---|---|---|
| 读取任务列表 | 直接读取 IndexedDB | 数据量大,避免 RPC 限制 |
| 读取工作流状态 | 直接读取 IndexedDB | 避免 RPC 超时问题 |
| 创建任务 | 通过 RPC | SW 需执行业务逻辑 |
| 提交工作流 | 通过 RPC | SW 需开始执行 |
| 读取内存状态 | 通过 RPC | 数据只存在于 SW 内存 |
相关文件:
packages/drawnix/src/services/task-storage-reader.tspackages/drawnix/src/services/workflow-storage-reader.tspackages/drawnix/src/services/base-storage-reader.ts
RPC 超时与重连策略
场景: 关键 RPC 调用(如工作流提交)可能因 SW 连接不稳定而超时
❌ 错误示例:
// 错误:直接调用 RPC,没有超时处理和重连逻辑
async submit(workflow: WorkflowDefinition): Promise<void> {
if (!swChannelClient.isInitialized()) {
throw new Error('SWChannelClient not initialized');
}
// 使用默认 120 秒超时,用户等待时间过长
const result = await swChannelClient.submitWorkflow(workflow);
if (!result.success) {
throw new Error(result.error);
}
}
✅ 正确示例:
// 正确:设置合理超时,超时时重连并重试
async submit(workflow: WorkflowDefinition): Promise<void> {
const maxRetries = 2;
const submitTimeout = 15000; // 15 秒超时
const ensureSWReady = async (): Promise<boolean> => {
if (!swChannelClient.isInitialized()) {
try {
await swChannelClient.initialize();
} catch {
return false;
}
}
return true;
};
const submitWithTimeout = async () => {
return Promise.race([
swChannelClient.submitWorkflow(workflow),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), submitTimeout)
)
]);
};
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await ensureSWReady();
const result = await submitWithTimeout();
if (result.success) return;
} catch (error) {
if (error.message === 'timeout' && attempt < maxRetries - 1) {
// 超时时重新初始化连接
await swChannelClient.initialize();
}
}
}
throw new Error('Submit failed after retries');
}
原因:
- SW 可能因更新、重启等原因暂时无法响应
- 默认 120 秒超时让用户等待时间过长
- 15-30 秒是更合理的超时时间
- 超时后主动重连可以解决临时的连接问题
相关文件:
packages/drawnix/src/services/workflow-submission-service.ts
降级路径必须保持与 SW 模式相同的功能行为
场景: 当 Service Worker 不可用时,代码会降级到主线程直接调用 API。降级路径需要保持与 SW 模式相同的功能行为,包括日志记录、错误处理等。
❌ 错误示例:
// 错误:降级路径没有记录 LLM API 日志
async function generateImageDirect(prompt: string): Promise<any> {
const response = await fetch('/images/generations', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
return response.json();
// ❌ 没有记录日志,调试面板看不到这次调用
}
✅ 正确示例:
// 正确:降级路径也要记录日志
import { startLLMApiLog, completeLLMApiLog, failLLMApiLog } from './llm-api-logger';
async function generateImageDirect(prompt: string): Promise<any> {
const startTime = Date.now();
// 开始记录
const logId = startLLMApiLog({
endpoint: '/images/generations',
model: modelName,
taskType: 'image',
prompt,
});
try {
const response = await fetch('/images/generations', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
if (!response.ok) {
const error = await response.text();
failLLMApiLog(logId, {
httpStatus: response.status,
duration: Date.now() - startTime,
errorMessage: error,
});
throw new Error(error);
}
const result = await response.json();
completeLLMApiLog(logId, {
httpStatus: response.status,
duration: Date.now() - startTime,
resultType: 'image',
});
return result;
} catch (error) {
failLLMApiLog(logId, {
duration: Date.now() - startTime,
errorMessage: error.message,
});
throw error;
}
}
原因:
- SW 模式和降级模式都写入同一个 IndexedDB(
llm-api-logs) - 调试面板
/sw-debug.html?tab=llmapi直接从 IndexedDB 读取日志 - 如果降级路径不记录日志,调试时会漏掉这些 API 调用
- 统一的日志记录有助于成本追踪和问题排查
相关文件:
packages/drawnix/src/services/media-executor/llm-api-logger.ts- 主线程日志记录器apps/web/src/sw/task-queue/llm-api-logger.ts- SW 日志记录器packages/drawnix/src/utils/gemini-api/services.ts-generateImageDirectpackages/drawnix/src/services/video-api-service.ts-submitVideoGeneration
分页查询必须在服务端过滤
场景: 实现带过滤条件的分页列表时
❌ 错误示例:
// 错误:后端分页,前端过滤
// 后端
async function getLogsPaginated(page, pageSize) {
const allLogs = await db.getAll();
return {
logs: allLogs.slice((page-1)*pageSize, page*pageSize),
total: allLogs.length
};
}
// 前端
function getFilteredLogs() {
// 只能过滤当前页,总数也不对
return state.logs.filter(l => l.type === filter.type);
}
✅ 正确示例:
// 正确:过滤条件传到后端,先过滤再分页
// 后端
async function getLogsPaginated(page, pageSize, filter) {
let logs = await db.getAll();
// 先过滤
if (filter.type) logs = logs.filter(l => l.type === filter.type);
if (filter.status) logs = logs.filter(l => l.status === filter.status);
// 再分页(基于过滤后的数据)
const total = logs.length;
const totalPages = Math.ceil(total / pageSize);
const pagedLogs = logs.slice((page-1)*pageSize, page*pageSize);
return { logs: pagedLogs, total, page, pageSize, totalPages };
}
// 前端
function onFilterChange() {
// 过滤条件变化时重新加载第一页
loadLogs(1);
}
原因:
- 前端过滤只能处理当前页数据,无法看到其他页符合条件的数据
- 分页信息(总数、总页数)会不准确
- 过滤条件变化时应回到第一页,避免显示空白页
云端同步恢复数据时保留原始 URL
场景: 从云端同步/恢复任务数据到本地时,处理媒体 URL
❌ 错误示例:
// 错误:修改任务的 result.url 为新格式
const processedTasks = tasksToRestore.map(task => {
if (task.result?.url) {
const cacheUrl = `/__aitu_cache__/${taskType}/synced-${task.id}.${extension}`;
return {
...task,
result: {
...task.result,
url: cacheUrl, // 修改了原始 URL
originalUrl: task.result.url,
},
};
}
return task;
});
问题: 画布上引用原始 URL 的元素无法显示,因为 Cache Storage 中只有新 URL 的数据。
✅ 正确示例:
// 正确:保留原始 URL,将数据缓存到原始 URL 位置
const processedTasks = tasksToRestore.map(task => {
if (task.result?.url?.startsWith("/__aitu_cache__/")) {
return {
...task,
result: {
...task.result,
needsMediaDownload: true, // 只添加标记,不修改 URL
},
};
}
return task;
});
// 下载媒体时,缓存到原始 URL 位置
async downloadAndCacheMediaForTask(task) {
const cacheUrl = task.result?.url; // 使用原始 URL
const blob = await downloadFromGist(task.id);
await unifiedCacheService.cacheToCacheStorageOnly(cacheUrl, blob); // 缓存到原始位置
}
原因:
- 画布元素引用的是原始 URL(如
/__aitu_cache__/image/xxx.png) - 修改 URL 会导致画布元素与缓存数据不匹配
- 正确做法是将下载的数据缓存到原始 URL 对应的位置
增量同步优化:使用 checksum 减少网络请求
场景: 实现云端数据同步功能时
❌ 错误示例:
// 错误:每次都上传/下载所有数据
async pushToRemote() {
const localData = await collectSyncData();
const encryptedFiles = await serialize(localData); // 序列化所有数据
await gitHubApiService.updateGistFiles(encryptedFiles); // 上传所有文件
}
✅ 正确示例:
// 正确:增量同步,只传输有变化的数据
async pushToRemote() {
const localData = await collectSyncData();
// 1. 获取远程 manifest 比较
const remoteManifest = await getRemoteManifest();
// 2. 使用 checksum 比较,只上传有变化的画板
const filesToUpdate = {};
for (const [boardId, board] of localData.boards) {
const localChecksum = calculateBoardChecksum(board);
const remoteInfo = remoteManifest?.boards[boardId];
if (!remoteInfo || remoteInfo.checksum !== localChecksum) {
filesToUpdate[boardFile(boardId)] = await encrypt(board);
}
}
// 3. 只更新有变化的文件
if (Object.keys(filesToUpdate).length > 0) {
await gitHubApiService.updateGistFiles(filesToUpdate);
}
}
原因:
- 全量同步会产生大量不必要的网络请求
- 使用 checksum 可以精确识别哪些数据有变化
- 只传输变化的数据可以显著减少带宽和延迟
- manifest/workspace/prompts/tasks 等小文件可以始终更新
同步数据双向合并:下载后自动上传
场景: 从远程下载数据时,需要与本地数据合并,确保双向同步
❌ 错误示例:
// 错误:单向合并,只下载远程有但本地没有的数据
async pullFromRemote() {
const remoteTasks = await fetchRemoteTasks();
const localTaskIds = new Set(localTasks.map(t => t.id));
// 只恢复本地不存在的任务
const tasksToRestore = remoteTasks.filter(t => !localTaskIds.has(t.id));
await restoreTasks(tasksToRestore);
// 问题:本地有但远程没有的数据不会同步到远程
}
✅ 正确示例:
// 正确:双向合并,基于 ID 去重,合并后自动上传
async pullFromRemote() {
const remoteTasks = await fetchRemoteTasks();
const localTaskMap = new Map(localTasks.map(t => [t.id, t]));
// 1. 基于 ID 去重合并,使用 updatedAt 判断保留哪个版本
const tasksToRestore: Task[] = [];
for (const remoteTask of remoteTasks) {
const localTask = localTaskMap.get(remoteTask.id);
if (!localTask) {
// 本地不存在,需要恢复
tasksToRestore.push(remoteTask);
} else if (remoteTask.updatedAt > localTask.updatedAt) {
// 远程更新,使用远程版本
tasksToRestore.push(remoteTask);
}
// 本地版本更新或相同,保留本地版本
}
await restoreTasks(tasksToRestore);
// 2. 合并完成后,自动上传合并后的数据到远程
await uploadMergedTasksToRemote();
}
async uploadMergedTasksToRemote() {
// 收集本地所有数据(包括刚合并的)
const allLocalTasks = getAllCompletedTasks();
const tasksData = { completedTasks: allLocalTasks };
const encrypted = await encrypt(tasksData);
await updateRemoteFile(SYNC_FILES.TASKS, encrypted);
}
原因:
- 单向合并会导致数据不一致:A 设备新增的数据不会同步到 B 设备的远程
- 基于 ID 去重避免重复数据
- 使用
updatedAt时间戳判断保留更新的版本,避免覆盖新数据 - 合并后自动上传确保本地独有的数据也能同步到远程
- 这是实现"最终一致性"的关键步骤
数据安全规范
破坏性操作前必须进行安全检查
场景: 执行删除、覆盖、清空等可能导致数据丢失的操作时
❌ 错误示例:
// 错误:直接执行删除,没有安全检查
async applyRemoteDeletions(toDeleteLocally: string[]) {
for (const boardId of toDeleteLocally) {
await workspaceStorageService.deleteBoard(boardId); // 直接删除
}
}
✅ 正确示例:
// 正确:执行安全检查后再删除
async applyRemoteDeletions(toDeleteLocally: string[]) {
// 1. 执行安全检查
const safetyCheck = performSafetyCheck({
localBoards,
toDeleteLocally,
currentBoardId, // 当前正在编辑的画板
isFirstSync, // 是否首次同步
remoteManifest,
});
// 2. 处理安全检查结果
if (safetyCheck.blockedReason) {
throw new Error(safetyCheck.blockedReason); // 严重错误,阻止执行
}
if (safetyCheck.warnings.length > 0) {
// 有警告,需要用户确认
return { needsConfirmation: true, warnings: safetyCheck.warnings };
}
// 3. 排除被保护的项目后执行删除
const skippedIds = new Set(safetyCheck.skippedItems.map(item => item.id));
const safeToDelete = toDeleteLocally.filter(id => !skippedIds.has(id));
for (const boardId of safeToDelete) {
await workspaceStorageService.deleteBoard(boardId);
}
}
安全检查规则:
| 检查项 | 规则 | 处理方式 |
|---|---|---|
| 当前画板保护 | 正在编辑的画板不能被删除 | 跳过该项,继续其他操作 |
| 新设备保护 | 首次同步不执行任何删除 | 跳过所有删除操作 |
| 批量删除阈值 | 删除超过 50% 数据时警告 | 暂停,要求用户确认 |
| 全部删除阻止 | 不允许删除所有数据 | 阻止执行,显示错误 |
| 空数据保护 | 远程数据异常时不执行删除 | 跳过所有删除操作 |
原因:
- 用户数据是核心资产,误删除可能导致不可恢复的损失
- 同步冲突可能导致意外的批量删除
- 安全检查提供多层保护,宁可同步失败也不能误删数据
不可逆操作需要用户输入确认文字
场景: 执行清空回收站、永久删除等不可恢复的操作时
❌ 错误示例:
// 错误:只用简单的确认对话框
<Button onClick={() => {
if (confirm('确定要清空回收站吗?')) {
emptyRecycleBin();
}
}}>
清空回收站
</Button>
✅ 正确示例:
// 正确:要求输入确认文字
function EmptyRecycleBinDialog({ onConfirm, onCancel }) {
const [confirmText, setConfirmText] = useState('');
const isValid = confirmText === '确认清空';
return (
<Dialog header="清空回收站">
<p>此操作将永久删除回收站中的所有数据,无法恢复。</p>
<ul>
<li>{itemCount.boards} 个画板</li>
<li>{itemCount.prompts} 条提示词</li>
</ul>
<p>请输入 <strong>确认清空</strong> 以继续:</p>
<Input
value={confirmText}
onChange={setConfirmText}
placeholder='输入"确认清空"'
/>
<Button
theme="danger"
disabled={!isValid} // 必须输入正确文字才能点击
onClick={onConfirm}
>
永久删除
</Button>
</Dialog>
);
}
原因:
- 简单的确认对话框容易被用户习惯性点击"确定"
- 输入确认文字强制用户阅读警告内容
- 对于不可恢复的数据操作,额外的确认步骤可以有效防止误操作
使用软删除(Tombstone)支持数据恢复
场景: 实现跨设备同步的删除功能时
❌ 错误示例:
// 错误:直接删除远程文件
async deleteBoard(boardId: string) {
await gitHubApiService.deleteGistFiles([`board_${boardId}.json`]); // 直接删除
delete manifest.boards[boardId]; // 从 manifest 中移除
}
✅ 正确示例:
// 正确:使用 tombstone 标记,保留文件便于恢复
async deleteBoard(boardId: string) {
// 1. 添加删除标记,但不删除文件
manifest.boards[boardId] = {
...manifest.boards[boardId],
deletedAt: Date.now(), // 删除时间戳
deletedBy: deviceId, // 删除设备
};
// 2. 更新 manifest
await updateManifest(manifest);
// 3. 删除本地数据
await workspaceStorageService.deleteBoard(boardId);
}
// 恢复时:移除 tombstone 标记,下载远程文件
async restoreBoard(boardId: string) {
const { deletedAt, deletedBy, ...rest } = manifest.boards[boardId];
manifest.boards[boardId] = rest; // 移除删除标记
const boardData = await downloadBoard(boardId); // 远程文件仍在
await workspaceStorageService.saveBoard(boardData);
}
优势:
- 删除可恢复:用户可以从回收站恢复误删的数据
- 跨设备同步:其他设备同步时能正确识别已删除的项目
- 永久删除可控:用户手动清空回收站时才真正删除远程文件
异步操作不应阻塞 UI 交互
场景: 删除、保存等操作需要同步到远程服务器时
❌ 错误示例:
// 错误:同步操作阻塞了 UI,弹窗要等网络请求完成才关闭
async deleteBoard(id: string): Promise<void> {
// 用户点击确认后,弹窗卡住等待网络请求
const result = await syncEngine.syncBoardDeletion(id); // 阻塞 2-3 秒
if (result.success) {
console.log('Synced to remote');
}
// 本地删除
this.boards.delete(id);
await storage.deleteBoard(id);
}
✅ 正确示例:
// 正确:异步执行同步,不阻塞本地操作
async deleteBoard(id: string): Promise<void> {
// 异步同步到远程(fire-and-forget)
import('./github-sync').then(({ syncEngine }) => {
syncEngine.syncBoardDeletion(id).then((result) => {
if (result.success) {
console.log('Board synced to remote recycle bin');
} else {
console.warn('Failed to sync deletion:', result.error);
}
}).catch(console.warn);
}).catch(console.warn);
// 本地删除立即执行,弹窗立即关闭
this.boards.delete(id);
await storage.deleteBoard(id);
}
原因:
- 用户期望点击"确认"后弹窗立即关闭
- 网络请求可能慢或失败,不应影响本地操作体验
- 远程同步失败时可以在下次同步时重试
关键操作应直接调用,不依赖事件订阅
场景: 需要在某个操作后触发另一个操作(如删除后同步到远程)
❌ 错误示例:
// 错误:依赖事件订阅来触发同步
// 问题:事件订阅可能有时序问题,订阅在事件触发后才建立
// 在 Context 中订阅事件
useEffect(() => {
const subscription = workspaceService.observeEvents().subscribe({
next: (event) => {
if (event.type === 'boardDeleted') {
// 可能收不到事件!因为订阅建立时机不确定
syncEngine.syncBoardDeletion(event.payload.id);
}
}
});
return () => subscription.unsubscribe();
}, []);
// 在 Service 中发出事件
async deleteBoard(id: string) {
this.boards.delete(id);
this.emit('boardDeleted', { id }); // 事件可能没有订阅者
}
✅ 正确示例:
// 正确:在操作中直接调用,保证执行
async deleteBoard(id: string) {
// 直接调用同步逻辑
import('./github-sync').then(({ syncEngine }) => {
syncEngine.syncBoardDeletion(id).catch(console.warn);
});
// 执行本地删除
this.boards.delete(id);
// 事件仅用于 UI 更新等非关键操作
this.emit('boardDeleted', { id });
}
原因:
- RxJS Subject 的事件是同步分发的,但订阅者的建立是异步的
- React useEffect 中的订阅可能在组件渲染后才建立
- 热更新(HMR)可能导致旧的订阅被替换
- 关键业务逻辑不应依赖于"某处可能存在的订阅者"
自定义菜单/按钮组件的焦点样式和键盘导航
场景: 实现自定义菜单、下拉列表等交互组件时
❌ 错误示例:
// 错误:没有处理焦点样式,浏览器会显示默认的蓝色边框
.menu-item {
background-color: transparent;
border: 1px solid transparent;
// 缺少 outline 处理
}
// 错误:菜单没有键盘导航支持
const Menu = ({ children }) => {
return (
<div className="menu">
{children}
</div>
);
};
✅ 正确示例:
// 正确:移除默认焦点边框,用 focus-visible 提供键盘导航反馈
.menu-item {
background-color: transparent;
border: 1px solid transparent;
outline: none; // 移除浏览器默认蓝色焦点边框
&:hover {
background-color: var(--color-surface-primary-container);
}
&:focus-visible {
// 仅键盘导航时显示视觉反馈(鼠标点击不会触发)
background-color: var(--color-surface-primary-container);
}
}
// 正确:完整的键盘导航和无障碍支持
const Menu = ({ children, onClose }) => {
const containerRef = useRef<HTMLDivElement>(null);
const getFocusableItems = useCallback(() => {
return Array.from(
containerRef.current?.querySelectorAll<HTMLButtonElement>(
'button.menu-item:not([disabled])'
) || []
);
}, []);
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
const items = getFocusableItems();
const currentIndex = items.findIndex(item => item === document.activeElement);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
items[(currentIndex + 1) % items.length]?.focus();
break;
case 'ArrowUp':
event.preventDefault();
items[(currentIndex - 1 + items.length) % items.length]?.focus();
break;
case 'Escape':
event.preventDefault();
onClose?.();
break;
}
}, [getFocusableItems, onClose]);
// 菜单打开时自动聚焦第一项
useEffect(() => {
requestAnimationFrame(() => {
getFocusableItems()[0]?.focus();
});
}, []);
return (
<div ref={containerRef} role="menu" onKeyDown={handleKeyDown}>
{children}
</div>
);
};
// MenuItem 添加 role="menuitem"
const MenuItem = ({ onClick, children }) => (
<button className="menu-item" onClick={onClick} role="menuitem">
{children}
</button>
);
关键点:
outline: none移除浏览器默认蓝色焦点边框focus-visible仅在键盘导航时提供视觉反馈,鼠标点击不会触发- 支持 ↑↓ 箭头键循环切换焦点
- 支持 Escape 关闭菜单
- 菜单打开时自动聚焦第一项
- 添加
role="menu"和role="menuitem"支持无障碍
Frame 拖动元素检测时机
场景: Frame 移动时会"吸附"路过的元素,导致不相关元素被一起移动
❌ 错误示例:
// 每次移动都重新检测相交元素
board.afterChange = () => {
if (movingFrameId) {
const frameRectBefore = RectangleClient.getRectangleByPoints(lastFramePoints);
const intersectingElements = board.children.filter((el) => {
return isElementIntersectingRect(el, frameRectBefore);
});
// 移动所有相交的元素
// 问题:Frame 移动过程中会不断"吸附"路过的元素
}
};
✅ 正确示例:
// 只在拖动开始时检测一次
let movingElementIds: Set<string> = new Set();
board.pointerDown = (event: PointerEvent) => {
const selected = getSelectedElements(board);
if (selected.length === 1 && isFrameElement(selected[0])) {
movingFrameId = selected[0].id;
lastFramePoints = [...(selected[0] as PlaitFrame).points];
// 记录拖动开始时与 Frame 相交的元素 ID
movingElementIds.clear();
const frameRect = RectangleClient.getRectangleByPoints(lastFramePoints);
board.children.forEach((el) => {
if (el.id === movingFrameId) return;
if (isFrameElement(el)) return;
if (isElementIntersectingRect(el, frameRect)) {
movingElementIds.add(el.id);
}
});
}
};
board.afterChange = () => {
if (movingFrameId) {
// 只移动预先记录的元素
const elementsToMove = board.children.filter((el) => {
return movingElementIds.has(el.id);
});
// 移动这些元素...
}
};
board.globalPointerUp = (event: PointerEvent) => {
if (movingFrameId) {
movingFrameId = null;
lastFramePoints = null;
movingElementIds.clear(); // 清理记录
}
};
原因:
- 如果每次移动都重新检测相交元素,Frame 移动路径上的所有元素都会被"吸附"进来
- 正确的做法是只在拖动开始时检测一次,记录初始相交的元素 ID
- 移动过程中只移动这些预先记录的元素,不再重新检测
- 拖动结束时清理记录,为下次拖动做准备
适用场景:
- Frame 拖动同步移动内部元素
- 任何需要"容器+内容"联动移动的场景
- 避免移动过程中的意外元素吸附
Plait Selection 类型结构
场景: 使用 Plait 的 Selection 类型进行框选检测时
❌ 错误示例:
// 错误:Selection 没有 ranges 属性
board.isRectangleHit = (element: PlaitElement, selection: Selection) => {
const rect = RectangleClient.getRectangleByPoints(element.points);
const selectionRect = RectangleClient.getRectangleByPoints(selection.ranges[0]);
// 类型错误:类型"Selection"上不存在属性"ranges"
return isRectIntersect(rect, selectionRect);
};
✅ 正确示例:
// 正确:使用 anchor 和 focus 属性
board.isRectangleHit = (element: PlaitElement, selection: Selection) => {
const rect = RectangleClient.getRectangleByPoints(element.points);
const selectionRect = RectangleClient.getRectangleByPoints([
selection.anchor,
selection.focus
]);
return isRectIntersect(rect, selectionRect);
};
原因:
- Plait 的 Selection 类型使用
anchor和focus两个点来表示选区 anchor是选区的起点,focus是选区的终点- 不存在
ranges属性,使用会导致类型错误
相关类型:
Selection.anchor: Point- 选区起点Selection.focus: Point- 选区终点- 用于框选、文本选择等场景
Plait 事件处理器类型匹配
场景: 重写 Plait board 的事件处理器时
❌ 错误示例:
// 错误:dblClick 期望 MouseEvent,但提供了 PointerEvent
board.dblClick = (event: PointerEvent) => {
const point = toViewBoxPoint(board, toHostPoint(board, event.x, event.y));
// 类型错误:不能将类型"(event: PointerEvent) => void"分配给类型"(event: MouseEvent) => void"
};
✅ 正确示例:
// 正确:使用 MouseEvent 类型
board.dblClick = (event: MouseEvent) => {
const point = toViewBoxPoint(board, toHostPoint(board, event.x, event.y));
// 类型匹配,编译通过
};
原因:
- Plait 的不同事件处理器期望不同的事件类型
dblClick期望MouseEvent,而pointerDown/pointerMove/pointerUp期望PointerEvent- 必须使用正确的事件类型,否则会导致类型错误
常见事件类型:
board.dblClick: (event: MouseEvent) => voidboard.pointerDown: (event: PointerEvent) => voidboard.pointerMove: (event: PointerEvent) => voidboard.pointerUp: (event: PointerEvent) => void