从简历扩展的前端基础与手写题
不背孤立题库;从 SKU、支付、聊天、PDA、请求层和 Monorepo 自然展开原理与代码。
这不是通用八股大全。每组问题都有简历入口,练习目标是先讲真实项目,再补原理、边界和一段最小实现。代码用于面试表达,不是完整生产库或 Polyfill。
从简历自然引出什么
| 简历入口 | 可以引出的原理 | 适合手写 |
|---|---|---|
| SKU Path Map | Map/Set、位掩码、组合枚举、复杂度、规范化 Key | 非空子集、Path Map |
| TanStack Query | 服务端状态、引用语义、不可变更新、缓存和失效 | 深拷贝、LRU |
| 支付结果轮询 | 事件循环、闭包、定时器漂移、取消和终态 | 递归轮询、Promise.all |
| WebSocket 聊天 | 长生命周期回调、ACK、去重、重连、React key | EventEmitter、消息合并 |
| PDA 扫码与上传 | 防抖、节流、异步互斥、有限并发、背压 | 防抖/节流、提交锁、并发池 |
| 公共请求层 | Adapter、错误模型、AbortSignal、幂等、重试 | 指数退避、错误分类 |
| ECharts 看板 | 浏览器渲染、重排重绘、rAF、生命周期 | 节流、观察器清理 |
| Manifest V3 / WebView | 执行上下文、同源策略、CORS、XSS、开放重定向 | 消息校验、URL 白名单 |
| Monorepo | ESM/CJS、依赖方向、循环依赖、Tree Shaking | 拓扑排序、依赖检测 |
| RN 稳定性 | JavaScript 引擎、Bundle、Native Runtime、OTA 边界 | 以原理讲述为主 |
JavaScript:引用语义与不可变更新
SKU 页面会根据 Query 返回的商品数据派生 selected、disabled 和 Path Map。不能直接修改缓存对象,因为多个订阅者可能共享同一引用。
- 基本类型变量保存值;对象变量保存指向对象的引用。
- 展开运算符、
Object.assign和数组slice都是浅拷贝,嵌套对象仍共享。 - 浅比较只检查第一层引用,无法发现原地修改,也不会比较对象内部。
- React 和 TanStack Query 大量依赖引用变化判断状态更新。
useMemo是性能优化,不是语义保证;依赖不稳定或计算便宜时反而增加复杂度。- 浏览器
structuredClone支持循环引用、Map、Set、Date 等类型,但函数和 DOM 节点不可克隆。
推荐回答:
SKU 页面不是为了“使用 useMemo”而使用它。真正的原则是 Query 缓存视为不可变数据,页面状态由原始商品数据派生;只有商品规格或库存变化时才重建 Path Map,避免原地修改污染其他订阅页面。
JavaScript:事件循环、轮询与闭包
事件循环的回答顺序:
- 当前同步调用栈执行完。
- 清空微任务队列,例如
Promise.then、queueMicrotask。 - 浏览器在合适时机执行渲染。
- 取下一项宏任务,例如
setTimeout、消息事件和 I/O 回调。
setTimeout(fn, 1000) 表示至少等待约 1000ms 后进入任务队列,不保证精确执行时间。主线程繁忙、后台标签页限流都会产生偏差。
支付轮询更适合递归 setTimeout,因为它会等上一轮请求完成后再安排下一轮;setInterval 不关心上一轮是否结束,接口慢于间隔时可能产生请求重叠。
长生命周期回调容易闭包捕获旧状态。聊天的 onmessage、支付轮询和定时器可以通过以下方式读取最新值:
- 正确维护 Effect 依赖并重新绑定。
- 使用函数式状态更新。
- 对必须保持稳定的订阅回调,用
ref.current保存最新值。
取消也要区分:
clearTimeout只取消尚未执行的定时器。- 活动标记只能忽略已经返回的旧结果,不能停止网络请求。
- 请求链支持时,应把
AbortSignal传到真正的 Transport。 - 即使客户端取消成功,服务端写操作也可能已经执行,仍需要幂等和状态机。
React:key、重渲染和 Effect
key 为什么重要
key 用于同一层级子节点的身份匹配,帮助 Reconciler 判断节点是复用、移动还是销毁。聊天中如果本地消息先使用 tempId,ACK 后直接换成服务端 ID 作为 key,React 可能重建节点并丢失局部状态。因此可以保留稳定 clientKey,服务端 ID 只用于去重和分页。
什么会触发组件重渲染
- 本组件 state 更新。
- 父组件重新渲染。
- Context value 身份变化。
- 订阅的外部 Store 更新。
重渲染不等于 DOM 一定变化。优化时先定位真实瓶颈,不要到处添加 useMemo。
useEffect 常见问题
- 依赖遗漏导致闭包读取旧状态。
- 在 Effect 中设置可以直接派生的数据,造成额外渲染。
- 未清理 WebSocket、监听器、定时器、Object URL 或观察器。
- 请求竞态导致旧响应覆盖新页面状态。
useLayoutEffect 在 DOM 变更后、浏览器绘制前同步执行,适合必须在首帧测量或修正布局的情况,但会阻塞绘制。ECharts 通常可以先用 useEffect + requestAnimationFrame 等布局稳定后读取容器尺寸。
TanStack Query:为什么不把接口数据放 Zustand
服务端状态通常需要缓存、过期、并发去重、取消、重试、分页、失效和重新获取语义,TanStack Query 已经处理这些问题。Zustand 更适合跨路由草稿、跨页选择、未提交编辑状态和临时流程上下文。
判断依据不是“哪个库更强”,而是:
- 谁拥有这份数据?
- 是否需要服务端同步?
- 生命周期多长?
- 失败与失效规则是什么?
Query Key 必须包含所有会影响结果的稳定参数。项目中的订单预览要包含 orderType + previewKey,否则购物车结算和立即购买可能命中同一份缓存。
Mutation 成功后常见三种更新方式:
invalidateQueries:重新获取,最稳妥。setQueryData:服务端返回完整结果时直接更新。- Optimistic Update:先改缓存,失败回滚,适合反馈要求高且冲突可控的场景。
网络:错误、幂等、重试和 401
Request 层为什么不能直接 Toast
Request 层只知道传输结果,不知道当前页面是否已经有旧数据、是否在后台刷新、是否属于用户主动操作。最终反馈应该由 Flow 或页面决定:
- Mutation 最终失败时提示一次。
- Query 首屏无数据时展示阻断错误。
- 已有缓存的后台刷新失败时继续展示内容,提供非阻断提示。
- 主动取消保持静默。
- required 401 交给会话边界统一处理。
GET 一定幂等吗
HTTP 语义上 GET 应安全且幂等,但后端实现可能不符合约定。客户端重试仍要结合接口合同、限流和业务影响。
POST 怎样保证幂等
- 客户端提交锁只能减少重复点击。
- 请求携带业务幂等键或唯一流水号。
- 服务端使用数据库唯一约束。
- 服务端状态机判断当前状态是否允许操作。
- 调用方保存并复用同一个幂等键,而不是每次重试重新生成。
支付、下单和发货不能仅凭“网络失败”自动重发,因为客户端不知道请求是否已经到达服务端。
并发 401 为什么要协调
一个页面可能同时存在多个 Query。Token 失效后它们会同时返回 401;若每个拦截器都清缓存、清 Store 和跳转,会产生重复操作和竞态。项目使用每 App 独立的会话协调器:只接收 required 401,同一会话只通知一次;新登录成功后 reset。
浏览器:渲染、CORS 与安全
浏览器渲染可概括为:解析 HTML/CSS → DOM/CSSOM → Render Tree → Layout → Paint → Composite。
- 重排/Layout:尺寸或位置变化,需要重新计算几何信息。
- 重绘/Paint:外观变化但布局不变,例如颜色。
- 合成/Composite:transform、opacity 等属性可能由合成层处理,通常成本更低,但并非绝对不重绘。
CORS 是浏览器同源策略下的跨域读取授权,不是服务器之间请求的安全墙。非简单请求通常先发 OPTIONS 预检。
浏览器扩展和 WebView 可以继续引出:
- XSS:不可信内容被当作代码执行。
- CSRF:浏览器自动携带身份凭证,攻击页面诱导发出请求。
- 开放重定向:登录回跳直接信任外部 URL。
- WebView Bridge:必须校验来源、方法、参数和允许的能力范围。
模块系统与 Monorepo
ESM 与 CommonJS
- ESM 使用静态
import/export,依赖关系可在构建前分析,导出是 live binding。 - CommonJS 主要在运行时通过
require和module.exports加载。 - Tree Shaking 依赖静态分析、包的 sideEffects 声明、导出方式和实际访问;使用 ESM 不代表一定能删除无用代码。
幽灵依赖
代码使用了 package.json 没声明的包,但因为扁平化 node_modules 暂时可以访问。pnpm 的严格依赖能更早暴露问题,避免换机器、换包管理器或调整依赖后突然失败。
循环依赖
ESM 循环依赖可能读取尚未初始化的绑定;CommonJS 可能得到未执行完成的 exports。项目通过固定 apps → packages 依赖方向、公共包之间的清晰边界和禁止反向依赖降低风险。
手写:防抖和节流
PDA 扫码引出防抖与节流后,要主动说明:扫码提交本质上还需要异步互斥,单独按时间窗口合并事件不够。
function debounce<T extends (...args: any[]) => void>(fn: T, wait: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
function throttle<T extends (...args: any[]) => void>(fn: T, wait: number) {
let lastTime = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
const remaining = wait - (Date.now() - lastTime);
if (remaining <= 0) {
if (timer) clearTimeout(timer);
timer = undefined;
lastTime = Date.now();
fn.apply(this, args);
return;
}
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
lastTime = Date.now();
timer = undefined;
fn.apply(this, args);
}, remaining);
};
}
需要补充:生产版本通常还会支持 leading、trailing 和 cancel。
手写:异步提交锁
function createSubmitLock() {
let inFlight = false;
return async function run<T>(task: () => Promise<T>): Promise<T> {
if (inFlight) throw new Error('操作处理中,请勿重复提交');
inFlight = true;
try {
return await task();
} finally {
inFlight = false;
}
};
}
回答边界:提交锁只解决当前进程和当前入口,不能代替服务端幂等。App 重启、多设备提交或网络重试仍可能重复。
手写:有限并发池
图片上传需要在吞吐、内存、网络和失败率之间平衡,不能无上限 Promise.all。
async function mapLimit<T, R>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new RangeError('limit 必须是正整数');
}
const results = new Array<R>(items.length);
let nextIndex = 0;
async function consume() {
while (true) {
const index = nextIndex++;
if (index >= items.length) return;
results[index] = await worker(items[index], index);
}
}
const workerCount = Math.min(limit, items.length);
await Promise.all(Array.from({ length: workerCount }, consume));
return results;
}
复杂度约为 O(N),结果空间为 O(N),同时运行任务数不超过 limit。生产实现还要决定单任务失败时立即 reject、收集全部结果还是取消剩余任务。
手写:指数退避重试
type RetryOptions = {
retries?: number;
baseDelay?: number;
shouldRetry?: (error: unknown) => boolean;
};
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
async function retry<T>(
task: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
retries = 3,
baseDelay = 300,
shouldRetry = () => true,
} = options;
for (let attempt = 0; ; attempt++) {
try {
return await task();
} catch (error) {
if (attempt >= retries || !shouldRetry(error)) throw error;
const backoff = baseDelay * 2 ** attempt;
const jitter = Math.random() * baseDelay;
await sleep(backoff + jitter);
}
}
}
项目边界:公共业务的读取 Query 只对可恢复错误有限重试;Mutation 不直接复用本策略。429 还应读取 Retry-After,没有该信息时不要固定短延迟再次撞击限流。
手写:可取消的递归轮询
type PollResult<T> = { done: boolean; data: T };
function startPolling<T>(
query: () => Promise<PollResult<T>>,
onDone: (data: T) => void,
interval = 1500,
maxAttempts = 20,
) {
let active = true;
let timer: ReturnType<typeof setTimeout> | undefined;
let attempts = 0;
const tick = async () => {
try {
const result = await query();
if (!active) return;
if (result.done) return onDone(result.data);
attempts += 1;
if (attempts < maxAttempts) {
timer = setTimeout(tick, interval);
}
} catch {
if (active) timer = setTimeout(tick, interval);
}
};
void tick();
return () => {
active = false;
if (timer) clearTimeout(timer);
};
}
活动标记防止旧结果更新页面,但不是真正取消请求;请求支持时还要把 AbortSignal 传给 query,并区分失败、终态与达到最大次数后的超时态。
手写:Promise.all
function promiseAll<T>(items: Array<T | PromiseLike<T>>): Promise<T[]> {
return new Promise((resolve, reject) => {
if (items.length === 0) return resolve([]);
const results = new Array<T>(items.length);
let completed = 0;
items.forEach((item, index) => {
Promise.resolve(item).then(
(value) => {
results[index] = value;
completed += 1;
if (completed === items.length) resolve(results);
},
reject,
);
});
});
}
要点:结果保持输入顺序,不按完成顺序;任一 Promise reject 时外层立刻 reject,但其他任务仍可能继续执行。真正的 Promise.all 接受 iterable,这里是面试中的数组简化版。
手写:EventEmitter
type Listener<T> = (payload: T) => void;
class EventEmitter<Events extends Record<string, unknown>> {
private listeners = new Map<keyof Events, Set<Listener<any>>>();
on<K extends keyof Events>(event: K, listener: Listener<Events[K]>) {
const bucket = this.listeners.get(event) ?? new Set();
bucket.add(listener);
this.listeners.set(event, bucket);
return () => this.off(event, listener);
}
off<K extends keyof Events>(event: K, listener: Listener<Events[K]>) {
const bucket = this.listeners.get(event);
bucket?.delete(listener);
if (bucket?.size === 0) this.listeners.delete(event);
}
once<K extends keyof Events>(event: K, listener: Listener<Events[K]>) {
const unsubscribe = this.on(event, (payload) => {
unsubscribe();
listener(payload);
});
return unsubscribe;
}
emit<K extends keyof Events>(event: K, payload: Events[K]) {
const bucket = this.listeners.get(event);
if (!bucket) return;
[...bucket].forEach((listener) => listener(payload));
}
}
emit 复制 Set 是因为监听器可能在回调中取消或新增订阅。生产版本还可加入异常隔离、通配事件、最大监听器告警和异步监听策略。
手写:SKU 非空子集与 Path Map
type Sku = {
id: string;
stock: number;
values: string[];
};
const buildPathKey = (values: string[]) =>
[...values].sort().join('|');
function getNonEmptySubsets(values: string[]) {
const result: string[][] = [];
const total = 1 << values.length;
for (let mask = 1; mask < total; mask++) {
const subset: string[] = [];
for (let index = 0; index < values.length; index++) {
if (mask & (1 << index)) subset.push(values[index]);
}
result.push(subset);
}
return result;
}
function buildSkuPathMap(skus: Sku[]) {
const map = new Map<string, Set<string>>();
for (const sku of skus) {
if (sku.stock <= 0) continue;
for (const subset of getNonEmptySubsets(sku.values)) {
const key = buildPathKey(subset);
const ids = map.get(key) ?? new Set<string>();
ids.add(sku.id);
map.set(key, ids);
}
}
return map;
}
必须主动说明的边界:JavaScript 位运算按 32 位有符号整数处理,不适合规格维度非常大的通用集合;规范化 Key 还要处理重复 valueId、分隔符冲突和来源平台字段差异。
手写题临场检查表
拿到题先确认:
- 输入、输出和空输入怎样处理。
- 是否要保持结果顺序。
- 异常时立即失败还是收集全部结果。
- 是否需要取消、超时和清理。
- 是否可能重复提交,谁保证幂等。
- 时间复杂度和空间复杂度。
- JavaScript 的运行时边界,例如 32 位位运算、最大安全整数和引用共享。
最符合当前简历的优先级:
- 必须熟练:防抖/节流、异步锁、有限并发、重试、轮询、Promise.all。
- 项目重点:EventEmitter、消息 ACK/去重、SKU Path Map。
- 基础保底:深拷贝、LRU、call/new/instanceof,理解边界即可,不要背成完整 Polyfill。
每写完一题,用 20 秒回到项目:“它解决了哪条简历经历中的问题,生产环境还需要什么保护,哪些责任仍在后端或原生层。”