webpack性能优化

266 阅读10分钟

实现高性能应用的重要的一点就是尽可能地让用户每次只加载必要的资源,对于优先级不太高的资源则采用延迟加载等技术循序渐进式获取,这样可以保证页面的首屏速度。

代码分片是webpack作为打包工具所特有的一项技术,通过这项技术,我们可以把代码按照特定的形式进行拆分,使用户不必一次性加载全部代码,而是按需加载。

本次研究用到的项目源码

webpack的基础安装和配置

全局安装和本地安装的区别

全局安装webpack的好处是npm会帮我们绑定一个命令行环境变量,一次安装、处处运行;本地安装webpack则会添加其为项目中的依赖,只能在项目内部使用。

webpack的安装指令

yarn add webpack webpack-cli -D

webpack是核心模块,webpack-cli是webpack的命令行工具,-D是将其安装为开发依赖。

检查是否安装成功

npx webpack -v
npx webpack-cli -v

可显示版本号即证明安装成功

分析打包体积

yarn add webpack-bundle-analyzer -D

webpack.config.js中引入const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;,配置使用

 plugins: [
    new BundleAnalyzerPlugin()
  ]

webpack的打包流程

chunk字面的意思是代码块,在webpack中可以理解成被抽象和包装后的一些模块。它就像一个装着很多文件的文件袋,里面的文件就是各个模块,webpack在外面加了一层包裹,从而形成了chunk。

从入口文件开始检索,并将具有依赖关系的模块生成一个依赖树,最终得到一个chunk。我们一般将由这个chunk得到的打包产物称为bundle.

在一些特殊情况下,一个入口也可能产生多个chunk并生成多个bundle。

webpack的优化

1. 代码分片

实现高性能应用的重要一点是尽可能低让用户每次只加载必要的资源,对于优先级不太高的资源则采用延迟加载等技术渐进式获取,这样可以保证页面的首屏速度。

1.1通过入口划分代码

// webpack.confing.js
const { chunk } = require('lodash');
const path = require('path');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;// 分析打包资源的体积
const { CleanWebpackPlugin } = require('clean-webpack-plugin'); // 先把本地已有的打包后的资源清空

module.exports = {
  entry: {
    app: './src/index.js',
    vendor:['./src/add-content'],
  },
  mode:'development',
  output: {
    path: path.resolve(__dirname, 'dist'),// __dirname是node环境的全局变量,指向当前文件的绝对目录路径
    filename:'[name]-[fullhash].js',
  },
  plugins: [
    new BundleAnalyzerPlugin(),
    new CleanWebpackPlugin(),
  ],
}

上面的配置会从两个入口,划分打包代码

image.png

对于多页面应用来说,我们也可以利用入口划分的方式拆分代码。比如,为每一个页面创建一个入口,并放入只涉及该页面的代码,这样诶个页面会生成一个对应的JavaScript文件。同时,再创建一个放置公共模块的入口,打包一个包含所有公共依赖的JavaScript文件,并添加到每个页面的HTML中。

1.2 optimization.splitChunks

可以设置一些提取条件,如提取的模式、提取模块的体积等,当某些模块达到这些条件后就会自动被提取出来。

//index.js
import b from './b'
import a from './a';

console.log('a', a);
console.log('b', b);

document.write('my first webpack app.<br>');

其中,a.jsb.js都引用了lodash

//webpack.config.js
const { chunk } = require('lodash');
const path = require('path');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;// 分析打包资源的体积
const { CleanWebpackPlugin } = require('clean-webpack-plugin'); // 先把本地已有的打包后的资源清空

module.exports = {
  entry: {
        app: '../src/index.js',
        a: '../src/a.js',
        b: '../src/b.js',
        vendor: ['lodash']
  },
  mode:'development',
  output: {
    path: path.resolve(__dirname, 'dist'),// __dirname是node环境的全局变量,指向当前文件的绝对目录路径
    filename:'[name]-[fullhash].js',
  },
  plugins: [
    new BundleAnalyzerPlugin(),
    new CleanWebpackPlugin(),
  ],
  optimization: {
    splitChunks: {
      chunks:'all',// initial(只从入口文件处提取代码),async(默认,从动态加载的内容提取代码),all(从入口及动态加载的文件中提取代码)
    }
  }
}

结合入口划分代码和optimization.splitChunks我们可以把a.jsb.js,lodash单独抽离文件, image.png

2. 打包优化

2.1 HappyPack

HappyPack是一个通过多线程来提升Webpack打包速度的工具。

打包过程中有一项非常耗时的工作,就是使用Loader对各种资源进行转译处理。最常见的包括使用babel-loader转译ES6语法和使用ts-loader转译type-script。

代码转译的工作流程大致如下:

  1. 从配置中获取打包入口。
  2. 匹配loader规则,并对入口模块进行转译。
  3. 对转译后的模块进行依赖查找(如a.js中加载了b.js和c.js)
  4. 对新找到的模块重复进行步骤2和步骤3,直到没有新的依赖模块。

2~4是一个递归的过程,webpack需要一步步获取更深层级的资源,然后逐个进行转译。这里的问题在于webpack是单线程的,假设一个模块依赖于其他几个模块,则webpack必须对这些模块逐个进行转译。虽然这些转译任务彼此之间没有任何依赖关系,却必须串行地执行。HappyPack恰恰以此为切入点,它的核心特性是可以开启多个线程,并行地对不同模块进行转译,从而充分利用本地的计算资源来提升打包速度。

HappyPack适用于转译任务比较重的工程,当我们把类似babel-loader和ts-loader等loader迁移到HappyPack之上后,一般都可以收到不错的效果,而对于其他如sass-loader、less-loader本身消耗时间并不太多的工程则效果一般。

在 webpack 5 中,HappyPack 已经被集成,因此你不需要单独安装或配置 HappyPack。webpack 5 提供了一个名为 parallel-webpack 的功能,它可以自动并行处理模块以提高构建速度。

要使用 webpack 5 的并行处理功能,你只需要在 webpack.config.js 文件中启用它。这可以通过设置 mode 配置为 'production' 或 'development' 来完成,webpack 会根据你选择的模式自动选择合适的并行处理策略。

module.exports = {
  // ...
  parallelism: require('os').cpus().length,
  // ...
};

这将设置并行处理线程的数量为系统上的CPU核心数。

2.2 缩小打包作用域

exclude和include

下面的例子使用exclude排除掉babel-loader对特定目录的应用

module..exports = {
    module:{
        rules:{
            test:/\.(js|jsx)$/,
            exclude:[/node_modules/,/static\/dist/],
            use:[
                loader:'babel-loader',
                options:{...}
            ]
        }
    }
}

下面的例子使用include使babel-loader只生效于源码目录

module:{
    rules:{
        test:/\.js$/,
        include:/src\/srcipt/,
        loader:'babel-loader'
    }
}
noParse

对于有些库,我们希望webpack完全不要去进行解析,即不希望应用任何loader规则,库的内部也不会有对其他模块的依赖,那么这时可以用no-Parse实现。

module.exports={
//...
    module:{
        noParse:/lodash/,
    }
}
IgnorePlugin

IgnorePlugin可以完全排除一些模块,被排除的模块即使被引用了也不会被打包进资源文件中。

IgnorePlugin对于排除一些库相关文件非常有用。对于一些由库产生的额外资源,我们其实并不会用到但又无法去年,因为引用的语句处于库文件的内部。比如,Moment.js是一个日期时间处理相关的库,为了做本地化它会加载很多语言包,占很大的体积,但我们一般用不到其他地区的语言包,这时就可以用IgnorePlugin来去掉。

plugins:[
    new webpack.IgnorePlugin({
        resourceRegExp:/^\.\/locale$/,// 匹配资源文件
        contextRegExp:/moment$/, // 匹配检索目录
    })
]
缓存

Webpack5引入了一个新的缓存配置项。在默认情况下,它会在开发模式中开启,在生产模式下禁用。我们也可以通过下面方式来强制开启或者关闭。

module.exports = {
    //...
    cache:true
}

webpack还支持一种基于文件系统的缓存,这种缓存机制必须要强制开启才会生效,开启的配置如下:

module.exports = {
    cache:{
        type:'filesystem'
    }
}

使用文件系统缓存可能会带来一定的风险。 比如说已经打包过,其结果已经缓存,这时升级了一个webpack相关插件,并重新进行了打包。但是webpack只会检查工程源代码是否有改动,并不会知道有个插件升级了。最后webpack会直接采用缓存,进而可能引发各种问题。

与此类型的情况还有:

  • 更改webpack配置
  • 通过命令行传入不同的构建参数。
  • loader、plugin或者第三方包更新
  • Node.js、npm 或 yarn 更新

上述情况都有可能引发缓存问题。相比较而言,基于内存的缓存持续时间短,且只在开发模式下启用,可一定程度上避免这种风险。因此,webpack宁可牺牲一部分性能,使用基于内存缓存的方式来保证构建的结果是正确的。

可以用配置cache.version的方式来解决文件系统缓存的风险。

module.exports = {
    //...
    cache:{
        type:'filesystem',
        version:'<version_string>'
    }
}

可以手动修改cache.version来让缓存过期,或者可以动态设置cache.version并将其内容依赖于yarn.lock的hash等,让其版本随着第三方包的更新而更新。

另外,有些loader会有一个缓存配置项,用来在编译代码后同时保存一份缓存。在执行下一次编译前,它会先检查源文件是否有变化,如果没有再直接采用缓存。这样可以使整体构建速度上有一定的提升。

2.3 动态链接库与DIIPlugin

DIIPlugin会将vendor完全拆分出来,定义一整套自己的webpack配置并独立打包,在实际工程构建时就不用再对它进行任何处理,直接取用即可。

vendor配置

需要为动态链接库单独创建一个webpack配置文件,比如命名为webpack.vendor.config.js,用来区别工程本身的配置文件webpack.config.js。

// webpack.vendor.config.js

const path = require('path');
const webpack = require('webpack');

const dllAssetPath = path.join(__dirname, 'dll');

const dllLibraryName = 'dllExample';

module.exports = {
  mode: 'development',
  entry: ['react'],
  output: {
    path: dllAssetPath,
    filename: 'vendor.js',
    library: dllLibraryName,
  },
  plugins: [
    new webpack.DllPlugin({
      name: dllLibraryName, // 导出的动态链接库的名字
      path:path.join(dllAssetPath, 'manifest.json'), // 资源清单的绝对路径
    }),
    new webpack.ids.HashedModuleIdsPlugin(),// 把id的生成算法改为根据模块的引用路径生成一个字符串hash,解决数字id潜在的bug
  ]
}

为了方便,在package.json文件中配置一条npm script,如下所示:

//package.json
{
 "scripts": {
    "dll":"webpack --config webpack.vendor.config.js",
  },
}

在运行yarn dll后会生成一个dll目录。

image.png
链接到业务代码

利用与DIIPlugin配套的插件DllReferencePlugin,它起到一个索引和链接的作用。 在webpack.config.js中,通过DllReferencePlugin来获取刚刚打包好的资源清单,然后在页面中添加vendor.js的引用就可以了。

// webpack.config.js
const webpack = require('webpack');
//...
  plugins: [
    new BundleAnalyzerPlugin(),
    new CleanWebpackPlugin(),
    new webpack.DllReferencePlugin({
      manifest:require(path.join(__dirname, 'dll/manifest.json'))
    })
  ],

  <script src="./dist/main.js"></script>
  <script src="./dll/vendor.js"></script>

当页面执行到vendor.js时,会声明dllExample全局变量。而manifest相当于我们注入app.js的资源地图,app.js会先通过name字段找到名为dllExample的库,再进一步获取其内部模块。 所以我们会在webpack.vendor.config.js中给DllPlugin的name和output.library赋相同值的原因。

2.4 去除死代码

ES6Module依赖关系的构建是在代码编译时而非运行时。基于这项特性Webpack提供了去除死代码(tree shaking)功能,他可以在打包过程中帮助我们检测工程中是否有没有引用过的模块,这部分代码将永远无法被执行到,因此也被称为“死代码”。webpack会对这部分代码进行标记,并在资源压缩时将它们从最终的bundle中去掉。

去除死代码只能对ES6 Module生效。有时我们只引用了某个库中的一个借口,却把整个库加载进来了,而bundle的体积并没有因为去除死代码而减小。这可能是由于该库是使用CommonJS形式到户的,为了获得更好的兼容性,目前大部分的npm包还在使用CommonJS的形式。

webpack提供的去除死代码的功能本身只是为死代码添加标记,真正去除死代码是通过压缩工具来进行的。使用terser-webpack-plugin即可。

总结

每一种优化策略都有其使用场景,并不是任何一个点放在一起项目中都有效。我们在发现性能的问题时,还是要根据现有情况分析出瓶颈在哪里,然后对症下药。