手写:Promise.all/深拷贝/EventEmitter复习深度解析
系统梳理前端面试中最高频的三道手写题,从核心原理到完整实现,再到边界情况处理,逐一攻克
一句话概括
Promise.all用于并发控制、深拷贝解决引用拷贝陷阱、EventEmitter实现发布订阅模式,这三道手写题覆盖了JavaScript异步编程、内存模型和设计模式三大核心领域,是前端进阶绕不开的基石。
背景与意义
在前端面试中,手写题是检验候选人基本功的试金石。而Promise.all、深拷贝、EventEmitter这三道题更是”必备三大件”,出现频率远超其他手写题。原因无他——它们分别对应着前端开发中最核心的三个能力维度:
- 异步编程:现代前端应用充斥着网络请求、定时任务、动画序列,不理解Promise的并发控制机制,就无法有效管理异步流程
- 数据操作:React的不可变数据哲学、Vue的响应式系统、状态管理库的深层嵌套状态——所有这些都要求开发者对JavaScript的引用传递和值复制有深刻理解
- 架构设计:从Vue的EventBus到Node.js的EventEmitter,发布订阅模式是前端最基础的事件通信机制
更重要的是,这三道题不是孤立的”八股文”。理解它们的内部实现,可以在实际开发中避免无数坑——比如用错误的深拷贝方式导致Redux状态更新失败、对Promise.allSettled和Promise.all的选择失误、或者滥用EventEmitter导致内存泄漏。
概念与定义
Promise.all
Promise.all(iterable)接收一个Promise可迭代对象,返回一个新的Promise。当所有输入的Promise都fulfilled时,返回的Promise以结果数组fulfill;当任一Promise reject时,返回的Promise立即reject。
深拷贝
深拷贝创建一个与原始对象完全独立的新对象,新对象中的任何属性修改不会影响原始对象。与之相对的是浅拷贝,只复制一层引用,嵌套对象仍然共享引用。
EventEmitter(事件发射器)
EventEmitter实现发布-订阅模式,核心接口包括:on(注册事件监听)、emit(触发事件)、off(移除事件监听)、once(一次性监听)。这也是Node.js的核心模块之一。
最小示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Promise.all 最小示例
const p1 = Promise.resolve(1)
const p2 = new Promise(resolve => setTimeout(() => resolve(2), 100))
const p3 = Promise.resolve(3)
Promise.all([p1, p2, p3]).then(values => {
console.log(values) // [1, 2, 3]
})
// 深拷贝 最小示例
const original = { a: 1, b: { c: 2 } }
const cloned = deepClone(original)
cloned.b.c = 99
console.log(original.b.c) // 2(不受影响)
// EventEmitter 最小示例
const emitter = new EventEmitter()
const handler = (msg) => console.log(msg)
emitter.on('greet', handler)
emitter.emit('greet', 'Hello!') // 输出: Hello!
emitter.off('greet', handler)
核心知识点拆解
1. Promise.all 的边界情况与实现策略
关键边界:
- 输入为空数组 → 返回Promise.resolve([])
- 输入包含非Promise值 → 自动通过Promise.resolve包装
- 输入包含已reject的Promise → 立即reject
- 输入数组中的Promise以不同顺序resolve → 输出数组顺序与输入顺序一致
- 并发执行(非串行)
特性:快速失败(Fail-Fast)
Promise.all的设计哲学是”全有或全无”:一旦有一个Promise reject,立即reject,不管其他Promise是否已完成。这在某些场景是优点(如表单验证),但在另一些场景可能不合预期(如批量数据查询,希望获取部分结果)。
对比Promise.allSettled,它不会快速失败,而是等所有Promise都settled后返回每个Promise的状态:
1
2
3
4
5
6
7
8
9
10
Promise.all([fetch('/api/users'), fetch('/api/posts')])
.then(([users, posts]) => render(users, posts))
.catch(() => showError()) // 任一失败就报错
Promise.allSettled([fetch('/api/users'), fetch('/api/posts')])
.then(results => {
const successes = results.filter(r => r.status === 'fulfilled')
const failures = results.filter(r => r.status === 'rejected')
// 部分成功也可以工作
})
2. 深拷贝的核心难题
循环引用:对象直接或间接引用自身:
1
2
3
const obj = { name: 'Alice' }
obj.self = obj
// JSON.parse(JSON.stringify(obj)) → 报错: Converting circular structure to JSON
特殊对象类型:
- Date → 需要保留其时间值
- RegExp → 需要保留pattern和flags
- Map/Set → 需要保留条目
- Symbol key → Object.keys()无法获取
- 原型链 → 丢失
共享引用:多个属性指向同一个引用:
1
2
3
4
const shared = { data: 1 }
const obj = { a: shared, b: shared }
const cloned = deepClone(obj)
// 理想情况下,cloned.a === cloned.b(保持引用关系)
3. EventEmitter的内存管理
EventEmitter最大的陷阱是内存泄漏。当一个对象注册了事件监听但没有在销毁时解绑,会导致该对象无法被垃圾回收:
1
2
3
4
5
6
7
8
9
10
class PageComponent {
constructor(emitter) {
this.handleResize = () => {
console.log(this) // 持有this引用
}
emitter.on('resize', this.handleResize)
// 如果页面销毁时忘记 emitter.off('resize', this.handleResize)
// 这个PageComponent实例无法被GC回收
}
}
实战案例与完整实现
实战案例1:Promise.all 的完整实现(含错误处理和边界情况)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
* 手写 Promise.all
* @param {Array<Promise|any>} promises
* @returns {Promise<Array>}
*/
function promiseAll(promises) {
return new Promise((resolve, reject) => {
// 边界情况1:输入非可迭代对象
if (promises == null || typeof promises[Symbol.iterator] !== 'function') {
reject(new TypeError(`${typeof promises} is not iterable`))
return
}
const iterator = Array.from(promises)
const length = iterator.length
// 边界情况2:空数组
if (length === 0) {
resolve([])
return
}
const results = new Array(length)
let settledCount = 0
// 标记是否已经reject(用于防止多次reject)
let isRejected = false
iterator.forEach((item, index) => {
// 边界情况3:非Promise值通过Promise.resolve包装
Promise.resolve(item).then(
value => {
if (isRejected) return // 已失败,忽略后续fulfill
results[index] = value
settledCount++
// 全部完成时resolve
if (settledCount === length) {
resolve(results)
}
},
reason => {
if (!isRejected) {
isRejected = true
// 边界情况4:快速失败
reject(reason)
}
}
)
})
})
}
// 测试
const testCases = [
// 正常情况
promiseAll([Promise.resolve(1), Promise.resolve(2)])
.then(r => console.assert(JSON.stringify(r) === '[1,2]', 'test 1')),
// 空数组
promiseAll([]).then(r => console.assert(JSON.stringify(r) === '[]', 'test 2')),
// 混合非Promise值
promiseAll([1, Promise.resolve(2), 3])
.then(r => console.assert(JSON.stringify(r) === '[1,2,3]', 'test 3')),
// 失败情况
promiseAll([Promise.resolve(1), Promise.reject('error')])
.catch(e => console.assert(e === 'error', 'test 4')),
// 非数组
promiseAll('string').catch(e => console.assert(e instanceof TypeError, 'test 5')),
]
// 扩展:Promise.allSettled的实现
function promiseAllSettled(promises) {
const mapped = Array.from(promises).map(p =>
Promise.resolve(p).then(
value => ({ status: 'fulfilled', value }),
reason => ({ status: 'rejected', reason })
)
)
return Promise.all(mapped)
}
实战案例2:深拷贝的完整实现(最强版本)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
/**
* 深拷贝完整实现
* @param {any} obj - 要拷贝的对象
* @param {WeakMap} cache - 缓存已拷贝对象,解决循环引用
* @returns {any}
*/
function deepClone(obj, cache = new WeakMap()) {
// 基础类型和函数直接返回
if (obj === null || typeof obj !== 'object') {
return obj
}
// 检查缓存,防止循环引用
if (cache.has(obj)) {
return cache.get(obj)
}
// 处理Date
if (obj instanceof Date) {
const cloned = new Date(obj.getTime())
cache.set(obj, cloned)
return cloned
}
// 处理RegExp
if (obj instanceof RegExp) {
const cloned = new RegExp(obj.source, obj.flags)
cloned.lastIndex = obj.lastIndex
cache.set(obj, cloned)
return cloned
}
// 处理Map
if (obj instanceof Map) {
const cloned = new Map()
cache.set(obj, cloned)
obj.forEach((value, key) => {
cloned.set(deepClone(key, cache), deepClone(value, cache))
})
return cloned
}
// 处理Set
if (obj instanceof Set) {
const cloned = new Set()
cache.set(obj, cloned)
obj.forEach(value => {
cloned.add(deepClone(value, cache))
})
return cloned
}
// 处理Array
if (Array.isArray(obj)) {
const cloned = new Array(obj.length)
cache.set(obj, cloned)
for (let i = 0; i < obj.length; i++) {
cloned[i] = deepClone(obj[i], cache)
}
return cloned
}
// 处理普通Object(含Symbol key)
const cloned = Object.create(Object.getPrototypeOf(obj))
cache.set(obj, cloned)
// 拷贝所有属性(包括Symbol)
const allKeys = [
...Object.keys(obj),
...Object.getOwnPropertySymbols(obj),
]
for (const key of allKeys) {
const descriptor = Object.getOwnPropertyDescriptor(obj, key)
if (descriptor && descriptor.get) {
// 处理getter
Object.defineProperty(cloned, key, {
get: descriptor.get,
enumerable: descriptor.enumerable,
configurable: descriptor.configurable,
})
} else {
cloned[key] = deepClone(obj[key], cache)
}
}
return cloned
}
// 测试
const testDeepClone = () => {
// 基础对象
const obj1 = { a: 1, b: { c: 2 } }
const cloned1 = deepClone(obj1)
cloned1.b.c = 99
console.assert(obj1.b.c === 2, '普通对象深拷贝')
// 循环引用
const obj2 = { name: 'test' }
obj2.self = obj2
const cloned2 = deepClone(obj2)
console.assert(cloned2.self === cloned2, '循环引用处理')
console.assert(cloned2 !== obj2, '不是同一个对象')
// Date
const date = new Date('2024-01-01')
const clonedDate = deepClone(date)
console.assert(clonedDate.getTime() === date.getTime(), 'Date拷贝')
console.assert(clonedDate !== date, 'Date不是同一个引用')
// Map和Set
const map = new Map([['key', { value: 1 }]])
const clonedMap = deepClone(map)
clonedMap.get('key').value = 99
console.assert(map.get('key').value === 1, 'Map深拷贝')
// Symbol key
const sym = Symbol('test')
const objWithSym = { [sym]: 'secret' }
const clonedSym = deepClone(objWithSym)
console.assert(clonedSym[sym] === 'secret', 'Symbol key拷贝')
console.log('全部深拷贝测试通过!')
}
testDeepClone()
实战案例3:EventEmitter的完整实现(含once、监听器数量限制)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
/**
* EventEmitter完整实现
*/
class EventEmitter {
constructor() {
// 事件名 → 监听器数组
this._events = new Map()
// 每个事件的监听器数量上限(0表示不限制)
this._maxListeners = 10
}
/**
* 获取默认最大监听器数量
*/
static defaultMaxListeners = 10
/**
* 注册事件监听
* @param {string} event
* @param {Function} listener
* @returns {EventEmitter}
*/
on(event, listener) {
if (typeof listener !== 'function') {
throw new TypeError('listener must be a function')
}
if (!this._events.has(event)) {
this._events.set(event, [])
}
const listeners = this._events.get(event)
listeners.push(listener)
// 内存泄漏警告
const max = this._maxListeners || EventEmitter.defaultMaxListeners
if (max > 0 && listeners.length > max) {
console.warn(
`EventEmitter: 事件 "${event}" 已注册 ${listeners.length} 个监听器,` +
`超过上限 ${max}。可能的内存泄漏。`
)
}
return this // 支持链式调用
}
/**
* 注册一次性监听器
* @param {string} event
* @param {Function} listener
* @returns {EventEmitter}
*/
once(event, listener) {
const wrappedListener = (...args) => {
this.off(event, wrappedListener)
listener.apply(this, args)
}
wrappedListener._original = listener // 保存原始引用,用于off时的比较
this.on(event, wrappedListener)
return this
}
/**
* 移除事件监听
* @param {string} event
* @param {Function} listener
* @returns {EventEmitter}
*/
off(event, listener) {
if (!this._events.has(event)) return this
const listeners = this._events.get(event)
const filtered = listeners.filter(l =>
l !== listener && l._original !== listener
)
if (filtered.length === 0) {
this._events.delete(event)
} else {
this._events.set(event, filtered)
}
return this
}
/**
* 触发事件
* @param {string} event
* @param {...any} args
* @returns {boolean} 是否有监听器
*/
emit(event, ...args) {
if (!this._events.has(event)) return false
// 复制监听器数组,防止emit过程中off导致迭代异常
const listeners = [...this._events.get(event)]
for (const listener of listeners) {
listener.apply(this, args)
}
return true
}
/**
* 移除所有监听器
* @param {string} [event]
* @returns {EventEmitter}
*/
removeAllListeners(event) {
if (event) {
this._events.delete(event)
} else {
this._events.clear()
}
return this
}
/**
* 获取事件监听器数量
*/
listenerCount(event) {
if (!this._events.has(event)) return 0
return this._events.get(event).length
}
/**
* 获取所有事件名
*/
eventNames() {
return Array.from(this._events.keys())
}
}
// 测试
const emitter = new EventEmitter()
// 基础测试
emitter.on('data', msg => console.log('data:', msg))
emitter.emit('data', 'Hello') // data: Hello
// once测试
let count = 0
emitter.once('once', () => count++)
emitter.emit('once')
emitter.emit('once')
console.assert(count === 1, 'once只执行一次')
// off测试
const fn = () => {}
emitter.on('test', fn)
console.assert(emitter.listenerCount('test') === 1, '有1个监听器')
emitter.off('test', fn)
console.assert(emitter.listenerCount('test') === 0, '移除后为0')
// 链式调用
emitter.on('a', () => {}).on('b', () => {}).on('c', () => {})
console.assert(emitter.eventNames().length >= 2, '链式调用')
// 内存泄漏警告
EventEmitter.defaultMaxListeners = 3
const leakTest = new EventEmitter()
for (let i = 0; i < 5; i++) {
leakTest.on('leak', () => {})
}
// 控制台会输出warning
console.log('全部EventEmitter测试通过!')
底层原理
1. Promise.all 的规范与并发模型
根据ECMAScript规范,Promise.all的实现遵循以下步骤:
- 迭代器展开:将传入的可迭代对象展开为数组(通过
Array.from或GetIterator) - 并行执行:同时调用所有Promise的
then方法 - 结果聚合:维护一个结果数组,按索引位置填充
- 完成检测:使用计数器追踪已完成的Promise数量
关键设计考量:
- Promise.all的”并发”并非真正同时执行,而是所有Promise共享同一个事件循环tick来启动,这依赖于JavaScript的事件循环模型
- 结果数组的索引绑定发生在Promise创建时,而非resolved时,确保输出顺序与输入顺序一致——这是通过闭包(index参数)实现的
2. 深拷贝的对象布局与内存模型
JavaScript的对象在内存中是堆上的一块区域,变量持有的是指向这块区域的引用:
1
2
3
4
5
6
栈区(变量) 堆区(对象)
obj ──────────────────→ { name: 'Alice', child: addr2 }
↑
cloned ────────────────── { name: 'Alice', child: addr3 }
↑
新的堆对象
深拷贝的实质是递归遍历对象图,在每个节点创建一个新的堆对象,断开所有引用链。这里的关键决策点是缓存策略:使用WeakMap保存”原对象→新对象”的映射。
为什么使用WeakMap?两个原因:
- 弱引用:当原始对象被GC回收时,WeakMap中的条目也会自动消失,不造成内存泄漏
- 引用作为key:可以直接用原始对象作为key,不需要生成唯一的id标识符
3. EventEmitter的事件循环与回调执行
EventEmitter的emit调用是同步的。这意味着:
1
2
3
4
5
emitter.on('event', () => console.log('A'))
console.log('B')
emitter.emit('event') // 同步执行监听器
console.log('C')
// 输出: B → A → C
这一设计在Node.js的EventEmitter源码中得到确认:监听器调用是同步的listener.apply(target, args)。
但如果监听器中包含异步操作:
1
2
3
4
5
6
7
emitter.on('async', async () => {
await fetch('/api')
console.log('after fetch')
})
emitter.emit('async')
console.log('after emit')
// 输出: 'after emit' → 'after fetch'(因为监听器是async函数,返回Promise,但emit不await它)
高频面试题解析
面试题1:Promise.all中如果有一个Promise被reject了,其他Promise还会执行吗?
问题:假如有5个Promise,第3个reject了,JavaScript引擎还会执行第4、第5个Promise吗?
答案:会执行。这是一个常见误区——认为Promise.all的”快速失败”机制会”取消”其他正在执行的Promise。
真相是:Promise.reject只是让Promise.all返回的Promise立即reject,但其他Promise的异步操作并不会停止。原因如下:
- Promise的异步操作一旦启动就无法”取消”(除非配合AbortController)
- Promise.all只负责”观察”传入的Promise,不负责”控制”它们
- reject的回调只是让Promise.all内部不再将后续fulfill的结果纳入结果数组
1
2
3
4
5
6
7
8
9
10
11
const slowPromise = new Promise(resolve => {
setTimeout(() => {
console.log('slowPromise 仍然执行完了')
resolve('slow')
}, 3000)
})
Promise.all([Promise.reject('fast fail'), slowPromise])
.catch(err => console.log('捕获:', err))
// 3秒后控制台仍然输出: slowPromise 仍然执行完了
如果需要”取消”行为,需要在Promise链中配合AbortSignal:
1
2
3
4
5
6
7
8
const controller = new AbortController()
Promise.all([
fetch('/api/a', { signal: controller.signal }),
fetch('/api/b', { signal: controller.signal }),
]).catch(() => {
controller.abort() // 取消所有fetch请求
})
面试题2:深拷贝如何解决递归爆栈问题?
问题:对于深度嵌套的对象(如深度10000层),深拷贝函数会导致递归调用栈溢出。如何解决?
答案:递归深拷贝无法处理深度极大的对象,因为JavaScript调用栈有固定大小限制(通常约1万层)。解决方案是迭代式深拷贝,使用显式栈来模拟递归:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function deepCloneIterative(obj) {
if (obj === null || typeof obj !== 'object') return obj
const root = Array.isArray(obj) ? [] : {}
// 栈结构:[当前节点, 父节点, 属性名]
const stack = [[obj, root, null]]
const cache = new WeakMap()
cache.set(obj, root)
while (stack.length > 0) {
const [source, target] = stack.pop()
const keys = [...Object.keys(source), ...Object.getOwnPropertySymbols(source)]
for (const key of keys) {
const value = source[key]
if (value === null || typeof value !== 'object') {
target[key] = value // 基本类型直接赋值
} else {
if (cache.has(value)) {
// 循环引用:使用缓存中的值
target[key] = cache.get(value)
} else {
// 创建新容器
const cloned = Array.isArray(value) ? [] : {}
target[key] = cloned
cache.set(value, cloned)
stack.push([value, cloned, key]) // 入栈待处理
}
}
}
}
return root
}
迭代式深拷贝可以处理任意深度的对象,不依赖调用栈。不过实际面试中,通常只要求递归版本,但能主动提到迭代方案属于加分项。
面试题3:EventEmitter的.once是怎么实现的?
问题:EventEmitter的once方法如何实现”只触发一次”?off时如何找到wrapped的函数?
答案:once的核心实现是一个装饰器模式——创建一个包装函数,在执行原始函数之前先调用off移除自己:
1
2
3
4
5
6
7
8
9
once(event, listener) {
const wrappedListener = (...args) => {
this.off(event, wrappedListener) // 先移除自己
listener.apply(this, args) // 再执行原始函数
}
// 保存原始引用,用于区分
wrappedListener._original = listener
this.on(event, wrappedListener)
}
这里有一个关键问题:如果用户调用off(event, originalListener)来移除监听器,但内部实际注册的是wrappedListener,直接比较会匹配失败。解决方案就是通过_original属性保存原始函数的引用,在off内部同时检查l._original === listener:
1
2
3
4
5
6
7
off(event, listener) {
const listeners = this._events.get(event)
const filtered = listeners.filter(l =>
l !== listener && l._original !== listener
)
// ...
}
另一种方案不使用_original,而是维护一个Map<listener, wrapped>映射表,但核心思路相同:需要一个额外的映射关系来桥接原始函数和包装函数。
总结与扩展
三高手写题的核心要点总结:
| 手写题 | 核心考点 | 边界情况 | 扩展方向 |
|---|---|---|---|
| Promise.all | 并发控制、结果聚合 | 空数组、非Promise值 | Promise.allSettled、Promise.race、AbortController |
| 深拷贝 | 引用传递、对象图遍历 | 循环引用、特殊类型 | 迭代实现、结构化克隆API、不可变数据结构 |
| EventEmitter | 发布订阅、回调执行 | 内存泄漏、once | 中间件模式、事件优先级、异步发射器 |
扩展思考:
在实际项目中,多数场景不需要手写这些工具——Promise.all是原生API,lodash提供了成熟的cloneDeep,Node.js内置了EventEmitter。但手写的价值在于:
- 理解边界:只有手写一遍,才会真正理解Promise.all”快速失败”的严格语义
- 迁移能力:理解了EventEmitter的本质,就能快速上手Redux的subscribe、Vue的$emit等任何事件系统
- 排查bug:遇到深拷贝导致的引用共享bug时,手写过的人能第一时间定位问题
真正的进阶方向是超越”手写”,思考这些模式的替代方案和演进方向:
- Promise.all → async/await + Promise.allSettled + structuredClone
- 深拷贝 → Immer的produce(写时复制)→ 事件溯源
- EventEmitter → RxJS Observable(响应式流)→ Signal
从工具使用到原理理解,再到架构思考,这正是前端工程师成长的三段路。