webpack

212 阅读7分钟

原理

打包结果分析

  • 就是一个立即执行函数
  • 函数内容: 声明了一个webpack_require, 然后执行
//合并两个模块
//  ./src/a.js
//  ./src/index.js

(function (modules) {
    var moduleExports = {}; //用于缓存模块的导出结果

    //require函数相当于是运行一个模块,得到模块导出结果
    function __webpack_require(moduleId) { //moduleId就是模块的路径
        if (moduleExports[moduleId]) {
            //检查是否有缓存
            return moduleExports[moduleId];
        }

        var func = modules[moduleId]; //得到该模块对应的函数
        var module = {
            exports: {}
        }
        func(module, module.exports, __webpack_require); //运行模块
        var result = module.exports; //得到模块导出的结果
        moduleExports[moduleId] = result; //缓存起来
        return result;
    }

    //执行入口模块
    return __webpack_require("./src/index.js"); //require函数相当于是运行一个模块,得到模块导出结果
})({ //该对象保存了所有的模块,以及模块对应的代码
    "./src/a.js": function (module, exports) {
        eval("console.log(\"module a\")\nmodule.exports = \"a\";\n //# sourceURL=webpack:///./src/a.js")
    },
    "./src/index.js": function (module, exports, __webpack_require) {
        eval("console.log(\"index module\")\nvar a = __webpack_require(\"./src/a.js\")\na.abc();\nconsole.log(a)\n //# sourceURL=webpack:///./src/index.js")
      
    }
});

css-loader 原理

  • 转换前的字符串
`
body {
    color: red;
}
`
  • 转换后的字符串
`
var style = document.createElement("style")
style.innerHTML = 'body{color: red}'
document.head.appendChild(style)
`
  • css-loader.js

module.exports = function (sourceCode) {
    var code = `
        var style = document.createElement("style")
        style.innerHTML = ${sourceCode}
        document.head.appendChild(style)
    `;
    return code;
}

img-loader原理

  • 将二进制的图片内容 --> 转成base64的字符串
  • img-loader.js
function loader(buffer) { //给的是buffer
    console.log("文件数据大小:(字节)", buffer.byteLength);

    var base64 = getBase64(buffer)
    return `module.exports = ${base64}`;
}

loader.raw = true; //该loader要处理的是原始数据

module.exports = loader;

function getBase64(buffer) {
    return "data:image/png;base64," + buffer.toString("base64");
}

性能优化: 打包体积: (生产环境)

压缩: js压缩

一键开启js压缩

  • minimize: true
    • 默认就会使用terser-webpack-plugin来压缩代码
    • 生产环境默认值为true, 生产环境下 自动压缩代码 ,减少文件体积,提升加载性能
    • 开发环境默认值为false, 开发环境下 不压缩代码 ,便于调试
module.exports = {
    optimization: {
        minimize: true, // <------------
    },
    entry: path.resolve(__dirname, "../src/main.ts"),
    output: { ... },
    resolve: { ... },
    plugins: [...],
    module: {...},
};

自定义js压缩

  • 使用TerserPlugin开启自定义压缩
  • 为什么要自定义压缩?
    • minimize: true 就像一辆「出厂配置的汽车」,能满足基本的代步需求;
    • 而显式配置 TerserPlugin 就像「改装汽车」,可以根据你的具体需求(比如性能、安全性、个性化)进行定制,让它更适合你的项目!
  • 当只设置 minimize: true 而不自定义 TerserPlugin 时:
    • webpack 会使用 TerserPlugin 的 默认配置 来压缩代码
    • 默认配置是比较保守的,主要是基本的体积压缩
    • 不会删除 console.log 、 debugger 等调试语句
    • 会保留一些注释
  • 通过自定义 TerserPlugin ,你可以:
    • 删除调试代码 :如 console.log 、 debugger
    • 清理注释 :进一步减小文件体积
    • 自定义压缩策略 :根据项目需求调整压缩强度
    • 提升安全性 :压缩后的代码更难被反编译
const TerserPlugin = require("terser-webpack-plugin");

// 自定义js压缩
const terserPlugin = new TerserPlugin({
    terserOptions: {
      format: {
        comments: false, // 不保留注释
      },
      compress: {
        drop_console: true, // 删除console.log
        drop_debugger: true, // 删除debugger
        pure_funcs: ['console.log','console.error','console.info'] // 删除console相关打印语句
      }
    },
    extractComments: false, // 不将注释提取到单独的文件中
})

webpackConfig.optimization = {
    minimizer: [terserPlugin] // <<------
}

压缩: css压缩

  • minimize: 默认压缩器
    • minimizer 是默认压缩器
    • 默认就会使用 terser-webpack-plugin 来压缩代码
    • 生成环境默认值true
    • 开发环境默认值false
  • minimizer: 自定义压缩器
    • minimizer 是 webpack 中用于配置代码压缩工具的核心配置项。
    • 当你自定义压缩代码时,webpack 会 完全替换默认的压缩器配置 ,而不再使用内置的默认压缩器。
  • 引号包裹的三个点: "..."
    • 引号包裹的三个点 "..." 被称为 "webpack 的默认压缩器占位符"
    • 它的作用是 保留 webpack 原本默认的压缩器配置 ,同时允许你添加自定义的压缩器
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

webpackConfig.optimization = {
  minimize: true, // 默认压缩器, 下面的 minimizer 会让它失效
  minimizer: [
    new CssMinimizerPlugin(), // 分离css
    // minimize 是默认压缩器
    // 当你设置 minimizer 数组时,会完全替换默认的压缩器
    // "..." 表示: 依旧使用默认压缩器压缩js
    "..." 
  ]
}

压缩: treeShaking

react实践

import {Button} from 'antd

vue实践

  • AutoImport 和 Components 插件是按需加载的具体实现,它们通过自动分析代码并仅导入使用的功能,从而减少了最终打包体积。这一过程与 Tree Shaking 相辅相成,共同提升应用性能。
module.exports = {
  entry: path.resolve(__dirname, "../src/main.ts"),
  output: { ... },
  resolve: { ... },
  plugins: [
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
  module: {...},
};

AutoImport作用

  plugins: [
    AutoImport({
      // 按需加载ElementPlu的 ElMessage
      resolvers: [ElementPlusResolver()],
    })
  ],
  • AutoImport 是 Vue 3 + Element Plus 项目中的 必备优化工具 ,它通过自动导入常用函数和 API,显著提高了开发效率,减少了代码冗余,是现代前端工程化的重要实践之一。
// 无需import 自动导入 ref, ElMessage
// 都是按需加载的
export default { 
  setup() { 
    const count = ref(0) // ref 自动导入自 vue
    ElMessage.success('操作成功') // ElMessage 自动导入自 element-plus
    return { count }
  } 
}

Components作用

  plugins: [
    Components({
      // 按需加载ElementPlu
      resolvers: [ElementPlusResolver()],
    }),
  ],

使用后效果, 不需导入直接使用即可, 并且是按需加载的

<template>
  <el-button type="primary" @click="showMessage">点我</el-button>
</template>

<script setup>
// 无需导入
</script>

与传统按需加载的区别

  • 使用 AutoImport 和 Components:
    • 无需手动导入,插件自动完成
    • 自动处理样式
    • 更简洁,更不容易出错
  • 传统按需加载:
    • 需要手动导入每个组件和对应的样式
    • 容易出错,维护麻烦
import { ElButton, ElTable } from 'element-plus'
import 'element-plus/es/components/button/style/css'
import 'element-plus/es/components/table/style/css'

app.component('ElButton', ElButton)
app.component('ElTable', ElTable)

压缩: GZIP

  • Nginx动态压缩: Nginx帮我们压缩
  • Nginx静态压缩: 提前压缩好交给Nginx
  • 如果需要压缩的文件太多, 服务器压力太大,我们就提前压缩好
  • 这里我们使用插件: CompressionPlugin, 提前压缩好
const gzipPlugin = new CompressionPlugin({
  algorithm: "brotliCompress", // 压缩算法,默认gzip,也可以是brotliCompress
  test: /\.(js|css)(\?.*)?$/i, //需要压缩的文件正则
  threshold: 1024, //文件大小大于这个值时启用压缩
  deleteOriginalAssets: true //压缩后是否删除原文件
})

webpackConfig.plugin = [gzipPlugin]

分包: 分离css

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const isProduction = process.env.NODE_ENV === "production";

const getStyleLoaders = () => {
  return [
    // 生产环境才分离css, 开发环境不分离
    isProduction ? MiniCssExtractPlugin.loader : "style-loader",
    "css-loader",
    "postcss-loader",
  ].filter(Boolean);
};

const cssLoader = {
  test: /\.css$/i,
  exclude: [/\.module\.css$/],
  use: getStyleLoaders(),
}
// 分离的css文件位置以及名称
const miniCssExtractPlugin = new MiniCssExtractPlugin({
  filename: "css/[name].[contenthash:6].css",
})

const webpackConfig.plugins = [miniCssExtractPlugin]
webpackConfig.module.rules = [cssLoader]

分包: js懒加载,预加载

{
    path: "/",
    name: "Home",
    component: () => import(
      /* webpackChunkName: "HomeView" */ 
      /* webpackPreload: true */ // 首页优先加载
      "@/views/HomeView.vue"),
  },
  {
    path: "/user",
    name: "User",
    component: () => import(
      // 给分包出去的文件取名
      /* webpackChunkName: "UserView" */ 
      // 预加载, 就是空闲时加载
      /* webpackPrefetch: true */ 
      "@/views/UserView.vue"),
  },
  {
    path: "/image",
    name: "Image",
    component: () => import(
      /* webpackChunkName: "ImageView" */
      /* webpackPrefetch: true */
      "@/views/ImageView.vue"),
  },

分包: 分离运行时代码

const baseConfig = require('./webpack.base.js');
const { merge } = require('webpack-merge');

const prodConfig = {
  mode: 'production',
  plugins: [...],
  optimization: {
    ...
    chunkIds: "named", // 给分离的包自动取名
    runtimeChunk: 'single', // 分离运行时代码<<------------
    ...
  },
}

module.exports = merge(baseConfig, prodConfig);

什么是运行时代码:

  • webpack产生的代码叫运行时代码

为什么要设置 runtimeChunk: 'single'

  • 默认行为 :运行时代码通常会嵌入到每个入口文件中 问题
    • 导致代码重复(多个入口会有相同的运行时代码)
    • 缓存效果差(只要运行时代码变化,所有入口文件都需要重新加载)
  • 设置为 'single' 后的好处 :
    • 减少代码体积:运行时代码只存在于一个文件中
    • 优化缓存:运行时代码与业务代码分离,业务代码变化时不会影响运行时代码的缓存
    • 提升构建效率:运行时代码单独提取,便于 webpack 进行更高效的代码分析和处理

分包: 自定义

  • webpack.production.js
const baseConfig = require('./webpack.base.js');
const { merge } = require('webpack-merge');

const prodConfig = {
  mode: 'production',
  plugins: [...],
  optimization: {
    ...
    ...
    splitChunks: {...} // 自定义分包<---------
  },
}

module.exports = merge(baseConfig, prodConfig);
  • splitChunks详细配置
    • element, echarts, 会被单独打包
    • 大于160KB 的大库(如 Vue 3、React 等)会被 lib 优先处理
    • 20KB - 160KB 的中等库会被 module 处理
    • 小于20KB 的小库会被 vendors 合并
// 将node_modules分离出来统一打包
const vendors = {
  name: 'chunk-vendors',
  test: /[\\/]node_modules[\\/]/,
  priority: 10,
  reuseExistingChunk: true,
  // chunks:'async'
}
// 将element单独打包
const element = {
  name: 'chunk-element',
  test: /[\\/]node_modules[\\/]element(.*)/,
  priority: 20,
  reuseExistingChunk: true,
}
// 将echarts 单独打包
const echarts = {
  name: 'chunk-echarts',
  test: /[\\/]node_modules[\\/]echarts|zrender(.*)/,
  priority: 20,
  reuseExistingChunk: true,
}
// 将共用代码单独打包: (如: tools.js)
const commons = {
  name: 'chunk-commons',
  minChunks: 2, // 为了演示效果,只要引用了2次以上就会打包成单独的js
  priority: 5,
  minSize: 0, // 为了演示效果,设为0字节,实际情况根据自己的项目需要设定
  reuseExistingChunk: true,
}
// 将 node_modules 下大于160kb的库单独打包
const lib = {
  test(module) { 
    // 如果模块大于160kb,并且模块名字中包含node_modules, 就会被单独打包到一个文件中
    return module.size() > 160000
      && module.nameForCondition()
      && module.nameForCondition().includes('node_modules')
  },
  name(module) { 
    const packageNameArr = module.context.match(/[\\/]node_modules[\\/]\.pnpm[\\/](.*?)(\/|$)/)
    const packageName = packageNameArr ? packageNameArr[1] : ''
    return `chunk-lib.${packageName.replace(/@/g,"")}`;
  },
  priority: 20,
  minChunks: 1,
  reuseExistingChunk: true,
}
// 将 node_modules 的库单独打包(默认设置: 大于20kb才会单独打包)
const module = {
  test: /[\\/]node_modules[\\/]/,
  name(module) { 
    const packageNameArr = module.context.match(/[\\/]node_modules[\\/]\.pnpm[\\/](.*?)(\/|$)/)
    const packageName = packageNameArr ? packageNameArr[1] : ''
    return `chunk-module.${packageName.replace(/@/g,"")}`;
  },
  priority: 15,
  minChunks: 1,
  reuseExistingChunk: true,
}

webpackConfig.optimization.splitChunks = {
  chunks: 'all',
  cacheGroups: {
    vendors,// 将node_modules分离出来统一打包
    element,// 将element单独打包
    echarts,// 将echarts 单独打包
    commons,// 将共用代码单独打包: (如: tools.js)
    lib,// 将 node_modules 下大于160kb的库单独打包
    module// 将 node_modules 下大于20kb的库单独打包
  }  
}

CDN: 外部依赖

  • 使用CDN 进一步减小打包体积
  • externals 是 webpack 的核心配置项之一,用于 声明"外部依赖" ,告诉 webpack 在打包时不要将这些依赖项包含到最终的 bundle 中,而是期望它们在运行时从外部环境(如 CDN、全局变量等)获得。
  • 开发环境 :不配置 externals ,直接从 node_modules 加载依赖,保持开发体验和调试便利性
  • 生产环境 :配置 externals ,使用 CDN 加载大型依赖,优化打包体积和加载性能
  • 这样既保证了开发效率,又能在生产环境获得性能优化的好处。

webpack.production.js

const prodConfig = {
  mode: 'production',
  plugins: [...],
  optimization: {...},
  externals: {
      vue: 'Vue',
      "vue-router": "VueRouter",
      "element-plus": "ElementPlus",
      "@vueuse/core": "VueUse",
      echarts: 'echarts',
      "vue-echarts": "VueECharts",
  }
}

module.exports = merge(baseConfig, prodConfig);

这个配置对象的结构是 { [包名]: [全局变量名] } ,表示:

  • 当你的代码中引用 vue 时,webpack 会假设运行时环境中存在一个全局变量 Vue
  • 当你的代码中引用 vue-router 时,运行时环境中需要存在全局变量 VueRouter
  • 其他同理...

记得在模板中引入CDN

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="<%= htmlWebpackPlugin.options.BASE_URL %>favicon.ico" type="image/x-icon">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title><%= htmlWebpackPlugin.options.title %></title>
  <link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/element-plus@2.3.12/dist/index.min.css"
  />
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-echarts@6.6.1/dist/csp/style.min.css">
  
  <script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue-router@4.2.4/dist/vue-router.global.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@vueuse/shared@10.4.1/index.iife.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@vueuse/core@10.4.1/index.iife.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/element-plus@2.3.12/dist/index.full.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue-echarts@6.6.1/dist/index.umd.min.js"></script>
</head>
<body>
  <div id="app"></div>
</body>
</html>

性能优化: 打包速度: (开发环境优化)

缓存: 介绍

  • 在现代 webpack 项目中, 多层级缓存策略 是常见的最佳实践:
    • 第一层 :webpack 核心缓存(管理整个构建流程)
    • 第二层 :各个 loader 缓存(如 babel-loader、ts-loader 等)
    • 第三层 :各个插件缓存(如 ESLintPlugin、TerserPlugin 等)

缓存: 第1层: 核心缓存

  • type: "filesystem"缓存到硬盘
  • config: [__filename] 的核心含义:
    • config 字段 :表示 webpack 配置文件的依赖
    • __filename :Node.js 内置变量,表示当前脚本文件的完整路径(即 webpack.base.js )
    • 整体功能 :当 webpack.base.js 文件内容发生任何修改时,缓存会自动失效,触发一次全新的构建
const cache = {
    type: "filesystem",
    buildDependencies: {
        config: [__filename],
    },
}

module.exports = {
    cache, // <<---------------
    entry: path.resolve(__dirname, "../src/main.ts"),
    output: { ... },
    resolve: { ... },
    plugins: [ ... ],
    module: { ... },
};

缓存: 第2层: loader缓存

  • babelLoader 开启缓存
    • 我们可以基于一种假设:如果某个文件内容不变,经过相同的loader解析后,解析后的结果也不变
    • 于是,可以将loader的解析结果保存下来,让后续的解析直接使用保存的结果
const babelLoader = {
    test: /\.m?jsx?$/,
    exclude: /node_modules/,
    use: [
      { loader: "thread-loader" }, // 多线程处理
      {
        loader: "babel-loader",
        options: {
          cacheDirectory: true, // 开启缓存
          cacheCompression: false, // 关闭缓存压缩
        }
      }
    ],
}

缓存: 第3层: 插件缓存

  • eslint 开启缓存
    new ESLintPlugin({
      extensions: ["js", "ts", "vue", "jsx", "tsx"],
      exclude: "node_modules",
      context: path.resolve(__dirname, "../src"),
      cache: true, // 开启缓存
      // 缓存目录
      cacheLocation: path.resolve(
        __dirname,
        "../node_modules/.cache/.eslintcache"
      ),
    }),

缩小范围: 减少AST模块解析

  • 模块解析是指: 构建AST语法树,分析文件依赖
  • jquery 不依赖任何第三方库, 所以不需要解析, 从而提高打包速度
module.exports = {
    mode: "development",
    devtool: "source-map",
    module: {
        noParse: /jquery/
    }
}

缩小范围: loader

  • 例如: 排除lodash
    • lodash 本来就是用es5写的
    • 不需要babel-loader 转换语法
module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /lodash/,  // <-------
                use: "babel-loader"
            }
        ]
    }
}
  • exclude: 排除第三方库
    • node_modules
    • 第三方库90%都是经过webpack打过包的
    • 不需要babel-loader 转换语法
// 写法一: babel-loader 不处理 node_modules
module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,  // <-------
                use: "babel-loader"
            }
        ]
    }
}
// 写法二: babel-loader 只处理 src 下的文件
module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                include: /src/,  // <-------
                use: "babel-loader"
            }
        ]
    }
}
  • include: 缩小图片处理范围
    • 缩小图片处理范围
    • 通过 include 指定要处理的文件夹
const fileLoader = {
    test: /\.(png|jpe?g|gif|webp|avif)(\?.*)?$/,
    // 只处理这个文件夹里的图片
    include: path.resolve(__dirname, "../src/assets/images"), 
    // webpack5通用资源处理模块,默认8kb以下的资源会被转换为base64
    type: "asset", 
    parser: {
      dataUrlCondition: {
      // 10kb以下的资源会被转换为base64
        maxSize: 10 * 1024, 
      },
    },
    generator: {
      // 所有匹配的图片资源都会被输出到 dist/img/ 目录下
      filename: "img/[name].[contenthash:6][ext]", 
    },
},

多线程: thread-loader

  • thread-loader 是 webpack 的一个插件,它可以将昂贵的 loader 处理(如 babel 转译、TypeScript 编译)分配到多个工作线程中并行执行,从而提高构建速度。
use: [
  { loader: "thread-loader" },
  { loader: "babel-loader" }
]
  • 什么时候应该开启多线程

      1. 项目规模较大时, 多线程可以显著减少构建时间
      1. CPU 核心较多时
      1. 处理复杂转译逻辑时(当需要处理大量第三方库代码时)
      1. 生产构建环境适合开启, 因为生产环境不用频繁打包
  • 什么时候不应该开启多线程

      1. 项目规模较小时
      1. CPU 资源有限时
      1. 处理简单任务时
      1. 开发模式下不宜开启, 因为开发环境需要频繁打包
const babelLoader = {
    test: /\.m?jsx?$/,
    exclude: /node_modules/,
    use: [
      { loader: "thread-loader" }, // 多线程处理
      {
        loader: "babel-loader",
        options: {
          cacheDirectory: true, 
          cacheCompression: false, 
        }
      }
    ],
}

文件指纹: 开发环境下不用指纹

filename: isProduction ? "js/[name].[contenthash:6].js" : 'js/[name].js', 
output: {
    path: path.resolve(__dirname, "../dist"), // 打包后的目录
    filename: isProduction ? "js/[name].[contenthash:6].js" : 'js/[name].js', // 打包后的文件名
    chunkFilename: isProduction ? "js/[name].[contenthash:8].js" : 'js/[name].chunk.js', // 代码拆分后的文件名
    // assetModuleFilename: isProduction ? "img/[name].[contenthash:6][ext]" : 'img/[name][ext]', // 资源模块打包后的文件名
    clean: true, // 清除上一次打包的文件
    publicPath: "/", // 打包后的资源的访问路径前缀
  },