Webpack 学习笔记

入口起点(entry point)

用法

语法:entry: string | [string] | { string | [string] } | {}

// webpack.config.js
module.exports = {
  entry: {
    app: './src/app.js',
    file: ['./src/file_1.js', './src/file_2.js'],
    a1: {
      dependOn: 'app', // 当前入口所依赖的入口。它们必须在该入口被加载前被加载。
      filename: '', // 指定要输出的文件名称
      import: '', // 启动时需加载的模块
      library: '', // 指定 library 选项,为当前 entry 构建一个 library
      runtime: '', // 运行时 chunk 的名字。如果设置了,就会创建一个新的运行时 chunk。在 webpack 5.43.0 之后可将其设为 false 以避免一个新的运行时 chunk
      publicPath: '', // 当该入口的输出文件在浏览器中被引用时,为它们指定一个公共 URL 地址
    },
  }
}

注意事项

  • runtime 和 dependOn 不应在同一个入口上同时使用
  • 确保 runtime 不能指向已存在的入口名称
  • dependOn 不能是循环引用的

经验之谈

  • 在webpack4.x及以上版本,不要为 vendor 或其他不是执行起点创建 entry,而是使用 optimization.splitChunks 选项,将 vendor 和 app(应用程序) 模块分开,并为其创建一个单独的文件
  • 每个 HTML 文档只使用一个入口起点

loader

定义

loader用于转换某些类型的模块,webpack 只能理解 JavaScript 和 JSON 文件,这是 webpack 开箱可用的自带能力。loader 让 webpack 能够去处理其他类型的文件,并将它们转换为有效模块,以供应用程序使用,以及被添加到依赖图中。

使用方式

  • 配置方式:在 webpack.config.js 文件中指定 loader

  • 内联方式:在每个 import 语句中显式指定 loader,通过为内联 import 语句添加前缀,可以覆盖配置文件中的所有 loader, preLoader 和 postLoader

    // 传递参数(类似 webpack 配置中的 options)
    import 'style-loader!css-loader?modules=true!./styles.css'
    
    // 多个参数用 & 分隔
    import 'url-loader?limit=1024&name=images/[hash].[ext]!./image.png'
    
    // 使用 ! 前缀,将禁用所有已配置的 normal loader(普通 loader)
    import Styles from '!style-loader!css-loader?modules!./styles.css'
    
    // 使用 !! 前缀,将禁用所有已配置的 loader(preLoader, loader, postLoader)
    import Styles from '!!style-loader!css-loader?modules!./styles.css'
    
    // 使用 -! 前缀,将禁用所有已配置的 preLoader 和 loader,但是不禁用 postLoaders
    import Styles from '-!style-loader!css-loader?modules!./styles.css'

知识点

  • loader本质上是导出为函数的JavaScript模块,webpack内部的loader runner会调用该函数,并将上一个loader的输出结果作为参数传给该函数
  • loader的执行顺序是从右到左,从下到上,可以通过enforce配置项或loader的pitch方法改变执行顺序,最后一个loader的输出结果应该为String或Buffer类型
  • loader可以是同步的,也可以是异步的
  • loader运行在Node.js中
  • loader内部的this上可访问的属性和方法都通过loader context上下文绑定

编写一个loader

// my-loader.js

// 同步loader
module.exports = function(source, map, meta) {
  // 单个处理结果可以直接return
  // return doSomething(source)
  // 多个处理结果则必须调用this.callback()
  this.callback(null, doSomething(source), map, meta)
  return
}

// 异步loader,必须调用this.async()来告知loader runner等待异步输出结果
// 它会返回 this.callback() 回调函数。随后 loader 必须返回 undefined 并且调用该回调函数
module.exports = function(source, map, meta) {
  var callback = this.async()
  doSomeAsyncthing(source, function(err, res) {
    if (err) {
      return callback(err)
    }
    callback(null, res, map, meta)
  })
}

plugin

定义

webpack插件是一个具有 apply 方法的 JavaScript 类。apply 方法会被 webpack compiler 调用,并且在 整个 编译生命周期都可以访问 compiler 对象,插件目的在于解决 loader 无法实现的其他事,包括:打包优化,资源管理,注入环境变量。

使用方式

  • 配置方式:在webpack.config.js文件中,向 plugins 属性传入一个 new 实例
  • Node API方式:
    const webpack = require('webpack') // 访问 webpack 运行时(runtime)
    const configuration = require('./webpack.config.js')
    
    let compiler = webpack(configuration)
    
    new webpack.ProgressPlugin().apply(compiler)
    
    compiler.run(function (err, stats) {
      // ...
    })

编写一个plugin

一个生成构建文件清单的插件:

// fileListPlugin.js
class FileListPlugin {
  constructor(options) {
    this.options = Object.assign({
      filename: '',
      title: '',
    }, options)
  }
  
  apply(compiler) {
    const { filename, title } = this.options
    
    compiler.hooks.emit.tapAsync('FileListPlugin', function (compilation, callback) {
      // 创建文件内容
      let fileList = title
      
      // 遍历所有编译资源
      for (const asset in compilation.assets) {
        const size = compilation.assets[asset].size()
        fileList += `- ${asset} (${size} bytes)\n`
      }
      
      // 将清单作为新资源添加到编译中
      compilation.assets[filename] = {
        source: () => fileList,
        size: () => fileList.length
      }
      
      callback()
    })
  }
}
module.exports = FileListPlugin

模块解析

webpack 使用 enhanced-resolve 来解析文件路径,支持三种文件路径的解析:

  • 相对路径:import '../src/file1'
  • 绝对路径:import '/home/me/file'
  • 模块路径:import 'module/lib/file'

解析规则

当文件路径类型为模块路径时,路径解析遵循以下规则:

  • 基于webpack.config.js中配置的resolve.modules中指定的目录(默认是['node_modules'])进行检索,如果添加一个目录到模块搜索目录,此目录优先于 node_modules/ 搜索

    // webpack.config.js
    const path = require('path')
    
    module.exports = {
      //...
      resolve: {
        modules: [path.resolve(__dirname, 'src'), 'node_modules'],
      },
    }
    // src/index.js
    // 将优先从src目录下开始检索
    import { Tabs } from 'antd'
  • 如果模块(package)中有package.json文件,则在resolve.exportsFields配置项中指定的字段会被依次查找,优先级高于package.json中的main/module字段

    // webpack.config.js
    module.exports = {
      //...
      resolve: {
        exportsFields: ['exports', 'myCompanyExports'],
      },
    }
    // myPkg中的package.json
    {
      name: 'myPkg',
      scripts: {},
      exports: {
        '.': {
          "require": "./index.cjs",
          "import": "./index.mjs",
        },
      },
      myCompanyExports: {
        '.': {
          "require": "./index.cjs",
          "import": "./index.mjs",
        },
      },
    }
    // src/index.js
    // ES模块导入
    import myPkg from 'myPkg'
    // CommonJS模块导入
    const myPkg = require('myPkg')

一旦根据上述规则解析路径后,enhanced-resolve 将会检查路径是指向文件还是文件夹。

如果路径指向文件:

  • 如果文件具有扩展名,则直接将文件打包
  • 否则,将使用 resolve.extensions 选项作为文件扩展名来解析,此选项会告诉解析器在解析中能够接受那些扩展名(例如 .js,.jsx)

如果路径指向一个文件夹,则进行如下步骤寻找具有正确扩展名的文件:

  • 如果文件夹中包含 package.json 文件,则会根据 resolve.mainFields 配置中的字段顺序查找,并根据 package.json 中的符合配置要求的第一个字段来确定文件路径。
    // webpack.config.js
    module.exports = {
      //...
      resolve: {
        mainFields: ['browser', 'module', 'main'],
      },
    }
    // myPkg中的package.json
    {
      "name": 'myPkg',
      "browser": "build/myPkg.js",
      "module": "index"
    }
    // src/index.js
    // 从build/myPkg.js中查找
    import * as myPkg from 'myPkg'
  • 如果不存在 package.json 文件或 resolve.mainFields 没有返回有效路径,则会根据 resolve.mainFiles 配置选项中指定的文件名顺序查找,看是否能在 import/require 的目录下匹配到一个存在的文件名
  • 然后使用 resolve.extensions 选项,以类似的方式解析文件扩展名

runtime和mainfest

在使用 webpack 构建的典型应用程序或站点中,有三种主要的代码类型:

  • 你或你的团队编写的源码。
  • 你的源码会依赖的任何第三方的 library 或 “vendor” 代码。
  • webpack 的 runtime 和 manifest,管理所有模块的交互。

runtime

主要指在浏览器运行过程中,webpack用来连接各个模块化代码的代码,runtime代码使模块化代码能加载和解析其他的模块化代码,从而与其他模块化代码交互。可以使用 optimization.runtimeChunk: 'single' 选项将 runtime 代码拆分为一个单独的 chunk。

mainfest

mainfest是一张映射表,记录了模块打包后的关键元数据,用来在运行时管理模块的加载和依赖关系。理解 Manifest 是掌握 Webpack 运行机制的关键,尤其在 代码分割缓存策略 优化中至关重要!

mainfest的主要作用有以下三点:

  • 模块标识:将打包前的模块路径映射为打包后的模块ID
  • 动态加载:管理异步加载的模块(如import()拆分的代码块)
  • 缓存优化:通过内容哈希实现持久缓存

典型的mainfest内容如下:

{
  "modules": {
    "./src/index.js": 0,          // 模块路径 → 模块ID
    "./src/utils.js": 1
  },
  "chunks": {
    "main": {                     // 入口代码块
      "js": "main.abc123.js",     // 生成的文件名
      "contains": [0, 1]          // 包含的模块ID
    },
    "async-chunk": {              // 异步代码块
      "js": "async.def456.js",
      "loaded": false             // 标记是否已加载
    }
  },
  "runtime": "runtime.xyz789.js"  // Webpack 运行时代码
}

代码分离

代码分离是webpack比较重要的特性,它能够将代码分离到不同的bundle,然后按需加载或并行加载,控制资源加载的优先级,提升页面加载速度,常见的代码分离方式有三种:

  • 入口分离:通过entry配置将三方库(如react、antd等)配置成单独的entry,实现vendor分离
  • 防止重复:使用入口依赖(entry dependOn)或splitChunksPlugin去重和分离chunk
  • 动态导入:将顶部的import _ from lodash改为在实际需要lodash的地方引入import(lodash).then(({ default: _ }) => {})

Webpack v4.6.0+ 增加了对预获取和预加载的支持:

  • 预获取:import(/* webpackPrefetch: true */ './path/to/LoginModal.js')
  • 预加载:import(/* webpackPreload: true */ 'ChartingLibrary')

可以使用webpack官方分析工具或者其他三方工具(webpack-bundle-analyzer、webpack-visualizer等)分析分离后的bundle,进一步优化代码

缓存/性能优化

缓存

  • 基于浏览器缓存规则,对output.filename使用contenthash可使文件名根据文件内容变化而变化
  • 使用 optimization.runtimeChunk: 'single' 选项将 runtime 代码拆分为一个单独的 chunk
  • 使用 splitChunksPlugin插件的cacheGroup选项来缓存很少修改的三方库
  • 通过模块标识符(optimization.moduleIds: 'deterministic')保证多次构建后三方vendor的文件名hash值不变

性能优化

  • 使用最新版本的webpack、node、npm
  • 精准使用loader,通过include/exclude排查不需要转换的资源
  • 尽可能少使用loader/plugin,它们会额外的消耗性能
  • 精确的匹配文件资源,减少资源解析的时间,减少resolve.modules, resolve.extensions, resolve.mainFiles, resolve.descriptionFiles 中条目数量,因为他们会增加文件系统调用的次数:import './src/demo'改为import './src/demo.js',resolve.extensions: [‘.js’, ‘.jsx’]
  • 使用Dllplugin为更改不频繁的代码单独编译
  • 使用更少/更小的三方库,删除未使用的代码,更合理的分离chunk,减少大体积资源的生成
  • 使用thread-loader将耗资源的loader交给worker池
  • 使用webpack的cache并配合package.json中的postinstall清除缓存目录实现持久化缓存
  • 移除webpack ProgressPlugin,该插件没多大实际用途,且耗费资源
  • 分环境使用loader/plugin,不要紧生产环境、开发环境的loader、plugin混用
  • 使用webpack压缩工具压缩生产环境代码
  • source-map非常消耗资源,生产环境应禁用
  • 合理的使用webpack三方工具(babel、typescript、sass等)
  • webpack5.x支持模块联邦,可以在微前端架构中提取公共模块
  • 合理使用tree shaking:通过webpack的optimization.usedExports: true配合package.json的sideEffects: false将文本标记为无副作用。tree shaking只适用于ES2015以上的import/export语法
  • 真正意义上的懒加载:比如button click事件触发时再动态import lodash

兼容性

webpack运行环境和webpack构建出来的项目的运行环境不是一个概念,webpack基于Node.js运行,webpack构建出来的项目一般基于浏览器环境运行

浏览器兼容性

webpack支持所有符合ES5标准的浏览器(不支持IE8及以下版本),如果想兼容旧版本浏览器,需要加载polyfill:

// src/index.js
import 'babel-polyfill'

function Component() {}

配置兼容性

webpack.config.js配置文件是webpack运行过程中本身需要的文件,应避免ES6语法编写代码:

  • webpack基于Node.js运行,遵循CommonJS规范
  • Node.js v12.x及以上版本才开始支持原生ES6模块(.mjs),v14.x及以上版本才稳定支持ES6
  • webpack原生支持ES6模块是指支持项目代码中的import/export语句,但仅限于模块化依赖分析,不包含语法转换。Webpack 会将 import/export 转换为自己的模块系统(如__webpack_require__),实现代码拆分和依赖管理。

零碎知识

  • webpack通过mode配置项设置模式,mode取值范围:string = ‘production’: ‘none’ | ‘development’ | ‘production’
  • webpack通过target配置项设置构建目标,默认值为 “browserslist”,target: string | [string] | false,多target通过多个独立的配置来实现:module.exports = [serverConfig, clientConfig]
  • 开发环境通过source-map跟踪堆栈信息(devtool: 'inline-source-map'),生产环境不建议使用source-map
  • webpack 提供了几种可选方式帮助在代码发生变化后自动编译代码:–watch、webpack-dev-server、webpack-dev-middleware,watch模式需要手动刷新浏览器,middleware是server的内部实现核心,middleware+express -> server
  • publicPath 配置选项在各种场景中都非常有用。可以通过它来指定应用程序中所有资源的基础路径
  • webpack5.x版本新增了资源模块,可以不再需要像之前的版本配置各种loader来转换静态资源(图片、字体等)
  • 命令行接口参数的优先级高于配置文件中的参数,例如:build: webpack --mode="production"会优于webpack.config.js中的mode: development
  • 可以结合mainfest和stats data分析数据,优化构建性能,mainfest存在于运行时(浏览器环境),stats data存在于构建后(本地json,不影响运行时)