← 返回资料库
面试准备 / PERSONAL ARCHIVE

从简历扩展的前端基础与手写题

不背孤立题库;从 SKU、支付、聊天、PDA、请求层和 Monorepo 自然展开原理与代码。

更新于 2026.09.11指南#JavaScript#React#网络#手写代码#八股

这不是通用八股大全。每组问题都有简历入口,练习目标是先讲真实项目,再补原理、边界和一段最小实现。代码用于面试表达,不是完整生产库或 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 返回的商品数据派生 selecteddisabled 和 Path Map。不能直接修改缓存对象,因为多个订阅者可能共享同一引用。

  • 基本类型变量保存值;对象变量保存指向对象的引用。
  • 展开运算符、Object.assign 和数组 slice 都是浅拷贝,嵌套对象仍共享。
  • 浅比较只检查第一层引用,无法发现原地修改,也不会比较对象内部。
  • React 和 TanStack Query 大量依赖引用变化判断状态更新。
  • useMemo 是性能优化,不是语义保证;依赖不稳定或计算便宜时反而增加复杂度。
  • 浏览器 structuredClone 支持循环引用、Map、Set、Date 等类型,但函数和 DOM 节点不可克隆。

推荐回答:

SKU 页面不是为了“使用 useMemo”而使用它。真正的原则是 Query 缓存视为不可变数据,页面状态由原始商品数据派生;只有商品规格或库存变化时才重建 Path Map,避免原地修改污染其他订阅页面。

JavaScript:事件循环、轮询与闭包

事件循环的回答顺序:

  1. 当前同步调用栈执行完。
  2. 清空微任务队列,例如 Promise.thenqueueMicrotask
  3. 浏览器在合适时机执行渲染。
  4. 取下一项宏任务,例如 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 成功后常见三种更新方式:

  1. invalidateQueries:重新获取,最稳妥。
  2. setQueryData:服务端返回完整结果时直接更新。
  3. 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 主要在运行时通过 requiremodule.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、分隔符冲突和来源平台字段差异。

手写题临场检查表

拿到题先确认:

  1. 输入、输出和空输入怎样处理。
  2. 是否要保持结果顺序。
  3. 异常时立即失败还是收集全部结果。
  4. 是否需要取消、超时和清理。
  5. 是否可能重复提交,谁保证幂等。
  6. 时间复杂度和空间复杂度。
  7. JavaScript 的运行时边界,例如 32 位位运算、最大安全整数和引用共享。

最符合当前简历的优先级:

  1. 必须熟练:防抖/节流、异步锁、有限并发、重试、轮询、Promise.all。
  2. 项目重点:EventEmitter、消息 ACK/去重、SKU Path Map。
  3. 基础保底:深拷贝、LRU、call/new/instanceof,理解边界即可,不要背成完整 Polyfill。

每写完一题,用 20 秒回到项目:“它解决了哪条简历经历中的问题,生产环境还需要什么保护,哪些责任仍在后端或原生层。”

自序 · 个人档案 2026-09-11