Web API集成

张玥 2026年9月20日 阅读时间 45分钟
Web API 浏览器 网络请求 兼容性 工程化
前端开发技巧之Web API集成

浏览器早就不是一个"渲染 HTML 的窗口"了。它更像一台装在盒子里的操作系统:能联网、能存储、能录音录像、能读文件、能开线程、能离线运行、能推送通知、能跨标签页通信。这些能力对外暴露的接口,统称为 Web API。问题是,很多前端工程师写了三五年业务代码,用过的 Web API 始终停留在 fetch、localStorage 和 addEventListener 这三件套上——不是不想用,而是不知道有什么、不知道怎么用、更不知道用了之后会在什么浏览器上翻车。本篇 45 分钟长文,把 Web API 从分类地图开始铺开,沿着网络请求、实时通信、存储缓存、观察者、设备能力、并发性能六条主线逐一拆解,再补上兼容性策略、错误处理、工程化封装、以及一个可以直接抄进项目的通用 API 客户端。读完你未必能立刻记住所有接口名,但你一定会建立起一套"遇到需求先想想有没有原生能力"的判断习惯。

一、Web API 地图:先知道有什么,再决定用什么

初学者最容易犯的错误,是把"Web API"和"后端 HTTP 接口"混为一谈。这两个词在中文语境里都叫"接口",但完全是两回事:

Web API 浏览器提供的、运行在页面里的原生能力,由 W3C / WHATWG / TC39 等标准组织定义
HTTP API 服务端提供的、通过网络调用的业务接口,由你的后端团队定义
关系 你用 fetch(Web API)去调用 HTTP API,前者是工具,后者是目标

浏览器里的 Web API 数量超过一千个,但真正在日常业务中高频使用的,其实集中在六大类。点击下面的卡片看看每一类都包含什么:

1
网络通信
Network
fetch、XMLHttpRequest、WebSocket、EventSource、Beacon、BroadcastChannel、WebRTC、WebTransport。负责让页面和外界交换数据。
2
存储缓存
Storage
localStorage、sessionStorage、IndexedDB、Cache Storage、StorageManager、File System Access。负责把数据留在客户端。
3
观察者
Observer
IntersectionObserver、MutationObserver、ResizeObserver、PerformanceObserver、ReportingObserver。负责在"某个东西变了"的时候通知你。
4
设备与并发
Device & Concurrency
Geolocation、Notification、Clipboard、MediaDevices、Web Share、Web Worker、requestIdleCallback、Scheduler。负责调用硬件能力和多线程计算。

兼容性:第一道必须先过的关

在写任何一行调用代码之前,先问自己三个问题:

  1. 目标浏览器支持吗?——查 caniuse.com 或 MDN 的兼容性表格,注意区分"部分支持"和"完全支持"。
  2. 不支持的时候怎么办?——是降级、是提示用户,还是直接放弃这个功能?
  3. 支持了但行为不一致吗?——同一个 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 都不可用,核心功能依然能跑通。比如一个表单页面:

// ❌ 强依赖: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 目前没有等价能力。
  • 同步请求:虽然已经被废弃并且会阻塞主线程,但在某些特殊场景(比如页面卸载前保存数据)仍能看到它的身影。
// 一个封装好的 XHR 请求(带上传进度)
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 请求的生命周期:每一步都可能出问题

一个"看起来很简单"的请求,实际上要穿过这么多环节。理解每一步,才知道错误应该在哪里被捕获:

① 构造
拼 URL、序列化参数、设置 headers。这里最容易出错的是忘记 encodeURIComponent。
② 发出
DNS 解析、TCP 连接、TLS 握手。这一阶段失败会 reject,是真正的网络错误。
③ 等待
服务端处理。如果超过用户耐心,需要超时中断 + 骨架屏或加载态。
④ 响应头
Promise 在此刻 resolve,可以读取 status、headers,但 body 还没读完。
⑤ 读 body
res.json() / res.text()。如果服务端返回的不是合法 JSON,这里会抛错。
⑥ 业务处理
校验业务码、写入状态、触发 UI 更新。这一步的错误属于业务错误,不是网络错误。

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 个请求只会让它们排长队。更糟的是,你的代码以为自己在"并行",实际上前几个请求把带宽占满,后面的全部超时。

// 一个极简的并发池:最多同时跑 limit 个任务
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
  • 用户主动取消的请求
  • 非幂等的写操作(除非有幂等键)
  • 已经被上游限流且没有退避策略
// 带指数退避 + 抖动(jitter)的重试
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 inflight = new Map();
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。它的最大优势是浏览器自动重连——断线之后会按照服务端指定的间隔自动重试,不需要你写任何重连逻辑:

const es = new EventSource('/api/notifications/stream');

// 默认事件(服务端发送 "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 不提供自动重连,也没有心跳机制。网络切换、代理超时、服务端重启,都会让连接静默断开。你必须自己实现一套完整的连接管理:

class ReconnectingSocket {
  #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 提供了一条极简的同源跨标签页通道:

// 标签页 A:登录成功后广播
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 的四个必须知道的坑

同步阻塞 读写会阻塞主线程。存一个 1MB 的 JSON,序列化加写入可能卡住几十毫秒
隐私模式 Safari 无痕模式下可能直接抛 QuotaExceededError,必须用 try/catch 包裹
同源共享 同一域名下所有页面共享,包括第三方 iframe(除非设置了分区)
仅存字符串 存对象要 JSON.stringify,取出来要 JSON.parse,Date、Map 会丢失类型
// 一个安全的 localStorage 包装
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 化的薄壳:

// 极简 IndexedDB 封装
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 查询存储配额

在决定往客户端存大量数据之前,先问问浏览器还有多少空间:

if (navigator.storage?.estimate) {
  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:懒加载的标准答案

const observer = new 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 事件加轮询,既不准确也不高效:

const ro = new ResizeObserver((entries) => {
  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 的修改,以及实现自定义元素的行为。

const mo = new MutationObserver((mutations) => {
  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:把真实用户体验量化

// 采集核心 Web 指标
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

文件处理是前端绕不开的需求。三个层次的方案,能力依次增强:

input[type=file]
最基础也最兼容。配合 accept 和 multiple 属性使用,得到的是 FileList。
拖拽上传
监听 dragover 与 drop,从 dataTransfer.files 取文件。必须阻止默认行为。
粘贴上传
监听 paste,从 clipboardData.items 取图片或文件,截图粘贴的常用方案。
File System Access
可以直接读写本地文件、记住目录句柄,接近桌面应用体验,但仅 Chromium 系支持。
// 拖拽上传 + 粘贴上传
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 网络状态与在线检测

// 注意:navigator.onLine 只能判断"有没有网络接口"
// 它无法判断"能不能真正访问到你的服务器"
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。

// main.js —— 主线程
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);
};
// heavy.worker.js —— 工作线程
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 转移所有权:

const buffer = new ArrayBuffer(1024 * 1024 * 10);

// 普通发送:复制一份,主线程的 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 分层的必要性

组件层
只关心"要什么数据"和"数据长什么样"。不出现任何 URL、header、错误码。
领域层
把业务语义翻译成接口调用。getUserProfile(id) 而不是 get('/api/v2/users/' + id)。
客户端层
统一的请求封装:baseURL、超时、重试、鉴权、错误规范化、日志。
传输层
fetch / XHR / WebSocket 的具体实现。理论上可以整体替换。

8.2 错误必须分类,否则无法处理

"请求失败了"这句话对用户没用,对排查问题也没用。一个负责任的客户端,必须把错误分成几类:

错误类型 来源 用户可感知的提示 是否重试
NetworkError 断网、DNS 失败、CORS 被拦 "网络连接异常,请检查网络后重试" 是
TimeoutError 超过设定时限 "请求超时,请稍后重试" 是
AbortError 主动取消 不提示 否
HttpError 4xx / 5xx 按状态码映射 仅 5xx
BizError HTTP 200 但业务码非成功 直接展示服务端返回的 message 否
ParseError 响应不是合法 JSON "数据格式异常" 否

8.3 一个可以抄进项目的 API 客户端

下面这个实现把前面讲的所有要点都整合到了一起:超时、取消、重试、错误分类、鉴权、请求去重。点击按钮可以对比"裸 fetch"和"封装后"的差异。

// 组件里的"裸 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 这个领域的知识有一个特点:你不去查,就永远不知道它存在。很多需求之所以写得又长又绕,不是因为技术难,而是因为不知道浏览器已经替你准备好了。所以最实用的建议只有一条——养成习惯,在动手实现一个复杂交互之前,先花两分钟问自己:这件事,浏览器是不是已经有原生能力了?

答案往往是有。而你要做的,只是把它接进来,然后处理好那些边界情况。