1. 常见的构建工具及对比
你一定会感叹前端技术发展之快,各种可以提高开发效率的新思想和框架被发明。但是这些东西都有一个共同点:源代码无法直接运行,必须通过转换后才可以正常运行。
构建就是做这件事情,把源代码转换成发布到线上的可执行 JavaScrip、CSS、HTML 代码,包括如下内容:
- 代码转换:TypeScript 编译成 JavaScript、SCSS 编译成 CSS 等。
- 文件优化:压缩 JavaScript、CSS、HTML 代码,压缩合并图片等。
- 代码分割:提取多个页面的公共代码、提取首屏不需要执行部分的代码让其异步加载。
- 模块合并:在采用模块化的项目里会有很多个模块和文件,需要构建功能把模块分类合并成一个文件。
- 自动刷新:监听本地源代码的变化,自动重新构建、刷新浏览器。
- 代码校验:在代码被提交到仓库前需要校验代码是否符合规范,以及单元测试是否通过。
- 自动发布:更新完代码后,自动构建出线上发布代码并传输给发布系统。
构建其实是工程化、自动化思想在前端开发中的体现,把一系列流程用代码去实现,让代码自动化地执行这一系列复杂的流程。 构建给前端开发注入了更大的活力,解放了我们前端的生产力。
历史上先后出现一系列构建工具,它们各有其优缺点。由于前端工程师很熟悉 JavaScript ,Node.js 又可以胜任所有构建需求,所以大多数构建工具都是用 Node.js 开发的。下面来一一介绍它们。
Npm Script
Npm Script 是一个任务执行者。Npm 是在安装 Node.js 时附带的包管理器,Npm Script 则是 Npm 内置的一个功能,允许在 package.json
文件里面使用 scripts
字段定义任务:
{
"scripts": {
"dev": "node dev.js",
"pub": "node build.js"
}
}
里面的 scripts
字段是一个对象,每个属性对应一段 Shell 脚本,以上代码定义了两个任务 dev
和 pub
。 其底层实现原理是通过调用 Shell 去运行脚本命令,例如执行 npm run pub
命令等同于执行命令 node build.js
。
Npm Script的优点是内置,无须安装其他依赖。其缺点是功能太简单,虽然提供了 pre
和 post
两个钩子,但不能方便地管理多个任务之间的依赖。
Grunt
Grunt 和 Npm Script 类似,也是一个任务执行者。Grunt 有大量现成的插件封装了常见的任务,也能管理任务之间的依赖关系,自动化执行依赖的任务,每个任务的具体执行代码和依赖关系写在配置文件 Gruntfile.js
里,例如:
module.exports = function(grunt) {
// 所有插件的配置信息
grunt.initConfig({
// uglify 插件的配置信息
uglify: {
app_task: {
files: {
'build/app.min.js': ['lib/index.js', 'lib/test.js']
}
}
},
// watch 插件的配置信息
watch: {
another: {
files: ['lib/*.js'],
}
}
});
// 告诉 grunt 我们将使用这些插件
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-watch');
// 告诉grunt当我们在终端中启动 grunt 时需要执行哪些任务
grunt.registerTask('dev', ['uglify','watch']);
};
在项目根目录下执行命令 grunt dev
就会启动 JavaScript 文件压缩和自动刷新功能。
Grunt的优点是:
- 灵活,它只负责执行你定义的任务;
- 大量的可复用插件封装好了常见的构建任务。
Grunt的缺点是集成度不高,要写很多配置后才可以用,无法做到开箱即用。
Grunt 相当于进化版的 Npm Script,它的诞生其实是为了弥补 Npm Script 的不足。
Gulp
Gulp 是一个基于流的自动化构建工具。 除了可以管理和执行任务,还支持监听文件、读写文件。Gulp 被设计得非常简单,只通过下面5个方法就可以胜任几乎所有构建场景:
- 通过
gulp.task
注册一个任务; - 通过
gulp.run
执行任务; - 通过
gulp.watch
监听文件变化; - 通过
gulp.src
读取文件; - 通过
gulp.dest
写文件。
Gulp 的最大特点是引入了流的概念,同时提供了一系列常用的插件去处理流,流可以在插件之间传递,大致使用如下:
// 引入 Gulp
var gulp = require('gulp');
// 引入插件
var jshint = require('gulp-jshint');
var sass = require('gulp-sass');
var concat = require('gulp-concat');
var uglify = require('gulp-uglify');
// 编译 SCSS 任务
gulp.task('sass', function() {
// 读取文件通过管道喂给插件
gulp.src('./scss/*.scss')
// SCSS 插件把 scss 文件编译成 CSS 文件
.pipe(sass())
// 输出文件
.pipe(gulp.dest('./css'));
});
// 合并压缩 JS
gulp.task('scripts', function() {
gulp.src('./js/*.js')
.pipe(concat('all.js'))
.pipe(uglify())
.pipe(gulp.dest('./dist'));
});
// 监听文件变化
gulp.task('watch', function(){
// 当 scss 文件被编辑时执行 SCSS 任务
gulp.watch('./scss/*.scss', ['sass']);
gulp.watch('./js/*.js', ['scripts']);
});
Gulp 的优点是好用又不失灵活,既可以单独完成构建也可以和其它工具搭配使用。其缺点是和 Grunt 类似,集成度不高,要写很多配置后才可以用,无法做到开箱即用。
可以将Gulp 看作 Grunt 的加强版。相对于 Grunt,Gulp增加了监听文件、读写文件、流式处理的功能。
Webpack
Webpack 是一个打包模块化 JavaScript 的工具,在 Webpack 里一切文件皆模块,通过 Loader 转换文件,通过 Plugin 注入钩子,最后输出由多个模块组合成的文件。Webpack 专注于构建模块化项目。
其官网的首页图很形象的画出了 Webpack 是什么,如下:
一切文件:JavaScript、CSS、SCSS、图片、模板,在 Webpack 眼中都是一个个模块,这样的好处是能清晰的描述出各个模块之间的依赖关系,以方便 Webpack 对模块进行组合和打包。 经过 Webpack 的处理,最终会输出浏览器能使用的静态资源。
Webpack 具有很大的灵活性,能配置如何处理文件,大致使用如下:
module.exports = {
// 所有模块的入口,Webpack 从入口开始递归解析出所有依赖的模块
entry: './app.js',
output: {
// 把入口所依赖的所有模块打包成一个文件 bundle.js 输出
filename: 'bundle.js'
}
}
Webpack的优点是:
- 专注于处理模块化的项目,能做到开箱即用一步到位;
- 通过 Plugin 扩展,完整好用又不失灵活;
- 使用场景不仅限于 Web 开发;
- 社区庞大活跃,经常引入紧跟时代发展的新特性,能为大多数场景找到已有的开源扩展;
- 良好的开发体验。
Rollup
Rollup 是一个和 Webpack 很类似但专注于 ES6 的模块打包工具。 Rollup 的亮点在于能针对 ES6 源码进行 Tree Shaking 以去除那些已被定义但没被使用的代码,以及 Scope Hoisting 以减小输出文件大小提升运行性能。 然而 Rollup 的这些亮点随后就被 Webpack 模仿和实现。 由于 Rollup 的使用和 Webpack 差不多,这里就不详细介绍如何使用了,而是详细说明它们的差别:
- Rollup 是在 Webpack 流行后出现的替代品;
- Rollup 生态链还不完善,体验不如 Webpack;
- Rollup 功能不如 Webpack 完善,但其配置和使用更加简单;
- Rollup 不支持 Code Spliting,但好处是打包出来的代码中没有 Webpack 那段模块的加载、执行和缓存的代码。
Rollup 在用于打包 JavaScript 库时比 Webpack 更加有优势,因为其打包出来的代码更小更快。 但功能不够完善,很多场景都找不到现成的解决方案。
为什么选择 Webpack?
上面介绍的构建工具是按照它们诞生的时间排序的,它们是时代的产物,侧面反映出 Web 开发的发展趋势如下:
- 在 Npm Script 和 Grunt 时代,Web 开发要做的事情变多,流程复杂,自动化思想被引入,用于简化流程;
- 在 Gulp 时代开始出现一些新语言用于提高开发效率,流式处理思想的出现是为了简化文件转换的流程,例如将 ES6 转换成 ES5。
- 在 Webpack 时代由于单页应用的流行,一个网页的功能和实现代码变得庞大,Web 开发向模块化改进。
这些构建工具都有各自的定位和专注点,它们之间既可以单独地完成任务,也可以相互搭配起来弥补各自的不足。 在了解这些常见的构建工具后,你需要根据自己的需求去判断应该如何选择和搭配它们才能更好地完成自己的需求。
经过多年的发展, Webpack 已经成为构建工具中的首选,这是有原因的:
- 大多数团队在开发新项目时会采用紧跟时代的技术,这些技术几乎都会采用“模块化+新语言+新框架”,Webpack 可以为这些新项目提供一站式的解决方案;
- Webpack 有良好的生态链和维护团队,能提供良好的开发体验和保证质量;
Webpack 被全世界的大量 Web 开发者使用和验证,能找到各个层面所需的教程和经验分享。
2. 核心概念
虽然Webpack 功能强大且配置项多,但只要你理解了其中的几个核心概念,就能随心应手地使用它。 Webpack 有以下几个核心概念:
Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
- Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
- Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
- Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
- Plugin:扩展插件,在 Webpack 构建流程中的特定时机注入扩展逻辑来改变构建结果或做你想要的事情。
- Output:输出结果,在 Webpack 经过一系列处理并得出最终想要的代码后输出结果。
Webpack 启动后会从 Entry 里配置的 Module 开始递归解析 Entry 依赖的所有 Module。 每找到一个 Module, 就会根据配置的 Loader 去找出对应的转换规则,对 Module 进行转换后,再解析出当前 Module 依赖的 Module。 这些模块会以 Entry 为单位进行分组,一个 Entry 和其所有依赖的 Module 被分到一个组也就是一个 Chunk。最后 Webpack 会把所有 Chunk 转换成文件输出。 在整个流程中 Webpack 会在恰当的时机执行 Plugin 里定义的逻辑。
在实际应用中你可能会遇到各种奇怪复杂的场景,不知道从哪开始。 根据以上总结,你会对 Webpack 有一个整体的认识,这能让你在以后使用 Webpack 的过程中快速知道应该通过配置什么去完成你想要的功能,而不是无从下手。
3. 配置用法
配置 Webpack 的方式有两种:
- 通过一个 JavaScript 文件描述配置,例如使用
webpack.config.js
文件里的配置; - 执行 Webpack 可执行文件时通过命令行参数传入,例如
webpack --devtool source-map
。
*还可以混合传入 例如可以执行 yarn build --devtool source-map
会合并命令行和js里面的配置,同种配置 terminal里面的优先级会更高
这两种方式可以相互搭配,例如执行 Webpack 时通过命令 webpack --config webpack-dev.config.js
指定配置文件,再去 webpack-dev.config.js
文件里描述部分配置。
按照配置方式来划分,可分为:
- 只能通过命令行参数传入的选项,这种最为少见;
- 只能通过配置文件配置的选项;
- 通过两种方式都可以配置的选项。
按照配置所影响的功能来划分,可分为:
- Entry 配置模块的入口;
- Output 配置如何输出最终想要的代码;
- Module 配置处理模块的规则;
- Resolve 配置寻找模块的规则;
- Plugins 配置扩展插件;
- DevServer 配置 DevServer;
- 其它配置项 其它零散的配置项;
- 整体配置结构 整体地描述各配置项的结构;
- 多种配置类型 配置文件不止可以返回一个 Object,还有其他返回形式;
- 配置总结 寻找配置 Webpack 的规律,减少思维负担
Entry
entry
是配置模块的入口,可抽象成输入,Webpack 执行构建的第一步将从入口开始搜寻及递归解析出所有入口依赖的模块。entry
配置是必填的,若不填则将导致 Webpack 报错退出。context
Webpack 在寻找相对路径的文件时会以context
为根目录,context
默认为执行启动 Webpack 时所在的当前工作目录。 如果想改变context
的默认配置,则可以在配置文件里这样设置它:
注意,module.exports = {
context: path.resolve(__dirname, 'app')
}
context
必须是一个绝对路径的字符串。 除此之外,还可以通过在启动 Webpack 时带上参数webpack --context
来设置context
。
之所以在这里先介绍context
,是因为 Entry 的路径和其依赖的模块的路径可能采用相对于context
的路径来描述,context
会影响到这些相对路径所指向的真实文件。Entry 类型
Entry 类型可以是以下三种中的一种或者相互组合:
类型 | 例子 | 含义 |
---|---|---|
string | './app/entry' |
入口模块的文件路径,可以是相对路径。 |
array | ['./app/entry1', './app/entry2'] |
入口模块的文件路径,可以是相对路径。 |
object | { a: './app/entry-a', b: ['./app/entry-b1', './app/entry-b2']} |
配置多个入口,每个入口生成一个 Chunk |
如果是 array
类型,则搭配 output.library
配置项使用时,只有数组里的最后一个入口文件的模块会被导出。
Chunk 名称
没有配置output的filename_时,_Webpack 会为每个生成的 Chunk 取一个名称,Chunk 的名称和 Entry 的配置有关:
- 如果
entry
是一个string
或array
,就只会生成一个 Chunk,这时 Chunk 的名称是main
; - 如果
entry
是一个object
,就可能会出现多个 Chunk,这时 Chunk 的名称是object
键值对里键的名称。配置动态 Entry
假如项目里有多个页面需要为每个页面的入口配置一个 Entry ,但这些页面的数量可能会不断增长,则这时 Entry 的配置会受到到其他因素的影响导致不能写成静态的值。其解决方法是把 Entry 设置成一个函数去动态返回上面所说的配置,代码如下:// 同步函数
entry: () => {
return {
a:'./pages/a',
b:'./pages/b',
}
};
// 异步函数
entry: () => {
return new Promise((resolve)=>{
resolve({
a:'./pages/a',
b:'./pages/b',
});
});
};
Output
output
配置如何输出最终想要的代码。output
是一个object
,里面包含一系列配置项。filename
output.filename
配置输出文件的名称,为string 类型。 如果只有一个输出文件,则可以把它写成静态不变的:
但是在有多个 Chunk 要输出时,就需要借助模版和变量了。前面说到 Webpack 会为每个 Chunk取一个名称,可以根据 Chunk 的名称来区分输出的文件名:output:{
filename: 'bundle.js'
}
代码里的output:{
filename: '[name].js'
}
[name]
代表用内置的name
变量去替换[name]
,这时你可以把它看作一个字符串模块函数, 每个要输出的 Chunk 都会通过这个函数去拼接出输出的文件名称。
内置变量除了name
还包括:
变量名 | 含义 |
---|---|
id | Chunk 的唯一标识,从0开始 |
name | Chunk 的名称 |
hash | Chunk 的唯一标识的 Hash 值 |
chunkhash | Chunk 内容的 Hash 值 |
其中 hash
和 chunkhash
的长度是可指定的,[hash:8]
代表取8位 Hash 值,默认是20位。
注意 ExtractTextWebpackPlugin 插件是使用 contenthash
来代表哈希值而不是 chunkhash
, 原因在于 ExtractTextWebpackPlugin 提取出来的内容是代码内容本身而不是由一组模块组成的 Chunk。
chunkFilename
output.chunkFilename
配置无入口的 Chunk 在输出时的文件名称。 chunkFilename 和上面的 filename 非常类似,但 chunkFilename 只用于指定在运行过程中生成的 Chunk 在输出时的文件名称。 常见的会在运行时生成 Chunk 场景有在使用 CommonChunkPlugin、使用 import('path/to/module')
动态加载等时。 chunkFilename 支持和 filename 一致的内置变量。
path
output.path
配置输出文件存放在本地的目录,必须是 string 类型的绝对路径。通常通过 Node.js 的 path
模块去获取绝对路径:
publicPath
在复杂的项目里可能会有一些构建出的资源需要异步加载,加载这些异步资源需要对应的 URL 地址。output.publicPath
配置发布到线上资源的 URL 前缀,为string 类型。 默认值是空字符串 ''
,即使用相对路径。
这样说可能有点抽象,举个例子,需要把构建出的资源文件上传到 CDN 服务上,以利于加快页面的打开速度。配置代码如下:
output:{
filename:'[name]_[chunkhash:8].js'
publicPath: 'https://cdn.example.com/assets/'
}
这时发布到线上的 HTML 在引入 JavaScript 文件时就需要:
<script src='https://cdn.example.com/assets/a_12345678.js'></script>
使用该配置项时要小心,稍有不慎将导致资源加载404错误。output.path
和 output.publicPath
都支持字符串模版,内置变量只有一个:hash
代表一次编译操作的 Hash 值。
crossOriginLoading
Webpack 输出的部分代码块可能需要异步加载,而异步加载是通过 JSONP 方式实现的。 JSONP 的原理是动态地向 HTML 中插入一个 <script src="url"></script>
标签去加载异步资源。 output.crossOriginLoading
则是用于配置这个异步插入的标签的 crossorigin
值。
script 标签的 crossorigin 属性可以取以下值:
anonymous
(默认) 在加载此脚本资源时不会带上用户的 Cookies;use-credentials
在加载此脚本资源时会带上用户的 Cookies。
通常用设置 crossorigin 来获取异步加载的脚本执行时的详细错误信息。
libraryTarget 和 library
当用 Webpack 去构建一个可以被其他模块导入使用的库时需要用到它们。
output.libraryTarget
配置以何种方式导出库。output.library
配置导出库的名称。
它们通常搭配在一起使用。output.libraryTarget
是字符串的枚举类型,支持以下配置。
var (默认)
编写的库将通过 var
被赋值给通过 library
指定名称的变量。
假如配置了 output.library='LibraryName'
,则输出和使用的代码如下:
// Webpack 输出的代码
var LibraryName = lib_code;
// 使用库的方法
LibraryName.doSomething();
假如 output.library
为空,则将直接输出:
lib_code
其中
lib_code
代指导出库的代码内容,是有返回值的一个自执行函数。
commonjs
编写的库将通过 CommonJS 规范导出。
假如配置了 output.library='LibraryName'
,则输出和使用的代码如下:
// Webpack 输出的代码
exports['LibraryName'] = lib_code;
// 使用库的方法
require('library-name-in-npm')['LibraryName'].doSomething();
其中
library-name-in-npm
是指模块发布到 Npm 代码仓库时的名称。
commonjs2
编写的库将通过 CommonJS2 规范导出,输出和使用的代码如下:
// Webpack 输出的代码
module.exports = lib_code;
// 使用库的方法
require('library-name-in-npm').doSomething();
CommonJS2 和 CommonJS 规范很相似,差别在于 CommonJS 只能用
exports
导出,而 CommonJS2 在 CommonJS 的基础上增加了module.exports
的导出方式。 在output.libraryTarget
为commonjs2
时,配置output.library
将没有意义。
this
编写的库将通过 this
被赋值给通过 library
指定的名称,输出和使用的代码如下:
// Webpack 输出的代码
this['LibraryName'] = lib_code;
// 使用库的方法
this.LibraryName.doSomething();
window
编写的库将通过 window
被赋值给通过 library
指定的名称,即把库挂载到 window
上,输出和使用的代码如下:
// Webpack 输出的代码
window['LibraryName'] = lib_code;
// 使用库的方法
window.LibraryName.doSomething();
global
编写的库将通过 global
被赋值给通过 library
指定的名称,即把库挂载到 global
上,输出和使用的代码如下:
// Webpack 输出的代码
global['LibraryName'] = lib_code;
// 使用库的方法
global.LibraryName.doSomething();
libraryExport
output.libraryExport
配置要导出的模块中哪些子模块需要被导出。 它只有在 output.libraryTarget
被设置成 commonjs
或者 commonjs2
时使用才有意义。
假如要导出的模块源代码是:
export const a=1;
export default b=2;
现在你想让构建输出的代码只导出其中的 a
,可以把 output.libraryExport
设置成 a
,那么构建输出的代码和使用方法将变成如下:
// Webpack 输出的代码
module.exports = lib_code['a'];
// 使用库的方法
require('library-name-in-npm')===1;
以上只是
output
里常用的配置项,还有部分几乎用不上的配置项没有一一列举,你可以在 Webpack 官方文档 上查阅它们。
Module
配置 Loader
rules
配置模块的读取和解析规则,通常用来配置 Loader。其类型是一个数组,数组里每一项都描述了如何去处理部分文件。 配置一项 rules
时大致通过以下方式:
- 条件匹配:通过
test
、include
、exclude
三个配置项来命中 Loader 要应用规则的文件。 - 应用规则:对选中后的文件通过
use
配置项来应用 Loader,可以只应用一个 Loader 或者按照从后往前的顺序应用一组 Loader,同时还可以分别给 Loader 传入参数。 - 重置顺序:一组 Loader 的执行顺序默认是从右到左执行,通过
enforce
选项可以让其中一个 Loader 的执行顺序放到最前或者最后。
下面来通过一个例子来说明具体使用方法:
module: {
rules: [
{
// 命中 JavaScript 文件
test: /\.js$/,
// 用 babel-loader 转换 JavaScript 文件
// ?cacheDirectory 表示传给 babel-loader 的参数,用于缓存 babel 编译结果加快重新编译速度
use: ['babel-loader?cacheDirectory'],
// 只命中src目录里的js文件,加快 Webpack 搜索速度
include: path.resolve(__dirname, 'src')
},
{
// 命中 SCSS 文件
test: /\.scss$/,
// 使用一组 Loader 去处理 SCSS 文件。
// 处理顺序为从后到前,即先交给 sass-loader 处理,再把结果交给 css-loader 最后再给 style-loader。
use: ['style-loader', 'css-loader', 'sass-loader'],
// 排除 node_modules 目录下的文件
exclude: path.resolve(__dirname, 'node_modules'),
},
{
// 对非文本文件采用 file-loader 加载
test: /\.(gif|png|jpe?g|eot|woff|ttf|svg|pdf)$/,
use: ['file-loader'],
},
]
}
在 Loader 需要传入很多参数时,你还可以通过一个 Object 来描述,例如在上面的 babel-loader 配置中有如下代码:
module: {
rules: [
{
// 命中 JavaScript 文件
test: /\.js$/,
// 用 babel-loader 转换 JavaScript 文件
// ?cacheDirectory 表示传给 babel-loader 的参数,用于缓存 babel 编译结果加快重新编译速度
use: [
{
loader:'babel-loader',
options:{
cacheDirectory:true,
},
// enforce:'post' 的含义是把该 Loader 的执行顺序放到最后
// enforce 的值还可以是 pre,代表把 Loader 的执行顺序放到最前面
enforce:'post'
},
// 省略其它 Loader
// 只命中src目录里的js文件,加快 Webpack 搜索速度
include: path.resolve(__dirname, 'src')
}
]
}
上面的例子中 test include exclude
这三个命中文件的配置项只传入了一个字符串或正则,其实它们还都支持数组类型,使用如下:
{
test:[
/\.jsx?$/,
/\.tsx?$/
],
include:[
path.resolve(__dirname, 'src'),
path.resolve(__dirname, 'tests'),
],
exclude:[
path.resolve(__dirname, 'node_modules'),
path.resolve(__dirname, 'bower_modules'),
]
}
数组里的每项之间是或的关系,即文件路径符合数组中的任何一个条件就会被命中。
noParse
noParse
配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析和处理,这样做的好处是能提高构建性能。 原因是一些库例如 jQuery 、ChartJS 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。这样能够增加打包速率。noParse
是可选配置项,类型需要是 RegExp
、[RegExp]
、function
其中一个。
如想要忽略掉 jQuery 、ChartJS,可以使用如下代码:
module: {
noParse: /jquery|chartjs/
// 使用函数,从 Webpack 3.0.0 开始支持
noParse: (content)=> {
// content 代表一个模块的文件路径
// 返回 true or false
return /jquery|chartjs/.test(content);
}
rules: [
{
test: /\.js$/,
use: ['babel-loader']
},
]
}
注意被忽略掉的文件里不应该包含 import
、 require
、 define
等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句。
parser
因为 Webpack 是以模块化的 JavaScript 文件为入口,所以内置了对模块化 JavaScript 的解析功能,支持 AMD、CommonJS、SystemJS、ES6。 parser
属性可以更细粒度的配置哪些模块语法要解析哪些不解析,和 noParse
配置项的区别在于 parser
可以精确到语法层面, 而 noParse
只能控制哪些文件不被解析。 parser
使用如下:
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
parser: {
amd: false, // 禁用 AMD
commonjs: false, // 禁用 CommonJS
system: false, // 禁用 SystemJS
harmony: false, // 禁用 ES6 import/export
requireInclude: false, // 禁用 require.include
requireEnsure: false, // 禁用 require.ensure
requireContext: false, // 禁用 require.context
browserify: false, // 禁用 browserify
requireJs: false, // 禁用 requirejs
}
},
]
}
Resolve
Webpack 在启动后会从配置的入口模块出发找出所有依赖的模块,Resolve 配置 Webpack 如何寻找模块所对应的文件。 Webpack 内置 JavaScript 模块化语法解析功能,默认会采用模块化标准里约定好的规则去寻找,但你也可以根据自己的需要修改默认的规则。
alias
resolve.alias
配置项通过别名来把原导入路径映射成一个新的导入路径。例如使用以下配置:
// Webpack alias 配置
resolve:{
alias:{
components: './src/components/'
}
}
当你通过 import Button from 'components/button'
导入时,实际上被 alias
等价替换成了 import Button from './src/components/button'
。
这样做可能会命中太多的导入语句,alias 还支持 $
符号来缩小范围到只命中以关键字结尾的导入语句:
resolve:{
alias:{
'react$': '/path/to/react.min.js'
}
}
react$
只会命中以 react
结尾的导入语句,即只会把 import 'react'
关键字替换成 import '/path/to/react.min.js'
。
mainFields
有一些第三方模块会针对不同环境提供几分代码。 例如分别提供采用 ES5 和 ES6 的2份代码,这2份代码的位置写在 package.json
文件里,如下:
{
"jsnext:main": "es/index.js",// 采用 ES6 语法的代码入口文件
"main": "lib/index.js" // 采用 ES5 语法的代码入口文件
}
Webpack 会根据 mainFields
的配置去决定优先采用那份代码,mainFields
默认如下:
resolve:{
alias:{
'react$': '/path/to/react.min.js'
},
mainFields: ['browser', 'main']
}
Webpack 会按照数组里的顺序去package.json
文件里寻找,只会使用找到的第一个。
假如你想优先采用 ES6 的那份代码,可以这样配置:
resolve:{
alias:{
'react$': '/path/to/react.min.js'
},
mainFields: ['jsnext:main', 'browser', 'main']
}
extensions
在导入语句没带文件后缀时,Webpack 会自动带上后缀后去尝试访问文件是否存在。 resolve.extensions
用于配置在尝试过程中用到的后缀列表,默认是:
extensions: ['.js', '.json']
也就是说当遇到 require('./data')
这样的导入语句时,Webpack 会先去寻找 ./data.js
文件,如果该文件不存在就去寻找 ./data.json
文件, 如果还是找不到就报错。
假如你想让 Webpack 优先使用目录下的 TypeScript 文件,可以这样配置:
extensions: ['.ts', '.js', '.json']
modules
resolve.modules
配置 Webpack 去哪些目录下寻找第三方模块,默认是只会去 node_modules
目录下寻找。 有时你的项目里会有一些模块会大量被其它模块依赖和导入,由于其它模块的位置分布不定,针对不同的文件都要去计算被导入模块文件的相对路径, 这个路径有时候会很长,就像这样 import '../../../components/button'
这时你可以利用 modules
配置项优化,假如那些被大量导入的模块都在 ./src/components
目录下,把 modules
配置成
modules:['./src/components','node_modules']
descriptionFiles
resolve.descriptionFiles
配置描述第三方模块的文件名称,也就是 package.json
文件。默认如下:
descriptionFiles: ['package.json']
enforceExtension
resolve.enforceExtension
如果配置为 true
所有导入语句都必须要带文件后缀, 例如开启前 import './foo'
能正常工作,开启后就必须写成 import './foo.js'
。
enforceModuleExtension
enforceModuleExtension
和 enforceExtension
作用类似,但 enforceModuleExtension
只对 node_modules
下的模块生效。 enforceModuleExtension
通常搭配 enforceExtension
使用,在 enforceExtension:true
时,因为安装的第三方模块中大多数导入语句没带文件后缀, 所以这时通过配置 enforceModuleExtension:false
来兼容第三方模块。
Plugin
Plugin 用于扩展 Webpack 功能,各种各样的 Plugin 几乎让 Webpack 可以做任何构建相关的事情。
配置 Plugin
Plugin 的配置很简单,plugins
配置项接受一个数组,数组里每一项都是一个要使用的 Plugin 的实例,Plugin 需要的参数通过构造函数传入。
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
module.exports = {
plugins: [
// 所有页面都会用到的公共代码提取到 common 代码块中
new CommonsChunkPlugin({
name: 'common',
chunks: ['a', 'b']
}),
]
};
使用 Plugin 的难点在于掌握 Plugin 本身提供的配置项,而不是如何在 Webpack 中接入 Plugin。
几乎所有 Webpack 无法直接实现的功能都能在社区找到开源的 Plugin 去解决,你需要善于使用搜索引擎去寻找解决问题的方法。
devServer
它提供了一些配置项可以改变 DevServer 的默认行为。 要配置 DevServer ,除了在配置文件里通过 devServer
传入参数外,还可以通过命令行参数传入。 注意只有在通过 DevServer 去启动 Webpack 时配置文件里 devServer
才会生效,因为这些参数所对应的功能都是 DevServer 提供的,Webpack 本身并不认识 devServer
配置项。
hot
devServer.hot
配置是否启用模块热替换功能。 DevServer 默认的行为是在发现源代码被更新后会通过自动刷新整个页面来做到实时预览,开启模块热替换功能后将在不刷新整个页面的情况下通过用新模块替换老模块来做到实时预览。
inline
DevServer 的实时预览功能依赖一个注入到页面里的代理客户端去接受来自 DevServer 的命令和负责刷新网页的工作。 devServer.inline
用于配置是否自动注入这个代理客户端到将运行在页面里的 Chunk 里去,默认是会自动注入。 DevServer 会根据你是否开启 inline
来调整它的自动刷新策略:
- 如果开启
inline
,DevServer 会在构建完变化后的代码时通过代理客户端控制网页刷新。 - 如果关闭
inline
,DevServer 将无法直接控制要开发的网页。这时它会通过 iframe 的方式去运行要开发的网页,当构建完变化后的代码时通过刷新 iframe 来实现实时预览。 但这时你需要去http://localhost:8080/webpack-dev-server/
实时预览你的网页了。
如果你想使用 DevServer 去自动刷新网页实现实时预览,最方便的方法是直接开启 inline
。
historyApiFallback
devServer.historyApiFallback
用于方便的开发使用了 HTML5 History API 的单页应用。 这类单页应用要求服务器在针对任何命中的路由时都返回一个对应的 HTML 文件,例如在访问 http://localhost/user
和 http://localhost/home
时都返回 index.html
文件, 浏览器端的 JavaScript 代码会从 URL 里解析出当前页面的状态,显示出对应的界面。
配置 historyApiFallback
最简单的做法是:
historyApiFallback: true
这会导致任何请求都会返回 index.html
文件,这只能用于只有一个 HTML 文件的应用。
如果你的应用由多个单页应用组成,这就需要 DevServer 根据不同的请求来返回不同的 HTML 文件,配置如下:
historyApiFallback: {
// 使用正则匹配命中路由
rewrites: [
// /user 开头的都返回 user.html
{ from: /^\/user/, to: '/user.html' },
{ from: /^\/game/, to: '/game.html' },
// 其它的都返回 index.html
{ from: /./, to: '/index.html' },
]
}
contentBase
devServer.contentBase
配置 DevServer HTTP 服务器的文件根目录。 默认情况下为当前执行目录,通常是项目根目录,所有一般情况下你不必设置它,除非你有额外的文件需要被 DevServer 服务。 例如你想把项目根目录下的 public
目录设置成 DevServer 服务器的文件根目录,你可以这样配置:
devServer:{
contentBase: path.join(__dirname, 'public')
}
这里需要指出可能会让你疑惑的地方,DevServer 服务器通过 HTTP 服务暴露出的文件分为两类:
- 暴露本地文件。
- 暴露 Webpack 构建出的结果,由于构建出的结果交给了 DevServer,所以你在使用了 DevServer 时在本地找不到构建出的文件。
contentBase
只能用来配置暴露本地文件的规则,你可以通过 contentBase:false
来关闭暴露本地文件。
headers
devServer.headers
配置项可以在 HTTP 响应中注入一些 HTTP 响应头,使用如下:
devServer:{
headers: {
'X-foo':'bar'
}
}
host
devServer.host
配置项用于配置 DevServer 服务监听的地址。 例如你想要局域网中的其它设备访问你本地的服务,可以在启动 DevServer 时带上 --host 0.0.0.0
。 host
的默认值是 127.0.0.1
即只有本地可以访问 DevServer 的 HTTP 服务。
port
devServer.port
配置项用于配置 DevServer 服务监听的端口,默认使用 8080 端口。 如果 8080 端口已经被其它程序占有就使用 8081,如果 8081 还是被占用就使用 8082,以此类推。
allowedHosts
devServer.allowedHosts
配置一个白名单列表,只有 HTTP 请求的 HOST 在列表里才正常返回,使用如下:
allowedHosts: [
// 匹配单个域名
'host.com',
'sub.host.com',
// host2.com 和所有的子域名 *.host2.com 都将匹配
'.host2.com'
]
disableHostCheck
devServer.disableHostCheck
配置项用于配置是否关闭用于 DNS 重绑定的 HTTP 请求的 HOST 检查。 DevServer 默认只接受来自本地的请求,关闭后可以接受来自任何 HOST 的请求。 它通常用于搭配 --host 0.0.0.0
使用,因为你想要其它设备访问你本地的服务,但访问时是直接通过 IP 地址访问而不是 HOST 访问,所以需要关闭 HOST 检查。
https
DevServer 默认使用 HTTP 协议服务,它也能通过 HTTPS 协议服务。 有些情况下你必须使用 HTTPS,例如 HTTP2 和 Service Worker 就必须运行在 HTTPS 之上。 要切换成 HTTPS 服务,最简单的方式是:
devServer:{
https: true
}
DevServer 会自动的为你生成一份 HTTPS 证书。
如果你想用自己的证书可以这样配置:
devServer:{
https: {
key: fs.readFileSync('path/to/server.key'),
cert: fs.readFileSync('path/to/server.crt'),
ca: fs.readFileSync('path/to/ca.pem')
}
}
clientLogLevel
devServer.clientLogLevel
配置在客户端的日志等级,这会影响到你在浏览器开发者工具控制台里看到的日志内容。 clientLogLevel
是枚举类型,可取如下之一的值 none | error | warning | info
。 默认为 info
级别,即输出所有类型的日志,设置成 none
可以不输出任何日志。
compress
devServer.compress
配置是否启用 gzip 压缩。boolean
为类型,默认为 false
。
open
devServer.open
用于在 DevServer 启动且第一次构建完时自动用你系统上默认的浏览器去打开要开发的网页。 同时还提供 devServer.openPage
配置项用于打开指定 URL 的网页。
其它配置项
除了前面介绍到的配置项外,Webpack 还提供了一些零散的配置项。下面来介绍它们中常用的部分。
Target
JavaScript 的应用场景越来越多,从浏览器到 Node.js,这些运行在不同环境的 JavaScript 代码存在一些差异。 target
配置项可以让 Webpack 构建出针对不同运行环境的代码。 target
可以是以下之一:
target值 | 描述 |
---|---|
web |
针对浏览器 (默认),所有代码都集中在一个文件里 |
node |
针对 Node.js,使用 require 语句加载 Chunk 代码 |
async-node |
针对 Node.js,异步加载 Chunk 代码 |
webworker |
针对 WebWorker |
electron-main |
针对 Electron 主线程 |
electron-renderer |
针对 Electron 渲染线程 |
例如当你设置 target:'node'
时,源代码中导入 Node.js 原生模块的语句 require('fs')
将会被保留,fs
模块的内容不会打包进 Chunk 里。
Devtool
devtool
配置 Webpack 如何生成 Source Map,默认值是 false
即不生成 Source Map,想为构建出的代码生成 Source Map 以方便调试,可以这样配置:
module.export = {
devtool: 'source-map'
}
Watch 和 WatchOptions
前面介绍过 Webpack 的监听模式,它支持监听文件更新,在文件发生变化时重新编译。在使用 Webpack 时监听模式默认是关闭的,想打开需要如下配置:
module.export = {
watch: true
}
在使用 DevServer 时,监听模式默认是开启的。
除此之外,Webpack 还提供了 watchOptions
配置项去更灵活的控制监听模式,使用如下:
module.export = {
// 只有在开启监听模式时,watchOptions 才有意义
// 默认为 false,也就是不开启
watch: true,
// 监听模式运行时的参数
// 在开启监听模式时,才有意义
watchOptions: {
// 不监听的文件或文件夹,支持正则匹配
// 默认为空
ignored: /node_modules/,
// 监听到变化发生后会等300ms再去执行动作,防止文件更新太快导致重新编译频率太高
// 默认为 300ms
aggregateTimeout: 300,
// 判断文件是否发生变化是通过不停的去询问系统指定文件有没有变化实现的
// 默认每隔1000毫秒询问一次
poll: 1000
}
}
Externals
Externals 用来告诉 Webpack 要构建的代码中使用了哪些不用被打包的模块,也就是说这些模版是外部环境提供的,Webpack 在打包时可以忽略它们。
有些 JavaScript 运行环境可能内置了一些全局变量或者模块,例如在你的 HTML HEAD 标签里通过以下代码:
<script src="path/to/jquery.js"></script>
引入 jQuery 后,全局变量 jQuery
就会被注入到网页的 JavaScript 运行环境里。
如果想在使用模块化的源代码里导入和使用 jQuery,可能需要这样:
import $ from 'jquery';
$('.my-element');
构建后你会发现输出的 Chunk 里包含的 jQuery 库的内容,这导致 jQuery 库出现了2次,浪费加载流量,最好是 Chunk 里不会包含 jQuery 库的内容。
Externals 配置项就是为了解决这个问题。
通过 externals
可以告诉 Webpack JavaScript 运行环境已经内置了那些全局变量,针对这些全局变量不用打包进代码中而是直接使用全局变量。 要解决以上问题,可以这样配置 externals
:
module.export = {
externals: {
// 把导入语句里的 jquery 替换成运行环境里的全局变量 jQuery
jquery: 'jQuery'
}
}
ResolveLoader
ResolveLoader 用来告诉 Webpack 如何去寻找 Loader,因为在使用 Loader 时是通过其包名称去引用的, Webpack 需要根据配置的 Loader 包名去找到 Loader 的实际代码,以调用 Loader 去处理源文件。
ResolveLoader 的默认配置如下:
module.exports = {
resolveLoader:{
// 去哪个目录下寻找 Loader
modules: ['node_modules'],
// 入口文件的后缀
extensions: ['.js', '.json'],
// 指明入口文件位置的字段
mainFields: ['loader', 'main']
}
}
整体配置结构
之前的章节分别讲述了每个配置项的具体含义,但没有描述它们所处的位置和数据结构,下面通过一份代码来描述清楚:
const path = require('path');
module.exports = {
// entry 表示 入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
// 类型可以是 string | object | array
entry: './app/entry', // 只有1个入口,入口只有1个文件
entry: ['./app/entry1', './app/entry2'], // 只有1个入口,入口有2个文件
entry: { // 有2个入口
a: './app/entry-a',
b: ['./app/entry-b1', './app/entry-b2']
},
// 如何输出结果:在 Webpack 经过一系列处理后,如何输出最终想要的代码。
output: {
// 输出文件存放的目录,必须是 string 类型的绝对路径。
path: path.resolve(__dirname, 'dist'),
// 输出文件的名称
filename: 'bundle.js', // 完整的名称
filename: '[name].js', // 当配置了多个 entry 时,通过名称模版为不同的 entry 生成不同的文件名称
filename: '[chunkhash].js', // 根据文件内容 hash 值生成文件名称,用于浏览器长时间缓存文件
// 发布到线上的所有资源的 URL 前缀,string 类型
publicPath: '/assets/', // 放到指定目录下
publicPath: '', // 放到根目录下
publicPath: 'https://cdn.example.com/', // 放到 CDN 上去
// 导出库的名称,string 类型
// 不填它时,默认输出格式是匿名的立即执行函数
library: 'MyLibrary',
// 导出库的类型,枚举类型,默认是 var
// 可以是 umd | umd2 | commonjs2 | commonjs | amd | this | var | assign | window | global | jsonp ,
libraryTarget: 'umd',
// 是否包含有用的文件路径信息到生成的代码里去,boolean 类型
pathinfo: true,
// 附加 Chunk 的文件名称
chunkFilename: '[id].js',
chunkFilename: '[chunkhash].js',
// JSONP 异步加载资源时的回调函数名称,需要和服务端搭配使用
jsonpFunction: 'myWebpackJsonp',
// 生成的 Source Map 文件名称
sourceMapFilename: '[file].map',
// 浏览器开发者工具里显示的源码模块名称
devtoolModuleFilenameTemplate: 'webpack:///[resource-path]',
// 异步加载跨域的资源时使用的方式
crossOriginLoading: 'use-credentials',
crossOriginLoading: 'anonymous',
crossOriginLoading: false,
},
// 配置模块相关
module: {
rules: [ // 配置 Loader
{
test: /\.jsx?$/, // 正则匹配命中要使用 Loader 的文件
include: [ // 只会命中这里面的文件
path.resolve(__dirname, 'app')
],
exclude: [ // 忽略这里面的文件
path.resolve(__dirname, 'app/demo-files')
],
use: [ // 使用那些 Loader,有先后次序,从后往前执行
'style-loader', // 直接使用 Loader 的名称
{
loader: 'css-loader',
options: { // 给 html-loader 传一些参数
}
}
]
},
],
noParse: [ // 不用解析和处理的模块
/special-library\.js$/ // 用正则匹配
],
},
// 配置插件
plugins: [
],
// 配置寻找模块的规则
resolve: {
modules: [ // 寻找模块的根目录,array 类型,默认以 node_modules 为根目录
'node_modules',
path.resolve(__dirname, 'app')
],
extensions: ['.js', '.json', '.jsx', '.css'], // 模块的后缀名
alias: { // 模块别名配置,用于映射模块
// 把 'module' 映射 'new-module',同样的 'module/path/file' 也会被映射成 'new-module/path/file'
'module': 'new-module',
// 使用结尾符号 $ 后,把 'only-module' 映射成 'new-module',
// 但是不像上面的,'module/path/file' 不会被映射成 'new-module/path/file'
'only-module$': 'new-module',
},
alias: [ // alias 还支持使用数组来更详细的配置
{
name: 'module', // 老的模块
alias: 'new-module', // 新的模块
// 是否是只映射模块,如果是 true 只有 'module' 会被映射,如果是 false 'module/inner/path' 也会被映射
onlyModule: true,
}
],
symlinks: true, // 是否跟随文件软链接去搜寻模块的路径
descriptionFiles: ['package.json'], // 模块的描述文件
mainFields: ['main'], // 模块的描述文件里的描述入口的文件的字段名称
enforceExtension: false, // 是否强制导入语句必须要写明文件后缀
},
// 输出文件性能检查配置
performance: {
hints: 'warning', // 有性能问题时输出警告
hints: 'error', // 有性能问题时输出错误
hints: false, // 关闭性能检查
maxAssetSize: 200000, // 最大文件大小 (单位 bytes)
maxEntrypointSize: 400000, // 最大入口文件大小 (单位 bytes)
assetFilter: function(assetFilename) { // 过滤要检查的文件
return assetFilename.endsWith('.css') || assetFilename.endsWith('.js');
}
},
devtool: 'source-map', // 配置 source-map 类型
context: __dirname, // Webpack 使用的根目录,string 类型必须是绝对路径
// 配置输出代码的运行环境
target: 'web', // 浏览器,默认
target: 'webworker', // WebWorker
target: 'node', // Node.js,使用 `require` 语句加载 Chunk 代码
target: 'async-node', // Node.js,异步加载 Chunk 代码
target: 'node-webkit', // nw.js
target: 'electron-main', // electron, 主线程
target: 'electron-renderer', // electron, 渲染线程
externals: { // 使用来自 JavaScript 运行环境提供的全局变量
jquery: 'jQuery'
},
stats: { // 控制台输出日志控制
assets: true,
colors: true,
errors: true,
errorDetails: true,
hash: true,
},
devServer: { // DevServer 相关的配置
proxy: { // 代理到后端服务接口
'/api': 'http://localhost:3000'
},
contentBase: path.join(__dirname, 'public'), // 配置 DevServer HTTP 服务器的文件根目录
compress: true, // 是否开启 gzip 压缩
historyApiFallback: true, // 是否开发 HTML5 History API 网页
hot: true, // 是否开启模块热替换功能
https: false, // 是否开启 HTTPS 模式
},
profile: true, // 是否捕捉 Webpack 构建的性能信息,用于分析什么原因导致构建性能不佳
cache: false, // 是否启用缓存提升构建速度
watch: true, // 是否开始
watchOptions: { // 监听模式选项
// 不监听的文件或文件夹,支持正则匹配。默认为空
ignored: /node_modules/,
// 监听到变化发生后会等300ms再去执行动作,防止文件更新太快导致重新编译频率太高
// 默认为300ms
aggregateTimeout: 300,
// 判断文件是否发生变化是不停的去询问系统指定文件有没有变化,默认每隔1000毫秒询问一次
poll: 1000
},
}
多种配置类型
除了通过导出一个 Object 来描述 Webpack 所需的配置外,还有其它更灵活的方式,以简化不同场景的配置。
导出一个 Function
在大多数时候你需要从同一份源代码中构建出多份代码,例如一份用于开发时,一份用于发布到线上。
如果采用导出一个 Object 来描述 Webpack 所需的配置的方法,需要写两个文件。 一个用于开发环境,一个用于线上环境。再在启动时通过 webpack --config webpack.config.js
指定使用哪个配置文件。
采用导出一个 Function 的方式,能通过 JavaScript 灵活的控制配置,做到只用写一个配置文件就能完成以上要求。
导出一个 Function 的使用方式如下:
const path = require('path');
const UglifyJsPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
module.exports = function (env = {}, argv) {
const plugins = [];
const isProduction = env['production'];
// 在生成环境才压缩
if (isProduction) {
plugins.push(
// 压缩输出的 JS 代码
new UglifyJsPlugin()
)
}
return {
plugins: plugins,
// 在生成环境不输出 Source Map
devtool: isProduction ? undefined : 'source-map',
};
}
在运行 Webpack 时,会给这个函数传入2个参数,分别是:
env
:当前运行时的 Webpack 专属环境变量,env
是一个 Object。读取时直接访问 Object 的属性,设置它需要在启动 Webpack 时带上参数。例如启动命令是webpack --env.production --env.bao=foo
时,则env
的值是{"production":"true","bao":"foo"}
。argv
:代表在启动 Webpack 时所有通过命令行传入的参数,例如--config
、--env
、--devtool
,可以通过webpack -h
列出所有 Webpack 支持的命令行参数。
就以上配置文件而言,在开发时执行命令 webpack
构建出方便调试的代码,在需要构建出发布到线上的代码时执行 webpack --env.production
构建出压缩的代码。
导出一个返回 Promise 的函数
在有些情况下你不能以同步的方式返回一个描述配置的 Object,Webpack 还支持导出一个返回 Promise 的函数,使用如下:
module.exports = function(env = {}, argv) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
// ...
})
}, 5000)
})
}
导出多份配置
除了只导出一份配置外,Webpack 还支持导出一个数组,数组中可以包含每份配置,并且每份配置都会执行一遍构建。
注意本特性从 Webpack 3.1.0 版本才开始支持。
使用如下:
module.exports = [
// 采用 Object 描述的一份配置
{
// ...
},
// 采用函数描述的一份配置
function() {
return {
// ...
}
},
// 采用异步函数描述的一份配置
function() {
return Promise();
}
]
以上配置会导致 Webpack 针对这三份配置执行三次不同的构建。
这特别适合于用 Webpack 构建一个要上传到 Npm 仓库的库,因为库中可能需要包含多种模块化格式的代码,例如 CommonJS、UMD。
配置总结
从前面的配置看来选项很多,Webpack 内置了很多功能。 你不必都记住它们,只需要大概明白 Webpack 原理和核心概念去判断选项大致属于哪个大模块下,再去查详细的使用文档。
通常你可用如下经验去判断如何配置 Webpack:
- 想让源文件加入到构建流程中去被 Webpack 控制,配置
entry
。 - 想自定义输出文件的位置和名称,配置
output
。 - 想自定义寻找依赖模块时的策略,配置
resolve
。 - 想自定义解析和转换文件的策略,配置
module
,通常是配置module.rules
里的 Loader。 - 其它的大部分需求可能要通过 Plugin 去实现,配置
plugin
。4. 实战
使用 ES6 语言
ECMAScript 6.0 是2015年发布的下一代 JavaScript 语言标准,它引入了新的语法和 API 来提升开发效率。
虽然目前部分浏览器和 Node.js 已经支持 ES6,但由于它们对 ES6 所有的标准支持不全,这导致在开发中不敢全面地使用 ES6。
通常我们需要把采用 ES6 编写的代码转换成目前已经支持良好的 ES5 代码,这包含2件事:
- 把新的 ES6 语法用 ES5 实现,例如 ES6 的
class
语法用 ES5 的prototype
实现。 给新的 API 注入 polyfill ,例如项目使用
fetch
API 时,只有注入对应的 polyfill 后,才能在低版本浏览器中正常运行。Babel
Babel 可以方便的完成以上2件事。 Babel 是一个 JavaScript 编译器,能将 ES6 代码转为 ES5 代码,让你使用最新的语言特性而不用担心兼容性问题,并且可以通过插件机制根据需求灵活的扩展。 在 Babel 执行编译的过程中,会从项目根目录下的
.babelrc
文件读取配置。.babelrc
是一个 JSON 格式的文件,内容大致如下:{
"plugins": [
[
"transform-runtime",
{
"polyfill": false
}
]
],
"presets": [
[
"es2015",
{
"modules": false
}
],
"stage-2",
"react"
]
}
Plugins
plugins
属性告诉 Babel 要使用哪些插件,插件可以控制如何转换代码。
以上配置文件里的transform-runtime
对应的插件全名叫做babel-plugin-transform-runtime
,即在前面加上了babel-plugin-
,要让 Babel 正常运行我们必须先安装它:npm i -D babel-plugin-transform-runtime
babel-plugin-transform-runtime
是 Babel 官方提供的一个插件,作用是减少冗余代码。 Babel 在把 ES6 代码转换成 ES5 代码时通常需要一些 ES5 写的辅助函数来完成新语法的实现,例如在转换class extent
语法时会在转换后的 ES5 代码里注入_extent
辅助函数用于实现继承:function _extent(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
}
这会导致每个使用了
class extent
语法的文件都被注入重复的_extent
辅助函数代码,babel-plugin-transform-runtime
的作用在于不把辅助函数内容注入到文件里,而是注入一条导入语句:var _extent = require('babel-runtime/helpers/_extent');
这样能减小 Babel 编译出来的代码的文件大小。
同时需要注意的是由于babel-plugin-transform-runtime
注入了require('babel-runtime/helpers/_extent')
语句到编译后的代码里,需要安装babel-runtime
依赖到你的项目后,代码才能正常运行。 也就是说babel-plugin-transform-runtime
和babel-runtime
需要配套使用,使用了babel-plugin-transform-runtime
后一定需要babel-runtime
。Presets
presets
属性告诉 Babel 要转换的源码使用了哪些新的语法特性,一个 Presets 对一组新语法特性提供支持,多个 Presets 可以叠加。 Presets 其实是一组 Plugins 的集合,每一个 Plugin 完成一个新语法的转换工作。Presets 是按照 ECMAScript 草案来组织的,通常可以分为以下三大类:已经被写入 ECMAScript 标准里的特性,由于之前每年都有新特性被加入到标准里,所以又可细分为:
- 它们之间的关系如图:
- 被社区提出来的但还未被写入 ECMAScript 标准里特性,这其中又分为以下四种:
- 它们之间的关系如图:
- 为了支持一些特定应用场景下的语法,和 ECMAScript 标准没有关系,例如
babel-preset-react
是为了支持 React 开发中的 JSX 语法。
在实际应用中,你需要根据项目源码所使用的语法去安装对应的 Plugins 或 Presets。
接入 Babel
在了解 Babel 后,下一步要知道如何在 Webpack 中使用它。 由于 Babel 所做的事情是转换代码,所以应该通过 Loader 去接入 Babel,Webpack 配置如下:
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
},
]
},
// 输出 source-map 方便直接调试 ES6 源码
devtool: 'source-map'
};
配置命中了项目目录下所有的 JavaScript 文件,通过 babel-loader
去调用 Babel 完成转换工作。 在重新执行构建前,需要先安装新引入的依赖:
# Webpack 接入 Babel 必须依赖的模块
npm i -D babel-core babel-loader
# 根据你的需求选择不同的 Plugins 或 Presets
npm i -D babel-preset-env
使用 TypeScript 语言
TypeScript 是 JavaScript 的一个超集,主要提供了类型检查系统和对 ES6 语法的支持,但不支持新的 API。 目前没有任何环境支持运行原生的 TypeScript 代码,必须通过构建把它转换成 JavaScript 代码后才能运行。
改造下前面用过的例子 Hello,Webpack
,用 TypeScript 重写 JavaScript。由于 TypeScript 是 JavaScript 的超集,直接把后缀 .js
改成 .ts
是可以的。 但为了体现出 TypeScript 的不同,我们重写 JavaScript 代码为如下,加入类型检查:
// show.ts
// 操作 DOM 元素,把 content 显示到网页上
// 通过 ES6 模块规范导出 show 函数
// 给 show 函数增加类型检查
export function show(content: string) {
window.document.getElementById('app').innerText = 'Hello,' + content;
}
// main.ts
// 通过 ES6 模块规范导入 show 函数
import {show} from './show';
// 执行 show 函数
show('Webpack');
TypeScript 官方提供了能把 TypeScript 转换成 JavaScript 的编译器。 你需要在当前项目根目录下新建一个用于配置编译选项的 tsconfig.json
文件,编译器默认会读取和使用这个文件,配置文件内容大致如下:
{
"compilerOptions": {
"module": "commonjs", // 编译出的代码采用的模块规范
"target": "es5", // 编译出的代码采用 ES 的哪个版本
"sourceMap": true // 输出 Source Map 方便调试
},
"exclude": [ // 不编译这些目录里的文件
"node_modules"
]
}
通过 npm install -g typescript
安装编译器到全局后,你可以通过 tsc hello.ts
命令编译出 hello.js
和 hello.js.map
文件。
减少代码冗余
TypeScript 编译器会有和在 3-1 使用ES6语言中 Babel 一样的问题:在把 ES6 语法转换成 ES5 语法时需要注入辅助函数, 为了不让同样的辅助函数重复的出现在多个文件中,可以开启 TypeScript 编译器的 importHelpers
选项,修改 tsconfig.json
文件如下:
{
"compilerOptions": {
"importHelpers": true
}
}
该选项的原理和 Babel 中介绍的 babel-plugin-transform-runtime
非常类似,会把辅助函数换成如下导入语句:
var _tslib = require('tslib');
_tslib._extend(target);
这会导致编译出的代码依赖 tslib
这个迷你库,但避免了代码冗余。
集成 Webpack
要让 Webpack 支持 TypeScript,需要解决以下2个问题:
- 通过 Loader 把 TypeScript 转换成 JavaScript。
- Webpack 在寻找模块对应的文件时需要尝试
ts
后缀。
对于问题1,社区已经出现了几个可用的 Loader,推荐速度更快的 awesome-typescript-loader。 对于问题2,根据2-4 Resolve 中的 extensions 我们需要修改默认的 resolve.extensions
配置项。
综上,相关 Webpack 配置如下:
const path = require('path');
module.exports = {
// 执行入口文件
entry: './main',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './dist'),
},
resolve: {
// 先尝试 ts 后缀的 TypeScript 源码文件
extensions: ['.ts', '.js']
},
module: {
rules: [
{
test: /\.ts$/,
loader: 'awesome-typescript-loader'
}
]
},
devtool: 'source-map',// 输出 Source Map 方便在浏览器里调试 TypeScript 代码
};
在运行构建前需要安装上面用到的依赖:
npm i -D typescript awesome-typescript-loader
安装成功后重新执行构建,你将会在 dist
目录看到输出的 JavaScript 文件 bundle.js
,和对应的 Source Map 文件 bundle.js.map
。 在浏览器里打开 index.html
页面后,来开发工具里可以看到和调试用 TypeScript 编写的源码。
使用 SCSS 语言
SCSS 可以让你用更灵活的方式写 CSS。 它是一种 CSS 预处理器,语法和 CSS 相似,但加入了变量、逻辑、等编程元素,代码类似这样:
$blue: #1875e7;
div {
color: $blue;
}
SCSS 又叫 SASS,区别在于 SASS 语法类似 Ruby,而 SCSS 语法类似 CSS,对于熟悉 CSS 的前端工程师来说会更喜欢 SCSS。
采用 SCSS 去写 CSS 的好处在于可以方便地管理代码,抽离公共的部分,通过逻辑写出更灵活的代码。 和 SCSS 类似的 CSS 预处理器还有 LESS 等。
使用 SCSS 可以提升编码效率,但是必须把 SCSS 源代码编译成可以直接在浏览器环境下运行的 CSS 代码。 SCSS 官方提供了多种语言实现的编译器,由于本书更倾向于前端工程师使用的技术栈,所以主要来介绍下 node-sass。
node-sass 核心模块是由 C++ 编写,再用 Node.js 封装了一层,以供给其它 Node.js 调用。 node-sass 还支持通过命令行调用,先安装它到全局:
npm i -g node-sass
再执行编译命令:
# 把 main.scss 源文件编译成 main.css
node-sass main.scss main.css
接入 Webpack
由于需要把 SCSS 源代码转换成 CSS 代码,在1-4 使用Loader中曾介绍过转换文件最适合的方式是使用 Loader,Webpack 官方提供了对应的 sass-loader。
Webpack 接入 sass-loader 相关配置如下:
module.exports = {
module: {
rules: [
{
// 增加对 SCSS 文件的支持
test: /\.scss$/,
// SCSS 文件的处理顺序为先 sass-loader 再 css-loader 再 style-loader
use: ['style-loader', 'css-loader', 'sass-loader'],
},
]
},
};
以上配置通过正则 /\.scss$/
匹配所有以 .scss
为后缀的 SCSS 文件,再分别使用3个 Loader 去处理。具体处理流程如下:
- 通过 sass-loader 把 SCSS 源码转换为 CSS 代码,再把 CSS 代码交给 css-loader 去处理。
- css-loader 会找出 CSS 代码中的
@import
和url()
这样的导入语句,告诉 Webpack 依赖这些资源。同时还支持 CSS Modules、压缩 CSS 等功能。处理完后再把结果交给 style-loader 去处理。 - style-loader 会把 CSS 代码转换成字符串后,注入到 JavaScript 代码中去,通过 JavaScript 去给 DOM 增加样式。如果你想把 CSS 代码提取到一个单独的文件而不是和 JavaScript 混在一起,可以使用1-5 使用Plugin 中介绍过的 ExtractTextPlugin。
由于接入 sass-loader,项目需要安装这些新的依赖:
# 安装 Webpack Loader 依赖
npm i -D sass-loader css-loader style-loader
# sass-loader 依赖 node-sass
npm i -D node-sass
为单页应用生成 HTML
在 Vue、React框架中, index.html
页面常常有很多资源要加载。接下来举一个实战中的例子,要求如下:
- 项目采用 ES6 语言加 React 框架。
- 给页面加入 Google Analytics,这部分代码需要内嵌进 HEAD 标签里去。
- 给页面加入 Disqus 用户评论,这部分代码需要异步加载以提升首屏加载速度。
- 压缩和分离 JavaScript 和 CSS 代码,提升加载速度。
在开始前先来看看该应用最终发布到线上的代码:
<html>
<head>
<meta charset="UTF-8">
<!--注入 Chunk app 依赖的 CSS-->
<style rel="stylesheet">h1{color:red}</style>
<!--内嵌 google_analytics 中的 JavaScript 代码-->
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
</script>
<!--异步加载 Disqus 评论-->
<script async="" src="https://dive-into-webpack.disqus.com/embed.js"></script>
</head>
<body>
<div id="app"></div>
<!--导入 app 依赖的 JS-->
<script src="app_746f32b2.js"></script>
<!--Disqus 评论容器-->
<div id="disqus_thread"></div>
</body>
</html>
HTML 应该是被压缩过的,这里为了方便大家阅读而格式化了 HTML,并且加入了注释。
构建出的目录结构为:
dist
├── app_792b446e.js
└── index.html
可以看到部分代码被内嵌进了 HTML 的 HEAD 标签中,部分文件的文件名称被打上根据文件内容算出的 Hash 值,并且加载这些文件的 URL 地址也被正常的注入到了 HTML 中。 如果你还采用手写 index.html
文件去完成以上要求,这就会使工作变得复杂、易错,项目难以维护。 本节教你如何自动化的生成这个符合要求的 index.html
。
解决方案
推荐一个用于方便的解决以上问题的 Webpack 插件 web-webpack-plugin。 该插件已经被社区上许多人使用和验证,解决了大家的痛点获得了很多好评,下面具体介绍如何用它来解决上面的问题。
首先,修改 Webpack 配置为如下:
const path = require('path');
const UglifyJsPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const { WebPlugin } = require('web-webpack-plugin');
module.exports = {
entry: {
app: './main.js'// app 的 JavaScript 执行入口文件
},
output: {
filename: '[name]_[chunkhash:8].js',// 给输出的文件名称加上 Hash 值
path: path.resolve(__dirname, './dist'),
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
// 排除 node_modules 目录下的文件,
// 该目录下的文件都是采用的 ES5 语法,没必要再通过 Babel 去转换
exclude: path.resolve(__dirname, 'node_modules'),
},
{
test: /\.css$/,// 增加对 CSS 文件的支持
// 提取出 Chunk 中的 CSS 代码到单独的文件中
use: ExtractTextPlugin.extract({
use: ['css-loader?minimize'] // 压缩 CSS 代码
}),
},
]
},
plugins: [
// 使用本文的主角 WebPlugin,一个 WebPlugin 对应一个 HTML 文件
new WebPlugin({
template: './template.html', // HTML 模版文件所在的文件路径
filename: 'index.html' // 输出的 HTML 的文件名称
}),
new ExtractTextPlugin({
filename: `[name]_[contenthash:8].css`,// 给输出的 CSS 文件名称加上 Hash 值
}),
new DefinePlugin({
// 定义 NODE_ENV 环境变量为 production,以去除源码中只有开发时才需要的部分
'process.env': {
NODE_ENV: JSON.stringify('production')
}
}),
// 压缩输出的 JavaScript 代码
new UglifyJsPlugin({
// 最紧凑的输出
beautify: false,
// 删除所有的注释
comments: false,
compress: {
// 在UglifyJs删除没有用到的代码时不输出警告
warnings: false,
// 删除所有的 `console` 语句,可以兼容ie浏览器
drop_console: true,
// 内嵌定义了但是只用到一次的变量
collapse_vars: true,
// 提取出出现多次但是没有定义成变量去引用的静态值
reduce_vars: true,
}
}),
],
};
以上配置中,大多数都是按照前面已经讲过的内容增加的配置,例如:
- 增加对 CSS 文件的支持,提取出 Chunk 中的 CSS 代码到单独的文件中,压缩 CSS 文件;
- 定义
NODE_ENV
环境变量为production
,以去除源码中只有开发时才需要的部分; - 给输出的文件名称加上 Hash 值;
- 压缩输出的 JavaScript 代码。
但最核心的部分在于 plugins
里的:
new WebPlugin({
template: './template.html', // HTML 模版文件所在的文件路径
filename: 'index.html' // 输出的 HTML 的文件名称
})
其中 template: './template.html'
所指的模版文件 template.html
的内容是:
<html>
<head>
<meta charset="UTF-8">
<!--注入 Chunk app 中的 CSS-->
<link rel="stylesheet" href="app?_inline">
<!--注入 google_analytics 中的 JavaScript 代码-->
<script src="./google_analytics.js?_inline"></script>
<!--异步加载 Disqus 评论-->
<script src="https://dive-into-webpack.disqus.com/embed.js" async></script>
</head>
<body>
<div id="app"></div>
<!--导入 Chunk app 中的 JS-->
<script src="app"></script>
<!--Disqus 评论容器-->
<div id="disqus_thread"></div>
</body>
</html>
该文件描述了哪些资源需要被以何种方式加入到输出的 HTML 文件中。
以 <link rel="stylesheet" href="app?_inline">
为例,按照正常引入 CSS 文件一样的语法来引入 Webpack 生产的代码。 href
属性中的 app?_inline
可以分为两部分,前面的 app
表示 CSS 代码来自名叫 app
的 Chunk 中,后面的 _inline
表示这些代码需要被内嵌到这个标签所在的位置。
同样的 <script src="./google_analytics.js?_inline"></script>
表示 JavaScript 代码来自相对于当前模版文件 template.html
的本地文件 ./google_analytics.js
, 而且文件中的 JavaScript 代码也需要被内嵌到这个标签所在的位置。
也就是说资源链接 URL 字符串里问号前面的部分表示资源内容来自哪里,后面的 querystring 表示这些资源注入的方式。
除了 _inline
表示内嵌外,还支持以下属性:
_dist
只有在生产环境下才引入该资源_dev
只有在开发环境下才引入该资源_ie
只有IE浏览器才需要引入的资源,通过[if IE]>resource<![endif]
注释实现
这些属性之间可以搭配使用,互不冲突。例如 app?_inline&_dist
表示只在生产环境下才引入该资源,并且需要内嵌到 HTML 里去。WebPlugin
插件还支持一些其它更高级的用法,详情可以访问该项目主页阅读文档。
管理多个单页应用
引入问题
上一节中只生成了一个 HTML 文件,但在实际应用中一个完整的系统不会把所有的功能都做到一个网页中,因为这会导致这个网页性能不佳。 实际的做法是按照功能模块划分成多个单页应用,每个单页应用生成一个 HTML 文件。并且随着业务的发展更多的单页应用可能会逐渐被加入到项目中去。
虽然上一节已经解决了自动化生成 HTML 的痛点,但是手动去管理多个单页应用的生成也是一件麻烦的事情。 来继续改造上一节的例子,要求如下:
- 项目目前共有2个单页应用组成,一个是主页
index.html
,一个是用户登入页login.html
; - 多个单页应用之间会有公共的代码部分,需要把这些公共的部分抽离出来,放到单独的文件中去以防止重复加载。例如多个页面都使用一套 CSS 样式,都采用了 React 框架,这些公共的部分需要抽离到单独的文件中;
- 随着业务的发展后面可能会不断的加入新的单页应用,但是每次新加入单页应用不能去改动构建相关的代码。
在开始前先来看看该应用最终发布到线上的代码。login.html
文件内容:
<html>
<head>
<meta charset="UTF-8">
<!--从多个页面中抽离出的公共 CSS 代码-->
<link rel="stylesheet" href="common_7cc98ad0.css">
<!--只有这个页面需要的 CSS 代码-->
<link rel="stylesheet" href="login_e31e214b.css">
<!--注入 google_analytics 中的 JS 代码-->
<script>(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');</script>
<!--异步加载 Disqus 评论-->
<script async="" src="https://dive-into-webpack.disqus.com/embed.js"></script>
</head>
<body>
<div id="app"></div>
<!--从多个页面中抽离出的公共 JavaScript 代码-->
<script src="common_a1d9142f.js"></script>
<!--只有这个页面需要的 JavaScript 代码-->
<script src="login_f926c4e6.js"></script>
<!--Disqus 评论容器-->
<div id="disqus_thread"></div>
</body>
</html>
构建出的目录结构为:
dist
├── common_029086ff.js
├── common_7cc98ad0.css
├── index.html
├── index_04c08fbf.css
├── index_b3d3761c.js
├── login.html
├── login_0a3feca9.js
└── login_e31e214b.css
如果按照上节的思路,可能需要为每个单页应用配置一段如下代码:
new WebPlugin({
template: './template.html', // HTML 模版文件所在的文件路径
filename: 'login.html' // 输出的 HTML 的文件名称
})
并且把页面对应的入口加入到 enrty
配置项中,就像这样:
entry: {
index: './pages/index/index.js',// 页面 index.html 的入口文件
login: './pages/login/index.js',// 页面 login.html 的入口文件
}
当有新页面加入时就需要修改 Webpack 配置文件,新插入一段以上代码,这会导致构建代码难以维护而且易错。
解决方案
上一节中的 web-webpack-plugin 插件也内置了解决这个问题的方法,上一节中只使用了它的 WebPlugin
, 这节将使用它的 AutoWebPlugin
来解决以上问题,使用方法非常简单,下面来教你具体如何使用。
项目源码目录结构如下:
├── pages
│ ├── index
│ │ ├── index.css // 该页面单独需要的 CSS 样式
│ │ └── index.js // 该页面的入口文件
│ └── login
│ ├── index.css
│ └── index.js
├── common.css // 所有页面都需要的公共 CSS 样式
├── google_analytics.js
├── template.html
└── webpack.config.js
从目录结构中可以看成出下几点要求:
- 所有单页应用的代码都需要放到一个目录下,例如都放在 pages 目录下;
- 一个单页应用一个单独的文件夹,例如最后生成的
index.html
相关的代码都在index
目录下,login.html
同理; - 每个单页应用的目录下都有一个
index.js
文件作为入口执行文件。虽然
AutoWebPlugin
强制性的规定了项目部分的目录结构,但从实战经验来看这是一种优雅的目录规范,合理的拆分了代码,又能让新人快速的看懂项目结构,也方便日后的维护。
Webpack 配置文件修改如下:
const { AutoWebPlugin } = require('web-webpack-plugin');
// 使用本文的主角 AutoWebPlugin,自动寻找 pages 目录下的所有目录,把每一个目录看成一个单页应用
const autoWebPlugin = new AutoWebPlugin('pages', {
template: './template.html', // HTML 模版文件所在的文件路径
postEntrys: ['./common.css'],// 所有页面都依赖这份通用的 CSS 样式文件
// 提取出所有页面公共的代码
commonsChunk: {
name: 'common',// 提取出公共代码 Chunk 的名称
},
});
module.exports = {
// AutoWebPlugin 会为寻找到的所有单页应用,生成对应的入口配置,
// autoWebPlugin.entry 方法可以获取到所有由 autoWebPlugin 生成的入口配置
entry: autoWebPlugin.entry({
// 这里可以加入你额外需要的 Chunk 入口
}),
plugins: [
autoWebPlugin,
],
};
以上配置文件为了重点展示出本节侧重修改的部分,省略了部分和上一节一致的代码,完整代码可以参照上一节或者下载本项目完整代码。
AutoWebPlugin
会找出 pages
目录下的2个文件夹 index
和 login
,把这两个文件夹看成两个单页应用。 并且分别为每个单页应用生成一个 Chunk 配置和 WebPlugin
配置。 每个单页应用的 Chunk 名称就等于文件夹的名称,也就是说 autoWebPlugin.entry()
方法返回的内容其实是:
{
"index":["./pages/index/index.js","./common.css"],
"login":["./pages/login/index.js","./common.css"]
}
但这些事情 AutoWebPlugin
都会自动为你完成,你不用操心,明白大致原理即可。template.html
模版文件如下:
<html>
<head>
<meta charset="UTF-8">
<!--在这注入该页面所依赖但没有手动导入的 CSS-->
<!--STYLE-->
<!--注入 google_analytics 中的 JS 代码-->
<script src="./google_analytics.js?_inline"></script>
<!--异步加载 Disqus 评论-->
<script src="https://dive-into-webpack.disqus.com/embed.js" async></script>
</head>
<body>
<div id="app"></div>
<!--在这注入该页面所依赖但没有手动导入的 JavaScript-->
<!--SCRIPT-->
<!--Disqus 评论容器-->
<div id="disqus_thread"></div>
</body>
</html>
注意到模版文件中出现了2个重要的新关键字 <!--STYLE-->
和 <!--SCRIPT-->
,它们是什么意思呢?
由于这个模版文件被当作项目中所有单页应用的模版,就不能再像上一节中直接写 Chunk 的名称去引入资源,因为需要被注入到当前页面的 Chunk 名称是不定的,每个单页应用都会有自己的名称。 <!--STYLE-->
和 <!--SCRIPT-->
的作用在于保证该页面所依赖的资源都会被注入到生成的 HTML 模版里去。
web-webpack-plugin 能分析出每个页面依赖哪些资源,例如对于 login.html
来说,插件可以确定该页面依赖以下资源:
- 所有页面都依赖的公共 CSS 代码
common.css
; - 所有页面都依赖的公共 JavaScrip 代码
common.js
; - 只有这个页面依赖的 CSS 代码
login.css
; - 只有这个页面依赖的 JavaScrip 代码
login.css
。
由于模版文件 template.html
里没有指出引入这些依赖资源的 HTML 语句,插件会自动将没有手动导入但页面依赖的资源按照不同类型注入到 <!--STYLE-->
和 <!--SCRIPT-->
所在的位置。
- CSS 类型的文件注入到
<!--STYLE-->
所在的位置,如果<!--STYLE-->
不存在就注入到 HTML HEAD 标签的最后; - JavaScrip 类型的文件注入到
<!--SCRIPT-->
所在的位置,如果<!--SCRIPT-->
不存在就注入到 HTML BODY 标签的最后。
如果后续有新的页面需要开发,只需要在 pages
目录下新建一个目录,目录名称取为输出 HTML 文件的名称,目录下放这个页面相关的代码即可,无需改动构建代码。
由于 AutoWebPlugin
是间接的通过上一节提到的 WebPlugin
实现的,WebPlugin
支持的功能 AutoWebPlugin
都支持。AutoWebPlugin
插件还支持一些其它更高级的用法,详情可以访问该项目主页阅读文档。
构建同构应用
同构应用是指写一份代码但可同时在浏览器和服务器中运行的应用。
认识同构应用
现在大多数单页应用的视图都是通过 JavaScript 代码在浏览器端渲染出来的,但在浏览器端渲染的坏处有:
- 搜索引擎无法收录你的网页,因为展示出的数据都是在浏览器端异步渲染出来的,大部分爬虫无法获取到这些数据。
- 对于复杂的单页应用,渲染过程计算量大,对低端移动设备来说可能会有性能问题,用户能明显感知到首屏的渲染延迟。
为了解决以上问题,有人提出能否将原本只运行在浏览器中的 JavaScript 渲染代码也在服务器端运行,在服务器端渲染出带内容的 HTML 后再返回。 这样就能让搜索引擎爬虫直接抓取到带数据的 HTML,同时也能降低首屏渲染时间。 由于 Node.js 的流行和成熟,以及虚拟 DOM 提出与实现,使这个假设成为可能。
实际上现在主流的前端框架都支持同构,包括 React、Vue2、Angular2,其中最先支持也是最成熟的同构方案是 React。 由于 React 使用者更多,它们之间又很相似,本节只介绍如何用 Webpack 构建 React 同构应用。
同构应用运行原理的核心在于虚拟 DOM,虚拟 DOM 的意思是不直接操作 DOM 而是通过 JavaScript Object 去描述原本的 DOM 结构。 在需要更新 DOM 时不直接操作 DOM 树,而是通过更新 JavaScript Object 后再映射成 DOM 操作。
虚拟 DOM 的优点在于:
- 因为操作 DOM 树是高耗时的操作,尽量减少 DOM 树操作能优化网页性能。而 DOM Diff 算法能找出2个不同 Object 的最小差异,得出最小 DOM 操作;
- 虚拟 DOM 的在渲染的时候不仅仅可以通过操作 DOM 树来表示出结果,也能有其它的表示方式,例如把虚拟 DOM 渲染成字符串(服务器端渲染),或者渲染成手机 App 原生的 UI 组件( React Native)。
以 React 为例,核心模块 react
负责管理 React 组件的生命周期,而具体的渲染工作可以交给 react-dom
模块来负责。react-dom
在渲染虚拟 DOM 树时有2中方式可选:
- 通过
render()
函数去操作浏览器 DOM 树来展示出结果。 - 通过
renderToString()
计算出表示虚拟 DOM 的 HTML 形式的字符串。
构建同构应用的最终目的是从一份项目源码中构建出2份 JavaScript 代码,一份用于在浏览器端运行,一份用于在 Node.js 环境中运行渲染出 HTML。 其中用于在 Node.js 环境中运行的 JavaScript 代码需要注意以下几点:
- 不能包含浏览器环境提供的 API,例如使用
document
进行 DOM 操作, 因为 Node.js 不支持这些 API; - 不能包含 CSS 代码,因为服务端渲染的目的是渲染出 HTML 内容,渲染出 CSS 代码会增加额外的计算量,影响服务端渲染性能;
- 不能像用于浏览器环境的输出代码那样把
node_modules
里的第三方模块和 Node.js 原生模块(例如fs
模块)打包进去,而是需要通过 CommonJS 规范去引入这些模块。 需要通过 CommonJS 规范导出一个渲染函数,以用于在 HTTP 服务器中去执行这个渲染函数,渲染出 HTML 内容返回。
解决方案
由于要从一份源码构建出2份不同的代码,需要有2份 Webpack 配置文件分别与之对应。 构建用于浏览器环境的配置和前面讲的没有差别,本节侧重于讲如何构建用于服务端渲染的代码。
用于构建浏览器环境代码的webpack.config.js
配置文件保留不变,新建一个专门用于构建服务端渲染代码的配置文件webpack_server.config.js
,内容如下:const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = {
// JS 执行入口文件
entry: './main_server.js',
// 为了不把 Node.js 内置的模块打包进输出文件中,例如 fs net 模块等
target: 'node',
// 为了不把 node_modules 目录下的第三方模块打包进输出文件中
externals: [nodeExternals()],
output: {
// 为了以 CommonJS2 规范导出渲染函数,以给采用 Node.js 编写的 HTTP 服务调用
libraryTarget: 'commonjs2',
// 把最终可在 Node.js 中运行的代码输出到一个 bundle_server.js 文件
filename: 'bundle_server.js',
// 输出文件都放到 dist 目录下
path: path.resolve(__dirname, './dist'),
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
exclude: path.resolve(__dirname, 'node_modules'),
},
{
// CSS 代码不能被打包进用于服务端的代码中去,忽略掉 CSS 文件
test: /\.css$/,
use: ['ignore-loader'],
},
]
},
devtool: 'source-map' // 输出 source-map 方便直接调试 ES6 源码
};
以上代码有几个关键的地方,分别是:
target: 'node'
由于输出代码的运行环境是 Node.js,源码中依赖的 Node.js 原生模块没必要打包进去;externals: [nodeExternals()]
webpack-node-externals 的目的是为了防止 node_modules 目录下的第三方模块被打包进去,因为 Node.js 默认会去 node_modules 目录下寻找和使用第三方模块;{test: /\.css$/, use: ['ignore-loader']}
忽略掉依赖的 CSS 文件,CSS 会影响服务端渲染性能,又是做服务端渲不重要的部分;libraryTarget: 'commonjs2'
以 CommonJS2 规范导出渲染函数,以供给采用 Node.js 编写的 HTTP 服务器代码调用。
为了最大限度的复用代码,需要调整下目录结构:
把页面的根组件放到一个单独的文件 AppComponent.js
,该文件只能包含根组件的代码,不能包含渲染入口的代码,而且需要导出根组件以供给渲染入口调用,AppComponent.js
内容如下:
import React, { Component } from 'react';
import './main.css';
export class AppComponent extends Component {
render() {
return <h1>Hello,Webpack</h1>
}
}
分别为不同环境的渲染入口写两份不同的文件,分别是用于浏览器端渲染 DOM 的 main_browser.js
文件,和用于服务端渲染 HTML 字符串的 main_server.js
文件。main_browser.js
文件内容如下:
import React from 'react';
import { render } from 'react-dom';
import { AppComponent } from './AppComponent';
// 把根组件渲染到 DOM 树上
render(<AppComponent/>, window.document.getElementById('app'));
main_server.js
文件内容如下:
import React from 'react';
import { renderToString } from 'react-dom/server';
import { AppComponent } from './AppComponent';
// 导出渲染函数,以给采用 Node.js 编写的 HTTP 服务器代码调用
export function render() {
// 把根组件渲染成 HTML 字符串
return renderToString(<AppComponent/>)
}
为了能把渲染的完整 HTML 文件通过 HTTP 服务返回给请求端,还需要通过用 Node.js 编写一个 HTTP 服务器。 由于本节不专注于将 HTTP 服务器的实现,就采用了 ExpressJS 来实现,http_server.js
文件内容如下:
const express = require('express');
const { render } = require('./dist/bundle_server');
const app = express();
// 调用构建出的 bundle_server.js 中暴露出的渲染函数,再拼接下 HTML 模版,形成完整的 HTML 文件
app.get('/', function (req, res) {
res.send(`
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app">${render()}</div>
<!--导入 Webpack 输出的用于浏览器端渲染的 JS 文件-->
<script src="./dist/bundle_browser.js"></script>
</body>
</html>
`);
});
// 其它请求路径返回对应的本地文件
app.use(express.static('.'));
app.listen(3000, function () {
console.log('app listening on port 3000!')
});
再安装新引入的第三方依赖:
# 安装 Webpack 构建依赖
npm i -D css-loader style-loader ignore-loader webpack-node-externals
# 安装 HTTP 服务器依赖
npm i -S express
以上所有准备工作已经完成,接下来执行构建,编译出目标文件:
- 执行命令
webpack --config webpack_server.config.js
构建出用于服务端渲染的./dist/bundle_server.js
文件。 - 执行命令
webpack
构建出用于浏览器环境运行的./dist/bundle_browser.js
文件,默认的配置文件为webpack.config.js
。
构建执行完成后,执行 node ./http_server.js
启动 HTTP 服务器后,再用浏览器去访问 http://localhost:3000
就能看到 Hello,Webpack
了。 但是为了验证服务端渲染的结果,你需要打开浏览器的开发工具中的网络抓包一栏,再重新刷新浏览器后,就能抓到请求 HTML 的包了,抓包效果图如下:
可以看到服务器返回的是渲染出内容后的 HTML 而不是 HTML 模版,这说明同构应用的改造完成。
构建 Electron 应用
认识 Electron
Electron 可以让你使用开发 Web 的技术去开发跨平台的桌面端应用,由 Github 主导和开源,大家熟悉的 Atom 和 VSCode 编辑器就是使用 Electron 开发的。
Electron 是 Node.js 和 Chromium 浏览器的结合体,用 Chromium 浏览器显示出的 Web 页面作为应用的 GUI,通过 Node.js 去和操作系统交互。 当你在 Electron 应用中的一个窗口操作时,实际上是在操作一个网页。当你的操作需要通过操作系统去完成时,网页会通过 Node.js 去和操作系统交互。
采用这种方式开发桌面端应用的优点有:
- 降低开发门槛,只需掌握网页开发技术和 Node.js 即可,大量的 Web 开发技术和现成库可以复用于 Electron;
- 由于 Chromium 浏览器和 Node.js 都是跨平台的,Electron 能做到写一份代码在不同的操作系统运行。
在运行 Electron 应用时,会从启动一个主进程开始。主进程的启动是通过 Node.js 去执行一个入口 JavaScript 文件实现的,这个入口文件 main.js
内容如下:
const { app, BrowserWindow } = require('electron')
// 保持一个对于 window 对象的全局引用,如果你不这样做,
// 当 JavaScript 对象被垃圾回收, window 会被自动地关闭
let win
// 打开主窗口
function createWindow() {
// 创建浏览器窗口
win = new BrowserWindow({ width: 800, height: 600 })
// 加载应用的 index.html
const indexPageURL = `file://${__dirname}/dist/index.html`;
win.loadURL(indexPageURL);
// 当 window 被关闭,这个事件会被触发
win.on('closed', () => {
// 取消引用 window 对象
win = null
})
}
// Electron 会在创建浏览器窗口时调用这个函数。
app.on('ready', createWindow)
// 当全部窗口关闭时退出
app.on('window-all-closed', () => {
// 在 macOS 上,除非用户用 Cmd + Q 确定地退出
// 否则绝大部分应用会保持激活
if (process.platform !== 'darwin') {
app.quit()
}
})
主进程启动后会一直驻留在后台运行,你眼睛所看得的和操作的窗口并不是主进程,而是由主进程新启动的窗口子进程。
应用从启动到退出有一系列生命周期事件,通过 electron.app.on()
函数去监听生命周期事件,在特定的时刻做出反应。 例如在 app.on('ready')
事件中通过 BrowserWindow
去展示应用的主窗口,具体用法见 BrowserWindow 的 API 文档。
启动的窗口其实是一个网页,启动时会去加载在 loadURL
中传入的网页地址。 每个窗口都是一个单独的网页进程,窗口之间的通信需要借助主进程传递消息。
总体来说开发 Electron 应用和开发 Web 应用很相似,区别在于 Electron 的运行环境同时内置了浏览器和 Node.js 的 API,在开发网页时除了可以使用浏览器提供的 API 外,还可以使用 Node.js 提供的 API。
接入 Webpack
接下来做一个简单的 Electron 应用,要求为应用启动后显示一个主窗口,在主窗口里有一个按钮,点击这个按钮后新显示一个窗口,且使用 React 开发网页。
由于 Electron 应用中的每一个窗口对应一个网页,所以需要开发2个网页,分别是主窗口的 index.html
和新打开的窗口 login.html
。 也就是说项目由2个单页应用组成,这和3-10管理多个单页应用 中的项目非常相似,让我们来把它改造成一个 Electron 应用。
需要改动的地方如下:
- 在项目根目录下新建主进程的入口文件
main.js
,内容和上面提到的一致; 主窗口网页的代码如下:
import React, { Component } from 'react';
import { render } from 'react-dom';
import { remote } from 'electron';
import path from 'path';
import './index.css';
class App extends Component {
// 在按钮被点击时
handleBtnClick() {
// 新窗口对应的页面的 URI 地址
const modalPath = path.join('file://', remote.app.getAppPath(), 'dist/login.html');
// 新窗口的大小
let win = new remote.BrowserWindow({ width: 400, height: 320 })
win.on('close', function () {
// 窗口被关闭时清空资源
win = null
})
// 加载网页
win.loadURL(modalPath)
// 显示窗口
win.show()
}
render() {
return (
<div>
<h1>Page Index</h1>
<button onClick={this.handleBtnClick}>Open Page Login</button>
</div>
)
}
}
render(<App/>, window.document.getElementById('app'));
其中最关键的部分在于在按钮点击事件里通过
electron
库里提供的 API 去新打开一个窗口,并加载网页文件所在的地址。
页面部分的代码已经修改完成,接下来修改构建方面的代码。 这里构建需要做到以下几点:构建出2个可在浏览器里运行的网页,分别对应2个窗口的界面;
- 由于在网页的 JavaScript 代码里可能会有调用 Node.js 原生模块或者
electron
模块,也就是输出的代码依赖这些模块。但由于这些模块都是内置支持的,构建出的代码不能把这些模块打包进去。
要完成以上要求非常简单,因为 Webpack 内置了对 Electron 的支持。 只需要给 Webpack 配置文件加上一行代码即可,如下:
target: 'electron-renderer',
这句配置曾在2-7其它配置项-Target中提到,意思是指让 Webpack 构建出用于 Electron 渲染进程用的 JavaScript 代码,也就是这2个窗口需要的网页代码。
以上修改都完成后重新执行 Webpack 构建,对应的网页需要的代码都输出到了项目根目录下的 dist
目录里。
为了以 Electron 应用的形式运行,还需要安装新依赖:
# 安装 Electron 执行环境到项目中
npm i -D electron
安装成功后在项目目录下执行 electron .
你就能成功看到启动的桌面应用了,效果如图:
构建 npm 模块
npm 是目前最大的 JavaScript 模块仓库,里面有来自全世界开发者上传的可复用模块。 虽然大多数情况下你都是这些开放模块的使用者,但有时候你也许会成为贡献者,开发一个模块上传到 Npm 仓库。
发布到 Npm 仓库的模块有以下几个特点:
- 每个模块根目录下都必须有一个描述该模块的
package.json
文件。该文件描述了模块的入口文件是哪个,该模块又依赖哪些模块等。想深入了解可以阅读文章 package.json文件。 - 模块中的文件以 JavaScript 文件为主,但不限于 JavaScript 文件。例如一个 UI 组件模块可能同时需要 JavaScript、CSS、图片文件等。
- 模块中的代码大多采用模块化规范,因为你的这个模块可能依赖其它模块,而且别的模块又可能依赖你的这个模块。因为目前支持比较广泛的是 CommonJS 模块化规范,上传到 Npm 仓库的代码最好遵守该规范。
抛出问题
Webpack 不仅可用于构建运行的应用,也可用于构建上传到 Npm 的模块。 接下来用教大家如何用 Webpack 构建一个可上传的 Npm 仓库的 React 组件,具体要求如下:
- 源代码采用 ES6 写,但发布到 Npm 仓库的需要是 ES5 的,并且遵守 CommonJS 模块化规范。如果发布到 Npm 上去的 ES5 代码是经过转换的,请同时提供 Source Map 以方便调试。
- 该 UI 组件依赖的其它资源文件例如 CSS 文件也需要包含在发布的模块里。
- 尽量减少冗余代码,减少发布出去的组件的代码文件大小。
- 发布出去的组件的代码中不能含有其依赖的模块的代码,而是让用户可选择性的去安装。例如不能内嵌 React 库的代码,这样做的目的是在其它组件也依赖 React 库时,防止 React 库的代码被重复打包。
在开始前先看下最终发布到 Npm 仓库的模块的目录结构:
node_modules/hello-webpack
├── lib
│ ├── index.css (组件所有依赖的 CSS 都在这个文件中)
│ ├── index.css.map
│ ├── index.js (符合 CommonJS 模块化规范的 ES5 代码)
│ └── index.js.map
├── src (ES6 源码)
│ ├── index.css
│ └── index.js
└── package.json (模块描述文件)
由于本节的重点不在于 React 而在于 Webpack,所以写一个最简单的 React 组件,其代码放在 src/index.js
文件中,内容如下:
import React, { Component } from 'react';
import './index.css';
// 导出该组件供给其它模块使用
export default class HelloWebpack extends Component {
render() {
return <h1 className="hello-component">Hello,Webpack</h1>
}
}
要使用该模块时只需要这样:
// 通过 ES6 语法导入
import HelloWebpack from 'hello-webpack';
import 'hello-webpack/lib/index.css';
// 或者通过 ES5 语法导入
var HelloWebpack = require('hello-webpack');
require('hello-webpack/lib/index.css');
// 使用 react-dom 渲染
render(<HelloWebpack/>);
使用 Webpack 构建 Npm 模块
接下来用 Webpack 一条条来解决上面抛出问题的4点要求。
对于要求1,可以这样做到:
- 使用 babel-loader 把 ES6 代码转换成 ES5 的代码。
- 通过开启
devtool: 'source-map'
输出 Source Map 以发布调试。 - 设置
output.libraryTarget='commonjs2'
使输出的代码符合CommonJS2 模块化规范,以供给其它模块导入使用。在2-2 Output-libraryTarget 和 library 有介绍这个配置的含义。
相关的 Webpack 配置代码如下:
module.exports = {
output: {
// 输出的代码符合 CommonJS 模块化规范,以供给其它模块导入使用。
libraryTarget: 'commonjs2',
},
// 输出 Source Map
devtool: 'source-map',
};
对于要求2,需要通过 css-loader 和 extract-text-webpack-plugin 实现,相关的 Webpack 配置代码如下:
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
module: {
rules: [
{
// 增加对 CSS 文件的支持
test: /\.css$/,
// 提取出 Chunk 中的 CSS 代码到单独的文件中
use: ExtractTextPlugin.extract({
use: ['css-loader']
}),
},
]
},
plugins: [
new ExtractTextPlugin({
// 输出的 CSS 文件名称
filename: 'index.css',
}),
],
};
此步引入了3个新依赖:
# 安装 Webpack 构建所需要的新依赖
npm i -D style-loader css-loader extract-text-webpack-plugin
对于要求3,需要注意的是 Babel 在把 ES6 代码转换成 ES5 代码时会注入一些辅助函数。
例如下面这段 ES6 代码
class HelloWebpack extends Component{
}
在被转换成能正常运行的 ES5 代码时需要以下2个辅助函数:
babel-runtime/helpers/createClass
用于实现 class 语法babel-runtime/helpers/inherits
用于实现 extends 语法
默认的情况下 Babel 会在每个输出文件中内嵌这些依赖的辅助函数的代码,如果多个源代码文件都依赖这些辅助函数,那么这些辅助函数的代码将会重复的出现很多次,造成代码冗余。 为了不让这些辅助函数的代重复出现,可以在依赖它们的时候通过 require('babel-runtime/helpers/createClass')
的方式去导入,这样就能做到只让它们出现一次。 babel-plugin-transform-runtime 插件就是用来做这个事情的。
修改 .babelrc
文件,为其加入 transform-runtime 插件:
{
"plugins": [
[
"transform-runtime",
{
// transform-runtime 默认会自动的为你使用的 ES6 API 注入 polyfill
// 假如你在源码中使用了 Promise,输出的代码将会自动注入 require('babel-runtime/core-js/Promise') 语句
// polyfill 的注入应该交给模块使用者,因为使用者可能在其它地方已经注入了其它的 Promise polyfill 库
// 所以关闭该功能
"polyfill": false
}
]
]
}
由于加入 babel-plugin-transform-runtime 后生成的代码中会大量出现类似 require('babel-runtime/helpers/createClass')
这样的语句,所以输出的代码将依赖 babel-runtime
模块。
此步引入了3个新依赖:
# 安装 Webpack 构建所需要的新依赖
npm i -D babel-plugin-transform-runtime
# 安装输出代码运行时所需的新依赖
npm i -S babel-runtime
对于要求4,需要用Externals 来实现。
Externals 用来告诉 Webpack 要构建的代码中使用了哪些不用被打包的模块,也就是说这些模版是外部环境提供的,Webpack 在打包时可以忽略它们。
相关的 Webpack 配置代码如下:
module.exports = {
// 通过正则命中所有以 react 或者 babel-runtime 开头的模块
// 这些模块通过注册在运行环境中的全局变量访问,不用被重复打包进输出的代码里
externals: /^(react|babel-runtime)/,
};
开启以上配置后,输出的代码中会存在导入 react 或者 babel-runtime 模块的代码,但是它们的 react 或者 babel-runtime 的内容不会被包含进去,如下:
[
(function (module, exports) {
module.exports = require("babel-runtime/helpers/inherits");
}),
(function (module, exports) {
module.exports = require("react");
})
]
这样就做到了在保持代码正确性的情况下,输出文件不存放 react 或者 babel-runtime 模块的代码。
实际上当你在开发 Npm 模块时,不只需要对 react 和 babel-runtime 模块做这样的处理,而是需要对所有正在开发的模块所依赖的模块进行这样的处理。 因为正在开发的模块所依赖的模块也可能被其它模块所依赖。 当一个项目中一个模块被依赖多次时,Webpack 只会将其打包一次,想更深入的了解可以阅读后面的输出文件分析。
完成以上4步后最终的 Webpack 完整配置代码如下:
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
// 模块的入口文件
entry: './src/index.js',
output: {
// 输出文件的名称
filename: 'index.js',
// 输出文件的存放目录
path: path.resolve(__dirname, 'lib'),
// 输出的代码符合 CommonJS 模块化规范,以供给其它模块导入使用。
libraryTarget: 'commonjs2',
},
// 通过正则命中所有以 react 或者 babel-runtime 开头的模块,
// 这些模块使用外部的,不能被打包进输出的代码里,防止它们出现多次。
externals: /^(react|babel-runtime)/,
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
// 排除 node_modules 目录下的文件,
// node_modules 目录下的文件都是采用的 ES5 语法,没必要再通过 Babel 去转换。
exclude: path.resolve(__dirname, 'node_modules'),
},
{
// 增加对 CSS 文件的支持
test: /\.css$/,
// 提取出 Chunk 中的 CSS 代码到单独的文件中
use: ExtractTextPlugin.extract({
use: ['css-loader']
}),
},
]
},
plugins: [
new ExtractTextPlugin({
// 输出的 CSS 文件名称
filename: 'index.css',
}),
],
// 输出 Source Map
devtool: 'source-map',
};
重新执行构建后,你将会在项目目录下看到一个新目录 lib
,里面放着要发布到 Npm 仓库的最终代码。
发布到 npm
在把构建出的代码发布到 Npm 仓库前,还需要确保你的模块描述文件 package.json
是正确配置的。
由于构建出的代码的入口文件是 ./lib/index.js
,需要修改 package.json
中的 main
字段如下:
{
"main": "lib/index.js",
"jsnext:main": "src/index.js"
}
其中 jsnext:main
字段用于指出采用 ES6 编写的模块入口文件所在的位置,这样做的目的是为了方便实现在 Tree Shaking。
修改完毕后在项目目录下执行 npm publish
就能把构建出的代码发布到 Npm 仓库中(确保已经 npm login
过)。
如果你想让发布到 Npm 上去的代码保持和源码的目录结构一致,那么用 Webpack 将不在适合。 因为源码是一个个分割的模块化文件,而 Webpack 会把这些模块组合在一起。 虽然 Webpack 输出的文件也可以是采用 CommonJS 模块化语法的,但在有些场景下把所有模块打包成一个文件发布到 Npm 是不适合的。 例如像 Lodash 这样的工具函数库在项目中可能只用到了其中几个工具函数,如果所有工具函数打包在一个文件中,那么所有工具函数都会被打包进去,而保持模块文件的独立能做到只打包进使用到的。 还有就是像 UI 组件库这样由大量独立组件组成的库也和 Lodash 类似。 所以 Webpack 适合于构建完整不可分割的 Npm 模块。
5. 原理
工作原理概括
Webpack 以其使用简单著称,在使用它的过程中,使用者只需把它当作一个黑盒,需要关心的只有它暴露出来的配置。 本节将带你走进这个黑盒,看看 Webpack 是如何运行的。
基本概念
在了解 Webpack 原理前,需要掌握以下几个核心概念,以方便后面的理解:
- Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
- Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
- Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
- Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
- Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。
流程概括
Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:
- 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
- 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
- 确定入口:根据配置中的 entry 找出所有的入口文件;
- 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
- 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
- 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
- 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。
流程细节
Webpack 的构建流程可以分为以下三大阶段:
- 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。
- 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
- 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。
如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:
在每个大阶段中又会发生很多事件,Webpack 会把这些事件广播出来供给 Plugin 使用,下面来一一介绍。
初始化阶段
事件名 | 解释 |
---|---|
初始化参数 | 从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。 这个过程中还会执行配置文件中的插件实例化语句 new Plugin() 。 |
实例化 Compiler | 用上一步得到的参数初始化 Compiler 实例,Compiler 负责文件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置,全局只有一个 Compiler 实例。 |
加载插件 | 依次调用插件的 apply 方法,让插件可以监听后续的所有事件节点。同时给插件传入 compiler 实例的引用,以方便插件通过 compiler 调用 Webpack 提供的 API。 |
environment | 开始应用 Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取。 |
entry-option | 读取配置的 Entrys,为每个 Entry 实例化一个对应的 EntryPlugin,为后面该 Entry 的递归解析工作做准备。 |
after-plugins | 调用完所有内置的和配置的插件的 apply 方法。 |
after-resolvers | 根据配置初始化完 resolver,resolver 负责在文件系统中寻找指定路径的文件。 |
编译阶段
事件名 | 解释 |
---|---|
run | 启动一次新的编译。 |
watch-run | 和 run 类似,区别在于它是在监听模式下启动的编译,在这个事件中可以获取到是哪些文件发生了变化导致重新启动一次新的编译。 |
compile | 该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上 compiler 对象。 |
compilation | 当 Webpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被创建。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了很多事件回调供插件做扩展。 |
make | 一个新的 Compilation 创建完毕,即将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析。 |
after-compile | 一次 Compilation 执行完成。 |
invalid | 当遇到文件不存在、文件编译错误等异常时会触发该事件,该事件不会导致 Webpack 退出。 |
在编译阶段中,最重要的要数 compilation 事件了,因为在 compilation 阶段调用了 Loader 完成了每个模块的转换操作,在 compilation 阶段又包括很多小的事件,它们分别是:
事件名 | 解释 |
---|---|
build-module | 使用对应的 Loader 去转换一个模块。 |
normal-module-loader | 在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析。 |
program | 从配置的入口模块开始,分析其 AST,当遇到 require 等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。 |
seal | 所有模块及其依赖的模块都通过 Loader 转换完成后,根据依赖关系开始生成 Chunk。 |
输出阶段
事件名 | 解释 |
---|---|
should-emit | 所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。 |
emit | 确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。 |
after-emit | 文件输出完毕。 |
done | 成功完成一次完成的编译和输出流程。 |
failed | 如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。 |
在输出阶段已经得到了各个模块经过转换后的结果和其依赖关系,并且把相关模块组合在一起形成一个个 Chunk。 在输出阶段会根据 Chunk 的类型,使用对应的模版生成最终要要输出的文件内容。
至于如何把 Chunk 输出为具体的文件,详情可以阅读 输出文件分析。
输出文件分析
虽然在前面的章节中你学会了如何使用 Webpack ,也大致知道其工作原理,可是你想过 Webpack 输出的 bundle.js
是什么样子的吗? 为什么原来一个个的模块文件被合并成了一个单独的文件?为什么 bundle.js
能直接运行在浏览器中? 本节将解释清楚以上问题。
最简单的项目构建出的 bundle.js
文件内容,代码如下:
(
// webpackBootstrap 启动函数
// modules 即为存放所有模块的数组,数组中的每一个元素都是一个函数
function (modules) {
// 安装过的模块都存放在这里面
// 作用是把已经加载过的模块缓存在内存中,提升性能
var installedModules = {};
// 去数组中加载一个模块,moduleId 为要加载模块在数组中的 index
// 作用和 Node.js 中 require 语句相似
function __webpack_require__(moduleId) {
// 如果需要加载的模块已经被加载过,就直接从内存缓存中返回
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 如果缓存中不存在需要加载的模块,就新建一个模块,并把它存在缓存中
var module = installedModules[moduleId] = {
// 模块在数组中的 index
i: moduleId,
// 该模块是否已经加载完毕
l: false,
// 该模块的导出值
exports: {}
};
// 从 modules 中获取 index 为 moduleId 的模块对应的函数
// 再调用这个函数,同时把函数需要的参数传入
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 把这个模块标记为已加载
module.l = true;
// 返回这个模块的导出值
return module.exports;
}
// Webpack 配置中的 publicPath,用于加载被分割出去的异步代码
__webpack_require__.p = "";
// 使用 __webpack_require__ 去加载 index 为 0 的模块,并且返回该模块导出的内容
// index 为 0 的模块就是 main.js 对应的文件,也就是执行入口模块
// __webpack_require__.s 的含义是启动模块对应的 index
return __webpack_require__(__webpack_require__.s = 0);
})(
// 所有的模块都存放在了一个数组里,根据每个模块在数组的 index 来区分和定位模块
[
/* 0 */
(function (module, exports, __webpack_require__) {
// 通过 __webpack_require__ 规范导入 show 函数,show.js 对应的模块 index 为 1
const show = __webpack_require__(1);
// 执行 show 函数
show('Webpack');
}),
/* 1 */
(function (module, exports) {
function show(content) {
window.document.getElementById('app').innerText = 'Hello,' + content;
}
// 通过 CommonJS 规范导出 show 函数
module.exports = show;
})
]
);
以上看上去复杂的代码其实是一个立即执行函数,可以简写为如下:
(function(modules) {
// 模拟 require 语句
function __webpack_require__() {
}
// 执行存放所有模块数组中的第0个模块
__webpack_require__(0);
})([/*存放所有模块的数组*/])
bundle.js
能直接运行在浏览器中的原因在于输出的文件中通过 __webpack_require__
函数定义了一个可以在浏览器中执行的加载函数来模拟 Node.js 中的 require
语句。
原来一个个独立的模块文件被合并到了一个单独的 bundle.js
的原因在于浏览器不能像 Node.js 那样快速地去本地加载一个个模块文件,而必须通过网络请求去加载还未得到的文件。 如果模块数量很多,加载时间会很长,因此把所有模块都存放在了数组中,执行一次网络加载。
如果仔细分析 __webpack_require__
函数的实现,你还有发现 Webpack 做了缓存优化: 执行加载过的模块不会再执行第二次,执行结果会缓存在内存中,当某个模块第二次被访问时会直接去内存中读取被缓存的返回值。
分割代码时的输出
在采用了 4-12 按需加载 中介绍过的优化方法时,Webpack 的输出文件会发生变化。
例如把源码中的 main.js
修改为如下:
// 异步加载 show.js
import('./show').then((show) => {
// 执行 show 函数
show('Webpack');
});
重新构建后会输出两个文件,分别是执行入口文件 bundle.js
和 异步加载文件 0.bundle.js
。
其中 0.bundle.js
内容如下:
// 加载在本文件(0.bundle.js)中包含的模块
webpackJsonp(
// 在其它文件中存放着的模块的 ID
[0],
// 本文件所包含的模块
[
// show.js 所对应的模块
(function (module, exports) {
function show(content) {
window.document.getElementById('app').innerText = 'Hello,' + content;
}
module.exports = show;
})
]
);
bundle.js
内容如下:
(function (modules) {
/***
* webpackJsonp 用于从异步加载的文件中安装模块。
* 把 webpackJsonp 挂载到全局是为了方便在其它文件中调用。
*
* @param chunkIds 异步加载的文件中存放的需要安装的模块对应的 Chunk ID
* @param moreModules 异步加载的文件中存放的需要安装的模块列表
* @param executeModules 在异步加载的文件中存放的需要安装的模块都安装成功后,需要执行的模块对应的 index
*/
window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
// 把 moreModules 添加到 modules 对象中
// 把所有 chunkIds 对应的模块都标记成已经加载成功
var moduleId, chunkId, i = 0, resolves = [], result;
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
while (resolves.length) {
resolves.shift()();
}
};
// 缓存已经安装的模块
var installedModules = {};
// 存储每个 Chunk 的加载状态;
// 键为 Chunk 的 ID,值为0代表已经加载成功
var installedChunks = {
1: 0
};
// 模拟 require 语句,和上面介绍的一致
function __webpack_require__(moduleId) {
// ... 省略和上面一样的内容
}
/**
* 用于加载被分割出去的,需要异步加载的 Chunk 对应的文件
* @param chunkId 需要异步加载的 Chunk 对应的 ID
* @returns {Promise}
*/
__webpack_require__.e = function requireEnsure(chunkId) {
// 从上面定义的 installedChunks 中获取 chunkId 对应的 Chunk 的加载状态
var installedChunkData = installedChunks[chunkId];
// 如果加载状态为0表示该 Chunk 已经加载成功了,直接返回 resolve Promise
if (installedChunkData === 0) {
return new Promise(function (resolve) {
resolve();
});
}
// installedChunkData 不为空且不为0表示该 Chunk 正在网络加载中
if (installedChunkData) {
// 返回存放在 installedChunkData 数组中的 Promise 对象
return installedChunkData[2];
}
// installedChunkData 为空,表示该 Chunk 还没有加载过,去加载该 Chunk 对应的文件
var promise = new Promise(function (resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
installedChunkData[2] = promise;
// 通过 DOM 操作,往 HTML head 中插入一个 script 标签去异步加载 Chunk 对应的 JavaScript 文件
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.type = 'text/javascript';
script.charset = 'utf-8';
script.async = true;
script.timeout = 120000;
// 文件的路径为配置的 publicPath、chunkId 拼接而成
script.src = __webpack_require__.p + "" + chunkId + ".bundle.js";
// 设置异步加载的最长超时时间
var timeout = setTimeout(onScriptComplete, 120000);
script.onerror = script.onload = onScriptComplete;
// 在 script 加载和执行完成时回调
function onScriptComplete() {
// 防止内存泄露
script.onerror = script.onload = null;
clearTimeout(timeout);
// 去检查 chunkId 对应的 Chunk 是否安装成功,安装成功时才会存在于 installedChunks 中
var chunk = installedChunks[chunkId];
if (chunk !== 0) {
if (chunk) {
chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
}
installedChunks[chunkId] = undefined;
}
};
head.appendChild(script);
return promise;
};
// 加载并执行入口模块,和上面介绍的一致
return __webpack_require__(__webpack_require__.s = 0);
})
(
// 存放所有没有经过异步加载的,随着执行入口文件加载的模块
[
// main.js 对应的模块
(function (module, exports, __webpack_require__) {
// 通过 __webpack_require__.e 去异步加载 show.js 对应的 Chunk
__webpack_require__.e(0).then(__webpack_require__.bind(null, 1)).then((show) => {
// 执行 show 函数
show('Webpack');
});
})
]
);
这里的 bundle.js
和上面所讲的 bundle.js
非常相似,区别在于:
- 多了一个
__webpack_require__.e
用于加载被分割出去的,需要异步加载的 Chunk 对应的文件; - 多了一个
webpackJsonp
函数用于从异步加载的文件中安装模块。
在使用了 CommonsChunkPlugin 去提取公共代码时输出的文件和使用了异步加载时输出的文件是一样的,都会有 __webpack_require__.e
和 webpackJsonp
。 原因在于提取公共代码和异步加载本质上都是代码分割。
Loader 原理
Loader 的职责
Loader 就像是一个翻译员,能把源文件经过转化后输出新的结果,并且一个文件还可以链式的经过多个翻译员翻译。
以处理 SCSS 文件为例:
- SCSS 源代码会先交给 sass-loader 把 SCSS 转换成 CSS;
- 把 sass-loader 输出的 CSS 交给 css-loader 处理,找出 CSS 中依赖的资源、压缩 CSS 等;
- 把 css-loader 输出的 CSS 交给 style-loader 处理,转换成通过脚本加载的 JavaScript 代码;
可以看出以上的处理过程需要有顺序的链式执行,先 sass-loader 再 css-loader 再 style-loader。 以上处理的 Webpack 相关配置如下:
module.exports = {
module: {
rules: [
{
// 增加对 SCSS 文件的支持
test: /\.scss$/,
// SCSS 文件的处理顺序为先 sass-loader 再 css-loader 再 style-loader
use: [
'style-loader',
{
loader:'css-loader',
// 给 css-loader 传入配置项
options:{
minimize:true,
}
},
'sass-loader'],
},
]
},
};
由上面的例子可以看出:一个 Loader 的职责是单一的,只需要完成一种转换。 如果一个源文件需要经历多步转换才能正常使用,就通过多个 Loader 去转换。 在调用多个 Loader 去转换一个文件时,每个 Loader 会链式的顺序执行, 第一个 Loader 将会拿到需处理的原内容,上一个 Loader 处理后的结果会传给下一个接着处理,最后的 Loader 将处理后的最终结果返回给 Webpack。
所以,在你开发一个 Loader 时,请保持其职责的单一性,你只需关心输入和输出。
Loader 基础
由于 Webpack 是运行在 Node.js 之上的,一个 Loader 其实就是一个 Node.js 模块,这个模块需要导出一个函数。 这个导出的函数的工作就是获得处理前的原内容,对原内容执行处理后,返回处理后的内容。
一个最简单的 Loader 的源码如下:
module.exports = function(source) {
// source 为 compiler 传递给 Loader 的一个文件的原内容
// 该函数需要返回处理后的内容,这里简单起见,直接把原内容返回了,相当于该 Loader 没有做任何转换
return source;
};
由于 Loader 运行在 Node.js 中,你可以调用任何 Node.js 自带的 API,或者安装第三方模块进行调用:
const sass = require('node-sass');
module.exports = function(source) {
return sass(source);
};
获得 Loader 的 options
在最上面处理 SCSS 文件的 Webpack 配置中,给 css-loader 传了 options 参数,以控制 css-loader。 如何在自己编写的 Loader 中获取到用户传入的 options 呢?需要这样做:
const loaderUtils = require('loader-utils');
module.exports = function(source) {
// 获取到用户给当前 Loader 传入的 options
const options = loaderUtils.getOptions(this);
return source;
};
返回其它结果
上面的 Loader 都只是返回了原内容转换后的内容,但有些场景下还需要返回除了内容之外的东西。
例如以用 babel-loader 转换 ES6 代码为例,它还需要输出转换后的 ES5 代码对应的 Source Map,以方便调试源码。 为了把 Source Map 也一起随着 ES5 代码返回给 Webpack,可以这样写:
module.exports = function(source) {
// 通过 this.callback 告诉 Webpack 返回的结果
this.callback(null, source, sourceMaps);
// 当你使用 this.callback 返回内容时,该 Loader 必须返回 undefined,
// 以让 Webpack 知道该 Loader 返回的结果在 this.callback 中,而不是 return 中
return;
};
其中的 this.callback
是 Webpack 给 Loader 注入的 API,以方便 Loader 和 Webpack 之间通信。 this.callback
的详细使用方法如下:
this.callback(
// 当无法转换原内容时,给 Webpack 返回一个 Error
err: Error | null,
// 原内容转换后的内容
content: string | Buffer,
// 用于把转换后的内容得出原内容的 Source Map,方便调试
sourceMap?: SourceMap,
// 如果本次转换为原内容生成了 AST 语法树,可以把这个 AST 返回,
// 以方便之后需要 AST 的 Loader 复用该 AST,以避免重复生成 AST,提升性能
abstractSyntaxTree?: AST
);
Source Map 的生成很耗时,通常在开发环境下才会生成 Source Map,其它环境下不用生成,以加速构建。 为此 Webpack 为 Loader 提供了
this.sourceMap
API 去告诉 Loader 当前构建环境下用户是否需要 Source Map。 如果你编写的 Loader 会生成 Source Map,请考虑到这点。
同步与异步
Loader 有同步和异步之分,上面介绍的 Loader 都是同步的 Loader,因为它们的转换流程都是同步的,转换完成后再返回结果。 但在有些场景下转换的步骤只能是异步完成的,例如你需要通过网络请求才能得出结果,如果采用同步的方式网络请求就会阻塞整个构建,导致构建非常缓慢。
在转换步骤是异步时,你可以这样:
module.exports = function(source) {
// 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
var callback = this.async();
someAsyncOperation(source, function(err, result, sourceMaps, ast) {
// 通过 callback 返回异步执行后的结果
callback(err, result, sourceMaps, ast);
});
};
处理二进制数据
在默认的情况下,Webpack 传给 Loader 的原内容都是 UTF-8 格式编码的字符串。 但有些场景下 Loader 不是处理文本文件,而是处理二进制文件,例如 file-loader,就需要 Webpack 给 Loader 传入二进制格式的数据。 为此,你需要这样编写 Loader:
module.exports = function(source) {
// 在 exports.raw === true 时,Webpack 传给 Loader 的 source 是 Buffer 类型的
source instanceof Buffer === true;
// Loader 返回的类型也可以是 Buffer 类型的
// 在 exports.raw !== true 时,Loader 也可以返回 Buffer 类型的结果
return source;
};
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据
module.exports.raw = true;
以上代码中最关键的代码是最后一行 module.exports.raw = true;
,没有该行 Loader 只能拿到字符串。
缓存加速
在有些情况下,有些转换操作需要大量计算非常耗时,如果每次构建都重新执行重复的转换操作,构建将会变得非常缓慢。 为此,Webpack 会默认缓存所有 Loader 的处理结果,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时, 是不会重新调用对应的 Loader 去执行转换操作的。
如果你想让 Webpack 不缓存该 Loader 的处理结果,可以这样:
module.exports = function(source) {
// 关闭该 Loader 的缓存功能
this.cacheable(false);
return source;
};
其它 Loader API
除了以上提到的在 Loader 中能调用的 Webpack API 外,还存在以下常用 API:
this.context
:当前处理文件的所在目录,假如当前 Loader 处理的文件是/src/main.js
,则this.context
就等于/src
。this.resource
:当前处理文件的完整请求路径,包括 querystring,例如/src/main.js?name=1
。this.resourcePath
:当前处理文件的路径,例如/src/main.js
。this.resourceQuery
:当前处理文件的 querystring。this.target
:等于 Webpack 配置中的 Target,详情见 2-7其它配置项-Target。this.loadModule
:当 Loader 在处理一个文件时,如果依赖其它文件的处理结果才能得出当前文件的结果时, 就可以通过this.loadModule(request: string, callback: function(err, source, sourceMap, module))
去获得request
对应文件的处理结果。this.resolve
:像require
语句一样获得指定文件的完整路径,使用方法为resolve(context: string, request: string, callback: function(err, result: string))
。this.addDependency
:给当前处理文件添加其依赖的文件,以便再其依赖的文件发生变化时,会重新调用 Loader 处理该文件。使用方法为addDependency(file: string)
。this.addContextDependency
:和addDependency
类似,但addContextDependency
是把整个目录加入到当前正在处理文件的依赖中。使用方法为addContextDependency(directory: string)
。this.clearDependencies
:清除当前正在处理文件的所有依赖,使用方法为clearDependencies()
。this.emitFile
:输出一个文件,使用方法为emitFile(name: string, content: Buffer|string, sourceMap: {...})
。
其它没有提到的 API 可以去 Webpack 官网 查看。
加载本地 Loader
在开发 Loader 的过程中,为了测试编写的 Loader 是否能正常工作,需要把它配置到 Webpack 中后,才可能会调用该 Loader。 在前面的章节中,使用的 Loader 都是通过 Npm 安装的,要使用 Loader 时会直接使用 Loader 的名称,代码如下:
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader'],
},
]
},
};
如果还采取以上的方法去使用本地开发的 Loader 将会很麻烦,因为你需要确保编写的 Loader 的源码是在 node_modules
目录下。 为此你需要先把编写的 Loader 发布到 Npm 仓库后再安装到本地项目使用。
解决以上问题的便捷方法有两种,分别如下:
Npm link
Npm link 专门用于开发和调试本地 Npm 模块,能做到在不发布模块的情况下,把本地的一个正在开发的模块的源码链接到项目的 node_modules
目录下,让项目可以直接使用本地的 Npm 模块。 由于是通过软链接的方式实现的,编辑了本地的 Npm 模块代码,在项目中也能使用到编辑后的代码。
完成 Npm link 的步骤如下:
- 确保正在开发的本地 Npm 模块(也就是正在开发的 Loader)的
package.json
已经正确配置好; - 在本地 Npm 模块根目录下执行
npm link
,把本地模块注册到全局; - 在项目根目录下执行
npm link loader-name
,把第2步注册到全局的本地 Npm 模块链接到项目的node_moduels
下,其中的loader-name
是指在第1步中的package.json
文件中配置的模块名称。
链接好 Loader 到项目后你就可以像使用一个真正的 Npm 模块一样使用本地的 Loader 了。
ResolveLoader
默认情况下只会去 node_modules
目录下寻找,为了让 Webpack 加载放在本地项目中的 Loader 需要修改 resolveLoader.modules
。
假如本地的 Loader 在项目目录中的 ./loaders/loader-name
中,则需要如下配置:
module.exports = {
resolveLoader:{
// 去哪些目录下寻找 Loajder,有先后顺序之分
modules: ['node_modules','./loaders/'],
}
}
加上以上配置后, Webpack 会先去 node_modules
项目下寻找 Loader,如果找不到,会再去 ./loaders/
目录下寻找。
实战
上面讲了许多理论,接下来从实际出发,来编写一个解决实际问题的 Loader。
该 Loader 名叫 comment-require-loader,作用是把 JavaScript 代码中的注释语法
// @require '../style/index.css'
转换成
require('../style/index.css');
该 Loader 的使用场景是去正确加载针对 Fis3 编写的 JavaScript,这些 JavaScript 中存在通过注释的方式加载依赖的 CSS 文件。
该 Loader 的使用方法如下:
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['comment-require-loader'],
// 针对采用了 fis3 CSS 导入语法的 JavaScript 文件通过 comment-require-loader 去转换
include: [path.resolve(__dirname, 'node_modules/imui')]
}
]
}
};
该 Loader 的实现非常简单,完整代码如下:
function replace(source) {
// 使用正则把 // @require '../style/index.css' 转换成 require('../style/index.css');
return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);');
}
module.exports = function (content) {
return replace(content);
};
Plugin 原理
Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
一个最基础的 Plugin 的代码是这样的:
class BasicPlugin{
// 在构造函数中获取用户给该插件传入的配置
constructor(options){
}
// Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象
apply(compiler){
compiler.plugin('compilation',function(compilation) {
})
}
}
// 导出 Plugin
module.exports = BasicPlugin;
在使用这个 Plugin 时,相关配置代码如下:
const BasicPlugin = require('./BasicPlugin.js');
module.export = {
plugins:[
new BasicPlugin(options),
]
}
Webpack 启动后,在读取配置的过程中会先执行 new BasicPlugin(options)
初始化一个 BasicPlugin 获得其实例。 在初始化 compiler 对象后,再调用 basicPlugin.apply(compiler)
给插件实例传入 compiler 对象。 插件实例在获取到 compiler 对象后,就可以通过 compiler.plugin(事件名称, 回调函数)
监听到 Webpack 广播出来的事件。 并且可以通过 compiler 对象去操作 Webpack。
通过以上最简单的 Plugin 相信你大概明白了 Plugin 的工作原理,但实际开发中还有很多细节需要注意,下面来详细介绍。
Compiler 和 Compilation
在开发 Plugin 时最常用的两个对象就是 Compiler 和 Compilation,它们是 Plugin 和 Webpack 之间的桥梁。 Compiler 和 Compilation 的含义如下:
- Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;
- Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。
Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。
事件流
Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。
Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。
Webpack 的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。 Compiler 和 Compilation 都继承自 Tapable,可以直接在 Compiler 和 Compilation 对象上广播和监听事件,方法如下:
/**
* 广播出事件
* event-name 为事件名称,注意不要和现有的事件重名
* params 为附带的参数
*/
compiler.apply('event-name',params);
/**
* 监听名称为 event-name 的事件,当 event-name 事件发生时,函数就会被执行。
* 同时函数中的 params 参数为广播事件时附带的参数。
*/
compiler.plugin('event-name',function(params) {
});
同理,compilation.apply 和 compilation.plugin 使用方法和上面一致。
在开发插件时,你可能会不知道该如何下手,因为你不知道该监听哪个事件才能完成任务。
在开发插件时,还需要注意以下两点:
- 只要能拿到 Compiler 或 Compilation 对象,就能广播出新的事件,所以在新开发的插件中也能广播出事件,给其它插件监听使用。
- 传给每个插件的 Compiler 和 Compilation 对象都是同一个引用。也就是说在一个插件中修改了 Compiler 或 Compilation 对象上的属性,会影响到后面的插件。
有些事件是异步的,这些异步的事件会附带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知 Webpack,才会进入下一处理流程。例如:
compiler.plugin('emit',function(compilation, callback) {
// 支持处理逻辑
// 处理完毕后执行 callback 以通知 Webpack
// 如果不执行 callback,运行流程将会一直卡在这不往下执行
callback();
});
常用 API
插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用 Webpack 提供的 API 能完成很多事情。 由于 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面来介绍一些常用的 API。
读取输出资源、代码块、模块及其依赖
有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。
在emit
事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。 插件代码如下:class Plugin {
apply(compiler) {
compiler.plugin('emit', function (compilation, callback) {
// compilation.chunks 存放所有代码块,是一个数组
compilation.chunks.forEach(function (chunk) {
// chunk 代表一个代码块
// 代码块由多个模块组成,通过 chunk.forEachModule 能读取组成代码块的每个模块
chunk.forEachModule(function (module) {
// module 代表一个模块
// module.fileDependencies 存放当前模块的所有依赖的文件路径,是一个数组
module.fileDependencies.forEach(function (filepath) {
});
});
// Webpack 会根据 Chunk 去生成输出的文件资源,每个 Chunk 都对应一个及其以上的输出文件
// 例如在 Chunk 中包含了 CSS 模块并且使用了 ExtractTextPlugin 时,
// 该 Chunk 就会生成 .js 和 .css 两个文件
chunk.files.forEach(function (filename) {
// compilation.assets 存放当前所有即将输出的资源
// 调用一个输出资源的 source() 方法能获取到输出资源的内容
let source = compilation.assets[filename].source();
});
});
// 这是一个异步事件,要记得调用 callback 通知 Webpack 本次事件监听处理结束。
// 如果忘记了调用 callback,Webpack 将一直卡在这里而不会往后执行。
callback();
})
}
}
监听文件变化
在4-5使用自动刷新 中介绍过 Webpack 会从配置的入口模块出发,依次找出所有的依赖模块,当入口模块或者其依赖的模块发生变化时, 就会触发一次新的 Compilation。
在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation,为此可以使用如下代码:// 当依赖的文件发生变化时会触发 watch-run 事件
compiler.plugin('watch-run', (watching, callback) => {
// 获取发生变化的文件列表
const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
// changedFiles 格式为键值对,键为发生变化的文件路径。
if (changedFiles[filePath] !== undefined) {
// filePath 对应的文件发生了变化
}
callback();
});
默认情况下 Webpack 只会监视入口和其依赖的模块是否发生变化,在有些情况下项目可能需要引入新的文件,例如引入一个 HTML 文件。 由于 JavaScript 文件不会去导入 HTML 文件,Webpack 就不会监听 HTML 文件的变化,编辑 HTML 文件时就不会重新触发新的 Compilation。 为了监听 HTML 文件的变化,我们需要把 HTML 文件加入到依赖列表中,为此可以使用如下代码:
compiler.plugin('after-compile', (compilation, callback) => {
// 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译
compilation.fileDependencies.push(filePath);
callback();
});
修改输出资源
有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听
emit
事件,因为发生emit
事件时所有模块的转换和代码块对应的文件已经生成好, 需要输出的资源即将输出,因此emit
事件是修改 Webpack 输出资源的最后时机。
所有需要输出的资源会存放在compilation.assets
中,compilation.assets
是一个键值对,键为需要输出的文件名称,值为文件对应的内容。
设置compilation.assets
的代码如下:compiler.plugin('emit', (compilation, callback) => {
// 设置名称为 fileName 的输出资源
compilation.assets[fileName] = {
// 返回文件内容
source: () => {
// fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer
return fileContent;
},
// 返回文件大小
size: () => {
return Buffer.byteLength(fileContent, 'utf8');
}
};
callback();
});
读取
compilation.assets
的代码如下:compiler.plugin('emit', (compilation, callback) => {
// 读取名称为 fileName 的输出资源
const asset = compilation.assets[fileName];
// 获取输出资源的内容
asset.source();
// 获取输出资源的文件大小
asset.size();
callback();
});
判断 Webpack 使用了哪些插件
在开发一个插件时可能需要根据当前配置是否使用了其它某个插件而做下一步决定,因此需要读取 Webpack 当前的插件配置情况。 以判断当前是否使用了 ExtractTextPlugin 为例,可以使用如下代码:
// 判断当前配置使用使用了 ExtractTextPlugin,
// compiler 参数即为 Webpack 在 apply(compiler) 中传入的参数
function hasExtractTextPlugin(compiler) {
// 当前配置所有使用的插件列表
const plugins = compiler.options.plugins;
// 去 plugins 中寻找有没有 ExtractTextPlugin 的实例
return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null;
}
实战
下面我们举一个实际的例子,带你一步步去实现一个插件。
该插件的名称取名叫 EndWebpackPlugin,作用是在 Webpack 即将退出时再附加一些额外的操作,例如在 Webpack 成功编译和输出了文件后执行发布操作把输出的文件上传到服务器。 同时该插件还能区分 Webpack 构建是否执行成功。使用该插件时方法如下:module.exports = {
plugins:[
// 在初始化 EndWebpackPlugin 时传入了两个参数,分别是在成功时的回调函数和失败时的回调函数;
new EndWebpackPlugin(() => {
// Webpack 构建成功,并且文件输出了后会执行到这里,在这里可以做发布文件操作
}, (err) => {
// Webpack 构建失败,err 是导致错误的原因
console.error(err);
})
]
}
要实现该插件,需要借助两个事件:
done:在成功构建并且输出了文件后,Webpack 即将退出时发生;
- failed:在构建出现异常导致构建失败,Webpack 即将退出时发生;
实现该插件非常简单,完整代码如下:
class EndWebpackPlugin {
constructor(doneCallback, failCallback) {
// 存下在构造函数中传入的回调函数
this.doneCallback = doneCallback;
this.failCallback = failCallback;
}
apply(compiler) {
compiler.plugin('done', (stats) => {
// 在 done 事件中回调 doneCallback
this.doneCallback(stats);
});
compiler.plugin('failed', (err) => {
// 在 failed 事件中回调 failCallback
this.failCallback(err);
});
}
}
// 导出插件
module.exports = EndWebpackPlugin;
从开发这个插件可以看出,找到合适的事件点去完成功能在开发插件时显得尤为重要。
原理总结
Webpack 是一个庞大的 Node.js 应用,如果你阅读过它的源码,你会发现实现一个完整的 Webpack 需要编写非常多的代码。 但你无需了解所有的细节,只需了解其整体架构和部分细节即可。
对 Webpack 的使用者来说,它是一个简单强大的工具; 对 Webpack 的开发者来说,它是一个扩展性的高系统。
Webpack 之所以能成功,在于它把复杂的实现隐藏了起来,给用户暴露出的只是一个简单的工具,让用户能快速达成目的。 同时整体架构设计合理,扩展性高,开发扩展难度不高,通过社区补足了大量缺失的功能,让 Webpack 几乎能胜任任何场景。