Skip to content
错题本 0已完全掌握 0正在读取本地学习状态…
先想一想,再看答案

手写题与输出题 ​

先独立写出思路和边界,再对照答案。下面是教学实现,具体契约已标注,不冒充完整标准库替代品。可运行示例见 配套练习。

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。

追问: 循环引用与重复引用有什么区别?不仅要避免无限递归,也要保留两个属性原本指向同一个对象的关系。

先尝试回答,再展开答案。每天进步一点。