webpack实战:将一个库的加载速度提升四倍

359 阅读4分钟

项目背景

该项目是一个公司内部使用的库,使用webpack进行打包。(webpack版本: 5.40.0)以往是使用npm的形式进行包管理,但由于项目打包后的体积较大,影响页面的加载速度,于是决定放弃使用npm包管理,将其打包成js文件,通过script标签进行引用。 对于npm包及js文件,各有其优劣势。

类型优点缺点
npm包便于做版本管理;引用方式简单包体积过大时无法拆分;难以做加载优化
js文件便于拆分模块,做加载优化;引用灵活性高版本管理比较繁琐;调用者需要选择合适的时机加载js文件

由于是内部使用的包,并且对加载性能要求更高,所以可以使用js的方式

优化步骤

一、动态导入部分模块

将项目中一些并非一定用到并且体积相对较大的模块,采用webpack中动态导入的方式引用,从而减少首次加载的包体积。 例如修改前:

import module1 from './module1';

修改后:

// 此处即将要用到module1对象
const module1 = await import(
   /* webpackChunkName: "module1" */ './module1'
);

这样webpack会将module1打包成一个独立的js文件。只有在你引入的时候才会去加载该文件。

webpack同时也支持预获取模块,就是在浏览器空闲的时候加载,而不是在你引入的时候就加载。这其实用到了<link>标签rel属性所支持的preloadprefetch值。

二、模块拆分

上一种方法只适用于并非立即会使用到的模块,但对于一些核心模块就无法使用该方法。例如我们项目中适用了three.js,该模块的体积也比较大,可以通过webpack配置optimization将其打包成单独的js文件,

// ...
optimization: {
    // ...
    splitChunks: {
      cacheGroups: {
        three: {
          test: /[\\/]node_modules[\\/](three)[\\/]/,
          name: 'three',
          chunks: 'all',
          priority: 2,
        },
      },
    },
  },

其它体积较大的模块也可以分离出来。

三、修改模块导出方式

前面两个步骤主要是对模块拆分,目的是为了实现延迟加载和并行加载,这基本就能大幅提升加载速度

现在我们需要修改一下模块的导出方式。采用npm包管理的时候实际上打包后是一个CommonJS module,即内部使用module.exports = myLibrary导出,然后使用require('myLibrary')方式引入。现在打包成一个js文件在浏览器直接引入,所以需要将myLibrary暴露到全局对象下。(当然也可以使用AMD或其它浏览器支持的模块语法,但目前没使用这些方式),将webpack的output.library.typecommonjs改为umd:

library: {
  name: 'myLibrary',
  type: 'umd',
},

四、部署js文件

我们需要把打包出来的产物部署到静态服务器。 配置webpack的publicPath:

// ...
output: {
  path: path.resolve(__dirname, 'dist'),
  publicPath: `https://cdn.xxx.com/your/server/path`,
},

每次打包后将dist目录上传到服务器路径https://cdn.xxx.com/your/server/path下。我们目前是使用gitlab的CI/CD完成自动化部署。

五、使用新的方式调用

使用动态加载script的方式,以下是加载函数:

function loadScript(src) {
  return new Promise((resolve, reject) => {
    const scriptEle = document.createElement('script');
    scriptEle.type = 'text/javascript';
    // 动态脚本async默认为true, 设为false才能按顺序执行(并行加载)
    scriptEle.async = false;

    if (scriptEle.readyState) {
      // IE
      scriptEle.onreadystatechange = () => {
        if (scriptEle.readyState == 'loaded' || scriptEle.readyState == 'complete') {
          resolve();
        }
      };
    } else {
      scriptEle.onload = () => {
        resolve();
      };
      scriptEle.onerror = () => {
        reject(`The script ${src} is not accessible`);
      };
    }

    scriptEle.src = src;
      document.currentScript.parentNode.insertBefore(scriptEle, document.currentScript);
  });
}

使用的是并行加载并按顺序执行,执行顺序为:

运行时文件 --> 被拆分出来的依赖 --> 主文件

六、更好利用缓存

使用哈希值作为打包后的文件名,以保证修改文件后缓存失效。

 output: {
    // ...
    filename: '[name].[contenthash].js',
  },

需要注意的是,当我们修改了其中一个文件,其它文件对应的打包产物的哈希值也可以改变.

举个例子,假如项目中存在三个模块并且都被打包成三个独立的js文件,模块依赖关系如下:

graph TD
module1 --> module2 --> module3

当我们修改了module1, 我们只期望module1对应的js文件名哈希值改变。而实际上这三个文件的文件名都发生改变。原因在于我们没有把运行时文件单独打包出来,一个文件的文件名改变会使另一个文件的内容改变,形成连锁反应。使用以下配置:

optimization: {
    runtimeChunk: 'single',
}

这样打包后就会生成一个独立的运行时文件,这个文件也需要部署并加载(在其它文件执行前执行)。

这样我们在修改module1,就只有module1对应的js文件以及运行时文件的名称改变,从而优化缓存。