做前端监控的人大概都见过这个东西:错误平台上密密麻麻一片 Script error.,message 就这十几个字符,source 是空字符串,lineno 和 colno 全是 0,error 对象是 null。你点进去,什么也看不到,不知道是哪个文件、哪一行、哪个变量炸了。
线上用户反馈”页面点了没反应”,你打开监控一看,满屏 Script error.,等于什么都没告诉你。于是本地复现,一切正常——因为本地复现时脚本多半就在同源,浏览器会把完整堆栈老实交出来。
差别就出在这个属性上。跨域脚本(CDN 上的 app.js、第三方 SDK)在执行中抛错时,浏览器出于安全考虑只给一个泛化的 “Script error.”,除非你在 <script> 标签上显式声明 crossorigin="anonymous",并且资源服务器配合返回 Access-Control-Allow-Origin。
html<!-- 拒绝透露细节 -->
<script src="https://cdn.example.com/app.js"></script>
<!-- 出错时能拿到真实的 message、文件和行号 -->
<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
就这么一个属性,把”什么都看不出来”变成”报错直接指到第 3 行第 12 列”。
它其实不是”跨域开关”
这是最容易搞混的地方,值得先掰正:不加 crossorigin,跨域脚本照样能加载、能执行。<script> 天生就被允许跨域,早年的 JSONP 就是靠这个特性活着的。所以别指望”加了才能引外部 JS”,完全不是这回事。
加上这个属性,本质是把请求从默认的 no-cors 模式切换成 cors 模式。切换之后:
-
请求头里会多出一个
Origin: https://your-site.com; -
浏览器会拿着响应头里的
Access-Control-Allow-Origin做一次校验,不匹配就直接拒收; -
校验通过,脚本内容对页面就是”可读”的,出错时报的就是真话。
换句话说,这是一笔交易:你放弃”宽松但看不清”的加载方式,换”严格但看得清”。服务器从”不用为自己的响应头负责”,变成”必须明确回答允许谁读”。CDN 没准备好,交易就谈崩,脚本直接不执行。

浏览器为什么要这么设计?因为跨域脚本的错误信息里可能夹带东西——变量名、接口路径、内部结构。一个恶意页面可以引你的 CDN 脚本,故意触发错误,再从错误信息里扒走它本不该知道的内容。 Mozilla 那边为此专门开过一个 bug(编号 363897),最后选定的做法就是:默认只给 “Script error.”,想看细节就走 CORS 让服务器点头。
三种写法,三种请求模式
这个属性只有两个有效值,但算上”不写”,实际有三种状态,行为差别不小:
|
写法 |
请求模式 |
是否带凭据 |
错误信息 |
|---|---|---|---|
|
不写这个属性 |
no-cors |
按浏览器 cookie 策略 |
只有 |
|
|
cors |
不带 |
服务端放行后可看全 |
|
|
cors |
带 cookie、证书、Basic 认证 |
校验更严,需额外响应头 |
空值和非法值都不等于”不写”
裸写一个 crossorigin、写 crossorigin=""、甚至写成 crossorigin="true" 这种瞎编的值,浏览器统统按 anonymous 处理。想回到”完全不校验”的默认行为,只有一个办法:把这个属性删掉。很多人以为留个空值等于关掉,结果线上照样被 CORS 拦。
日常 95% 的场景用 anonymous 就够了。use-credentials 只在资源本身需要登录态时才用(比如要带会话 cookie 才能取到的私有脚本),代价是服务器必须同时返回 Access-Control-Allow-Credentials: true,而且 Access-Control-Allow-Origin 不能是通配符 *,必须写死具体来源。这两条缺一条,浏览器直接拒收。
静态标签与动态脚本的写法
静态标签没什么好说的,属性往上一挂就行:
html<script src="https://cdn.example.com/app.4f21c8.js"
crossorigin="anonymous"
integrity="sha384-oqVuAFXRJKap227..."></script>
动态创建脚本时容易踩坑,注意顺序——先设 crossOrigin,再赋 src。属性名在 DOM 里是大写 O 的 crossOrigin,不是 crossorigin:
jsconst el = document.createElement('script');
el.crossOrigin = 'anonymous'; // 必须在 src 之前
el.src = 'https://cdn.example.com/app.js';
document.head.appendChild(el);
顺序写反了会怎样?赋 src 的那一刻浏览器可能就已经发起预加载了,此时还没带上 CORS 标记,等到执行时再补属性已经晚了——请求会以 no-cors 模式发出去,或者干脆发两次。
打包工具这边,webpack 可以统一给产物加:
js// webpack.config.js
module.exports = {
output: { crossOriginLoading: 'anonymous' },
};
// 或者交给 HtmlWebpackPlugin
new HtmlWebpackPlugin({ scriptLoading: 'defer', crossOriginLoading: 'anonymous' });
Vite 生成的 index.html 里,产物 script 默认就是带 crossorigin 的,自定义模板时别手抖删掉。另外动态 import() 拉下来的异步 chunk 也走同一套规则,配了主配置一般能覆盖到,上生产前打开 Network 面板扫一眼更稳。
服务端那一半必须跟上
标签只是单方面表态。要真正生效,三个条件得同时成立:
-
<script>标签上显式写了crossorigin="anonymous"; -
资源响应头里有
Access-Control-Allow-Origin,且值能匹配当前页面的协议 + 域名 + 端口; -
请求没有跳到另一个不带 CORS 头的地址上去。
第 2 条里的”端口也算”,是被忽略最多的一次翻车:http://localhost:3000 和服务器允许列表里的 https://prod.com 不是一回事,差一个协议都不算匹配。
nginx 上给静态资源补头:
nginxlocation ~* \.js$ {
add_header Access-Control-Allow-Origin "*" always;
}
always 别漏,不加的话 4xx、5xx 响应上这个头就不出现,排查时会被绕进去。允许多个指定来源时,用 map 回显 Origin,同时记得 Vary:
nginxmap $http_origin $cors_origin {
default "";
"~^https://(www\.)?example\.com$" $http_origin;
}
server {
location /static/ {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
try_files $uri =404;
}
}
CDN 缓存是个隐形坑
响应一旦被 CDN 缓存,就可能把”某个 Origin 的响应”发给所有人。只要你的 ACAO 不是固定的 *,就必须让缓存按 Origin 分桶,也就是加上 Vary: Origin。不然会出现”上午还好好的,下午某些用户全挂”这种玄学故障,而且只在部分节点复现。
验证不用等页面刷新,命令行直接问一次最快:
bashcurl -I https://cdn.example.com/app.js -H "Origin: https://your-site.com"
# 看返回里有没有 access-control-allow-origin,值对不对得上
加了之后反而加载失败的几种情况
这是新手最容易懵的一点:本来跑得好好的脚本,加上 crossorigin 之后直接报 “blocked by CORS policy”,脚本不执行了。原因前面说过——模式变严格了。常见几种:
1. 服务器压根没返回 ACAO
一些自建对象存储、老 CDN、公司内部静态服务器,从来没配过 CORS。这类资源选两条路:要么去服务端把头加上,要么直接放弃这个属性(脚本照常跑,只是拿不到错误细节)。
2. 第三方脚本不支持
不少商业 SDK 的服务端就是不返回 CORS 头(支付类、验证码类、部分统计脚本很常见)。给它们加 crossorigin="anonymous" 是纯亏:细节拿不到,脚本还加载不了。第三方脚本一律先 curl 验一遍,通过再加。
3. 重定向把 CORS 头弄丢了
CORS 模式下,请求一旦被 302 到另一个地址,浏览器会对最终响应重新做校验。很多 CDN 的跳转目标不带 CORS 头,或者中间经过一层不带头的网关,校验就失败了。解决办法是让 src 直接指向最终地址,别让请求中途跳。
4. 值写成了 use-credentials
服务端返回 Access-Control-Allow-Origin: *,而标签上写的是 use-credentials——这个组合浏览器明确禁止,因为带凭据的请求不允许用通配符放行。要么改用 anonymous,要么服务端改成回显具体 Origin 并加 Access-Control-Allow-Credentials: true。
5. 带版本号的资源被旧缓存命中
配完了头,发现线上还是老的报错。资源 URL 带 ?v=1.2.3 之类的参数时,CDN 可能按不同 key 缓存了旧响应。改完配置顺手刷一下对应路径的缓存,别只盯着代码。
排查时别只盯 HTML
看到页面标签上写着 crossorigin 不等于它生效了。真正的证据在网络请求里:请求头有没有 Origin、响应头有没有 Access-Control-Allow-Origin、两者的值对不对得上。标签只是意愿,请求才是事实。
顺带被牵连的字体和 SRI
这个属性的影响范围不止 <script>,有两个地方经常被连带:
用了 integrity 就必须配 crossorigin。 子资源完整性校验要求浏览器读到真实字节内容,而默认 no-cors 模式下内容是”不透明”的,读不了。所以凡是带 integrity 的标签,crossorigin 是强制搭档,缺了浏览器会拒绝执行并给出提示。
html<link rel="stylesheet" href="https://cdn.example.com/a.css"
integrity="sha384-..." crossorigin="anonymous">
字体和 preload 一定要加。 字体文件的请求天生走 CORS 模式,<link rel="preload" as="font"> 如果不带这个属性,预加载和后面 @font-face 真正发起的请求模式对不上,结果是同一个字体下载两遍,控制台还会甩一条 “preload 未被使用” 的警告。这条几乎是白送的性能问题,加上就行:
html<link rel="preload" href="/fonts/inter.woff2" as="font"
type="font/woff2" crossorigin="anonymous">
同理,跨域图片要画进 canvas 再读像素(验证码拼图、截图合成这类需求),也得加,否则画布一被”污染”,getImageData 直接抛错。
四步自查清单
线上发现报错还是 “Script error.”,或者脚本干脆没执行,按这个顺序走一遍,基本能定位:
-
看页面有哪些跨域脚本缺属性——控制台跑一行:
document.querySelectorAll('script[src]'),逐个看crossOrigin(值为 null 的就是漏网的)。 -
看请求头——Network 面板找到那个 js,Request Headers 里应当有
Origin;没有,说明属性没写上去或者写晚了。 -
看响应头——Response Headers 里有没有
Access-Control-Allow-Origin,值是否精确等于当前页面的协议 + 域名 + 端口;用了use-credentials的还得有Access-Control-Allow-Credentials: true。 -
看是不是被缓存或重定向坑了——响应头里有没有
Vary: Origin、请求是不是经过了 302、CDN 上缓存的版本是不是配置更新前的。
还有一个临时绕过排查的办法:DevTools 的 Console 面板不受这套屏蔽限制,跨域脚本的真实错误在那里是完整显示的。所以”本地能复现就先本地看”永远是最快的一条路,crossorigin 这套配置是为了让生产环境的监控也能收到同样完整的信息。
一句话的取舍原则
自己可控的 CDN 资源,默认全加 crossorigin="anonymous",服务端把 ACAO 和 Vary: Origin 配齐;第三方脚本先验证再决定,服务端不支持就别硬加;只要用了 integrity 或字体 preload,这个属性就不是可选项。
主要依据 MDN 对 crossorigin 枚举属性与 CORS settings attributes 的定义,以及 HTML 规范中关于脚本错误信息屏蔽的相关条款;文中配置示例针对 nginx 与 webpack 的常规版本,具体参数以你所用版本的官方文档为准。

