webpack 的性能优化

关于 webpack 的性能优化,主要体现在三个方面:

  • 构建性能:是指在开发阶段的构建性能。当构建性能越高,开发效率越高。
  • 传输性能:在这方面重点考虑网络中的总传输量、JS 文件数量以及浏览器缓存。
  • 运行性能:主要是指 JS 代码在浏览器端运行的速度。

一、构建性能

1.1 减少模块解析

模块解析包括:AST 抽象语法树分析、依赖分析、模板语法替换。

对某个模块不进行解析,可以缩短构建时间。

如果某个模块不做解析,该模块经过 loader 处理后的代码就是最终代码。如果没有 loader 对该模块进行处理,该模块的源码就是最终打包结果的代码。

对于模块中没有其他依赖模块,则不需要解析,可以通过配置 module.noParse 进行处理:

js 复制代码
module.exports = {
    mode: "development",
    module: {
        noParse: /JQuery/
    }
}

1.2 优化 loader 性能

限制 loader 的应用范围

针对一些第三方库,不使用 loader 进行处理。例如 babel-loader,转换一些本身就是用 ES5 语法书写的第三方库,反而会浪费构建时间。因此通过 module.rules.excludemodule.rules.include,排除或仅包含需要应用 loader 的场景。

js 复制代码
module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                // 或
                // include: /src/,
                use: "babel-loader"
            }
        ]
    }
}

缓存 loader 结果

如果某个文件内容不变,经过相同的 loader 解析后,解析后的结果也不变,所以我们可以把 loader 的解析结果保存下来,让后续的解析直接用缓存的结果:

js 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [{
            loader: "cache-loader",
            options:{
                cacheDirectory: "./cache"
            }
        }, ...loaders]
      },
    ],
  },
};

cache-loader 的原理是,在执行 loader 之前,如果发现有缓存文件,则直接在 loader.pitch 函数里 return 源代码。

那么问题来了,loader 明明不是从后往前执行的吗?那为什么 cache-loader 还可以拿到 loader 的缓存结果?

其实每个 loader 的运行过程中,还包括一个过程,即 pitch

js 复制代码
function loader(source){
  return `new source`
}

loader.pitch = function(filePath){
  // 可返回可不返回
  // 如果返回,返回源代码
}

module.exports = loader;

第一次打包时,会先把 filePath 交给 loader1.pitch 执行,检查是否有缓存结果,若无缓存,往后执行。调用 loader2.pitch,检查是否有缓存,若无缓存,往后执行,依次类推......直到最后结束了再调用 loader,当调用 cache-loader 时,就会返回 loader 处理的结果并缓存。

当第二次打包时(流程同上),若发现有缓存,则直接返回缓存结果,不会继续往后走了。

pitch 的好处:可以根据是否有返回,来控制下一步到哪。

对于 babel-loader,使用它本身的配置也是可以缓存的。

1.3 开启多线程打包

通过 thread-loader 会开启一个线程池,它会把后续的 loader 放到线程池的线程中运行,以提高构建效率。

js 复制代码
module.exports = {
    module: {
        rules: [{
            test: /\.js$/,
            use: [
                "thread-loader",
                "babel-loader"
            ]
        }]
    }
};

放到线程池的 loader 的缺点:

  • 无法使用 webpack API 生成文件。
  • 无法使用自定义的 plugin API。
  • 无法访问 webpack options。

thread-loader 可以通过测试决定放置的位置。由于开启和管理线程需要消耗时间,所以在小项目使用会增加构建时间。

1.4 热替换 (Hot Module Replacement)

其实,热更新并不能降低构建时间(可能还会稍微增加),因为它发生在代码运行期间,但它可以降低代码改动到效果显现的时间。

js 复制代码
// 配置文件
module.exports = {
    devServer: {
        open: true,
        hot: true  // 开启 HMR
    },
    plugins: [
        new HTMLWebpackPlugin({
            template: "./public/index.html"
        })
    ]
}

// index.js
if(module.hot){            // 是否开启热更新
    module.hot.accept()    // 接收热更新
}

默认情况下,webpack-dev-server 不管是否开启了热更新,当重新打包后,都会调用 location.reload 刷新页面。但如果运行 module.hot.accept(),将改变这一行为。

module.hot.accept() 的作用是让 webpack-dev-server 通过 socket 管道,把服务器更新的内容发送到浏览器,然后将结果交给插件 HotModuleReplacementPlugin 注入的代码执行,插件 HotModuleReplacementPlugin 会覆盖原始代码,然后让代码重新执行。

对于样式热替换,可使用 style-loader

js 复制代码
module.exports = {
    devServer: {
        open: true,
        hot: true  // 开启 HMR
    },
    module: {
        rules: [{
            test: /\.css$/,
            use: ["style-loader", "css-loader"]
        }]
    },
    plugins: [
        new HTMLWebpackPlugin({
            template: "./public/index.html"
        })
    ]
}

为什么不使用 mini-css-extract-plugin 插件? 因为热更新发生时,HotModuleReplacementPlugin 只会简单的重新运行模块代码。因此 style-loader 的代码一运行,就会重新设置 style 元素中的样式,而 mini-css-extract-plugin 生成文件是在构建期间,运行期间无法改动文件。

整个原理流程: 当开启热更新后,Webpack 会轮询有没有哪些模块发生变化,如果文件内容发生改变,会异步下载更新的代码,向服务器发送请求。下载完毕后,服务器就会主动发送信息给浏览器告知有文件内容发生改变,浏览器发送请求给服务器请求发送修改后的资源文件,服务器接收到请求后把修改的资源文件发送给浏览器,浏览器把接收到的结果交给 HotModuleReplacementPluginHotModuleReplacementPlugin 再覆盖原始代码,再重新执行代码。

二、传输性能

2.1 分包

webpack 默认情况下是不会分包的,它会把所有依赖文件合并到一个 bundle 中。而分包的时机是:当公共模块体积较大或有较少的变动,特别是在多页面打包的情况下,会存在多个 chunk 引入公共模块导致冗余代码的情况,占用打包体积。

分包的目的是在不影响源代码编写的情况下,减少公共代码,降低总体积(特别是一些大型的第三方库)和充分利用浏览器缓存。并非所有的情况都适合分包,需要视具体情况而定。

手动分包

总体思路:

  1. 先单独打包公共模块,并利用 DllPlugin 生成资源清单。
  2. 手动引入公共模块,重新设置 clean-webpack-plugin,然后使用 DllReferencePlugin 控制打包结果。

具体打包过程:

  1. 开启 output.library 暴露公共模块
  2. DllPlugin 创建资源清单
  3. DllReferencePlugin 使用资源清单
js 复制代码
// webpack.dll.config.js
const webpack = require('webpack');
module.exports = {
    mode: "production",
    entry: {
        jquery: ["jquery"],
        lodash: ["lodash"]
    },
    output: {
        filename: "dll/[name].js",
        library: "[name]",
        // libraryTarget: "var" // 暴露方式
    },
    plugins: [
        new webpack.DllPlugin({
            path: path.resolve(__dirname, "dll", "[name].manifest.json"), // 资源清单的保存位置
            name: "[name]"  // 资源清单中,暴露的变量名
        })
    ]
};

// webpack.config.js
module.exports = {
    plugins:[
        // 指定资源清单,在打包时对照资源清单,当发现该模块是资源清单里的资源时不进行打包处理
        new webpack.DllReferencePlugin({
            manifest: require("./dll/jquery.manifest.json")
        }),
        new webpack.DllReferencePlugin({
            manifest: require("./dll/lodash.manifest.json")
        })
    ]
};

引用:webpack.docschina.org/plugins/dll...

自动分包

原理:

  1. 检查每个 chunk 编译的结果
  2. 根据分包策略,找到那些满足策略的模块
  3. 根据分包策略,生成新的 chunk 打包这些模块(代码有所变化)
  4. 把打包出去的模块从原始包中移除,并修正原始包代码

在代码层面,有以下变动:

  • 分包的代码中,加入一个全局变量,类型为数组,其中包含公共模块的代码
  • 原始包的代码中,使用数组中的公共代码

webpack 提供了 optimization 配置项,其中 splitChunks 是分包策略的配置。实际上,webpack 在内部是使用 SplitChunksPlugin 进行分包的,分包时 webpack 开启了一个新的 chunk,对分离的模块进行打包。打包结果中,公共的部分被提取出来形成了一个单独的文件,它是新 chunk 的产物。

过去有一个库 CommonsChunkPlugin 也可以实现分包,不过由于该库某些地方并不完善,到了 webpack 4 之后,已被 SplitChunksPlugin 取代。

一般分包是在生产环境下进行的。分包策略有其默认的配置,只需小小改动即可应用大部分分包场景。

chunks 配置项:

说明
all 对于所有的 chunk 都要应用分包策略
async 【默认】仅针对异步 chunk 应用分包策略
initial 仅针对普通 chunk 应用分包策略

maxSize: 可以控制包的最大字节数。如果某个包(包括分出来的包)超过了该值,则 webpack 会尽可能的将其分离成多个包。分包的基础单位是模块,如果一个完整的模块超过了该体积,它是无法做到再切割的,因此尽管使用了这个配置,完全有可能某个包还是会超过这个体积。

全局策略:

js 复制代码
module.exports = {
    mode: "production",
    entry: {},
    output: {},
    optimization: {
        splitChunks: {
            // 分包策略
            chunks: "all",
            maxSize: 60000,
            // 分包策略的其他配置
            automaticNameDelimiter: ".",  // 新 chunk 名称的分隔符,默认值 ~
            minChunks: 1,                 // 一个模块被多少个 chunk 使用时,才会进行分包,默认值 1
            minSize: 30000,               // 当分包达到多少字节后才允许被真正的拆分,默认值 30000
        },
    },
    plugins: [],
}

缓存组策略:

每个缓存组提供一套独有的策略,webpack 按照缓存组的优先级依次处理每个缓存组,被缓存组处理过的分包不需要再次分包。

默认情况下,webpack 提供了两个缓存组:

js 复制代码
module.exports = {
  optimization:{
    splitChunks: {
      // 全局配置
      cacheGroups: {
        // 属性名是缓存组名称,会影响到分包的 chunk 名
        // 属性值是缓存组的配置,缓存组继承所有的全局配置,也有自己特殊的配置
        vendors: {
          test: /[\\/]node_modules[\\/]/, // 当匹配到相应模块时,将这些模块进行单独打包
          priority: -10                   // 缓存组优先级,优先级越高,该策略越先进行处理,默认值为 0
        },
        default: {
          minChunks: 2,                   // 覆盖全局配置,将最小 chunk 引用数改为 2
          priority: -20,                  // 优先级
          reuseExistingChunk: true        // 重用已经被分离出去的 chunk
        }
      }
    }
  }
}

2.2 单模块体积压缩

代码压缩

代码压缩除了减少代码体积,还可以破坏代码的可读性,提升破解成本。

Terser 是一个新起的代码压缩工具,支持 ES6+ 语法。webpack 会内置 Terser,当启用生产环境后即可用其进行代码压缩。

Terser 官网:terser.org/

如果想更改、添加压缩工具,又或者是想对 Terser 进行配置,使用下面的 webpack 配置即可:

js 复制代码
module.exports = {
    optimization: {
        minimize: true,   // 是否要启用压缩,默认情况下,生产环境会自动开启
        minimizer: [
            // 压缩时使用的插件,可以有多个
            new TerserPlugin(),            // js 压缩插件
            new OptimizeCSSAssetsPlugin()  // css 压缩插件
        ],
    }
}

terser、webpack、rollup.js 都能够识别 /*#__PURE__*/ 注释标记,/*#__PURE__*/ 的作用就是告诉打包工具该函数的调用不会产生副作用。

tree shaking

tree shaking 可以移除模块之间的无效代码。

如果运行环境是生产环境,tree shaking 自动开启。

在编写代码时,由于 tree shaking 需要满足一定的代码规范,所以应该尽量注意规范。例如:

js 复制代码
// 推荐使用
export xxx;              // 导出
import { xxx } from "xxx";  // 导入

// 不推荐使用
export default { xxx };  // 导出
import xxx from "xxx";   // 导入

当 webpack 依赖分析完毕后,webpack 会根据每个模块每个导出是否被使用,标记其他导出为 dead code,然后交给代码压缩工具处理,代码压缩工具最终移除掉那些 dead code 代码。

commonjs 很难做到 tree shaking,所以主流的库为了做 tree shaking,都会发布其 ES6 版本,比如 lodash-es

webpack 在 tree shaking 的使用,有一个原则:一定要保证代码正确运行。在满足该原则的基础上,再来决定如何 tree shaking。因此,当 webpack 无法确定某个模块是否有副作用时,它往往将其视为有副作用。

某些情况可能并不是我们所想要的:

js 复制代码
// common.js
var n = Math.random();

// index.js
import "./common.js"

虽然我们根本没用有 common.js 的导出,但 webpack 担心 common.js 有副作用,如果去掉会影响某些功能。

如果要解决该问题,就需要标记该文件是没有副作用的。在 package.json 中加入 sideEffects

json 复制代码
{
    "sideEffects": false
}

有两种配置方式:

  • false:当前工程中,所有模块都没有副作用。注意,这种写法会影响到某些 css 文件的导入。
  • 数组 :设置哪些文件拥有副作用,例如:["!src/common.js"],表示只要不是 src/common.js 的文件,都有副作用。

这种方式我们一般不处理,通常是一些第三方库在它们自己的 package.json 中标注。

由于 webpack 无法对 css 完成 tree shaking,所以可以通过正则匹配页面样式有没有引用进行移除样式代码。可以通过 purgecss-webpack-plugin 进行处理,该插件对 css module 无法处理。

2.3 懒加载

通过动态导入模块,例如在判断里使用导入语句。导入语句不能使用 commonjs,虽然 require 支持动态导入,但是它在打包环节也会进入依赖分析。

动态加载可以使用 import(),import 作为 ES6 的草案,webpack 打包发现使用 import() 的调用,会对其单独打包,打包结果该代码时,浏览器会使用 JSONP 的方式远程去读取一个 js 模块,import() 返回的是一个 promise。

js 复制代码
async function run(){
    if(判断条件){
        const { chunk } = await import(/* webpackChunkName:'自定义chunkName' */'xxx.js')
    }
}
run();

请求的异步的模块会加入 webpackJsonp 数组里。

值得注意的是,这样的异步导入是不可以做到 tree shaking 的,不过可以使用取巧的方法,通过一个媒介引入,打包分析过程既能 tree shaking 又能异步加载:

js 复制代码
// 媒介文件
export { xxx } from '目标文件'

// 主文件
async function run(){
    if(判断条件){
        const { chunk } = await import('媒介文件')
    }
}
run()

2.4 gzip

gzip 是一种压缩文件的算法。

gzip 工作原理: 浏览器发送请求时,会在请求头中设置 Accept-Encoding: gzip, deflate, br,表明浏览器支持 gzip。服务器收到浏览器发送的请求之后,判断浏览器是否支持 gzip,如果支持 gzip,则向浏览器传送压缩过的内容,不支持则向浏览器发送未经压缩的内容。一般情况下,浏览器和服务器都支持 gzip,响应头返回包含 Content-Encoding: gzip。浏览器接收到服务器的响应之后判断内容是否被压缩,如果被压缩则解压缩显示页面内容。

对哪些文件压缩,采用哪种压缩算法,这个需要测试权衡,毕竟压缩文件和解压文件都是需要时间的,对于相对大点的文件一般会有收益。

webpack 压缩参与的步骤在于将文件预压缩,当请求到来时直接响应已经压缩的文件,而不需要先压缩再响应。使用 compression-webpack-plugin 插件对打包结果进行预压缩,可以移除服务器的压缩时间。

js 复制代码
const CompressionWebpackPlugin = require("compression-webpack-plugin")
module.exports = {
  plugins: [
    new CompressionWebpackPlugin({
      // filename: "[file].gzip"
      test: /\.js/,      // 针对需要预压缩的文件
      minRatio: 0.5      // 压缩比率
    })
  ]
};

以 gzip 为例,打包之后的文件包含了 .js.js.gz 文件。

2.5 辅助工具

三、运行性能

运行性能是指 JS 代码在浏览器端的运行速度,它主要取决于我们书写代码质量的高低。

关于高性能的代码,可以参考常见的设计模式、代码规范、最佳实践等。


版权声明:本文为 CSDN 博主「是加薪呀」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。 原文链接:blog.csdn.net/weixin_4532...

相关推荐
gogoing1 小时前
Node.js 模块查找策略(require 完整流程)
javascript·node.js
桃花键神1 小时前
Bright Data Web Scraping指南 2026: 使用 MCP + Dify 自动采集海外社交媒体数据
大数据·前端·人工智能
gogoing1 小时前
await fetch() 的两阶段设计
前端·javascript
gogoing1 小时前
前端首屏加载优化
前端·javascript
gogoing1 小时前
重排与重绘
前端·javascript
打小就很皮...1 小时前
基于Python + LangChain + 通义千问的聊天机器人实战
前端·langchain·机器人·千问
REDcker2 小时前
个人博客网站建设指南 Markdown资产化与静态站选型部署
前端·后端·博客·markdown·网站·资产·建站
zhangfeng11332 小时前
小龙虾 wordbuddy 安装浏览器控制器 agent-browser npm install -g agent-browse
前端·人工智能·npm·node.js
徐小夕2 小时前
100小时,我做了一款AI CAD建模软件,开源!
前端·vue.js·github