进阶配置
上面的项目虽然可以跑起来了,但有几个点我们还没有考虑到:
- 设置静态资源的 url 路径前缀
- 各个页面分开打包
- 第三方库和业务代码分开打包
- 输出的 entry 文件加上 hash
- 开发环境关闭 performance.hints
- 配置 favicon
- 开发环境允许其他电脑访问
- 打包时自定义部分参数
- webpack-serve 处理路径带后缀名的文件的特殊规则
- 代码中插入环境变量
- 简化 import 路径
- 优化 babel 编译后的代码性能
- 使用 webpack 自带的 ES6 模块处理功能
- 使用 autoprefixer 自动创建 css 的 vendor prefixes
那么,让我们在上面的配置的基础上继续完善,下面的代码我们只写出改变的部分。代码在 examples/advanced 目录。
设置静态资源的 url 路径前缀
现在我们的资源文件的 url 直接在根目录,比如 http://127.0.0.1:8080/index.js
, 这样做缓存控制和 CDN 不是很方便,因此我们给资源文件的 url 加一个前缀,比如 http://127.0.0.1:8080/assets/index.js
. 我们来修改一下 webpack 配置:
{
output: {
publicPath: '/assets/'
}
}
webpack-serve
也需要修改:
if (dev) {
module.exports.serve = {
port: 8080,
host: '0.0.0.0',
dev: {
/*
指定 webpack-dev-middleware 的 publicpath
一般情况下与 output.publicPath 保持一致(除非 output.publicPath 使用的是相对路径)
https://github.com/webpack/webpack-dev-middleware# publicpath
*/
publicPath: '/assets/'
},
add: app => {
app.use(convert(history({
index: '/assets/' // index.html 文件在 /assets/ 路径下
})))
}
}
}
各个页面分开打包
这样浏览器只需加载当前页面所需的代码。
webpack 可以使用异步加载文件的方式引用模块,我们使用 async/
await 和 dynamic import 来实现:
src/router.js:
// 将 async/await 转换成 ES5 代码后需要这个运行时库来支持
import 'regenerator-runtime/runtime'
const routes = {
// import() 返回 promise
'/foo': () => import('./views/foo'),
'/bar.do': () => import('./views/bar.do')
}
class Router {
// ...
// 加载 path 路径的页面
// 使用 async/await 语法
async load(path) {
// 首页
if (path === '/') path = '/foo'
// 动态加载页面
const View = (await routes[path]()).default
// 创建页面实例
const view = new View()
// 调用页面方法,把页面加载到 document.body 中
view.mount(document.body)
}
}
这样我们就不需要在开头把所有页面文件都 import 进来了。
regenerator-runtime 是 regenerator
的运行时库。Babel 通过插件 transform-regenerator 使用 regenerator
将 generator 函数和 async/await
语法转换成 ES5 语法后,需要运行时库才能正确执行。
另外因为 import()
还没有正式进入标准,需要使用 syntax-dynamic-import 来解析此语法。
我们可以安装 babel-preset-stage-2,它包含了 import()
和其他 stage 2 的语法支持。
npm install regenerator-runtime babel-preset-stage-2 --save-dev
package.json
改一下:
{
"babel": {
"presets": [
"env",
"stage-2"
]
}
}
然后修改 webpack 配置:
{
output: {
/*
代码中引用的文件(js、css、图片等)会根据配置合并为一个或多个包,我们称一个包为 chunk。
每个 chunk 包含多个 modules。无论是否是 js,webpack 都将引入的文件视为一个 module。
chunkFilename 用来配置这个 chunk 输出的文件名。
[chunkhash]:这个 chunk 的 hash 值,文件发生变化时该值也会变。使用 [chunkhash] 作为文件名可以防止浏览器读取旧的缓存文件。
还有一个占位符 [id],编译时每个 chunk 会有一个id。
我们在这里不使用它,因为这个 id 是个递增的数字,增加或减少一个chunk,都可能导致其他 chunk 的 id 发生改变,导致缓存失效。
*/
chunkFilename: '[chunkhash].js',
}
}
第三方库和业务代码分开打包
这样更新业务代码时可以借助浏览器缓存,用户不需要重新下载没有发生变化的第三方库。
Webpack 4 最大的改进便是自动拆分 chunk, 如果同时满足下列条件,chunk 就会被拆分:
- 新的 chunk 能被复用,或者模块是来自 node_modules 目录
- 新的 chunk 大于 30Kb(min+gz 压缩前)
- 按需加载 chunk 的并发请求数量小于等于 5 个
- 页面初始加载时的并发请求数量小于等于 3 个
一般情况只需配置这几个参数即可:
{
plugins: [
// ...
/*
使用文件路径的 hash 作为 moduleId。
虽然我们使用 [chunkhash] 作为 chunk 的输出名,但仍然不够。
因为 chunk 内部的每个 module 都有一个 id,webpack 默认使用递增的数字作为 moduleId。
如果引入了一个新文件或删掉一个文件,可能会导致其他文件的 moduleId 也发生改变,
那么受影响的 module 所在的 chunk 的 [chunkhash] 就会发生改变,导致缓存失效。
因此使用文件路径的 hash 作为 moduleId 来避免这个问题。
*/
new webpack.HashedModuleIdsPlugin()
],
optimization: {
/*
上面提到 chunkFilename 指定了 chunk 打包输出的名字,那么文件名存在哪里了呢?
它就存在引用它的文件中。这意味着一个 chunk 文件名发生改变,会导致引用这个 chunk 文件也发生改变。
runtimeChunk 设置为 true, webpack 就会把 chunk 文件名全部存到一个单独的 chunk 中,
这样更新一个文件只会影响到它所在的 chunk 和 runtimeChunk,避免了引用这个 chunk 的文件也发生改变。
*/
runtimeChunk: true,
splitChunks: {
/*
默认 entry 的 chunk 不会被拆分
因为我们使用了 html-webpack-plugin 来动态插入 <script> 标签,entry 被拆成多个 chunk 也能自动被插入到 html 中,
所以我们可以配置成 all, 把 entry chunk 也拆分了
*/
chunks: 'all'
}
}
}
webpack 4 支持更多的手动优化,详见: https://gist.github.com/sokra/1522d586b8e5c0f5072d7565c2bee693
但正如 webpack 文档中所说,默认配置已经足够优化,在没有测试的情况下不要盲目手动优化。
输出的 entry 文件加上 hash
上面我们提到了 chunkFilename
使用 [chunkhash]
防止浏览器读取错误缓存,那么 entry 同样需要加上 hash。
但使用 webpack-serve
启动开发环境时,entry 文件是没有 [chunkhash]
的,用了会报错。
因此我们只在执行 webpack-cli
时使用 [chunkhash]
。
{
output: {
filename: dev ? '[name].js' : '[chunkhash].js'
}
}
这里我们使用了 [name]
占位符。解释它之前我们先了解一下 entry
的完整定义:
{
entry: {
NAME: [FILE1, FILE2, ...]
}
}
我们可以定义多个 entry 文件,比如你的项目有多个 html 入口文件,每个 html 对应一个或多个 entry 文件。
然后每个 entry 可以定义由多个 module 组成,这些 module 会依次执行。
在 webpack 4 之前,这是很有用的功能,比如之前提到的第三方库和业务代码分开打包,在以前,我们需要这么配置:
{
entry {
main: './src/index.js',
vendor: ['jquery', 'lodash']
}
}
entry 引用文件的规则和 import
是一样的,会寻找 node_modules
里的包。然后结合 CommonsChunkPlugin
把 vendor 定义的 module 从业务代码分离出来打包成一个单独的 chunk。
如果 entry 是一个 module,我们可以不使用数组的形式。
在 simple 项目中,我们配置了 entry: './src/index.js'
,这是最简单的形式,转换成完整的写法就是:
{
entry: {
main: ['./src/index.js']
}
}
webpack 会给这个 entry 指定名字为 main
。
看到这应该知道 [name]
的意思了吧?它就是 entry 的名字。
有人可能注意到官网文档中还有一个 [hash]
占位符,这个 hash 是整个编译过程产生的一个总的 hash 值,而不是单个文件的 hash 值,项目中任何一个文件的改动,都会造成这个 hash 值的改变。[hash]
占位符是始终存在的,但我们不希望修改一个文件导致所有输出的文件 hash 都改变,这样就无法利用浏览器缓存了。因此这个 [hash]
意义不大。
开发环境关闭 performance.hints
我们注意到运行开发环境是命令行会报一段 warning:
WARNING in asset size limit: The following asset(s) exceed the recommended size limit (250 kB).
This can impact web performance.
这是说建议每个输出的 js 文件的大小不要超过 250k。但开发环境因为包含了 sourcemap 并且代码未压缩所以一般都会超过这个大小,所以我们可以在开发环境把这个 warning 关闭。
webpack 配置中加入:
{
performance: {
hints: dev ? false : 'warning'
}
}
配置 favicon
在 src 目录中放一张 favicon.png,然后 src/index.html
的 <head>
中插入:
<link rel="icon" type="image/png" href="favicon.png">
修改 webpack 配置:
{
module: {
rules: [
{
test: /\.html$/,
use: [
{
loader: 'html-loader',
options: {
/*
html-loader 接受 attrs 参数,表示什么标签的什么属性需要调用 webpack 的 loader 进行打包。
比如 <img> 标签的 src 属性,webpack 会把 <img> 引用的图片打包,然后 src 的属性值替换为打包后的路径。
使用什么 loader 代码,同样是在 module.rules 定义中使用匹配的规则。
如果 html-loader 不指定 attrs 参数,默认值是 img:src, 意味着会默认打包 <img> 标签的图片。
这里我们加上 <link> 标签的 href 属性,用来打包入口 index.html 引入的 favicon.png 文件。
*/
attrs: ['img:src', 'link:href']
}
}
]
},
{
/*
匹配 favicon.png
上面的 html-loader 会把入口 index.html 引用的 favicon.png 图标文件解析出来进行打包
打包规则就按照这里指定的 loader 执行
*/
test: /favicon\.png$/,
use: [
{
// 使用 file-loader
loader: 'file-loader',
options: {
/*
name:指定文件输出名
[hash] 为源文件的hash值,[ext] 为后缀。
*/
name: '[hash].[ext]'
}
}
]
},
// 图片文件的加载配置增加一个 exclude 参数
{
test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/,
// 排除 favicon.png, 因为它已经由上面的 loader 处理了。如果不排除掉,它会被这个 loader 再处理一遍
exclude: /favicon\.png$/,
use: [
{
loader: 'url-loader',
options: {
limit: 10000
}
}
]
}
]
}
}
其实 html-webpack-plugin 接受一个 favicon
参数,可以指定 favicon 文件路径,会自动打包插入到 html 文件中。但它有个 bug,打包后的文件名路径不带 hash,就算有 hash,它也是 [hash],而不是 [chunkhash]。导致修改代码也会改变 favicon 打包输出的文件名。issue 中提到的 favicons-webpack-plugin 倒是可以用,但它依赖 PhantomJS, 非常大。
开发环境允许其他电脑访问
const internalIp = require('internal-ip')
module.exports.serve = {
host: '0.0.0.0',
hot: {
host: {
client: internalIp.v4.sync(),
server: '0.0.0.0'
}
},
// ...
}
打包时自定义部分参数
在多人开发时,每个人可能需要有自己的配置,比如说 webpack-serve 监听的端口号,如果写死在 webpack 配置里,而那个端口号在某个同学的电脑上被其他进程占用了,简单粗暴的修改 webpack.config.js
会导致提交代码后其他同学的端口也被改掉。
还有一点就是开发环境、测试环境、生产环境的部分 webpack 配置是不同的,比如 publicPath
在生产环境可能要配置一个 CDN 地址。
我们在根目录建立一个文件夹 config
,里面创建 3 个配置文件:
- default.js: 生产环境
module.exports = {
publicPath: 'http://cdn.example.com/assets/'
}
- dev.js: 默认开发环境
module.exports = {
publicPath: '/assets/',
serve: {
port: 8090
}
}
- local.js: 个人本地环境,在 dev.js 基础上修改部分参数。
const config = require('./dev')
config.serve.port = 8070
module.exports = config
package.json
修改 scripts
:
{
"scripts": {
"local": "npm run webpack-serve --config=local",
"dev": "npm run webpack-serve --config=dev",
"webpack-serve": "webpack-serve webpack.config.js",
"build": "webpack-cli"
}
}
webpack 配置修改:
// ...
const url = require('url')
const config = require('./config/' + (process.env.npm_config_config || 'default'))
module.exports = {
// ...
output: {
// ...
publicPath: config.publicPath
}
// ...
}
if (dev) {
module.exports.serve = {
host: '0.0.0.0',
port: config.serve.port,
dev: {
publicPath: config.publicPath
},
add: app => {
app.use(convert(history({
index: url.parse(config.publicPath).pathname
})))
}
}
}
这里的关键是 npm run
传进来的自定义参数可以通过 process.env.npm_config_*
获得。参数中如果有 -
会被转成 _
。
还有一点,我们不需要把自己个人用的配置文件提交到 git,所以我们在 .gitignore
中加入:
config/*
!config/default.js
!config/dev.js
把 config
目录排除掉,但是保留生产环境和 dev 默认配置文件。
可能有同学注意到了 webpack-cli
可以通过 —env 的方式从命令行传参给脚本,遗憾的是 webpack-cli
不支持。
webpack-serve 处理带后缀名的文件的特殊规则
当处理带后缀名的请求时,比如 http://localhost:8080/bar.do ,connect-history-api-fallback
会认为它应该是一个实际存在的文件,就算找不到该文件,也不会 fallback 到 index.html,而是返回 404。但在 SPA 应用中这不是我们希望的。
幸好有一个配置选项 disableDotRule: true
可以禁用这个规则,使带后缀的文件当不存在时也能 fallback 到 index.html
module.exports.serve = {
// ...
add: app => {
app.use(convert(history({
// ...
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'] // 需要配合 disableDotRule 一起使用
})))
}
}
代码中插入环境变量
在业务代码中,有些变量在开发环境和生产环境是不同的,比如域名、后台 API 地址等。还有开发环境可能需要打印调试信息等。
我们可以使用 DefinePlugin 插件在打包时往代码中插入需要的环境变量。
// ...
const pkgInfo = require('./package.json')
module.exports = {
// ...
plugins: [
new webpack.DefinePlugin({
DEBUG: dev,
VERSION: JSON.stringify(pkgInfo.version),
CONFIG: JSON.stringify(config.runtimeConfig)
}),
// ...
]
}
DefinePlugin 插件的原理很简单,如果我们在代码中写:
console.log(DEBUG)
它会做类似这样的处理:
'console.log(DEBUG)'.replace('DEBUG', true)
最后生成:
console.log(true)
这里有一点需要注意,像这里的 VERSION
, 如果我们不对 pkgInfo.version
做 JSON.stringify()
,
console.log(VERSION)
然后做替换操作:
'console.log(VERSION)'.replace('VERSION', '1.0.0')
最后生成:
console.log(1.0.0)
这样语法就错误了。所以,我们需要 JSON.stringify(pkgInfo.version)
转一下变成 '"1.0.0"'
,替换的时候才会带引号。
还有一点,webpack 打包压缩的时候,会把代码进行优化,比如:
if (DEBUG) {
console.log('debug mode')
} else {
console.log('production mode')
}
会被编译成:
if (false) {
console.log('debug mode')
} else {
console.log('production mode')
}
然后压缩优化为:
console.log('production mode')
简化 import 路径
文件 a 引入文件 b 时,b 的路径是相对于 a 文件所在目录的。如果 a 和 b 在不同的目录,藏得又深,写起来就会很麻烦:
import b from '../../../components/b'
为了方便,我们可以定义一个路径别名(alias):
resolve: {
alias: {
'~': resolve(__dirname, 'src')
}
}
这样,我们可以以 src
目录为基础路径来 import
文件:
import b from '~/components/b'
html 中的 <img>
标签没法使用这个别名功能,但 html-loader
有一个 root
参数,可以使 /
开头的文件相对于 root
目录解析。
{
test: /\.html$/,
use: [
{
loader: 'html-loader',
options: {
root: resolve(__dirname, 'src'),
attrs: ['img:src', 'link:href']
}
}
]
}
那么,<img src="/favicon.png">
就能顺利指向到 src 目录下的 favicon.png 文件,不需要关心当前文件和目标文件的相对路径。
PS: 在调试 <img>
标签的时候遇到一个坑,html-loader
会解析 <!-- -->
注释中的内容,之前在注释中写的
<!--
大于 10kb 的图片,图片会被储存到输出目录,src 会被替换为打包后的路径
<img src="/assets/f78661bef717cf2cc2c2e5158f196384.png">
-->
之前因为没有加 root
参数,所以 /
开头的文件名不会被解析,加了 root
导致编译时报错,找不到该文件。大家记住这一点。
优化 babel 编译后的代码性能
babel 编译后的代码一般会造成性能损失,babel 提供了一个 loose 选项,使编译后的代码不需要完全遵循 ES6 规定,简化编译后的代码,提高代码执行效率:
package.json:
{
"babel": {
"presets": [
[
"env",
{
"loose": true
}
],
"stage-2"
]
}
}
但这么做会有兼容性的风险,可能会导致 ES6 源码理应的执行结果和编译后的 ES5 代码的实际结果并不一致。如果代码没有遇到实际的效率瓶颈,官方 不建议 使用 loose
模式。
使用 webpack 自带的 ES6 模块处理功能
我们目前的配置,babel 会把 ES6 模块定义转为 CommonJS 定义,但 webpack 自己可以处理 import
和 export
, 而且 webpack 处理 import
时会做代码优化,把没用到的部分代码删除掉。因此我们通过 babel 提供的 modules: false
选项把 ES6 模块转为 CommonJS 模块的功能给关闭掉。
package.json:
{
"babel": {
"presets": [
[
"env",
{
"loose": true,
"modules": false
}
],
"stage-2"
]
}
}
使用 autoprefixer 自动创建 css 的 vendor prefixes
css 有一个很麻烦的问题就是比较新的 css 属性在各个浏览器里是要加前缀的,我们可以使用 autoprefixer 工具自动创建这些浏览器规则,那么我们的 css 中只需要写:
:fullscreen a {
display: flex
}
autoprefixer 会编译成:
:-webkit-full-screen a {
display: -webkit-box;
display: flex
}
:-moz-full-screen a {
display: flex
}
:-ms-fullscreen a {
display: -ms-flexbox;
display: flex
}
:fullscreen a {
display: -webkit-box;
display: -ms-flexbox;
display: flex
}
首先,我们用 npm 安装它:
npm install postcss-loader autoprefixer --save-dev
autoprefixer 是 postcss 的一个插件,所以我们也要安装 postcss 的 webpack loader。
修改一下 webpack 的 css rule:
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader']
}
然后创建文件 postcss.config.js
:
module.exports = {
plugins: [
require('autoprefixer')()
]
}