ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebAPI实战指南:从存储到Fetch到观察者API的核心操作与避坑

WebAPI实战指南:从存储到Fetch到观察者API的核心操作与避坑

1. 先把地基夯实:语言能力与浏览器能力的边界

做前端这些年,面试过不少人,也带过一些新人。大家聊JavaScript时,闭包、原型链、事件循环都能说得头头是道,但一问到 localStorage 和 sessionStorage 的区别、fetch 请求怎么正确取消、图片懒加载有没有比 scroll 更稳的方案,很多人就开始含糊。WebAPI 这个词几乎天天出现在需求讨论和简历里,可真正把浏览器提供的能力用顺、用透的人,并没有想象中那么多。

我不打算再把一堆 API 列成清单——那种手册网上一搜一大把。真正值得写的,是那些在实战里反复出现、又容易踩坑的核心操作:存储怎么选,请求怎么控制,剪贴板、通知、全屏这类增强能力有什么边界,以及 IntersectionObserver 这类观察者 API 为什么值得优先掌握。这篇内容适合正在系统整理 WebAPI 知识、又不想只停留在文档示例的前端同学,也适合做技术方案时希望少走弯路的工程师。

1.1 JavaScript语言本身与WebAPI的分工

很多前端同学对“WebAPI”的理解停留在“浏览器里有这么个东西”的层面,但真要问它和 JavaScript 语言本体是什么关系,又说不清楚。实际上,JavaScript 语言本身只规定了语法、数据类型、操作符、控制流,以及 Array、Object、Promise 这些内置对象。真正让页面能操作 DOM、发请求、存数据的能力,全部来自浏览器这个“宿主环境”向外暴露的接口,也就是我们常说的 WebAPI。

这个边界在写代码时特别重要。一套代码如果既要在浏览器里跑,又要跑在 Node 环境,那凡是碰到 window、document、navigator 的地方都得做兼容处理。我之前接过一个服务端渲染改造的活,原本组件里直接用 localStorage 存用户偏好,结果在 Node 端跑初始化逻辑时直接抛错,原因就是服务端环境根本没有 localStorage 这个对象。语言本身是同一套,可用的 API 却完全看宿主脸色,这是 WebAPI 学习和使用绕不开的第一课。

浏览器提供的 WebAPI 家族其实非常庞大,但日常业务里真正高频使用的并没有想象中那么多。按我的经验,最值得优先吃透的几类包括:以 localStorage、sessionStorage、IndexedDB 为代表的存储类,以 fetch 为代表的网络请求类,以 Clipboard、Notification、Fullscreen 为代表的设备与能力增强类,还有 IntersectionObserver、MutationObserver、ResizeObserver 这三个观察者类。这些几乎覆盖了前端开发里“存数据”“传数据”“改体验”“做优化”这四个最核心的诉求。

1.2 为什么“API存在”不等于“现在就能用”

WebAPI 还有一个和普通 JavaScript 语法不同的特点:同一个 API,在不同页面环境、不同浏览器、不同安全等级下,可能时有时无、时灵时不灵。最常见的是安全上下文限制。像navigator.clipboard、Notification、Geolocation这些 API,官方规范要求必须在 HTTPS 环境(本地 localhost 也算)下才暴露出来。你在 http 的测试域名下打开控制台,直接打印navigator.clipboard,得到的就是undefined,代码里调用writeText必然报错。

另一类是权限机制。有些 API 即使存在,也需要用户在页面弹窗里主动授权之后才能使用。比如通知权限,第一次调用Notification.requestPermission()会弹出询问框,用户选了“允许”之后才能new Notification()弹出系统通知;如果用户选了“阻止”,后续代码再怎么调,权限状态也只会直接返回denied,不会再次弹窗询问。这个机制很容易被忽视,尤其做 H5 页面和内部系统时,很多功能代码写得很完整,但用户没点授权,整个链路就断了。

还有一种情况是时序或手势限制。Fullscreen API 的requestFullscreen()必须在用户手势里调用,比如 click 回调内部;如果你在页面加载完成后的 setTimeout 里去调用,浏览器会直接 reject。这边还牵扯到 iframe 和沙箱环境,某些嵌套页面里即便调用了localStorage,也可能因为安全策略抛SecurityError。所以,判断一个 WebAPI “能不能用”,不能只看文档说支持,还要看我这个具体页面环境是否满足条件。

2. 浏览器存储怎么选:localStorage、sessionStorage与IndexedDB的实战取舍

存储类 API 是每个前端项目都躲不开的。小到 token 存放、用户偏好,大到离线数据、消息记录,选错存储方案后面会非常难受。我见过不少项目,所有东西不管大小一律塞 localStorage,结果用户配置一多、数据一变复杂,读写性能掉得明显,容量也不够用。存储选型这件事,值得花时间理清楚。

2.1 从“能用”到“够用”:三种存储的能力差距

先看一张我平时做方案时习惯性列出的对比表,这样比单纯背概念直观得多:

对比项localStoragesessionStorageIndexedDB
容量一般 5MB 左右一般 5MB 左右远大于前两者,常见上限 50MB 以上
持久性持久,直到手动清除标签页关闭即销毁持久,直到手动清除
数据类型只能存字符串只能存字符串支持结构化克隆,可存对象、Blob、ArrayBuffer
接口同步,阻塞主线程同步,阻塞主线程异步,事件驱动,不阻塞主线程
作用域同源共享同源,但仅当前标签页同源共享
事务能力无无支持事务、索引、游标

很多人以为 localStorage 和 sessionStorage 除了生命周期没区别,其实 sessionStorage 还有一个细节经常被忽略:如果你复制了一个标签页,浏览器会把原标签页的 sessionStorage 数据一起复制过去,但两个标签页之后的数据变更互不影响。另外,localStorage 是跨标签页共享的,同一个源下的所有标签页都能读到同一份数据,sessionStorage 则严格限制在单个标签页内。这个差异在做多标签页同步需求时特别关键。

IndexedDB 和前面两个完全不是同一个量级的工具。它的接口是异步的,底层有事务和索引机制,能存完整的对象和二进制数据,非常适合离线缓存、聊天记录这类场景。很多人一听 IndexedDB 就觉得复杂,事件回调嵌套太多,不好用。但换个角度想,只要封装一层 Promise,日常读写其实没有想象中那么麻烦。

2.2 一个Promise化的IndexedDB轻量封装

先解释一下 IndexedDB 的工作模式:打开数据库、在onupgradeneeded里创建对象仓库(object store)、然后通过事务拿到对象仓库、发起增删改查请求。每个操作都是事件回调风格,所以第一步就是把它包成 Promise。下面这个封装我已经在项目和教学里用了很久,足够覆盖百分之八九十的日常需求:

class LocalDB { constructor(dbName, storeName, version = 1) { this.dbName = dbName this.storeName = storeName this.version = version this.db = null } open() { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.version) request.onupgradeneeded = (event) => { const db = event.target.result if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName) } } request.onsuccess = () => { this.db = request.result resolve(this.db) } request.onerror = () => reject(request.error) }) } _getStore(mode) { return this.db.transaction(this.storeName, mode).objectStore(this.storeName) } put(key, value) { return new Promise((resolve, reject) => { const request = this._getStore('readwrite').put(value, key) request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) }) } get(key) { return new Promise((resolve, reject) => { const request = this._getStore('readonly').get(key) request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) }) } delete(key) { return new Promise((resolve, reject) => { const request = this._getStore('readwrite').delete(key) request.onsuccess = () => resolve() request.onerror = () => reject(request.error) }) } clear() { return new Promise((resolve, reject) => { const request = this._getStore('readwrite').clear() request.onsuccess = () => resolve() request.onerror = () => reject(request.error) }) } }

使用起来很简单:先open()拿到实例,再调用put、get、delete、clear。因为存储大对象不需要像 localStorage 那样 JSON.stringify,代码会干净不少。

这里要特别提一个 IndexedDB 的版本坑:onupgradeneeded只在数据库版本号变化时触发,如果你第一次打开用的是 version 1,之后建好了 store,下次打开还是 version 1,浏览器会直接复用现有数据库,不会执行onupgradeneeded里的建表逻辑。所以如果以后要加索引、改 keyPath,必须把版本号往上调,并在迁移逻辑里处理旧数据。很多人以为“反正每次打开都会执行 onupgradeneeded”,结果新增的字段永远没生效,查了半天才发现是版本号没变。这是我在多个项目里看到过的真实失误。

2.3 项目里的存储选型经验

综合来说,我的选型思路大概是这样的:短小的键值配置,比如主题色、语言、账号 token,直接用 localStorage;只希望当前标签页有效的临时状态,比如表单草稿、某一步操作过程数据,用 sessionStorage;体积大、结构复杂、需要检索的数据,比如离线文章、用户行为日志、上传文件的元信息,用 IndexedDB。

还有几个必须养成的习惯。第一,所有 localStorage 和 sessionStorage 的读写都要包 try/catch,因为隐私模式或者某些浏览器安全策略下,写入会直接抛异常,不加保护很容易让整页白屏。第二,键名建议统一用常量管理,比如const STORAGE_KEYS = { TOKEN: 'app_token' },避免散落在代码四面八方,以后改键名还得全局搜。第三,localStorage 里不要明文放敏感数据,毕竟任何同源脚本都能读,真要存也得先做脱敏或加密处理,最好的方案是敏感数据只保存在后端会话里。

3. Fetch没有你想的那么简单:超时、取消与并发控制的完整方案

fetch 是现在前端发请求最通用的方式,但恰恰因为它“看起来很简单”,反而容易在项目里交出很多隐性问题。我接手过好几个项目,实际代码里直接用裸 fetch,没人管超时和取消,结果用户网络稍差,页面就大面积出现“请求挂起”的状态,既没法提示失败,也没有取消入口。要把它用出生产级的效果,必须自己补上三层能力。

3.1 先搞清楚fetch的“反直觉”设计

fetch 和老的 XMLHttpRequest 最大的区别在于“分层”。fetch 的 Promise 在拿到响应头时就会 resolve,即使响应体 body 还在下载中。所以如果你直接await fetch(url)然后立刻读取数据,逻辑上没错,但你要理解这个“成功”并不代表整段数据已经接收完成。另外,fetch 默认只会在网络层出错时 reject,也就是说断网、DNS 解析失败、连接被拒才会进 catch;服务端返回 404、500 这类 HTTP 错误,fetch 照样 resolve,只是response.ok为 false。很多人第一次写 fetch 没检查ok,把 500 当成功处理,这属于必踩的坑。

还有一个细节是凭据携带。fetch 的credentials默认值是same-origin,意思是同源请求会带 cookie,跨域请求默认不带。如果你要跨域发送携带 cookie,得显式配置:

fetch(url, { credentials: 'include' })

这里涉及后端 CORS 也要配合设置,否则浏览器会拒绝接受响应。类似这种“默认值反直觉”的点很多,建议先把 MDN 参数表挑几个重点字段过一遍,再决定怎么写封装。

3.2 超时与取消:AbortController的标准解法

fetch 本身没有超时机制,服务端一直不返回,请求就永远挂在那里。标准做法是用AbortController手动控制取消。它的原理不复杂:创建一个 controller,把它的signal传给 fetch,当你想取消时调用controller.abort(),fetch 内部会自动中断请求,并抛出一个 name 为AbortError的异常。

基于这个机制,超时就是“定时 + 取消”的组合:

async function fetchWithTimeout(url, options = {}, timeout = 8000) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeout) try { const response = await fetch(url, { credentials: 'include', ...options, signal: controller.signal }) return response } finally { clearTimeout(timer) } }

注意finally里清理定时器是必须的,否则请求很快成功时定时器还在跑,过一会儿又触发一次 abort,虽然对已完成请求没有影响,但会白白多一个无效操作。这也是我代码审查时总会提醒的一点。

使用场景不止超时。比如用户从列表页点进详情页,又立刻后退,此时上一页还在飞的请求已经不关心结果了,就可以在组件卸载时调用controller.abort()主动取消,避免请求回来之后还去 setState,触发“在已卸载组件上更新状态”的警告或内存泄漏。类似这样的取消逻辑,在多页面路由切换、搜索防抖、关闭弹窗后清理副请求时都很常用。

3.3 一个可落地的fetch封装:超时、重试与并发控制

光有超时还不够,生产环境通常还需要重试和并发控制。先说重试。重试必须小心,不是所有错误都适合重试:网络错误、超时可以重试,但 4xx 这种服务端明确拒绝的请求,重试多少次都没意义,反而会给服务端添堵。封装时建议把“HTTP 错误状态”和“网络层错误”区分开。

下面是一个带重试能力的简单封装,核心判断思路可以直接迁移到自己项目里:

class RequestError extends Error { constructor(message, status) { super(message) this.name = 'RequestError' this.status = status } } async function fetchWithRetry(url, options = {}, { timeout = 8000, retries = 2 } = {}) { let lastError for (let i = 0; i <= retries; i++) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeout) try { const response = await fetch(url, { credentials: 'include', ...options, signal: controller.signal }) if (!response.ok) { throw new RequestError(`HTTP ${response.status}`, response.status) } return response } catch (error) { lastError = error // HTTP 错误没必要重试,直接抛出 if (error instanceof RequestError) { throw error } // 最后一次尝试失败后,不继续循环 if (i >= retries) { throw error } } finally { clearTimeout(timer) } } throw lastError }

这里的重试只针对网络异常和超时(AbortError),不会在 404、500 上反复浪费时间。如果需要重试“幂等”的 GET 请求,一般没问题;但如果是 POST、PUT 这类写操作,必须确认接口幂等性,否则重试可能导致重复下单、重复写入。这是很多团队在使用“自动重试”功能时翻车的高频原因。

并发控制是另一个容易被忽视的点。比如进入一个数据大屏页面,同时要拉十几个统计接口,全发出去没问题,但会让浏览器连接数瞬间拉满,性能差的机器明显卡顿。用一个小函数控制最多同时发出几个请求,后面的排队执行,体验会好很多:

async function runWithLimit(tasks, limit) { const results = [] let index = 0 const workerCount = Math.min(limit, tasks.length) const workers = new Array(workerCount).fill(0).map(async () => { while (index < tasks.length) { const current = index++ results[current] = await tasks[current]() } }) await Promise.all(workers) return results }

这个实现的思路是固定几个“工人”同时跑,每个人从任务队列里领一个任务,执行完再领下一个。results 按下标写入,最终结果依然是按传入顺序排列的,不会乱。实际项目里并发数可以设置在 3 到 5 之间,既能保证速度,也不会把浏览器资源打满。

4. 借助浏览器能力提升体验:Clipboard、Notification与Fullscreen的实用操作

除了存储和网络,WebAPI 里还有一批增强体验的接口,功能都很小,但能不能用好会直接影响用户的体感。这些 API 主要踩点集中在“安全上下文”“用户手势”“权限状态”这三个维度,我把项目里最常用的三个拿出来逐个说。

4.1 剪贴板操作:不是每次“复制成功”都可靠

页面里最常见的操作就是点击按钮复制一段文本。现代浏览器提供了navigator.clipboard.writeText(),用法很简单:

async function copyText(text) { await navigator.clipboard.writeText(text) }

但如果你直接这么用,几乎必然遇到问题。首先是安全上下文限制,navigator.clipboard只存在于 HTTPS 或 localhost 环境,普通 http 页面直接是 undefined。第二,就算 API 存在,调用时机也有限制,理想情况是在用户点击的同步回调里调用,这样浏览器能确信是一次用户主动操作。第三,一些浏览器对权限策略比较严格,不在活跃标签页或没有获得焦点时,调用也可能被 reject。

所以实际项目里我一般不会只写一行writeText,而是做成一个带降级方案的函数:

async function copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text) return true } catch (error) { // 继续走降级 } } const textarea = document.createElement('textarea') textarea.value = text textarea.style.position = 'fixed' textarea.style.opacity = '0' textarea.setAttribute('readonly', 'readonly') document.body.appendChild(textarea) textarea.select() let result = false try { result = document.execCommand('copy') } finally { document.body.removeChild(textarea) } return result }

降级方案用document.execCommand('copy'),这个老接口仍然被大量浏览器支持。这里有个细节:textarea 不能用display: none,否则某些浏览器无法选中内容导致复制失败,我用的是固定定位加透明,让元素在页面上“看不见但可选中”。通过这样的函数,兼容面会宽很多,而且返回值能明确告诉调用方是否成功,方便前端在失败时提示用户手动复制。

4.2 浏览器通知:权限申请与展示的完整链路

Notification API 用在消息提醒场景很合适,比如待办提醒、系统通知、订单到达。它最核心的流程不是弹通知本身,而是权限管理。通常代码是这样写的:

async function showNotice(title, body) { if (Notification.permission === 'granted') { new Notification(title, { body }) return } if (Notification.permission === 'denied') { // 用户已经拒绝过,继续调用也不会弹窗 return } const permission = await Notification.requestPermission() if (permission === 'granted') { new Notification(title, { body }) } }

权限状态有三种:default(还没询问过)、granted(已允许)、denied(已拒绝)。requestPermission()必须在用户手势里调用,比如点击按钮后,不然会被浏览器无视。一旦状态变成denied,页面再次调用requestPermission()不会弹窗,而是直接返回denied。这意味着权限引导页必须在用户拒过一次之前就做好,错过那一次,后面只能靠文字提示用户自己去浏览器站点设置里手动改,转化率非常低。

这里还有一个细节:页面内的new Notification()适合简单提醒,如果要用到更强大的能力,比如在后台推送、点击通知跳转到指定页面、多条通知分组,就需要结合 Service Worker 的registration.showNotification()来做。从成本角度考虑,如果只是做基础版本,先用页面内通知就行,不需要一上来就上整套 PWA 方案。

4.3 全屏API的“半知半解”陷阱

requestFullscreen()看起来就是让元素全屏,但踩坑点非常多。最典型的是手势限制:必须在用户触发的事件里调用,比如 click、keydown。你要是把它放在异步回调里,比如先等接口返回再全屏,浏览器会以“未由用户手势触发”为由拒绝。解决办法是把“进入全屏”和“异步数据准备”分开,等数据准备好了,再让用户点一次按钮触发全屏,或者在用户已经触发的同一事件里同步调用。

监听全屏状态变化要使用fullscreenchange事件,而不是自己猜。判断当前是否处于全屏,看document.fullscreenElement是否存在:

document.addEventListener('fullscreenchange', () => { if (document.fullscreenElement) { // 已进入全屏 } else { // 已退出全屏 } })

移动端还有个老坑:document.documentElement.requestFullscreen()和视频元素的video.webkitEnterFullscreen()是两套完全不同的事。前者是页面级全屏,后者是媒体元素的原生全屏播放,不能互相替换使用。而且全屏状态下,很多页面布局会有微妙变化,比如position: fixed相对于视口的定位在某些浏览器里表现不一致。所以上了全屏功能后,一定要在目标浏览器里实测一遍退出全屏时页面状态是否正确,我见过不少项目全屏后退出,布局直接错位的情况。

5. 观察者三件套:IntersectionObserver、MutationObserver与ResizeObserver的实战价值

观察者类 API 是性能优化的利器,但很多项目里用得很少,原因是大家习惯了“事件驱动”的思维,遇到需要监听变化的需求,第一反应都是挂 scroll、resize、轮询。实际上,这三个 Observer 能覆盖绝大多数“需要感知变化”的场景,而且比事件监听更高效、更精准。

5.1 用IntersectionObserver做图片懒加载,为什么比onScroll可靠

很多前端第一个想到的懒加载方案是“scroll + getBoundingClientRect 判断是否进入视口”。这个方案有两个问题:scroll 事件触发频率极高,主线程很容易被打满;每次触发都要手动计算元素位置,计算方式稍微不小心就会引起强制回流,性能更差。IntersectionObserver 是浏览器自己维护的观察机制,元素和视口相交状态发生变化时,异步回调通知你,不用频繁计算。

简单做图片懒加载,代码可以这么写:

const lazyImages = document.querySelectorAll('img[data-src]') const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { const img = entry.target img.src = img.dataset.src img.removeAttribute('data-src') observer.unobserve(img) } } }, { rootMargin: '0px 0px 200px 0px', threshold: 0.01 }) lazyImages.forEach((img) => observer.observe(img))

rootMargin相当于把视口范围向下扩展了 200px,这样图片还没真正滚动到视口中心时就会开始加载,体感上无所谓,但加载时机更从容。图片加载完成后记得observer.unobserve(img),否则后续滚动每次触发回调都会再走一遍逻辑,虽然结果一样,但没必要。

这个 API 还能做曝光埋点、无限滚动、吸顶检测等。无限滚动里,你只需要观察一个“哨兵”元素(通常是列表底部的空元素),当它进入视口时就加载下一页数据。相比 scroll 判断页面高度,代码简洁很多,而且不用担心滚动容器是 body 还是某个 div 的影响。

5.2 MutationObserver:能够“看到”DOM变化的监听器

MutationObserver 监听的是 DOM 树的变动。为什么要用它?最典型的场景是动态内容注入。比如某个图表库渲染完成后往容器里塞了节点,或者第三方脚本悄悄往页面里加元素,你只靠普通事件监听很难捕捉到这些变化,但 MutationObserver 可以。

基本用法如下:

const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { // 有新节点插入,做对应处理 } } }) observer.observe(document.body, { childList: true, subtree: true })

childList表示监听子节点增删,subtree表示递归观察所有后代节点,attributes和characterData则用于监听属性和文本内容变化。回调里拿到的mutation记录包含变动类型、被增删的节点、旧值等信息,信息量很大。

使用 MutationObserver 最大的坑是忘记清理。如果观察的是长生命周期的容器,比如 body,而页面一直在动态渲染新内容,回调会持续触发。组件销毁时必须调用observer.disconnect(),否则会有无意义的回调不断执行,页面越用越卡。我在某个数据看板项目里排查性能问题,最后就发现是这类监听器泄漏导致的,清理后主线程占用直接降了一个量级。

5.3 ResizeObserver:组件级响应式不是只能靠window resize

ResizeObserver 监听的是具体元素盒子尺寸的变化,而不是整个窗口。为什么需要它?因为很多时候不是窗口大小变了,而是某个元素因为周围布局变化被挤压了。比如左侧导航折叠后,右侧内容区宽度变化;或者某个容器宽度由 flex 布局决定,窗口没动但宽度变了。window resize 事件根本感知不到这些,ResizeObserver 才行。

const ro = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect // 根据新的宽度重绘图表、调整布局等 } }) ro.observe(targetElement)

有个细节要注意:observe 一个元素后,回调会先触发一次,带着元素初始尺寸,这是设计行为,不是 bug。另外元素被设置为display: none时,尺寸会变成 0,所以判断是否可见时要注意这个语义。

这个 API 在自适应图表场景特别有用。以前做图表适配,都是用 window resize 然后重新计算所有图表尺寸,页面里有多个图表时会重复计算很多次。用 ResizeObserver 只观察每个图表容器,哪个容器尺寸变了就重绘哪个,精准且省资源。

6. 收尾前必须说的几个大坑:来自真实项目的踩坑复盘

写到这里,核心的 WebAPI 都已经覆盖了,但最后我还想专门留一章,把那些“不是文档里能直接学到的”实战坑集中说一下。这些坑几乎每个都是我在实际项目里或者帮别人排查问题时见过的,重复率极高,值得大家提前避一避。

6.1 你以为存进去了,其实浏览器没答应

localStorage 的写入异常比想象中频繁得多。隐私模式下,部分浏览器的 localStorage 对象虽然存在,但写入时会直接抛异常;存储配额满了也一样。有一个项目就是在用户浏览器设置里关闭了本地存储,结果代码执行到localStorage.setItem时整个脚本中断,后续初始化逻辑全部没跑,页面直接表现异常。

所以所有 localStorage 和 sessionStorage 的读写,我都建议包一层安全函数:

function safeSetItem(key, value) { try { localStorage.setItem(key, value) return true } catch (error) { // 存储不可用时做内存降级或提示 memoryStorage[key] = value return false } }

用内存对象做降级是好办法,至少当前会话内功能还能继续跑,不至于白屏。另外要注意,存储失败并不意味着可以忽略,最好还是把失败信息上报一下,方便你了解真实用户环境里有多少比例访问是没法用本地存储的。

6.2 接口报错不一定是你代码的错,也可能执行环境不支持

排查 WebAPI 相关 bug 时,第一件事不是看业务逻辑,而是看这个 API 在当前环境里到底存不存在。有一次做 H5 内嵌到一个老旧的 WebView 里,页面所有剪贴板按钮点着都没反应,排查了很久才发现navigator.clipboard在非安全上下文下根本没有。不是代码有问题,是执行环境压根不支持。

同理,有些浏览器虽然支持某个 API,但是实现不完整。比如navigator.permissions.query({ name: 'clipboard-write' })在不同浏览器返回值格式并不一致;再比如部分内核浏览器里Notification存在,但requestPermission返回的是一个老旧的 callback 风格而不是 Promise。做这类功能时,特征检测是必须的,而且要预留一个“功能不可用”的降级 UI,不能默默失败。

6.3 权限类API的“一次性决定”机制

通知权限、地理位置权限这一类,用户一旦选择了“阻止”,浏览器基本不会再弹出授权询问,除非用户自己在浏览器设置里手动改回来。这意味着“反复调用 requestPermission”是一种无效策略,不该出现在代码里。

正确做法是把权限状态当作业务状态去管理:进入页面时先读取Notification.permission,如果已经是denied,就不要再去触发任何授权请求,直接展示“权限已被拒绝,请到浏览器设置中开启”的提示条,并且可以放一个帮助文案;如果还是default,才在合适的用户手势时机去申请。

这个思路在实际项目里带来的体验提升非常明显。以前很多功能是用户点一下“开启通知”,结果弹窗出来又瞬间消失,或者干脆没反应,用户完全不知道发生了什么。改成权限状态管理后,每一步用户都清楚接下来会发生什么,团队里排查问题的工单也少了很多。权限类 API 和普通业务 API 最大的不同就是“一次决定,长期影响”,把它当状态机处理,比当成一个随手调用的函数要靠谱得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进