解决前端监控上报 Script Error实践

前言

在微前端和多国业务的场景下,我们经常会遇到 HTML 页面域名和静态资源域名不统一的情况。这种架构虽然带来了部署和 CDN 优化的便利,但也引入了一个常见的问题:跨域 Script Error。本文将详细介绍这个问题的原因、影响,以及一套完整的解决方案。

问题背景

业务场景

我们的项目是一个多国业务的前端应用,采用了以下架构:

  • HTML 页面域名 :每个地区/业务线使用不同主域名
    • 例如:countryA-web.example.com, countryB-web.example.com, ...
    • 静态资源 CDN 域名:统一使用一个公共 CDN 地址
    • 例如:cdn.example.com

问题表现

在这种架构下,我们遇到了以下问题:

  1. 监控上报的 JS 错误全部显示为 "Script Error"

    • 无法获取详细的错误堆栈信息
    • 无法定位具体的错误位置
    • SourceMap 无法正确映射
  2. 错误信息丢失

    • 错误消息被浏览器隐藏
    • 无法获取错误发生的文件、行号、列号
    • 调试和问题排查变得困难

问题原因分析

1. 浏览器的同源策略

当脚本从不同源加载时,浏览器会应用**同源策略(Same-Origin Policy)**的安全机制:

  • 如果脚本发生错误,且脚本的源与页面不同,浏览器会隐藏错误的详细信息
  • 只返回通用的 "Script Error" 消息
  • 这是为了防止恶意网站通过错误信息获取敏感数据

2. 缺少 CORS 配置

要获取跨域脚本的详细错误信息,需要满足两个条件:

  1. 脚本标签添加 crossorigin 属性
  2. 服务器返回正确的 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 问题:

  1. HTML 模板层 :手动为静态资源添加 crossorigin 属性
  2. 构建时处理层:Webpack 插件自动处理构建时插入的资源
  3. 运行时拦截层 :全局拦截 document.createElement,处理所有动态加载

如果这篇文章对你有帮助,欢迎点赞和收藏! 🎉

相关推荐
星空的资源小屋1 小时前
Explorer++:更强大的Windows文件管理器
javascript·人工智能·django·电脑
JarvanMo1 小时前
如何在 Flutter 应用中大规模实现多语言翻译并妥善处理 RTL(从右到左)布局?
前端
Haha_bj1 小时前
iOS深入理解事件传递及响应
前端·ios·app
1024小神1 小时前
用html和css实现放苹果的liquidGlass效果
前端
拜晨1 小时前
CG-01: 深入理解 2D 变换的数学原理
前端
im_AMBER1 小时前
Canvas架构手记 07 状态管理 | 组件通信 | 控制反转
前端·笔记·学习·架构·前端框架·react
JarvanMo1 小时前
理解 Flutter 中的 runApp() 与异步初始化
前端
掘金安东尼1 小时前
🧭 前端周刊第442期(24–30 Nov 2025)
前端
h***8561 小时前
Rust在Web中的前端开发
开发语言·前端·rust