浏览器早就不是一个"渲染 HTML 的窗口"了。它更像一台装在盒子里的操作系统:能联网、能存储、能录音录像、能读文件、能开线程、能离线运行、能推送通知、能跨标签页通信。这些能力对外暴露的接口,统称为 Web API。问题是,很多前端工程师写了三五年业务代码,用过的 Web API 始终停留在 fetch、localStorage 和 addEventListener 这三件套上——不是不想用,而是不知道有什么、不知道怎么用、更不知道用了之后会在什么浏览器上翻车。本篇 45 分钟长文,把 Web API 从分类地图开始铺开,沿着网络请求、实时通信、存储缓存、观察者、设备能力、并发性能六条主线逐一拆解,再补上兼容性策略、错误处理、工程化封装、以及一个可以直接抄进项目的通用 API 客户端。读完你未必能立刻记住所有接口名,但你一定会建立起一套"遇到需求先想想有没有原生能力"的判断习惯。
一、Web API 地图:先知道有什么,再决定用什么
初学者最容易犯的错误,是把"Web API"和"后端 HTTP 接口"混为一谈。这两个词在中文语境里都叫"接口",但完全是两回事:
fetch(Web API)去调用 HTTP API,前者是工具,后者是目标
浏览器里的 Web API 数量超过一千个,但真正在日常业务中高频使用的,其实集中在六大类。点击下面的卡片看看每一类都包含什么:
兼容性:第一道必须先过的关
在写任何一行调用代码之前,先问自己三个问题:
- 目标浏览器支持吗?——查
caniuse.com或 MDN 的兼容性表格,注意区分"部分支持"和"完全支持"。 - 不支持的时候怎么办?——是降级、是提示用户,还是直接放弃这个功能?
- 支持了但行为不一致吗?——同一个 API 在 Safari 和 Chrome 上可能有不同的默认参数、不同的错误类型、不同的边界行为。
特性检测:唯一正确的判断方式
判断浏览器能力,永远不要用 navigator.userAgent 去嗅探浏览器品牌和版本。UA 字符串早已被各种浏览器改得面目全非,而且会随着版本不断变化,维护成本极高。正确做法是直接检测你要用的那个 API 是否存在:
if ('IntersectionObserver' in window) {
// 支持,放心用
} else {
// 不支持,走降级方案
}
// ✅ 需要检测方法而不是属性时
if (typeof navigator.clipboard?.writeText === 'function') { ... }
// ✅ 检测某个属性是否可用(注意 undefined 与 null 的区别)
if ('share' in navigator) { ... }
// ❌ 永远不要这样写
const isChrome = /Chrome/.test(navigator.userAgent);
if (isChrome && version > 80) { ... }
下面这个面板会实时检测你当前浏览器支持哪些常用 API——它就是特性检测思路的直接产物:
渐进增强:把"不支持"当成常态
一个健康的页面,应该做到即使所有新 API 都不可用,核心功能依然能跑通。比如一个表单页面:
const pos = await getCurrentPosition();
const city = await reverseGeocode(pos);
renderWeather(city);
let city = await getCityFromIP();
if (navigator.geolocation) {
try {
const pos = await getCurrentPosition();
city = await reverseGeocode(pos);
} catch {
// 用户拒绝授权,保持 IP 定位结果
}
}
renderWeather(city);
一个必须建立的认知:权限是异步的、可撤销的
和早期浏览器不同,现代 Web API 中涉及隐私的能力(定位、摄像头、麦克风、通知、剪贴板、文件系统)都需要用户显式授权。这意味着:
- 调用会返回 Promise,可能被拒绝,必须处理失败分支。
- 用户可以在浏览器地址栏里随时撤销已授予的权限,你的代码要能应对"上次能用这次不能用"。
- 授权弹窗只能在用户手势(点击等)之后触发,页面加载时自动弹窗会被浏览器拦截。
- 权限状态可以用
navigator.permissions.query()查询,避免无谓的弹窗。
async function ensureCamera() {
if (!navigator.permissions?.query) return true;
const status = await navigator.permissions.query({ name: 'camera' });
if (status.state === 'denied') {
// 已经被拒绝过,再调用也不会弹窗,直接引导用户去设置
throw new Error('摄像头权限已被拒绝,请在浏览器设置中开启');
}
return true;
}
二、网络请求:从 XHR 到 fetch 再到流
网络请求是前端和 Web API 打交道最多的地方。这一节从最老的 XHR 讲起,一路讲到现代的流式响应——不是为了怀旧,而是因为很多遗留项目还在用 XHR,你得能读懂、能改。
2.1 XMLHttpRequest:必须认识的历史遗产
XHR 是 AJAX 时代的开创者。它的事件驱动模型(onreadystatechange、readyState)在今天看来非常笨重,但有两个能力至今没有被 fetch 完全取代:
- 上传进度:
xhr.upload.onprogress是获取上传进度的标准方式,fetch 目前没有等价能力。 - 同步请求:虽然已经被废弃并且会阻塞主线程,但在某些特殊场景(比如页面卸载前保存数据)仍能看到它的身影。
function uploadFile(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
// 上传进度:这是 fetch 至今做不到的
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress?.(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('网络错误')));
xhr.addEventListener('timeout', () => reject(new Error('请求超时')));
xhr.timeout = 30000;
xhr.send(file);
});
}
2.2 fetch:语法更好,但坑也更多
fetch 基于 Promise,写起来清爽得多,但它的行为有几个反直觉之处,是新手翻车的重灾区:
| 行为 | 说明 | 应对 |
|---|---|---|
| 4xx / 5xx 不 reject | fetch 只在网络层失败时 reject,HTTP 错误状态码属于"成功的响应" |
手动检查 res.ok |
| 默认不带 Cookie | 跨域请求需要显式设置 credentials |
传 { credentials: 'include' } |
| 没有超时机制 | 只有 AbortController 可以中断 |
自己封一层 AbortSignal.timeout() |
| body 只能读一次 | res.json() 之后再调 res.text() 会报错 |
先 clone() 或用变量保存 |
| 不支持上传进度 | 只能拿到下载进度(通过流) | 上传进度回退到 XHR |
2.3 AbortController:取消请求的正确姿势
AbortController 不只能取消 fetch,它还能取消事件监听、取消流读取。这是一个被严重低估的 API:
let controller = null;
async function search(keyword) {
// 取消上一次未完成的请求
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
signal: controller.signal,
});
return await res.json();
} catch (err) {
// 主动取消会抛出一个名为 AbortError 的异常,要单独识别
if (err.name === 'AbortError') return null;
throw err;
}
}
// 现代浏览器还提供了两个便捷方法
const signal1 = AbortSignal.timeout(5000); // 5 秒后自动中断
const signal2 = AbortSignal.any([signal1, userSignal]); // 任一触发即中断
// 同一个 signal 还能批量注销事件监听
const ctrl = new AbortController();
window.addEventListener('resize', onResize, { signal: ctrl.signal });
window.addEventListener('scroll', onScroll, { signal: ctrl.signal });
ctrl.abort(); // 一行注销全部
2.4 请求的生命周期:每一步都可能出问题
一个"看起来很简单"的请求,实际上要穿过这么多环节。理解每一步,才知道错误应该在哪里被捕获:
encodeURIComponent。res.json() / res.text()。如果服务端返回的不是合法 JSON,这里会抛错。2.5 流式响应:让数据边到边用
大模型应用普及之后,流式响应从前端"冷门技巧"变成了"必备技能"。fetch 的响应体是一个 ReadableStream,你可以逐块读取:
async function streamRequest(url, onChunk) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true 保证多字节字符不会被截断
buffer += decoder.decode(value, { stream: true });
// 按行切分(SSE 格式以 \n\n 分隔事件)
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // 最后一段可能不完整,留到下一轮
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6);
if (payload === '[DONE]') return;
onChunk(JSON.parse(payload));
}
}
}
三个容易踩的坑:第一,TextDecoder 必须传 { stream: true },否则一个 UTF-8 汉字被拆到两个 chunk 里时会解码成乱码;第二,必须自己维护一个 buffer,因为 chunk 边界不会和行边界对齐;第三,流式请求同样需要 AbortController,用户点"停止生成"时要真的断开连接,而不是只停止渲染。
2.6 并发控制:不要让浏览器替你排队
浏览器对同域名并发连接数有限制(HTTP/1.1 通常是 6 个),一次性发起 200 个请求只会让它们排长队。更糟的是,你的代码以为自己在"并行",实际上前几个请求把带宽占满,后面的全部超时。
async function pool(tasks, limit = 5) {
const results = new Array(tasks.length);
let cursor = 0;
async function worker() {
while (cursor < tasks.length) {
const index = cursor++;
try {
results[index] = { status: 'ok', value: await tasks[index]() };
} catch (err) {
results[index] = { status: 'error', reason: err };
}
}
}
// 启动 limit 个 worker,它们会共享同一个 cursor
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
const tasks = urls.map((u) => () => fetch(u).then((r) => r.json()));
const all = await pool(tasks, 4);
2.7 重试:必须带退避,必须识别可重试错误
无脑重试比不重试更糟——它会把一次网络抖动放大成一次雪崩。正确的重试要满足三个条件:
- 网络错误(
TypeError: Failed to fetch) - 超时(
AbortError且非用户主动取消) - 服务端错误:
500、502、503、504 - 限流:
429(并读取Retry-After头)
- 业务错误:
400、401、403、404 - 用户主动取消的请求
- 非幂等的写操作(除非有幂等键)
- 已经被上游限流且没有退避策略
function isRetryable(err, res) {
if (err?.name === 'AbortError') return false;
if (err) return true; // 网络层错误
return res.status >= 500 || res.status === 429;
}
async function retry(fn, { times = 3, base = 300, max = 5000 } = {}) {
let lastError;
for (let attempt = 0; attempt <= times; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === times || !isRetryable(err)) break;
// 指数退避 + 随机抖动,避免所有客户端同时重试
const delay = Math.min(base * 2 ** attempt, max);
const jitter = delay * 0.3 * Math.random();
await new Promise((r) => setTimeout(r, delay + jitter));
}
}
throw lastError;
}
2.8 请求去重与缓存
同一个数据在页面上被三个组件同时请求,是最常见的浪费。一层简单的内存缓存加"进行中请求共享"就能解决大部分问题:
const cache = new Map();
function request(key, fetcher, ttl = 30000) {
// 1. 命中缓存
const cached = cache.get(key);
if (cached && Date.now() - cached.time < ttl) {
return Promise.resolve(cached.value);
}
// 2. 已有相同请求在飞行中,直接复用
if (inflight.has(key)) return inflight.get(key);
const promise = fetcher()
.then((value) => {
cache.set(key, { value, time: Date.now() });
return value;
})
.finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
需要提醒的是:内存缓存只适合读多写少、容忍短暂不一致的数据。一旦涉及写操作,必须主动失效对应的缓存键,否则用户会看到"我明明改了,怎么还是旧数据"。更复杂的场景应该交给 Service Worker 的 Cache Storage 或者专业的数据请求库。
三、实时通信:四种方案的取舍
"实时"是前端需求里最容易高估的词。很多所谓的实时需求,5 秒一次的轮询就够了;也有一些看起来简单的需求(比如协作编辑),必须上 WebSocket。选错方案的代价,往往在用户量上来之后才暴露。
| 方案 | 方向 | 延迟 | 服务端压力 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 客户端拉 | 高(取决于间隔) | 中 | 数据变化不频繁、容忍延迟 |
| 长轮询 | 客户端拉 | 较低 | 高 | 兼容性要求极高的老系统 |
| SSE | 服务端推(单向) | 低 | 低 | 通知、进度、日志、AI 流式输出 |
| WebSocket | 双向 | 最低 | 中 | 聊天、协作、游戏、行情 |
3.1 EventSource:被低估的服务端推送
SSE 的浏览器端接口叫 EventSource。它的最大优势是浏览器自动重连——断线之后会按照服务端指定的间隔自动重试,不需要你写任何重连逻辑:
// 默认事件(服务端发送 "data: xxx")
es.onmessage = (event) => {
console.log('收到:', JSON.parse(event.data));
};
// 具名事件(服务端发送 "event: progress")
es.addEventListener('progress', (event) => {
updateProgressBar(JSON.parse(event.data));
});
// 连接建立
es.onopen = () => console.log('SSE 已连接');
// 出错(浏览器会自动重连,除非连接被永久关闭)
es.onerror = (err) => {
if (es.readyState === EventSource.CLOSED) {
console.log('连接已关闭,不再重连');
}
};
// 页面卸载前主动关闭,避免浏览器报"连接被中断"
window.addEventListener('beforeunload', () => es.close());
SSE 的限制也很明确:单向(只能服务端推给客户端)、走 HTTP/1.1 时受同域名 6 连接数限制(HTTP/2 下没这个问题)、不能自定义请求头(所以鉴权通常靠 Cookie 或 URL 参数)。如果你的场景是"服务端有消息就推给我,我很少往回发",SSE 几乎总是比 WebSocket 更好的选择。
3.2 WebSocket:双向通道,但要自己兜底
WebSocket 最大的坑是:浏览器的 WebSocket API 不提供自动重连,也没有心跳机制。网络切换、代理超时、服务端重启,都会让连接静默断开。你必须自己实现一套完整的连接管理:
#url; #ws = null;
#handlers = new Map();
#retry = 0;
#heartbeatTimer = null;
#manualClose = false;
constructor(url) {
this.#url = url;
this.#connect();
}
#connect() {
this.#ws = new WebSocket(this.#url);
this.#ws.onopen = () => {
this.#retry = 0; // 连接成功,重置退避计数
this.#startHeartbeat();
this.#emit('open');
};
this.#ws.onmessage = (e) => {
if (e.data === 'pong') return; // 心跳响应,不交给业务
this.#emit('message', JSON.parse(e.data));
};
this.#ws.onclose = () => {
this.#stopHeartbeat();
this.#emit('close');
if (!this.#manualClose) this.#scheduleReconnect();
};
this.#ws.onerror = () => this.#emit('error');
}
#scheduleReconnect() {
// 指数退避,上限 30 秒
const delay = Math.min(1000 * 2 ** this.#retry++, 30000);
setTimeout(() => this.#connect(), delay);
}
#startHeartbeat() {
// 每 20 秒发一次心跳,防止中间代理断开空闲连接
this.#heartbeatTimer = setInterval(() => {
if (this.#ws?.readyState === WebSocket.OPEN) this.#ws.send('ping');
}, 20000);
}
#stopHeartbeat() {
clearInterval(this.#heartbeatTimer);
}
on(type, fn) {
if (!this.#handlers.has(type)) this.#handlers.set(type, new Set());
this.#handlers.get(type).add(fn);
return () => this.#handlers.get(type)?.delete(fn);
}
#emit(type, payload) {
this.#handlers.get(type)?.forEach((fn) => fn(payload));
}
send(data) {
if (this.#ws?.readyState !== WebSocket.OPEN) {
// 生产环境应该放进一个待发送队列,连接恢复后重放
throw new Error('连接未就绪');
}
this.#ws.send(JSON.stringify(data));
}
close() {
this.#manualClose = true;
this.#stopHeartbeat();
this.#ws?.close();
}
}
3.3 BroadcastChannel:跨标签页通信
用户在两个标签页打开了同一个应用,一个登录了一个没登录,或者一个改了数据另一个还显示旧的——这是很常见的体验问题。BroadcastChannel 提供了一条极简的同源跨标签页通道:
const channel = new BroadcastChannel('app-sync');
channel.postMessage({ type: 'login', user: { id: 1, name: '张玥' } });
// 标签页 B:接收并同步状态
const channel = new BroadcastChannel('app-sync');
channel.onmessage = (event) => {
const { type, user } = event.data;
if (type === 'login') store.setUser(user);
if (type === 'logout') store.clear();
};
// 页面卸载时关闭,避免内存泄漏
window.addEventListener('beforeunload', () => channel.close());
配合 localStorage 的 storage 事件,可以实现同样的效果,但 BroadcastChannel 的语义更清晰、支持结构化克隆(可以传对象、Map、ArrayBuffer),也不受存储配额影响。
四、存储与缓存:把数据放在对的地方
"这个数据该存哪"是前端架构里的一个经典问题。答案取决于数据的大小、生命周期、是否需要在 Worker 中访问、以及是否需要在离线时可用。
| 方案 | 容量 | 同步/异步 | 可存类型 | 适用 |
|---|---|---|---|---|
localStorage |
约 5MB | 同步 | 仅字符串 | 用户偏好、主题、Token(谨慎) |
sessionStorage |
约 5MB | 同步 | 仅字符串 | 表单草稿、一次性跳转数据 |
IndexedDB |
数百 MB ~ GB | 异步 | 结构化克隆 | 离线数据、大量本地记录 |
Cache Storage |
受配额限制 | 异步 | Request / Response | 静态资源、离线页面 |
| 内存变量 | 受内存限制 | 同步 | 任意 | 会话级状态、请求缓存 |
4.1 localStorage 的四个必须知道的坑
QuotaExceededError,必须用 try/catch 包裹
JSON.stringify,取出来要 JSON.parse,Date、Map 会丢失类型
const storage = {
get(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
// 数据损坏或存储不可用,返回兜底值而不是崩溃
return fallback;
}
},
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (err) {
// 配额满了,清理一些非关键数据后重试一次
if (err.name === 'QuotaExceededError') {
cleanupOldKeys();
try { localStorage.setItem(key, JSON.stringify(value)); return true; }
catch { return false; }
}
return false;
}
},
};
4.2 IndexedDB:笨重但强大
IndexedDB 的原生 API 以"难用"著称——全是回调、事务、游标。实际项目中几乎一定会封装一层 Promise 化的薄壳:
function openDB(name, version, upgrade) {
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, version);
req.onupgradeneeded = (event) => {
// 只有版本号变化时才会执行,用来建表、建索引
upgrade(req.result, event.oldVersion);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function tx(db, store, mode, fn) {
return new Promise((resolve, reject) => {
const transaction = db.transaction(store, mode);
const result = fn(transaction.objectStore(store));
transaction.oncomplete = () => resolve(result?.result ?? result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
// 使用
const db = await openDB('app', 1, (database) => {
const store = database.createObjectStore('articles', { keyPath: 'id' });
store.createIndex('byDate', 'createdAt');
});
await tx(db, 'articles', 'readwrite', (store) => {
store.put({ id: 1, title: 'Web API 集成', createdAt: Date.now() });
});
IndexedDB 的几个关键特性值得记住:事务是自动提交的(只要没有新的异步操作插进来);版本升级会阻塞其他标签页(需要用 onblocked 处理);数据是结构化克隆存储的,可以直接存 Date、Blob、ArrayBuffer,不需要 JSON 序列化。
4.3 查询存储配额
在决定往客户端存大量数据之前,先问问浏览器还有多少空间:
const { quota, usage } = await navigator.storage.estimate();
const percent = ((usage / quota) * 100).toFixed(1);
console.log(`已使用 ${(usage / 1048576).toFixed(1)}MB / ${(quota / 1048576).toFixed(1)}MB(${percent}%)`);
}
// 申请持久化存储:避免浏览器在空间紧张时自动清理
if (navigator.storage?.persist) {
const granted = await navigator.storage.persist();
console.log(granted ? '数据已被持久化保护' : '浏览器未授予持久化权限');
}
五、观察者 API:告别轮询和滚动监听
在 Observer 系列 API 出现之前,前端要检测"元素是否进入视口"只能监听 scroll 事件再调 getBoundingClientRect()。这个方案有两个致命问题:scroll 事件触发极其频繁(每次滚动可能几十次),而且 getBoundingClientRect() 会强制浏览器同步重排。几十个元素一起这么做,页面立刻卡顿。
Observer API 的思路是:把"检测"这件事交给浏览器,浏览器只在真正发生变化时通知你,而且通知是异步批量的。
| Observer | 观察什么 | 典型用途 |
|---|---|---|
IntersectionObserver |
元素与视口(或指定容器)的交叉状态 | 图片懒加载、无限滚动、曝光埋点、目录高亮 |
MutationObserver |
DOM 节点的增删改、属性变化 | 第三方脚本监控、富文本编辑器、自定义元素 |
ResizeObserver |
元素尺寸变化 | 图表自适应、虚拟列表、容器查询降级 |
PerformanceObserver |
性能条目(LCP、CLS、长任务等) | 真实用户性能监控(RUM) |
5.1 IntersectionObserver:懒加载的标准答案
(entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
// 加载完就停止观察,避免重复触发
obs.unobserve(img);
}
},
{
root: null, // null 表示视口
rootMargin: '200px 0px', // 提前 200px 开始加载
threshold: 0, // 交叉比例达到 0 就触发
}
);
document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));
三个参数的理解:root 是参照容器,默认是浏览器视口,也可以指定为某个可滚动的 div(滚动容器内的懒加载);rootMargin 把参照区域向外扩张或收缩,正值表示"提前触发",负值表示"进入更多才算数";threshold 是触发阈值,可以是数组如 [0, 0.5, 1],表示在交叉比例达到 0%、50%、100% 时各触发一次。
5.2 ResizeObserver:容器查询出现之前的最优解
组件需要知道自己被放到多宽的容器里,才能决定显示几列、要不要隐藏次要信息。在 ResizeObserver 之前,这件事只能靠 window 的 resize 事件加轮询,既不准确也不高效:
for (const entry of entries) {
// contentBoxSize 是内容盒子,borderBoxSize 含边框
const { inlineSize: width } = entry.contentBoxSize[0];
entry.target.dataset.size =
width < 400 ? 'sm' : width < 800 ? 'md' : 'lg';
}
});
document.querySelectorAll('.responsive-card').forEach((el) => ro.observe(el));
特别注意:ResizeObserver 的回调里不要直接修改被观察元素的尺寸,否则会触发"回调 → 改尺寸 → 再回调"的死循环。浏览器会检测到这种情况并抛出 ResizeObserver loop limit exceeded 错误。正确做法是在回调里只更新状态,把 DOM 修改放到 requestAnimationFrame 里。
5.3 MutationObserver:观察 DOM 的每一次变化
这个 API 在业务代码里用得不多,但在两类场景下不可替代:监控第三方脚本对 DOM 的修改,以及实现自定义元素的行为。
for (const m of mutations) {
if (m.type === 'childList') {
m.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) enhance(node);
});
}
if (m.type === 'attributes' && m.attributeName === 'data-theme') {
applyTheme(m.target.getAttribute('data-theme'));
}
}
});
mo.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-theme'],
characterData: false,
});
MutationObserver 的性能开销和观察范围成正比。不要对整个 document 开启 subtree: true 还监听所有属性变化,那会让每一次 DOM 操作都产生一条记录。尽可能收窄 attributeFilter 和观察的根节点。
5.4 PerformanceObserver:把真实用户体验量化
const metrics = {};
// LCP:最大内容绘制
new PerformanceObserver((list) => {
const entries = list.getEntries();
metrics.lcp = entries[entries.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
// CLS:累计布局偏移
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 忽略用户交互后产生的偏移
if (!entry.hadRecentInput) cls += entry.value;
}
metrics.cls = cls;
}).observe({ type: 'layout-shift', buffered: true });
// 长任务:超过 50ms 的任务会阻塞交互
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`长任务:${entry.duration.toFixed(0)}ms`, entry);
}
}).observe({ type: 'longtask', buffered: true });
六、设备与系统能力:把浏览器当操作系统用
6.1 剪贴板:从 execCommand 到 Clipboard API
老式的 document.execCommand('copy') 需要先创建一个不可见的 textarea、选中内容、执行命令、再删掉——丑但兼容性好。现代方案是 navigator.clipboard:
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
toast('已复制');
} catch {
// 降级到老方案
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
// 复制富文本 / 图片
const item = new ClipboardItem({
'text/html': new Blob(['<b>加粗文本</b>'], { type: 'text/html' }),
'text/plain': new Blob(['加粗文本'], { type: 'text/plain' }),
});
await navigator.clipboard.write([item]);
6.2 文件:从 input 到拖拽到 File System Access
文件处理是前端绕不开的需求。三个层次的方案,能力依次增强:
accept 和 multiple 属性使用,得到的是 FileList。dragover 与 drop,从 dataTransfer.files 取文件。必须阻止默认行为。paste,从 clipboardData.items 取图片或文件,截图粘贴的常用方案。const dropzone = document.querySelector('.dropzone');
dropzone.addEventListener('dragover', (e) => {
e.preventDefault(); // 不阻止的话浏览器会直接打开文件
dropzone.classList.add('dragging');
});
dropzone.addEventListener('dragleave', () => {
dropzone.classList.remove('dragging');
});
dropzone.addEventListener('drop', async (e) => {
e.preventDefault();
dropzone.classList.remove('dragging');
const files = [...e.dataTransfer.files];
await uploadAll(files);
});
// 粘贴截图
document.addEventListener('paste', async (e) => {
const items = [...(e.clipboardData?.items ?? [])];
const imageItem = items.find((i) => i.type.startsWith('image/'));
if (!imageItem) return;
const file = imageItem.getAsFile();
await upload(file);
});
6.3 媒体:摄像头、麦克风与屏幕共享
async function openCamera(videoEl) {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, facingMode: 'user' },
audio: false,
});
videoEl.srcObject = stream;
await videoEl.play();
// 用完必须停止所有轨道,否则摄像头指示灯会一直亮
return () => stream.getTracks().forEach((t) => t.stop());
}
// 枚举设备
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter((d) => d.kind === 'videoinput');
// 切换摄像头(手机前后置切换)
const stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: cameras[1].deviceId } },
});
// 屏幕共享
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
});
// 用户点浏览器自带的"停止共享"按钮时,需要监听轨道结束事件
screenStream.getVideoTracks()[0].onended = () => {
console.log('用户停止了屏幕共享');
cleanup();
};
注意:enumerateDevices() 在用户授权之前返回的设备列表里,label 是空字符串(浏览器出于隐私保护不暴露设备名)。只有用户授权过一次之后,才能拿到完整的设备信息。
6.4 通知与分享
async function notify(title, options) {
if (!('Notification' in window)) return false;
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return false;
}
if (Notification.permission !== 'granted') return false;
const n = new Notification(title, {
body: options.body,
icon: '/icon.png',
tag: options.tag, // 相同 tag 会替换而非叠加
});
n.onclick = () => {
window.focus();
n.close();
};
return true;
}
// 系统分享面板
async function share(data) {
if (navigator.canShare?.(data) && navigator.share) {
try {
await navigator.share(data);
return true;
} catch (err) {
// 用户取消分享会抛 AbortError,这不是错误
if (err.name === 'AbortError') return false;
throw err;
}
}
// 降级:复制链接
await copyText(data.url ?? data.text);
return true;
}
6.5 网络状态与在线检测
// 它无法判断"能不能真正访问到你的服务器"
function useOnlineStatus(onChange) {
const update = () => onChange(navigator.onLine);
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}
// 更可靠的判断:定期发一个极小的探测请求
async function isReachable() {
try {
await fetch('/ping', {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(3000),
});
return true;
} catch {
return false;
}
}
七、并发与性能:别让主线程堵死
浏览器的 JavaScript 是单线程的。所有渲染、事件处理、脚本执行共享同一条主线程。一个耗时 500ms 的循环,意味着这 500ms 内页面完全无法响应点击,动画也会掉帧。
7.1 Web Worker:真正的多线程
Web Worker 在独立线程中运行 JavaScript,与主线程通过消息通信。Worker 里不能访问 DOM,但可以访问 fetch、IndexedDB、WebSocket、大部分计算相关的 API。
const worker = new Worker('./heavy.worker.js', {
type: 'module',
});
// 发任务
worker.postMessage({
id: 1,
type: 'parse',
payload: hugeText,
});
// 收结果
worker.onmessage = (e) => {
const { id, result } = e.data;
resolveTask(id, result);
};
worker.onerror = (err) => {
console.error('Worker 出错:', err.message);
};
self.onmessage = async (e) => {
const { id, type, payload } = e.data;
try {
let result;
if (type === 'parse') {
result = heavyParse(payload);
}
// 也可以在这里发起网络请求
const res = await fetch('/api/enrich');
result = { ...result, extra: await res.json() };
self.postMessage({ id, result });
} catch (err) {
self.postMessage({ id, error: err.message });
}
};
Worker 通信的数据是结构化克隆的,也就是说大对象会被完整复制一份。传输 10MB 的数据意味着两次内存拷贝,开销不小。如果数据量大,应该用 Transferable Objects 转移所有权:
// 普通发送:复制一份,主线程的 buffer 还在
worker.postMessage({ buffer });
// 转移所有权:零拷贝,但主线程的 buffer 会变成 detached
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0,已经被转移走了
7.2 requestIdleCallback:在浏览器空闲时干活
有些任务不紧急,但必须做——比如上报埋点、预计算、清理缓存。这类任务适合放到浏览器空闲时间执行:
function processInChunks(items, process, onDone) {
let index = 0;
function run(deadline) {
// 只要还有剩余时间,就继续处理
while (index < items.length && deadline.timeRemaining() > 2) {
process(items[index++]);
}
if (index < items.length) {
requestIdleCallback(run, { timeout: 2000 });
} else {
onDone?.();
}
}
requestIdleCallback(run, { timeout: 2000 });
}
// timeout 是兜底:如果一直没空闲,2 秒后强制执行
// 这在低端设备上很重要,否则任务可能永远不执行
7.3 Beacon:页面关闭前可靠上报
用户点了关闭按钮,你还有最后一次机会上报数据。这时候用 fetch 是不行的——页面卸载会中断请求。navigator.sendBeacon 专门解决这个问题:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
const data = new Blob([JSON.stringify({
duration: Date.now() - startTime,
scrollDepth: getMaxScrollDepth(),
path: location.pathname,
})], { type: 'application/json' });
// 返回 true 表示已排队,浏览器保证会尽力发送
navigator.sendBeacon('/api/analytics', data);
});
Beacon 有三个关键特性:不阻塞页面卸载、由浏览器负责发送(即使页面已经关闭)、只能发 POST 且不能读取响应。它天生就是为统计上报设计的。
八、工程化封装:把散落的调用收拢起来
理解了各个 API 怎么用,只是第一步。真正决定项目可维护性的,是怎么组织这些调用。如果每个组件都自己写 fetch、自己拼 headers、自己处理错误,那么三个月后你会面对一个无法维护的泥潭。
8.1 分层的必要性
getUserProfile(id) 而不是 get('/api/v2/users/' + id)。8.2 错误必须分类,否则无法处理
"请求失败了"这句话对用户没用,对排查问题也没用。一个负责任的客户端,必须把错误分成几类:
| 错误类型 | 来源 | 用户可感知的提示 | 是否重试 |
|---|---|---|---|
NetworkError |
断网、DNS 失败、CORS 被拦 | "网络连接异常,请检查网络后重试" | 是 |
TimeoutError |
超过设定时限 | "请求超时,请稍后重试" | 是 |
AbortError |
主动取消 | 不提示 | 否 |
HttpError |
4xx / 5xx | 按状态码映射 | 仅 5xx |
BizError |
HTTP 200 但业务码非成功 | 直接展示服务端返回的 message | 否 |
ParseError |
响应不是合法 JSON | "数据格式异常" | 否 |
8.3 一个可以抄进项目的 API 客户端
下面这个实现把前面讲的所有要点都整合到了一起:超时、取消、重试、错误分类、鉴权、请求去重。点击按钮可以对比"裸 fetch"和"封装后"的差异。
async function loadUser(id) {
try {
const res = await fetch(`/api/v2/users/${id}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
// 忘了检查 res.ok,404 也会走到这里
const data = await res.json();
// 业务码判断散落各处,格式还不统一
if (data.code !== 0) throw new Error(data.msg);
return data.data;
} catch (err) {
// 所有错误混在一起,无法区分网络问题还是业务问题
toast('加载失败');
throw err;
}
}
// 问题清单:
// 1. 没有超时,请求可能一直挂着
// 2. 没有取消,组件卸载后 setState 会报警告
// 3. 没有重试,偶发的 502 直接暴露给用户
// 4. 每个组件都要重复写这些代码
8.4 必须遵守的几条纪律
- 所有请求都经过统一的客户端,不允许组件直接调
fetch - 错误类型必须可区分,UI 才能给出有意义的提示
- 组件卸载时取消未完成的请求
- 写操作成功后主动失效相关缓存
- 关键请求带埋点,失败率可观测
- 在组件里硬编码 baseURL 和 token 读取逻辑
- 把
catch写成空函数,错误静默消失 - 所有错误都弹"网络异常",用户一头雾水
- 无限重试,把服务端压垮
- 在响应拦截器里直接
location.href跳登录页
九、Web API 速查表与落地清单
最后把全文浓缩成一份可以随时查阅的速查表。
| 你要做的事 | 优先考虑 | 备选 / 降级 |
|---|---|---|
| 发起一个 HTTP 请求 | fetch + AbortController |
XHR(需要上传进度时) |
| 取消一个请求或事件监听 | AbortController |
手动记录并移除 |
| 服务端单向推送 | EventSource |
长轮询 |
| 双向实时通信 | WebSocket + 自建心跳重连 |
SSE + POST |
| 跨标签页同步状态 | BroadcastChannel |
storage 事件 |
| 存少量配置 | localStorage(带 try/catch) |
Cookie |
| 存大量结构化数据 | IndexedDB |
分片存 localStorage |
| 离线可用 | Service Worker + Cache Storage | IndexedDB 存数据 |
| 图片懒加载 / 曝光埋点 | IntersectionObserver |
scroll + getBoundingClientRect |
| 容器尺寸自适应 | ResizeObserver |
window resize + 轮询 |
| 监控 DOM 变化 | MutationObserver |
定时比对快照 |
| 采集性能指标 | PerformanceObserver |
手动打点 performance.now() |
| 复制到剪贴板 | navigator.clipboard |
execCommand('copy') |
| 读取用户选择的文件 | input[type=file] / 拖拽 / 粘贴 |
File System Access |
| 调用摄像头麦克风 | getUserMedia |
无降级,必须提示用户 |
| 页面关闭前上报 | sendBeacon |
fetch + keepalive |
| 重计算不阻塞界面 | Web Worker | 分片 + requestIdleCallback |
| 不紧急的任务延后执行 | requestIdleCallback |
setTimeout |
落地清单
- 先查兼容性,再写代码。MDN 的兼容性表格是你最好的朋友,别等上线才发现某个 API 在 Safari 上不存在。
- 永远用特性检测,不用 UA 嗅探。
'xxx' in window比正则匹配 UA 可靠一百倍。 - 把"不支持"当成正常路径。渐进增强不是可选项,是底线。
- 所有异步 API 都要有失败分支。没有
catch的 Promise 迟早会变成线上事故。 - 主动取消是礼貌。组件卸载、参数变化、用户离开,都该取消还在飞的请求。
- 权限弹窗只放在用户手势之后。页面一加载就弹窗,用户只会点"拒绝"。
- 重试要退避,要有上限,要能识别可重试错误。
- 订阅必须能取消。让订阅函数返回
off,或者用AbortSignal统一管理。 - Observer 用完要
disconnect()。它们会一直持有元素引用,不释放就是内存泄漏。 - 封装一层,但不要封十层。一个客户端类足够了,别为每个接口写一个 Service 类再写一个 Factory。
Web API 这个领域的知识有一个特点:你不去查,就永远不知道它存在。很多需求之所以写得又长又绕,不是因为技术难,而是因为不知道浏览器已经替你准备好了。所以最实用的建议只有一条——养成习惯,在动手实现一个复杂交互之前,先花两分钟问自己:这件事,浏览器是不是已经有原生能力了?
答案往往是有。而你要做的,只是把它接进来,然后处理好那些边界情况。