进阶配置

上面的项目虽然可以跑起来了, 但有几个点我们还没有考虑到:

  • 指定静态资源的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配置:

  1. {
  2. output: {
  3. publicPath: '/assets/'
  4. },
  5. devServer: {
  6. // 指定index.html文件的url路径
  7. historyApiFallback: {
  8. index: '/assets/'
  9. }
  10. }
  11. }

各个页面分开打包

这样浏览器只需加载当前访问的页面的代码.

webpack可以使用异步加载文件的方式引用模块, webpack 1的API是require.ensure(), webpack 2开始支持TC39的dynamic import. 我们这里就使用新的import()来实现页面分开打包异步加载. 话不多说, 上代码.

src/index.js:

  1. load(path) {
  2. import('./views' + path + '/index.js').then(module => {
  3. // export default ... 的内容通过module.default访问
  4. const View = module.default
  5. const view = new View()
  6. view.mount(document.body)
  7. })
  8. }

这样我们就不需要在开头把所有页面文件都import进来了.

因为import()还没有正式进入标准, 因此babel和eslint需要插件来支持它:

  1. npm install babel-eslint babel-preset-stage-2 --save-dev

package.json改一下:

  1. {
  2. "babel": {
  3. "presets": [
  4. "env",
  5. "stage-2"
  6. ]
  7. },
  8. "eslintConfig": {
  9. "parser": "babel-eslint",
  10. "extends": "enough",
  11. "env": {
  12. "browser": true,
  13. "node": true
  14. }
  15. }
  16. }

然后修改webpack配置:

  1. {
  2. output: {
  3. /*
  4. import()加载的文件会被分开打包, 我们称这个包为chunk, chunkFilename用来配置这个chunk输出的文件名.
  5. [chunkhash]: 这个chunk的hash值, 文件发生变化时该值也会变. 使用[chunkhash]作为文件名可以防止浏览器读取旧的缓存文件.
  6. 还有一个占位符[id], 编译时每个chunk会有一个id. 我们在这里不使用它, 因为这个id是个递增的数字, 引入一个新的异步加载的文件或删掉一个, 都会导致其他文件的id发生改变, 导致缓存失效.
  7. */
  8. chunkFilename: '[chunkhash].js',
  9. }
  10. }

打包时区分开发环境和生产环境

如果webpack.config.js导出的是一个function, 那么webpack会执行它, 并把返回的结果作为配置对象.

  1. module.exports = (options = {}) => {
  2. return {
  3. // 配置内容
  4. }
  5. }

该function接受一个参数, 这个参数的值是由命令行传入的. 比如当我们在命令行中执行:

  1. webpack --env.dev --env.server localhost

那么options值为 { dev: true, server: 'localhost' }

该参数对 webpack-dev-server 命令同样有效.

我们修改一下package.json, 给dev脚本加上env.dev:

  1. {
  2. "scripts": {
  3. "dev": "webpack-dev-server -d --hot --env.dev",
  4. }
  5. }

输出的entry文件加上hash

上面我们提到了chunkFilename使用[chunkhash]防止浏览器读取错误缓存, 那么entry同样需要加上hash. 但使用webpack-dev-server启动开发环境时, entry文件是没有[chunkhash]的, 用了会报错. 因此我们需要利用上面提到的区分开发环境和生产环境的功能, 只在打包生产环境代码时加上[chunkhash]

  1. module.exports = (options = {}) => {
  2. return {
  3. /*
  4. 这里entry我们改用对象来定义
  5. 属性名在下面的output.filename中使用, 值为文件路径
  6. */
  7. entry: {
  8. index: './src/index',
  9. },
  10. output: {
  11. /*
  12. entry字段配置的入口js的打包输出文件名
  13. [name]作为占位符, 在输出时会被替换为entry里定义的属性名, 比如这里会被替换为"index"
  14. [chunkhash]是打包后输出文件的hash值的占位符, 把[chunkhash]加入文件名可以防止浏览器使用缓存的过期内容,
  15. 这里, webpack会生成以下代码插入到index.html中:
  16. <script type="text/javascript" src="/assets/index.d835352892e6aac768bf.js"></script>
  17. 这里/assets/目录前缀是output.publicPath配置的
  18. options.dev是命令行传入的参数. 这里是由于使用webpack-dev-server启动开发环境时, 是没有[chunkhash]的, 用了会报错
  19. 因此我们不得已在使用webpack-dev-server启动项目时, 命令行跟上--env.dev参数, 当有该参数时, 不在文件名中加入[chunkhash]
  20. */
  21. filename: options.dev ? '[name].js' : '[name].[chunkhash].js',
  22. }
  23. }
  24. }

有人可能注意到官网文档中还有一个[hash]占位符, 这个hash是整个编译过程产生的一个总的hash值, 而不是单个文件的hash值, 项目中任何一个文件的改动, 都会造成这个hash值的改变. [hash]占位符是始终存在的, 但我们不希望修改一个文件导致所有输出的文件hash都改变, 这样就无法利用浏览器缓存了. 因此这个[hash]意义不大.

第三方库和业务代码分开打包

这样更新业务代码时可以借助浏览器缓存, 用户不需要重新下载没有发生变化的第三方库.

我们的思路是, 入口的html文件引两个js, vendor.jsindex.js. vendor.js用来引用第三方库, 比如这儿我们引入一个第三方库来做路由, 我们先安装它:

  1. npm install spa-history --save

然后在vendor.js中, 我们引用一下它:

  1. import 'spa-history/PathHistory'

我们import它但不需要做什么, 这样webpack打包的时候会把这个第三方库打包进vendor.js.

然后在src/index.js中, 我们使用它:

  1. import PathHistory from 'spa-history/PathHistory'
  2. const history = new PathHistory({
  3. async change(location) {
  4. // 使用import()将加载的js文件分开打包, 这样实现了仅加载访问的页面
  5. const module = await import('./views' + location.path + '/index.js')
  6. // export default ... 的内容通过module.default访问
  7. const View = module.default
  8. const view = new View()
  9. view.mount(document.body)
  10. }
  11. })
  12. document.body.addEventListener('click', e => history.captureLinkClickEvent(e))
  13. history.start()

这里我们用到了 async/await, 为了保证浏览器的兼容性, 我们安装一下polyfill:

  1. npm install regenerator-runtime --save

然后在vendor.js中引入:

  1. import 'regenerator-runtime/runtime'
  2. import 'spa-history/PathHistory'

页面foobar的js和html文件因为路由的改变也要做些微调.

src/views/foo/index.js:

  1. import template from './index.html'
  2. import './style.css'
  3. export default class {
  4. mount(container) {
  5. document.title = 'foo'
  6. container.innerHTML = template
  7. }
  8. }

src/views/foo/index.html:

  1. <div class="foo">
  2. <h1>Page Foo</h1>
  3. <a href="/bar">goto bar</a>
  4. <p>
  5. <img src="smallpic.png">
  6. </p>
  7. <p>
  8. <img src="/views/foo/largepic.png">
  9. </p>
  10. </div>

src/views/bar/index.js:

  1. import template from './index.html'
  2. import './style.css'
  3. export default class {
  4. mount(container) {
  5. document.title = 'bar'
  6. container.innerHTML = template
  7. }
  8. }

src/views/bar/index.html:

  1. <div class="bar">
  2. <h1>Page Bar</h1>
  3. <a href="/foo">goto foo</a>
  4. </div>

然后最重要的webpack的配置需要修改一下: (参见webpack官方文档: https://webpack.js.org/guides/caching/ )

  1. // 引入webpack, 等会需要用
  2. const webpack = require('webpack')
  3. module.exports = (options = {}) => {
  4. return {
  5. // entry中加入vendor
  6. entry: {
  7. vendor: './src/vendor',
  8. index: './src/index'
  9. },
  10. plugins: [
  11. /*
  12. 使用文件路径的hash作为moduleId
  13. webpack默认使用递增的数字作为moduleId, 如果引入了一个新文件或删掉一个文件, 会导致其他的文件的moduleId也发生改变,
  14. 这样未发生改变的文件在打包后会生成新的[chunkhash], 导致缓存失效
  15. */
  16. new webpack.HashedModuleIdsPlugin(),
  17. /*
  18. 使用CommonsChunkPlugin插件来处理重复代码
  19. 因为vendor.js和index.js都引用了spa-history, 如果不处理的话, 两个文件里都会有spa-history包的代码,
  20. 我们用CommonsChunkPlugin插件来使共同引用的文件只打包进vendor.js
  21. */
  22. new webpack.optimize.CommonsChunkPlugin({
  23. /*
  24. names: 将entry文件中引用的相同文件打包进指定的文件, 可以是新建文件, 也可以是entry中已存在的文件
  25. 这里我们指定打包进vendor.js
  26. 但这样还不够, 还记得那个chunkFilename参数吗? 这个参数指定了chunk的打包输出的名字,
  27. 我们设置为 [chunkhash].js 的格式. 那么打包时这个文件名存在哪里的呢?
  28. 它就存在引用它的文件中. 这就意味着被引用的文件发生改变, 会导致引用的它文件也发生改变.
  29. 然后CommonsChunkPlugin有个附加效果, 会把所有chunk的文件名记录到names指定的文件中.
  30. 那么这时当我们修改页面foo或者bar时, vendor.js也会跟着改变, 而index.js不会变.
  31. 那么怎么处理这些chunk, 使得修改页面代码而不会导致entry文件改变呢?
  32. 这里我们用了一点小技巧. names参数可以是一个数组, 意思相当于调用多次CommonsChunkPlugin,
  33. 比如:
  34. plugins: [
  35. new webpack.optimize.CommonsChunkPlugin({
  36. names: ['vendor', 'manifest']
  37. })
  38. ]
  39. 相当于
  40. plugins: [
  41. new webpack.optimize.CommonsChunkPlugin({
  42. names: 'vendor'
  43. }),
  44. new webpack.optimize.CommonsChunkPlugin({
  45. names: 'manifest'
  46. })
  47. ]
  48. 首先把重复引用的库打包进vendor.js, 这时候我们的代码里已经没有重复引用了, chunk文件名存在vendor.js中,
  49. 然后我们在执行一次CommonsChunkPlugin, 把所有chunk的文件名打包到manifest.js中.
  50. 这样我们就实现了chunk文件名和代码的分离. 这样修改一个js文件不会导致其他js文件在打包时发生改变, 只有manifest.js会改变.
  51. */
  52. names: ['vendor', 'manifest']
  53. })
  54. ]
  55. }
  56. }

开发环境关闭performance.hints

我们注意到运行开发环境是命令行会报一段warning:

  1. WARNING in asset size limit: The following asset(s) exceed the recommended size limit (250 kB).
  2. This can impact web performance.
  3. ...

这是说建议每个输出的js文件的大小不要超过250k. 但开发环境因为包含了sourcemap并且代码未压缩所以一般都会超过这个大小, 所以我们可以在开发环境把这个warning关闭.

webpack配置中加入:

  1. {
  2. performance: {
  3. hints: options.dev ? false : 'warning'
  4. }
  5. }

配置favicon

在src目录中放一张favicon.png, 然后src/index.html<head>中插入:

  1. <link rel="icon" type="image/png" href="favicon.png">

修改webpack配置:

  1. {
  2. module: {
  3. rules: [
  4. {
  5. test: /\.html$/,
  6. use: [
  7. {
  8. loader: 'html-loader',
  9. options: {
  10. /*
  11. html-loader接受attrs参数, 表示什么标签的什么属性需要调用webpack的loader进行打包.
  12. 比如<img>标签的src属性, webpack会把<img>引用的图片打包, 然后src的属性值替换为打包后的路径.
  13. 使用什么loader代码, 同样是在module.rules定义中使用匹配的规则.
  14. 如果html-loader不指定attrs参数, 默认值是img:src, 意味着会默认打包<img>标签的图片.
  15. 这里我们加上<link>标签的href属性, 用来打包入口index.html引入的favicon.png文件.
  16. */
  17. attrs: ['img:src', 'link:href']
  18. }
  19. }
  20. ]
  21. },
  22. {
  23. /*
  24. 匹配favicon.png
  25. 上面的html-loader会把入口index.html引用的favicon.png图标文件解析出来进行打包
  26. 打包规则就按照这里指定的loader执行
  27. */
  28. test: /favicon\.png$/,
  29. use: [
  30. {
  31. // 使用file-loader
  32. loader: 'file-loader',
  33. options: {
  34. /*
  35. name: 指定文件输出名
  36. [name]是源文件名, 不包含后缀. [ext]为后缀. [hash]为源文件的hash值,
  37. 我们加上[hash]防止浏览器读取过期的缓存文件.
  38. */
  39. name: '[name].[hash].[ext]'
  40. }
  41. }
  42. ]
  43. },
  44. // 图片文件的加载配置增加一个exclude参数
  45. {
  46. test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/,
  47. // 排除favicon.png, 因为它已经由上面的loader处理了. 如果不排除掉, 它会被这个loader再处理一遍
  48. exclude: /favicon\.png$/,
  49. use: [
  50. {
  51. loader: 'url-loader',
  52. options: {
  53. limit: 10000
  54. }
  55. }
  56. ]
  57. }
  58. ]
  59. }
  60. }

其实html-webpack-plugin接受一个favicon参数, 可以指定favicon文件路径, 会自动打包插入到html文件中. 但它有个bug, 打包后的文件名路径不带hash, 就算有hash, 它也是[hash], 而不是[chunkhash], 导致修改代码也会改变favicon打包输出的文件名. issue中提到的favicons-webpack-plugin倒是可以用, 但它依赖PhantomJS, 非常大.

开发环境允许其他电脑访问

  1. {
  2. devServer: {
  3. host: '0.0.0.0',
  4. disableHostCheck: true
  5. }
  6. }

打包时自定义部分参数

在多人开发时, 每个人可能需要有自己的配置, 比如说webpack-dev-server监听的端口号, 如果写死在webpack配置里, 而那个端口号在某个同学的电脑上被其他进程占用了, 简单粗暴的修改webpack.config.js会导致提交代码后其他同学的端口也被改掉.

还有一点就是开发环境/测试环境/生产环境的部分webpack配置是不同的, 比如publicPath在生产环境可能要配置一个CDN地址.

我们在根目录建立一个文件夹config, 里面创建3个配置文件:

  • default.js: 生产环境
  1. module.exports = {
  2. publicPath: 'http://cdn.example.com/assets/'
  3. }
  • dev.js: 默认开发环境
  1. module.exports = {
  2. publicPath: '/assets/',
  3. devServer: {
  4. port: 8100,
  5. proxy: {
  6. '/api/auth/': {
  7. target: 'http://api.example.dev',
  8. changeOrigin: true,
  9. pathRewrite: { '^/api': '' }
  10. },
  11. '/api/pay/': {
  12. target: 'http://pay.example.dev',
  13. changeOrigin: true,
  14. pathRewrite: { '^/api': '' }
  15. }
  16. }
  17. }
  18. }
  • local.js: 个人本地环境, 在dev.js基础上修改部分参数.
  1. const config = require('./dev')
  2. config.devServer.port = 8200
  3. module.exports = config

package.json修改scripts:

  1. {
  2. "scripts": {
  3. "local": "npm run dev --config=local",
  4. "dev": "webpack-dev-server -d --hot --env.dev --env.config dev",
  5. "build": "webpack -p"
  6. }
  7. }

webpack配置修改:

  1. // ...
  2. const url = require('url')
  3. module.exports = (options = {}) => {
  4. const config = require('./config/' + (process.env.npm_config_config || options.config || 'default'))
  5. return {
  6. // ...
  7. devServer: config.devServer ? {
  8. host: '0.0.0.0',
  9. port: config.devServer.port,
  10. proxy: config.devServer.proxy,
  11. historyApiFallback: {
  12. index: url.parse(config.publicPath).pathname
  13. }
  14. } : undefined,
  15. }
  16. }

这里的关键是npm run传进来的自定义参数可以通过process.env.npm_config_*获得. 参数中如果有-会被转成_

--env.*传进来的参数可以通过options.*获得. 我们优先使用npm run指定的配置文件. 这样我们可以在命令行覆盖scripts中指定的配置文件:

  1. 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请求头中的Hosttarget的域名, 这里会被改为api.example.dev

pathRewrite用来改写URL, 这里我们把/api前缀去掉.

还有一点, 我们不需要把自己个人用的配置文件提交到git, 所以我们在.gitignore中加入:

  1. config/*
  2. !config/default.js
  3. !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

  1. historyApiFallback: {
  2. index: url.parse(config.publicPath).pathname,
  3. disableDotRule: true
  4. }

代码中插入环境变量

在业务代码中, 有些变量在开发环境和生产环境是不同的, 比如域名, 后台API地址等. 还有开发环境可能需要打印调试信息等.

我们可以使用DefinePlugin插件在打包时往代码中插入需要的环境变量,

  1. // ...
  2. const pkgInfo = require('./package.json')
  3. module.exports = (options = {}) => {
  4. const config = require('./config/' + (process.env.npm_config_config || options.config || 'default')).default
  5. return {
  6. // ...
  7. plugins: [
  8. new webpack.DefinePlugin({
  9. DEBUG: Boolean(options.dev),
  10. VERSION: JSON.stringify(pkgInfo.version),
  11. CONFIG: JSON.stringify(config.runtimeConfig)
  12. })
  13. ]
  14. }
  15. }

DefinePlugin插件的原理很简单, 如果我们在代码中写:

  1. console.log(DEBUG)

它会做类似这样的处理:

  1. 'console.log(DEBUG)'.replace('DEBUG', true)

最后生成:

  1. console.log(true)

这里有一点需要注意, 像这里的VERSION, 如果我们不对pkgInfo.versionJSON.stringify(),

  1. console.log(VERSION)

然后做替换操作:

  1. 'console.log(VERSION)'.replace('VERSION', '1.0.0')

最后生成:

  1. console.log(1.0.0)

这样语法就错误了. 所以, 我们需要JSON.stringify(pkgInfo.version)转一下变成'"1.0.0"', 替换的时候才会带引号.

还有一点, webpack打包压缩的时候, 会把代码进行优化, 比如:

  1. if (DEBUG) {
  2. console.log('debug mode')
  3. } else {
  4. console.log('production mode')
  5. }

会被编译成:

  1. if (false) {
  2. console.log('debug mode')
  3. } else {
  4. console.log('production mode')
  5. }

然后压缩优化为:

  1. console.log('production mode')

简化import路径

文件a引入文件b时, b的路径是相对于a文件所在目录的. 如果a和b在不同的目录, 藏得又深, 写起来就会很麻烦:

  1. import b from '../../../components/b'

为了方便, 我们可以定义一个路径别名(alias):

  1. resolve: {
  2. alias: {
  3. '~': resolve(__dirname, 'src')
  4. }
  5. }

这样, 我们可以以src目录为基础路径来import文件:

  1. import b from '~/components/b'

html中的<img>标签没法使用这个别名功能, 但html-loader有一个root参数, 可以使/开头的文件相对于root目录解析.

  1. {
  2. test: /\.html$/,
  3. use: [
  4. {
  5. loader: 'html-loader',
  6. options: {
  7. root: resolve(__dirname, 'src'),
  8. attrs: ['img:src', 'link:href']
  9. }
  10. }
  11. ]
  12. }

那么, <img src="/favicon.png">就能顺利指向到src目录下的favicon.png文件, 不需要关心当前文件和目标文件的相对路径.

PS: 在调试<img>标签的时候遇到一个坑, html-loader会解析<!-- -->注释中的内容, 之前在注释中写的

  1. <!--
  2. 大于10kb的图片, 图片会被储存到输出目录, src会被替换为打包后的路径
  3. <img src="/assets/f78661bef717cf2cc2c2e5158f196384.png">
  4. -->

之前因为没有加root参数, 所以/开头的文件名不会被解析, 加了root导致编译时报错, 找不到该文件. 大家记住这一点.

优化babel编译后的代码性能

babel编译后的代码一般会造成性能损失, babel提供了一个loose选项, 使编译后的代码不需要完全遵循ES6规定, 简化编译后的代码, 提高代码执行效率:

package.json:

  1. {
  2. "babel": {
  3. "presets": [
  4. [
  5. "env",
  6. {
  7. "loose": true
  8. }
  9. ],
  10. "stage-2"
  11. ]
  12. }
  13. }

但这么做会有兼容性的风险, 可能会导致ES6源码理应的执行结果和编译后的ES5代码的实际结果并不一致. 如果代码没有遇到实际的效率瓶颈, 官方不建议使用loose模式.

使用webpack自带的ES6模块处理功能

我们目前的配置, babel会把ES6模块定义转为CommonJS定义, 但webpack自己可以处理importexport, 而且webpack处理import时会做代码优化, 把没用到的部分代码删除掉. 因此我们通过babel提供的modules: false选项把ES6模块转为CommonJS模块的功能给关闭掉.

package.json:

  1. {
  2. "babel": {
  3. "presets": [
  4. [
  5. "env",
  6. {
  7. "loose": true,
  8. "modules": false
  9. }
  10. ],
  11. "stage-2"
  12. ]
  13. }
  14. }

使用autoprefixer自动创建css的vendor prefixes

css有一个很麻烦的问题就是比较新的css属性在各个浏览器里是要加前缀的, 我们可以使用autoprefixer工具自动创建这些浏览器规则, 那么我们的css中只需要写:

  1. :fullscreen a {
  2. display: flex
  3. }

autoprefixer会编译成:

  1. :-webkit-full-screen a {
  2. display: -webkit-box;
  3. display: flex
  4. }
  5. :-moz-full-screen a {
  6. display: flex
  7. }
  8. :-ms-fullscreen a {
  9. display: -ms-flexbox;
  10. display: flex
  11. }
  12. :fullscreen a {
  13. display: -webkit-box;
  14. display: -ms-flexbox;
  15. display: flex
  16. }

首先, 我们用npm安装它:

  1. npm install postcss-loader autoprefixer --save-dev

autoprefixer是postcss的一个插件, 所以我们也要安装postcss的webpack loader.

修改一下webpack的css rule:

  1. {
  2. test: /\.css$/,
  3. use: ['style-loader', 'css-loader', 'postcss-loader']
  4. }

然后创建文件postcss.config.js:

  1. module.exports = {
  2. plugins: [
  3. require('autoprefixer')()
  4. ]
  5. }