前言
在微前端和多国业务的场景下,我们经常会遇到 HTML 页面域名和静态资源域名不统一的情况。这种架构虽然带来了部署和 CDN 优化的便利,但也引入了一个常见的问题:跨域 Script Error。本文将详细介绍这个问题的原因、影响,以及一套完整的解决方案。
问题背景
业务场景
我们的项目是一个多国业务的前端应用,采用了以下架构:
- HTML 页面域名 :每个地区/业务线使用不同主域名
- 例如:
countryA-web.example.com,countryB-web.example.com, ...
- 例如:
-
- 静态资源 CDN 域名:统一使用一个公共 CDN 地址
- 例如:
cdn.example.com
问题表现
在这种架构下,我们遇到了以下问题:
-
监控上报的 JS 错误全部显示为 "Script Error"
- 无法获取详细的错误堆栈信息
- 无法定位具体的错误位置
- SourceMap 无法正确映射
-
错误信息丢失
- 错误消息被浏览器隐藏
- 无法获取错误发生的文件、行号、列号
- 调试和问题排查变得困难
问题原因分析
1. 浏览器的同源策略
当脚本从不同源加载时,浏览器会应用**同源策略(Same-Origin Policy)**的安全机制:
- 如果脚本发生错误,且脚本的源与页面不同,浏览器会隐藏错误的详细信息
- 只返回通用的 "Script Error" 消息
- 这是为了防止恶意网站通过错误信息获取敏感数据
2. 缺少 CORS 配置
要获取跨域脚本的详细错误信息,需要满足两个条件:
- 脚本标签添加
crossorigin属性 - 服务器返回正确的 CORS 响应头
如果缺少其中任何一个,浏览器都会隐藏错误详情。
3. Webpack/Rspack 动态加载机制
现代前端构建工具(Webpack、Rspack)在实现代码分割和懒加载时,会通过 document.createElement('script') 动态创建 script 标签来加载 chunk。如果这些动态创建的标签没有 crossorigin 属性,同样会导致 Script Error。
解决方案
我们采用了一套三层防护的解决方案:
方案架构图
┌─────────────────────────────────────────┐
│ 解决方案架构 │
├─────────────────────────────────────────┤
│ 1. HTML 模板层 │
│ └─ 手动添加 crossorigin 属性 │
│ │
│ 2. 构建时处理层 │
│ └─ Webpack 插件自动添加 │
│ │
│ 3. 运行时拦截层 │
│ └─ 拦截 createElement 全局处理 │
└─────────────────────────────────────────┘
HTML层:模板引用资源手动修改
对于 HTML 模板中直接引用的静态资源,我们手动添加 crossorigin="anonymous" 属性:
html
<!-- 所有跨域的 script 标签 -->
<script
src="https://cdn.example.com/static/polyfill.min.js"
defer
crossorigin="anonymous"
></script>
<!-- 所有跨域的 link 标签(CSS) -->
<link
rel="stylesheet"
href="https://cdn.example.com/assets/iconfont.css"
crossorigin="anonymous"
/>
打包工具层:Webpack/Rspack 插件自动处理(构建时)
对于构建工具自动插入的脚本和样式,我们创建了一个自定义插件:
ts
class CrossOriginAssetsPlugin {
apply(compiler) {
const pluginName = 'CrossOriginAssetsPlugin';
compiler.hooks.compilation.tap(pluginName, (compilation) => {
const HtmlWebpackPlugin = require('html-webpack-plugin');
if (HtmlWebpackPlugin && HtmlWebpackPlugin.getHooks) {
HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tap(
pluginName,
data => {
// 处理 script
data.assetTags.scripts.forEach(tag => {
if (isCrossOrigin(tag.attributes?.src)) {
tag.attributes.crossorigin = 'anonymous';
}
});
// 处理 link
data.assetTags.styles.forEach(tag => {
if (isCrossOrigin(tag.attributes?.href)) {
tag.attributes.crossorigin = 'anonymous';
}
});
return data;
}
);
}
});
}
}
function isCrossOrigin(url) {
// 补充判断逻辑:绝对路径跨域、相对路径同源
return url && /^https?:///.test(url);
}
方案三:运行时全局拦截(动态加载)
在业务开发中我们会遇到许多异步动态加载的脚本文件,通过拦截 document.createElement,为所有动态 script/link 节点设置跨域属性,无死角覆盖 chunk、第三方库等异步加载场景。
ts
function isCrossOriginUrl(url: string | null | undefined): boolean {
return !!url && /^https?:///.test(url);
}
export function setScriptCrossOrigin(script: HTMLScriptElement) {
const src = script.src || script.getAttribute('src');
if (isCrossOriginUrl(src)) script.crossOrigin = 'anonymous';
}
export function setLinkCrossOrigin(link: HTMLLinkElement) {
const href = link.href || link.getAttribute('href');
if (isCrossOriginUrl(href)) link.crossOrigin = 'anonymous';
}
export function initCrossOriginScriptHandler() {
const originCreateElement = document.createElement.bind(document);
document.createElement = function (tagName: string, options?: ElementCreationOptions) {
const el = originCreateElement(tagName, options);
if (tagName.toLowerCase() === 'script') {
// 当 src 插入时才设置
const observer = new MutationObserver(mutations => {
mutations.forEach(m => {
if (m.type === 'attributes' && m.attributeName === 'src') {
setScriptCrossOrigin(el as HTMLScriptElement);
observer.disconnect();
}
});
});
observer.observe(el, { attributes: true, attributeFilter: ['src'] });
}
if (tagName.toLowerCase() === 'link') {
const observer = new MutationObserver(mutations => {
mutations.forEach(m => {
if (m.type === 'attributes' && m.attributeName === 'href') {
setLinkCrossOrigin(el as HTMLLinkElement);
observer.disconnect();
}
});
});
observer.observe(el, { attributes: true, attributeFilter: ['href'] });
}
return el;
};
}
在应用启动处调用初始化:
为什么这个方案可以覆盖所有场景?
Webpack/Rspack 在运行时加载 chunk 时,会生成类似这样的代码:
js
// Webpack 生成的 chunk 加载代码
function loadChunk(chunkId) {
return new Promise((resolve, reject) => {
const script = document.createElement('script'); // ← 关键
script.src = chunkUrl;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
通过拦截 document.createElement,我们可以捕获所有通过此方式创建的 script 标签,包括:
- React.lazy 懒加载的组件
- 动态 import() 加载的模块
- Webpack chunk 动态加载
- 第三方库的动态加载
- 手动通过
loadScript()加载的脚本
总结
通过这套三层防护的解决方案,我们成功解决了多域名架构下的跨域 Script Error 问题:
- HTML 模板层 :手动为静态资源添加
crossorigin属性 - 构建时处理层:Webpack 插件自动处理构建时插入的资源
- 运行时拦截层 :全局拦截
document.createElement,处理所有动态加载
如果这篇文章对你有帮助,欢迎点赞和收藏! 🎉