第1篇:JS 大整数精度问题——Number 的边界与进化方向
刚面了个5年前端工程师,简历上写着精通 JavaScript 核心原理,处理过海量数据场景。我递过水,问了一个让他笑容逐渐凝固的问题。
JS 里 Number 最大安全整数是 2 的 53 次方减 1(即 Number.MAX_SAFE_INTEGER = 9007199254740991)。如果我给你一个后端返回的字符串 ID 9007199254740993,超过了这个值,你怎么处理?
他很快接住:用 BigInt,原生支持大整数,末尾加 n 或用 BigInt() 构造,精度不丢失。我点点头,继续追问。
那好,BigInt 和 Number 不能混算,后端接口大部分还是 JSON 格式,JSON.stringify 碰到 BigInt 直接报错,你打算怎么序列化?前端传给后端时又怎么保证服务端能正确解析?他想了想,可以自定义 replacer 把 BigInt 转成字符串,或者用第三方库,比如 lossless-json。
行,那如果业务需要做加减乘除,甚至开根号、幂运算,BigInt 不支持小数,财务场景下要算一个超过 Number 上限的利率,精确到小数点后四位怎么办?有没有比 BigInt 加手动移位更优雅的方案?
他有点紧张了:可以用 decimal 库,或者把小数转成整数再运算。我继续加压——假如你的页面里既有普通 Number 运算,又有 BigInt 运算,还有来自 WebAssembly 的 i64 整数,三种类型在同一个表达式中互操作,你怎么保证类型安全又不损耗性能?IndexedDB、postMessage 传递 BigInt 有什么坑?
他沉默了,额头微微渗汗。
这道题问的是大数处理,本质上考的是你能否看清 JavaScript 数值体系的边界、缺位与进化方向。只会用 BigInt 的人太多,真正能讲透三层防御的很少。
第一层:BigInt 的基础应用与收口策略(及格线)
BigInt 的致命短板:与 Number 混算会抛出 TypeError,Math 对象的方法全部不支持,不能用位运算,JSON.stringify 直接报错。
成熟的做法是在 API 边界做统一收口——在 axios 拦截器里,对后端返回的大整数字段自动转换,同时在 TypeScript 层面用 branded type 严格区分类型,避免混用。
// ✅ TypeScript branded type 区分 BigInt 与 Number
type BigIntId = bigint & { readonly __brand: 'BigIntId' };
function toBigIntId(val: string): BigIntId {
return BigInt(val) as BigIntId;
}
// ✅ axios 响应拦截器:对白名单字段自动转 BigInt
const BIG_INT_FIELDS = ['id', 'orderId', 'userId', 'amount'];
axios.interceptors.response.use((response) => {
response.data = transformBigIntFields(response.data, BIG_INT_FIELDS);
return response;
});
function transformBigIntFields(obj: any, fields: string[]): any {
if (Array.isArray(obj)) return obj.map(item => transformBigIntFields(item, fields));
if (obj && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [
k,
fields.includes(k) && typeof v === 'string' ? BigInt(v) : transformBigIntFields(v, fields)
])
);
}
return obj;
}
// ✅ 自定义 replacer,序列化时把 BigInt 转回字符串
function bigIntReplacer(_key: string, value: any) {
return typeof value === 'bigint' ? value.toString() : value;
}
const payload = { id: 9007199254740993n, name: '订单' };
JSON.stringify(payload, bigIntReplacer);
// → '{"id":"9007199254740993","name":"订单"}'
// ✅ lossless-json 方案(第三方库,parse 时自动保留大数精度)
import { parse, stringify } from 'lossless-json';
const parsed = parse('{"id":9007199254740993}'); // id 为 LosslessNumber 对象,不丢精度
对于需要小数的超大数(如财务金额),退化为字符串传输,交给后端或专用微服务计算,前端只做展示:
// 财务场景:前端不做超大浮点运算,只格式化展示
function formatLargeAmount(amountStr: string, decimals = 4): string {
// 用 Intl.NumberFormat 展示,不参与运算
const num = parseFloat(amountStr);
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(num);
}
第二层:高性能运算与跨类型互操作
BigInt 的性能比 Number 慢,且会频繁触发垃圾回收。对于高频大数运算(链上交易解析、大量订单聚合),可以把热路径运算下沉到 WebAssembly。
⚠️ 注意:Wasm 原生支持 i64/u64,但 128 位整数需要 Rust 侧自行实现分拆,并非 Wasm 规范内置类型。Decimal 提案(TC39 Stage 1)目前仍在早期讨论阶段,2026 年尚无主流浏览器原生支持,生产中仍需依赖
decimal.js等库。
// Rust 侧(编译为 Wasm):u64 加法,通过两个 i32 拼接传递 i64
#[no_mangle]
pub extern "C" fn add_u64(a_lo: u32, a_hi: u32, b_lo: u32, b_hi: u32) -> u64 {
let a = ((a_hi as u64) << 32) | (a_lo as u64);
let b = ((b_hi as u64) << 32) | (b_lo as u64);
a + b
}
// JS 胶水代码:调用 Wasm,将 BigInt 拆分为高低 32 位传入
const wasmModule = await WebAssembly.instantiateStreaming(fetch('/math.wasm'));
const { add_u64 } = wasmModule.instance.exports;
function bigIntToHiLo(n) {
return { lo: Number(n & 0xFFFFFFFFn), hi: Number(n >> 32n) };
}
const a = 9007199254740993n;
const b = 9007199254740994n;
const { lo: alo, hi: ahi } = bigIntToHiLo(a);
const { lo: blo, hi: bhi } = bigIntToHiLo(b);
// Wasm 返回的是 JS Number(f64),大整数需用 BigInt 重新组合
const result = add_u64(alo, ahi, blo, bhi);
console.log(result); // Wasm 层面精确,JS 侧需注意精度边界
对于需要高精度小数的财务场景,使用成熟的 decimal.js 库:
import Decimal from 'decimal.js';
Decimal.set({ precision: 20, rounding: Decimal.ROUND_HALF_UP });
// 超大利率精确计算
const principal = new Decimal('99999999999999999');
const rate = new Decimal('0.0325');
const interest = principal.times(rate).toDecimalPlaces(4);
console.log(interest.toString()); // '3249999999999999.9675' — 不丢精度
// BigInt 与 Decimal 互操作
function bigIntToDecimal(val: bigint): Decimal {
return new Decimal(val.toString());
}
关于 IndexedDB 和 postMessage 传递 BigInt 的坑:
// ❌ IndexedDB 不支持直接存储 BigInt(会抛 DataCloneError)
const req = indexedDB.open('db', 1);
req.onsuccess = () => {
const store = req.result.transaction('ids', 'readwrite').objectStore('ids');
// store.put(9007199254740993n); // ❌ 报错:DataCloneError
store.put('9007199254740993'); // ✅ 存字符串
};
// ❌ postMessage 跨域传递 BigInt 同样报 DataCloneError(部分浏览器)
// worker.postMessage(9007199254740993n); // 可能报错
// ✅ 统一在跨上下文边界处序列化为字符串,接收端再还原
worker.postMessage({ bigVal: '9007199254740993' });
// worker 内部:const val = BigInt(data.bigVal);
第三层:端到端的大数治理与可观测性
// ✅ PerformanceObserver 监控大数运算长任务
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`[BigInt长任务] ${entry.name}: ${entry.duration.toFixed(2)}ms`);
reportToMonitor({ name: entry.name, duration: entry.duration });
}
}
});
observer.observe({ entryTypes: ['measure'] });
// 包裹大数运算并打点
function trackedBigIntOp(label, fn) {
performance.mark(`${label}-start`);
const result = fn();
performance.mark(`${label}-end`);
performance.measure(label, `${label}-start`, `${label}-end`);
return result;
}
const result = trackedBigIntOp('order-id-transform', () => {
return orderList.map(o => BigInt(o.id));
});
// ✅ 熔断校验:超过业务允许范围直接拒绝
function validateBigIntId(idStr: string): bigint {
if (!/^\d+$/.test(idStr)) throw new Error('非法 ID 格式');
if (idStr.length > 20) {
reportSecurityEvent({ type: 'oversized-id', value: idStr });
throw new Error('ID 超出业务允许范围');
}
return BigInt(idStr);
}
// ✅ 微前端边界:统一通过字符串传递,各应用内部自行还原
// 主应用 → 子应用通信
window.__MICRO_SHARED__ = {
serializeBigInt: (val: bigint) => val.toString(),
deserializeBigInt: (val: string) => BigInt(val),
};
第2篇:前端截图方案——不只是调个库那么简单
刚面了一个5年经验的前端工程师,简历写精通 Canvas,熟悉各种截图方案。我问了个很常见的问题:用户在我们的在线设计工具里,想把自己做的海报截图保存,你会怎么实现?
他几乎是秒答:用 html2canvas 把 DOM 转成 canvas,然后导出图片就行了,很多项目都这么干。我点点头,继续问。
那如果海报里有跨域的图片,截图出来是空白,你怎么处理?他愣了一下——配一下 crossorigin 属性,让后端配一下跨域头。好,那如果海报里有自定义字体,html2canvas 渲染出来和真实样式不一样,字间距、行高全乱了,怎么办?他开始犹豫——那先把字体转成图片。
我笑着说:转成图片,那文字就不能二次编辑了。更麻烦的是,假如海报里嵌了一个 Lottie 动画,截图的时候那一帧刚好是模糊的,截出来一片空白,你怎么保证截图内容和用户看到的一致?
他彻底不说话了。
第一层:渲染保真——让截图和屏幕像素级一致
跨域图片同源代理:
// 服务端代理路由(Node.js/Express)
// 前端图片 URL 统一替换为走自己代理的同源地址
app.get('/img-proxy', async (req, res) => {
const { url } = req.query;
const response = await fetch(decodeURIComponent(url));
const buffer = await response.arrayBuffer();
res.set('Content-Type', response.headers.get('content-type'));
res.set('Cache-Control', 'public, max-age=86400');
res.send(Buffer.from(buffer));
});
// 前端:截图前把所有图片 src 替换为代理地址
function proxyImageSrc(element) {
const imgs = element.querySelectorAll('img[src]');
imgs.forEach(img => {
const original = img.src;
if (!original.startsWith(location.origin)) {
img.src = `/img-proxy?url=${encodeURIComponent(original)}`;
}
});
}
字体加载完成检测后再截图:
// ✅ 用 FontFace API 确保字体真正渲染完毕
async function waitForFonts(fontFamilies) {
const checks = fontFamilies.map(family =>
document.fonts.load(`16px "${family}"`)
);
await Promise.all(checks);
// 额外等一帧,让浏览器完成布局重排
await new Promise(resolve => requestAnimationFrame(resolve));
}
async function captureWithFont(element) {
await waitForFonts(['思源黑体', 'Source Han Sans']);
// 此时字体已完全加载,再触发 html2canvas
const canvas = await html2canvas(element, { useCORS: true });
return canvas.toDataURL('image/png');
}
动画冻结帧:
// ✅ Lottie 动画暂停到当前帧再截图
async function captureWithAnimation(lottieInstance, element) {
// 暂停动画,锁定当前帧
lottieInstance.pause();
// 等一帧确保 canvas 已更新
await new Promise(resolve => requestAnimationFrame(resolve));
const canvas = await html2canvas(element);
const dataUrl = canvas.toDataURL('image/png');
// 截图完成后恢复播放
lottieInstance.play();
return dataUrl;
}
// ✅ CSS 动画暂停
function freezeCSSAnimations(element) {
const all = element.querySelectorAll('*');
all.forEach(el => {
el.style.animationPlayState = 'paused';
});
return () => all.forEach(el => {
el.style.animationPlayState = '';
});
}
第二层:性能与稳定性——截图不能把页面卡死
分块渲染 + Web Worker 合成:
// 主线程:把大图分块,交给 Worker 合成
async function captureInChunks(element, chunkHeight = 1000) {
const { width, height } = element.getBoundingClientRect();
const chunks = Math.ceil(height / chunkHeight);
const blobs = [];
for (let i = 0; i < chunks; i++) {
const canvas = await html2canvas(element, {
y: i * chunkHeight,
height: Math.min(chunkHeight, height - i * chunkHeight),
width,
});
blobs.push(await canvasToBlob(canvas));
// 主动释放每块 canvas 内存
canvas.width = 0;
canvas.height = 0;
}
// 发给 Worker 合成
return new Promise((resolve) => {
const worker = new Worker('/capture-worker.js');
worker.postMessage({ blobs, width, height }, blobs.map(b => b));
worker.onmessage = (e) => resolve(e.data.result);
});
}
function canvasToBlob(canvas) {
return new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
}
// capture-worker.js(Worker 内合成)
self.onmessage = async ({ data: { blobs, width, height } }) => {
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
let y = 0;
for (const blob of blobs) {
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, 0, y);
y += bitmap.height;
bitmap.close(); // 释放 ImageBitmap 内存
}
const result = await canvas.convertToBlob({ type: 'image/png' });
self.postMessage({ result }, [result]);
};
内存检测与降级:
// ✅ 检测可用内存,自动降级分辨率
async function captureWithMemoryGuard(element) {
// navigator.deviceMemory 单位 GB(部分浏览器支持)
const memoryGB = navigator.deviceMemory ?? 4;
const scale = memoryGB < 2 ? 1 : window.devicePixelRatio;
const canvas = await html2canvas(element, { scale });
const format = memoryGB < 2 ? 'image/jpeg' : 'image/png';
const quality = memoryGB < 2 ? 0.8 : 1;
const dataUrl = canvas.toDataURL(format, quality);
// 立即释放 canvas 内存
canvas.width = 0;
canvas.height = 0;
return dataUrl;
}
超时熔断 + 服务端降级:
async function captureWithFallback(element) {
const TIMEOUT_MS = 2000;
const localCapture = html2canvas(element);
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('截图超时')), TIMEOUT_MS)
);
try {
const canvas = await Promise.race([localCapture, timeout]);
return canvas.toDataURL('image/png');
} catch (e) {
console.warn('客户端截图失败,降级为服务端截图', e);
// 把 DOM 序列化发给服务端渲染
const html = element.outerHTML;
const res = await fetch('/api/screenshot', {
method: 'POST',
body: JSON.stringify({ html, css: getInlineStyles(element) }),
headers: { 'Content-Type': 'application/json' },
});
return res.json().then(d => d.imageUrl);
}
}
第三层:可观测与自适应——让截图失败能被发现
// ✅ 截图打点上报
async function captureWithMonitor(element) {
const start = performance.now();
let success = false;
let fallback = false;
try {
const result = await captureWithFallback(element);
success = true;
return result;
} catch (e) {
fallback = true;
throw e;
} finally {
const duration = performance.now() - start;
reportMetric({
event: 'screenshot',
duration,
success,
fallback,
dpr: window.devicePixelRatio,
memory: navigator.deviceMemory,
});
}
}
// ✅ 像素差异一致性校验(开发/灰度环境)
async function validateScreenshotConsistency(element) {
if (!['development', 'gray'].includes(window.__ENV__)) return;
const [screenshot, reference] = await Promise.all([
captureWithFallback(element),
generateReferenceSnapshot(element), // 用 puppeteer 服务端生成基准图
]);
const diff = await pixelDiff(screenshot, reference);
if (diff.percentage > 0.01) {
console.error(`截图与基准差异 ${(diff.percentage * 100).toFixed(2)}%,疑似字体回退`);
reportInconsistency(diff);
}
}
第3篇:退出前发送埋点数据——可靠上报系统的三层架构
刚面了一个5年前端,简历写精通前端监控,对埋点 SDK 有深入落地经验。我问他:退出浏览器之前,发送积压的埋点数据请求该如何做?
他脱口而出:用 navigator.sendBeacon,它在页面卸载时异步发送,不阻塞页面关闭,而且本身就是 POST 请求,体积小。我点头——标准答案。
那如果用户直接杀进程、浏览器崩溃,你的 beacon 还发得出去吗?他愣了下:那应该不行,崩溃时监听不到。我继续压——一个电商大促页面,用户连续打开 30 个商品详情,每个详情积压了 50 条交互埋点,退出瞬间要发 1500 条,beacon 单次请求能携带多少数据?
他想了想:不同浏览器限制不太一样,大概 64KB。我说那 1500 条,你准备发多少次?sendBeacon 快速连续调用十次以上浏览器会怎么处理?还有用户点击关闭标签页的那一刻,主线程是否还能执行你的压缩序列化?
他开始沉默,面试到此就结束了。
第一层:底层持久化——让数据不依赖内存,崩溃也能续传
⚠️ 注意:Periodic Background Sync 目前仅 Chrome 支持,且需要用户授予权限并安装 PWA,不是普通网页可以随意使用的 API。
// ✅ 埋点产生后立即写入 IndexedDB,不依赖内存
class PersistentEventQueue {
constructor(dbName = 'tracker-db') {
this.dbPromise = this._initDB(dbName);
}
async _initDB(name) {
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore('events', {
keyPath: 'id',
autoIncrement: true,
});
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async push(event) {
const db = await this.dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction('events', 'readwrite');
tx.objectStore('events').add({
...event,
timestamp: Date.now(),
retryCount: 0,
});
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
async getAll() {
const db = await this.dbPromise;
return new Promise((resolve) => {
const tx = db.transaction('events', 'readonly');
const req = tx.objectStore('events').getAll();
req.onsuccess = () => resolve(req.result);
});
}
async clearIds(ids) {
const db = await this.dbPromise;
const tx = db.transaction('events', 'readwrite');
const store = tx.objectStore('events');
ids.forEach(id => store.delete(id));
return new Promise(resolve => tx.oncomplete = resolve);
}
}
// ✅ 页面启动时检查上次未上报的"孤儿"记录
async function recoverOrphanEvents(queue) {
const events = await queue.getAll();
if (events.length === 0) return;
console.log(`[Tracker] 发现 ${events.length} 条未上报事件,开始断点续传`);
const ok = navigator.sendBeacon('/api/track', JSON.stringify(events));
if (ok) {
await queue.clearIds(events.map(e => e.id));
}
}
第二层:多级退出通道——分层优先,不把鸡蛋放在 beacon 一个篮子里
class ExitReporter {
constructor(queue) {
this.queue = queue;
this._bindExitEvents();
}
_bindExitEvents() {
// visibilitychange 比 beforeunload 更早触发,移动端更可靠
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this._flush();
}
});
window.addEventListener('pagehide', () => {
this._flush();
}, { capture: true });
}
async _flush() {
const events = await this.queue.getAll();
if (events.length === 0) return;
const body = JSON.stringify(events);
// 第一优先:fetch keepalive(支持更大 body,最大约 64KB)
const sent = await this._trySendKeepAlive(body, events);
if (sent) return;
// 第二优先:sendBeacon(体积限制约 64KB,超出返回 false)
const beaconOk = navigator.sendBeacon('/api/track', body);
if (beaconOk) {
await this.queue.clearIds(events.map(e => e.id));
return;
}
// 兜底:数据留在 IndexedDB,等下次启动续传(已在第一层实现)
console.warn('[Tracker] 所有退出通道失败,数据已持久化等待重传');
}
async _trySendKeepAlive(body, events) {
try {
await fetch('/api/track', {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
keepalive: true, // 页面关闭后请求继续
});
await this.queue.clearIds(events.map(e => e.id));
return true;
} catch {
return false;
}
}
}
// ✅ Service Worker 后台同步(需安装 SW,是进阶方案)
// sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'tracker-sync') {
event.waitUntil(flushTrackerFromSW());
}
});
async function flushTrackerFromSW() {
// 从 SW 侧读取 IndexedDB 并上报
const cache = await caches.open('tracker-cache');
const events = await getEventsFromIDB(); // 同上方的 getAll 逻辑
if (events.length === 0) return;
await fetch('/api/track', {
method: 'POST',
body: JSON.stringify(events),
headers: { 'Content-Type': 'application/json' },
});
}
// 主页面注册一次性后台同步
async function registerSyncOnFail() {
if (!('serviceWorker' in navigator) || !('SyncManager' in window)) return;
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('tracker-sync');
}
第三层:退出时机与执行预算——在最后 50ms 里抢到数据
// ✅ 利用 scheduler.postTask 保证高优先级执行(Chrome 94+)
async function flushWithBudget(events) {
if ('scheduler' in window && 'postTask' in scheduler) {
await scheduler.postTask(() => doFlush(events), {
priority: 'user-blocking', // 最高优先级
});
} else {
// 降级为同步执行
doFlush(events);
}
}
function doFlush(events) {
// 限制 50ms 内完成:超时则发原始数据片段
const deadline = Date.now() + 45;
const toSend = [];
for (const e of events) {
if (Date.now() > deadline) {
console.warn('[Tracker] 时间预算耗尽,截断发送');
break;
}
toSend.push(e);
}
// 就算只有部分数据也发出去
navigator.sendBeacon('/api/track', JSON.stringify(toSend));
}
// ✅ 监控退出上报成功率
function monitorBeaconSuccess(events) {
const ok = navigator.sendBeacon('/api/track', JSON.stringify(events));
reportMetric({
event: 'exit_beacon',
success: ok,
eventCount: events.length,
// sendBeacon 返回 false 说明浏览器队列已满或数据超限
});
return ok;
}
第4篇:微前端 JS 隔离——从变量不撞名到分布式运行时
刚面了个5年前端,简历上写着主导过大型微前端项目,对 JS 隔离有深入落地经验。我开门见山:微前端架构里,你们怎么让多个子应用的 JavaScript 互不污染?
他几乎没停顿:常用三种。iframe 隔离最彻底,但通讯成本高;快照沙箱激活时存全局快照、卸载时恢复;Proxy 沙箱伪造一个干净 window,读写都代理到假对象上,qiankun 用的就是这种。
我接着问:那 Proxy 沙箱只能拦截显式属性赋值,如果某个子应用直接改 proto 或者篡改 Array.prototype.push,你怎么办?
他迟疑了一下:可以做冻结,或者在加载前用严格模式检测。我说:就算你能防住原型链污染,Shadow Realm API 能给每个子应用一个独立全局,连 Object、Promise 这种内建对象都是全新副本,彻底隔断。你为什么还要在同一个 Realm 里用 Proxy 玩猫捉老鼠?
他直接沉默了。
⚠️ 注意:Shadow Realm 目前(2025 年底)处于 TC39 Stage 3,Chrome 等浏览器尚未正式落地,生产中仍以 Proxy 沙箱 + iframe 为主流。
第一层:执行环境级隔离——Proxy 沙箱实现与 Shadow Realm 对比
// ✅ Proxy 沙箱实现(qiankun 类似思路)
class ProxySandbox {
constructor(name) {
this.name = name;
this.active = false;
this.modifiedProps = new Map();
const rawWindow = window;
this.proxy = new Proxy(rawWindow, {
set: (target, prop, value) => {
if (this.active) {
this.modifiedProps.set(prop, value);
}
return true;
},
get: (target, prop) => {
// 优先从沙箱自己的修改中取值
if (this.modifiedProps.has(prop)) {
return this.modifiedProps.get(prop);
}
return Reflect.get(target, prop);
},
});
}
activate() {
this.active = true;
}
deactivate() {
this.active = false;
// 卸载时不需要还原 window,因为修改都在 modifiedProps 里
this.modifiedProps.clear();
}
}
// ❌ Proxy 无法拦截原型链污染
// 子应用执行:Array.prototype.push = () => 'hacked'
// 主应用的 Array.prototype.push 也被污染了
// ✅ 防御原型链污染:在子应用加载前冻结内建对象
function freezeBuiltins() {
[Array, Object, Function, Promise, Map, Set].forEach(ctor => {
Object.freeze(ctor.prototype);
});
}
// ✅ Shadow Realm(目前 Stage 3,可在 polyfill 或 Node.js 22+ 中试用)
// const realm = new ShadowRealm();
// realm.evaluate(`globalThis.myVar = 'isolated'`);
// console.log(typeof myVar); // 'undefined' — 主页面不受影响
// ✅ 降级方案:同源 Worker 实现逻辑隔离
function createWorkerSandbox(scriptUrl) {
const worker = new Worker(scriptUrl, { type: 'module' });
// 主线程与 Worker 通过消息总线通信
const rpc = {
call(method, args) {
return new Promise((resolve, reject) => {
const id = Math.random().toString(36);
worker.postMessage({ id, method, args });
worker.addEventListener('message', function handler({ data }) {
if (data.id === id) {
worker.removeEventListener('message', handler);
data.error ? reject(data.error) : resolve(data.result);
}
});
});
},
};
return rpc;
}
第二层:依赖共享与运行时去重——用 Import Maps 消灭重复 React 实例
<!-- ✅ Import Maps:全站统一 React 版本,子应用 import React 命中同一份缓存 -->
<script type="importmap">
{
"imports": {
"react": "https://cdn.example.com/react@18.3.0/index.js",
"react-dom": "https://cdn.example.com/react-dom@18.3.0/index.js",
"react-dom/client": "https://cdn.example.com/react-dom@18.3.0/client.js"
}
}
</script>
<!-- 子应用无需打包 React,直接 import 命中全局缓存 -->
<script type="module">
import React from 'react'; // 走 Import Map,不重复加载
import ReactDOM from 'react-dom';
// ...
</script>
// ✅ Module Federation(webpack 5)共享配置
// webpack.config.js(子应用)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'subApp',
shared: {
react: {
singleton: true, // 全局只允许一个实例
requiredVersion: '^18.0.0',
eager: false,
},
'react-dom': {
singleton: true,
requiredVersion: '^18.0.0',
},
},
}),
],
};
第三层:运行时可观测与故障熔断——每个子应用都有死刑线
// ✅ 用 PerformanceObserver 监控子应用长任务
function monitorSubApp(appName) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`[${appName}] 长任务 ${entry.duration.toFixed(0)}ms`);
handleSubAppOverload(appName, entry.duration);
}
}
});
observer.observe({ entryTypes: ['longtask'] });
return () => observer.disconnect(); // 返回卸载函数
}
function handleSubAppOverload(appName, duration) {
// 上报监控
reportMetric({ app: appName, type: 'longtask', duration });
// 超过 3 次触发降级
const count = (overloadCounts[appName] = (overloadCounts[appName] ?? 0) + 1);
if (count >= 3) {
console.error(`[${appName}] 连续超载,降级为静态快照`);
unmountSubApp(appName);
showFallbackSnapshot(appName);
}
}
// ✅ 子应用崩溃隔离(Worker 方案,崩溃不影响主应用)
function createIsolatedSubApp(scriptUrl) {
let worker = new Worker(scriptUrl);
worker.onerror = (e) => {
console.error('[子应用崩溃]', e.message);
reportMetric({ type: 'subapp-crash', url: scriptUrl, error: e.message });
// 主应用只收到通知,不崩溃
showFallbackUI();
// 可选:自动重启
worker = new Worker(scriptUrl);
};
return worker;
}
const overloadCounts = {};
第5篇:2026 年前端求职备考路线——精准对标,高效提分
现在前端求职内卷激烈,拼的不是无效时长,而是精准对标考点的高效提分。只要心态稳,肯沉下心,闭环练题加实操,完全能跟上面试新标准。
第一阶段:基础夯实期
主攻语义化标签、H5 新特性、页面结构规范,CSS 核心(盒模型、flex/grid、动画),以及原生 JS 核心原理。
// 面试高频手写:实现 Promise.allSettled
function myAllSettled(promises) {
return Promise.all(
promises.map(p =>
Promise.resolve(p).then(
value => ({ status: 'fulfilled', value }),
reason => ({ status: 'rejected', reason })
)
)
);
}
// 面试高频手写:防抖
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 面试高频手写:原型继承
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function () {
return `${this.name} makes a sound.`;
};
function Dog(name) {
Animal.call(this, name); // 继承实例属性
}
Dog.prototype = Object.create(Animal.prototype); // 继承原型方法
Dog.prototype.constructor = Dog;
// 事件循环(口述必备)
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出顺序:1 → 4 → 3 → 2(同步 → 微任务 → 宏任务)
第二阶段:ES6+ 语法进阶期
// Set / Map 高频考点
const set = new Set([1, 2, 2, 3]); // {1, 2, 3} 自动去重
const map = new Map([['key', 'value']]);
// 解构 + 默认值 + 重命名
const { name: userName = 'anonymous', age = 0 } = user ?? {};
// 可选链 + 空值合并(面试常问区别)
const city = user?.address?.city ?? '未知城市';
// ?. 处理 null/undefined;?? 只在 null/undefined 时取默认值,不同于 ||(0、'' 也会触发 ||)
// 模块化:export / import 静态分析 vs require 动态加载
export const util = () => {}; // 命名导出
export default class MyClass {} // 默认导出
import { util } from './util.js'; // 静态导入,构建时 Tree Shaking
const module = await import('./heavy') // 动态导入,按需加载
// Generator + async/await 底层关系
function* gen() {
const a = yield Promise.resolve(1);
const b = yield Promise.resolve(2);
return a + b;
}
// async/await 本质是 Generator + 自动执行器(Promise 驱动)
第三阶段:框架深根加原理期
// Vue 3 响应式原理(Proxy 实现)
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
track(obj, key); // 依赖收集
return Reflect.get(obj, key, receiver);
},
set(obj, key, value, receiver) {
const result = Reflect.set(obj, key, value, receiver);
trigger(obj, key); // 触发更新
return result;
},
});
}
// React Hooks 闭包陷阱(面试必考)
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// ❌ 闭包捕获的 count 永远是 0
// console.log(count);
// setCount(count + 1);
// ✅ 用函数式更新,始终拿到最新值
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组,effect 只运行一次
return <div>{count}</div>;
}
// React 性能优化:memo + useCallback 避免子组件无效重渲染
const Child = React.memo(({ onClick }) => {
console.log('Child render');
return <button onClick={onClick}>Click</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// ✅ useCallback 缓存函数引用,count 变化时 Child 不重渲染
const handleClick = useCallback(() => console.log('clicked'), []);
return (
<>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>+</button>
<Child onClick={handleClick} />
</>
);
}
第四阶段:实战项目期
// 权限控制(中后台必考)
const routes = [
{
path: '/admin',
component: AdminPage,
meta: { roles: ['admin', 'superAdmin'] },
},
];
router.beforeEach((to, _from, next) => {
const userRoles = store.state.user.roles;
const required = to.meta.roles;
if (!required || required.some(r => userRoles.includes(r))) {
next();
} else {
next('/403');
}
});
// 虚拟列表(海量数据性能优化)
function VirtualList({ items, itemHeight = 50, containerHeight = 500 }) {
const [scrollTop, setScrollTop] = useState(0);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + visibleCount + 1, items.length);
const visibleItems = items.slice(startIndex, endIndex);
const offsetY = startIndex * itemHeight;
return (
<div
style={{ height: containerHeight, overflowY: 'auto' }}
onScroll={e => setScrollTop(e.target.scrollTop)}
>
<div style={{ height: items.length * itemHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item, i) => (
<div key={startIndex + i} style={{ height: itemHeight }}>
{item.name}
</div>
))}
</div>
</div>
</div>
);
}
第6篇:数据驱动视图——不是自动驱动,是调度式治理
刚面了个6年前端,简历写着深入理解数据驱动视图。我直接问:用口述解释它和 jQuery 操作 DOM 的本质区别。
他答:以前手动拼 DOM 找节点,现在框架用响应式系统追踪状态,自动算最小变化,程序员只关心数据。我问:复杂表单 30 个输入框双向绑定,用户快速输入时怎么保证不掉帧?他说框架用异步批量更新,改数据合并成一次微任务。我接着问:那一次微任务算出来超 8ms,下一帧来不及怎么办?他犹豫说加 debounce。我反问:防抖 400ms 意味着每敲一键半天没反馈,这叫数据驱动视图,是你把驱动轮子拆了。
第一层:数据驱动不是自动驱动,是变更代理与调度式更新
// ✅ 高频数据(行情推送):脱离响应式,用 rAF 批量提交
class HighFreqStore {
constructor() {
this._raw = {}; // 裸对象,不进响应式
this._dirty = false;
this._subscribers = [];
}
set(key, value) {
this._raw[key] = value;
if (!this._dirty) {
this._dirty = true;
requestAnimationFrame(() => {
this._subscribers.forEach(fn => fn(this._raw));
this._dirty = false;
});
}
}
subscribe(fn) {
this._subscribers.push(fn);
}
}
// 股票价格每 200ms 更新,不走响应式,只在 rAF 里刷新一次 DOM
const stockStore = new HighFreqStore();
stockStore.subscribe(data => {
document.getElementById('price').textContent = data.price;
});
setInterval(() => stockStore.set('price', Math.random() * 100), 200);
// ✅ scheduler.postTask(Chrome 94+):给中频数据分配优先级
async function updateForm(data) {
if ('scheduler' in window) {
await scheduler.postTask(() => {
formState.value = data;
}, { priority: 'user-visible' }); // 不是 user-blocking,让出给输入事件
} else {
// 降级:用 Promise.resolve() 放入微任务队列
await Promise.resolve();
formState.value = data;
}
}
第二层:精确订阅——响应式孤岛,避免 A 的高频抖动带跑 C
// Vue 3 中用 shallowRef + watchEffect 实现孤岛隔离
import { shallowRef, watchEffect, computed } from 'vue';
// A:高频股票价(每 200ms 变)
const stockPrice = shallowRef(0);
// B:用户表单(低频)
const formData = shallowRef({ amount: 100 });
// C 的快照缓存:只有距上次超 500ms 且 C 可见时才重算
let lastSnapshotTime = 0;
const chartSnapshot = shallowRef({ price: 0, amount: 100 });
watchEffect(() => {
const now = Date.now();
// C 不直接订阅 stockPrice,而是定时读快照
if (now - lastSnapshotTime > 500) {
chartSnapshot.value = {
price: stockPrice.value,
amount: formData.value.amount,
};
lastSnapshotTime = now;
}
});
// React 中用 useSyncExternalStore 实现类似的孤岛订阅
import { useSyncExternalStore } from 'react';
function useStockPrice() {
return useSyncExternalStore(
stockStore.subscribe.bind(stockStore),
() => stockStore.snapshot(), // 快照函数
);
}
第三层:SSR 水合接缝——防止激活前后 DOM 冲突
// ✅ React 18 选择性水合(Suspense 包裹低优先级区域)
import { Suspense, lazy } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
function Page({ serverData }) {
return (
<div>
{/* 高优先区域:立即水合,保证可交互 */}
<Header data={serverData.header} />
<UserForm data={serverData.form} />
{/* 低优先区域:延迟水合,不阻塞首屏交互 */}
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={serverData.chart} />
</Suspense>
</div>
);
}
// ✅ 版本号 diff:检测服务端快照与客户端最新数据的差异
async function hydrateWithVersionCheck(serverSnapshot, fetchLatest) {
const latest = await fetchLatest();
if (latest.version === serverSnapshot.version) {
// 无差异,直接水合
return hydrateDOM(serverSnapshot);
}
// 只有非交互区域有变化,延迟更新
const diffKeys = getDiff(serverSnapshot, latest);
const isInteractiveArea = diffKeys.some(k => INTERACTIVE_FIELDS.includes(k));
if (!isInteractiveArea) {
// 后台静默替换
requestIdleCallback(() => hydrateDOM(latest));
} else {
// 交互区有变化,渐进式水合
hydrateDOM(serverSnapshot); // 先用服务端数据保证可交互
requestIdleCallback(() => patchDOM(latest)); // 空闲时打补丁
}
}
const INTERACTIVE_FIELDS = ['formData', 'userInput'];
第7篇:script 放 head 还是 body——2026 年这道题已被彻底重构
面了一个前端,简历上写精通性能优化与浏览器渲染原理。我问:JS 放在 head 里和放在 body 底部到底有什么区别?
候选人答得很快:放 head 里会阻塞页面渲染,因为浏览器遇到 script 标签就要先下载执行,执行完再继续解析 HTML,导致白屏时间长。放 body 底部就不会阻塞 DOM 树的构建,页面能尽早出来。我点点头——那你有没有想过,即使放 body 底部,如果脚本执行本身要 1 秒,用户狂点按钮,一个都没反应,这叫不阻塞渲染了吗?
第一层:传统阻塞模型的真实边界
<!-- ❌ 同步脚本:阻塞 HTML 解析 + 渲染 -->
<head>
<script src="heavy.js"></script> <!-- 下载 + 执行完才继续解析 -->
</head>
<!-- ✅ defer:不阻塞解析,DOMContentLoaded 之前按顺序执行 -->
<head>
<script src="app.js" defer></script>
<script src="vendor.js" defer></script>
<!-- 保证 vendor 先于 app 执行 -->
</head>
<!-- ✅ async:不阻塞解析,下载完立即执行(不保证顺序) -->
<head>
<script src="analytics.js" async></script>
<!-- 适合无依赖的第三方脚本 -->
</head>
// 核心区别口述:放底部只是"视觉上快",交互阻塞依然存在
// 用 Long Task API 验证脚本执行时长
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`长任务 ${entry.duration.toFixed(0)}ms,阻塞了交互`);
// 即使脚本放底部,执行超 50ms 就是长任务,FID/INP 恶化
}
});
observer.observe({ entryTypes: ['longtask'] });
第二层:async/defer 没解决的根本问题
// defer 把阻塞从"解析阶段"后移到"交互就绪阶段"
// 所有 defer 脚本执行完,页面才真正可交互
// 验证:DOMContentLoaded 触发时机
document.addEventListener('DOMContentLoaded', () => {
// defer 脚本全部执行完毕后才触发这里
// 如果 defer 脚本共耗时 800ms,用户在这 800ms 里交互全冻结
console.log('DOM 就绪,defer 脚本执行完毕');
});
// ✅ 解决方案:把非关键逻辑推入 requestIdleCallback
document.addEventListener('DOMContentLoaded', () => {
// 先激活核心交互
initCriticalUI();
// 非关键逻辑等浏览器空闲时执行
requestIdleCallback(() => {
initAnalytics();
initChatPlugin();
}, { timeout: 2000 }); // 最迟 2 秒后强制执行
});
第三层:2026 年的调度策略——拓扑取代物理位置
<!-- ✅ blocking="render":显式声明哪些脚本必须阻塞渲染 -->
<!-- 目前 Chrome 105+ 支持 -->
<script src="critical-polyfill.js" blocking="render"></script>
<!-- ✅ fetchpriority:控制下载优先级 -->
<link rel="preload" href="app.js" as="script" fetchpriority="high">
<link rel="preload" href="analytics.js" as="script" fetchpriority="low">
<!-- ✅ modulepreload:预加载 ES Module 及其依赖图 -->
<link rel="modulepreload" href="/src/main.js">
<link rel="modulepreload" href="/src/utils.js">
// ✅ 把计算密集型逻辑推入 Web Worker,主线程只保留渲染逻辑
// main.js
const worker = new Worker('/heavy-worker.js', { type: 'module' });
worker.postMessage({ type: 'COMPUTE', data: largeDataSet });
worker.onmessage = ({ data }) => {
// Worker 计算完成,主线程只做渲染
renderChart(data.result);
};
// heavy-worker.js(在 Worker 线程执行,不占主线程)
self.onmessage = ({ data }) => {
if (data.type === 'COMPUTE') {
const result = heavyComputation(data.data);
self.postMessage({ result });
}
};
// ✅ SSR 水合阶段:分片执行,不让水合成为长任务
async function hydrateInChunks(components) {
for (const comp of components) {
// 每次水合前检查是否还有时间预算
await scheduler.yield?.() ?? await new Promise(r => setTimeout(r, 0));
hydrateComponent(comp);
}
}
第8篇:JS 阻塞渲染的真相——从解析阻塞到主线程抢占
刚面了个6年前端,写精通浏览器渲染原理与性能优化。我问他:JS 的加载会阻塞浏览器渲染吗?
他答得很快:会,遇到 script 就暂停解析,所以放底部或者用 async/defer。我追问:电商首页头部有个同步风控脚本,CDN 抖动延迟 3 秒,这 3 秒浏览器到底在干什么?阻塞的是解析还是渲染?有没有可能先画出上方骨架?
他顿住:应该不行,脚本可能操作 DOM。我接着问:那如果把 CSS 放在同步脚本之前呢?脚本下载被 CSS 阻塞(浏览器会等 CSSOM 构建完再执行脚本),渲染又被谁阻塞,预加载扫描器在这中间做了什么?
他开始乱了。
⚠️ 注意:浏览器解析到内联 script 前,若存在未完成的 CSS 加载,会先等 CSSOM 就绪再执行脚本(因为脚本可能查询样式)。这是 CSS 间接阻塞脚本执行的真正原因。
第一层:加载编排——让脚本下载时机服从视觉优先级
<!-- ✅ Early Hints(103 状态码):服务端在 HTML 返回前推送关键资源 -->
<!-- 服务器响应头 -->
<!--
HTTP/1.1 103 Early Hints
Link: </styles/critical.css>; rel=preload; as=style
Link: </scripts/app.js>; rel=preload; as=script
-->
<!-- ✅ preload + fetchpriority:关键资源最高优先级 -->
<link rel="preload" href="/scripts/risk-control.js" as="script" fetchpriority="high">
<!-- ✅ 非关键第三方脚本:低优先级 + defer -->
<script src="https://cdn.analytics.com/track.js" defer fetchpriority="low"></script>
// ✅ 把第三方脚本移入 Worker(partytown 方案思路)
// 主线程只做 DOM,analytics、chat 等推到 Worker
function loadInWorker(scriptUrl, commsConfig) {
const worker = new Worker('/partytown-worker.js');
worker.postMessage({ scriptUrl, commsConfig });
// Worker 内执行第三方脚本,通过消息同步回主线程的 DOM 操作
worker.onmessage = ({ data }) => {
if (data.type === 'DOM_OP') {
executeDOMOperation(data.op);
}
};
}
第二层:执行解耦——JS 执行对像素管线的干扰进入毫秒级可控
// ✅ 首屏脚本执行时间限制在 50ms 内(移动端更严格)
function scheduleHeavyInit(tasks) {
// 把初始化任务拆成多个小片,放入 rIC
function processNext(deadline) {
while (tasks.length > 0 && deadline.timeRemaining() > 5) {
const task = tasks.shift();
task();
}
if (tasks.length > 0) {
requestIdleCallback(processNext, { timeout: 1000 });
}
}
requestIdleCallback(processNext, { timeout: 1000 });
}
// ✅ INP 优化:交互事件优先于渲染
document.getElementById('buy-btn').addEventListener('click', async (e) => {
// 立即给用户反馈(同步,不等数据)
e.currentTarget.disabled = true;
showLoadingSpinner();
// 异步处理业务逻辑,不阻塞渲染
try {
const result = await submitOrder();
showSuccess(result);
} catch (err) {
showError(err);
e.currentTarget.disabled = false;
}
});
// ✅ React 18 Concurrent 模式:startTransition 标记低优先级更新
import { startTransition, useState } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function handleInput(value) {
setQuery(value); // 高优先级:立即更新输入框
startTransition(() => {
// 低优先级:搜索结果可以被新的输入打断
setResults(searchData(value));
});
}
return (
<>
<input value={query} onChange={e => handleInput(e.target.value)} />
<ResultList items={results} />
</>
);
}
第三层:可观测熔断——精确揪出是哪个脚本吃掉了首屏
// ✅ 用 PerformanceObserver 精确定位阻塞脚本
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 找出耗时超 100ms 的资源
if (entry.entryType === 'resource' && entry.duration > 100) {
const breakdown = {
name: entry.name,
total: entry.duration.toFixed(0),
// 区分下载慢、解析慢、执行慢
dns: (entry.domainLookupEnd - entry.domainLookupStart).toFixed(0),
connect: (entry.connectEnd - entry.connectStart).toFixed(0),
download: (entry.responseEnd - entry.responseStart).toFixed(0),
// 注意:单纯的脚本执行时间需要配合 longtask 才能区分
};
console.table(breakdown);
reportBlockingScript(breakdown);
}
}
});
observer.observe({ entryTypes: ['resource', 'longtask', 'navigation'] });
// ✅ 动态脚本降级:第三方脚本执行超时自动降级
function loadThirdPartyWithCircuitBreaker(src, budgetMs = 150) {
return new Promise((resolve, reject) => {
const start = performance.now();
const script = document.createElement('script');
script.src = src;
script.async = true;
script.defer = true;
const timeout = setTimeout(() => {
script.remove();
console.warn(`[熔断] ${src} 超过 ${budgetMs}ms 未加载,已降级`);
reportMetric({ type: 'script-timeout', src, budget: budgetMs });
resolve(null); // 降级为 null,不阻塞主流程
}, budgetMs);
script.onload = () => {
clearTimeout(timeout);
const cost = performance.now() - start;
reportMetric({ type: 'script-load', src, cost });
resolve(script);
};
script.onerror = () => {
clearTimeout(timeout);
reject(new Error(`脚本加载失败: ${src}`));
};
document.head.appendChild(script);
});
}
第9篇:SPA 首屏加载慢——打赢四场战役,压缩到 300ms 以内
面了一个6年前端,简历写着主导过日均千万 PV 的电商 SPA 重构性能优化。我问:SPA 首屏加载慢,你怎么解决?
他放下杯子:代码分割加路由懒加载、Tree Shaking、CDN、开 Brotli、关键资源 preload、dns-prefetch,要更快就预渲染或 SSR。我点点头——标准答案。可你说代码分割,首屏入口 chunk 里依然躺着框架运行时、状态管理、埋点脚本等无法剔除的基础设施,这个初始包体积你怎么继续往下压?
第一战役:网络与资源调度
<!-- ✅ preconnect:提前建立与关键域名的连接 -->
<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- ✅ modulepreload:预加载 ES Module 及其依赖 -->
<link rel="modulepreload" href="/src/main.js">
<!-- ✅ fetchpriority:LCP 图片最高优先级 -->
<img src="/hero.webp" fetchpriority="high" loading="eager" alt="hero">
<!-- 低优先级图片 -->
<img src="/banner.webp" fetchpriority="low" loading="lazy" alt="banner">
// ✅ Service Worker:stale-while-revalidate 策略
// sw.js
const SHELL_CACHE = 'shell-v1';
const SHELL_URLS = ['/', '/index.html', '/styles/main.css', '/scripts/main.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then(cache => cache.addAll(SHELL_URLS))
);
self.skipWaiting();
});
self.addEventListener('fetch', (event) => {
if (SHELL_URLS.includes(new URL(event.request.url).pathname)) {
event.respondWith(
caches.open(SHELL_CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
// 立即返回缓存,后台静默更新
const networkFetch = fetch(event.request).then(res => {
cache.put(event.request, res.clone());
return res;
});
return cached ?? networkFetch;
})
);
}
});
// ✅ Navigation Preload:解决 SW 启动延时
self.addEventListener('activate', (event) => {
event.waitUntil(self.registration.navigationPreload?.enable());
});
第二战役:构建与交付
// ✅ 路由级代码分割(React)
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Product = lazy(() => import('./pages/Product'));
const Cart = lazy(() => import('./pages/Cart'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/product/:id" element={<Product />} />
<Route path="/cart" element={<Cart />} />
</Routes>
</Suspense>
);
}
// ✅ 组件级懒加载(用户交互时才加载)
function ProductPage() {
const [showReviews, setShowReviews] = useState(false);
const Reviews = showReviews ? lazy(() => import('./Reviews')) : null;
return (
<div>
<ProductInfo />
<button onClick={() => setShowReviews(true)}>查看评价</button>
{Reviews && <Suspense fallback={<Spinner />}><Reviews /></Suspense>}
</div>
);
}
// ✅ Webpack Bundle Analyzer 找出体积大头
// package.json
// "analyze": "webpack-bundle-analyzer dist/stats.json"
<!-- ✅ Import Maps:浏览器层面共享依赖,子应用无需打包框架 -->
<script type="importmap">
{
"imports": {
"react": "https://cdn.skypack.dev/react@18",
"react-dom/client": "https://cdn.skypack.dev/react-dom@18/client"
}
}
</script>
第三战役:渲染路径
<!-- ✅ 关键 CSS 内联,消除额外 RTT -->
<style>
/* 首屏必需的最小 CSS,直接内联 */
.hero { width: 100%; height: 60vh; background: #f5f5f5; }
.nav { display: flex; justify-content: space-between; }
</style>
<!-- 非关键 CSS 异步加载 -->
<link rel="stylesheet" href="/styles/full.css" media="print" onload="this.media='all'">
<!-- ✅ font-display: swap + size-adjust:防止字体阻塞文本,减少 CLS -->
<style>
@font-face {
font-family: 'MyFont';
src: url('/fonts/my-font.woff2') format('woff2');
font-display: swap;
}
/* 系统字体回退时用 size-adjust 匹配尺寸,减少布局偏移 */
@font-face {
font-family: 'MyFont-fallback';
src: local('Arial');
size-adjust: 105%;
ascent-override: 90%;
}
</style>
// ✅ content-visibility: auto:跳过视口外元素的布局和绘制
// 长列表页性能提升显著
const style = `
.product-card {
content-visibility: auto;
contain-intrinsic-size: 0 300px; /* 预留高度,防止滚动条跳动 */
}
`;
// ✅ Speculation Rules API:预渲染下一个用户可能访问的页面
// Chrome 109+
const speculationScript = document.createElement('script');
speculationScript.type = 'speculationrules';
speculationScript.textContent = JSON.stringify({
prerender: [
{
where: { href_matches: '/product/*' },
eagerness: 'moderate', // 鼠标悬浮时触发预渲染
},
],
});
document.head.appendChild(speculationScript);
第四战役:持续监控与自适应降级
// ✅ 真用户监控(RUM):采集核心 Web Vitals
import { onLCP, onFCP, onTTFB, onINP, onCLS } from 'web-vitals';
function reportWebVitals(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
navigationType: metric.navigationType,
deviceMemory: navigator.deviceMemory,
connection: navigator.connection?.effectiveType,
});
navigator.sendBeacon('/api/vitals', body);
}
onLCP(reportWebVitals);
onFCP(reportWebVitals);
onTTFB(reportWebVitals);
onINP(reportWebVitals);
onCLS(reportWebVitals);
// ✅ 自适应降级:低端设备关闭动画
function adaptToDevice() {
const memory = navigator.deviceMemory ?? 4; // GB
const connection = navigator.connection?.effectiveType ?? '4g';
const cores = navigator.hardwareConcurrency ?? 4;
if (memory < 2 || connection === '2g' || cores < 2) {
document.documentElement.classList.add('low-end');
// CSS: .low-end * { animation: none !important; transition: none !important; }
disableHeavyFeatures();
}
}
// ✅ 性能预算:CI 中用 Lighthouse 强制执行
// lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
'first-contentful-paint': ['error', { maxNumericValue: 1500 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
},
},
},
};
第10篇:通知用户刷新页面——从弹窗到版本一致性调度治理系统
面了一个4年前端,简历写着做过大型 B 端系统,对前端工程化和用户体验有深刻理解。我直接问:应用上线后怎么通知用户刷新页面?
他回答很快:WebSocket 推送,前端弹个 snackbar 提示用户刷新。我问:用户就是不点,连关十次怎么办?他补:加倒计时 5 秒自动刷。我继续:如果用户正在填复杂表单,你这自动刷新把人家未保存内容全毁了,怎么防御?
他开始冒汗:用 beforeunload 二次确认。我笑了:beforeunload 在现代浏览器已经被阉割得差不多了,移动端自定义文案根本不显示。况且代码触发的 location.reload() 根本拦不住 beforeunload。这不是防御,是在给自己埋生产事故。
第一层:多通道版本检测与无侵入提示
// ✅ Service Worker:安装阶段检测版本变化
// sw.js
const APP_VERSION = '__BUILD_VERSION__'; // 构建时注入
self.addEventListener('install', (event) => {
// 新 SW 安装时,不立即激活,而是通知所有客户端
event.waitUntil(
self.clients.matchAll({ includeUncontrolled: true }).then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'NEW_VERSION_READY',
version: APP_VERSION,
});
});
})
);
// 不调用 skipWaiting(),等用户确认
});
// 主页面接收版本通知
navigator.serviceWorker?.addEventListener('message', ({ data }) => {
if (data.type === 'NEW_VERSION_READY') {
showUpdateBanner(data.version);
}
});
// ✅ 主线程心跳轮询(5 分钟一次)
async function startVersionPolling() {
async function check() {
try {
// 请求带 ETag 的版本文件,304 说明没变化
const res = await fetch('/version.json', { cache: 'no-cache' });
const { version } = await res.json();
const current = window.__APP_VERSION__;
if (version !== current) {
handleVersionChange(version);
}
} catch {
// 网络异常静默处理
}
}
await check();
setInterval(check, 5 * 60 * 1000);
// 网络重连后立即检测
window.addEventListener('online', check);
}
// ✅ 分级提示策略:非破坏性更新只显示静默横幅
function showUpdateBanner(newVersion) {
// 检测用户是否正在输入
if (navigator.scheduling?.isInputPending?.()) {
// 推迟 300ms 后再显示
setTimeout(() => showUpdateBanner(newVersion), 300);
return;
}
const banner = document.createElement('div');
banner.className = 'update-banner';
banner.innerHTML = `
<span>🎉 新版本已就绪</span>
<button id="update-now">立即更新</button>
<button id="update-later">稍后</button>
`;
document.body.prepend(banner);
document.getElementById('update-now').onclick = () => activateNewVersion();
document.getElementById('update-later').onclick = () => banner.remove();
}
第二层:全 tab 协同升级与 SW 无缝接管
// ✅ BroadcastChannel:通知同源所有 tab
const updateChannel = new BroadcastChannel('app-update');
// 一个 tab 决定升级,通知其他 tab
function broadcastUpdate() {
updateChannel.postMessage({ type: 'PREPARE_UPDATE' });
}
// 其他 tab 收到通知,暂停新请求
updateChannel.onmessage = ({ data }) => {
if (data.type === 'PREPARE_UPDATE') {
pauseNewRequests(); // 暂停发出新的业务请求
saveCurrentTabState(); // 暂存当前 tab 状态
}
if (data.type === 'UPDATE_DONE') {
window.location.reload();
}
};
// ✅ 表单状态暂存,刷新后恢复
function saveCurrentTabState() {
const state = {
formData: collectAllFormData(),
scrollY: window.scrollY,
timestamp: Date.now(),
};
sessionStorage.setItem('page-state-snapshot', JSON.stringify(state));
}
function restorePageState() {
const snapshot = sessionStorage.getItem('page-state-snapshot');
if (!snapshot) return;
const { formData, scrollY, timestamp } = JSON.parse(snapshot);
// 只恢复 30 秒内的快照
if (Date.now() - timestamp > 30000) return;
fillFormData(formData);
window.scrollTo({ top: scrollY, behavior: 'instant' });
sessionStorage.removeItem('page-state-snapshot');
}
// 页面加载时尝试恢复
document.addEventListener('DOMContentLoaded', restorePageState);
// ✅ SW 条件化 skipWaiting:等所有 tab 确认才激活
// sw.js
let pendingClients = new Set();
self.addEventListener('message', async ({ data, source }) => {
if (data.type === 'READY_TO_UPDATE') {
pendingClients.add(source.id);
const allClients = await self.clients.matchAll();
if (pendingClients.size >= allClients.length) {
// 所有 tab 都确认了,才执行激活
self.skipWaiting();
const clients = await self.clients.matchAll();
clients.forEach(c => c.postMessage({ type: 'UPDATE_DONE' }));
}
}
});
第三层:局部热替换——用户无感知升级
// ✅ Import Maps 动态替换:不刷新页面,按需替换模块
async function hotReplaceModule(moduleName, newUrl) {
// 注意:Import Maps 一旦注入就无法修改,此方案需配合动态 import()
// 实际热替换通过动态 import 新版本 URL 实现
try {
// 加载新版本组件
const newModule = await import(/* webpackIgnore: true */ newUrl);
// 替换注册表中的组件(以 Vue 为例)
app.component(moduleName, newModule.default);
console.log(`[热替换] ${moduleName} 已更新,用户无感知`);
} catch (e) {
console.error(`[热替换失败] ${moduleName},保持旧版本`, e);
}
}
// ✅ 监控闭环:追踪版本升级关键指标
const updateMetrics = {
bannerShown: 0,
userAccepted: 0,
taskAbandoned: 0, // 升级时用户正在操作导致的任务丢失
report() {
const acceptRate = this.userAccepted / this.bannerShown;
reportMetric({
event: 'version-update',
bannerShown: this.bannerShown,
acceptRate: acceptRate.toFixed(2),
taskAbandoned: this.taskAbandoned,
});
// 接受率低于 30% 说明提示策略太激进,自动调整
if (acceptRate < 0.3) {
console.warn('[版本升级] 接受率过低,切换为更温和的提示策略');
switchToGentlerStrategy();
}
}
};
function switchToGentlerStrategy() {
// 降级为:只在用户主动触发关键操作时才提示
interceptCriticalActions(() => {
showUpdateBanner();
});
}
function interceptCriticalActions(callback) {
// 在支付、提交订单等高价值操作前检查版本
document.addEventListener('click', (e) => {
if (e.target.matches('[data-action="submit"], [data-action="pay"]')) {
if (hasNewVersion()) callback();
}
});
}
这 10 道题,问的从不是某个 API 的用法,而是你在真实系统压力下,能否设计出有边界、可降级、能观测的工程解法。背答案是入门,理解每一层为什么这样设计,才是本事。
