前端高频面试题
目录
- 第一部分:前端性能优化
- [Q1: 谈谈你对 Core Web Vitals (核心网页指标) 的理解及优化手段](#Q1: 谈谈你对 Core Web Vitals (核心网页指标) 的理解及优化手段)
- [Q2: 浏览器渲染流程是怎样的?如何避免重排(Reflow)与重绘(Repaint)?](#Q2: 浏览器渲染流程是怎样的?如何避免重排(Reflow)与重绘(Repaint)?)
- [Q3: 虚拟列表(Virtual List)的实现原理是什么?](#Q3: 虚拟列表(Virtual List)的实现原理是什么?)
- [Q4: 如何避免长任务阻塞主线程?](#Q4: 如何避免长任务阻塞主线程?)
- 第二部分:前端工程化
- [Q1: 深度对比 Webpack 与 Vite 的构建机制与差异](#Q1: 深度对比 Webpack 与 Vite 的构建机制与差异)
- [Q2: Tree Shaking 的底层原理是什么?CommonJS 和 ESM 有何不同?](#Q2: Tree Shaking 的底层原理是什么?CommonJS 和 ESM 有何不同?)
- [Q3: 微前端(如 qiankun)的沙箱隔离机制是如何实现的?](#Q3: 微前端(如 qiankun)的沙箱隔离机制是如何实现的?)
- [Q4: Webpack HMR(热更新)的工作流与核心原理](#Q4: Webpack HMR(热更新)的工作流与核心原理)
- 第三部分:网络与传输
- [Q1: HTTP/1.1、HTTP/2、HTTP/3 的演进及性能改进](#Q1: HTTP/1.1、HTTP/2、HTTP/3 的演进及性能改进)
- [Q2: 强缓存与协商缓存的完整决策流程及常见应用场景](#Q2: 强缓存与协商缓存的完整决策流程及常见应用场景)
- [Q3: HTTPS 的加密握手及秘钥协商完整过程](#Q3: HTTPS 的加密握手及秘钥协商完整过程)
- [Q4: WebRTC 的 P2P 连接建立过程(信令与打洞)](#Q4: WebRTC 的 P2P 连接建立过程(信令与打洞))
第一部分:前端性能优化
Q1: 谈谈你对 Core Web Vitals (核心网页指标) 的理解及优化手段
1. 核心指标解析
- LCP (Largest Contentful Paint - 最大内容绘制) :测量加载性能 。要求页面首次开始加载后的 2.5秒 内,视口内最大的图片或文本块渲染完成。
- FID (First Input Delay - 首次输入延迟) / INP (Interaction to Next Paint - 交互到下次绘制) :测量交互响应性 。
- 注:INP 已在 2024 年 3 月正式取代 FID。INP 衡量用户在访问期间做出的所有交互的完整延迟,优秀标准为小于 200毫秒。
- CLS (Cumulative Layout Shift - 累计布局偏移) :测量视觉稳定性 。衡量可见元素在视觉上发生意外偏移的程度,优秀标准为小于 0.1。
2. 指标监控思路(PerformanceObserver)
- 第一步 :创建
PerformanceObserver监听实例。 - 第二步 :注册感兴趣 of 性能条目类型(如
largest-contentful-paint,layout-shift,first-input)。 - 第三步 :在回调中收集指标数据并进行上报(利用
navigator.sendBeacon进行无阻塞上报)。
【结构化伪代码:CLS 与 LCP 监控上报逻辑】
javascript
// 1. 初始化指标累加器
LCP数值 = 0
CLS数值 = 0
// 2. 创建性能观测器
创建 性能观测器 (条目列表) {
对于 列表中的每个 条目 {
如果 (条目类型 是 "largest-contentful-paint") {
// 持续更新最大值,因为 LCP 会随着加载过程不断更新
LCP数值 = 条目.开始时间
}
如果 (条目类型 是 "layout-shift" 并且 条目没有用户近期输入动作) {
CLS数值 = CLS数值 + 条目.偏移值
}
}
}
// 3. 开启监听
性能观测器.监听({
条目类型集合: ["largest-contentful-paint", "layout-shift"],
是否缓存已有数据: 真
})
// 4. 页面卸载时上报
当 页面即将关闭或隐藏时 {
数据包 = 格式化为JSON({ lcp: LCP数值, cls: CLS数值 })
浏览器异步免阻碍发送(上报接口URL, 数据包)
}
【CLS 与 LCP 监控上报】
javascript
let lcpValue = 0;
let clsValue = 0;
try {
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// 收集最大内容绘制时间 (LCP)
if (entry.entryType === 'largest-contentful-paint') {
lcpValue = entry.startTime;
}
// 收集非用户主动交互引起的意外布局偏移量 (CLS)
if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
clsValue += entry.value;
}
}
});
// 开始监测对应的性能条目,并缓存旧指标数据
observer.observe({ entryTypes: ['largest-contentful-paint', 'layout-shift'], buffered: true });
} catch (e) {
console.warn('当前浏览器不支持 PerformanceObserver 性能监控 API。', e);
}
// 页面即将销毁/隐藏时,利用无阻塞的 sendBeacon 异步上报指标数据
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
const payload = JSON.stringify({ lcp: lcpValue, cls: clsValue });
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/report/performance', payload);
} else {
// fallback:传统异步请求,设置 keepalive 保证页面关闭时也能发出
fetch('/api/report/performance', { method: 'POST', body: payload, keepalive: true });
}
}
});
3. 核心优化手段
- 优化 LCP :
- 预加载关键资源 :对 LCP 元素图片使用
<link rel="preload" as="image">。 - 移除阻塞渲染的 JS/CSS :使用
async/defer,内联关键 CSS。 - 服务端渲染 (SSR) / 静态生成 (SSG):缩短获取 HTML 的网络耗时。
- 预加载关键资源 :对 LCP 元素图片使用
- 优化 INP/FID :
- 拆分长任务 (Long Tasks):将占用超过 50ms 的 JavaScript 任务切片执行。
- 减少主线程阻塞:将非 UI 相关的重计算逻辑移入 Web Worker。
- 优化防抖与节流:避免高频交互触发密集重渲染。
- 优化 CLS :
- 为图片和媒体保留宽高属性 :显式书写
width和height,或使用 CSS 的aspect-ratio。 - 避免动态插入无尺寸内容:广告位、动态 banner 等设置最小骨架高度。
- CSS 动画优化 :优先使用
transform/opacity进行位移动画,避免使用top/left/height。
- 为图片和媒体保留宽高属性 :显式书写
Q2: 浏览器渲染流程是怎样的?如何避免重排(Reflow)与重绘(Repaint)?
1. 浏览器经典渲染流水线
- 构建 DOM 树:解析 HTML,生成 DOM 树。
- 构建 CSSOM 树:解析 CSS(包含外部和内联样式),生成样式结构树。
- 合并为 Render Tree(渲染树) :将 DOM 树与 CSSOM 树合并,剔除不可见节点(如
display: none)。 - Layout(重排/回流):计算渲染树中每个节点在屏幕上的几何大小与绝对坐标。
- Paint(重绘):绘制节点的背景、颜色、边框、文本等视觉信息。
- Composite(图层合成):将各个渲染图层(Layers)合并,发送至 GPU 进行渲染输出。
2. 重排与重绘的根本区别
- 重排(Reflow) :当元素的几何属性(如宽、高、外边距、定位、显示状态等)发生改变,浏览器必须重新计算元素的位置 and 大小。重排必定导致重绘。
- 重绘(Repaint) :当元素的视觉外观属性(如背景色、文字颜色、阴影等)改变,但几何尺寸未变,浏览器仅需重新绘制该元素。重绘不一定会触发重排。
3. 避免重排与重绘的实战策略
- 合并样式修改 :避免逐条修改样式,使用合并 class 或
cssText。 - 读写分离(防范强制同步布局) :
- 问题根源 :当在代码中修改了样式,紧接着读取
offsetWidth、scrollTop等几何属性时,浏览器为了提供准确的读取值,会被迫立即触发一次重排。 - 解决方案 :使用
FastDOM思想,将读取操作和写入操作归类合并。
- 问题根源 :当在代码中修改了样式,紧接着读取
- 离线操作 DOM :
- 使用
DocumentFragment临时缓存 DOM 修改,一次性挂载。 - 修改频繁的元素可以先设为
display: none(触发一次重排),修改完后再显式化(再触发一次重排)。
- 使用
- 利用 CSS 硬件加速 (GPU 加速) :
- 通过
transform、opacity、will-change属性将元素提升为独立合成层 (Compositing Layer)。 - 提升为独立合成层后,该元素的变动只会触发
Composite(图层合成)阶段,完全绕过Layout和Paint阶段。
- 通过
Q3: 虚拟列表(Virtual List)的实现原理是什么?
1. 核心思路分析
虚拟列表是一种针对**超长数据列表(如 10 万条数据)**的极致渲染优化手段。其核心原理是:只渲染用户当前视口可见的元素,通过绝对定位和填充容器高度来模拟真实滚动。
- 容器高度(Viewport Container) :固定高度,设置
overflow-y: auto。 - 占位滚动区域(Scroll Phantom) :绝对定位,高度为
单项高度 * 总数据项数,用于支撑起滚动条。 - 真实渲染列表(Render List) :绝对定位在容器顶部,只渲染
视口高度 / 单项高度 + 缓冲区个数项。 - 偏移量(Offset) :当滚动发生时,计算当前滚动了多少项,动态改变真实列表的
translateY,使其始终停留在视口内。
【结构化伪代码:虚拟列表滚动计算核心逻辑】
javascript
// 1. 初始化常量与变量
视口高度 = 容器.当前可见高度
单项高度 = 50
总数据列表 = [...]
总项数 = 总数据列表.长度
缓冲区大小 = 3 // 避免滚动露白
// 页面可见渲染项数
可见项数 = 向上取整(视口高度 / 单项高度)
// 虚拟列表容器的总仿真高度
占位高度 = 总项数 * 单项高度
// 2. 监听滚动事件的核心回调
当容器触发滚动(滚动事件) {
当前滚动高度 = 滚动事件.目标.scrollTop
// 计算开始渲染的索引
开始索引 = 向下取整(当前滚动高度 / 单项高度)
// 加入缓冲区防露白
安全开始索引 = 取最大值(0, 开始索引 - 缓冲区大小)
// 计算结束渲染的索引
结束索引 = 开始索引 + 可见项数
安全结束索引 = 取最小值(总项数, 结束索引 + 缓冲区大小)
// 提取出当前应当渲染的数据切片
当前渲染数据 = 总数据列表.切片(安全开始索引, 安全结束索引)
// 计算当前渲染区域的偏移位移,保持渲染节点处于视口内
当前偏移量 = 安全开始索引 * 单项高度
// 更新 DOM 状态
更新视图({
列表偏移样式: `translateY(${当前偏移量}px)`,
渲染数据列表: 当前渲染数据
})
}
【正确可执行的 JS 代码:虚拟列表滚动计算核心】
javascript
// 假设 HTML 结构如下:
// <div id="list-container" style="height: 400px; overflow-y: auto; position: relative;">
// <div id="list-phantom" style="position: absolute; left: 0; top: 0; right: 0; z-index: -1;"></div>
// <div id="list-actual" style="position: absolute; left: 0; top: 0; right: 0;"></div>
// </div>
const container = document.getElementById('list-container');
const phantom = document.getElementById('list-phantom');
const list = document.getElementById('list-actual');
const itemHeight = 50; // 单项高度
const totalCount = 100000; // 总计 10 万条数据
const bufferSize = 3; // 缓冲区项数
const viewportHeight = container.clientHeight;
const visibleCount = Math.ceil(viewportHeight / itemHeight); // 视口内最多容纳项数
// 1. 初始化仿真占位高度,撑开滚动条
phantom.style.height = `${totalCount * itemHeight}px`;
// 模拟海量原始数据
const allData = Array.from({ length: totalCount }, (_, i) => `列表项内容 #${i + 1}`);
// 2. 监听滚动事件
container.addEventListener('scroll', (e) => {
const scrollTop = e.target.scrollTop;
// 3. 计算可见区域的数据切片索引
const startIndex = Math.floor(scrollTop / itemHeight);
const safeStartIndex = Math.max(0, startIndex - bufferSize);
const endIndex = startIndex + visibleCount;
const safeEndIndex = Math.min(totalCount, endIndex + bufferSize);
// 4. 计算渲染区域在父容器中的绝对偏移位移(以保持显示在视口内)
const offset = safeStartIndex * itemHeight;
list.style.transform = `translate3d(0, ${offset}px, 0)`;
// 5. 更新 DOM 渲染
updateDOM(allData.slice(safeStartIndex, safeEndIndex));
});
function updateDOM(renderData) {
const fragment = document.createDocumentFragment();
renderData.forEach((text) => {
const el = document.createElement('div');
el.className = 'list-item';
el.style.height = `${itemHeight}px`;
el.style.lineHeight = `${itemHeight}px`;
el.style.boxSizing = 'border-box';
el.style.borderBottom = '1px solid #eee';
el.innerText = text;
fragment.appendChild(el);
});
list.innerHTML = '';
list.appendChild(fragment);
}
// 首次主动触发一次渲染初始化
container.dispatchEvent(new Event('scroll'));
Q4: 如何避免长任务(Long Tasks)阻塞主线程?
1. 什么是长任务?
浏览器的主线程负责解析 HTML、CSS,执行 JavaScript,以及处理用户交互。当一个 JavaScript 任务执行时间超过 50毫秒 时,就会被定义为长任务 (Long Task)。长任务会导致浏览器无法及时响应用户操作,造成掉帧或卡顿(即影响 INP 指标)。
2. 优化思路一:时间分片 (Time Slicing)
- 原理:将一个庞大的任务拆分成若干个在 50ms 内完成的子任务。在子任务执行完的间隙,将控制权交还给浏览器,让其有机会处理绘制和用户事件,然后再继续执行下一个子任务。
- 主要 API 选型 :
requestIdleCallback:在浏览器空闲时期依次执行低优先级任务。MessageChannel/setTimeout:利用宏任务将任务排队到下一次事件循环。scheduler.postTask:现代浏览器原生调度 API。
【结构化伪代码:大数组处理的时间分片调度器】
javascript
// 任务队列
待处理任务队列 = [...]
每次处理批次大小 = 100
函数 时间分片调度() {
如果 (待处理任务队列 为空) {
返回 // 任务全部结束
}
// 记录开始执行的时间戳
开始时间 = 获取当前时间戳()
// 循环执行,直到超时(例如超过16ms,保证60fps渲染)
循环 (队列不为空 并且 (获取当前时间戳() - 开始时间 < 16)) {
// 弹出一批任务执行
单次批次任务 = 待处理任务队列.取出(每次处理批次大小)
执行业务处理(单次批次任务)
}
// 释放主线程:通过宏任务将剩余任务调度到下一个 tick 执行
如果 (待处理任务队列 不为空) {
注册宏任务(时间分片调度)
}
}
【正确可执行的 JS 代码:时间分片调度器】
javascript
/**
* 时间分片处理器
* @param {Array} tasks 待处理的大批次任务数组
* @param {number} batchSize 每次循环处理的任务批次大小
* @param {Function} onProcess 处理任务的回调函数
*/
function timeSlicing(tasks, batchSize = 100, onProcess) {
if (!tasks || tasks.length === 0) return;
const queue = [...tasks];
function run() {
if (queue.length === 0) return;
const startTime = performance.now();
// 限制单个 tick 连续执行耗时不超过 16ms(约 60fps 一帧的渲染间隔),防阻塞主线程
while (queue.length > 0 && (performance.now() - startTime < 16)) {
const batch = queue.splice(0, batchSize);
onProcess(batch);
}
// 如果任务没处理完,利用宏任务推迟到下一个事件循环 tick,释放主线程控制权
if (queue.length > 0) {
if (typeof MessageChannel !== 'undefined') {
const channel = new MessageChannel();
channel.port1.onmessage = run;
channel.port2.postMessage(null);
} else {
setTimeout(run, 0);
}
}
}
run();
}
// 示例用法:
// const bigList = Array.from({ length: 50000 }, (_, i) => i);
// timeSlicing(bigList, 200, (items) => {
// items.forEach(item => {
// // 模拟某种重度逻辑计算
// Math.sqrt(item) * Math.sin(item);
// });
// });
3. 优化思路二:Web Worker 多线程计算
- 原理:将纯计算型、与 DOM 渲染无关的长任务(如大文件 MD5 校验、图像处理、音频 DSP)完全剥离,扔到子线程(Web Worker)中运行。
- 通信开销优化 :主线程与 Worker 之间使用
postMessage通信,默认会复制数据造成额外开销。对于超大数据,应当使用 Transferable Objects(可转移对象,如 ArrayBuffer),以转移所有权的方式实现零拷贝。
第二部分:前端工程化
Q1: 深度对比 Webpack 与 Vite 的构建机制与差异
| 维度 | Webpack | Vite |
|---|---|---|
| 底层核心语言 | Node.js (JavaScript) | Go (esbuild) + Rust (rollup/oxc插件) + JS |
| 开发环境核心原理 | 编译打包后生成 Bundle,将 Bundle 提供给 Dev Server。 | 基于浏览器原生 ESM,无需预先打包,按需加载,即用即编译。 |
| 生产环境打包 | Webpack 自身(丰富的 Loader 与 Plugin)。 | Rollup(更加注重 Tree Shaking 和产物纯净度)。 |
| 热更新 (HMR) 速度 | 速度随项目模块体量增大而等比例变慢(需重构建依赖图)。 | 速度为常数级 (O(1)),只让修改模块失活并重新请求。 |
| 冷启动速度 | 慢(必须分析所有模块依赖,全量构建完毕才启动 Server)。 | 极快(直接启动 Server,依赖预构建交给 esbuild)。 |
1. Vite 开发环境下的"快"是如何实现的?
- 依赖预构建(Dependency Pre-bundling) :
- 痛点 :许多第三方依赖(如
lodash-es)有成百上千个 ESM 模块,浏览器一次性请求会造成网络风暴;且有些包使用的是 CommonJS 格式,浏览器无法解析。 - 解决 :Vite 在首次启动时,利用 Go 编写的 esbuild 将第三方依赖快速打包成单个 ESM 包并存入
.vite缓存文件夹。esbuild 速度比传统 JS 构建器快 10-100 倍。
- 痛点 :许多第三方依赖(如
- 按需编译 :
- 当浏览器解析到
import { Page } from './views/Page.js'时,才会向 Vite Dev Server 发起 HTTP 请求。 - Vite 接收到请求后,再对当前文件进行单文件编译(如解析 Vue/SFC、Sass 等),然后返回。页面上未渲染的组件完全不参与编译。
- 当浏览器解析到
2. Vite 既然这么好,为什么很多大型企业项目仍在使用 Webpack?
- 生态与兼容性:Webpack 积累了十余年庞大的 Loader 和 Plugin 生态,几乎可以处理任何复杂的、古老的、非标的构建定制需求。
- 开发与生产一致性:Vite 在开发环境使用 esbuild/原生ESM,而在生产环境使用 Rollup。这导致存在"开发环境运行正常,部署到生产环境报错"的边缘风险。而 Webpack 开发与生产均采用同一套编译器和依赖图谱。
Q2: Tree Shaking 的底层原理是什么?CommonJS 和 ESM 有何不同?
1. Tree Shaking 的核心定义
Tree Shaking(摇树优化)指的是在构建打包时,只保留实际执行到的代码,消除无用(Dead Code)代码,以减小最终打包体积。
2. 为什么 Tree Shaking 必须依赖 ESM?
- CommonJS 的动态特性 :
- CommonJS 的引入和导出是动态的(运行时的)。
- 可以在
if条件语句里进行require,或者动态拼接路径:require('./libs/' + name)。 - 这意味着编译器在静态分析阶段,根本无法得知哪些模块被真正使用了。
- ESM 的静态特性 :
- ESM 的
import和export是静态的(编译时的)。 - 它们只能出现在模块的顶层,模块路径必须是静态字符串,不可包含变量。
- 构建工具可以在不运行代码的情况下,通过静态分析构建出一条明确的**AST(抽象语法树)**依赖链,精准识别无用代码。
- ESM 的
3. Tree Shaking 的工作步骤与机制
- AST 静态分析 :分析每个模块的
export,以及哪些export被其他模块import了。 - 标记无用代码 :标记那些虽有
export却从未被import的声明。 - 消除死代码:在代码压缩(Minification)阶段(如使用 Terser 或 esbuild),将这些未被引用的函数/变量彻底从最终产物中剔除。
4. 面试高频深水坑:副作用(Side Effects)对 Tree Shaking 的影响
- 什么是副作用 :当一个模块被导入时,除了导出变量,它还执行了其他影响外部环境的操作(例如:修改了全局变量
window.foo = 'bar',自执行了 CSS 注入,或者修改了原型链)。 - 编译器的保守策略 :如果编译器在分析时发现某段未使用的代码可能带有副作用,为了保证程序的运行安全,它绝不敢摇掉这部分代码。
- 如何规避 :
- 在
package.json中配置"sideEffects": false,告知构建工具该项目所有模块皆无副作用,可放心摇树。 - 或指定
"sideEffects": ["*.css", "*.scss"],仅声明 CSS 文件有副作用。
- 在
Q3: 微前端(如 qiankun)的沙箱隔离机制是如何实现的?
微前端为了防止多个独立的子应用之间发生全局变量污染 和CSS 样式冲突 ,必须设计严格的沙箱机制。以下重点解析 JS 沙箱 的三种主流实现方案。
1. 单实例沙箱:快照沙箱 (SnapshotSandbox)
- 原理 :在子应用激活时 ,记录当前
window的快照;在子应用卸载时 ,通过对比当前window与快照的差异,将window还原,并将差异保存。等下次该子应用再次激活时,再把差异恢复回去。 - 缺点 :必须遍历
window上的所有属性,性能较差,且不支持同时运行多个子应用。
【结构化伪代码:快照沙箱还原原理】
javascript
类 快照沙箱 {
构造函数() {
window快照 = {}
增量修改记录 = {}
}
激活沙箱() {
// 1. 记录当前 window 的状态快照
对于 window 中的每个 属性 {
window快照[属性] = window[属性]
}
// 2. 恢复上一次在此子应用运行中产生的增量修改
对于 增量修改记录 中的每个 属性 {
window[属性] = 增量修改记录[属性]
}
}
失活沙箱() {
对于 window 中的每个 属性 {
如果 (window[属性] 不等于 window快照[属性]) {
// 1. 记录发生了改变的属性(增量)
增量修改记录[属性] = window[属性]
// 2. 还原 window 属性到快照状态
window[属性] = window快照[属性]
}
}
}
}
【正确可执行的 JS 代码:快照沙箱】
javascript
class SnapshotSandbox {
constructor() {
this.windowSnapshot = {}; // 用于存储激活时的 window 状态快照
this.modifyMap = {}; // 记录子应用对 window 产生的增量修改
}
active() {
// 1. 记录当前真实 window 的属性快照
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop];
}
}
// 2. 恢复该子应用在上一次运行期间遗留下来的增量属性修改
for (const prop in this.modifyMap) {
if (this.modifyMap.hasOwnProperty(prop)) {
window[prop] = this.modifyMap[prop];
}
}
}
inactive() {
// 对比当前的全局 window 与之前的快照差异
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
if (window[prop] !== this.windowSnapshot[prop]) {
// 1. 保存发生改变的增量修改
this.modifyMap[prop] = window[prop];
// 2. 还原当前 window 的变量状态,避免污染全局
window[prop] = this.windowSnapshot[prop];
}
}
}
}
}
// 示例测试:
// const sandbox = new SnapshotSandbox();
// window.appName = 'global';
// sandbox.active();
// window.appName = 'sub-app-1'; // 子应用修改全局变量
// console.log(window.appName); // 'sub-app-1'
// sandbox.inactive();
// console.log(window.appName); // 'global' (全局 window 成功被还原)
2. 单实例升级版:遗留沙箱 (LegacySandbox)
- 原理 :基于 ES6
Proxy,只代理单实例window。它内部维护了修改记录、新增记录。通过Proxy的set拦截属性变化,当卸载时直接反向应用这些记录来还原window。
3. 多实例沙箱:代理沙箱 (ProxySandbox)
- 原理 :这是目前最完美 的方案。为每个子应用创建一个全新的伪 window 对象 (fakeWindow) 。当子应用运行时,所有的读写操作都通过
Proxy进行拦截:- 写操作 :全部写入
fakeWindow中,绝不污染真实的window。 - 读操作 :优先从
fakeWindow中读取;若找不到,再去真实的window上读取。
- 写操作 :全部写入
- 优势 :由于每个子应用拥有独立的
fakeWindow代理对象,真实window自始至终是干净的。因此,支持在同一个页面上并发运行多个子应用。
【结构化伪代码:多实例 Proxy 沙箱核心拦截逻辑】
javascript
类 代理沙箱 {
构造函数() {
// 创建一个干净的伪 window 容器
伪Window = 创建空对象()
// 创建 Proxy 实例代理真实 window
沙箱代理 = 新建 Proxy(window, {
设置属性(目标真实Window, 属性Key, 新值) {
// 所有写操作被拦截,只写入自己的伪 window
伪Window[属性Key] = 新值
返回 成功标志
},
获取属性(目标真实Window, 属性Key) {
// 优先读取子应用内部自定义的属性
如果 (属性Key 存在于 伪Window 中) {
返回 伪Window[属性Key]
}
// 兜底读取全局 window 上的通用属性(如 setTimeout, document等)
值 = 目标真实Window[属性Key]
// 特殊处理:如果是系统方法,需要绑定 window 的 this 指向
如果 (值 是 函数) {
返回 值.绑定上下文(目标真实Window)
}
返回 值
}
})
}
}
【多实例 Proxy 沙箱】
javascript
class ProxySandbox {
constructor() {
const fakeWindow = Object.create(null); // 创建独立的伪 window 沙箱容器
this.proxy = new Proxy(window, {
set(target, prop, value) {
// 所有子应用的写入行为,一律只拦截并写入 fakeWindow,绝对不污染真正的 window
fakeWindow[prop] = value;
return true;
},
get(target, prop) {
// 1. 优先从子应用专属的代理容器中提取变量
if (prop in fakeWindow) {
return fakeWindow[prop];
}
const value = target[prop];
// 2. 特殊处理:如果是系统方法(如 window.setTimeout/addEventListener),
// 必须强制绑定为宿主全局 window 的 context,否则执行会报错。
if (typeof value === 'function') {
return value.bind(target);
}
return value;
}
});
}
}
// 示例测试:
// const sandboxA = new ProxySandbox();
// const sandboxB = new ProxySandbox();
//
// // 在沙箱 A 代理的作用域内修改变量
// sandboxA.proxy.customVal = 'App A';
// // 在沙箱 B 代理的作用域内修改变量
// sandboxB.proxy.customVal = 'App B';
//
// console.log(sandboxA.proxy.customVal); // 'App A'
// console.log(sandboxB.proxy.customVal); // 'App B'
// console.log(window.customVal); // undefined (真实全局 window 毫无污染)
Q4: Webpack HMR(热更新)的工作流与核心原理
HMR(Hot Module Replacement)是指在应用程序运行过程中,替换、添加或删除模块,而无需重新加载整个页面。
1. HMR 核心工作流大图景
- 文件监听:Webpack Compiler 监听本地文件修改。
- 增量编译 :文件变化后,Compiler 进行增量编译,生成两个文件:
manifest.json:说明本次更新了哪些模块的 Hash。update.js:本次更新模块的最新代码。
- 推送通知 :Webpack Dev Server (WDS) 与浏览器之间维持着一个 WebSocket 长连接。WDS 将最新的编译 Hash 发送给浏览器客户端(HMR Runtime)。
- 拉取更新 :HMR Runtime 收到通知后,对比当前版本,通过 Ajax 发起请求拉取最新的
manifest.json,接着通过创建<script>标签拉取最新的增量代码update.js。 - 模块热插拔 :HMR Runtime 执行最新代码,顺着依赖链向上冒泡 寻找能够接受(accept)此模块热更新的父级模块。若找到,则执行对应回调并替换内存中的模块定义;若一直冒泡到顶层仍无人能接收,则被迫退化为Live Reload(刷新整页)。
第三部分:网络与传输(Networking)
Q1: HTTP/1.1、HTTP/2、HTTP/3 的演进及性能改进
| 协议版本 | 传输层协议 | 多路复用 | 头部压缩 | 队头阻塞 (HOL Blocking) 问题 |
|---|---|---|---|---|
| HTTP/1.1 | TCP | ✕ (有管道化但鸡肋) | ✕ (纯文本 Header,重复大) | 存在(应用层队头阻塞,管道化请求必须按顺序响应)。 |
| HTTP/2 | TCP | ✓ (基于二进制分帧) | ✓ (HPACK 静态/动态表) | 仍存在(TCP 传输层队头阻塞:若某个 TCP 包丢失,会阻塞后续所有流的重传)。 |
| HTTP/3 | UDP (基于 QUIC) | ✓ (连接内各流完全独立) | ✓ (QPACK,支持乱序解压) | 彻底解决(各流之间无干扰,丢失一个数据包仅影响对应的那一条流)。 |
1. HTTP/2 的核心武器:二进制分帧与多路复用
- 二进制分帧(Binary Framing):HTTP/2 将通信的最小单位改为二进制的"帧(Frame)"(如 Headers 帧、Data 帧),不再是 HTTP/1 的文本换行。
- 多路复用(Multiplexing) :
- 在同一个 TCP 连接上,可以并发发送无数个请求和响应。
- 每个请求/响应被拆分为带有
Stream ID的帧。 - 这些帧在 TCP 通道上是交错、乱序发送 的,客户端收到后,通过
Stream ID将属于同一个请求的帧重组。 - 这解决了 HTTP/1.1 下浏览器为了并发请求不得不开启 6 个 TCP 连接的尴尬,实现了连接复用。
2. HTTP/2 的遗留痛点与 HTTP/3 的 QUIC
- TCP 协议的队头阻塞 :
- TCP 是面向连接、保证可靠传输的协议。在传输层看来,所有的 HTTP/2 流都被塞入同一个 TCP 管道中。
- 如果其中一个分片包在网络传输中丢失,TCP 必须停下来,等待重传这个丢失的包,才能把后面的数据递交给应用层。
- 这导致:即使其他 HTTP/2 流的数据早已安全到达,也无法被解包读取,形成了传输层队头阻塞。
- HTTP/3 拥抱 UDP (QUIC) :
- QUIC 协议将可靠传输逻辑移到了应用层,在 UDP 之上实现了连接管理、丢包重传和拥塞控制。
- 在 QUIC 中,不同的流(Stream)之间是绝对隔离、相互独立的。
- Stream A 的数据包丢失,只会导致 Stream A 阻塞等待重传;Stream B、C 的传输完全不受任何影响,从而在根本上消除了队头阻塞。
Q2: 强缓存与协商缓存的完整决策流程及常见应用场景
浏览器在发起 HTTP 请求时,会优先检索缓存。缓存分为强缓存 和协商缓存。
1. 强缓存 (Strong Cache)
- 特点 :浏览器直接从本地缓存中读取数据,不向服务器发起任何网络请求 。在 Chrome Network 中会显示为
from disk cache或from memory cache,状态码为 200。 - 控制字段 :
Expires(HTTP/1.0):服务器绝对时间戳(如Thu, 01 Dec 2026 16:00:00 GMT)。缺点是若客户端本地时间被修改,会导致缓存失效。Cache-Control(HTTP/1.1,优先级最高 ):相对时间跨度,单位秒(如max-age=31536000)。常见可选值有:no-store:绝对不允许缓存任何数据。no-cache:可以缓存,但每次使用前必须先向服务器发起请求进行协商缓存校验。public/private:是否允许中间代理服务器(如 CDN)缓存。
2. 协商缓存 (Validation Cache)
- 特点 :当强缓存失效(超时)后,浏览器携带缓存标识向服务器发起请求,由服务器决定当前缓存是否依然有效。
- 若有效:服务器返回 304 Not Modified,不返回响应体,浏览器直接使用本地缓存。
- 若失效:服务器返回 200 OK,并返回最新的响应体和新的缓存标识。
- 控制字段对 :
- 第一对(基于时间戳) :
Last-Modified(响应头,服务器给出)与If-Modified-Since(请求头,浏览器携带)。- 缺点:时间戳精确度仅为秒级;且如果文件在 1 秒内被修改,或者文件只是被编辑了但内容没变,这种机制会发生误判。
- 第二对(基于内容哈希 - 优先级更高) :
ETag(响应头,唯一资源指纹)与If-None-Match(请求头,浏览器携带)。- 优点:只要文件内容变动,ETag 哈希值必然改变,精准度极高。
- 第一对(基于时间戳) :
【协商缓存决策流程图解】
text
[ 发起 HTTP 请求 ]
│
是否命中强缓存(max-age)?
/ \
(是) (否)
/ \
[ 200 from cache ] 携带缓存标识(If-None-Match/If-Modified-Since)
(不与服务器发生任何通信) 向服务器发起 HTTP 请求
│
服务器比对标识是否一致?
/ \
(是) (否)
/ \
[ 304 Not Modified ] [ 200 OK + 最新数据 ]
(快速返回,复用旧缓存) (传输新内容,更新缓存标识)
3. 最佳实践部署策略
- 静态资源(HTML 除外,如 JS, CSS, 图片) :
- 方案 :开启超长强缓存:
Cache-Control: max-age=31536000。 - 更新机制 :在打包工程中,采用**内容哈希(ContentHash)**作为文件名(如
index.a8bc93.js)。一旦代码改变,文件名改变,等同于请求一个全新资源,完美避开强缓存陷阱。
- 方案 :开启超长强缓存:
- 单页应用 HTML 入口文件(如 index.html) :
- 方案 :设置无强缓存,但走协商缓存:
Cache-Control: no-cache。 - 原因 :必须保证每次用户打开页面,浏览器都要去服务器校验一次
index.html是否有更新,以确保用户能第一时间加载到带有最新 ContentHash 的 JS 静态文件。
- 方案 :设置无强缓存,但走协商缓存:
Q3: HTTPS 的加密握手及秘钥协商完整过程
HTTPS 的核心是 TLS/SSL 协议。为了兼顾安全性 (非对称加密安全性高,但计算极慢)与性能 (对称加密计算快,但秘钥无法在明文通道安全传输),HTTPS 采用了混合加密机制。
1. 核心流程步骤拆解
- Client Hello(客户端握手请求) :
- 客户端向服务端发送自己支持的 TLS 版本、加密套件列表(Cipher Suites)、以及一个客户端随机数 (Client Random)。
- Server Hello(服务端应答) :
- 服务端确认 TLS 版本、选择一套加密套件。
- 向客户端发送一个服务端随机数 (Server Random)。
- 向客户端发送数字证书(Certificate) (内含服务端的 非对称公钥)。
- 证书校验 :
- 客户端读取证书,顺着证书链向内置在操作系统/浏览器中的受信任根证书颁发机构 (CA) 发起数字签名校验,确保证书真实合法,防止中间人攻击。
- Pre-Master Secret 传输 :
- 证书校验无误后,客户端在本地生成第三个随机数:预主秘钥 (Pre-Master Secret)。
- 客户端使用证书中的服务端公钥 对
Pre-Master Secret进行加密,并发送给服务端。
- 生成会话秘钥(Session Key) :
- 服务端收到密文,使用自己绝对保密的非对称私钥 解密,得到
Pre-Master Secret。 - 此时,客户端与服务端都同时拥有了:
Client Random+Server Random+Pre-Master Secret。 - 双方各自利用这三个随机数,通过约定的算法计算出完全一致的会话秘钥(Session Key)。
- 服务端收到密文,使用自己绝对保密的非对称私钥 解密,得到
- 握手完毕与对称通信 :
- 双方互相发送 Finished 报文,声明后续所有的应用层 HTTP 数据传输都将全部采用 Session Key(对称加密) 进行加解密。
Q4: WebRTC 的 P2P 连接建立过程(信令与打洞)
WebRTC 允许浏览器之间直接进行端到端(P2P)的音视频和数据传输。但由于防火墙 and NAT(网络地址转换)的存在,两个浏览器很难直接获知对方的真实 IP 从而建立连接。这就需要一套复杂的信令协商 与打洞机制。
1. 核心概念释义
- 信令服务器(Signaling Server):双端在建立 P2P 前,用于中转媒体配置信息(SDP)和网络候选地址(ICE Candidate)的公共服务器(通常基于 WebSocket 实现)。
- SDP(Session Description Protocol - 会话描述协议):一种文本格式,包含本端支持的音视频编码格式、分辨率、以及其他媒体协商参数。
- STUN 服务器:用于帮助处于 NAT 后方的客户端查询自己映射后的公网 IP 和端口号。
- TURN 服务器 :当 NAT 限制极度严格(对称型 NAT),双端无论如何打洞都无法直接互通时,充当流量中转服务器(此时 P2P 退化为中转,但传输依旧受 TLS 加密保护)。
【结构化伪代码:WebRTC 双端 P2P 建立的核心逻辑】
javascript
// 假设双端为 呼叫端(Caller) 和 接收端(Callee)
// 双端通过 WebSocket信令通道 进行数据中转
/* ---------------- 呼叫端 (Caller) 逻辑 ---------------- */
函数 发起呼叫() {
// 1. 创建本地 PeerConnection 连接对象
本地连接 = 创建 RTCPeerConnection({
ice服务器配置列表: [{ url: "stun:stun.l.google.com:19302" }]
})
// 2. 监听 ICE 候选地址生成
本地连接.当收集到ICE候选地址 = (事件) => {
如果 (事件.候选地址存在) {
通过信令通道发送给接收端("candidate", 事件.候选地址)
}
}
// 3. 创建 SDP 提议 (Offer)
提议SDP = 异步 本地连接.创建提议()
异步 本地连接.设置本地描述(提议SDP)
// 4. 将提议 SDP 通过信令服务器发送给接收端
通过信令通道发送给接收端("offer", 提议SDP)
}
当信令通道收到应答(应答SDP) {
// 5. 写入接收端的回应 SDP
异步 本地连接.设置远端描述(应答SDP)
}
/* ---------------- 接收端 (Callee) 逻辑 ---------------- */
当信令通道收到提议(提议SDP) {
// 1. 创建自己的 PeerConnection 对象
接收端连接 = 创建 RTCPeerConnection({
ice服务器配置列表: [{ url: "stun:stun.l.google.com:19302" }]
})
// 2. 监听并收集自己的 ICE 候选地址发送给呼叫端
接收端连接.当收集到ICE候选地址 = (事件) => {
如果 (事件.候选地址存在) {
通过信令通道发送给呼叫端("candidate", 事件.候选地址)
}
}
// 3. 写入呼叫端的提议 SDP
异步 接收端连接.设置远端描述(提议SDP)
// 4. 创建 SDP 应答 (Answer)
应答SDP = 异步 接收端连接.创建应答()
异步 接收端连接.设置本地描述(应答SDP)
// 5. 回复应答 SDP
通过信令通道发送给呼叫端("answer", 应答SDP)
}
/* ---------------- 双端通用网络协商 (ICE Candidate) ---------------- */
当任何一端从信令通道收到("candidate", 候选地址) {
// 将对方的网络候选通路塞入连接中,WebRTC 会在后台自动尝试打洞测试联通性
异步 本端连接.添加远端候选地址(候选地址)
}
【WebRTC 双端 P2P 建立的核心流程】
javascript
// 真实业务中,双端通过 WebSocket 信令通道交换消息。这里定义底层的 WebRTC 连接类。
// 1. 呼叫端 (Caller)
class CallerPC {
constructor(signaling) {
this.signaling = signaling; // 外部传入的信令通信实例
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] // 免费 STUN 服务器用于获取公网 IP
});
// 监听本地 ICE 候选地址的生成并发送给接收端
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.signaling.send(JSON.stringify({ type: 'candidate', data: event.candidate }));
}
};
}
// 发起呼叫,创建 SDP Offer 提议
async startCall() {
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.signaling.send(JSON.stringify({ type: 'offer', data: offer }));
}
// 写入远端的 SDP Answer 应答
async handleAnswer(answer) {
await this.pc.setRemoteDescription(new RTCSessionDescription(answer));
}
// 写入远端的 ICE 候选通道,开始底层打洞连接
async handleRemoteCandidate(candidate) {
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
}
}
// 2. 接收端 (Callee)
class CalleePC {
constructor(signaling) {
this.signaling = signaling;
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.signaling.send(JSON.stringify({ type: 'candidate', data: event.candidate }));
}
};
}
// 写入呼叫端的 SDP Offer,并创建回应的 SDP Answer
async handleOffer(offer) {
await this.pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.signaling.send(JSON.stringify({ type: 'answer', data: answer }));
}
async handleRemoteCandidate(candidate) {
await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
}
}