1. webpack详解
1.1 webpack配置
1.1.1 Entry
entry
是配置模块的入口,可抽象成输入,Webpack 执行构建的第一步将从入口开始搜寻及递归解析出所有入口依赖的模块。
entry 配置是必填的,若不填则将导致 Webpack 报错退出。
1.1.1.1 context
Webpack 在寻找相对路径的文件时会以 context
为根目录,context 默认为执行启动 Webpack 时所在的当前工作目录。 如果想改变 context
的默认配置,则可以在配置文件里这样设置它:
javascript
module.exports = {
context: path.resolve(__dirname, 'app')
}
注意, context
必须是一个绝对路径的字符串。 除此之外,还可以通过在启动 Webpack 时带上参数 webpack --context
来设置 context
。
之所以在这里先介绍 context
,是因为 Entry
的路径和其依赖的模块的路径可能采用相对于 context
的路径来描述,context
会影响到这些相对路径所指向的真实文件。
1.1.1.2 Entry类型
Entry 类型可以是以下三种中的一种或者相互组合:
如果是 array 类型,则搭配 output.library
配置项使用时,只有数组里的最后一个入口文件的模块会被导出。
1.1.1.3 Chunk名称
Webpack 会为每个生成的 Chunk 取一个名称,Chunk 的名称和 Entry 的配置有关:
-
如果 entry 是一个 string 或 array,就只会生成一个 Chunk,这时 Chunk 的名称是 main;
-
如果 entry 是一个 object,就可能会出现多个 Chunk,这时 Chunk 的名称是 object 键值对里键的名称;
1.1.1.4 动态配置Entry
假如项目里有多个页面需要为每个页面的入口配置一个 Entry ,但这些页面的数量可能会不断增长,则这时 Entry 的配置会受到到其他因素的影响导致不能写成静态的值。其解决方法是把 Entry 设置成一个函数去动态返回上面所说的配置,代码如下:
javascript
// 同步函数
entry: () => {
return {
a:'./pages/a',
b:'./pages/b',
}
};
// 异步函数
entry: () => {
return new Promise((resolve)=>{
resolve({
a:'./pages/a',
b:'./pages/b',
});
});
};
1.1.2 Output
output
配置如何输出最终想要的代码。output
是一个 object
,里面包含一系列配置项:
1.1.2.1 filename
output.filename 配置输出文件的名称,为string 类型。 如果只有一个输出文件,则可以把它写成静态不变的:
javascript
filename: 'bundle.js'
但是在有多个 Chunk 要输出时,就需要借助模版和变量了。前面说到 Webpack 会为每个 Chunk取一个名称,可以根据 Chunk 的名称来区分输出的文件名:
javascript
filename: '[name].js'
代码里的[name]
代表用内置的 name 变量去替换[name]
,这时你可以把它看作一个字符串模块函数, 每个要输出的 Chunk 都会通过这个函数去拼接出输出的文件名称。
内置变量除了 name 还包括:
其中 hash 和 chunkhash 的长度是可指定的,[hash:8] 代表取8位 Hash 值,默认是20位。
1.1.2.2 chunkFilename
output.chunkFilename
配置无入口的 Chunk 在输出时的文件名称。 chunkFilename 和上面的 filename 非常类似,但 chunkFilename 只用于指定在运行过程中生成的 Chunk 在输出时的文件名称。 常见的会在运行时生成 Chunk 场景有在使用 CommonChunkPlugin、使用 import('path/to/module') 动态加载等时。 chunkFilename 支持和 filename 一致的内置变量。
1.1.2.3 path
output.path 配置输出文件存放在本地的目录,必须是 string 类型的绝对路径。通常通过 Node.js 的 path 模块去获取绝对路径:
javascript
path: path.resolve(__dirname, 'dist_[hash]')
1.1.2.4 publicPath
在复杂的项目里可能会有一些构建出的资源需要异步加载,加载这些异步资源需要对应的 URL 地址。
output.publicPath 配置发布到线上资源的 URL 前缀,为string 类型。 默认值是空字符串 '',即使用相对路径。
这样说可能有点抽象,举个例子,需要把构建出的资源文件上传到 CDN 服务上,以利于加快页面的打开速度。配置代码如下:
javascript
filename:'[name]_[chunkhash:8].js'
publicPath: 'https://cdn.example.com/assets/'
这时发布到线上的 HTML 在引入 JavaScript 文件时就需要:
javascript
<script src='https://cdn.example.com/assets/a_12345678.js'></script>
使用该配置项时要小心,稍有不慎将导致资源加载404错误。
output.path 和 output.publicPath 都支持字符串模版,内置变量只有一个:hash 代表一次编译操作的 Hash 值。
1.1.2.5 crossOriginLoading
Webpack 输出的部分代码块可能需要异步加载,而异步加载是通过 JSONP 方式实现的。 JSONP 的原理是动态地向 HTML 中插入一个 <script src="url"></script> 标签去加载异步资源。 output.crossOriginLoading 则是用于配置这个异步插入的标签的 crossorigin 值。
script 标签的 crossorigin 属性可以取以下值:
-
anonymous
(默认) 在加载此脚本资源时不会带上用户的 Cookies; -
use-credentials
在加载此脚本资源时会带上用户的 Cookies;
通常用设置 crossorigin 来获取异步加载的脚本执行时的详细错误信息。
1.1.2.6 libraryTarget 和 library
当用 Webpack 去构建一个可以被其他模块导入使用的库时需要用到它们。
-
output.libraryTarget
配置以何种方式导出库; -
output.library
配置导出库的名称;
它们通常搭配在一起使用。
output.libraryTarget 是字符串的枚举类型,支持以下配置:
1.1.2.7 libraryExport
output.libraryExport 配置要导出的模块中哪些子模块需要被导出。 它只有在 output.libraryTarget 被设置成 commonjs 或者 commonjs2 时使用才有意义。
假如要导出的模块源代码是:
javascript
export const a=1;
export default b=2;
现在你想让构建输出的代码只导出其中的 a,可以把 output.libraryExport 设置成 a,那么构建输出的代码和使用方法将变成如下:
javascript
// Webpack 输出的代码
module.exports = lib_code['a'];
// 使用库的方法
require('library-name-in-npm')===1;
以上都是基础的配置,详情请见官网
1.1.3 Module
module 配置如何处理模块。
1.1.3.1 配置Loader
rules 配置模块的读取和解析规则,通常用来配置 Loader。其类型是一个数组,数组里每一项都描述了如何去处理部分文件。 配置一项 rules 时大致通过以下方式:
-
条件匹配:通过 test 、 include 、 exclude 三个配置项来命中 Loader 要应用规则的文件;
-
应用规则:对选中后的文件通过 use 配置项来应用 Loader,可以只应用一个 Loader 或者按照从后往前的顺序应用一组 Loader,同时还可以分别给 Loader 传入参数;
-
重置顺序:一组 Loader 的执行顺序默认是从右到左执行,通过 enforce 选项可以让其中一个 Loader 的执行顺序放到最前或者最后;
下面来通过一个例子来说明具体使用方法:
javascript
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 配置中有如下代码:
javascript
use: [
{
loader:'babel-loader',
options:{
cacheDirectory:true,
},
// enforce:'post' 的含义是把该 Loader 的执行顺序放到最后
// enforce 的值还可以是 pre,代表把 Loader 的执行顺序放到最前面
enforce:'post'
},
// 省略其它 Loader
]
上面的例子中 test
include
exclude
这三个命中文件的配置项只传入了一个字符串或正则,其实它们还都支持数组类型,使用如下:
javascript
{
test:[
/\.jsx?$/,
/\.tsx?$/
],
include:[
path.resolve(__dirname, 'src'),
path.resolve(__dirname, 'tests'),
],
exclude:[
path.resolve(__dirname, 'node_modules'),
path.resolve(__dirname, 'bower_modules'),
]
}
1.1.3.1.1 noParse
noParse
配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析和处理,这样做的好处是能提高构建性能。 原因是一些库例如 jQuery 、ChartJS 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。
noParse 是可选配置项,类型需要是 RegExp、[RegExp]、function 其中一个。
例如想要忽略掉 jQuery 、ChartJS,可以使用如下代码:
javascript
// 使用正则表达式
noParse: /jquery|chartjs/
// 使用函数,从 Webpack 3.0.0 开始支持
noParse: (content)=> {
// content 代表一个模块的文件路径
// 返回 true or false
return /jquery|chartjs/.test(content);
}
注意被忽略掉的文件里不应该包含 import
、 require
、 define
等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句。
1.1.3.1.2 parser
因为 Webpack 是以模块化的 JavaScript 文件为入口,所以内置了对模块化 JavaScript 的解析功能,支持 AMD、CommonJS、SystemJS、ES6。 parser
属性可以更细粒度的配置哪些模块语法要解析哪些不解析,和 noParse 配置项的区别在于 parser 可以精确到语法层面, 而 noParse 只能控制哪些文件不被解析。 parser 使用如下:
javascript
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
}
},
]
}
1.1.4 Resolve
Webpack 在启动后会从配置的入口模块出发找出所有依赖的模块,Resolve 配置 Webpack 如何寻找模块所对应的文件。 Webpack 内置 JavaScript 模块化语法解析功能,默认会采用模块化标准里约定好的规则去寻找,但你也可以根据自己的需要修改默认的规则。
1.1.4.1 alias
resolve.alias
配置项通过别名来把原导入路径映射成一个新的导入路径。例如使用以下配置:
javascript
// Webpack alias 配置
resolve:{
alias:{
components: './src/components/'
}
}
当你通过 import Button from 'components/button'
导入时,实际上被 alias 等价替换成了 import Button from './src/components/button'
。
以上 alias 配置的含义是把导入语句里的 components 关键字替换成 ./src/components/。
这样做可能会命中太多的导入语句,alias 还支持 $ 符号来缩小范围到只命中以关键字结尾的导入语句:
javascript
resolve:{
alias:{
'react$': '/path/to/react.min.js'
}
}
react$ 只会命中以 react 结尾的导入语句,即只会把 import 'react'
关键字替换成 import '/path/to/react.min.js'
。
1.1.4.2 mainFields
有一些第三方模块会针对不同环境提供几分代码。 例如分别提供采用 ES5 和 ES6 的2份代码,这2份代码的位置写在 package.json 文件里,如下:
javascript
{
"jsnext:main": "es/index.js",// 采用 ES6 语法的代码入口文件
"main": "lib/index.js" // 采用 ES5 语法的代码入口文件
}
Webpack 会根据 mainFields 的配置去决定优先采用那份代码,mainFields 默认如下:
javascript
mainFields: ['browser', 'main']
Webpack 会按照数组里的顺序去package.json 文件里寻找,只会使用找到的第一个。
假如你想优先采用 ES6 的那份代码,可以这样配置:
javascript
mainFields: ['jsnext:main', 'browser', 'main']
1.1.4.3 extensions
在导入语句没带文件后缀时,Webpack 会自动带上后缀后去尝试访问文件是否存在。 resolve.extensions 用于配置在尝试过程中用到的后缀列表,默认是:
javascript
extensions: ['.js', '.json']
也就是说当遇到 require('./data') 这样的导入语句时,Webpack 会先去寻找 ./data.js 文件,如果该文件不存在就去寻找 ./data.json 文件, 如果还是找不到就报错。
假如你想让 Webpack 优先使用目录下的 TypeScript 文件,可以这样配置:
javascript
extensions: ['.ts', '.js', '.json']
1.1.4.4 modules
resolve.modules 配置 Webpack 去哪些目录下寻找第三方模块,默认是只会去 node_modules
目录下寻找。 有时你的项目里会有一些模块会大量被其它模块依赖和导入,由于其它模块的位置分布不定,针对不同的文件都要去计算被导入模块文件的相对路径, 这个路径有时候会很长,就像这样 import '../../../components/button'
这时你可以利用 modules 配置项优化,假如那些被大量导入的模块都在 ./src/components
目录下,把 modules 配置成
javascript
modules:['./src/components','node_modules']
后,你可以简单通过 import 'button' 导入。
1.1.4.5 descriptionFiles
resolve.descriptionFiles 配置描述第三方模块的文件名称,也就是 package.json 文件。默认如下:
javascript
descriptionFiles: ['package.json']
1.1.5 总结
Webpack 内置了很多功能。 你不必都记住它们,只需要大概明白 Webpack 原理和核心概念去判断选项大致属于哪个大模块下,再去查详细的使用文档。
通常你可用如下经验去判断如何配置 Webpack:
-
想让源文件加入到构建流程中去被 Webpack 控制,配置 entry;
-
想自定义输出文件的位置和名称,配置 output;
-
想自定义寻找依赖模块时的策略,配置 resolve;
-
想自定义解析和转换文件的策略,配置 module,通常是配置 module.rules 里的 Loader;
-
其它的大部分需求可能要通过 Plugin 去实现,配置 plugin;
1.2 webpack原理解析
1.2.1 工作原理概括
Webpack 以其使用简单著称,在使用它的过程中,使用者只需把它当作一个黑盒,需要关心的只有它暴露出来的配置。
1.2.1.1 基本概念
-
Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入;
-
Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块;
-
Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割;
-
Loader:模块转换器,用于把模块原内容按照需求转换成新内容;
-
Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情;
1.2.1.2 流程概括
Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:
-
初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
-
开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
-
确定入口:根据配置中的 entry 找出所有的入口文件;
-
编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
-
完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
-
输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
-
输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。
1.2.1.3 流程细节
Webpack 的构建流程可以分为以下三大阶段:
-
初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler;
-
编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理;
-
输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统;
如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:
在每个大阶段中又会发生很多事件,Webpack 会把这些事件广播出来供给 Plugin 使用。
1.2.1.3.1 初始化阶段
事件名 | 解释 |
---|---|
初始化参数 | 从配置文件和 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 负责在文件系统中寻找指定路径的文件。 |
1.2.1.3.2 编译阶段
事件名 | 解释 |
---|---|
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。 |
1.2.1.3.3 输出阶段
事件名 | 解释 |
---|---|
should-emit | 所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。 |
emit | 确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。 |
after-emit | 文件输出完毕。 |
done | 成功完成一次完成的编译和输出流程。 |
failed | 如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。 |
在输出阶段已经得到了各个模块经过转换后的结果和其依赖关系,并且把相关模块组合在一起形成一个个 Chunk。 在输出阶段会根据 Chunk 的类型,使用对应的模版生成最终要要输出的文件内容。
1.2.2 输出文件分析
Webpack 输出的 bundle.js 是什么样子的吗? 为什么原来一个个的模块文件被合并成了一个单独的文件?为什么 bundle.js 能直接运行在浏览器中? 本节将解释清楚以上问题。
先来看看由最初的例子中最简单的项目构建出的 bundle.js
文件内容,代码如下:
以上看上去复杂的代码其实是一个立即执行函数,可以简写为如下:
javascript
(function(modules) {
// 模拟 require 语句
function __webpack_require__() {
}
// 执行存放所有模块数组中的第0个模块
__webpack_require__(0);
})([/*存放所有模块的数组*/])
bundle.js 能直接运行在浏览器中的原因在于输出的文件中通过 __webpack_require__
函数定义了一个可以在浏览器中执行的加载函数来模拟 Node.js 中的 require 语句。
原来一个个独立的模块文件被合并到了一个单独的 bundle.js
的原因在于浏览器不能像 Node.js 那样快速地去本地加载一个个模块文件,而必须通过网络请求去加载还未得到的文件。 如果模块数量很多,加载时间会很长,因此把所有模块都存放在了数组中,执行一次网络加载。
如果仔细分析 webpack_require
函数的实现,你还有发现 Webpack 做了缓存优化: 执行加载过的模块不会再执行第二次,执行结果会缓存在内存中,当某个模块第二次被访问时会直接去内存中读取被缓存的返回值。
1.2.2.1 分割代码时的输出
在按需加载时的优化方案中,webapck的输出文件会发生变化:
javascript
// 异步加载 show.js
import('./show').then((show) => {
// 执行 show 函数
show('Webpack');
});
重新构建后会输出两个文件,分别是执行入口文件 bundle.js 和 异步加载文件 0.bundle.js。
其中 0.bundle.js 内容如下:
javascript
// 加载在本文件(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 内容如下:
这里的 bundle.js 和上面所讲的 bundle.js 非常相似,区别在于:
-
多了一个
__webpack_require__.e
用于加载被分割出去的,需要异步加载的 Chunk 对应的文件; -
多了一个
webpackJsonp
函数用于从异步加载的文件中安装模块;
在使用了 CommonsChunkPlugin
去提取公共代码时输出的文件和使用了异步加载时输出的文件是一样的,都会有 __webpack_require__.e
和 webpackJsonp
。 原因在于提取公共代码和异步加载本质上都是代码分割。
1.2.3 编写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 相关配置如下:
javascript
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'],
},
]
},
};
1.2.3.1 Loader的职责
由上面的例子可以看出:一个 Loader 的职责是单一的,只需要完成一种转换。 如果一个源文件需要经历多步转换才能正常使用,就通过多个 Loader 去转换。 在调用多个 Loader 去转换一个文件时,每个 Loader 会链式的顺序执行, 第一个 Loader 将会拿到需处理的原内容,上一个 Loader 处理后的结果会传给下一个接着处理,最后的 Loader 将处理后的最终结果返回给 Webpack。
所以,在你开发一个 Loader 时,请保持其职责的单一性,你只需关心输入和输出。
1.2.3.2 Loader的基础
由于 Webpack 是运行在 Node.js 之上的,一个 Loader 其实就是一个 Node.js 模块,这个模块需要导出一个函数。 这个导出的函数的工作就是获得处理前的原内容,对原内容执行处理后,返回处理后的内容。
一个最简单的 Loader 的源码如下:
javascript
module.exports = function(source) {
// source 为 compiler 传递给 Loader 的一个文件的原内容
// 该函数需要返回处理后的内容,这里简单起见,直接把原内容返回了,相当于该 Loader 没有做任何转换
return source;
};
由于 Loader 运行在 Node.js 中,你可以调用任何 Node.js 自带的 API,或者安装第三方模块进行调用:
javascript
const sass = require('node-sass');
module.exports = function(source) {
return sass(source);
};
1.2.3.3 Loader的进阶
以上只是个最简单的 Loader,Webpack 还提供一些 API 供 Loader 调用,下面来一一介绍。
1.2.3.3.1 获得 Loader 的 options
在最上面处理 SCSS 文件的 Webpack 配置中,给 css-loader 传了 options 参数,以控制 css-loader。 如何在自己编写的 Loader 中获取到用户传入的 options 呢?需要这样做:
javascript
const loaderUtils = require('loader-utils');
module.exports = function(source) {
// 获取到用户给当前 Loader 传入的 options
const options = loaderUtils.getOptions(this);
return source;
};
1.2.3.3.2 返回其他结果
上面的 Loader 都只是返回了原内容转换后的内容,但有些场景下还需要返回除了内容之外的东西。
例如以用 babel-loader 转换 ES6 代码为例,它还需要输出转换后的 ES5 代码对应的 Source Map,以方便调试源码。 为了把 Source Map 也一起随着 ES5 代码返回给 Webpack,可以这样写:
javascript
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 的详细使用方法如下:
javascript
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,请考虑到这点。
1.2.3.3.3 同步与异步
Loader 有同步和异步之分,上面介绍的 Loader 都是同步的 Loader,因为它们的转换流程都是同步的,转换完成后再返回结果。 但在有些场景下转换的步骤只能是异步完成的,例如你需要通过网络请求才能得出结果,如果采用同步的方式网络请求就会阻塞整个构建,导致构建非常缓慢。
在转换步骤是异步时,你可以这样:
javascript
module.exports = function(source) {
// 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
var callback = this.async();
someAsyncOperation(source, function(err, result, sourceMaps, ast) {
// 通过 callback 返回异步执行后的结果
callback(err, result, sourceMaps, ast);
});
};
1.2.3.3.4 处理二进制数据
在默认的情况下,Webpack 传给 Loader 的原内容都是 UTF-8 格式编码的字符串。 但有些场景下 Loader 不是处理文本文件,而是处理二进制文件,例如 file-loader,就需要 Webpack 给 Loader 传入二进制格式的数据。 为此,你需要这样编写 Loader:
javascript
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 只能拿到字符串。
1.2.3.3.5 缓存加速
在有些情况下,有些转换操作需要大量计算非常耗时,如果每次构建都重新执行重复的转换操作,构建将会变得非常缓慢。 为此,Webpack 会默认缓存所有 Loader 的处理结果,也就是说在需要被处理的文件或者其依赖的文件没有发生变化时, 是不会重新调用对应的 Loader 去执行转换操作的。
如果你想让 Webpack 不缓存该 Loader 的处理结果,可以这样:
javascript
module.exports = function(source) {
// 关闭该 Loader 的缓存功能
this.cacheable(false);
return source;
};
1.2.3.4 其他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;
-
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 官网 查看。
1.2.3.5 加载本地Loader
在开发 Loader 的过程中,为了测试编写的 Loader 是否能正常工作,需要把它配置到 Webpack 中后,才可能会调用该 Loader。 上文中使用的 Loader 都是通过 Npm 安装的,要使用 Loader 时会直接使用 Loader 的名称,代码如下:
javascript
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader'],
},
]
},
};
如果还采取以上的方法去使用本地开发的 Loader 将会很麻烦,因为你需要确保编写的 Loader 的源码是在 node_modules 目录下。 为此你需要先把编写的 Loader 发布到 Npm 仓库后再安装到本地项目使用。
解决以上问题的便捷方法有两种,分别如下:
1.2.3.5.1 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 了。
1.2.3.5.2 ResolveLoader
为了让 Webpack 加载放在本地项目中的 Loader 需要修改 resolveLoader.modules。
假如本地的 Loader 在项目目录中的 ./loaders/loader-name 中,则需要如下配置:
javascript
module.exports = {
resolveLoader:{
// 去哪些目录下寻找 Loader,有先后顺序之分
modules: ['node_modules','./loaders/'],
}
}
加上以上配置后, Webpack 会先去 node_modules 项目下寻找 Loader,如果找不到,会再去 ./loaders/ 目录下寻找。
1.2.3.6 实战
创建名为comment-require-loader
,作用是把 JavaScript 代码中的注释语法
javascript
// @require '../style/index.css'
转为
require('../style/index.css');
该 Loader 的使用场景是去正确加载针对 Fis3 编写的 JavaScript,这些 JavaScript 中存在通过注释的方式加载依赖的 CSS 文件。
该 Loader 的使用方法如下:
javascript
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['comment-require-loader'],
// 针对采用了 fis3 CSS 导入语法的 JavaScript 文件通过 comment-require-loader 去转换
include: [path.resolve(__dirname, 'node_modules/imui')]
}
]
}
};
具体实现时:
javascript
function replace(source) {
// 使用正则把 // @require '../style/index.css' 转换成 require('../style/index.css');
return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);');
}
module.exports = function (content) {
return replace(content);
};
1.2.4 编写plugin
Webpack 通过 Plugin 机制让其更加灵活,以适应各种应用场景。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
一个最基础的 Plugin 的代码是这样的:
javascript
class BasicPlugin{
// 在构造函数中获取用户给该插件传入的配置
constructor(options){
}
// Webpack 会调用 BasicPlugin 实例的 apply 方法给插件实例传入 compiler 对象
apply(compiler){
compiler.plugin('compilation',function(compilation) {
})
}
}
// 导出 Plugin
module.exports = BasicPlugin;
在使用这个 Plugin 时,相关配置代码如下:
javascript
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 的工作原理,但实际开发中还有很多细节需要注意:
1.2.4.1 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 只是代表了一次新的编译。
1.2.4.2 事件流
Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。
Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。
Webpack 的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。 Compiler 和 Compilation 都继承自 Tapable,可以直接在 Compiler 和 Compilation 对象上广播和监听事件,方法如下:
javascript
/**
* 广播出事件
* 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,才会进入下一处理流程。例如:
javascript
compiler.plugin('emit',function(compilation, callback) {
// 支持处理逻辑
// 处理完毕后执行 callback 以通知 Webpack
// 如果不执行 callback,运行流程将会一直卡在这不往下执行
callback();
});
1.2.4.3 常用API
插件可以用来修改输出文件、增加输出文件、甚至可以提升 Webpack 性能、等等,总之插件通过调用 Webpack 提供的 API 能完成很多事情。 由于 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面来介绍一些常用的 API:
1.2.4.3.1 读取输出资源、代码块、模块及其依赖
有些插件可能需要读取 Webpack 的处理结果,例如输出资源、代码块、模块及其依赖,以便做下一步处理。
在 emit 事件发生时,代表源文件的转换和组装已经完成,在这里可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。 插件代码如下:
javascript
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();
})
}
}
1.2.4.3.2 监听文件变化
在开发插件时经常需要知道是哪个文件发生变化导致了新的 Compilation,为此可以使用如下代码:
javascript
// 当依赖的文件发生变化时会触发 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 文件加入到依赖列表中,为此可以使用如下代码:
javascript
compiler.plugin('after-compile', (compilation, callback) => {
// 把 HTML 文件添加到文件依赖列表,好让 Webpack 去监听 HTML 模块文件,在 HTML 模版文件发生变化时重新启动一次编译
compilation.fileDependencies.push(filePath);
callback();
});
1.2.4.3.3 修改输出资源
有些场景下插件需要修改、增加、删除输出的资源,要做到这点需要监听 emit 事件,因为发生 emit 事件时所有模块的转换和代码块对应的文件已经生成好, 需要输出的资源即将输出,因此 emit 事件是修改 Webpack 输出资源的最后时机。
所有需要输出的资源会存放在 compilation.assets 中,compilation.assets 是一个键值对,键为需要输出的文件名称,值为文件对应的内容。
设置 compilation.assets 的代码如下:
javascript
compiler.plugin('emit', (compilation, callback) => {
// 设置名称为 fileName 的输出资源
compilation.assets[fileName] = {
// 返回文件内容
source: () => {
// fileContent 既可以是代表文本文件的字符串,也可以是代表二进制文件的 Buffer
return fileContent;
},
// 返回文件大小
size: () => {
return Buffer.byteLength(fileContent, 'utf8');
}
};
callback();
});
读取 compilation.assets 的代码如下:
javascript
compiler.plugin('emit', (compilation, callback) => {
// 读取名称为 fileName 的输出资源
const asset = compilation.assets[fileName];
// 获取输出资源的内容
asset.source();
// 获取输出资源的文件大小
asset.size();
callback();
});
1.2.4.4 判断webpack使用哪些插件
在开发一个插件时可能需要根据当前配置是否使用了其它某个插件而做下一步决定,因此需要读取 Webpack 当前的插件配置情况。 以判断当前是否使用了 ExtractTextPlugin
为例,可以使用如下代码:
javascript
// 判断当前配置使用使用了 ExtractTextPlugin,
// compiler 参数即为 Webpack 在 apply(compiler) 中传入的参数
function hasExtractTextPlugin(compiler) {
// 当前配置所有使用的插件列表
const plugins = compiler.options.plugins;
// 去 plugins 中寻找有没有 ExtractTextPlugin 的实例
return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null;
}
1.2.4.5 实战
实现一个插件,名叫 EndWebpackPlugin,作用是在 Webpack 即将退出时再附加一些额外的操作,例如在 Webpack 成功编译和输出了文件后执行发布操作把输出的文件上传到服务器。 同时该插件还能区分 Webpack 构建是否执行成功。使用该插件时方法如下:
javascript
module.exports = {
plugins:[
// 在初始化 EndWebpackPlugin 时传入了两个参数,分别是在成功时的回调函数和失败时的回调函数;
new EndWebpackPlugin(() => {
// Webpack 构建成功,并且文件输出了后会执行到这里,在这里可以做发布文件操作
}, (err) => {
// Webpack 构建失败,err 是导致错误的原因
console.error(err);
})
]
}
要实现该插件,需要借助两个事件:
-
done:在成功构建并且输出了文件后,Webpack 即将退出时发生;
-
failed:在构建出现异常导致构建失败,Webpack 即将退出时发生;
实现该插件非常简单,完整代码如下:
javascript
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;
从开发这个插件可以看出,找到合适的事件点去完成功能在开发插件时显得尤为重要。
1.3 webpack补充
1.3.1 常见的Loaders
1.3.1.1 加载文件
-
raw-loader:把文本文件的内容加载到代码中;
-
file-loader:把文件输出到一个文件夹中,在代码中通过相对 URL 去引用输出的文件;
-
url-loader:和 file-loader 类似,但是能在文件很小的情况下以 base64 的方式把文件内容注入到代码中去;
-
source-map-loader:加载额外的 Source Map 文件,以方便断点调试;
-
svg-inline-loader:把压缩后的 SVG 内容注入到代码中;
-
node-loader:加载 Node.js 原生模块 .node 文件;
-
image-loader:加载并且压缩图片文件;
-
json-loader:加载 JSON 文件;
-
yaml-loader:加载 YAML 文件;
1.3.1.2 编译模版
-
pug-loader:把 Pug 模版转换成 JavaScript 函数返回;
-
handlebars-loader:把 Handlebars 模版编译成函数返回;
-
ejs-loader:把 EJS 模版编译成函数返回;
-
haml-loader:把 HAML 代码转换成 HTML;
-
markdown-loader:把 Markdown 文件转换成 HTML;
1.3.1.3 转换脚本语言
-
babel-loader:把 ES6 转换成 ES5;
-
ts-loader:把 TypeScript 转换成 JavaScript;
-
awesome-typescript-loader:把 TypeScript 转换成 JavaScript,性能要比 ts-loader 好;
-
coffee-loader:把 CoffeeScript 转换成 JavaScript;
1.3.1.4 转换样式文件
-
css-loader:加载 CSS,支持模块化、压缩、文件导入等特性;
-
style-loader:把 CSS 代码注入到 JavaScript 中,通过 DOM 操作去加载 CSS;
-
sass-loader:把 SCSS/SASS 代码转换成 CSS;
-
postcss-loader:扩展 CSS 语法,使用下一代 CSS;
-
less-loader:把 Less 代码转换成 CSS 代码;
-
stylus-loader:把 Stylus 代码转换成 CSS 代码;
1.3.1.5 检查代码
-
eslint-loader:通过 ESLint 检查 JavaScript 代码;
-
tslint-loader:通过 TSLint 检查 TypeScript 代码;
-
mocha-loader:加载 Mocha 测试用例代码;
-
coverjs-loader:计算测试覆盖率;
1.3.1.6 其它
-
vue-loader:加载 Vue.js 单文件组件;
-
i18n-loader:加载多语言版本,支持国际化;
-
ignore-loader:忽略掉部分文件;
-
ui-component-loader:按需加载 UI 组件库,例如在使用 antd UI 组件库时,不会因为只用到了 Button 组件而打包进所有的组件;
1.3.2 常见的Plugins
1.3.2.1 用于修改行为
-
define-plugin:定义环境变量;
-
context-replacement-plugin:修改 require 语句在寻找文件时的默认行为;
-
ignore-plugin:用于忽略部分文件;
1.3.2.2 用于优化
-
commons-chunk-plugin:提取公共代码;
-
extract-text-webpack-plugin:提取 JavaScript 中的 CSS 代码到单独的文件中;
-
prepack-webpack-plugin:通过 Facebook 的 Prepack 优化输出的 JavaScript 代码性能;
-
uglifyjs-webpack-plugin:通过 UglifyES 压缩 ES6 代码;
-
webpack-parallel-uglify-plugin:多进程执行 UglifyJS 代码压缩,提升构建速度;
-
imagemin-webpack-plugin:压缩图片文件;
-
webpack-spritesmith:用插件制作雪碧图;
-
ModuleConcatenationPlugin:开启 Webpack Scope Hoisting 功能;
-
dll-plugin:借鉴 DDL 的思想大幅度提升构建速度;
-
hot-module-replacement-plugin:开启模块热替换功能;
1.3.2.3 其它
-
serviceworker-webpack-plugin:给网页应用增加离线缓存功能;
-
stylelint-webpack-plugin:集成 stylelint 到项目;
-
i18n-webpack-plugin:给你的网页支持国际化;
-
provide-plugin:从环境中提供的全局变量中加载模块,而不用导入对应的文件;
-
web-webpack-plugin:方便的为单页应用输出 HTML,比 html-webpack-plugin 好用;
1.4 webpack优化(面试向)
1.4.1 缩小文件搜索范围
Webpack 启动后会从配置的 Entry 出发,解析出文件中的导入语句,再递归的解析。 在遇到导入语句时 Webpack 会做两件事情:
-
根据导入语句去寻找对应的要导入的文件。例如
require('react')
导入语句对应的文件是./node_modules/react/react.js,require('./util')
对应的文件是./util.js
; -
根据找到的要导入文件的后缀,使用配置中的 Loader 去处理文件。例如使用 ES6 开发的 JavaScript 文件需要使用
babel-loader
去处理;
以上两件事情虽然对于处理一个文件非常快,但是当项目大了以后文件量会变的非常多,这时候构建速度慢的问题就会暴露出来。 虽然以上两件事情无法避免,但需要尽量减少以上两件事情的发生,以提高速度。
1.4.1.1 优化 loader 配置
由于 Loader 对文件的转换操作很耗时,需要让尽可能少的文件被 Loader 处理。
可以适当的调整项目的目录结构,以方便在配置 Loader 时通过 include 去缩小命中范围。
在使用 Loader 时可以通过 test 、 include 、 exclude 三个配置项来命中 Loader 要应用规则的文件。 为了尽可能少的让文件被 Loader 处理,可以通过 include 去命中只有哪些文件需要被处理。
以采用 ES6 的项目为例,在配置 babel-loader 时,可以这样:
javascript
module.exports = {
module: {
rules: [
{
// 如果项目源码中只有 js 文件就不要写成 /\.jsx?$/,提升正则表达式性能
test: /\.js$/,
// babel-loader 支持缓存转换出的结果,通过 cacheDirectory 选项开启
use: ['babel-loader?cacheDirectory'],
// 只对项目根目录下的 src 目录中的文件采用 babel-loader
include: path.resolve(__dirname, 'src'),
},
]
},
};
1.4.1.2 优化 resolve.modules 配置
resolve.modules
的默认值是 ['node_modules'],含义是先去当前目录下的 ./node_modules
目录下去找想找的模块,如果没找到就去上一级目录 ../node_modules
中找,再没有就去 ../../node_modules
中找,以此类推,这和 Node.js 的模块寻找机制很相似。
当安装的第三方模块都放在项目根目录下的 ./node_modules
目录下时,没有必要按照默认的方式去一层层的寻找,可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:
javascript
module.exports = {
resolve: {
// 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
// 其中 __dirname 表示当前工作目录,也就是项目根目录
modules: [path.resolve(__dirname, 'node_modules')]
},
};
1.4.1.3 优化 resolve.mainFields 配置
resolve.mainFields 用于配置第三方模块使用哪个入口文件。
安装的第三方模块中都会有一个 package.json
文件用于描述这个模块的属性,其中有些字段用于描述入口文件在哪里,resolve.mainFields
用于配置采用哪个字段作为入口文件的描述。
可以存在多个字段描述入口文件的原因是因为有些模块可以同时用在多个环境中,针对不同的运行环境需要使用不同的代码。 以 isomorphic-fetch 为例,它是 fetch API 的一个实现,但可同时用于浏览器和 Node.js 环境。 它的 package.json 中就有2个入口文件描述字段:
javascript
{
"browser": "fetch-npm-browserify.js",
"main": "fetch-npm-node.js"
}
isomorphic-fetch 在不同的运行环境下使用不同的代码是因为 fetch API 的实现机制不一样,在浏览器中通过原生的 fetch 或者 XMLHttpRequest 实现,在 Node.js 中通过 http 模块实现。
resolve.mainFields
的默认值和当前的 target 配置有关系,对应关系如下:
-
当 target 为 web 或者 webworker 时,值是
["browser", "module", "main"]
-
当 target 为其它情况时,值是
["module", "main"]
以 target 等于 web 为例,Webpack 会先采用第三方模块中的 browser
字段去寻找模块的入口文件,如果不存在就采用 module
字段,以此类推。
为了减少搜索步骤,在你明确第三方模块的入口文件描述字段时,你可以把它设置的尽量少。 由于大多数第三方模块都采用 main
字段去描述入口文件的位置,可以这样配置 Webpack:
javascript
module.exports = {
resolve: {
// 只采用 main 字段作为入口文件描述字段,以减少搜索步骤
mainFields: ['main'],
},
};
使用本方法优化时,你需要考虑到所有运行时依赖的第三方模块的入口文件描述字段,就算有一个模块搞错了都可能会造成构建出的代码无法正常运行
1.4.1.4 优化 resolve.alias 配置
resolve.alias
配置项通过别名来把原导入路径映射成一个新的导入路径。
在实战项目中经常会依赖一些庞大的第三方模块,以 React 库为例,安装到 node_modules 目录下的 React 库的目录结构如下:
javascript
├── dist
│ ├── react.js
│ └── react.min.js
├── lib
│ ... 还有几十个文件被忽略
│ ├── LinkedStateMixin.js
│ ├── createClass.js
│ └── React.js
├── package.json
└── react.js
可以看到发布出去的 React 库中包含两套代码:
-
一套是采用 CommonJS 规范的模块化代码,这些文件都放在 lib 目录下,以 package.json 中指定的入口文件 react.js 为模块的入口。
-
一套是把 React 所有相关的代码打包好的完整代码放到一个单独的文件中,这些代码没有采用模块化可以直接执行。其中 dist/react.js 是用于开发环境,里面包含检查和警告的代码。dist/react.min.js 是用于线上环境,被最小化了。
默认情况下 Webpack 会从入口文件 ./node_modules/react/react.js
开始递归的解析和处理依赖的几十个文件,这会时一个耗时的操作。 通过配置 resolve.alias
可以让 Webpack 在处理 React 库时,直接使用单独完整的 react.min.js 文件,从而跳过耗时的递归解析操作。
javascript
module.exports = {
resolve: {
// 使用 alias 把导入 react 的语句换成直接使用单独完整的 react.min.js 文件,
// 减少耗时的递归解析操作
alias: {
'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js'), // react15
// 'react': path.resolve(__dirname, './node_modules/react/umd/react.production.min.js'), // react16
}
},
};
除了 React 库外,大多数库发布到 Npm 仓库中时都会包含打包好的完整文件,对于这些库你也可以对它们配置 alias。
但是对于有些库使用本优化方法后会影响到后面要讲的使用 Tree-Shaking 去除无效代码的优化,因为打包好的完整文件中有部分代码你的项目可能永远用不上。 一般对整体性比较强的库采用本方法优化,因为完整文件中的代码是一个整体,每一行都是不可或缺的。 但是对于一些工具类的库,例如 lodash,你的项目可能只用到了其中几个工具函数,你就不能使用本方法去优化,因为这会导致你的输出代码中包含很多永远不会执行的代码。
1.4.1.5 优化 resolve.extensions 配置
在导入语句没带文件后缀时,Webpack 会自动带上后缀后去尝试询问文件是否存在。 resolve.extensions
用于配置在尝试过程中用到的后缀列表,默认是:
javascript
extensions: ['.js', '.json']
也就是说当遇到 require('./data')
这样的导入语句时,Webpack 会先去寻找 ./data.js
文件,如果该文件不存在就去寻找 ./data.json
文件,如果还是找不到就报错。
如果这个列表越长,或者正确的后缀在越后面,就会造成尝试的次数越多,所以 resolve.extensions
的配置也会影响到构建的性能。 在配置 resolve.extensions
时你需要遵守以下几点,以做到尽可能的优化构建性能:
-
后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中;
-
频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程;
-
在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在你确定的情况下把
require('./data')
写成require('./data.json')
;
相关 Webpack 配置如下:
javascript
module.exports = {
resolve: {
// 尽可能的减少后缀尝试的可能性
extensions: ['js'],
},
};
1.4.1.6 优化 module.noParse 配置
module.noParse
配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。 原因是一些库,例如 jQuery 、ChartJS, 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。
在上面的 优化 resolve.alias
配置 中讲到单独完整的 react.min.js
文件就没有采用模块化,让我们来通过配置 module.noParse 忽略对 react.min.js 文件的递归解析处理, 相关 Webpack 配置如下:
javascript
const path = require('path');
module.exports = {
module: {
// 独完整的 `react.min.js` 文件就没有采用模块化,忽略对 `react.min.js` 文件的递归解析处理
noParse: [/react\.min\.js$/],
},
};
注意被忽略掉的文件里不应该包含 import 、 require 、 define 等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句。
1.4.2 使用 DllPlugin
1.4.2.1 什么是DLL?
在介绍 DllPlugin 前先给大家介绍下 DLL。 用过 Windows 系统的人应该会经常看到以 .dll 为后缀的文件,这些文件称为动态链接库,在一个动态链接库中可以包含给其他模块调用的函数和数据。
要给 Web 项目构建接入动态链接库的思想,需要完成以下事情:
-
把网页依赖的基础模块抽离出来,打包到一个个单独的动态链接库中去。一个动态链接库中可以包含多个模块;
-
当需要导入的模块存在于某个动态链接库中时,这个模块不能被再次被打包,而是去动态链接库中获取;
-
页面依赖的所有动态链接库需要被加载;
为什么给 Web 项目构建接入动态链接库的思想后,会大大提升构建速度呢? 原因在于包含大量复用模块的动态链接库只需要编译一次,在之后的构建过程中被动态链接库包含的模块将不会在重新编译,而是直接使用动态链接库中的代码。 由于动态链接库中大多数包含的是常用的第三方模块,例如react
、react-dom
,只要不升级这些模块的版本,动态链接库就不用重新编译。
1.4.2.2 接入 Webpack
Webpack 已经内置了对动态链接库的支持,需要通过2个内置的插件接入,它们分别是:
-
DllPlugin
插件:用于打包出一个个单独的动态链接库文件; -
DllReferencePlugin
插件:用于在主要配置文件中去引入 DllPlugin 插件打包好的动态链接库文件;
下面以基本的 React 项目为例,为其接入 DllPlugin,在开始前先来看下最终构建出的目录结构:
javascript
├── main.js
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
其中包含两个动态链接库文件,分别是:
-
polyfill.dll.js
里面包含项目所有依赖的 polyfill,例如 Promise、fetch 等 API; -
react.dll.js
里面包含 React 的基础运行环境,也就是 react 和 react-dom 模块;
以 react.dll.js
文件为例,其文件内容大致如下:
javascript
var _dll_react = (function(modules) {
// ... 此处省略 webpackBootstrap 函数代码
}([
function(module, exports, __webpack_require__) {
// 模块 ID 为 0 的模块对应的代码
},
function(module, exports, __webpack_require__) {
// 模块 ID 为 1 的模块对应的代码
},
// ... 此处省略剩下的模块对应的代码
]));
可见一个动态链接库文件中包含了大量模块的代码,这些模块存放在一个数组里,用数组的索引号作为 ID。 并且还通过 _dll_react
变量把自己暴露在了全局中,也就是可以通过 window._dll_react
可以访问到它里面包含的模块。
其中 polyfill.manifest.json
和 react.manifest.json
文件也是由 DllPlugin 生成出,用于描述动态链接库文件中包含哪些模块, 以 react.manifest.json
文件为例,其文件内容大致如下:
javascript
{
// 描述该动态链接库文件暴露在全局的变量名称
"name": "_dll_react",
"content": {
"./node_modules/process/browser.js": {
"id": 0,
"meta": {}
},
// ... 此处省略部分模块
"./node_modules/react-dom/lib/ReactBrowserEventEmitter.js": {
"id": 42,
"meta": {}
},
"./node_modules/react/lib/lowPriorityWarning.js": {
"id": 47,
"meta": {}
},
// ... 此处省略部分模块
"./node_modules/react-dom/lib/SyntheticTouchEvent.js": {
"id": 210,
"meta": {}
},
"./node_modules/react-dom/lib/SyntheticTransitionEvent.js": {
"id": 211,
"meta": {}
},
}
}
可见 manifest.json
文件清楚地描述了与其对应的 dll.js
文件中包含了哪些模块,以及每个模块的路径和 ID。
main.js
文件是编译出来的执行入口文件,当遇到其依赖的模块在 dll.js
文件中时,会直接通过 dll.js
文件暴露出的全局变量去获取打包在 dll.js
文件的模块。 所以在 index.html
文件中需要把依赖的两个 dll.js 文件给加载进去,index.html 内容如下:
javascript
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
<!--导入依赖的动态链接库文件-->
<script src="./dist/polyfill.dll.js"></script>
<script src="./dist/react.dll.js"></script>
<!--导入执行入口文件-->
<script src="./dist/main.js"></script>
</body>
</html>
1.4.2.3 构建出动态链接库文件
构建输出的以下这四个文件
javascript
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
和以下这一个文件
javascript
├── main.js
是由两份不同的构建分别输出的。
动态链接库文件相关的文件需要由一份独立的构建输出,用于给主构建使用。新建一个 Webpack 配置文件 webpack_dll.config.js
专门用于构建它们,文件内容如下:
javascript
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');
module.exports = {
// JS 执行入口文件
entry: {
// 把 React 相关模块的放到一个单独的动态链接库
react: ['react', 'react-dom'],
// 把项目需要所有的 polyfill 放到一个单独的动态链接库
polyfill: ['core-js/fn/object/assign', 'core-js/fn/promise', 'whatwg-fetch'],
},
output: {
// 输出的动态链接库的文件名称,[name] 代表当前动态链接库的名称,
// 也就是 entry 中配置的 react 和 polyfill
filename: '[name].dll.js',
// 输出的文件都放到 dist 目录下
path: path.resolve(__dirname, 'dist'),
// 存放动态链接库的全局变量名称,例如对应 react 来说就是 _dll_react
// 之所以在前面加上 _dll_ 是为了防止全局变量冲突
library: '_dll_[name]',
},
plugins: [
// 接入 DllPlugin
new DllPlugin({
// 动态链接库的全局变量名称,需要和 output.library 中保持一致
// 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
// 例如 react.manifest.json 中就有 "name": "_dll_react"
name: '_dll_[name]',
// 描述动态链接库的 manifest.json 文件输出时的文件名称
path: path.join(__dirname, 'dist', '[name].manifest.json'),
}),
],
};
1.4.2.4 使用动态链接库文件
构建出的动态链接库文件用于给其它地方使用,在这里也就是给执行入口使用。
用于输出 main.js
的主 Webpack 配置文件内容如下:
javascript
const path = require('path');
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');
module.exports = {
entry: {
// 定义入口 Chunk
main: './main.js'
},
output: {
// 输出文件的名称
filename: '[name].js',
// 输出文件都放到 dist 目录下
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
// 项目源码使用了 ES6 和 JSX 语法,需要使用 babel-loader 转换
test: /\.js$/,
use: ['babel-loader'],
exclude: path.resolve(__dirname, 'node_modules'),
},
]
},
plugins: [
// 告诉 Webpack 使用了哪些动态链接库
new DllReferencePlugin({
// 描述 react 动态链接库的文件内容
manifest: require('./dist/react.manifest.json'),
}),
new DllReferencePlugin({
// 描述 polyfill 动态链接库的文件内容
manifest: require('./dist/polyfill.manifest.json'),
}),
],
devtool: 'source-map'
};
注意:在 webpack_dll.config.js
文件中,DllPlugin
中的 name 参数必须和 output.library 中保持一致。 原因在于 DllPlugin
中的 name 参数会影响输出的 manifest.json
文件中 name 字段的值, 而在 webpack.config.js
文件中 DllReferencePlugin
会去 manifest.json
文件读取 name 字段的值, 把值的内容作为在从全局变量中获取动态链接库中内容时的全局变量名。
1.4.2.5 执行构建
在修改好以上两个 Webpack 配置文件后,需要重新执行构建。 重新执行构建时要注意的是需要先把动态链接库相关的文件编译出来,因为主 Webpack 配置文件中定义的 DllReferencePlugin
依赖这些文件。
执行构建时流程如下:
-
如果动态链接库相关的文件还没有编译出来,就需要先把它们编译出来。方法是执行
webpack --config webpack_dll.config.js
命令; -
在确保动态链接库存在时,才能正常的编译出入口执行文件。方法是执行 webpack 命令。这时你会发现构建速度有了非常大的提升。
1.4.3 使用 ParallelUglifyPlugin
在使用 Webpack 构建出用于发布到线上的代码时,都会有压缩代码这一流程。 最常见的 JavaScript 代码压缩工具是 UglifyJS,并且 Webpack 也内置了它。
用过 UglifyJS
的你一定会发现在构建用于开发环境的代码时很快就能完成,但在构建用于线上的代码时构建一直卡在一个时间点迟迟没有反应,其实卡住的这个时候就是在进行代码压缩。
由于压缩 JavaScript 代码需要先把代码解析成用 Object 抽象表示的 AST 语法树,再去应用各种规则分析和处理 AST,导致这个过程计算量巨大,耗时非常多。
这里可以使用多进程并行处理的思想也引入到代码压缩。ParallelUglifyPlugin 就做了这个事情。 当 Webpack 有多个 JavaScript 文件需要输出和压缩时,原本会使用 UglifyJS 去一个个挨着压缩再输出, 但是 ParallelUglifyPlugin
则会开启多个子进程,把对多个文件的压缩工作分配给多个子进程去完成,每个子进程其实还是通过 UglifyJS 去压缩代码,但是变成了并行执行。 所以 ParallelUglifyPlugin
能更快的完成对多个文件的压缩工作。
使用 ParallelUglifyPlugin
也非常简单,把原来 Webpack 配置文件中内置的 UglifyJsPlugin
去掉后,再替换成 ParallelUglifyPlugin
,相关代码如下:
javascript
const path = require('path');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
module.exports = {
plugins: [
// 使用 ParallelUglifyPlugin 并行压缩输出的 JS 代码
new ParallelUglifyPlugin({
// 传递给 UglifyJS 的参数
uglifyJS: {
output: {
// 最紧凑的输出
beautify: false,
// 删除所有的注释
comments: false,
},
compress: {
// 在UglifyJs删除没有用到的代码时不输出警告
warnings: false,
// 删除所有的 `console` 语句,可以兼容ie浏览器
drop_console: true,
// 内嵌定义了但是只用到一次的变量
collapse_vars: true,
// 提取出出现多次但是没有定义成变量去引用的静态值
reduce_vars: true,
}
},
}),
],
};
在通过 new ParallelUglifyPlugin()
实例化时,支持以下参数:
-
test:使用正则去匹配哪些文件需要被
ParallelUglifyPlugin
压缩,默认是 /.js$/,也就是默认压缩所有的 .js 文件; -
include:使用正则去命中需要被
ParallelUglifyPlugin
压缩的文件。默认为 []; -
exclude:使用正则去命中不需要被
ParallelUglifyPlugin
压缩的文件。默认为 []; -
cacheDir:缓存压缩后的结果,下次遇到一样的输入时直接从缓存中获取压缩后的结果并返回。cacheDir 用于配置缓存存放的目录路径。默认不会缓存,想开启缓存请设置一个目录路径;
-
workerCount:开启几个子进程去并发的执行压缩。默认是当前运行电脑的 CPU 核数减去1;
-
sourceMap:是否输出 Source Map,这会导致压缩过程变慢;
-
uglifyJS:用于压缩 ES5 代码时的配置,Object 类型,直接透传给 UglifyJS 的参数;
-
uglifyES:用于压缩 ES6 代码时的配置,Object 类型,直接透传给 UglifyES 的参数;
其中的 test、include、exclude 与配置 Loader 时的思想和用法一样。
UglifyES 是 UglifyJS 的变种,专门用于压缩 ES6 代码,它们两都出自于同一个项目,并且它们两不能同时使用。
UglifyES 一般用于给比较新的 JavaScript 运行环境压缩代码,例如用于 ReactNative 的代码运行在兼容性较好的 JavaScriptCore 引擎中,为了得到更好的性能和尺寸,采用 UglifyES 压缩效果会更好;
ParallelUglifyPlugin 同时内置了 UglifyJS 和 UglifyES,也就是说 ParallelUglifyPlugin 支持并行压缩 ES6 代码;
接入 ParallelUglifyPlugin 后,项目需要安装新的依赖:
javascript
npm i -D webpack-parallel-uglify-plugin
安装成功后,重新执行构建你会发现速度变快了许多。如果设置 cacheDir 开启了缓存,在之后的构建中会变的更快。
1.4.4 使用自动刷新
在开发阶段,修改源码是不可避免的操作。 对于开发网页来说,要想看到修改后的效果,需要刷新浏览器让其重新运行最新的代码才行。 虽然这相比于开发原生 iOS 和 Android 应用来说要方便很多,因为那需要重新编译这个项目再运行,但我们可以把这个体验优化的更好。 借助自动化的手段,可以把这些重复的操作交给代码去帮我们完成,在监听到本地源码文件发生变化时,自动重新构建出可运行的代码后再控制浏览器刷新。
Webpack 把这些功能都内置了,并且还提供多种方案可选。
1.4.4.1 文件监听
文件监听是在发现源码文件发生变化时,自动重新构建出新的输出文件。
Webpack 官方提供了两大模块,一个是核心的 webpack,一个是webpack-dev-server 扩展模块。 而文件监听功能是 webpack 模块提供的。
Webpack 支持文件监听相关的配置项如下:
javascript
module.export = {
// 只有在开启监听模式时,watchOptions 才有意义
// 默认为 false,也就是不开启
watch: true,
// 监听模式运行时的参数
// 在开启监听模式时,才有意义
watchOptions: {
// 不监听的文件或文件夹,支持正则匹配
// 默认为空
ignored: /node_modules/,
// 监听到变化发生后会等300ms再去执行动作,防止文件更新太快导致重新编译频率太高
// 默认为 300ms
aggregateTimeout: 300,
// 判断文件是否发生变化是通过不停的去询问系统指定文件有没有变化实现的
// 默认每隔1000毫秒询问一次
poll: 1000
}
}
要让 Webpack 开启监听模式,有两种方式:
-
在配置文件
webpack.config.js
中设置 watch: true; -
在执行启动
Webpack
命令时,带上 --watch 参数,完整命令是 webpack --watch;
1.4.4.2 文件监听工作原理
在 Webpack 中监听一个文件发生变化的原理是定时的去获取这个文件的最后编辑时间,每次都存下最新的最后编辑时间,如果发现当前获取的和最后一次保存的最后编辑时间不一致,就认为该文件发生了变化。 配置项中的 watchOptions.poll
就是用于控制定时检查的周期,具体含义是每隔多少毫秒检查一次。
当发现某个文件发生了变化时,并不会立刻告诉监听者,而是先缓存起来,收集一段时间的变化后,再一次性告诉监听者。 配置项中的 watchOptions.aggregateTimeout
就是用于配置这个等待时间。 这样做的目的是因为我们在编辑代码的过程中可能会高频的输入文字导致文件变化的事件高频的发生,如果每次都重新执行构建就会让构建卡死。
对于多个文件来说,原理相似,只不过会对列表中的每一个文件都定时的执行检查。 但是这个需要监听的文件列表是怎么确定的呢? 默认情况下 Webpack 会从配置的 Entry 文件出发,递归解析出 Entry 文件所依赖的文件,把这些依赖的文件都加入到监听列表中去。 可见 Webpack 这一点还是做的很智能的,不是粗暴的直接监听项目目录下的所有文件。
由于保存文件的路径和最后编辑时间需要占用内存,定时检查周期检查需要占用 CPU 以及文件 I/O,所以最好减少需要监听的文件数量和降低检查频率。
1.4.4.3 优化文件监听性能
在明白文件监听工作原理后,就好分析如何优化文件监听性能了。
开启监听模式时,默认情况下会监听配置的 Entry 文件和所有其递归依赖的文件。 在这些文件中会有很多存在于 node_modules
下,因为如今的 Web 项目会依赖大量的第三方模块。 在大多数情况下我们都不可能去编辑 node_modules
下的文件,而是编辑自己建立的源码文件。 所以一个很大的优化点就是忽略掉 node_modules 下的文件,不监听它们。相关配置如下:
javascript
module.export = {
watchOptions: {
// 不监听的 node_modules 目录下的文件
ignored: /node_modules/,
}
}
采用这种方法优化后,Webpack 消耗的内存和 CPU 将会大大降低。
有时你可能会觉得 node_modules
目录下的第三方模块有 bug,想修改第三方模块的文件,然后在自己的项目中试试。 在这种情况下如果使用了以上优化方法,我们需要重启构建以看到最新效果。 但这种情况毕竟是非常少见的。
除了忽略掉部分文件的优化外,还有如下两种方法:
-
watchOptions.aggregateTimeout
值越大性能越好,因为这能降低重新构建的频率; -
watchOptions.poll
值越大越好,因为这能降低检查的频率;
但两种优化方法的后果是会让你感觉到监听模式的反应和灵敏度降低了。
1.4.4.4 自动刷新浏览器
监听到文件更新后的下一步是去刷新浏览器,webpack 模块负责监听文件,webpack-dev-server
模块则负责刷新浏览器。 在使用 webpack-dev-server
模块去启动 webpack 模块时,webpack 模块的监听模式默认会被开启。 webpack 模块会在文件发生变化时告诉 webpack-dev-server
模块。
1.4.4.5 自动刷新的原理
控制浏览器刷新有三种方法:
-
借助浏览器扩展去通过浏览器提供的接口刷新;
-
往要开发的网页中注入代理客户端代码,通过代理客户端去刷新整个页面;
-
把要开发的网页装进一个 iframe 中,通过刷新 iframe 去看到最新效果;
DevServer 支持第2、3种方法,第2种是 DevServer 默认采用的刷新方法。
通过 DevServer 启动构建后,你会看到如下日志:
javascript
> webpack-dev-server
Project is running at http://localhost:8080/
webpack output is served from /
Hash: e4e2f9508ac286037e71
Version: webpack 3.5.5
Time: 1566ms
Asset Size Chunks Chunk Names
bundle.js 1.07 MB 0 [emitted] [big] main
bundle.js.map 1.27 MB 0 [emitted] main
[115] multi (webpack)-dev-server/client?http://localhost:8080 ./main.js 40 bytes {0} [built]
[116] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[117] ./node_modules/url/url.js 23.3 kB {0} [built]
[120] ./node_modules/querystring-es3/index.js 127 bytes {0} [built]
[123] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[125] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[126] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[158] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[159] ./node_modules/ansi-html/index.js 4.26 kB {0} [built]
[163] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[165] (webpack)/hot/emitter.js 77 bytes {0} [built]
[167] ./main.js 2.28 kB {0} [built]
+ 255 hidden modules
我们观察到输出的 bundle.js 中包含了以下七个模块:
javascript
[116] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[117] ./node_modules/url/url.js 23.3 kB {0} [built]
[120] ./node_modules/querystring-es3/index.js 127 bytes {0} [built]
[123] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[125] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[126] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[158] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
这七个模块就是代理客户端的代码,它们被打包进了要开发的网页代码中。
在浏览器中打开网址 http://localhost:8080/
后, 在浏览器的开发者工具中你会发现由代理客户端向 DevServer 发起的 WebSocket 连接:
1.4.5 开启模块热替换
要做到实时预览,除了刷新整个网页外,DevServer 还支持一种叫做模块热替换(Hot Module Replacement
)的技术可在不刷新整个网页的情况下做到超灵敏的实时预览。 原理是当一个源码发生变化时,只重新编译发生变化的模块,再用新输出的模块替换掉浏览器中对应的老模块。
模块热替换技术的优势有:
-
实时预览反应更快,等待时间更短;
-
不刷新浏览器能保留当前网页的运行状态,例如在使用 Redux 来管理数据的应用中搭配模块热替换能做到代码更新时 Redux 中的数据还保持不变;
总的来说模块热替换技术很大程度上的提高了开发效率和体验。
1.4.5.1 模块热替换的原理
模块热替换的原理和自动刷新原理类似,都需要往要开发的网页中注入一个代理客户端用于连接 DevServer 和网页, 不同在于模块热替换独特的模块替换机制。
DevServer 默认不会开启模块热替换模式,要开启该模式,只需在启动时带上参数 --hot,完整命令是 webpack-dev-server --hot
。
除了通过在启动时带上 --hot 参数,还可以通过接入 Plugin 实现,相关代码如下:
javascript
const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');
module.exports = {
entry:{
// 为每个入口都注入代理客户端
main:['webpack-dev-server/client?http://localhost:8080/', 'webpack/hot/dev-server','./src/main.js'],
},
plugins: [
// 该插件的作用就是实现模块热替换,实际上当启动时带上 `--hot` 参数,会注入该插件,生成 .hot-update.json 文件。
new HotModuleReplacementPlugin(),
],
devServer:{
// 告诉 DevServer 要开启模块热替换模式
hot: true,
}
};
在启动 Webpack 时带上参数 --hot 其实就是自动为你完成以上配置。
启动后日志如下:
javascript
> webpack-dev-server --hot
Project is running at http://localhost:8080/
webpack output is served from /
webpack: wait until bundle finished: /
webpack: wait until bundle finished: /bundle.js
Hash: fe62ac6b753c1d98961b
Version: webpack 3.5.5
Time: 3563ms
Asset Size Chunks Chunk Names
bundle.js 1.11 MB 0 [emitted] [big] main
bundle.js.map 1.33 MB 0 [emitted] main
[50] (webpack)/hot/log.js 1.04 kB {0} [built]
[118] multi (webpack)-dev-server/client?http://localhost:8080 webpack/hot/dev-server ./main.js 52 bytes {0} [built]
[119] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[120] ./node_modules/url/url.js 23.3 kB {0} [built]
[126] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[128] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[129] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[161] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[168] (webpack)/hot/dev-server.js 1.61 kB {0} [built]
[169] (webpack)/hot/log-apply-result.js 1.31 kB {0} [built]
[170] ./main.js 2.35 kB {0} [built]
+ 262 hidden modules
可以看出 bundle.js 代理客户端相关的代码包含九个文件:
javascript
[119] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[120] ./node_modules/url/url.js 23.3 kB {0} [built]
[126] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[128] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[129] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[161] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[168] (webpack)/hot/dev-server.js 1.61 kB {0} [built]
[169] (webpack)/hot/log-apply-result.js 1.31 kB {0} [built]
相比于自动刷新的代理客户端,多出了后三个用于模块热替换的文件,也就是说代理客户端更大了。
修改源码 main.css 文件后,新输出了如下日志:
javascript
webpack: Compiling...
Hash: 18f81c959118f6230623
Version: webpack 3.5.5
Time: 551ms
Asset Size Chunks Chunk Names
bundle.js 1.11 MB 0 [emitted] [big] main
0.ea11a51f97f2b52bca7d.hot-update.js 353 bytes 0 [emitted] main
ea11a51f97f2b52bca7d.hot-update.json 43 bytes [emitted]
bundle.js.map 1.33 MB 0 [emitted] main
0.ea11a51f97f2b52bca7d.hot-update.js.map 577 bytes 0 [emitted] main
[68] ./node_modules/css-loader!./main.css 217 bytes {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
+ 275 hidden modules
webpack: Compiled successfully.
DevServer 新生成了一个用于替换老模块的补丁文件 0.ea11a51f97f2b52bca7d.hot-update.js,同时在浏览器开发工具中也能看到请求这个补丁的抓包:
可见补丁中包含了 main.css 文件新编译出来 CSS 代码,网页中的样式也立刻变成了源码中描述的那样。
但当你修改 main.js 文件时,会发现模块热替换没有生效,而是整个页面被刷新了,为什么修改 main.js 文件时会这样呢?
Webpack 为了让使用者在使用了模块热替换功能时能灵活地控制老模块被替换时的逻辑,可以在源码中定义一些代码去做相应的处理。
把的 main.js 文件改为如下:
javascript
import React from 'react';
import { render } from 'react-dom';
import { AppComponent } from './AppComponent';
import './main.css';
render(<AppComponent/>, window.document.getElementById('app'));
// 只有当开启了模块热替换时 module.hot 才存在
if (module.hot) {
// accept 函数的第一个参数指出当前文件接受哪些子模块的替换,这里表示只接受 ./AppComponent 这个子模块
// 第2个参数用于在新的子模块加载完毕后需要执行的逻辑
module.hot.accept(['./AppComponent'], () => {
// 新的 AppComponent 加载成功后重新执行下组建渲染逻辑
render(<AppComponent/>, window.document.getElementById('app'));
});
}
其中的 module.hot
是当开启模块热替换后注入到全局的 API,用于控制模块热替换的逻辑。
现在修改 AppComponent.js
文件,把 Hello,Webpack 改成 Hello,World,你会发现模块热替换生效了。 但是当你编辑 main.js 时,你会发现整个网页被刷新了。为什么修改这两个文件会有不一样的表现呢?
当子模块发生更新时,更新事件会一层层往上传递,也就是从 AppComponent.js 文件传递到 main.js 文件, 直到有某层的文件接受了当前变化的模块,也就是 main.js 文件中定义的 module.hot.accept(['./AppComponent'], callback)
, 这时就会调用 callback 函数去执行自定义逻辑。如果事件一直往上抛到最外层都没有文件接受它,就会直接刷新网页。
那为什么没有地方接受过 .css 文件,但是修改所有的 .css 文件都会触发模块热替换呢? 原因在于 style-loader 会注入用于接受 CSS 的代码。
请不要把模块热替换技术用于线上环境,它是专门为提升开发效率生的。
1.4.5.2 优化模块热替换
在发生模块热替换时,你会在浏览器的控制台中看到类似这样的日志:
其中的 Updated modules: 68 是指 ID 为68的模块被替换了,这对开发者来说很不友好,因为开发者不知道 ID 和模块之间的对应关系,最好是把替换了的模块的名称输出出来。 Webpack 内置的 NamedModulesPlugin
插件可以解决该问题,修改 Webpack 配置文件接入该插件:
javascript
const NamedModulesPlugin = require('webpack/lib/NamedModulesPlugin');
module.exports = {
plugins: [
// 显示出被替换模块的名称
new NamedModulesPlugin(),
],
};
除此之外,模块热替换还面临着和自动刷新一样的性能问题,因为它们都需要监听文件变化和注入客户端。 要优化模块热替换的构建性能,思路文件刷新中提到的很类似:监听更少的文件,忽略掉 node_modules 目录下的文件。 但是其中提到的关闭默认的 inline 模式手动注入代理客户端的优化方法不能用于在使用模块热替换的情况下, 原因在于模块热替换的运行依赖在每个 Chunk 中都包含代理客户端的代码。
1.4.6 区分环境
1.4.6.1 为什么需要区分环境
在开发网页的时候,一般都会有多套运行环境,例如:
-
在开发过程中方便开发调试的环境;
-
发布到线上给用户使用的运行环境;
这两套不同的环境虽然都是由同一套源代码编译而来,但是代码内容却不一样,差异包括:
-
线上代码压缩过;
-
开发用的代码包含一些用于提示开发者的提示日志,这些日志普通用户不可能去看它;
-
开发用的代码所连接的后端数据接口地址也可能和线上环境不同,因为要避免开发过程中造成对线上数据的影响;
为了尽可能的复用代码,在构建的过程中需要根据目标代码要运行的环境而输出不同的代码,我们需要一套机制在源码中去区分环境。 幸运的是 Webpack 已经为我们实现了这点。
1.4.6.2 如何区分环境
具体区分方法很简单,在源码中通过如下方式:
javascript
if (process.env.NODE_ENV === 'production') {
console.log('你正在线上环境');
} else {
console.log('你正在使用开发环境');
}
其大概原理是借助于环境变量的值去判断执行哪个分支。
当你的代码中出现了使用 process 模块的语句时,Webpack 就自动打包进 process 模块的代码以支持非 Node.js 的运行环境。 当你的代码中没有使用 process 时就不会打包进 process 模块的代码。这个注入的 process 模块作用是为了模拟 Node.js 中的 process,以支持上面使用的 process.env.NODE_ENV === 'production'
语句。
在构建线上环境代码时,需要给当前运行环境设置环境变量 NODE_ENV = 'production',Webpack 相关配置如下:
javascript
const DefinePlugin = require('webpack/lib/DefinePlugin');
module.exports = {
plugins: [
new DefinePlugin({
// 定义 NODE_ENV 环境变量为 production
'process.env': {
NODE_ENV: JSON.stringify('production')
}
}),
],
};
注意在定义环境变量的值时用 JSON.stringify 包裹字符串的原因是环境变量的值需要是一个由双引号包裹的字符串,而 JSON.stringify('production')的值正好等于'"production"'
javascript
if (true) {
console.log('你正在使用线上环境');
} else {
console.log('你正在使用开发环境');
}
定义的环境变量的值被代入到了源码中,process.env.NODE_ENV === 'production'
被直接替换成了 true。 并且由于此时访问 process 的语句被替换了而没有了,Webpack 也不会打包进 process 模块了。
DefinePlugin 定义的环境变量只对 Webpack 需要处理的代码有效,而不会影响 Node.js 运行时的环境变量的值。
通过 Shell 脚本的方式去定义的环境变量,例如 NODE_ENV=production webpack
,Webpack 是不认识的,对 Webpack 需要处理的代码中的环境区分语句是没有作用的。
也就是说只需要通过 DefinePlugin 定义环境变量就能使上面介绍的环境区分语句正常工作,没必要又通过 Shell 脚本的方式去定义一遍。
如果你想让 Webpack 使用通过 Shell 脚本的方式去定义的环境变量,你可以使用 EnvironmentPlugin,代码如下:
javascript
new webpack.EnvironmentPlugin(['NODE_ENV'])
// 等同于
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
})
1.4.6.3 第三方库中的环境区分
除了在自己写的源码中可以有环境区分的代码外,很多第三方库也做了环境区分的优化。 以 React 为例,它做了两套环境区分,分别是:
-
开发环境:包含类型检查、HTML 元素检查等等针对开发者的警告日志代码;
-
线上环境:去掉了所有针对开发者的代码,只保留让 React 能正常运行的部分,以优化大小和性能;
例如 React 源码中有大量类似下面这样的代码:
javascript
if (process.env.NODE_ENV !== 'production') {
warning(false, '%s(...): Can only update a mounted or mounting component.... ')
}
如果你不定义 NODE_ENV=production
那么这些警告日志就会被包含到输出的代码中,输出的文件将会非常大。
process.env.NODE_ENV !== 'production'
中的 NODE_ENV
和 'production'
两个值是社区的约定,通常使用这条判断语句在区分开发环境和线上环境。
1.4.7 压缩代码
压缩代码 浏览器从服务器访问网页时获取的 JavaScript、CSS 资源都是文本形式的,文件越大网页加载时间越长。 为了提升网页加速速度和减少网络传输流量,可以对这些资源进行压缩。 压缩的方法除了可以通过 GZIP 算法对文件压缩外,还可以对文本本身进行压缩。
对文本本身进行压缩的作用除了有提升网页加载速度的优势外,还具有混淆源码的作用。 由于压缩后的代码可读性非常差,就算别人下载到了网页的代码,也大大增加了代码分析和改造的难度。
1.4.7.1 压缩 JavaScript
目前最成熟的 JavaScript 代码压缩工具是 UglifyJS
, 它会分析 JavaScript 代码语法树,理解代码含义,从而能做到诸如去掉无效代码、去掉日志输出代码、缩短变量名等优化。
要在 Webpack 中接入 UglifyJS 需要通过插件的形式,目前有两个成熟的插件,分别是:
-
UglifyJsPlugin
:通过封装 UglifyJS 实现压缩; -
ParallelUglifyPlugin
:多进程并行处理压缩;
UglifyJS 提供了非常多的选择用于配置在压缩过程中采用哪些规则,所有的选项说明可以在 其官方文档 上看到。 由于选项非常多,就挑出一些常用的拿出来详细讲解其应用方式:
-
sourceMap
:是否为压缩后的代码生成对应的 Source Map,默认为不生成,开启后耗时会大大增加。一般不会把压缩后的代码的 Source Map 发送给网站用户的浏览器,而是用于内部开发人员调试线上代码时使用; -
beautify
: 是否输出可读性较强的代码,即会保留空格和制表符,默认为是,为了达到更好的压缩效果,可以设置为 false; -
comments
:是否保留代码中的注释,默认为保留,为了达到更好的压缩效果,可以设置为 false; -
compress.warnings
:是否在 UglifyJs 删除没有用到的代码时输出警告信息,默认为输出,可以设置为 false 以关闭这些作用不大的警告; -
drop_console
:是否剔除代码中所有的 console 语句,默认为不剔除。开启后不仅可以提升代码压缩效果,也可以兼容不支持 console 语句 IE 浏览器; -
collapse_vars
:是否内嵌定义了但是只用到一次的变量,例如把 var x = 5; y = x 转换成 y = 5,默认为不转换。为了达到更好的压缩效果,可以设置为 true; -
reduce_vars
: 是否提取出出现多次但是没有定义成变量去引用的静态值,例如把 x = 'Hello'; y = 'Hello' 转换成 var a = 'Hello'; x = a; y = b,默认为不转换。为了达到更好的压缩效果,可以设置为 true;
也就是说,在不影响代码正确执行的前提下,最优化的代码压缩配置为如下:
javascript
const UglifyJSPlugin = require('webpack/lib/optimize/UglifyJsPlugin');
module.exports = {
plugins: [
// 压缩输出的 JS 代码
new UglifyJSPlugin({
compress: {
// 在UglifyJs删除没有用到的代码时不输出警告
warnings: false,
// 删除所有的 `console` 语句,可以兼容ie浏览器
drop_console: true,
// 内嵌定义了但是只用到一次的变量
collapse_vars: true,
// 提取出出现多次但是没有定义成变量去引用的静态值
reduce_vars: true,
},
output: {
// 最紧凑的输出
beautify: false,
// 删除所有的注释
comments: false,
}
}),
],
};
从以上配置中可以看出 Webpack 内置了 UglifyJsPlugin,需要指出的是 UglifyJsPlugin 当前采用的是 UglifyJS2 而不是老的 UglifyJS1, 这两个版本的 UglifyJS 在配置上有所区别,看文档时注意版本。
除此之外 Webpack 还提供了一个更简便的方法来接入 UglifyJSPlugin,直接在启动 Webpack 时带上 --optimize-minimize
参数,即 webpack --optimize-minimize
, 这样 Webpack 会自动为你注入一个带有默认配置的 UglifyJSPlugin
。
1.4.7.2 压缩ES6
虽然当前大多数 JavaScript 引擎还不完全支持 ES6 中的新特性,但在一些特定的运行环境下已经可以直接执行 ES6 代码了,例如最新版的 Chrome、ReactNative 的引擎 JavaScriptCore。
运行 ES6 的代码相比于转换后的 ES5 代码有如下优点:
-
一样的逻辑用 ES6 实现的代码量比 ES5 更少;
-
JavaScript 引擎对 ES6 中的语法做了性能优化,例如针对 const 申明的变量有更快的读取速度;
所以在运行环境允许的情况下,我们要尽可能的使用原生的 ES6 代码去运行,而不是转换后的 ES5 代码。
在你用上面所讲的压缩方法去压缩 ES6 代码时,你会发现 UglifyJS 会报错退出,原因是 UglifyJS 只认识 ES5 语法的代码。 为了压缩 ES6 代码,需要使用专门针对 ES6 代码的 UglifyES。
UglifyES 和 UglifyJS 来自同一个项目的不同分支,它们的配置项基本相同,只是接入 Webpack 时有所区别。 在给 Webpack 接入 UglifyES 时,不能使用内置的 UglifyJsPlugin,而是需要单独安装和使用最新版本的 uglifyjs-webpack-plugin。 安装方法如下:
javascript
npm i -D uglifyjs-webpack-plugin@beta
webpack如下:
javascript
const UglifyESPlugin = require('uglifyjs-webpack-plugin')
module.exports = {
plugins: [
new UglifyESPlugin({
// 多嵌套了一层
uglifyOptions: {
compress: {
// 在UglifyJs删除没有用到的代码时不输出警告
warnings: false,
// 删除所有的 `console` 语句,可以兼容ie浏览器
drop_console: true,
// 内嵌定义了但是只用到一次的变量
collapse_vars: true,
// 提取出出现多次但是没有定义成变量去引用的静态值
reduce_vars: true,
},
output: {
// 最紧凑的输出
beautify: false,
// 删除所有的注释
comments: false,
}
}
})
]
}
同时,为了不让 babel-loader
输出 ES5 语法的代码,需要去掉 .babelrc
配置文件中的 babel-preset-env
,但是其它的 Babel 插件,比如 babel-preset-react
还是要保留, 因为正是 babel-preset-env 负责把 ES6 代码转换为 ES5 代码。
1.4.7.3 压缩 CSS
CSS 代码也可以像 JavaScript 那样被压缩,以达到提升加载速度和代码混淆的作用。 目前比较成熟可靠的 CSS 压缩工具是 cssnano,基于 PostCSS。
cssnano 能理解 CSS 代码的含义,而不仅仅是删掉空格,例如:
-
margin: 10px 20px 10px 20px
被压缩成margin: 10px 20px
; -
color: #ff0000
被压缩成color:red
;
还有很多压缩规则可以去其官网查看,通常压缩率能达到 60%。
把 cssnano 接入到 Webpack 中也非常简单,因为 css-loader 已经将其内置了,要开启 cssnano 去压缩代码只需要开启 css-loader 的 minimize 选项。 相关 Webpack 配置如下:
javascript
const path = require('path');
const {WebPlugin} = require('web-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
module: {
rules: [
{
test: /\.css$/,// 增加对 CSS 文件的支持
// 提取出 Chunk 中的 CSS 代码到单独的文件中
use: ExtractTextPlugin.extract({
// 通过 minimize 选项压缩 CSS 代码
use: ['css-loader?minimize']
}),
},
]
},
plugins: [
// 用 WebPlugin 生成对应的 HTML 文件
new WebPlugin({
template: './template.html', // HTML 模版文件所在的文件路径
filename: 'index.html' // 输出的 HTML 的文件名称
}),
new ExtractTextPlugin({
filename: `[name]_[contenthash:8].css`,// 给输出的 CSS 文件名称加上 Hash 值
}),
],
};
1.4.8 CDN加速
1.4.8.1 什么是 CDN
虽然前面通过了压缩代码的手段来减小网络传输大小,但实际上最影响用户体验的还是网页首次打开时的加载等待。 导致这个问题的根本是网络传输过程耗时大,CDN 的作用就是加速网络传输。
CDN 又叫内容分发网络,通过把资源部署到世界各地,用户在访问时按照就近原则从离用户最近的服务器获取资源,从而加速资源的获取速度。 CDN 其实是通过优化物理链路层传输过程中的网速有限、丢包等问题来提升网速的,其大致原理可以如下:
你不必理解 CDN 的具体运行流程和实现原理,你可以简单的把 CDN 服务看作成速度更快的 HTTP 服务。 并且目前很多大公司都会建立自己的 CDN 服务,就算你自己没有资源去搭建一套 CDN 服务,各大云服务提供商都提供了按量收费的 CDN 服务。
1.4.8.2 接入 CDN
要给网站接入 CDN,需要把网页的静态资源上传到 CDN 服务上去,在服务这些静态资源的时候需要通过 CDN 服务提供的 URL 地址去访问。
举个详细的例子,有一个单页应用,构建出的代码结构如下:
javascript
dist
|-- app_9d89c964.js
|-- app_a6976b6d.css
|-- arch_ae805d49.png
`-- index.html
其中 index.html
内容如下:
javascript
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="app_a6976b6d.css">
</head>
<body>
<div id="app"></div>
<script src="app_9d89c964.js"></script>
</body>
</html>
app_a6976b6d.css
内容如下:
css
body{background:url(arch_ae805d49.png) repeat}h1{color:red}
可以看出到导入资源时都是通过相对路径去访问的,当把这些资源都放到同一个 CDN 服务上去时,网页是能正常使用的。 但需要注意的是由于 CDN 服务一般都会给资源开启很长时间的缓存,例如用户从 CDN 上获取到了 index.html
这个文件后, 即使之后的发布操作把 index.html
文件给重新覆盖了,但是用户在很长一段时间内还是运行的之前的版本,这会新的导致发布不能立即生效。
要避免以上问题,业界比较成熟的做法是这样的:
-
针对 HTML 文件:不开启缓存,把 HTML 放到自己的服务器上,而不是 CDN 服务上,同时关闭自己服务器上的缓存。自己的服务器只提供 HTML 文件和数据接口;
-
针对静态的 JavaScript、CSS、图片等文件:开启 CDN 和缓存,上传到 CDN 服务上去,同时给每个文件名带上由文件内容算出的 Hash 值, 例如上面的
app_a6976b6d.css
文件。 带上 Hash 值的原因是文件名会随着文件内容而变化,只要文件发生变化其对应的 URL 就会变化,它就会被重新下载,无论缓存时间有多长;
采用以上方案后,在 HTML 文件中的资源引入地址也需要换成 CDN 服务提供的地址,例如以上的 index.html
变为如下:
javascript
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="//cdn.com/id/app_a6976b6d.css">
</head>
<body>
<div id="app"></div>
<script src="//cdn.com/id/app_9d89c964.js"></script>
</body>
</html>
并且 app_a6976b6d.css
的内容也应该变为如下:
javascript
body{background:url(//cdn.com/id/arch_ae805d49.png) repeat}h1{color:red}
也就是说,之前的相对路径,都变成了绝对的指向 CDN 服务的 URL 地址。
如果你对形如 //cdn.com/id/app_a6976b6d.css
这样的 URL 感到陌生,你需要知道这种 URL 省掉了前面的 http: 或者 https: 前缀, 这样做的好处时在访问这些资源的时候会自动的根据当前 HTML 的 URL 是采用什么模式去决定是采用 HTTP 还是 HTTPS 模式。
除此之外,如果你还知道浏览器有一个规则是同一时刻针对同一个域名的资源并行请求是有限制的话(具体数字大概4个左右,不同浏览器可能不同), 你会发现上面的做法有个很大的问题。由于所有静态资源都放到了同一个 CDN 服务的域名下,也就是上面的 cdn.com
。 如果网页的资源很多,例如有很多图片,就会导致资源的加载被阻塞,因为同时只能加载几个,必须等其它资源加载完才能继续加载。 要解决这个问题,可以把这些静态资源分散到不同的 CDN 服务上去, 例如把 JavaScript 文件放到 js.cdn.com
域名下、把 CSS 文件放到 css.cdn.com
域名下、图片文件放到 img.cdn.com
域名下, 这样做之后 index.html 需要变成这样:
javascript
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="//css.cdn.com/id/app_a6976b6d.css">
</head>
<body>
<div id="app"></div>
<script src="//js.cdn.com/id/app_9d89c964.js"></script>
</body>
</html>
使用了多个域名后又会带来一个新问题:增加域名解析时间。是否采用多域名分散资源需要根据自己的需求去衡量得失。 当然你可以通过在 HTML HEAD 标签中 加入 <link rel="dns-prefetch" href="//js.cdn.com">
去预解析域名,以降低域名解析带来的延迟。
1.4.8.3 用 Webpack 实现 CDN 的接入
总结上面所说的,构建需要实现以下几点:
-
静态资源的导入 URL 需要变成指向 CDN 服务的绝对路径的 URL 而不是相对于 HTML 文件的 URL;
-
静态资源的文件名称需要带上有文件内容算出来的 Hash 值,以防止被缓存;
-
不同类型的资源放到不同域名的 CDN 服务上去,以防止资源的并行加载被阻塞;
先来看下要实现以上要求的最终 Webpack 配置:
javascript
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const {WebPlugin} = require('web-webpack-plugin');
module.exports = {
// 省略 entry 配置...
output: {
// 给输出的 JavaScript 文件名称加上 Hash 值
filename: '[name]_[chunkhash:8].js',
path: path.resolve(__dirname, './dist'),
// 指定存放 JavaScript 文件的 CDN 目录 URL
publicPath: '//js.cdn.com/id/',
},
module: {
rules: [
{
// 增加对 CSS 文件的支持
test: /\.css$/,
// 提取出 Chunk 中的 CSS 代码到单独的文件中
use: ExtractTextPlugin.extract({
// 压缩 CSS 代码
use: ['css-loader?minimize'],
// 指定存放 CSS 中导入的资源(例如图片)的 CDN 目录 URL
publicPath: '//img.cdn.com/id/'
}),
},
{
// 增加对 PNG 文件的支持
test: /\.png$/,
// 给输出的 PNG 文件名称加上 Hash 值
use: ['file-loader?name=[name]_[hash:8].[ext]'],
},
// 省略其它 Loader 配置...
]
},
plugins: [
// 使用 WebPlugin 自动生成 HTML
new WebPlugin({
// HTML 模版文件所在的文件路径
template: './template.html',
// 输出的 HTML 的文件名称
filename: 'index.html',
// 指定存放 CSS 文件的 CDN 目录 URL
stylePublicPath: '//css.cdn.com/id/',
}),
new ExtractTextPlugin({
// 给输出的 CSS 文件名称加上 Hash 值
filename: `[name]_[contenthash:8].css`,
}),
// 省略代码压缩插件配置...
],
};
1.4.9 tree shaking
1.4.9.1 认识 Tree Shaking
Tree Shaking
可以用来剔除 JavaScript 中用不上的死代码。它依赖静态的 ES6 模块化语法,例如通过 import 和 export 导入导出。 Tree Shaking 最先在 Rollup 中出现,Webpack 在 2.0 版本中将其引入。
为了更直观的理解它,来看一个具体的例子。假如有一个文件 util.js
里存放了很多工具函数和常量,在 main.js
中会导入和使用 util.js
,代码如下:
util.js
源码:
javascript
export function funcA() {
}
export function funB() {
}
export const a = 'a';
main.js
源码:
javascript
import {funcA} from './util.js';
funcA();
Tree Shaking 后的 util.js
:
javascript
export function funcA() {
}
由于只用到了 util.js
中的 funcA,所以剩下的都被 Tree Shaking
当作死代码给剔除了。
需要注意的是要让 Tree Shaking
正常工作的前提是交给 Webpack 的 JavaScript 代码必须是采用 ES6 模块化语法的, 因为 ES6 模块化语法是静态的(导入导出语句中的路径必须是静态的字符串,而且不能放入其它代码块中),这让 Webpack 可以简单的分析出哪些 export 的被 import 过了。 如果你采用 ES5 中的模块化,例如 module.export={...}
、require(x+y)
、if(x){require('./util')}
,Webpack 无法分析出哪些代码可以剔除。
目前的 Tree Shaking 还有些的局限性,经实验发现:
-
不会对entry入口文件做 Tree Shaking;
-
不会对按序加载出去的代码做 Tree Shaking;
1.4.9.2 接入 Tree Shaking
上面讲了 Tree Shaking
是做什么的,接下来一步步教你如何配置 Webpack 让 Tree Shaking 生效。
首先,为了把采用 ES6 模块化的代码交给 Webpack,需要配置 Babel 让其保留 ES6 模块化语句,修改 .babelrc
文件为如下:
javascript
{
"presets": [
[
"env",
{
"modules": false
}
]
]
}
其中 "modules": false
的含义是关闭 Babel 的模块转换功能,保留原本的 ES6 模块化语法。
配置好 Babel 后,重新运行 Webpack,在启动 Webpack 时带上 --display-used-exports
参数,以方便追踪 Tree Shaking
的工作, 这时你会发现在控制台中输出了如下的日志:
javascript
> webpack --display-used-exports
bundle.js 3.5 kB 0 [emitted] main
[0] ./main.js 41 bytes {0} [built]
[1] ./util.js 511 bytes {0} [built]
[only some exports used: funcA]
其中 [only some exports used: funcA]
提示了 util.js 只导出了用到的 funcA,说明 Webpack 确实正确的分析出了如何剔除死代码。
但当你打开 Webpack 输出的 bundle.js
文件看下时,你会发现用不上的代码还在里面,如下:
javascript
/* harmony export (immutable) /
__webpack_exports__["a"] = funcA;
/ unused harmony export funB */
function funcA() {
console.log('funcA');
}
function funB() {
console.log('funcB');
}
Webpack 只是指出了哪些函数用上了哪些没用上,要剔除用不上的代码还得经过 UglifyJS
去处理一遍。 要接入 UglifyJS 也很简单,不仅可以UglifyJSPlugin去实现, 也可以简单的通过在启动 Webpack 时带上 --optimize-minimize
参数,为了快速验证 Tree Shaking 我们采用较简单的后者来实验下。
通过 webpack --display-used-exports --optimize-minimize
重启 Webpack 后,打开新输出的 bundle.js,内容如下:
javascript
function r() {
console.log("funcA")
}
t.a = r
可以看出 Tree Shaking 确实做到了,用不上的代码都被剔除了。
当你的项目使用了大量第三方库时,你会发现 Tree Shaking
似乎不生效了,原因是大部分 Npm 中的代码都是采用的 CommonJS 语法, 这导致 Tree Shaking 无法正常工作而降级处理。 但幸运的时有些库考虑到了这点,这些库在发布到 Npm 上时会同时提供两份代码,一份采用 CommonJS 模块化语法,一份采用 ES6 模块化语法。 并且在 package.json 文件中分别指出这两份代码的入口。
以 redux 库为例,其发布到 Npm 上的目录结构为:
javascript
node_modules/redux
|-- es
| |-- index.js # 采用 ES6 模块化语法
|-- lib
| |-- index.js # 采用 ES5 模块化语法
|-- package.json
package.json
文件中有两个字段:
javascript
{
"main": "lib/index.js", // 指明采用 CommonJS 模块化的代码入口
"jsnext:main": "es/index.js" // 指明采用 ES6 模块化的代码入口
}
mainFields
用于配置采用哪个字段作为模块的入口描述。 为了让 Tree Shaking 对 redux 生效,需要配置 Webpack 的文件寻找规则为如下:
javascript
module.exports = {
resolve: {
// 针对 Npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
mainFields: ['jsnext:main', 'browser', 'main']
},
};
以上配置的含义是优先使用 jsnext:main
作为入口,如果不存在jsnext:main
就采用 browser 或者 main 作为入口。 虽然并不是每个 Npm 中的第三方模块都会提供 ES6 模块化语法的代码,但对于提供了的不能放过,能优化的就优化。
目前越来越多的 Npm 中的第三方模块考虑到了 Tree Shaking,并对其提供了支持。 采用 jsnext:main
作为 ES6 模块化代码的入口是社区的一个约定,假如将来你要发布一个库到 Npm 时,希望你能支持 Tree Shaking, 以让 Tree Shaking 发挥更大的优化效果。
1.4.10 提取公共代码
1.4.10.1 为什么需要提取公共代码
大型网站通常会由多个页面组成,每个页面都是一个独立的单页应用。 但由于所有页面都采用同样的技术栈,以及使用同一套样式代码,这导致这些页面之间有很多相同的代码。
如果每个页面的代码都把这些公共的部分包含进去,会造成以下问题:
-
相同的资源被重复的加载,浪费用户的流量和服务器的成本;
-
每个页面需要加载的资源太大,导致网页首屏加载缓慢,影响用户体验;
如果把多个页面公共的代码抽离成单独的文件,就能优化以上问题。 原因是假如用户访问了网站的其中一个网页,那么访问这个网站下的其它网页的概率将非常大。 在用户第一次访问后,这些页面公共代码的文件已经被浏览器缓存起来,在用户切换到其它页面时,存放公共代码的文件就不会再重新加载,而是直接从缓存中获取。 这样做后有如下好处:
-
减少网络传输流量,降低服务器成本;
-
虽然用户第一次打开网站的速度得不到优化,但之后访问其它页面的速度将大大提升;
1.4.10.2 如何提取公共代码
你已经知道了提取公共代码会有什么好处,但是在实战中具体要怎么做,以达到效果最优呢? 通常你可以采用以下原则去为你的网站提取公共代码:
-
根据你网站所使用的技术栈,找出网站所有页面都需要用到的基础库,以采用 React 技术栈的网站为例,所有页面都会依赖 react、react-dom 等库,把它们提取到一个单独的文件。 一般把这个文件叫做
base.js
,因为它包含所有网页的基础运行环境; -
在剔除了各个页面中被
base.js
包含的部分代码外,再找出所有页面都依赖的公共部分的代码提取出来放到common.js
中去; -
再为每个网页都生成一个单独的文件,这个文件中不再包含
base.js
和common.js
中包含的部分,而只包含各个页面单独需要的部分代码;
文件之间的结构图如下:
既然能找出所有页面都依赖的公共代码,并提取出来放到 common.js
中去,为什么还需要再把网站所有页面都需要用到的基础库提取到 base.js
去呢? 原因是为了长期的缓存 base.js
这个文件。
发布到线上的文件都会采用CDN的方法,对静态文件的文件名都附加根据文件内容计算出 Hash 值,也就是最终 base.js
的文件名会变成 base_3b1682ac.js
,以长期缓存文件。 网站通常会不断的更新发布,每次发布都会导致 common.js
和各个网页的 JavaScript 文件都会因为文件内容发生变化而导致其 Hash 值被更新,也就是缓存被更新。
把所有页面都需要用到的基础库提取到 base.js
的好处在于只要不升级基础库的版本,base.js 的文件内容就不会变化,Hash 值不会被更新,缓存就不会被更新。 每次发布浏览器都会使用被缓存的 base.js
文件,而不用去重新下载 base.js
文件。 由于 base.js
通常会很大,这对提升网页加速速度能起到很大的效果。
1.4.10.3 如何通过 Webpack 提取公共代码
Webpack 内置了专门用于提取多个 Chunk 中公共部分的插件 CommonsChunkPlugin
,CommonsChunkPlugin
大致使用方法如下:
javascript
const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
new CommonsChunkPlugin({
// 从哪些 Chunk 中提取
chunks: ['a', 'b'],
// 提取出的公共部分形成一个新的 Chunk,这个新 Chunk 的名称
name: 'common'
})
以上配置就能从网页 A 和网页 B 中抽离出公共部分,放到 common 中。
每个 CommonsChunkPlugin
实例都会生成一个新的 Chunk,这个新 Chunk 中包含了被提取出的代码,在使用过程中必须指定 name 属性,以告诉插件新生成的 Chunk 的名称。 其中 chunks 属性指明从哪些已有的 Chunk 中提取,如果不填该属性,则默认会从所有已知的 Chunk 中提取。
Chunk 是一系列文件的集合,一个 Chunk 中会包含这个 Chunk 的入口文件和入口文件依赖的文件。
通过以上配置输出的 common Chunk
中会包含所有页面都依赖的基础运行库 react、react-dom,为了把基础运行库从 common 中抽离到 base 中去,还需要做一些处理。
首先需要先配置一个 Chunk,这个 Chunk 中只依赖所有页面都依赖的基础库以及所有页面都使用的样式,为此需要在项目中写一个文件 base.js
来描述 base Chunk
所依赖的模块,文件内容如下:
javascript
// 所有页面都依赖的基础库
import 'react';
import 'react-dom';
// 所有页面都使用的样式
import './base.css';
接着再修改 Webpack 配置,在 entry 中加入 base,相关修改如下:
javascript
module.exports = {
entry: {
base: './base.js'
},
};
以上就完成了对新 Chunk base 的配置。
为了从 common 中提取出 base 也包含的部分,还需要配置一个 CommonsChunkPlugin
,相关代码如下:
javascript
new CommonsChunkPlugin({
// 从 common 和 base 两个现成的 Chunk 中提取公共的部分
chunks: ['common', 'base'],
// 把公共的部分放到 base 中
name: 'base'
})
由于 common 和 base 公共的部分就是 base 目前已经包含的部分,所以这样配置后 common 将会变小,而 base 将保持不变。
以上都配置好后重新执行构建,你将会得到四个文件,它们分别是:
-
base.js:所有网页都依赖的基础库组成的代码;
-
common.js:网页A、B都需要的,但又不在 base.js 文件中出现过的代码;
-
a.js:网页 A 单独需要的代码;
-
b.js:网页 B 单独需要的代码;
为了让网页正常运行,以网页 A 为例,你需要在其 HTML 中按照以下顺序引入以下文件才能让网页正常运行:
javascript
<script src="base.js"></script>
<script src="common.js"></script>
<script src="a.js"></script>
以上就完成了提取公共代码需要的所有步骤。
针对 CSS 资源,以上理论和方法同样有效,也就是说你也可以对 CSS 文件做同样的优化。
以上方法可能会出现 common.js 中没有代码的情况,原因是去掉基础运行库外很难再找到所有页面都会用上的模块。 在出现这种情况时,你可以采取以下做法之一:
-
CommonsChunkPlugin
提供一个选项minChunks
,表示文件要被提取出来时需要在指定的 Chunks 中最小出现最小次数。 假如minChunks=2、chunks=['a','b','c','d']
,任何一个文件只要在 ['a','b','c','d'] 中任意两个以上的 Chunk 中都出现过,这个文件就会被提取出来。 你可以根据自己的需求去调整minChunks
的值,minChunks
越小越多的文件会被提取到common.js
中去,但这也会导致部分页面加载的不相关的资源越多;minChunks
越大越少的文件会被提取到common.js
中去,但这会导致common.js
变小、效果变弱; -
根据各个页面之间的相关性选取其中的部分页面用
CommonsChunkPlugin
去提取这部分被选出的页面的公共部分,而不是提取所有页面的公共部分,而且这样的操作可以叠加多次。 这样做的效果会很好,但缺点是配置复杂,你需要根据页面之间的关系去思考如何配置,该方法不通用;
1.4.11 分割代码按需加载
1.4.11.1 为什么需要按需加载
随着互联网的发展,一个网页需要承载的功能越来越多。 对于采用单页应用作为前端架构的网站来说,会面临着一个网页需要加载的代码量很大的问题,因为许多功能都集中的做到了一个 HTML 里。 这会导致网页加载缓慢、交互卡顿,用户体验将非常糟糕。
导致这个问题的根本原因在于一次性的加载所有功能对应的代码,但其实用户每一阶段只可能使用其中一部分功能。 所以解决以上问题的方法就是用户当前需要用什么功能就只加载这个功能对应的代码,也就是所谓的按需加载。
1.4.11.2 如何使用按需加载
在给单页应用做按需加载优化时,一般采用以下原则:
-
把整个网站划分成一个个小功能,再按照每个功能的相关程度把它们分成几类;
-
把每一类合并为一个 Chunk,按需加载对应的 Chunk;
-
对于用户首次打开你的网站时需要看到的画面所对应的功能,不要对它们做按需加载,而是放到执行入口所在的 Chunk 中,以降低用户能感知的网页加载时间;
-
对于个别依赖大量代码的功能点,例如依赖 Chart.js 去画图表、依赖 flv.js 去播放视频的功能点,可再对其进行按需加载;
被分割出去的代码的加载需要一定的时机去触发,也就是当用户操作到了或者即将操作到对应的功能时再去加载对应的代码。 被分割出去的代码的加载时机需要开发者自己去根据网页的需求去衡量和确定。
由于被分割出去进行按需加载的代码在加载的过程中也需要耗时,你可以预言用户接下来可能会进行的操作,并提前加载好对应的代码,从而让用户感知不到网络加载时间。
1.4.11.3 用 Webpack 实现按需加载
Webpack 内置了强大的分割代码的功能去实现按需加载,实现起来非常简单。
举个例子,现在需要做这样一个进行了按需加载优化的网页:
-
网页首次加载时只加载
main.js
文件,网页会展示一个按钮,main.js
文件中只包含监听按钮事件和加载按需加载的代码。 -
当按钮被点击时才去加载被分割出去的
show.js
文件,加载成功后再执行show.js
里的函数。
其中 main.js
文件内容如下:
javascript
window.document.getElementById('btn').addEventListener('click', function () {
// 当按钮被点击后才去加载 show.js 文件,文件加载成功后执行文件导出的函数
import(/* webpackChunkName: "show" */ './show').then((show) => {
show('Webpack');
})
});
show.js
文件内容如下:
javascript
module.exports = function (content) {
window.alert('Hello ' + content);
};
代码中最关键的一句是 import(/* webpackChunkName: "show" */ './show')
,Webpack 内置了对 import(*)
语句的支持,当 Webpack 遇到了类似的语句时会这样处理:
-
以
./show.js
为入口新生成一个 Chunk; -
当代码执行到 import 所在语句时才会去加载由 Chunk 对应生成的文件;
-
import 返回一个 Promise,当文件加载成功时可以在 Promise 的 then 方法中获取到 show.js 导出的内容;
在使用 import() 分割代码后,你的浏览器并且要支持 Promise API 才能让代码正常运行, 因为 import() 返回一个 Promise,它依赖 Promise。对于不原生支持 Promise 的浏览器,你可以注入 Promise polyfill。
/* webpackChunkName: "show"
/
的含义是为动态生成的 Chunk 赋予一个名称,以方便我们追踪和调试代码。 如果不指定动态生成的 Chunk 的名称,默认名称将会是 [id].js。 /
webpackChunkName: "show" */
是在 Webpack3 中引入的新特性,在 Webpack3 之前是无法为动态生成的 Chunk 赋予名称的。
为了正确的输出在 /* webpackChunkName: "show" */
中配置的 ChunkName,还需要配置下 Webpack,配置如下:
javascript
module.exports = {
// JS 执行入口文件
entry: {
main: './main.js',
},
output: {
// 为从 entry 中配置生成的 Chunk 配置输出文件的名称
filename: '[name].js',
// 为动态加载的 Chunk 配置输出文件的名称
chunkFilename: '[name].js',
}
};
其中最关键的一行是 chunkFilename: '[name].js'
,它专门指定动态生成的 Chunk 在输出时的文件名称。 如果没有这行,分割出的代码的文件名称将会是 [id].js。
1.4.12 开启 Scope Hoisting
Scope Hoisting
可以让 Webpack 打包出来的代码文件更小、运行的更快, 它又译作 "作用域提升",是在 Webpack3 中新推出的功能。
1.4.12.1 认识 Scope Hoisting
让我们先来看看在没有 Scope Hoisting
之前 Webpack 的打包方式。
假如现在有两个文件分别是 util.js
:
javascript
export default 'Hello,Webpack';
和入口文件 main.js
:
javascript
import str from './util.js';
console.log(str);
以上源码用 Webpack 打包后输出中的部分代码如下:
javascript
[
(function (module, __webpack_exports__, __webpack_require__) {
var __WEBPACK_IMPORTED_MODULE_0__util_js__ = __webpack_require__(1);
console.log(__WEBPACK_IMPORTED_MODULE_0__util_js__["a"]);
}),
(function (module, __webpack_exports__, __webpack_require__) {
__webpack_exports__["a"] = ('Hello,Webpack');
})
]
在开启 Scope Hoisting 后,同样的源码输出的部分代码如下:
javascript
[
(function (module, __webpack_exports__, __webpack_require__) {
var util = ('Hello,Webpack');
console.log(util);
})
]
从中可以看出开启 Scope Hoisting
后,函数申明由两个变成了一个,util.js
中定义的内容被直接注入到了 main.js
对应的模块中。 这样做的好处是:
-
代码体积更小,因为函数申明语句会产生大量代码;
-
代码在运行时因为创建的函数作用域更少了,内存开销也随之变小;
Scope Hoisting
的实现原理其实很简单:分析出模块之间的依赖关系,尽可能的把打散的模块合并到一个函数中去,但前提是不能造成代码冗余。 因此只有那些被引用了一次的模块才能被合并。
由于 Scope Hoisting
需要分析出模块之间的依赖关系,因此源码必须采用 ES6 模块化语句,不然它将无法生效。 原因和Tree shaking
类似。
1.4.12.2 使用 Scope Hoisting
要在 Webpack 中使用 Scope Hoisting
非常简单,因为这是 Webpack 内置的功能,只需要配置一个插件,相关代码如下:
javascript
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
module.exports = {
plugins: [
// 开启 Scope Hoisting
new ModuleConcatenationPlugin(),
],
};
同时,考虑到 Scope Hoisting 依赖源码需采用 ES6 模块化语法,还需要配置 mainFields
。
javascript
module.exports = {
resolve: {
// 针对 Npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
mainFields: ['jsnext:main', 'browser', 'main']
},
};
对于采用了非 ES6 模块化语法的代码,Webpack 会降级处理不使用 Scope Hoisting 优化,为了知道 Webpack 对哪些代码做了降级处理, 你可以在启动 Webpack 时带上 --display-optimization-bailout
参数,这样在输出日志中就会包含类似如下的日志:
javascript
[0] ./main.js + 1 modules 80 bytes {0} [built]
ModuleConcatenation bailout: Module is not an ECMAScript module
其中的 ModuleConcatenation bailout
告诉了你哪个文件因为什么原因导致了降级处理。
也就是说要开启 Scope Hoisting 并发挥最大作用的配置如下:
javascript
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
module.exports = {
resolve: {
// 针对 Npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
mainFields: ['jsnext:main', 'browser', 'main']
},
plugins: [
// 开启 Scope Hoisting
new ModuleConcatenationPlugin(),
],
};
1.4.13 输出分析
我们需要对输出结果做分析,以决定下一步的优化方向。
最直接的分析方法就是去阅读 Webpack 输出的代码,但由于 Webpack 输出的代码可读性非常差而且文件非常大,这会让你非常头疼。 为了更简单直观的分析输出结果,社区中出现了许多可视化的分析工具。这些工具以图形的方式把结果更加直观的展示出来,让你快速看到问题所在。 接下来教你如何使用这些工具。
在启动 Webpack 时,支持两个参数,分别是:
-
--profile:记录下构建过程中的耗时信息;
-
--json:以 JSON 的格式输出构建结果,最后只输出一个 .json 文件,这个文件中包括所有构建相关的信息;
在启动 Webpack 时带上以上两个参数,启动命令如下 webpack --profile --json > stats.json
,你会发现项目中多出了一个 stats.json
文件。 这个 stats.json 文件是给后面介绍的可视化分析工具使用的。
webpack --profile --json
会输出字符串形式的 JSON,> stats.json
是 UNIX/Linux 系统中的管道命令、含义是把 webpack --profile --json
输出的内容通过管道输出到 stats.json 文件中。
1.4.13.1 官方的可视化分析工具
Webpack 官方提供了一个可视化分析工具 Webpack Analyse,它是一个在线 Web 应用。
打开 Webpack Analyse 链接的网页后,你就会看到一个弹窗提示你上传 JSON 文件,也就是需要上传上面讲到的 stats.json 文件,如图:
Webpack Analyse 不会把你选择的 stats.json 文件发达到服务器,而是在浏览器本地解析,你不用担心自己的代码为此而泄露。 选择文件后,你马上就能如下的效果图:
它分为了六大板块,分别是:
-
Modules:展示所有的模块,每个模块对应一个文件。并且还包含所有模块之间的依赖关系图、模块路径、模块ID、模块所属 Chunk、模块大小;
-
Chunks:展示所有的代码块,一个代码块中包含多个模块。并且还包含代码块的ID、名称、大小、每个代码块包含的模块数量,以及代码块之间的依赖关系图;
-
Assets:展示所有输出的文件资源,包括 .js、.css、图片等。并且还包括文件名称、大小、该文件来自哪个代码块;
-
Warnings:展示构建过程中出现的所有警告信息;
-
Errors:展示构建过程中出现的所有错误信息;
-
Hints:展示处理每个模块的过程中的耗时。