Frontend SWE · Phone Screen / React Coding

Uber 前端 React 格子灯:全亮后按点击逆序逐个熄灭

用纯 reducer 建模点亮与倒序熄灭,再由 React effect 安排单个 timeout;处理重复点击、旧定时器回调、Strict Mode 清理和卸载。

题目重点:用纯 reducer 建模点亮与倒序熄灭,再由 React effect 安排单个 timeout;处理重复点击、旧定时器回调、Strict Mode 清理和卸载。

题意与假设

这道练习题保留了面经中值得训练的核心约束,并将需要澄清的条件单独列出。现场拿到变体时,先和面试官确认这些条件。

  • 格子编号为 0..count−1;重复点击已亮格无效果;熄灭阶段忽略点击,直到全部熄灭后才可开启新一轮。题目未说明这些交互细节。
  • 每次 TICK 只熄灭最后一个被点亮且仍亮的格子,间隔由 UI 设定;代码中的 reducer 不直接计时,使状态转换可独立验证。
  • 一个动画 run 用 generation 标识;旧 timeout 发出的 TICK 若 generation 不等于当前值就忽略。React 组件卸载与依赖变化还必须清理实际 timeout。

解题路线

  1. 维护 lit、order、phase、generation 四类状态:lit 是当前亮格,order 保存本轮首次点击顺序;CLICK 只在 idle 且格子未亮时追加。最后一个格亮起后 phase 转 clearing,并递增 generation。
  2. React 组件用 useReducer(reduceLights, count, initialState)。useEffect 仅在 phase=clearing 时设置一个 setTimeout,回调 dispatch({type:'TICK',generation:state.generation});依赖 phase、order.length、generation,清理函数 clearTimeout(timer)。因此每轮只存在一个有效的下一步定时器。
  3. TICK 校验 phase 与 generation 后移除 order 最后一项;若还有亮格继续 clearing,effect 因 order.length 改变而清理旧 timeout 并安排下一个。最后一项熄灭后回 idle;重新点满时 generation 增大,旧回调不能污染新一轮。
  4. 在 React Strict Mode 开发环境中 effect 可能经历额外 setup/cleanup 周期;把 timeout 的创建和清理放同一个 effect,避免在 render 中计时或依赖闭包里过时的数组。

手动走一遍

count=3,依次点击 2、0、1,order=[2,0,1]。全亮后依次 TICK,lit 从 [2,0,1] 到 [2,0]、[2]、[],实际熄灭顺序是 1→0→2。若上一轮 generation=1 的延迟回调在新一轮 generation=2 到达,reducer 会直接忽略。

JavaScript 参考实现

下面的代码按上文假设实现,适合本地运行并追加自己的测试用例。

代码区可横向滚动;使用键盘时,先聚焦代码区再按方向键查看。

// Pure state transition core for a React useReducer + useEffect implementation.
export function initialState(count) {
  if (!Number.isInteger(count) || count < 1) throw new RangeError("count must be positive");
  return { count, lit: [], order: [], phase: "idle", generation: 0 };
}

export function reduceLights(state, event) {
  if (event.type === "CLICK") {
    const index = event.index;
    if (!Number.isInteger(index) || index < 0 || index >= state.count) return state;
    if (state.phase !== "idle" || state.lit.includes(index)) return state;
    const lit = [...state.lit, index];
    const order = [...state.order, index];
    return {
      ...state, lit, order,
      phase: lit.length === state.count ? "clearing" : "idle",
      generation: lit.length === state.count ? state.generation + 1 : state.generation,
    };
  }
  if (event.type === "TICK") {
    if (state.phase !== "clearing" || event.generation !== state.generation) return state;
    const removed = state.order.at(-1);
    const order = state.order.slice(0, -1);
    const lit = state.lit.filter((index) => index !== removed);
    return { ...state, lit, order, phase: order.length ? "clearing" : "idle" };
  }
  return state;
}

复杂度

count=n:每次 CLICK 的 includes 与数组复制最坏 O(n),每次 TICK 的 filter 也是 O(n),整轮 O(n²) 时间、状态 O(n);典型小网格足够。若 n 很大,可用 Set/位图和索引栈优化状态查询,但 React 不可原地修改共享状态。定时器同时存活数为 O(1)。

易错点与追问

容易失分的边界

  • 在一个 effect 内递归安排多个 setTimeout,却没有在依赖变化/卸载时清理,旧闭包继续写入新状态。
  • 重复点击同一格也追加 order,导致全部点亮时序与熄灭顺序错误。
  • 只 clearTimeout 而不校验 generation;极端情况下旧回调已排入任务队列,仍可能影响下一轮。
  • 把 lit 或 order 直接 push/pop 原数组后 setState,React 可能看不到引用变化,且旧闭包共享被修改对象。

继续深挖

  • 熄灭中若允许再次点亮格子,应排队到下一轮还是取消当前动画?先定义状态机,再改 reducer。
  • 若同时允许多个格子以不同速度熄灭,需要每格定时器还是单个时间轴?如何在 unmount 时完整清理?
  • 如何用 fake timers/事件序列测试 effect 的清理与 reducer 的代际保护,而不依赖真实等待?
✓已复制微信号!