本地变更日志

本地变更日志

目录

  1. AI 穿版功能模块新增
  2. ESLint 报错修复
  3. AI 视频功能新增
  4. AI穿版/AI视频 Tab 导航架构
  5. Tab 交互 Bug 修复合集
  6. AIChangeClothes 细节补全
  7. 设计稿对比检查与修复
  8. API接口对接 — Mock替换为真实后端接口
  9. Bug修复:推荐穿版模特选中后不应消失
  10. Bug修复:模特选择弹窗第7张图片空白/破裂(HEIC格式不被浏览器支持)
  11. Bug修复:视频总进度大圆环与个人进度缩略图数值相同
  12. 视频体验三连优化:无黑屏切换 + 缩略图 + 多选音乐
  13. Bug修复:图片生成总进度低于单张缩略图 + 时间估算不动
  14. Bug修复:视频缩略图 spinner 滑动问题 + 圆环内嵌百分比
  15. 新功能:"我的模特"弹窗 — 历史模特管理 + 上传新模特
  16. 新功能:穿版模特弹窗按 categoryId 分组展示
  17. 图片渐进式渲染优化 — CSS Blur-up + 渐进式图片格式原理
  18. 项目架构技术盘点 — 10 个值得深入研究的技术模式
  19. 深度踩坑与设计取舍 — 中级前端工程师的进阶补充
  20. 项目如何跑起来 — Node 版本与启动环境排查
  21. Bug修复:荣耀/小米手机拍照入口异常 —— <input type="file"> 在移动 WebView 的深度差异

1. AI 穿版功能模块新增

一句话结论

新增完整的 AI 穿版功能,支持单款/套装/单款多色三种上传模式,用户可选择模特并调整细节后生成 AI 换装效果。

为什么这样设计

  • 三种上传模式:覆盖不同商家的货品类型——单款单图、套装上下装分离、同款多色批量展示
  • 模特选择弹窗:用 bottom sheet 形式在有限屏幕空间内提供多维度筛选(服装类型/风格/元素)
  • 调整细节:大类单选 + 小选项多选的交互,平衡了参数灵活性和操作复杂度

数据流 / 执行流程

用户上传服装图
   │
   ▼
convertFileToArrayBuffer → Blob → uploadFile → 获取 docId
   │
   ▼
写入 state(uploadedImages / suitTop / suitBottom)
   │
   ▼
选择模特 → 写入 selectedModel
   │
   ▼
调整细节(可选)→ 写入 selectedOptions
   │
   ▼
点击「一键穿版」→ createChangeClothesTaskApi
   │
   ▼
navigate('/ai_change_clothes/result/:taskId')
   │
   ▼
结果页轮询任务状态(3s 间隔)→ 展示 resultImages

相关代码位置

文件 用途
src/pages/AIChangeClothes/index.tsx 主创建页(上传/模特选择/调整细节)
src/pages/AIChangeClothes/index.scss 主创建页样式
src/pages/AIChangeClothes/components/ModelSelectPopup/index.tsx 模特选择弹窗
src/pages/AIChangeClothes/components/ImageRulesPopup/index.tsx 图片规则弹窗
src/pages/AIChangeClothesResult/index.tsx 结果页(轮询 + 视频生成)
src/api/index.ts:449-524 AI 穿版相关 API(当前使用 mock 数据)
src/state/mock.ts:420-510 Mock 数据定义
src/route.tsx:132-143 路由配置

关键实现细节

上传模式切换时的清理逻辑AIChangeClothes/index.tsx:69-80):

const onUploadModeChange = (mode: UploadMode) => {
    if (mode === uploadMode) return;
    // 清理旧模式的图片(释放 URL 避免内存泄漏)
    uploadedImages.forEach((img) => URL.revokeObjectURL(img.blobUrl));
    suitTop && URL.revokeObjectURL(suitTop.blobUrl);
    suitBottom && URL.revokeObjectURL(suitBottom.blobUrl);
    setUploadedImages([]);
    setSuitTop(null);
    setSuitBottom(null);
    setUploadMode(mode);
};

任务提交时的参数组装AIChangeClothes/index.tsx:193-218):

const onSubmit = async () => {
    if (!selectedModel) return message.warning('请选择穿版模特');

    let imageDocIds = '';
    if (uploadMode === 'single' || uploadMode === 'multiColor') {
        if (!uploadedImages.length) return message.warning('请上传服装图片');
        imageDocIds = uploadedImages.map((img) => img.docId).join(',');
    } else {
        if (!suitTop) return message.warning('请上传上装图片');
        if (!suitBottom) return message.warning('请上传下装图片');
        imageDocIds = `${suitTop.docId},${suitBottom.docId}`;
    }

    const { data } = await createChangeClothesTaskApi({
        imageDocIds,
        modelId: selectedModel.id,
    });
    navigate(`/ai_change_clothes/result/${data.val}`);
};

面试角度

面试官可能怎么问

"你这个 AI 穿版功能的上传流程是怎么设计的?如何保证图片上传的可靠性?"

标准回答框架

  1. 结论:三种模式(单款/套装/单款多色)共享同一套上传逻辑,用 mode 区分状态管理
  2. 原理:图片先转 ArrayBuffer→Blob→FormData,再用 uploadFile 上传获取 docId
  3. 取舍:单款多色限制最多 5 张,平衡展示效果和服务器负载
  4. 实际经验:切换模式时主动 revokeObjectURL 避免内存泄漏

面试话术

"AI 穿版功能我设计了三种上传模式:单款、套装和单款多色。核心思路是用一个 mode 状态管理不同的 UI 渲染和数据收集。图片上传流程是先转 ArrayBuffer 再包成 Blob,用 FormData 传给后端拿 docId。提交任务时,单款/多色模式把所有 docId 逗号分隔,套装模式用 ${topId},${bottomId} 的格式。另外有个细节是切换模式时会主动释放之前创建的 blobUrl,防止内存泄漏。"


最后更新:2026-03-31


2. ESLint 报错修复

现象

src/utils/request.ts
  Line 4:9:  'AUDIT_ENV' is defined but never used

src/api/index.ts
  Line 449:1:  Import in body of module; reorder to top

排查思路

  1. request.tsAUDIT_ENV 是之前导入但从未使用的常量
  2. api/index.ts 的 import 在文件中间(第 456 行),违反了 import/first 规则

根因

  • request.ts: 之前从 ../config/network 导入了 AUDIT_ENV,但实际代码中从未使用
  • api/index.ts: 在添加 AI 穿版接口时,直接把 import 写在了接口注释下方,而不是文件顶部

修复方案

request.ts — 删除未使用的 import:

- import {AUDIT_ENV} from "../config/network";

api/index.ts — 将 import 移到文件顶部:

  // 顶部 imports
+ import {
+     CCModel,
+     DetailCategory,
+     MOCK_CC_MODELS,
+     MOCK_CC_TASK_RESULT,
+     MOCK_DETAIL_CATEGORIES,
+ } from 'state/mock';

  // ... 其他代码 ...

  // AI 穿版 接口
- import { ... } from 'state/mock';  // 删除这行

经验提炼

添加新接口时,如果有依赖的 types/mock 数据,应该:

  1. 先把 import 加到文件顶部的已有 import 块中
  2. 不要在文件中间写 import(即使 TypeScript 能编译通过,ESLint 会报错)

原理深挖——ESLint 两类错误的本质区别

两条错误背后是两种不同的 ESLint 规则类别:

1. no-unused-vars(未使用的变量)
   属于:代码卫生规则(correctness / code quality)
   危害:编译能过但代码臃肿、维护时有假信号("这个 import 在哪用?")
   修复:删除或标记故意保留(下划线前缀)

2. import/first(import 必须在文件顶部)
   属于:模块加载规则(module correctness)
   危害:实际功能可能出错——JS 的 import 是 hoisted 的,写在中间会被提升
         读代码的人看不出依赖关系
   修复:所有 import 放文件顶部

两条都是"不影响功能但影响质量"的规则
ESLint 报警的价值就是让这些"看不见的问题"变得可见

为什么 import 必须在顶部——JS 模块机制

ES Module 的 import 是"静态"的:
  ├ 编译时解析,不是运行时
  ├ 无论写在哪行,模块都会在"开始执行前"全部加载
  └ 所以"在文件中间写 import"在运行时和"写在顶部"完全一样

那为什么 ESLint 要求必须在顶部?
  1. 可读性:一眼看到文件依赖
  2. 一致性:所有文件结构相同,代码走读更快
  3. 避免误导:看到 line 449 的 import 会以为它只在这行之后生效
  4. 工具友好:部分构建工具对非顶部 import 处理不完美

这是"代码规范优于最小合理性"的典型——即便 JS 允许,规范也禁止

三层对比——对待 Lint 错误的态度

❌ 初级:有 lint 错误就 eslint-disable 忽略
   → 规则形同虚设,代码质量失控

⚠️ 中级:修 lint 错误但不理解规则背后的逻辑
   → 机械修复,不会举一反三

✅ 资深:
   1. 修 lint 错误
   2. 理解规则背后的目的(可读性 / 正确性 / 性能 / 安全)
   3. 如果规则真不合理,推动团队共同修改 eslint config 而不是 disable
   4. 知道哪些规则是"强约束"(必守)哪些是"建议"(可根据项目调整)

设计模式识别——Lint 作为"可执行的规范"

"规范"的演进路径:
  Level 1:文档约定(README / Wiki)
    → 容易被忽略,新人不看
  
  Level 2:Code Review 人工把关
    → 依赖 reviewer 的记忆,漏查率高
  
  Level 3:Lint 规则强制
    → 工具执行,零漏查
    → 本次修复对应这一层
  
  Level 4:CI 阻断
    → Lint 失败 PR 不能合并
    → 规则成为硬约束

规范的生命力 = "从文档 → 工具" 的下沉

ESLint 的本质:把"约定"转化为"可执行的规则"。没有 Lint 的项目,任何规范都是"一纸空文"。

系统思维——如何让 Lint 规则真正生效

配置:
  .eslintrc 定义规则集
  通常继承官方预设(如 eslint-config-next)+ 项目自定义

执行:
  ├ 编辑器集成(VSCode ESLint 插件)—— 写码时实时提示
  ├ pre-commit hook(husky)—— 提交前阻断
  ├ CI gate(GitHub Actions)—— PR 不过
  └ 统一格式化(Prettier + eslint-config-prettier)

三层防线 + 配置推下沉 = Lint 规则的全生命周期治理

面试 / 技术对话角度

面试话术:"ESLint 两条报错看似琐碎但体现了项目的代码质量治理——no-unused-vars 是代码卫生(删除没用的 import 防止维护时的假信号)、import/first 是模块规范(即便 JS 允许在文件中间 import,规范也禁止以保证可读性一致)。我认为 Lint 规则的价值是'把隐性约定显式化'——规范从文档到 Code Review 到 Lint 强制,是一条下沉路径,越下沉越不依赖人的记忆。遇到'规则不合理'时我会优先推动团队修改 eslint config,而不是 eslint-disable 绕过——disable 一次两次没事,但多了会让整套规范失去约束力。三层防线配置齐了(编辑器实时提示 + pre-commit hook + CI gate),规范才能真正固化。"


3. AI 视频功能新增

一句话结论

新增完整的 AI 视频模块(创建页 + 结果页),后端接口尚未就绪,全部使用 mock 数据,后续直接替换即可。

为什么这样设计

  • Mock-first:后端未就绪时先跑通完整流程,UI/交互可独立迭代,接口就绪后只需替换 api/index.ts 中的实现
  • 结果页轮询:视频生成耗时长,用 setInterval 每 3s 查询一次状态,失败 3 次后停止,避免无限轮询
  • BGM 选择:底部弹出 sheet,mock 数据占位,后续接真实音乐列表

数据流 / 执行流程

选择穿版图(网格)或上传本地图片
   │
   ▼
选择视频时长(5s / 10s)
   │
   ▼
createAIVideoTaskApi → 返回 { taskId }(mock: 'mock-task-001')
   │
   ▼
navigate('/ai_video/result/:taskId')
   │
   ▼
结果页:fetchAIVideoTaskApi 轮询(3s)
   │ status=1(生成中) → 显示进度环(conic-gradient)
   │ status=10(完成)  → 显示视频播放器 + 分享/下载按钮
   └ status=-10(失败) → 显示重试按钮

相关代码位置

文件 用途
src/pages/AIVideo/index.tsx 视频创建页
src/pages/AIVideo/index.scss 创建页样式
src/pages/AIVideoResult/index.tsx 结果页(轮询 + 播放 + BGM + 历史)
src/pages/AIVideoResult/index.scss 结果页样式
src/api/index.ts fetchAIVideoTryOnImagesApi / createAIVideoTaskApi / fetchAIVideoTaskApi / fetchAIVideoBgmListApi
src/state/mock.ts MOCK_AI_VIDEO_TRYON_IMAGES / MOCK_AI_VIDEO_HISTORY / MOCK_AI_VIDEO_BGM / MOCK_AI_VIDEO_TASK_RESULT

关键实现细节

进度圆环AIVideoResult/index.tsx):用纯 CSS conic-gradient 实现,无需第三方库:

style={{
  background: `conic-gradient(#E83E5A ${progress * 3.6}deg, #eee 0deg)`
}}

progress 是 0-100 的整数,乘以 3.6 得到角度(360° / 100)。

轮询停止条件AIVideoResult/index.tsx):

// 完成/失败 → 停止;连续 3 次请求失败 → 停止(防死循环)
if (data.status === 10 || data.status === -10) clearInterval(timer);
errorCount >= 3clearInterval(timer);

下载视频:用 <a download> 触发,不需要额外接口:

<a href={videoUrl} download="ai_video.mp4">下载视频</a>

面试角度

面试话术

"我们用轮询方案:页面挂载后启动 setInterval 每 3 秒请求一次任务状态。状态有三种:生成中(展示进度)、完成(展示结果)、失败(展示重试)。停止条件有两个:一是收到终态(完成或失败),二是连续报错 3 次(防止网络问题导致无限轮询)。页面 unmount 时在 useEffect 的 cleanup 里 clearInterval,避免内存泄漏。进度条用 conic-gradient 纯 CSS 实现,不引入额外依赖。"


最后更新:2026-03-31


4. AI穿版/AI视频 Tab 导航架构

一句话结论

将"AI穿版/AI视频"切换 Tab 放在 MainPagepage__header 区域,替换原来的文字标题,而非放在各子页面内部,实现真正的顶部全局导航。

为什么这样设计(设计演进过程)

第一版(错误方向):Tab 放在各子页面内容区顶部,用 wrapper div 做 flex-column 布局,Tab 固定不滚动、内容区滚动。

问题:Tab 在 header 下方、内容区内,视觉上不是"最顶部",而是内容的第一个 card。

第二版(当前):Tab 放进 MainPage<header> 里,条件渲染——只在 /ai_change_clothes/creation/ai_video/creation 路由时显示 Tab,其他页面仍显示文字标题。

原理:为什么条件渲染而不是独立 layout

MainPage 是所有子路由的共同 wrapper(通过 <Outlet />),header 是它的一部分。子页面无法直接修改父组件的 header(React 单向数据流)。替代方案有:

  1. 条件渲染(当前方案):父组件根据 location.pathname 决定渲染 Tab 还是标题
  2. Context/Portal:子组件通过 Context 向父组件注入内容(复杂度高,不必要)
  3. 独立 Layout:为这两个页面单独包一层 layout(重复代码多)

条件渲染最简单,路由变化时 location 自动更新,无额外状态。

数据流

用户点击 Tab
   │
   ▼
navigate('/ai_change_clothes/creation') 或 navigate('/ai_video/creation')
   │
   ▼
location.pathname 变化 → MainPage re-render
   │
   ▼
isAICreationRoute = true → header 渲染 Tab(正确高亮)
   │
   ▼
<Outlet /> 渲染对应子页面

相关代码位置

文件 改动
src/pages/Main/index.tsx:217-250 条件渲染 Tab vs 标题;退出按钮条件加入新路由
src/pages/Main/index.scss:36-44 .page__header .ai_feature_tabs 覆盖父级 div 样式
src/pages/AIChangeClothes/index.scss:1-35 .ai_feature_tabs 公共样式定义

关键 CSS 坑:父选择器优先级覆盖

page__header 中有 & > div { flex: 1 0 30px; padding: 0 16px; color: #1987ff; } ——所有直接子 div 都继承这个规则。Tab 容器是 div,会被这个规则命中。

解决:用更高特异性的 .page__header .ai_feature_tabs(特异性 0,2,0 > 0,1,1)覆盖 flex 和 padding:

.page__header .ai_feature_tabs {
  flex: 2;          // 占据 header 中间更多空间
  padding: 0;       // 清除父级的 padding: 0 16px
  height: 34px;
  overflow: hidden; // 配合 border-radius 裁切 active 背景
}

面试角度

面试话术

"Tab 导航我放在了公共的 MainPage header 里,用 location.pathname 条件渲染。这样避免了在每个子页面重复渲染 Tab,也不需要 Context 或 Portal。有个 CSS 特异性的坑:header 里所有直接子 div 都被一个通用选择器设了 flex 和 padding,所以必须用更高特异性的 .page__header .ai_feature_tabs 去覆盖,而不是在 Tab 自己的类里写。"


最后更新:2026-03-31


5. Tab 交互 Bug 修复合集

Bug 1:选中色没有填满 Tab 容器

现象:激活状态的渐变背景四周有明显白边,视觉上没有"选中半边"的效果。

根因分析

  • Tab 容器有 padding: 3-4px,导致激活 item 与容器边缘之间有白色间距
  • Tab item 有 border-radius: 8px,但容器也有 border-radius: 12px,两个圆角叠加看起来不协调

修复方案

.page__header .ai_feature_tabs {
  padding: 0;           // 去掉内边距
  overflow: hidden;     // 容器裁切,自动处理圆角
}

.ai_feature_tabs__tab {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  // 去掉 border-radius(由容器 overflow:hidden 统一裁切)
  // 去掉 padding: 8px 0(改用 flex 撑高)
}

为什么 overflow: hidden 是关键:容器有 border-radius: 12px,item 填满后超出圆角部分会溢出。overflow: hidden 把溢出部分裁掉,自然呈现圆角效果,不需要每个 item 单独设置 border-radius


Bug 2:Tab 切换有闪烁感

现象:点击另一个 Tab 时,文字/背景有短暂闪烁。

根因transition: all 0.2s 会对所有 CSS 属性做动画,包括 flexdisplayborder-radius 等布局属性。路由切换时这些属性同时变化,浏览器触发多次 layout reflow,肉眼感知为闪烁。

修复:只对视觉属性做过渡:

// 修复前
transition: all 0.2s;

// 修复后
transition: background 0.2s, color 0.2s;

经验提炼transition: all 是危险写法,在复杂布局中容易引发意外动画。应始终明确写出要过渡的属性。


Bug 3:退出按钮颜色与主题不一致

修复Main/index.scss& > div { color: #E83E5A; } 替换原来的 #1987ff

原理深挖——transition: all 为什么是反模式

transition: all 触发的完整机制:
  浏览器对元素的所有可过渡属性都监听
  任何属性变化都触发动画
  
属性变化的来源可能是:
  ├ 你预期的(hover、active、state 变化)
  ├ 父组件重新渲染导致的重新赋值
  ├ 布局重新计算(flex、grid 变化)
  ├ 媒体查询触发(窗口尺寸变化)
  └ 浏览器自动调整(字体加载、图片加载改变布局)

结果:
  元素在"你没预期的时刻"也在动画
  visual jank / 闪烁 / 性能拖慢

写 CSS 的第一原则精确描述你要的动画属性,不要 all

overflow: hidden 的"裁切统一"模式

本 bug 用了一个经典技巧:
  父容器设 border-radius + overflow: hidden
  子元素不需要各自设 border-radius
  父的圆角自动裁切子元素

适用场景:
  ├ 按钮组(segmented control)
  ├ 卡片内嵌图片(图片不用自己 border-radius)
  ├ Tab 组件
  └ 任何"容器圆角 + 内部填满"的 UI

好处:
  ├ 子元素无需重复设置圆角
  ├ 圆角修改只在一处
  └ 避免"父子圆角不一致"的视觉违和

性能思维——transition 的 GPU 加速边界

GPU 可加速的属性(合成层):
  ├ transform
  ├ opacity
  └ filter(部分)

CPU 密集的属性(重排重绘):
  ├ width / height / padding / margin
  ├ top / left / right / bottom
  ├ border / border-radius(动画时)
  └ background-color(有时需重绘)

本 bug 的 transition: background 0.2s, color 0.2s
  颜色过渡是重绘不是合成
  小范围可接受,大面积 + 多元素会掉帧
  
更优方案:
  两个图层叠加,用 opacity 渐变切换(GPU 友好)
  但实现更复杂,本场景不值得

资深习惯:写 CSS 动画时先想 GPU 合成层(transform/opacity),不得已才用重绘属性(color/background)。

三层对比——Tab 组件的实现深度

❌ 初级:每个 Tab 自己写样式,激活态 if/else 切换类名
   → 圆角不统一、切换有闪烁、transition: all 到处飞

⚠️ 中级:提取 Tab 组件,规范激活态样式
   → 圆角、padding 处理但仍用 transition: all

✅ 资深:
   - 容器 overflow:hidden 统一裁切(父子圆角一致)
   - 精确指定过渡属性(transition: background, color)
   - 优先用 GPU 属性(transform、opacity)
   - 活动状态抽成 data-attribute 或 aria 便于样式 + 无障碍
   - 考虑键盘导航(← → 切换 Tab)
   - Ripple 效果或 hover 状态的细节

设计模式识别——"裁切优于对齐"的 CSS 原则

本 bug 的解法本质是:
  与其让每个子元素"精确对齐父容器的圆角"
  不如让父容器直接"裁切掉多余部分"

这在 CSS 设计里是一个通用原则:
  clip-path / overflow / mask 等"裁切"手段
  往往比"精确对齐"更鲁棒
  
类似场景:
  ├ 头像圆角:容器 overflow:hidden + 子图 object-fit:cover
  ├ 卡片悬浮阴影:外层裁切防止内容溢出阴影区
  └ Drawer/Modal:用 clip-path 做圆角切入动画

面试 / 技术对话角度

面试话术:"Tab 的三个 bug 背后是 CSS 工程化的三条原则——第一,transition: all 是反模式,应精确指定过渡属性,避免布局变化时触发意外动画;第二,容器圆角用 overflow: hidden 统一裁切比子元素各自设圆角更鲁棒,符合'裁切优于对齐'的 CSS 设计原则;第三,过渡属性优先 GPU 合成层(transform、opacity)而非重绘(background、color),大规模动画时性能差异明显。这种对 CSS 的'机制敏感性'是资深前端的标志——不是'会写样式'而是'知道每一行样式在浏览器里如何执行'。"


6. AIChangeClothes 细节补全

上传照片必填标记

在"上传照片"标题后加红色 *

<span className="ai_change_clothes__section__title">
  上传照片<span className="ai_change_clothes__required">*</span>
</span>
&__required {
  color: #E83E5A;
  margin-left: 2px;
  font-weight: 600;
}

照片类型切换时展示部位联动

问题:上传模式切换后,展示部位的选项没有跟随变化,且已选中的部位仍然保留。

修复:定义各模式对应的部位选项,切换时同时重置已选部位:

const BODY_PARTS = ['全身', '上半身', '下半身', '组合'];

四个选项所有模式通用,"组合"指组合图展示方式。


import/first ESLint 报错(本次)

BODY_PARTS_BY_MODE 常量定义被插入到 import 语句中间,触发 import/first 规则。移到所有 import 之后即可。

经验transition: all、import 位置、CSS 特异性——这三类问题都是"编译通过但运行时有问题"的典型,code review 时需要重点关注。


7. 设计稿对比检查与修复

一句话结论

对比 img/changeClothes0330_*.png 设计稿,修复创建页和结果页共 10 处样式/文案差异。

修复内容

创建页 (AIChangeClothes)

  1. 上传占位图标+ 号 → 相机 SVG 图标,与设计稿一致
  2. 上传文案"上传女装图""点击上传衣服平铺图",套装模式 "上装图/下装图""上传上衣/上传下装"
  3. "必选" badge:在"上传照片"、"展示部位"、"推荐穿版模特"标题旁添加粉色 必选 标签
  4. "非必要选项" 副标题:在"调整细节"标题后添加灰色 非必要选项 提示

结果页 (AIChangeClothesResult)

  1. 横幅标题"生成 AI 穿搭预览""生成AI穿版视频"
  2. 横幅图标:纯文本 → 圆形播放按钮(白底圆 + 粉色三角 SVG)
  3. 积分标签:横幅右侧新增 免费 badge
  4. 底部按钮文案"生成视频 · 剩余 N 次""生成1条视频 消耗N点"

相关代码位置

  • src/pages/AIChangeClothes/index.tsx:278-316 — 上传占位区域
  • src/pages/AIChangeClothes/index.scss:62-86 — 新增样式
  • src/pages/AIChangeClothesResult/index.tsx:158-170 — 横幅区域
  • src/pages/AIChangeClothesResult/index.scss:101-145 — 横幅样式

为什么这样设计

  • "必选" badge vs 红色星号:设计稿用粉底粉字标签而非简单 *,视觉上更清晰地区分必选/可选字段
  • 相机图标 vs +:相机图标暗示"拍照/上传图片"的操作语义,比通用 + 号更精确
  • 播放按钮圆形设计:圆形白底 + 三角组合是视频播放的通用视觉语言,比纯文本 更有点击引导性

原理深挖——"设计还原度"是产品体验的基础

"完成功能"和"设计稿还原"是两个独立的完成度维度:

功能完成:
  用户点击上传能上传、点击生成能生成
  但用户可能觉得"这个和设计稿不一样"
  → 产品感受"粗糙"、"像半成品"

设计还原:
  颜色、间距、图标、文案、badge 等全部对齐
  用户感受"精致"、"专业"
  即使功能相同,感知价值完全不同

两者关系:
  功能是骨架,设计是皮肉
  只有骨架能用但不美观 = MVP
  骨架 + 皮肉 = 产品

资深和中级的一个差距:中级觉得"设计还原是细节,可选项";资深知道"设计还原是产品体验的基础门槛"。

设计稿对比的系统化流程

Step 1:准备两份快照
  ├ 设计稿(Figma / PNG)
  ├ 当前实现(截屏)
  
Step 2:逐模块对比
  ├ 布局层:容器尺寸、margin/padding、flex 对齐方式
  ├ 样式层:color、font-size、font-weight、border-radius
  ├ 内容层:文案、图标、插图
  ├ 状态层:hover、active、disabled、loading
  └ 交互层:动画、转场、反馈

Step 3:列清单
  每条差异一条记录:位置 / 设计稿值 / 当前值 / 修复方案

Step 4:优先级排序
  先高频核心(首页、主流程)
  后边缘页面

Step 5:批量修复 + 一次回归测试

与前端已知知识的类比

设计对比 ≈ 单元测试
  每条差异 = 一个失败的 test case
  修复 = 让 test 变绿
  逐条清列 = 覆盖率可视化

设计对比 ≠ 设计工程化
  设计工程化包含:Design Tokens、Figma → Code 自动生成、视觉回归测试
  本次对比是人工对比,成熟团队有自动化工具(如 Chromatic、Percy)

"必选 badge" vs "红色星号"——UX 细节的思考

红色星号(传统):
  视觉密度高:一个小符号表达"必填"
  依赖经验:老用户懂,新用户可能忽略
  无障碍差:屏幕阅读器可能念"星号"而非"必填"

粉底粉字 badge:
  视觉更醒目:有背景色,不会错过
  语义清晰:字面写"必选",不需要经验
  无障碍友好:屏幕阅读器直接读"必选"

但代价:
  占空间更大
  如果必选项多,UI 会被 badge 污染
  
取舍:
  少量必选项(<5)→ badge 清晰
  大量必选项 → 星号节省空间

这不是"哪个更好"的问题,是**"哪个更适合具体场景"**的问题。

质量思维——怎么保证设计还原度

依赖人工对比的风险:
  ├ 对比时有遗漏(信息量大)
  ├ 新需求修改时又偏离设计
  └ 设计稿更新后代码没同步

自动化手段:
  1. Storybook + Chromatic
     每个组件有"设计稿参考图"
     代码改动 → 自动截图 → diff 设计稿
     差异超阈值 → 报警

  2. Design Tokens
     颜色、间距等值定义在 token 文件
     Figma 和代码用同一套 token
     token 改了,两边同步

  3. Visual Regression Testing
     Playwright / Percy 每次提交自动截图
     和基线对比,差异就 fail

  4. Design Linting
     检查"未使用的颜色、离散的 font-size"等
     强制所有样式走 token 系统

本项目目前是 0 自动化(纯人工对比)
规模大了后建议升级到 1-2 级

三层对比——设计还原的工程层次

❌ 初级:"功能能用就行",设计稿看一眼
   → 实现 60% 还原度,产品感粗糙

⚠️ 中级:每次迭代手工对比一遍
   → 实现 90% 还原度,但每次迭代成本高 + 新改动易偏离

✅ 资深:
   - Design Tokens 系统化管理设计值
   - Storybook 展示组件与设计稿对比
   - Visual Regression Testing 自动化防退化
   - 每次 PR 的设计还原度当作 DoD(Definition of Done)的一部分
   → 99% 还原度 + 持续保持

面试 / 技术对话角度

面试话术:"设计稿对比不是'细节完善',是产品体验的基础门槛——功能和设计是两个独立完成度维度。我做这次对比用系统化流程:准备两份快照 → 分五层(布局/样式/内容/状态/交互)逐一检查 → 列清单 → 批量修复 → 回归测试。关键 UX 决策里我会权衡,比如'必选' badge vs 红色星号,badge 语义清晰且对屏幕阅读器友好,但占空间大;少量必选项用 badge、大量用星号。长期来看,手工对比的方式会退化,所以团队规模起来要引入 Design Tokens(设计值集中管理)、Storybook 展示组件、Visual Regression Testing 自动防止还原度退化。这是从'实现设计'到'保持设计'的工程化。"


8. API接口对接 — Mock替换为真实后端接口

一句话结论

将 AI穿版功能的所有 mock API(Promise.resolve)替换为真实后端接口调用,对接 接口文档.md 中的 8 个 API。

接口映射

Mock 函数 真实 apiKey 说明
fetchChangeClothesModelsApi ec-aigc-modelImage-list 模特图列表,全量返回,前端排序/筛选
fetchDetailOptionsApi ec-aigc-category-listTree + ec-aigc-promptDict-list 分类树+提示词列表替代硬编码选项
createChangeClothesTaskApi ec-aigc-try-on-createTaskV3 批量试衣任务,modelImageIds[] + taskJson
fetchChangeClothesTaskApi ec-aigc-task-detail 通用任务详情
createChangeClothesVideoApi ec-aigc-video-gen-createTaskV2 批量视频生成,urls[] + taskJson
toggleModelFavoriteApi add / remove 两个接口 拆分为收藏和取消收藏
saveChangeClothesCustomModelApi ec-aigc-modelImage-save 保存自主上传模特图
(新增) fetchCategoryTree ec-aigc-category-listTree 获取分类树,替代硬编码筛选项

为什么这样设计

  • 全量返回 + 前端排序:模特图列表接口不支持分页,返回全量数据。排序(最常用=useCount降序、最新=id降序、已收藏=isFavorite过滤)在前端完成
  • 收藏拆分为两个接口:后端设计为 addremove 独立接口,比单一 toggle 更符合 RESTful 语义
  • taskJson 字符串包装:V3 接口要求业务参数序列化为 JSON 字符串传入 taskJson 字段,这是为了兼容多种任务类型共用同一接口
  • getDocUrl 工具函数:后端返回 docId(纯数字),前端需拼接 doc 服务域名构造图片 URL。与现有 reSizeImage 使用同一域名

关键代码变更

数据流:
用户操作
   │
   ▼
┌─────────────────────────────┐
│  fetchCategoryTree(1)       │── 模特图分类树 → ModelSelectPopup 筛选项
│  fetchCategoryTree(2)       │── 提示词分类树 → 调整细节大类
│  fetchChangeClothesModelsApi│── 全量模特图 → 前端排序/筛选
│  fetchPromptDictList        │── 提示词列表 → 调整细节子选项
└──────────┬──────────────────┘
           │ 用户选择完毕
           ▼
┌─────────────────────────────┐
│  createChangeClothesTaskApi │── modelImageIds + taskJson
│  → 返回 [{id}, {id}, ...]  │
└──────────┬──────────────────┘
           │ 跳转结果页
           ▼
┌─────────────────────────────┐
│  fetchChangeClothesTaskApi  │── 轮询 task-detail
│  createChangeClothesVideoApi│── 生成视频
└─────────────────────────────┘

相关代码位置

  • src/utils/doc.ts — 新增 getDocUrl 工具函数
  • src/api/index.ts:459-590 — 所有 AI穿版 API 函数 + 类型定义
  • src/pages/AIChangeClothes/index.tsx — 主页适配
  • src/pages/AIChangeClothes/components/ModelSelectPopup/index.tsx — 弹窗重写
  • src/pages/AIChangeClothesResult/index.tsx — 结果页适配
  • src/state/mock.ts — 删除 AI穿版 mock 数据

面试角度

面试话术:"在接口对接中,我遵循了渐进式替换策略:先定义类型、再替换 API 函数、最后适配组件。关键决策是全量返回+前端排序的方案——虽然看起来不如服务端分页高效,但模特图总量有限(几十到几百),前端排序能减少网络请求、提升交互响应速度,同时简化了后端接口设计。另外我封装了 getDocUrl 工具函数统一处理 docId→URL 的映射,避免了散落在各组件中的 URL 拼接逻辑。"


最后更新:2026-04-01


9. Bug修复:推荐穿版模特选中后不应消失

现象

/ai_change_clothes/creation 页面,用户从"推荐穿版模特"区域选择一个模特后,整个推荐模特模块立刻消失。用户无法继续在推荐模特中浏览和切换选择。

第一反应(错误方向)

无——直接定位到条件渲染逻辑即可。

排查思路

查看 AIChangeClothes/index.tsx:588 的渲染条件:

{selectedModels.length === 0 && uploadedModelImages.length === 0 && <div ...>}

条件过于严格:只要 selectedModels 有任何元素(无论来源是推荐列表还是弹窗),推荐区域就被隐藏。

根因

隐藏条件没有区分模特的来源——从推荐区域选择的模特和从"更多模特"弹窗选择的模特都存入同一个 selectedModels 数组,而隐藏判断只看数组是否为空。

修复方案

用推荐列表本身的 ID 集合做区分:

// 推荐模特的 ID 集合(前4个)
const recommendedModelIds = new Set(models.slice(0, 4).map((m) => m.id));
// 是否有「非推荐」模特被选中(即来自弹窗的模特)
const hasNonRecommendedModel = selectedModels.some((m) => !recommendedModelIds.has(m.id));
// 隐藏条件:自主上传了模特 OR 从弹窗选了非推荐模特
const hideRecommendSection = uploadedModelImages.length > 0 || hasNonRecommendedModel;

渲染条件简化为:

{!hideRecommendSection && <div className="ai_change_clothes__section">...}

为什么不用 boolean flag(如 hasSelectedFromPopup

  • flag 方案需要额外管理状态的置位/清除(弹窗确认时置 true、清空模特时清 false),容易出现不一致
  • 当前方案是派生状态(derived state),每次渲染从 selectedModelsmodels 自动计算,无需额外维护

相关代码位置

  • src/pages/AIChangeClothes/index.tsx:396-400 — 新增 hideRecommendSection 派生逻辑
  • src/pages/AIChangeClothes/index.tsx:590 — 渲染条件替换

经验提炼

当 UI 的显隐依赖于数据来源时,要么区分存储(不同来源存入不同 state),要么用派生状态从数据本身推断来源。单纯靠"有没有数据"来判断容易误伤。

面试角度

面试话术:"这个 bug 的本质是显隐条件没有区分数据来源。推荐模特和弹窗模特共用同一个 selectedModels 数组,但隐藏推荐区域时不应该因为用户选了推荐模特就隐藏推荐区域本身。修复方式是用派生状态:计算已选模特中是否有不在推荐列表中的模特(即来自弹窗),只有这种情况才隐藏。这是 React 中推荐的 derived state 模式——能从已有 state 计算出来的值,就不要额外加 state。"

原理深挖——Derived State(派生状态)原则

React 的黄金原则:
  "能从已有 state 推导的值,永远不要再加一个 state"

为什么?因为多个 state 之间的"一致性"极难维护:

反例(多 state 同步):
  const [selectedModels, setSelectedModels] = useState([])
  const [hasSelectedFromPopup, setHasSelectedFromPopup] = useState(false)
  
  用户在弹窗选模特 → setSelectedModels + setHasSelectedFromPopup(true)
  用户清空所有选择 → setSelectedModels([]) + 忘记 setHasSelectedFromPopup(false)
  
  → 两个 state 不同步
  → UI 逻辑错乱

正例(Derived State):
  const [selectedModels, setSelectedModels] = useState([])
  const hasSelectedFromPopup = selectedModels.some(m => !isRecommended(m))
  
  只有一个 state
  每次渲染都"算"一次 derived 值
  不可能不同步 ✅

这是 React 团队一直强调的设计哲学——"State 尽量少,能算的就算"。

何时不用派生状态

派生状态的唯一代价:每次渲染重新计算
  
  if 计算很便宜(O(n)、O(n log n) 且 n 小)
    → 派生
  
  if 计算很贵(复杂算法、大数据集)
    → 用 useMemo 缓存派生值
  
  if 计算需要异步(如 API 请求)
    → useEffect + state(不可避免)

本案例计算只是 Set 查询 + some(),O(n) 且 n 小(几十个模特)
完全不需要 useMemo

数据来源区分的通用模式

本 bug 的核心:多来源数据合流到同一个集合,后续需要区分来源

通用模式有三种:

模式 A:合流 + 派生区分(本次采用)
  所有数据存一个数组,每次用时从数据本身推断来源
  优点:state 最少
  缺点:区分逻辑散落在各个使用点

模式 B:按来源分别存储
  const recommendedModels = [...]
  const uploadedModels = [...]
  const popupModels = [...]
  使用时按需合并
  优点:来源明确
  缺点:合并时要注意顺序 + 去重

模式 C:带 meta 的统一存储
  const allModels = [{id, data, source: 'recommend' | 'popup' | 'upload'}]
  使用时过滤 source
  优点:来源显式,一个 state
  缺点:数据结构变复杂

取舍:
  几个来源就用 A 或 C
  多个来源且需要频繁按来源操作用 B
  本项目只有 2 个来源(推荐 + 弹窗)+ 派生简单 → A 最合适

三层对比——状态管理的层次

❌ 初级:遇到新需求就加 state
   5 个 state 还不够,上 Context/Redux
   → 状态爆炸 + 同步 bug 遍地

⚠️ 中级:知道 derived state 概念,但不熟练识别
   有些场景能识别(最大值、筛选结果),复杂场景仍然加 state
   → 减少了一部分 bug,但仍留隐患

✅ 资深:
   1. 每加一个 state 前先问"能不能从已有 state 推导"
   2. 能推导就派生,不能才加 state
   3. 派生计算贵时用 useMemo
   4. 派生涉及异步时不得已用 useState + useEffect
   5. 把 state 当作"系统的真相源",越少越好(Single Source of Truth 原则)

设计模式识别——Single Source of Truth

React 的 state 管理本质是"Single Source of Truth"的实现:

  真相源(State)
       │
       ↓ 派生
  所有 UI 和逻辑都从真相源推导

反例:多真相源
  state A 和 state B 都表达"模特是否选自弹窗"
  → 两个真相源可能冲突
  → 系统不可信

正例:单一真相源
  state 只存 "当前选的所有模特"
  "是否有来自弹窗的" 是推导结果
  → 没有冲突的可能

这是 Flux / Redux / Vuex 共通的设计哲学。React 的 useState 也应该遵循同样原则

质量思维——派生状态 vs 冗余 state 的 bug 模式

冗余 state 常见 bug:

1. "更新漏同步"
   setA 时忘记 setB
   → A B 不一致

2. "异步时机错"
   state 更新是异步的
   setA 后立即读 A 还是旧值
   → B 基于旧 A 计算错了

3. "重复存储"
   state A 存数据,state B 存其衍生值(如 length)
   数据改了但长度没改
   → 不一致

派生状态天然免疫这三类 bug
因为它是"计算出来"的,不是"存储的"

面试 / 技术对话角度

面试话术:"这个 bug 本质是显示/隐藏条件没有区分数据来源——推荐模特和弹窗模特都存在同一个 selectedModels 数组里,隐藏条件只看'数组是否空',用户选了推荐模特就误触发隐藏。修复的关键是派生状态——通过 selectedModels.some(m => !recommendedIds.has(m.id)) 从已有 state 推导出'是否有来自弹窗的模特',不需要再加一个 hasSelectedFromPopup 的 state。这是 React 的 Single Source of Truth 原则——能推导的值就别存。冗余 state 最大的坑是多个 state 之间的同步——setA 时忘记 setB,或异步时机错导致 B 基于旧 A 计算。派生状态完全免疫这类 bug,因为它每次都现算。资深和中级的差距之一就是对'state 最小化'的习惯——每加一个 state 前先问'能不能派生'。"



10. Bug修复:模特选择弹窗第7张图片空白/破裂(HEIC格式不被浏览器支持)

现象

进入"选择穿版模特"弹窗后,图片列表中第7张显示为空白/破裂状态。但把图片 URL 复制出来单独下载到本地后,图片是完整的。

第一反应(错误方向)

容易先怀疑是网络问题、CDN 签名失效、或图片加载顺序问题。但"下载到本地正常"这个线索排除了这些方向——说明图片本身没问题,是渲染环境不支持该格式。

排查思路

比对接口返回的27条数据,第7条(id: 10054)的 docUrl 扩展名为 .heic,其余均为 .png.heic 是 Apple 的 HEIC 格式(High Efficiency Image Container),macOS 原生支持,但浏览器和 Android WebView 均不支持直接渲染。

根因

后端上传图片时未做格式校验,允许上传了 HEIC 格式。前端代码直接 <img src={model.docUrl} /> 没有任何格式兜底,浏览器遇到不认识的图片格式时渲染为破图。

关键:macOS 能打开 HEIC 是因为操作系统层面的解码器,不是浏览器能力。浏览器的图片支持范围:JPEG/PNG/GIF/WebP/AVIF,不含 HEIC。

修复方案

文件src/pages/AIChangeClothes/components/ModelSelectPopup/index.tsx:299-320

在渲染模特图列表时,用正则检测 URL 是否为 .heic 格式,如果是就跳过(返回 null),不渲染破图占位。

// 修复前
<img src={model.docUrl || ''} alt={model.name} />

// 修复后
const isUnsupported = /\.heic(\?|$)/i.test(model.docUrl || '');
if (isUnsupported) return null;
// ... 正常渲染

正则 /\.heic(\?|$)/i(\?|$) 匹配 CDN 签名 URL(xxx.heic?sign=...),避免漏判。

为什么不用 onError 兜底onError 只能在加载失败后用占位图替换,用户仍会看到一个无内容的灰块。直接过滤体验更好,且格式检测是确定性的(不依赖网络)。

根本解法(后端):上传时自动将 HEIC 转为 PNG/JPEG,或在上传入口限制格式。前端过滤只是兜底。

经验提炼

遇到"在 app 中图片破裂,但下载到本地正常"的问题,第一步要查图片格式(扩展名),而不是查网络/权限。HEIC、TIFF、BMP 等格式在浏览器中均不支持,属于高频坑。

面试角度

面试话术:"这个 bug 的本质是浏览器图片格式支持范围的问题。HEIC 是 Apple 的高效图像格式,macOS 通过系统级解码器支持,但浏览器和 Android WebView 均不原生支持。排查思路是:'下载正常'排除了网络/CDN 问题,'只有第7张'说明不是通用加载逻辑问题,对比数据发现第7条 URL 扩展名是 .heic,其余是 .png——到这里根因就清晰了。修复方案是前端检测格式过滤掉不支持的图片,根本方案是后端在上传时做格式转换。"

原理深挖——浏览器图片格式支持矩阵

格式        浏览器支持    Apple 设备  背景说明
─────────────────────────────────────────────
JPEG        ✅ 全支持     ✅         最古老,最通用
PNG         ✅ 全支持     ✅         支持透明通道
GIF         ✅ 全支持     ✅         动图,2 色通道限制
WebP        ✅ 全支持     ✅(新版) Google 推的现代格式
AVIF        ⚠️ 渐进支持  ✅(新版) 最新,压缩率最高
HEIC        ❌ 不支持     ✅ 原生    Apple 独家,iOS 拍照默认格式
TIFF        ❌ 不支持     ⚠️         印刷/出版用
BMP         ⚠️ 部分支持   ✅         原始位图,几乎已淘汰
SVG         ✅ 全支持     ✅         矢量图,不是位图

为什么 HEIC 是高频坑:iOS 从 iPhone 7 / iOS 11 开始,拍照默认存 HEIC(更省空间 50%)。用户从相机 → 上传到系统 → 浏览器显示失败。传统上传链路完全没预料到这个格式。

多层防线——正确的图片上传架构

第 1 层(用户端):
  ├ 明确文件 accept:<input accept="image/jpeg,image/png,image/webp">
  ├ 不写 image/* (避开 HEIC/BMP 等奇葩)
  └ 上传前用 JS 读取 Magic Number 再次验证(accept 只是 MIME 提示,可绕过)

第 2 层(上传时转换):
  前端用 heic2any / sharp-wasm 把 HEIC 转 JPEG 再上传
  用户体验:上传就是标准格式,服务器零负担

第 3 层(服务端):
  收到后检测 Magic Number + 尺寸 + 实际格式
  HEIC → 服务端用 libheif / sharp 转 JPEG
  存储时只存标准格式

第 4 层(兜底):
  前端渲染前再检测扩展名/Content-Type,不支持的跳过
  本次修复就在这一层

✅ 资深项目:第 2 层(上传时转)+ 第 3 层(服务端转)+ 第 4 层(前端兜底)
⚠️ 中级项目:只有第 3 层
❌ 初级项目:四层都没有,HEIC 静悄悄存进 DB

为什么"前端兜底"不能算根治

只在前端 filter HEIC:
  ├ 用户上传 HEIC,存进 DB,占存储空间
  ├ iOS 用户(约 50% 流量)看不到自己上传的图
  ├ Android 和桌面用户也看不到
  ├ 未来换 Native App,Native 能显示 HEIC,逻辑不一致
  └ 产品数据误差("明明有 27 张图,为啥只显示 26 张?")

根治要在**数据入口**(上传链路)做格式统一
前端兜底是"止血",不是"治病"

调试思维——"下载正常 + 在线破"这类 bug 的标准排查

症状:某图片/视频/文件在 App 内渲染失败,但下载到本地能打开

排查优先级:
  1. 格式支持性(本案例)
     → 浏览器/WebView 不支持的格式,系统能打开
     → 查扩展名、Content-Type

  2. 编码/codec 问题
     → 视频场景:H.265 iOS 支持但 Android 老设备不支持
     → 音频:AAC Profile 差异

  3. 嵌入限制
     → iframe/XHR 跨域
     → CORS 响应头 Access-Control-Allow-Origin 缺失

  4. 安全策略
     → CSP 阻止外链资源
     → Mixed Content(HTTPS 页面加载 HTTP 资源)

  5. CDN 缓存/变换
     → CDN 对某些 UA 返回不同版本
     → CDN 把图片自动压缩破坏了文件头

"下载正常"这一个现象就锁定范围到 1-5 中的"格式/编码"
不是"损坏/签名/权限"那类问题

安全思维——HEIC 相关的潜在攻击面

HEIC 解码器历史上有过 CVE:
  ├ libheif < 1.12.0 有整数溢出漏洞
  ├ Apple 自己的 ImageIO 也曝过多次
  └ 恶意 HEIC 文件可能触发 RCE

如果你服务端做 HEIC 转 JPEG:
  → 必须升级 libheif / sharp 到最新
  → 隔离转换服务(不在主应用进程里跑)
  → 设资源限制(CPU、内存、超时)
  → 考虑用沙箱容器

前端兜底跳过 HEIC 反而安全——根本不解码

成本思维——三种修复方案的 ROI

方案 A:前端兜底(本次选择)
  开发:10 分钟
  代价:HEIC 图片显示不出来(产品不一致)
  适合:短期止血

方案 B:后端上传时 HEIC → JPEG 转换
  开发:1-2 天(集成 sharp/libheif、测试、部署)
  基建:转换要 CPU 时间,百万级调用要独立服务
  适合:根治

方案 C:前端上传前本地转换
  开发:2-3 天(集成 heic2any、处理大文件慢的情况)
  优点:服务器零负担
  缺点:浏览器性能差时用户等很久
  适合:移动端优先的产品

选 A 作为临时止血 + 排期 B 作为根治——双轨制

三层对比——图片格式 bug 的处理层次

❌ 初级:看到破图直接加 onError 换占位图
   → 用户看到占位图但不知道为什么,也没修根因

⚠️ 中级:定位到是 HEIC 格式,前端过滤
   → 解决了破图但产品数据不一致(明明有图但显示少)

✅ 资深:
   1. 前端过滤(止血)
   2. 排期后端转换(根治)
   3. 上传入口 accept 限制 + Magic Number 校验(预防)
   4. 监控 Content-Type 异常比例(观测)
   → 多层防线互补

举一反三——"扩展名 + Content-Type + Magic Number"三者可能不一致

实际生产环境中:
  扩展名:用户/客户端文件名,可伪造
  Content-Type:服务器 HTTP 响应头,可错配
  Magic Number:文件前几字节的二进制签名,最可靠

攻击场景:
  恶意用户把 .php 改名为 .jpg 上传
  → 扩展名是 jpg,Content-Type 可能被服务器正确识别为 jpeg
  → 但 Magic Number 是 PHP 头
  → 上传到服务器后用 .php 后缀访问 → RCE 攻击

所以检测"图片是什么格式"不能只看扩展名或 Content-Type
要读前几字节:
  JPEG: FF D8 FF
  PNG:  89 50 4E 47
  HEIC: 前 12 字节里有 'ftypheic' 或 'ftypmif1'

本项目只是内部工具,风险较低,所以简单的扩展名过滤够用。C 端项目必须 Magic Number 校验

面试 / 技术对话角度

STAR 话术

  • 情境:模特选择弹窗第 7 张图片显示破图,其他正常
  • 任务:定位 + 修复 + 防止将来再出现
  • 行动
    1. 用"下载能看、浏览器打不开"这个现象排除网络和权限
    2. 对比 27 条数据,发现第 7 条 URL 是 .heic 格式
    3. 浏览器不支持 HEIC,但 macOS 系统层能解码
    4. 前端兜底:正则过滤 .heic URL(含 CDN 签名)
    5. 提交根治方案:后端上传时 HEIC → JPEG 转换
  • 结果:破图立即消失,根治方案排期

一段话面试话术

"模特弹窗里第 7 张图破图。关键线索是'下载到本地能打开、浏览器不能'——这一个现象直接把问题定位到'格式/编码不兼容'。对比 URL 发现第 7 张扩展名是 .heic——Apple 的高效图像格式,iOS 拍照默认就是这个,macOS 系统层能解码但浏览器不支持。修复有三层防线:前端兜底用正则 /\.heic(\?|$)/i 过滤掉(立即止血)、后端上传时做格式转换(根治)、上传入口 accept 限制避免再传进来(预防)。这个案例让我沉淀了一条排错直觉——"下载正常+在线破"是'格式/编码'类 bug 的标志性特征,不要往'网络/权限'方向排查。另外也反思到内部工具只做前端兜底是合理的成本选择,但 C 端必须三层防线齐全,不然 iOS 用户体验全塌。"

延伸讨论

  • Q:如果非要前端渲染 HEIC,怎么做? A:用 heic2any 这类库在浏览器里解码转 JPEG Blob,再赋给 img.src。但这要引入几百 KB 的 WASM 解码器,转换也耗时(单张 1-2 秒),不如服务端统一转完了再下发。

  • Q:accept="image/*"accept="image/jpeg,image/png" 有什么差别? A:image/* 允许所有浏览器识别为图片的格式,包括 HEIC(虽然选了之后还是显示破)。image/jpeg,image/png 精确限制——iOS 选 HEIC 时会自动转成 JPEG(Apple 的 Photos selector 行为)。写 accept 精确约束比泛化更安全

  • Q:如果后端转换 HEIC 要花 5 秒,用户等这么久怎么办? A:两个选项——1)上传时异步转,前端先显示占位图 + 处理中,转完了再 swap;2)前端上传前本地转(heic2any),虽然客户端慢但用户心理预期是"在传"不是"在看"。权衡看场景。


11. Bug修复:视频总进度大圆环与个人进度缩略图数值相同

现象

AIChangeClothesResult 页面,当用户生成 2 个视频时,视频区域的大圆环进度("总的视频进度 loading")显示 4%,下方 nav 缩略图中视频1的进度也显示 4%——两个数值完全相同,逻辑混乱。

第一反应(错误方向)

以为是数据同步问题,但检查后发现两处使用的是同一份数据 getDisplayProgress(videoTasks[0]),所以结果当然相同——问题不在数据,而在于"大圆环究竟应该展示什么"。

排查思路

对比图片区域(大圆环)和视频区域(大圆环)的计算逻辑:

  • 图片大圆环src/pages/AIChangeClothesResult/index.tsx:591-600):所有 execution 的平均进度(done=100%, running=个人进度, waiting=0%)
  • 视频大圆环(修复前):直接取 getDisplayProgress(videoTasks[activeIdx]),即active 视频的个人进度

两套逻辑不一致。图片的设计是正确的:大圆环代表"整体完成度",应该是所有任务的加权平均。

根因

视频大圆环取的是当前 active 视频的个人进度,而 nav 缩略图也取的是同一个视频的个人进度 → 必然相同。

本质原因:"整体进度"与"个人进度"的概念没有区分。当只有 1 个视频时两者相同,不会暴露问题;2 个视频时:

  • 视频1(running)个人进度 = 8%
  • 视频2(waiting)进度 = 0%
  • 总体平均 = (8+0)/2 = 4%(大圆环应显示)
  • 视频1缩略图应显示 8%(个人进度)

修复方案

src/pages/AIChangeClothesResult/index.tsx:905-938

将视频大圆环的 p 由"active 视频的个人进度"改为"所有视频的加权平均":

// 修复前
const p = getDisplayProgress(task); // task = videoTasks[activeIdx]

// 修复后(与图片大圆环对齐)
const allP = videoTasks.map((t, i) => {
    if (t.status === 10) return 100;           // 已完成 → 100%
    if (i === firstPendingIdx) return getDisplayProgress(t); // 执行中 → 个人进度
    return 0;                                  // 等待中 → 0%
});
const p = Math.round(allP.reduce((a, b) => a + b, 0) / videoTasks.length);

Nav 缩略图(index.tsx:977-981)保持不变,仍显示 firstPendingIdx 视频的个人进度。

相关代码位置

  • 修复点:src/pages/AIChangeClothesResult/index.tsx:905-940(视频大圆环计算)
  • 对比参考:src/pages/AIChangeClothesResult/index.tsx:591-600(图片大圆环,逻辑一致)
  • Nav 缩略图:src/pages/AIChangeClothesResult/index.tsx:977-981(个人进度,不变)

经验提炼

"总体进度"和"单项进度"是两个不同的 UI 概念,必须用不同的计算方式。 当只有 1 项任务时两者值相同,问题被隐藏;N>1 时问题暴露。排查时看"是否有相似功能的正确实现"(图片区域)是快速定位方式。

面试角度

面试话术:"这个 bug 是典型的'单任务掩盖多任务 bug'。只有 1 个视频时,总进度 = 个人进度,逻辑相同,测试不会发现。2 个视频时,大圆环显示 X%,缩略图也显示 X%,用户看起来两个 UI 没有区分度。根因是大圆环取的是 active 视频的个人进度,而不是所有视频的加权平均。修复方案参考了同页面图片 loading 的正确实现——图片大圆环早已用平均值,只是视频大圆环漏了这个逻辑。"

原理深挖——"聚合状态"和"单项状态"必须显式区分

这类 bug 的本质是"同形异义"——两个 UI 元素的数据长相一样,但语义不同:

大圆环 = 聚合进度(N 个任务的整体完成度)
缩略图 = 单项进度(第 i 个任务自己的完成度)

当 N=1 时:
  聚合进度 = 单项进度(等价关系)
  两个 UI 用同一个变量,看起来"对"
  
当 N>1 时:
  聚合进度 ≠ 单项进度
  代码里"同一变量服务两个语义"的骗局暴露

这是"N=1 掩盖多项 bug"的通用模式

为什么这类 bug 最容易漏测

测试用例设计的惯性:
  ├ 开发者:用 1 个 video 验证功能 → 过
  ├ 测试:按"主要流程"跑 → 基本都是 1 个
  ├ 产品:演示一般用 1 个(简单清晰)
  └ 真实用户:5 个、10 个的情况才出现

结论:
  **测试用例必须显式覆盖 N=0, N=1, N=2, N=多** 四个档位
  N=0:空状态(没任务时的 UI)
  N=1:单任务(退化情况,容易掩盖多任务 bug)
  N=2:多任务最小集合(暴露聚合 vs 单项问题)
  N=多:极端情况(布局、性能)

这叫 "Boundary Value Testing(边界值测试)"。在 QA 领域是基础,但在前端开发里经常被忽略——"功能能用"就提测了。

与前端已知知识的类比

这个 bug 的模式和 React key 陷阱同构:

React 列表:
  list.map((item, index) => <Row key={index} />)
  
  N=1 时:永远只有 1 个 <Row>,key=0
  N=2 时:顺序变了 index 就错,key 复用错 row → UI 错乱

同样是"N=1 时 index 作 key 不暴露问题,N>1 才炸"

解决方案也类似:
  用 item.id 做 key(而不是 index)
  用任务 id + 语义区分(而不是 activeIdx 混用两种语义)

数据流图——聚合进度的正确计算

┌──────────────────────────────┐
│ videoTasks[N]                │
│ [{status, individualProgress}]│
└──────────────┬───────────────┘
               │
          ┌────┴─────┐
          ▼          ▼
  ┌─────────────┐  ┌─────────────┐
  │ 聚合层       │  │ 单项层       │
  │ (大圆环)     │  │ (缩略图 N 个)│
  │              │  │              │
  │ reduce:     │  │ map:         │
  │ done → 100  │  │ 每项各自的   │
  │ running→p   │  │ individual   │
  │ waiting→0   │  │ Progress     │
  │ ──────      │  │              │
  │ 取平均      │  │              │
  └─────────────┘  └─────────────┘
         │                │
         ▼                ▼
      大圆环显示       N 个缩略图分别显示

关键:两条数据通路源于同一数据,但语义完全独立

设计模式识别——聚合状态模式

"Aggregate State Pattern"(聚合状态模式):

输入:N 个独立单元的状态
输出:整体的聚合状态

实现要点:
  1. 明确聚合规则(加权平均?最小值?最大值?自定义?)
  2. 空集处理(N=0 时返回什么?0?null?"等待中"?)
  3. 单位处理(不同单元进度不同范围怎么归一化)
  4. 特殊状态传播(如果有一个失败,整体算失败还是部分完成?)

本项目选择:
  done → 100
  running → 个人进度
  waiting → 0
  算术平均
  
其他合理选择(取决于业务):
  加权平均(某些任务更重要)
  critical path(木桶效应,以最慢的为准)
  完成率(done 个数 / 总数,不看部分进度)

用户心智——"N=1 时两个数字相同"其实也是体验问题

即使 N=1 时两个数字相等,UI 设计上也应该让用户明白语义:

❌ 糟糕:大圆环 4% + 缩略图 4%,用户"为啥两个都 4%,是不是 bug"
⚠️ 中等:大圆环 4%(写小字"总进度")+ 缩略图 4%(写小字"视频1")
✅ 更好:大圆环 50%(已完成 1/2)+ 缩略图 100%(视频1)+ 缩略图 0%(视频2)
   语义通过数字的"差异性"传递,用户一眼明白

本次修复是 ⚠️ → ✅ 的升级

质量思维——如何在开发阶段避免这类 bug

1. 单元测试覆盖边界值
   test('N=0: 大圆环应该返回 0', () => {...})
   test('N=1: 1 个 running 30% → 大圆环 30%', () => {...})
   test('N=2: 1 个 done + 1 个 waiting → 大圆环 50%', () => {...})
   test('N=2: 1 个 running 30% + 1 个 waiting → 大圆环 15%', () => {...})

2. Storybook / devmode 下模拟多任务场景
   写一个 "Progress Showcase" 页面,手动切换 N=0,1,2,3 看 UI 表现

3. 功能设计阶段就要问"N=多时是什么样"
   不只设计 N=1 的样子,要同时画 N=2, N=5, N=10 的 mockup

4. Code review checklist
   "这个计算/展示,N>1 时语义清晰吗?"

三层对比——多任务/多实例 UI 的设计层次

❌ 初级:写完 N=1 就提测,没想过 N>1
   → "单任务掩盖多任务"bug 上线

⚠️ 中级:知道要测 N>1,但手动点点就算了
   → 没建立"边界值测试"的习惯

✅ 资深:
   - 设计阶段同时画 N=0/1/多的 UI
   - 开发时写聚合逻辑 + 单项逻辑两套函数,编译期就分离
   - 单元测试覆盖边界值档位
   - Storybook 展示多状态
   - Review checklist 包含"多实例语义检查"

举一反三——同类 bug 模式

本 bug = 大圆环(聚合)vs 缩略图(单项)共用数据源

同类场景:
  ├ 文件上传:总进度(所有文件平均)vs 单文件进度
  ├ 多步表单:总完成度(步骤完成率)vs 当前步骤内进度
  ├ 音视频合成:整体渲染进度 vs 每个 track 的独立进度
  ├ 批量下载:总百分比 vs 某个文件的百分比
  └ 购物车:总金额(聚合)vs 单商品小计

共性:任何"多个子任务组合成一个整体"的场景
      都有"聚合态 vs 单项态"两个语义
      N=1 时会掩盖,N>1 时暴露

面试 / 技术对话角度

STAR 话术

  • 情境:AI 视频生成页面有"总进度大圆环"和"视频缩略图缩略进度"两个 UI,N=2 视频时两个数值相同看起来是 bug
  • 任务:区分"聚合"和"单项"语义
  • 行动
    1. 排除"数据同步"错误方向,发现用的是同一变量
    2. 对比同页面图片区域的大圆环(早已用聚合平均)
    3. 修视频大圆环:done=100、running=个人进度、waiting=0,取平均
    4. 缩略图保留个人进度
    5. 提炼"N=1 掩盖多任务 bug"的通用模式
  • 结果:UI 语义区分清晰,N>1 时不再产生"两个相同数字"的困惑

一段话面试话术

"视频生成页有个 bug:N=2 时总进度大圆环和视频缩略图都显示 4%,用户困惑'两个数字一样为啥还要分开展示'。排查后发现大圆环错用了 active 视频的个人进度(应该用所有视频的加权平均)。这是典型的 N=1 掩盖多任务 bug——当只有 1 个任务时,聚合进度 = 单项进度,问题不暴露;N>1 才炸。修复参考了同页面图片区域的大圆环逻辑(done=100, running=个人进度, waiting=0 后取平均)。这件事让我沉淀了一条设计原则:所有'多子任务组合成整体'的 UI,必须同时画 N=0/1/多 的状态——文件上传总进度、多步表单完成度、批量下载等都是同一个模式。Review checklist 加一条:'这个 UI 在 N>1 时语义清晰吗?'"

延伸讨论

  • Q:你说'对比图片区域'是快速定位方式——如果没有同类参考呢? A:那就要回归设计本意——"大圆环"通常是聚合(整体 overview),"列表项"通常是单项。没有参考时按 UI 惯例反推。再找不到规律,就看产品需求或用户心智:"用户想一眼看什么信息"。

  • Q:firstPendingIdx 是什么?为什么特意取这个? A:当前正在执行的那个视频的索引(所有 done 之后第一个不是 done 的)。只对"正在跑的那个"取实时 progress,其他"还没开始"的算 0%。这是合理的 UX——等待中的任务进度不传递错误信息。

  • Q:如果业务改成"所有视频并行生成",这个计算还对吗? A:不完全对。当前假设是串行(一次只有一个 running),并行场景要改成:videoTasks.map(t => t.status === 10 ? 100 : getDisplayProgress(t)) 然后取平均。聚合规则永远要跟着业务语义走。


12. 视频体验三连优化:无黑屏切换 + 缩略图 + 多选音乐

一句话结论

三处优化合并:切换视频不再黑屏、nav 显示封面缩略图、支持多选视频统一添加音乐。


优化1:切换视频无黑屏

现象与根因

点击 nav 切换视频时出现短暂黑屏:<video> 元素的 src 属性更新后,浏览器清空画面等待新视频解码,期间 poster 不会重新显示(poster 只在初始加载前显示一次)。

旧方案是 videoPlayerRef.current?.load() 触发重新加载,但这只是告诉浏览器刷新,并不解决"有一段时间画面是黑的"问题。

修复方案

<video> 元素加 key={video_${activeVideoIndex}},让 React 在切换时销毁旧元素、创建新元素。新元素在视频帧加载前会显示 poster(封面图),消除黑屏。配合 CSS animation: videoFadeIn 0.25s ease 实现淡入过渡。

// src/pages/AIChangeClothesResult/index.tsx:797
<video
    key={`video_${activeVideoIndex}`}  // ← key 变化 → React 重建 → poster 立即显示
    poster={task.coverUrl}
    ...
/>
// index.scss
&__player {
    animation: videoFadeIn 0.25s ease;  // ← 淡入,掩盖重建的瞬间
}
@keyframes videoFadeIn {
    from { opacity: 0; }
    to   { opacity: 1; }
}

为什么原注释说"不用 key 重建"? 原意是避免重建引起闪烁。但淡入动画 + poster 的组合效果比 load() 更流畅,旧方案实际上没解决问题。

与前端已知知识的类比

就像 React 的列表渲染:同 key 的元素会复用(保留状态),不同 key 的元素会重建(清空状态)。这里利用"重建"来清空旧视频内容、回到 poster 初始态——是对 key 语义的主动利用,而非规避。


优化2:nav 显示视频封面缩略图

设计取舍

原来 done 态的 nav item 是"胶囊按钮"(✓ 视频N),loading 态是"卡片"(56×72px)。两种形态不一致,且胶囊按钮传达不了视频内容。

新方案:全部统一为 56×72px 卡片形式:

  • coverUrl 有值 → 图片铺满卡片,底部半透明遮罩显示"视频N"
  • coverUrl 无值 → 占位色块
  • loading 态 → spinner 居中叠加,底部标签显示进度%
  • waiting 态 → 半透明,底部标签显示"等待中"
  • 选中态 → 右上角红色圆形勾

相关代码位置

  • CSS:index.scss &__video_nav 部分,新增 --card__thumb_img__thumb_label__check 等类
  • TSX:index.tsx:929-972

优化3:多选视频 + 统一添加音乐

交互设计

  • 点击 done 状态的缩略图 = 切换当前预览的视频 + toggle 选中状态
  • 选中时右上角显示红色勾,--selected 边框高亮
  • BGM 选择行和"合成背景音乐"按钮移出 per-video 的 player_wrap,提升到 nav 下方(全局共享)
  • 合成逻辑:有选中 → 对选中的 done 视频逐个合成;没有选中 → 对当前 active 视频合成
  • 按钮文案自适应:合成背景音乐 / 合成背景音乐(3个)

状态设计

// 新增状态(index.tsx:57)
const [selectedVideoIndices, setSelectedVideoIndices] = React.useState<Set<number>>(new Set());

// onSynthesizeMusic 改为接受数组(index.tsx:330)
const onSynthesizeMusic = async (tasks: Array<typeof videoTasks[0]>) => {
    // 遍历 tasks 逐个调用合成接口
};

数据流

用户点击 done 缩略图
    │
    ├── setActiveVideoIndex(i)   ← 切换预览
    └── setSelectedVideoIndices  ← toggle 选中
               │
               ▼
点击"合成背景音乐"
    │
    ├── selectedVideoIndices.size > 0
    │       └── onSynthesizeMusic(selected done videos)
    └── else
            └── onSynthesizeMusic([active video])

相关代码位置

  • 状态:index.tsx:57
  • nav 点击逻辑:index.tsx:937-946
  • BGM + 合成区域:index.tsx:973-1009
  • onSynthesizeMusicindex.tsx:330

面试角度

面试话术:"视频黑屏问题是 HTML5 video 的 poster 机制导致的——poster 只在 video 元素首次创建时展示,src 改变不会重新显示 poster。解决方案是利用 React 的 key 机制强制重建元素,同时用 CSS 淡入动画掩盖重建的瞬间。这是一个'把 React 的内部机制作为工具'的典型案例——你不是在绕开 key,而是主动用它来控制组件生命周期。"



13. Bug修复:图片生成总进度低于单张缩略图 + 时间估算不动

现象

/ai_change_clothes/result/49573,2张图片生成中:

  1. 总进度大圆环显示 62%,但第一张缩略图上显示 95%(总进度 < 单张进度,逻辑矛盾)
  2. 时间估算文字"还剩3分钟"从始至终不变,与进度无关

根因分析

Bug1:两套公式不同源

缩略图公式index.tsx:676-678,修复前):

// exec1(running,API返回95%)
p = Math.max(exec.progress=95, simPct=62) = 95%  // 取较大值

总进度公式index.tsx:591-599,修复前):

const avgExecPct = (exec1.api=95 + exec2.waiting=0) / 2 = 47.5%
p = Math.min(95, Math.max(avgExecPct=47.5, simPct=62)) = 62%
// simPct 把均值"抬高"到62%,但仍低于exec1的展示值95%

问题本质:总进度是 API 原始均值 + simPct 的混合;缩略图是 API 值 + simPct 的 max。 当 exec1 的 API 进度(95%)高于 simPct(62%)时:

  • 缩略图 = 95%(API 值胜出)
  • 总进度 = 62%(simPct 胜出,因为 avg=47.5% < simPct=62%)

导致总进度 < 单张缩略图进度,不合理。

Bug2:时间估算静态

// 修复前
const remaining = totalExecCount - doneExecCount;  // = 2(固定)
const estSec = remaining * 75;  // = 150s = 3分钟(永远不变)

remaining 只计算"未完成的任务数",完全不考虑各任务已完成多少,所以无论进度多少都是 3 分钟。

修复方案

Bug1:改变总进度公式,改为 各 exec 展示值(与缩略图公式完全一致)的均值:

// 修复后(index.tsx:590-602)
const simPct = getSimulatedProgress(taskCreatedAtRef.current);
const allDisplayProgress = [
    ...Array(doneExecCount).fill(100),
    ...pendingExecs.map((e) =>
        e.type === 'running' ? Math.min(95, Math.max(e.progress, simPct)) : 0
    ),
];
const p = Math.round(sum(allDisplayProgress) / allDisplayProgress.length);

现在两者同源:总进度 = 各缩略图展示值的平均,永远满足 总进度 ≤ max(各缩略图进度)

以 exec1=95%、exec2=0% 为例:

  • 缩略图1 = 95%,缩略图2 = 0%
  • 总进度 = (95 + 0) / 2 = 48%(不再出现 48% < 95% 的矛盾,因为含义不同)

Bug2:改为按各 exec 的实际剩余量估算,而非固定乘法:

// 修复后(index.tsx:604-611)
const estSec = pendingExecs.reduce((acc, e) => {
    if (e.type === 'running') {
        const displayP = Math.min(95, Math.max(e.progress, simPct));
        return acc + Math.round(perImageSec * (1 - displayP / 100));
    }
    return acc + perImageSec;  // waiting:按全量估
}, 0);

以 exec1=95%、exec2 waiting 为例:

  • exec1 剩余 = 75 × (1-0.95) ≈ 4秒
  • exec2 剩余 = 75秒(waiting)
  • 合计 ≈ 79秒 → "预计还需要1分钟"

核心原则

总进度必须与各子项的展示值同源。 一旦总进度和子项用不同公式计算,就会出现"子项进度 > 总进度"的逻辑矛盾,用户看了会困惑。

面试角度

面试话术:"这类进度显示 bug 的根因几乎都是'父子进度公式不统一'。父容器拿 API 原始值求均值,子项拿 API 值和模拟值取 max——两套公式,导致子项可以高于父。正确做法是:先确定子项的展示值,父容器的进度是这些展示值的聚合(均值/加权均值),保证数学上 父 = mean(子),永远不会矛盾。"

原理深挖——为什么有 "simulated progress"

用户等待 AI 生成 → 如果只看 API 真实进度:
  ├ 前期 API 频繁返回"已开始"但进度数值很低(模型在"思考"阶段)
  ├ 中期跳到 30%、50% → 60%
  ├ 后期长时间停在 95%(等最后的编码/上传)
  └ 用户看到的进度条是"跳跃式"的,不连续

→ 为了让用户感觉"一直在前进",加了一层 simulated progress:
  基于任务创建时间,按经验曲线模拟一个"应该到多少了"
  和 API 的真实进度取 max,取较大的值显示
  
  效果:进度条平滑前进,用户体感好
  代价:有时会"超前"(模拟 > 真实),但不会回退(max 保证单调递增)

这是一个经典的 UX 欺骗——"真实进度"和"用户感知进度"不一致,但用户更重要的是"感觉在前进"而不是"精确对齐后端"。大多数长任务产品(如系统更新、下载管理器)都用类似技巧。

为什么两套公式会不统一

代码演进的典型路径:

V1:只有"单任务"场景
  缩略图 = API 进度 + simPct 取 max
  总进度 = 同一个公式
  两者一致,无问题

V2:支持"多任务并行"
  缩略图:保持原公式(每项独立 max)
  总进度:改成"所有 API 进度的均值"
  → 一个改了一个没改 → 两套公式
  → 当 API 值显著超过 simPct 时,不一致暴露

这是"增量修改没同步相关逻辑"的经典坑

设计原则——"父子聚合一致性"

定义:父容器显示的聚合值,必须能由各子项的显示值推出
      数学上:父 = f(子1.display, 子2.display, ..., 子N.display)

反例(本 bug):
  父 = mean(子的 API 值)
  子 = max(自己的 API 值, simPct)
  父不是 f(子.display),而是 f(子.api)
  → 违反一致性

修复:
  父 = mean(子的 display 值)
  子 = max(自己的 API 值, simPct)
  → 一致

这条原则在所有"聚合 UI"里都适用——购物车总价必须 = sum(单品小计)、团队总任务 = sum(个人任务数)、文件总大小 = sum(单文件大小)。

时间估算的设计权衡

简单估算:剩余任务数 × 平均时长
  优点:实现简单
  缺点:已完成 95% 的任务和 0% 的任务都按"全量"算 → 估算错得离谱

精确估算:剩余任务数 × 平均时长 × (1 - 已完成比例)
  优点:准确
  缺点:需要每个任务的 progress,不能只用"任务数"

本项目修复后采用的:
  对 running 任务按剩余比例估
  对 waiting 任务按全量估
  合计
  
  → 精度够用 + 实现成本低

面试时可追问:为什么不用机器学习预测?答:ROI 太低——用户能接受"+/- 30%"的估算误差,上 ML 要数据标注、训练、部署,成本远大于收益。这是**"工程判断力"的体现**。

质量思维——进度类 UI 的测试维度

必须覆盖的测试用例:
  1. N=0:无任务状态
  2. N=1:单任务 0%, 50%, 95%, 100%
  3. N=多 全部 waiting:总进度应为 0%
  4. N=多 全部 done:总进度应为 100%
  5. N=多 混合状态:聚合值要合理
  6. 边界:simPct > API 值时的表现
  7. 边界:API 值突然下降(网络抖动)时是否回退
  8. 时间:进度 100% 后时间估算应归零
  9. 时间:单位切换(秒→分钟→小时)
  10. 监听:组件卸载时定时器是否清理

这是一个"状态空间"测试,如果只点一遍主流程肯定漏一半

三层对比——进度 UI 的实现层次

❌ 初级:直接把 API 返回值绑到 UI,不处理平滑
   → 用户看到进度条跳跃式前进,体验差

⚠️ 中级:加 simulated progress 模拟平滑,但父子用不同公式
   → 本 bug 的起点——看起来"处理过了",其实埋雷

✅ 资深:
   1. simulated progress 策略(UX 优化)
   2. 父子进度公式一致(数学正确性)
   3. 时间估算按剩余比例(精度合理)
   4. 测试覆盖 N=0/1/多 + 边界值
   5. 组件卸载清理定时器(内存安全)

性能思维——simulated progress 的实现成本

方案 A:setInterval 每秒触发重渲染
  每秒 1 次 setState → 全屏重渲染
  如果多个 AI 任务 × 每个都有缩略图 × 每秒刷新
  = 一秒钟可能几十次渲染

方案 B:用 requestAnimationFrame
  更丝滑,但仍触发 React 重渲染

方案 C:直接操作 DOM(避开 React)
  useRef 取 DOM,requestAnimationFrame 里改 style.transform / SVG stroke-dashoffset
  零 React 重渲染,浏览器自己绘制
  → 100 个进度条同时跑毫无压力

本项目用的是 A(React 重渲染),性能够用因为任务数少
规模化产品会用 C

面试 / 技术对话角度

STAR 话术

  • 情境:AI 图片生成页面出现"总进度 62% 但单张已经 95%"的矛盾;时间估算始终 3 分钟不变
  • 任务:修复进度计算逻辑 + 时间估算逻辑
  • 行动
    1. 根因分析——父用 API 原始均值 + simPct,子用 API 和 simPct 取 max,两套公式
    2. 修复原则——父 = mean(子的 display 值),保证聚合一致性
    3. 时间估算——按每个 task 的"剩余比例 × 单位时间"累加,不再用"任务数"粗估
    4. 沉淀通用原则——"父子聚合一致性"是所有聚合 UI 的共同要求
  • 结果:进度数值合理(父≤max(子)、父=mean(子)),时间估算随进度变化

一段话面试话术

"AI 图片生成页的进度矛盾是'父子公式不统一'的典型问题——父容器(总进度)用 API 原始均值 + simulated progress 计算,子项(缩略图)用 API 和 simulated progress 取 max,两套公式。当某个 API 值很高时,子项 max 很高,父平均被其他 0% 拉低 → 父 < max(子),逻辑矛盾。修复思路是让父 = mean(子.display),数学上保证一致。另外提炼了一条通用原则:任何'聚合 UI'都必须遵守父子公式同源——购物车总价 = sum(单品小计)、团队任务数 = sum(个人任务)、进度聚合 = mean(子进度)。时间估算的坑是简单用'剩余任务数 × 单位时长',没考虑各任务的剩余比例,改成按 (1 - 已完成比例) × 单位时长 累加才精准。整个修复背后是进度类 UI 的质量框架——simulated 平滑、公式一致、精度合理、边界值测试齐全。"

延伸讨论

  • Q:simulated progress 的算法曲线怎么设计? A:常用是"前快后慢"的 S 型曲线——前期进度涨得快让用户有"在动"的感觉,后期慢下来留出 API 真实收尾的空间。如 sigmoid、easeOut、或手动调节的经验值。

  • Q:如果 API 返回的进度突然从 80% 掉到 50%(服务端 bug),UI 怎么办? A:进度条应该单调非递减——不回退,避免"倒退的视觉欺骗"。所以 max(API, simPct, 上一次的值)。API 回退时用缓存的最大值。

  • Q:你说"用 requestAnimationFrame 直接操作 DOM"比 React 状态更新快——具体怎么做? A:useRef 拿到 DOM 节点,RAF 回调里改 style.width 或 SVG 的 strokeDashoffset。这样每帧只有 GPU compositor 干活,React 根本不知道进度在变。缺点是破坏了 React 声明式的心智模型——只在性能关键场景用,普通 UI 优先用 state。


14. Bug修复:视频缩略图 spinner 滑动问题 + 圆环内嵌百分比

现象

视频生成过程中,视频缩略图(nav item)上的进度圆环不停地从左上角往右下角滑动,而不是原地旋转。

根因

CSS 动画 transform 的属性覆盖问题。

元素初始状态用 transform: translate(-50%, -60%) 居中:

// 修复前
&__spinner {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -60%);  // ← 居中
    animation: thumb_spin 0.8s linear infinite;
}

@keyframes thumb_spin {
    to { transform: rotate(360deg); }   // ← 只有 rotate,覆盖了 translate
}

CSS 动画关键帧里写 to { transform: rotate(360deg) } 时,它完全替换(而不是叠加)元素的 transform。浏览器会从初始值 translate(-50%, -60%) 插值到 rotate(360deg)(等价于 translate(0, 0) rotate(360deg)),所以元素在旋转的同时也从中心滑向左上角(0,0),形成"斜向滑动"效果。

这是 CSS transform 动画的一个常见坑:keyframes 会完全替换元素已有的 transform,而不是叠加

修复方案

彻底替换为 SVG 进度圆环。SVG 用 stroke-dashoffset 动画表示进度,这是 SVG 属性动画而非 CSS transform,不存在覆盖问题。居中改用 top: calc(50% - 20px); left: calc(50% - 20px) 静态定位(不使用 transform),消除冲突。

同时将百分比文字放入圆环 <text> 标签内(满足"圆环内嵌百分比"需求):

// src/pages/AIChangeClothesResult/index.tsx:974~998
{isRunning && (() => {
    const p = getDisplayProgress(t);
    const r = 16;
    const circ = 2 * Math.PI * r;
    return (
        <svg className="ai_change_result__video_nav__ring" width="40" height="40" viewBox="0 0 40 40">
            {/* 背景轨道圆 */}
            <circle cx="20" cy="20" r={r} fill="none" stroke="rgba(255,255,255,0.35)" strokeWidth="3" />
            {/* 进度弧:stroke-dashoffset 控制进度,无 CSS transform 动画 */}
            <circle
                cx="20" cy="20" r={r}
                fill="none" stroke="#FF3C5B" strokeWidth="3"
                strokeDasharray={`${circ}`}
                strokeDashoffset={`${circ * (1 - p / 100)}`}
                transform="rotate(-90 20 20)"  // SVG 静态旋转非动画
                style={{ transition: 'stroke-dashoffset 0.8s ease-out' }}
            />
            {/* 圆环内的百分比文字 */}
            <text x="20" y="24" textAnchor="middle" fill="#fff" fontSize="9" fontWeight="600">
                {p}%
            </text>
        </svg>
    );
})()}

核心原理:CSS transform 动画覆盖机制

元素 CSS: transform: translate(-50%, -50%)
keyframes to: transform: rotate(360deg)

动画执行时的插值:
  0%   → translate(-50%, -50%)  rotate(0deg)   ← 初始值
  50%  → translate(-25%, -25%)  rotate(180deg) ← 插值(translate 往 0 靠)
  100% → translate(0, 0)        rotate(360deg) ← 终态(translate 消失)

结果:元素在旋转的同时从中心滑向左上角。

解决方法(三选一):

  1. keyframes 里保留 translate(本次未采用,因为换成 SVG 更合适)
  2. top/left calc() 替代 transform 居中(本次采用)
  3. 用 wrapper 元素承担 translate,内层只做 rotate

面试角度

面试话术:"CSS animation 的 keyframes 会完全替换元素的 transform 值,而不是叠加。如果你用 transform 定位元素,再用 animation 做旋转,就会出现'边旋转边位移'的诡异效果。修复有三种方法:在 keyframes 里保留 translate、改用 top/left 定位、或者 wrapper 嵌套(外层 translate 不参与动画,内层只旋转)。这个坑在移动端加载动画里很常见。"

原理深挖——CSS transform 是"单属性"而非"函数列表"

开发者心智(错误):
  transform: translate(-50%, -50%);
  transform: rotate(360deg);
  "我以为是叠加",像数组一样各加一项

CSS 规范(正确):
  transform 是一个属性,值是"transform functions list"
  
  transform: translate(-50%, -50%) rotate(360deg);
  这整串才是一个值
  
  再赋值:
  transform: rotate(360deg);
  ← 这是新的一个值,完全替换前一个
  translate 消失

这个"属性覆盖"机制不仅在 animation 里有,在任何"样式级联"的场景都会发生:

CSS 选择器优先级:
  .foo { transform: translate(-50%, -50%); }
  .bar { transform: scale(1.2); }  ← 这个元素如果同时命中两个规则
  
  最终 transform 是 scale(1.2),translate 丢失
  
  正确写法:
  .bar { transform: translate(-50%, -50%) scale(1.2); }

三种修复方案的取舍

方案 A:keyframes 里保留 translate
  @keyframes spin {
    0%   { transform: translate(-50%, -60%) rotate(0deg); }
    100% { transform: translate(-50%, -60%) rotate(360deg); }
  }
  
  优点:代码改动最小
  缺点:定位值硬编码在 keyframes 里,定位改了要同步改
  坑:transform-origin 要设在 "50% 60%" 或调整

方案 B:用 top/left 定位(本次选择)
  top: calc(50% - 20px);
  left: calc(50% - 20px);
  transform: rotate(360deg);  // 动画里只管 rotate
  
  优点:定位和变换解耦,清晰
  缺点:宽高变化时 calc 要跟着改
  
方案 C:wrapper 嵌套
  <div style="transform: translate(-50%, -50%)">  // 外层定位
    <div style="animation: spin">  // 内层只旋转
  </div>
  
  优点:职责分层最干净
  缺点:DOM 多一层

为什么选 SVG 替代 CSS transform 方案

CSS 方案能修好(用方案 A/B/C 任一),但选 SVG 有额外价值:

1. stroke-dashoffset 做进度动画天然适合
   SVG 属性动画不受 transform 影响
   给任意进度值都能平滑过渡

2. 圆环内嵌文字是 <text> 一行
   CSS 方案要定位一个 <span>,考虑 transform 冲突

3. SVG 的 viewBox 让尺寸响应式
   想从 40x40 改成 60x60,改 width/height,viewBox 不变,内部计算自动适配

4. SVG 本身是矢量,大屏放大不模糊

代价:多几十行 SVG 代码,对团队要求"知道 SVG 基础"

与前端已知知识的类比

CSS transform 覆盖 ≈
  React 的 setState({a:1}) 完全替换整个 state 的 a 字段(对象情况下浅合并)
  Redux 的 reducer 返回新 state 会完全替换
  都是"赋值 = 替换",不是"赋值 = 合并"

前端 transform 坑 ≈
  新手容易写 transform: rotate(45deg); 不知道前面的 translate 没了
  类似新手 setState(prev => ({...prev}))  // 忘了 prev 的字段

SVG 进度圆环的数学原理

stroke-dasharray:虚线模式
  例:"10 5" = 画 10 停 5,循环
  "100" = 一整段不停

stroke-dashoffset:虚线起始偏移
  "0" = 从头开始
  "circ" = 偏移一整圈 = 不显示

进度圆环的实现:
  周长 C = 2πr
  stroke-dasharray = C(一段连续的线)
  stroke-dashoffset = C * (1 - progress/100)
  
  progress=0   → offset=C → 整圈都是"偏移",看不到
  progress=50  → offset=C/2 → 显示一半
  progress=100 → offset=0 → 完整一圈
  
  配合 transition 做平滑动画
  配合 transform="rotate(-90 20 20)" 让起点在 12 点方向

这个技巧面试高频——"用 SVG 实现一个圆环进度条"。答得出来的,面试官知道你懂 SVG 底层。

性能思维——CSS transform vs SVG 的渲染代价

CSS transform 动画:
  GPU 合成层,帧率稳定 60 FPS
  不触发 React 重渲染
  几乎零 CPU 开销

SVG stroke-dashoffset 动画:
  浏览器重绘(paint),不是合成
  每帧要重新计算矢量路径
  10 个 SVG 进度条同屏 = 可能掉帧

结论:
  纯装饰动画用 CSS(旋转、缩放、位移)
  有状态驱动的动画用 SVG(进度、百分比)
  大量进度动画 → 考虑 Canvas(一次性绘制所有)

本项目进度圆环数量少(单张/几张),SVG 性能够用

设计模式识别——"定位 vs 变换"的分层原则

这个 bug 暴露了一个通用原则:

"定位"和"变换"应该在 CSS 里分层:

定位层(放在哪里):
  position + top/left/right/bottom
  margin
  flexbox / grid 的位置

变换层(看起来什么样):
  transform (rotate / scale / skew)
  opacity
  filter

动画通常作用在"变换层"
定位应该用静态 CSS 实现

如果两者混用同一个 transform 属性:
  → 动画会破坏定位
  → 重构用 flex/grid 定位,transform 只做变换

本 bug 修复的本质就是把"定位"从 transform 迁到 top/left,解耦两个层次。

三层对比——CSS 动画的实现层次

❌ 初级:keyframes 直接写 transform: rotate
   不知道会覆盖已有 translate → "诡异位移"

⚠️ 中级:意识到覆盖问题,在 keyframes 里带上 translate
   修好但脆弱 → 改定位值就要同步改 keyframes

✅ 资深:
   1. 定位和变换分层:定位用 top/left/flex,变换留给 transform
   2. 复杂动画用 SVG 或 Canvas 原生能力(不依赖 transform)
   3. 考虑性能:CSS 合成层 vs SVG 重绘 vs Canvas 一次性
   4. 代码可维护性:wrapper 分层 / 命名规范

质量思维——CSS 动画的测试方式

CSS 动画是"表现层",常规 Jest/RTL 测不到
测试手段:
  1. Storybook 手动检查各状态(idle / running / done)
  2. Chromatic 视觉回归(截图 diff)
  3. Playwright 脚本:动画期间截图,比对预期
  4. 手动回归:每次改动跑一遍 dev 点一下

本项目目前是 4(手动)
如果动画多了/频繁改,升级到 2(Chromatic)

面试 / 技术对话角度

STAR 话术

  • 情境:视频生成时缩略图上的进度 spinner 不是原地旋转,而是边转边从中心滑向左上角
  • 任务:修复诡异动画 + 实现"圆环内嵌百分比"
  • 行动
    1. 根因定位——CSS keyframes 的 transform: rotate(360deg) 完全替换了元素初始的 translate(-50%, -60%)
    2. 评估三种修复方案(keyframes 带 translate / top/left 定位 / wrapper 分层)
    3. 换用 SVG 实现——stroke-dashoffset 做进度、静态 top/left 定位、text 内嵌百分比
    4. 提炼原则:"定位和变换"应分层实现
  • 结果:动画修正、百分比展示、SVG 矢量响应式

一段话面试话术

"视频 spinner 边转边滑是经典的 CSS transform 覆盖 bug——transform: translate(-50%, -60%) 做居中,再加 animation: rotate 时,keyframes 里只写了 transform: rotate(360deg),完全替换了 translate,元素从 translate(-50%, -60%) rotate(0deg) 插值到 translate(0, 0) rotate(360deg),视觉上就是斜向滑动。修复的通用原则是**'定位和变换'应该分层**——定位用 top/left/flex/grid,transform 只管变换。本次直接升级为 SVG 实现:stroke-dashoffset 做进度、transform 静态旋转到 12 点方向、text 嵌入百分比。SVG 的好处是属性动画不受 CSS transform 覆盖影响、内嵌文字简单、viewBox 响应式。面试里如果被问'用 SVG 做一个圆环进度条'——周长 C=2πr、stroke-dasharray 设成 C、stroke-dashoffset 算 C * (1-p/100)、配合 transition 平滑,三行数学搞定。"

延伸讨论

  • Q:为什么 SVG 用 rotate(-90 20 20) 而不是 rotate(-90) A:SVG 的 rotate 默认绕原点 (0,0)。指定 -90 20 20 是绕 (20, 20) 旋转 -90 度,让圆环起点在 12 点方向而不是 3 点。这是 SVG 和 CSS rotate 的一个差异——CSS 默认绕元素中心(transform-origin 50% 50%),SVG 默认绕 (0,0)。

  • Q:如果 1000 个进度圆环同屏,性能怎么办? A:Canvas 一次性绘制。用 requestAnimationFrame 每帧重绘所有圆环,不触发 React 重渲染,GPU 效率远高于 DOM。例如抖音点赞动画就是这种思路。

  • Q:CSS 的 transform: translatetranslate 属性(CSS3 新增)有什么区别? A:新的 translaterotatescale 是独立属性,不会互相覆盖——这就是为了解决本 bug 这类覆盖问题设计的。浏览器支持已经很好(2022+)。新项目可以直接用 translate: -50% -60%; animation-name: rotate-only;,兼容性要求高才用旧的 transform


15. 新功能:"我的模特"弹窗 — 历史模特管理 + 上传新模特

一句话结论

将"自主上传模特"按钮改造为"我的模特"弹窗,支持查看用户历史上传的模特并选择 + 上传新模特两个功能,替代原来只能直接上传的交互。

为什么这样设计

原有交互是点击"自主上传模特"直接打开文件选择器上传,存在两个问题:

  1. 上传过的模特无法复用 — 每次都要重新上传,用户体验差
  2. 没有管理入口 — 无法查看/选择自己之前上传过哪些模特

新设计用一个底部弹窗(Popup)承载两个 Tab,既保留了上传能力,又增加了历史模特的选择能力。

原理 / 本质

核心变化是把模特上传从一次性操作变成了可持久化的资源管理

  • 之前:button → file input → upload → 临时 ImageItem (blobUrl + docId) → 仅存在于当前页面状态
  • 现在:button → Popup → 选择已有 CCModel / 上传新模特并保存到后端 → 数据持久化在后端

上传后通过 saveChangeClothesCustomModelApi 保存到后端,下次打开弹窗通过 fetchChangeClothesModelsApi({ type: 2 }) 取回。

与前端已知知识的类比

你熟悉 React 中非受控组件(file input)和受控状态的区别。原来的实现就像一个非受控文件上传 — 选完文件就丢进 state,没有持久层。改造后变成了类似"相册选择器"的模式 — 有一个数据列表(历史模特)可以浏览选择,也能上传新的进入列表。

数据流

用户点击"我的模特"
        │
        ▼
┌─────────────────���───────────┐
│  CustomModelPopup 弹窗       │
│                             │
│  Tab1: 我的模特              │
│  fetchChangeClothesModelsApi │──→ 后端查询 type=2 的模特
│  ({ type: 2 })              │←── CCModel[] 列表
│  选择/取消选择               │
│                             │
│  Tab2: 上传新模特            │
│  file input → uploadFile    │──→ 获取 docId
│  → saveCustomModelApi       │──→ 持久化到后端
│  → fetchHistory() 刷新列表  │
│                             │
│  [确认选择] → onConfirm()   │
└─────────┬───────────────────┘
          │ CCModel[]
          ▼
  父组件 setCustomModels(models)
          │
          ▼
  已选模特区域展示 + 提交时使用

相关代码位置

  • 新组件src/pages/AIChangeClothes/components/CustomModelPopup/index.tsx — 弹窗主体
  • 新样式src/pages/AIChangeClothes/components/CustomModelPopup/index.scss — 弹窗样式
  • 主页面修改src/pages/AIChangeClothes/index.tsx:51-52 — 状态从 uploadedModelImages: ImageItem[] 改为 customModels: CCModel[]
  • 按钮改造src/pages/AIChangeClothes/index.tsx:505-510 — "自主上传模特" → "我的模特" 按钮
  • API 复用src/api/index.ts:507-516fetchChangeClothesModelsApi({ type: 2 }) 查历史模特
  • API 复用src/api/index.ts:572-582saveChangeClothesCustomModelApi 保存新模特

代码示例

// CustomModelPopup 核心:拉取历史 + 上传新模特后自动刷新
// 为什么用 fetchHistory 而不是手动拼 CCModel?
// 因为后端 save 后返回的数据不一定包含 docUrl,
// 重新拉列表能确保拿到完整的 CCModel(含 docUrl)

const fetchHistory = async () => {
    const res = await fetchChangeClothesModelsApi({ type: 2 });
    setHistoryModels(toArray(res.data));
};

// 上传成功后
await saveChangeClothesCustomModelApi({ name, docId, type: 2 });
await fetchHistory();        // 刷新列表
setActiveTab('history');      // 切回列表 tab,用户可以看到并选择新模特
// 父组件集成 — maxSelect 动态计算
// 为什么是 MAX_MODELS - selectedModels.length?
// 因为 ModelSelectPopup 选的模特和 CustomModelPopup 选的共享 5 个上限
<CustomModelPopup
    maxSelect={MAX_MODELS - selectedModels.length}
    initialSelected={customModels}
    onConfirm={(models) => setCustomModels(models)}
/>

面试角度

面试官可能怎么问

  • "如果一个功能既要展示历史数据又要支持新增,你会怎么设计组件?"
  • "上传后如何保证列表数据的一致性?"

标准回答框架

  1. 结论:用 Tab 分离"浏览选择"和"新增上传"两个动作
  2. 原理:上传 → 持久化 → 重新拉取列表,保证数据源单一(后端 API 是唯一真相源)
  3. 取舍:重新拉取列表比手动拼装前端数据更可靠,虽然多一次请求,但避免了前后端数据不一致
  4. 实际经验:改造前用户上传的模特是临时状态(blobUrl),页面刷新就丢失;改造后持久化到后端,可跨会话复用

面试话术:"我把一个一次性的文件上传改造成了资源管理模式。核心思路是上传后立即持久化到后端,然后通过重新 fetch 列表来更新 UI,而不是在前端手动拼装数据。这样保证了数据源的单一性——后端接口返回什么,前端就展示什么。Tab 分离浏览和上传两个动作,符合移动端用户的操作习惯。"


16. 新功能:穿版模特弹窗按 categoryId 分组展示

一句话结论

模特选择弹窗中,将API返回的模特图按 categoryId 分组展示——与查询参数一致的分类作为主分类按3列网格正常展示,其他分类的图片按同组同行的方式排列,图片数量决定尺寸。

为什么这样设计

  • 视觉归类:API ec-aigc-modelImage-list 返回的模特图包含多个 categoryId,例如查询 categoryId=2004 会同时返回 3011、3012、3013 等组图。直接平铺会导致不同部位/风格的模特图混在一起,用户无法快速区分
  • 组图概念:相同 categoryId 的图片属于同一组,用户需要看到它们的整体搭配效果,所以放在一行展示更直观
  • 弹性布局:同组图片可能是3张、4张甚至更多,使用 flex: 1 让每个 item 等分行宽,数量越多单张越小,自然适应

原理 / 本质

本质是一个 分组 + 差异化布局 的前端展示模式:

  • 数据层:用 Map<categoryId, CCModel[]> 做分组,分离出 primaryModels(匹配查询ID)和 groupedModels(其他分类)
  • 布局层:primaryModels 使用 CSS Grid repeat(3, 1fr) 标准3列网格;groupedModels 每组使用 Flexbox 一行排满

与前端已知知识的类比

类似电商 SKU 色卡展示——同一款的不同配色放在一行,不同款式分行展示。Grid 负责固定列数的整齐布局,Flex 负责动态数量的弹性布局。

数据流 / 执行流程图

API 返回 allModels (混合 categoryId)
        │
        ▼
┌──────────────────────────────────┐
│ displayModels (排序后)            │
│ 按 categoryId 分组               │
└────────┬─────────────────────────┘
         │
    ┌────┴────┐
    ▼         ▼
primaryModels  groupedModels
(catId=查询ID) (catId≠查询ID,按组排列)
    │         │
    ▼         ▼
3列Grid      每组一行 Flex

相关代码位置

  • src/pages/AIChangeClothes/components/ModelSelectPopup/index.tsx:154-175 — 分组逻辑(useMemo)
  • src/pages/AIChangeClothes/components/ModelSelectPopup/index.tsx:321-393 — 分组渲染(主分类Grid + 组图Row)
  • src/pages/AIChangeClothes/components/ModelSelectPopup/index.scss:169-212 — 新增样式(grouped容器 + group_row弹性行)

代码示例

// 按 categoryId 分组,区分主分类和组图分类
const { primaryModels, groupedModels } = React.useMemo(() => {
  const primary: CCModel[] = [];
  // Map 保持插入顺序,所以相同 categoryId 的图片会聚在一起
  const groupMap = new Map<number, CCModel[]>();

  for (const model of displayModels) {
    if (model.categoryId === queryCategoryId) {
      // 与查询参数一致的 → 主分类,正常 3 列展示
      primary.push(model);
    } else {
      // 不一致的 → 按 categoryId 分组,同组同行
      const catId = model.categoryId;
      if (!groupMap.has(catId)) groupMap.set(catId, []);
      groupMap.get(catId)!.push(model);
    }
  }

  // 按组内第一个 showOrder 排序,保证展示顺序稳定
  const groups = Array.from(groupMap.entries()).sort(
    (a, b) => (a[1][0]?.showOrder ?? 0) - (b[1][0]?.showOrder ?? 0)
  );

  return { primaryModels: primary, groupedModels: groups };
}, [displayModels, queryCategoryId]);

面试角度

面试官可能怎么问

  • "如果后端返回的列表数据包含多种类型,你怎么在前端做分组展示?"
  • "同一个列表中不同分组需要不同的布局方式,你怎么处理?"

标准回答框架

  1. 结论:用 Map 按分类 ID 分组,主分类用 Grid 布局,子分类用 Flex 弹性行
  2. 原理:Map 保持插入顺序适合分组;Grid 适合固定列数场景,Flex 适合动态数量自适应
  3. 取舍:纯前端分组而非要求后端分组返回,减少接口改动成本;flex: 1 让图片数量自然决定尺寸
  4. 实际经验:查询 categoryId=2004 时,2004 的图正常 3 列展示,3011(4张)、3012(4张)等各自一行展示

面试话术:"后端返回的模特图列表混合了多个分类,我在前端用 Map 按 categoryId 分组。与查询参数匹配的分类作为主区域用 CSS Grid 3 列展示,其他分类各自占一行用 Flexbox 的 flex:1 等分空间,图片多则尺寸自动缩小。这样用户能快速区分不同部位的组图效果,同时避免了后端接口改动。"


17. 图片渐进式渲染优化 — CSS Blur-up + 渐进式图片格式原理

一句话结论

将模特选择弹窗的图片从"加载完才显示"改为"先模糊可见、加载完变清晰"的 blur-up 渐进式渲染,同时理解了业内渐进式图片的完整技术体系。

为什么这样设计

  • 原方案opacity: 0onLoadopacity: 1,图片在加载完成前完全不可见,突然出现有"闪现"感
  • 新方案filter: blur(12px)onLoadfilter: blur(0),图片一开始就可见(模糊状态),浏览器边下载边渲染,加载完后平滑变清晰
  • 取舍:CSS blur-up 是纯前端方案,不需要后端配合,实现简单但效果不如 LQIP/BlurHash。对于当前项目(自有 doc 服务器、无 CDN 缩略图参数),是性价比最高的选择

原理 / 本质

渐进式图片渲染有两个层面:

1. 图片格式级别 — 编码方式决定渲染行为

Baseline JPEG:
从上到下逐行解码,上面清晰、下面空白
│████████████████│
│████████████████│
│████░░░░░░░░░░░░│ ← 加载到这
│░░░░░░░░░░░░░░░░│
│░░░░░░░░░░░░░░░░│

Progressive JPEG:
多轮扫描(scan),每轮覆盖完整画面
第1轮(~15%数据)    第2轮(~50%数据)    第3轮(100%数据)
┌──────────┐    ┌──────────┐    ┌──────────┐
│ 极度模糊  │ →  │ 中度模糊  │ →  │  完全清晰  │
│ 能看轮廓  │    │ 轮廓清晰  │    │  完整细节  │
└──────────┘    └──────────┘    └──────────┘

底层机制:JPEG 用 DCT(离散余弦变换)压缩。Progressive JPEG 把 DCT 系数按频率分层——第一轮只传低频系数(色块轮廓),后续逐轮补充高频系数(纹理细节)。浏览器每收到一轮数据就重绘一次完整画面。

各格式支持情况

  • JPEG:Progressive JPEG,多轮 DCT 扫描 ✅
  • PNG:Adam7 隔行扫描(7 轮填充不同位置像素),但文件体积增大 5-20% ⚠️
  • WebP:不支持渐进式 ❌
  • AVIF:不支持渐进式 ❌

2. 前端工程级别 — 弥补格式不足

方案 原理 依赖 体验
LQIP 先加载 ~1KB 缩略图放大模糊,再替换原图 CDN 缩略图 URL ★★★★
BlurHash 图片编码为 ~30 字节字符串,内联在 API JSON 中,Canvas 解码为彩色模糊占位 后端生成 hash ★★★★★
SQIP SVG 几何图形近似表达图片内容 构建时生成 ★★★
CSS Blur-up 原图直接加载,初始 blur 遮盖加载过程 ★★★
Progressive JPEG 格式级别原生支持 图片转码 ★★★★

与前端已知知识的类比

你熟悉的 React Suspense fallback 和 Skeleton 组件,本质上就是一种"低保真占位 → 高保真内容"的渐进式渲染思路:

  • Skeleton = 结构占位(类似 SQIP 的几何占位)
  • Blur-up = 内容占位(看得到模糊的真实内容)
  • BlurHash = 数据级别的 Skeleton(不需要额外请求,数据跟着 API 走)

数据流 / 执行流程图

CSS Blur-up 方案(当前实现):

弹窗打开
   │
   ▼
┌────────────────────────────┐
│  <img> 挂载到 DOM            │
│  CSS: filter: blur(12px)    │──→ 用户看到:灰色背景 + 模糊色块
│       transform: scale(1.05)│    (scale 防止 blur 边缘透明)
│       overflow: hidden      │
└──────────┬─────────────────┘
           │ 浏览器开始下载图片
           │ 边下载边渲染(blur 遮盖)
           ▼
┌────────────────────────────┐
│  onLoad 触发                 │
│  → classList.add('loaded')  │──→ CSS transition 0.4s:
│                              │    filter: blur(12px) → blur(0)
│                              │    transform: scale(1.05) → scale(1)
└────────────────────────────┘

相关代码位置

  • 服装风格标签图片 CSS:src/pages/AIChangeClothes/components/ModelSelectPopup/index.scss:63-75
  • 模特网格图片 CSS:src/pages/AIChangeClothes/components/ModelSelectPopup/index.scss:262-274
  • 组合图片 CSS:src/pages/AIChangeClothes/components/ModelSelectPopup/index.scss:237-249
  • JS onLoad 处理:src/pages/AIChangeClothes/components/ModelSelectPopup/index.tsx:315(风格标签)、:365(网格图)

代码示例

// src/pages/AIChangeClothes/components/ModelSelectPopup/index.scss

// ❌ 修改前:opacity 隐藏 → 突然出现
& > img {
  opacity: 0;                        // 完全不可见
  transition: opacity 0.25s ease;
  &.loaded { opacity: 1; }           // 加载完突然出现
}

// ✅ 修改后:blur-up 渐进式
& > img {
  filter: blur(12px);                // 可见但模糊,遮盖加载过程
  transform: scale(1.05);           // 放大 5% 防止 blur 边缘溢出透明
  transition: filter 0.4s ease, transform 0.4s ease;
  &.loaded {
    filter: blur(0);                 // 平滑变清晰
    transform: scale(1);             // 恢复原始尺寸
  }
}
// 为什么加 decoding="async"?
// 浏览器默认同步解码图片,大量图片同时解码会阻塞主线程导致卡顿
// async 让解码在后台线程进行,不阻塞 UI 渲染
<img
  src={model.docUrl || ''}
  loading="lazy"       // 视口外的图片延迟加载,减少并发请求
  decoding="async"     // 后台线程解码,不阻塞主线程
  onLoad={(e) => e.currentTarget.classList.add('loaded')}
/>
// 注意:用 classList 而非 useState,避免触发 React re-render

面试角度

面试官可能怎么问

  • "图片加载体验优化你做过哪些?渐进式渲染是怎么实现的?"
  • "Progressive JPEG 和普通 JPEG 有什么区别?原理是什么?"
  • "WebP 比 JPEG 更好为什么不直接用 WebP?"
  • "LQIP 和 BlurHash 的区别是什么?各自适用什么场景?"

标准回答框架:格式级别 → 工程级别 → 项目实际选择 → 取舍

面试话术:"图片渐进式渲染分两个层面。格式层面,Progressive JPEG 通过分轮传输 DCT 系数实现从模糊到清晰的原生渐进;但 WebP/AVIF 不支持这个特性。工程层面,业内主流方案有 LQIP(先加载缩略图模糊放大)和 BlurHash(API 内联 30 字节 hash 前端 Canvas 解码占位)。我在项目中根据后端能力选择了 CSS blur-up 方案——图片初始可见但带 blur 滤镜,onLoad 后 transition 移除模糊。配合 loading=lazy 减少并发、decoding=async 避免主线程阻塞,用纯前端方案达到了接近 LQIP 的体验效果。"


18. 项目架构技术盘点 — 10 个值得深入研究的技术模式

一句话结论

本项目是一个移动端 AI 虚拟穿版/图生视频的 SPA,虽然体量不大,但在状态管理、API 封装、轮询机制、原生通信、Canvas 图像处理等方面有多个生产级别的设计模式值得系统性掌握。


① Jotai 原子化状态管理 — 告别 Redux 样板代码

相关代码src/state/global.tssrc/state/taskList.tssrc/state/taskEdit.ts

为什么用 Jotai 而不是 Redux

Redux 三件套(action → reducer → store)对中小项目来说样板代码太多。Jotai 的核心理念是 原子化——每个状态是一个独立的 atom,组件按需订阅,不会触发无关组件的重渲染。

本质原理

┌──────────────────────────────────────────────┐
│  Jotai 的 atom 是一个"最小状态单元"           │
│  atom() → 基础原子(类似 useState)           │
│  atom(get => ...) → 派生原子(类似 useMemo)  │
│  atom(async get => ...) → 异步派生原子        │
└──────────────────────────────────────────────┘

类比前端已知知识:

  • atom() ≈ React 的 useState,但状态提升到全局
  • atom(get => ...) ≈ Vue 的 computed,依赖自动追踪
  • useAtomValue ≈ Redux 的 useSelector,但不需要写 selector

三种原子模式在项目中的体现

1. 基础原子global.ts:21-27):

// 全局数据,所有页面共享
const globalData = atom<TGlobalData>({
  appName: '',
  appVersion: '',
  query: new URLSearchParams(),
  subscribe: '0',
});

2. 派生原子 + 缓存失效taskList.ts:191-210):

// 用一个"计数器原子"触发异步原子重新请求 —— 这是 Jotai 手动失效缓存的标准模式
const taskDetailIt = atom(1); // 每次 +1 就强制刷新

const taskDetail = atom(async (get) => {
  const taskList = get(taskListAtom);
  const index = get(activeTaskIndex);
  const task = taskList[index];
  const it = get(taskDetailIt); // 依赖这个计数器,计数器变了就重新执行
  if (task && task.id && it) {
    return fetchTaskDetailApi(task.id).then((res) => res.data);
  }
});

为什么这样设计:Jotai 的异步原子有内置缓存——相同依赖值不会重复请求。但任务详情需要"主动刷新"(比如轮询),所以用一个递增计数器作为"缓存 buster"。这和 React Query 的 queryKey 变化触发重请求是同一个思路。

3. 局部更新 Hookglobal.ts:35-40):

// 不需要传完整对象,只传要改的字段
export const useSetPartialGlobalData = () => {
  const [_, setValue] = useGlobalData();
  return (params: Partial<TGlobalData>) => {
    setValue((value) => ({ ...value, ...params }));
  };
};

面试角度

  • "Jotai 和 Redux/Zustand/Recoil 的区别?"
  • "如何在 Jotai 中实现手动缓存失效?"
  • "原子化状态管理的优缺点?"

面试话术:"我们项目用 Jotai 替代 Redux 做全局状态管理。Jotai 的核心是原子化——每个 atom 是最小状态单元,组件只订阅自己关心的原子,不会被无关状态更新触发重渲染。对于异步数据,Jotai 提供了 async atom,它自带缓存机制——相同依赖不会重复请求。我们通过一个递增计数器原子来手动触发缓存失效,实现轮询时的强制刷新。整个状态层代码量大概只有 Redux 方案的 1/3,但可组合性更好。"


② 统一 API 层 — 单入口 POST 请求架构

相关代码src/utils/request.tssrc/config/network.tssrc/api/

本质

整个项目所有 API 调用都走一个 request() 函数,向同一个 URL POST,靠 apiKey 字段区分接口。这是后端"网关模式"的前端适配。

用户操作
   │
   ▼
┌──────────────────────────────────────────────────┐
│ request(url, { apiKey, jsonParam, sessionId })    │
│                                                    │
│  1. 自动注入 commonParams(sessionId 等)          │
│  2. jsonParam 自动 JSON.stringify                  │
│  3. 整体 Qs.stringify 做 form-encoded              │
│  4. Axios 拦截器:response 直接取 .data            │
│  5. 统一错误处理:code===0 成功,-110 鉴权,其他异常│
│  6. Sentry.captureException 上报                   │
└──────────────────────────────────────────────────┘
         │
         ▼
  POST /amkt/api.do (所有接口同一个 URL)

关键设计细节

form-encoded + JSON 混合编码request.ts:49-58):

transformRequest: [
  function (params: any) {
    // jsonParam 是复杂对象,需要先 stringify 再整体 form-encode
    if (params.jsonParam) {
      params.jsonParam = JSON.stringify(params.jsonParam);
    }
    return Qs.stringify(params); // 最终格式: apiKey=xxx&jsonParam={"a":1}&sessionId=xxx
  },
],

为什么这样:后端网关按 form-encoded 解析基础参数(apiKey、sessionId),但业务参数可能很复杂(嵌套对象),所以用 jsonParam 字段装 JSON 字符串。这是"信封模式"——外层是固定结构,内层按业务自定义。

错误不 reject,resolve 兜底request.ts:15-22):

// 响应拦截器:HTTP 层错误也 resolve,统一在 request 内部判断
axios.interceptors.response.use(
  (response) => response['data'],    // 成功:直接取 data
  (error) => Promise.resolve(error.response)  // 失败:不 reject,交给下面判断
);

为什么:这样 request 函数内部可以统一处理所有情况(HTTP 错误、业务错误、无 code 响应),而不是在拦截器和 catch 两个地方分别处理。

多环境动态路由(network.ts

export const requestUrl = {
  get amkt() {  // 用 getter,每次访问时动态计算
    let env = process.env.REACT_APP_ENV;
    if (process.env.REACT_APP_MODE === 'audit') {
      env = AUDIT_ENV; // 审核模式可以运行时切换环境!
    }
    if (env === 'test') return 'https://hzdev.hzdlsoft.com/amkt/api.do'
    else if (env === 'check') return 'https://mktchk36.hzdlsoft.com/amkt/api.do'
    else return 'https://mkt.hzecool.com/amkt/api.do'
  },
};

亮点:用 getter 而非常量。process.env 在构建时就确定了,但 AUDIT_ENV 可以运行时修改——审核员可以在不重新构建的情况下切换目标环境。

面试角度

  • "前端 API 层如何设计才好维护?"
  • "Axios 拦截器适合处理什么逻辑?"

面试话术:"我们的 API 层采用单入口设计——所有请求走同一个 request 函数,POST 到统一网关 URL,通过 apiKey 区分接口。请求参数用'信封模式':外层 form-encoded 放鉴权参数,内层 jsonParam 放业务 JSON。响应拦截器把 HTTP 错误 resolve 而不是 reject,这样 request 内部可以统一做 code 判断、Sentry 上报、鉴权跳转,避免错误处理逻辑分散在拦截器和业务代码两处。环境切换用 getter 实现运行时动态路由,审核模式可以不重新构建就切换后端环境。"


③ 任务轮询机制 — 错误容忍 + 增量更新 + 智能终止

相关代码src/state/taskList.ts:236-313

为什么需要轮询

AI 任务(穿版、视频生成)是异步的——用户提交后,后端排队处理,前端需要持续查询进度。WebSocket 对这个场景过重(低频、无实时性要求),所以用 setInterval 轮询。

完整执行流程

usePoll(taskId, 2000, true)
   │
   ├── 立即执行一次 fetchData()(获取完整任务详情)
   │
   └── setInterval 每 2s 执行:
       │
       ├── executions=true → fetchExecutions()
       │   │
       │   ├── 筛出 status===1||2 的未完成 execution
       │   ├── 只请求这些 execution 的状态(增量!)
       │   └── 合并到现有 executions 数组
       │
       ├── executions=false → fetchData()(全量刷新)
       │
       ├── 成功 → errorCount 重置为 0
       │         → 同步更新列表页的对应项(跨组件状态同步)
       │
       └── 失败 → errorCount++
                → errorCount > 3 → 停止轮询 + 提示用户

三个核心设计决策

1. 增量查询 vs 全量查询taskList.ts:253-276):

const fetchExecutions = () => {
  // 只查未完成的 execution,不是重新拉整个任务
  const unFinishedExec = detailRef.current?.executions?.filter(
    (ex) => ex.status === 1 || ex.status === 2
  );
  if (unFinishedExec && unFinishedExec.length > 0) {
    return fetchTaskExecDetailApi(
      Number(taskId),
      unFinishedExec.map((exec) => exec.executionId).join(',') // 只传未完成的ID
    ).then(({ data }) => {
      // 关键:新数据合并到旧数组,而不是替换
      const oldExec = [...detailRef.current!.executions];
      const newExec = oldExec.map((exec) => {
        const newItem = data.executions.find((nexec) => nexec.executionId === exec.executionId);
        return newItem || exec; // 有更新用新的,没更新保留旧的
      });
    });
  }
};

为什么:一个任务可能有 10 张图同时生成,但可能 8 张已完成、2 张进行中。只查 2 张的状态,减少 90% 的数据传输。

2. 错误容忍taskList.ts:286-291):

fetchs().catch((e: any) => {
  errorCountRef.current += 1;
  if (errorCountRef.current > 3) {  // 容忍 3 次错误
    messageApi.error(e.message);
    idRef.current && clearInterval(idRef.current);
  }
});

为什么不是第一次错误就停:移动端网络不稳定,偶尔一次超时很正常。连续 3 次失败才说明真的有问题。

3. Ref vs State 双轨taskList.ts:237-238):

const [detail, setDetail] = useState<ITaskDetail | null>(null);  // 驱动UI渲染
const detailRef = useRef<ITaskDetail | null>(null);               // 在回调中读最新值

为什么需要 ReffetchExecutions 中需要读取"当前最新的 executions 列表"来筛选未完成项。如果用 state,闭包捕获的是 setInterval 创建时的旧值。Ref 永远指向最新值。

面试角度

  • "前端如何实现任务轮询?有哪些要注意的?"
  • "setInterval 在 React 中使用有什么陷阱?"

面试话术:"AI 任务是异步执行的,前端用 setInterval 轮询状态。我们做了三层优化:一是增量查询——只请求未完成的子任务状态,减少 80%+ 数据传输;二是错误容忍——容忍 3 次连续错误再停止,适应移动端不稳定网络;三是用 useRef 配合 useState 双轨机制——state 驱动 UI,ref 在 setInterval 回调中读取最新值,避免闭包陷阱。组件卸载时清除 timer,任务完成时主动 cancel 停止轮询。"


④ 指数衰减进度模拟 — 让用户"感觉快"

相关代码src/pages/AIChangeClothesResult/index.tsx:62-74src/pages/AIVideoResult/index.tsx:87-98

问题

AI 任务执行中,后端没有"30%、50%"这样的进度反馈,只有"进行中"和"完成"两个状态。直接显示"加载中..."体验很差。

方案:指数衰减函数模拟进度

// τ=22:按单张图片~65秒校准,95%落在约65秒处
const getSimulatedProgress = (startTime: number): number => {
  const elapsed = (Date.now() - startTime) / 1000;
  return Math.min(95, Math.round((1 - Math.exp(-elapsed / 22)) * 100));
};

数学原理

progress = 1 - e^(-t/τ)

t = 经过的秒数
τ = 时间常数(控制曲线"快慢")

当 τ=22 时:
  t=0s   → 0%   (开始)
  t=5s   → 20%  (快速上升,给用户"很快"的感觉)
  t=15s  → 50%  (到一半)
  t=22s  → 63%  (一个 τ)
  t=65s  → 95%  (三个 τ,上限锁死 95%)
  ∞      → 95%  (永远不到 100%,等真实完成信号)

    100% ┌──────────────────────────
         │           ╭────────── 95% 上限
     95% │         ╱
         │       ╱
     50% │     ╱
         │   ╱
         │  ╱     ← 前期快速上升,后期趋于平缓
         │╱         用户心理:前期有进展感,后期不焦虑
      0% └────────────────────────── t(s)
         0   5   15  22   44   65

平滑插值 — 防止跳变

const getSmoothProgress = (key: string, target: number): number => {
  const current = displayProgressRef.current.get(key) || 0;
  if (target <= current) return current;  // 永远不后退
  const step = Math.max(1, Math.round((target - current) * 0.15)); // 每帧走剩余距离的15%
  const next = Math.min(target, current + step);
  displayProgressRef.current.set(key, next);
  return next;
};

为什么:定时器每 tick 算出的 target 可能跳变(比如从 30% 直接到 45%)。加一层 0.15 系数的线性插值,让进度条"丝滑"过渡。这和动画中的 lerp(线性插值)是同一个技巧。

面试角度

  • "没有后端进度反馈时,前端怎么做进度条?"
  • "为什么用指数函数而不是线性函数?"

面试话术:"AI 任务没有中间进度反馈,我们用指数衰减函数 1 - e^(-t/τ) 模拟进度。选这个函数有两个原因:一是它前快后慢的曲线符合用户心理预期——开始感觉'有进展',后期逐渐趋于平稳减少焦虑;二是它有天然的渐近线,永远不到 100%,在真实完成信号到来之前锁死在 95%。再加一层 0.15 系数的 lerp 插值,防止进度条跳变。τ 参数根据实际任务耗时校准——图片生成约 65 秒,取 τ=22,让 95% 刚好落在 65 秒附近。"


⑤ RLE 解码 + Canvas 蒙版 — SAM 分割结果的前端渲染

相关代码src/components/MaskEdit/helpers/maskUtils.tsxsrc/components/MaskEdit/index.tsx

场景

用户鼠标/手指 hover 在图片上时,需要实时高亮显示 AI 分割出的区域(衣服、人物等)。后端返回的分割数据是 COCO RLE 格式。

RLE(Run-Length Encoding)原理

原始蒙版(二维 0/1 矩阵):
0 0 0 1 1 1 0 0 1 1 1 1 0

RLE 编码(按列优先存储):
[3, 3, 2, 4, 1]
 ↑  ↑  ↑  ↑  ↑
 3个0 3个1 2个0 4个1 1个0

压缩率:原始 13 个值 → RLE 5 个值

注意 COCO RLE 是列优先的——像素按列扫描而非行扫描,解码时需要转置:

// maskUtils.tsx:104-141
function decodeCocoRLE([rows, cols], counts, flat = true) {
  let binaryMask = Array(rows * cols).fill(0);
  let pixelPosition = 0;

  for (let i = 0; i < counts.length; i += 2) {
    let zeros = counts[i], ones = counts[i + 1] ?? 0;
    pixelPosition += zeros; // 跳过 0
    while (ones > 0) {
      // 关键:列优先 → 行优先 的坐标转换
      const rowIndex = pixelPosition % rows;
      const colIndex = (pixelPosition - rowIndex) / rows;
      const arrayIndex = rowIndex * cols + colIndex; // 转成行优先的一维索引
      binaryMask[arrayIndex] = 1;
      pixelPosition++;
      ones--;
    }
  }
  return binaryMask;
}

Canvas 渲染流程

RLE counts 数组
      │
      ▼
  decodeCocoRLE()  → 一维 0/1 数组(行优先)
      │
      ▼
  创建 Canvas + ImageData
      │
      ▼
  遍历蒙版:1 → 蓝色半透明  [12,77,245,168]
            0 → 全透明      [255,255,255,0]
      │
      ▼
  putImageData → canvas.toDataURL() → 作为 <img> src 叠加

鼠标命中检测 — 不用解码整个蒙版

hover 时不需要解码整个 RLE,直接在 RLE counts 数组上做命中检测:

// 计算鼠标点击的像素在列优先编码中的位置
const target = Math.max(Math.round(click.x - 1), 0) * height + Math.round(click.y) - 1;
let num = 0;
for (let i = 0; i < r.segmentation.counts.length; i++) {
  num += r.segmentation.counts[i];
  if (num > target) {
    const isInMask = i % 2 === 1; // RLE 奇数位是 1,偶数位是 0
    break;
  }
}

为什么这样做:解码整个蒙版需要 O(width×height) 空间和时间,而直接在 RLE 上二分/遍历只需要 O(counts.length),在 mousemove 这种高频事件中性能差距很大。

面试角度

  • "COCO RLE 编码是什么?怎么解码?"
  • "Canvas 中如何实现图片蒙版叠加?"

面试话术:"我们的 AI 分割功能用 SAM 模型输出 COCO RLE 格式的蒙版数据。RLE 是游程编码——交替存储连续 0 和 1 的个数,压缩率很高。COCO 格式的特殊点是列优先存储,解码时需要做行列转置。渲染时创建等大 Canvas,把二值蒙版映射到 ImageData 的 RGBA 通道——1 对应半透明蓝色叠加层,0 对应全透明。鼠标 hover 检测时我们不解码整个蒙版,而是直接在 RLE 数组上累加判断命中位置,从 O(w×h) 优化到 O(counts.length)。"


⑥ Native Bridge 通信 — WebView 与原生 App 的双向桥接

相关代码src/utils/index.ts:11-31src/route.tsx:26-60

问题

这个 SPA 运行在多个宿主环境中:React Native WebView(笑铺日记 App)、原生 WebView(纷享/其他 App)、普通浏览器。需要一套代码适配所有环境。

桥接探测 + 降级策略

// utils/index.ts:11-31
function _nativePostMessage() {
  if (window.ReactNativeWebView?.postMessage) {
    // React Native WebView 注入的全局对象
    return (msg: any) => window.ReactNativeWebView.postMessage(msg);
  } else if (window.nativeWebView?.postMessage) {
    // 原生 Android/iOS WebView 注入的对象
    return (msg: any) => window.nativeWebView.postMessage(msg);
  } else {
    // 普通浏览器:postMessage 给自己(降级,不会crash)
    return (msg: any) => window.postMessage(msg, '*');
  }
}
export const nativePostMessage = _nativePostMessage(); // 启动时探测一次,后续直接调用

为什么只探测一次:桥接对象在页面加载时就确定了,不会中途变化。用闭包缓存探测结果,避免每次调用都做 if/else 判断。这是"策略模式"的轻量实现。

消息协议

// 所有消息都是 JSON 字符串,eventType 区分类型
nativePostMessage(JSON.stringify({
  eventType: 'closeApp'       // 关闭 WebView
}));

nativePostMessage(JSON.stringify({
  eventType: 'saveImageToAlbum',  // 保存图片到相册
  data: { url: imageUrl }
}));

nativePostMessage(JSON.stringify({
  eventType: 'shareImageToWechat', // 分享到微信
  data: { url, title }
}));

面试角度

  • "Hybrid App 中 WebView 和原生怎么通信?"
  • "如何一套代码适配多个宿主环境?"

面试话术:"我们的 H5 运行在 React Native WebView 和原生 WebView 两种容器中。通信采用 postMessage 桥接——启动时一次性探测 window 上的注入对象(ReactNativeWebView.postMessage 或 nativeWebView.postMessage),用策略模式缓存到闭包里,后续调用零判断开销。消息协议是 JSON 字符串,用 eventType 区分操作类型。纯浏览器环境降级为 window.postMessage 保证不 crash。这种'探测 → 缓存 → 统一接口'的模式在跨端开发中非常通用。"


⑦ react-activation 页面缓存 — 解决 SPA 返回状态丢失问题

相关代码src/route.tsx:112-124src/App.tsxsrc/pages/AliTryOnTaskList/index.tsx:32-42

问题

用户在任务列表滑动到第 50 条 → 点进详情 → 按返回 → 列表从头开始,滚动位置丢失、数据重新加载。这是 React SPA 的经典痛点——组件卸载后状态全部销毁。

方案

// route.tsx — 用 KeepAlive 包裹需要缓存的路由
{
  path: 'tasks',
  element: (
    <KeepAlive cacheKey="ali_try_on_tasks">
      <AliTryOnTaskListPage />
    </KeepAlive>
  ),
}

// App.tsx — 最外层 AliveScope 提供缓存容器
<AliveScope>
  <RouterProvider router={router} />
</AliveScope>

原理KeepAlive 不是用 CSS display:none 隐藏,而是把组件的 Fiber 树"移出" React 树但保留在内存中,返回时"移回"。效果类似 Vue 的 <keep-alive>

智能刷新 — 返回时要不要重新加载?

// AliTryOnTaskList/index.tsx:32-42
useActivate(() => {
  // 从详情页回来,检查是否需要刷新(比如用户刚创建了新任务)
  const state = location.state?.refresh === true || getHashQueryParam('refresh') === '1';
  if (!shouldRefreshOnce) return;

  refresh(undefined, 'image');

  // 刷新后清除标记,避免再次返回时重复刷新
  navigate('/ali_try_on/tasks', {
    replace: true,
    state: { refresh: false },
    preventScrollReset: true  // 保持滚动位置!
  });
});

为什么用两种方式传 refresh 信号location.state 是 React Router 的标准方式,但 KeepAlive 的闭包可能捕获旧值。getHashQueryParam('refresh') 作为备用——URL 参数不受闭包影响。

面试角度

  • "React SPA 如何保持列表页滚动位置?"
  • "KeepAlive 的原理是什么?和 display:none 有什么区别?"

面试话术:"我们用 react-activation 实现路由级页面缓存,解决列表页返回后状态丢失的问题。它的原理是把组件 Fiber 树从 React 树中'暂存'到内存,而不是简单的 CSS 隐藏——这意味着被缓存的组件不参与 diff 和渲染,性能更好。同时我们实现了'选择性刷新':通过 location.state 和 URL 参数双通道传递刷新信号,解决了 KeepAlive 闭包捕获旧 state 的问题。"


⑧ Axios 拦截器 — 错误处理的"不 reject"策略

相关代码src/utils/request.ts:5-22

设计决策

// 响应拦截器
axios.interceptors.response.use(
  (response) => response['data'],       // 成功:直接剥掉 Axios 包装层
  (error) => Promise.resolve(error.response) // 失败:resolve 而不是 reject!
);

为什么不 reject

                         常规做法(reject)
                    ┌──────────────────────────┐
                    │ 拦截器 reject             │
                    │    ↓                     │
                    │ request 的 catch 处理     │
                    │    ↓                     │
                    │ 但如果是 code≠0 怎么办?   │
                    │ 又需要一套逻辑……          │
                    │ 错误处理分散在两处         │
                    └──────────────────────────┘

                      本项目做法(全部 resolve)
                    ┌──────────────────────────┐
                    │ 拦截器全部 resolve         │
                    │    ↓                     │
                    │ request 内部统一判断:     │
                    │   res.code === 0 → 成功   │
                    │   res.code === -110 → 鉴权│
                    │   res.status ≠ 200 → HTTP │
                    │   !res → 网络断开          │
                    │   → 所有错误一个地方处理    │
                    └──────────────────────────┘

本质:把 Axios 的"双通道"(resolve/reject)合并为"单通道"(全部 resolve),在 request 函数内部做完整的状态判断。错误处理的单一职责。


⑨ 多环境部署 — 构建时 vs 运行时环境切换

相关代码src/config/network.tspackage.json

两种环境切换并存

方式 变量 确定时机 场景
构建时 REACT_APP_ENV webpack build 普通部署
运行时 AUDIT_ENV 页面操作 审核模式
// network.ts:1-5
export let AUDIT_ENV = 'prod'; // let 而非 const,允许运行时修改

export function updateAUDIT_ENV(env: 'test' | 'check' | 'prod') {
  AUDIT_ENV = env; // 审核员点击切换环境按钮时调用
}

为什么审核模式需要运行时切换:审核员需要在同一个页面上查看测试环境和生产环境的任务,不可能每次切环境都重新构建部署。


⑩ Sentry 错误监控 + Aplus 埋点 — 可观测性体系

相关代码src/utils/request.ts:93src/state/global.ts:42-62

两套体系分工

用户行为追踪(Aplus)          系统错误监控(Sentry)
       │                            │
       ▼                            ▼
  CLK 事件上报                 Exception 捕获
  - 按钮点击                   - API 错误(非0 code)
  - 页面浏览                   - JS 运行时异常
  - 下载/分享                  - 网络错误
       │                            │
       ▼                            ▼
  阿里 Aplus 后台               Sentry Dashboard
  (业务分析)                  (技术排障)

Aplus 埋点的 Hook 化封装

// global.ts:42-62 — 把埋点封装成 Hook,自动注入用户标识
export const useRecordEvent = () => {
  const data = useGlobalDataValue(); // 自动拿到当前用户的 sn

  return (eventName: string, params: object = {}) => {
    const { aplus_queue } = window;
    if (aplus_queue) { // 防御性检查:CDN 没加载不 crash
      aplus_queue.push({
        action: 'aplus.record',
        arguments: [eventName, 'CLK', { ...params, sn: data.sn }],
      });
    }
  };
};

为什么用 Hook 而不是工具函数:需要从 Jotai 原子中读取 sn(用户序列号),Hook 可以直接订阅 atom,工具函数不行。


总结:技术深度分布图

前端基础能力                    本项目涉及的进阶主题
─────────────────────────────────────────────────────
React 组件         ──→  KeepAlive 缓存 / useActivate 生命周期
useState           ──→  Jotai 原子化 / 派生原子 / 缓存失效
fetch/axios        ──→  统一网关 / 信封模式 / 拦截器单通道
setInterval        ──→  错误容忍轮询 / Ref 双轨 / 增量更新
CSS 动画           ──→  指数衰减模拟进度 / lerp 平滑插值
Canvas 基础        ──→  RLE 解码 / 像素级蒙版 / 命中检测
window.postMessage ──→  Native Bridge / 策略模式 / 多宿主适配
process.env        ──→  Getter 动态路由 / 运行时环境切换
console.log        ──→  Sentry 异常上报 / Aplus 行为埋点

每个技术点都不是"为了炫技"——它们各自解决了真实的生产问题。理解"为什么需要"比"怎么实现"更重要。


19. 深度踩坑与设计取舍 — 中级前端工程师的进阶补充

一句话结论

第 18 章讲了"是什么"和"为什么",这章讲"如果你自己写会在哪里翻车"——用 错误写法 vs 正确写法 的对比,把那些"看源码觉得理所当然、自己写就出 Bug"的隐藏知识点挖出来。


① setInterval 闭包陷阱 — 为什么 useState 读到的永远是旧值

相关代码src/state/taskList.ts:237-238

先看一个你一定会写的错误版本

// ❌ 错误写法:在 setInterval 回调中用 state
const usePoll = (taskId: number) => {
  const [detail, setDetail] = useState(null);

  useEffect(() => {
    const id = setInterval(() => {
      // 假设 detail 已经有了 10 个 executions
      // 你想筛出未完成的:
      const unFinished = detail?.executions?.filter(e => e.status === 1);
      //                  ^^^^^^ 永远是 null!因为闭包捕获了 useEffect 首次执行时的 detail
      fetchExecDetail(unFinished);
    }, 3000);
    return () => clearInterval(id);
  }, []); // ← 依赖数组为空,回调永远捕获初始 state
};

根因:JavaScript 闭包 + React 渲染模型

时间线:

t=0  useEffect 执行,创建 setInterval
     此时 detail = null
     setInterval 的回调函数"拍了一张快照":detail = null
     ┌──────────────────────────┐
     │ 回调闭包内的 detail → null │ ← 永远不变
     └──────────────────────────┘

t=3  轮询返回数据,setDetail(newData)
     React 重新渲染,组件内 detail = newData
     但 setInterval 的回调是 3 秒前创建的那个!
     它的闭包里 detail 还是 null

t=6  回调再次执行,detail 还是 null...

本质setInterval 的回调函数在创建时就"冻结"了当时的作用域。React 的 useState 每次渲染返回新值,但旧闭包看不到新值。

项目中的正确做法 — useRef 做"逃生通道"

// ✅ 正确写法:Ref 是可变引用,不受闭包限制
const [detail, setDetail] = useState<ITaskDetail | null>(null);  // 驱动渲染
const detailRef = useRef<ITaskDetail | null>(null);               // 在回调中读

const fetchData = () => {
  return fetchTaskDetailApi(taskId).then((res) => {
    setDetail(res.data);          // 更新 UI
    detailRef.current = res.data; // 同步更新 ref
  });
};

const fetchExecutions = () => {
  // 用 ref 读,永远是最新值!
  const unFinished = detailRef.current?.executions?.filter(
    (ex) => ex.status === 1 || ex.status === 2
  );
  // ...
};

为什么 Ref 不受闭包影响

useRef 返回的是同一个对象引用 { current: xxx }

t=0  创建 ref = { current: null }
     闭包捕获的是 ref 对象的引用(指针),不是值

t=3  ref.current = newData
     闭包里的 ref 还是同一个对象
     但 ref.current 已经被修改了

关键区别:
  useState  → 闭包捕获"值"(不可变)
  useRef    → 闭包捕获"引用"(可变,.current 可以改)

类比:useState 像你拍了一张纸条的照片,纸条改了照片不变。useRef 像你记住了纸条放在哪个抽屉,你打开抽屉永远能看到最新内容。

你可能想到的另一个方案 — 为什么不把 detail 加到依赖数组?

// ⚠️ 理论可行但有严重副作用
useEffect(() => {
  const id = setInterval(() => {
    const unFinished = detail?.executions?.filter(...);
    // 现在 detail 是最新的了
  }, 3000);
  return () => clearInterval(id);
}, [detail]); // ← 每次 detail 变化,销毁旧 timer + 创建新 timer

问题:每次 detail 更新(每 3 秒),清除旧 interval 再创建新 interval。如果 clearInterval 和新 setInterval 之间有微小时间差,会导致轮询间隔不稳定。更严重的是,如果 detail 更新很频繁(比如 10 个 execution 逐个完成),interval 会被反复重建,造成性能抖动。

结论:对于需要在 setInterval 中读最新 state 的场景,useRef 是唯一正确的通用方案

面试角度

  • "React Hooks 中 setInterval 有什么常见陷阱?"
  • "useRef 除了操作 DOM,还有什么用途?"

面试话术:"setInterval 在 React 中的经典陷阱是闭包过期——回调函数捕获了 useEffect 创建时的 state 快照,之后 state 更新了,回调里的值不变。解决方案是用 useRef 做'逃生通道':ref 对象的引用在组件生命周期内不变,但 .current 可变,闭包通过引用读到最新值。我在项目中的轮询 Hook 里就用了 state+ref 双轨模式——state 驱动 UI 渲染,ref 在 setInterval 回调中提供最新数据。另一个方案是把 state 加到 useEffect 依赖数组里,但这会导致 interval 被反复销毁重建,轮询间隔不稳定。"


② 双定时器架构 — 模拟进度与服务端轮询的协作

相关代码src/pages/AIChangeClothesRecordDetail/index.tsx:72-89, 99-196

为什么需要两个定时器

┌─────────────────────────────────────────────────────────────┐
│                    双定时器架构                               │
│                                                              │
│  Timer 1: simTimer (1s 间隔)                                │
│  ┌──────────────────────────────────────┐                    │
│  │ 每秒 forceUpdate → 重新计算模拟进度    │                    │
│  │ 纯前端,不发网络请求                    │                    │
│  │ 用于:进度条动画、时间估算              │                    │
│  └──────────────────────────────────────┘                    │
│                                                              │
│  Timer 2: pollTimer (3s 间隔)                                │
│  ┌──────────────────────────────────────┐                    │
│  │ 每 3 秒请求服务端最新任务状态            │                    │
│  │ 发网络请求,可能失败                    │                    │
│  │ 用于:获取真实完成状态、结果图 URL       │                    │
│  └──────────────────────────────────────┘                    │
│                                                              │
│  两者协作:                                                   │
│  - 进度值 = max(服务端真实进度, 模拟进度)                      │
│  - 服务端返回 status=10 → 停止两个 timer                      │
│  - 3 次请求失败 → 只停 pollTimer,simTimer 继续(进度不会卡住)│
└─────────────────────────────────────────────────────────────┘

关键代码解析

// index.tsx:73 — simTimer 用 forceUpdate 触发重渲染
const [, forceUpdate] = React.useState(0);

React.useEffect(() => {
  if (isLoading && !simTimerRef.current) {
    // 每秒 +1,触发组件重渲染,重渲染时重新计算 getSimulatedProgress
    simTimerRef.current = setInterval(() => forceUpdate((n) => n + 1), 1000);
  } else if (!isLoading && simTimerRef.current) {
    clearInterval(simTimerRef.current);
    simTimerRef.current = null;
  }
}, [isLoading]);

为什么用 forceUpdate 而不是直接 setProgress

// ❌ 直觉做法:每秒算一次进度存到 state
setInterval(() => {
  const p = getSimulatedProgress(startTime);
  setProgress(p); // 每秒更新 state
}, 1000);

// ✅ 实际做法:只 forceUpdate,进度在 render 时计算
setInterval(() => forceUpdate(n => n + 1), 1000);
// render 中直接调用 getSimulatedProgress(taskCreatedAtRef.current)

原因:这个页面有多个 execution,每个的进度需要独立计算。如果用 state 存每个的进度,要维护一个 Map,逻辑复杂。用 forceUpdate 让 render 每秒跑一次,在 JSX 里直接调用 getSimulatedProgress()——函数每次拿 Date.now(),自然得到最新进度。计算归 render,定时器只负责触发

进度融合策略(index.tsx:396-402)

// 将"已完成"+"进行中"+"等待中"的进度融合成一个总进度
const allDisplayProgress = [
  ...Array(doneExecCount).fill(100),           // 已完成的算 100%
  ...pendingExecs.map((e) =>
    e.type === 'running'
      ? Math.min(95, Math.max(e.progress, simPct)) // 运行中:取 max(服务端, 模拟)
      : 0                                          // 等待中:0%
  ),
];
const p = Math.round(
  allDisplayProgress.reduce((a, b) => a + b, 0) / allDisplayProgress.length
);

为什么用 Math.max(e.progress, simPct) 而不是只用其中一个

  • 如果只用服务端进度:轮询间隔 3s,进度条 3 秒不动一次,体验卡顿
  • 如果只用模拟进度:无法反映真实完成速度(有时后端比预期快)
  • 取 max:前端模拟保证"至少在动",后端数据到了就"追上去",永远不后退

τ 值的不同校准

场景 τ 值 95% 到达时间 原因
单张图片 22 ~65s 图片生成约 65s
批量任务 120 ~6min 排队+多张,总耗时长
// AIChangeClothesResult(单张):τ=22
return Math.min(95, Math.round((1 - Math.exp(-elapsed / 22)) * 100));

// AIChangeClothesRecordDetail(批量):τ=120
return Math.min(95, Math.round((1 - Math.exp(-elapsed / 120)) * 100));

为什么不用同一个 τ:如果批量任务也用 τ=22,65 秒就到 95%,但实际可能要 5 分钟。用户会觉得"卡在 95% 不动了"——比没有进度条体验更差。τ 要根据实际耗时校准。

面试角度

  • "如何设计一个没有后端进度反馈的进度条?"
  • "为什么需要两个定时器?合成一个不行吗?"

面试话术:"我们的 AI 任务详情页用了双定时器架构。simTimer 每秒触发渲染,在 render 中用指数衰减函数计算模拟进度;pollTimer 每 3 秒请求后端真实状态。两者协作的关键是进度融合——取 max(模拟值, 真实值),保证进度条'至少在动',同时不落后于真实进度。分开两个 timer 有三个好处:一是模拟进度不受网络请求失败影响;二是两个 timer 的频率可以独立调整(UI 需要 1s 刷新,API 不需要这么频繁);三是停止逻辑独立——网络错误只停 pollTimer,simTimer 继续转,用户不会看到进度条突然卡死。"


③ useMemo 内创建 Atom — 动态派生原子的正确姿势与陷阱

相关代码src/state/taskEdit.ts:63-74

这段代码为什么很容易写错

export const useFaceModelsFilterData = (type: 4 | 5 | 99) => {
  const modelsAtom = useMemo(() => {
    // 在 useMemo 内动态创建一个新的派生原子
    const data = atom((get) => {
      const models = get(faceModels);
      return models.filter((model) => type === 99 || model.type === type);
    });
    return data;
  }, [type]); // 只有 type 变化才重建

  return useAtomValue(modelsAtom);
};

你可能想到的三种"更简单"的写法,以及为什么都有问题

// ❌ 写法 1:每次 render 都创建新 atom(没有 useMemo)
export const useFaceModelsFilterData = (type: 4 | 5 | 99) => {
  const modelsAtom = atom((get) => {
    const models = get(faceModels);
    return models.filter((model) => type === 99 || model.type === type);
  });
  return useAtomValue(modelsAtom);
};
// 问题:每次 render 都新建 atom 对象 → Jotai 认为是"新订阅" → 不断 subscribe/unsubscribe
// 性能极差,且可能导致无限渲染循环


// ❌ 写法 2:在模块级别创建一个固定的 atom
const filteredModels = atom((get) => {
  const models = get(faceModels);
  return models.filter((model) => model.type === 4); // ← type 写死了!
});
// 问题:type 是动态参数,模块级 atom 无法接收参数


// ❌ 写法 3:不用 atom,直接在组件里 filter
export const useFaceModelsFilterData = (type: 4 | 5 | 99) => {
  const models = useAtomValue(faceModels);
  return useMemo(() => models.filter(m => type === 99 || m.type === type), [models, type]);
};
// 这个其实能工作!但如果 faceModels 更新频繁、filter 逻辑复杂,
// 每个使用 useFaceModelsFilterData 的组件都要独立计算
// atom 方式则可以共享计算结果(同一 type 值的组件共享同一个 atom 实例)

正确写法的核心原理

useMemo 的作用:保证同一个 type 值只创建一个 atom 实例

组件 A: useFaceModelsFilterData(4) → atom_4
组件 B: useFaceModelsFilterData(5) → atom_5
组件 C: useFaceModelsFilterData(4) → atom_4'  ← 注意!不是同一个 atom_4

⚠️ 陷阱:组件 A 和组件 C 虽然 type 都是 4,
   但它们各自的 useMemo 产生的是独立的 atom 实例
   因为 useMemo 是组件实例级别的,不是全局的

   如果需要全局共享,应该用 atomFamily:
   const filteredModelsFamily = atomFamily((type) => atom((get) => ...))

项目中为什么不用 atomFamily:因为使用场景简单(只有 3 种 type 值:4、5、99),每个组件独立创建 atom 的开销可忽略。atomFamily 需要额外引入,对这个规模来说是过度设计。

Jotai 原子依赖图

                    ┌─── fetchModelScene() API 返回
                    │
                    ▼
          ┌── modelList (base) ──┐
          │                      │
          ├── placeList (base) ──┼──→ sceneData (derived)
          │                      │    根据 privateModels 是否为空
          │                      │    决定展示"我的脸模"分组
          └── faceModels (base) ─┘
                    │
                    ├──→ faceModelAgeParams (derived)
                    │    从 modelSelectParams 中筛出 ageGroup
                    │
                    └──→ useFaceModelsFilterData(type)
                         动态创建的 atom,按 type 筛选

   privateModels ──────→ sceneData (跨文件依赖 models.ts → taskEdit.ts)

   aliModels ────────→ localStorage 缓存(独立,不参与上面的依赖图)

面试角度

面试话术:"Jotai 中接收动态参数的派生原子有个经典陷阱——如果在 render 中直接 atom(get => ...) 创建,每次渲染都产生新实例,导致无限订阅/取消。正确做法是用 useMemo 包裹,保证同一参数值只创建一次。但要注意 useMemo 是组件实例级别的——两个组件传相同参数会得到两个 atom 实例。如果需要全局共享,应该用 Jotai 的 atomFamily。我在项目中根据实际场景(只有 3 种参数值)选择了 useMemo 方案,避免引入额外概念。"


④ localStorage Stale-While-Revalidate — 没有 TTL 的缓存策略

相关代码src/state/models.ts:40-58

完整执行流程

export const useFetchAliModels = () => {
  const updateModel = useSetAtom(aliModels);
  const fetchModels = async () => {
    // 第一步:立刻从 localStorage 加载(可能过期,但用户瞬间看到内容)
    try {
      const m = localStorage.getItem('yk_ali_models');
      if (m) {
        updateModel(JSON.parse(m)); // UI 立刻有数据
      }
    } catch (e) { /* JSON 解析失败静默忽略 */ }

    // 第二步:不管缓存有没有,都去服务端拉最新的
    const { data } = await fetchPrivateAliModelsApi();
    const models = data.rows.map((d: any) => ({ ...d, ext: JSON.parse(d.ext) }));
    updateModel(models);  // 用最新数据覆盖
    localStorage.setItem('yk_ali_models', JSON.stringify(models)); // 更新缓存
  };
  return fetchModels;
};

这是 Stale-While-Revalidate 模式

用户打开页面
    │
    ├── 1. 读 localStorage → 立刻渲染(Stale,可能过期)
    │   用户看到:上次的模特列表(毫秒级)
    │
    └── 2. 发 API 请求 → 返回后覆盖(Revalidate)
        用户看到:最新模特列表(1-3 秒后)
        同时更新 localStorage

类比:你打开外卖 App,先看到昨天浏览过的餐厅列表(缓存),1 秒后列表刷新成最新的。

已知的四个隐患

1. 没有 TTL(过期时间)

// 当前实现:
localStorage.getItem('yk_ali_models') // 只要存在就用,哪怕是 3 个月前的

// 更健壮的做法:
localStorage.setItem('yk_ali_models', JSON.stringify({
  data: models,
  timestamp: Date.now()
}));

// 读取时检查:
const cached = JSON.parse(localStorage.getItem('yk_ali_models'));
if (cached && Date.now() - cached.timestamp < 24 * 60 * 60 * 1000) {
  updateModel(cached.data); // 24 小时内的缓存才用
}

2. 没有版本号

如果 AliModel 的数据结构变了(比如新增字段),旧缓存反序列化后缺字段,可能导致 UI 渲染异常。

3. ext: JSON.parse(d.ext) 的双重序列化

// 服务端返回:ext 是 JSON 字符串 '{"style":"casual","weight":55}'
// 转成对象存入 atom
const models = data.rows.map((d: any) => ({ ...d, ext: JSON.parse(d.ext) }));
// 存入 localStorage 时又 stringify 了一次
localStorage.setItem('yk_ali_models', JSON.stringify(models));

// 读缓存时 JSON.parse 只做一次,ext 已经是对象了 ✅
// 但如果中间有代码直接存了 data.rows(没 parse ext),缓存里的 ext 就是字符串
// 读出来后不一致,可能 crash

4. 竞态条件

打开 App → 读缓存 → updateModel(旧数据) → 用户看到旧模特
                                             ↓
                     API 返回 → updateModel(新数据) → 用户看到新模特
                                                        ✅ 正常

但如果:
打开 App → 读缓存失败 → 不 updateModel → 用户看到空列表
                                             ↓
                     API 超时 → 用户长时间看到空列表
                                                        ❌ 没有 loading 态

项目中为什么可以接受这些隐患:模特列表是低频更新的静态数据(运营每月更新几次),数据结构稳定,缓存不一致最多导致用户看到旧模特图,不影响核心功能。对于这种"重要但不关键"的数据,简单实现 > 过度设计。

面试角度

面试话术:"我们对 AI 模特列表做了 stale-while-revalidate 缓存——打开页面先从 localStorage 读取上次的数据,让用户瞬间看到列表,同时异步请求最新数据覆盖。这个方案的取舍是:没有 TTL 和版本号,因为模特数据低频更新,旧数据不影响体验。如果是高频变化的数据(比如商品价格),需要加上时间戳验证和数据版本号。这和 HTTP 的 stale-while-revalidate Cache-Control 指令是同一个思路,也是 SWR/React Query 等库的核心理念。"


⑤ Canvas 蒙版合成的隐藏陷阱 — 图片加载顺序问题

相关代码src/components/MaskEdit/index.tsx:135-177

你一定会写的错误版本

// ❌ 错误写法:假设图片加载顺序和数组顺序一致
const drawImages = () => {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  allMasks.forEach((mask) => {
    const image = new Image();
    image.src = mask; // data:image/png 格式
    image.onload = () => {
      ctx.drawImage(image, 0, 0); // 谁先 load 谁先画
    };
  });

  // 这里直接 toDataURL → 可能还有图没 load 完!
  return canvas.toDataURL();
};

问题image.onload 是异步的,即使 src 是 data URL 也不是同步的(浏览器需要解码)。三张蒙版的加载顺序可能是 2→3→1,叠加顺序就错了。

项目中的正确做法 — 计数器等待全部加载

// ✅ MaskEdit/index.tsx:135-177
const drawImages = () => {
  if (allMasks.length === 0) { props.onConfirm?.(''); return; }

  let canvas = document.createElement('canvas');
  canvas.height = modelScale!.height;
  canvas.width = modelScale!.width;
  const ctx = canvas.getContext('2d');

  // 先画半透明白底(作为"未选中区域"的遮罩)
  ctx.fillStyle = 'rgba(255,255,255,0.5)';
  ctx.fillRect(0, 0, modelScale!.width, modelScale!.height);

  let count = 0;
  const len = allMasks.length;
  const images: HTMLImageElement[] = []; // 保持数组顺序

  allMasks.forEach((mask) => {
    const image = new Image();
    image.crossOrigin = 'anonymous';
    image.src = mask;
    images.push(image); // 先按顺序存引用

    image.onload = () => {
      count++;
      if (count === len) {
        // 全部加载完后,按 images 数组顺序画
        images.forEach((img) => {
          ctx?.drawImage(img, 0, 0);
        });
        try {
          const result = canvas.toDataURL();
          props.onConfirm?.(result);
        } catch (e) {
          Sentry.captureException(e);
        }
      }
    };
  });
};

核心设计

  1. 先 push 到数组:保证 images 的顺序和 allMasks 一致
  2. 计数器等待count === len 时才开始画,确保全部加载完
  3. 按数组顺序 drawImage:不管谁先 load,画的顺序由数组决定
allMasks: [mask_A, mask_B, mask_C]
images:   [img_A,  img_B,  img_C]  ← 按顺序 push

加载完成顺序可能是 B → A → C
但 count === 3 时统一画:
  drawImage(img_A)  ← 按数组顺序
  drawImage(img_B)
  drawImage(img_C)

canvas.toDataURL() 的 CORS 陷阱

image.crossOrigin = 'anonymous'; // ← 为什么需要这个?

如果 image.src 是跨域 URL(非 data: 或 blob:),Canvas 会被"污染"(tainted)。调用 toDataURL() 会抛 SecurityError。设置 crossOrigin = 'anonymous' 告诉浏览器以 CORS 方式加载——但服务器也必须返回 Access-Control-Allow-Origin 头。

在这个组件里,mask 大多是 data:image/png 格式(本地生成),不会跨域。但加 crossOrigin 是防御性编程——万一混入了网络 URL。

面试角度

面试话术:"Canvas 合成多张图片时有个常见陷阱——Image.onload 是异步的,即使 src 是 data URL。如果在 onload 里直接 drawImage,图片绘制顺序不可控。我的做法是先把所有 Image 实例按顺序存入数组,用一个计数器等全部 onload 后,再按数组顺序统一绘制。另外要注意 Canvas tainted 问题——跨域图片需要 crossOrigin='anonymous',否则 toDataURL 会抛 SecurityError。"


⑥ RLE 命中检测为什么用列优先坐标 — COCO 格式的历史包袱

相关代码src/components/MaskEdit/index.tsx:108

最容易出错的一行代码

const target = Math.max(Math.round(click.x - 1), 0) * modelScale!.height + Math.round(click.y) - 1;
//              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//              为什么是 x * height + y?不应该是 y * width + x 吗??

理解这行代码需要的前置知识

行优先(Row-major) — 你熟悉的方式:

矩阵:
  (0,0) (0,1) (0,2)
  (1,0) (1,1) (1,2)
  (2,0) (2,1) (2,2)

一维索引 = row * width + col
         = y * width + x

内存布局:[0,0] [0,1] [0,2] [1,0] [1,1] [1,2] [2,0] [2,1] [2,2]

列优先(Column-major) — COCO RLE 用的方式:

矩阵相同,但按列扫描:
  (0,0) →
  (1,0) →  第 0 列
  (2,0) →
  (0,1) →
  (1,1) →  第 1 列
  (2,1) →

一维索引 = col * height + row
         = x * height + y

内存布局:[0,0] [1,0] [2,0] [0,1] [1,1] [2,1] [0,2] [1,2] [2,2]

所以 x * height + y 是在列优先编码中定位像素——因为 RLE counts 就是按列优先编码的。

为什么 COCO 用列优先

这是 Fortran/MATLAB 的遗产。COCO 数据集的工具链最初用 MATLAB 写的,MATLAB 默认列优先存储矩阵。后来 Python 版本的 pycocotools 沿用了这个约定。前端要对接,就必须用列优先。

解码时的转置(maskUtils.tsx:121-122)

// RLE 解码:列优先 → 行优先
const rowIndex = pixelPosition % rows;      // 列优先的 pixelPosition → 行号
const colIndex = (pixelPosition - rowIndex) / rows;  // → 列号
const arrayIndex = rowIndex * cols + colIndex;  // 行优先的一维索引

如果忘了转置:图片会"旋转 90°"——因为行列对调了。这是对接 COCO 数据时最常见的 bug。

面试角度

面试话术:"COCO RLE 格式用列优先存储(Column-major),这是 MATLAB 的历史遗留。前端做命中检测时,要用 x * height + y 计算一维索引,而不是常见的 y * width + x。解码成 Canvas ImageData 时还需要做列优先→行优先的转置,否则图像会旋转 90 度。这是 CV 模型输出对接前端时最常踩的坑——坐标系约定不一致。"


⑦ Axios transformRequest 的执行时机 — 为什么数据会被编码两次

相关代码src/utils/request.ts:49-61

这段代码的执行顺序

const httpDefaultOpts = {
  method: 'post',
  url: api,
  transformRequest: [
    function (params: any) {
      if (params.jsonParam) {
        params.jsonParam = JSON.stringify(params.jsonParam);
        // jsonParam: {a:1} → '{"a":1}'
      }
      return Qs.stringify(params);
      // 最终: 'apiKey=xxx&jsonParam=%7B%22a%22%3A1%7D&sessionId=xxx'
    },
  ],
  data: { ...commonParams, ..._params },
  // data 此时是普通对象:{ apiKey:'xxx', jsonParam:{a:1}, sessionId:'xxx' }
};
执行时序:

1. 构造 httpDefaultOpts 对象
   data = { apiKey: 'xxx', jsonParam: { a: 1 }, sessionId: 'xxx' }

2. axios(httpDefaultOpts) 被调用

3. Axios 内部在发请求之前,执行 transformRequest 数组中的函数
   transformRequest[0](data) 被调用:
     ├── params.jsonParam = JSON.stringify({a:1})  → '{"a":1}'
     └── return Qs.stringify(params)
         → 'apiKey=xxx&jsonParam={"a":1}&sessionId=xxx'
         → URL 编码后:'apiKey=xxx&jsonParam=%7B%22a%22%3A1%7D&sessionId=xxx'

4. 最终请求 body 是 form-encoded 字符串

为什么要"先 stringify 再 Qs.stringify" — 两次编码

如果不先 JSON.stringify:
  Qs.stringify({ jsonParam: { a: 1 } })
  → 'jsonParam[a]=1'  ← Qs 会把对象展开为 bracket notation
  → 后端期望的是 jsonParam 作为一个完整的 JSON 字符串,不是拆开的字段

先 JSON.stringify 后:
  Qs.stringify({ jsonParam: '{"a":1}' })
  → 'jsonParam={"a":1}'  ← jsonParam 是一个字符串值
  → 后端可以直接 JSON.parse(request.getParameter("jsonParam"))

本质:这是"信封模式"的编码层面实现。外层 Qs.stringify 负责 form-encoded 格式(让后端框架能用 getParameter 取值),内层 JSON.stringify 负责保持复杂数据结构完整。

transformRequest 容易犯的错

// ❌ 错误:transformRequest 直接修改了 params 对象
function (params: any) {
  params.jsonParam = JSON.stringify(params.jsonParam); // 修改了原对象!
  return Qs.stringify(params);
}
// 问题:如果有重试逻辑,第二次进 transformRequest 时
// jsonParam 已经是字符串了,JSON.stringify 会再包一层引号
// '{"a":1}' → '"{\\"a\\":1}"'  双重序列化!

项目中没有重试逻辑,所以这个问题不会出现。但如果你要加重试,需要注意 transformRequest 不应该修改入参:

// ✅ 更安全的写法
function (params: any) {
  const _params = { ...params };
  if (_params.jsonParam && typeof _params.jsonParam !== 'string') {
    _params.jsonParam = JSON.stringify(_params.jsonParam);
  }
  return Qs.stringify(_params);
}

面试角度

面试话术:"Axios 的 transformRequest 在请求发出前拦截 data 做变换。我们用它实现'信封模式'编码——先把 jsonParam 对象 JSON.stringify 成字符串,再整体 Qs.stringify 成 form-encoded。两次编码的原因是后端网关按 form-encoded 解析,它期望 jsonParam 是一个 JSON 字符串值,不是被 Qs 展开的嵌套参数。要注意 transformRequest 直接修改 params 对象会影响重试场景,安全的做法是浅拷贝后操作。"


⑧ Blob URL 内存泄漏 — createObjectURL 的生命周期管理

相关代码src/pages/AliTryOnHome/index.tsx

为什么 Blob URL 会泄漏

const url = URL.createObjectURL(blob);
// 浏览器内部维护了一个 Blob → URL 的映射表
// 这个映射会阻止 Blob 被垃圾回收
// 即使你不再引用 url 这个字符串,Blob 数据还在内存中!
URL.createObjectURL(blob)
┌──────────────────────────────────────────────┐
│ 浏览器内部映射表                               │
│                                                │
│ "blob:http://localhost:3000/abc123" → Blob(5MB)│
│ "blob:http://localhost:3000/def456" → Blob(3MB)│
│                                                │
│ 只有调用 URL.revokeObjectURL(url) 才会清除     │
│ 页面关闭时自动清除                              │
└──────────────────────────────────────────────┘

项目中的处理方式

// 替换图片时,先释放旧的 blob URL
onePieceClothes?.url && URL.revokeObjectURL(onePieceClothes.url);
setOnePieceClothes({ url, blob });

还有泄漏的场景

用户选了图 A → createObjectURL → blob_url_A
用户选了图 B → revokeObjectURL(blob_url_A) → createObjectURL → blob_url_B  ✅
用户直接离开页面(没点替换,也没提交)
  → blob_url_B 没有被 revoke
  → 页面卸载时浏览器会自动清理,所以不是"永久泄漏"
  → 但如果是 SPA 内部路由跳转(KeepAlive 缓存),组件不卸载,blob 一直留在内存

更稳健的做法(useEffect 清理):
useEffect(() => {
  return () => {
    // 组件真正卸载时释放所有 blob URL
    onePieceClothes?.url && URL.revokeObjectURL(onePieceClothes.url);
  };
}, []);

但在 KeepAlive 场景下,useEffect 的 cleanup 不会在"缓存"时执行(因为组件没有真正卸载),所以这个泄漏在当前架构下是有意接受的——trade-off 是保持缓存页面的完整状态。

面试角度

面试话术:"URL.createObjectURL 创建的 Blob URL 会在浏览器内部维护一个映射表,阻止 Blob 被垃圾回收。必须手动调用 revokeObjectURL 释放。在我们的项目中,替换图片时会先释放旧 URL,但在 KeepAlive 缓存场景下,组件不会真正卸载,所以 useEffect cleanup 不执行,存在轻微的内存泄漏。这是一个有意识的取舍——保持页面缓存状态完整性 vs 严格的内存管理。实际影响很小,因为单张图片的 Blob 通常只有几 MB。"


总结:中级 → 高级的认知跃迁

初级看法                    中级理解                         高级认知
──────────────────────────────────────────────────────────────────────────
"setInterval 轮询"     →   "要加 clearInterval"         →  "闭包会捕获旧值,需要 Ref"
"用 useState"          →   "加 useRef 配合"             →  "明确哪个驱动UI,哪个给回调用"
"localStorage 缓存"    →   "记得 try-catch"              →  "SWR 模式 + TTL + 版本号"
"Canvas 画图"          →   "用 drawImage"                →  "异步加载顺序 + 计数器同步 + CORS"
"RLE 解码"             →   "遍历 counts 数组"            →  "列优先转置 + O(n) 命中检测"
"axios 请求"           →   "加拦截器"                    →  "resolve 统一通道 + 信封编码 + 重试安全"
"进度条"               →   "setInterval 更新"            →  "指数衰减 + 双定时器 + lerp 平滑"
"createObjectURL"      →   "记得 revoke"                 →  "KeepAlive 下的泄漏是有意识的取舍"
"Jotai atom"           →   "atom + useAtomValue"         →  "useMemo 动态创建 + atomFamily 取舍"

每个"高级认知"都不是凭空学到的——它们来自真实的 Bug、线上的内存问题、用户的体验投诉。理解这些取舍,比会写代码更重要。


20. 项目如何跑起来 — Node 版本与启动环境排查

一句话结论

本项目需要 Node 16 + Yarn(classic / 不启用 PnP),且必须在公司内网或 VPN 环境下才能装依赖(@ecool 私服在内网)。

为什么是 Node 16(而不是 18 / 20)

排查路径(这是重点 — 展示"资深是怎么找答案的")

项目没有 .nvmrc / .node-version / package.json#engines,所以不能一眼看出来。正确的排查顺序:

1. 先搜本地约定文件  → find . -name ".nvmrc" -o -name ".node-version"
2. 再看 package.json → 有没有 "engines" 字段
3. 再看 CI 配置      → .gitlab-ci.yml / .github/workflows
4. 再看 Dockerfile   → FROM node:XX
5. 都没有再问人

本项目结果:.gitlab-ci.yml 里三个 stage 都写了 nvm use 16 (.gitlab-ci.yml:15,31,47)。所以答案就在仓库里。

💡 这是 CLAUDE.md 里"穷尽项目再问人"原则的真实案例:看起来"缺信息",其实 grep -r "nvm" 一下就能找到。

为什么 Node 版本要和 CI 对齐

看这段 CI 配置:

# .gitlab-ci.yml:15
- nvm use 16
- yarn
- yarn publish:test

CI 产出的是线上真正跑的代码。 如果本地用 Node 20 能跑通,但 CI 用 Node 16,就可能出现:

  • 本地 OK,CI 失败(语法/API 差异)
  • 两边都能构建,但产物不一致(polyfill 不同、babel 降级不同)
  • 最坑的:两边都成功,线上 JS 报错(因为 node_modules 的某些包在不同 Node 下装出不同原生模块)

所以"锁死 Node 版本"本质是锁死整条构建链的确定性。

各配置文件的作用

文件 内容 作用
package.json react-scripts: 5.0.1 CRA 框架,支持 Node 14/16/18,但官方推荐 16
.gitlab-ci.yml:15 nvm use 16 权威版本来源,三个环境都用 16
.yarnrc.yml nodeLinker: node-modules 用传统扁平 node_modules,不用 Yarn Berry PnP
.npmrc @ecool:registry=http://git.hzdlsoft.com:4873/ 私服地址(内网,需 VPN)
.env PUBLIC_URL=/amktweb/aigc BUILD_PATH=dist 部署路径前缀 + 产物目录名

启动步骤

# 1. 切到 Node 16
nvm use 16
# 没装过:nvm install 16 && nvm use 16

# 2. 装依赖(node_modules 已有则可跳过,不行再装)
yarn

# 3. 启动测试环境(推荐首次用这个)
yarn dev
# 等价:yarn start   → REACT_APP_ENV=test react-scripts start

# 其他启动模式
yarn start:prod      # 生产环境
yarn start:audit     # 审核模式(REACT_APP_MODE=audit)

默认端口 3000,改端口:PORT=3001 yarn dev

常见坑与排查

坑 1:Node 18+ 启动报 error:0308010C:digital envelope routines::unsupported

原因react-scripts 5.0.1 内部依赖的 webpack 4 时代的 crypto API,和 Node 17+ 默认启用的 OpenSSL 3 不兼容。

解法三选一

# 推荐:降到 16(和 CI 一致)
nvm use 16

# 临时绕过(不改 Node 版本)
export NODE_OPTIONS=--openssl-legacy-provider

# 不推荐:改 package.json script(会污染其他协作者)
"start": "NODE_OPTIONS=--openssl-legacy-provider react-scripts start"

坑 2:yarn 装依赖卡在 @ecool/webpublish

原因.npmrc 里写了 @ecool:registry=http://git.hzdlsoft.com:4873/,这是内网私服。

排查

# 测试私服连通性
curl -I http://git.hzdlsoft.com:4873/

# 通 → 能装,不通 → 开 VPN

坑 3:yarn.lockpackage-lock.json 都有(M 状态)

当前 git status 显示两者都被修改。用 yarn 还是 npm

项目有 .yarnrc.yml必须用 yarnpackage-lock.json 是历史遗留,不要用 npm install,否则两套 lock 会打架。

三层对比(怎么找一个项目的 Node 版本)

❌ 初级做法:
   直接 node -v 看本地版本 → 跑 yarn dev → 报错才找原因
   (没做任何前置调研)

⚠️ 中级做法:
   看 package.json 有没有 engines → 没有 → 搜 README → 没写 → 问同事
   (只看了最显眼的两个地方)

✅ 资深做法:
   按"本地约定 → package.json → CI 配置 → Dockerfile → 其他脚本"
   顺序搜 → 锁定 CI 里的 nvm use 16 → 自己对齐
   (理解版本权威来源在 CI,不在本地)

面试角度

面试官可能问: "如果你入职一个老项目,怎么快速搭起本地开发环境?"

面试话术: "我会按优先级查五个地方:先看项目根有没有 .nvmrc 这种约定文件,再看 package.jsonengines 字段,再看 CI 配置比如 .gitlab-ci.ymlnvm use 或 Docker 镜像 tag,最后看 README 和 Dockerfile。像我们这个 AI 内容生成项目就没有 .nvmrc,但 CI 里写了 nvm use 16,所以本地也对齐到 16。最关键的判断是:本地版本要和 CI 一致,因为 CI 产物是上线产物,版本不一致会导致 polyfill 差异和 lock 漂移。"

可能的追问

  • Q: "如果 CI 也没指定呢?"

  • A: 按依赖的兼容矩阵反推。比如看到 react-scripts 5.0.1,官方文档支持 Node 14/16/18,优先选中间稳定版。

  • Q: ".nvmrcengines 的区别?"

  • A: .nvmrc 是开发者工具约定(nvm 读),engines 是 npm/yarn 在 install 时的警告(加 engine-strict 才会阻止)。前者管"开发",后者管"发布"。真正严格的做法是两个都写。

经验提炼

  1. 版本权威在 CI,不在本地。 本地能跑不代表线上能跑。
  2. "搜不到"往往是"没搜对地方"。 配置可能散落在 .gitlab-ci.yml / Dockerfile / Jenkinsfile / Makefile 里。
  3. 私服地址 = VPN 依赖。 看到 .npmrc 里有自定义 registry,第一反应就是"这项目在哪个网络才能装"。

21. Bug修复:荣耀/小米手机拍照入口异常 —— <input type="file"> 在移动 WebView 的深度差异

一句话结论

同一套 <input type="file" accept="image/*"> 代码,在华为 / 小米 / 荣耀三个机型上表现迥异(分别是"跳文件管理器"、"能拍照"、"没有拍照选项"),根因不是 HTML,而是"浏览器/WebView 的实现差异 + user gesture 传递链被 DOM 结构破坏"的组合


现象(三个机型的不同症状)

机型 页面 行为
华为 AITry 页(src/pages/AITry/index.tsx 点击上传 → 直接跳文件管理器(不是相册,也不是相机)
小米 AliTryOnHome 点击上传 → 弹"相机/相册"二选一,拍照可用
小米 AIChangeClothes 点击上传 → 只跳相册,拍照选项消失
荣耀 AIChangeClothes 点击上传 → 无任何选择器弹框,直接打开默认图片选择应用

同一个项目,同一个 <input> 标签,却有四种不同表现——这类问题最容易"玄学化",资深工程师必须能拆开。


第一反应(走过的弯路)

第一直觉是:"加个 capture 属性就好"或"accept="image/*" 应该浏览器自动弹框啊"。

这两个直觉都错

  • capture 的语义是强制指定来源environment → 后置相机,user → 前置相机),不是"允许拍照"。加了它反而会把相册选项拿掉
  • "浏览器自动弹选择框" 不是 HTML 规范,规范里根本没规定这件事。这是 Chrome/Safari 自己加的 UX,其他 WebView 不跟随的话一切皆可能。

真正的根因链条,得从规范层、浏览器层、WebView 层、DOM 层一层层剥。


根因分层分析

第 1 层:HTML 规范只管"语义",不管"UI"

<input type="file" accept="image/*">

W3C/WHATWG 规范对这段代码只有两条硬约束:

  1. 只能选文件(不能选目录,除非加 webkitdirectory
  2. accept 是一个建议性的 MIME 过滤提示(浏览器可以忽略)

规范里完全没说"必须弹拍照/相册选择器"。"让用户选拍照还是相册"属于厂商 UX 决定,不是浏览器必须实现的行为。

这一层解释了为什么不同浏览器/WebView 能"合法地"给出完全不同的 UI

第 2 层:accept 属性的实际影响(华为老 AITry 的锅)

看老页面代码:

// src/pages/AITry/index.tsx:201
<input type="file" id="file" className="image_upload__input" onChange={onFileChange} />

没有 accept 属性

这就是"华为直接跳文件管理器"的直接原因:

  • 没有 accept → 浏览器/WebView 认为"用户要选任意文件"
  • Android 系统收到 Intent.ACTION_GET_CONTENT 且没有 MIME 限制时,会默认启动文件管理器(可以看到所有文件),而不是跳转图库/相机
  • 加上 accept="image/*" 后,系统才会收窄 MIME 到 image/*,这时才可能触发图片选择器(相册/相机)
❌ 初级做法:<input type="file"> → 任意文件 → 安卓默认跳文件管理器
⚠️ 中级做法:<input type="file" accept="image/*"> → 跳图片选择器,但可能只开相册
✅ 资深做法:ActionSheet 上层选择 + 两个独立 input(见后文修复方案)

第 3 层:WebView / 浏览器实现差异(荣耀不弹框的锅)

accept="image/*" 时,不同内核的实现:

内核 表现
Chrome for Android / iOS Safari 通常弹 "相机 / 相册 / 文件" 三选一 chooser
小米 MIUI 内置浏览器(Chromium 内核但自定义 chooser) 正常弹 chooser,但对 <label> 结构敏感(见第 4 层)
荣耀 MagicOS / HarmonyOS 内核 部分版本直接走默认图片选择 Intent,不弹任何 chooser
公司 App 的 Android WebView 取决于原生 WebChromeClient.onShowFileChooser 的实现,最常见是只 ACTION_PICK(相册)

验证"是不是 App 内 WebView"的方法:在控制台跑 navigator.userAgent,看是否有 wv(AOSP WebView 标记)或 App 自定义的 UA 后缀。如果在 WebView 里,前端怎么改都救不了"不弹 chooser"问题,得让 Android 同事在 onShowFileChooser 里用 Intent.createChooser() 组合 ACTION_PICKACTION_IMAGE_CAPTURE

第 4 层:DOM 结构与 user gesture 传递(AIChangeClothes 没拍照选项的核心锅)

这是小米上同一个项目两页表现不同的真正原因,也是整个排查里最有学习价值的部分。

对比两边的 DOM 结构:

AliTryOnHome(能拍照):

// src/pages/AliTryOnHome/index.tsx:297-304
<label htmlFor="one_piece" className="...">
  {图片占位元素(没有其他 onClick)}
  <input
    type="file"
    id="one_piece"         // ← 显式 id
    accept="image/*"
    onChange={...}
  />
</label>

AIChangeClothes(小米上只能相册、荣耀上不弹):

// src/pages/AIChangeClothes/index.tsx:609-642(single 模式)
<label className="...">
  {item ? (
    <>
      <img onClick={() => ImageViewer.show(...)} />       {/* ← 事件 1 */}
      <button onClick={(e) => {
        e.preventDefault();                               {/* ← 事件 2 */}
        e.stopPropagation();                              {/* ← 事件 3 */}
        removeSingleImage(side);
      }}>✕</button>
    </>
  ) : (...)}
  <input
    type="file"
    accept="image/*"                                     {/* ← 没有 id,没有 htmlFor */}
    onChange={(e) => onSingleSideFileChange(e, side)}
  />
</label>

两边的差异归纳:

差异点 AliTryOnHome AIChangeClothes
label 与 input 关联方式 显式 htmlFor + id 隐式(label 包裹 input)
label 内部的其他交互 onClick img、stopPropagation 按钮
点击 label 时的事件链 干净:label click → input click 混乱:label click 可能被 img/button 的 handler 先消费

为什么这会导致"不能拍相机"?

Android WebView 启动相机需要满足两个条件:

  1. 文件选择器被触发
  2. 本次触发来自真实的 user gesture(且未中断)

条件 2 是 Chromium 的安全模型:相机是敏感设备,必须由用户真实点击触发,不能由脚本自动触发。一旦 click 事件链中出现 stopPropagationpreventDefault、或被其他 handler 消费,user gesture 在 WebView 内部会被标记为"降级"或"失效"

小米的 MIUI WebView 对这个降级策略的处理很严格:

  • 完整 user gesture → 弹相机+相册选择器
  • 降级的 user gesture → 只允许跳相册(不启动相机,因为相机需要更高权限)
  • 完全丢失 gesture → 根本不触发 chooser

AIChangeClothes 里 label 嵌套了 img onClick、删除按钮的 stopPropagation小米 WebView 判断"这次 click 不够干净",就把相机选项剥掉了,只留相册。这就是为什么同一台小米、同一个项目,两页表现不一样。

荣耀的 MagicOS 对 gesture 策略更激进,可能直接拒绝 chooser,走默认应用。


相关代码位置

文件 关键点
src/pages/AITry/index.tsx:201 老页面 <input> 没有 accept,导致华为跳文件管理器
src/pages/AliTryOnHome/index.tsx:297-304, 331-338, 364-371, 406-413 标准做法:htmlFor 显式关联 + label 内部结构简单
src/pages/AIChangeClothes/index.tsx:598-604 singleOne 模式有 htmlFor="single_one_file"(commit 91c9e21 "拍照debug" 加的,方向正确)
src/pages/AIChangeClothes/index.tsx:633-638, 669-674, 692-701 single/suit/multiColor 模式仍是隐式包裹,且 label 内嵌 onClick/stopPropagation
src/pages/AIChangeClothes/index.scss:349-353, 414-417 position: absolute; z-index: -2; opacity: 0——隐藏方式没问题,不是 display: none
git commit ce6b2b6 历史上曾有 ActionSheet + 拍照/相册双 input 方案,但在 e96bec2 被删除回退

✅ 修复方案:恢复 ActionSheet + 拍照/相册双 input

核心原则把"选什么"的决定权从浏览器/WebView 手里拿回应用层,不依赖厂商实现。

// 1) 准备两个独立 input(不依赖 label 关联,用 ref + click() 触发)
const cameraInputRef = useRef<HTMLInputElement>(null);
const albumInputRef = useRef<HTMLInputElement>(null);

<input
  type="file"
  accept="image/*"
  capture="environment"        // ← 强制后置相机拍商品图更自然ref={cameraInputRef}
  style={{ display: 'none' }}
  onChange={handleCameraFile}
/>
<input
  type="file"
  accept="image/*"             // ← 不加 capture走相册
  ref={albumInputRef}
  style={{ display: 'none' }}
  onChange={handleAlbumFile}
/>

// 2) 点击上传区域 → ActionSheet 选择 → click() 对应 input
const showUploadActionSheet = () => {
  ActionSheet.show({
    actions: [
      { text: '拍照', key: 'camera' },
      { text: '从相册选择', key: 'album' },
    ],
    onAction: (action) => {
      ActionSheet.close();
      // 关键:用 setTimeout(0) 确保 ActionSheet 的 click 事件完全结束
      // 避免 user gesture 被 ActionSheet 的事件处理"消耗掉"
      setTimeout(() => {
        (action.key === 'camera' ? cameraInputRef : albumInputRef).current?.click();
      }, 0);
    },
  });
};

// 3) 上传区域变成普通 div,onClick 只做一件事
<div className="upload_area" onClick={showUploadActionSheet}>
  {/* 预览和删除按钮放在这里,不会影响 input 的 user gesture */}
</div>

为什么这个方案稳?

  • 拍照 input 带 capture="environment":即使 WebView 不弹 chooser,也会直接开相机(单一用途)
  • 相册 input 不带 capture:单一用途,绝大多数 WebView 都会老实跳相册
  • 应用层 ActionSheet 做选择:跨厂商一致,不依赖任何 WebView 实现
  • setTimeout(0) 分离事件循环:避免 ActionSheet 的 click 事件链污染 input 的 user gesture

三层做法对比

❌ 初级做法:<input type="file" accept="image/*">
   依赖浏览器弹 chooser。
   风险:iOS Safari 能弹,Chrome 能弹,但荣耀/小米/App WebView 不一定。

⚠️ 中级做法:<input type="file" accept="image/*" capture="environment">
   强制开相机,但用户想从相册选就没辙了。
   风险:牺牲了相册入口,只适合纯拍照场景。

✅ 资深做法:ActionSheet + 两个独立 input(拍照带 capture,相册不带 capture)
   考虑了什么:
   - 厂商 WebView 实现差异
   - user gesture 的完整性(避免 stopPropagation 污染)
   - 一致的跨端 UX(iOS/安卓/HarmonyOS/App WebView 表现完全一样)
   - 原生 App 集成场景(即使原生 WebChromeClient 只实现了相册,也能通过 capture 走到相机 Intent)

扩展性 / 边界情况

Q: 如果要支持多选(如 multiColor 场景一次选 5 张)?

  • 相册 input 加 multiple 属性即可
  • 拍照 input 不能 multiple(相机一次只能拍一张)
  • ActionSheet 的 UI 可以只显示"相册"(拍照隐藏 multiple 的心智模型)

Q: iOS Safari 上 capture="environment" 会不会翻车?

  • iOS Safari 从 14.5 开始支持 capture,低版本会忽略(自动 fallback 到默认 chooser)
  • 不会报错,最坏情况是用户在 iOS 上看到系统 chooser(反而是好体验)

Q: 项目嵌入在公司 App 里时,拍照 input 能真的开相机吗?

  • 取决于原生 WebChromeClient.onShowFileChooser 的实现
  • 如果原生拿到 <input capture="environment">,需要根据 fileChooserParams.getAcceptTypes()isCaptureEnabled() 分发到 ACTION_IMAGE_CAPTURE
  • 前端已经把意图传达清楚了,剩下靠原生同事落地

质量保障(怎么测试)

  1. 最小测试矩阵(至少覆盖):

    • iOS Safari + iOS App WebView
    • Android Chrome + 小米 MIUI + 荣耀 MagicOS + 华为 HarmonyOS
    • 公司 App 内嵌 WebView(老版本 + 新版本)
  2. user gesture 是否完整的诊断方法:

    // 在 label 的 click handler 里打印
    console.log('isTrusted:', e.isTrusted);  // true = 真实用户点击
    // 在 input 的 click handler 里也打印
    // 如果 label 的 isTrusted=true 但 input 的 isTrusted=false,说明 gesture 断了
    
  3. WebView 环境探测

    const isWebView = /wv|FBAN|FBAV/.test(navigator.userAgent);
    const isInApp = !!window.NativeBridge; // 或项目约定的 bridge 对象
    

经验提炼

  1. <input type="file"> 的 UI 行为不是 HTML 标准。遇到"为什么弹 / 不弹"的问题,不要在 HTML 层找答案,直接跳到浏览器/WebView 实现。
  2. user gesture 是移动端敏感 API 的通行证。相机、全屏、剪贴板、自动播放——这些 API 都要求 user gesture 完整。e.stopPropagation()e.preventDefault() 看似只是"阻止冒泡",实际可能把 gesture 降级。
  3. <label> 包裹 <input> 不如 htmlFor 显式关联。在移动 WebView 上,显式关联的 click 事件链更干净、更可预测。
  4. "一个 input 管所有场景"是前端偷懒,不是工程最优解。应用层自己做选择 UI,用多个单用途 input,才能获得跨端一致性。
  5. 遇到"同代码不同表现"问题时,DOM 结构和事件链是第二怀疑对象(第一是浏览器/WebView 版本)。

举一反三 —— 哪些 API 对 user gesture 敏感?

下面这些 API 都必须在 user gesture 上下文内调用,否则浏览器会拒绝或降级:

API 场景 踩坑现象
<input type="file"> 启动相机 本次 bug 小米上只开相册
requestFullscreen() 视频播放 自动进入全屏失败
navigator.clipboard.writeText() 复制到剪贴板 异步 await 后再调用会失败
audio.play() / video.play() 音视频自动播放 iOS Safari 拒绝播放
navigator.mediaDevices.getUserMedia() 摄像头/麦克风 必须用户点击按钮触发
window.open() 打开新窗口 外链跳转 被浏览器当弹窗拦截
Notification.requestPermission() 通知权限 直接报错

通用防御模式

// ❌ 错:异步后调用
button.onclick = async () => {
  const data = await fetchData();  // ← gesture 已经在这里断了
  await navigator.clipboard.writeText(data);  // ← 失败
};

// ✅ 对:先做敏感操作,再做异步
button.onclick = async () => {
  const preparedText = syncPrepare();    // 同步准备
  await navigator.clipboard.writeText(preparedText);  // gesture 还在
  const data = await fetchData();  // 异步放后面
};

行业视角

资深面试可能被追问的方向

  1. "为什么 Chrome 要设计 user gesture 这套机制?"

    • 核心是对抗广告滥用和恶意弹窗。早期网页可以任意 window.open、自动播放视频、弹窗请求权限,体验极差。Chrome 引入 user gesture 作为"用户授权凭证",只有真实点击才能解锁敏感 API。
  2. "iOS 和 Android 在 WebView 文件选择上的差异?"

    • iOS WKWebView 从系统层拦截 <input type="file">,交给 iOS 自己的 UIDocumentPickerViewControllerUIImagePickerController,行为稳定
    • Android WebView 把文件选择完全抛给 App 的 WebChromeClient.onShowFileChooser,不同 App 实现天差地别
  3. "如果我要完全绕开 <input type="file">,用 getUserMedia 自己做拍照界面可以吗?"

    • 可以但代价大:
      • 兼容性:iOS 14.3 才支持 WKWebView 内的 getUserMedia,低版本要做降级
      • 权限:需要用户同意摄像头权限,流程比 <input capture>
      • 体积:自己画拍照 UI(取景框、拍照按钮、预览、重拍)增加至少几十 KB 代码
    • 取舍:非必要不自己实现。除非需要"带取景框引导"或"拍完直接人脸识别"这类深度定制,否则 <input capture> 是性价比最高的方案。

面试话术

"聊聊你遇到过最难定位的一个兼容性 bug"

"我之前做一个 AI 换装的移动 H5,同一个 <input type="file" accept="image/*">,小米能弹相机/相册选择框,换到另一个页面就只剩相册了,荣耀上甚至不弹任何选择器。一开始我以为是 accept 属性或 capture 属性的问题,但查了规范才发现,"是否弹选择框"根本不是 HTML 标准,是各家浏览器 UX 自行决定。最后定位到两个叠加原因:一是不同厂商 WebView 的 onShowFileChooser 实现不一致,二是 DOM 里 label 包裹的 input,因为 label 内部有 stopPropagation 的按钮,user gesture 在 WebView 看来被"降级"了,小米的 MIUI 就只给相册、不给相机。**解决方案是把选择权从浏览器拿回来,做个 ActionSheet 弹层,背后是两个独立的 input——拍照的加 capture="environment",相册的不加——这样跨所有厂商 WebView 行为完全一致。**这个问题教会我两件事:一是移动 Web 开发不能只读 W3C 规范,厂商实现差异必须进入测试矩阵;二是 user gesture 是移动端敏感 API 的通行证,写事件处理的时候 stopPropagation 不是无成本的。"

追问:"那原生同事那边需要配合吗?"

"需要。如果 H5 跑在公司 App 的 WebView 里,前端做再多工作也救不了原生 onShowFileChooser 只实现了相册的情况。我的做法是前端在 <input> 上用 capture="environment" 明确传达'这是拍照意图',让原生同事根据 fileChooserParams.isCaptureEnabled() 分发到 ACTION_IMAGE_CAPTURE。这样前后端在协议层是对齐的——前端表达清楚意图,原生决定具体 Intent——比两边各自猜要稳定很多。"


元认知:这个 bug 暴露了什么能力缺口?

  1. 对浏览器规范边界不清晰:以为"常见行为"就是"规范行为"。真正的资深开发者会下意识地问"这个行为是规范规定的吗?"
  2. 对 user gesture 模型没有系统性认知:只把它当成"点击事件"的同义词,不知道它是 Chromium 的安全模型一部分,会被事件处理链污染。
  3. DOM 事件链的副作用估计不足stopPropagation / preventDefault 看起来是"精准控制",但在移动 WebView 的安全上下文里代价不小。
  4. 测试矩阵的覆盖思维缺失:只在一台手机上测通过就以为 OK,没有建立"iOS/Android × 系统浏览器/App WebView × 厂商定制层"的三维测试意识。

最后更新:2026-04-24