进阶配置

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

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

  1. {
  2. output: {
  3. publicPath: '/assets/'
  4. }
  5. }

webpack-serve 也需要修改:

  1. if (dev) {
  2. module.exports.serve = {
  3. port: 8080,
  4. host: '0.0.0.0',
  5. dev: {
  6. /*
  7. 指定 webpack-dev-middleware 的 publicpath
  8. 一般情况下与 output.publicPath 保持一致(除非 output.publicPath 使用的是相对路径)
  9. https://github.com/webpack/webpack-dev-middleware# publicpath
  10. */
  11. publicPath: '/assets/'
  12. },
  13. add: app => {
  14. app.use(convert(history({
  15. index: '/assets/' // index.html 文件在 /assets/ 路径下
  16. })))
  17. }
  18. }
  19. }

各个页面分开打包

这样浏览器只需加载当前页面所需的代码。

webpack 可以使用异步加载文件的方式引用模块,我们使用 async/
awaitdynamic import 来实现:

src/router.js:

  1. // 将 async/await 转换成 ES5 代码后需要这个运行时库来支持
  2. import 'regenerator-runtime/runtime'
  3. const routes = {
  4. // import() 返回 promise
  5. '/foo': () => import('./views/foo'),
  6. '/bar.do': () => import('./views/bar.do')
  7. }
  8. class Router {
  9. // ...
  10. // 加载 path 路径的页面
  11. // 使用 async/await 语法
  12. async load(path) {
  13. // 首页
  14. if (path === '/') path = '/foo'
  15. // 动态加载页面
  16. const View = (await routes[path]()).default
  17. // 创建页面实例
  18. const view = new View()
  19. // 调用页面方法,把页面加载到 document.body 中
  20. view.mount(document.body)
  21. }
  22. }

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

regenerator-runtimeregenerator
的运行时库。Babel 通过插件 transform-regenerator 使用 regeneratorgenerator 函数和 async/await
语法转换成 ES5 语法后,需要运行时库才能正确执行。

另外因为 import() 还没有正式进入标准,需要使用 syntax-dynamic-import 来解析此语法。
我们可以安装 babel-preset-stage-2,它包含了 import() 和其他 stage 2 的语法支持。

  1. npm install regenerator-runtime babel-preset-stage-2 --save-dev

package.json 改一下:

  1. {
  2. "babel": {
  3. "presets": [
  4. "env",
  5. "stage-2"
  6. ]
  7. }
  8. }

然后修改 webpack 配置:

  1. {
  2. output: {
  3. /*
  4. 代码中引用的文件(js、css、图片等)会根据配置合并为一个或多个包,我们称一个包为 chunk。
  5. 每个 chunk 包含多个 modules。无论是否是 js,webpack 都将引入的文件视为一个 module。
  6. chunkFilename 用来配置这个 chunk 输出的文件名。
  7. [chunkhash]:这个 chunk 的 hash 值,文件发生变化时该值也会变。使用 [chunkhash] 作为文件名可以防止浏览器读取旧的缓存文件。
  8. 还有一个占位符 [id],编译时每个 chunk 会有一个id。
  9. 我们在这里不使用它,因为这个 id 是个递增的数字,增加或减少一个chunk,都可能导致其他 chunk 的 id 发生改变,导致缓存失效。
  10. */
  11. chunkFilename: '[chunkhash].js',
  12. }
  13. }

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

这样更新业务代码时可以借助浏览器缓存,用户不需要重新下载没有发生变化的第三方库。
Webpack 4 最大的改进便是自动拆分 chunk, 如果同时满足下列条件,chunk 就会被拆分:

  • 新的 chunk 能被复用,或者模块是来自 node_modules 目录
  • 新的 chunk 大于 30Kb(min+gz 压缩前)
  • 按需加载 chunk 的并发请求数量小于等于 5 个
  • 页面初始加载时的并发请求数量小于等于 3 个

一般情况只需配置这几个参数即可:

  1. {
  2. plugins: [
  3. // ...
  4. /*
  5. 使用文件路径的 hash 作为 moduleId。
  6. 虽然我们使用 [chunkhash] 作为 chunk 的输出名,但仍然不够。
  7. 因为 chunk 内部的每个 module 都有一个 id,webpack 默认使用递增的数字作为 moduleId。
  8. 如果引入了一个新文件或删掉一个文件,可能会导致其他文件的 moduleId 也发生改变,
  9. 那么受影响的 module 所在的 chunk 的 [chunkhash] 就会发生改变,导致缓存失效。
  10. 因此使用文件路径的 hash 作为 moduleId 来避免这个问题。
  11. */
  12. new webpack.HashedModuleIdsPlugin()
  13. ],
  14. optimization: {
  15. /*
  16. 上面提到 chunkFilename 指定了 chunk 打包输出的名字,那么文件名存在哪里了呢?
  17. 它就存在引用它的文件中。这意味着一个 chunk 文件名发生改变,会导致引用这个 chunk 文件也发生改变。
  18. runtimeChunk 设置为 true, webpack 就会把 chunk 文件名全部存到一个单独的 chunk 中,
  19. 这样更新一个文件只会影响到它所在的 chunk 和 runtimeChunk,避免了引用这个 chunk 的文件也发生改变。
  20. */
  21. runtimeChunk: true,
  22. splitChunks: {
  23. /*
  24. 默认 entry 的 chunk 不会被拆分
  25. 因为我们使用了 html-webpack-plugin 来动态插入 <script> 标签,entry 被拆成多个 chunk 也能自动被插入到 html 中,
  26. 所以我们可以配置成 all, 把 entry chunk 也拆分了
  27. */
  28. chunks: 'all'
  29. }
  30. }
  31. }

webpack 4 支持更多的手动优化,详见: https://gist.github.com/sokra/1522d586b8e5c0f5072d7565c2bee693

但正如 webpack 文档中所说,默认配置已经足够优化,在没有测试的情况下不要盲目手动优化。

输出的 entry 文件加上 hash

上面我们提到了 chunkFilename 使用 [chunkhash] 防止浏览器读取错误缓存,那么 entry 同样需要加上 hash。
但使用 webpack-serve 启动开发环境时,entry 文件是没有 [chunkhash] 的,用了会报错。
因此我们只在执行 webpack-cli 时使用 [chunkhash]

  1. {
  2. output: {
  3. filename: dev ? '[name].js' : '[chunkhash].js'
  4. }
  5. }

这里我们使用了 [name] 占位符。解释它之前我们先了解一下 entry 的完整定义:

  1. {
  2. entry: {
  3. NAME: [FILE1, FILE2, ...]
  4. }
  5. }

我们可以定义多个 entry 文件,比如你的项目有多个 html 入口文件,每个 html 对应一个或多个 entry 文件。
然后每个 entry 可以定义由多个 module 组成,这些 module 会依次执行。
在 webpack 4 之前,这是很有用的功能,比如之前提到的第三方库和业务代码分开打包,在以前,我们需要这么配置:

  1. {
  2. entry {
  3. main: './src/index.js',
  4. vendor: ['jquery', 'lodash']
  5. }
  6. }

entry 引用文件的规则和 import 是一样的,会寻找 node_modules 里的包。然后结合 CommonsChunkPlugin 把 vendor 定义的 module 从业务代码分离出来打包成一个单独的 chunk。
如果 entry 是一个 module,我们可以不使用数组的形式。

在 simple 项目中,我们配置了 entry: './src/index.js',这是最简单的形式,转换成完整的写法就是:

  1. {
  2. entry: {
  3. main: ['./src/index.js']
  4. }
  5. }

webpack 会给这个 entry 指定名字为 main

看到这应该知道 [name] 的意思了吧?它就是 entry 的名字。

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

开发环境关闭 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.

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

webpack 配置中加入:

  1. {
  2. performance: {
  3. hints: 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. [hash] 为源文件的hash值,[ext] 为后缀。
  37. */
  38. name: '[hash].[ext]'
  39. }
  40. }
  41. ]
  42. },
  43. // 图片文件的加载配置增加一个 exclude 参数
  44. {
  45. test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/,
  46. // 排除 favicon.png, 因为它已经由上面的 loader 处理了。如果不排除掉,它会被这个 loader 再处理一遍
  47. exclude: /favicon\.png$/,
  48. use: [
  49. {
  50. loader: 'url-loader',
  51. options: {
  52. limit: 10000
  53. }
  54. }
  55. ]
  56. }
  57. ]
  58. }
  59. }

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

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

  1. const internalIp = require('internal-ip')
  2. module.exports.serve = {
  3. host: '0.0.0.0',
  4. hot: {
  5. host: {
  6. client: internalIp.v4.sync(),
  7. server: '0.0.0.0'
  8. }
  9. },
  10. // ...
  11. }

打包时自定义部分参数

在多人开发时,每个人可能需要有自己的配置,比如说 webpack-serve 监听的端口号,如果写死在 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. serve: {
  4. port: 8090
  5. }
  6. }
  • local.js: 个人本地环境,在 dev.js 基础上修改部分参数。
  1. const config = require('./dev')
  2. config.serve.port = 8070
  3. module.exports = config

package.json 修改 scripts:

  1. {
  2. "scripts": {
  3. "local": "npm run webpack-serve --config=local",
  4. "dev": "npm run webpack-serve --config=dev",
  5. "webpack-serve": "webpack-serve webpack.config.js",
  6. "build": "webpack-cli"
  7. }
  8. }

webpack 配置修改:

  1. // ...
  2. const url = require('url')
  3. const config = require('./config/' + (process.env.npm_config_config || 'default'))
  4. module.exports = {
  5. // ...
  6. output: {
  7. // ...
  8. publicPath: config.publicPath
  9. }
  10. // ...
  11. }
  12. if (dev) {
  13. module.exports.serve = {
  14. host: '0.0.0.0',
  15. port: config.serve.port,
  16. dev: {
  17. publicPath: config.publicPath
  18. },
  19. add: app => {
  20. app.use(convert(history({
  21. index: url.parse(config.publicPath).pathname
  22. })))
  23. }
  24. }
  25. }

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

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

  1. config/*
  2. !config/default.js
  3. !config/dev.js

config 目录排除掉,但是保留生产环境和 dev 默认配置文件。

可能有同学注意到了 webpack-cli 可以通过 —env 的方式从命令行传参给脚本,遗憾的是 webpack-cli 不支持

webpack-serve 处理带后缀名的文件的特殊规则

当处理带后缀名的请求时,比如 http://localhost:8080/bar.doconnect-history-api-fallback 会认为它应该是一个实际存在的文件,就算找不到该文件,也不会 fallback 到 index.html,而是返回 404。但在 SPA 应用中这不是我们希望的。

幸好有一个配置选项 disableDotRule: true 可以禁用这个规则,使带后缀的文件当不存在时也能 fallback 到 index.html

  1. module.exports.serve = {
  2. // ...
  3. add: app => {
  4. app.use(convert(history({
  5. // ...
  6. disableDotRule: true,
  7. htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'] // 需要配合 disableDotRule 一起使用
  8. })))
  9. }
  10. }

代码中插入环境变量

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

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

  1. // ...
  2. const pkgInfo = require('./package.json')
  3. module.exports = {
  4. // ...
  5. plugins: [
  6. new webpack.DefinePlugin({
  7. DEBUG: dev,
  8. VERSION: JSON.stringify(pkgInfo.version),
  9. CONFIG: JSON.stringify(config.runtimeConfig)
  10. }),
  11. // ...
  12. ]
  13. }

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. }