前端監(jiān)控上報(bào):Script Error問(wèn)題的解決方法
前言
在微前端和多國(guó)業(yè)務(wù)的場(chǎng)景下,我們經(jīng)常會(huì)遇到 HTML 頁(yè)面域名和靜態(tài)資源域名不統(tǒng)一的情況。這種架構(gòu)雖然帶來(lái)了部署和 CDN 優(yōu)化的便利,但也引入了一個(gè)常見(jiàn)的問(wèn)題:跨域 Script Error。本文將詳細(xì)介紹這個(gè)問(wèn)題的原因、影響,以及一套完整的解決方案。
問(wèn)題背景
業(yè)務(wù)場(chǎng)景
我們的項(xiàng)目是一個(gè)多國(guó)業(yè)務(wù)的前端應(yīng)用,采用了以下架構(gòu):
- HTML 頁(yè)面域名:每個(gè)地區(qū)/業(yè)務(wù)線使用不同主域名
- 例如:
countryA-web.example.com,countryB-web.example.com, ...
- 例如:
- 靜態(tài)資源 CDN 域名:統(tǒng)一使用一個(gè)公共 CDN 地址
- 例如:
cdn.example.com
問(wèn)題表現(xiàn)
在這種架構(gòu)下,我們遇到了以下問(wèn)題:
監(jiān)控上報(bào)的 JS 錯(cuò)誤全部顯示為 "Script Error"
- 無(wú)法獲取詳細(xì)的錯(cuò)誤堆棧信息
- 無(wú)法定位具體的錯(cuò)誤位置
- SourceMap 無(wú)法正確映射
錯(cuò)誤信息丟失
- 錯(cuò)誤消息被瀏覽器隱藏
- 無(wú)法獲取錯(cuò)誤發(fā)生的文件、行號(hào)、列號(hào)
- 調(diào)試和問(wèn)題排查變得困難
問(wèn)題原因分析
1. 瀏覽器的同源策略
當(dāng)腳本從不同源加載時(shí),瀏覽器會(huì)應(yīng)用同源策略(Same-Origin Policy)的安全機(jī)制:
- 如果腳本發(fā)生錯(cuò)誤,且腳本的源與頁(yè)面不同,瀏覽器會(huì)隱藏錯(cuò)誤的詳細(xì)信息
- 只返回通用的 "Script Error" 消息
- 這是為了防止惡意網(wǎng)站通過(guò)錯(cuò)誤信息獲取敏感數(shù)據(jù)
2. 缺少 CORS 配置
要獲取跨域腳本的詳細(xì)錯(cuò)誤信息,需要滿足兩個(gè)條件:
- 腳本標(biāo)簽添加
crossorigin屬性 - 服務(wù)器返回正確的 CORS 響應(yīng)頭
當(dāng)你在 <script> 或 <link> 標(biāo)簽上添加 crossorigin="anonymous",實(shí)際上是在告訴瀏覽器:“允許以跨源模式拉取這個(gè)資源,并且無(wú)須附帶任何憑證(cookie、鑒權(quán)頭等) 。”
如果該標(biāo)簽沒(méi)有 crossorigin 屬性,瀏覽器以默認(rèn)的“no-cors”模式加載資源,只要腳本跨域,一旦腳本發(fā)生錯(cuò)誤,錯(cuò)誤信息就會(huì)被隱藏,只能看到 Script Error,同時(shí) sourceMap 也無(wú)法正確還原堆棧。
只有當(dāng) crossorigin 屬性為 anonymous 并且服務(wù)器響應(yīng)了正確的 CORS 頭,瀏覽器才會(huì)呈現(xiàn)完整的報(bào)錯(cuò)信息和堆棧文件行號(hào),對(duì)異常監(jiān)控和調(diào)試至關(guān)重要。
與此同時(shí),服務(wù)器/CDN 響應(yīng)也必須包含正確的 CORS 相關(guān)頭部,如 Access-Control-Allow-Origin。
這是因?yàn)闉g覽器在跨域加載資源時(shí),會(huì)先根據(jù)標(biāo)簽屬性判斷是否允許訪問(wèn)細(xì)節(jié),再檢查服務(wù)器是不是“回應(yīng)放行”了該跨域請(qǐng)求。如果服務(wù)器未設(shè)置這些頭部,哪怕你加了 crossorigin 屬性,瀏覽器也會(huì)隱藏資源細(xì)節(jié)和所有報(bào)錯(cuò)內(nèi)容,僅顯示 Script Error。
如果缺少其中任何一個(gè),瀏覽器都會(huì)隱藏錯(cuò)誤詳情。
3. Webpack/Rspack 動(dòng)態(tài)加載機(jī)制
現(xiàn)代前端構(gòu)建工具(Webpack、Rspack)在實(shí)現(xiàn)代碼分割和懶加載時(shí),會(huì)通過(guò) document.createElement('script') 動(dòng)態(tài)創(chuàng)建 script 標(biāo)簽來(lái)加載 chunk。如果這些動(dòng)態(tài)創(chuàng)建的標(biāo)簽沒(méi)有 crossorigin 屬性,同樣會(huì)導(dǎo)致 Script Error。
解決方案
我們采用了一套三層防護(hù)的解決方案:
方案架構(gòu)圖
解決方案架構(gòu)
1. HTML 模板層
手動(dòng)添加 crossorigin 屬性
2. 構(gòu)建時(shí)處理層
Webpack 插件自動(dòng)添加
3. 運(yùn)行時(shí)攔截層
攔截 createElement 全局處理
HTML層:模板引用資源手動(dòng)修改
對(duì)于 HTML 模板中直接引用的靜態(tài)資源,我們手動(dòng)添加 crossorigin="anonymous" 屬性:
<!-- 所有跨域的 script 標(biāo)簽 --> <script src="https://cdn.example.com/static/polyfill.min.js" defer crossorigin="anonymous" ></script> <!-- 所有跨域的 link 標(biāo)簽(CSS) --> <link rel="stylesheet" rel="external nofollow" crossorigin="anonymous" />
打包工具層:Webpack/Rspack 插件自動(dòng)處理(構(gòu)建時(shí))
對(duì)于構(gòu)建工具自動(dòng)插入的腳本和樣式,我們創(chuàng)建了一個(gè)自定義插件:
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) {
// 補(bǔ)充判斷邏輯:絕對(duì)路徑跨域、相對(duì)路徑同源
return url && /^https?:///.test(url);
}
異步加載層:運(yùn)行時(shí)全局?jǐn)r截(動(dòng)態(tài)加載)
在業(yè)務(wù)開(kāi)發(fā)中我們會(huì)遇到許多異步動(dòng)態(tài)加載的腳本文件,通過(guò)攔截 document.createElement,為所有動(dòng)態(tài) script/link 節(jié)點(diǎn)設(shè)置跨域?qū)傩?,無(wú)死角覆蓋 chunk、第三方庫(kù)等異步加載場(chǎng)景。
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') {
// 當(dāng) src 插入時(shí)才設(shè)置
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;
};
}
在應(yīng)用啟動(dòng)處調(diào)用初始化:
為什么這個(gè)方案可以覆蓋所有場(chǎng)景?
Webpack/Rspack 在運(yùn)行時(shí)加載 chunk 時(shí),會(huì)生成類似這樣的代碼:
// Webpack 生成的 chunk 加載代碼
function loadChunk(chunkId) {
return new Promise((resolve, reject) => {
const script = document.createElement('script'); // ← 關(guān)鍵
script.src = chunkUrl;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
通過(guò)攔截 document.createElement,我們可以捕獲所有通過(guò)此方式創(chuàng)建的 script 標(biāo)簽,包括:
- React.lazy 懶加載的組件
- 動(dòng)態(tài) import() 加載的模塊
- Webpack chunk 動(dòng)態(tài)加載
- 第三方庫(kù)的動(dòng)態(tài)加載
- 手動(dòng)通過(guò)
loadScript()加載的腳本
服務(wù)器端配置
后端需確保 CDN/stastic 資源的 HTTP 響應(yīng)頭允許跨源:
Nginx 配置樣例(脫敏)
location /static/ {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Range' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length, Content-Range' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
總結(jié)
通過(guò)這套三層防護(hù)的解決方案,我們成功解決了多域名架構(gòu)下的跨域 Script Error 問(wèn)題:
- HTML層:手動(dòng)為靜態(tài)資源添加
crossorigin屬性 - 打包工具層:Webpack 插件自動(dòng)處理構(gòu)建時(shí)插入的資源
- 異步加載層:全局?jǐn)r截
document.createElement,處理所有動(dòng)態(tài)加載
以上就是前端監(jiān)控上報(bào):Script Error問(wèn)題的解決方法的詳細(xì)內(nèi)容,更多關(guān)于前端監(jiān)控上報(bào):Script Error的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
微信小程序?qū)崿F(xiàn)翻牌抽獎(jiǎng)動(dòng)畫
這篇文章主要為大家詳細(xì)介紹了微信小程序?qū)崿F(xiàn)翻牌抽獎(jiǎng)動(dòng)畫,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-09-09
JavaScript實(shí)現(xiàn)二叉搜索樹(shù)
這篇文章主要為大家詳細(xì)介紹了JavaScript實(shí)現(xiàn)二叉搜索樹(shù),文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-03-03
layui 地區(qū)三級(jí)聯(lián)動(dòng) form select 渲染的實(shí)例
今天小編就為大家分享一篇layui 地區(qū)三級(jí)聯(lián)動(dòng) form select 渲染的實(shí)例,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2019-09-09
現(xiàn)代 JavaScript 開(kāi)發(fā)編程風(fēng)格Idiomatic.js指南中文版
下面的章節(jié)描述的是一個(gè) 合理 的現(xiàn)代 JavaScript 開(kāi)發(fā)風(fēng)格指南,并非硬性規(guī)定。其想送出的核心理念是高度統(tǒng)一的代碼風(fēng)格(the law of code style consistency)。2014-05-05
利用js的Node遍歷找到repeater的一個(gè)字段實(shí)例介紹
本文教大家使用js的Node遍歷找到repeater的一個(gè)字段的具體實(shí)現(xiàn)思路,感興趣的朋友可參考下,希望可以幫助到你2013-04-04
如何基于webpack創(chuàng)建plugin并發(fā)布npm包
webpack 插件是一個(gè)具有 apply 方法的 JavaScript 對(duì)象,apply 方法會(huì)被 webpack compiler 調(diào)用,并且在 整個(gè)編譯生命周期都可以訪問(wèn) compiler 對(duì)象,這篇文章主要介紹了基于webpack創(chuàng)建plugin并發(fā)布npm包,需要的朋友可以參考下2024-07-07

