这七类能力应该按同一条路子接:当作渐进增强层,启动时探测、用户手势内授权、拿不到就走显式降级,主流程不依赖任何一条。
OPFS 和 File System Access 是两种东西,别当成一个 API 的两代
OPFS 是 origin 私有的沙箱文件系统,用户看不见,适合放缓存、日志、大二进制、SQLite 的 wasm 落盘;File System Access 操作的是用户真实文件,要弹选择器、要授权、用户随时能撤销。前者解决「存不下、localStorage 5MB 不够」,后者解决「用户想打开自己硬盘上那份文件」。
OPFS 的同步句柄只在 Worker 里有
// 主线程:只有异步写入
const root = await navigator.storage.getDirectory();
const fh = await root.getFileHandle('cache.bin', { create: true });
const w = await fh.createWritable();
await w.write(new Uint8Array([1, 2, 3]));
await w.close();
// worker.js:同步读写,适合 wasm 那种一帧里几百次小 IO 的场景
const access = await fh.createSyncAccessHandle(); // 主线程调会抛错
access.write(new Uint8Array([4, 5, 6]), { at: 0 });
access.flush();
access.close(); // 不关就一直占着文件锁,别的上下文再也打不开
createSyncAccessHandle() 在 Safari 的历史实现里是直接返回句柄而不是 Promise,用 await 包一层两边都能跑。Safari 的 createWritable() 支持比同步句柄晚,具体以官方文档为准。
OPFS 占的是 storage quota,navigator.storage.estimate() 看用量,navigator.storage.persist() 申请持久化,批不批由浏览器决定。清站点数据时 OPFS 一起没,用户唯一的一份数据别只放这儿。
File System Access 的坑在权限和游标
// 1. 用户手势里挑文件
const [handle] = await showOpenFilePicker({
types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }],
});
// 2. 句柄可以结构化克隆,存 IndexedDB,下次页面加载直接复用
await idb.put('cfg', handle);
// 3. 复用时权限可能已经掉了,补请求同样要在用户手势里
if (await handle.queryPermission({ mode: 'readwrite' }) !== 'granted') {
if (await handle.requestPermission({ mode: 'readwrite' }) !== 'granted') return;
}
const w = await handle.createWritable({ keepExistingData: true });
await w.seek(await handle.getFile().then(f => f.size)); // 想追加得手动把游标移过去
await w.write(JSON.stringify(patch));
await w.close(); // 不 close 不落盘,这点和 Node 的 fs 一样
keepExistingData 默认是 false,一开写就把原文件截断了,改配置文件的时候很容易把用户数据写没。它只影响打开时的初始内容,游标仍然在 0,追加要自己 seek。
兼容性上这套选择器只有 Chromium 系有,Safari 和 Firefox 都没有。降级路径就是 <input type="file"> 读 + Blob + <a download> 写,能覆盖大部分「导入导出配置」的需求。
剪贴板写宽松、读严格,读取要按会失败来写
navigator.clipboard.writeText() 在 Chromium 上通常不弹权限提示,只要在用户手势的调用链里就行。读取完全是另一回事:
const text = await navigator.clipboard.readText(); // 可能被拒绝,可能弹系统确认
Chromium 会弹权限气泡,Safari 会出系统粘贴确认,Firefox 对用户激活的要求更严。页面加载时自动读剪贴板这条路走不通,别试。
写富内容时 Safari 有个额外约束:ClipboardItem 的值必须是 Promise,且 write() 要在手势内同步发起。
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': renderPng() }) // renderPng() 返回 Promise<Blob>
]);
降级用 document.execCommand('copy'),废弃是废弃了,但在需要兼容旧 WebView 的内部页面里仍然是最省事的兜底:
function legacyCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
}
Window Management:屏幕信息拿得到,窗口不一定移得动
const details = await window.getScreenDetails(); // 需要 window-management 权限,且在手势内
console.log(details.currentScreen, details.screens);
details.addEventListener('screenschange', () => { /* 插拔屏 */ });
判断是否多屏有更轻的入口:window.screen.isExtended,不需要授权,先看它再决定要不要请求权限。
几个实际会遇到的问题:
screens[]的left/top/width/height是 CSS 像素,跨屏 DPR 不一致时直接用screen.width * devicePixelRatio算物理像素会错位。- 拿到屏幕布局和移动窗口是两件事。普通标签页里
window.moveTo/moveBy基本无效,通常只有脚本用window.open打开的窗口才动得了,各浏览器策略还不完全一致。 - Safari 和 Firefox 没有这个 API,降级就是
window.screen的粗略值加上「让用户自己选目标屏」。
Screen Wake Lock:熄屏不是唯一的失败点
let sentinel = null;
async function keepAwake() {
if (!('wakeLock' in navigator)) return false;
try {
sentinel = await navigator.wakeLock.request('screen');
return true;
} catch {
return false; // 省电模式、后台标签、权限策略都会走到这里
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !sentinel?.released) keepAwake();
});
页面切到后台时锁会被自动释放,visibilitychange 里重新申请是必须的,不是可选优化。另外它只防熄屏,不防系统睡眠,也不保证在低电量模式下给。
Web Bluetooth 和 Web Serial 只在 Chromium,按内部工具定位
两个 API 的调用模型一样:用户手势内 requestDevice() / requestPort() 弹选择器,授权按 origin 记住,之后可以用 getDevices() / getPorts() 拿回已授权设备,不用反复弹窗。
// 串口:注意锁的释放
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
const writer = port.writable.getWriter();
await writer.write(new Uint8Array([0x01]));
writer.releaseLock(); // 不释放,后续 close() 会抛 InvalidStateError
const reader = port.readable.getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
console.log(value);
}
reader.releaseLock();
await port.close();
// 蓝牙:非标准 service 必须放进 optionalServices,否则 getPrimaryService 会抛
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: ['battery_service'] }],
optionalServices: ['device_information'],
});
const server = await device.gatt.connect();
const ch = await (await server.getPrimaryService('battery_service'))
.getCharacteristic('battery_level');
await ch.startNotifications(); // 不订阅收不到 characteristicvaluechanged
这几个坑我踩过:串口读写锁不释放,第二次 open() 直接报错;蓝牙 GATT 断连后 device.gatt.connected 变 false,得自己写重连退避;filters 里的 service UUID 写错,选择器里一个设备都不显示,而且没有任何报错。
面向 C 端的网页别指望这两个 API。Android Chrome 有蓝牙、没有串口,iOS 两个都没有,桌面 Firefox/Safari 全没有。它的合理定位是内部配置工具、产线刷写页、硬件调试面板。
WebGPU 换不换 WebGL,看有没有 compute 和 draw call 压力
WebGPU 的收益集中在两处:计算和提交开销。
- WebGL 没有 compute shader,粒子、物理、图像后处理只能拿片元着色器绕,或者靠 transform feedback,写法别扭且受限于纹理格式。WebGPU 的 compute pass 是原生的,同一份 WGSL 在浏览器和 Node 侧(通过 Dawn 绑定)都能跑。
- draw call 上千时,WebGL 每帧的状态校验和驱动侧开销很实在,WebGPU 走 command buffer 一次提交,CPU 侧开销低一个量级(量级参考,具体看场景)。
const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
const device = await adapter.requestDevice();
@group(0) @binding(0) var<storage, read_write> data: array<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
data[gid.x] = data[gid.x] * 2.0;
}
代价同样明确:着色器要重写,渲染管线的显式绑定组、纹理视图、同步语义都得重新学一遍,three.js 之类的库虽然有两套后端,但自定义 shader 的迁移量按天算。只画图表、只做页面转场的话,WebGL 完全够用,换过去纯亏。另外移动端和 App 内置 WebView 的 WebGPU 支持要按不可用来设计,navigator.gpu 存在也可能 requestAdapter() 返回 null。
安全上下文、授权和降级:写成一张能力表
上面所有 API 都要求安全上下文,也就是 https、localhost 或 127.0.0.1。file:// 下剪贴板、Service Worker、WebGPU 基本都不给,本地调试别用双击打开 HTML 的方式。
探测写在启动流程里,别用 UA 判断:
export const caps = {
opfs: 'getDirectory' in navigator.storage,
fsAccess: 'showOpenFilePicker' in window,
clipboardRead: !!navigator.clipboard?.readText,
screens: 'getScreenDetails' in window,
wakeLock: 'wakeLock' in navigator,
bluetooth: 'bluetooth' in navigator,
serial: 'serial' in navigator,
webgpu: !!navigator.gpu,
};
navigator.permissions.query() 不能当能力检测用,不在规范里的名字会直接抛 TypeError:
try {
const st = await navigator.permissions.query({ name: 'clipboard-read' });
} catch {
// 查不了就当作 prompt 处理,走请求分支
}
降级路径按能力列清楚,比散落在业务代码里的 if 好维护:
| 能力 | 不可用时 |
|---|---|
| OPFS | IndexedDB / Cache Storage |
| 文件读写 | <input type="file"> 读 + <a download> 写 |
| 剪贴板读取 | 让用户手动粘贴到 textarea |
| 多屏信息 | window.screen 粗值 + 用户手选 |
| 不熄屏 | 提示用户改系统电源设置 |
| 蓝牙 / 串口 | 桌面客户端或本地代理服务 |
| WebGPU | WebGL2 后端 |
还有两条容易漏的:在 iframe 里,clipboard-read、bluetooth、serial、window-management、screen-wake-lock 都是 Permissions-Policy 管控的特性,父页面不给 allow 属性,子页面里探测结果是 false,跟浏览器支不支持无关;所有 request* 调用必须落在用户手势的同一个任务链里,中间 await 一个网络请求再弹窗,用户激活已经丢了,Chromium 会直接拒绝。