JavaScript
已收录:本页完成领域概览规划清单中 JavaScript 的 4 项内容(原型链 / this / 闭包、Event Loop 与异步、ES202x 速览、常见手写题)。示例均在正文标注运行环境,可直接复制验证。
本页按「语法要点 → 运行机制 → 示例 → 踩坑」组织,覆盖语言核心与异步模型:
原型链、this 绑定与闭包
原型与原型链(语法要点)
每个对象都有一个隐藏的内部属性 [[Prototype]](可理解为「指向另一个对象的引用」)。访问对象属性时,若自身没有,就沿这条引用链向上查找,直到 null 为止——这条链就是原型链。
// 运行环境:浏览器控制台或 Node.js 均可
const obj = { a: 1 }
// 从 Object.prototype 上继承来的方法
console.log(obj.toString()) // "[object Object]"
console.log(Object.getPrototypeOf(obj) === Object.prototype) // true
console.log(obj.__proto__ === Object.prototype) // true(__proto__ 是历史遗留的访问器,生产代码请用上面的 API)
console.log(Object.getPrototypeOf(Object.prototype)) // null —— 链条终点创建带指定原型的对象:
// 运行环境:Node.js ≥ 0.12 / 现代浏览器
const base = { greet() { return 'hi' } }
const child = Object.create(base)
child.own = 1
console.log(child.greet()) // "hi" —— 来自原型 base
console.log(child.own) // 1 —— 自有属性
// 判断属性归属
console.log(Object.hasOwn(child, 'own')) // true
console.log(Object.hasOwn(child, 'greet')) // false
// 属性查找
console.log('greet' in child) // true(含原型链)原型链查找、遮蔽与 instanceof(运行机制)
属性读取:对象 → 自身(自有属性)→ 原型链逐级向上,找到即停,这就是「就近原则」。若子类/原型上定义了同名属性,则遮蔽(shadowing)上层。
// 运行环境:Node.js 或浏览器
const grand = { name: 'grand' }
const parent = Object.create(grand)
const child = Object.create(parent)
console.log(child.name) // "grand" —— 沿链找到
parent.name = 'parent' // 给 parent 增加自有属性,遮蔽 grand.name
console.log(child.name) // "parent"
console.log(grand.name) // "grand" —— 上层不受影响(遮蔽 ≠ 修改)instanceof 的本质是遍历左侧对象的原型链,看能否找到右侧函数的 prototype:
// 运行环境:Node.js 或浏览器
function Animal() {}
const a = new Animal()
console.log(a instanceof Animal) // true
console.log(a instanceof Object) // true(原型链上还有 Object.prototype)
console.log(Object.create(null) instanceof Object) // false —— 无原型链Symbol.hasInstance 允许自定义 instanceof 行为(如 Array[Symbol.hasInstance] 相关定制),面试里遇到「手写 instanceof」即复刻上述遍历。
构造函数与 new(运行机制)
普通函数用 new 调用时,引擎执行 4 步:
- 创建新对象,其原型指向构造函数的
prototype属性; - 把函数体内的
this绑定到这个新对象; - 执行函数体(给
this挂属性); - 若函数显式返回了一个对象,则返回该对象;否则返回第 1 步创建的对象。
// 运行环境:Node.js 或浏览器
function Person(name) {
this.name = name
}
Person.prototype.say = function () { return `我是 ${this.name}` }
const p = new Person('小明')
console.log(p.say()) // "我是 小明"
console.log(p instanceof Person) // true
console.log(p.constructor === Person) // true(constructor 亦来自原型链)
console.log(Person.prototype.isPrototypeOf(p)) // trueclass 是构造函数的语法糖,底层仍是「函数 + 原型」:
// 运行环境:Node.js ≥ 12 / 现代浏览器
class Animal {
constructor(name) { this.name = name }
speak() { return `${this.name} 叫了一声` } // 实例方法 → Animal.prototype
static create(name) { return new Animal(name) } // 静态方法 → Animal 本身
}
class Dog extends Animal {
speak() { return `${this.name} 汪汪` } // 覆盖父类方法(原型链遮蔽)
}
const d = new Dog('旺财')
console.log(d.speak()) // "旺财 汪汪"
console.log(d.speak === Dog.prototype.speak) // true
console.log(Dog.prototype.__proto__ === Animal.prototype) // true —— extends 建立原型链
console.log(Dog.__proto__ === Animal) // true —— 静态方法也沿链继承
console.log(Dog.create('猫') instanceof Animal) // true与普通构造函数的差异:class 必须用 new 调用(直接调用抛 TypeError),方法不可枚举,且类体默认运行在严格模式。
this 绑定(语法要点 + 规则优先级)
this 的值在调用时确定,与函数定义位置无关,由「如何被调用」决定。四类绑定规则(优先级从低到高):
| 规则 | 调用形态 | this 指向 | 示例 |
|---|---|---|---|
| 默认绑定 | fn() | 非严格模式:全局对象;严格模式:undefined | function f(){ return this }; f() |
| 隐式绑定 | obj.fn() | 点号前的对象 | obj.f() → obj |
| 显式绑定 | fn.call/apply/bind(x) | 传入的对象 | f.call(o) → o |
| new 绑定 | new Fn() | 新建的对象(最高优先级) | new F() |
// 运行环境:浏览器(node 里默认绑定指向 globalThis,效果一致;非严格模式)
const obj = {
name: 'obj',
show() { return this.name }
}
console.log(obj.show()) // "obj" 隐式绑定
const fn = obj.show
console.log(fn()) // undefined 绑定丢失 → 默认绑定(非严格下 this 为 window)
console.log(fn.call(obj)) // "obj" 显式绑定扳回
const bound = fn.bind(obj)
console.log(bound()) // "obj" bind 永久生效
// bind 之后再用 call 也改不了已绑定函数的 this
console.log(bound.call({ name: 'other' })) // "obj"箭头函数没有自己的 this:它捕获定义时外层词法作用域的 this,且无法被 call/apply/bind/new 改变,也没有自己的 arguments:
// 运行环境:浏览器或 Node.js
const outer = {
list: [1, 2, 3],
collect() {
// 箭头函数沿用 collect 被调用时的 this(此处为 outer)
return this.list.map((n) => this.list.length * n)
}
}
console.log(outer.collect()) // [3, 6, 9]高频 this 丢失场景与修复:
| 丢失场景 | 现象 | 修复 |
|---|---|---|
const f = obj.method; f() | this 变全局/undefined | f.bind(obj) / 箭头函数包装 |
setTimeout(obj.method, 1000) | 回调里 this 丢 | setTimeout(() => obj.method(), 1000) |
回调函数(forEach、事件) | this 指向 window/元素 | 箭头函数或显式传第二参(forEach(fn, thisArg)) |
解构 const { method } = obj | 同第一种 | 调用处重新绑定 |
// 运行环境:浏览器
class Counter {
count = 0
// 用类字段 + 箭头函数把 this 永久锁在实例上,事件回调不会丢
onClick = () => { this.count += 1 }
}
// addEventListener('click', counter.onClick) —— this 始终为 counter 实例闭包(语法要点 + 运行机制)
闭包 = 函数 + 它定义时所处词法环境的引用。内层函数即使被「带出」外层执行,仍能访问外层作用域的变量——因为函数对象上挂着对词法环境的引用,外层变量因此存活。
// 运行环境:Node.js 或浏览器
function createCounter(start = 0) {
let count = start // 被内层函数捕获的私有状态
return {
inc() { return ++count },
get() { return count }
}
}
const c = createCounter()
c.inc(); c.inc()
console.log(c.get()) // 2
console.log(c.count) // undefined —— 外部无法直接访问 count典型用途:私有变量(模块模式)、工厂/柯里化、一次性 IIFE 隔离作用域、回调中保存状态(防抖节流即依赖闭包保存 timer,见下文手写题)。
// 运行环境:Node.js 或浏览器 —— 柯里化:闭包逐层固定参数
const add = (a) => (b) => a + b
const add5 = add(5)
console.log(add5(3)) // 8循环中的经典陷阱(var 与闭包):
// 运行环境:Node.js 或浏览器
// ✗ 错误写法:var 共享同一个变量 i,回调执行时 i 已是 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var 版:', i), 0) // 输出 3、3、3
}
// ✓ 用 let 声明块级变量,每次迭代都是独立绑定
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let 版:', j), 0) // 输出 0、1、2
}
// ✓ 兼容旧代码:IIFE 复制一份参数
for (var k = 0; k < 3; k++) {
((n) => setTimeout(() => console.log('IIFE 版:', n), 0))(k) // 0、1、2
}踩坑记录
class声明会「暂时性死区」:new之前必须先执行到声明语句,不存在 var 式提升;typeof null === 'object'、null instanceof Object === false——判空要写成x === null;- 修改
Object.prototype/内置原型会污染所有对象,务必避免(除非 polyfill 且先检查不存在); - 箭头函数不能作为构造函数,也没有
prototype属性; - 闭包长期持有大对象会造成无法回收,用完把引用置
null,或用WeakMap/WeakRef挂载缓存; - 原型链越长查找越慢,深继承可读性差,优先用组合(mixin/类组合)而非深继承。
Event Loop 与异步编程
为什么需要事件循环(运行机制)
JavaScript 是单线程语言:同一时刻只有一段代码在执行,调用栈(call stack)是唯一执行场所。若网络请求/定时器等慢操作直接阻塞栈,UI 就无法响应。解决方案是宿主环境提供异步 API + 事件循环:耗时操作交给浏览器/Node 后台,完成后把回调按规则排队,等主线程空闲再执行。
// 运行环境:Node.js 或浏览器
// 递归压栈 → 栈溢出(RangeError: Maximum call stack size exceeded)
function boom() { boom() }
// boom() // 解除注释可复现浏览器事件循环:宏任务与微任务(运行机制)
主线程维护两类队列(以 HTML 规范为准的简化模型):
- 宏任务(task):
setTimeout/setInterval、I/O、UI 事件、postMessage、MessageChannel。每轮事件循环取一个宏任务执行。 - 微任务(microtask):
Promise.then/catch/finally、queueMicrotask、MutationObserver。当前宏任务执行完后,会一次性清空全部微任务(新产生的微任务也继续执行,直到队列为空)。
每一轮大致是:执行一个宏任务 → 清空整个微任务队列 →(必要时)更新渲染。requestAnimationFrame 在渲染之前回调;因此:
- 微任务总在下一个宏任务之前执行;
- 微任务里再排微任务不会被「插队」,但会无限循环(警惕死循环卡死页面)。
// 运行环境:浏览器控制台或 Node.js(Node ≥ 11 后行为与浏览器一致)
console.log('1 同步')
setTimeout(() => console.log('2 宏任务'), 0)
Promise.resolve().then(() => console.log('3 微任务'))
queueMicrotask(() => console.log('4 微任务'))
console.log('5 同步')
// 输出顺序:1 → 5 → 3 → 4 → 2
// 解释:同步代码先全部执行完;随后清空微任务(3、4);最后才轮到宏任务 2综合题(面试常客):
// 运行环境:浏览器
setTimeout(() => console.log('A'))
console.log('B')
Promise.resolve()
.then(() => { console.log('C'); setTimeout(() => console.log('D')) })
.then(() => console.log('E'))
queueMicrotask(() => console.log('F'))
// 预期输出:B → C → F → E → A → D
// 推理:同步 B;微任务先 C/F;then 链中 C 里注册的 setTimeout(D) 是宏任务,
// 所以先执行同批微任务 E,再依次执行宏任务 A、D(按注册顺序)Node.js 环境额外注意:process.nextTick 回调优先于 Promise 微任务执行(nextTick 队列有自己的最高优先级);事件循环阶段为 timers → pending callbacks → poll → check(即 setImmediate) → close,每个阶段之间都会清空微任务。
Promise(语法要点)
Promise 表示一个尚未完成但最终会出结果的操作。三态:
pending(进行中)→fulfilled(成功,带值)或rejected(失败,带原因),状态一旦改变不可逆;executor(构造参数)同步执行;resolve/reject触发状态迁移;executor 内抛错自动变成reject;then返回新 Promise,支持链式;回调返回普通值会被包成 resolved Promise,返回 Promise 则会被「拍平」(吸收其状态)——这就是链式传递值的原理。
// 运行环境:Node.js ≥ 0.12 或浏览器
new Promise((resolve, reject) => {
console.log('executor 同步执行')
setTimeout(() => resolve('数据'), 100)
})
.then((v) => { console.log('then1:', v); return v + ' 处理过' })
.then((v) => console.log('then2:', v))
.catch((e) => console.error('出错:', e))
.finally(() => console.log('无论成败都执行'))静态方法与用途速查:
| 方法 | 行为 | 典型场景 |
|---|---|---|
Promise.resolve(x) | 包成 fulfilled Promise(thenable 会被吸收) | 把同步值转异步 |
Promise.reject(e) | 包成 rejected Promise | 快速失败 |
Promise.all([...]) | 全部 fulfilled 才成功(按序返回数组);任一 reject 则整体 reject | 并行无依赖请求 |
Promise.allSettled([...]) | 等全部敲定,返回 {status, value/reason} 数组,永不 reject | 批量任务不在乎个别失败 |
Promise.race([...]) | 首个敲定者决定结果 | 超时控制 |
Promise.any([...]) | 首个 fulfilled 胜出;全部 reject 才 reject(AggregateError) | 多路请求取最快成功 |
Promise.withResolvers() | 一次拿到 {promise, resolve, reject} | 把 resolve 存到外部(事件回调触发) |
// 运行环境:浏览器(Chrome 119+)—— withResolvers:把 resolve 交给事件回调
const { promise, resolve } = Promise.withResolvers()
window.addEventListener('click', resolve) // 点击时 promise 落定
promise.then((e) => console.log('点击事件坐标:', e.clientX))// 运行环境:Node.js ≥ 12 —— 超时控制(race)
function withTimeout(p, ms, msg = '请求超时') {
return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(msg)), ms))])
}Promise 踩坑
- 未处理的 rejection:链末端没有
catch,Node 会打印UnhandledPromiseRejection(高版本直接抛错);浏览器派发unhandledrejection事件。规则:每条链都要有终点 catch,或用void p.catch(...)显式声明; - 回调里
throw会把该次then变成 reject(捕获错误),但不会影响已敲定的外层 Promise; Promise.all中一项 reject,其余项仍会继续执行(只是结果被丢弃),别误以为会「取消」;- then 回调返回值是自身链上的 Promise 时可能死循环;同一 Promise 上多次
then是分支而非链式(各自独立); executor抛出的同步错误会自动 reject,无需手动 try/catch 包一层。
async / await(语法要点)
async 函数总是返回 Promise(return 的值自动包成 fulfilled;内部 throw 自动 reject)。await 是 then 的语法糖:暂停函数执行,等右侧 Promise 落定后继续,并把值作为表达式结果(等价于把后续代码塞进 then 回调)。await 不会阻塞主线程——只是把函数「挂起」。
// 运行环境:Node.js ≥ 8 或浏览器
async function main() {
const a = await delay(300, 'A')
const b = await delay(200, 'B') // 串行:总耗时 ≈ 500ms
console.log(a, b)
}
function delay(ms, v) { return new Promise((r) => setTimeout(() => r(v), ms)) }
main()并行写法(无依赖时不要逐个 await):
// 运行环境:Node.js ≥ 8 或浏览器
async function parallel() {
const [u, c] = await Promise.all([fetchUser(), fetchConfig()]) // 同时发起,总耗时取最慢
console.log(u, c)
}错误处理:try/catch 只覆盖本函数内的 await;若要「捕获但继续」,需保证错误不外泄:
// 运行环境:Node.js 或浏览器
async function safe() {
try {
await risky()
} catch (e) {
console.error('已捕获:', e.message)
}
console.log('继续执行')
}其他要点:顶层 await 仅在 ESM 模块(.mjs 或 "type": "module")可用;for await (const x of asyncIterable) 消费异步迭代器;await 只对 Promise 生效,普通值直接返回。
异步踩坑
- 忘记
await:const data = fetchData()得到的是 Promise 而非数据;async回调里的错误不会自动被外层 try 捕获; - 竞态(stale response):用户快速切换搜索词,旧请求后返回覆盖新结果。解法:请求序号比对 /
AbortController取消旧请求; setTimeout嵌套 5 层以上会被钳制为最低 4ms(浏览器);后台标签页定时器被节流——别用 setTimeout 做精确计时,用performance.now()差值 + 高频补偿;- await 数组里某一项 reject 会让
Promise.all立即 reject,若其他请求仍有副作用,考虑allSettled; - async 箭头函数要写成
async (x) => ...,把await放进非 async 的 map 回调里是无效的。
ES202x 新特性速览
标注:ES6 = ES2015,之后每年一版。表格为速查索引,重点是随手能写对的高频语法。示例运行环境:Node.js LTS ≥ 20(标注版本的特性需更高版本)或现代浏览器。
版本里程碑
| 版本 | 年份 | 代表性特性 |
|---|---|---|
| ES5 | 2009 | 严格模式、JSON、Object.defineProperty、数组 map/filter/reduce |
| ES6 (ES2015) | 2015 | let/const、箭头函数、class、模板字符串、解构、默认参数/rest/展开、for...of、Map/Set、Symbol、Proxy/Reflect、Promise、模块、迭代器与生成器 |
| ES2016 | 2016 | Array.prototype.includes、指数运算符 ** |
| ES2017 | 2017 | async/await、Object.values/entries、字符串补全 |
| ES2018 | 2018 | 异步迭代、对象 rest/spread、Promise.finally |
| ES2019 | 2019 | Array.flat/flatMap、Object.fromEntries、String.trimStart/trimEnd、可选 catch 绑定 |
| ES2020 | 2020 | 可选链 ?.、空值合并 ??、BigInt、Promise.allSettled、globalThis、动态 import() |
| ES2021 | 2021 | 逻辑赋值 ??=/` |
| ES2022 | 2022 | 类字段与私有 #、静态块、顶层 await、Error 的 cause、Array.at、Object.hasOwn |
| ES2023 | 2023 | findLast 系列、不可变排序 toSorted/toReversed/toSpliced/with |
| ES2024 | 2024 | Object.groupBy / Map.groupBy、Promise.withResolvers、Array.fromAsync |
| ES2025 | 2025 | Promise.try、迭代器辅助方法、Float16Array、Atomics.pause、import defer |
高频语法速查
| 特性 | 用法 | 示例(Node ≥ 20 直接运行) |
|---|---|---|
| 可选链 | 链上某环为空时短路为 undefined | user?.profile?.name;arr?.[0];fn?.(x) |
| 空值合并 | 仅当左侧为 null/undefined 时取右侧 | const n = input ?? 10(0 和 '' 不会被替换) |
| 逻辑赋值 | 短路赋值 | a ??= b、`obj.x |
| 展开/解构 | 复制、合并、取子集 | {...a, ...b}、const { x, y = 1, ...rest } = obj、[...arr] |
| 数值分隔符 | 提高大数可读性 | 1_000_000_000 === 1e9 |
at | 支持负索引取元素 | [1,2,3].at(-1) → 3 |
| 不可变更新 | 返回新数组,不改原数组 | arr.toSorted()、arr.toReversed()、arr.toSpliced(i, n)、arr.with(i, v) |
| 查找最后一项 | 尾部开始查找 | arr.findLast(x => x > 2)、arr.findLastIndex(...) |
structuredClone | 原生深拷贝(浏览器/Node 17+) | structuredClone(obj) |
Object.groupBy | 按回调键分组 | Object.groupBy(users, u => u.dept)(Node ≥ 21) |
高频语法的立即体验:
// 运行环境:Node.js ≥ 20
// 可选链 + 空值合并组合:安全取值并给默认值
const res = await fetch('https://api.example.com/user').catch(() => null)
const name = res?.data?.name ?? '匿名用户'
console.log(name)
// 逻辑赋值:配置合并的常见写法
const cfg = {}
cfg.retries ??= 3 // 未设置 → 3
cfg.list ??= []
cfg.list.push('a')
console.log(cfg) // { retries: 3, list: ['a'] }
// 不可变更新:React 等场景替代 push/splice 的原地修改
const items = [1, 2, 3]
const next = items.with(0, 9).toSorted((a, b) => b - a)
console.log(items, next) // [1, 2, 3](原数组不变) [9, 3, 2]演进中的重点特性(ES2024–ES2025)
// 运行环境:Node.js ≥ 21
// Promise.withResolvers —— 不需要包一层再手动导出的样板
const { promise, resolve, reject } = Promise.withResolvers()
// 把 resolve/reject 交给队列消费方,promise 供调用方 await
// Object.groupBy —— 替代手写 reduce 分组
const nums = [1, 2, 3, 4]
console.log(Object.groupBy(nums, (n) => (n % 2 ? 'odd' : 'even')))
// { odd: [1, 3], even: [2, 4] }// 运行环境:Node.js ≥ 22(迭代器辅助方法)—— 迭代器先执行操作,是惰性的
function* nums() { let i = 0; while (i < 10) yield ++i }
const first3even = nums().filter((n) => n % 2 === 0).take(3).toArray()
console.log(first3even) // [2, 4, 6]
// Promise.try —— 同步函数/异步函数统一走 Promise 通道
Promise.try(() => JSON.parse('{"a":1}')).then(console.log, console.error)建议练习
- 用
Object.groupBy与Map.groupBy分别处理「接口返回的用户列表按部门/角色分组」; - 用
structuredClone替换 JSON 深拷贝,比较对Date/Map/undefined的处理差异; - 手写
Array.fromAsync:给定一组异步数据源,并发且保序地收集结果。
常见手写题
运行环境:以下代码在浏览器控制台与 Node.js ≥ 18 均可运行。建议先不看答案自己实现,再对照注释检查边界。
防抖(debounce)
要点:事件触发后等待一段时间,若期间再次触发则重置计时,只在「安静期结束」后执行一次。适合搜索输入、窗口 resize。
/**
* 防抖:触发后 wait 毫秒内无再次触发才执行
* @param {Function} fn
* @param {number} wait
* @param {boolean} immediate 为 true 时先立即执行一次,期间再次触发不执行
*/
function debounce(fn, wait = 300, immediate = false) {
let timer = null
let called = false // immediate 模式下记录是否已立即执行过
function debounced(...args) {
const context = this
if (immediate && !called) { // 首次触发立即执行
fn.apply(context, args)
called = true
}
clearTimeout(timer) // 每次触发都重置等待
timer = setTimeout(() => {
if (!immediate) fn.apply(context, args) // trailing 执行
called = false // 重置,允许下一轮再次立即执行
}, wait)
}
debounced.cancel = () => { clearTimeout(timer); timer = null }
return debounced
}
// 用法:输入框停止输入 300ms 后才请求
const search = debounce((kw) => console.log('请求:', kw), 300)
search('a'); search('ab'); search('abc') // 只打印一次 "请求: abc"
search.cancel() // 组件卸载时清理,避免回调在销毁后执行节流(throttle)
要点:固定时间窗口内最多执行一次(不必等待安静期)。适合滚动、鼠标移动、拖拽。
/**
* 节流:wait 时间窗口内最多触发一次
* @param {Function} fn
* @param {number} wait
* @param {boolean} trailing 是否在窗口结束时补一次(捕获最后一次调用)
*/
function throttle(fn, wait = 200, trailing = true) {
let lastTime = 0
let timer = null
function throttled(...args) {
const now = Date.now()
const remaining = wait - (now - lastTime)
if (remaining <= 0) { // 超出窗口:立即执行
if (timer) { clearTimeout(timer); timer = null }
lastTime = now
fn.apply(this, args)
} else if (trailing && !timer) { // 窗口内:记下最后一次调用,窗口结束时补执行
timer = setTimeout(() => {
lastTime = Date.now()
fn.apply(this, args)
timer = null
}, remaining)
}
}
throttled.cancel = () => { clearTimeout(timer); timer = null; lastTime = 0 }
return throttled
}
// 用法:滚动时每 200ms 至多更新一次位置
const onScroll = throttle((e) => console.log('滚动位置更新'), 200)深拷贝
要点:递归复制所有层级;必须处理循环引用(否则栈溢出)、特殊对象(Date/RegExp/Map/Set)、数组与 Symbol 键。
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') return value // 原始值直接返回
if (seen.has(value)) return seen.get(value) // 循环引用:返回已克隆的副本
if (value instanceof Date) return new Date(value.getTime())
if (value instanceof RegExp) return new RegExp(value.source, value.flags)
if (value instanceof Map) {
const m = new Map()
seen.set(value, m)
for (const [k, v] of value) m.set(deepClone(k, seen), deepClone(v, seen))
return m
}
if (value instanceof Set) {
const s = new Set()
seen.set(value, s)
for (const v of value) s.add(deepClone(v, seen))
return s
}
if (ArrayBuffer.isView(value)) return value.slice() // Uint8Array 等 TypedArray
const target = Array.isArray(value) ? [] : {}
seen.set(value, target) // 先登记再递归,处理环
// 复制自有可枚举属性(含 Symbol 键),自动带上原型链上非可枚举属性则需 getOwnPropertyDescriptors
for (const key of Reflect.ownKeys(value)) {
const desc = Object.getOwnPropertyDescriptor(value, key)
if (desc && typeof desc.get === 'function' && !('set' in desc)) {
// 只有 getter 的属性:直接调用读取值再复制,避免拷贝 getter 本身
target[key] = deepClone(value[key], seen)
} else {
Object.defineProperty(target, key, { ...desc, value: deepClone(value[key], seen) })
}
}
return target
}
// 验证:循环引用与特殊类型
const src = { a: 1, when: new Date(2026, 0, 1), map: new Map([['k', [1, 2]]]), reg: /js$/gi }
src.self = src // 循环引用
const copy = deepClone(src)
console.log(copy !== src, copy.a === src.a) // true true(外壳已复制)
console.log(copy.self === copy, copy.self !== src) // true true(环被正确重建)
console.log(copy.when instanceof Date) // true(非字符串!)对比 JSON.parse(JSON.stringify(x)) 的局限(面试高频):
| 数据 | JSON 深拷贝结果 | 说明 |
|---|---|---|
undefined / 函数 / Symbol | 属性被静默丢弃 | 数组里则变 null |
Date | 变成字符串 | 不再是 Date 实例 |
RegExp / Map / Set | 变成 {} | 完全失真 |
| 循环引用 | 抛 TypeError | 无法处理环 |
NaN / Infinity | 变 null | 数值失真 |
生产环境优先用 structuredClone(原生、快、支持环与多数内置类型);上文的 deepClone 用于面试手写与理解内部机制。
发布订阅(EventEmitter)
要点:on 注册、emit 触发、off 注销、once 只触发一次;emit 遍历当前监听器副本,避免回调中注册新监听器导致的迭代副作用。
class EventEmitter {
#listeners = new Map() // event -> Set<fn>
on(event, fn) {
if (!this.#listeners.has(event)) this.#listeners.set(event, new Set())
this.#listeners.get(event).add(fn)
return this // 支持链式调用
}
once(event, fn) {
const wrapper = (...args) => {
this.off(event, wrapper) // 触发前先注销自己
fn.apply(this, args)
}
wrapper.original = fn // 记录原函数,off 时可正确匹配
return this.on(event, wrapper)
}
off(event, fn) {
const set = this.#listeners.get(event)
if (!set) return this
// once 包装时传入的是 wrapper,按 original 匹配也能删掉
for (const listener of set) {
if (listener === fn || listener.original === fn) set.delete(listener)
}
if (set.size === 0) this.#listeners.delete(event)
return this
}
emit(event, ...args) {
const set = this.#listeners.get(event)
if (!set) return false
for (const fn of [...set]) fn.apply(this, args) // 复制一份:回调内 on/off 不影响本次遍历
return true
}
}
// 用法
const bus = new EventEmitter()
bus.on('update', (msg) => console.log('收到:', msg))
bus.once('update', (msg) => console.log('只收一次:', msg))
bus.emit('update', 'A') // 收到: A 只收一次: A
bus.emit('update', 'B') // 收到: B(once 已自动注销)
bus.off('update', undefined) // 忽略;实际应 off 保存的引用说明:
emit同步执行所有监听器;若某个监听器抛错会中断后续监听器(Node 的EventEmitter同理会把错误抛给emit调用者,可用error事件约定)。浏览器环境下可用CustomEvent或框架自带事件系统替代。
同源扩展练习
以下为面试常见同类题,原理均可由上文推导,留作自测:
| 题目 | 考察点 | 提示 |
|---|---|---|
手写 new | 原型链、构造返回值 | 见「构造函数与 new」小节 4 步 |
手写 bind/call/apply | this 显式绑定、参数透传 | 用 Symbol 作临时的 key 避免覆盖属性 |
手写 instanceof | 原型链遍历 | 沿 __proto__ 找 F.prototype |
手写 Promise.all/race | 异步并发聚合、计数落定 | 用剩余计数器 + resolve/reject 各触发一次 |
| 并发池(限制并发数) | 异步调度、队列 | N 个 worker 循环取队首执行 |
柯里化 curry | 闭包、参数累计 | fn.length 判断参数集齐与否 |
深比较 isEqual | 递归 + 循环引用 | 复用 deepClone 的思路(WeakSet 记录已比) |
| 惰性函数 / 单例 | 闭包缓存 | 首次执行后替换自身实现 |
状态与参考
- 状态:已收录(2026-09-02 完成领域概览规划的 4 项主题)。
- 运行环境:全文示例基于 Node.js LTS(≥ 20,个别标注 ≥ 21/22)与现代浏览器(Chrome 118+ / Edge / Firefox / Safari 16.4+);涉及 DOM/
fetch的示例在浏览器运行。 - 参考:MDN JavaScript、TC39 Proposals、ECMAScript 规范。
下一步
- [ ] 结合踩坑记录沉淀实际项目中遇到的 this / 异步问题
- [ ] 补充「闭包与内存泄漏」的真实案例(长列表订阅未销毁等)
- [ ] 维护 ES 新特性速览表,随 TC39 定稿每年更新一行
写作规范与页面规划请参阅领域概览。