如果你在项目里遇到过SharedArrayBuffer is not defined这类报错,那你应该已经搜索过不少资料,答案大概率指向同一个词:跨域隔离(cross-origin isolation)。我去年帮团队优化一个浏览器端图片批量处理工具时,就因为这个限制卡了两天,把 COOP/COEP 的组合逻辑彻底弄明白之后才真正打通。今天这篇文章,我不念文档,只讲实操——它是什么、为什么这么设计、在 Nginx/Express/Vite 里怎么加、以及加上之后哪些坑等着你。
1. SharedArrayBuffer 被“上锁”的前因后果
1.1 SharedArrayBuffer 到底能干什么
SharedArrayBuffer 是 ES2017 标准里引入的共享内存对象,可以简单理解成一块“主线程和 Worker 线程都能直接读写”的内存区域。普通数据在 postMessage 的时候要经历结构化克隆,数据量一大就有明显开销;而 SharedArrayBuffer 不用复制,两份线程拿着同一块内存地址,读写都是同一份数据。
它最常出现在这几类场景里:
- 图像处理:主线程拿到图片像素,把像素数组塞进 SharedArrayBuffer,Worker 直接做滤镜、缩放、人脸检测,处理完主线程立刻能看到结果。
- 音视频解码:WebCodecs、Web Audio 这类 API 处理连续帧数据时,用共享内存可以省掉大量帧拷贝。
- WebAssembly 高性能计算:WASM 实例的内存本身就是 ArrayBuffer,可以和 SharedArrayBuffer 打通,让多个线程直接访问。
- 游戏、云渲染、协同编辑这类低频高流量场景。
如果你不需要多线程共享数据,只是想在 Worker 里跑任务,那普通 postMessage 也够用。SharedArrayBuffer 是给“性能敏感”的场景准备的,这也是它后来被浏览器严格管控的原因。
1.2 一个处理器漏洞引发的连锁反应
2018 年开年,安全界投下两颗深水炸弹:Spectre(幽灵)和 Meltdown(熔断)。这类处理器漏洞利用分支预测和缓存命中的时间差异,可以跨进程读取本不该访问的内存内容。
问题在于,浏览器里恰好有几种能力可以被组合成一条利用链:SharedArrayBuffer 提供共享内存,Atomics 提供原子操作,performance.now()提供高精度计时器。攻击者可以把目标数据读入共享内存,用计时器测量缓存访问时间,从而一点点还原出敏感信息。Chrome 68 开始默认禁用了 SharedArrayBuffer,Firefox、Safari 也陆续跟随,导致一大批依赖共享内存的 Web 应用被砍掉功能。
浏览器厂商的解决思路是“用安全换性能”:只要页面处于一个可以信任的隔离环境,就重新开放 SharedArrayBuffer。这个“可以信任的隔离环境”,就是跨域隔离。它要求页面同时声明两个 HTTP 响应头:COOP 和 COEP。只要这两个头设置正确,window.crossOriginIsolated就会变成 true,SharedArrayBuffer 重新可用,performance.now()的分辨率也会回到微秒级。
注意:这里的关键是“同时”。单独设置 COOP 或者单独设置 COEP 都不能让 crossOriginIsolated 生效,必须两个头都符合条件。
2. 跨域隔离的核心:COOP 与 COEP 各管一块
2.1 COOP 管的是窗口关系
COOP 全称 Cross-Origin-Opener-Policy,它管的是“当前窗口和其他窗口之间的引用关系”。默认情况下,一个页面用window.open打开另一个跨源页面,返回的引用会连到那个窗口的 DOM,这就是window.opener机制。恶意站点可以把你引导进一个隐藏页面,再通过 opener 关系操控你的窗口,或者反过来读取你的敏感信息。
COOP 能切断这层关系:
Cross-Origin-Opener-Policy: same-origin当响应头设置为same-origin时,当前页面会被放进一个隔离的“浏览上下文组”里,和其他非同源窗口不再共享 opener,跨源 window.open 返回的引用基本归 null。想让 SharedArrayBuffer 恢复可用,COOP 必须取same-origin,不能只设same-origin-allow-popups,也不能用默认的unsafe-none。
这里有个实践中容易困惑的点:设置了 COOP 之后,你项目里那些“弹窗登录后父页面要刷新一下”的代码可能就失效了。因为弹窗和父页面之间的 opener 关系被切断,父页面拿不到弹窗里传回来的信号。解决办法也很统一:改用 postMessage 或者在弹窗里直接通知服务器,父页面通过接口轮询状态。
2.2 COEP 管的是子资源加载
COEP 全称 Cross-Origin-Embedder-Policy,它管的是“当前页面里所有嵌入子资源是否被明确许可”。这里说的子资源包括图片、脚本、样式表、字体、iframe、Worker 脚本等。跨域隔离环境要求页面里不能出现任何“来源不明的跨源资源”,否则攻击者可能趁乱加载恶意数据参与侧信道攻击。
标准写法:
Cross-Origin-Embedder-Policy: require-corprequire-corp意味着:页面加载的所有跨源资源,都必须通过以下两种方式之一声明“我愿意被跨源加载”:
- 响应自带
Cross-Origin-Resource-Policy(简称 CORP)头,值可以是same-origin、same-site或cross-origin。 - 资源支持 CORS,响应里带着
Access-Control-Allow-Origin,并且资源标签上加了crossorigin属性。
如果你在 devtools 里看到某个图片被拦截,大概率就是这些条件没满足。同源资源不受影响,本地开发时正常加载的本地资源一般不会踩坑。
2.3 COEP 的另一个取值:credentialless
很多实际项目根本没有办法控制所有第三方资源的响应头。CDN 上的老脚本、广告平台的上报图片、某地图服务的 aysnc 加载,都不可能给你加 CORP。如果硬上require-corp,页面里会出现大量资源被拦截的情况。
COEP 提供了第二种模式:
Cross-Origin-Embedder-Policy: credentiallesscredentialless 的意思是:跨源子资源仍然可以加载,但所有跨源请求都不携带凭据(比如 Cookie),相当于自动把所有跨源请求降级为“匿名跨源”。这样既缓解了携带凭据时的敏感信息泄露风险,也免去了逐个给第三方资源加头的烦恼。
我自己的建议是:如果项目里第三方资源不多,优先用require-corp,安全性更高;如果接入了一堆你控制不了的外部脚本、iframe、图片,先上credentialless把功能跑通,再逐步把关键资源改成require-corp。现在 Chrome、Safari 对 credentialless 支持已经比较完整,Firfox 主流版本也没有障碍。
3. 实操:从零配置跨域隔离
3.1 在 Nginx 里设置响应头
如果你是常规 Nginx 部署,配置相当直接,在要启用跨域隔离的 server 块里加上两个 add_header 就可以:
server { listen 443 ssl; server_name your-domain.com; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; root /var/www/html; index index.html; }always参数的作用是保证即使响应状态码是 404、302 等非 200 情况,响应头也会带上。不加 always 的情况下,Nginx 只会在 200、201、204、206、301、302、303、304、307、308 这几种状态里附加 add_header,错误页和部分重定向就不会带,可能导致排查半天找不出原因。
如果你在某个 location 里单独配置了 add_header,一定要小心 Nginx 的继承覆盖问题。Nginx 规定如果一个 location 中出现任何 add_header 指令,更外层所有的 add_header 都会被忽略。我见过不少踩坑案例:server 级别设置了 COEP,location 里为了给某个接口加 Cache-Control 又写了一个 add_header,结果 COEP 头全丢了。解决办法是外层保持统一,location 里需要加头时把 COOP/COEP 也一并写进去,或者把公共头提取到单独配置文件里 include。
3.2 在 Express / Node.js 里设置响应头
前后端同域部署的 Node 服务,用中间件统一加:
const express = require('express'); const app = express(); app.use((req, res, next) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); next(); }); app.use(express.static('public')); app.listen(3000, () => { console.log('server running at http://localhost:3000'); });这里需要注意:静态资源中间件要放在设置头的中间件之后,否则某些静态文件可能已经返回了才拿到头。实际上只要顺序正确,所有响应都会带上这两个头。如果你想对 API 请求和静态页面区别对待,可以在中间件里按路径判断,但通常没必要。
3.3 在 Vite / Webpack 开发服务器里设置
本地开发时最常见的痛点是“生产环境配好了,开发环境没有”,导致 SharedArrayBuffer 跑不起来。Vite dev server 可以这样配:
// vite.config.js export default { server: { headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }, };Webpack devServer 的写法:
module.exports = { devServer: { headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }, };开发环境下如果你用了跨域的前端代理 /api,COEP 不会拦代理接口,因为 fetch 请求天然走 CORS 逻辑,和嵌入资源不是一回事。真正需要关注的是图片、Worker、iframe 这类嵌入资源,本地开发如果碰到拦截,先看 devtools 里的具体报错,再决定是加 CORP 头还是换 credentialless。
3.4 静态资源如何配合 CORP
如果你用的 COEP 是require-corp,需要给可能被跨域加载的静态资源加上 CORP 响应头:
location /assets/ { add_header Cross-Origin-Resource-Policy "cross-origin" always; }这个头同样可以在服务器代码里统一加:
app.use('/assets', (req, res, next) => { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); next(); });CORP 的取值有三个档位:
same-origin:只有同源资源可以加载。same-site:同站点(可以跨子域)可以加载。cross-origin:所有跨源都允许。
如果你不确定资源的来源,最宽松的cross-origin能避免大部分资源被拦截,但安全性打折扣。更好的做法是区分场景:字体、CDN 静态文件设成cross-origin,内部接口资源保持默认。
注意:CORP 是响应头,它不会因为你页面里加了 crossorigin 属性就自动改变。CORP 和 CORS 是两套独立机制,CORP 是资源“单方面声明允许跨源”,CORS 是请求方发起的“询问式许可”。COEP 只要满足其中任何一套,资源就不会被拦。
3.5 还有一条路:Service Worker 代理
如果项目里第三方资源实在多到改不动,又不能用 credentialless,还有一个压箱底方案:用 Service Worker 拦截请求,在响应里补加 CORP 头。常见思路是给所有跨源响应包一层new Response,然后手动加上Cross-Origin-Resource-Policy: cross-origin。但这要求你对流量做仔细过滤,不要把所有响应都无脑加头,否则隔离效果等于没有。Service Worker 方案适合有专门前端基建团队的场景,小项目不太建议,复杂度明显高于前两种。
4. 配置之后,如何验证确实启用成功
4.1 用 crossOriginIsolated 这个“官方开关”
最靠谱的验证方式是直接读取浏览器暴露的全局属性:
if (self.crossOriginIsolated) { console.log('跨域隔离已启用,SharedArrayBuffer 可用'); const sab = new SharedArrayBuffer(1024); const view = new Int32Array(sab); console.log(view.length); // 256 } else { console.warn('跨域隔离未启用'); }crossOriginIsolated是浏览器根据 COOP、COEP、安全上下文三个条件综合计算出来的最终结果。它为 true 就说明条件都满足了,SharedArrayBuffer 一定会重新出现在全局作用域里。反过来,它如果为 false,就算页面里某个 case 下能拿到 SharedArrayBuffer,整体也不能依赖。
这里要提醒一点:安全上下文是硬性前提。跨域隔离只在 HTTPS 环境或者 localhost 环境下生效。如果通过http://192.168.1.10:8080访问,即便响应头配置完全正确,crossOriginIsolated 仍然会是 false。
4.2 用 DevTools 和命令行看响应头
验证响应头最直接的办法是打开 Chrome DevTools 的 Network 面板,点击上方任意一个文档请求,查看 Response Headers 里是否出现:
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp没有的话就是没下发成功。还可以在命令行里快速确认:
curl -I https://your-domain.com/输出里如果能看到这两个头,说明服务器配置生效。需注意到 CDN 边缘节点如果缓存了旧头,你修改源站之后可能还是拿到旧值,这时候需要刷新 CDN 缓存或者设置默认的兜底响应头。
4.3 用一个真实场景做最终验证
只看属性还不够,稳妥的做法是直接让一个 Worker 用 SharedArrayBuffer 做一次内存共享读写,确认业务路径真的通了。主线程代码:
const sab = new SharedArrayBuffer(4); const arr = new Int32Array(sab); arr[0] = 0; const worker = new Worker('/worker.js'); worker.postMessage(sab); worker.onmessage = (e) => { console.log('Worker 写入后的结果:', e.data); };worker.js:
self.onmessage = (e) => { const arr = new Int32Array(e.data); Atomics.store(arr, 0, 128); self.postMessage(arr[0]); };如果 Worker 能正常把 128 传回主线程,而且没有出现SharedArrayBuffer is not defined,那整条链路就是通的。这个测试建议放在应用的核心初始化流程里,一旦环境异常立刻给出明确提示,避免用户用到后面才发现功能失效。
5. 常见问题与排查技巧实录
5.1 页面图片全碎了:COEP 拦截了跨域图床
我自己第一次开启require-corp后,最先崩的就是运营配置的图片 CDN。那些图片来自完全不同的域名,加载时没带 crossorigin 属性,CDN 响应也没 CORP 头,结果全被 COEP 拦下来。报错长这样:
Refused to load the image 'https://cdn.example.com/a.jpg' because it violates the following Content Security Policy directive: "require-corp"注意这里的用词虽然提到了 CSP,但实际上是在说 COEP 的资源拦截。解决办法最快的是给图片标签加 crossorigin:
<img src="https://cdn.example.com/a.jpg" crossorigin="anonymous" />同时要求 CDN 返回Access-Control-Allow-Origin头。另一种方案是让运维在 CDN 上统一加Cross-Origin-Resource-Policy: cross-origin,这样就不用改前端代码。
5.2 window.opener 失效了:登录弹窗回不去
设置了 COOP: same-origin 后,跨源弹窗的 opener 被切断,弹窗里window.opener.xxx()会直接报 null。这在做第三方授权登录时特别常见。我一般推荐用 postMessage 替代:父页面监听 message 事件,弹窗用window.opener.postMessage({type: 'ok'}, '*')上报结果。如果你还依赖弹窗同步消息,最好在弹窗里直接请求后端接口标记登录状态,父页面轮询该接口,最稳。
5.3 第三方脚本突然不执行了
很多第三方 SDK 的加载地址不会主动设置 CORS 头,脚本标签又没有 crossorigin,COEP 一开就会把这些脚本全部拦截。表现为统计代码不生效、客服组件不出来、广告系统直接空白。
有三个应对方向:
- 把用到的第三方脚本改成通过同源代理加载,代理返回带 CORP 头。
- 给 script 标签加 crossorigin,前提是第三方服务器支持 CORS。
- 把 COEP 切到 credentialless,接受跨源请求不带凭据的代价。
如果业务无法接受 credentialless,又确实要加载大量第三方脚本,那么开发成本最大的方向是全面梳理所有第三方资源,确认各自能不能补 CORP/CORS,必要时用 Service Worker 兜底。
5.4 跨域 iframe 被拦截
涉及支付页面、地图组件、视频播放器等跨域 iframe 时,COEP 同样会要求 iframe 的响应带上 CORP 头。这类页面往往不能由你控制,问题非常棘手。常见的做法是给 iframe 的 src 做一个同源包装页,或者干脆把目标站点放在子域名,用same-site级别的 CORP 处理。这里必须做单独的兼容测试,不能想当然。
5.5 排查清单速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| crossOriginIsolated 为 false | COOP/COEP 缺一个,或页面非 HTTPS/localhost | 确认响应头完整且页面在安全上下文 |
| 图片/脚本被拦截 | 跨源资源没有 CORP/CORS | 加 CORP 头、加 crossorigin 属性,或改用 credentialless |
| window.open 拿不到 opener | COOP 切断了跨源窗口引用 | 用 postMessage 或后端接口代替 |
| 本地正常,线上失败 | 代理、CDN 没有透传响应头 | 检查 CDN 配置和源站是否一致 |
| 错误页/重定向没有头 | Nginx add_header 没加 always | 补上 always 并检查继承覆盖 |
| 页面在 iframe 里被嵌 | 被嵌页面本身需要隔离,嵌入方也受影响 | 单独评估嵌入场景,必要时对 iframe 场景降级 |
5.6 灰度与降级方案
跨域隔离不是加个响应头就能无感上线的。最大的风险在于 COEP 拦截资源后页面直接破相,而用户往往不会看控制台报错,只会觉得“网站坏了”。我现在的习惯是分三步走:先在测试环境把跨境资源全部梳理一遍,列出一个“受 COEP 影响的资源清单”;然后在一小部分流量上灰度,用 Reporting API 收集异常资源,等确认拦截数量在可接受范围后再全量放开。如果项目不允许这么细的灰度,直接切到credentialless会平滑很多,代价是跨源请求不再带 Cookie,需要评估业务是否有依赖。
5.7 无第三方资源的简单项目配置参考
如果你的项目是完全自控资源的纯前端应用,推荐直接上全套:
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Resource-Policy: cross-origin第一个头保护窗口隔离,第二个头强制所有跨源资源都要声明许可,第三个头告诉其他站点:这里的静态资源允许被跨源加载。三者配合,既满足 SharedArrayBuffer 的启用条件,也不会拦截同域静态资源,遇到跨域 CDN 只要 CDN 把 CORP 配上就能跑。
配置跨域隔离和 SharedArrayBuffer 的整个过程,本质上是一次“用安全约束换取性能特性”的等价交换。你多做几个响应头的配置,解决的是多线程共享内存的可用性问题,但这个配置会让你的页面在嵌入第三方资源时变得比较挑剔。我的体会是动手之前先盘一遍现有资源,再决定用 require-corp 还是 credentialless,否则很容易在改完之后被各种拦截报错折腾得怀疑人生。