背景
笔者参与开发的前端性能监控平台使用了OpenTelemetry作为监控日志的标准协议。而在使用过程中发现该协议提供的库中使用了较新的 ES6
特性,在低版本浏览器中不支持,触发了ReferenceError。从而中断了主线程,引发严重的页面白屏问题。
尝试解决
模拟问题
经过排查,发现是引用的OpenTelemetry
里使用了较新的 ES6
特性,该特性在版本较老的浏览器中不支持。为了复现问题,在sdk
中创建一个不存在的类实例:
js
// mock error
const mockError = new NotExistFeature();
class WebPerformanceSDK {
constructor(callback: ReportCallback, config: SDKConfig = {}) {
this.reportCallback = callback;
this.config = config;
}
public init(): void {
try {
...
} catch (error) {
...
}
}
}
效果如下:
代码报了 ReferenceError
异常,并且中断了后续脚本执行,引发了页面白屏。
init时加try-catch
既然是sdk
初始化引发的问题,自然而然想到的是在应用系统中给 sdk
添加 try-catch
防止异常影响到应用代码的执行。
文中的代码均为脱敏后的demo代码
js
import { useState, useEffect, useRef } from "react";
import "./App.css";
import WebPerformanceSDK from "../../dist/index.esm.js";
...
const init = useRef<boolean>(false);
useEffect(() => {
if (init.current) {
return;
}
init.current = true;
try {
const sdk = new WebPerformanceSDK(
(metric: { name: string; value: number }) => {
setMetrics((prev) => [
...prev,
{ name: metric.name, value: metric.value },
]);
},
{ debug: true }
);
sdk.init();
} catch (e: any) {
console.error(e.message);
}
}, []);
...
重新构建后再运行项目...依然白屏!
通过打印日志发现代码压根没有执行到 init()
,在import WebPerformanceSDK from "../../dist/index.esm.js
时就已经出现问题了:
为了证明是 import 代码的问题,使用 import() 动态导入catch中间发生的异常
js
import("../../dist/index.esm.js")
.then((sdk) => {
console.log("sdk", sdk);
})
.catch((e: any) => {
console.error("import() error: ", e.message);
});
异常日志:
问下 deepseek import
一个模块时会发生什么:
- 加载
index.esm.js
后,会 立即执行其顶层代码(如变量初始化、函数调用)。- 若该文件包含
export
语句,导出的内容会绑定到模块命名空间,供外部调用。例如,若index.js
导出export default function() {}
,导入方可通过import func from './index.js'
获取该函数。
好吧,怪不得在init
方法里加try-catch
没用,原来异常在import
的时候就已经发生了。
对于应用侧来讲,上面例子里用到import()
就可以作为临时解决方案,在import().then(...)
中完成sdk的初始化。
但是对于sdk方来肯定不能偷懒说建议大家用动态加载的方式,那怎么办呢?
这里也不卖关子了,因为想让接入方尽可能简单而安全地使用sdk,所以需要在sdk内部添加全局的try-catch
,提供代码的健壮性。
使用rollup添加try-catch
为产物添加try-catch
还是得借助构建器的能力,我们的sdk使用的是rollup
作为构建工具,rollup
提供了输出插件选项,支持开发者自定义plugin
去修改产物内容。
既然官方提供了这样一个入口,肯定有相应的plugin
,所以先去社区寻找相应的rollup
插件。找了一圈后还真的找到了一个npm包: @rollup/plugin-babel
@rollup/plugin-babel
exposes a plugin-builder utility that allows users to add custom handling of Babel's configuration for each file that it processes.
createBabelInputPluginFactory
accepts a callback that will be called with the loader's instance ofbabel
so that tooling can ensure that it using exactly the same@babel/core
instance as the loader itself.It's main purpose is to allow other tools for configuration of transpilation without forcing people to add extra configuration but still allow for using their own babelrc / babel config files.
官方文档里只提供了 createBabelInputPluginFactory
方法的说明,但是这个包还提供了 createBabelOutputPluginFactory
方法,这个 Output 方法的用法跟 Input 是完全一样的。
createBabelOutputPluginFactory
添加try-catch
为了添加全局try-catch,需要把sdk的初始化在内部提前完成,也就是说需要创建一个 IIFE,让sdk代码在IIFE中执行,然后return待导出的WebPerformanceSDK
类。
export
改为 return
为什么要做这样的修改?
上文说到我们将生成的code用IIFE包裹了起来,让其成为一个函数执行。因此需要在拿到IIFE的结果后,再自主export,导出真正的class。
原产物code:
js
var WebPerformanceSDK = /** @class */ (function () {
function WebPerformanceSDK(callback, config) {
if (config === void 0) { config = {}; }
this.reportCallback = callback;
this.config = config;
}
WebPerformanceSDK.prototype.handleError = function (error, context) {
...
};
WebPerformanceSDK.prototype.safeCallback = function (metric) {
...
};
WebPerformanceSDK.prototype.init = function () {
try {
this.collectWebVitals();
this.collectResourceTiming();
this.collectNavigationTiming();
}
catch (error) {
this.handleError(error, "init");
}
};
WebPerformanceSDK.prototype.collectWebVitals = function () {
...
};
WebPerformanceSDK.prototype.collectResourceTiming = function () {
...
};
WebPerformanceSDK.prototype.collectNavigationTiming = function () {
...
}
catch (error) {
this.handleError(error, "collectNavigationTiming");
}
};
return WebPerformanceSDK;
}());
export { WebPerformanceSDK as default };
可以发现其导出语句为 export { WebPerformanceSDK as default }
。可以直接采用字符串替换的方式将其改为 return WebPerformanceSDK
。
在catch中mock WebPerformanceSDK
要完整的try-catch还需要在catch中mock 出一个空的 WebPerformanceSDK
,否则当导入sdk时,会出现 TypeError: WebPerformanceSDK is not a constructor
异常。mock代码其实就是在初步产物的基础上裁剪掉业务代码,只保留一个个空的Function。
js
...
} catch (e) {
console.warn('SDK 初始化异常,已启动mock逻辑', e.message);// 打印警告提示
var WebPerformanceSDK = (function () {
function WebPerformanceSDK(callback, config) {
if (config === void 0) {
config = {};
}
this.reportCallback = callback;
this.config = config;
}
WebPerformanceSDK.prototype.handleError = function (error, context) {
if (this.config.debug) {
console.error(
"[WebPerformanceSDK] Error in ".concat(context, ":"),
error
);
}
};
WebPerformanceSDK.prototype.safeCallback = function (metric) {
try {
this.reportCallback(metric);
} catch (error) {
this.handleError(error, "reportCallback");
}
};
WebPerformanceSDK.prototype.init = function () {
try {
this.collectWebVitals();
this.collectResourceTiming();
this.collectNavigationTiming();
} catch (error) {
this.handleError(error, "init");
}
};
WebPerformanceSDK.prototype.collectWebVitals = function () {};
WebPerformanceSDK.prototype.collectResourceTiming = function () {};
WebPerformanceSDK.prototype.collectNavigationTiming = function () {};
return WebPerformanceSDK;
})();
return WebPerformanceSDK;
}
plugin完整实现
这里贴出plugin
的完整实现:
js
// rollup-try-catch-babel.mjs
import { createBabelOutputPluginFactory } from "@rollup/plugin-babel";
// @desc: https://github.com/rollup/plugins/tree/master/packages/babel
export const babelOutputPluginWithESM = createBabelOutputPluginFactory(() => {
function customPlugin() {
return {
visitor: {},
};
}
// 添加try-catch,防止sdk初始化异常影响主线程
function addTryCatch(code) {
code = code.replace(
"export { WebPerformanceSDK as default }",
"return WebPerformanceSDK"
);
return `const result = (function () {
try {
${code}
} catch (e) {
console.warn('SDK 初始化异常,已启动mock逻辑', e.message);// 打印警告提示
var WebPerformanceSDK = (function () {
function WebPerformanceSDK(callback, config) {
if (config === void 0) {
config = {};
}
this.reportCallback = callback;
this.config = config;
}
WebPerformanceSDK.prototype.handleError = function (error, context) {
if (this.config.debug) {
console.error(
"[WebPerformanceSDK] Error in ".concat(context, ":"),
error
);
}
};
WebPerformanceSDK.prototype.safeCallback = function (metric) {
try {
this.reportCallback(metric);
} catch (error) {
this.handleError(error, "reportCallback");
}
};
WebPerformanceSDK.prototype.init = function () {
try {
this.collectWebVitals();
this.collectResourceTiming();
this.collectNavigationTiming();
} catch (error) {
this.handleError(error, "init");
}
};
WebPerformanceSDK.prototype.collectWebVitals = function () {};
WebPerformanceSDK.prototype.collectResourceTiming = function () {};
WebPerformanceSDK.prototype.collectNavigationTiming = function () {};
return WebPerformanceSDK;
})();
return WebPerformanceSDK;
}
})();
export { result as default };
`;
}
return {
// Passed the plugin options.
options({ opt1, opt2, ...pluginOptions }) {
return {
// Pull out any custom options that the plugin might have.
customOptions: { opt1, opt2 },
// Pass the options back with the two custom options removed.
pluginOptions,
};
},
config(
cfg /* Passed Babel's 'PartialConfig' object. */,
{ code, customOptions }
) {
if (cfg.hasFilesystemConfig()) {
// Use the normal config
return cfg.options;
}
return {
...cfg.options,
plugins: [
...(cfg.options.plugins || []),
// Include a custom plugin in the options.
customPlugin,
],
};
},
result(result, { code, customOptions, config, transformOptions }) {
return {
...result,
// 使用 addTryCatch 方法为code添加全局的try-catch
code: addTryCatch(result.code),
};
},
};
});
使用plugin
使用起来也很简单,在rollup.config.mjs里配置一下就可以:
js
import typescript from "@rollup/plugin-typescript";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import { babelOutputPluginWithESM } from "./plugins/rollup-try-catch-babel.mjs";
export default {
input: "src/index.ts",
output: [
{
file: "dist/index.esm.js",
format: "es",
sourcemap: true,
plugins: [babelOutputPluginWithESM()],
},
],
plugins: [
resolve(),
commonjs(),
typescript({
tsconfig: "./tsconfig.json",
declaration: true,
declarationDir: "./dist",
}),
],
};
使用效果:
下图是最终构建产物代码,可以发现已经加上了 try-catch
:\
在项目中引用新的sdk代码,可以页面的渲染已经不受sdk的异常影响了,可以正常执行:\
总结
- 开发js-sdk需要有完备的错误捕获机制,在高危操作外部,甚至sdk初始化方法里都加上异常捕获逻辑,保证sdk的异常不影响到业务系统(本文解决方案属于亡羊补牢,最好的时机仍然是问题没发生前);
rollup
产物支持通过自定义的plugin
去修改,本文基于@rollup/plugin-babel
实现了自定义的rollup-try-catch-babel
plugin;- 修改打包产物应该是克制且谨慎的,因为这会造成打包产物与业务代码不一致问题,容易引起其他开发同学不必要的困惑。