快手商业化前端面经:React Hooks 与事件循环
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- React 有哪些 hooks?
- 解释一下 useRef。(答:基础变量定义以及 DOM 绑定)
- 那它跟 useState 有什么区别?(答:useState 会生成一个快照)
- 看题说输出:下面代码点击后
console.log(count)打印什么、div 里的 count 显示什么?
const [count, setCount] = useState(0);
function dianji() {
setCount(1);
setCount(1);
console.log(count);
}
return <div onClick={dianji}>{count}</div>;
- 你知道有哪些 HTTP 状态码?
- 你刚才说的 304 Not Modified 是协商缓存,能展开说说吗?
- 你能说一下浏览器的事件循环吗?
- 那 Node 的事件循环能说一下吗?
- 你了解 MCP、tools 以及 skill 三者之间的关系吗?
- 那 function calling 跟 tools 是什么关系?
- 场景题:如果你个人编排的 skill 每一环节出了问题,该怎么告知 AI 以及排查?
- 需不需要给 AI 设置一些循环轮次?
- 讲一下个人的项目。
- 给你一个树形数组,求最大深度。
- 自我介绍。
- 反问:团队负责的业务、对实习生的期望。
《参考解析》
useRef 与 useState 的区别,以及那道输出题:useRef 返回一个在组件整个生命周期内保持同一个对象的 { current } 容器,改 current 不触发重渲染,常用于保存 DOM 引用、定时器 id、上一次的值、以及不需要参与渲染的可变变量;useState 返回值与 setter,setter 会触发重渲染,且每次渲染拿到的 state 是当次渲染的快照(闭包捕获的常量),不能用它读最新值。上面的输出题答案:console.log(count) 打印 0(当次渲染的闭包快照,setState 不会同步改这个变量),div 中显示 1(两次 setCount(1) 传入相同值,React 用 Object.is 比较后 bail out,只安排一次更新;即便逻辑上叠加,用值而非函数式更新时也不会累加)。要拿到最新值并能连续累加,应该用函数式更新 setCount(c => c + 1),或者在 useEffect 里读;如果确实需要同步读最新值,用 useRef 镜像一份。追问时还可以说明批量更新(React 18 之后在 promise、setTimeout 里也会自动批处理)和「渲染是异步的」这两点。
HTTP 状态码与 304 协商缓存:状态码按类记——1xx 信息(101 协议切换)、2xx 成功(200、201 创建、204 无内容、206 断点续传)、3xx 重定向与缓存(301 永久、302 临时、304 未修改、307/308 保留方法与 body)、4xx 客户端错误(400、401 未认证、403 无权限、404、405、408 超时、413、429 限流)、5xx 服务端错误(500、502、503、504)。304 属于协商缓存:服务端响应里带 Cache-Control: no-cache(可缓存但每次要校验)或 ETag/Last-Modified,浏览器下次请求时带上 If-None-Match(ETag)和 If-Modified-Since,服务端比对后如果内容没变就返回 304 且不带 body,浏览器直接用本地副本。ETag 优先级高于 Last-Modified(后者精度只到秒、且「修改时间变了内容没变」会误判);强缓存则靠 Cache-Control: max-age / Expires,命中时连请求都不发。工程上要注意:入口 HTML 用协商缓存(避免用户拿到旧页面引用已删除的资源),带哈希的静态资源用长 max-age + immutable。
浏览器与 Node 事件循环:浏览器侧要点是「宏任务 → 清空所有微任务 → 可能的渲染 → 下一个宏任务」,微任务包括 Promise.then、await 后续、MutationObserver、queueMicrotask,宏任务包括 script、定时器、事件回调、I/O;渲染只在需要时发生,用 requestAnimationFrame 可以在渲染前插入回调。Node 侧则是分阶段循环:timers(setTimeout/setInterval 到期回调)→ pending callbacks → idle/prepare → poll(等待 I/O,执行 I/O 回调,队列空且没有 timers 时会在这里阻塞)→ check(setImmediate)→ close callbacks,每两个阶段之间清空微任务。差异点:process.nextTick 队列优先于 Promise 微任务;setTimeout(0) 与 setImmediate 在主模块里的执行顺序不稳定(受进程启动耗时影响),但在 I/O 回调里 setImmediate 一定先执行;Node 还有 worker_threads 处理 CPU 密集任务,避免阻塞主循环。
MCP、tools、skill 与 function calling 的关系:function calling 是模型侧的能力协议——你告诉模型有哪些函数(名字、描述、JSON Schema 参数),模型在需要时返回一个结构化的调用请求而不是自然语言;tools 就是这套协议里被声明出来的那些函数的集合,属于「模型能做什么」的清单。MCP(Model Context Protocol)解决的是工具从哪里来、怎么接的问题,它把「工具/资源/提示词提供方」标准化成一个服务端,客户端按协议握手后动态发现并调用工具,于是同一套工具可以跨不同模型与应用复用,不用在每个应用里手写一遍适配。skill 则是更上层的任务编排单元:把一段固定的流程(若干步骤、用到哪些工具、每步的输入输出与校验)打包成可复用的能力,模型或开发者按需触发。三者的层次关系可以一句话概括:skill 编排流程,流程里调用 tools,tools 通过 MCP 被标准化地提供和发现,而 function calling 是模型发起这些调用的底层机制。
AI 编排出错怎么排查、要不要设循环轮次:这套设计问题的答案要落到「可观测 + 可恢复」。环节出错时,第一步是让错误对模型可见且结构化——不要只把 Error: failed 塞回上下文,而要给出「哪一步、什么工具、什么参数、什么类型的失败(超时/参数非法/权限不足/结果不符合预期)、是否可以重试」,模型才能决定改参数重试、换工具还是放弃并汇报。第二步是校验点前置:每个环节定义明确的成功判据(schema 校验、断言、必要的二次确认),失败即中断当前分支,避免错误结果被下一步继续消费。第三步是可观测性:全链路 trace 每次工具调用的入参出参、耗时与 token 消耗,把中间产物落盘,出问题时能回放而不是靠猜。第四步是降级与人工兜底:连续失败后切备用方案或转人工,并把这次失败沉淀成回归用例。要不要设循环轮次——要,而且是硬性预算:最大轮次、最大 token、最大时长三者取先触发者,再加「重复调用同一工具且参数不变则打断」的循环检测。上限值按任务类型给(简单信息查询 35 轮、编码类 1530 轮),并允许在预算即将耗尽时让模型输出当前进展与未完成事项,而不是无声截断。
树形数组求最大深度:最简写法是递归:const depth = (nodes) => nodes.length === 0 ? 0 : 1 + Math.max(...nodes.map(n => depth(n.children ?? [])))。要注意三点:空数组返回 0 还是 1(看题目对深度的定义,根节点算不算一层);节点可能没有 children 字段,要兜底成空数组;数据量大或层级很深时递归有爆栈风险(JS 引擎的调用栈通常在一万层上下),应改成显式栈的迭代写法——用一个栈存 [node, depth],逐层压入子节点并更新最大值,避免递归。如果节点里存的是 parent 指针而不是 children,则对每个节点沿 parent 向上走到根,路径长度即为其深度,取最大值;可以用记忆化把复杂度从 O(n·h) 降到接近 O(n)。面试现场记得先问清数据结构形状(children 还是 parent、深度从 0 还是 1 起算)再动手,这本身就是考点之一。
面试节奏的观察:原帖作者提到整场只有 36 分钟、忘记录音所以是回忆,并且怀疑是 KPI 面——从题目构成看,React、HTTP 缓存、事件循环、AI 编排、手撕都摸了一遍,广度够但每块深度有限,这种安排常见于「快速筛人」的面试。应对方式是在每个知识点上主动多讲一层(例如讲 useRef 时顺带说清闭包陷阱、讲 304 时补上强缓存与 ETag 优先级),让面试官看到深度;同时在反问环节问「团队业务具体做什么、对实习生的期望」,原帖得到的回答是「业务相关度高、上手快」,这其实是很实用的信号——准备时把项目里与对方业务相关的部分提到最前面讲。