一种简单实用的 JS 动态加载方案

6,797 阅读3分钟

背景

在做 Web 应用的时候,你或许遇到过这样的场景:为了实现一个使用率很低的功能,却引入了超大的第三方库,导致项目打包后的 JS bundle 体积急剧膨胀。

我们有一些具体的案例,例如:

产品要求在项目中增加一个导出数据为 Excel 文件的功能,这个功能其实只有管理员才能看到,而且最多一周才会使用一次,绝对属于低频操作。

团队里的小伙伴为了实现这个功能,引入了 XLSX 这个库,JS bundle 体积因而增加了一倍,所有用户的体验都受到影响了。

XLSX 用来做 Excel 相关的操作是不错的选择,但因为新增低频操作影响全部用户却不值得。

除了导出 Excel 这种功能外,类似的场景还有使用 html2canvas 生成并下载海报,使用 fabric 动态生成图片等。

针对这种情况,你觉得该如何优化呢?

自动分包和动态加载

机智如你很快就想到使用 JS 动态加载,如果熟悉 React,还知道可以使用 react-loadable 来解决。

原理就是利用 React Code-Splitting,配合 Webpack 自动分包,动态加载。

这种方案可以,React 也推荐这么做,但是对于引用独立的第三方库这样的场景,还有更简单的方案。

更简单的方案

这些第三方库往往都提供了 umd 格式的 min.js,我们动态加载这些 min.js 就可以了。比如 XLSX,引入其 min.js 文件之后,就可以通过 window.XLSX 来实现 Excel 相关的操作。

此方案的优点有:

  • 与框架无关,不需要和 React 等框架或 Webpack 等工具绑定
  • 精细控制,React Code-Splitting 之类的方案只能到模块级别,想要在点击按钮后才动态加载较难实现

具体实现

我们重点需要实现一个 JS 动态加载器 AsyncLoader,代码如下:

function initLoader() {
  // key 是对应 JS 执行后在 window 中添加的变量
  const jsUrls = {
    html2canvas: 'https://cdn.jsdelivr.net/npm/html2canvas@1.0.0-rc.7/dist/html2canvas.min.js',
    XLSX: 'https://cdn.jsdelivr.net/npm/xlsx@0.16.9/dist/xlsx.min.js',
    flvjs: 'https://cdn.jsdelivr.net/npm/flv.js@1.5.0/dist/flv.min.js',
    domtoimage: 'https://cdn.jsdelivr.net/npm/dom-to-image@2.6.0/src/dom-to-image.min.js',
    fabric: 'https://cdn.jsdelivr.net/npm/fabric@4.3.1/dist/fabric.min.js',
  };

  const loadScript = (src) => {
    return new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.type = 'text/javascript';
      script.onload = resolve;
      script.onerror = reject;
      script.crossOrigin = 'anonymous';
      script.src = src;
      if (document.head.append) {
        document.head.append(script);
      } else {
        document.getElementsByTagName('head')[0].appendChild(script);
      }
    });
  };

  const loadByKey = (key) => {
    // 判断对应变量在 window 是否存在,如果存在说明已加载,直接返回,这样可以避免多次重复加载
    if (window[key]) {
      return Promise.resolve();
    } else {
      if (Array.isArray(jsUrls[key])) {
        return Promise.all(jsUrls[key].map(loadScript));
      }
      return loadScript(jsUrls[key]);
    }
  };

  // 定义这些方法只是为了方便使用,其实 loadByKey 就够了。
  const loadHtml2Canvas = () => {
    return loadByKey('html2canvas');
  };

  const loadXlsx = () => {
    return loadByKey('XLSX');
  };

  const loadFlvjs = () => {
    return loadByKey('flvjs');
  };

  window.AsyncLoader = {
    loadScript,
    loadByKey,
    loadHtml2Canvas,
    loadXlsx,
    loadFlvjs,
  };
}

initLoader();

使用方式

以 XLSX 为例,使用这种方式之后,我们不需要在顶部 import xlsx from 'xlsx',只有当用户点击 导出Excel 按钮的时候,才从 CDN 动态加载 xlsx.min.js,加载成功后使用 window.XLSX 即可,代码如下:

await window.AsyncLoader.loadXlsx().then(() => {
  const XLSX = window.XLSX;
  if (resp.data.signList && resp.data.signList.length > 0) {
    const new_workbook = XLSX.utils.book_new();

    resp.data.signList.map((item) => {
      const header = ['班级/学校/单位', '姓名', '帐号', '签到时间'];
      const { signRecords } = item;
      signRecords.unshift(header);

      const worksheet = XLSX.utils.aoa_to_sheet(signRecords);
      XLSX.utils.book_append_sheet(new_workbook, worksheet, item.signName);
    });

    XLSX.writeFile(new_workbook, `${resp.data.fileName}.xlsx`);
  } else {
    const new_workbook = XLSX.utils.book_new();
    const header = [['班级/学校/单位', '姓名', '帐号']];
    const worksheet = XLSX.utils.aoa_to_sheet(header);
    XLSX.utils.book_append_sheet(new_workbook, worksheet, '');
    XLSX.writeFile(new_workbook, `${resp.data.fileName}.xlsx`);
  }
});

另一个动态加载 domtoimage 的示例:

window.CommonJsLoader.loadByKey('domtoimage').then(() => {
  const scale = 2;
  window.domtoimage
    .toPng(poster, {
      height: poster.offsetHeight * scale,
      width: poster.offsetWidth * scale,
      style: {
        zoom: 1,
        transform: `scale(${scale})`,
        transformOrigin: 'top left',
        width: `${poster.offsetWidth}px`,
        height: `${poster.offsetHeight}px`,
      },
    })
    .then((dataUrl) => {
      copyImage(dataUrl, liveData?.planName);
      message.success(`${navigator.clipboard ? '复制' : '下载'}成功`);
    });
});

AsyncLoader 方案使用方便、理解简单,而且可以很好地利用 CDN 缓存,多个项目可以共用同样的 URL,进一步提高加载速度。而且这种方式使用的是原生 JS,在任何框架中都可以使用。

注意,如果你用 TypeScript 开发,这种方案或许会丢失一些智能提示,如果引入了对应的 @types/xxx 应该没影响。如果你特别在意开发时的智能提示,也可以在开发的过程中 import 对应的包,开发完成后才换成 AsyncLoader 方案。