# 启动拆包与工具栏状态经验总结 更新日期:2026-04-21 ## 这轮最值得记住的规律 ### 1. 启动进度只能有一个“百分比真源” 这轮 loading 卡顿感的根因,不是进度条本身,而是进度来源混杂: - HTML 启动壳自己在写阶段百分比 - 主线程初始化阶段也在写百分比 - Service Worker 又在回传资源进度 结果就是进度条会“跳”“卡”“倒像是更慢了”。 更稳的做法是: - `SW` 未就绪前,只做短时间平滑模拟,让用户知道系统在工作 - `SW` 一旦开始回传进度,就由 `SW` 接管后半段百分比 - 其余阶段只更新文案,不再写数值 一句话:文案可以多源,百分比只能单源。 ### 2. 懒加载要区分“触发器”和“内容体” 这轮多个两击问题,本质都和这个边界有关: - 可以延后的是抽屉内容、弹窗内容、工具窗口内容 - 不该延后的是用户第一下要点到的按钮、触发器、壳层容器 如果把触发器本身也放进懒加载链路,第一次点击就可能只完成“挂载按钮/挂载容器”,第二次点击才真正打开内容,用户会直接感知成 bug。 推荐原则: - 按钮、抽屉骨架、输入框外壳常驻 - 重内容、重依赖、低频模块按需挂载 - 首次点击允许局部 loading,但不允许“点击无响应” ### 3. 依赖编辑器上下文的组件,不能被随意挪出树外 这轮 `AI 图片生成` 报 `useBoard must be used inside `,本质是把依赖 `Plait` 上下文的对话框,移到了错误的挂载层级。 经验是: - 凡是依赖 `useBoard()`、`useDrawnix()`、选择态、画布上下文的组件 - 在做延迟加载、拆层、抽离壳层时 - 先确认它依赖的 Provider / Board Context 还在不在上游 尤其是: - 对话框懒加载 - 抽屉懒加载 - “核心画布壳层 / 延后功能层”拆分 如果组件依赖的是“当前画布实例”,那它的懒加载位置必须仍在画布上下文树内。 ### 4. `setState(updater)` 里不要顺手改别的面板状态 左侧“项目 / 工具箱 / 任务面板”要点两下的问题,根因不是点击事件丢了,而是 toggle 逻辑写成了: - 在 `setState(prev => ...)` 的 updater 里 - 顺手调用 `closeAllDrawers()` - 于是一次点击里混进了多份状态更新 这种写法在互斥面板场景里很容易让首击状态被冲掉。 更稳的方式是显式顺序化: 1. 先判断目标当前是否已打开 2. 如果要打开,先关闭其它互斥面板 3. 最后再打开目标面板 一句话:updater 里只算当前值,不做跨状态副作用。 ### 5. 左侧常驻工具栏的顺序,不能跟“最近激活顺序”绑定 这轮另一类体验问题是:左侧常驻工具点一下就换位。 根因不是图标组件,也不是 `selected` 高亮,而是工具栏数据源按 `activationOrder` 排序: - 工具一打开 / 一恢复,激活顺序就刷新 - 工具栏再按这个顺序重排 - 用户看到的就是“按钮自己跑了” 对常驻工具栏来说,用户心智更接近“固定栏位”,不是“最近使用列表”。 所以更合理的规则是: - pinned 工具始终保持既有栏位顺序 - 打开后只替换本栏位的状态 - 非 pinned 的临时实例再单独排到后面 一句话:常驻栏看重空间记忆,不看重最近激活。 ### 6. 版本更新后的懒加载 chunk 失效,要有“一次性自恢复” 这次 `DrawnixDeferredRuntime` 的报错: - `Failed to fetch dynamically imported module` 本质不是业务逻辑错误,而是用户页面停留在旧版本,部署后旧 HTML / 旧运行时还在引用已经失效的 hash chunk。 这类问题的特点是: - 用户刷新前一切正常 - 一旦触发某个 `React.lazy()` 模块才爆 - 报错看起来像功能坏了,实际是版本切换窗口里的静态资源失配 更稳的处理方式不是把所有懒加载都改成手写 loader,而是补一层轻量兜底: - 在 `ErrorBoundary` 里识别动态导入 / chunk 失效错误 - 按“当前版本 + 当前模块”做一次性重试标记,避免死循环 - 自动刷新一次,并带时间戳参数绕过旧缓存 - 应用成功启动后再清理恢复参数,避免 URL 污染 一句话:发布后 chunk 失效不是“要不要刷新”的问题,而是“要不要帮用户只刷新一次并避免循环”的问题。 ## 这轮适合沉淀成默认规则的做法 - 启动进度:前半段可模拟,后半段由资源进度单源接管 - 懒加载:只懒内容,不懒触发器 - 组件拆层:先确认上下文依赖,再移动挂载位置 - 互斥面板:显式开关,不在 updater 内联副作用 - 常驻工具栏:固定顺序优先于最近激活 - 版本切换:懒加载 chunk 失效时自动恢复一次,但必须有防循环保护 ## 后续开发检查清单 - 这个 loading 百分比现在到底是谁在写? - 这个入口第一次点击,是打开内容,还是只完成挂载? - 这个懒加载组件是否依赖画布 / Provider 上下文? - 如果新版本刚发布,这个懒加载失败是不是“旧页面引用失效 chunk”而不是业务异常? - 这个 toggle 是否在 updater 里偷偷改了别的 state? - 这个工具栏是“固定栏位”还是“最近使用列表”?排序规则是否和用户心智一致? ## 一句话总结 这轮真正值钱的经验不是“把 bug 修掉了”,而是明确了 6 条边界: - 百分比单源 - 触发器常驻 - 上下文不丢 - toggle 不串副作用 - 常驻栏位不乱跳 - 失效 chunk 只自恢复一次 --- ## 2026-05-17:首屏壳层拆分与 chunk 守门经验 ### 现象 - 首屏慢不只来自代码体积,也可能来自运行时静态环和 chunk 归属错误。 - 这轮真正卡住构建的,不是业务组件本身,而是 `ai-chat` 和 `tool-windows` 之间被常量文件、服务入口、懒加载边一起拉成了静态回边。 - 纯工具函数如果和会反向依赖 `data/image` 的运行时模块放在同一个文件里,循环会在源码层先炸,然后再在 chunk 层放大。 ### 架构变更 - `apps/web/src/main.tsx` 只保留最早期恢复与 bootstrap 入口,后续应用初始化下沉到 `bootstrap.tsx`。 - `Drawnix` 拆成首屏壳层和延后功能层,任务恢复、自动插入、字体/视频恢复等副作用交给 idle 启动器。 - 动态导入失败恢复独立到 `lazy-asset-recovery.ts`,避免首屏入口顺手背上重恢复逻辑。 - 图片自然尺寸这类轻量能力单独下沉到纯工具模块,`utils/image.ts` 只保留导出和编辑逻辑。 - `tool-ids.ts` 这类纯常量不能被目录级 `tool-windows` 规则误收编,否则很容易把轻量调用侧拖回重 chunk。 - 构建后校验不再只看入口 HTML,还要看入口静态依赖链、chunk SCC 和首屏 500KB 预算。 ### 经验 - 先修运行时循环,再谈 chunk 瘦身。 - manualChunks 是分包守门,不是架构补丁。 - 纯常量、纯工具、纯类型文件尽量留在低层共享入口,别让它们进入重运行时桶。 - 首屏建议目标 200-300KB 未压缩,500KB 作为硬上限。 - 构建校验脚本最好同时守三件事:入口链、chunk 环、首屏体积。 - 当环只剩纯常量误入重 chunk 时,先给低层入口加分包例外,再考虑继续拆模块。 ### 复盘清单 - 这个文件是不是既做纯工具,又回引了运行时服务? - 这个常量是不是被错误分到重 chunk 里了? - 这个懒加载边界是不是只该延后内容,不该拖延触发器? - 构建守门脚本有没有把“能跑”升级成“能跑且不超预算”? 后面继续做启动性能和工具系统时,只要这 6 条不破,很多体验问题都会提前消失。 --- ## 2026-04-22:SW 空闲预取与常驻工具条恢复经验 ### 现象 - 左侧常驻工具 icon 只有在点开工具抽屉后才出现 - `SW` 日志显示已经激活,但页面侧 `SWChannelClient` 多次初始化超时 - 开发态 `idle-prefetch-manifest.json` 缺失,`prefetchIdleGroups` 直接中止 ### 根因 - 页面把“有 `controller`”误当成“有可用 `Service Worker`”,但实际存在 `ready.active` 已激活、页面仍未被 controller 接管的窗口期 - 常驻工具条显示链路和完整 `tool-window runtime` 启用链路绑死,导致“显示 icon”也被重运行时阻塞 - UI 又额外依赖 `tool-windows` 分组预取完成信号,开发态 manifest 缺失时就会一直卡住 - `idle prefetch` 单次跑批如果人为截断,完成状态就会长期失真,最后只能靠超时兜底 ### 最终方案 - `SWChannelClient` 初始化放宽为:`controller` 或 `navigator.serviceWorker.ready.active` 任一可用即可建通道 - 常驻工具条拆成两段: icon 显示单独启用;点击 icon 时再按需启完整 `tool-window runtime` - `toolWindowService` 恢复 launcher 状态时优先读取完整工具定义,保证首屏图标和标题完整 - `SW` 在 `activate` 后自动消费 idle prefetch 默认分组,并把完成状态广播给页面 - 页面监听 `SW_IDLE_PREFETCH_STATUS`,但只把它当优化信号;超时后直接放行常驻工具条,避免功能被优化链路反向阻塞 - `idle prefetch` 仍用小并发,但不能再裁剪总任务数,否则完成分组永远不闭环 ### 这次最值得固化的规则 - `Service Worker` 可用性判断要区分: `controller` 是“当前页已受控”,`ready.active` 是“后台已有可用 worker”,两者不是一回事 - 首屏可见能力和重运行时能力要解耦: 先让用户看到、点得到,再做按需增强 - 预取状态只能作为加速信号,不能作为功能显示前置条件 - 开发态缺 manifest、生产态资源回源失败,都要保证 UI 有可退化路径 ### 排障顺序建议 1. 先看 `navigator.serviceWorker.controller` 和 `navigator.serviceWorker.ready.active` 是否分叉 2. 再看显示链路是否被 runtime 初始化、manifest、预取完成状态串联阻塞 3. 最后才看图标数据源、launcher 恢复、工具定义是否缺字段 --- ## 2026-04-26:SW 关闭场景下的通道初始化超时经验 ### 现象 - 控制台持续出现 `[SWChannelClient] Attempt 4 failed: Error: SW activation timeout` - 调试状态里 `supported: true`,但 `controller: null`、`registration: null` - 本地开发默认关闭 SW 或 URL 带 `?sw=0` 时仍然触发了 `swChannelClient.initialize()` ### 根因 - 页面只用“浏览器支持 `serviceWorker`”判断是否启动 SW 诊断链路,没有复用真正的 SW 启用开关 - `sw-console-capture` 为了尽早捕获日志,会主动初始化 `swChannelClient`,结果在 SW 未注册时反复等待不存在的 `controllerchange` - SW 通道自身没有识别 `?sw=0`,其他调用点即使绕过主入口,也可能重新进入初始化重试 - 日志队列在通道不可用时只进不出,虽然单条较小,但长期打开页面会形成不必要内存占用 ### 固化规则 - SW 相关入口必须共享同一个开关语义: 生产和本地调试默认启用;`?sw=0` 显式关闭;本地默认启动命令必须同步 watch 最新 `sw.js` - 调试、崩溃、控制台捕获这类旁路能力,不能因为“想诊断 SW”而绕过 SW 开关主动拉起通道 - `SWChannelClient.initialize()` 自身也要兜底识别 `?sw=0`,避免未来新增调用点造成重复超时 - 离线日志队列必须有容量上限,通道不可用时丢弃最旧项,不能无限积压 - 控制台捕获要过滤 `[SWChannelClient]`、`[ServiceWorkerChannel]` 这类自身通道日志,避免错误日志再次驱动初始化与上报 ### 排障顺序建议 1. 先看当前 URL 是否带 `?sw=0`,以及本地是否正在 watch 最新 `sw.js` 2. 再看是否存在 `registration`;如果没有注册,不要继续等 `controllerchange` 3. 最后排查是谁调用了 `swChannelClient.initialize()`,优先检查控制台捕获、崩溃上报、调试面板等旁路链路 --- ## 2026-04-27:左侧浮岛工具栏与抽屉联动经验 ### 现象 - 用户浏览器或系统存在左侧边缘标签时,固定贴边工具栏容易误触冲突。 - 直接把工具栏整体外移虽然能避让,但看起来像布局 bug,破坏“贴边悬浮岛”的原始观感。 - 将工具栏改成可拖动后,拖动过程中任务队列抽屉会被关闭态动画带出来。 - 任务队列抽屉从屏幕边缘大幅滑入,动画过重;抽屉打开后,左侧画布底色和抽屉底色割裂。 - 顶部拖动把手、底部固定按钮区如果只改父容器圆角,局部背景仍会把圆角视觉盖平。 ### 根因 - “避让侧边标签”是用户个体环境问题,不适合用全局默认外移解决。 - 工具栏位置、抽屉位置、提示浮层位置、画布 fit-frame 避让如果各自写死宽度,拖动后必然错位。 - 关闭态抽屉的 `transform` 依赖工具栏右边界变量时,拖动工具栏会触发关闭态抽屉过渡,形成“被拖出来”的错觉。 - 任务队列抽屉和工具箱共用基础抽屉,但任务队列内容更重,应该用更轻的专属 motion。 - 抽屉只从工具栏右侧开始绘制时,工具栏左侧仍露出画布底色;抽屉色和画布色差会被用户感知成断层。 ### 最终方案 - 默认仍贴屏幕左边缘,保持悬浮岛观感;只提供顶部拖动把手,让需要避让的用户手动拖入内侧。 - 工具栏横向位置用 `localStorage` 轻量持久化,双击把手复位到默认贴边位置。 - 统一维护 `--aitu-toolbar-left` 和 `--aitu-toolbar-right-edge`,抽屉、提示、fit-frame 都读同一套右边界。 - 拖动过程中只更新 CSS 变量和 DOM style,拖动结束后再落 React state,减少高频重渲染。 - 关闭态抽屉从 `translateX(-100%)` 隐藏,打开态再平移到工具栏右边界;拖动期间给根节点加 `aitu-toolbar-dragging` 禁用抽屉 transition。 - 任务队列抽屉使用轻量淡入: `160ms transform + 120ms opacity`,关闭态保持 `visibility: hidden` 和 `pointer-events: none`。 - 抽屉打开时用同色 `box-shadow` 向左铺到屏幕边缘,工具栏 z-index 高于抽屉,让工具栏仍像浮在抽屉上。 - 工具栏底部固定区也要继承底部圆角,避免底部按钮区背景把父容器圆角盖平。 ### 固化规则 - 默认位置优先保留产品最自然的视觉形态;兼容特殊环境时优先给用户可调能力,不要把所有用户都推到次优默认。 - 可拖动 UI 的高频 pointer move 不要每帧依赖 React state;能用 CSS 变量同步的位置,优先走 CSS 变量。 - 所有依赖工具栏边界的组件必须共享同一来源,不能散落硬编码宽度。 - 隐藏态元素如果跟随一个高频变化变量,要么禁用 transition,要么让隐藏态不依赖这个变量。 - 抽屉“内容从哪里开始”和“背景铺到哪里”可以分离:内容对齐工具栏,背景可延展到屏幕边缘。 - tooltip 只负责展示提示,不应接收 pointer events 挡住工具按钮点击。 - 改圆角时要检查内部固定区域、底部区域、滚动区域背景是否覆盖父容器弧度。 ### 回归检查清单 - 默认桌面视口:工具栏 `left = 0`,右边界 CSS 变量等于工具栏实际右边界。 - 拖动把手:拖动时任务抽屉不外露,松手后位置持久化,双击可复位。 - 任务队列:首次打开有轻量动画,关闭态不可见且不可点击。 - 工具箱 / 项目抽屉 / 任务队列:打开后左边缘贴齐工具栏右边界。 - 抽屉打开后:工具栏左侧到屏幕边缘为抽屉同色背景,工具栏仍在上层。 - 移动端:不显示拖动把手,默认收起形态不变。 --- ## 2026-04-27:右侧停靠工具栏与双向抽屉经验 ### 现象 - 工具栏只允许拖到左侧内缘时,右手用户或右侧工作流无法获得同等避让能力。 - 工具栏拖到右侧后,项目/工具箱/任务抽屉如果仍向右展开,会被屏幕边界截断。 - 对话抽屉、小地图、缩放控件如果只按“右屏幕边缘”定位,会被右侧工具栏遮挡。 - 对话抽屉收起态如果只平移,不做 `visibility` / `pointer-events` 隐藏,右侧仍会露出面板边缘。 - 抽屉内容对齐工具栏后,抽屉背景没有铺满到屏幕边缘时,会出现画布色和抽屉色断层。 ### 根因 - 工具栏位置从“左边界变量”升级为“双侧停靠语义”后,依赖它的浮层也必须知道当前停靠方向。 - 右侧停靠时,遮挡区域不是工具栏右边界,而是 `viewportWidth - toolbarLeft`。 - 抽屉宽度、抽屉展开方向、导航控件避让、fit-frame 可视区计算,不能各自用硬编码边距。 - 关闭态元素跟随工具栏位置时,仅靠 `transform` 容易在边缘露出残影或接收交互。 ### 最终方案 - 工具栏可拖到屏幕最右侧,超过屏幕中线后在根节点标记 `aitu-toolbar-dock-right`。 - 继续用轻量 CSS 变量同步布局,不新增配置实体: `--aitu-toolbar-left`、`--aitu-toolbar-right-edge`、`--aitu-toolbar-right-dock-width`、`--aitu-toolbar-right-avoidance`、`--aitu-toolbar-side-panel-max-width`。 - 右侧停靠时,项目/工具箱/任务抽屉向左展开,关闭态从屏幕外隐藏,打开态左边缘贴齐工具栏左侧。 - 右侧停靠的抽屉用同色 `box-shadow` 向屏幕边缘延展背景,内容对齐和背景铺满分离处理。 - `SideDrawer` 的 resize 方向按停靠侧切换;右侧停靠时拖左边缘调整宽度。 - `ChatDrawer` 右侧停靠时让出工具栏宽度,打开态背景铺到屏幕右边缘,关闭态使用 `opacity + visibility + pointer-events` 彻底隐藏。 - `ViewNavigation` / 小地图同时避让工具栏占位和打开的对话抽屉;fit-frame 按左右遮挡分别计算可视区。 - 顶部拖拽把手默认态进一步弱化,只在 hover、focus、dragging 时增强。 ### 固化规则 - 可拖动悬浮工具栏一旦支持跨屏停靠,所有依赖边界的组件都要读统一 CSS 变量,不再假设工具栏永远在左侧。 - 判断抽屉展开方向应基于工具栏中心点所在半屏,而不是用户最后一次拖动方向。 - 右侧停靠时,导航控件的避让值要叠加工具栏宽度和已打开抽屉宽度,避免小地图被抽屉压住。 - 关闭态抽屉必须同时处理视觉隐藏和交互隐藏:`visibility: hidden`、`pointer-events: none`、必要时配合 `opacity`。 - 抽屉背景延展可以用同色阴影铺屏,但不要让内容区域跟着铺满,否则会破坏对齐和可读性。 - 改动右侧面板时,要同步回归 ChatDrawer、ViewNavigation、Minimap、fit-frame 和各类 toolbar-right 抽屉。 ### 回归检查清单 - 工具栏拖到右边缘:工具栏不出屏,刷新后位置保留。 - 右侧工具栏打开项目/工具箱/任务抽屉:抽屉向左展开,不被屏幕裁切,背景铺到右侧屏幕边缘。 - 对话抽屉关闭态:面板不可见且不可点击,只保留把手。 - 对话抽屉打开态:右侧让出工具栏宽度,背景延伸到屏幕右侧。 - 对话抽屉打开 + 小地图展开:小地图和缩放控件避开“工具栏 + 对话抽屉”组合宽度。 - fit-frame:工具栏/抽屉在左侧或右侧时,Frame 都居中到剩余可视区。