前言
大家好,我是木斯佳。
相信很多人都感受到了,在AI浪潮的席卷之下,前端领域的门槛在变高,纯粹的"增删改查"岗位正在肉眼可见地减少。曾经热闹非凡的面经分享,如今也沉寂了许多。但我们都知道,市场的潮水退去,留下的才是真正在踏实准备、努力沉淀的人。学习的需求,从未消失,只是变得更加务实和深入。
这个专栏的初衷很简单:拒绝过时的、流水线式的PDF引流贴,专注于收集和整理当下最新、最真实的前端面试资料。我会在每一份面经和八股文的基础上,尝试从面试官的角度去拆解问题背后的逻辑,而不仅仅是提供一份静态的背诵答案。无论你是校招还是社招,目标是中大厂还是新兴团队,只要是真实发生、有价值的面试经历,我都会在这个专栏里为你沉淀下来。

温馨提示:市面上的面经鱼龙混杂,甄别真伪、把握时效,是我们对抗内卷最有效的武器。
面经原文内容
📍面试公司:字节跳动
🕐面试时间:近期,用户上传于2026-03-30
💻面试岗位:前端一面
⏱️面试时长:未提及
📝面试体验:难度plus ultra版,苦战,加粗的是没答上来的
❓面试问题:
- Reconciler 如何遍历 fiber 树(先序遍历)
- 为什么要这么设计
- DOM 树和 fiber 树的区别
- diff 算法是怎么比较新旧两个树的
- 浏览器从拿到渲染树以后都经过了哪些阶段(布局→分层→分块→光栅化→直接显示(其实是合成))
- 为什么光栅化要由 GPU 去做
- 为什么会这样呢
- Webpack 和 Vite 有什么区别
- Vite 打包用的什么
- ESM 和 CJS 区别(提到同步导入和异步导入)
- 微任务队列和宏任务队列都是什么
- 任务循环在浏览器和 Node 有什么区别
- Message channel 是什么
- 为什么 React 用了 Message channel 调度没用 setTimeout
- 听说过 React 时间分片吗
- 说一下 JavaScript 是不是单线程的语言
- 用过哪些设计模式
- 手撕:同时允许 2 个任务执行的异步调度器
- 手撕:两个有序数组合并成一个有序数组
来源:牛客网 期望去月球上班
💡 木木有话说(刷前先看)
这个好像确实有点难度。问题深入到React Reconciler的fiber树遍历、diff算法底层、浏览器渲染的GPU光栅化原理、MessageChannel调度机制......这些都是React源码级别的深度。用户坦言很多题没答上来,但能答出大部分已经很厉害了。这份面经的价值在于:它划出了顶尖大厂对校招/实习生的上限要求------不是为了让你全答对,而是看你的技术天花板在哪里。如果你正在准备字节面试,这篇文章值得反复研读。
📝 字节跳动前端一面·深度解析(Plus Ultra版)
🎯 面试整体画像
| 维度 | 特征 |
|---|---|
| 面试风格 | 源码级深挖型 + 底层原理型 + 追根究底型 |
| 难度评级 | ⭐⭐⭐⭐(四星半,React原理+浏览器底层+工程化深度) |
| 考察重心 | React fiber架构、浏览器渲染流水线、构建工具原理、事件循环机制、设计模式 |
| 特殊之处 | 问题层层递进,连续追问"为什么这样设计",考察真正的理解深度而非背诵 |
🔍 逐题深度解析
一、Reconciler如何遍历fiber树(先序遍历)
回答思路 :这是React fiber架构的核心。Reconciler(协调器)负责找出组件树的变化,它采用深度优先遍历(DFS) ,具体是先序遍历(pre-order)。
遍历过程:
- 从根fiber开始,先处理当前节点
- 如果有child,进入child
- child处理完后,如果有sibling,进入sibling
- 重复直到完成所有节点
javascript
// 伪代码示意
function workLoop(unitOfWork) {
while (unitOfWork !== null) {
// 处理当前节点(beginWork)
unitOfWork = beginWork(unitOfWork)
// 如果有child,继续向下
if (unitOfWork !== null && unitOfWork.child !== null) {
unitOfWork = unitOfWork.child
} else {
// 没有child,向上返回
while (unitOfWork !== null) {
// 完成当前节点(completeWork)
completeWork(unitOfWork)
// 有sibling,转到sibling
if (unitOfWork.sibling !== null) {
unitOfWork = unitOfWork.sibling
break
}
// 否则返回父节点
unitOfWork = unitOfWork.return
}
}
}
}
二、为什么要这么设计
回答思路:这是追问"为什么是DFS,而不是BFS"。考察对React设计意图的理解。
核心原因:
- 可中断性:React需要实现"时间分片"(time slicing),DFS可以随时暂停和恢复,因为每个节点有明确的"return"指针指向父节点。BFS需要维护整个层级队列,恢复成本高。
- 优先级调度:DFS便于按优先级处理节点,可以优先处理用户交互相关的分支(如输入框所在的子树)。
- 生命周期对应 :组件挂载/更新的生命周期(
componentDidMount、useEffect)需要在子树完全处理完后执行,DFS的"递"阶段(beginWork)和"归"阶段(completeWork)天然匹配这一需求。 - 内存效率:DFS只需维护当前路径的节点引用,BFS需要维护整个队列。
三、DOM树和fiber树的区别
回答思路:从目的、结构、可变性等方面对比。
| 维度 | DOM树 | Fiber树 |
|---|---|---|
| 目的 | 页面渲染的结构表示 | React内部的工作单元,用于调度渲染 |
| 节点关系 | parent、children(单向) | child、sibling、return(双向链表) |
| 可变性 | 不可变(更新会创建新节点) | 可复用(fiber节点可以保留、更新) |
| 生命周期 | 与页面渲染绑定 | 独立于渲染,可暂停/恢复 |
| 内容 | 存储样式、属性等渲染信息 | 存储组件类型、state、props、副作用列表 |
核心 :fiber树是React自己的数据结构,它的设计是为了增量渲染------把渲染任务拆分成多个小任务,分散到多个帧中执行。
四、diff算法是怎么比较新旧两个树的
回答思路:用户说"还没学到",这里给出标准答案。React的diff算法基于三个假设:
- 不同类型的元素产生不同的树
- 开发者可以通过
keyprop暗示哪些子元素是稳定的 - 只进行同层比较,不跨层比较
比较过程:
- 节点类型不同:直接销毁旧子树,新建新子树
- 节点类型相同(DOM元素):保留DOM节点,更新变化的属性
- 节点类型相同(组件):组件实例不变,更新props,触发生命周期
- 子节点列表比较:使用key进行优化,通过移动、插入、删除操作最小化变更
javascript
// 子节点比较核心逻辑(简化)
function reconcileChildren(prevChildren, nextChildren) {
// 使用key建立映射
const prevMap = new Map()
prevChildren.forEach(child => prevMap.set(child.key, child))
const newChildren = []
let lastIndex = 0
nextChildren.forEach(nextChild => {
const prevChild = prevMap.get(nextChild.key)
if (prevChild) {
if (prevChild.index < lastIndex) {
// 需要移动
markMove(prevChild)
} else {
lastIndex = prevChild.index
}
// 更新节点
updateNode(prevChild, nextChild)
newChildren.push(prevChild)
} else {
// 新增节点
const newFiber = createFiber(nextChild)
newChildren.push(newFiber)
}
})
return newChildren
}
五、浏览器渲染阶段(从渲染树到显示)
回答思路:用户回答"布局→分层→分块→光栅化→直接显示(其实是合成)",基本正确。完整流程如下:
布局(Layout)→ 分层(Layer)→ 分块(Tiling)→ 光栅化(Rasterization)→ 合成(Composite)
各阶段说明:
- 布局:计算每个元素的位置和尺寸,生成Layout Tree
- 分层:根据层叠上下文、transform、will-change等属性,将页面拆分成多个图层(Layer)
- 分块:将每个图层分成若干图块(Tile),通常是256x256或512x512大小
- 光栅化:将图块转换成位图(像素信息),GPU负责执行
- 合成:将各个图层的位图按照顺序合成为最终显示的图像,由GPU的合成器(Compositor)完成
注意:用户说的"直接显示"不准确,最后一步是合成,不是直接显示。
六、为什么光栅化要由GPU去做
回答思路:从GPU的架构优势出发。
原因:
- 并行计算能力:光栅化是"将向量图形转换为像素"的过程,每个像素可以独立计算。GPU有数千个核心,天然适合这种大规模并行任务。
- 硬件优化:GPU专为图形处理设计,有专门的纹理映射、抗锯齿、透明度混合等硬件单元。
- 效率:CPU做光栅化需要逐像素循环,速度慢;GPU可以同时处理大量图块。
- 帧率保障:60fps需要16.6ms内完成一帧,GPU能保证合成器快速合成。
七、为什么会这样呢(GPU架构)
回答思路:这是上一题的"追问到底",考察对GPU原理的理解。用户可以简单说"因为GPU是SIMD架构,单指令多数据流",但更深入可以讲:
GPU的核心特点:
- SIMD(单指令多数据流):一条指令控制多个处理单元同时执行相同操作,适合像素处理
- 高吞吐量:GPU有数千个计算核心,虽然单核比CPU慢,但总吞吐量是CPU的数十倍
- 内存带宽高:GPU有专用的显存(VRAM),带宽远超系统内存
八、Webpack和Vite的区别
回答思路:从开发体验、构建方式、生产打包等方面对比。
| 维度 | Webpack | Vite |
|---|---|---|
| 开发环境 | 打包所有模块,启动慢 | 利用ESM,直接启动,秒级 |
| 热更新 | 重新打包相关模块,慢 | 利用ESM的HMR,只更新变更的模块,快 |
| 生产打包 | 统一打包成bundle | 使用Rollup预打包,优化较好 |
| 配置复杂度 | 高,需要大量配置 | 低,零配置开箱即用 |
| 生态 | 成熟,插件丰富 | 快速追赶,生态渐全 |
核心区别:Vite利用浏览器原生ESM支持,开发环境不打包,启动和热更新更快;Webpack需要在开发环境也打包所有模块。
九、Vite打包用的什么
回答思路:用户回答"我想也是ESM吧",不完全正确。
正确答案 :Vite开发环境用ESM (原生模块),生产打包 用的是Rollup。因为生产环境需要更精细的优化(tree-shaking、代码分割、兼容性处理),Rollup在这些方面做得更好。
十、ESM和CJS区别
回答思路:用户提到"同步导入和异步导入",这是核心区别之一。
| 维度 | CJS(CommonJS) | ESM(ES Module) |
|---|---|---|
| 加载方式 | 同步(require) | 异步(import) |
| 执行时机 | 运行时执行 | 编译时解析 |
| 导出 | module.exports | export default / export |
| 静态分析 | 不支持 | 支持(tree-shaking依赖) |
| 浏览器支持 | 需打包 | 原生支持 |
| 循环依赖 | 有坑(拿到的是部分导出) | 更好处理(实时绑定) |
关键点 :CJS的require是同步的,在服务器端(Node.js)没问题;ESM的import是异步的,适合浏览器环境。
十一、微任务队列和宏任务队列
回答思路:参考之前面经的解析。微任务队列优先级高于宏任务队列,在当前宏任务执行完后、下一个宏任务开始前清空。
十二、事件循环在浏览器和Node的区别
回答思路:用户说"没研究过Node",这里简要说明。
| 维度 | 浏览器 | Node |
|---|---|---|
| 宏任务 | setTimeout、setInterval、I/O、UI渲染 | setTimeout、setInterval、setImmediate、I/O |
| 微任务 | Promise.then、MutationObserver | Promise.then、process.nextTick |
| 阶段 | 简单(宏任务→微任务→渲染) | 复杂(timers→pending→idle→poll→check→close) |
| process.nextTick | 无 | 优先级高于Promise,在每阶段结束后立即执行 |
Node事件循环阶段:
- timers:执行setTimeout/setInterval的回调
- pending:执行上一轮遗留的I/O回调
- idle/prepare:内部使用
- poll:获取新的I/O事件,执行相关回调
- check:执行setImmediate回调
- close:执行close事件回调
十三、Message channel是什么
回答思路:用户猜测"跨线程通信",正确但不完全。
MessageChannel 是浏览器提供的通信API,用于在不同执行上下文(如主线程和Web Worker)之间传递消息,也可以在同一线程的不同任务之间传递。
javascript
const channel = new MessageChannel()
const port1 = channel.port1
const port2 = channel.port2
port1.onmessage = (e) => console.log(e.data)
port2.postMessage('hello') // port1收到消息
在React中的作用 :React用它来模拟requestIdleCallback,实现时间分片调度。因为setTimeout有最小4ms延迟(嵌套时),不适合高精度调度;MessageChannel可以做到0延迟的宏任务,且不阻塞渲染。
十四、为什么React用MessageChannel调度,没用setTimeout
回答思路:用户对React调度机制不够了解,这里详细解释。
核心原因:
- setTimeout有延迟 :嵌套的setTimeout最小延迟是4ms,即使写
setTimeout(fn, 0),实际也会等待至少4ms。这会让React的时间分片颗粒度过粗。 - MessageChannel是0延迟:通过MessageChannel派生的宏任务,可以在下一帧立即执行,没有最小延迟。
- 优先级调度 :React需要区分高优先级(用户输入)和低优先级(数据更新),MessageChannel可以配合
requestAnimationFrame实现精确的优先级调度。 - 与渲染帧对齐:React需要在每帧结束前执行低优先级任务,避免掉帧。MessageChannel能更好地控制时机。
javascript
// React调度器简化逻辑
let scheduledCallback = null
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = () => {
if (scheduledCallback) {
const callback = scheduledCallback
scheduledCallback = null
callback()
}
}
function scheduleCallback(callback) {
scheduledCallback = callback
port.postMessage(null) // 触发宏任务
}
十五、听说过React时间分片吗
回答思路:如果没听说过,可以诚实说"了解过但没深入"。这里简要说明。
时间分片(Time Slicing):React将渲染任务拆分成多个小任务(每个fiber节点是一个任务),每个任务执行一段时间(默认5ms),然后检查是否需要让出主线程(如是否有用户输入等待处理)。如果需要,就暂停,把控制权交还给浏览器,等下一帧再继续。这保证了页面在高频更新时(如长列表渲染)不会卡死。
十六、JavaScript是不是单线程的语言
回答思路:用户回答得不错,区分了JS语言和浏览器环境。
正确理解:
- JavaScript语言本身是单线程的,它有且只有一个调用栈,一次只能执行一段代码。
- 浏览器环境是多线程的:主线程(JS引擎+渲染)、Web Worker线程(可运行JS)、网络线程、定时器线程、GPU线程等。
- JS引擎的单线程指执行JS代码的线程只有一个,但浏览器通过事件循环和异步API(Web Worker)提供了并发能力。
十七、用过哪些设计模式
回答思路:用户提到"双重扩展问题",可能是"双缓冲"或"扩展点"模式。常见设计模式:
| 模式 | 使用场景 |
|---|---|
| 单例 | 全局状态管理(Vuex/Pinia) |
| 观察者 | 事件总线、响应式系统 |
| 工厂 | 创建不同组件(如弹窗类型) |
| 策略 | 表单校验规则 |
| 装饰器 | HOC(高阶组件) |
| 发布订阅 | 跨组件通信 |
回答示例 :"我在项目中使用过策略模式来处理表单校验。不同字段的校验规则不同(手机号、邮箱、非空),我把校验函数抽象成策略对象,根据字段类型动态选择。这样新增校验规则时不需要修改原有代码,符合开闭原则。"
十八、手撕:同时允许2个任务执行的异步调度器
题目:实现一个异步调度器,最多同时执行2个任务,任务完成后自动执行队列中的下一个。
javascript
class Scheduler {
constructor(limit = 2) {
this.limit = limit
this.running = 0
this.queue = []
}
add(promiseFactory) {
return new Promise((resolve, reject) => {
this.queue.push(() => {
promiseFactory().then(resolve, reject).finally(() => {
this.running--
this.next()
})
})
this.next()
})
}
next() {
if (this.running < this.limit && this.queue.length) {
const task = this.queue.shift()
this.running++
task()
}
}
}
// 使用示例
const scheduler = new Scheduler(2)
const timeout = (time, order) => new Promise(resolve => {
setTimeout(() => {
console.log(order)
resolve()
}, time)
})
scheduler.add(() => timeout(1000, '1'))
scheduler.add(() => timeout(500, '2'))
scheduler.add(() => timeout(300, '3'))
scheduler.add(() => timeout(400, '4'))
// 输出顺序:2 3 1 4
十九、手撕:两个有序数组合并成一个有序数组
javascript
function mergeSortedArrays(arr1, arr2) {
const result = []
let i = 0, j = 0
while (i < arr1.length && j < arr2.length) {
if (arr1[i] < arr2[j]) {
result.push(arr1[i])
i++
} else {
result.push(arr2[j])
j++
}
}
// 处理剩余元素
while (i < arr1.length) result.push(arr1[i++])
while (j < arr2.length) result.push(arr2[j++])
return result
}
📚 知识点速查表
| 知识点 | 核心要点 |
|---|---|
| fiber树遍历 | 深度优先、先序遍历,支持可中断恢复 |
| fiber设计原因 | 时间分片、优先级调度、生命周期匹配 |
| DOM树 vs fiber树 | 目的、节点关系、可变性、内容差异 |
| diff算法 | 同层比较、key优化、类型决定策略 |
| 渲染流水线 | 布局→分层→分块→光栅化→合成 |
| GPU光栅化 | 并行计算、硬件优化、高吞吐量 |
| Webpack vs Vite | 开发体验、构建方式、生产打包、配置复杂度 |
| ESM vs CJS | 同步/异步、静态/运行时、浏览器支持 |
| 事件循环(Node) | 多阶段、process.nextTick优先级高 |
| MessageChannel | 跨线程通信、0延迟宏任务、React调度器 |
| 时间分片 | 5ms切片,优先响应用户交互 |
| 异步调度器 | 并发控制、任务队列、Promise返回 |
📌 最后一句:
字节这场一面,我觉得其实可以拆为两篇发出来,因为关键内容还挺多的,但是今天因为其他事情伤心了,先这样吧。只能说查漏补缺吧