Webpack 详解
前端开发已经模块化,它改进了代码库的封装和结构。打包工具已经成为了一个项目必不可少的部分,如今这儿有几种可能的选择,例如 webpack,grunt,gulp 等。webpack 因为他的功能和扩展性在过去的几年中,受到非常大的欢迎。

不像大多数的模块打包机,webpack 将项目当作一个整体,通过一个给定的的主文件,从这个文件开始找到项目的所有依赖文件,使用 loaders 处理它们,最后打包成一个或多个浏览器可识别的 js 文件。
(一)webpack 作用
- 模块打包。可以将不同模块的文件打包整合在一起,并且保证它们之间的引用正确,执行有序。利用打包就可以在开发时根据自身业务自由划分文件模块,保证项目结构的清晰和可读性。
- 编译兼容。在前端发展早期阶段,手写一堆浏览器兼容代码一直是令前端工程师头皮发麻的事情,而在今天这个问题被大大弱化了,通过
webpack的Loader机制,不仅仅可以对代码做polyfill,还可以编译转换诸如.less, .vue, .jsx这类在浏览器无法识别的格式文件,让我们在开发时可以使用新特性和新语法,提高开发效率。 - 能力扩展。通过
webpack的Plugin机制,我们在实现模块化打包和编译兼容的基础上,可以进一步实现诸如按需加载,代码压缩等一系列功能,帮助我们进一步提高自动化程度,工程效率以及打包输出的质量。
(二)模块打包运行原理
Webpack究竟是如何把这些模块合并到一起,并且保证其正常工作的。首先我们应了解webpack的整个打包流程:
- 读取
webpack的配置参数; - 启动
webpack,创建Compiler对象并开始解析项目; - 从入口文件(
entry)开始解析,并且找到其导入的依赖模块,递归遍历分析,形成依赖关系树; - 对不同文件类型的依赖模块文件使用对应的
Loader进行编译,最终转为Javascript文件; - 整个过程中
webpack会通过发布订阅模式,向外抛出一些hooks,而webpack的插件即可通过监听这些关键的事件节点,执行插件任务进而达到干预输出结果的目的。
其中文件的解析与构建是一个比较复杂的过程,在webpack源码中主要依赖于compiler和compilation两个核心对象实现。
compiler对象是一个全局单例,它负责把控整个webpack打包的构建流程。compilation对象是每一次构建的上下文对象,它包含了当次构建所需要的所有信息,每次热更新和重新构建,compiler都会重新生成一个新的compilation对象,负责此次更新的构建过程。
而每个模块间的依赖关系,则依赖于AST抽象语法树。每个模块文件在通过Loader解析完成之后,会通过acorn库生成模块代码的AST语法树,通过语法树就可以分析这个模块是否还有依赖的模块,进而继续循环执行下一个模块的编译解析。最终Webpack打包出来的bundle文件是一个IIFE(立即调用函数表达式)的执行函数。
1 | // webpack 5 打包的 bundle 文件内容 |
(三)sourceMap
sourceMap是一项将编译、打包、压缩后的代码映射回源代码的技术,由于打包压缩后的代码并没有阅读性可言,一旦在开发中报错或者遇到问题,直接在混淆代码中debug问题会带来非常糟糕的体验,sourceMap可以帮助我们快速定位到源代码的位置,提高我们的开发效率。sourceMap并非Webpack特有的功能,像JQuery也支持sourceMap。
既然是一种源码的映射,那必然就需要有一份映射的文件,来标记混淆代码里对应的源码的位置,通常这份映射文件以.map结尾,里边的数据结构大概长这样:
1 | { |
其中mappings数据有如下规则:
- 生成文件中的一行的每个组用“;”分隔;
- 每一段用“,”分隔;
- 每个段由 1、4 或 5 个可变长度字段组成;
有了这份映射文件,我们只需要在压缩代码的最末端加上这句注释,即可让 sourceMap 生效:
1 | //# sourceURL=/path/to/file.js.map |
有了这段注释后,浏览器就会通过sourceURL去获取这份映射文件,通过解释器解析后,实现源码和混淆代码之间的映射。因此 sourceMap 其实也是一项需要浏览器支持的技术。
如果我们仔细查看 webpack 打包出来的 bundle 文件,就可以发现在默认的development开发模式下,每个_webpack_modules__文件模块的代码最末端,都会加上//# sourceURL=webpack://file-path?,从而实现对 sourceMap 的支持。
(四)webpack 使用
install
首先添加我们即将使用的包:
1 | npm install webpack webpack-dev-server --save-dev |
webpack 是我们需要的模块打包机,webpack-dev-server 用来创建本地服务器,监听你的代码修改,并自动刷新修改后的结果。这些是 webpack.config.js 文件中有关 devServer 的配置。
contentBase:为文件提供本地服务器port:监听端口,默认 8080inline:设置为 true,源文件发生改变自动刷新页面historyApiFallback:依赖 HTML5 history API,如果设置为 true,所有的页面跳转指向 index.html
1 | // exemple |
1 | // 在'package.json'添加两个命令用于本地开发和生产发布 |
Entry
entry: 用来写入口文件,它将是整个依赖关系的根
1 | var baseConfig = { |
当我们需要多个入口文件的时候,可以把 entry 写成一个对象
1 | var baseConfig = { |
Output
output: 即使入口文件有多个,但是只有一个输出配置
1 | var path = require('path') |
如果你定义的入口文件有多个,那么我们需要使用占位符来确保输出文件的唯一性
1 | output: { |
如今这么少的配置,就能够运行一个服务器并在本地使用命令 npm start 或者 npm run build 来打包项目代码进行发布。
Loader
Webpack最后打包出来的成果是一份Javascript代码,实际上在Webpack内部默认也只能够处理JS模块代码,在打包过程中,会默认把所有遇到的文件都当作 JavaScript代码进行解析,因此当项目存在非JS类型文件时,我们需要先对其进行必要的转换,才能继续执行打包任务,这也是Loader机制存在的意义。
loader 的作用:
实现对不同格式的文件的处理,比如说将 scss 转换为 css,或者 typescript 转化为 js
转换这些文件,从而使其能够被添加到依赖图中
loader 是 webpack 最重要的部分之一,通过使用不同的 Loader,我们能够调用外部的脚本或者工具,实现对不同格式文件的处理,loader 需要在 webpack.config.js 里边单独用 module 进行配置,配置如下:
- test:匹配所处理文件的扩展名的正则表达式(必须)
- loader:loader 的名称(必须)
- include/exclude:手动添加处理的文件,屏蔽不需要处理的文件(可选)
- query:为 loaders 提供额外的设置选项
1 | // example |
要使 loader 工作,我们需要一个正则表达式来标识我们要修改的文件,然后用一个数组表示我们即将使用的 Loader,当然我们需要的 loader 需要通过 npm 进行安装。例如我们需要解析 less 的文件,那么 webpack.config.js 的配置如下:
1 | var baseConfig = { |
- babel-loader:让下一代的 js 文件转换成现代浏览器能够支持的 JS 文件。
- babel:有些复杂,所以大多数都会新建一个
.babelrc进行配置 - css-loader,style-loader:两个建议配合使用,用来解析 css 文件,能够解释
@import url(),如果需要解析 less 就在后面加一个 less-loader - file-loader:生成的文件名就是文件内容的 MD5 哈希值,并会保留所引用资源的原始扩展名
- url-loader:功能类似 file-loader,但是文件大小低于指定的限制时,可以返回一个
DataURL。事实上,在使用 less,scss,stylus 时,npm 会提示你差什么插件,差什么你安上就行了
Plugins
如果说Loader负责文件转换,那么Plugin便是负责功能扩展。Loader和Plugin作为Webpack的两个重要组成部分,承担着两部分不同的职责。上文已经说过,webpack基于发布订阅模式,在运行的生命周期中会广播出许多事件,插件通过监听这些事件,就可以在特定的阶段执行自己的插件任务,从而实现自己想要的功能。
既然基于发布订阅模式,那么知道Webpack到底提供了哪些事件钩子供插件开发者使用是非常重要的,上文提到过compiler和compilation是Webpack两个非常核心的对象,其中compiler暴露了和 Webpack整个生命周期相关的钩子(compiler-hooks),而compilation则暴露了与模块和依赖有关的粒度更小的事件钩子(Compilation Hooks)。
Webpack的事件机制基于webpack自己实现的一套Tapable事件流方案(github)
1 | // Tapable 的简单使用 |
Plugin的开发和开发Loader一样,需要遵循一些开发上的规范和原则:
- 插件必须是一个函数或者是一个包含
apply方法的对象,这样才能访问compiler实例; - 传给每个插件的
compiler和compilation对象都是同一个引用,若在一个插件中修改了它们的属性,会影响后面的插件; - 异步的事件需要在插件处理完任务时调用回调函数通知
Webpack进入下一个流程,不然会卡住;
了解了以上这些内容,想要开发一个 Webpack Plugin,其实也并不困难。
1 | class MyPlugin { |
loaders 负责处理源文件,如 css、jsx,一次处理一个文件。而 plugins 并不是直接操作单个文件,它直接对整个构建过程起作用。下面列举了一些常用的 plugins 和对应用法。
ExtractTextWebpackPlugin:
它会将入口中引用的 css 文件,都打包在独立的 css 文件中,而不是内嵌在 js 打包文件中。应用如下:
1 | var ExtractTextPlugin = require('extract-text-webpack-plugin') |
HtmlWebpackPlugin
依据一个简单的 index.html 模版,生成一个自动引用你打包后的 js 文件的新 index.html
1 | var HTMLWebpackPlugin = require('html-webpack-plugin') |
HotModuleReplacementPlugin
它允许在修改组件代码时自动进行刷新,以实时预览修改后的结果。注意不要在生产环境中使用 HMR(一般情况分为开发环境,测试环境,生产环境)。用法为: new webpack.HotModuleReplacementPlugin()
webpack.config.js 的全部内容示例
1 | const webpack = require("webpack") |
产品阶段的构建
目前为止,在开发阶段的东西已经基本完成。但在产品阶段还需要对资源进行别的处理,例如压缩,优化,缓存,分离 CSS 和 JS。首先我们来定义产品环境:
1 | var ENV = process.env.NODE_ENV |
然后还需要修改 script 命令:
1 | "scripts": { |
process.env.NODE_ENV 将被一个字符串替代,它运行压缩器排除那些不可到达的开发代码分支。
当你引入那些不会进行生产的代码,下面这个代码将非常有用。
1 | if (process.env.NODE_ENV === 'development') { |
优化插件
下面介绍几个插件用来优化代码
OccurrenceOrderPlugin:为组件分配 ID,通过这个插件 webpack 可以分析和优先考虑使用最多的模块,然后为它们分配最小的 IDUglifyJsPlugin:压缩代码
下面是他们的使用方法
1 | var baseConfig = { |
然后在我们使用 npm run build 会发现代码是压缩的。
(五)编写自定义 Plugin
插件是 webpack 的支柱功能。webpack 自身也是构建于,你在 webpack 配置中用到的相同的插件系统之上!插件目的在于解决 loader 无法实现的其他事。要想写好插件就要知道Webpack中的两个比较核心的概念compiler、compilation、tapable。在webpack 编译流程已经都要记录。 Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
实现一个 plugin
一个 webpack plugin 基本包含以下几步:
- 一个 JavaScript 函数或者类
- 在函数原型(prototype)中定义一个注入
compiler对象的apply方法。 apply函数中通过compiler插入指定的事件钩子,在钩子回调中拿到compilation对象- 使用
compilation操纵修改webapack内部实例数据。 - 异步插件,数据处理完后使用
callback回调
最后会实现一个简单的clean-webpack-plugin。
一个简单的插件
1 | class WebpackCleanupPlugin { |
如何使用在 webpack.config.js 中引入并且使用如下:
1 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); |
自己写的插件如下执行:
webpack启动后,在读取配置的过程中会先执行new WebpackCleanupPlugin(),初始化一个WebpackCleanupPlugin。- 在初始化
compiler对象后,再调用WebpackCleanupPlugin.apply(compiler)给插件实例传入compiler对象。 - 插件实例在获取到
compiler对象后,就可以通过compiler.plugin(事件名称,回调函数) 监听到Webpack广播出来的事件。 - 并且可以通过
compiler对象去操作webpack。
Compiler、Compilation
- Compiler 对象包含了 Webpack 环境所有的的配置信息,包含
options,hook,loaders,plugins这些信息,这个对象在Webpack启动时候被实例化,它是全局唯一的,可以简单地把它理解为Webpack实例;Compiler中包含的东西如下所示:
- Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当
Webpack以开发模式运行时,每当检测到一个文件变化,一次新的Compilation将被创建。Compilation对象也提供了很多事件回调供插件做扩展。通过Compilation也能读取到Compiler对象。
Compilation中包含的东西如下所示:
Compiler 和 Compilation 的区别在于:
Compiler代表了整个Webpack从启动到关闭的生命周期,而Compilation只是代表了一次新的编译。
一个简单的清除文件插件
每次打包如果文件有修改会生成新的文件,文件的hash也会跟着变化,那么这个改变了的文件,他以前的文件就是无效的了,要把以前的文件清除掉,我们使用比较多的就是clean-webpack-plugin,这里自己实现一个简单的文件清除。如果不知道hash、contenthash、chunkhash的区别可以看这一片文章。
大致分为以下几步:
- 获取
output路径,也就是出口路径一般为dist - 绑定钩子事件
compiler.plugin('done', (stats) => {}) - 编译文件,与原来文件对比,删除未匹配文件(同时可以 options 设置要忽略的文件)
代码实现如下
1 | const recursiveReadSync = require("recursive-readdir-sync"); |
上面的这个插件实现了一个清除编译文件的效果。在这里就不做实验了,如果有兴趣可以自己把代码 copy 到本地,运行一下看一下结果。
总结
在上面大致知道怎么写一个简单的清除文件的webpack的插件,其实还可以做更多的事情如下:
- 读取输出资源、代码块、模块及其依赖(在
emit事件发生) - 监听文件变化
watch-run - 修改输出资源
compilation.assets
具体实现可以看一下一下webpack 深入浅出