Let's Go! 🚀
React 篇:深入原理,不再迷糊
1. useState 是同步还是异步的?
这个问题有点“坑”,不能简单地用“同步”或“异步”来概括。
- 在 React 合成事件处理函数或生命周期函数中,
setState
的表现是“异步”的。 这里打引号是因为 React 并不是真的用了异步 API(像setTimeout
),而是它会进行 批量更新(Batching)。在一个事件处理函数中多次调用setState
,React 会将它们合并,只触发一次重新渲染,以优化性能。所以,你在调用setState
后立即访问 state,拿到的还是旧值。
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1); // 安排更新,但 count 还是 0
console.log(count); // 输出 0
setCount(count + 1); // 再次安排更新,count 还是 0
console.log(count); // 依然输出 0
// React 会合并这两次更新,最终 count 变为 1(而不是 2)
}
// ...
}
- 在
setTimeout
、setInterval
、原生 DOM 事件处理函数或Promise
回调中,setState
的表现是“同步”的。 因为这些场景脱离了 React 的事务(Transaction)或批处理上下文,每次setState
调用都会立即触发一次重新渲染,并且调用后能马上获取到新值。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
setCount(count + 1); // 立即触发渲染
console.log(count + 1); // React 18 前会立即生效,输出 1 (React 18 默认也会批处理)
}, 0);
}, []);
// ...
}
- React 18 的变化: React 18 引入了 自动批处理(Automatic Batching)。现在,默认情况下,即使在
setTimeout
或Promise
回调中,React 也会尝试进行批处理。这意味着在 React 18+ 中,setState
的行为在各种场景下都更趋向于“异步”(批处理)。如果你确实需要同步更新,可以使用flushSync
。
小结: 理解关键在于 批处理 机制,以及 React 18 的自动批处理带来的行为统一。
2. useState 的源码实现?
源码这块儿,咱不用一行行啃,抓住核心就行。
useState
本质上是 useReducer
的一个语法糖(简化版)。
核心逻辑围绕着 Hooks 链表 和 Fiber 节点:
-
首次渲染(Mount):
- 当你调用
useState(initialState)
时,React 会创建一个hook
对象,包含状态值memoizedState
和一个更新队列queue
。 - 这个
hook
对象会被添加到当前组件 Fiber 节点的memoizedState
链表上。 - 返回
[currentState, dispatchFn]
,dispatchFn
就是那个用来更新状态的函数。
- 当你调用
-
更新渲染(Update):
- 当你调用
setCount(newState)
(也就是dispatchFn
)时:- 创建一个
update
对象,包含新的状态或更新函数。 - 将这个
update
对象添加到对应hook
的queue
队列中。 - 触发一次 React 的更新调度。
- 创建一个
- 在组件重新渲染时,React 会按顺序找到对应的
hook
对象。 - 执行
queue
中的所有update
,计算出最新的memoizedState
。 - 返回新的
[currentState, dispatchFn]
。
- 当你调用
关键点:
- Hooks 存储在 Fiber 节点的链表上。
setState
函数(dispatchFn
)负责创建更新对象并加入队列,然后触发调度。- 重新渲染时,根据队列计算新状态。
3. useState 怎么用链表处理的?
这个和上一个问题紧密相关。
React 组件中的 每个 Hook 调用(useState
, useEffect
, useMemo
等)都会创建一个对应的 Hook 对象。在一个组件实例(对应的 Fiber 节点)中,这些 Hook 对象会按照它们 被调用的顺序 形成一个 单向链表。
- Fiber 节点上有个属性(比如
memoizedState
)指向这个链表的 头节点。 - 每个 Hook 对象内部有一个
next
指针,指向 下一个 Hook 对象。
为什么是链表?
- 状态隔离: 每个 Hook 对象存储自己的状态 (
memoizedState
) 和更新队列 (queue
)。 - 顺序保证: 链表结构保证了每次渲染时,只要 Hook 的调用顺序不变,React 总能准确地找到每个 Hook 对应的状态。这就是为什么 不能在条件语句或循环中调用 Hooks,否则链表顺序会错乱,导致状态取错。
想象一下:
useState(0)
-> Hook1 (next 指向 Hook2)
useEffect(() => {})
-> Hook2 (next 指向 Hook3)
useState('')
-> Hook3 (next 指向 null)
每次渲染,React 就顺着这个链表走一遍,取状态、执行 effect。
4. 其他 hooks 的实现原理?
简单过一下几个常用的:
-
useEffect
:- 类似
useState
,也在 Hook 链表上创建节点。 - 存储
effect
函数、cleanup
函数和deps
依赖数组。 - 在 Commit 阶段(DOM 更新后)异步执行
effect
函数。 - 下次执行
effect
前或组件卸载时,执行上一次的cleanup
函数。 - 依赖项检查:只有
deps
数组中的值发生变化时,才会重新执行effect
。
- 类似
-
useContext
:- 读取
Context
对象,并订阅其变化。 - 实现上会向上遍历 Fiber 树,找到最近的
Context.Provider
,获取其value
。 - 当
Provider
的value
变化时,所有订阅了该Context
的组件都会重新渲染。
- 读取
-
useReducer
:useState
的“底层”实现。- 原理与
useState
类似,但管理状态更新的逻辑更集中在reducer
函数中。 dispatch(action)
会将action
添加到更新队列,渲染时由reducer(currentState, action)
计算新状态。
-
useMemo
/useCallback
:- Memoization(记忆化) 的实现。
- 也在 Hook 链表上创建节点,存储计算函数/回调函数和
deps
依赖数组。 - 只有当
deps
变化时,才 重新计算useMemo
的值或 重新创建useCallback
的函数实例,否则返回上一次缓存的结果。
5. fiber 原理?
Fiber 是 React v16 引入的核心 调度和协调(Reconciliation) 架构。你可以把它想象成 React 内部管理工作的一种方式。
为什么需要 Fiber?
- 旧的 Stack Reconciler 是 同步递归 的,一旦开始更新,就必须一口气完成,如果组件树很大,可能会导致 主线程长时间阻塞,影响用户交互和动画流畅度。
Fiber 的核心思想:
- 可中断、可恢复: 把庞大的更新任务 分片(Chunking) 成一个个小的 工作单元(Fiber Node)。每个 Fiber 节点代表一个组件实例、DOM 节点或其他工作。
- 优先级调度: 可以给不同的更新任务(如用户输入、动画、数据请求)分配 优先级。高优先级的任务(如输入响应)可以 打断 正在进行的低优先级任务(如渲染长列表)。
- 增量渲染: 可以在浏览器的 空闲时间 执行这些工作单元,不阻塞主线程。
- 更好的并发: 为未来的并发模式(Concurrent Mode)奠定基础。
两个主要阶段:
- Render/Reconciliation Phase (可中断):
- 构建 Fiber 树 (WorkInProgress Tree),对比新旧 VDOM(Diffing)。
- 计算出需要进行的 DOM 操作(增、删、改)。
- 这个阶段的工作可以被更高优先级的任务打断。
- Commit Phase (不可中断):
- 将 Render 阶段计算出的所有 DOM 操作 一次性、同步地 应用到真实 DOM 上。
- 执行相关的生命周期方法(如
componentDidMount/Update
)和useEffect
的 effect/cleanup。
Fiber 节点结构:
每个 Fiber 节点是个 JS 对象,包含组件类型、props、state、DOM 引用,以及 child
、sibling
、return
三个指针,用来连接成 Fiber 树,方便遍历和调度。
6. 任务调度机制 scheduler, 各个调度器?
为了让 Fiber 的可中断和优先级调度落地,React 引入了一个独立的包:scheduler
。
核心职责:
- 时间分片(Time Slicing): 与浏览器协作,在每一帧的空闲时间内执行 React 的工作,如果时间不够了就 主动让出(Yield) 主线程控制权,等待下一帧。
- 优先级管理: 维护一个 任务队列(通常是小顶堆),根据任务的 优先级 和 过期时间 来决定下一个要执行的任务。优先级高的(如用户交互
ImmediatePriority
)先执行。 - 宏任务/微任务模拟: 使用
MessageChannel
(优先)或setTimeout
来模拟实现 低优先级的宏任务,确保 React 的工作不会阻塞更高优先级的浏览器任务(如渲染、用户输入)。
调度器的工作流程(简化版):
- React 组件
setState
或触发更新,会创建一个带有优先级的更新任务。 - 这个任务被提交给
scheduler
。 scheduler
将任务放入优先级队列。scheduler
请求浏览器在下一帧空闲时执行一个回调(performWorkUntilDeadline
)。- 浏览器空闲时,调用该回调。
- 回调函数从队列中取出最高优先级的任务,开始执行 Fiber 的 Render 阶段工作。
- 持续工作,直到:
- 当前任务完成。
- 分配的时间片用完(通过
navigator.scheduling.isInputPending
或简单的时间检查判断)。 - 有更高优先级的任务插入。
- 如果工作没做完就暂停,记录下当前进度,等待下一次调度。
- 当整个 Render 阶段完成后,进入同步的 Commit 阶段。
7. 自己模拟实现一个 requestIdleCallback?
requestIdleCallback
是一个浏览器 API,允许你在浏览器 主线程空闲时 执行一些低优先级的任务。但它有兼容性问题,且触发频率不稳定。React 的 scheduler
实际上用了更可靠的 MessageChannel
或 setTimeout
来模拟类似效果。
模拟实现的核心思路是:利用宏任务(如 setTimeout(fn, 0)
或 MessageChannel
)将任务推迟到事件循环的后续阶段执行,并模拟一个 deadline
对象。
// 极简版模拟,主要体现延迟执行和 deadline 概念
function requestIdleCallbackShim(callback, options = {}) {
const timeout = options.timeout || 0; // 可选的超时时间
const channel = new MessageChannel();
const port1 = channel.port1;
const port2 = channel.port2;
let frameDeadline; // 模拟的截止时间
let timeoutId;
// 任务执行函数
const runTask = () => {
const currentTime = performance.now();
// 简单模拟:给 50ms 的空闲时间预算
frameDeadline = currentTime + 50;
// 模拟 deadline 对象
const deadline = {
didTimeout: false, // 是否超时
timeRemaining: function () {
// 返回剩余时间,至少为 0
return Math.max(0, frameDeadline - performance.now());
},
};
// 如果设置了超时,检查是否已经超时
if (timeout > 0) {
timeoutId = setTimeout(() => {
deadline.didTimeout = true;
try {
callback(deadline);
} catch (e) {
console.error(e);
}
}, timeout);
}
try {
// 调用用户传入的回调,并传入 deadline 对象
callback(deadline);
} catch (e) {
console.error(e);
} finally {
// 如果设置了超时,清除超时计时器
if (timeoutId) {
clearTimeout(timeoutId);
}
}
};
// 使用 MessageChannel 发送消息,触发 runTask 在下一个宏任务执行
port2.onmessage = runTask;
port1.postMessage(null);
// 返回一个可以取消任务的 ID(这里简单返回 null,实际可以更完善)
return null; // 实际应返回一个可以调用 cancelIdleCallback 的 id
}
// 对应的 cancelIdleCallback (极简)
function cancelIdleCallbackShim(id) {
// 需要配合 requestIdleCallbackShim 的实现来取消
// 比如清除 MessageChannel 的 onmessage 或清除 setTimeout
}
// 使用示例
requestIdleCallbackShim((deadline) => {
console.log(`当前帧剩余时间: ${deadline.timeRemaining()}ms`);
if (deadline.timeRemaining() > 0 || deadline.didTimeout) {
// 做一些低优先级的工作...
console.log("Executing low priority task...");
} else {
// 时间不够了,安排下次再做
requestIdleCallbackShim(/* ... */);
}
}, { timeout: 1000 }); // 1秒内必须执行
注意: 这是一个非常简化的模拟,仅用于理解概念。实际的 Polyfill 会更复杂,需要处理更多边界情况和兼容性。React scheduler
的实现比这精妙得多。
8. 如何调度延迟任务?
这取决于“延迟”的场景:
- 简单的、一次性的延迟: 使用浏览器原生的
setTimeout
。如果你在 React 组件中用,记得在useEffect
里使用,并在 cleanup 函数中clearTimeout
,防止内存泄漏和意外行为。
useEffect(() => {
const timerId = setTimeout(() => {
// 执行延迟任务
console.log("Delayed task executed!");
}, 1000); // 延迟 1 秒
return () => {
clearTimeout(timerId); // 组件卸载或 effect 重新执行前清除
};
}, []); // 空依赖数组表示只在 mount 时设置,unmount 时清除
-
React 内部的、需要协调的延迟任务: React 的
scheduler
本身就处理任务的调度和优先级,虽然它不直接提供一个“延迟 N 毫秒执行”的 API,但它会根据任务优先级和当前主线程的繁忙程度来决定何时执行。React 的目标是 尽快 响应用户,并在 不阻塞 的前提下完成后台任务,而不是精确地按固定延迟执行。 -
周期性任务: 使用
setInterval
,同样要在useEffect
中管理,并在 cleanup 时clearInterval
。
小结: 对于应用层面的简单延迟,用 setTimeout
+ useEffect
管理。对于 React 渲染更新相关的调度,交给 React 和 scheduler
处理优先级即可。
9. diff 算法原理?
Diff 算法是 React Reconciliation(协调) 过程中的关键部分,用来比较 新旧两棵虚拟 DOM 树(或者说新 VDOM 和旧 Fiber 树),计算出 最小化的 DOM 操作。
React 的 Diff 算法基于几个 启发式策略,以达到 O(n) 的复杂度:
-
Tree Diff (树比较):
- 只比较同层节点,不跨层级比较。如果一个节点在父节点中的位置变了(比如从
div > p
变成span > p
),React 不会尝试复用p
,而是直接删除旧的div
子树,创建新的span
子树。 - 这大大简化了比较,因为跨层级移动节点的操作在实际开发中很少见,牺牲这部分性能换来了算法效率。
- 只比较同层节点,不跨层级比较。如果一个节点在父节点中的位置变了(比如从
-
Component Diff (组件比较):
- 类型相同: 如果新旧 VDOM 节点的组件类型相同(如都是
<MyComponent>
),React 会 保持组件实例不变,只更新该实例的props
,然后 递归 对其子节点进行 Diff。 - 类型不同: 如果组件类型不同(如从
<MyComponent>
变成<YourComponent>
),React 会 卸载 旧组件(调用componentWillUnmount
),创建 新组件实例(调用构造函数和componentDidMount
)。旧组件及其所有子节点都会被完全销毁。
- 类型相同: 如果新旧 VDOM 节点的组件类型相同(如都是
-
Element Diff (元素/列表比较):
- 这是最复杂的部分,尤其针对 同一层级的一组子节点(如
ul
下的多个li
)。 - React 会遍历新的子节点列表,并尝试在旧的子节点列表中找到 具有相同
key
的节点。 key
的作用:key
是给同一层级兄弟节点的一个 唯一标识。React 用key
来 快速匹配 新旧列表中的节点,判断哪些是新增、删除、移动或更新。- 没有
key
或key
不稳定(如用index
作key
): 会导致效率低下,甚至出现状态混乱的问题。React 可能需要进行大量不必要的 DOM 创建和销毁。 - 有稳定
key
: React 可以高效地识别出节点的移动(只需调整 DOM 顺序,无需销毁重建),以及精确地增删节点。
- 这是最复杂的部分,尤其针对 同一层级的一组子节点(如
总结: React Diff = 同层比较 + 同类型组件复用 + key 优化列表比较。key
至关重要!
10. commit 阶段做了什么?
Commit 阶段是 React 更新流程的 第二个(也是最后一个)主要阶段。这个阶段是 同步的、不可中断的,因为它要操作真实的 DOM。
主要工作:
-
DOM 操作:
- 根据 Render 阶段计算出的 Effect List(一个记录了所有需要执行的 DOM 操作的链表),执行实际的 DOM 增、删、改操作。
- 例如:
parent.appendChild(newDomNode)
,node.removeChild(child)
,node.setAttribute('class', 'new-class')
等。 - 更新 DOM 节点的属性、样式等。
-
调用生命周期方法 / Hooks:
- 类组件:
- 调用
componentDidMount
(对于新插入的组件)。 - 调用
componentDidUpdate
(对于更新的组件)。 - 调用
getSnapshotBeforeUpdate
(在 DOM 更新前,Commit 阶段开始时)。
- 调用
- 函数组件 (Hooks):
- 调用
useEffect
的 cleanup 函数(对于上次渲染的 effect)。 - 调用
useEffect
的 effect 函数(对于本次渲染的 effect)。这些 effect 是 异步调度 的,不会阻塞浏览器绘制。 - 调用
useLayoutEffect
的 cleanup 和 effect 函数。这些是 同步执行 的,在浏览器绘制之前完成,可以用来读取或同步修改 DOM 布局。 - 调用
useInsertionEffect
(React 18),在所有 DOM 变更之前同步触发,主要用于 CSS-in-JS 库注入样式。
- 调用
- 类组件:
-
其他副作用:
- 比如更新
ref
对象指向的 DOM 节点。 - 触发 profiler API 等。
- 比如更新
一句话概括: Commit 阶段就是把 Render 阶段“算好的账”(DOM 变更和副作用),实打实地“结清”(应用到 DOM 并调用相关函数)。
11. 合成事件的实现原理?
React 并未直接使用原生的浏览器事件,而是实现了一套 合成事件系统(SyntheticEvent System)。
为什么需要合成事件?
- 跨浏览器兼容性: 抹平不同浏览器之间事件对象的差异(如
event.target
vsevent.srcElement
),提供一致的 API。 - 性能优化:
- 事件委托(Event Delegation): React 不会 在每个可能触发事件的 DOM 元素上都绑定监听器。而是在 文档的根节点(如
document
或 React 17+ 的root
节点)上监听 所有 事件类型。当事件触发并冒泡到根节点时,React 根据event.target
找到触发事件的 React 组件,并 模拟事件冒泡/捕获 流程,调用对应的组件事件处理函数。这大大减少了事件监听器的数量。 - 事件对象池(Event Pooling): 为了避免频繁创建和销毁事件对象带来的性能开销和 GC 压力,React 维护了一个 合成事件对象池。当事件触发时,从池中取出一个合成事件对象,填充好信息(如
nativeEvent
,target
等)后传递给处理函数。事件处理函数执行完毕后,该合成事件对象会被 重置并放回池中,供下次复用。(注意: React 17+ 已经 取消了事件池,因为现代浏览器 JS 引擎性能提升,事件池带来的优化效果不再明显,反而增加了理解成本和一些潜在问题。)
- 事件委托(Event Delegation): React 不会 在每个可能触发事件的 DOM 元素上都绑定监听器。而是在 文档的根节点(如
工作流程(简化版,React 17+ 无事件池):
- React 应用启动时,在根节点通过
addEventListener
注册各种事件的监听器(只需注册一次)。 - 用户操作(如点击按钮)触发原生 DOM 事件。
- 事件冒泡到根节点,被 React 的根监听器捕获。
- React 根据原生事件的
target
找到对应的 Fiber 节点。 - React 创建一个 SyntheticEvent 对象,包装原生事件
nativeEvent
,并提供兼容的属性和方法(如stopPropagation
,preventDefault
)。 - React 模拟事件在 React 组件树中的 冒泡和捕获 阶段,按顺序调用路径上所有绑定了该事件类型(如
onClick
,onClickCapture
)的处理函数,并将 SyntheticEvent 对象传给它们。 - 所有处理函数执行完毕。
12. 不同节点连接原理?
这个问题有点模糊,我猜测它可能是在问 Fiber 树中节点是如何连接 的,或者是 Diff 算法中新旧节点如何关联。我们主要按 Fiber 树连接来理解。
在 Fiber 架构 中,组件树或 DOM 树中的每个单元(组件、DOM 元素、文本节点、Portals 等)都对应一个 Fiber 节点。这些 Fiber 节点通过几个关键的 指针 相互连接,形成一个 链表树 结构,方便 React 进行遍历和调度。
主要的连接指针:
-
return
(父指针): 指向当前 Fiber 节点的 父 Fiber 节点。当一个工作单元完成时,可以通过return
指针返回到父节点,继续处理兄弟节点或返回更上层。它代表了逻辑上的父子关系。 -
child
(子指针): 指向当前 Fiber 节点的 第一个子 Fiber 节点。当处理完一个节点后,下一步通常是处理它的child
。 -
sibling
(兄弟指针): 指向当前 Fiber 节点的 下一个兄弟 Fiber 节点。当处理完一个节点的child
(以及该 child 的所有子孙)后,会通过sibling
指针移动到下一个兄弟节点继续处理。
遍历顺序(深度优先): React 的工作循环(Work Loop)通常按照这个顺序遍历 Fiber 树:
- 先尝试向下移动到
child
。 - 如果没有
child
,或者child
处理完了,尝试向右移动到sibling
。 - 如果没有
sibling
,或者sibling
处理完了,通过return
指针向上移动,然后尝试移动到父节点的下一个sibling
。 - 重复这个过程,直到遍历完整个 Fiber 树。
这种连接方式使得 React 可以在任何一个 Fiber 节点 暂停 工作,记录下当前位置,然后在恢复时从断点处 继续 遍历和工作,这是实现 可中断渲染 的基础。
JavaScript 基础 & 手写篇:内功修炼,必不可少
13. 生成器、迭代器?
这两个是 ES6 引入的重要概念,通常一起使用。
- 迭代器(Iterator):
- 一个 对象,它知道如何 按顺序 访问一个 集合 中的项。
- 它必须实现一个
next()
方法。 next()
方法每次被调用时,返回一个包含两个属性的对象:value
: 集合中的下一个值。done
: 一个布尔值,如果集合已经迭代完毕,则为true
,否则为false
。
// 手动创建一个简单的迭代器
function makeRangeIterator(start = 0, end = Infinity, step = 1) {
let nextIndex = start;
let iterationCount = 0;
const rangeIterator = {
next: function() {
let result;
if (nextIndex < end) {
result = { value: nextIndex, done: false };
nextIndex += step;
iterationCount++;
return result;
}
return { value: iterationCount, done: true }; // 迭代结束
}
};
return rangeIterator;
}
const it = makeRangeIterator(1, 4);
console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: 3, done: true }
-
可迭代对象(Iterable):
- 一个对象,如果它实现了
Symbol.iterator
方法,那么它就是可迭代的。 Symbol.iterator
方法是一个 工厂函数,调用它会 返回一个迭代器对象。- 像
Array
,String
,Map
,Set
都是内置的可迭代对象。for...of
循环就是专门用来遍历可迭代对象的。
- 一个对象,如果它实现了
-
生成器(Generator):
- 一种 特殊的函数,可以 暂停执行 并在稍后 恢复。
- 用
function*
语法定义。 - 内部使用
yield
关键字来 产出(返回) 一个值,并 暂停 函数执行。 - 调用生成器函数 不会立即执行 函数体,而是 返回一个迭代器对象(生成器对象)。
- 每次调用生成器对象的
next()
方法时,函数会从上次yield
暂停的地方 恢复执行,直到遇到下一个yield
、return
或函数结束。
function* numberGenerator() {
console.log("Generator starts");
yield 1;
console.log("After yield 1");
yield 2;
console.log("After yield 2");
yield 3;
console.log("Generator ends");
return "Finished"; // return 的值在最后一次 next() 的 value 中,done 为 true
}
const gen = numberGenerator(); // 调用生成器函数,返回生成器对象 (迭代器)
console.log(gen.next()); // 输出 "Generator starts", { value: 1, done: false }
console.log(gen.next()); // 输出 "After yield 1", { value: 2, done: false }
console.log(gen.next()); // 输出 "After yield 2", { value: 3, done: false }
console.log(gen.next()); // 输出 "After yield 3", "Generator ends", { value: "Finished", done: true }
console.log(gen.next()); // { value: undefined, done: true }
关系: 生成器函数是创建迭代器的一种 便捷方式。它自动为你处理了 next()
方法和状态管理。
用途:
- 简化创建自定义迭代器的过程。
- 实现惰性计算(按需生成值)。
- 在 Redux-Saga 等库中用于 管理异步流程,以同步代码的风格编写异步逻辑。
14. for in 和 for of 的区别?
这两个循环长得像,但干的活儿和适用对象完全不同:
for...in
:- 遍历对象的可枚举属性键(key)。
- 遍历的是 字符串类型的键名。
- 会遍历对象 自身 的可枚举属性,以及 它从 原型链 上继承来的可枚举属性。
- 不 适合用来遍历 数组,因为:
- 它遍历的是数组的 索引(字符串形式),而不是元素值。
- 遍历顺序 不一定 是按数组元素的顺序。
- 可能会遍历到数组原型上添加的属性或方法。
- 通常用于 遍历普通对象 的属性。
const obj = { a: 1, b: 2 };
Object.prototype.c = 3; // 在原型上添加属性
for (const key in obj) {
// 建议加上 hasOwnProperty 判断,只遍历自身属性
if (Object.hasOwnProperty.call(obj, key)) {
console.log(key); // 输出 'a', 'b' (顺序不保证)
}
}
// 如果不加 hasOwnProperty,还会输出 'c'
const arr = ['x', 'y'];
arr.foo = 'bar'; // 给数组添加属性
for (const index in arr) {
console.log(index); // 输出 '0', '1', 'foo' (字符串索引和属性名)
}
for...of
:- 遍历可迭代对象(Iterable)的元素值(value)。
- 适用于内置的可迭代对象,如
Array
,String
,Map
,Set
,arguments
对象,以及NodeList
等 DOM 集合。 - 也适用于通过
Symbol.iterator
实现的 自定义可迭代对象 和 生成器对象。 - 不 能直接用于遍历 普通对象(因为普通对象默认不是可迭代的)。
- 遍历顺序通常是 按照 集合中元素的顺序(如数组的索引顺序)。
- 是遍历数组元素的推荐方式。
const arr = ['a', 'b', 'c'];
for (const value of arr) {
console.log(value); // 输出 'a', 'b', 'c'
}
const str = "hello";
for (const char of str) {
console.log(char); // 输出 'h', 'e', 'l', 'l', 'o'
}
const map = new Map([['a', 1], ['b', 2]]);
for (const [key, value] of map) {
console.log(key, value); // 输出 'a' 1, 'b' 2
}
// const obj = { x: 1, y: 2 };
// for (const value of obj) { // TypeError: obj is not iterable
// console.log(value);
// }
总结:
- 遍历 对象属性键 用
for...in
(记得hasOwnProperty
)。 - 遍历 数组/字符串/Map/Set 等元素值 用
for...of
。
15. 判断一个对象是空对象,最优解?
判断一个对象是否为空(即没有任何自身的、可枚举的属性),最常用且推荐的方法是:
最优解:Object.keys(obj).length === 0
function isEmptyObject(obj) {
// 首先确保是对象且不为 null
if (typeof obj !== 'object' || obj === null) {
return false; // 或者根据需求抛出错误或返回 true/false
}
// 检查是否有自身可枚举属性
return Object.keys(obj).length === 0;
}
console.log(isEmptyObject({})); // true
console.log(isEmptyObject({ a: 1 })); // false
console.log(isEmptyObject(null)); // false
console.log(isEmptyObject([])); // true (数组也是对象,且 length 为 0 时 keys 也为空) - 注意这点!
console.log(isEmptyObject(Object.create(null))); // true
为什么这个最优?
- 语义清晰:
Object.keys()
返回一个包含对象 自身可枚举属性键 的数组,检查其长度是否为 0 非常直观。 - 性能较好: 这是 V8 等现代 JS 引擎优化过的操作。
- 兼容性好: ES5 标准方法。
其他方法及其缺点:
for...in
循环 + 计数器/标志位:
function isEmptyObjectForIn(obj) {
if (typeof obj !== 'object' || obj === null) return false;
for (const key in obj) {
// 必须用 hasOwnProperty 排除原型链属性
if (Object.hasOwnProperty.call(obj, key)) {
return false; // 只要找到一个自身属性,就不是空的
}
}
return true;
}
* **缺点:** 代码稍显冗余;需要注意 `hasOwnProperty`;理论上可能比 `Object.keys()` 慢一点点(需要启动循环)。
2. JSON.stringify(obj) === '{}'
:
function isEmptyObjectJson(obj) {
// 这种方法对 null 和非对象类型处理不佳,需额外判断
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
return false;
}
try {
return JSON.stringify(obj) === '{}';
} catch (e) {
// 处理循环引用等 stringify 错误
return false;
}
}
* **缺点:**
* **性能差:** 序列化和字符串比较开销大。
* **功能限制:** 如果对象包含 `undefined`、`Symbol` 值或函数作为属性值,这些属性在 `JSON.stringify` 时会被忽略,可能导致误判。
* 无法处理包含循环引用的对象(会抛错)。
* 对 `new Date()` 等特殊对象行为不符合“空对象”直觉。
结论: 对于判断一个普通对象是否没有自身可枚举属性,Object.keys(obj).length === 0
是最简洁、高效和常用的方法。需要注意它对空数组也会返回 true
,如果需要区分数组和对象,应先用 Array.isArray()
判断。
16. 手写 new 运算符?
面试经典手写题!new
运算符在 JavaScript 中用于创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。其执行过程可以分解为以下步骤:
function myNew(Constructor, ...args) {
// 1. 创建一个新的空对象
// 这个新对象的原型 (__proto__) 应该指向构造函数的 prototype 对象
// 可以使用 Object.create() 来实现这一点
const newObject = Object.create(Constructor.prototype);
// 2. 将构造函数的 this 指向这个新创建的对象,并执行构造函数
// 使用 apply 或 call 来改变 this 指向,并传递参数
const result = Constructor.apply(newObject, args);
// 3. 判断构造函数的返回值类型
// 如果构造函数显式返回了一个对象(包括函数、数组等),则返回这个对象
// 否则(返回了非对象类型,如 undefined, null, number, string, boolean, symbol),就返回第一步创建的新对象 newObject
if (result !== null && (typeof result === 'object' || typeof result === 'function')) {
return result;
} else {
return newObject;
}
}
// --- 示例 ---
function Person(name, age) {
this.name = name;
this.age = age;
// 如果构造函数 return 一个对象,new 的结果就是这个对象
// return { custom: 'value' };
// 如果 return 一个原始类型,会被忽略
// return 123;
}
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name}`);
};
// 使用原生的 new
const person1 = new Person('Alice', 30);
console.log(person1); // Person { name: 'Alice', age: 30 }
person1.sayHello(); // "Hello, my name is Alice"
// 使用我们手写的 myNew
const person2 = myNew(Person, 'Bob', 25);
console.log(person2); // Person { name: 'Bob', age: 25 } (看起来一样)
person2.sayHello(); // "Hello, my name is Bob" (原型链也正确连接)
// 测试构造函数返回对象的情况
function Car() {
this.make = 'Toyota';
return { brand: 'Lexus' }; // 返回一个不同的对象
}
const car1 = new Car();
const car2 = myNew(Car);
console.log(car1); // { brand: 'Lexus' }
console.log(car2); // { brand: 'Lexus' }
// 测试构造函数返回原始类型的情况
function Bike() {
this.wheels = 2;
return 'fast'; // 返回原始类型
}
const bike1 = new Bike();
const bike2 = myNew(Bike);
console.log(bike1); // Bike { wheels: 2 } (返回值被忽略)
console.log(bike2); // Bike { wheels: 2 } (返回值被忽略)
核心步骤总结:
- 创建一个空对象,
__proto__
指向构造函数的prototype
。 this
绑定到新对象,执行构造函数(传入参数)。- 如果构造函数返回对象,则返回该对象,否则返回新创建的对象。
17. 实现深拷贝,支持循环引用?
深拷贝是指创建一个对象的完全独立副本,包括其所有嵌套的对象和数组。浅拷贝只复制顶层属性,如果属性值是对象或数组,只复制引用。
实现深拷贝的关键在于 递归,并且需要处理 循环引用(对象 A 的属性指向 B,B 的属性又指向 A)以避免无限递归导致栈溢出。处理循环引用的常用方法是使用 Map
或 WeakMap
来 缓存 已经拷贝过的对象。
function deepClone(target, cache = new WeakMap()) {
// 1. 处理原始类型和 null
// 原始类型直接返回,它们本身就是不可变的副本
if (target === null || typeof target !== 'object') {
return target;
}
// 2. 处理特殊对象:Date 和 RegExp
// 它们需要调用自身的构造函数来创建副本
if (target instanceof Date) {
return new Date(target);
}
if (target instanceof RegExp) {
return new RegExp(target.source, target.flags);
}
// 3. 处理循环引用:检查缓存
// 如果对象已经在缓存中,说明遇到了循环引用,直接返回缓存中的副本
if (cache.has(target)) {
return cache.get(target);
}
// 4. 创建新的容器(数组或对象)
// 根据目标是数组还是对象,创建对应类型的新容器
const cloneTarget = Array.isArray(target) ? [] : {};
// 5. 将新创建的副本放入缓存,key 是原始对象,value 是副本
// 这一步必须在递归调用之前做!
cache.set(target, cloneTarget);
// 6. 递归拷贝属性或元素
// 区分数组和对象进行遍历
if (Array.isArray(target)) {
// 遍历数组元素
for (let i = 0; i < target.length; i++) {
cloneTarget[i] = deepClone(target[i], cache);
}
} else {
// 遍历对象自身的可枚举属性 (可以用 Reflect.ownKeys 处理 Symbol 属性和不可枚举属性,但这里简化)
for (const key in target) {
if (Object.hasOwnProperty.call(target, key)) {
cloneTarget[key] = deepClone(target[key], cache);
}
}
// 如果需要拷贝 Symbol 属性:
// const symbolKeys = Object.getOwnPropertySymbols(target);
// for (const symKey of symbolKeys) {
// cloneTarget[symKey] = deepClone(target[symKey], cache);
// }
}
// 7. 返回创建的副本
return cloneTarget;
}
// --- 示例 ---
const obj1 = {
a: 1,
b: { c: 2, d: [3, 4] },
date: new Date(),
reg: /abc/gi,
func: function() { console.log('hello'); }, // 函数通常是浅拷贝引用
sym: Symbol('id')
};
// 创建循环引用
obj1.self = obj1;
const obj2 = deepClone(obj1);
console.log(obj2);
console.log(obj1 === obj2); // false (顶层对象不同)
console.log(obj1.b === obj2.b); // false (嵌套对象不同)
console.log(obj1.b.d === obj2.b.d); // false (嵌套数组不同)
console.log(obj1.date === obj2.date); // false (Date 对象不同,但值相同)
console.log(obj1.reg === obj2.reg); // false (RegExp 对象不同,但模式和标志相同)
console.log(obj1.func === obj2.func); // true (函数通常是共享引用)
// console.log(obj1.sym === obj2.sym); // 如果处理了 Symbol,应该是 false
// 检查循环引用是否正确处理
console.log(obj2.self === obj2); // true (拷贝后的对象也正确指向自身)
console.log(obj1.self === obj1); // true (原始对象)
console.log(obj1.self === obj2.self); // false (原始循环和拷贝后的循环是不同的对象实例)
// 修改拷贝后的对象,不影响原始对象
obj2.a = 100;
obj2.b.c = 200;
obj2.b.d.push(5);
console.log(obj1.a); // 1
console.log(obj1.b.c); // 2
console.log(obj1.b.d); // [3, 4]
要点:
- 区分原始类型和引用类型。
- 处理
Date
和RegExp
等特殊内置对象。 - 使用
WeakMap
做缓存,key 是原对象,value 是拷贝后的对象,在递归前存入,遇到已存在 key 则直接返回 value,解决循环引用。WeakMap
的好处是不会阻止原对象被垃圾回收。 - 递归调用
deepClone
处理嵌套结构。 - 注意函数和 Symbol 属性的处理方式(通常函数是浅拷贝引用,Symbol 需要额外处理
Object.getOwnPropertySymbols
)。
算法篇:硬核实力,拉开差距
18. 合并第k个升序列表? (更正理解:应该是 合并k个升序链表/数组)
这道题通常是指 LeetCode 上的经典问题:“Merge k Sorted Lists”(合并 K 个升序链表)或者类似的“合并 K 个升序数组”。核心思想是如何高效地从 K 个列表中找出当前最小的元素。
最优解法:使用最小堆(Min-Heap / 优先队列 Priority Queue)
思路:
- 创建一个 最小堆。
- 将 每个 列表的 第一个元素(如果列表不为空)及其 来源列表索引 和 在列表中的索引 包装成一个对象或元组,放入最小堆中。堆根据元素的值进行排序。
- 当堆不为空时,重复以下步骤:
- 从堆中 提取(删除) 最小值(堆顶元素)。这个元素就是当前所有列表头元素中最小的。
- 将这个最小值添加到 结果列表 中。
- 找到这个最小值 来源 的那个列表。
- 如果该列表 还有下一个元素,则将 下一个元素(及其来源信息)插入 到最小堆中。
- 当堆为空时,说明所有列表的所有元素都已被处理,结果列表就是合并后的排序列表。
为什么用最小堆?
- 堆能在
O(log k)
的时间内找到 K 个候选元素中的最小值。 - 插入新元素也是
O(log k)
。 - 如果总共有 N 个元素,总的时间复杂度是
O(N log k)
。
如果 K 很小,也可以用其他方法:
- 两两合并: 每次合并两个列表,重复 K-1 次。如果每次合并的时间复杂度是 O(n),总复杂度可能接近 O(N*k)。
- 暴力法: 把所有列表的元素放到一个大数组里,然后排序。复杂度是 O(N log N),其中 N 是总元素个数。如果 K 很大,
log N
可能比log k
大很多,但如果 K 很小,这个方法可能反而简单。
示例代码(合并 K 个升序数组,使用最小堆): (JavaScript 没有内置堆,通常需要手写或使用库。这里用概念伪代码+模拟)
// 假设有一个 MinHeap 类可用 (add, extractMin, isEmpty, size)
// MinHeap 存储的元素是 { value: number, listIndex: number, elementIndex: number }
// 堆根据 value 排序
function mergeKSortedArrays(lists) {
const result = [];
const k = lists.length;
// 简易模拟 MinHeap (实际应使用更高效的实现)
const minHeap = []; // 用数组模拟,每次找最小/插入需要 O(k) 或 O(k log k)
// 1. 初始化堆:加入每个列表的第一个元素
for (let i = 0; i < k; i++) {
if (lists[i] && lists[i].length > 0) {
// 实际应使用 heap.add()
minHeap.push({ value: lists[i][0], listIndex: i, elementIndex: 0 });
}
}
// 保持堆有序 (实际堆在 add 时维护)
minHeap.sort((a, b) => a.value - b.value);
// 2. 循环提取最小元素,并加入下一个元素
while (minHeap.length > 0) {
// 实际应使用 heap.extractMin()
const smallest = minHeap.shift(); // 提取最小 (数组模拟 O(k))
result.push(smallest.value);
const { listIndex, elementIndex } = smallest;
const nextElementIndex = elementIndex + 1;
// 如果该列表还有下一个元素,加入堆中
if (nextElementIndex < lists[listIndex].length) {
// 实际应使用 heap.add()
minHeap.push({
value: lists[listIndex][nextElementIndex],
listIndex: listIndex,
elementIndex: nextElementIndex
});
// 保持堆有序 (实际堆在 add 时维护)
minHeap.sort((a, b) => a.value - b.value); // 数组模拟 O(k log k)
}
}
return result;
}
// --- 示例 ---
const list1 = [1, 4, 5];
const list2 = [1, 3, 4];
const list3 = [2, 6];
const mergedList = mergeKSortedArrays([list1, list2, list3]);
console.log(mergedList); // 输出 [1, 1, 2, 3, 4, 4, 5, 6]
对于链表版本: 思路完全一样,只是操作从数组索引变成链表节点的 next
指针。堆里存储的是链表节点对象。
这些问题覆盖了前端面试中非常核心和高频的知识点。理解它们不仅能帮你应对面试,更能让你在日常开发中对 React 和 JavaScript 有更深的理解。
微信公众号:【前端大大大】