重温前端打包工具-Webpack
Webpack
理解 Webpack,最好不要一上来就把它拆成一堆孤立概念。Webpack 的核心其实只有一句话:它从入口文件开始,递归分析项目里的模块依赖,把 JavaScript、CSS、图片、字体、框架组件等资源都纳入同一张依赖图,最后输出浏览器能够加载的静态资源。
所以 Webpack 首先是一个静态模块打包器。所谓“静态”,是指它在构建阶段分析代码;所谓“模块”,是指在 Webpack 的视角里,不只有 JS 文件才是模块,CSS、图片、字体、Vue 单文件组件、TypeScript 文件也都可以是模块;所谓“打包”,就是把这些模块经过转换、合并、拆分、优化之后,生成一个或多个 bundle。
这也是 Webpack 和 Grunt、Gulp 这类工具最大的区别。Grunt 和 Gulp 更像任务执行器,关注“文件如何按任务流转”,例如先编译 Sass,再压缩 JS,再复制图片。Webpack 更关注“模块之间如何依赖”,只要告诉它入口在哪里,它就会沿着 import、require、CSS 中的 url() 等关系自动构建依赖图。它当然也能完成编译、压缩、复制资源这些任务,但它的出发点不是任务流水线,而是模块图。
从入口到出口:Webpack 如何组织一次构建
Webpack 配置里最基础的是 entry 和 output。entry 告诉 Webpack 从哪个文件开始分析依赖;output 告诉 Webpack 最终把构建结果输出到哪里、文件如何命名。
1 | |
单入口常用于单页应用,多入口常用于多页面应用。多入口时,每个入口通常对应一个页面,Webpack 会为不同入口生成不同 chunk。再配合 HtmlWebpackPlugin,可以让每个页面只引入自己需要的 chunk。
1 | |
完整构建流程可以理解成四步。
- 第一步是初始化,Webpack 会读取配置文件和命令行参数,合并出最终配置。
- 第二步是编译,它从
entry出发,解析模块内容,遇到非原生可理解的文件就交给 loader 转换,然后继续分析这些模块的依赖,直到形成完整的依赖图。 - 第三步是生成资源,Webpack 会把模块组合成 chunk,再把 chunk 转成最终文件。
- 第四步是输出,把文件写入
output.path。在这整个过程中,plugin 会在不同生命周期钩子里介入,比如生成 HTML、清理目录、压缩资源、注入变量等。
Loader:让 Webpack 看懂各种文件
Webpack 原生只能理解 JavaScript 和 JSON。如果项目里有 CSS、Sass、Less、图片、字体、TypeScript、JSX、Vue 单文件组件,这些内容都需要经过 loader 转换成 Webpack 能够纳入依赖图的模块。
Loader 的核心职责是“转换单个文件”。它配置在 module.rules 里,每条规则通常包含 test 和 use。test 用来匹配文件,use 指定使用哪些 loader。多个 loader 的执行顺序是从右到左,也就是 use: ['style-loader', 'css-loader', 'sass-loader'] 中,先执行 sass-loader,再执行 css-loader,最后执行 style-loader。
1 | |
处理 JavaScript 兼容性时,最常见的是 babel-loader。它是 Webpack 和 Babel 之间的桥梁,用 Babel 把 ES6+ 代码转换成更兼容的 JavaScript。(实际工作都是babel做的,babel-loader只是为了兼容webpack做的adapter)实际项目中通常还会有 babel.config.js 或 .babelrc,用 @babel/preset-env 控制目标浏览器,用 @babel/preset-react 处理 JSX,用 @babel/preset-typescript 处理 TypeScript。
1 | |
如果要支持 TypeScript,也可以直接用 ts-loader。这种方式需要安装 typescript 和 ts-loader,并准备 tsconfig.json。
1 | |
样式处理最典型的是 css-loader 和 style-loader 的配合。css-loader 负责解析 CSS 中的 @import 和 url(),让 CSS 变成 Webpack 能识别的模块;style-loader 负责把解析后的 CSS 通过 <style> 标签注入页面。也就是说,css-loader 解决“读懂 CSS”,style-loader 解决“把 CSS 应用到页面”。
如果使用 Sass 或 Less,就在链路最右侧再加预处理器 loader。
1 | |
生产环境里通常不再用 style-loader 把样式塞进 JS,而是用 MiniCssExtractPlugin.loader 把 CSS 抽成独立文件。这样 CSS 和 JS 可以并行加载,也能分别缓存。
1 | |
处理图片和字体时,Webpack 5 推荐使用内置 Asset Modules,不再需要 Webpack 4 时代常见的 file-loader 和 url-loader。asset/resource 会输出独立文件并返回 URL,类似 file-loader;asset/inline 会把资源转成 Data URI,类似 url-loader;asset/source 会导出资源源码;asset 会根据文件大小自动在独立文件和内联之间选择。
1 | |
如果是 Webpack 4,图片字体一般用 file-loader 或 url-loader。file-loader 负责复制文件并返回 URL;url-loader 在文件小于 limit 时会转成 Base64 内联,大于阈值时行为类似 file-loader。
1 | |
Loader 也可以自定义。一个 loader 本质上就是一个 Node.js 模块,导出一个函数,接收源文件内容,返回转换后的内容。
1 | |
使用自定义 loader 时,可以通过 resolveLoader.modules 告诉 Webpack 去哪里找 loader。
1 | |
对于耗时 loader,还可以开启缓存。比如 babel-loader 支持 cacheDirectory: true,Webpack 5 本身也提供文件系统级别的持久化缓存。
1 | |
Plugin:介入整个构建生命周期
如果说 loader 解决的是“某类文件怎么转换”,那么 plugin 解决的是“构建过程中要额外做什么”。Plugin 作用于整个构建流程,可以监听 Webpack 生命周期钩子,在合适时机执行任务,比如生成 HTML、清理 dist、提取 CSS、压缩资源、注入环境变量、分析包体积、拆分公共代码等。
Loader 和 plugin 的区别可以这样记:loader 面向单个模块,职责是转换;plugin 面向整个 compilation,职责是增强。需要改文件内容,用 loader;需要参与构建过程、产物管理或优化,用 plugin。
Plugin 配置在 plugins 数组中,通常需要 new 一个实例。
1 | |
HtmlWebpackPlugin 用来生成 HTML,并自动把构建出来的 JS 和 CSS 注入进去。它可以指定模板、输出文件名、注入位置、压缩选项,也可以通过 chunks 控制某个 HTML 只引入指定 chunk,这在多页面应用里很常见。
1 | |
CleanWebpackPlugin 的作用是构建前清理输出目录,避免 dist 中残留旧文件。不过 Webpack 5 已经内置了这个能力,推荐直接使用 output.clean: true。
1 | |
DefinePlugin 全局变量无脑替换插件,用于在编译时创建全局常量。需要注意,它做的是文本替换,不是定义插件,所以值通常要用 JSON.stringify 包起来。否则 'production' 可能会被替换成变量名 production,而不是字符串字面量。
- 服务器一般通过nginx转发,不通过 defineplugin 硬编码,可是有一些第三方服务依然需要
1 | |
Webpack 配置环境还可以通过 mode 和 --env。mode 设置为 development 或 production 后,Webpack 会自动启用对应默认优化,并设置 process.env.NODE_ENV。--env 可以把命令行参数传给配置函数。
1 | |
自定义 plugin 通常是一个类,并实现 apply 方法。Webpack 启动时会调用 apply,并传入 compiler,插件可以通过 compiler.hooks 注册生命周期事件。
1 | |
开发体验:DevServer、HMR、Source Map 和代理
开发阶段最常见的组合是 webpack-dev-server、HMR 和 Source Map。Webpack DevServer 是一个基于 Express 的轻量开发服务器,可以提供静态资源服务、自动刷新、热模块替换和接口代理。
1 | |
HMR,也就是 Hot Module Replacement,指的是应用运行时不刷新整个页面,只替换、添加或删除发生变化的模块。配置上通常在 devServer 中开启 hot: true。Webpack 4+ 在开发模式下通常会自动添加 HotModuleReplacementPlugin。如果是 React、Vue 等框架,脚手架往往已经处理好模块更新逻辑;如果是原生 JS,则可能需要手动使用 module.hot.accept() 指定更新后的处理方式。
关于webpack热模块更新的总结如下:
- 通过
webpack-dev-server创建两个服务器:提供静态资源的服务(express)和Socket服务 - express server 负责直接提供静态资源的服务(打包后的资源直接被浏览器请求和解析)
- socket server 是一个 websocket 的长连接,双方可以通信
- 当 socket server 监听到对应的模块发生变化时,会生成两个文件.json(manifest文件)和.js文件(update chunk)
- 通过长连接,socket server 可以直接将这两个文件主动发送给客户端(浏览器)
- 浏览器拿到两个新的文件后,通过HMR runtime机制,加载这两个文件,并且针对修改的模块进行更新
Source Map 用来把打包转换后的代码映射回原始源码,让浏览器调试时看到的是源文件,而不是构建后的 bundle。它通过 devtool 配置。常见值包括 eval、source-map、eval-source-map、cheap-module-source-map、inline-source-map 等。开发环境常用 eval-cheap-module-source-map,兼顾构建速度和调试体验;生产环境如果需要线上调试可以用 source-map,但要注意源码暴露风险,也可以选择关闭。
1 | |
开发环境跨域问题通常用 devServer.proxy 解决。例如把 /api 开头的请求代理到后端服务:
1 | |
框架支持:React、Vue、Angular
Webpack 支持框架的方式,本质仍然是 loader 和 plugin 的组合。
React 主要是处理 JSX,一般使用 babel-loader 加 @babel/preset-react。
1 | |
Vue 需要 vue-loader 处理 .vue 单文件组件,同时还要配合 VueLoaderPlugin。Vue 2 需要 vue-template-compiler,Vue 3 需要 @vue/compiler-sfc。
1 | |
Angular 通常不需要手动配置 Webpack,因为 Angular CLI 已经封装了构建链路。除非项目有特殊需求,否则直接使用 Angular CLI 是更常见的选择。
生产优化:代码分割、Tree Shaking、压缩和缓存
Webpack 的生产优化可以围绕两个目标理解:让用户少下载、让浏览器好缓存。
代码分割 Code Splitting 是把代码拆成多个 bundle 或 chunk,而不是一次性把所有代码打进一个大文件。这样首屏只加载必要代码,其他部分可以按需加载或并行加载。Webpack 实现代码分割主要有三种方式:多入口、动态导入和 SplitChunksPlugin。本质上都是为了减少用户首次加载时一次性加载过多的文件卡死。
动态导入使用 import() 语法。Webpack 看到它时,会自动把对应模块拆成独立 chunk,并在代码执行到这里时再加载。
1 | |
SplitChunksPlugin 是 Webpack 内置的公共代码拆分能力,配置位置是 optimization.splitChunks。它可以把 node_modules 中的依赖或多个入口共享的模块提取到单独 chunk,减少重复代码,也有利于长期缓存。
1 | |
Webpack 5 在 production 模式下已经有比较智能的默认分包策略,很多项目不需要一开始就手动写复杂的 cacheGroups。
Tree Shaking 是移除未引用代码的优化技术。它依赖 ES Module 的静态结构,也就是 import 和 export。CommonJS 的 require 更动态,不利于静态分析。要让 Tree Shaking 生效,通常需要使用 ES Module,开启 production 模式,并正确声明副作用。如果项目代码没有副作用,可以在 package.json 里设置:
1 | |
如果某些文件确实有副作用,比如全局样式或 polyfill 或者某些js,就要显式保留:
1 | |
- js 副作用场景,如果你开启了sideEffects: false 选项,这里就会无脑取消你的 window.isHappy = true; 操作。因为引入js即全局作用域的代码就会被执行
1 | |
压缩方面,production 模式下 Webpack 会自动使用 TerserPlugin 压缩 JS。CSS 压缩通常使用 CssMinimizerWebpackPlugin。
1 | |
缓存可以分成构建缓存和浏览器缓存。构建缓存用于提高本地二次构建速度,Webpack 5 可以开启文件系统缓存,babel-loader 也可以开启自己的缓存。浏览器缓存则主要依赖输出文件名中的 [contenthash]。内容不变,hash 不变,浏览器可以继续使用缓存;内容变化,hash 变化,浏览器会请求新文件。
包体积分析可以使用 webpack-bundle-analyzer。它能可视化展示每个依赖占用的体积,帮助定位过大的库、重复打包、拆包不合理等问题。
- webpack可以打包
require,但是tree-shaking时require进来的会失效,webpack 可以同时打包 require 和 ESM,他自己做的有兼容操作。2015年之前前端都是用 CommonJS 写的。
面试收束:如何把这些点串起来
如果面试官问“Webpack 是什么”,可以从模块打包器开始回答:Webpack 从入口出发构建依赖图,把各种资源当作模块处理,最终输出 bundle。
如果继续问“它怎么处理不同文件”,就顺着讲 loader:Webpack 原生只认识 JS 和 JSON,其他资源需要 loader 转换;loader 写在 module.rules,按从右到左执行;CSS、Sass、Babel、TypeScript、图片字体都可以放到这条线里说。
如果问“loader 和 plugin 区别”,就强调职责边界:loader 转换单个文件,plugin 监听构建生命周期、增强整个构建流程。生成 HTML、抽离 CSS、定义环境变量、清理输出目录、代码分割、分析包体积,这些都属于 plugin 或 optimization 参与的范围。
如果问“Webpack 怎么优化性能”,可以从开发和生产分开。开发侧关注 DevServer、HMR、Source Map、缓存;生产侧关注 Tree Shaking、Code Splitting、懒加载、压缩、contenthash、SplitChunks、CSS 抽离和包体积分析。
这样整套知识就不是“基础概念、配置、Loader、Plugin”四块彼此重复的问答,而是一条完整链路:Webpack 为什么出现,它如何从入口构建依赖图,如何用 loader 读懂资源,如何用 plugin 介入构建流程,如何服务开发体验,最后如何优化生产产物。