前端工程能力面试评估:从巨型表单到并发调度

1. 核心结论

“工程能力”并不等于:

  • 记住多少框架 API
  • 能复述多少源码原理
  • 会说多少性能优化名词

更关键的是:面对已经失控、充满历史包袱、边界条件复杂的真实业务场景时,能否快速识别问题本质,并把混乱局面收敛为可维护、可扩展、可验证的方案。

可以重点观察候选人是否具备以下能力:

  • 抽象能力:能否从组件代码中抽离出稳定的领域模型
  • 约束意识:能否识别性能、并发、内存、网络等现实边界
  • 解耦能力:能否把视图、状态、规则、调度、观测拆开
  • 可验证性:能否把方案变成可测试、可灰度、可监控的系统
  • 权衡能力:能否说明为什么选这个方案,而不是机械堆 useMemoPromise.all 或“先试试看”

2. 面试中的判断口径

当候选人回答工程场景题时,可以优先判断以下几个层次。

2.1 初中级信号

  • 习惯直接在 UI 层堆业务逻辑
  • 优先想到“拆组件”“加 Hooks”“加缓存”
  • 能给出局部优化,但无法解释系统为什么容易失控
  • 只会描述工具,不会描述边界和约束

2.2 高级信号

  • 先定义问题类型,再给实现方案
  • 能识别系统中的核心不变量与触发链路
  • 会先设计状态流、规则流、任务流、观测流
  • 方案能独立测试,不依赖具体框架组件才能运行
  • 能补充失败场景、监控手段、回滚路径与后续演进方式

3. 场景一:接手 2000 行巨型表单

3.1 问题本质

复杂表单真正难的地方,不是“组件太大”,而是下面这些问题混在了一起:

  • 字段状态
  • 校验逻辑
  • 联动规则
  • 远程请求
  • 显隐控制
  • 默认值与重置逻辑

如果这些逻辑全部散落在 useEffect、事件回调和 JSX 条件分支里,那么组件拆得再细,复杂度也只是被分散,不会真正下降。

3.2 更好的重构方向

推荐把“表单渲染”和“联动规则”分层:

  • 视图层:只负责展示字段、响应用户输入
  • 状态层:维护统一的表单状态与字段元数据
  • 规则层:定义字段变化后应该触发哪些动作
  • 执行层:负责清空、赋值、显隐、请求、校验等动作派发

一个轻量规则引擎的最小形态如下:

type FormState = Record<string, unknown>;
 
type RuleAction =
  | { type: "setValue"; field: string; value: unknown }
  | { type: "clearValue"; field: string }
  | { type: "setVisible"; field: string; visible: boolean }
  | { type: "fetchOptions"; field: string; api: string };
 
type RuleContext = {
  state: FormState;
  dispatch: (action: RuleAction) => void;
};
 
type RuleHandler = (value: unknown, context: RuleContext) => void;
 
class FormRuleEngine {
  private state: FormState = {};
  private rules = new Map<string, RuleHandler[]>();
 
  register(field: string, handler: RuleHandler) {
    const handlers = this.rules.get(field) ?? [];
    handlers.push(handler);
    this.rules.set(field, handlers);
  }
 
  update(field: string, value: unknown) {
    this.state[field] = value;
 
    const handlers = this.rules.get(field) ?? [];
    const context: RuleContext = {
      state: this.state,
      dispatch: (action) => this.apply(action),
    };
 
    handlers.forEach((handler) => handler(value, context));
  }
 
  private apply(action: RuleAction) {
    switch (action.type) {
      case "setValue":
        this.state[action.field] = action.value;
        break;
      case "clearValue":
        this.state[action.field] = undefined;
        break;
      case "setVisible":
        break;
      case "fetchOptions":
        break;
    }
  }
}

业务联动配置可以继续外置:

engine.register("userType", (value, { dispatch }) => {
  if (value === "VIP") {
    dispatch({ type: "setVisible", field: "discountCode", visible: true });
    dispatch({ type: "fetchOptions", field: "balance", api: "/api/balance" });
    return;
  }
 
  dispatch({ type: "clearValue", field: "discountCode" });
  dispatch({ type: "setVisible", field: "discountCode", visible: false });
});

3.3 面试里真正想听到的点

  • 先梳理字段模型、联动规则和副作用边界
  • 把规则写成配置或策略,而不是散在组件里
  • 把“字段变更”收敛到统一入口,避免多点写状态
  • 为规则层补单元测试,为关键表单流补集成测试
  • 重构顺序采用“外包裹、逐步替换、灰度迁移”,而不是一次性重写

4. 场景二:批量导出触发 1000 个请求

4.1 问题本质

这个场景考的不是会不会写 for + Promise.all,而是是否理解以下约束:

  • 浏览器与服务端的并发连接限制
  • 慢请求对整体吞吐的影响
  • 失败重试与取消机制
  • 前端内存占用与结果聚合方式

固定分批的 Promise.all 能跑,但吞吐通常不稳定。原因在于,整批任务会被最慢的那个请求拖住,导致并发槽位无法及时回填。

4.2 更合适的实现方式

应该使用“最大并发数固定、任务完成后立即补位”的调度器。

class ConcurrencyScheduler {
  private readonly limit: number;
  private running = 0;
  private queue: Array<() => void> = [];
 
  constructor(limit: number) {
    this.limit = limit;
  }
 
  add<T>(task: () => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      const run = () => {
        this.running += 1;
 
        task()
          .then(resolve, reject)
          .finally(() => {
            this.running -= 1;
            this.flush();
          });
      };
 
      this.queue.push(run);
      this.flush();
    });
  }
 
  private flush() {
    while (this.running < this.limit && this.queue.length > 0) {
      const job = this.queue.shift();
      job?.();
    }
  }
}

使用方式:

const scheduler = new ConcurrencyScheduler(6);
 
const tasks = urls.map((url) =>
  scheduler.add(async () => {
    const response = await fetch(url);
    return response.json();
  }),
);
 
const results = await Promise.allSettled(tasks);

4.3 面试里可以继续追问的点

  • 最大并发数为什么是 6,而不是拍脑袋决定
  • 是否需要失败重试、指数退避、超时控制
  • 是否支持 AbortController 主动取消
  • 导出结果是边拉边写,还是全部完成后再组装
  • 如果服务端支持任务化导出,是否应该改成后端异步任务而不是前端硬拉 1000 次

能主动谈到“前端调度只是过渡方案,长期应推动服务端导出架构升级”,通常比只会手写调度器更强。

5. 场景三:偶发 OOM 与内存泄漏排查

5.1 问题本质

这类问题通常不是“忘记清理定时器”这么简单,而是:

  • 只在长会话、多路由切换、特定数据规模下出现
  • 本地很难稳定复现
  • 浏览器采样与线上用户行为不一致

因此,真正重要的是“观测能力”,而不只是“本地排查动作清单”。

5.2 更可靠的排查路径

建议把排查分成三层:

  1. 本地基线排查
    重点检查事件监听、定时器、订阅、缓存、Detached DOM、超大列表与图片资源。

  2. 预发复现与压测
    用脚本模拟长时间停留、路由反复切换、大数据量加载与异常网络重试。

  3. 线上观测体系
    持续采集关键页面的内存趋势、节点数量、请求堆积、路由切换次数与异常崩溃日志。

5.3 可落地的线上观测思路

  • 为高风险页面记录会话级指标
  • 统计路由切换前后对象数量与缓存大小
  • 对关键容器做挂载/卸载计数
  • 结合错误监控平台上报崩溃前的用户路径与资源使用情况
  • 对长列表、图表、编辑器、富文本、地图等重组件单独埋点

5.4 关于 FinalizationRegistry

FinalizationRegistry 可以作为辅助实验工具,用来帮助理解某些对象是否有机会被垃圾回收;但它不适合被当作生产环境中的确定性证据,原因包括:

  • 回调触发时机不可预测
  • 不保证一定及时执行
  • 不适合作为业务逻辑依赖

因此,更稳妥的做法是把它放在“辅助观察”层,而把核心判断建立在:

  • 可重复的复现脚本
  • 线上趋势埋点
  • 堆快照对比
  • 组件生命周期与缓存策略审计

6. 可以复用的面试题设计方式

如果要考察真实工程能力,题目最好具备以下特征:

  • 来自真实项目,而不是脱离业务的算法题
  • 同时包含功能、性能、可维护性和风险控制
  • 可以继续追问迁移路径、回滚策略和监控方案
  • 没有唯一标准答案,但能明显区分回答层次

这类题目比单纯问“React Fiber 是什么”更能判断候选人是否真正做过复杂项目。

7. 一份更实用的评价标准

可以把候选人的回答分成四档:

  • 只会工具名词:知道 ContextPromise.allMemory 面板,但无法形成体系
  • 能写局部代码:可以完成功能,但缺少抽象、约束和演进意识
  • 能设计可维护方案:会分层、会建模、会测试、会监控
  • 能推动系统升级:不仅能修局部问题,还能反推团队规范、平台能力与架构演进

对于高级前端岗位,重点应放在后两档。

8. 相关链接