Files
TrueGrowth/docs/STARTUP_AND_TOOLBAR_LESSONS.md

19 KiB
Raw Blame History

启动拆包与工具栏状态经验总结

更新日期2026-04-21

这轮最值得记住的规律

1. 启动进度只能有一个“百分比真源”

这轮 loading 卡顿感的根因,不是进度条本身,而是进度来源混杂:

  • HTML 启动壳自己在写阶段百分比
  • 主线程初始化阶段也在写百分比
  • Service Worker 又在回传资源进度

结果就是进度条会“跳”“卡”“倒像是更慢了”。

更稳的做法是:

  • SW 未就绪前,只做短时间平滑模拟,让用户知道系统在工作
  • SW 一旦开始回传进度,就由 SW 接管后半段百分比
  • 其余阶段只更新文案,不再写数值

一句话:文案可以多源,百分比只能单源。

2. 懒加载要区分“触发器”和“内容体”

这轮多个两击问题,本质都和这个边界有关:

  • 可以延后的是抽屉内容、弹窗内容、工具窗口内容
  • 不该延后的是用户第一下要点到的按钮、触发器、壳层容器

如果把触发器本身也放进懒加载链路,第一次点击就可能只完成“挂载按钮/挂载容器”,第二次点击才真正打开内容,用户会直接感知成 bug。

推荐原则:

  • 按钮、抽屉骨架、输入框外壳常驻
  • 重内容、重依赖、低频模块按需挂载
  • 首次点击允许局部 loading但不允许“点击无响应”

3. 依赖编辑器上下文的组件,不能被随意挪出树外

这轮 AI 图片生成useBoard must be used inside <Plait>,本质是把依赖 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-chattool-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-22SW 空闲预取与常驻工具条恢复经验

现象

  • 左侧常驻工具 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 初始化放宽为:controllernavigator.serviceWorker.ready.active 任一可用即可建通道
  • 常驻工具条拆成两段: icon 显示单独启用;点击 icon 时再按需启完整 tool-window runtime
  • toolWindowService 恢复 launcher 状态时优先读取完整工具定义,保证首屏图标和标题完整
  • SWactivate 后自动消费 idle prefetch 默认分组,并把完成状态广播给页面
  • 页面监听 SW_IDLE_PREFETCH_STATUS,但只把它当优化信号;超时后直接放行常驻工具条,避免功能被优化链路反向阻塞
  • idle prefetch 仍用小并发,但不能再裁剪总任务数,否则完成分组永远不闭环

这次最值得固化的规则

  • Service Worker 可用性判断要区分: controller 是“当前页已受控”,ready.active 是“后台已有可用 worker”两者不是一回事
  • 首屏可见能力和重运行时能力要解耦: 先让用户看到、点得到,再做按需增强
  • 预取状态只能作为加速信号,不能作为功能显示前置条件
  • 开发态缺 manifest、生产态资源回源失败都要保证 UI 有可退化路径

排障顺序建议

  1. 先看 navigator.serviceWorker.controllernavigator.serviceWorker.ready.active 是否分叉
  2. 再看显示链路是否被 runtime 初始化、manifest、预取完成状态串联阻塞
  3. 最后才看图标数据源、launcher 恢复、工具定义是否缺字段

2026-04-26SW 关闭场景下的通道初始化超时经验

现象

  • 控制台持续出现 [SWChannelClient] Attempt 4 failed: Error: SW activation timeout
  • 调试状态里 supported: true,但 controller: nullregistration: 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: hiddenpointer-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: hiddenpointer-events: none、必要时配合 opacity
  • 抽屉背景延展可以用同色阴影铺屏,但不要让内容区域跟着铺满,否则会破坏对齐和可读性。
  • 改动右侧面板时,要同步回归 ChatDrawer、ViewNavigation、Minimap、fit-frame 和各类 toolbar-right 抽屉。

回归检查清单

  • 工具栏拖到右边缘:工具栏不出屏,刷新后位置保留。
  • 右侧工具栏打开项目/工具箱/任务抽屉:抽屉向左展开,不被屏幕裁切,背景铺到右侧屏幕边缘。
  • 对话抽屉关闭态:面板不可见且不可点击,只保留把手。
  • 对话抽屉打开态:右侧让出工具栏宽度,背景延伸到屏幕右侧。
  • 对话抽屉打开 + 小地图展开:小地图和缩放控件避开“工具栏 + 对话抽屉”组合宽度。
  • fit-frame工具栏/抽屉在左侧或右侧时Frame 都居中到剩余可视区。