rollup进阶——为产物添加全局try-catch

背景

笔者参与开发的前端性能监控平台使用了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 of babel 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的异常影响了,可以正常执行:\

总结

  1. 开发js-sdk需要有完备的错误捕获机制,在高危操作外部,甚至sdk初始化方法里都加上异常捕获逻辑,保证sdk的异常不影响到业务系统(本文解决方案属于亡羊补牢,最好的时机仍然是问题没发生前);
  2. rollup产物支持通过自定义的plugin去修改,本文基于 @rollup/plugin-babel 实现了自定义的 rollup-try-catch-babel plugin;
  3. 修改打包产物应该是克制且谨慎的,因为这会造成打包产物与业务代码不一致问题,容易引起其他开发同学不必要的困惑。
相关推荐
纪元A梦13 小时前
Redis最佳实践——性能优化技巧之Pipeline 批量操作
数据库·redis·性能优化
从零开始学习人工智能16 小时前
支持向量机(SVM):解锁数据分类与回归的强大工具
人工智能·性能优化·开源
七灵微18 小时前
【前端】性能优化篇
前端·性能优化
小小工匠18 小时前
性能优化 - 工具篇:基准测试 JMH
性能优化·基准测试 jmh
小小工匠18 小时前
性能优化 - 案例篇:缓存_Guava#LoadingCache设计
缓存·性能优化
heart000_12 天前
MySQL索引与性能优化入门:让查询提速的秘密武器【MySQL系列】
数据库·mysql·性能优化
厚衣服_32 天前
第十五篇:MySQL 高级实战项目:构建高可用、可观测、性能优化一体化数据库平台
数据库·mysql·性能优化
DemonAvenger2 天前
Go内存逃逸分析:优化堆内存分配的技术文章
性能优化·架构·go
EndingCoder2 天前
React从基础入门到高级实战:React 高级主题 - 性能优化:深入探索与实践指南
前端·javascript·react.js·性能优化·前端框架