一、webpack 配置
1. 资源入口
Webpack 通过 context 和 entry 这两个配置项来共同决定入口文件的路径。在配置入口时,实际做了两件事
- 确定入口模块位置,告诉 Webpack 从哪里开始进行打包
- 定义 chunk name。如果工程只有一个入口,那么默认其 chunk name 为 “main"; 如果工厂有多个入口,我们需要为每个入口定义chunk name,来作为该 chunk 的唯一标识。
1.1、 context
Syntax:
// webpack.config.js
module.exports = {
context: path.join(__dirname, './src'),
entry: './scripts/index.js'
// ....
}
配置 context 的主要木丁是让 entry 的编写更加简洁,尤其是在多人口的情况下。context可以省略,默认值为当前工程的根目录。
1.2、 entry
enrty 的配置可以有多种形式:字符串、数组、对象、函数。可以根据不同的需求场景来选择。
-
字符串类型入口
Syntax:
module.exports = { enrty:'./src/index.js' }
-
数组类型的入口
传入一个数组的作用是将多个资源预先合并,在 打包时 Webpack 会将数组中的最后一个元素作为实际的入口路径。
Syntax:
module.exports = { entry: ['babel-polyfill', './src/index.js'] }
上诉的配置等价于
module.exports = { entry: './src/index.js' }
// index.js import 'babel-polyfill'
-
对象类型入口
定义多个入口时,则必须使用对象的形式。对象的属性名(key) 是 chunkname,属性值(value) 是入口路径。
Syntax:
// webpack.config.js module.exports = { enrty: { index: './src/index.js', // chunk name 为 index,入口路径为 ./src/index.js lib: './src/lib.js' // chunk name 为 lib,入口路径为 ./src/lib.js } }
当 entry 为对象时,对象的属性值也可以同时使用 数组来表示
Syntax:
// webpack.config.js module.exports = { enrty: { index: ['babel-polyfill', './src/index.js'], lib: './src/lib.js' } }
<mark>在使用字符串或数组定义单入口时,并没有办法更改 chunk name, 只能为默认的 “main”。 在使用对象来定义多入口时,则必须为每一个入口定义chunk name</mark>
-
函数类型入口
用函数定义入口时,只要返回上面已介绍的任何配置形式即可。
// webpack.config.js module.exports = { entry: () => './src/index.js' }
传入一个函数的优点在于我们可以在函数体里添加一些动态的逻辑来获取工程的入口。另外,<mark>函数也支持返回一个 Promise 对象来进行异步操作</mark>
2. 资源出口
出口相关的配置都集中在 output 对象中,output 对象里可以包含 十几个 配置项,其中的大多数在日常开发中使用的频率不高。以下介绍的基本可以覆盖大多数场景。
2.1、filename
filename 的作用是 控制输出资源的文件名,请形式为字符串。
Syntax:
// webpack.config.js
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js'
}
}
filename 可以不仅仅是 bundle 的名字,还可以是一个相对路径,几遍路径中的目录不存在也没关系,Webpack 会在输出资源时创建该目录。
Syntax:
// webpack.config.js
module.exports = {
entry: './src/index.js',
output: {
filename: './js/bundle.js'
}
}
在多入口的场景中,我们需要为对应产生的每个bundle 指定不同的名字,Webpack 支持使用一种<mark>类似模板语言</mark>的形式动态地生成文件名。
Syntax:
// webpack.config.js
module.exports = {
entry: {
index: './src/index.js',
vendor: './src/vendor.js'
},
output: {
filename: '[name].js'
}
}
上面的 [name] 会被替换为 chunk name
filename 配置项模板变量
变量名称 | 功能 |
---|---|
[name] | 对应 entry 的 chunk name |
[hash] | 指带 Webpack 此次打包所有资源生成的 hash |
[chunhash] | 顶戴当前 chunk 内容的 hash |
[id] | 指代当前 chunk 的 id |
[query] | 指代 filename 配置项中的 query |
上诉变量一般有如下两种作用:
- 当有多个chunk 存在时对不同的chunk进行区分。如[name]、[chunkhash] 和 、 [id], 它们对于每个 chunk 来说都是不同的
- 控制客户端缓存,表中的 [hash] 和 [chunkhash] 斗鱼 chunk 内容直接相关,在 filename 中使用了这些变量后,当 chunk 的内容改变时,可以同时引起资源文件名的更改,从而使用户在下一次请求资源文件时会立即下载新的版本而不会使用本地缓存。
[query] 也可以起到类似的效果,只不过它与 chunk 内容无关,要由开发者手动指定
在实际工程中,使用比较多的是 [name],可读性较高。如果要控制客户端缓存,最好还要加上 [chunkhash],因为每个 chunk 所产生的 [chunkhash] 只与自身内容有关,单个 chunk 内容的改变不会影响其他资源,可以最精确地让客户端缓存得到更新。
2.2、path
path 可以指定资源输出的位置,要求值必须为<mark>绝对路径</mark>。
**Syntax: **
// webpack.config.js
const path = require('path')
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
}
}
在 Webpack 之后 output.path 默认为 dist 目录,除非要去更改它,否则不必单独配置。
3.3、publicPath
publicPath 是一个非常重要的配置项,用来指定资源的输出位置。
输出位置(path):打包完成后资源产生的目录。
-
请求位置(publicPath):<mark>由 JS 或 CSS 所请求的间接资源路径</mark>。
页面的资源请求可以分为两种:
- 直接请求:如HTML页面通过 script 标签加载 JS 发起的请求。
- 间接请求:如在 JS 中异步加载JS,从 CSS 请求的图片字体
publicPath 的作用就是制定这部分间接资源的请求位置。
publicPath 有三种形式
-
HTML 相关
与 HTML 相关,也就是说我们可以将 publicPath 指定为 HTML 的相对路径,在请求这些资源时,会以当前页面 HTML 所在路径加上相对路径,构成实际请求的 URL,
Syntax:
// 假设当前 HTML 的地址为 https://example.com/app/index.html // 异步加载的资源名为 0.chunk.js publicPath: '', // 实际路径 https://example.com/app/0.chunk.js publicPath: './js', // 实际路径 https://example.com/app/js/0.chunk.js publicPath: '../assets/' // 实际路径 https://example.com/assets/0.chunk.js
-
Host 相关
若 publicPath 的值以 "/" 开始,则代表此时 publicPath 是以当前页面的 host name 为基础路径的。
Syntax:
// 假设当前 HTML 路径为 https://example.com/app/index.html // 异步加载的资源名为 0.chunk.js publicPath: '/', // 实际路径 https://example.com/0.chunk.js publicPath: '/js', // 实际路径 https://example.com/js/0.chunk.js publicPath: '/dist', // 实际路径 https://example.com/dist/0.chunk.js
-
CDN 相关
publicPath 以协议头或相对协议的形式开始时,代表当前路径是 CDN 相关。
Syntax:
// 假设当前页面路径为:https://example.com/app/index.html // 异步加载的资源名为 0.chunk.js publicPath: 'http://cdn.com', // 实际路径为 http://cdn.com/0.chunk.js publicPath: 'https://cdn.com', // 实际路径为 https://cdn.com/0.chunk.js publicPath: '//cdn.com/assets/', // 实际路径为 //cdn.com/assets/0.chunk.js
PS
在 webpack-dev-server 的配置中也有一个 publicPath,值得注意的是,这个 publicPath 与 Webpack 中的配置项含义不同,它的作用是指定 webpack-dev-server 的静态资源服务路径。
Syntax:
// webpack.config.server
const path = require('path')
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
devServer: {
publicPath: '/dist/',
port: 3000,
}
}
为了避免开发环境和生产环境产生不一致二造成开发者的疑惑,我们可以将 webpack-dev-server 的 publicPath 与 Webpack 中的 output.path 保持一致,这样在任何环境下资源输出的目录都是相同的。
3. module
loader 相关的配置都在 module 对象中
3.1. rules
代表了模块的处理规则,每条规则内部可以包含很多配置项。
3.1.1、text
test 可接受一个正则表达式或者一个元素为正则表达式的数组,只有正则匹配上的模块才会使用这条规则,(比如:使用 /\.css$/ 来匹配 css文件)
Syntax:
// webpack.config.js
module.exports = {
enrty: './src/index.js',
output: {
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.css$/,
}
]
}
}
3.1.2、use
use 可接受一个数组,数组包含该规则所使用的 loader。当只使用 一个 loader 是,可以使用 字符串表示
Syntax:
// webpack.config.js
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.css$/,
use: 'css-loader',
// use: ['style-loader', 'css-loader']
}
]
}
}
use 还可以为对象,当为对象时通过 <mark>loader</mark>来指定要加载的 loader,通过 <mark>options</mark> 来指定 当前loader 的配置参数
Syntax:
// webpack.config.js
module.exports = {
//...
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
// css-loader 配置项
}
}
]
}
]
}
}
3.1.3、exclude
exclude 用来排除 指定目录下的模块
Syntax:
// webpack.config.js
module.exports = {
//...
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
// css-loader 配置项
}
}
],
exclude: /node_modules/,
}
]
}
}
改配置项通常时必加的,否则可能拖慢整体打包的速度
3.1.4、include
include 代表该规则只对正则匹配到的模块生效
Syntax:
// webpack.config.js
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
include: /src/,
}
]
<mark>exclude 个 include 同时存在时, exclude 的优先级更高。</mark>
3.1.5、resource 与 issuer
在 Webpack 中,我们认为被加载模块是 resource,而加载者是 issuer。
// index.js
import './style.css'
在上方示例中, index.js
为 resource。 style.css
为 issuer
前面介绍的 test、exclude、include 本质上属于对 resource 也就是被加载者的配置。
如果我们只想让 /src/pages 目录下的 js 可以引用 css。
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
exclude: /node_modules/,
issuer: {
test: /\.js$/,
include: /src/pages/,
}
}
]
}
}
如上配置可以实现我们的需求,但是 test、exclude、include 这些配置项分布在不同的层级上,可读性较差。
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
{
use: ['style-loader', 'css-loader'],
resource: {
test: /\.css$/,
exclude: /node_modules/
},
issuer: {
test: /\.js$/,
exclude: /node_modules/,
}
}
]
}
}
上面的配置与把 resource 的配置卸载外层在本质上是一样的,然而这两种形式无法共存,只能选择一种风格进行配置。
3.1.6、enforce
enforce 用来指定一个 loader 的种类,只接收 "pre" 或 "post" 这两种字符串类型的值。
Webpack 中的 loader 按照执行顺序可分为 pre,inline,normal,post 四种类型。
直接定义的 loader 默认是 normal
inline形式官方已不推荐使用。
pre 和 post 需要通过 enforce 来指定。
- pre:代表它将在所有正常 koader 之前执行,这样可以保证其检查的代码不是被其他 loader 更改过的。
- post:如果某一个 loader 需要在所有 loader 之后执行的,可以指定为 post
Syntax:
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
{
test: /\.js$/,
enforce: 'pre',
use: 'eslint-loader'
}
]
}
}
事实上不使用 enforce,保证 loader 正常的顺序即可。配置 enforce 的主要目的是在大型项目中,曾倩可读性,使模块规则更加清晰。
4. plugins
plugins 用于接收一个插件数组,可以使用 Webpack 内部提供的一些插件,也可以加载外部插件。webpack 为插件提供了各种 API,使其可以在打包的各个环节中添加一些额外任务。
二、核心概念
1. loader
loader 的字面意思是装载器,在 Webpack 中它的实际功能则更像是预处理器。Webpack 本身只认识 Javascript,对于其他类型的资源必须先定义一个或多个 loader 对其进行转译,输出为 Webpack 能够接收的形式再继续进行,因此 loader 做的实际上是一个预处理工作。
每个 loader 本质上都是一个函数。在 Webpack 4 之前,函数的输入和输出都必须为 字符串;在 Webpack4 之后,loader 也同时支持抽象语法树(AST)的传递,通过这种方法来减少重复的代码解析。
用公式表达 loader 的本质则为以下形式:
output = loader(input)
比如使用babel-loader 将 ES6+的代码转为 ES5的代码
ES5 = babel-loader(ES6+)
loader可以是链式的
output = loaderA(loaderB(sass-loader(SCSS)))
loader的源码结构:
module.exports = function loader(context, map, meta) {
var callback = this.async()
var result = handler(context, map, meta),
callback(
null, // error
result.context, //转换后的内容
result.map, // 转换后的 source-map
result.meta, // 转换后的 AST
)
}
1.1、常用 loader 介绍
1. babel-loader
babel-loader 用来处理 ES6+ 并将其编译为 ES5,它使我们能够在工程中使用最新的语言特性,同时不必特别关注这些特性在不同平台的兼容问题。
-
安装
yarn add babel-loader @babel/core @babel/preset-env
- babel-loader:它是使 Babel 与 Webpack 协同工作的模块
- @babel/core:顾名思义,它是 Babel 编译器的核心模块。
- @babel/preset-env:它是 Babel 官方推荐的预置器,可根据用户设置的目标环境自动添加所需的插件和补丁来编译ES6+ 代码
-
Syntax:
rules: [ { test: /\.js/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { cacheDirectory: true, presets: [[ 'env', { modules: false, } // 这个地方应该是 "@babel/preset-env" ]] } } } ]
注意事项:
- exclude 中添加 node_modules
- cacheDirectory选项,启用缓存机制,在重复打包未改变过的模块时防止二次编译,同样也会加快打包速度。
cacheDirectory 可以接受一个字符串类型的路径来作为缓存路径,这个值也可以为true
- @babel/preset-env 会将ES6 Module 转化为 CommonJS 的形式,这会导致 Webpack 中的 tree-shaking特性失效,将 modules 设置项设置为 false 会禁用模块语句的转化,而将 ES6 Module 配置文件中提取出来,也能达到相同的效果。
2. ts-loader
ts-loader 与 babel-loader 的性质类似,它是用于 连接 webpack 与 typescript 的模块
-
安装
yarn add ts-loader typescript
-
Syntax:
// webpack.config.js rules: [ { test: /\.ts$/, use: 'ts-loader' } ]
需要注意,Typescript 本身的配置并不在 ts-loader 中,而实必须要放在工程目录下的 tsconfig.json 中。
{
"compilerOptions": {
"target": "es5",
"sourceMap": true,
}
}
3. html-loader
html-loader 用于将 HTML 文件转化为字符串并进行格式化,这使得我们可以把一个 HTML 片段通过 JS 加载进来。
-
安装
yarn add html-loader
-
Syntax:
// webpack.config.js rules: [ { test: /\.html$/, use: 'html-loader' } ]
-
使用 示例:
// header.html <header> <h1> This is a Header. </h1> </header>
// index.js import headerHtml from './header.html' document.write(headerHtml)
4. handlebars-loader
handlebars-loader 用于处理 handlebars 函数,在安装时要额外安装 handlebars。
-
安装
yarn add handlebars-loader handlebars
-
Syntax:
rules: [ { test: /\.handlebars$/, use: 'handlebars-loader', } ]
-
使用 示例:
// content.handlebars <div class="entry"> <h1>{{ title }}</h1> <div class="body">{{ body }}</div> </div>
// index.js import contextTemplate from './content.handlebars' const div = document.createElement('div') div.innerHTML = contentTemplate({ title: 'Title', body: 'Your books are due next Tuseday' }) document.body.appendChild(div)
handlebars 问价加载后得到的是一个函数,可以接受一个变量对象并返回最终的字符串。
5. file-loader
file-loader 用于打包文件类型的资源,并返回其 publicPath
-
安装
yarn add file-loader
-
Syntax:
// webpack.config.js const path = require('path') module.exports = { entry: './app.js', output: { path: path.join(__dirname, 'dist'), filename: 'bundle.js' }, module: { rules: [ { test: '\.(png|jpg|gif)$', use: 'file-loader' } ] } }
-
使用 示例:
// index.js import avatarImg from './assets/img/hamster.png' const img = document.createElement('img') img.src = hamster console.log(hamster) // a9aa1548d1923942ada2266e2525628b.jpg document.body.appendChild(img)
当打包完成后,dist 目录下会生成 一名为 a9aa1548d1923942ada2266e2525628b.jpg
的图片文件。由于配置中斌没有指定 output,publicPath,因此 这里打印出的图片路径只是文件名,默认为文件的 hash 值加上文件后缀。
-
指定 publickPath
// webpack.config.js const path = require('path') module.exports = { entry: './app.js', output: { path: path.join(__dirname, 'dist'), filename: 'bundle.js', // publicPath: './assets/', // 可以在这个地方指定 简介资源的输出 }, module: { rules: [ { test: '\.(png|jpg|gif)$', use: 'file-loader', name: '[name].[ext]' publicPath: './asstes/' // 在 file-loader 内助的 publicPath 优先级更大 } ] } }
此时路径就变成了
./assets/hamster.png
6. url-loader
url-loader 与 file-loader 作用类似,唯一的不同在于用户可以设置一个文件大小的阈值,当大于该阈值时与 file-loader一样返回 publicPath,而小于该阈值时则返回文件 base64 编码
-
安装
yarn install url-loader
-
Syntax:
// webpack.config.js rules: [ { test: /\.(png|jpg|gif)/, use: { loader: 'url-loader', use: { loader: 'url-loader', options: { limit: 10240, name: './static/[name]_[hash:4].[ext]', } } } } ]
url-loader 可接受与 file-loader 相同的参数,如 name 和 publicPath 等,同时也可以接受一个 limit 参数。
7. vue-loader
vue-loader 用于处理 vue 组件,可以将 组件的模板,js,样式进行拆分。在安装时,除了必需的 vue 与 vue-loader 以外,还要安装 vue-template-compiler 来编译 Vue 模板,以及 css-loader 来处理样式(如果使用 scss 或 less 则任需要对应的 loader)
-
安装
yarn add vue-loader vue vue-template-compiler css-loader
-
Syntax:
// webpack.config.js rules: [ { test: /\.vue$/, use: 'vue-loader', } ]
vue-loader 支持更多高级配置。。
自定义loader
loader 实现:
功能:为所有 js 文件启用严格模式。 即在文件头部加上 ‘use strict’
-
初始化项目
npm init -y
-
创建 index.js, loader 的主体
module.exports = function(content) { var useStrictPrefix = '\'use strict\'; \n\n' return useStrictPrefix + content }
-
启用缓存
当文件输入和其他依赖没有发生变化时,应该让 loader 直接使用缓存,而不是重复进行转换的工作。在 webpack 中可以使用 this.cacheable 进行控制,修改loader
module.exports = function(content) {
if (this.cacheable) {
this.cacheable()
}
var useStrictPrefix = '\'use strict\'; \n\n console.log(\'my loader\'); \n\n';
return useStrictPrefix + content
}
通过启用缓存可以加快 webpack 打包速度,并且可保证相同的输入产生相同的输出。
- 获取 options
获取 options 需要安装一个依赖库, loader-utils, 它主要用于提供一些帮助函数。
yarn add loader-utils
-
修改 loader
var loaderUtils = require('loader-utils') module.exports = function(content) { if (this.cacheable) this.cacheable() // 获取和打印 options var options = loaderUtils.getOptions(this) || {} console.log('options', options); var useStrictPrefix = '\'use strict\'; \n\n console.log(\'my loader\'); \n\n'; return useStrictPrefix + content }
利用 loaderUtils.getOptions 可以获取到配置对象,接下来看如何实现一个 source-map 功能
-
source-map
source-map 可以便于实际开发者在浏览器控制台查看源码。如果没有对 source-map 进行处理,最终也就无法生成正确的 map 文件,在浏览器的 dev tool 中可能就会看到错乱的源码。
-
继续修改 loader
// strict-loader var loaderUtils = require('loader-utils') var SourceNode = require('source-map').SourceNode; var SourceMapConsumer = require('source-map').SourceMapConsumer module.exports = function(content, sourceMap) { if (this.cacheable) this.cacheable() var useStrictPrefix = '\'use strict\'; \n\n console.log(\'my loader\'); \n\n'; // options var options = loaderUtils.getOptions(this) || {} if (options.sourceMap && sourceMap) { var currentRequest = loaderUtils.getCurrentRequest(this) var node = SourceNode.fromStringWithSourceMap( content, new SourceMapConsumer(sourceMap)) node.prepend(useStrictPrefix) var result = node.toStringWithSourceMap({ file: currentRequest }) var callback = this.async(); callback(null, result.code, result.map.toJSON()); } return useStrictPrefix + content }
2. 样式处理
在具有一定规范的工程中,由于 手工维护 CSS 成功果与高昂,我们可能会需要更智能的方案来解决浏览器兼容性问题,更优雅地处理组件间的样式隔离,甚至是借助一些更强大的语言特性来实现各种各样的需求。
1. 分离样式文件
前面接受loader 的时候,有
style-loader
和css-loader
,我们 通过 附加 style 标签的方式引入样式的,那么 如何输出 单独的 css 文件勒?生成环境下,我们希望样存在与 css 文件中而不是 style 标签中,因为文件 更有利于 客户端进行缓存。使用对应插件extract-text-webpack-plugin
,mini-css-extract-plugin
extract-text-webpack-plugin( before 4.x )
-
安装
yarn add extract-text-webpack-plugin
-
Syntax:
// webpack.config.js const ExtractTextPlugin = require('extract-text-webpack-plugin') module.exports = { entry: './app.js', output: { filename: 'bundle.js' }, mode: 'development', module: { rules: [ { test: /\.scss$/, use: ExtractTextPlugin.extract({ fallback: 'style-loader', use: 'css-loader' }) } ] }, plugins: [ new ExtractTextPlugin('bundle.css') ] }
fallback 属性用于指定当前插件无法提取样式时所采用的 loader, use(extract 方法里面的)用于指定在提取样式之前采用哪些loader 来预先进行处理
2.多样式文件的处理
当工程有多个如可时就会发生重名问题。就像在前面的章节中我们配置动态的 output.filename 一样,这里我们也要对插件提取的 CSS 文件使用 类似模板的命名方式。
// foo.js
import '../styles/foo-style.less'
document.write('foo.js')
// bar.js
import '../styles/bar-style.css'
document.write('bar.js')
如上,假如我们有 foo.js 和 bar.js,并且它们分别引用了 foo-style.css 和 bar-style.css, 现在我们要通过配置使它们输出各自的 CSS 文件。
// webpack.config.js
const ExtractTextPlugin = require('extract-text-webpack-plugin')
module.exports = {
entry: {
foo: './src/scripts/foo.js',
bar: './src/scripts/bar.js'
},
output: {
filename: '[name].js'
},
mode: 'development',
module: {
rules: [
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: 'css-loader'
})
}
]
},
plugins: [
new ExtractTextPlugin('[name].css')
]
}
这里的 [name] 和在 output.filename 中的意义一样,都是指代 chunk 名字,即 entry 中我们为每一个入口分配的名字( foo、bar)。
mini-css-extract-plugin(4.x later)
mini-css-extract-plugin 可以理解成 extract-text-webpack-plugin 的升级版,拥有更丰富的特性和更好的性能。从 Webpack 4 开始官方推荐使用该插件进行样式提取。最重要的特性是,支持按需加载CSS
按需加载
:假如:a.js 通过 import() 函数异步加载了 b.js, b.js里面加载了 style.css,那么 style.css 最终只能被同步加载(通过 HTML 的link 标签)。但是现在 mini-css-extract-plugin 会单独打包出一个 0.css(默认配置), 这个 css 文件将由 a.js 通过动态插入 link 标签的 方式加载。
// app.js
import './style.css'
import('./next-page')
document.write('app.js<br />')
// next-page.js
import './next-page.css',
document.write('next page. <br/>')
/* styles.css */
body { background-color: #eee }
/* next-page.css */
body { background-color: #999 }
// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
entry: './app.js',
output: {
filename: '[name].js'
},
mode: 'development',
module: {
rules: [{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugn.loader,
options: {
publicPath: '../',
}
},
'css-loader'
]
}]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
chunFilename: '[id].css'
})
]
}
在配置上 mini-css-extract-plugin
与 extract-etxt-webpack-plugin
有以下几点不同
- loader 规则 设置的形式不同,并且 mini-css-extract-plugin 支持配置 pluginPath,用来指定异步 CSS 的加载路径。
- 不需要设置 fallback。
- 在 plugins 设置中,除了指定同步 加载的 CSS 资源名(filename),还要指定异步加载的 CSS 资源名( chunkFileName)
3. 样式预处理
样式预处理指的是在开发中我们经常会使用一些样式预编译语言,如 SCSS,Less等,在项目打包过程中再将这些预编译语言转换为 CSS。结组这些语言强大和便捷的特性,可以降低项目的开发和维护成本。
Sass 与 SCSS
Sass 本身是对 CSS 的语法增强,他有两种语法,现在使用更多的是 SCSS(对 CSS3 的扩充版本)。所以你会发现,再安装和配置 loader 时都是 sass-loader,而实际的 文件后缀时 .scss
sass-loader 就是将 SCSS 语法编译为 CSS,因此再使用时通常要搭配 css-loader 和 style-loader。loader 本身只是编译核心库与 Webpack 的连接器,因此 处理 sass-loader 还要安装 node-sass,node-saa 时真正用来编译 SCSS 的。
-
安装
yarn add sass-loader node-sass --registry=https://registry.npm.taobao.org
-
Syntax:
// webpack.config.js module: { rules: [ { test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] } ] }
如果我们想在浏览器的调试工具里面查看源码,需要分别为 sass-loader 和 css-loader 但如配置 source mao 的配置项
{
test: /\.sacc$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
sourceMap: true,
}
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
}
}
]
}
Less
Less 同样是对 CSS 的一种 扩展。与 SCSS 类似,它也需要安装 loader 和其本身的编译模块。
-
安装
yarn add less-loader less
-
Syntax:
// webpack.config.js rules: [ { test: /\.less$/, use: [ 'style-loader', { loader: 'css-loader', options: { sourceMap: true } }, { loader: 'less-loader', options: { sourceMap: true } } ] } ]
4. PostCss
PostCss 并不能算是一个 Css 的预编译器,他只是一个编译插件的内容器。他的工作模式是接受样式源码并交由编译插件处理,最后输出 CSS。
-
安装
yarn add postcss-loader
-
Syntax:
// webpack.config.js module: { rules: [ { test: /\.css/, use: { 'style-loader', 'css-loader', 'postcss-loader' } } ] }
postcss-loader 可以结合 css-loader 使用,也可以单独使用。单独使用时 不建议使用 CSS 中的 @impor 语句,否则会参数 冗余代码,因此官方推荐还是将 postcss-loader 放在 css-loader 之后使用。
此外,postCss 必需要一个单独的配置文件,
postcss.config.js
PostCss 的特性
-
自动前缀
PostCss 一个最广泛的应用场景就是 与 Autoprefixer 结合,为 CSS 自动添加厂商前缀。
-
安装
yarn add autoprefixer
-
-
在 postcss.config.js 中配置
const autoprefixer = require('autopefixer') module.exports = { plugins: [ autoprefixer({ grid: true, browsers: [ '> 1%', 'last 3 versions', 'android 4.2', 'ie 8' ] }) ] }
在 autoprefixer 中配置需要自支持的特性 比如 grid。以及兼容哪些浏览器。
由于我们指定 了
grid:true
,也就是为 grid 特性添加了IE前缀,经过编译后则会变成:.container { display: -ms-grid; display: grid; }
-
stylelint
stylelint 是一个 css 的质量检测工具,就像 eslint 一样。我们可以为其添加各种规则,来统一项目的代码风格。
-
安装
yarn add stylelint
-
Syntax:
// postcss.config.js const stylelint = require('stylelint') module.exports = { plugins: [ stylelint({ config: { rules: { 'declaration-no-important': true } } }) ] }
上面的规则,则规定不饿能使用 !improtant, 否则在打包的时候回由警告信息
-
-
CSSNext
PostCSS 可以与 CSSNext 使用,在应用中使用最新的 CSS 语法特性。
- 安装
yarn add postcss-cssnext
-
Syntax:
const postcssCssnext = require('postcss-cssnext') module.exports = { plugins: [ postcssCssnext({ // 指定 所支持的浏览器 browsers: [ "> 1%", "last 2 versions" ] }) ] }
PostCss 会帮我们把 CSSNext 的语法翻译为浏览器能接受的属性和形式。
:root {
--highlightColor: hwb(190, 35%, 20%);
}
body {
color: var(--highlightColor)
}
/* 转换后 */
body {
color: rgb(89, 185, 204)
}
-
CSS Modules
CSS Modules 是近年来比较流行的一种开发模式,理念是把 CSS 模块化,具有如下特点:
- 每个 CSS 文件中的样式都拥有单独的作用域,不会和外界发生命名冲突
- 对 CSS 进行依赖管理,可以通过相对路径引入 CSS 文件
- 可以通过 composes 轻松复用其他 CSS 模块
使用 CSS Modal 不需要额外安装模块,只需要开启 css-loader 中的 modules 配置项接可。
// webpack.config.js rules: [ { test: /\.css$/, use: [ 'style-loader', { loader: 'css-loader', options: { // modules: true, // localIdentName: '[name]_[local]_[hash:base64:5]', // 上面的写法会报错 modules: { localIdentName: '[name]_[local]_[hash:base64:5]' } } } ] } ]
- localIdentName 配置项,它用于指明 CSS 代码中的类名会如何来编译。
- [name]:指代的是模块名,这里被替换为 style
- [local]:指代的是原本的选择器标识符,这里被替换为 title.
- [hash:base64:5]:指代的是一个 5 位的 hash 值,这个 hash 值是更具模块名和标识符计算的,因此不同模块中相同的标识符也不会造成样式冲突。
使用 CSS Models 时 CSS 文件会导出一个对象,我们需要把这个对象中的属性添加到 HTML 标签上
3. 代码分片
实现高性能应用其中重要的一点就是尽可能地让用户每次只加载必要的资源,优先级不太高的资源则采用延迟加载等技术渐进式地获取,这样可以保证页面的首屏渲染速度。
代码分片(Code splitting) 是 Webpack 作为打包工具所特有的一项技术,通过这项技术我们可以把代码按照特定的形式进行拆分,使用户不必一次全部加载,而是按需加载。
1. 通过入口划分代码
在 Webpack 中每个入口(entry) 都将生成一个对应的资源文件,通过入口的配置我们可以进行一些简单有效的代码拆分
// webpack.config.js
entry: {
app: './app.js',
lib: ['lib-a', 'lib-b', 'lib-c']
}
<!-- index.html -->
<script src="dist/lib.js"></script>
<script src="dist/app.js"></script>
这种拆分方法主要适合于那些将接口绑定在全局对象上的库,因为业务代码中的模块无法直接引用库中的模块,二者属于不同的依赖树。
2. CommonsChunkPlugin(before 4.x)
CommonsChunkPlugin 是 Webpack4 之前内部自带的插件(Webpack 4 之后替换为 SplitChunks)。它可以将多个Chunk 中公共的部分提取出来。公共模块的提取可以为项目带来几个收益:
- 开发过程中减少了重复模块打包,可以提升开发速度;
- 减少整体资源体积
- 合理分片后的代码可以有效地利用客户端缓存
使用示例
假如 有两个入口分别引用了 React
// foo.js
import React from 'react'
document.write('foo.js', React.version)
// bar.js
import React from 'react'
document.write('bar.js', React.version)
// webpack.config.js
const path = require('path')
module.exports = {
context: path.join(__dirname, "./src/splitChunks/"),
entry: {
foo: './foo.js',
bar: './bar.js'
},
mode: "development",
output: {
filename: '[name].js',
path: path.join(__dirname, 'dist')
}
}
打包结果
从资源体积可以看出,react被分别打包到了 foo.js 和 bar.js 中
。
添加 CommonsChunkPlugin(before 4.x)
// webpack.config.js
const webpack = require('webpack')
module.exports = {
// ...
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'commons',
filename: 'commons.js'
})
]
}
- name:用于指定公共 chunk 的名字
- filename:提取后的资源文件名
PS: 最后记得在页面添加一个 script 标签来引入 common.js,并且注意,该 JS 一定要在其他 JS 之前引入。
2.1、 提取 vendor
如上 CommonsChunkPlugin 主要用于提取多入口之间的公共模块,但并不代表 单入口的应用就无法使用,可以让它来提取第三方库及业务中不常更新的模块,只要是单独为他们创建一个入口即可。
// webpack.config.js
const webpack = require('webpack')
module.exports = {
entry: {
app: './app.js',
vendor: ['react']
},
output: {
filename: '[name].js'
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
filename: 'vendor.js'
})
]
}
// app.js
import React from 'react'
document.write('app.js', React.version)
添加一个 vendor 入口,使其只包含 react,这样就把 react 变为了 app 和 vender 这两个 chunk 所共有的模块。
在插件内部,将 name 指定为 vendor,这样 由 CommonsChunkPlugin 所产生的资源将覆盖原有的有 vendor 这个入口所产生的资源。
2.2、设置提取范围
通过 CommonsChunkPlugin 中的 chunks 配置可以规定从哪些入口中提取公共模块。
// webpack.config.js
const webpack = require('webpack')
module.exports = {
entry: {
a: './a.js',
b: './b.js',
c: './c.js'
},
output: {
filename: '[name].js'
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'commons',
filename: 'commons.js',
chunks: ['a', 'b']
})
]
}
在 chunks 中配置了 a 和 b,这意味着只会从 a.js 和 b.js 中提取公共模块
对一个大型应用来说,拥有几十个页面是很正常的,这也就意味着会有几十个资源入口。这些入口所共享的模块也许会有些差异,在这种情况下,我们可以配置多个 CommonsChunkPlugin,并为每个插件规定提取的范围,来更有效地进行提取。
2.3、设置提取规则
CommonsChunkPlugin 的默认规则是只要一个模块被两个入口 chunk 所使用就会被提取出来,比如只要 a 和 b 用来 react,react 就会被提取出来
开发中,有时候我们不希望所有的公共模块都被提取出来,比如项目中一些组件或工具模块,虽然被多次引用,但是可能经常修改,如果将其和 react 这种库放在一起反而不利于客户端缓存。
通过 CommonsChunkPlugin 的 minChunks 配置项来设置提取的规则。该配置项非常灵活,支持多种输入形式。
-
数字
当设置 minChunks 为 n 时,只有该模块被 n 个人同时引用才会进行提取。另外,这个阈值不会影响通过数组形式入口传入模块的提取
// webpack.config.js const webpack = require('webpack') module.exports = { entry: { foo: './foo.js', bar: './bar.js', vender: ['react'] }, output: { filename: '[name].js' }, plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', filename: 'vendor.js', minChunks: 3, }) ] } // foo.js import React from 'react' import './util'; document.write('foo.js', React.version) // bar.js import React from 'react' import './util'; document.write('bar.js', React.version) // util.js console.log('util')
由于我们设置 minChunks 为3,util.js 并不会被提取到 vendor.js 中,然而 react 并不受这个的影响,仍然会出现在 vendor.js 中。这就是所说的数组形式入口的模块会照常提取。
-
Infinity
设置为无穷代表提取的阈值无限高,也就是说所有模块都不会被提取。
意义:
- 和上面的情况类似,即我们只想让 webpack 提取特定的几个模块,并将这些模块通过数组型入口传入,这样做的好处是提取哪些模块是完全可控的。
- 为了生成一个没有任何模块仅仅包含 Webpack 初始化环境的文件,这个文件我们通常为 manifest。
-
函数
minChunks 支持传入一个函数,它可以让我们更细粒度地控制公共模块。Webpack 打包过程中的每个模块都会经过这个函数的处理,当函数的返回值是 true 时进行提取。
// webpack.config.js new webpacl.optimize.CommonsChunkPlugin({ name: 'vendor', filename: 'vendor.js', minChunks: function(module, count) { // module.context 模块目录路径 if (module.context && module.context.includes('node_modules')) { return true } // module.resource 包含模块名的完整路径 if (module.resource && module.resource.endsWith('util.js')) { return true } // count 模块引用的次数 if (count > 5) { return true; } } })
2.4、hash 与 长效缓存
使用 CommonsChunkPlugin 时,一个绕不开的问题就是 hash 与 长效缓存。当我们使用该插件提取公共模块时,提取后的资源内部不仅仅是模块的代码,往往还包含 webpack 的运行时(runtime)。Webpack 的运行时指的是初始化环境的代码,如创建模块缓存对象,声明模块加载函数等。
这个问题的解决方案是:将运行时的代码单独 提取出来。
// webpack.config.js
const webpack = require('webpack')
module.exports = {
entry: {
app: './app.js',
vendor: ['react']
},
output: {
filename: '[name].js'
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor'
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
})
]
}
manifest 的 CommonsChunkPlugin 必须出现在最后,否则 Webpack 将无法正常提取模块。而在页面引用时,也应该最先引用 manifest.js 用来初始化 webpack 环境
2.5、CommonsChunkPlugin 的不足
CommonsChunkPlugin 只能提取一个 vendor,加入我们想提取多个 vendor 则需要配置多个插件,这会增加很多重复的配置代码
manifest 实际上会使浏览器多加载一个资源,这对于页面渲染速度是不友好的
-
CommonsChunkPlugin 在 提取公共模块的时候会破坏掉原有 Chunk 中模块的依赖关系,导致难以进行更多的优化。
比如在异步 Chunk 的场景下 CommonsChunkPlugin 并不会按照我们的预期正常工作:
// webpack.config.js const webpack = require('webpack') module.exports = { entry: './foo.js', output: { filename: 'foo.js' }, plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: 'commons', filename: 'commons.js' }) ] } // foo.js import React from 'react' import('./bar.js') document.write('foo.js', React.version) // bar.js import React from 'react' document.write('bar.js', React.version)
从结果可以看出,react 任然在 foo.js 中。并没有提取到 公共模块。
3. optimization.SplitChunks(4.x later)
optimization.SplitChunks (简称 SplitChunks)是 Webpack 4 为了改进 CommonsChunkPlugin 而重新设计和实现的代码分片特性。相比 CommonsChunkPlugin 功能更强大,还简单易用。
比如上面异步加载的问题,在 SplitChunks 中可以自动提取公共模块
// webpack.config.js
module.exports = {
entry: './foo.js',
output: {
filename: 'foo.js',
publicPath: '/dist/',
},
mode: 'development',
optimization: {
splitChunks: {
chunks: 'all'
}
}
}
// foo.js
import React from 'react'
import('./bar.js')
document.write('foo.js', React.version)
// bar.js
import React from 'react'
console.log('bar.js', React.version)
不同之处
- 使用 optimization.splitChunks 替代了 CommonsChunkPlugin,并指定了 chunks 的值为 all,这个配置项的含义是,SplitChunks 将会对所有的 chunks 生效(默认情况下,SplitChunks 只是对异步 chunks 生效,并且不需要配置)。
- mode 是 Webpack4 中新增的配置项,可以针对当前是开发环境还是生产环境自动添加一些 Webpack 配置
1. 从命令式到声明式
在 CommonsChunkPlugin 中,更贴近命令式的方式,而 SplitChunks 的不同之处在于我们只需要设置一些提取条件,如提取的模式,提取模块的体积等,当某些模块达到这些条件后就会自动被提取出来。
SplitChunks 默认情形下的提取条件:
-
提取后的 chunk 可 被共享或来自 node_modules 目录。
这一条很容易理解,被多次引用或处于 node_modules 中的模块更倾向于是通用模块,比较适合被提取出来。
-
提取后的 JavaScript chunk 体积大于 30KB(压缩和gzip之前),css chunk 体积大于 50kb
如果提取后的资源体积太小,那么带来的优化效果也比较一般。
-
在按需加载过程中,并行请求的资源最大值大于等于5
按需加载指,通过动态插入 script 标签的方式加载脚本。因为每一个请求都要花费建立连接和释放连接的成本,因此提取的规则只在并行请求不多的时候生效。
-
在首次加载时,并行请求的资源数量值大于等于3.
和上一条类似,只不过在页面首次加载时往往对性能的要求更高,所以阈值更低。
如上面提取 react的例子,只有满足了条件 reac 才会被提取出来。
- react 是与 node_modules 目录下的模块。
- react 的体积大于 30KB。
- 按需加载时的并行请求数量为1,为 0.js
- 首次加载时的并行请求数量为2,为 main.js 和 vendors-main.js。之所以 vendors-main.js 不算在第三条因为它需要被添加在 HTML 的script 标签中,在页面初始化的时候就会进行加载、
2. 默认的异步提取
SplitChunks 不需要配置也能生效,但仅仅针对异步资源。
默认的配置
splitChunks: {
chunks: 'async',
minSize: {
javascript: 30000,
style: 50000
}
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 3,
automaticNameDelimiter: '~',
name: true.
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
},
default: {
minChunks: 1,
priority: -20,
reuseExistingChunk: true
}
}
}
-
匹配模式
SplitChunks 有三个工作模式
- async(默认):只能提取异步 chunk
- initial:只对入口chunk 生效
- all:两种模式同时开启
-
匹配条件
minSize、minChunks、maxAsyncRequests、maxInitialRequests 都属于匹配条件
-
命名
name 默认为 true,以未这 SplitChunks 可以更具 cacheGroups 和作用域范围自动为新生成的chunk命名,并以 automaticNameDelimiter 分割。
如 vendors~a~b~c.js 意思时 cacheGroups 为 vendors,并且该 chunk 是由a、b、c三个入口 chunk 所产生的。
-
cacheGroups
成分 离 chunks 时的规则。默认情况下有两种规则 —— vendors 和 default。vendors 用于提取所有 node_modules 中符合条件的模块,default 则用于被多次引用的模块。
3. 资源异步加载
资源异步加载主要解决的问题时,当模块数量过多、资源体积过大时,可以把一些暂时用不到的模块延迟加载。这样使用不到的模块延迟加载。这样使页面初次渲染的时候用户下载的资源经可能小,后续的模块等到恰当的时机再去触发加载。因此以便也把这种方法叫作按需加载。
-
import()
在 webpack 中有两种异步加载的方式 —— import 函数及 require.ensure。官方推荐 import
与正常 ES6 中的import 语法不同,通过 import 函数加载的模块及其依赖会被异步地进行加载,并返回一个 Promise 对象。
// foo.js import('./bar.js').then(({add}) => { console.log(add(2, 3)) }) // bar.js export function add(a, b) { return a + b }
更改 webpack 配置
module.exports = { entry: { foo: './foo.js' }, output: { publicPath: '/dist/', filename: '[name].js' }, mode: 'development', devServer: { publicPath: '/dist/', port: 3000 } }
import 函数相当于 使 bar.js 成为了一个 间接资源,我们需要配置 publicPath 来告诉 Webpack 去哪里获取它。
import 函数还有一个比较重要的特性。ES6 Module 中要求 import 必须出现在代码的顶层作用域,而 Webpack 的 import 函数则可以在任何我们希望的时候调用,这种异步加载方式赋予应用很强的动态属性,它经常被用来在用户切换到某地特定路由时去渲染相应组件,这样分离之后首屏加载的资源就会小很多。
-
异步 chunk 的配置
通过 webpack 的配置来为其添加有意义的名字,以便于管理。
// webpack.config.js module.exports = { entry: { foo: './foo.js' }, output: { publicPath: '/dist/', filename: '[name].js', chunkFilename: '[name].js' }, mode: 'development' } // foo.js import(/* webpacjChunkName: 'bar' */ 'bar.js').then(({add}) => { console.log(add(2, 4)) })
4.生成环境配置
在生成环境中我们关注的是如何让用户更加快地加载资源,涉及如何压缩资源、如何添加环境变量优化打包、如何最大限度地利用缓存等
1. 环境配置的封装
推荐的方式:为不同环境创建各自的配置文件
例如:
// package.json
"scripts": {
"dev": "webpack-dev-server --config=webpack.development.config.js",
"build": "webpack --config=webpack.production.config.js"
}
可以将 两个配置的 公共部分抽离出来,然后使用 webpack-merge 来进行合并,便于对繁杂的配置进行管理。
2. 开启 production 模式
在 webpack4 中通过 指明 mode 来开启。大部分时候仅仅设置 mode 是不够的。
3. 环境变量
通常 我们需要为生成环境和本地环境添加不同的环境变量,在 webpack 中可以使用 DefinePlugin 进行设置。
// webpack.config.js
const webpack = require('webpack')
module.exports = {
entry: './app.js',
output: {
filename:'bundle.js'
},
mode: 'production',
plugins: [
new webpack.DefinePlugin({
ENV: JSON.stringify('production')
})
]
}
// app.js
document.write(ENV) // production
除了 字符类型的值以外,我们也可以设置其他类型的环境变量。
new webpack.DefinePlugin({
ENV: JSON.stringify('production'),
IS_PRODUCTION: true,
ENV_ID: 1313213,
CONSTANTS: JSON.stringify({
TYPES: ['foo', 'bar']
})
})
在一些值的外面加上了 JSON.stringify,这是因为 DefinePlugin 在替换环境变量时 对于字符串类型的值进行的 是完全替换。假如不添加 JSON.stringify,在替换后就会变成变量名,而非字符串。因此对于字符串环境变量及包含字符串的对象都要加上 JSON.stringify。
许多框架与库都采用 process.env.NODE_ENV 作为一个区别开发环境和生成环境的变量。
new webpack.DefinePlugin({
process.env.Node_ENV: 'production'
})
如果启用了 mode:production,则 Webpack 已经设置好了 process.env.NODE_ENV, 不需要再认为添加了。
4. source map
source map 指的是将 编译、打包、压缩后的代码映射回源码的过程。有了 source mao,再加上浏览器调试工具。对于向上问题的追查也有一定帮助。
原理:
webpack 对于工厂源代码的每一步处理都有可能会改变代码的位置、结构、甚至是所处文件,因此每一步都需要生成对应的 source map。若 我们启用了 devtool 配置项,source map就会跟随源代码一步步被传递,直到生成最后的 map 文件。这个文件默认就是打包后的文件加上.map。
在生成 mapping 文件的同时,bundle 文件中会追加上一句注释来标识 map 文件的位置。
// bundle.js
(function() {
// ...
})();
// # sourceMapping.bundle.js.map
使用 source map 会有一定的安全隐患,即任何人都可以通过 dev tools 看到工厂源码。后面会介绍如何解决这个问题。
source map 配置
-
js:在 webpack.config.js 中添加 devtool 即可。
// webpack.config.js module.exports = { devtool: 'source-map' }
-
对于 css、scss、less 来说,则需要添加额外的 source map 配置项。
// webpack.config.js const path = require('path') module.exports = { // ... devtool: 'source-map', module: { rules: [ { test: /\.scss$/, use: [ 'style-loader', { loader: 'css-loader', options: { sourceMap: true }, }, { loader: 'sass-loader', options: { sourceMap: true } } ] } ] } }
webpack 支持多种 source ma 的形式,可以根据不同的需求选择 cheap-source-map、eval-source-map 等,通常他们都是 source map 的一些简略版本。
比如在 开发环境中,cheap-module-eval-source-map 通常是一个不错的选择,属于 打包速度和源码信息还远程度的一个良好折中。
在生成环境中我们会对代码进行压缩,最常见的压缩插件是 UglifyjsWebpackPlugin 目前只支持完全的 source-map,只能使用 source-map、hidden-source-ma、nosources-source-map 这三种。
安全
source map 也为之任何人通过浏览器的开发中工具都可以看到工程源码,对于安全性来说也是极大的隐患。那么如何才能在保持其功能的同事,放置暴露源码給用户呢? webpack 提供了 hidden-source-map 及 nosources-source-map 两种策略来提升 source map 的安全性。
hidden-source-map:意味着 Webpack 仍然会产生出完整的 map 文件,只不过不会在 bundle 文件中添加对于 map 文件的引用。浏览器自然也无法对 bundle 进行解析。如果我们想要追溯源码,则要利用一些第三方服务,将 map 文件上传到那上面。目前最流行的解决方案是 Sentry。
Sentry:Sentry 支持 JavaScript 的source map,可以通过它提供的命令行工具或者 Webpack 插件来自动上传 map 文件。同时我们还要在工厂代码中添加 Sentry 对应的工作包,每当 JavaScript 执行出错时就会上报给 Sentry。Sentry在接收到错误后,就会去找对应的 map 文件进行源码解析,并给出源码中的错误栈。
nosources-source-map:他对于安全性的保护则没有那么强,但是使用方式相对简单。打包部署之后,我们可以在浏览器开发中工具的 Sources 选项卡中看到源码的目录结构,但是文件的具体内容会被隐藏起来。可以在Console 控制台中查看源码的错误栈,或者 console 日志的准确行数。他对于追溯错误来说基本足够。
other way:我们可以正常打包出 source map,然后通过服务器的 nginx 配置(或其他类似工具)将.map 文件值对于固定的 白名单开发,这样我们仍然能看到源码,而在以便用户的浏览器中就无法获取到他们了。
5. 资源压缩
-
Webpack 3 中,开启压缩需要调用 webpack.optimize.UglifyJsPlugin. --- UglifyJs
// webpack.config.js const webpack = require('webpack') module.exports = { entry:'./app.js', output: { filename: 'bundle.js' }, plugins: [new webpack.optimize.UglifyJsPlugin()] }
-
Webpack 4 之后,迁移到了 config.optimization.minimize --- terser
module.exports = { entry:'./app.js', ouput: { file: 'bundle.js' }, optimization: { minmize: true, } }
插件同时支持自定义配置:...
const TerserPlugin = require('terser-webpack-plugin') module.exports = { // ... optimization: { // 覆盖默认的 minimizer new TerserPlugin({ // config test: /.js(\?.*)?$/i, exclude: /\/excludes/, }) } }
CSS 压缩
压缩 CSS 文件的前提是 使用 extract-text-webpack-plugin 或 mini-css-extract-plugin 将样式提取出来,接着使用 optimize-css-assets-webpack-plugin 来进行压缩,这个插件本质上使用的是压缩器 cssnano,当然我们也可以通过其配置进行切换。
6. 缓存
缓存是指重复利用浏览器已经获取过的资源,合理地使用缓存是提升客户端性能的一个关键因素。具体的缓存策略(如制定缓存时间等)有服务器来决定,浏览器会在资源过期前一直使用本地缓存进行响应。
1. 资源 hash
一个常用的方法是在每次打包的过程中对资源的内容计算一次 hash,并作为版本号存放在文件名中,每当代码发生变化相应的 hash 也会变化。
通常使用 chunkhash 来作为文件版本号,因为它会为每一个 chunk 单独计算一个 hash。
module.exports = {
entry: './app.js',
output: {
filename: 'bundle@[chunkhash].js'
},
mode: 'production'
}
2. 输出动态HTML
资源名的改变也就意味着 HTML 中的引用路径的改变,每次更改后都要手动地去维护它是很困难的,理想的情况时是在打包结束后自动把最新的资源名同步过去。html-webpack-plugin 可以帮我们做到这一点。
const htmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
plugins: [
new HtmlWebpackPlugin()
]
}
html-webpack-plugin 会自动地将我们打包出来的资源名放入生成的 index.html 中,这样我们就不必手动地更新资源 URL 了。
html-webpack-plugin 也可以指定已有的模板
new HtmlWebpackPlugin({
template: './template.html'
})
html-webpack-plugin 还支持更多的个性化配置,具体参考官方文档。
使用 chunk id 更稳定
理想状态下,对于缓存的应用是尽量让用户在启动是只更新代码变化的部分,而对没有变化的部分使用缓存。
之前介绍使用 CommonsChunkPlugin 和 SplitChunksPlugin 来划分代码。通过他们来尽可能地将一些不常变动的代码单独提取出来,与经常迭代的业务代码区别开,这些资源就可能在客户端一直使用缓存。
如果使用的 Webpack3 或者以下的版本,在使用 CommonsChunkPlugin 时要注意 vendor chunk hash 变动的问题,它有可能影响缓存的正常使用。
7. bundle 体积监控和分析
VS Code 中有一个插件Import Cost 可以帮助我们对引入模块的大小进行实时监测。
另一个很有用的工具时 webpack-bundle-analyzer,它能够帮助我们分析一个 bundle 的构成。
const Analyzer = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
// ...
plugins: [
new Analyzer()
]
}
最后我们还需要自动化对资源他自己进行监控,budlesize 这个工具包可以帮忙做到这一点。安装之后只需要配置 package.json 即可。
{
"name": 'my-app',
"version": "1.0.0",
"bundlesize": [
{
"path": "./bundle.js",
"maxSize": "50 kb"
}
],
"scripts": {
"test:size": "bundlesize"
}
}
通过 npm 脚本可以执行 bundlesize 命令,它会根据我们配置的资源路基和最大体积验证最终的 bundle 是否超限,也可以将其作为自动化测试的一部分,来保证输出的资源如果超限了不会在不知情的情况下就发布出去。
5. 打包优化
软件工程领域的经验 —— 不要过早优化,在项目的初期不要看到任何优化点就拿来加到项目中,这样不但增加了复杂度,优化的效果也会不太理想。一般是项目发展到一定规模后,性能问题随之而来,这是再去分析然后对症下药,才有可能达到理想的优化效果。
1. HappyPack
对于很多大中型工程而言, HappyPack 确实可以显著地缩短打包时间。
工作原理:在打包过程中有意向非常耗时的工作,就是使用 loader 将各种资源进行转译处理。最常见的包括使用 babel-loader 转译 ES6+ 语法 和 ts-loader转译 TypeScript,大致的工作流程如下:
- 从配置中获取入口
- 匹配 loader 规则,并对入口模块进行转译
- 对转译后的模块进行依赖查找
- 对新找到的模块重复进行不走,直到没有新的依赖模块。
问题在于:Webpack 是单线程的,假设一个模块依赖于其他几个模块,Webpack 必须对这些模块逐个进行转译,HappyPack 恰恰以此为切入点,它的核心特定是可以开启多个线程,并行地对不同模块进行转译,这样就可以充分利用本地的计算资源来提升打包速度。
单个 loader 的优化
// before use
module.exports = {
// ...
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
presets: ['react']
}
}
]
}
}
// use HapplyPack
const HappyPack = require('happypack')
module.exports = {
// ...
modules: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'happypacj/loader',
}
]
},
plugins: [
new HappyPack({
loader: [
{
loader: 'babel-loader',
options: {
presets: ['react']
}
}
]
})
]
}
使用 happypack/loader 替换了原有的 babel-loader,并在 plugins 中添加了 HappyPack 的插件,将原有的 babel-loader 连同它的配置插入进入。
多个 loader 的优化
在使用 HappyPack 优化多个 loader 是,需要为每一个 loader 配置一个 id,否则 HappyPack 无法知道 rules 与 plugins 如何 一一对应。
const webpack = require('happypack')
module.exports = {
// ...
module:{
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'happypacj/loader?id=js',
},
{
test: /\.ts$/,
exclude: /node_modules/,
loader: 'happypack/loader?id=ts',
}
]
},
plugins: [
new HappyPack({
id: 'js',
loader: [{
loader: 'babel-loader',
options: {}, // babel options
}]
}),
new HappyPack({
id: 'ts',
loaders: [{
loader: 'ts-loader',
options: {}, // ts options
}]
})
]
}
多个 HappyPack loader 的同时也就意味着要插入多个 HappyPack 的插件每个插件加上 id 来作为标识。同时我们也可以为每个插件设置具体不同的配置项,如使用的线程数、是否开启 debug 模式等。
2. 缩小打包作用域
宏观来说,提升性能的方式无非两种:
- 增加资源
- 缩小范围
HappyPack 属于增加资源(开启多个线程)
1. exclude 和 include
使用 include 和 babel-loader 只生效与 源码目录
module: {
rules: [
{
test: /\.js$/,
include: /src\/scripts/,
loader: 'babel-loader',
}
]
}
2. noParse
希望 Webpack 完全不要去进行解析的,都不希望应用任何 loader 规范,库的内部也不会有对其他模块的依赖,可以使用 noParse 对其进行忽略。
module: {
noParse: /lodash/
}
上面的配置将会忽略所有文件名中包含 lodash 的模块,这些模块仍然会被打包仅资源文件,只不过 webpack 不会对其进行任何解析。
webpack 3 之后至此完整的路径匹配
noParse: fullPath => {
// fullPath 是绝对路径,/User/me/app/webpack-no-parse/lib/lodash.js
return /lib/.test(fullPath)
}
3. IgnorePlugin
exclude 和 include 是确定loader 的规则范围,
noParse 是不去解析但仍会打包到 bundle 中。
IgnorePlugin:可以完全排除一些模块,被排除的模块即便被引用了也不会被打包进资源文件中。(比如 moment.js 是一个日期处理相关的库,为了做本地化它会加载很多语言包,对于我们来说以便用不到其他地区的语言包,但他们会占很多体积,这时就可以用 IgnorePlugin 来去掉。)
plugins: [
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/, // 匹配资源文件
contextRegExp: /moment$/, // 匹配检索目录
})
]
4. Cache
loader 会有一个 cache 配置项,在执行下一次编译前会先检查源码文件是否有变化,如果没有就直接采用缓存,也就是上次编译的结果。相当于实际编译的只有变化了的文件,整体速度上会有一定提升
3. 动态链接库 与 DLLPlugin
动态链接库是早期 Windows 系统由于受限于当时计算机内存空间较小的问题而出现的一种内存优化方法,当一段相同的子程序被多个程序调用时,为了减少内存消耗可以将这段子程序存储为一个可执行文件,当被多个程序调用时只在内存中生成和使用同一个用例。
vendor 配置
首先需要为动态链接库单独创建一个 Webpack 配置文件,
// webpack.vendor.config.js
const path = require('path')
const webpack = require('webpack')
const dllAssetPath = path.join(__dirname, 'dll')
const dllLibraryName = 'dllExample'
module.exports = {
entry: ['react'],
output: {
path: dllAssetsPath,
filename: 'vendor.js',
library: dllLibraryName
},
plugins: [
new webpack.DllPlugin({
name: dllLibraryName,
path: path.join(dllAssetPath, 'manifest.json')
})
]
}
配置中的 entry 指定了把那些模块打包为 vendor,plugins 的部分我们引入了DllPlugin,并添加了以下配置项。
- name:导出的dll library 的名字,他需要与output.library 的值对应。
- path:资源清单的绝对路径,业务代码打包是会将这个清单进行模块索引。
vendor 打包
// package.json
"dll": "webpack --config webpack.vendor.config.js"
dllExample 正是在 webpack 中指定的 dllLibraryName
manifest.json 中有一个 name 字段,这是我们通过DLLPlugin 中的name 配置项指定的。
链接到业务代码
将 vendor 链接到项目中很简单,这里我们将使用与 DllPlugin 配套的插件 DllReferencePlugin,它起到一个索引和链接的作用。通过 DllReferencePlugin 来获取刚刚打包好的资源清单,然后再页面中添加 vendor.js 的引用就可以了
// webpack.config.js
const path = require('path')
const webpack = require('webpack')
module.exports = {
plugins: [
new webpack.DllReferencePlugin({
manifest: require(path.join(__dirname, 'dll/manifest.json'))
})
]
}
<body>
<!-- ... --->
<script src="dll/vendor.js"></script>
<script src="dist/app.js"></script>
</body>
app.js 会先通过 name 字段找到名为 dllExample 的library,在进一步获取其内部模块,这就是我们在 webpack.vendor.config.js 中给 DllPlugin 的 name,和 output.library 赋相同值的原因。
潜在问题
manifest.json 中,可以反向每个模块都有一个 id,其值是按照数字顺序递增的,当我们改变 vendor 是这个数字id 也会随之发送改变
假设我们的工程中目前有以下资源文件,并为每个资源都加上了 chunk hash。
- vendor@[hash].js (DllPlugin 构建)
- page1@[hash].js
- page2@[hash].js
- util@[hash].js
现在 vendor 中有一些模块,不妨假定其中包含了 react,其 id 是5.当尝试添加 更多的模块到 vendor 中时,那么重新进行 Dll 构建时 moment.js 有可能会出现在 react 之前,此时 react 的id 就变为了 6,page1.js 和 page2.js 是通过 id 进行引用的,因此他们的文件内容也相应发生了改变。此时我们可能会面临以下两种情况:
- page1.js 和 page.js 的chunk hash 均发生了变化。这是我们不希望看到的,因为特闷内容本省并没有改变,而现在 vendor 的变化却使得用户必须重新下载所有资源。
- page1.js 和 page2.js 的chunk hash 没有改变。这种情况大多发生在较老版本的 Webpack 中,并且比第一种情况更为糟糕。因为 vendor 中的模块id改变了,而用户却由于没有更新缓存而继续使用过去版本的 page1.js 和 page2.js,也就应用不到 新的 vendor 模块而导致页面错误。对于开发中来说,这个问题很难排查,因为在开发环境下一切都正常的,只有在生成环境会看到页面崩溃。
这个问题的根源在于:当我们对 vendor 进行操作是,本来 vendor 中不应该受到 影响的模块却改变了他们的id。
解决方法:HashedModuledsPlugin
// webpack.config.js
module.exports = {
// ...
plugins: [
new webpack.DllPlugin({
name: dllLibraryName,
path: path.join(dllAssetPath, 'manifest.json')
}),
new webpack.HashedModuleIdsplugin(),
]
}
HashedModuleldsPlugin 可以把 id 的生成算法改为根据模块的引用路径生成一个字符串 hash。它的引用路径不会因为操作 vendor 中的其他模块而改变,id 将会是统一的,这样就解决了我们前面提到的问题。
4. tree shaking
ES6 Module 依赖关系的构建是在代码编译时而非运行时。基于这项特性 Webpack 提供了 truee shaking 功能,没有引用过的模块,在资源压缩是将他们从最终的 bundle 中去掉。
// index.js
import { foo } from './util'
foo()
// util.js
export function foo() {
console.log('foo')
}
export function bar() { // 没有被任何模块引用,死代码。
console.log(bar)
}
在 Webpack 打包时会对 bar() 添加一个标记,在郑成才开发模式下它仍然存在,只是在生产环境的压缩的一步会被移除掉
tree shaking 需要一些前提条件:
- tree shaking 只对 ES6 Module 生效。
使用 Webpack 进行依赖关系构建
如果我们在工程中使用了 babel-loader,那么一定要通过配置来禁用它的模块依赖解析。不然,Webpack接收到的是转化过的 CommonJS 模块,无法进行tree-shaking
module.exports = {
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: [{
loader: 'babel-loader',
options: [
presets: [
[@babel/preset-env, {
modules: false
}]
]
]
}]
}]
}
}
使用压缩工具取出死代码
tree shaking 本身只是为死代码添加上标记,真正取出死代码是通过压缩工具来进行的。terser-webpack-plugin。在 webpack4 之后的版本中,将 mode 设置为 production 也可以达到相同的效果。
6. 开发环境调优
模块热替换
检测到代码改动就会自动重新构建,然后出发网页刷新,这种一般称为live reload。Webpack 则在 live reload 的基础上又进一步,可以让代码在 网页不刷新的前提下得到最新的改动,我们甚至不需要重新发起请求技能看到更新后的效果。这就是模块热替换功能(Hot Module Replacement,HMR)
HMR 对于大型应用尤其适用
1. 开启 HMR
HMR 需要手动开启,并且有一些必要条件。
首先我们要确保项目时基于 webpack-dev-server 或者 webpack-dev-middle 进行开发的,Webpack 本身的命令行并不支持 HMR,下面是一个适用 webpack-dev-server 开启 HMR 的例子。
const webpack = require('webpack')
module.exports = {
// ...
plugins: [
new webpack.HotModuleReplacementPlugin()
],
devServer: {
hot: true
}
}
Webpack 会为每个模块绑定一个 module.hot 对象,这个对象包含了 HMR 的API。借助这些 API 我们不仅可以实现对特定模块开启或关闭 HMR,也可以添加热替换之后的逻辑。
调用 HMR API 有两种方式,一种是手动的添加部分代码;另一种是借助一些线程的工具,比如 react-hot-loader、vue-loader 等。
如果应用的逻辑比较简单,我们可以直接添加代码来开启HMR,
// index.js
import { add } from 'util.js'
add(2, 3)
if (module.hot) {
module.hot.accept();
}
假设 index.js 是引用的入口,那么我们就可以把调用 HMR API 的代码放在该入口中,这样 HMR 对于 index.js 和其他依赖的所有模块都会生效。
大多数时候,建议应的开发者使用第三方提供的 HMR 解决方案。
2. HMR 原理
在开启 HRM 的状态下进行并发,你会发现资源的体积比原本大很多,这是因为 Webpack 为了实现 HMR 而诸如了很多相关代码。
在本地开发环境下,浏览器是客户端,webpack-dev-server(WDS)相当于是我们的服务器,HMR 的核心就是客户端从服务器拉取更新后的资源。
第一步:浏览器上面时候去拉取这些更新,这就需要 WDS 对本地源文件进行监听,实际上 WDS 与浏览器之间维护了一个 websocket,当本地资源发生变化时 WDS 回向浏览器推送 更新时间,并带上这次构建的 hash,让客户端与上一次资源进行对比
websocket 并不是只有开启了 HMR 才会有,live reload 其实也是依赖这个而实现的。
第二步:有了恰当的拉取资源的时机,下一步就是要知道拉取什么。客户端已经知道新的构建结果和当前有了差别,就会像 WDS 发起一个请求来获取更改文件的列表,那些模块有了改动。通常这个请求的名字为[hash].hot-update.json。
该返回结果告诉客户端,需要更新的 chunk 为 main, 版本为(构建 hash) f7cb....。这样客户端就可以再借助这些信息继续向WDS获取该chunk的增量更新。
示例
// index.js
import { logToScreen } from './util.js';
let counter = 0;
console.log('setInterval starts');
setInterval(() => {
counter += 1;
logToScreen(counter)
}, 1000)
// util.js
export function logToScreen(content) {
document.body.innerHTML = `context: ${content}`
}
添加 HMR
if (module.hot) {
module.hot.accept()
}
它会带来一个问题:在当前的运行时我们已经有了一个 setInterval,而每次 HMR 过后又会添加新的 setInterval,并没有对之前的进行清除,所以最后我们会看到屏幕上有不同的数值闪来闪去。
为了避免这个问题,可以让 HMR 不对 index.js 生效,也就是说,当 index.js 发生改变时,就直接让整个页面刷新,以防止逻辑出现问题,但是对于其他模块来说我们还想让HMR继续生效。那么可以将上面的代码修改如下:
if (module.hot) {
module.hot.decline()
module.hot.accept(['./util.js'])
}
module.hot.decline 将当前页面 index.js 的HMR 关掉,module.hot.accept(['./util,js'])的意识是让util.js 改变是任然可以启用 HMR 更新。
更详细的配置参考官方文档。
三、插件
1. webpack-dev-server
webpack-dev-server的主要职能:
- 令 webpack 进行模块打包,并处理打包结果的资源请求
- 作为普通的 Web server。处理静态资源文件请求
直接使用 Webpack 开发和使用 Webpack-dev-server 开发有一个很大的区别,前者每次都会生成 bundle.js。而 webpack-dev-server 只是将打包结果放在内存中,并不会写入实际的 bundle.js。在每次 webpack-dev-server 接收到请求时都只是将内存中的打包结果返回给浏览器。
配置
// webpack.config.js
module.exports = {
entry: './src/index.js',
output: './bundle.js',
mode: 'development',
devServer: {
publicPath: '/dist'
}
}
2. webpack-dashboard
Webpack 每一次构建结束后都会在控制台输出一些打包相关的信息,但是这些信息是以列表的形式展示的,有时会显得不够直观,webpack-dashboard 就是用来更好展示这些信息的。
-
安装
yarn add webpack-dashboard
-
Syntax:
// webpack.config.js const DashboardPlugin = require('webpack-dashboard/plugin') module.exports = { entry: './app.js', output: { filename: '[name].js' }, mode: 'development', plugins: [ new DashboardPlugin() ] }
为了是 webpack-dashboard 生效还要更改一下 webpack 的启动方式,就是用 webpack-dashboard 模块命令替代原本的 webpack 或者 webpack-dev-server 命令,并将所有的启动名称作为参数传递给它,
// package.json before use webpack-dashboard { // ... "scripts": { "dev": "webpack-dev-server" } } // use webpack-dashboard { // ... "scripts": { "dev": "webpack-dashboard -- webpack-dev-server" } }
左上角的 Log 面板就是 Webpack 本身的日志;下面的 Modules 面板则是此次参与打包的模块,从中我们可以看出那些模块资源占用比较多;而从又下的 problems 面板中可以看到构建过程中的警告和错误。
3. webpack-merge
webpack-merge 是一个非常常用的工具。每一个环境对应的配置都不同,但也有一些公关的部分,那么我们就可以将这些公共的部分提取出来。假设我们创建一个 webpack.common.js 来存放这些配置。
-
安装
yarn add webpack-merge
// webpack,common.js
module.exports = {
entry: './app.js',
output: {
filename: '[name].js'
},
modules: {
rules: [
{
test: /\.(png|jpg|gif)$/,
use: 'file-loader'
},
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
}
]
}
}
// webpack.prod.js
const merge = require('webpack-merge')
const commonConfig = require('./webpack.common.js')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
module.exports = merge.smart(commonConfig, {
mode: 'production',
module: {
rules: [
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: 'css-loader'
})
}
]
}
})
在合并 module.rules 的过程中会以 test 属性作为表舒服,当发现有相同项出现的时候会以后面的规则覆盖前面的规则,这样我们就不必添加冗余代码 了。
4. speed-measure-webpack-plugin
简称 SMP: SMP可以分析出 webpack 整个打包过程中在各个loader 和 plugin 上耗费的事件,这件会有助于找出构建过程中的瓶颈。
-
安装
yarn add speed-measure-webpack-plugin
-
Syntax:
SMP的使用非常简单,只要用它的 wrap 方法包裹在 Webpack 的配置对象外面即可。
// webpack.config.js const SpeedMeasurePlugin = require('speed-measure-webpack-plugin') const smp = new SpeedMeasurePlugin() module.exports = smp.wrap({ entry: './app.js' })
执行 webpack 构建命令,将会输出 SMP 的事件测量结果
5. size-plugin
size-plugin 可以帮助我们监控资源体积的变化。
-
安装
yarn add size-plugin
-
Syntax:
const path = require('path') const SizePlugin = require('size-plugin') module.exports = { entry: './app.js', output: { path: path.join(__dirname, 'dist'), filename: '[name].js' }, mode: 'production', plugins: { new SizePlugin() } }
每次 打包后,size-plugin 都会输出本次构建的资源体积(gzip 过后),以及上次构建相比体积变化了多少。