从零搭建webpack前端类库脚手架[2]-常用loader和插件

webpack的配置有不少,比如除了上一节讲过的module,entry,output,resolve等,还有 resolveLoader, devtool, targets, devServer, externals, performance等. 而在webpack编译过程中,最常需要做自定义配置的就是loader和plugin,也就是module配置部分。本节我们重点讲解webpack中常用loader和plugin的使用

plugins介绍

插件用来解决loader完成不了的事情, 所以当一件事情你觉得webpack无法完成时,就可以考虑搜一下有没有相应的插件了。loader和plugin之间的关系大概如图:

一般认为, loaders是在打包构建过程中用来处理源文件的(JSX,Scss,Less..),一次处理一个,插件并不直接操作单个文件,它直接对整个构建过程其作用,所以插件的作用贯穿于整个打包的过程当中。

plugin和loader都不属于webpack的核心,用到的插件和loader都需要额外安装。

plugin配置在webpack配置的plugins属性下,且需要创建为实例:

1
2
3
4
5
const HtmlWebpackPlugin = require('html-webpack-plugin'); //installed via npm
plugins: [
new webpack.optimize.UglifyJsPlugin(),
new HtmlWebpackPlugin({template: './src/index.html'})
]

由于需要在一个配置中,多次使用一个插件,来针对不同的目的,因此你需要使用 new 来创建插件的实例,并且通过实例来调用插件. 由上面配置可见webpack其实内置了一些可用的插件,如optimize.UglifyJsPlugin. 这个插件可以在webpack打包后将bundle进行压缩再输出,所以你看到的最终结果是被压缩了的。 而HTMLWebpackPlugin可以在webpack打包完之后,基于一个html模板生成一份index.html放到bundle同样的输出目录,并且将bundle.js注入到index.html中。

注: webpack4 之后不再使用 UglifyJsPlugin 来压缩代码。为了减少配置复杂性,webpack4 内置了一个新的 mode 配置字段,可以通过传递 development 或 production 字符串来决定是否压缩

几个常用插件

index.html的处理

很多时候,你的项目并不是在开发一个bundle.js。你希望最终的输出结果是一个html5 webapp, 那这个应用的项目中肯定需要有index.html,且index.html中要引入bundle.js,且最终的dist目录下应该需要有index.html且要有bundle.js,且index.html中需要引用正确的bundle.js的路径。 这个场景就需要通过plugin机制实现了,因为有一个跟模块依赖无关的东西-index.html存在,且要注入到index.html里bundle.js的引用,所以index.html并不属于webpack打包的范畴,而是一个额外的东西。

使用 html-webpack-plugin 可以解决这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var HtmlWebpackPlugin = require('html-webpack-plugin')
var path = require('path')

module.exports = {
entry: __dirname + '/src/index.js', // webpack加载入库
output: {
path: path.resolve(__dirname, 'dist'), // webpack输出目录
filename: 'app.js', // 编译后文件名
},
devtool: 'source-map', // 开启sourcemap
devServer: {
contentBase: '/', // 托管目录. 在publicPath未设置的情况下, webpack的bundle结果会创建在devServer域名根目录
historyApiFallback: false, // h5的history模式,可以任何链接都返回index.html
inline: true, // 实时刷新
port: 9090
},
plugins: [
// 实例化一个html-webpack-plugin 插件,配置他为针对src/index.html进行操作
new HtmlWebpackPlugin({
title: 'html5app-template',
template: path.resolve(__dirname, './src/index.html')
})
]
}

其中 ./src/index.html 其实是一个模板,设置这个模板可以是ejs等模板,HTMLWebpackPlugin也是支持的。另外有个单独的template项目,提供了一个配合html-webpack-plugin的很好的模板–html-webpack-template

css的处理

开发一个webapp,必然也要写css、或者stylus、less等。那么必然要将你写的css打包为目标文件,并引入到index.html中。 这个处理过程可以用两个loader来实现, css-loader用来将css代码转换为JavaScript代码,这个在编译后的app.js/bundle.js里可以发现端倪(成为了一个js模块,css代码成为js字符串)。 而style-loader是将css-loader转换后的js模块解析并弄成css片段再塞到dom上,这个塞css到dom的行为也被封装为js模块一起打包到了bundle.js/app.js里。

配置如下:

1
2
3
4
5
6
7
8
9
10
11
var 
{
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
}
}

use是从后往前执行,所以css-loader放在后面。 其编译过程大概是这样的: 首先webpack发现你代码中依赖了一个.css扩展名的文件模块,然后webpack调用css-loader将其处理为js模块,这些css最终在bundle.js里确实是一个个的cssjs模块。 然后经过style-loader转换,webpack形成了一个特殊js模块,这个特殊js模块会依赖所有经过css-loader转换的那些cssjs模块,并把他们塞到dom里,这样webpack最终编译后,执行的其实是style-loader搞出来的那个模块。

所以,采用css-loader和style-loader之后,css已然变成了js,页面加载进来,等js执行了页面中才会塞入css,你才会看到页面样式。

但是上述方法,css变成了js,放到了js bundle里,显然不符合前端的样式与逻辑分离的习俗。 我们希望webpack能够把bundle.js里的css部分抽离出来,弄成一个单独的css文件,因为css跟js不应当融合在一块。此时,可以利用ExractTextPlugin来完成这个事情。

1
2
3
4
npm install --save-dev extract-text-webpack-plugin
plugins: [
new ExtractTextPlugin('style.css'),
],

这样,在webpack编译时,这个插件会把css的js模块从此webpack依赖图中挪掉,单独输出为css文本文件。而webpack打包的js中由于没有css相关的模块了,所以打包出来的内容自然也没有cssjs模块了。

变量替换的处理

有时候你的代码中某些常量是要变化的,比如某个请求url要根据当前开发还是联调环境来从localhost的mock地址转换为联调地址。
所以有些变量你希望能根据webpack配置中来变化,或者甚至是根据NODE_ENV环境变量来变化。 利用webpack其实可以做到这一点,

第一种方法以前我见过,就是在webpack配置文件中利用node代码判断来替换resolve.alias的配置,这样代码中引用了alias的地方自然就引用了不同的文件。 比如本来 require(‘env’) 表示的是 ./src/abc.js, 你给换掉之后, require(‘env’) 就相当于引用 ‘./src/def.js’ 了,你在abc.js和def.js分别定义了不同的内容,自然就可以实现环境切换了。
实际上这个方法仅仅利用了webpack的 “模块别名” 这个配置功能, 当做改变环境的一种办法。 实际上是个奇技淫巧。 而正统的做法应该是像Vue源码一样,使用DefinePlugin这个插件

第二种方法是利用webpack.DefinePlugin插件,他可以在编译模块时自动替换掉业务模块中的一些变量。

1
2
3
4
5
6
plugins: [
new webpack.DefinePlugin({
VERSION: JSON.stringify('5fa4bg'),
BROWSER_SUPPORT_HTML5: true
})
]

基于此,我们可以做很多事情,比如在webpack配置中读到package.json中的当前项目版本输入到目标js文件里,读到当前的日期输入到目标js文件里等等,或者基于不同的环境(NODE_ENV)引用不同的联调地址url。 或者像vue里面,直接在业务代码中使用 process.env.NODE_ENV 作为判断, 然后利用插件替换掉这个变量到时候自然而然就改变了最终的代码逻辑。

自动打开浏览器预览的处理

关于如何使用 webpack-dev-server 来启动本地server预览,在本节的后面会讲解。

通过webpack-dev-server我们可以实现本地启动server来预览编译结果。 但是我们又希望webpack-dev-server能够在编译完webpack之后自动打开浏览器。因为webpack-dev-server做的事情仅仅是利用webpack编译,编译之后再启动一个server,他不会自动打开浏览器。

而webpack-browser-plugin就可以做到,我们只需要配置好这个插件,webpack-dev-server编译完成启动server后,他就会自动打开你的webpack中devServer配置的地址和端口。

1
2
3
plugins: [
new WebpackBrowserPlugin()
]

然而,我发现,如果我们直接用webpack-dev-server命令来启动server的话,其实webpack-dev-server本身就支持自动打开浏览器,我更推荐使用这种方式:

1
2
3
4
5
{
"scripts": {
"start": "webpack-dev-server --open"
}
}

希望用全局变量的方式引用其他模块

基于webpack写代码后,你的代码是模块,你想用jquery就必须用commonjs的语法引入jquery模块。而你又希望像以前一样想用jquery就直接$. 此时可以让webpack自动帮你把jquery注入到模块里,这时可以采用webpack.ProvidePlugin插件,他可以自动给你的模块中引入其他模块,并且你可以直接使用,不需要自己手工引入。(但其本质上还是模块化的,jquery还是作为一个模块存在的)。

1
2
3
4
5
6
plugins: [
new webpack.ProvidePlugin({
$: 'jquery'
})
]


其原理是由webpack给你的模块中注入了require()语法,从而让你的代码中出现了$变量。我去看了下webpack编译后的代码,原理如图:

webpack模块化了,还希望使用CDN引入库

有些项目中的情况恰恰相反,是jquery已经作为一个 <script> 标签引入到页面了,而你的webpack模块化代码中想使用jquery,此时你不应该在webpack中重新require一个jquery,因为将来页面运行时,全局已经有了jquery,你在webpack模块化代码中require,会导致webpack打包出来的bundle里也有个jquery。 此时,就应该利用webpack的external配置项,将jquery声明为一个宿主环境已经存在的东西,也就是不让webpack再去寻找这个依赖编译到bundle中。

jquery变成external配置之后呢,这样你还是可以在代码中 require('jquery'), 只是webpack会创造一个假的jquery模块,这个假的模块仅仅对全局的jQuery对象进行包装一下。

这个场景其实比较常见,比如有时候我们希望某些模块走CDN并以 <script> 的形式挂载到页面上来加载(这可以充分利用浏览器缓存来缓存很多第三方库),但又希望能在 webpack 的模块中使用上。那我们就在配置文件里使用 externals 属性来配置一下吧:

1
2
3
4
5
6
7
{
externals: {
// require("jquery") 是引用自外部模块的
// 对应全局变量 jQuery
"jquery": "jQuery"
}
}

还有一种靠第三方库来异步加载的方式,这跟webpack应该没关系了:

1
2
3
4
var $script = require("scriptjs");
$script("//ajax.googleapis.com/ajax/libs/jquery/2.0.0/jquery.min.js", function() {
$('body').html('It works!')
});

这种跟上面直接写script的区别是: 这种属于运行时动态加载cdn资源。

利用 CommonChunkPlugin 实现库和业务js的分离

业务代码是经常变更的,而依赖的moment等库代码是不经常变更的。让他们分离输出,可以让库单独打包为vendor.js,充分利用浏览器缓存。
要想让库作为单独bundle打包,其实只需要改entry为多个chunk即可。

1
2
3
4
entry: {
app: path.resolve(__dirname, './src/index.js'), // webpack加载入口
vendor: ['moment', 'lodash'] // 第三方库的入口声明,CommonChunkPlugin会把它们打包到vendor.js
},

然而,这种分离是完全独立的,vendor和app打包出来之后,每个js里都有个webpack的runtime,甚至如果各自都依赖了lodash,也会各自打包自己的lodash模块。我们希望多个entry能够把公共的东西抽离出来成为一个common.js,那么就要用到CommonChunkPlugin(上一篇中其实已经讲到了)

1
2
3
new webpack.optimize.CommonsChunkPlugin({
name: 'common'
})

这样的话,起码多个entry的公共的webpack runtime会抽离出来成为common.js。如果有共同引用某个模块(例如可能index.js和moment.js以及lodash都依赖了c.js),那么这个公共的模块c.js也会抽到common.js中; 当然在目前这个场景下app和vendor之间是依赖与被依赖的关系,不会存在其他公共模块了。

但是,我们希望这些不可能发生变更的类库如moment和lodash,跟webpack的runtime,都放在一起。因为这3个东西都是几乎不发生变化的代码。要实现这个,可以这样:

1
2
3
4
5
6
7
8
9
entry: {
app: path.resolve(__dirname, './src/index.js'), // webpack加载入口
vendor: ['moment', 'lodash'] // 第三方库的入口声明,CommonChunkPlugin会把它们打包到vendor.js
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor'
})
]

也就是说,指定抽取出来的公共模块的common文件名为vendor.js. 由于webpack这个配置默认就会打包出2个js: app和vendor。而commonChunkPlugin插件再抽离两个bundle中的公共部分-webpackRuntime,把这个公共部分放置到vendor.js里面。最终vendor.js里面就相当于有了这三个内容—webpackRuntime、lodash、moment。

该插件会先检查两个bundle(app和vendoer所共用的模块–webpack的运行时代码),把这个公共模块(运行时)抽离出来。然后由于该插件指定抽离出的公共模块为 vendor, 因此 插件会再把公共部分合入到 vendor 这个bundle里。结果就是 vendor 这个包里会包含 “vendor入口自身所有的模块+vendor与app两个的公共模块”

打包后,lodash和moment 就被打包到了vendor.js里,包括webpack的runtime。而app.js里只有业务模块代码。不过有个问题是,每次编译还会导致 vender.js 的hash值发生变化,此时需要用到mainfest技术,暂且不表。

本插件的具体操作有点复杂,我上面讲的也不一定完全正确,具体研究可参考: http://www.css88.com/doc/webpack2/plugins/commons-chunk-plugin

常用loader

老版本的类库

对于上个时代的一些js库,他们在编写时根本没有考虑作为commonjs模块给其他模块调用,例如angularjs1.4以下的版本。因此他们的实现可能是这样的:

1
2
3
4
(function (window, document, undefined) {
var Angular = function() {}
window.angular = Angular
})(window,document)

或者这样的:

1
2
var Angular = function() {}
window.angular = Angular

这时如果你直接 require 这个模块,则会发现获得的导出是 undefined。在 AMD/CMD 中,我们需要对不符合规范的模块(比如一些直接返回全局变量的插件)进行 shim 处理. 在webpack中也是需要做处理,webpack 可以使用 exports-loader 来加载这个模块,那么编译时 loader 会自动修改该模块添加上导出语句. 这个loader是用来将(模块中的)某些内容导出的。之所以为“模块中的”加上括号,是因为实际上只要在模块中能被访问到的成员(变量)都可以被导出,当然也包括全局变量。

webpack配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module.exports = { 
mode: 'development',
devtool: 'none',
entry: {
main: './index.js'
},
module: {
rules: [
{
test: /angular\.js$/,
use: [
{
loader: 'exports-loader?Angular'
}
]
}
]
}
}

index.js 调用 angular.js 的代码如下:

1
2
3
4
5
6
7
8
9
10
11
// index.js
(function (window, document) {
var Angular = window.Angular = function () {}
Angular.version = '1.0'
})(window, document)

// angular.js
(function (window, document) {
var Angular = window.Angular = function () {}
Angular.version = '1.0'
})(window, document)

编译后:

1
2
3
4
5
6
7
8
// angular.js
(function (window, document) {
var Angular = window.Angular = function () {}
Angular.version = '1.0'
})(window, document)

/*** EXPORTS FROM exports-loader ***/
module.exports = Angular; // 看到这里增加了一个module.exports导出

由此可见, export-loader 主要用于解决老的代码库没有使用模块化导出的问题

imports-loader

这个 loade 是用来解决某些老的代码库里依赖全局的一些变量的问题。我们知道: 在使用了webpack的开发模式之后,一般全局没有变量而是使用模块化加载的方式来互相引用模块。在这种开发场景下,如果我们开发的一个模块依赖jquery(假设jquery已经UMD方式打包),我们一般是这样:

1
2
3
// index.js
const $ = require('jquery')
$('.xxx').attr()

在这种开发模式下,全局空间window上是不可能有$这个变量的。全部被webpack的runtime以模块化的方式维护。这时问题就出现了,一些老的代码库(例如网上某些jquery插件)你直接拿过来作为一个commonjs模块来用他就运行不起来。我们假设上面的 angular 这个例子 Angular 是依赖全局的 $ 变量的。而我们项目中 $ 已经变成一个模块。项目代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// index.js
(function (window, document) {
var Angular = window.Angular = function () {}
Angular.version = '1.0'
})(window, document)

// angular.js
(function (window, document) {
var Angular = window.Angular = function () {
console.log($.version) // 这里调用了全局的 $
}
Angular.version = '1.0'
})(window, document)

// mockJquery.js
module.exports = {
version: 456
}

这时,我们使用 imports-loader 来解决 Angular.js 文件中的代码问题; webpack配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = { 
mode: 'development',
devtool: 'none',
entry: {
main: './index.js'
},
module: {
rules: [
{
test: require.resolve('./angular.js'),
use: [
{
loader: 'exports-loader?Angular'
},
{
loader: 'imports-loader?$=./mockJquery.js' // 把 $ 符号注入到引用的 angular.js 中; 注意这里等号后面要写完整一个模块的引用方式。
}
]
}
]
}
}

编译结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
/*** IMPORTS FROM imports-loader; 看这里 $ 符号被写成了 require 模块的方式 ***/
var $ = __webpack_require__(/*! ./mockJquery.js */ "./mockJquery.js");

(function (window, document) {
var Angular = window.Angular = function () {
console.log($.version)
}
Angular.version = '1.0'
})(window, document)

/*** EXPORTS FROM exports-loader ***/
module.exports = Angular;

file-loader加载资源文件

上文讲到了css的处理。然而我们css中引用的背景图片和字体如何处理呢。用file-loader即可,他可以把你css中类似url('./icon.png') 这样的代码去解析加载对应的图片,并放置到dist目录下。然后自动修改css中的url引用为正确的引用。使用css-loader时也会发生如上的过程。

1
2
3
4
5
6
+       {
+ test: /\.(png|svg|jpg|gif)$/,
+ use: [
+ 'file-loader'
+ ]
+ }

如果是在html中使用了 <img> 标签引用图片,则需要使用 html-loader 来进行处理。

file-loader 也可以应用于font字体的引用,同样的配置:

1
2
3
4
5
6
7
8
9
10
11
12
        {
test: /\.(png|svg|jpg|gif)$/,
use: [
'file-loader'
]
},
+ {
+ test: /\.(woff|woff2|eot|ttf|otf)$/,
+ use: [
+ 'file-loader'
+ ]
+ }

这样的话,当遇到 @font-face 中配置了字体url时,webpack就会做对应的处理。(过程跟image图片类似)

资源文件和业务逻辑可以采用另外一种方式进行分组。比如现在流行的组件化方案,把某个模块相关的资源放在一块,这样便于资源的迁移到其他项目。比如可以这样进行资源组织:

1
2
3
4
5
6
7
- |- /assets
+ |– /components
+ | |– /my-component
+ | | |– index.jsx
+ | | |– index.css
+ | | |– icon.svg
+ | | |– img.png

多个组件共享的资源可以放到root根目录下的assets目录下。

加载数据文件

webpack还可以加载json,csv,xml,tsv等。json是内置支持的,其他的需要安装对应的loader。

1
npm install --save-dev csv-loader xml-loader

1
2
3
4
5
6
7
8
9
10
11
12
+       {
+ test: /\.(csv|tsv)$/,
+ use: [
+ 'csv-loader'
+ ]
+ },
+ {
+ test: /\.xml$/,
+ use: [
+ 'xml-loader'
+ ]
+ }

项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  webpack-demo
|- package.json
|- webpack.config.js
|- /dist
|- bundle.js
|- index.html
|- /src
+ |- data.xml
|- my-font.woff
|- my-font.woff2
|- icon.png
|- style.css
|- index.js
|- /node_modules

然后在代码中使用时,只需要:

1
2
+ import Data from './data.xml';
+ import Data from './data.json'

这种功能非常适合用d3等进行数据可视化的时候,你如果本地就有数据,就不需要在运行时进行ajax加载或者解析数据。通过webpack这个特性,只需要在编译期间把数据加载到module中,这样数据就已经被提前解析完成直接在模块中保存了。浏览器中运行时就可以直接使用而无需再解析。

url-loader

可以让css中引用的图片或字体在小于某个阈值的时候自动转为base64编码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
rules: [
{
test:/\.(jpg|png|gif)$/,
use:{
loader:'url-loader',
options: {
limit: 8192
}
}
},
{
test:/\.(woff|woff2|eot|ttf|svg)$/,
use:{
loader:'url-loader',
options: {
limit: 100000
}
}
},
]

babel-loader

写最新的代码,让webpack帮你编译为ES5.

1
2
3
4
5
{
test: /\.js$/,
exclude:/node_modules/,
use: 'babel-loader'
}

你可以在use中对bable-loader进行配置:
1
2
3
4
5
6
7
8
9
10
11
12
{
test: /\.js$/,
exclude:/node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['es2015']
}
}
]
}

对于options选项,也可以不写在webpack配置里,而是单独用 .babelrc 文件的方式,在里面配置如下:

1
2
3
{
"presets": ["es2015"]
}

.babelrc 的配置就属于babel知识点的范畴了,下一讲我会专门写babel的文章。

与gulp结合

webpack是一个打包工具,而gulp可以当做前端工作流任务的定义工具。所以他俩是可以分工协作的,即把webpack打包当成gulp流程中的一个任务。例如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
gulp.task("webpack", function(callback) {
// run webpack
webpack({
// configuration
}, function(err, stats) {
if(err) throw new gutil.PluginError("webpack", err);
gutil.log("[webpack]", stats.toString({
// output options
}));
callback();
});
});

总结

这一节我们学习了webpack常见的插件和loader配置。下一节我们学习下babel这个loader。因为babel是一个比较强大的转换器,具有强大的语言转换功能,babel-loader只是webpack调用它的一种形式,所以需要独立学习下。

Refer

中文文档
一小时包会-webpack入门指南
什么是webpack,为什么要使用它
webpack高级-插件的使用,webpack1.x
webpack是答案吗
入门 Webpack,看这篇就够了

扩展知识

自己实现一个webpack
webpack性能优化