面试问题
📍面试公司:字节跳动
🕐面试时间:近期
💻面试岗位:前端一面
⏱️面试时长:未提及
📝面试体验:难度 Plus Ultra 版,苦战,加粗的是没答上来的
❓面试问题:
- Reconciler 如何遍历 fiber 树(先序遍历)
- 为什么要这么设计
- DOM 树和 fiber 树的区别
- diff 算法是怎么比较新旧两个树的
- 浏览器从拿到渲染树以后都经过了哪些阶段(布局→分层→分块→光栅化→直接显示(其实是合成))
- 为什么光栅化要由 GPU 去做
- 为什么会这样呢
- Webpack 和 Vite 有什么区别
- Vite 打包用的什么
- ESM 和 CJS 区别(提到同步导入和异步导入)
- 微任务队列和宏任务队列都是什么
- 任务循环在浏览器和 Node 有什么区别
- Message channel 是什么
- 为什么 React 用了 Message channel 调度没用 setTimeout
- 听说过 React 时间分片吗
- 说一下 JavaScript 是不是单线程的语言
- 用过哪些设计模式
- 手撕:同时允许 2 个任务执行的异步调度器
- 手撕:两个有序数组合并成一个有序数组
字节跳动前端一面深度解析
面试整体画像
| 维度 | 特征 |
|---|---|
| 面试风格 | 源码级深挖型 + 底层原理型 + 追根究底型 |
| 难度评级 | ⭐⭐⭐⭐(四星半,React 原理 + 浏览器底层 + 工程化深度) |
| 考察重心 | React fiber 架构、浏览器渲染流水线、构建工具原理、事件循环机制、设计模式 |
| 特殊之处 | 问题层层递进,连续追问'为什么这样设计',考察真正的理解深度而非背诵 |
逐题深度解析
一、Reconciler 如何遍历 fiber 树(先序遍历)
回答思路:这是 React fiber 架构的核心。Reconciler(协调器)负责找出组件树的变化,它采用深度优先遍历(DFS),具体是先序遍历(pre-order)。
遍历过程:
- 从根 fiber 开始,先处理当前节点
- 如果有 child,进入 child
- child 处理完后,如果有 sibling,进入 sibling
- 重复直到完成所有节点
// 伪代码示意
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 进行优化,通过移动、插入、删除操作最小化变更
// 子节点比较核心逻辑(简化)
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 有数千个计算核心,虽然单核比 CPU 慢,但总吞吐量是 CPU 的数十倍
- 内存带宽高:GPU 有专用的显存(VRAM),带宽远超系统内存
八、Webpack 和 Vite 的区别
回答思路:从开发体验、构建方式、生产打包等方面对比。
| 维度 | Webpack | Vite |
|---|---|---|
| 开发环境 | 打包所有模块,启动慢 | 利用 ESM,直接启动,秒级 |
| 热更新 | 重新打包相关模块,慢 | 利用 ESM 的 HMR,只更新变更的模块,快 |
| 生产打包 | 统一打包成 bundle | 使用 Rollup 预打包,优化较好 |
| 配置复杂度 | 高,需要大量配置 | 低,零配置开箱即用 |
| 生态 | 成熟,插件丰富 | 快速追赶,生态渐全 |
核心区别:Vite 利用浏览器原生 ESM 支持,开发环境不打包,启动和热更新更快;Webpack 需要在开发环境也打包所有模块。
九、Vite 打包用的什么
回答思路: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 |
|---|---|---|
| 宏任务 | 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)之间传递消息,也可以在同一线程的不同任务之间传递。
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
回答思路:
核心原因:
- setTimeout 有延迟:嵌套的 setTimeout 最小延迟是 4ms,即使写
setTimeout(fn, 0),实际也会等待至少 4ms。这会让 React 的时间分片颗粒度过粗。 - MessageChannel 是 0 延迟:通过 MessageChannel 派生的宏任务,可以在下一帧立即执行,没有最小延迟。
- 优先级调度:React 需要区分高优先级(用户输入)和低优先级(数据更新),MessageChannel 可以配合
requestAnimationFrame实现精确的优先级调度。 - 与渲染帧对齐:React 需要在每帧结束前执行低优先级任务,避免掉帧。MessageChannel 能更好地控制时机。
// 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 是不是单线程的语言
回答思路:
- JavaScript 语言本身是单线程的,它有且只有一个调用栈,一次只能执行一段代码。
- 浏览器环境是多线程的:主线程(JS 引擎 + 渲染)、Web Worker 线程(可运行 JS)、网络线程、定时器线程、GPU 线程等。
- JS 引擎的单线程指执行 JS 代码的线程只有一个,但浏览器通过事件循环和异步 API(Web Worker)提供了并发能力。
十七、用过哪些设计模式
回答思路:常见设计模式:
| 模式 | 使用场景 |
|---|---|
| 单例 | 全局状态管理(Vuex/Pinia) |
| 观察者 | 事件总线、响应式系统 |
| 工厂 | 创建不同组件(如弹窗类型) |
| 策略 | 表单校验规则 |
| 装饰器 | HOC(高阶组件) |
| 发布订阅 | 跨组件通信 |
回答示例:'我在项目中使用过策略模式来处理表单校验。不同字段的校验规则不同(手机号、邮箱、非空),我把校验函数抽象成策略对象,根据字段类型动态选择。这样新增校验规则时不需要修改原有代码,符合开闭原则。'
十八、手撕:同时允许 2 个任务执行的异步调度器
题目:实现一个异步调度器,最多同时执行 2 个任务,任务完成后自动执行队列中的下一个。
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();
= () => ( {
( {
.(order);
();
}, time);
});
scheduler.( (, ));
scheduler.( (, ));
scheduler.( (, ));
scheduler.( (, ));
十九、手撕:两个有序数组合并成一个有序数组
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 返回 |


