来自生产环境故障的25种JavaScript面试题场景
竞赛、混乱、幂等支付、漏洞、水合机制、WeakMap缓存、不可靠的测试以及共享的获取层——附带面试官真正想要的答案。
来自 AI
二十五道贴近实际工作的 JavaScript 问题
面试中的抽问仍然集中在typeof null的返回值、变量提升机制或如何实现bind函数的内置化等内容上。这类问题容易评分,也容易被考生通过周末的记忆就能应付过去。然而有些候选人提交的搜索框却会在用户已清除查询条件后仍显示相关结果。
那些有真实业务流量的团队则改变了考核方式。他们不会要求候选人讲解事件循环,而是在日期范围更改后展示一个静态的分析界面,给出相关代码片段,然后询问其中存在什么问题。核心知识点相同,但考核方式不同:前者考察记忆能力,后者则检验候选人在被观察的情况下能否对陌生系统进行逻辑分析。
这种差距在三个领域最为明显:真实网络环境下的异步行为(响应顺序混乱、承诺已满足但已无意义)、内存问题(笔记本打开六分钟也不会崩溃),以及故障情况(供应商返回带有HTML错误内容的HTTP 200状态码,导致response.json()在凌晨2点崩溃)。
接下来是二十五种源自实际生产环境中的故障场景:缓慢的仪表板响应、重复请求、路由变更导致的内存持续增长、过时的搜索结果、双重收费问题、失效的认证机制、包含两万行的表格,以及96%时间都处于“正常”状态的API。每个场景都会说明具体情况、面试官关注的答案要点、简短的代码示例、常见的错误处理方式、可能的后续问题,以及需要测量的指标。
示例首先以 JavaScript 形式呈现,仅在类型会改变结果时添加 TypeScript 相关说明。难度级别:初学者适合为中级水平做准备,中级者适用于大多数高级界面,高级内容则用于处理员工及管理层的对话。
初学者——生产环境基础
这有助于区分那些已完成项目开发的人与仅学完教程的人。内容并不晦涩,所有知识点在真实应用中都会遇到问题。
搜索与滚动功能中的去抖与节流
场景。搜索框在每次按键时都会发送请求。产品团队希望减少调用次数,同时又不让输入体验变得迟滞。其他地方的滚动处理则需要持续但有限度的更新。
问题。何时该使用去抖而非节流,又如何在 React 中安全地实现它们?
答案。节流能确保以固定频率发起请求,非常适合处理滚动和调整大小的操作。而防抖则会等待输入暂停后再触发请求,这更符合搜索意图。初始值可设定在四分之一秒到三百毫秒之间,再根据实际数据进行调整。防抖应与最小查询长度以及请求取消功能结合使用;仅靠防抖无法解决响应顺序混乱的问题。
function debounce(fn, wait = 250) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
const search = debounce((q) => {
if (q.length < 2) return;
fetchResults(q);
});
错误做法。在每次渲染时都创建新的防抖封装函数。由于定时器会针对新的闭包重新启动,因此防抖功能实际上从未真正起作用。应使用useMemo/useRef来稳定状态,并在组件卸载时清除相关数据。
进一步探讨。用户快速输入后清空了输入框。此时防抖函数仍会使用最后一个非空值发起请求。那么组件卸载时的取消操作与输入框清空时的操作是如何相互作用的呢?
已测量。 工具选择基于用户体验需求,同时考虑在多次渲染后仍能正常工作的闭包实现方式。
两万个点击监听器
场景。 一个数据网格为每一行操作都绑定了onClick事件。当有20,000行数据时,页面需要数秒才能具备交互功能,且分页时会占用更多内存。
问题。 应该如何重构事件处理机制?
答案。 使用事件委托。在容器上设置一个监听器,当事件向上冒泡时读取目标元素——只需在内存中保存一个函数,行数据变化时无需重新绑定监听器,也能处理后续添加的行。可通过数据属性和closest方法来确定行及操作,因为点击往往发生在按钮内的图标上,而非按钮本身。
grid.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]');
if (!button || !grid.contains(button)) return;
const { action } = button.dataset;
const rowId = button.closest('tr')?.dataset.rowId;
handleAction(action, rowId);
});
错误的方法。仍然要为数千个行监听器编写代码,寄希望于某个清理循环能将它们移除。设置成本依然存在,而不匹配的函数引用也会在无声中导致无法正确断开连接。
后续问题。在 React 17+ 中,该库是在哪里注册其根监听器的?委托给原生的处理函数又该如何与合成事件的冒泡机制共存?
评估标准。该方案是否将监听器的数量视为真正的性能限制因素,而不仅仅是风格上的选择。
已经解析的承诺与仍然有效的承诺并非同一概念。
中级内容——异步、内存与性能
大多数高级面试的结果都在这一环节决定。这些问题看起来像调试过程,因为这正是其职责所在。
会冻结两秒钟的控制面板
场景。更改日期范围后,页面会冻结。点击操作无效,动画停止,旋转图标也不再转动。网络请求本身耗时180毫秒。
问题。为什么旋转图标没有动画效果,时间去哪儿了?
答案。主线程需要同时处理脚本执行、布局计算、绘图以及用户输入。如果请求堆栈占用时间过长,就连旋转图标也无法绘制,因为用于显示它的帧根本不会被执行。那180毫秒是网络传输时间,而页面冻结则是后续的同步处理工作——解析庞大的JSON数据,然后对数以万计的行应用map/filter方法,每处理一行都会分配一个新的数组。
应将复杂的计算任务放到Web Worker中执行,或分块处理并在各块之间暂停,尽可能向后台请求汇总后的数据。
// Yield to the event loop between chunks so input and paint can run
async function processInChunks(items, fn, chunkSize = 500) {
const out = [];
for (let i = 0; i < items.length; i += chunkSize) {
for (const item of items.slice(i, i + chunkSize)) out.push(fn(item));
await new Promise((r) => setTimeout(r, 0));
}
return out;
}
错误的方法。在CPU密集型循环中随意使用async/await,并称其为非阻塞式处理。实际上并没有新增线程,同步操作依然会导致标签页卡住。
进一步说明。请解释在这种卡顿现象中微任务与宏任务的区别,以及为何使用await Promise.resolve()后仍会出现长时段的同步操作。
量化分析。将JS并发理解为任务调度,并明确何时需要使用工作线程。
来自AI的建议
用户已删除的查询的搜索结果
场景。输入“sam”后再精确为“samantha”,有时在已经显示“samantha”的结果之后还会出现“sam”的相关结果。在快速网络连接下很难复现该问题。
问题。究竟发生了什么,正确的解决方法是什么?
答案。解决无序响应带来的竞争问题。当有两个请求同时发出时,速度较慢的那个属于较早的查询;最终完成响应的那一个将获得状态更新权。建议采用两种缓解措施:使用AbortController取消之前的请求,并对状态写入进行保护,确保只有当前输入的响应才能被提交。
const controllerRef = useRef(null);
async function search(query) {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
try {
const res = await fetch(`/api/customers?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
setResults(await res.json());
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
}
错误的方法。将防抖时间延长至整整一秒后再宣布胜利。这样竞争情况会变得更少,用户体验也会变差,而且速度较慢的网络依然会导致响应顺序混乱。
补充问题。在何种情况下,单调递增的请求ID比AbortController更适用于丢弃过时的搜索数据?
衡量标准。正确标识竞争情况,并避免将中止操作的相关信息显示在错误监控面板中。
同一请求,五次
场景。五个组件在加载时都会请求/api/current-user。网络标签页会显示五次完全相同的请求;在个人资料更新后,偶尔会有一个响应变为过时数据。
问题。如何在不重写整个数据层的情况下去重正在处理的请求?
答案。应缓存promise而非结果。以请求标识作为键创建映射表,将同一个promise返回给所有调用者;当该promise状态确定后删除对应键,这样就可以重试失败请求,并在之后获取最新数据。TanStack Query和SWR等库正是基于这一思路增加了无效化与过时检测功能。
const inFlight = new Map();
export function dedupedFetch(key, fetcher) {
if (inFlight.has(key)) return inFlight.get(key);
const promise = fetcher().finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
// All five callers receive the same promise
const user = await dedupedFetch('/api/me', () => fetch('/api/me').then((r) => r.json()));
错误做法。将已处理完成的响应数据永久存储在模块级变量中。虽然重复的GET请求消失了,但界面仍会显示昨天的用户信息,直到有人强制刷新页面。
后续问题。如果两个调用者为同一个正在处理的承诺添加了不同的取消信号,哪种取消策略才能确保两者都得到正确处理?
解决方案。应谨慎共享正在处理的承诺,并提前规划其失效处理方式。
某个不可靠的供应商导致页面无法显示
场景。个人资料、账单信息以及第三方推荐组件会同时加载。该供应商的可用性约为96%。一旦其出现故障,整个页面都会出错,账单信息也将无法显示。
问题。应如何重新设计数据获取逻辑?在此情况下,Promise.all与Promise.allSettled有何区别?
答案。 当任意一个输入的 Promise 被拒绝时,Promise.all 会立即拒绝,并忽略那些成功的 Promise。而 Promise.allSettled 始终会根据每个输入的状态返回结果——展示成功的部分,对其他部分则进行降级处理。应将计费功能和用户资料处理放在关键路径上;为推荐内容设定较短的截止时间,这样即便相关服务后来返回成功状态,也不会导致整体流程停滞。
const [profile, billing, recs] = await Promise.allSettled([
getProfile(),
getBilling(),
withTimeout(getRecommendations(), 2000),
]);
if (profile.status === 'rejected' || billing.status === 'rejected') {
return renderError();
}
render({
profile: profile.value,
billing: billing.value,
recs: recs.status === 'fulfilled' ? recs.value : [],
});
错误做法。 将失败情况映射为 null,以此让 Promise.all 保持正常状态。这样一来,监控系统就无法获取拒绝原因,所有失败案例看起来都会完全相同。
后续讨论。 需要明确在何种情况下应使用 Promise.any 而非 Promise.race,并说明那些未获胜的 Promise 会如何处理。
评估标准。 对 Promise 组合操作的熟练度,以及对其潜在影响范围的判断能力。
重复扣款的支付问题
场景。 支付网关在结账时超时,前端系统会尝试重试,结果导致客户被扣款两次。
问题。 对于支付接口而言,哪种重试策略是安全的?
答案。 只有对幂等操作才能安全地使用重试机制。默认情况下,创建扣款的POST请求并非幂等的——需要通过客户端生成的幂等性键让服务器能够识别重复请求并避免重复扣款。仅应在传输失败或收到5xx/429响应时进行重试,绝不能对4xx响应重试。应在重试之间加入指数退避和随机延迟,以避免正在恢复的主机因频繁请求而不堪重负。同时要遵守Retry-After头部指示,并对重试次数设置上限。
async function postWithRetry(url, body, key, attempts = 3) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status < 500 && res.status !== 429) throw new HttpError(res);
const backoff = 2 ** i * 300 + Math.random() * 300; // jitter
await new Promise((r) => setTimeout(r, backoff));
}
throw new Error('Payment could not be confirmed');
}
错误做法。 按固定时间间隔盲目地对所有操作进行三次重试。这样不仅可能导致重复扣款,4xx响应的循环尝试也永远无法成功,而且系统故障还可能引发更严重的混乱。
后续处理。浏览器超时了,但服务器端的扣费操作已经完成——请说明用户可见的恢复流程。
已验证。在客户端存在不确定性的情况下仍能保持功能一致性。
不会失败而是陷入挂起状态的API
场景。服务提供商停止响应但并未关闭连接。fetch请求永远无法完成;处理程序不断堆积;加载指示器会持续数分钟。
问题。如何控制这种情况?超出超时时间后还应采取什么措施?
答案。浏览器并未为fetch提供默认超时时间——需要手动设置。现代做法是使用AbortSignal.timeout;而AbortSignal.any则结合了超时机制与用户主动取消功能。此外还应加入断路器机制:在连续失败后暂停请求以进行冷却,随即提供备用内容,从而保护应用程序及正在恢复的依赖服务。
const signal = AbortSignal.any([
AbortSignal.timeout(3000),
userController.signal,
]);
try {
const res = await fetch(url, { signal });
breaker.recordSuccess();
return res.json();
} catch (err) {
breaker.recordFailure();
if (err.name === 'TimeoutError') return cachedFallback();
throw err;
}
错误做法。让fetch与计时器同时运行,然后假装失败方已停止工作。实际上HTTP请求仍会持续执行、占用连接资源,甚至在最终完成时还会改变状态。
后续措施。应在浏览器、API网关以及针对同一供应商的Node上游调用中都设置超时机制,避免出现无人处理的请求。
衡量标准。对依赖服务的默认不信任态度、真正的取消功能,以及避免遗留未处理请求的措施。
每次路由变化都会增加内存占用
场景。一个全天保持打开状态的辅助工具,其内存占用达到了1.4GB。堆快照显示,每次切换工单时脱离的DOM节点数量都在增加。
问题。常见原因是什么?如何确认?
答案。脱离的节点之所以仍然存在,是因为仍有某些代码在引用它们:如未移除的window/document监听器、仍在运行的setInterval定时任务、未断开的IntersectionObserver/ResizeObserver观察器,以及全局存储中的监听器;长期存在的缓存中的闭包也会捕获DOM节点。确认方法为:先获取一次堆快照,进行页面导航,强制触发垃圾回收,再次获取堆快照,然后逐层查看引用链,直到找到这些节点的持有者。
useEffect(() => {
const onResize = () => recalcLayout();
const observer = new ResizeObserver(onResize);
const id = setInterval(pollTicket, 5000);
window.addEventListener('resize', onResize);
observer.observe(panelRef.current);
return () => {
window.removeEventListener('resize', onResize);
observer.disconnect();
clearInterval(id);
};
}, []);
错误的方法。在清理时将局部变量置为 null,期望内存使用量随之下降。但实际上,仍会有通过相同数据连接的监听器和定时器导致变量可访问。
补充说明。将这种内存泄漏模式称为WeakMap可以很好地解决该问题,而另一种情况则是误用了弱引用这一工具。
实际操作。需要掌握堆内存调试技巧以及变量可访问性的相关知识。
始终显示零值的计数器
场景描述。某个小部件每五秒查询一次数据并添加警报信息。它始终只显示一条警报;而在定时器触发的代码中,却会永久打印初始状态。
问题所在。为什么定时器会看到过时的数据状态?该如何解决这个问题?
答案。 使用 [] 时该效果仅执行了一次,因此回调函数使用了首次渲染时的状态。更新操作会生成新的值,但旧的回调函数仍然指向旧的状态。建议使用函数式更新器,这样 React 才会提供最新队列中的值。当回调需要更新器无法表达的数据时,应在每次渲染时将其映射到 ref 中。
// Broken: `alerts` is frozen at the first render
useEffect(() => {
const id = setInterval(() => setAlerts([...alerts, poll()]), 5000);
return () => clearInterval(id);
}, []);
// Fixed: functional update, no stale capture
useEffect(() => {
const id = setInterval(() => setAlerts((prev) => [...prev, poll()]), 5000);
return () => clearInterval(id);
}, []);
错误做法。 将 alerts 列为效果依赖项。虽然过时的回调函数会消失,但每次添加新元素时定时器都会重新启动,从而导致五秒的间隔被打破。
延伸问题。 自定义钩子如何能在始终读取最新状态的同时保持五秒的轮询间隔?
分析结果。 时空穿越式的回调函数——这是 React 中级开发中常见的陷阱。
DOM 中有两万行数据
场景。清单表格会显示所有API记录。首次渲染需要6秒;过滤操作会导致延迟;该表格还会占用数百兆字节的内存。
问题。如何让这个表格可用?首先应该衡量什么指标?
答案。先进行性能分析:脚本、样式、布局以及渲染过程。对于这种规模的表格,DOM节点数量通常是影响性能的主要因素。应对列表进行虚拟化处理,使得DOM中仅存在可视区域及少量缓冲区。同时要确保行标识的稳定性、合理使用缓存机制,而当客户端数据量过大时,则在服务器端进行过滤和排序操作。
// Row identity matters as much as row count
{visibleRows.map((row) => (
<Row key={row.id} data={row} /> // stable id, not the array index
))}
错误做法。仅用React.memo包裹所有行就认为解决了问题。新的内联属性会破坏缓存效果,而且面对数以万计的节点时,布局和渲染问题依然无法解决。
后续问题。当行键为数组索引且中间某行被移除时,焦点控制与输入处理会出现什么问题?
测量分析。养成先测量的习惯,并了解记忆化的实际局限。
来自AI
过滤/调整大小循环导致的布局混乱
场景描述。数据加载完成后,控制面板会调整图表容器的大小。此时界面中会出现数百次重复的紫色样式/布局块。
问题。是什么机制导致了这种情况,该如何解决?
答案。 布局抖动问题。使用高度偏移、边界矩形或滚动位置等几何 API 时,引擎必须同步刷新样式与布局才能给出准确结果。如果在循环中交替进行这些读取操作和样式写入操作,每次迭代都会产生额外的刷新开销。应先收集相关尺寸数据,再修改样式,最后通过 requestAnimationFrame 安排写入操作。对于显示/隐藏逻辑,可利用 IntersectionObserver 实现异步可见性处理,从而避免强制触发布局更新。
// Broken: read, write, read, write
panels.forEach((p) => { p.style.height = p.offsetHeight * 1.2 + 'px'; });
// Fixed: batch reads, then batch writes
const heights = panels.map((p) => p.offsetHeight);
requestAnimationFrame(() => {
panels.forEach((p, i) => { p.style.height = heights[i] * 1.2 + 'px'; });
});
错误做法。 每次写入都在独立的动画帧中执行,而读取操作仍混杂在一起。这会导致成本分散在多个帧上,从而使界面卡顿现象持续更长时间。
延伸问题。 哪些 CSS 属性会保留在合成层上?又在什么情况下 will-change 造成的开销会超过其带来的优化效果?
已测量。浏览器处理流程的成本是可追溯的,而非神秘的黑箱。
等待同步计算并不会释放事件循环。
高级内容——安全、Node、测试与设计
团队层面的决策更关注权衡取舍、影响范围以及责任归属,而非唯一的完美解决方案。
执行脚本的CMS字段
场景。营销团队在无头CMS中存储富文本。安全审查发现每个产品页面都在运行<img onerror=...>代码。
问题。这是如何发生的?该如何在整个技术栈中解决这个问题?
答案。该内容未经过滤就被直接放入了innerHTML或React的dangerouslySetInnerHTML中。默认情况下会进行转义处理(React中使用{value},DOM中使用textContent)。当确实需要使用HTML时,应使用如DOMPurify这样的经过维护的允许列表库进行过滤——处理必须在服务器端完成,因为客户端并非可信环境。此外还需设置内容安全策略,以防止未被过滤的代码执行内联脚本。
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(cmsHtml, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'li'],
ALLOWED_ATTR: ['href', 'title'],
});
<div dangerouslySetInnerHTML={{ __html: clean }} />
错误做法。使用正则表达式删除<script>标签,但事件处理程序属性、以javascript:开头的URL、SVG矢量图形以及嵌套编码的内容却能逃过过滤。
后续问题。分析工具会注入内联脚本,而内容安全策略又会阻止其执行——此时应在不启用unsafe-inline选项的情况下恢复该标签的功能。
已测量。采用多层XSS防御机制,并在渲染时进行数据净化处理。
localStorage中的令牌
场景。在发生XSS攻击后,安全团队注意到通过Axios拦截器存储在localStorage中的会话令牌。他们需要一个解决方案。
问题。在用于存储认证令牌的localStorage与cookies之间该如何权衡?有什么推荐吗?
答案。源服务器上的脚本可以读取localStorage,因此一次XSS攻击就能窃取整个会话信息。带有SameSite=Lax属性的HttpOnly安全Cookie对JavaScript来说是不可见的,但浏览器会自动添加这些Cookie,因此在进行数据修改时仍需防范CSRF攻击。常见的设计方案是:在内存中存储短期有效的访问令牌,将刷新令牌保存在HttpOnly Cookie中,在数据修改时使用CSRF令牌或SameSite属性,并定期更换刷新令牌。没有任何机制能让XSS攻击完好无损地通过——预防XSS攻击依然是首要任务。
// Server side, Express
res.cookie('refresh_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/auth/refresh',
maxAge: 1000 * 60 * 60 * 24 * 7,
});
错误做法。用同样存在于页面JavaScript中的密钥来加密localStorage中的令牌。任何能够读取存储内容的人都能获取到该密钥——这纯粹是徒劳之举。
补充问题。如果访问令牌仅存储在内存中,新打开的标签页该如何获取会话信息?
已测量。基于威胁模型的令牌存储方案,而非空洞口号。
拖累其他所有端点的 Node 端点
场景。当调用 Express 的 PDF 报告处理程序时,会引发无关的健康检查超时,从而导致整个服务的 p99 值飙升。
问题。为何一个端点会影响所有其他端点?该如何解决?
答案。Node 在单个线程上运行应用程序 JavaScript。CPU 密集型任务会占用事件循环,导致其他请求、定时器或 IO 回调无法处理。应将 CPU 密集型任务转移到 worker_threads 或外部队列中,这样 HTTP 处理程序只需负责排队和响应。应直接传输大容量数据流而非进行缓冲处理。优先使用异步加密 API,避免使用 Sync 版本;绝不在请求路径上使用 readFileSync。
import { Worker } from 'node:worker_threads';
app.post('/reports', async (req, res) => {
const job = await queue.add('generate-report', req.body); // returns immediately
res.status(202).json({ jobId: job.id, status: 'queued' });
});
// Or for in process CPU work
const worker = new Worker('./report-worker.js', { workerData: params });
错误的方法。等待CPU密集型任务完成,同时寄希望于事件循环能正常运行。横向扩展只会增加更多实例,而这些实例仍会因相同类型的请求而停滞不前。
后续问题。在客户投诉之前,有哪些生产环境指标可以表明Node事件循环已堵塞?
解决方案。将IO密集型任务与CPU密集型任务分开处理,并通过相应的生产环境指标进行监控。
加载时显示错误内容(数据同步问题)
场景。一个Next.js应用在服务器端渲染“登录”界面,加载完成后再切换为用户的姓名。此时会出现数据同步不匹配的警告,有时还会显示“文本内容与服务器渲染的HTML不一致”的提示。
问题。造成数据同步不匹配的原因是什么?如何解决这个问题?
答案。 服务器缺少仅存在于浏览器中的状态:客户端读取的 Cookie、localStorage、window.matchMedia、Date.now()。Hydration 过程要求客户端的初次渲染结果与服务器端生成的树结构一致。若出现不一致,React 会丢弃该子树的服务器端标记并发出警告。要在服务器端渲染时呈现相关内容,需在服务器上读取 Cookie 并将该值传递到树结构中。对于真正仅存在于浏览器中的值,则应先绘制一个稳定的占位符,待组件挂载后再进行更新。
// Server component reads the session, so no mismatch
export default async function Layout({ children }) {
const session = await getSession(cookies());
return <AuthProvider value={session}>{children}</AuthProvider>;
}
错误做法。 在包装组件上屏蔽 Hydration 警告,或动态修改标题以跳过 SSR。这样依然存在结构不一致的问题,SSR 的优势也会随之消失。
进一步探讨。 如何让相对时间戳在服务器端渲染时不会与客户端的时钟产生冲突?
已测量。 应正确处理水分平衡问题,而非屏蔽警告信息。
永不释放的缓存
场景。 某图表库会以 DOM 元素为键来缓存画布。当图表离开页面后,内存仍会持续增长。快照显示有 Map 保留着这些缓冲区。
问题。 为何这些条目不会被回收,而 WeakMap 又如何改变这一结果?
答案。 垃圾回收是从根节点开始判断可达性的。普通的 Map 会同时固定键和值,因此用作键的已断开的 DOM 节点仍能让其画布缓冲区保持可达状态。而 WeakMap 以弱引用方式保存键——当没有其他元素引用该节点时,对应的条目就会随之消失。WeakMap 不可枚举且没有大小属性;正是这种权衡使得弱引用方式更加安全。
// Leaks: the Map keeps removed nodes alive
const cache = new Map();
// Collectable: entry dies with the element
const cache = new WeakMap();
cache.set(chartEl, renderedCanvas);
错误的方法。采用类似Cron的定时清理机制,删除那些节点已离开文档的缓存条目。这种做法在有人记得执行清理任务时还行;但使用WeakMap则可以自动处理这项工作。
后续问题。何时适合使用FinalizationRegistry?为何其正确性绝不能取决于执行的时间点?
衡量标准。以可达性作为垃圾回收的判断依据,而非假装收集时间是可以控制的。
每二十次运行就会失败的测试
场景。某个搜索组件的测试在本地能够通过,但在持续集成环境中查找文本“Samantha”时失败率约为5%。有人已经添加了waitFor(3000),让持续集成系统重新尝试。
问题。如何使该测试更加可靠?测试的不稳定性能说明什么问题?
答案。 不稳定的异步测试通常检测的是时间而非实际行为。可使用虚拟计时器来实现防抖功能,通过 MSW 等工具在边界处模拟 HTTP 请求以确保数据传输稳定,并使用能等待 DOM 更新的查询语句进行断言,而非固定延迟一段时间。如果必须通过任意延时才能通过测试,往往说明组件中存在真正的竞态条件——此时测试结果才是正确的。
test('shows results for the final query', async () => {
vi.useFakeTimers();
render(<Search />);
await userEvent.type(screen.getByRole('searchbox'), 'samantha');
await vi.advanceTimersByTimeAsync(300); // debounce window
expect(await screen.findByText('Samantha')).toBeInTheDocument();
});
错误的方法。 持续重试加上越来越长的超时时间。信号会被噪声淹没,三次重试后的测试套件可能掩盖那些日后会在生产环境中出现的竞态问题。
进一步探讨。 测试该如何强制让旧搜索结果在新的结果之后返回?
解决方案。 将不稳定性视为信号,并使异步测试具有确定性。
调用 fetch 的六十个位置
场景。 一个已有四年的代码库在六十个组件中使用了fetch函数。错误处理方式不一致;身份验证刷新操作被复制粘贴了十一次;没人清楚每日超时次数。
问题。 如何设计一个共享的数据访问层?又如何在不停止系统运行的情况下进行迁移?
答案。 将所有共享的逻辑集中到同一个客户端中:统一处理基础URL和请求头,采用单次身份验证刷新机制,这样一旦出现大量401错误只需触发一次刷新;设置超时控制,仅允许幂等重试,对错误进行类型化标准化处理,并加入遥测功能。保持接口简洁,以便更多人采用而非绕过该机制。逐步进行迁移——先发布客户端,优先处理流量最大且错误最频繁的路径;通过代码检查禁止在新代码中使用原始的fetch函数,从而避免边界不断变动。
type ApiError =
| { kind: 'network' }
| { kind: 'timeout' }
| { kind: 'http'; status: number; body: unknown }
| { kind: 'parse' };
export async function apiRequest<T>(
path: string,
init: RequestInit & { timeoutMs?: number } = {},
): Promise<{ ok: true; data: T } | { ok: false; error: ApiError }> {
// timeout, auth, retry, telemetry all live here
}
错误的方法。要么提议全新的框架迁移,要么过度封装响应,导致异常情况只能通过原始的fetch处理。这两种方式都会留下两个相互冲突的数据层。
后续问题。六个月过去了,代码检查规则与代码审查如何防止原始的fetch再次出现?
实际措施。在交付压力下进行团队规模的API设计及逐步迁移。
总结——边准备边实践,而非死记硬背
琐事类准备的局限性在于:只能记住对应关系表,而仪表板却仍会莫名卡住。情景类准备的失败模式则是:只懂流程却不了解实现机制。应通过分析真实代码来避免这两种情况。
故意重现故障。先发布一个在网络带宽受限时会出现性能问题的搜索字段,然后打开“性能”和“网络”面板,直到那些过时的数据变得明显。留出一个未清除的时间间隔,切换到其他页面,再在“内存”面板中查找那些脱离主进程的节点。亲自操作开发者工具所得到的经验比任何参考手册都更持久。
在给出标准答案之前,先学会如何进行测量。Chrome的“性能”、“内存”和“网络”面板,再加上React Profiler,能让许多原本看似“高级”的问题变成普通的性能分析问题。
试着大声说出所做出的权衡决策。几乎每种情况都有多种可行的解决方案,而每一种方案的成本也各不相同。优秀的面试官能够判断出候选人的选择是否经过深思熟虑。
阅读实际生产环境中的故障案例。任何有过产品发布经验的人,都经历过至少五种不同的故障场景。这些真实案例比借鉴来的例子更好,因为可以无限深入地探讨其背后的原因。
准备一份简短的故障清单,记录故障原因及解决方案——这只是笔记,而非作品集。当被问及难以解决的漏洞时,具体的诊断结果总比泛泛而谈更有用。
从初学者到高级用户,规律始终如一:明确故障类型,提出与威胁相匹配的解决方案,并了解在压力之下为何错误的解决方案会显得有吸引力。这正是技术面试真正看重的能力——不是完美的故障处理表,而是在网络出问题、内存不足以及供应商失约时仍能保持系统稳定运行的能力。
采用这种面试方式的团队往往也能进行更有效的复盘:相同的术语(竞态条件、严重性等级、幂等性、影响范围)会同时出现在招聘流程和事故分析中。因此研究这些场景能带来双重收益——一次是在面试现场,另一次则是在控制台界面卡住两秒、加载指示器静止不动时。
以实际生产场景为基础的面试环节还能体现沟通能力。能够用简洁的语言描述竞态条件、为AbortController绘制快速序列图,或解释为何allSettled能缩小影响范围,这些都能展现某人在事故处理中的表现。常识类问题很少能体现这种能力,而场景分析题几乎总能做到,这也是为何这种面试形式会在中高级职位的招聘中不断普及。