09-handwritten-js
大厂前端面试「手写题」全集。所有实现均为 ES2020+ 语法,附中文注释、边界处理、可运行用例,以及面试官真正会追问的【实现要点】。 使用建议:先自己写一遍再对照。面试现场写不完是常态,先写出主干 …
手撕 JS 代码
大厂前端面试「手写题」全集。所有实现均为 ES2020+ 语法,附中文注释、边界处理、可运行用例,以及面试官真正会追问的【实现要点】。
使用建议:先自己写一遍再对照。面试现场写不完是常态,先写出主干 + 口述边界,比闷头写完更容易拿分。
目录
一、异步控制(最高频,优先掌握)
- 带最大并发数限制的 Promise.all(asyncPool)
- 竞态请求只消费最后一次
- 手写 Promise.all / race / allSettled
- promisify
- sleep
- Promise 重试 retry
- 用 setTimeout 实现 setInterval
- 防抖 debounce 与节流 throttle
二、原理模拟
三、数组与函数
- 数组扁平化 flat
- 手写 reduce / map / filter / find
- 手写 Array.prototype.reverse
- 数组去重
- 数组乱序 Fisher-Yates
- 数组合并 + 去重 + 排序(不用任何数组 API)
四、网络相关
一、异步控制
1. 带最大并发数限制的 Promise.all (asyncPool)
字节二面原题。别名:并发控制器、异步任务池、
pLimit。 题面:asyncPool(limit, tasks),tasks是一组「返回 Promise 的函数」,任意时刻最多同时跑limit个,结果顺序与输入顺序一致。
解法 A:固定 worker 数(推荐,最好写也最好讲)
思路:开 min(limit, tasks.length) 个「工人」协程,每个工人循环从共享游标取下一个任务执行,取完为止。
/**
* 并发受限的 Promise.all
* @param {number} limit 最大并发数
* @param {Array<() => Promise<any>>} tasks 任务工厂数组(注意:是函数,不是已经启动的 Promise)
* @returns {Promise<any[]>} 与 tasks 顺序一致的结果数组
*/
async function asyncPool(limit, tasks) {
const list = Array.from(tasks); // 兼容任意可迭代对象,同时快照一份防止外部改动
if (!Number.isInteger(limit) || limit < 1) {
throw new TypeError('limit 必须是 >= 1 的整数');
}
const results = new Array(list.length);
if (list.length === 0) return results; // 边界:空任务,直接返回 []
let cursor = 0; // 共享游标。JS 单线程,cursor++ 与后面的 await 之间不会被打断,天然无竞态
// 边界:limit 大于任务数时,worker 数量取小值,避免空转
const workerCount = Math.min(limit, list.length);
const worker = async () => {
while (cursor < list.length) {
const index = cursor++; // 先同步占坑
results[index] = await list[index](); // 再异步执行,结果按 index 回填保证顺序
}
};
// 任一任务抛错则整体 reject,语义对齐 Promise.all
await Promise.all(Array.from({ length: workerCount }, worker));
return results;
}解法 B:Promise.race 滑动窗口(经典写法,面试官爱听)
function asyncPoolRace(limit, tasks) {
const list = Array.from(tasks);
const results = []; // 存 Promise,最后统一 Promise.all
const executing = new Set(); // 正在执行的 Promise 集合
return (async () => {
for (const task of list) {
// 用 Promise.resolve().then 包一层:即使 task 同步抛错也能转成 rejected Promise
const p = Promise.resolve().then(() => task());
results.push(p);
// 给 p 挂一个「完成即出队」的回调。注意两个分支都要挂,否则 reject 时窗口不释放
const clean = () => executing.delete(p);
p.then(clean, clean); // 这里已经消费了 rejection,不会产生 unhandledrejection
executing.add(p);
if (executing.size >= limit) {
// 等最快的一个完成后再放行下一个。catch 兜底,防止 race 的 rejection 无人处理
await Promise.race(executing).catch(() => {});
}
}
return Promise.all(results);
})();
}变体:失败不中断(allSettled 语义)
面试官常追问「某个任务失败了怎么办」:
async function asyncPoolSettled(limit, tasks) {
// 把每个任务包成永不 reject 的形式,复用上面的 asyncPool
return asyncPool(
limit,
Array.from(tasks, (task) => async () => {
try {
return { status: 'fulfilled', value: await task() };
} catch (reason) {
return { status: 'rejected', reason };
}
})
);
}用例
const sleep = (ms, v) => new Promise((r) => setTimeout(() => r(v), ms));
const tasks = [1000, 500, 300, 400, 200].map((ms, i) => () => sleep(ms, `任务${i}(${ms}ms)`));
console.time('pool');
asyncPool(2, tasks).then((res) => {
console.timeEnd('pool'); // ≈1200ms(并发 2),而非串行的 2400ms
console.log(res); // ['任务0(1000ms)', '任务1(500ms)', ... ] 顺序严格对应
});
asyncPool(10, tasks).then(console.log); // 边界:limit > 任务数,等价于 Promise.all
asyncPool(3, []).then(console.log); // 边界:空数组 -> []【实现要点】
- 参数必须是「函数数组」而不是「Promise 数组」。
Promise一创建就已经在跑了,传 Promise 数组根本控制不了并发 —— 这是本题第一个淘汰点,写之前要先说出来。 - 结果顺序。用
results[index] = ...按下标回填,不要用push,否则返回顺序变成「完成顺序」,与Promise.all语义不符。 - 无竞态的原因。
const index = cursor++是同步语句,和后面的await之间没有让出事件循环,所以多个 worker 不会拿到同一个 index。这个点能主动讲出来会加分。 - 错误语义要问清楚:默认「快速失败」(对齐
Promise.all),还是「失败不中断」(allSettled)。解法 B 里Promise.race(executing)不加.catch会产生未处理的 rejection 警告,是隐藏扣分项。
2. 竞态请求只消费最后一次
字节原题。场景:搜索框连续输入,先发的请求后返回,会用旧数据覆盖新数据(请求竞态 / race condition)。 要求:只让「最后一次」发出的请求生效。
解法 A:自增 id 丢弃过期响应(通用,不依赖底层能力)
/**
* 包装一个请求函数,使其只有「最后一次调用」的结果会被消费
* @param {(...args:any[]) => Promise<any>} request 原始请求函数
*/
function createLatestOnly(request) {
let seq = 0; // 自增序号,闭包持有
return function (...args) {
const id = ++seq; // 本次调用的编号,先自增再记录
return Promise.resolve(request.apply(this, args)).then(
(data) => {
// 回来时如果 seq 已经被更新过,说明我不是最后一次 -> 丢弃
if (id !== seq) throw new StaleError();
return data;
},
(err) => {
// 过期请求的错误同样不该冒泡到 UI(否则会弹出早已过时的报错)
if (id !== seq) throw new StaleError();
throw err;
}
);
};
}
/** 过期标记错误:调用方据此静默忽略,而不是当成真正的失败 */
class StaleError extends Error {
constructor() {
super('STALE_RESPONSE');
this.name = 'StaleError';
}
}
const isStale = (err) => err instanceof StaleError;解法 B:AbortController 真正取消(推荐,能省流量)
/**
* 每次新调用都 abort 掉上一次未完成的请求
* 约定:被包装的函数最后一个参数接收 { signal }
*/
function createAbortableLatest(request) {
let controller = null;
const wrapped = function (...args) {
controller?.abort(); // 取消上一次(首次为 null,可选链安全)
controller = new AbortController();
const current = controller;
return Promise.resolve(request.apply(this, [...args, { signal: current.signal }]))
.finally(() => {
// 只有「自己还是当前那一次」时才清空,避免误清掉后来者的 controller
if (controller === current) controller = null;
});
};
wrapped.cancel = () => controller?.abort(); // 组件卸载时手动取消
return wrapped;
}
// 配合 fetch 使用
const search = createAbortableLatest((keyword, { signal }) =>
fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { signal }).then((r) => r.json())
);用例
// 模拟:先发的请求反而更慢
const mockRequest = (kw) =>
new Promise((r) => setTimeout(() => r(`结果:${kw}`), kw === 'a' ? 800 : 100));
const latest = createLatestOnly(mockRequest);
latest('a').then(console.log).catch((e) => isStale(e) || Promise.reject(e)); // 被丢弃,无输出
latest('ab').then(console.log); // 100ms 后输出「结果:ab」—— 最终 UI 正确
// AbortController 版的调用方需要过滤 AbortError
search('ab').catch((e) => {
if (e.name !== 'AbortError') throw e; // 主动取消不算错误
});【实现要点】
- 两种方案的差别要讲清楚:自增 id 是「请求照发,结果丢弃」,通用但浪费带宽;
AbortController是「直接掐断连接」,省流量但要求底层支持 signal(fetch、axios的AbortController、XHR.abort())。生产上常两者结合。 - 过期请求的 error 也要吞掉。只处理 success 分支是最常见的漏洞:一个早已过期的 500 错误,几秒后弹个 toast,用户会莫名其妙。
AbortError不是业务错误。fetch被 abort 后 reject 的是DOMException: AbortError,调用方必须按err.name === 'AbortError'过滤,否则会误报。- 和防抖的关系:防抖减少的是「发出的请求数」,竞态处理保证的是「结果的正确性」。二者解决的不是同一个问题,防抖 + 竞态控制通常同时上。
3. 手写 Promise.all / Promise.race / Promise.allSettled
/**
* Promise.all:全部成功才成功,任一失败立即失败
* 边界:空数组立即 resolve([]);非 Promise 值原样透传;结果严格按输入顺序
*/
function myAll(iterable) {
return new Promise((resolve, reject) => {
const list = Array.from(iterable); // 支持任意可迭代对象(Set、生成器…)
const results = new Array(list.length);
let count = 0;
if (list.length === 0) return resolve(results); // 边界:空数组同步 resolve
list.forEach((item, index) => {
// Promise.resolve 兼容 thenable 和普通值
Promise.resolve(item).then(
(value) => {
results[index] = value; // 按下标回填,保证顺序而非完成顺序
if (++count === list.length) resolve(results); // 用计数而非 index 判断,避免乱序完成时提前 resolve
},
reject // 任一失败直接 reject(Promise 状态不可逆,后续 reject 无效,无需加锁)
);
});
});
}
/**
* Promise.race:第一个 settle(无论成功失败)的结果决定最终状态
* 边界:空数组 -> 永远 pending(这是规范行为,不是 bug)
*/
function myRace(iterable) {
return new Promise((resolve, reject) => {
for (const item of iterable) {
Promise.resolve(item).then(resolve, reject); // 谁先到谁生效,后来者被忽略
}
});
}
/**
* Promise.allSettled:永不 reject,返回每一项的状态描述
*/
function myAllSettled(iterable) {
const list = Array.from(iterable);
return myAll(
list.map((item) =>
Promise.resolve(item).then(
(value) => ({ status: 'fulfilled', value }),
(reason) => ({ status: 'rejected', reason }) // 把失败转成成功,所以整体永不 reject
)
)
);
}
/** 附赠:Promise.any —— 任一成功即成功,全部失败抛 AggregateError */
function myAny(iterable) {
return new Promise((resolve, reject) => {
const list = Array.from(iterable);
const errors = new Array(list.length);
let count = 0;
if (list.length === 0) return reject(new AggregateError([], 'All promises were rejected'));
list.forEach((item, index) => {
Promise.resolve(item).then(resolve, (err) => {
errors[index] = err;
if (++count === list.length) reject(new AggregateError(errors, 'All promises were rejected'));
});
});
});
}用例
myAll([1, Promise.resolve(2), sleep(50, 3)]).then(console.log); // [1, 2, 3]
myAll([]).then((r) => console.log('空数组:', r)); // 空数组: []
myAll([1, Promise.reject(new Error('x'))]).catch((e) => console.log(e.message)); // x
myRace([sleep(100, 'slow'), sleep(10, 'fast')]).then(console.log); // fast
myAllSettled([Promise.resolve(1), Promise.reject('e')]).then(console.log);
// [{status:'fulfilled',value:1}, {status:'rejected',reason:'e'}]【实现要点】
- 计数器而不是
index === len - 1。异步完成顺序不确定,用最后一个下标判断会在「最后一个最先返回」时提前 resolve,这是本题最高频的 bug。 - 空数组的两种行为:
all/allSettled立即 resolve[],race永远 pending。能主动说出race([])永远 pending 是加分项。 - 必须
Promise.resolve(item)包一层。数组里可能混有普通值或 thenable 对象,直接.then会报错。 - 不需要防重入锁。Promise 状态一旦 settle 就不可变,重复调用
resolve/reject会被内部忽略,所以 reject 可以直接当回调传。 allSettled的精髓是**「把 reject 转成 fulfilled」**,从而复用all,不用重写一遍计数逻辑。
4. promisify
飞书原题。把 Node 风格的
(err, data) => {}回调函数转成返回 Promise 的函数。
/**
* 将 error-first 回调风格函数转为 Promise 风格
* @param {Function} fn 形如 fn(...args, (err, ...values) => {})
*/
function promisify(fn) {
if (typeof fn !== 'function') throw new TypeError('promisify 的参数必须是函数');
// 必须用 function 而不是箭头函数:要透传调用时的 this(如 obj.method)
return function (...args) {
return new Promise((resolve, reject) => {
// 用 fn.call(this, ...) 把 this 传给原函数,否则 fs.read 这类依赖 this 的方法会崩
fn.call(this, ...args, (err, ...values) => {
if (err) return reject(err);
// Node 的 util.promisify 语义:多个返回值时给出数组,单个时直接给值
resolve(values.length > 1 ? values : values[0]);
});
});
};
}
/** 批量转换一个对象上的方法(保留原方法) */
function promisifyAll(obj, suffix = 'Async') {
for (const key of Object.keys(obj)) {
if (typeof obj[key] === 'function') {
obj[`${key}${suffix}`] = promisify(obj[key]);
}
}
return obj;
}用例
function readFileMock(path, callback) {
setTimeout(() => {
path ? callback(null, `${path} 的内容`) : callback(new Error('路径为空'));
}, 10);
}
const readFile = promisify(readFileMock);
readFile('/a.txt').then(console.log); // /a.txt 的内容
readFile('').catch((e) => console.log(e.message)); // 路径为空
// this 透传验证
const api = {
base: 'https://x.com',
get(path, cb) { cb(null, this.base + path); }, // 依赖 this
};
promisifyAll(api);
api.getAsync('/user').then(console.log); // https://x.com/user【实现要点】
this必须透传。返回箭头函数或直接fn(...args, cb)都会丢失 this,obj.methodAsync()会报Cannot read property of undefined。这是本题唯一的核心考点。- 回调可能有多个返回值。
(err, stdout, stderr)这类要返回数组;只有一个时返回值本身。Node 官方util.promisify还支持util.promisify.custom符号来自定义,能提一句是加分。 if (err)还是if (err != null)。有些库会回调callback(0)或callback('')表示成功,严格实现应该用err != null,但 Node 惯例是 truthy 判断,答题时说清取舍即可。- 回调只能被消费一次。若原函数错误地多次调用 callback,Promise 会因状态不可逆而自动忽略后续 —— 这算是 Promise 帮我们兜了底。
5. sleep
/**
* 最简版:延迟指定毫秒后 resolve
* @param {number} ms
* @param {any} [value] 可选透传值,方便链式使用
*/
const sleep = (ms, value) => new Promise((resolve) => setTimeout(resolve, ms, value));
/**
* 可取消版:支持 AbortSignal,避免组件卸载后定时器还在跑
*/
function sleepCancelable(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort); // 正常结束也要摘监听,防内存泄漏
resolve();
}, ms);
function onAbort() {
clearTimeout(timer); // 关键:真正清掉定时器,而不只是 reject
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
}
signal?.addEventListener('abort', onAbort, { once: true });
});
}用例
async function demo() {
console.log('start', Date.now());
await sleep(500);
console.log('after 500ms', Date.now());
const v = await sleep(100, 'hello');
console.log(v); // hello
}
demo();
// 可取消
const ac = new AbortController();
sleepCancelable(5000, { signal: ac.signal }).catch((e) => console.log(e.name)); // AbortError
ac.abort();【实现要点】
setTimeout(resolve, ms, value)的第三个参数会作为实参传给回调,比写() => resolve(value)更简洁,是个小加分点。sleep不能保证精确延时。setTimeout只保证「不早于」,实际受事件循环阻塞、后台标签页节流(浏览器对隐藏页面的 timer 最小间隔会被钳到 1s+)影响。- 可取消能力是区分档次的地方。面试官追问「组件卸载了怎么办」时,能给出
AbortSignal版本并记得clearTimeout就稳了 —— 只 reject 不 clearTimeout 是典型的假取消。 - Node 里有现成的
require('timers/promises').setTimeout,浏览器里可用AbortSignal.timeout(ms),答完手写版顺带提一句显得有工程经验。
6. Promise 重试 retry
/**
* 失败自动重试(支持次数、延迟、指数退避、条件重试、可取消)
* @param {(attempt:number) => Promise<any>} fn 任务工厂,每次重试都会重新调用
* @param {object} options
* @param {number} options.retries 重试次数(不含首次),总执行次数 = retries + 1
* @param {number} options.delay 首次重试前的等待毫秒
* @param {number} options.factor 退避倍数,1 表示固定间隔
* @param {number} options.maxDelay 延迟上限
* @param {(err:Error, attempt:number) => boolean} options.shouldRetry 是否值得重试
*/
async function retry(fn, options = {}) {
const {
retries = 3,
delay = 300,
factor = 2,
maxDelay = Infinity,
shouldRetry = () => true,
signal,
} = options;
let attempt = 0; // 已失败次数
let wait = delay;
for (;;) {
try {
return await fn(attempt); // 把当前轮次告诉任务,方便打日志
} catch (err) {
// 三个终止条件:次数用尽 / 该错误不值得重试 / 外部已取消
if (attempt >= retries || !shouldRetry(err, attempt) || signal?.aborted) {
throw err; // 抛出的是「最后一次」的错误,保留原始堆栈
}
attempt++;
await sleepCancelable(Math.min(wait, maxDelay), { signal });
wait *= factor; // 指数退避:300 -> 600 -> 1200…
}
}
}用例
let n = 0;
const flaky = () => {
n++;
return n < 3 ? Promise.reject(new Error(`第${n}次失败`)) : Promise.resolve(`第${n}次成功`);
};
retry(flaky, { retries: 5, delay: 100 }).then(console.log); // 约 300ms 后输出「第3次成功」
// 只对 5xx / 网络错误重试,4xx 直接放弃(真实业务里最常见的诉求)
retry(() => fetch('/api').then((r) => {
if (!r.ok) { const e = new Error(r.statusText); e.status = r.status; throw e; }
return r.json();
}), {
retries: 3,
shouldRetry: (err) => err.status === undefined || err.status >= 500,
}).catch(console.error);
// 边界:retries = 0 表示不重试,只跑一次
retry(() => Promise.reject(new Error('boom')), { retries: 0 }).catch((e) => console.log(e.message));【实现要点】
retries的语义要说清:是「总次数」还是「额外重试次数」。业界惯例(p-retry、axios-retry)是后者,总执行次数 =retries + 1。答题时主动定义,避免歧义。- 必须传「函数」而非 Promise。同第 1 题,Promise 无法重新执行,重试的前提是能重新调用工厂函数。
- 指数退避 + 抖动(jitter)。固定间隔重试在服务端故障时会造成「重试风暴」,生产实现会在延迟上加随机抖动:
wait * (0.5 + Math.random() * 0.5)。提到这点说明写过真实的容错代码。 - 不是所有错误都该重试。400/401/404 重试多少次都没用,
shouldRetry是这道题从「能跑」到「能上线」的分水岭。 - 抛出的应该是最后一次的错误,不要吞掉换成笼统的
new Error('重试失败'),否则线上没法定位。
7. 用 setTimeout 实现 setInterval
/**
* 用递归 setTimeout 模拟 setInterval
* @returns {() => void} 取消函数(不用返回 id,闭包更安全)
*/
function mySetInterval(fn, delay, ...args) {
let timer = null;
let stopped = false;
const loop = () => {
timer = setTimeout(() => {
if (stopped) return; // 双保险:clear 之后仍可能进来(极端时序)
fn(...args); // 先执行业务
if (!stopped) loop(); // 再排下一次;若 fn 里调用了 cancel,这里就不会再排
}, delay);
};
loop();
return function clear() {
stopped = true;
clearTimeout(timer);
timer = null;
};
}
/**
* 进阶:补偿版,抵消 setTimeout 的累积漂移(做倒计时、动画节拍时必需)
* 思路:每次都以「理论下一次触发时刻」和「当前真实时刻」的差值来修正等待时间
*/
function accurateInterval(fn, delay) {
let stopped = false;
let count = 0;
const start = Date.now();
let timer = null;
const loop = () => {
count++;
// 第 count 次理论触发时刻是 start + count*delay,直接减去「现在」就是还要等多久。
// 注意:这里要用绝对时刻相减,写成 delay - (now - (start + count*delay)) 会多等一个 delay
const next = Math.max(0, start + count * delay - Date.now()); // 落后太多时立刻触发,不会是负数
timer = setTimeout(() => {
if (stopped) return;
fn(count);
if (!stopped) loop();
}, next);
};
loop();
return () => { stopped = true; clearTimeout(timer); };
}
/** 反向:用 setInterval 实现 setTimeout(同样是常见追问) */
function mySetTimeout(fn, delay, ...args) {
const timer = setInterval(() => {
clearInterval(timer); // 先清再执行,防止 fn 抛错导致定时器永远清不掉
fn(...args);
}, delay);
return () => clearInterval(timer);
}用例
let i = 0;
const cancel = mySetInterval(() => {
console.log('tick', ++i, Date.now());
if (i === 5) cancel(); // 在回调内部取消,也能正确停止
}, 200);
// 验证漂移补偿:第 10 次触发应该落在 ≈1000ms,而不是被误差越拖越晚
const t0 = Date.now();
const marks = [];
const stop = accurateInterval((n) => {
marks.push(Date.now() - t0);
if (n === 10) { stop(); console.log(marks.join(','), '总耗时', Date.now() - t0); }
}, 100);
// 故意在 320ms 处同步阻塞 250ms,模拟长任务
setTimeout(() => { const end = Date.now() + 250; while (Date.now() < end); }, 320);
// 实测输出:115,209,304,585,596,611,705,813,906,1001 总耗时 1010
// ↑ 阻塞期间欠下的 3 次会「追账式」补齐(585/596/611 紧挨着触发),
// 随后自动回到 100ms 的节拍上,总耗时仍锚定在 1000ms 附近。
// 如果不想要这种补触发,可以给 next 设一个下限,代价是放弃总时长的准确性。【为什么比原生 setInterval 好】
- 原生
setInterval会「堆积任务」。它只管每隔delay往任务队列里塞一个回调,不管上一个跑完没有。如果fn执行耗时 >delay(或主线程被长任务阻塞),回调会挤在队列里,阻塞解除后连续爆发式执行,中间没有任何间隔。递归setTimeout是「上一次执行完,才开始计下一次的时间」,永远不会堆积。 - 间隔语义更符合直觉。原生是「两次开始之间隔 delay」(实际间隔 = max(delay, 执行耗时),且不可控);递归版是「上一次结束到下一次开始隔 delay」,稳定可预期。
- 每一轮的 delay 可以动态调整。比如轮询接口失败后自动拉长间隔(退避),原生
setInterval做不到,只能清掉重建。 - 清理更安全。原生
setInterval忘记clearInterval会永久泄漏;递归版只要不再排下一次,链条自然终止。 - 代价:递归版本会有累积漂移(每轮多出的执行耗时会累加),做倒计时必须用上面的
accurateInterval做时间补偿;原生setInterval反而没有累积漂移(它按绝对时刻排期)。这个「反例」能讲出来,说明你真的理解了两者差异,而不是背结论。
【实现要点】
- 返回取消函数而不是 timer id。递归版每轮的 id 都在变,把 id 交给外部无法正确清除;必须用闭包持有最新 id。
stopped标志位不可省。在fn内部调用cancel(),或clearTimeout与回调执行存在竞态时,只靠clearTimeout可能已经来不及。- 异步任务要用
await版本:如果fn是异步的,应写成await fn(); if (!stopped) loop();,否则依然会并发堆积(轮询接口场景的经典坑)。 - 漂移补偿的公式:
next = (start + count * delay) - now,即「第 n 次的理论时刻」减「当前时刻」。必须用固定的start作为绝对锚点,不能用「上一次实际触发时刻 + delay」,后者的误差同样会累积。这行很容易写成delay - (now - 理论时刻),会导致第一次就多等一个delay,写完务必用 5 次 tick 的总耗时验一下(应接近5 * delay而不是6 * delay)。
8. 防抖 debounce 与节流 throttle
/**
* 防抖:事件停止触发 wait 毫秒后才执行。高频触发时不断重新计时。
* @param {Function} fn
* @param {number} wait
* @param {boolean} immediate 是否在触发的第一时间就执行一次(前沿触发)
*/
function debounce(fn, wait = 300, immediate = false) {
let timer = null;
let lastArgs = null;
let lastThis = null;
let result;
const debounced = function (...args) {
lastArgs = args;
lastThis = this; // 保存 this,保证 DOM 事件里 this 仍是元素
const callNow = immediate && timer === null; // 只有「空闲期」的第一次才立即执行
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!immediate) { // 后沿触发:延迟结束时才真正执行
result = fn.apply(lastThis, lastArgs);
lastArgs = lastThis = null; // 及时释放引用,避免闭包持有 DOM 造成泄漏
}
}, wait);
if (callNow) {
result = fn.apply(this, args);
lastArgs = lastThis = null;
}
return result; // 注意:immediate=false 时返回的是「上一次」的结果,异步天然如此
};
/** 取消未执行的调用(组件卸载时必调) */
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
lastArgs = lastThis = null;
};
/** 立即执行挂起的调用 */
debounced.flush = () => {
if (timer && !immediate && lastArgs) {
clearTimeout(timer);
timer = null;
result = fn.apply(lastThis, lastArgs);
lastArgs = lastThis = null;
}
return result;
};
/** 是否有待执行任务 */
debounced.pending = () => timer !== null;
return debounced;
}/**
* 节流:wait 毫秒内最多执行一次(时间戳 + 定时器合并版)
* @param {object} options
* @param {boolean} options.leading 是否响应「第一次」触发(默认 true)
* @param {boolean} options.trailing 是否在最后补一次(默认 true)
*/
function throttle(fn, wait = 300, { leading = true, trailing = true } = {}) {
let last = 0; // 上一次执行的时间戳
let timer = null;
let lastArgs = null;
let lastThis = null;
let result;
const throttled = function (...args) {
const now = Date.now();
// leading=false 时,把 last 初始化为 now,从而让第一次落到 trailing 分支
if (last === 0 && !leading) last = now;
const remaining = wait - (now - last);
lastArgs = args;
lastThis = this;
// remaining > wait 是防御系统时间被回拨导致永远不触发
if (remaining <= 0 || remaining > wait) {
// —— 时间戳分支:立刻执行 ——
if (timer) { clearTimeout(timer); timer = null; }
last = now;
result = fn.apply(this, args);
lastArgs = lastThis = null;
} else if (timer === null && trailing) {
// —— 定时器分支:补上最后一次,保证「停止触发后」还有一次响应 ——
timer = setTimeout(() => {
last = leading ? Date.now() : 0; // leading=false 时重置,让下一轮仍走 trailing
timer = null;
if (lastArgs) result = fn.apply(lastThis, lastArgs);
lastArgs = lastThis = null;
}, remaining);
}
return result;
};
throttled.cancel = () => {
clearTimeout(timer);
timer = null;
last = 0;
lastArgs = lastThis = null;
};
return throttled;
}用例
// 防抖:搜索框,用户停止输入 500ms 后才请求
const onSearch = debounce((e) => console.log('请求:', e.target.value), 500);
input.addEventListener('input', onSearch);
// 组件卸载
onSearch.cancel();
// 立即执行版:按钮防连点,第一次立刻生效,1s 内的后续点击全忽略
const onSubmit = debounce(() => console.log('提交'), 1000, true);
// 节流:滚动计算位置,最多 100ms 一次
window.addEventListener('scroll', throttle(() => console.log(window.scrollY), 100));
// 快速验证节流:50ms 触发一次,共 10 次,wait=200 -> 大约执行 3~4 次而非 10 次
let c = 0;
const t = throttle(() => console.log('throttle 执行', ++c), 200);
const iv = setInterval(t, 50);
setTimeout(() => clearInterval(iv), 1000);【使用场景差异】
| 防抖 debounce | 节流 throttle | |
|---|---|---|
| 语义 | 只认最后一次,事件停下来才执行 | 匀速执行,固定频率放行 |
| 高频不停触发时 | 一次都不执行(会被无限推迟) | 每隔 wait 稳定执行一次 |
| 典型场景 | 搜索框联想、表单校验、按钮防连点、窗口 resize 后重排布局、自动保存 | scroll 视差/曝光埋点、mousemove 拖拽、射击游戏的开火、无限滚动加载 |
| 一句话选型 | 「我只要最终结果」 | 「我要持续但别太频繁的反馈」 |
补充:resize 用防抖是因为只关心「稳定后」的最终尺寸;但如果要做实时跟随的画布重绘,就该用节流(或 requestAnimationFrame 节流),否则拖拽过程中界面完全不动,体验很差。
【实现要点】
this与参数透传。必须用function而非箭头函数接收调用,并用fn.apply(lastThis, lastArgs)转发。写成setTimeout(fn, wait)会让this变成window、参数全丢,这是最高频的扣分点。cancel是必答项。ReactuseEffect的清理函数、VueonUnmounted里不 cancel,卸载后回调仍会跑,轻则报错重则内存泄漏。能主动补cancel/flush说明写过业务。- 节流的两种实现有行为差异:纯时间戳版第一次立即执行、最后一次不执行;纯定时器版第一次延迟执行、最后一次会执行。合并版通过
leading/trailing让调用方选择 —— 面试官很爱追问这个差别。 leading:false且trailing:false是退化配置:本实现(Node 实测)会跳过首个窗口、之后仍按窗口放行;lodash 在该组合下则一次都不执行。两种语义都说得通,面试被追问时说明这是无意义组合、以及自己实现的取舍即可。- 防抖
immediate模式下不能再执行 trailing,否则一次点击会触发两次。上面代码里用if (!immediate)守住了这一点。 - 及时置空
lastThis/lastArgs。它们常常持有 DOM 事件对象和元素引用,不清理会阻止 GC,长列表页面上很容易看出来。 rAF节流:视觉相关的高频事件(scroll/mousemove 引起的重绘)用requestAnimationFrame节流比时间节流更合适,天然对齐屏幕刷新率,且页面隐藏时自动暂停。
二、原理模拟
9. call / apply / bind
/**
* myCall:改变 this 并立即执行
* 原理:把函数临时挂到目标对象上作为方法调用,利用「谁调用 this 指向谁」,用完删除
*/
Function.prototype.myCall = function (thisArg, ...args) {
if (typeof this !== 'function') {
throw new TypeError('myCall 必须由函数调用');
}
// null / undefined 指向全局对象;原始值被包装成对象(这是非严格模式的原生行为)
const ctx = thisArg === null || thisArg === undefined ? globalThis : Object(thisArg);
// 用 Symbol 做 key,杜绝覆盖对象上的同名属性
const key = Symbol('__myCall__');
Object.defineProperty(ctx, key, {
value: this,
enumerable: false, // 不可枚举,避免污染 for...in
configurable: true, // 必须可配置,否则删不掉
});
try {
return ctx[key](...args); // 作为 ctx 的方法调用,函数内部 this === ctx
} finally {
delete ctx[key]; // 放在 finally,保证抛异常时也能清理
}
};
/**
* myApply:与 call 一致,只是参数是数组
*/
Function.prototype.myApply = function (thisArg, argsArray) {
if (typeof this !== 'function') throw new TypeError('myApply 必须由函数调用');
// 边界:不传或传 null/undefined 视为无参;传非类数组应报错
if (argsArray === null || argsArray === undefined) return this.myCall(thisArg);
if (typeof argsArray !== 'object') throw new TypeError('CreateListFromArrayLike called on non-object');
return this.myCall(thisArg, ...Array.from(argsArray)); // Array.from 兼容类数组(arguments、NodeList)
};
/**
* myBind:返回一个 this 被固定的新函数,支持柯里化,且支持 new 调用
*/
Function.prototype.myBind = function (thisArg, ...boundArgs) {
if (typeof this !== 'function') throw new TypeError('myBind 必须由函数调用');
const target = this;
// 用普通函数(非箭头),才能拿到 new.target 和自己的 this
function bound(...callArgs) {
// 关键:作为构造函数调用(new bound())时,new.target 有值,
// 此时必须忽略 thisArg,让 this 指向新创建的实例
return target.apply(
new.target ? this : thisArg,
[...boundArgs, ...callArgs] // 绑定时的参数在前,调用时的参数在后
);
}
// 维持原型链,使 new bound() 的实例满足 instanceof target
// 用 Object.create 中转而不是直接 bound.prototype = target.prototype,
// 防止改动 bound.prototype 时污染原函数的原型
if (target.prototype) {
bound.prototype = Object.create(target.prototype);
}
// 规范里 bound function 的 length 是「原函数 length 减去已绑定参数数」,且不小于 0
Object.defineProperty(bound, 'length', {
value: Math.max(0, target.length - boundArgs.length),
configurable: true,
});
Object.defineProperty(bound, 'name', {
value: `bound ${target.name}`,
configurable: true,
});
return bound;
};用例
function greet(greeting, punctuation) {
return `${greeting}, ${this.name}${punctuation}`;
}
const user = { name: '张三' };
console.log(greet.myCall(user, '你好', '!')); // 你好, 张三!
console.log(greet.myApply(user, ['Hi', '.'])); // Hi, 张三.
console.log(greet.myApply(user)); // 边界:不传参数数组也不报错
console.log(greet.myBind(user, '嗨')('~')); // 嗨, 张三~ 柯里化
// 重点验证:bind 后被 new 调用
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.say = function () { return `${this.name}-${this.age}`; };
const BoundPerson = Person.myBind({ name: '应该被忽略' }, '李四');
const p = new BoundPerson(28);
console.log(p.name, p.age); // 李四 28 —— thisArg 被正确忽略
console.log(p.say()); // 李四-28 —— 原型链完好
console.log(p instanceof Person); // true
console.log(p instanceof BoundPerson); // true
// 不污染原型验证
BoundPerson.prototype.extra = 1;
console.log(Person.prototype.extra); // undefined【实现要点】
bind支持new是本题的核心考点,几乎必问。判断方式用new.target(ES6 起)最干净;老写法是this instanceof bound,但在bound.call(obj)且 obj 恰好是 bound 实例时会误判。- 原型链要用
Object.create(target.prototype)中转。直接bound.prototype = target.prototype也能让instanceof通过,但外部给bound.prototype加属性会污染原函数原型,是隐蔽的副作用。 call里的 key 必须用Symbol且用完删除。用字符串'fn'有覆盖同名属性的风险;不删除会给对象留垃圾属性;delete要放在finally里,否则原函数抛异常时属性残留。- 原始值的装箱行为要说清。非严格模式下
fn.call(1)中this是Number包装对象;严格模式下this就是原始值1。手写版因为要靠属性挂载,无法完全复刻严格模式的行为 —— 主动指出这个局限,比装作没这回事更能加分。 length和name的修正属于细节分,写不写不影响主干,但能体现对规范的熟悉度。
10. myNew:new 的过程
new 做了四件事:① 创建空对象;② 把对象原型指向构造函数的 prototype;③ 以该对象为 this 执行构造函数;④ 若构造函数返回对象则用它,否则用新创建的对象。
function myNew(Ctor, ...args) {
if (typeof Ctor !== 'function') {
throw new TypeError(`${Ctor} is not a constructor`);
}
// 箭头函数、Symbol、部分内置方法没有 prototype,不能被 new
if (!('prototype' in Ctor)) {
throw new TypeError(`${Ctor.name} is not a constructor`);
}
// ① + ② 创建对象并链接原型(Ctor.prototype 被改成原始值时回退到 Object.prototype,对齐规范)
const proto = (typeof Ctor.prototype === 'object' || typeof Ctor.prototype === 'function')
&& Ctor.prototype !== null
? Ctor.prototype
: Object.prototype;
const instance = Object.create(proto);
// ③ 执行构造函数,this 指向新对象
const result = Ctor.apply(instance, args);
// ④ 构造函数显式返回「对象/函数」则以它为准,返回原始值(含 undefined/null)则忽略
const isObject = result !== null && (typeof result === 'object' || typeof result === 'function');
return isObject ? result : instance;
}用例
function Person(name) {
this.name = name;
}
Person.prototype.hello = function () { return 'hi ' + this.name; };
const p = myNew(Person, '王五');
console.log(p.name, p.hello(), p instanceof Person); // 王五 hi 王五 true
// 边界:构造函数返回对象 -> 返回值胜出
function Weird() { this.a = 1; return { b: 2 }; }
console.log(myNew(Weird)); // { b: 2 },a 丢失
// 边界:返回原始值 -> 忽略
function Weird2() { this.a = 1; return 42; }
console.log(myNew(Weird2)); // Weird2 { a: 1 }
// 边界:箭头函数不可 new
const arrow = () => {};
try { myNew(arrow); } catch (e) { console.log(e.message); } // ... is not a constructor【实现要点】
- 「返回对象则覆盖」这条规则是本题唯一的机关,只写前三步会被判定为背诵不完整。注意
typeof null === 'object',必须额外排除null。 - 函数也算对象。
return function(){}同样会覆盖实例,判断条件不能只写typeof result === 'object'。 - 箭头函数 /
class的方法没有prototype,不能被new;而class本身虽然typeof是'function',却不能被myNew正确调用(class 强制要求new,Ctor.apply会抛TypeError)。这是手写版无法覆盖的边界,能主动说明是加分点。 - 用
Object.create(Ctor.prototype)而不是obj.__proto__ = ...。前者是标准 API,后者是历史遗留且性能差(会导致引擎去优化)。
11. instanceof
原理:沿着左侧对象的原型链逐级向上,看有没有等于右侧函数的 prototype。
function myInstanceof(left, right) {
// 右侧必须是可调用对象
if (typeof right !== 'function') {
throw new TypeError('Right-hand side of instanceof is not callable');
}
// 边界:左侧是原始值(含 null)直接 false,不抛错 —— 与原生行为一致
if (left === null || (typeof left !== 'object' && typeof left !== 'function')) {
return false;
}
const prototype = right.prototype;
if (prototype === null || (typeof prototype !== 'object' && typeof prototype !== 'function')) {
throw new TypeError('Function has non-object prototype in instanceof check');
}
let proto = Object.getPrototypeOf(left); // 标准取原型方式,优于 left.__proto__
while (proto !== null) { // 一直找到原型链尽头 Object.prototype -> null
if (proto === prototype) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}用例
console.log(myInstanceof([], Array)); // true
console.log(myInstanceof([], Object)); // true(原型链上有 Object.prototype)
console.log(myInstanceof(1, Number)); // false(原始值)
console.log(myInstanceof(new Number(1), Number)); // true(包装对象)
console.log(myInstanceof(null, Object)); // false
console.log(myInstanceof(() => {}, Function)); // true(函数也是对象)
class A {}
class B extends A {}
console.log(myInstanceof(new B(), A)); // true(继承链)
// 边界:原型被改写后 instanceof 会「说谎」
function C() {}
const c = new C();
C.prototype = {}; // 改写后,老实例的原型链上已经没有新的 C.prototype
console.log(myInstanceof(c, C)); // false【实现要点】
- 判断的是「原型链上有没有
right.prototype」,不是构造函数。所以C.prototype被重新赋值后,已有实例的instanceof会变 false —— 这是面试官最爱的追问。 - 左侧原始值返回
false而不是抛错;右侧非函数才抛TypeError。很多人反过来写。 instanceof跨 iframe / 跨 realm 会失效(每个 window 有各自的Array.prototype),所以判断数组的正确方式是Array.isArray,通用类型判断用Object.prototype.toString.call(x)。Symbol.hasInstance可以劫持instanceof。原生a instanceof B会先看B[Symbol.hasInstance],手写版没实现这一步;提一句能显示对规范的了解:class Even { static [Symbol.hasInstance](n) { return n % 2 === 0; } } console.log(2 instanceof Even); // true,原生会走自定义逻辑
12. Object.create
/**
* @param {object|null} proto 新对象的原型
* @param {object} [descriptors] 属性描述符对象,同 Object.defineProperties 的第二参
*/
function myObjectCreate(proto, descriptors) {
if (proto !== null && typeof proto !== 'object' && typeof proto !== 'function') {
throw new TypeError('Object prototype may only be an Object or null');
}
let obj;
if (proto === null) {
// 边界:F.prototype = null 时 new F() 的原型会回退到 Object.prototype,得不到真正的「无原型对象」。
// 只有对象字面量里的 __proto__ 才能制造 null 原型(这是语法特性,不是普通属性赋值)
obj = { __proto__: null };
} else {
// 借用「new 时实例原型 = 构造函数 prototype」这一机制
function F() {}
F.prototype = proto;
obj = new F();
}
if (descriptors !== undefined) {
Object.defineProperties(obj, descriptors); // 复用原生,题目考的是原型链那步
}
return obj;
}用例
const animal = { speak() { return `${this.name} 在叫`; } };
const dog = myObjectCreate(animal, {
name: { value: '旺财', writable: true, enumerable: true, configurable: true },
});
console.log(dog.speak()); // 旺财 在叫
console.log(Object.getPrototypeOf(dog) === animal); // true
// 边界:null 原型,可当纯净字典用(不怕 key 撞上 toString/constructor)
const dict = myObjectCreate(null);
dict.toString = '我是数据,不是方法';
console.log(dict.toString, Object.getPrototypeOf(dict)); // 我是数据,不是方法 null
// 边界:非法原型
try { myObjectCreate(1); } catch (e) { console.log(e.message); }【实现要点】
proto === null是唯一的陷阱。用经典的F.prototype = proto; new F()写法时,null会被引擎忽略并回退到Object.prototype,得到的对象仍有toString,与原生行为不符。必须单独处理。- 默认属性描述符全是
false。Object.create(proto, { a: { value: 1 } })得到的a是不可写、不可枚举、不可配置的 —— 与直接写obj.a = 1完全不同,这点常被追问。 Object.create(null)的实用价值:做纯净的 map/字典,避免原型链上的属性干扰(obj['constructor']不再意外命中),也天然免疫原型链污染攻击。代价是不能用obj.hasOwnProperty(...),要改用Object.hasOwn(obj, k)。- 与
new的关系:new F()≈Object.create(F.prototype)+ 执行构造函数,两题可以互相印证着答。
13. 深拷贝 deepClone
/**
* 深拷贝:支持循环引用、Date/RegExp/Map/Set、Symbol 键、原型链、getter
* @param {any} value
* @param {WeakMap} cache 已拷贝对象的映射,用于处理循环引用(WeakMap 弱引用,不阻碍 GC)
*/
function deepClone(value, cache = new WeakMap()) {
// 1) 原始值(含 null、undefined、Symbol、BigInt)与函数:直接返回引用
// typeof function === 'function',会走到这里,即函数按引用共享 —— 这是业界通用取舍
if (value === null || typeof value !== 'object') return value;
// 2) 循环引用:已经拷过就直接复用,避免无限递归
if (cache.has(value)) return cache.get(value);
// 3) 特殊内置对象
if (value instanceof Date) return new Date(value.getTime());
if (value instanceof RegExp) {
const re = new RegExp(value.source, value.flags);
re.lastIndex = value.lastIndex; // 带 g/y 标志时 lastIndex 是状态的一部分,别漏
return re;
}
if (value instanceof Error) {
// 注意:假定构造签名为 (message)。构造签名不同的自定义 Error 子类
// (如 class HttpError extends Error { constructor(status, msg) })会重建出错,
// 业务里有这类子类时应退化为 new Error(message) 或按类型加白名单处理
const err = new value.constructor(value.message);
err.stack = value.stack;
if ('cause' in value) err.cause = deepClone(value.cause, cache);
return err;
}
// 包装对象 new String/Number/Boolean
if (value instanceof String || value instanceof Number || value instanceof Boolean) {
return new value.constructor(value.valueOf());
}
// 二进制类型
if (value instanceof ArrayBuffer) return value.slice(0);
if (ArrayBuffer.isView(value)) {
// TypedArray / DataView:拷贝底层 buffer,保持视图类型
return new value.constructor(deepClone(value.buffer, cache), value.byteOffset, value.length ?? value.byteLength);
}
// WeakMap/WeakSet 无法枚举,理论上不可深拷贝
if (value instanceof WeakMap || value instanceof WeakSet) {
throw new TypeError('WeakMap / WeakSet 无法被深拷贝(内容不可枚举)');
}
// 4) 创建同类型的空壳,保持原型链(自定义类的实例拷贝后仍是该类实例)
let clone;
if (Array.isArray(value)) clone = [];
else if (value instanceof Map) clone = new Map();
else if (value instanceof Set) clone = new Set();
else clone = Object.create(Object.getPrototypeOf(value)); // null 原型也能正确处理
// 5) 关键:先入缓存再递归子属性,否则自引用会死循环
cache.set(value, clone);
// 6) Map / Set 的内容不在自有属性里,要单独搬
if (value instanceof Map) {
for (const [k, v] of value) clone.set(deepClone(k, cache), deepClone(v, cache)); // 键也要深拷贝
}
if (value instanceof Set) {
for (const v of value) clone.add(deepClone(v, cache));
}
// 7) Reflect.ownKeys 同时拿到字符串键和 Symbol 键(含不可枚举的)
for (const key of Reflect.ownKeys(value)) {
const desc = Object.getOwnPropertyDescriptor(value, key);
if ('value' in desc) {
// 数据属性:递归拷贝值,并保留 writable/enumerable/configurable
Object.defineProperty(clone, key, { ...desc, value: deepClone(desc.value, cache) });
} else {
// 访问器属性:原样搬运 getter/setter,不去触发它(触发可能有副作用)
Object.defineProperty(clone, key, desc);
}
}
return clone;
}用例
const obj = {
num: 1,
str: 'a',
nil: null,
und: undefined,
big: 10n,
[Symbol('sk')]: 'symbol 键',
date: new Date('2024-01-01'),
reg: /ab+c/gi,
arr: [1, [2, [3]]],
map: new Map('k', { deep: true }),
set: new Set([1, { x: 1 }]),
fn() { return 1; },
};
obj.self = obj; // 循环引用
obj.arr.push(obj.arr); // 数组自引用
const copy = deepClone(obj);
console.log(copy.self === copy); // true 循环引用被正确重建(而不是 obj)
console.log(copy.self === obj); // false
console.log(copy.arr[2] === copy.arr); // true
console.log(copy.date instanceof Date, +copy.date === +obj.date); // true true
console.log(copy.reg.source === obj.reg.source, copy.reg !== obj.reg); // true true
console.log(copy.map.get('k') !== obj.map.get('k')); // true 深层也断开
console.log('und' in copy, copy.big); // true 10n(undefined 键被保留)
// 自定义类的原型保持
class Node2 { constructor(v) { this.v = v; } getV() { return this.v; } }
const n = deepClone(new Node2(9));
console.log(n instanceof Node2, n.getV()); // true 9【structuredClone:原生方案及其局限】
现代浏览器与 Node 17+ 已内置全局 structuredClone(value),基于「结构化克隆算法」,能处理循环引用,支持 Date、RegExp、Map、Set、ArrayBuffer、TypedArray、Blob、File、ImageData、Error 等:
const copy = structuredClone(obj); // 一行搞定,性能通常优于手写递归但有明确局限,面试时这几条要能说出来:
- 函数、Symbol、DOM 节点会直接抛
DataCloneError(不是忽略,是抛错),而JSON方案是静默丢弃。 - 不保留原型链。自定义类的实例克隆后变成普通
Object,instanceof MyClass为false,方法全丢。 - 不保留属性描述符。getter/setter 会被求值成普通数据属性,
writable/enumerable/configurable全部重置为true。 - 不拷贝不可枚举属性和 Symbol 键。
- 同步阻塞,超大对象会卡主线程(它本质是为 postMessage 序列化设计的)。
- 兼容性:Chrome 98+ / Safari 15.4+ / Node 17+,需要兼容老环境时仍得手写。
顺带对比 JSON.parse(JSON.stringify(obj)) 的坑(几乎必问):循环引用直接抛错;undefined、函数、Symbol 值被丢弃(对象里键消失,数组里变成 null);Date 变成字符串;RegExp 变成 {};NaN/Infinity 变成 null;Map/Set 变成 {};BigInt 抛 TypeError;会触发 toJSON 方法;原型链丢失。唯一优点是快,且对纯 JSON 数据完全够用。
【实现要点】
- 循环引用必须「先存缓存,再递归」。
cache.set(value, clone)一定要写在遍历子属性之前,写反了照样栈溢出。用WeakMap而非Map,键是弱引用,拷贝结束后不阻止源对象被 GC。 - Map/Set 的键也要深拷贝。只拷 value 不拷 key 是常见半成品;
clone.set(k, deepClone(v))会让两个 Map 共享对象键。 - 保持原型链用
Object.create(Object.getPrototypeOf(value)),不要一律用{},否则自定义类的实例拷完就退化成普通对象。 Reflect.ownKeys+getOwnPropertyDescriptor才能覆盖 Symbol 键、不可枚举属性和访问器属性;for...in会连原型链上的属性一起拷进来(还得配hasOwnProperty过滤),Object.keys则会漏掉 Symbol 和不可枚举属性。- 递归爆栈:极深的嵌套(几万层)会
Maximum call stack size exceeded。生产级实现会改写成「显式栈 + 循环」的迭代版本,能提到这一点就很扎实了。
14. 发布订阅 EventEmitter 与观察者模式
EventEmitter(发布订阅模式)
class EventEmitter {
#events = new Map(); // 事件名 -> Set<listener>,用 Map 支持 Symbol 事件名
/** 订阅 */
on(event, listener) {
if (typeof listener !== 'function') throw new TypeError('listener 必须是函数');
if (!this.#events.has(event)) this.#events.set(event, new Set());
this.#events.get(event).add(listener);
return this; // 支持链式调用
}
/** 只订阅一次 */
once(event, listener) {
if (typeof listener !== 'function') throw new TypeError('listener 必须是函数');
const wrapper = (...args) => {
// 先解绑再执行:万一 listener 内部抛错,也不会留下永不触发的僵尸监听
this.off(event, wrapper);
listener.apply(this, args);
};
// 关键:把原函数挂在包装函数上,这样 off(event, 原函数) 也能解绑
wrapper.listener = listener;
return this.on(event, wrapper);
}
/** 取消订阅:不传 listener 清空该事件,不传 event 清空全部 */
off(event, listener) {
if (event === undefined) { this.#events.clear(); return this; }
if (listener === undefined) { this.#events.delete(event); return this; }
const set = this.#events.get(event);
if (!set) return this;
if (!set.delete(listener)) {
// 没直接命中,说明可能是 once 包装过的,按 wrapper.listener 反查
for (const fn of set) {
if (fn.listener === listener) { set.delete(fn); break; }
}
}
if (set.size === 0) this.#events.delete(event); // 及时清理空集合,防止 Map 无限膨胀
return this;
}
/** 发布 */
emit(event, ...args) {
const set = this.#events.get(event);
if (!set || set.size === 0) return false; // 对齐 Node:无监听者返回 false
// 关键:先快照。否则回调里 on/off 会改动正在遍历的 Set,导致漏触发或死循环
for (const listener of [...set]) {
try {
listener.apply(this, args);
} catch (err) {
// 单个监听器抛错不应中断其余监听器
queueMicrotask(() => { throw err; }); // 异步抛到全局,保留错误可见性
}
}
return true;
}
/** 监听器数量,测试和排查泄漏时很有用 */
listenerCount(event) {
return this.#events.get(event)?.size ?? 0;
}
}用例
const bus = new EventEmitter();
const onMsg = (a, b) => console.log('on:', a, b);
bus.on('msg', onMsg);
bus.once('msg', (a) => console.log('once:', a));
bus.emit('msg', 1, 2); // on: 1 2 / once: 1
bus.emit('msg', 3, 4); // on: 3 4(once 已自动解绑)
bus.off('msg', onMsg);
console.log(bus.emit('msg'), bus.listenerCount('msg')); // false 0
// 边界:用原函数解绑 once
const fn = () => console.log('never');
bus.once('x', fn).off('x', fn);
console.log(bus.emit('x')); // false —— 正确解绑
// 边界:回调中动态增删监听器不会导致漏触发
const e2 = new EventEmitter();
e2.on('a', () => { e2.on('a', () => console.log('新增的不应本轮触发')); console.log('第一个'); });
e2.on('a', () => console.log('第二个'));
e2.emit('a'); // 第一个 / 第二个观察者模式(Observer)对照
/** 被观察者:直接持有观察者列表并主动通知 */
class Subject {
#observers = new Set();
attach(observer) { this.#observers.add(observer); return this; }
detach(observer) { this.#observers.delete(observer); return this; }
notify(data) { for (const o of [...this.#observers]) o.update(data); }
}
/** 观察者:必须实现 update 接口 */
class Observer {
constructor(name) { this.name = name; }
update(data) { console.log(`${this.name} 收到:`, data); }
}
const subject = new Subject();
const o1 = new Observer('观察者A');
subject.attach(o1).attach(new Observer('观察者B'));
subject.notify({ price: 100 }); // 两个观察者都收到
subject.detach(o1);
subject.notify({ price: 200 }); // 只有 B 收到【两种模式的区别(高频追问)】
| 观察者模式 | 发布订阅模式 | |
|---|---|---|
| 耦合 | Subject 直接持有 Observer 引用,两者互相知道 | 通过中间的 事件中心(Broker) 通信,发布者与订阅者互不知道对方 |
| 通道 | 无事件名概念,notify 通知全部 | 按 event 名分频道 |
| 典型实现 | Vue2 的 Dep / Watcher、MutationObserver | EventEmitter、事件总线、消息队列、postMessage |
| 灵活性 | 简单直接,同步调用 | 完全解耦,易做异步、跨模块、跨进程 |
【实现要点】
once用「包装函数 +wrapper.listener反查」。只存包装函数会导致off(event, 原函数)解绑失败,这是本题最常见的漏洞。emit里必须先复制监听器列表([...set])。回调里调用on/off会改动正在迭代的集合:新增的监听器不该在本轮触发,删除的不该继续执行。不快照会出现漏触发,once场景下甚至死循环。once要先解绑后执行。反过来写的话,一旦listener抛异常,解绑语句永远执行不到,监听器就变成了僵尸。- 单个监听器抛错不能中断广播。裸
for循环里一个回调抛错会让后面的监听器全部收不到事件,必须try/catch兜住(Node 的做法是把error事件特殊处理)。 - 用
Set而非数组:off从 O(n) 变 O(1),且天然去重。但要注意:Node 原生EventEmitter允许重复注册同一函数并触发多次,用Set会自动去重,行为略有差异,答题时说明这是有意的取舍即可。 - 内存泄漏:这是事件总线在业务里最大的坑。组件卸载必须
off,否则闭包持有的组件实例和 DOM 永远无法回收。Node 默认超过 10 个监听器就警告,正是为此。
三、数组与函数
15. 数组扁平化 flat
/** 解法一:递归(最直观,能自然支持 depth) */
function flatRecursive(arr, depth = 1) {
const result = [];
for (const item of arr) {
if (Array.isArray(item) && depth > 0) {
result.push(...flatRecursive(item, depth - 1)); // depth 每下一层减 1
} else {
result.push(item);
}
}
return result;
}
/** 解法二:reduce + 递归(写法最短,面试常写这个) */
const flatReduce = (arr, depth = 1) =>
depth > 0
? arr.reduce(
(acc, cur) => acc.concat(Array.isArray(cur) ? flatReduce(cur, depth - 1) : cur),
[]
)
: arr.slice();
/** 解法三:迭代 + 显式栈(无递归,超深数组也不会爆栈,是本题的最优解) */
function flatStack(arr, depth = Infinity) {
// 栈里存 [元素, 剩余深度]
const stack = arr.map((item) => [item, depth]);
const result = [];
while (stack.length > 0) {
const [item, d] = stack.pop(); // 从尾部取,所以结果是逆序的
if (Array.isArray(item) && d > 0) {
// 展开后压回栈,深度 -1
for (const sub of item) stack.push([sub, d - 1]);
} else {
result.push(item);
}
}
return result.reverse(); // 最后统一反转,恢复原始顺序
}
/** 解法四:无限展开时的取巧写法(仅适用于纯数字/字符串数组,面试当彩蛋讲) */
const flatByToString = (arr) => arr.toString().split(',').map(Number);
// 缺陷:所有元素被转成字符串(null -> ''、undefined -> ''、对象 -> [object Object]),
// 只在「纯数字数组 + 无嵌套对象」时可用,绝不能当正解交上去
/** 对照:原生 API */
// arr.flat(Infinity) —— ES2019,会跳过空位(holes)
// arr.flat() —— 默认 depth = 1用例
const a = [1, [2, [3, [4, [5]]]], 6];
console.log(flatRecursive(a)); // [1, 2, [3,[4,[5]]], 6] 默认 depth=1
console.log(flatRecursive(a, Infinity)); // [1,2,3,4,5,6]
console.log(flatReduce(a, 2)); // [1, 2, 3, [4,[5]], 6]
console.log(flatStack(a)); // [1,2,3,4,5,6]
console.log(a.flat(Infinity)); // [1,2,3,4,5,6] 原生对照
// 边界
console.log(flatStack([])); // []
console.log(flatStack([[], [[]], 1])); // [1] 全空数组被抹平
console.log(flatRecursive([1, 2], 0)); // [1, 2] depth=0 原样返回
// 边界:数组空位
const holes = [1, , 3]; // 索引 1 是空位(hole),不是 undefined
console.log(holes.flat()); // [1, 3] 原生跳过空位
console.log(flatRecursive(holes)); // [1, undefined, 3] for...of 把空位读成 undefined
// 边界:超深数组,验证栈版本不爆栈
let deep = [];
let cur = deep;
for (let i = 0; i < 100000; i++) { const next = []; cur.push(next); cur = next; }
cur.push('底部');
console.log(flatStack(deep)); // ['底部'],递归版会 RangeError
// console.log(flatRecursive(deep, Infinity)); // RangeError: Maximum call stack size exceeded【实现要点】
depth必须支持。只会写flat(Infinity)而处理不了flat(2)是减分项;递归里depth - 1的位置是关键。- 栈版本的
reverse()。pop()从尾部取会让输出逆序,要么最后reverse()(我用的写法),要么改用shift()(但shift是 O(n),性能差),要么压栈时反向压入。这个细节写错的人很多,一定要在纸上走一遍[1,[2,3]]。 - 递归爆栈是唯一的硬伤。数据来源不可控时(比如后端返回的树),显式栈版本是唯一安全的选择 —— 主动提出这一点通常能直接结束这道题。
- 数组空位(hole)与
undefined不同。原生flat/map/forEach都跳过空位,for...of和展开运算符则会把它读成undefined。能说清这个区别属于高级细节分。 concat的隐藏特性:[].concat([1,[2]])只会展开一层,正是 reduce 解法能成立的原因。
16. 手写 reduce / map / filter / find
/**
* reduce:初始值缺省时用第一个「非空位」元素做累加器
* 用剩余参数接收 initialValue,才能区分「没传」和「传了 undefined」
*/
Array.prototype.myReduce = function (callback, ...initialValue) {
if (this === null || this === undefined) throw new TypeError('Array.prototype.myReduce called on null or undefined');
if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
const O = Object(this);
const len = O.length >>> 0; // 无符号右移 0:把 length 规范成 [0, 2^32-1] 的整数
let k = 0;
let accumulator;
if (initialValue.length > 0) {
accumulator = initialValue[0];
} else {
// 跳过前导空位,找到第一个真实存在的元素
while (k < len && !(k in O)) k++;
if (k >= len) throw new TypeError('Reduce of empty array with no initial value'); // 边界:空数组 + 无初始值
accumulator = O[k++];
}
while (k < len) {
if (k in O) accumulator = callback(accumulator, O[k], k, O); // 空位不参与迭代
k++;
}
return accumulator;
};
/** map:返回等长新数组,保留空位 */
Array.prototype.myMap = function (callback, thisArg) {
if (this === null || this === undefined) throw new TypeError('called on null or undefined');
if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
const O = Object(this);
const len = O.length >>> 0;
const result = new Array(len); // 预分配长度,保证返回数组与原数组等长
for (let k = 0; k < len; k++) {
if (k in O) { // 空位跳过:既不调用回调,结果里也保持为空位
result[k] = callback.call(thisArg, O[k], k, O);
}
}
return result;
};
/** filter:保留回调返回真值的项,结果是紧凑数组(无空位) */
Array.prototype.myFilter = function (callback, thisArg) {
if (this === null || this === undefined) throw new TypeError('called on null or undefined');
if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
const O = Object(this);
const len = O.length >>> 0;
const result = [];
for (let k = 0; k < len; k++) {
if (k in O && callback.call(thisArg, O[k], k, O)) {
result.push(O[k]);
}
}
return result;
};
/** find:返回第一个满足条件的「元素」;findIndex 返回下标。注意:不跳过空位 */
Array.prototype.myFind = function (callback, thisArg) {
if (this === null || this === undefined) throw new TypeError('called on null or undefined');
if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
const O = Object(this);
const len = O.length >>> 0;
for (let k = 0; k < len; k++) {
const value = O[k]; // 空位读出来是 undefined,且照样调用回调(与 map/filter 不同)
if (callback.call(thisArg, value, k, O)) return value;
}
return undefined; // 边界:找不到返回 undefined(findIndex 返回 -1)
};
Array.prototype.myFindIndex = function (callback, thisArg) {
const O = Object(this);
const len = O.length >>> 0;
for (let k = 0; k < len; k++) {
if (callback.call(thisArg, O[k], k, O)) return k;
}
return -1;
};
/** 附赠:用 reduce 实现 map / filter,用来展示对 reduce 的理解 */
const mapByReduce = (arr, fn) => arr.reduce((acc, cur, i, src) => (acc.push(fn(cur, i, src)), acc), []);
const filterByReduce = (arr, fn) => arr.reduce((acc, cur, i, src) => (fn(cur, i, src) && acc.push(cur), acc), []);用例
console.log([1, 2, 3, 4].myReduce((a, b) => a + b)); // 10(无初始值)
console.log([1, 2, 3, 4].myReduce((a, b) => a + b, 100)); // 110
console.log([].myReduce((a, b) => a + b, 0)); // 0(空数组 + 初始值,正常)
try { [].myReduce((a, b) => a + b); } catch (e) { console.log(e.message); } // Reduce of empty array...
console.log([1, 2].myReduce((a, b) => a + b, undefined)); // NaN —— 正确识别「显式传了 undefined」
console.log([1, 2, 3].myMap((x) => x * 2)); // [2, 4, 6]
console.log([1, 2, 3, 4].myFilter((x) => x % 2 === 0)); // [2, 4]
console.log([1, 5, 9].myFind((x) => x > 4)); // 5
console.log([1, 5, 9].myFind((x) => x > 100)); // undefined
console.log([1, 5, 9].myFindIndex((x) => x > 4)); // 1
// 边界:空位行为对齐原生
const holes = [1, , 3];
console.log(holes.myMap((x) => x * 2)); // [2, <1 empty item>, 6] 空位保留,回调未被调用
console.log(holes.myFilter(() => true)); // [1, 3] 空位被过滤掉
console.log(holes.myFind((x) => x === undefined)); // undefined,但回调确实被调用了【实现要点】
reduce无初始值 + 空数组必须抛TypeError,这是规范明确规定的,也是本题唯一的硬性边界。- **用剩余参数
...initialValue区分「未传」与「传了 undefined」**。写成function (cb, initial)后用initial === undefined判断是错的:arr.reduce(fn, undefined)应该以undefined` 为初始值,而不是回退到第一个元素。这个细节能立刻区分是否真读过规范。 k in O跳过空位。map/filter/forEach/reduce都跳过空位,而find/findIndex/some/every... 中的find系列不跳过(会把空位当undefined传给回调)。能说出这个差异是高分点。length >>> 0把length规范化成合法的无符号 32 位整数,处理类数组对象({0:'a', length:'2'})和负数/小数 length。这是照着规范写的标志。callback.call(thisArg, ...)支持第二个thisArg参数,很多人会漏。map用new Array(len)预分配而不是push:这样空位位置才能真的保持为空位,且返回数组长度与原数组一致。
17. 手写 Array.prototype.reverse
飞书原题。要求原地反转(in-place),不能用额外数组。
/**
* 原地反转:双指针从两端向中间交换
* 时间 O(n),空间 O(1),返回原数组本身(原生行为也是返回自身引用)
*/
Array.prototype.myReverse = function () {
if (this === null || this === undefined) throw new TypeError('called on null or undefined');
const O = Object(this); // 支持类数组
const len = O.length >>> 0;
// i < j 保证:偶数长度两两交换完停止,奇数长度中间元素不动,长度 0/1 时循环体一次都不进
for (let i = 0, j = len - 1; i < j; i++, j--) {
// 解构交换,无需临时变量(等价于 const t = O[i]; O[i] = O[j]; O[j] = t;)
[O[i], O[j]] = [O[j], O[i]];
}
return O; // 返回自身,支持链式调用
};
/** 变体:不改原数组的版本(面试官可能追问) */
const reversed = (arr) => arr.reduce((acc, cur) => [cur, ...acc], []); // 简洁但 O(n²)
const reversedFast = (arr) => { // O(n),推荐
const res = new Array(arr.length);
for (let i = 0, j = arr.length - 1; i < arr.length; i++, j--) res[j] = arr[i];
return res;
};
// ES2023 起有原生的 arr.toReversed(),不改原数组用例
const a = [1, 2, 3, 4, 5];
console.log(a.myReverse()); // [5, 4, 3, 2, 1] 奇数长度,中间的 3 不动
console.log(a); // [5, 4, 3, 2, 1] 验证是原地修改
console.log([1, 2, 3, 4].myReverse()); // [4, 3, 2, 1] 偶数长度
console.log([].myReverse()); // [] 边界:空数组
console.log([1].myReverse()); // [1] 边界:单元素
console.log([[1], { a: 1 }].myReverse()); // [{a:1}, [1]] 引用类型只换位置,不做拷贝
// 返回自身引用验证
const b = [1, 2];
console.log(b.myReverse() === b); // true
// 类数组
const arrayLike = { 0: 'a', 1: 'b', 2: 'c', length: 3 };
console.log(Array.prototype.myReverse.call(arrayLike)); // { 0:'c', 1:'b', 2:'a', length:3 }【实现要点】
- 循环条件是
i < j而不是i <= j或i < len。写成i < len会把整个数组交换两遍,等于什么都没做 —— 这是本题最经典的翻车点,务必手动走一遍[1,2,3,4]。 - 必须返回原数组本身(
return O),不是返回新数组。原生reverse是 mutating 方法,arr.reverse() === arr为true。 - 只需要遍历一半(
len >> 1次),说出时间复杂度 O(n)、空间 O(1) 是基本要求。 - 解构交换
[a[i], a[j]] = [a[j], a[i]]语法上更漂亮,但会创建临时数组,极端性能场景下临时变量版更快。写哪个都行,被问到时能解释即可。 - 原生
reverse对空位的处理很特殊(会用delete保持空位),手写版会把空位读成undefined从而「填实」。属于极冷门细节,知道即可。 - ES2023 新增
toReversed()/toSorted()/toSpliced()/with()是不可变版本,顺带提一句显得知识新。
18. 数组去重
/** 方法一:Set(首选,O(n),语义最清晰) */
const uniqueBySet = (arr) => [...new Set(arr)];
/** 方法二:filter + indexOf(O(n²),兼容性好,但有 NaN 陷阱) */
const uniqueByFilter = (arr) => arr.filter((item, index) => arr.indexOf(item) === index);
// indexOf 内部用 ===,而 NaN !== NaN,所以 indexOf(NaN) 永远返回 -1,
// 条件恒为 false -> 所有 NaN 都会被「删光」,而不是保留一个!
/** 方法二改良:用 findIndex + Object.is 修正 NaN */
const uniqueByFindIndex = (arr) =>
arr.filter((item, index) => arr.findIndex((x) => Object.is(x, item)) === index);
/** 方法三:reduce + includes(includes 用 SameValueZero,能正确处理 NaN) */
const uniqueByReduce = (arr) =>
arr.reduce((acc, cur) => (acc.includes(cur) ? acc : (acc.push(cur), acc)), []);
/** 方法四:Map 记录出现过的值(O(n),且能顺手统计次数) */
function uniqueByMap(arr) {
const seen = new Map();
const result = [];
for (const item of arr) {
if (!seen.has(item)) { // Map 的键比较同样是 SameValueZero,NaN 可作为键
seen.set(item, true);
result.push(item);
}
}
return result;
}
/** 方法五:对象数组按 key 去重(业务里最实用) */
const uniqueByKey = (arr, key) => {
const map = new Map();
// 后出现的会覆盖先出现的;想保留最先出现的改成 if (!map.has(k)) map.set(k, item)
for (const item of arr) map.set(typeof key === 'function' ? key(item) : item[key], item);
return [...map.values()];
};
/** 方法六:排序后相邻比较(O(n log n),空间 O(1),适合已排序或超大数组) */
function uniqueBySort(arr) {
const sorted = [...arr].sort((a, b) => (a > b ? 1 : a < b ? -1 : 0));
const result = [];
for (let i = 0; i < sorted.length; i++) {
// Object.is 处理 NaN;注意 NaN 排序后位置不确定,此法对 NaN 并不可靠
if (i === 0 || !Object.is(sorted[i], sorted[i - 1])) result.push(sorted[i]);
}
return result;
}用例
const arr = [1, 1, '1', 2, NaN, NaN, 0, -0, null, null, undefined, undefined];
console.log(uniqueBySet(arr));
// [1, '1', 2, NaN, 0, null, undefined] —— NaN 保留一个,0 和 -0 视为同一个
console.log(uniqueByFilter(arr));
// [1, '1', 2, 0, null, undefined] —— 注意:两个 NaN 全都消失了!这是坑
console.log(uniqueByFindIndex(arr));
// [1, '1', 2, NaN, 0, -0, null, undefined]
// NaN 修好了,但 Object.is 用的是 SameValue,认为 +0 !== -0,于是 0 和 -0 各留一个!
// 想要和 Set 完全一致的行为,比较函数得写成 (x) => x === item || (Number.isNaN(x) && Number.isNaN(item))
console.log(uniqueByReduce(arr)); // [1, '1', 2, NaN, 0, null, undefined] 与 Set 完全一致
// 边界:引用类型无法按值去重
console.log(uniqueBySet([{ a: 1 }, { a: 1 }])); // 两个都在,因为是不同引用
console.log(uniqueByKey([{ id: 1, v: 'a' }, { id: 1, v: 'b' }, { id: 2 }], 'id'));
// [{id:1,v:'b'}, {id:2}] —— 后者覆盖前者
console.log(uniqueBySet([])); // [] 边界:空数组【实现要点】
-
NaN是本题的核心陷阱。三套相等语义要分清:===(indexOf用):NaN !== NaN,+0 === -0- SameValueZero(
Set、Map键、includes用):NaN等于NaN,+0等于-0 - SameValue(
Object.is用):NaN等于NaN,但+0不等于-0
所以
filter + indexOf会把所有NaN删光(不是保留一个),说出这一点基本就赢了这道题。 -
复杂度:
Set/Map是 O(n);filter+indexOf、reduce+includes是 O(n²),十万级数据会明显卡顿。答题时主动给出复杂度对比。 -
引用类型去重要指定 key。
[{a:1},{a:1}]用Set去不掉,必须按业务主键(uniqueByKey)或做值序列化(JSON.stringify作为 key,但键顺序不同就会失效,且无法处理循环引用)。 -
Set保持插入顺序,这是它相对于「先排序再去重」的重要优势 —— 后者会打乱原始顺序。 -
保留第一个还是最后一个:
uniqueByKey用map.set覆盖是保留最后一个,业务上往往需要保留第一个,改成if (!map.has(k))即可。这是很实际的追问。
19. 数组乱序 Fisher-Yates
/**
* Fisher-Yates(Knuth)洗牌算法 —— 唯一能保证等概率的 O(n) 洗牌
* 思想:从后往前,把当前位 i 与 [0, i] 中随机选出的一位交换。
* 交换后位置 i 就「定稿」了,下一轮只在剩余的 [0, i-1] 里选。
*/
function shuffle(arr) {
const a = [...arr]; // 不修改原数组(要原地就删掉这行,直接操作 arr)
for (let i = a.length - 1; i > 0; i--) {
// 关键:范围是 [0, i] 闭区间,所以是 (i + 1),包含 i 自己(元素可以留在原地)
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/** 原地版本(不额外占空间) */
function shuffleInPlace(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
/** 需要密码学级随机时(抽奖、发牌),用 crypto 替代 Math.random */
function shuffleSecure(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = crypto.getRandomValues(new Uint32Array(1))[0] % (i + 1); // 严格来说有微小模偏差,高要求场景需拒绝采样
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}验证均匀性(面试可以口述这个实验)
function testUniformity(shuffleFn, times = 120000) {
const counter = {}; // 记录每种排列出现的次数
for (let i = 0; i < times; i++) {
const key = shuffleFn([1, 2, 3]).join('');
counter[key] = (counter[key] || 0) + 1;
}
return counter;
}
// Fisher-Yates:6 种排列各约 20000 次,偏差 < 1%
console.log(testUniformity(shuffle));
// { '123': 19998, '132': 19985, '213': 20176, '231': 20020, '312': 19891, '321': 19930 }
// sort 随机比较:分布严重不均(以下为 Node 24 / V8 实测)
console.log(testUniformity((a) => [...a].sort(() => Math.random() - 0.5)));
// { '123': 44950, '132': 7444, '213': 15119, '231': 7500, '312': 7472, '321': 37515 }
// ↑ 期望值都是 20000,但 '123'(原序)被高估了 2.2 倍,'132' 只有期望的 37%,
// 最高与最低相差 6 倍 —— 这根本不是「随机」
// 边界
console.log(shuffle([])); // []
console.log(shuffle([1])); // [1](循环条件 i > 0,单元素直接跳过)【为什么 sort(() => Math.random() - 0.5) 不均匀】
- 违反比较函数契约。排序算法要求比较函数是确定的(同一对元素每次比较结果相同)和可传递的(a<b 且 b<c 则 a<c)。随机比较两条都不满足,此时排序结果是未定义行为 —— 引擎有权返回任意顺序,甚至在某些实现里可能不终止。
- 比较次数远少于 n!。排序只做 O(n log n) 次比较,每次产生 1 bit 随机信息,总共约
n log nbit;而 n 个元素的排列有n!种,需要log₂(n!) ≈ n log nbit —— 数量级看似相当,但每种排列的到达路径数不相等,无法均分概率。更直白地说:算法的决策树叶子数不是n!的整数倍,必然有排列被偏爱。 - 依赖具体实现。V8 中数组长度 ≤ 10 用插入排序,更长用 TimSort。插入排序对「已基本有序」的输入比较次数少,导致元素越靠前越倾向于留在原位,这正是上面实验里
'123'被高估的原因。换个引擎/换个长度,偏差模式还会变。 - 性能也更差:O(n log n) 且带随机数开销,不如 Fisher-Yates 的 O(n)。
一句话结论:sort 随机比较是「看起来随机」,Fisher-Yates 是「可证明等概率」。业务上做抽奖、发牌、A/B 分流,必须用后者。
【实现要点】
- 随机范围必须是
[0, i]闭区间,即Math.random() * (i + 1)。写成Math.random() * i(不含 i)或Math.random() * n(全长范围)都会破坏均匀性 —— 后者正是错误的「Sattolo 变体/朴素洗牌」,会让某些排列概率偏高。这是本题最容易写错的一行。 - 循环从后往前、到
i > 0为止。i = 0时范围只剩它自己,交换无意义,多跑一轮不出错但没必要。 - 均匀性的直观证明:第一轮最后一位从 n 个里等概率选出(概率 1/n),第二轮从剩余 n-1 个里等概率选(1/(n-1))…… 相乘正好是
1/n!,每种排列概率相等。能说出这个推导就非常扎实。 - 是否原地要问清楚。默认不修改入参(函数式风格)更安全,但大数组场景原地版省内存。
Math.random()不是密码学安全的。涉及真金白银的抽奖必须用crypto.getRandomValues,且要注意取模会引入微小偏差(严格实现需拒绝采样)。
20. 数组合并 + 去重 + 排序(不用任何数组 API)
快手原题。约束:不能用
concat、push、sort、indexOf、includes、Set、filter等任何数组方法,只能用for循环、下标读写和length属性。
/**
* 合并两个数组,去重后升序排序 —— 全程只用 for 循环 + 下标赋值
* @param {number[]} arr1
* @param {number[]} arr2
* @returns {number[]} 新数组
*/
function mergeUniqueSort(arr1, arr2) {
// ---------- 第一步:合并(用下标赋值代替 push/concat)----------
const merged = [];
let total = 0; // 自己维护写入位置,不依赖 push
for (let i = 0; i < arr1.length; i++) merged[total++] = arr1[i];
for (let i = 0; i < arr2.length; i++) merged[total++] = arr2[i];
// ---------- 第二步:去重(双层循环,不用 Set/indexOf)----------
const unique = [];
let uniqueLen = 0;
for (let i = 0; i < total; i++) {
let duplicated = false;
// 只和「已收录的结果」比较,而不是和整个 merged 比,天然保留第一次出现的位置
for (let j = 0; j < uniqueLen; j++) {
if (unique[j] === merged[i]) { duplicated = true; break; } // break 提前退出,减少无谓比较
}
if (!duplicated) unique[uniqueLen++] = merged[i];
}
// ---------- 第三步:手写排序 ----------
quickSort(unique, 0, uniqueLen - 1);
return unique;
}
/** 交换(不用解构也行,这里用临时变量最朴素) */
function swap(arr, i, j) {
const temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
/**
* 快速排序(原地,Lomuto 分区 + 随机基准)
* 随机基准很重要:合并后的数组常常已经部分有序,固定取末位会退化成 O(n²)
*/
function quickSort(arr, low, high) {
if (low >= high) return; // 边界:0 或 1 个元素,天然有序
// 随机选基准并换到末尾
const randomIndex = low + Math.floor(Math.random() * (high - low + 1));
swap(arr, randomIndex, high);
const pivot = arr[high];
// i 指向「小于 pivot 区域」的下一个待填位置
let i = low;
for (let j = low; j < high; j++) {
if (arr[j] < pivot) {
swap(arr, i, j);
i++;
}
}
swap(arr, i, high); // 把基准放到它最终该在的位置,此时 arr[i] 已定稿
quickSort(arr, low, i - 1);
quickSort(arr, i + 1, high);
}
/**
* 备选:冒泡排序(写起来最简单,面试时间紧就用这个,但要主动说明 O(n²))
* 加了 swapped 标志:已有序时可提前结束,最好情况 O(n)
*/
function bubbleSort(arr, len) {
for (let i = 0; i < len - 1; i++) {
let swapped = false;
// 每轮结束后,末尾 i 个元素已就位,内层可以少比 i 次
for (let j = 0; j < len - 1 - i; j++) {
if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1); swapped = true; }
}
if (!swapped) break; // 一轮下来没交换过,说明已经有序
}
return arr;
}用例
console.log(mergeUniqueSort([3, 1, 2, 3], [5, 2, 8, 5])); // [1, 2, 3, 5, 8]
console.log(mergeUniqueSort([], [])); // [] 边界:双空
console.log(mergeUniqueSort([1], [])); // [1] 边界:单空
console.log(mergeUniqueSort([2, 2, 2], [2])); // [2] 边界:全重复
console.log(mergeUniqueSort([5, 4, 3, 2, 1], [0])); // [0,1,2,3,4,5] 边界:完全逆序
console.log(mergeUniqueSort([1, 2, 3], [4, 5, 6])); // [1,2,3,4,5,6] 边界:已有序(随机基准防退化)
console.log(mergeUniqueSort([-1, 0, -1], [0, 10])); // [-1, 0, 10] 负数与 0
// 快排单独验证
const t = [5, 3, 8, 1, 9, 2, 7];
quickSort(t, 0, t.length - 1);
console.log(t); // [1, 2, 3, 5, 7, 8, 9]【实现要点】
- 「不用数组 API」的替代手法:用
arr[n++] = x代替push,用「双层循环 +break」代替indexOf/includes,用手写排序代替sort。length是属性不是方法,通常允许读;开场先跟面试官确认边界(能不能用length、能不能用解构),别自己瞎猜。 - 去重时只和「结果数组」比较,不和原数组比。和原数组比需要额外处理「自己不能和自己比」,逻辑更绕且容易出错;和结果比天然保留首次出现的顺序。
- 快排的基准要随机化。合并去重后的数组经常是部分有序的,固定取首/末元素做基准会退化成 O(n²),甚至在长数组上爆栈。能主动提出随机基准是加分点。
low >= high而不是low === high。分区后可能出现i - 1 < low的情况(基准是最小值时),用>=才能安全兜住。- 快排是不稳定排序。本题元素已去重、且是数字,稳定性无所谓;但如果题目变成「对象数组按某字段排序」,就得说明稳定性问题,改用归并排序。
- 复杂度:合并 O(n+m),去重 O(k²)(k 为去重后长度),排序平均 O(k log k)。整体瓶颈在去重的双层循环 —— 如果允许用对象/Map 做哈希,去重可降到 O(n),可以顺口提一句作为优化方向。
四、网络相关
21. 原生 JS 实现 ajax(XHR 封装成 Promise)
/**
* 基于 XMLHttpRequest 的完整请求封装
* @param {object} options
* @param {string} options.url
* @param {string} [options.method='GET']
* @param {object} [options.params] URL 查询参数
* @param {any} [options.data] 请求体
* @param {object} [options.headers]
* @param {number} [options.timeout=0] 0 表示不超时
* @param {string} [options.responseType='json']
* @param {boolean}[options.withCredentials=false] 跨域是否带 cookie
* @param {(e:ProgressEvent)=>void} [options.onProgress] 下载进度
* @param {AbortSignal} [options.signal] 取消信号
*/
function ajax(options) {
const {
url,
method = 'GET',
params,
data,
headers = {},
timeout = 0,
responseType = 'json',
withCredentials = false,
onProgress,
signal,
} = options;
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new DOMException('Aborted', 'AbortError'));
const xhr = new XMLHttpRequest();
// 1) 拼接查询参数(URLSearchParams 会自动做 encode)
let finalUrl = url;
if (params && Object.keys(params).length > 0) {
const qs = new URLSearchParams(params).toString();
finalUrl += (url.includes('?') ? '&' : '?') + qs;
}
// 2) open 必须在设置 header 之前调用,顺序反了会抛 InvalidStateError
xhr.open(method.toUpperCase(), finalUrl, true); // 第三参 true = 异步
xhr.timeout = timeout;
xhr.withCredentials = withCredentials;
// responseType 为 'json' 时,xhr.response 会被自动解析;解析失败得到 null 而不是抛错
if (responseType !== 'text') xhr.responseType = responseType;
// 3) 请求体序列化:FormData 不能手动设 Content-Type(浏览器要自动加 boundary)
let body = data;
if (data != null && !(data instanceof FormData) && !(data instanceof Blob)
&& !(data instanceof ArrayBuffer) && !(data instanceof URLSearchParams)) {
body = JSON.stringify(data);
if (!Object.keys(headers).some((k) => k.toLowerCase() === 'content-type')) {
headers['Content-Type'] = 'application/json;charset=UTF-8';
}
}
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
// 4) 各类事件
xhr.onload = () => {
// 注意:只要服务端有响应,即使 404/500 也会走 onload,必须自己判断状态码
// 304 也算成功(协商缓存命中)
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
let result = xhr.response;
// responseType='text' 时手动尝试解析 JSON
if (responseType === 'text' && typeof result === 'string') {
try { result = JSON.parse(result); } catch { /* 不是 JSON 就保持原样 */ }
}
resolve({
data: result,
status: xhr.status,
statusText: xhr.statusText,
headers: parseHeaders(xhr.getAllResponseHeaders()),
xhr,
});
} else {
const err = new Error(`Request failed with status code ${xhr.status}`);
err.status = xhr.status;
err.response = xhr.response;
reject(err);
}
};
// 只有网络层面失败(断网、CORS 被拒、DNS 失败)才触发 onerror,且拿不到具体原因
xhr.onerror = () => reject(new Error('Network Error'));
xhr.ontimeout = () => reject(new Error(`Timeout of ${timeout}ms exceeded`));
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'));
if (onProgress) xhr.onprogress = onProgress; // 下载进度
if (onProgress) xhr.upload.onprogress = onProgress; // 上传进度(必须挂在 xhr.upload 上)
// 5) 取消支持
const onAbort = () => xhr.abort();
signal?.addEventListener('abort', onAbort, { once: true });
// 无论成败都摘掉监听,避免 signal 长期持有 xhr 造成泄漏
xhr.onloadend = () => signal?.removeEventListener('abort', onAbort);
xhr.send(body ?? null); // GET 请求 body 必须是 null
});
}
/** 把 "a: 1\r\nb: 2\r\n" 形式的原始响应头解析成对象 */
function parseHeaders(raw) {
const result = {};
if (!raw) return result;
for (const line of raw.trim().split(/[\r\n]+/)) {
const index = line.indexOf(':');
if (index === -1) continue;
result[line.slice(0, index).trim().toLowerCase()] = line.slice(index + 1).trim();
}
return result;
}fetch 对照版
/** 用 fetch 实现同样的能力,代码量少很多,但坑也集中在几个点上 */
async function request(url, { method = 'GET', params, data, headers = {}, timeout = 10000, ...rest } = {}) {
const finalUrl = params ? `${url}${url.includes('?') ? '&' : '?'}${new URLSearchParams(params)}` : url;
const init = { method, headers, ...rest };
if (data != null && method.toUpperCase() !== 'GET') {
if (data instanceof FormData) {
init.body = data; // FormData 不要手动设 Content-Type
} else {
init.body = JSON.stringify(data);
init.headers = { 'Content-Type': 'application/json;charset=UTF-8', ...headers };
}
}
// fetch 原生没有超时,靠 AbortSignal 实现(Chrome 103+ 可直接用 AbortSignal.timeout(ms))
const controller = new AbortController();
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null;
init.signal = rest.signal ?? controller.signal;
try {
const res = await fetch(finalUrl, init);
// 坑:fetch 只在网络失败时 reject,404/500 依然是 resolve,必须手动判断 res.ok
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
err.response = res;
throw err;
}
const contentType = res.headers.get('content-type') ?? '';
return contentType.includes('application/json') ? res.json() : res.text();
} finally {
if (timer) clearTimeout(timer);
}
}用例
ajax({ url: '/api/user', params: { id: 1 }, timeout: 5000 })
.then((res) => console.log(res.status, res.data))
.catch((e) => console.error(e.status ?? 'network', e.message));
ajax({ url: '/api/user', method: 'POST', data: { name: '张三' } }).then(console.log);
// 上传 + 进度 + 取消
const ac = new AbortController();
const fd = new FormData();
fd.append('file', fileInput.files[0]);
ajax({
url: '/upload',
method: 'POST',
data: fd,
signal: ac.signal,
onProgress: (e) => e.lengthComputable && console.log(`${((e.loaded / e.total) * 100).toFixed(1)}%`),
}).catch((e) => console.log(e.name)); // 点击取消后输出 AbortError
// ac.abort();【XHR vs fetch 对比(必问)】
| XMLHttpRequest | fetch | |
|---|---|---|
| API 风格 | 事件回调 | Promise |
| HTTP 错误 | 走 onload,需判断 status | 走 resolve,需判断 res.ok(两者都不会自动 reject) |
| 超时 | 原生 xhr.timeout | 无原生超时,靠 AbortController / AbortSignal.timeout |
| 取消 | xhr.abort() | AbortController |
| 上传进度 | xhr.upload.onprogress ✅ | ❌ 不支持(只有下载可用 ReadableStream 读) |
| 携带 cookie | withCredentials | credentials: 'include'(默认 same-origin) |
| 流式响应 | 只能靠 onprogress 拼接 | res.body 是 ReadableStream,天然支持流 |
| 拦截/Service Worker | 不易被拦截 | 可被 Service Worker 拦截,配合缓存策略 |
【实现要点】
open必须在setRequestHeader之前,send在最后。顺序写错会抛InvalidStateError,是现场手写最容易踩的坑。- HTTP 错误不是网络错误。XHR 的 404/500 会触发
onload而非onerror;fetch 的 404/500 会 resolve 而非 reject。两者都需要手动判断状态码,这是面试官最爱确认的点。别忘了304也应算成功。 FormData绝不能手动设Content-Type。浏览器需要自动生成带boundary的multipart/form-data,手动覆盖会导致服务端解析失败 —— 这是真实业务里的高频事故。- 上传进度只能用 XHR。这是
fetch至今没补上的短板,也是很多项目仍在用axios(底层 XHR)的原因。 - 超时与取消要清理干净:
clearTimeout放finally,removeEventListener放onloadend,否则 signal 会长期持有 xhr 引用。 - fetch 版的超时和用户取消都抛
AbortError,无法区分。要在 UI 上区分「超时了」和「用户点了取消」,需要自己打标记(超时回调里先timedOut = true再 abort,catch 里据此改写错误),或用controller.abort(new DOMException('Timeout', 'TimeoutError'))传入自定义 reason(signal.reason可取回)。这是很实际的追问。 readyState的四步(1 opened / 2 headers received / 3 loading / 4 done):老写法用onreadystatechange+readyState === 4,现代写法直接用onload。能说清两者关系即可。
22. 实现 jsonp
原理:<script> 标签的 src 不受同源策略限制。前端预先在全局挂一个回调函数并把函数名通过查询参数告诉服务端,服务端返回 callbackName({...}) 这样一段 JS,浏览器加载后立即执行,数据就进到回调里了。
/**
* JSONP 请求
* @param {string} url
* @param {object} [options]
* @param {object} [options.params] 附加查询参数
* @param {string} [options.callbackKey='callback'] 回调函数名的参数名(后端约定,有的是 cb/jsonpCallback)
* @param {number} [options.timeout=8000]
* @returns {Promise<any>}
*/
function jsonp(url, { params = {}, callbackKey = 'callback', timeout = 8000 } = {}) {
return new Promise((resolve, reject) => {
// 1) 生成全局唯一的回调名,避免并发请求互相覆盖
const callbackName = `__jsonp_cb_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const script = document.createElement('script');
let timer = null;
let settled = false; // 防止 resolve/reject 被重复触发
// 2) 统一清理:删全局函数、移除 script、清定时器。缺一不可,否则内存泄漏 + 全局污染
const cleanup = () => {
delete window[callbackName];
script.onerror = null;
script.remove(); // ChildNode.remove(),等价于 parentNode.removeChild
if (timer) clearTimeout(timer);
};
// 3) 挂全局回调,供服务端返回的脚本调用
window[callbackName] = (data) => {
if (settled) return;
settled = true;
cleanup();
resolve(data);
};
// 4) 拼 URL。URLSearchParams 会自动 encode,避免参数里的 & 和中文出问题
const query = new URLSearchParams({ ...params, [callbackKey]: callbackName }).toString();
script.src = url + (url.includes('?') ? '&' : '?') + query;
script.async = true;
// 5) 网络错误 / 404 / 被拦截。注意:拿不到具体错误信息,这是 JSONP 的固有缺陷
script.onerror = () => {
if (settled) return;
settled = true;
cleanup();
reject(new Error(`JSONP 请求失败: ${url}`));
};
// 6) 超时兜底。服务端返回 200 但脚本里没调用回调时,只能靠超时兜住,否则 Promise 永远 pending
if (timeout > 0) {
timer = setTimeout(() => {
if (settled) return;
settled = true;
cleanup();
reject(new Error(`JSONP 超时(${timeout}ms): ${url}`));
}, timeout);
}
document.head.appendChild(script); // 插入即发起请求
});
}服务端应返回什么(Node 示例,帮助理解闭环)
// GET /api/user?id=1&callback=__jsonp_cb_xxx
// 服务端返回的不是 JSON,而是一段可执行的 JS:
app.get('/api/user', (req, res) => {
const cb = req.query.callback;
// 安全:必须校验回调名,只允许合法标识符,否则等于把 XSS 注入点交给攻击者
if (!/^[\w$.]{1,64}$/.test(cb)) return res.status(400).end();
const data = { id: req.query.id, name: '张三' };
res.type('application/javascript');
res.send(`${cb}(${JSON.stringify(data)});`); // 输出:__jsonp_cb_xxx({"id":"1","name":"张三"});
});用例
jsonp('https://api.example.com/user', { params: { id: 1 } })
.then((data) => console.log('成功:', data))
.catch((err) => console.error(err.message));
// 后端约定回调参数名叫 cb 的情况
jsonp('https://api.example.com/list', { callbackKey: 'cb', timeout: 3000 }).then(console.log);
// 并发多个请求互不干扰(回调名各自唯一)
Promise.all([jsonp('/a'), jsonp('/b')]).then(console.log);【实现要点】
- 只支持 GET。
<script src>天生只能发 GET,无法 POST、无法自定义请求头、参数长度受 URL 限制(约 2KB)。这是与 CORS 最本质的差别,必须第一句就说清楚。 - 清理是本题的采分点:删除
window[callbackName](否则全局越积越多)、移除script节点(否则 DOM 膨胀)、清timer。三者缺一都算不完整。 - 超时兜底不可省。
script.onerror只在网络层面失败时触发;如果服务端返回 200 但内容里没调用回调(比如返回了纯 JSON),既不会 error 也不会 resolve,Promise 永远 pending。加超时是唯一的兜底方案。 - 回调名必须唯一。用固定名字(如
window.jsonpCallback)会导致并发请求互相覆盖,只有最后一个能拿到数据 —— 这是很常见的半成品实现。 - 安全风险很大:服务端返回的是可执行代码,等于把页面控制权交给了第三方域。若对方被劫持或返回恶意脚本,直接就是 XSS,且能读取当前页面的 cookie/localStorage。只对完全可信的域使用;服务端也必须校验 callback 参数名(如上例的正则),否则攻击者可构造
callback=<script>之类的注入。 - JSONP 已是历史方案。现代跨域首选 CORS(服务端设
Access-Control-Allow-Origin),其次是 Nginx / Node 反向代理,开发环境用 devServer.proxy,还有 postMessage(跨 iframe/窗口)、WebSocket(不受同源限制)。面试时按「JSONP 原理 → 缺陷 → 现代替代方案」的顺序讲,比只会背实现强得多。
附:手撕题临场策略
- 先问清边界再动手:入参类型、是否允许用某些 API、错误语义(快速失败还是全部执行完)、是否要
cancel。问一句能避免整题跑偏。 - 先写主干,再补边界。边写边说「这里我先不处理 XX,一会儿补」,比沉默硬写更能拿分。
- 写完必须自测。当场口述走一遍:空数组、单元素、并发数大于任务数、循环引用、深度为 0、
NaN。面试官打断你问边界,说明你没先讲。 - 主动说复杂度和权衡。「这版是 O(n²),如果允许用 Map 可以降到 O(n)」这类表达,比多写一个实现更值钱。
- 优先级排序(时间不够时的取舍):并发控制 > 防抖节流 > 深拷贝 >
bind>Promise.all> 手写数组方法 > JSONP。
反向链接(0)
还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。