主题
附录 A2 · 15 道大厂场景设计题完整解答
每道题统一按 背景 / 核心需求 / 设计方案(含 ASCII 架构图)/ 关键技术点 / 难点与权衡 / 加分项 / 兜底容错 七段展开。 答题时按 README 中的"五段式"组织语言:需求 → 高层设计 → 核心组件 → 难点权衡 → 扩展兜底。
Q1:大文件分片上传(断点续传 + 秒传 + 并发控制)
背景
教育平台、网盘、视频站、企业云盘场景,用户经常上传 100MB ~ 10GB 的大文件。直接 <input type=file> + XHR 上传,碰到弱网就要从头来过,体验崩塌。
核心需求
- 分片:把大文件切成固定大小(5MB),逐片上传
- 断点续传:上传中断 / 关闭浏览器后,下次继续从断点开始
- 秒传:服务端已存在相同文件 → 跳过上传,直接秒传
- 并发控制:最多同时上传 4 个分片,防止占满带宽
- 进度显示 + 失败重试 + 暂停/恢复
设计方案
┌──────────────────── 前端 ────────────────────┐ ┌──────── 后端 ────────┐
│ │ │ │
│ ① 选择文件 │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ ② 计算 Hash(Web Worker + spark-md5) │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ ③ POST /verify { hash, name, size } │───▶│ 查询:文件是否已存在 │
│ │ │ │ 已上传哪些分片? │
│ │ ◀────────── { existed, uploaded[] } │◀───│ │
│ ▼ │ │ │
│ ④ 已存在? ── 是 ──▶ 秒传成功 ✅ │ │ │
│ │ 否 │ │ │
│ ▼ │ │ │
│ ⑤ 切片 (Blob.slice) → 过滤已上传的 │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ ⑥ asyncPool(4) 并发上传分片 │───▶│ POST /upload?index= │
│ │ │ │ 存储分片 │
│ ▼ │ │ │
│ ⑦ POST /merge { hash } │───▶│ 合并所有分片 │
│ │ │ │ 返回最终 URL │
│ ▼ │ │ │
│ ⑧ 上传完成 ✅ │ │ │
└──────────────────────────────────────────────┘ └──────────────────────┘关键技术点
| 模块 | 技术 |
|---|---|
| 切片 | File.prototype.slice(start, end) |
| Hash 计算 | spark-md5 增量算法 + Web Worker 避免主线程卡顿;可改用抽样 hash(首 + 中 + 尾各 2MB)加速 |
| 并发控制 | 自己实现 asyncPool(limit, tasks) 或用 p-limit |
| 断点续传 | 上传前请求 /verify 拿到已上传的 index 列表,前端跳过 |
| 秒传 | hash 比对,服务端已有就直接 link |
| 进度 | XHR onprogress 累加,全局 = sum(已传分片) / total |
| 失败重试 | 单分片失败重试 3 次,指数退避 (1s/2s/4s) |
| 暂停 | AbortController.abort() 取消进行中的请求 |
核心代码(见 examples/01-chunk-upload.js):
js
async function chunkUpload(file) {
const CHUNK_SIZE = 5 * 1024 * 1024;
const hash = await calcHashInWorker(file); // ① 计算 hash
const { existed, uploaded } = await verify(hash, file.name); // ② 校验
if (existed) return { url: existed }; // ③ 秒传
const chunks = [];
for (let i = 0; i < file.size; i += CHUNK_SIZE) {
chunks.push({ index: chunks.length, blob: file.slice(i, i + CHUNK_SIZE) });
}
const todo = chunks.filter((c) => !uploaded.includes(c.index));
await asyncPool(4, todo.map((c) => () => uploadChunk(hash, c)));
return await merge(hash, file.name);
}难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| Hash 算 1GB 太慢(10s+) | 全文 hash(准确) | 抽样 hash(快) | 大文件抽样,误差可接受;服务端再精确比对 |
| 并发数选多少 | 2(保守) | 8(激进) | 4 比较平衡;可根据 navigator.connection.effectiveType 动态调整 |
| 分片大小 | 1MB | 10MB | 5MB;太小请求多,太大单片重传贵 |
| Hash 用主线程 vs Worker | 主线程(简单) | Worker(不卡 UI) | Worker;大文件必须 |
| 秒传 hash 唯一性 | MD5 | SHA-256 | MD5 已够,碰撞概率 < 10^-15,对业务不影响 |
加分项
- 抽样哈希:首 2MB + 中间 2MB + 末 2MB,1 秒内算完 1GB
- Service Worker 拦截上传请求,离线时缓存到 IndexedDB,上线后自动恢复
- WebTransport / HTTP/3:替代 XHR,弱网下吞吐高 30%
- 可视化进度:分片网格(绿=完成、蓝=进行中、红=失败、灰=待上传)
- 预签名 URL:直接传到 OSS / S3,跳过应用服务器(COS 的 Multi-Part Upload)
- 分片二进制差分:rsync 思路,只传改动部分(高级场景)
兜底容错
| 场景 | 兜底 |
|---|---|
| 网络断开 | online / offline 事件监听,断开时自动暂停 |
| 单分片失败 | 重试 3 次,指数退避;3 次后标红,提示用户手动重试 |
| 关闭浏览器 | 上传记录存 localStorage,下次进来恢复 |
| Hash 计算超时 | 5 分钟还没算完,降级为不秒传,直接进入分片阶段 |
| 服务端合并失败 | 保留 24 小时分片,次日重试合并 |
| 浏览器内存爆 | 分片不一次性 readAsArrayBuffer,按需读取 |
Q2:无限滚动列表(虚拟列表 + IntersectionObserver)
背景
电商商品列表、信息流、聊天历史、日志页面:动辄 1 万条数据,全部渲染会卡到死。
核心需求
- 滚到底部自动加载下一页
- 数据量过大时只渲染可视区域(虚拟列表)
- 上拉刷新 / 下拉加载
- 滚动流畅(60fps)
- 跳转回来位置不丢
设计方案
可视区域 (viewport)
│
┌─────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────┐ │
│ │ 缓冲区上 (3 项) │ │
│ ├─────────────────────────────────┤ │
│ │ 可见 item 1 │ │
│ │ 可见 item 2 │ ◀── 视口
│ │ 可见 item 3 │ │
│ │ 可见 item 4 │ │
│ │ 可见 item 5 │ │
│ ├─────────────────────────────────┤ │
│ │ 缓冲区下 (3 项) │ │
│ └─────────────────────────────────┘ │
│ ↓ 哨兵 sentinel │ ◀── IntersectionObserver
│ (进入视口触发 loadMore) │
└─────────────────────────────────────────────┘
│
总滚动高度由 N * itemHeight 撑开(占位 div)关键技术点
| 模块 | 技术 |
|---|---|
| 滚动监听 | IntersectionObserver 监听底部哨兵元素,性能优于 scroll 事件 |
| 虚拟列表 | 维护 startIndex / endIndex,只渲染 [start, end] |
| 占位 | 用一个 height = N * itemHeight 的空 div 撑出滚动条 |
| 偏移 | 渲染区域用 transform: translateY(start * itemHeight) 定位 |
| 不定高 | 渲染后用 ResizeObserver 测量实际高度,缓存修正 |
| 上拉刷新 | touchstart / touchmove 计算 dy,超过阈值触发 |
| 滚动恢复 | 路由切换前保存 scrollTop,回来 el.scrollTop = saved |
核心代码(见 examples/02-virtual-list.js):
js
class VirtualList {
constructor(el, { itemHeight, buffer = 3, fetchMore }) {
this.itemHeight = itemHeight; this.buffer = buffer; this.fetch = fetchMore;
this.data = []; this.start = 0;
el.addEventListener("scroll", () => this.onScroll());
new IntersectionObserver(([e]) => e.isIntersecting && this.loadMore()).observe(this.sentinel);
}
onScroll() {
const { scrollTop, clientHeight } = this.el;
this.start = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.buffer);
this.end = Math.min(this.data.length, this.start + Math.ceil(clientHeight / this.itemHeight) + this.buffer * 2);
this.render();
}
// ...
}难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| 检测底部 | scroll 监听 | IntersectionObserver | IO,零成本,无 layout thrashing |
| 高度处理 | 定高(计算简单) | 不定高(更通用) | 优先定高,做不到再用 ResizeObserver 测量 |
| key 复用 | index | 业务 id | 业务 id,避免数据更新错位 |
| 列表变更 | 全量替换 | diff 增量 | 增量,避免抖动 |
| 上拉刷新 | 自己写 touch | 用 BetterScroll | 中小项目自己写够;复杂场景用库 |
加分项
- 渲染层用
react-window/vue-virtual-scroll-list现成方案 - 配合 骨架屏 缓解首次加载白屏
- 滚动到底部 200px 时预加载下一页,体验无缝
- 用
requestIdleCallback在空闲帧预渲染下一屏 - 极端长列表用 二维虚拟化(行 + 列都虚拟)
兜底容错
| 场景 | 兜底 |
|---|---|
| 接口失败 | toast + "重试"按钮 + 上一次缓存数据保留 |
| 没有更多数据 | "没有更多了"提示,停止 IO 监听 |
| 弱网 | loading 超时 5s 显示"网速较慢",但不报错 |
| 切换路由回来 | 缓存 scrollTop 与已加载页码 |
| 数据空 | empty 状态图 + CTA |
Q3:前端权限系统(菜单 / 按钮 / 接口三级)
背景
中后台系统标配:不同角色看到不同菜单、不同按钮、调用不同接口。
核心需求
- 菜单级:根据角色动态渲染左侧导航
- 按钮级:同一页面内不同按钮按权限隐藏/禁用
- 接口级:兜底,前端漏拦截时后端拒绝
- 角色变更后热更新,不用刷新页面
- 支持RBAC(角色) + ABAC(属性)混合模型
设计方案
┌─────────────────── 登录 ───────────────────┐
│ POST /login → 返回 { token, userInfo } │
└────────────────────┬───────────────────────┘
│
▼
┌─────────────────── 拉取权限 ───────────────────────┐
│ GET /perm/list → { menus, buttons, apis } │
│ 存入 Pinia/Redux:authStore │
└────────────────────┬──────────────────────────────┘
│
┌────────────┼────────────┬─────────────┐
▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────────┐ ┌────────┐
│ 路由层 │ │ 菜单层 │ │ 按钮指令 │ │ Axios │
│ Guard │ │ Render │ │ v-perm │ │ 拦截器 │
└────────┘ └────────┘ └────────────┘ └────────┘
│ │ │ │
│ │ │ ▼
│ │ │ 接口前端校验失败
│ │ │ → 直接 reject
│ │ ▼
│ │ <button v-perm="user:delete">
│ │ 无权限则 v-if=false(隐藏)或 disabled
│ ▼
│ 根据 menus 渲染侧边栏
▼
beforeEach: 当前路由 not in perms → /403关键技术点
| 层级 | 实现方式 |
|---|---|
| 菜单 | 后端返菜单树,前端递归渲染 |
| 路由 | router.beforeEach 校验 to.meta.code in userPerms |
| 按钮 | Vue: v-perm="code" 自定义指令;React: <Auth code="x"> 高阶组件 |
| 接口 | Axios 请求拦截器加 code 头;响应 401/403 统一跳登录 / 提示 |
| 缓存 | userPerms 存 Pinia,刷新时从 token 还原 |
| 角色变更 | 后端 SSE / WebSocket 推送 → 前端清缓存重新拉权限 |
核心代码(见 examples/03-permission.js):
js
// Pinia
export const useAuth = defineStore('auth', {
state: () => ({ menus: [], buttons: new Set(), apis: new Set() }),
actions: {
async fetch() {
const { menus, buttons, apis } = await api.perm();
this.menus = menus;
this.buttons = new Set(buttons);
this.apis = new Set(apis);
},
can(code) { return this.buttons.has(code); },
}
});
// Vue 指令
app.directive('perm', {
mounted(el, { value }) {
if (!useAuth().can(value)) el.parentNode?.removeChild(el);
}
});
// 路由守卫
router.beforeEach((to) => {
const code = to.meta.code;
if (code && !useAuth().can(code)) return { path: '/403' };
});
// Axios 拦截
http.interceptors.response.use(null, (err) => {
if (err.response?.status === 403) message.error('无权限');
if (err.response?.status === 401) location.href = '/login';
return Promise.reject(err);
});难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| 路由表 | 全部预置,按权限隐藏 | 按权限动态 addRoute | 动态,更安全,菜单和路由一致 |
| 按钮控制 | v-if | v-show / disabled | v-if,不渲染更安全 |
| 权限模型 | RBAC | ABAC | 中后台 RBAC 够用;ToB 复杂场景 RBAC + ABAC |
| 数据权限 | 后端做 | 前端做 | 必须后端(数据可见列表过滤) |
加分项
- 权限编码规范化:
模块:对象:动作如order:detail:edit - 支持多租户:用户的 perms 加上 tenantId 命名空间
- 超级管理员走通配
*:*:*,避免维护爆炸 - 提供
<Auth fallback={<Tooltip>无权限</Tooltip>}>替代单纯隐藏,提升体验 - 配合操作日志:用户每次操作上报,便于审计
兜底容错
- 前端拦截只是体验,不能信任 → 后端必须有同等校验
- 接口 403 → 全局 toast + 上报埋点(防止恶意篡改 token)
- 权限拉取失败 → 回退 fallback 角色(最低权限)
- 用户无任何菜单 → 引导到"申请权限"页
Q4:单点登录(SSO)
背景
公司有 OA、CRM、ERP、邮箱、文档……用户希望"登录一次,全部可用"。
核心需求
- 多个子系统只登录一次
- 任一子系统登出,其他系统同步登出
- 支持跨域子系统
- 支持第三方接入(OAuth 2.0)
- Token 安全,能对抗 CSRF / XSS
设计方案(CAS 模式 + JWT)
┌──────── SSO 中心 ────────┐
│ https://sso.x.com │
│ - 登录页 │
│ - 颁发 ticket │
│ - 全局 session │
└─────────┬─────────────────┘
│
┌─────────────────────┬───────────┼───────────┬────────────────┐
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ OA │ │ CRM │ │ ERP │ │ 邮箱 │ │ 文档 │
│ a.x.com│ │ b.x.com│ │ c.x.com│ │d.x.com │ │ e.x.com│
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘
登录流程:
1. 用户访问 a.x.com,未登录 → 302 跳到 sso.x.com/login?service=a.x.com
2. SSO 拿用户登录 → 设置 SSO 域 cookie(标记已登录)→ 302 回 a.x.com?ticket=xxx
3. a.x.com 拿 ticket 调 sso.x.com/validate → 拿到 userInfo → 设置 a.x.com 的 cookie
4. 用户访问 b.x.com → 302 sso.x.com → 已有 cookie → 直接颁发 ticket → 回跳
登出流程:
1. 用户在 a.x.com 点击退出 → 通知 sso 中心
2. SSO 通知所有已登录子系统(保存的回调 URL)
3. 各子系统清除本地登录态关键技术点
| 技术 | 说明 |
|---|---|
| CAS (Central Authentication Service) | 经典 SSO 协议,ticket 中转 |
| OAuth 2.0 | 第三方授权(GitHub / 微信登录) |
| OIDC | OAuth 2.0 + Identity Layer,主流方案 |
| JWT | 自包含 token,避免每次查 session |
| Cookie SameSite=None; Secure | 跨域子系统共享 cookie 必须 |
| CORS + withCredentials | 跨域接口带 cookie |
| iframe 同步登出 | 在主域加 iframe 监听消息 |
| BroadcastChannel | 同源多 Tab 同步登出 |
核心代码
js
// 子系统检测登录状态
function ensureLogin() {
const token = getCookie('token');
if (token) return Promise.resolve();
return new Promise((resolve) => {
const ticket = new URL(location.href).searchParams.get('ticket');
if (ticket) {
validateTicket(ticket).then(setLocalToken).then(resolve);
} else {
location.href = `https://sso.x.com/login?service=${encodeURIComponent(location.href)}`;
}
});
}
// 全局登出
function logout() {
fetch('https://sso.x.com/logout', { credentials: 'include' });
// 通知本系统其他 Tab
new BroadcastChannel('auth').postMessage({ type: 'logout' });
}难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| Token 存储 | localStorage | httpOnly Cookie | Cookie,防 XSS(localStorage 可被脚本读取) |
| Session vs JWT | Session(服务端存) | JWT(自包含) | 中小Session,大流量JWT |
| 跨域 | CORS | postMessage | CORS 现代浏览器都支持 |
| 登出同步 | 轮询 | iframe / WebSocket | iframe + postMessage 最通用 |
加分项
- 静默续期:access_token 5 分钟,refresh_token 7 天
- CSRF 防护:双重 Cookie / SameSite=Lax
- 设备指纹:异常设备登录二次验证
- 审计日志:登录 IP、设备、时间全记录
- OAuth 第三方接入:直接接微信、企业微信、钉钉
兜底容错
- SSO 中心宕机 → 本地缓存 token 继续可用,过期后失败回登录
- 跨域 cookie 被禁用 → 降级到 URL 传 token(仅一次)
- 时钟漂移导致 JWT 失效 → 客户端容差 5 分钟
Q5:SPA 首屏优化
背景
Vue/React SPA 默认首屏慢:HTML 是空 div,加载 JS → 解析 → 渲染才有内容,FCP/LCP 经常 3s+。
核心需求
- FCP < 1s,LCP < 2.5s(Google 评级 Good)
- 不影响 SEO
- 不影响交互响应(FID < 100ms)
- 兼容低端机
设计方案
┌─────────────────────────────────────────────────────────────┐
│ 首屏优化 6 大维度 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ① 网络层 │
│ - HTTP/2 多路复用 + Server Push │
│ - CDN + 边缘缓存 │
│ - 预连接 <link rel=preconnect> │
│ - DNS Prefetch │
│ │
│ ② 渲染层 │
│ - SSR (Next/Nuxt) → 首屏 HTML 直出 │
│ - SSG → 极致首屏,CDN 静态 │
│ - 骨架屏 → 感知性能 │
│ - <link rel="preload"> 关键资源 │
│ │
│ ③ 资源层 │
│ - 路由懒加载 import() │
│ - Tree-shaking 移除死代码 │
│ - 图片懒加载 IntersectionObserver │
│ - 图片格式 WebP / AVIF │
│ - 字体子集化 (font-spider) │
│ │
│ ④ 缓存层 │
│ - Service Worker 缓存 │
│ - HTTP 缓存(Cache-Control / ETag) │
│ - localStorage 缓存接口数据 │
│ │
│ ⑤ JS 优化 │
│ - Code Splitting │
│ - 动态 polyfill │
│ - 预渲染(Vite SSG / React Snap) │
│ - Hydration 优化(React 18 Selective) │
│ │
│ ⑥ 体验层 │
│ - 骨架屏 / Loading Placeholder │
│ - PWA 离线访问 │
│ - Web Vitals 实时监控 │
│ │
└─────────────────────────────────────────────────────────────┘关键技术点
| 优化项 | 收益 | 实现 |
|---|---|---|
| SSR | LCP -50% | Next.js / Nuxt / Vite SSR |
| 路由懒加载 | 首屏 JS -60% | () => import('./Page') |
| 骨架屏 | FCP 感知 -30% | 预渲染插件 |
| CDN | TTFB -70% | 静态资源全量上 CDN |
| HTTP/2 | 多路复用 | Nginx 配置 |
| 图片懒加载 | 首屏请求 -50% | loading="lazy" 或 IO |
| WebP | 体积 -30% | <picture> |
| Brotli 压缩 | 体积 -20% | Nginx brotli on; |
| Preload | 关键资源提前 | <link rel="preload" as="font"> |
| Tree Shaking | 体积 -10% | 用 ESM 写代码,构建工具自动 |
难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| SSR vs CSR | CSR 简单 | SSR 首屏快 | 内容站SSR,工具站CSR + SW |
| 拆包粒度 | 大块(少请求) | 细块(缓存友好) | 路由级 + 公共 chunk |
| 预渲染 vs SSR | 预渲染(构建慢) | SSR(运行慢) | 静态页预渲染,动态页SSR |
| 骨架屏 | 自己写 | 工具生成 | 工具(page-skeleton-webpack-plugin) |
加分项
- 关键 CSS 内联:首屏必需 CSS 直接 inline 到 HTML
- HTTP/3 + QUIC:弱网首屏快 30%
- Bundle Analyzer 持续优化:上 CI 卡 size budget
- 基于 RUM 数据迭代:用户真实数据驱动优化方向
- WASM 加速(图像处理、加密等场景)
兜底容错
- SSR 渲染失败 → 降级返回普通 HTML,CSR 接管
- CDN 挂了 → DNS CNAME 切回源站
- 图片加载失败 → 默认占位图
- 弱网超时 → 显示"加载较慢,请检查网络" + 重试按钮
Q6:前端埋点系统(曝光 / 点击 / 性能 / 异常)
背景
业务方需要"用户怎么用产品"的数据:哪个按钮点击多、哪个页面跳出率高、哪些性能瓶颈。
核心需求
- 曝光埋点:元素进入视口才算
- 点击埋点:点击事件 + 元素信息
- 性能埋点:FP / FCP / LCP / FID / CLS
- 异常埋点:JS 报错 / 接口失败
- 路由埋点:PV / UV / 停留时长
- 批量上报 + 离线缓存 + 采样
设计方案
┌─────────────── 业务代码 ───────────────┐
│ │
│ <button data-track="btn:order:submit"> │
│ <div v-track:expose="banner:home"> │
│ router.afterEach → tracker.pv() │
│ window.onerror → tracker.error() │
│ │
└────────────────┬───────────────────────┘
│
▼
┌─────────────── SDK ──────────────────────────────┐
│ │
│ Tracker │
│ ├── click() ──┐ │
│ ├── expose() ──┤ │
│ ├── pv() ──┼──▶ Queue (内存数组) │
│ ├── error() ──┤ │ │
│ ├── perf() ──┘ │ │
│ │ │
│ 触发上报: │ │
│ - 队列满 10 条 │ │
│ - 5 秒定时 ▼ │
│ - visibilitychange → sendBeacon │
│ - beforeunload → sendBeacon │
│ │ │
│ 离线兜底:失败 → IndexedDB ─┘ │
└────────────────┬─────────────────────────────────┘
│
▼
┌─────────────── 后端 ──────────────┐
│ POST /track (gzip) │
│ → Kafka → 数据仓库 → BI 看板 │
└───────────────────────────────────┘关键技术点
| 模块 | 实现 |
|---|---|
| 曝光 | IntersectionObserver,元素 50% 可见持续 1s 才算曝光 |
| 点击 | 事件代理 document.addEventListener('click', e => ...),自动收集 data-track |
| PV/UV | Vue Router afterEach / React Router useLocation |
| 性能 | PerformanceObserver + web-vitals 库 |
| JS 异常 | window.onerror + unhandledrejection |
| 资源异常 | addEventListener('error', e => ..., true) 用捕获阶段 |
| 接口异常 | Axios 拦截器 |
| 批量上报 | 队列 + 定时 + 阈值 |
| 离开上报 | navigator.sendBeacon 或 fetch keepalive,绝对不能用 XHR(浏览器关闭会取消) |
| 离线缓存 | 上报失败存 IndexedDB,下次进入页面重传 |
| 采样 | 性能数据 10% 采样,异常数据 100% |
核心代码(见 examples/06-tracker.js):
js
class Tracker {
constructor() {
this.queue = [];
setInterval(() => this.flush(), 5000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.flush(true);
});
this.bindClick();
this.bindError();
this.bindPerf();
}
bindClick() {
document.addEventListener('click', (e) => {
const target = e.target.closest('[data-track]');
if (target) this.push({ type: 'click', code: target.dataset.track });
});
}
bindError() {
window.addEventListener('error', (e) => this.push({ type: 'jsError', msg: e.message, stack: e.error?.stack }));
window.addEventListener('unhandledrejection', (e) => this.push({ type: 'promise', msg: String(e.reason) }));
}
bindPerf() {
new PerformanceObserver((list) => {
list.getEntries().forEach((e) => this.push({ type: 'perf', name: e.name, value: e.startTime }));
}).observe({ entryTypes: ['paint', 'largest-contentful-paint'] });
}
push(data) {
this.queue.push({ ...data, ts: Date.now(), uid: getUid() });
if (this.queue.length >= 10) this.flush();
}
flush(isUnload = false) {
if (!this.queue.length) return;
const data = this.queue.splice(0);
if (isUnload) navigator.sendBeacon('/track', JSON.stringify(data));
else fetch('/track', { method: 'POST', body: JSON.stringify(data), keepalive: true });
}
}难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| 上报方式 | XHR / fetch | sendBeacon | 平时fetch,离开Beacon |
| 数据格式 | JSON | Protobuf | JSON 简单;高频用 Protobuf 体积省 30% |
| 采样 | 100% | 10% | 异常 100%,行为按 UID 分组采样 |
| 命名规范 | 自由命名 | 强约束 模块:对象:动作 | 强约束,便于聚合 |
| 接入方式 | 业务侵入 | AOP/装饰器 | 声明式(指令 / data 属性) |
加分项
- 可视化埋点:管理后台框选元素自动生成埋点代码
- 无埋点:全量采集 click / view,业务后选取
- A/B 标签:埋点带实验组信息
- 链路追踪:traceId 串起前后端,定位慢请求
- 隐私合规:GDPR / 个保法,敏感字段脱敏
兜底容错
- 上报接口挂 → IndexedDB 缓存,重试 3 次后丢
- 数据量超出 → 队列截断(保留最近 100 条)
- SDK 自身崩溃 → try-catch 包裹,绝不影响业务
- 大数据 hot key → 客户端聚合后上报
Q7:前端错误监控系统
背景
线上偶发 bug,用户反馈不清不楚,开发查不到日志。需要主动捕获、定位、告警。
核心需求
- 捕获 JS 报错 / Promise 异常 / 资源加载失败 / 接口异常
- 上报关键上下文(用户 / 页面 / 浏览器 / 操作录像)
- Source Map 还原压缩后的栈
- 去重 + 聚合:相同错误不重复告警
- 告警阈值:5 分钟内 100 次自动通知
设计方案
┌────────────────── 客户端 SDK ──────────────────┐
│ │
│ ┌────────────────────┐ │
│ │ 错误捕获层 │ │
│ │ - window.onerror │ │
│ │ - unhandledrejection│ │
│ │ - addEventListener('error', cap=true) │ │
│ │ - axios.interceptors.response │ │
│ │ - try/catch wrap (Vue.errorHandler) │ │
│ └─────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ 数据增强层 │ │
│ │ + url / userAgent │ │
│ │ + uid / sessionId │ │
│ │ + breadcrumbs (用户最近 50 个操作) │ │
│ │ + UA / 屏幕 / 网络 │ │
│ └─────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ 上报层 │ │
│ │ - 去重 (相同 hash 1 分钟内只发 1 次)│ │
│ │ - 节流上报 │ │
│ │ - sendBeacon │ │
│ └─────────┬───────────┘ │
└────────────┼────────────────────────────────────┘
│
▼
┌────────────── 服务端 ──────────────────┐
│ 接收 → 解析 → Source Map 反查 → 写库 │
│ → 聚合分组 → 告警(钉钉/企微/邮件) │
└────────────────────────────────────────┘关键技术点
| 类型 | 监听方式 | 注意 |
|---|---|---|
| JS 同步异常 | window.onerror | 跨域脚本要 crossorigin + Access-Control-Allow-Origin |
| Promise 异常 | unhandledrejection | 全局兜底未 catch 的 Promise |
| 资源加载失败 | addEventListener('error', cb, true) 捕获阶段 | onerror 拿不到 |
| Vue 异常 | Vue.config.errorHandler | 拦截组件渲染错误 |
| React 异常 | ErrorBoundary 组件 | hooks 用 useErrorBoundary |
| Axios 异常 | 拦截器 | 4xx/5xx 都上报 |
| 白屏检测 | MutationObserver 1s 后无 DOM 节点 | 兜底 |
| 性能异常 | PerformanceObserver LCP > 4s |
核心代码(见 examples/07-error-monitor.js):
js
function setup() {
window.addEventListener('error', (e) => {
if (e.target?.tagName) {
report({ type: 'resource', src: e.target.src || e.target.href, tag: e.target.tagName });
} else {
report({ type: 'jsError', msg: e.message, stack: e.error?.stack, line: e.lineno });
}
}, true);
window.addEventListener('unhandledrejection', (e) => {
report({ type: 'promise', msg: String(e.reason), stack: e.reason?.stack });
});
axios.interceptors.response.use(null, (err) => {
report({ type: 'api', url: err.config?.url, status: err.response?.status, msg: err.message });
return Promise.reject(err);
});
Vue.config.errorHandler = (err, vm, info) => report({ type: 'vue', msg: err.message, stack: err.stack, info });
}难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| Source Map | 上传到 CDN | 上传到监控服务 | 上传到服务,安全(不公开) |
| 去重 | 全文 hash | (msg + stack 前 3 行) hash | 后者,避免抖动 |
| 用户操作录像 | rrweb | 自己写 | rrweb(成熟) |
| 告警通道 | 邮件 | 企微/钉钉机器人 | 机器人,及时 |
加分项
- Source Map 服务自动反查(生产不带 sourceMappingURL)
- rrweb 录屏:复现用户操作轨迹(可控制采样率)
- 自动归因:根据 PR 时间 / 提交人自动 @ 责任人
- 趋势告警:基线偏离 3σ 才告警,避免噪音
- 流量保护:错误风暴时自动降级上报
兜底容错
- 监控 SDK 本身要 try-catch 全包,绝不能崩溃业务
- 上报失败重试 3 次后丢弃
- 跨域错误
Script error.→ 提示用户加crossorigin - 死循环错误抑制:1 分钟内同 hash 只上报 5 次
Q8:多 Tab 通信方案对比
背景
同一用户开了 3 个 Tab,A Tab 退出登录,希望 B、C 同步登出;A Tab 收到新消息,B、C 也要刷新红点。
核心需求
- 同源跨 Tab 通信
- 支持广播(一对多)
- 支持定向(一对一)
- 兼容性好,覆盖到 IE / 老 Safari
5 种方案全对比
| 方案 | 原理 | 优点 | 缺点 | 兼容性 |
|---|---|---|---|---|
| BroadcastChannel | 浏览器原生广播 API | 最简洁 | IE 不支持 | Edge/FF/Chrome ✅ |
localStorage storage 事件 | 存储变化触发其他 Tab 事件 | 兼容好 | 触发 Tab 自身收不到 | 全部 ✅ |
| SharedWorker | 共享 Worker 中转 | 长连接、可计算 | iOS Safari 不支持 | 部分 |
| IndexedDB + 轮询 | 数据库变更检测 | 适合数据量大 | 实时性差 | 全部 |
| Service Worker | SW 中转消息 | 离线也支持 | 复杂 | 现代浏览器 |
设计方案:组合最佳实践
┌────────── 主方案 ──────────┐ ┌──── 降级方案 ────┐
│ BroadcastChannel │ ── 不支持 ──▶│ localStorage │
│ channel.postMessage(msg) │ │ storage 事件 │
└─────┬──────────────────────┘ └──────┬───────┘
│ │
└──────────────────┬──────────────────────────┘
▼
统一封装为 BusEmitter
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
Tab A Tab B Tab C
on('logout') on('logout') on('logout')核心代码
js
class TabBus {
constructor(name) {
this.name = name;
this.listeners = {};
if (typeof BroadcastChannel !== 'undefined') {
this.bc = new BroadcastChannel(name);
this.bc.onmessage = (e) => this.dispatch(e.data);
} else {
window.addEventListener('storage', (e) => {
if (e.key === `__tabbus__${name}` && e.newValue) {
this.dispatch(JSON.parse(e.newValue));
}
});
}
}
emit(event, payload) {
const data = { event, payload, ts: Date.now() };
if (this.bc) this.bc.postMessage(data);
else localStorage.setItem(`__tabbus__${this.name}`, JSON.stringify(data));
}
on(event, fn) { (this.listeners[event] ||= []).push(fn); }
dispatch({ event, payload }) {
this.listeners[event]?.forEach((fn) => fn(payload));
}
}
// 使用
const bus = new TabBus('auth');
bus.on('logout', () => location.href = '/login');
// 在 A Tab:
bus.emit('logout');难点与权衡
| 场景 | 推荐方案 |
|---|---|
| 简单广播 | BroadcastChannel + localStorage 兜底 |
| 跨域 Tab | postMessage + iframe 中转(同 SSO) |
| 长连接 | SharedWorker(一个 WebSocket 多 Tab 共享) |
| 离线消息 | Service Worker |
| 数据同步 | IndexedDB + change listener(如 Dexie) |
加分项
- 用 SharedWorker 共用一个 WebSocket,节省连接数
- 主 Tab 选举(leader election):一个 Tab 负责轮询接口,其他 Tab 共享数据
- BroadcastChannel 上加 traceId,便于调试
- 提供
request/response模式(一对一通信)
兜底容错
- BroadcastChannel 失败自动降级 localStorage
- 消息体大于 5MB(localStorage 上限)时分片
- 监听
storage事件要做 try-catch 解析 - Tab 关闭时清理订阅
Q9:复杂表单设计(嵌套 / 动态字段 / 校验联动)
背景
ToB 后台经常有"配置型表单":100+ 字段、嵌套 List、字段间联动(A 选 1 → B 显示,A 选 2 → B 隐藏)。
核心需求
- 动态字段:增减子表单(数组型)
- 嵌套:表单里套表单
- 联动:字段值变化触发其他字段变化
- 校验:同步 / 异步 / 跨字段校验
- 回填 / 重置 / 草稿保存
- 性能:100+ 字段不卡
设计方案
┌──────────── 表单架构 ────────────┐
│ │
│ Schema (JSON 配置) │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Form Engine │ │
│ │ - 字段树管理 │ │
│ │ - 值的双向同步 │ │
│ │ - 校验调度 │ │
│ │ - 联动调度 │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Field 组件 │ │
│ │ <Input> │ │
│ │ <Select> │ │
│ │ <DatePicker> │ │
│ │ <FormList> │ │
│ └─────────────────┘ │
└───────────────────────────────────┘Schema 示例:
js
const schema = [
{ name: 'type', type: 'radio', options: ['企业', '个人'], rules: [{ required: true }] },
{ name: 'companyName', type: 'input', when: (val) => val.type === '企业', rules: [{ required: true }] },
{ name: 'idCard', type: 'input', when: (val) => val.type === '个人', rules: [{ pattern: /^\d{18}$/ }] },
{
name: 'contacts', type: 'list', itemFields: [
{ name: 'name', type: 'input' },
{ name: 'phone', type: 'input', rules: [{ pattern: /^1\d{10}$/ }] },
]
},
];关键技术点
| 难点 | 实现 |
|---|---|
| 字段管理 | Form 实例维护 values / errors / touched Map |
| 联动 | when(values) 函数判断显示,change 时重新计算依赖项 |
| 嵌套 | 字段路径用点 address.city 或数组 contacts.0.phone |
| 校验 | 用 yup / zod 描述 schema;async-validator 老牌选择 |
| 跨字段校验 | 校验函数接收 (value, allValues) |
| 性能 | 用细粒度订阅(只重渲染变更字段,不全量 render) |
| 草稿 | 每 5s 存 localStorage,进入页面回填 |
难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| 表单库 | 手写 | Formily / antd Form | Formily(专为复杂表单) |
| 状态管理 | 整体一个对象 | 字段级 atom | atom(jotai 思路),性能好 |
| 联动 | 配置 when | 自动响应(MobX) | 配置式,可序列化 |
| 校验时机 | onChange | onBlur | onBlur(不打断输入) |
加分项
- JSON Schema 驱动:完全可视化配置表单
- TypeScript 类型推导:字段类型从 schema 推
- 可视化拖拽:拖出表单设计器
- AI 自动校验规则:根据字段名推测正则
- 防回退丢失:路由切换前 confirm
兜底容错
- 接口提交失败 → 保留输入,按钮恢复
- 字段配置异常 → 渲染 fallback "字段配置错误"
- 联动循环依赖 → 检测并打断
- 草稿恢复时校验已变化的 schema
Q10:富文本编辑器选型与实现思路
背景
需要做评论 / 文档 / 邮件编辑器,比 textarea 高级,比 Word 简单。
选型对比
| 编辑器 | 类型 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|---|
| Quill | 自研模型 | 简洁、文档全 | 扩展难 | 评论 / 简单文档 |
| TinyMCE / CKEditor | 老牌 | 功能全 | 体积大 | 企业邮件 / CMS |
| ProseMirror | 框架 | 极致灵活、协同好 | 学习曲线陡 | 需深度定制 |
| Slate | React 生态 | API 优雅 | 大版本不稳 | React 项目 |
| Lexical (Meta) | 现代 | 性能极好、A11y | 较新 | 新项目 |
| Tiptap | ProseMirror 包装 | 易用 + 强大 | 商业插件收费 | 协同文档 |
实现核心架构
┌────────── 富文本三层模型 ──────────┐
│ │
│ ① 数据层(Document Model) │
│ 树状结构,节点有类型/属性/子节点│
│ {type:'doc',content:[...]} │
│ │
│ ② 视图层(Renderer) │
│ 根据数据生成 DOM │
│ contenteditable 容器 │
│ │
│ ③ 交互层(Plugins) │
│ - 输入处理(IME) │
│ - 选区管理(Selection / Range) │
│ - 命令系统(execCommand) │
│ - 快捷键(Ctrl+B 等) │
│ - 上传图片 / 粘贴 │
└─────────────────────────────────────┘关键技术点
| 难点 | 实现 |
|---|---|
| 数据模型 | 自己设计 JSON 树(如 ProseMirror Schema),别用 HTML 字符串(脏数据多) |
| DOM 与模型同步 | MutationObserver 监听 DOM 变化反向更新模型;模型变化重渲染 DOM |
| 选区 | window.getSelection() + Range API |
| 粘贴 | paste 事件,过滤 HTML,保留必要样式 |
| 拖拽 | dragstart / dragover / drop |
| 图片上传 | 拦截粘贴 / 拖拽 / 工具栏,转 Blob 上传,回填 URL |
| 协同 | OT (Operational Transform) 或 CRDT (Y.js) |
| 历史栈 | 命令模式 + 双栈(undo / redo) |
难点与权衡
| 难点 | 选择 |
|---|---|
contenteditable vs Canvas | contenteditable 兼容性好;Canvas(如腾讯文档)性能极好但门槛高 |
| 自研 vs 用框架 | 99% 场景别自研,水深无比 |
| 协同 | 简单选 OT,复杂选 CRDT (Y.js / Automerge) |
| 数据存储 | JSON > HTML > Markdown,JSON 最灵活 |
加分项
- 协同:基于 Y.js + WebSocket
- AI 续写:调用 GPT 流式插入
- @提及 / 表情 / 链接预览
- 大文档虚拟滚动(lexical 内置)
兜底容错
- 输入丢失:每 5s 自动保存草稿到 localStorage
- IME 中文输入:
compositionstart / end期间不触发任何处理 - 粘贴富文本污染:白名单过滤,移除
stylescript - 移动端虚拟键盘弹起后 input 被遮 →
scrollIntoView
Q11:实时聊天界面(WebSocket + 消息状态)
背景
IM、客服、协作工具的核心。
核心需求
- 实时收发消息(WebSocket)
- 消息状态:发送中 / 已发送 / 已送达 / 已读 / 失败
- 离线消息 + 历史消息分页
- 断线重连
- 多端同步
设计方案
┌─────────── 客户端 ────────────┐
│ │
│ ChatStore (Pinia/Zustand) │
│ - sessions[] │
│ - messages: {sessionId: []} │
│ - draft / unread / typing │
│ │
│ WSManager │
│ - connect / reconnect │
│ - heartbeat (ping/pong) │
│ - send / receive │
│ - subscribe(event, fn) │
│ │
│ MessageQueue (发送中) │
│ - 本地立即上屏 │
│ - status: pending → sent │
│ - 超时重发 │
│ │
└────────┬───────────────────────┘
│ WSS
▼
┌─────────── 后端 ──────────────┐
│ 长连接网关 → 消息中心 → 存储 │
└───────────────────────────────┘消息状态机
[本地输入] → [pending] ──发送成功─▶ [sent] ─对端 ack──▶ [delivered] ─对端阅读─▶ [read]
│
超时/失败
▼
[failed] ──手动重试──▶ [pending]关键技术点
| 模块 | 实现 |
|---|---|
| WebSocket | new WebSocket(url) + 重连 + 心跳 |
| 重连 | 指数退避 1s → 2s → 4s → ... 最大 30s |
| 心跳 | 每 30s 发 ping,60s 没收到 pong 重连 |
| 乐观更新 | 发送瞬间本地上屏(pending 状态) |
| 去重 | 客户端 clientMsgId,服务端用此判断幂等 |
| 顺序 | 服务端 server seq + 客户端排序 |
| 历史消息 | 翻页用 cursor (lastMsgId) |
| 离线消息 | 上线后 pull 最近 N 条 |
| 多端同步 | 服务端通知所有在线设备 |
| 未读 | 进入会话清零 + 通知服务端 |
核心代码
js
class WSManager {
constructor(url) {
this.url = url; this.handlers = {};
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => { this.flushQueue(); this.startHeartbeat(); };
this.ws.onmessage = (e) => this.dispatch(JSON.parse(e.data));
this.ws.onclose = () => this.reconnect();
}
reconnect() {
this.retry = (this.retry || 1000) * 2;
setTimeout(() => this.connect(), Math.min(this.retry, 30000));
}
startHeartbeat() {
this.hb = setInterval(() => {
if (this.ws.readyState === 1) this.ws.send('{"type":"ping"}');
}, 30000);
}
send(msg) {
if (this.ws.readyState === 1) this.ws.send(JSON.stringify(msg));
else (this.queue ||= []).push(msg);
}
// ...
}难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| 长连接 | WebSocket | SSE | WS(双向) |
| 消息列表 | 全部存内存 | 虚拟列表 | 历史长的会话虚拟列表 |
| 顺序 | 服务端 seq | 客户端 ts | 服务端 seq(ts 不可信) |
| 离线 | 上线全 pull | 增量 pull | 增量 + 最大限制 |
加分项
- 消息端到端加密(Signal 协议)
- @提及推送 / 全员消息广播
- 消息撤回(2 分钟内)
- 输入中状态 typing indicator
- 消息搜索(全文索引)
兜底容错
- WS 断开 → UI 显示"重连中"
- 发送失败 → 红色感叹号 + 重试按钮
- 服务端踢出 → 提示重新登录
- 消息超大 → 自动转图片附件
Q12:撤销重做画板
背景
设计工具、流程图工具、白板的核心能力。
核心需求
- 画图(直线 / 矩形 / 圆 / 自由笔触)
- 撤销 / 重做(Ctrl+Z / Ctrl+Shift+Z)
- 多对象选中 / 移动 / 缩放
- 性能流畅(60fps)
- 序列化保存
设计方案:命令模式
┌─────────────── 编辑器 ────────────────┐
│ │
│ ┌──────────┐ │
│ │ Canvas │ ◀── render(state) │
│ └──────────┘ │
│ ▲ │
│ │ │
│ ┌──────────┐ │
│ │ State │ {shapes:[...]} │
│ └──────────┘ │
│ ▲ │
│ │ │
│ ┌─────────────────────┐ │
│ │ HistoryManager │ │
│ │ undoStack: Command[]│ │
│ │ redoStack: Command[]│ │
│ └─────────────────────┘ │
│ ▲ │
│ │ │
│ ┌─────────────────────┐ │
│ │ Command 接口 │ │
│ │ - execute() │ │
│ │ - undo() │ │
│ └─────────────────────┘ │
│ Implementations: │
│ AddShapeCommand │
│ MoveShapeCommand │
│ DeleteShapeCommand │
│ StyleChangeCommand │
└────────────────────────────────────────┘关键技术点
| 模块 | 实现 |
|---|---|
| 画布 | Canvas 2D 或 SVG,复杂场景用 PixiJS / Konva |
| 命令模式 | 每个操作封装为 Command,有 execute / undo |
| 栈管理 | undoStack 限长 100;新操作清空 redoStack |
| 拾取 | hitTest(坐标判断在哪个 shape 内) |
| 渲染 | requestAnimationFrame 重绘脏区 |
| 性能 | 离屏 Canvas / OffscreenCanvas / 分层(背景 + 内容 + 选中) |
| 序列化 | shapes 数组转 JSON,反向 deserialize |
核心代码
js
class History {
constructor() { this.undoStack = []; this.redoStack = []; }
do(cmd) { cmd.execute(); this.undoStack.push(cmd); this.redoStack = []; }
undo() { const c = this.undoStack.pop(); if (c) { c.undo(); this.redoStack.push(c); } }
redo() { const c = this.redoStack.pop(); if (c) { c.execute(); this.undoStack.push(c); } }
}
class AddShapeCommand {
constructor(state, shape) { this.state = state; this.shape = shape; }
execute() { this.state.shapes.push(this.shape); }
undo() { this.state.shapes.pop(); }
}难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| 撤销实现 | 状态快照(immer) | 命令模式 | 命令模式(粒度细,省内存) |
| 渲染 | 全量重绘 | 脏区更新 | 简单全量;复杂脏区 |
| 高频操作(拖拽) | 每帧新 Command | 拖拽完合并为 1 个 | 合并,否则栈炸 |
| 大画布 | 一张大 Canvas | 切片渲染 | 切片 + 视口剔除 |
加分项
- 多人协作:每个 Command 通过 WS 广播,本地 + 远程合并
- 自动保存:每 30s 序列化存 IndexedDB
- 操作录像 → 回放
- WebWorker 离屏渲染(OffscreenCanvas)
兜底容错
- 栈过大 → 截断最旧命令
- 操作失败 → catch 后从快照恢复
- 浏览器崩溃 → 草稿恢复
Q13:微前端架构(qiankun vs Module Federation)
背景
巨型管理后台多团队协作,单仓库部署慢、技术栈难统一。
核心需求
- 子应用独立开发 / 部署
- 主应用聚合,路由分发
- 子应用之间通信
- 技术栈无关(React / Vue 共存)
- 样式 / JS 隔离
主流方案对比
| 方案 | 原理 | 优点 | 缺点 | 适合 |
|---|---|---|---|---|
| iframe | 浏览器原生隔离 | 最稳 | 体验差、通信难 | 完全独立子应用 |
| qiankun (single-spa) | JS 沙箱 + Style 隔离 | 主流,文档全 | 需要主应用配置多 | 中后台 |
| Module Federation | Webpack 5 远程加载 | 共享依赖、打包优 | 强依赖 Webpack | 现代项目 |
| Web Components | 浏览器原生 | 标准、轻 | 状态管理麻烦 | 简单组件 |
| micro-app (京东) | Web Components + 沙箱 | 接入简单 | 生态小 | 京东系 |
| Garfish (字节) | qiankun 思路 | 性能优 | 生态小 | 字节系 |
qiankun 架构
┌──────────── 主应用 (Base) ────────────┐
│ - 路由表:/app1 → app1, /app2 → app2 │
│ - 注册子应用 │
│ - 全局状态 / 通信中心 │
│ │
│ registerMicroApps([ │
│ { name:'app1', entry:'http://...', │
│ container:'#sub', activeRule:'/app1' },│
│ ]); │
│ start(); │
└────────┬─────────────┬─────────────────┘
│ │
匹配路由 │
▼ ▼
┌────────────┐ ┌────────────┐
│ 子应用 1 │ │ 子应用 2 │
│ (React) │ │ (Vue) │
└────────────┘ └────────────┘
- bootstrap()
- mount()
- unmount()Module Federation 架构
┌──── shell (host) ────┐ ┌──── app1 (remote) ────┐
│ webpack: │ │ webpack: │
│ remotes: { │ │ exposes: { │
│ app1: 'app1@..' │ │ './App':'./src/App'│
│ } │ │ } │
│ │ │ │
│ 代码: │ │ │
│ const App = await │ │ │
│ import('app1/App') │ │
└──────────────────────┘ └────────────────────────┘关键技术点
| 难点 | qiankun | MF |
|---|---|---|
| JS 隔离 | Proxy / Snapshot 沙箱 | webpack scope |
| CSS 隔离 | Shadow DOM / 加 ID 前缀 | scoped CSS |
| 通信 | initGlobalState | shared module |
| 资源加载 | import-html-entry 解析子应用 HTML | webpack remote |
| 路由 | 主应用劫持 popstate | 客户端路由分发 |
| 共享依赖 | externals | shared 配置 |
难点与权衡
| 维度 | qiankun | MF |
|---|---|---|
| 接入成本 | 中(子应用需改 main.js) | 高(webpack 配置) |
| 运行时性能 | 每次切换重启子应用 | 模块级共享 |
| 适合场景 | 后台聚合、多技术栈 | 现代单技术栈、共享组件 |
| 生态 | 蚂蚁开源 | webpack 5 原生 |
加分项
- 子应用预加载:空闲时预下载未激活子应用
- 路由级灰度:5% 用户访问新版子应用
- 依赖去重:React、Vue 走 CDN externals
- 统一登录态:全局 store 共享 token
兜底容错
- 子应用加载失败 → 显示 fallback 页 + 重试
- 子应用 JS 报错不崩主应用(沙箱 try-catch)
- 主应用 down → 子应用可独立访问 URL(独立部署)
- 版本不一致 → 强制刷新
Q14:前端国际化(i18n)方案
背景
产品出海 / 多语言需求。
核心需求
- 文案多语言
- 时间 / 数字 / 货币按区域格式化
- 复数 / 性别变体
- 切换语言不刷新
- 支持懒加载(不一次性加载所有语言包)
设计方案
┌──────────── 配置 ────────────┐
│ locales/ │
│ zh-CN.json │
│ en-US.json │
│ ja-JP.json │
│ { "hello": "你好 {name}", │
│ "count": "{n, plural, │
│ one {1 item} │
│ other {# items}}"} │
└────────┬───────────────────────┘
│
▼
┌──────────── i18n 引擎 ──────────┐
│ - locale 当前语言 │
│ - messages 字典 │
│ - t(key, vars) 翻译函数 │
│ - format ICU 占位符 │
│ - 切换语言:动态 import │
│ - fallback 找不到回 enUS │
└────────┬────────────────────────┘
│
▼
┌──────────── UI ─────────────────┐
│ Vue: $t('hello', { name:'X' }) │
│ React: useTranslation() │
│ 自动响应 locale 变化 │
└─────────────────────────────────┘关键技术点
| 模块 | 实现 |
|---|---|
| 库选型 | Vue: vue-i18n;React: react-i18next / formatjs |
| ICU 格式 | 复数 / 选择 / 嵌套占位 |
| 懒加载 | import(./locales/${lang}.json) |
| 持久化 | locale 存 localStorage / cookie |
| 检测 | navigator.language 自动判断初始语言 |
| 数字 / 时间 | Intl.NumberFormat / Intl.DateTimeFormat |
| 方向 | 阿语等 RTL:<html dir="rtl"> |
| 图片 / 链接 | 资源也要按 locale 切换 |
难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| 翻译来源 | 写死 JSON | CMS 动态拉 | 静态文案JSON,运营文案CMS |
| 切语言 | 整页刷新 | 不刷新动态切 | 不刷新,更顺滑 |
| 翻译协作 | 工程师手填 | Crowdin/Phrase | 协作平台,避免漏翻 |
| Key 管理 | 嵌套 | 扁平点分 | 扁平,便于工具扫描 |
加分项
- 自动扫描 missing key 上报
- A/B 测试不同译文转化率
- 富文本翻译:用占位符保留组件
<a>{0}</a> - 服务端预渲染对应语言(SEO)
- AI 翻译辅助初稿
兜底容错
- key 找不到 → 显示 key(不要空白)+ 上报
- 语言包加载失败 → 回退到默认语言
- 时区差:服务端时间统一 UTC,客户端格式化
- 字符宽度差:UI 设计预留 30% 弹性
Q15:前端 A/B 测试系统
背景
新功能不知道效果,需要小流量验证。
核心需求
- 流量分桶(按 userId / deviceId)
- 变体配置(控制组 / 实验组)
- 实时下发(不发版改实验)
- 埋点关联(行为带实验组标签)
- 效果分析(转化率 / 留存)
设计方案
┌──────────── 实验平台后台 ────────────┐
│ - 创建实验:实验 ID / 变体 / 流量 │
│ - 用户分桶规则 │
│ - 实时启停 │
└────────────┬───────────────────────────┘
│
▼
┌──────────── 客户端 SDK ──────────────┐
│ init: 拉取所有实验配置(缓存) │
│ getVariant(expId): 返回 'A' / 'B' │
│ │ │
│ ▼ │
│ hash(uid + expId) % 100 │
│ │ │
│ 根据流量分配落到 A or B │
│ │ │
│ 曝光埋点:上报 user 进入哪个实验组 │
└────────────┬───────────────────────────┘
│
▼
┌──────────── 业务代码 ────────────────┐
│ if (ab.getVariant('newCheckout') === 'B') {│
│ renderNewVersion(); │
│ } else { │
│ renderOldVersion(); │
│ } │
└────────────────────────────────────────┘关键技术点
| 模块 | 实现 |
|---|---|
| 分桶算法 | hash(uid + expId) % 100(保证同一用户始终同组) |
| 配置下发 | 启动时拉一次,本地缓存;变更时 SSE 实时推送 |
| 曝光埋点 | 进入实验立即上报(不等转化) |
| Sticky | 同一用户多次访问命中同一组 |
| 冲突管理 | 互斥实验(A 实验和 B 实验不能同时命中) |
| 首屏一致性 | SSR 时也要分桶,避免客户端切换闪烁 |
核心代码
js
class ABTest {
constructor() { this.experiments = {}; this.exposed = new Set(); }
async init(uid) {
this.uid = uid;
this.experiments = await fetch('/ab/config').then((r) => r.json());
}
getVariant(expId) {
const exp = this.experiments[expId];
if (!exp || !exp.enabled) return 'A';
const bucket = this.hash(this.uid + expId) % 100;
let acc = 0;
for (const v of exp.variants) {
acc += v.weight;
if (bucket < acc) {
this.expose(expId, v.name);
return v.name;
}
}
return 'A';
}
expose(expId, variant) {
const key = expId + ':' + variant;
if (this.exposed.has(key)) return;
this.exposed.add(key);
tracker.push({ type: 'abExpose', expId, variant });
}
hash(s) {
let h = 0;
for (const c of s) h = ((h << 5) - h + c.charCodeAt(0)) | 0;
return Math.abs(h);
}
}难点与权衡
| 难点 | 方案 A | 方案 B | 推荐 |
|---|---|---|---|
| 分桶 | 服务端分 | 客户端分 | 客户端(不依赖网络) |
| 配置 | 启动拉 | 实时推 | 启动拉 + SSE 增量推 |
| 多实验冲突 | 自由叠加 | 互斥分层 | 分层,避免相互污染 |
| SSR | 不支持 | Cookie 分桶 | Cookie,前后端一致 |
加分项
- 多变量实验(MVT,A/B/C/D 同时)
- 分层实验:流量正交分层
- 自动停损:转化率显著下降自动暂停
- 显著性计算:UI 显示 p-value
- 灰度回滚:一键关闭实验
兜底容错
- SDK 初始化失败 → 全部走默认变体 A
- 实验配置异常 → fallback 到 A
- 上报失败 → 队列重试
- 用户无 uid(未登录) → 用 deviceId
总结:场景题答题"金句库"
把这些话术练熟,能在面试中本能脱口而出。
| 场景 | 金句 |
|---|---|
| 开场 | "我先确认几个前提:用户量 / 性能要求 / 兼容性范围" |
| 架构 | "整体我会拆成 4 层:UI 层 / 逻辑层 / 数据层 / 服务层" |
| 选型 | "这里有 3 个方案,我会选 X 因为……,但 Y 在 N 场景下更好" |
| 性能 | "用 IO 而不是 scroll,避免 layout thrashing" |
| 监控 | "上线前会做埋点,用数据验证优化效果" |
| 兜底 | "如果接口失败,我会从 LocalStorage 读上次缓存兜底" |
| 灰度 | "新功能 1% → 10% → 50% → 100% 灰度,配合 A/B 验证" |
| 收尾 | "如果还有时间,我可以聊一下 X 的扩展性 / 协同方案 / 离线场景" |
写到最后:场景题没有标准答案,体现的是思考路径。能讲清"为什么这么设计、有什么取舍、还有哪些坑",就比 80% 的候选人强。