深色模式
手写题与输出题
先独立写出思路和边界,再对照答案。下面是教学实现,具体契约已标注,不冒充完整标准库替代品。可运行示例见 配套练习。
1. 实现防抖
展开答案与追问
答案思路: 保存定时器,每次调用取消上一次,延迟后使用最近一次的参数和 this。这里实现尾触发,提供 cancel,不支持首触发、flush 或返回异步结果。
js
function debounce(fn, wait = 200) {
let timer;
function wrapped(...args) {
clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
fn.apply(this, args);
}, wait);
}
wrapped.cancel = () => {
clearTimeout(timer);
timer = undefined;
};
return wrapped;
}追问: 组件卸载时如何清理?为什么不直接用箭头函数作为 wrapped?前者调用 cancel,后者是为了保留调用者的 this。
2. 实现节流
展开答案与追问
答案思路: 这里约定立即首触发,无尾触发。使用单调时钟记录上次时间;不保证最后一次调用一定执行。
js
function throttle(fn, wait = 200) {
let last = -Infinity;
return function (...args) {
const now = performance.now();
if (now - last >= wait) {
last = now;
return fn.apply(this, args);
}
};
}追问: 滚动最终位置必须处理时,需要补尾触发或在滚动结束事件中单独处理。
3. 实现带并发上限的异步映射
展开答案与追问
答案思路: 必须接收尚未执行的任务或映射函数;传入已经创建的 Promise 无法限制其启动。使用固定数量 worker 领取索引,结果按输入顺序存放。
js
async function mapLimit(items, limit, mapper) {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError('limit 必须是正整数');
}
const results = new Array(items.length);
let cursor = 0;
let stopped = false;
async function worker() {
while (!stopped && cursor < items.length) {
const index = cursor++;
try {
results[index] = await mapper(items[index], index);
} catch (error) {
stopped = true;
throw error;
}
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker)
);
return results;
}边界: 空数组返回空数组;任一任务失败后停止继续领取,整体拒绝;已经运行的任务不会自动取消。多 worker 的结果先后顺序不影响输出索引。
4. 按业务 ID 去重
展开答案与追问
js
function uniqueById(items) {
return [...new Map(items.map(item => [item.id, item])).values()];
}参考答案: 同 ID 保留最后一个值,位置按该 ID 首次插入顺序。假设每项都有有效 id;缺失 id 会被当作同一个键。时间复杂度通常可按 O(n) 分析,额外空间 O(n)。
追问: 如果保留首项?遍历时仅在 Map 尚无该 ID 时写入。
5. 将扁平数据转为树
展开答案与追问
答案思路: 先创建 ID 到节点的映射,再连接父子节点,避免按层重复扫描。配套实现限定输入是无环森林,根 parentId 为 null;拒绝重复 ID 和缺失父节点。不支持环检测,生产场景若输入不可信需补充验证。
js
function toTree(items) {
const nodes = new Map();
for (const item of items) {
if (nodes.has(item.id)) throw new Error('重复 ID');
nodes.set(item.id, { ...item, children: [] });
}
const roots = [];
for (const item of items) {
const node = nodes.get(item.id);
if (item.parentId === null) roots.push(node);
else {
const parent = nodes.get(item.parentId);
if (!parent) throw new Error('父节点不存在');
parent.children.push(node);
}
}
return roots;
}追问: 为什么要约定根标识?0 可能是合法 ID,不能用 if (!parentId) 含混判断根。
6. 事件循环输出题
展开答案与追问
js
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
(async () => {
console.log('D');
await 0;
console.log('E');
})();
console.log('F');答案: A、D、F、C、E、B。同步部分先执行;Promise.then 回调先入微任务队列,await 的后续稍后入队;定时器任务在它们之后。
7. 闭包与状态输出题
展开答案与追问
js
function makeCounter() {
let count = 0;
return () => ++count;
}
const a = makeCounter();
const b = makeCounter();
console.log(a(), a(), b());答案: 1、2、1。a 和 b 来自两次调用,各有独立词法环境;同一个 a 的多次调用共享其 count。
8. async 中的异常会如何传播?
展开答案与追问
js
async function run() {
try {
return Promise.reject(new Error('失败'));
} catch {
return '已恢复';
}
}
run().catch(error => console.log(error.message));答案: 打印“失败”。try 中没有 await,返回的 Promise 后续拒绝并不会由该同步 catch 捕获。改成 return await Promise.reject(...) 才能在此处 catch 中恢复。
9. React 连续更新会怎样?
展开答案与追问
js
// 同一次事件处理,当前 count 为 0:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);答案: 最终为 1,因为三次都根据同一次渲染的 count 计算替换值。若三次都是 setCount(c => c + 1),则依次应用更新,最终为 3。分析时要明确当前状态和调用发生在哪个事件中。
10. 如何口述实现深拷贝?
展开答案与追问
参考答案: 先明确数据范围。只处理普通对象、数组和循环引用时,可以递归并使用 WeakMap 缓存“原对象 → 新对象”,且要先登记再递归。支持 Date、Map、Set、Symbol 键、属性描述符和自定义原型需要专门策略;函数、DOM 和宿主资源不能笼统复制。能满足需求时优先评估 structuredClone。
追问: 循环引用与重复引用有什么区别?不仅要避免无限递归,也要保留两个属性原本指向同一个对象的关系。