JavaScript 手写系列:20 个高频面试题的原生实现

XiaoC
XiaoC 正式会员正式会员认证极客认证极客
发布于 2026-10-18 09:26 ·2 浏览 ·2 回复
内容摘要

围绕20道JavaScript高频手写面试题,按函数控制、this与对象、原型继承和异步分类给出可用原生实现及常见边界条件,强调先判断题目类型再套模板,并提醒防抖需保留this和参数、节流需用定时器补最后一次触发。

学完这篇,你能拿到 20 道高频手写题的可用原生实现,每份实现都标出了面试官最常追问的边界条件。

第一步:先把手写题分成四类

按主题分组记忆,出现任何题目都能快速对应到同一套模板:

  • 函数控制:防抖、节流、柯里化、compose
  • this 与对象:call、apply、bind、new、instanceof、深拷贝
  • 原型与继承:Object.create、寄生组合继承
  • 异步:Promise.all、并发池、sleep、重试、简易 Promise、发布订阅

面试时先说出题目类型,再写代码,比直接写出代码得分更高。

第二步:防抖与节流(最常考,也是边界最多的)

function debounce(fn, wait = 300, immediate = false) {
  let timer = null, result;
  function debounced(...args) {
    if (timer) clearTimeout(timer);
    if (immediate) {
      if (!timer) result = fn.apply(this, args);
      timer = setTimeout(() => { timer = null; }, wait);
    } else {
      timer = setTimeout(() => { fn.apply(this, args); timer = null; }, wait);
    }
    return result;
  }
  debounced.cancel = () => { clearTimeout(timer); timer = null; };
  return debounced;
}

function throttle(fn, wait = 300) {
  let last = 0, timer = null;
  return function (...args) {
    const now = Date.now();
    const remain = wait - (now - last);
    if (remain <= 0) {
      clearTimeout(timer); timer = null;
      last = now;
      fn.apply(this, args);
    } else if (!timer) {
      timer = setTimeout(() => { last = Date.now(); timer = null; fn.apply(this, args); }, remain);
    }
  };
}

注意:防抖必须用 fn.apply(this, args) 保留调用时的 this 和参数;节流只写时间戳版会丢最后一次触发,必须补定时器。

第三步:call、apply、bind、new

Function.prototype.myCall = function (ctx, ...args) {
  ctx = (ctx === null || ctx === undefined) ? globalThis : Object(ctx);
  const key = Symbol('fn');
  ctx[key] = this;
  const res = ctx[key](...args);
  delete ctx[key];
  return res;
};
// apply 只把 ...args 换成 args 数组即可

Function.prototype.myBind = function (ctx, ...preset) {
  const fn = this;
  function Bound(...args) {
    return fn.apply(this instanceof Bound ? this : ctx, [...preset, ...args]);
  }
  Bound.prototype = Object.create(fn.prototype);
  return Bound;
};

function myNew(Ctor, ...args) {
  if (typeof Ctor !== 'function') throw new TypeError('not a constructor');
  const obj = Object.create(Ctor.prototype);
  const res = Ctor.apply(obj, args);
  return (res !== null && typeof res === 'object') ? res : obj;
}

注意:ctx 为 null 或 undefined 时要指向 globalThis;原始值要先用 Object() 包装。bind 返回的函数要支持 new,所以要判断 this instanceof Bound。

第四步:instanceof 与 Object.create

function myInstanceof(left, right) {
  if (left === null || (typeof left !== 'object' && typeof left !== 'function')) return false;
  let proto = Object.getPrototypeOf(left);
  while (proto) {
    if (proto === right.prototype) return true;
    proto = Object.getPrototypeOf(proto);
  }
  return false;
}

function myCreate(proto) {
  function F() {}
  F.prototype = proto;
  return new F();
}

寄生组合继承最简单:Child.prototype = Object.create(Parent.prototype),再把 constructor 指回 Child,最后 Parent.call(this, ...)。

第五步:深拷贝(注意 WeakMap 处理循环引用)

function deepClone(obj, map = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (map.has(obj)) return map.get(obj);
  if (obj instanceof Date) return new Date(obj);
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
  const target = Array.isArray(obj) ? [] : Object.create(Object.getPrototypeOf(obj));
  map.set(obj, target);
  Reflect.ownKeys(obj).forEach(k => { target[k] = deepClone(obj[k], map); });
  return target;
}

注意:面试官一定会问循环引用和 Symbol 键,WeakMap 和 Reflect.ownKeys 就是这两处的答案。

第六步:数组与函数式四道

const flatten = (arr, depth = 1) =>
  depth < 1 ? arr.slice() :
  arr.reduce((acc, v) => acc.concat(Array.isArray(v) ? flatten(v, depth - 1) : v), []);

const uniq = arr => [...new Set(arr)]; // 对象去重再说 Map + JSON.stringify

function curry(fn) {
  return function curried(...args) {
    return args.length >= fn.length ? fn.apply(this, args) : (...rest) => curried.apply(this, [...args, ...rest]);
  };
}

const compose = (...fns) => fns.reduce((a, b) => (...args) => a(b(...args)));

注意:flat 的 depth 默认是 1,并非无限;不是传入 Infinity 才无限扁平。

第七步:异步四道

function myPromiseAll(list) {
  return new Promise((resolve, reject) => {
    const arr = [...list], res = new Array(arr.length);
    let count = 0;
    if (!arr.length) return resolve(res);
    arr.forEach((p, i) => {
      Promise.resolve(p).then(v => { res[i] = v; if (++count === arr.length) resolve(res); }, reject);
    });
  });
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

async function pool(tasks, limit = 3) {
  const ret = []; let i = 0;
  const worker = async () => { while (i < tasks.length) { const idx = i++; ret[idx] = await tasks[idx](); } };
  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
  return ret;
}

顺带要能说出简易 Promise 的三个要点:状态只能从 pending 变一次、then 回调进微任务(用 queueMicrotask)、then 返回新 Promise 以支持链式调用。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-879.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 2

shandian
shandian 见习用户见习用户 1楼 2026-10-18 09:29

这套「先分四类再记模板」的思路是对的,debounce/throttle 那两个混合版也基本没毛病;但 call/apply/bind 那段代码里的坑正好是面试官最爱追问的地方。

几个具体的:

throttle 混合版保证了「首次立即执行 + 尾部不丢」,但缺 cancel 和 leading/trailing 配置项——面试常追问「我要第一次不执行怎么办」。另外要注意:频繁触发时尾部那次的实际执行时间是 last + wait,不是「最后一次触发 + wait」,被问「为什么我的节流延迟执行」时这能解释清楚。

debounce 的 immediate 版用 timer 是否存在判断首次,所以 cancel() 之后状态会重置成「首次」,下回触发又立即执行一次。这个行为本身合理,但主动讲出来是加分项,否则容易被当成 bug 追问。

myCall 里 Object(ctx) 装箱、globalThis 兜底、Symbol 防属性覆盖都对,补两点:记得 delete ctx[key](ctx 是长期对象时否则会残留一个 Symbol 属性);同时说明这是在模拟非严格模式语义。myBind 最容易翻车的是返回函数被 new 调用时,this 必须指向新实例而不是绑定对象——要用 new.target 或 instanceof 判断,还要把原函数的 prototype 继承过去,instanceof 才不会断。

后面深拷贝、Promise.all、并发池那几题的坑建议单独列一张表:循环引用走 WeakMap、Map/Set/Symbol key、Promise.all 空数组立即 resolve 且结果按索引不按完成顺序、并发池结果按 index 存而不是 push。面试前扫一眼比背代码有用。

延伸一句:真实项目里这套东西最常落地的场景就是论坛的输入框自动保存——debounce 300ms + 卸载前 flush,比纯节流体验好很多。

最长的电影
最长的电影 正式会员正式会员 #929 2楼 2026-10-18 09:32
shandian:这套「先分四类再记模板」的思路是对的,debounce/throttle 那两个混合版也基本没毛病;但 call/apply/bind 那段代码里的坑正好是面试…

补充得比我原文到位,尤其 myBind 的 new.target 那条,是这套题里唯一真正能区分"背过"和"写过"的点。

顺着你列的补几个具体写法:throttle 的 leading/trailing 我一般用配置项直接重写,leading:false 时把 last 初始化为 Date.now() 而不是 0,首次触发自然就落到定时器分支;cancel 里要同时清 timer 和把 last = 0,否则取消后再触发会被 remain<=0 判成立、又变成立即执行一次。你提的那个"尾部执行时间是 last+wait",根源就是 last 是上次实际执行时间而非上次触发时间,讲清这点trailing的延迟行为就不用绕了。

myBind 我给个完整骨架:返回函数里先 if (new.target) { return fn.apply(this, args.concat(rest)) },然后 F.prototype = fn.prototype 做原型桥接,最后 Object.setPrototypeOf(bound, fn) 保留静态属性。注意 new.target 判断要放在 ctx 解构之后,不然箭头函数和 Symbol 那块容易写串。

深拷贝那张表我再加两行:RegExp 要 new RegExp(source, flags) 复刻(直接 Object.assign 会丢 lastIndex),以及只拷 Object.keys 会漏 Symbol 和不可枚举属性;Promise.all 空数组那条可以顺带说"allSettled 空数组返回 [] 且永不 reject",面试官经常连着问。并发池别用 shift() 取任务,多 worker 下有隐性开销,用自增 index 指针更稳。

论坛自动保存那个例子我认同,另外补一句:debounce 的 cancel 在 beforeunload 里一定要调,不然 iOS 上 visibilitychange 切后台再回来,定时器可能没跑完就丢最后一次输入。