进阶配置
上面的项目虽然可以跑起来了, 但有几个点我们还没有考虑到:
- 指定静态资源的url路径前缀
- 各个页面分开打包
- 打包时区分开发环境和生产环境
- 输出的entry文件加上hash
- 第三方库和业务代码分开打包
- 开发环境关闭performance.hints
- 配置favicon
- 开发环境允许其他电脑访问
- 打包时自定义部分参数
- webpack-dev-server处理带后缀名的文件的特殊规则
- 代码中插入环境变量
- 简化import路径
- 优化babel编译后的代码性能
- 使用webpack自带的ES6模块处理功能
- 使用autoprefixer自动创建css的vendor prefixes
那么, 让我们在上面的配置的基础上继续完善, 下面的代码我们只写出改变的部分. 代码在examples/advanced目录.
指定静态资源的url路径前缀
现在我们的资源文件的url直接在根目录, 比如http://127.0.0.1:8100/index.js
, 这样做缓存控制和CDN都不方便, 我们需要给资源文件的url加一个前缀, 比如 http://127.0.0.1:8100/assets/index.js
这样. 我们来修改一下webpack配置:
{
output: {
publicPath: '/assets/'
},
devServer: {
// 指定index.html文件的url路径
historyApiFallback: {
index: '/assets/'
}
}
}
各个页面分开打包
这样浏览器只需加载当前访问的页面的代码.
webpack可以使用异步加载文件的方式引用模块, webpack 1的API是require.ensure(), webpack 2开始支持TC39的dynamic import. 我们这里就使用新的import()
来实现页面分开打包异步加载. 话不多说, 上代码.
src/index.js
:
load(path) {
import('./views' + path + '/index.js').then(module => {
// export default ... 的内容通过module.default访问
const View = module.default
const view = new View()
view.mount(document.body)
})
}
这样我们就不需要在开头把所有页面文件都import进来了.
因为import()
还没有正式进入标准, 因此babel和eslint需要插件来支持它:
npm install babel-eslint babel-preset-stage-2 --save-dev
package.json
改一下:
{
"babel": {
"presets": [
"env",
"stage-2"
]
},
"eslintConfig": {
"parser": "babel-eslint",
"extends": "enough",
"env": {
"browser": true,
"node": true
}
}
}
然后修改webpack配置:
{
output: {
/*
import()加载的文件会被分开打包, 我们称这个包为chunk, chunkFilename用来配置这个chunk输出的文件名.
[chunkhash]: 这个chunk的hash值, 文件发生变化时该值也会变. 使用[chunkhash]作为文件名可以防止浏览器读取旧的缓存文件.
还有一个占位符[id], 编译时每个chunk会有一个id. 我们在这里不使用它, 因为这个id是个递增的数字, 引入一个新的异步加载的文件或删掉一个, 都会导致其他文件的id发生改变, 导致缓存失效.
*/
chunkFilename: '[chunkhash].js',
}
}
打包时区分开发环境和生产环境
如果webpack.config.js导出的是一个function, 那么webpack会执行它, 并把返回的结果作为配置对象.
module.exports = (options = {}) => {
return {
// 配置内容
}
}
该function接受一个参数, 这个参数的值是由命令行传入的. 比如当我们在命令行中执行:
webpack --env.dev --env.server localhost
那么options值为 { dev: true, server: 'localhost' }
该参数对 webpack-dev-server 命令同样有效.
我们修改一下package.json, 给dev脚本加上env.dev:
{
"scripts": {
"dev": "webpack-dev-server -d --hot --env.dev",
}
}
输出的entry文件加上hash
上面我们提到了chunkFilename使用[chunkhash]防止浏览器读取错误缓存, 那么entry同样需要加上hash. 但使用webpack-dev-server启动开发环境时, entry文件是没有[chunkhash]的, 用了会报错. 因此我们需要利用上面提到的区分开发环境和生产环境的功能, 只在打包生产环境代码时加上[chunkhash]
module.exports = (options = {}) => {
return {
/*
这里entry我们改用对象来定义
属性名在下面的output.filename中使用, 值为文件路径
*/
entry: {
index: './src/index',
},
output: {
/*
entry字段配置的入口js的打包输出文件名
[name]作为占位符, 在输出时会被替换为entry里定义的属性名, 比如这里会被替换为"index"
[chunkhash]是打包后输出文件的hash值的占位符, 把[chunkhash]加入文件名可以防止浏览器使用缓存的过期内容,
这里, webpack会生成以下代码插入到index.html中:
<script type="text/javascript" src="/assets/index.d835352892e6aac768bf.js"></script>
这里/assets/目录前缀是output.publicPath配置的
options.dev是命令行传入的参数. 这里是由于使用webpack-dev-server启动开发环境时, 是没有[chunkhash]的, 用了会报错
因此我们不得已在使用webpack-dev-server启动项目时, 命令行跟上--env.dev参数, 当有该参数时, 不在文件名中加入[chunkhash]
*/
filename: options.dev ? '[name].js' : '[name].[chunkhash].js',
}
}
}
有人可能注意到官网文档中还有一个[hash]占位符, 这个hash是整个编译过程产生的一个总的hash值, 而不是单个文件的hash值, 项目中任何一个文件的改动, 都会造成这个hash值的改变. [hash]占位符是始终存在的, 但我们不希望修改一个文件导致所有输出的文件hash都改变, 这样就无法利用浏览器缓存了. 因此这个[hash]意义不大.
第三方库和业务代码分开打包
这样更新业务代码时可以借助浏览器缓存, 用户不需要重新下载没有发生变化的第三方库.
我们的思路是, 入口的html文件引两个js, vendor.js
和index.js
. vendor.js
用来引用第三方库, 比如这儿我们引入一个第三方库来做路由, 我们先安装它:
npm install spa-history --save
然后在vendor.js
中, 我们引用一下它:
import 'spa-history/PathHistory'
我们import
它但不需要做什么, 这样webpack打包的时候会把这个第三方库打包进vendor.js.
然后在src/index.js
中, 我们使用它:
import PathHistory from 'spa-history/PathHistory'
const history = new PathHistory({
async change(location) {
// 使用import()将加载的js文件分开打包, 这样实现了仅加载访问的页面
const module = await import('./views' + location.path + '/index.js')
// export default ... 的内容通过module.default访问
const View = module.default
const view = new View()
view.mount(document.body)
}
})
document.body.addEventListener('click', e => history.captureLinkClickEvent(e))
history.start()
这里我们用到了 async/await, 为了保证浏览器的兼容性, 我们安装一下polyfill:
npm install regenerator-runtime --save
然后在vendor.js
中引入:
import 'regenerator-runtime/runtime'
import 'spa-history/PathHistory'
页面foo
和bar
的js和html文件因为路由的改变也要做些微调.
src/views/foo/index.js
:
import template from './index.html'
import './style.css'
export default class {
mount(container) {
document.title = 'foo'
container.innerHTML = template
}
}
src/views/foo/index.html
:
<div class="foo">
<h1>Page Foo</h1>
<a href="/bar">goto bar</a>
<p>
<img src="smallpic.png">
</p>
<p>
<img src="/views/foo/largepic.png">
</p>
</div>
src/views/bar/index.js
:
import template from './index.html'
import './style.css'
export default class {
mount(container) {
document.title = 'bar'
container.innerHTML = template
}
}
src/views/bar/index.html
:
<div class="bar">
<h1>Page Bar</h1>
<a href="/foo">goto foo</a>
</div>
然后最重要的webpack的配置需要修改一下: (参见webpack官方文档: https://webpack.js.org/guides/caching/ )
// 引入webpack, 等会需要用
const webpack = require('webpack')
module.exports = (options = {}) => {
return {
// entry中加入vendor
entry: {
vendor: './src/vendor',
index: './src/index'
},
plugins: [
/*
使用文件路径的hash作为moduleId
webpack默认使用递增的数字作为moduleId, 如果引入了一个新文件或删掉一个文件, 会导致其他的文件的moduleId也发生改变,
这样未发生改变的文件在打包后会生成新的[chunkhash], 导致缓存失效
*/
new webpack.HashedModuleIdsPlugin(),
/*
使用CommonsChunkPlugin插件来处理重复代码
因为vendor.js和index.js都引用了spa-history, 如果不处理的话, 两个文件里都会有spa-history包的代码,
我们用CommonsChunkPlugin插件来使共同引用的文件只打包进vendor.js
*/
new webpack.optimize.CommonsChunkPlugin({
/*
names: 将entry文件中引用的相同文件打包进指定的文件, 可以是新建文件, 也可以是entry中已存在的文件
这里我们指定打包进vendor.js
但这样还不够, 还记得那个chunkFilename参数吗? 这个参数指定了chunk的打包输出的名字,
我们设置为 [chunkhash].js 的格式. 那么打包时这个文件名存在哪里的呢?
它就存在引用它的文件中. 这就意味着被引用的文件发生改变, 会导致引用的它文件也发生改变.
然后CommonsChunkPlugin有个附加效果, 会把所有chunk的文件名记录到names指定的文件中.
那么这时当我们修改页面foo或者bar时, vendor.js也会跟着改变, 而index.js不会变.
那么怎么处理这些chunk, 使得修改页面代码而不会导致entry文件改变呢?
这里我们用了一点小技巧. names参数可以是一个数组, 意思相当于调用多次CommonsChunkPlugin,
比如:
plugins: [
new webpack.optimize.CommonsChunkPlugin({
names: ['vendor', 'manifest']
})
]
相当于
plugins: [
new webpack.optimize.CommonsChunkPlugin({
names: 'vendor'
}),
new webpack.optimize.CommonsChunkPlugin({
names: 'manifest'
})
]
首先把重复引用的库打包进vendor.js, 这时候我们的代码里已经没有重复引用了, chunk文件名存在vendor.js中,
然后我们在执行一次CommonsChunkPlugin, 把所有chunk的文件名打包到manifest.js中.
这样我们就实现了chunk文件名和代码的分离. 这样修改一个js文件不会导致其他js文件在打包时发生改变, 只有manifest.js会改变.
*/
names: ['vendor', 'manifest']
})
]
}
}
开发环境关闭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: options.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: 指定文件输出名
[name]是源文件名, 不包含后缀. [ext]为后缀. [hash]为源文件的hash值,
我们加上[hash]防止浏览器读取过期的缓存文件.
*/
name: '[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, 非常大.
开发环境允许其他电脑访问
{
devServer: {
host: '0.0.0.0',
disableHostCheck: true
}
}
打包时自定义部分参数
在多人开发时, 每个人可能需要有自己的配置, 比如说webpack-dev-server监听的端口号, 如果写死在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/',
devServer: {
port: 8100,
proxy: {
'/api/auth/': {
target: 'http://api.example.dev',
changeOrigin: true,
pathRewrite: { '^/api': '' }
},
'/api/pay/': {
target: 'http://pay.example.dev',
changeOrigin: true,
pathRewrite: { '^/api': '' }
}
}
}
}
local.js
: 个人本地环境, 在dev.js基础上修改部分参数.
const config = require('./dev')
config.devServer.port = 8200
module.exports = config
package.json
修改scripts
:
{
"scripts": {
"local": "npm run dev --config=local",
"dev": "webpack-dev-server -d --hot --env.dev --env.config dev",
"build": "webpack -p"
}
}
webpack配置修改:
// ...
const url = require('url')
module.exports = (options = {}) => {
const config = require('./config/' + (process.env.npm_config_config || options.config || 'default'))
return {
// ...
devServer: config.devServer ? {
host: '0.0.0.0',
port: config.devServer.port,
proxy: config.devServer.proxy,
historyApiFallback: {
index: url.parse(config.publicPath).pathname
}
} : undefined,
}
}
这里的关键是npm run
传进来的自定义参数可以通过process.env.npm_config_*
获得. 参数中如果有-
会被转成_
--env.*
传进来的参数可以通过options.*
获得. 我们优先使用npm run
指定的配置文件. 这样我们可以在命令行覆盖scripts中指定的配置文件:
npm run dev --config=CONFIG_NAME
local
命令就是这样做的.
这样, 当我们执行npm run dev
时使用的是dev.js
, 执行npm run local
使用local.js
, 执行npm run build
使用default.js
.
config.devServer.proxy
用来配置后端api的反向代理, ajax /api/auth/*
的请求会被转发到 http://api.example.dev/auth/*
, /api/pay/*
的请求会被转发到 http://api.example.dev/pay/*
.
changeOrigin
会修改HTTP请求头中的Host
为target
的域名, 这里会被改为api.example.dev
pathRewrite
用来改写URL, 这里我们把/api
前缀去掉.
还有一点, 我们不需要把自己个人用的配置文件提交到git, 所以我们在.gitignore中加入:
config/*
!config/default.js
!config/dev.js
把config
目录排除掉, 但是保留生产环境和dev默认配置文件.
webpack-dev-server处理带后缀名的文件的特殊规则
当处理带后缀名的请求时, 比如 http://localhost:8100/bar.do , webpack-dev-server会认为它应该是一个实际存在的文件, 就算找不到该文件, 也不会fallback到index.html, 而是返回404. 但在SPA应用中这不是我们希望的. 幸好webpack-dev-server有一个配置选项disableDotRule: true
可以禁用这个规则, 使带后缀的文件当不存在时也能fallback到index.html
historyApiFallback: {
index: url.parse(config.publicPath).pathname,
disableDotRule: true
}
代码中插入环境变量
在业务代码中, 有些变量在开发环境和生产环境是不同的, 比如域名, 后台API地址等. 还有开发环境可能需要打印调试信息等.
我们可以使用DefinePlugin插件在打包时往代码中插入需要的环境变量,
// ...
const pkgInfo = require('./package.json')
module.exports = (options = {}) => {
const config = require('./config/' + (process.env.npm_config_config || options.config || 'default')).default
return {
// ...
plugins: [
new webpack.DefinePlugin({
DEBUG: Boolean(options.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')()
]
}