# Opentu 编码规则详解 本文档包含项目中积累的具体编码规则和常见错误案例。这是 `CLAUDE.md` 的详细补充,当需要具体的实现指导时参考本文档。 > **注意**:本文档由 CLAUDE.md 拆分而来,包含详细的错误示例和解决方案。基础编码规范请参考 `docs/CODING_STANDARDS.md`。 --- ## 目录 - [架构与重构规范](#架构与重构规范) - [文件命名规范](#文件命名规范) - [TypeScript 规范](#typescript-规范) - [React 组件规范](#react-组件规范) - [CSS/SCSS 规范](#cssscss-规范) - [Service Worker 规范](#service-worker-规范) - [缓存与存储规范](#缓存与存储规范) - [API 与任务处理规范](#api-与任务处理规范) - [Plait 插件规范](#plait-插件规范) - [UI 交互规范](#ui-交互规范) - [E2E 测试规范](#e2e-测试规范) - [数据安全规范](#数据安全规范) --- ## 架构与重构规范 ### 循环依赖防回归 **场景**: 新增共享类型、服务单例、插件 transform、通用 UI、工具 registry 或 barrel export 时 **核心原则**: - 类型、常量、元数据只能依赖更底层的纯模块 - 通用 UI 不直接 import 全量业务服务,优先接收回调或使用窄 helper - service 之间需要互访时,优先用 runtime bridge,而不是静态双向 import - 插件的纯 transforms/API 与 React 渲染组件分离 - 从 barrel import 前先确认 barrel 没有注册副作用或重组件 re-export 提交前至少跑: ```bash 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 模式**: ```typescript // 错误:为简单的任务管理引入过多抽象层 // 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; save(task: Task): Promise; } // infrastructure/persistence/indexeddb-task-repository.ts class IndexedDBTaskRepository implements TaskRepository { // 只是包装了现有的 taskStorageReader/Writer } // infrastructure/execution/execution-strategy.ts interface TaskExecutionStrategy { execute(task: Task): Promise; } // infrastructure/execution/sw-execution-strategy.ts class SWExecutionStrategy implements TaskExecutionStrategy { // 包装了一个简单的条件判断 } // 结果:30 个新文件,3000+ 行代码,但没有解决实际问题 ``` ✅ **正确示例 - 保持简单**: ```typescript // 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 { const task = { /* ... */ }; await taskStorageWriter.saveTask(task); return task; } async executeTask(task: Task): Promise { // 简单的条件判断,不需要 Strategy 模式 if (shouldUseSWTaskQueue()) { await swTaskQueueService.createTask(task.type, task.params); } else { await taskQueueService.createTask(task.type, task.params); } } } // 结果:清晰、简单、易维护 ``` **判断是否需要抽象层的标准**: 1. **Repository 模式**: - ❌ 不需要:只有一种存储实现(如只用 IndexedDB) - ✅ 需要:需要支持多种存储后端(IndexedDB、LocalStorage、远程 API) 2. **Strategy 模式**: - ❌ 不需要:只是简单的 if/else 判断(如 `if (shouldUseSW)`) - ✅ 需要:有 3+ 种算法需要动态切换,且逻辑复杂 3. **Factory 模式**: - ❌ 不需要:对象创建逻辑简单(如 `{ id, type, status }`) - ✅ 需要:创建逻辑复杂,需要根据类型创建不同的对象 4. **Aggregate Root**: - ❌ 不需要:简单的数据结构,状态转换逻辑简单 - ✅ 需要:复杂的业务规则,需要保证不变性约束 **原因**: - 过度设计会增加代码量和维护成本 - 抽象层会增加理解难度和调试复杂度 - 简单的问题应该用简单的方案解决 - 只在有明确需求时才引入复杂模式 --- ### 删除未使用的"基础设施"代码 **场景**: 创建了"为未来准备"的代码,但实际上没有被使用 **核心原则**: - 代码应该解决当前的实际问题 - 未使用的代码应该删除,不要"为未来预留" - 如果未来真的需要,可以从 Git 历史恢复 ❌ **错误示例 - 创建未使用的事件系统**: ```typescript // services/app-events.ts - 创建了统一事件总线 export function publishEvent(event: AppEvent): void { } export function subscribeToEventType(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 个结果 ``` ✅ **正确做法 - 删除未使用的代码**: ```bash # 检查是否有代码使用 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. **有实际使用**: - ✅ 保留:至少有 1 处代码导入并使用 - ❌ 删除:没有任何地方使用 2. **解决实际问题**: - ✅ 保留:解决了当前存在的问题 - ❌ 删除:只是"可能有用"的基础设施 3. **维护成本**: - ✅ 保留:维护成本低,不会成为负担 - ❌ 删除:需要持续维护,但没有实际价值 **原因**: - 未使用的代码会增加维护负担 - 会让新开发者困惑("这个是干什么的?") - Git 历史可以保存所有删除的代码 - YAGNI 原则:不要为未来可能的需求编写代码 --- ### 重构前先问"解决什么问题" **场景**: 在进行架构重构时,应该先明确要解决的实际问题 **核心原则**: - 重构应该解决具体的痛点,而不是追求"更好的架构" - 先列出当前的实际问题,再设计解决方案 - 验证重构是否真的解决了问题 ❌ **错误示例 - 为了架构而重构**: ```typescript // 问题描述:"代码不够优雅,需要引入 DDD" // 实际情况:代码工作正常,没有明确的痛点 // 重构方案: // - 添加 domain 层(18 个文件) // - 添加 application 层(3 个文件) // - 添加 infrastructure 层(9 个文件) // 结果: // - 新增 30 个文件,3000+ 行代码 // - 原有问题没有解决 // - 引入了新的复杂度 ``` ✅ **正确示例 - 针对问题重构**: ```typescript // 问题 1:类型定义在 3 处重复,需要手动同步 // 解决方案:创建共享类型文件 // 结果:删除 2 处重复定义,统一到 1 个文件 // 问题 2:DDD 层增加了 30 个文件但没有解决实际问题 // 解决方案:删除 DDD 层,回归简单的 interface + service // 结果:删除 30 个文件,减少 3000 行代码 // 验证: // - 类型定义只有 1 处 ✓ // - 代码量减少 ✓ // - 构建和测试通过 ✓ ``` **重构前的检查清单**: 1. **明确问题**: - [ ] 列出当前存在的具体问题 - [ ] 问题是否真的影响开发效率或代码质量 - [ ] 问题是否频繁出现 2. **评估方案**: - [ ] 方案是否直接解决问题 - [ ] 方案是否引入新的复杂度 - [ ] 方案的成本是否合理 3. **验证结果**: - [ ] 原有问题是否解决 - [ ] 是否引入新问题 - [ ] 代码是否更简单(文件数、行数) **原因**: - 架构模式不是目的,解决问题才是 - 好的架构应该让代码更简单,而不是更复杂 - 重构应该有明确的收益,而不是"看起来更好" --- ### 文件命名规范 - **组件**: `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 // 错误:使用数组类型,TypeScript 无法确定长度 const points: [number, number][] = [ [x1, y1], [x2, y2], ]; // 类型错误:类型"[number, number][]"不能赋给类型"[Point, Point]" // 目标仅允许 2 个元素,但源中的元素可能不够 createShape(board, points, shapeType); ``` ✅ **正确示例**: ```typescript // 正确:显式声明为元组类型 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`)基础上添加新值时 ❌ **错误示例**: ```typescript // 错误:直接修改外部库的枚举(无法做到)或使用魔术字符串 import { StrokeStyle } from '@plait/common'; // 无法向 StrokeStyle 添加 'hollow' 值 // 使用字符串字面量会导致类型不兼容 const strokeStyle = 'hollow'; // ❌ 类型不匹配 setStrokeStyle(board, strokeStyle); // 错误:类型 'string' 不能赋给 StrokeStyle ``` ✅ **正确示例**: ```typescript // 正确:创建扩展类型,同时保持与原始枚举的兼容性 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 类型 ❌ **错误示例**: ```typescript // 错误:假设只有 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' } ``` ✅ **正确示例**: ```typescript // 正确: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 语句时 ❌ **错误示例**: ```typescript interface MyInterface { name: string; } const MAX_SIZE = 100; // 错误:import 在变量声明之后 import { someUtil } from './utils'; ``` ✅ **正确示例**: ```typescript import { someUtil } from './utils'; interface MyInterface { name: string; } const MAX_SIZE = 100; ``` **原因**: ESLint 规则 `import/first` 要求所有 `import` 语句必须放在模块最顶部(JSDoc 注释之后),位于任何变量声明、类型定义或其他代码之前。这样做便于快速了解模块的依赖关系,保持代码结构清晰。 #### 类型可推断时移除显式类型注解 **场景**: 变量直接赋值为字面量时 ❌ **错误示例**: ```typescript // 错误:类型可从字面量推断,显式声明是冗余的 private isEnabled: boolean = false; private count: number = 0; private name: string = 'default'; ``` ✅ **正确示例**: ```typescript // 正确:让 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 导入 **场景**: 当枚举既作为类型又作为运行时值使用时 ❌ **错误示例**: ```typescript // 错误:使用 type-only import 导入枚举 import type { TaskType, TaskStatus } from './types'; // 运行时错误:TaskType is not defined const config = { [TaskType.IMAGE]: 10000, // ❌ TaskType 在运行时不存在 [TaskType.VIDEO]: 20000, }; ``` ✅ **正确示例**: ```typescript // 正确:枚举作为值使用时,必须使用普通 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 块(必须有日志) **场景**: 捕获异常后需要记录或处理 ❌ **错误示例**: ```typescript // 错误:静默吞掉错误,调试困难 try { await swChannelClient.getTask(taskId); } catch { return; // 什么都不做 } // 错误:catch 块为空 try { await someOperation(); } catch { // 静默忽略错误 } ``` ✅ **正确示例**: ```typescript // 正确:至少记录 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 和主线程中使用相同逻辑时 ❌ **错误示例**: ```typescript // apps/web/src/sw/index.ts // 错误:SW 中直接导入主线程包 import { sanitizeObject } from '@drawnix/drawnix'; // 会导致打包体积膨胀或循环依赖 ``` ✅ **正确示例**: ```typescript // 正确: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) 错误 ❌ **错误示例**: ```typescript // 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 ``` ```typescript // chat-workflow-client.ts const checkAndSubscribe = () => { // ❌ ES 模块环境不支持 CommonJS require const { swChannelClient: client } = require('./client'); }; // 结果:Uncaught ReferenceError: require is not defined ``` ✅ **正确示例**: ```typescript // 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` 数据库)进行过滤时 ❌ **错误示例**: ```typescript // sw-debug 或其他外部模块读取 SW 数据 const TaskStatus = { COMPLETED: 'COMPLETED', // ❌ 大写 }; const TaskType = { IMAGE: 'IMAGE', // ❌ 大写 VIDEO: 'VIDEO', }; // 过滤已完成任务 - 永远匹配不到! const completedTasks = tasks.filter( task => task.status === TaskStatus.COMPLETED // 实际数据是 'completed' ); ``` ✅ **正确示例**: ```typescript // 正确:使用小写,与 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 枚举定义 #### 共享模块与统一配置模式 **场景**: 多个功能模块有相似逻辑但细节不同时(如宫格图和灵感图的拆分) ❌ **错误示例**: ```typescript // 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); // ... } ``` ✅ **正确示例**: ```typescript // 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); } ``` **原因**: 1. 避免代码重复,便于统一维护 2. 通过配置类型区分行为,而非复制代码 3. 核心模块命名规范:`*-core.ts`(如 `image-split-core.ts`) **相关文件**: - `packages/drawnix/src/utils/image-split-core.ts` - 图片拆分核心模块 #### 工具函数组织与导入 **场景**: 项目中有通用工具函数需要在多个包之间共享 ❌ **错误示例**: ```typescript // 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'; // ❌ 间接导入 ``` ✅ **正确示例**: ```typescript // 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()` #### 避免不必要的函数包装/别名 **场景**: 导入工具函数后,创建了一个简单包装函数 ❌ **错误示例**: ```typescript 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); ``` ✅ **正确示例**: ```typescript 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` 定义的函数时 ❌ **错误示例**: ```typescript // 错误: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(() => { // 重置逻辑 }, []); ``` ✅ **正确示例**: ```typescript // 正确:被依赖的 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 延迟展开) ❌ **错误示例**: ```typescript // 错误:没有清理计时器,可能导致内存泄漏和意外行为 const [open, setOpen] = useState(false);
{ setTimeout(() => setOpen(true), 300); // 计时器没有被追踪 }} > ``` ✅ **正确示例**: ```typescript // 正确:使用 ref 追踪计时器,在离开和卸载时清理 const hoverTimeoutRef = useRef | null>(null); const clearHoverTimeout = useCallback(() => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } }, []); // 组件卸载时清理 useEffect(() => { return () => clearHoverTimeout(); }, [clearHoverTimeout]);
{ clearHoverTimeout(); // 先清除之前的计时器 hoverTimeoutRef.current = setTimeout(() => setOpen(true), 300); }} onPointerLeave={() => { clearHoverTimeout(); // 离开时取消延迟操作 }} onPointerDown={() => { clearHoverTimeout(); // 点击时立即响应,取消延迟 setOpen(true); }} > ``` **关键点**: - 使用 `useRef` 存储计时器 ID(不用 state,避免不必要的重渲染) - `onPointerLeave` 清除计时器(用户离开后取消待执行的操作) - `onPointerDown` 清除计时器(点击时立即响应,不等待延迟) - `useEffect` 清理函数确保组件卸载时清除计时器 #### 单击/双击区分场景的计时器清理 **场景**: 使用 `setTimeout` 延迟单击操作以区分单击和双击时 ❌ **错误示例**: ```typescript // 错误:没有在组件卸载时清理计时器 const clickTimerRef = useRef(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 void>(fn: T, delay: number): T { let timeoutId: ReturnType | null = null; return ((...args: Parameters) => { if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }) as T; } ``` ✅ **正确示例**: ```typescript const clickTimerRef = useRef(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(); }} ``` **原因**: 如果用户在计时器等待期间导航离开页面(组件卸载),计时器回调仍会执行,可能导致: 1. 内存泄漏(闭包引用已卸载组件的状态) 2. React 警告:"Can't perform a React state update on an unmounted component" 3. 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] ); ``` ✅ **正确示例**: ```typescript // 正确:使用 throttle(节流),定时触发更新,平衡响应性和性能 import { throttle } from '@aitu/utils'; // 节流版本的外部回调 const throttledOnChange = useMemo( () => throttle((newConfig: Config) => { onChange?.(newConfig); }, 100), // 100ms 节流 [onChange] ); // 滑块专用的更新函数:立即更新 UI,节流触发外部回调 const updateConfigThrottled = useCallback( (updates: Partial) => { const newConfig = { ...config, ...updates }; setConfig(newConfig); // 立即更新 UI throttledOnChange(newConfig); // 节流触发外部回调 }, [config, throttledOnChange] ); updateConfigThrottled({ scale: Number(e.target.value) })} /> ``` **关键点**: - 内部状态 (`setConfig`) 立即更新,保证滑块 UI 的即时响应 - 外部回调 (`onChange`) 使用 `throttle`(节流),减少昂贵操作的执行频率 - **防抖 vs 节流**: 防抖等用户停止操作后才触发(适合搜索框);节流定时触发(适合滑块) - 节流时间根据操作开销选择:轻量操作 50-100ms,重量操作(SVG/Canvas)100-200ms - 使用 `useMemo` 包装 throttle 函数,避免每次渲染创建新实例 #### React Context 回调中必须使用函数式更新 **场景**: 在 Context 提供的回调函数(如 `openDialog`, `closeDialog`)中更新状态时 ❌ **错误示例**: ```typescript // 错误:使用闭包中的 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 也被关闭了! ``` ✅ **正确示例**: ```typescript // 正确:使用函数式更新,确保始终使用最新的状态 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` 可能导致相关状态不同步 ❌ **错误示例**: ```typescript // ViewerToolbar.tsx - 直接调用 setMode // UnifiedMediaViewer.tsx - 传递底层 setMode // 结果:模式变成 'edit',但 editingItem 仍为 null // 后续保存时 editingItem 为 null,无法正确覆盖原图 ``` ✅ **正确示例**: ```typescript // 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]); ``` **原因**: - 当多个状态需要联动更新时,直接暴露底层的单一状态更新函数容易导致状态不一致 - 应该封装成一个函数,确保所有相关状态同步更新 - 这在模式切换、打开/关闭弹窗等场景中尤其重要 #### 传递 React 组件作为 prop 时必须实例化 **场景**: 将 React 组件作为 `icon` 或其他 prop 传递给子组件时 ❌ **错误示例**: ```typescript // 错误:传递组件函数本身,而不是 JSX 实例 import { BackgroundColorIcon } from './icons'; const icon = !hexColor ? BackgroundColorIcon : undefined; // 子组件中渲染时: // {icon} → React 警告 "Functions are not valid as a React child" ``` ✅ **正确示例**: ```typescript // 正确:传递 JSX 实例 import { BackgroundColorIcon } from './icons'; const icon = !hexColor ? : undefined; // 子组件中渲染时: // {icon} → 正常渲染 ``` **原因**: React 组件本质上是函数,直接将函数作为子元素传递会导致 React 警告。需要调用组件(``)生成 JSX 元素后再传递。 #### 内联 style 的 undefined 值会覆盖 CSS 类样式 **场景**: 当需要条件性地应用内联样式,同时使用 CSS 类作为备选样式时 ❌ **错误示例**: ```typescript // 错误:style 对象中的 undefined 值会覆盖 CSS 类的 background