JavaScript 热门面试题
使用说明
本篇共 75 道题。
JavaScript 面试通常沿着“类型与作用域—对象模型—异步—运行时—工程实践”追问。回答时先说明语言规则,再结合浏览器或 Node.js 场景,不要只背输出结果。
Q1: JavaScript 有哪些数据类型?
答案:
原始类型包括 undefined、null、boolean、number、bigint、string、symbol;其余都是 object,包括数组、函数和日期。
原始值按值使用且不可变,对象变量保存引用。typeof null 返回 "object" 是历史兼容问题,判断数组用 Array.isArray()。
Q2: == 和 === 有什么区别?
答案:
=== 不做隐式类型转换,类型和值都一致才相等;== 会按抽象相等规则转换,容易出现 0 == false 等反直觉结果。
业务代码通常使用严格相等。少数场景可用 value == null 同时判断 null 和 undefined,但要有明确团队约定。
Q3: null 和 undefined 应该如何区分?
答案:
null 和 undefined 都表示"没有值",但语义不同:
undefined:表示变量已声明但未赋值,或者缺少预期的值null:表示有意设置为空值,是一种"空的对象引用"
| 特性 | null | undefined |
|---|---|---|
| 含义 | 主动赋值为空 | 未定义/未赋值 |
typeof | 'object'(历史 bug) | 'undefined' |
Number() 转换 | 0 | NaN |
== 比较 | null == undefined → true | undefined == null → true |
=== 比较 | null === undefined → false | undefined === null → false |
| JSON 序列化 | 保留为 null | 属性被忽略 |
| 函数默认参数 | 不触发默认值 | 触发默认值 |
// 1. typeof 差异
console.log(typeof null); // 'object'(历史 bug)
console.log(typeof undefined); // 'undefined'
// 2. Number 转换差异
console.log(Number(null)); // 0
console.log(Number(undefined)); // NaN
// 3. JSON 序列化差异
const obj = { a: null, b: undefined, c: 1 };
console.log(JSON.stringify(obj)); // '{"a":null,"c":1}' ← b 被忽略
// 4. 函数默认参数差异
function greet(name: string = 'World'): string {
return `Hello, ${name}`;
}
console.log(greet(undefined)); // 'Hello, World' ← 触发默认值
console.log(greet(null!)); // 'Hello, null' ← 不触发默认值
// 5. 使用场景
// undefined:变量未赋值、函数无返回值、参数未传
let x: number | undefined; // 声明未赋值
function foo(): void {} // 返回 undefined
function bar(a?: string): void {} // 可选参数为 undefined
// null:主动设置为空
const user: { name: string | null } = { name: null }; // 用户名为空
const cache: Map<string, string | null> = new Map();
cache.set('key', null); // 显式缓存空值,区分"无缓存"和"缓存为空"
判断空值时,== null 可以同时检查 null 和 undefined,这是 == 少数推荐的用法:
if (value == null) {
// value 是 null 或 undefined
}
// 等价于
if (value === null || value === undefined) {
// ...
}
Q4: var、let 和 const 有什么区别?
答案:
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 值为 undefined | TDZ | TDZ |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 重新赋值 | 允许 | 允许 | 不允许 |
| 全局挂载 | window | 不挂载 | 不挂载 |
最佳实践:默认使用 const,需要重新赋值时用 let,避免使用 var。
Q5: 什么是作用域链和词法作用域?
答案:
词法作用域也叫静态作用域,指作用域在代码书写时确定,而不是在运行时确定。
const x = 10;
function foo(): void {
console.log(x);
}
function bar(): void {
const x = 20;
foo(); // 输出 10,使用定义时的作用域
}
Q6: 什么是闭包?
答案:
闭包是指一个函数能够记住并访问其词法作用域,即使该函数在其词法作用域之外执行。
function outer(): () => void {
const message = 'Hello';
return function inner(): void {
console.log(message); // inner 可以访问 outer 的变量
};
}
const fn = outer();
fn(); // 'Hello' - 即使 outer 已执行完毕
形成条件:
- 函数嵌套
- 内部函数引用外部函数的变量
- 内部函数被返回或传递到外部
Q7: JavaScript 中的 this 如何确定?
答案:
普通函数的 this 主要由调用方式决定:作为对象方法、构造调用、call/apply/bind 或普通调用。箭头函数没有自己的 this,从外层词法环境捕获。
面试时先看调用点,不只看函数定义位置。把对象方法直接传作回调可能丢失接收者,需要绑定或包装。
Q8: 箭头函数和普通函数有哪些关键区别?
答案:
| 特性 | 箭头函数 | 普通函数 |
|---|---|---|
| this | 继承外层 | 动态绑定 |
| arguments | 无 | 有 |
| new 调用 | 不能 | 可以 |
| prototype | 无 | 有 |
| yield | 不能 | 可以(Generator) |
Q9: 原型链是什么?
答案:
每个普通对象都有一个内部 [[Prototype]] 引用。读取对象属性时,如果对象自身没有该属性,引擎会沿 [[Prototype]] 逐级查找,直到找到属性或到达 null,这条查找路径就是原型链。
构造函数的 prototype 属性通常会成为实例的原型:
function User(this: { name: string }, name: string) {
this.name = name;
}
User.prototype.sayHi = function () {
return `Hi, ${this.name}`;
};
const user = new (User as any)('Alice');
Object.getPrototypeOf(user) === User.prototype; // true
class 只是更清晰的语法,方法仍放在原型上。实践中使用 Object.getPrototypeOf() 查看原型,不建议运行时频繁调用 Object.setPrototypeOf() 或修改 __proto__,因为这会破坏引擎优化并让对象关系难以维护。
Q10: new 操作符做了什么?
答案:
function myNew<T>(
Constructor: new (...args: any[]) => T,
...args: any[]
): T {
// 1. 创建空对象,原型指向构造函数的 prototype
const obj = Object.create(Constructor.prototype);
// 2. 执行构造函数,this 指向新对象
const result = Constructor.apply(obj, args);
// 3. 如果构造函数返回对象,则返回该对象;否则返回新对象
return result instanceof Object ? result : obj;
}
// 使用
function Person(this: any, name: string) {
this.name = name;
}
const p = myNew(Person as any, 'Alice');
console.log(p.name); // 'Alice'
Q11: JavaScript 的 Event Loop 是怎么工作的?
答案:
Event Loop(事件循环) 是 JavaScript 实现异步编程的核心机制。
为什么需要:
- JavaScript 是单线程语言,同一时间只能执行一个任务
- 如果没有 Event Loop,耗时操作(网络请求、定时器)会阻塞主线程
- Event Loop 使得 JS 能够非阻塞地执行异步操作
工作原理:
- 执行同步代码(调用栈)
- 调用栈为空时,检查微任务队列,执行所有微任务
- 执行一个宏任务
- 重复步骤 2-3
Q12: Promise 的状态和链式调用规则是什么?
答案:
Promise 从 pending 变为 fulfilled 或 rejected 后不可逆。then 会返回新的 Promise,回调返回普通值就完成,返回 Promise/thenable 则采用它的最终状态,抛错则拒绝。
链式调用的价值是统一值、异步和异常传播。末端要处理拒绝,避免未处理异常。
Q13: async/await 的本质是什么?
答案:
async/await 是建立在 Promise 语义之上的异步控制流语法:
async函数调用后总会返回一个 Promise;返回普通值会被包装为 fulfilled,抛错会变成 rejected。await value会先按Promise.resolve(value)的语义接纳值,并暂停当前 async 函数后续逻辑,而不是阻塞 JavaScript 线程。- Promise 完成后,函数的继续执行会作为微任务被调度。
try/catch/finally可以用同步代码的结构处理异步拒绝和清理。
可以用 Generator 加自动执行器解释它的控制流,但 ECMAScript 语义并不是简单把源码机械改写成 Generator。面试中更重要的是说明 Promise、微任务、错误传播和串并行关系。
Q14: Promise.all、allSettled、race 和 any 怎么选?
答案:
| 方法 | 成功条件 | 失败条件 | 返回值 |
|---|---|---|---|
all | 全部成功 | 任一失败 | 结果数组 |
allSettled | 无 | 无 | 状态对象数组 |
// all - 任一失败立即失败
Promise.all([p1, p2, p3])
.then(([r1, r2, r3]) => { })
.catch((error) => { });
// allSettled - 等待全部完成
Promise.allSettled([p1, p2, p3])
.then((results) => {
results.forEach((result) => {
if (result.status === 'fulfilled') {
console.log('成功:', result.value);
} else {
console.log('失败:', result.reason);
}
});
});
Q15: CommonJS 和 ES Modules 有什么区别?
答案:
ESM 使用静态 import/export,依赖可在编译前分析,导出是 live binding,浏览器和现代工具原生支持;CommonJS 以运行时 require/module.exports 为主,Node.js 历史生态广。
二者互操作受运行时和构建器规则影响。库发布要明确模块格式、条件导出和类型声明,不能只改文件扩展名。
Q16: 深拷贝有哪些常见陷阱?
答案:
深拷贝不是简单递归复制所有属性,要先定义支持的数据类型、原型、循环引用、属性描述符和函数语义。
structuredClone 支持循环引用和多种内建类型,但不能克隆函数和部分平台对象;JSON 方案会丢失 undefined、symbol、日期语义等,只适合明确的 JSON 数据。
Q17: Map 和普通对象怎么选?
答案:
对象适合固定字段的数据记录;Map 适合动态键值集合,键可以是任意类型,提供稳定的 size、迭代和增删 API。
对象还涉及原型键和序列化生态。选择应看数据语义,不是简单说 Map 一定更快。
- 键只来自固定字符串、需要 JSON 序列化时,普通对象更自然;键类型动态、频繁增删或需要可靠迭代顺序时优先
Map。 Map的size、has、clear语义更直接,也不会混入原型链属性;但它不能直接JSON.stringify,需要显式转换。
Q18: WeakMap 和 WeakSet 的使用场景是什么?
答案:
WeakMap 和 WeakSet 的键(或值)是弱引用,不会阻止垃圾回收,因此特别适合以下场景:
1. DOM 元素关联数据
// 为 DOM 元素附加额外数据,元素移除后数据自动回收
const elementData = new WeakMap<HTMLElement, { clickCount: number; lastClick: number }>();
function trackElement(el: HTMLElement): void {
elementData.set(el, { clickCount: 0, lastClick: 0 });
el.addEventListener('click', () => {
const data = elementData.get(el);
if (data) {
data.clickCount++;
data.lastClick = Date.now();
}
});
}
// 当 el 被从 DOM 移除且没有其他引用时,
// WeakMap 中的条目会自动被 GC 清理,不会内存泄漏
2. 私有数据存储
// 使用 WeakMap 模拟私有属性
const privateProps = new WeakMap<object, { _password: string }>();
class User {
name: string;
constructor(name: string, password: string) {
this.name = name;
privateProps.set(this, { _password: password });
}
validatePassword(input: string): boolean {
return privateProps.get(this)?._password === input;
}
}
const user = new User('Alice', 'secret123');
console.log(user.name); // 'Alice'
console.log(user.validatePassword('secret123')); // true
// 无法从外部直接访问 _password
3. 缓存计算结果(防止内存泄漏)
// 对象参数的计算结果缓存
const computeCache = new WeakMap<object, number>();
function expensiveCompute(obj: { values: number[] }): number {
if (computeCache.has(obj)) {
return computeCache.get(obj)!;
}
const result = obj.values.reduce((sum, v) => sum + v * v, 0);
computeCache.set(obj, result);
return result;
}
let data = { values: [1, 2, 3, 4, 5] };
expensiveCompute(data); // 计算并缓存
expensiveCompute(data); // 直接返回缓存
data = null as any; // data 不再使用后,缓存自动被 GC 清理
4. WeakSet 标记已处理对象
// 防止重复处理
const processed = new WeakSet<object>();
function processOnce(obj: object): void {
if (processed.has(obj)) {
console.log('已处理过,跳过');
return;
}
// 处理逻辑...
console.log('正在处理...');
processed.add(obj);
}
// 防止循环引用导致无限递归(如深拷贝)
function deepClone<T>(obj: T, seen = new WeakSet<object>()): T {
if (obj === null || typeof obj !== 'object') return obj;
if (seen.has(obj as object)) return obj; // 检测循环引用
seen.add(obj as object);
const clone = (Array.isArray(obj) ? [] : {}) as T;
for (const key of Object.keys(obj as object)) {
(clone as any)[key] = deepClone((obj as any)[key], seen);
}
return clone;
}
WeakMap 和 WeakSet 不支持遍历(没有 keys()、values()、entries()、forEach、size),原因是:
- 弱引用的对象随时可能被 GC 回收,遍历结果不确定
- 如果支持遍历,引擎需要维护完整的键列表,这与"弱引用"的语义矛盾
- 设计上刻意限制,确保不会意外阻止垃圾回收
Q19: Proxy 和 Reflect 的关系是什么?
答案:
Proxy 拦截对象的基本操作,例如读取、赋值和删除;Reflect 提供与这些内部操作对应的函数,并返回更统一的结果。
在 trap 中调用 Reflect 可保留默认语义,减少手工实现错误。Proxy 适合响应式和校验,但有不变量限制、调试和性能成本。
Q20: 什么是迭代器和可迭代对象?
答案:
可迭代对象实现 Symbol.iterator,返回带 next() 的迭代器;每次 next() 返回 { value, done }。for...of、展开和部分集合构造都会消费该协议。
生成器是创建迭代器的便捷语法,适合惰性序列和可暂停流程。
Q21: V8 垃圾回收大致如何工作?
答案:
核心依据可达性回收不再从根对象访问到的对象。V8 通常按对象生命周期分代,新生代频繁、小规模回收,老生代使用标记清除、压缩和增量/并发策略降低停顿。
开发者不能手动释放普通对象,重点是消除不必要的强引用,并通过内存快照验证泄漏。
- V8 通常按“多数对象朝生夕死”的假设做分代回收:新生代使用复制算法快速清理,存活较久的对象晋升老生代。
- 老生代以标记清除、标记整理为主,并通过增量、并发回收降低停顿。业务侧应关注持续增长的引用,而不是尝试手动触发 GC。
Q22: 前端常见内存泄漏有哪些?
答案:
常见来源是未移除的事件监听和定时器、闭包持有大对象、脱离 DOM 的节点仍被引用、无限缓存,以及未取消的订阅或异步任务。
排查用堆快照、Allocation Timeline 和 Retainer Path 找“谁还在引用”,不能只看内存一时上涨。
Q23: 数组常用遍历方法应该怎么选?
答案:
根据返回语义选择:map 做一一转换,filter 筛选,reduce 聚合,some/every 做短路判断,find 找首项,forEach 只执行副作用。
不要为了炫技把复杂流程塞进一个 reduce;可读性和是否需要提前退出比“函数式”标签更重要。
Q24: JavaScript 如何处理不可变更新?
答案:
不可变更新不是禁止所有修改,而是不直接改变仍被其他代码共享的旧值,创建包含变化的新结构。
浅拷贝只复制一层,嵌套对象要沿修改路径复制。框架状态管理可借助结构共享或 Immer,但要避免无意义的整棵深拷贝。
- 对象或数组更新时只复制发生变化的路径,未变化分支继续复用引用,这样既保持不可变语义,也避免整棵数据深拷贝。
- React 等框架依赖引用变化做快速判断;可以使用展开语法、
map/filter或 Immer,但要避免先修改原对象再复制。
Q25: JavaScript 错误处理有哪些工程原则?
答案:
在能补充上下文或恢复的边界捕获错误,区分可预期业务错误和程序错误,不能空 catch 吞掉异常。
- 异步链要统一处理拒绝并记录因果链。
- 对外展示用户可行动的信息,日志保留脱敏上下文和错误标识。
- 清理资源使用
finally,错误上报本身也要防止再次影响主流程。
Q26: V8 为什么要分代回收?
答案:
基于弱分代假说(Weak Generational Hypothesis):
- 大部分对象生命周期很短(临时变量、函数参数等)
- 少部分对象生命周期很长(全局对象、缓存等)
分代回收的优势:
- 新生代:使用快速的 Scavenge 算法,频繁回收短周期对象
- 老生代:使用完整的标记算法,较少回收长周期对象
- 性能优化:针对不同特征的对象使用不同策略,整体效率更高
对比表格:
| 策略 | 新生代 | 老生代 |
|---|---|---|
| 对象特征 | 生命周期短(90% 以上) | 生命周期长 |
| 回收频率 | 高(毫秒级) | 低(秒级) |
| 算法 | Scavenge(复制) | Mark-Sweep/Compact |
| 单次耗时 | 极短(<10ms) | 较长(100ms+) |
Q27: 什么是异步迭代器?for await...of 适合什么场景?
答案:
异步可迭代对象实现 Symbol.asyncIterator,它的 next() 返回 Promise,适合“数据分批到达”的序列,例如网络流、分页结果和消息队列。
async function* pages() {
for (let page = 1; page <= 3; page += 1) {
const response = await fetch(`/api/items?page=${page}`);
yield response.json();
}
}
for await (const items of pages()) {
console.log(items);
}
它表达的是按序消费和背压,不是并行执行。若各任务彼此独立且希望并发,应先创建有限数量的任务并做并发控制,而不是直接在 for await...of 中逐个等待。
Q28: prototype 和 __proto__ 的区别?
答案:
| 属性 | 所属 | 作用 |
|---|---|---|
prototype | 函数 | 指向原型对象,实例会继承这个对象 |
__proto__ | 对象 | 指向创建该对象的构造函数的 prototype |
function Foo() {}
const foo = new Foo();
// prototype 是函数的属性
console.log(Foo.prototype); // { constructor: Foo }
// __proto__ 是实例的属性,指向 prototype
console.log(foo.__proto__ === Foo.prototype); // true
// 函数也是对象,也有 __proto__
console.log(Foo.__proto__ === Function.prototype); // true
Q29: 属性描述符中的 writable、enumerable、configurable 有什么作用?
答案:
数据属性除了值,还有三个关键开关:
writable:能否通过赋值修改value。enumerable:是否出现在Object.keys()、对象展开等常见枚举流程中。configurable:能否删除属性、修改大部分描述符,或在数据属性和访问器属性之间切换。
const user = {};
Object.defineProperty(user, 'id', {
value: 1,
writable: false,
enumerable: true,
configurable: false,
});
通过 defineProperty 新建属性时,这些布尔项默认是 false;普通对象字面量创建的属性通常都是 true。访问器属性使用 get/set,不能同时声明 value/writable。
Q30: Promise.all() 会启动任务吗?怎样限制并发?
答案:
Promise.all() 只负责等待传入的 Promise,并不会替你“启动”任务。下面这段代码在数组创建时就已经调用了全部请求函数:
const tasks = urls.map((url) => fetch(url));
const responses = await Promise.all(tasks);
因此大量任务直接放进 Promise.all() 可能瞬间耗尽连接、内存或下游配额。限制并发的常见做法是维护固定数量的 worker,从共享任务队列取任务,或使用成熟的并发限制器。
并发数不能写死成行业标准,应根据浏览器连接、接口限流、任务耗时和设备能力压测决定;同时处理取消、超时、重试和部分失败。
Q31: BigInt 和 Number 有什么区别?
答案:
Number 使用 IEEE 754 双精度浮点数,只能精确表示 -(2^53-1) 到 2^53-1 范围内的整数;BigInt 可以表示任意精度整数,适合大整数 ID、精确计数和部分密码学运算。
注意:
- BigInt 字面量写作
123n,不能与 Number 直接做算术运算。 - BigInt 没有小数,除法结果会向零截断。
JSON.stringify()默认不能序列化 BigInt,需要定义字符串协议。- 金额通常应使用“最小货币单位整数”或专用十进制定点库,BigInt 本身不解决小数与舍入规则。
Q32: typeof null 为什么是 'object'?
答案:
这是 JavaScript 早期实现遗留并被标准保留的历史行为,不代表 null 真的是对象。为了兼容已经存在的网页,语言无法再把结果改成 'null'。
判断时应直接使用 value === null。若要判断“非 null 对象”,通常写 typeof value === 'object' && value !== null;数组、日期等还要继续用各自的精确判断方式。
可以用早期值标签实现解释这个现象的来源,但不应把某个具体 32 位布局背成今天所有引擎的运行时实现。
Q33: Object.freeze() 能实现深度不可变吗?
答案:
不能。Object.freeze() 只冻结当前对象自身的属性:不能新增、删除或重新赋值,但嵌套对象如果没有单独冻结,仍然可以修改。
const config = Object.freeze({
theme: { color: 'blue' },
});
config.theme.color = 'red'; // 仍可修改嵌套对象
需要深冻结时可以递归处理并记录循环引用,但它会增加运行时成本,也不等于持久化数据结构。业务状态更常用约定、TypeScript 的 readonly、结构共享或 Immer;其中 TypeScript 只做编译期检查,不能阻止运行时修改。
Q34: 浅拷贝和深拷贝的区别?
答案:
| 特性 | 浅拷贝 | 深拷贝 |
|---|---|---|
| 复制层级 | 仅第一层 | 所有层级 |
| 嵌套对象 | 共享引用 | 独立副本 |
| 性能 | 快 | 慢 |
| 内存 | 少 | 多 |
Q35: Top-level await 有什么作用和风险?
答案:
ES Module 可以在模块顶层直接使用 await。依赖该模块的执行会等待它完成,因此适合模块初始化确实依赖异步资源的少量场景。
风险是把异步等待传播到整个依赖链,形成难察觉的启动瀑布;循环依赖还可能产生执行阻塞或难调试的顺序问题。公共库尤其不应在入口随意做网络请求。
实践中优先导出显式初始化函数或 Promise,让调用方决定时机、超时和错误处理;若使用 Top-level await,要评估目标运行时与构建器支持,并保持模块初始化可失败、可观测。
Q36: map 和 forEach 的区别?
答案:
| 特性 | map | forEach |
|---|---|---|
| 返回值 | 新数组 | undefined |
| 链式调用 | ✅ | ❌ |
| 用途 | 转换数据 | 遍历执行 |
// map: 转换并返回新数组
const doubled = [1, 2, 3].map(n => n * 2);
// forEach: 仅遍历,无返回值
[1, 2, 3].forEach(n => console.log(n));
Q37: Set 如何判断两个值相等?
答案:
Set 使用 SameValueZero 算法判断相等,类似于 ===,但认为 NaN === NaN。
const set = new Set();
set.add(NaN);
set.add(NaN);
console.log(set.size); // 1(NaN 被认为相等)
set.add(0);
set.add(-0);
console.log(set.size); // 2(0 和 -0 被认为相等)
Q38: Proxy 有哪些拦截操作?
答案:
Proxy 支持 13 种拦截操作:
| 拦截器 | 触发操作 |
|---|---|
| get | 读取属性 |
| set | 设置属性 |
| has | in 操作符 |
| deleteProperty | delete 操作符 |
| ownKeys | Object.keys 等 |
| apply | 函数调用 |
| construct | new 操作符 |
| getPrototypeOf | Object.getPrototypeOf |
| setPrototypeOf | Object.setPrototypeOf |
| isExtensible | Object.isExtensible |
| preventExtensions | Object.preventExtensions |
| defineProperty | Object.defineProperty |
| getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor |
Q39: Symbol 的特点是什么?
答案:
- 唯一性:每个 Symbol 都是独一无二的
- 不可枚举:不会出现在 for...in 和 Object.keys 中
- 原始类型:typeof 返回 'symbol'
- 不能 new:不能用 new 调用
- 可作为键:可以作为对象属性键
Q40: 贪婪匹配和非贪婪匹配的区别?
答案:
| 类型 | 语法 | 行为 |
|---|---|---|
| 贪婪 | *, +, ? | 尽可能多匹配 |
| 非贪婪 | *?, +?, ?? | 尽可能少匹配 |
const str = '<div>hello</div>';
// 贪婪:匹配最长
str.match(/<.*>/); // ['<div>hello</div>']
// 非贪婪:匹配最短
str.match(/<.*?>/); // ['<div>']
Q41: 不使用临时变量交换两个数?
答案:
// 方法 1: 异或
let a = 5, b = 3;
a = a ^ b;
b = a ^ b;
a = a ^ b;
console.log(a, b); // 3, 5
// 方法 2: 加减法
a = a + b;
b = a - b;
a = a - b;
// 方法 3: 解构(推荐)
[a, b] = [b, a];
Q42: 什么是 Stop-The-World?V8 如何优化?
答案:
Stop-The-World (STW) 是指垃圾回收时暂停所有 JavaScript 执行,会导致:
- 页面卡顿
- 动画掉帧
- 接口响应延迟
V8 的优化策略:
-
增量标记(Incremental Marking)
- 将标记工作拆分成多个小步骤
- 与 JavaScript 执行交替进行
- 减少单次停顿时间
-
并发标记(Concurrent Marking)
- 在后台线程执行标记
- 主线程继续运行 JavaScript
- 通过写屏障跟踪变化
-
并发清除(Concurrent Sweeping)
- 在后台线程清除未标记对象
- 不阻塞主线程
-
惰性清除(Lazy Sweeping)
- 按需清除内存页
- 分散清除工作
对比表格:
| 技术 | 停顿时间 | 实现复杂度 | V8 版本 |
|---|---|---|---|
| 传统 GC | 100-200ms | 低 | - |
| 增量标记 | 每次 5-10ms | 中 | V8 v4.0+ |
| 并发标记 | <5ms | 高 | V8 v6.6+ |
| 并发清除 | <1ms | 高 | V8 v7.0+ |
Q43: 宏任务和微任务有什么区别?
答案:
宏任务通常指一次事件循环中被挑选执行的任务,例如定时器、用户事件或消息;微任务包括 Promise reaction、queueMicrotask 和 MutationObserver 回调。
一个任务执行完成、调用栈清空后,事件循环会清空当前微任务队列,然后浏览器才有机会更新渲染并选择下一个任务。渲染并不是“每个微任务后必定发生”,也不是固定夹在任意两个宏任务之间。
微任务中继续无限创建微任务会产生饥饿,阻止渲染和后续任务。长工作应主动分片并让出主线程,而不是把所有逻辑都改成 Promise。
Q44: Object.create(null) 创建的对象有什么特点?
答案:
它创建一个原型为 null 的对象,因此没有继承 Object.prototype 上的 toString、hasOwnProperty 等成员,也不会和 __proto__、constructor 这类继承属性发生名字冲突。
这种对象可用作纯字符串字典,但要注意:
- 不能调用
dict.hasOwnProperty(key),应使用Object.hasOwn(dict, key)。 - 某些依赖普通对象原型或构造器的库可能不兼容。
- 如果需要任意键类型、稳定迭代顺序、
size等能力,通常直接使用Map更清晰。 - 它不是安全容器,外部输入仍要做字段白名单和业务校验。
Q45: addEventListener 的 once、passive 和 signal 有什么用?
答案:
once: true:回调第一次触发后自动移除。passive: true:承诺回调不会调用preventDefault(),浏览器可更放心地处理滚动。signal:把监听器生命周期绑定到AbortSignal,取消时批量清理。capture: true:在捕获阶段而不是冒泡阶段监听。
const controller = new AbortController();
window.addEventListener('resize', onResize, {
signal: controller.signal,
});
controller.abort(); // 自动移除监听器
passive 不是通用性能开关;确实要阻止默认手势时不能使用。组件卸载或功能关闭时,signal 能减少遗漏清理造成的泄漏。
Q46: 闭包的优缺点?
答案:
| 优点 | 缺点 |
|---|---|
| 数据私有化 | 内存占用 |
| 状态保持 | 可能内存泄漏 |
| 模块化 | 调试困难 |
| 函数工厂 | 性能开销 |
Q47: queueMicrotask() 和 Promise.resolve().then() 有什么区别?
答案:
二者都可以把回调放入微任务队列,但语义不同:
queueMicrotask(callback)明确表达“调度一个微任务”,不需要创建一个用于链式结果的 Promise。- Promise 回调抛错会转化为 rejected Promise,若未处理通常触发未处理拒绝事件。
queueMicrotask回调抛错按普通异常报告。- Promise 方案适合需要继续链式传值和错误处理;单纯统一同步/异步分支时可用
queueMicrotask。
无论使用哪种方式,都要避免递归排入微任务导致事件循环饥饿。
Q48: 什么是暂时性死区(TDZ)?
答案:
TDZ 是指变量在作用域内已声明但未初始化的区间。在 TDZ 内访问变量会抛出 ReferenceError。
{
// TDZ 开始
console.log(x); // ❌ ReferenceError
let x = 1; // TDZ 结束
}
Q49: 如何准确判断数组?
答案:
const arr: number[] = [1, 2, 3];
// 方法 1: Array.isArray(推荐)
Array.isArray(arr); // true
// 方法 2: Object.prototype.toString
Object.prototype.toString.call(arr) === '[object Array]';
// 方法 3: instanceof(有跨 iframe 问题)
arr instanceof Array;
// 方法 4: constructor(不推荐,可被修改)
arr.constructor === Array;
Q50: ?? 和 || 的区别?
答案:
// || 对所有假值生效
0 || 'default'; // 'default'
'' || 'default'; // 'default'
false || 'default'; // 'default'
null || 'default'; // 'default'
// ?? 只对 null/undefined 生效
0 ?? 'default'; // 0
'' ?? 'default'; // ''
false ?? 'default'; // false
null ?? 'default'; // 'default'
Q51: JSON.parse(JSON.stringify()) 有什么问题?
答案:
- 函数丢失:函数会被忽略
- Symbol 丢失:Symbol 键和值都会丢失
- undefined 丢失:undefined 会被忽略
- Date 变字符串:Date 对象变成 ISO 字符串
- RegExp 变空对象:正则表达式变成
{} - Map/Set 变空对象:集合类型变成
{} - 循环引用报错:会抛出 TypeError
- 特殊值变 null:NaN、Infinity 变成 null
Q52: 静态 import 和动态 import() 有什么区别?
答案:
静态 import 写在模块顶层,依赖关系在解析阶段就能确定,适合应用启动必需的依赖,也更利于类型检查、静态分析和 Tree Shaking。
动态 import() 是运行时表达式,返回 Promise,适合按路由、功能或条件延迟加载模块。打包器通常把它识别为代码分割边界,但最终如何分 Chunk 仍由构建配置决定。
不要把所有模块都改成动态导入:过度拆分会增加请求、调度和错误处理成本。动态模块还要处理加载失败、旧版本页面请求新 Chunk 产生的 404、预取策略和 Loading UI。
Q53: 如何用 reduce 实现 map?
答案:
function myMap<T, U>(arr: T[], fn: (item: T, index: number) => U): U[] {
return arr.reduce((acc, item, index) => {
acc.push(fn(item, index));
return acc;
}, [] as U[]);
}
myMap([1, 2, 3], n => n * 2); // [2, 4, 6]
Q54: Object.hasOwn()、in 和直接读取属性有什么区别?
答案:
Object.hasOwn(obj, key):只判断对象自身是否拥有该属性,即使值是undefined也返回true。key in obj:会沿原型链查找自身或继承属性。obj[key] !== undefined:无法区分“不存在”和“存在但值为 undefined”。
处理外部字典对象、配置合并和权限字段时,通常应明确使用 Object.hasOwn()。它比直接调用 obj.hasOwnProperty() 更安全,因为对象可能没有原型,或覆盖了同名方法。
Q55: Proxy 相比 Object.defineProperty() 的能力边界是什么?
答案:
Object.defineProperty() 针对某个已知属性定义读取、写入等行为;新增属性、删除属性和数组索引等变化需要额外处理。Proxy 包装整个对象,可拦截 get、set、has、deleteProperty、ownKeys、函数调用等多种内部操作。
但 Proxy 也有边界:
- 它不会自动递归代理嵌套对象,需要按需包装。
- 必须通过代理对象访问才能触发 trap,直接持有原对象会绕过代理。
- 部分内建对象有内部槽,简单代理后方法的
this可能不兼容。 - Trap 必须遵守对象不变量,不能随意伪造不可配置属性。
所以 Proxy 更强,但不是“无成本、无约束”的观察机制。
Q56: for...of 可以遍历哪些对象?
答案:
实现了 [Symbol.iterator] 方法的对象都可以用 for...of 遍历:
- Array
- String
- Map
- Set
- TypedArray
- arguments
- NodeList
- Generator 对象
- 自定义可迭代对象
Q57: 正则表达式的正向/负向断言?
答案:
| 断言 | 语法 | 说明 |
|---|---|---|
| 正向先行 | (?=...) | 后面必须是 |
| 负向先行 | (?!...) | 后面不能是 |
| 正向后行 | (?<=...) | 前面必须是 |
| 负向后行 | (?<!...) | 前面不能是 |
// 正向先行:匹配后面跟着数字的字母
'a1b2c3'.match(/\w(?=\d)/g); // ['a', 'b', 'c']
// 负向先行:匹配后面不跟数字的字母
'a1bc3'.match(/\w(?!\d)/g); // ['1', 'b', 'c', '3']
// 正向后行:匹配前面是 $ 的数字
'$100 €200'.match(/(?<=\$)\d+/); // ['100']
// 负向后行:匹配前面不是 $ 的数字
'$100 200'.match(/(?<!\$)\d+/); // ['200']
Q58: 找出数组中只出现一次的数?
答案:
// 其他数都出现两次
function singleNumber(nums: number[]): number {
return nums.reduce((a, b) => a ^ b, 0);
}
// 原理:a ^ a = 0, a ^ 0 = a
// [4, 1, 2, 1, 2]
// 4 ^ 1 ^ 2 ^ 1 ^ 2 = 4 ^ (1 ^ 1) ^ (2 ^ 2) = 4 ^ 0 ^ 0 = 4
Q59: WeakRef 和 FinalizationRegistry 适合什么场景?为什么要谨慎使用?
答案:
WeakRef 可以弱引用对象,不阻止它被垃圾回收;FinalizationRegistry 可以在对象被回收后的某个不确定时间收到清理通知。它们适合少数“对象存在就复用、不存在就重建”的缓存或原生资源兜底清理场景。
不能依赖它们实现核心业务逻辑:
- GC 是否执行、何时执行都不确定,回调可能很晚,程序退出前也可能不触发。
deref()返回对象后,对象只在当前作业期间可认为存活。- 注册表回调不适合做必须发生的关闭、提交或计费操作。
- 优先使用
dispose()、AbortController和组件卸载清理等显式生命周期;弱引用只能作为补充。
Q60: 分析以下代码的输出顺序
答案:
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise(resolve => {
console.log('promise1');
resolve(undefined);
}).then(() => console.log('promise2'));
console.log('script end');
输出结果:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
分析:
| 步骤 | 类型 | 输出 |
|---|---|---|
| 1 | 同步 | script start |
| 2 | 同步(async1 调用) | async1 start |
| 3 | 同步(async2 调用) | async2 |
| 4 | 同步(Promise 构造器) | promise1 |
| 5 | 同步 | script end |
| 6 | 微任务(await 后续) | async1 end |
| 7 | 微任务(then 回调) | promise2 |
| 8 | 宏任务(setTimeout) | setTimeout |
Q61: call、apply、bind 的区别?
答案:
| 方法 | 执行时机 | 参数形式 | 返回值 |
|---|---|---|---|
call | 立即执行 | 逐个传递 | 函数返回值 |
apply | 立即执行 | 数组传递 | 函数返回值 |
bind | 不执行 | 逐个传递 | 新函数 |
fn.call(obj, 1, 2); // 立即执行
fn.apply(obj, [1, 2]); // 立即执行
const bound = fn.bind(obj, 1); // 返回新函数
bound(2); // 后续调用
Q62: 如何解决循环中闭包问题?
答案:
// 问题
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 3, 3, 3
}
// 解决方案
// 1. let 块级作用域
for (let i = 0; i < 3; i++) { ... }
// 2. IIFE
for (var i = 0; i < 3; i++) {
((j) => setTimeout(() => console.log(j), 100))(i);
}
// 3. setTimeout 参数
for (var i = 0; i < 3; i++) {
setTimeout((j) => console.log(j), 100, i);
}
Q63: 如何避免 async/await 造成请求瀑布?
答案:
如果多个异步任务彼此独立,连续 await 会人为串行:
// 串行:第二个请求必须等待第一个完成
const user = await fetchUser();
const settings = await fetchSettings();
// 并行启动,再统一等待
const userPromise = fetchUser();
const settingsPromise = fetchSettings();
const [user, settings] = await Promise.all([userPromise, settingsPromise]);
先画出依赖关系:有数据依赖的步骤必须串行,独立步骤可以并行,任务很多时再增加并发上限。并行后还要定义失败策略,决定使用 fail-fast、收集全部结果还是允许部分降级,并把取消信号传给底层请求。
Q64: 以下代码输出什么?
答案:
var a = 1;
function test(): void {
console.log(a);
var a = 2;
}
test();
输出结果:undefined。
分析:函数内的 var a 被提升,但赋值在 console.log 之后执行。
Q65: [] == ![] 的结果?
答案:
结果是 true。
分析:
[] == ![]
// 1. ![] = false(空数组转布尔是 true,取反是 false)
[] == false
// 2. [] 转原始值 = ''
'' == false
// 3. '' 和 false 都转数字
0 == 0
// 4. 结果为 true
Q66: 可选链有哪些用法?
答案:
// 1. 访问属性
obj?.property;
// 2. 访问动态属性
obj?.[expression];
// 3. 调用方法
obj?.method?.();
// 4. 访问数组元素
arr?.[index];
// 组合使用
user?.profile?.settings?.theme ?? 'dark';
Q67: 如何处理循环引用?
答案:
使用 WeakMap 记录已克隆的对象:
function deepClone<T>(obj: T, map = new WeakMap()): T {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 检查是否已克隆
if (map.has(obj as object)) {
return map.get(obj as object);
}
const cloned = Array.isArray(obj) ? [] : {};
// 记录已克隆
map.set(obj as object, cloned);
for (const key of Object.keys(obj)) {
(cloned as any)[key] = deepClone((obj as any)[key], map);
}
return cloned as T;
}
Q68: 为什么 ESM 更利于 Tree Shaking?
答案:
ESM 的 import/export 具有静态结构,工具在不执行模块的情况下就能分析导入导出关系;导出还是 live binding,模块边界语义明确,因此更适合做未使用导出消除。
CommonJS 的 require() 和 module.exports 可以在条件、函数和动态表达式中变化,完整静态分析更困难。现代工具可能对部分 CommonJS 模式做启发式优化,但不能等同于 ESM 的可靠静态分析。
Tree Shaking 还依赖副作用分析、包的 sideEffects 声明、导入方式和压缩阶段。使用 ESM 是重要前提之一,不代表所有未使用代码都会自动消失。
Q69: 如何数组去重?
答案:
const arr = [1, 2, 2, 3, 3, 3];
// 方法 1: Set
const unique1 = [...new Set(arr)];
// 方法 2: filter
const unique2 = arr.filter((item, index) => arr.indexOf(item) === index);
// 方法 3: reduce
const unique3 = arr.reduce((acc, item) => {
if (!acc.includes(item)) acc.push(item);
return acc;
}, [] as number[]);
// 对象数组去重(按 id)
const users = [
{ id: 1, name: 'a' },
{ id: 2, name: 'b' },
{ id: 1, name: 'c' }
];
const uniqueUsers = users.filter(
(user, index, self) => self.findIndex(u => u.id === user.id) === index
);
Q70: 如何将 Map 转为 Object,Object 转为 Map?
答案:
// Map -> Object
const map = new Map([['a', 1], ['b', 2]]);
const obj = Object.fromEntries(map);
console.log(obj); // { a: 1, b: 2 }
// Object -> Map
const obj2 = { x: 10, y: 20 };
const map2 = new Map(Object.entries(obj2));
console.log(map2); // Map(2) { 'x' => 10, 'y' => 20 }
Q71: Object.preventExtensions、Object.seal 和 Object.freeze 有什么区别?
答案:
三者约束逐步增强:
preventExtensions:不能新增自有属性,但已有属性仍可按描述符修改或删除。seal:在不可扩展基础上,把现有属性设为不可配置,因此不能删除或重新定义;可写数据属性仍可改值。freeze:再把现有数据属性设为不可写,是三者中最强的一层。
它们都只作用于对象自身的第一层,不会递归冻结嵌套对象;访问器属性的 getter 仍可能返回变化值,也不等于业务数据永久不可变。
可用对应的 isExtensible、isSealed、isFrozen 检查状态。需要深度不可变时要明确处理循环引用、Map/Set 和性能,而不是盲目递归。
Q72: Iterator 和 Generator 的关系?
答案:
- Iterator 是一种协议,定义了
next()方法 - Generator 是更简洁的迭代器实现方式
- Generator 函数返回的对象同时实现了迭代器协议和可迭代协议
function* gen(): Generator<number> {
yield 1;
yield 2;
}
const g = gen();
// g 既是迭代器(有 next 方法)
console.log(g.next()); // { value: 1, done: false }
// 也是可迭代对象(有 Symbol.iterator)
console.log(g[Symbol.iterator]() === g); // true
Q73: 如何匹配中文?
答案:
// Unicode 范围
const chineseRegex = /[\u4e00-\u9fa5]+/g;
'hello 你好 world 世界'.match(chineseRegex); // ['你好', '世界']
// Unicode 属性(ES2018)
/\p{Script=Han}+/gu.test('你好'); // true
Q74: 如何判断一个数是 2 的幂?
答案:
function isPowerOfTwo(n: number): boolean {
return n > 0 && (n & (n - 1)) === 0;
}
// 原理:2 的幂的二进制只有一个 1
// 8 = 1000
// 7 = 0111
// 8 & 7 = 0000 = 0
Q75: 如何排查生产环境的内存泄露?
答案:
排查步骤:
-
监控内存趋势
// 定期上报内存使用情况
setInterval(() => {
const mem = performance.memory;
if (mem) {
reportMetrics({
usedJSHeapSize: mem.usedJSHeapSize,
totalJSHeapSize: mem.totalJSHeapSize,
jsHeapSizeLimit: mem.jsHeapSizeLimit
});
}
}, 60000); // 每分钟上报 -
使用 Chrome DevTools
- 录制 Heap Snapshot(堆快照)
- 对比多个时间点的快照
- 查看 Retainers(持有者)找到泄露源
-
Performance Monitor
- 监控 JS heap size 趋势
- 查看是否持续增长不回落
-
代码审查重点
- 全局变量和缓存
- 事件监听器和定时器清理
- 闭包使用
- 第三方库的生命周期管理
快速诊断技巧:
// 创建一个诊断函数
function memoryLeakCheck(): void {
const baseline = performance.memory.usedJSHeapSize;
// 执行疑似泄露的操作
for (let i = 0; i < 100; i++) {
suspiciousOperation();
}
// 手动触发 GC(Chrome 需要 --expose-gc)
if ('gc' in window) {
(window as any).gc();
}
// 检查内存增长
const current = performance.memory.usedJSHeapSize;
const growth = current - baseline;
if (growth > 10 * 1024 * 1024) { // 增长超过 10MB
console.warn('Possible memory leak detected:', {
growth: `${Math.round(growth / 1024 / 1024)}MB`,
operation: 'suspiciousOperation'
});
}
}
预防措施:
- 代码规范:强制清理生命周期(ESLint 规则)
- 单元测试:测试组件卸载后内存是否释放
- 自动化监控:CI/CD 集成内存泄露检测工具
- 定期审计:Review 缓存、监听器、定时器代码