Webpack 4 和单页应用入门

写在开头

先说说为什么要写这篇文章,最初的原因是组里的小朋友们看了 webpack 文档后,表情都是这样的:摘自 webpack 一篇文档的评论区)

WTF

和这样的:

You Couldn't Handle Me

是的,即使是外国佬也在吐槽这文档不是人能看的。回想起当年自己啃 webpack 文档的血与泪的往事,觉得有必要整一个教程,可以让大家看完后愉悦地搭建起一个 webpack 打包方案的项目。

官网新的 webpack 文档现在写的很详细了,能看英文的小伙伴可以直接去看官网。

可能会有人问 webpack 到底有什么用,你不能上来就糊我一脸代码让我马上搞,我照着搞了一遍结果根本没什么用,都是骗人的。所以,在说 webpack 之前,我想先谈一下前端打包方案这几年的演进历程,在什么场景下,我们遇到了什么问题,催生出了应对这些问题的工具。了解了需求和目的之后,你就知道什么时候 webpack 可以帮到你。我希望我用完之后很爽,你们用完之后也是。

先说说前端打包方案的黑暗历史

在很长的一段前端历史里,是不存在打包这个说法的。那个时候页面基本是纯静态的或者服务端输出的,没有 AJAX,也没有 jQuery。那个时候的 JavaScript 就像个玩具,用处大概就是在侧栏弄个时钟,用 media player 放个 mp3 之类的脚本,代码量不是很多,直接放在 <script> 标签里或者弄个 js 文件引一下就行,日子过得很轻松愉快。

随后的几年,人们开始尝试在一个页面里做更多的事情。容器的显示,隐藏,切换。用 css 写的弹层,图片轮播等等。但如果一个页面内不能向服务器请求数据,能做的事情毕竟有限的,代码的量也能维持在页面交互逻辑范围内。这时候很多人开始突破一个页面能做的事情的范围,使用隐藏的 iframe 和 flash 等作为和服务器通信的桥梁,新世界的大门慢慢地被打开,在一个页面内和服务器进行数据交互,意味着以前需要跳转多个页面的事情现在可以用一个页面搞定。但由于 iframe 和 flash 技术过于 tricky 和复杂,并没能得到广泛的推广。

直到 Google 推出 Gmail 的时候(2004 年),人们意识到了一个被忽略的接口,XMLHttpRequest, 也就是我们俗称的 AJAX, 这是一个使用方便的,兼容性良好的服务器通信接口。从此开始,我们的页面开始玩出各种花来了,前端一下子出现了各种各样的库,PrototypeDojoMooToolsExt JSjQuery…… 我们开始往页面里插入各种库和插件,我们的 js 文件也就爆炸了。

随着 js 能做的事情越来越多,引用越来越多,文件越来越大,加上当时大约只有 2Mbps 左右的网速,下载速度还不如 3G 网络,对 js 文件的压缩和合并的需求越来越强烈,当然这里面也有把代码混淆了不容易被盗用等其他因素在里面。JSMinYUI CompressorClosure CompilerUglifyJS 等 js 文件压缩合并工具陆陆续续诞生了。压缩工具是有了,但我们得要执行它,最简单的办法呢,就是 windows 上搞个 bat 脚本,mac / linux 上搞个 bash 脚本,哪几个文件要合并在一块的,哪几个要压缩的,发布的时候运行一下脚本,生成压缩后的文件。

基于合并压缩技术,项目越做越大,问题也越来越多,大概就是以下这些问题:

  • 库和插件为了要给他人调用,肯定要找个地方注册,一般就是在 window 下申明一个全局的函数或对象。难保哪天用的两个库在全局用同样的名字,那就冲突了。
  • 库和插件如果还依赖其他的库和插件,就要告知使用人,需要先引哪些依赖库,那些依赖库也有自己的依赖库的话,就要先引依赖库的依赖库,以此类推。

恰好就在这个时候(2009 年),随着后端 JavaScript 技术的发展,人们提出了 CommonJS 的模块化规范,大概的语法是: 如果 a.js 依赖 b.js 和 c.js, 那么就在 a.js 的头部,引入这些依赖文件:

var b = require('./b')
var c = require('./c')

那么变量 b 和 c 会是什么呢?那就是 b.js 和 c.js 导出的东西,比如 b.js 可以这样导出:

exports.square = function(num) {
  return num * num
}

然后就可以在 a.js 使用这个 square 方法:

var n = b.square(2)

如果 c.js 依赖 d.js, 导出的是一个 Number, 那么可以这样写:

var d = require('./d')
module.exports = d.PI // 假设 d.PI 的值是 3.14159

那么 a.js 中的变量 c 就是数字 3.14159,具体的语法规范可以查看 Node.js 的 文档

但是 CommonJS 在浏览器内并不适用。因为 require() 的返回是同步的,意味着有多个依赖的话需要一个一个依次下载,堵塞了 js 脚本的执行。所以人们就在 CommonJS 的基础上定义了 Asynchronous Module Definition (AMD) 规范(2011 年),使用了异步回调的语法来并行下载多个依赖项,比如作为入口的 a.js 可以这样写:

require(['./b', './c'], function(b, c) {
  var n = b.square(2)
  console.log(c)
})

相应的导出语法也是异步回调方式,比如 c.js 依赖 d.js, 就写成这样:

define(['./d'], function(d) {
  return d.PI
})

可以看到,定义一个模块是使用 define() 函数,define() 和 require() 的区别是,define() 必须要在回调函数中返回一个值作为导出的东西,require() 不需要导出东西,因此回调函数中不需要返回值,也无法作为被依赖项被其他文件导入,因此一般用于入口文件,比如页面中这样加载 a.js:

<script src="js/require.js" data-main="js/a"></script>

以上是 AMD 规范的基本用法,更详细的就不多说了(反正也淘汰了~),有兴趣的可以看 这里

js 模块化问题基本解决了,css 和 html 也没闲着。什么 lesssassstylus 的 css 预处理器横空出世,说能帮我们简化 css 的写法,自动给你加 vendor prefix。html 在这期间也出现了一堆模板语言,什么 handlebarsejsjade,可以把 ajax 拿到的数据插入到模板中,然后用 innerHTML 显示到页面上。

托 AMD 和 CSS 预处理和模板语言的福,我们的编译脚本也洋洋洒洒写了百来行。命令行脚本有个不好的地方,就是 windows 和 mac/linux 是不通用的,如果有跨平台需求的话,windows 要装个可以执行 bash 脚本的命令行工具,比如 msys(目前最新的是 msys2),或者使用 php 或 python 等其他语言的脚本来编写,对于非全栈型的前端程序员来说,写 bash / php / python 还是很生涩的。因此我们需要一个简单的打包工具,可以利用各种编译工具,编译 / 压缩 js、css、html、图片等资源。然后 Grunt 产生了(2012 年),配置文件格式是我们最爱的 js,写法也很简单,社区有非常多的插件支持各种编译、lint、测试工具。一年多后另一个打包工具 gulp 诞生了,扩展性更强,采用流式处理效率更高。

依托 AMD 模块化编程,SPA(Single-page application) 的实现方式更为简单清晰,一个网页不再是传统的类似 word 文档的页面,而是一个完整的应用程序。SPA 应用有一个总的入口页面,我们通常把它命名为 index.html、app.html、main.html,这个 html 的 <body> 一般是空的,或者只有总的布局(layout),比如下图:

layout

布局会把 header、nav、footer 的内容填上,但 main 区域是个空的容器。这个作为入口的 html 最主要的工作是加载启动 SPA 的 js 文件,然后由 js 驱动,根据当前浏览器地址进行路由分发,加载对应的 AMD 模块,然后该 AMD 模块执行,渲染对应的 html 到页面指定的容器内(比如图中的 main)。在点击链接等交互时,页面不会跳转,而是由 js 路由加载对应的 AMD 模块,然后该 AMD 模块渲染对应的 html 到容器内。

虽然 AMD 模块让 SPA 更容易地实现,但小问题还是很多的:

  • 不是所有的第三方库都是 AMD 规范的,这时候要配置 shim,很麻烦。
  • 虽然 RequireJS 支持通过插件把 html 作为依赖加载,但 html 里面的 <img> 的路径是个问题,需要使用绝对路径并且保持打包后的图片路径和打包前的路径不变,或者使用 html 模板语言把 src 写成变量,在运行时生成。
  • 不支持动态加载 css,变通的方法是把所有的 css 文件合并压缩成一个文件,在入口的 html 页面一次性加载。
  • SPA 项目越做越大,一个应用打包后的 js 文件到了几 MB 的大小。虽然 r.js 支持分模块打包,但配置很麻烦,因为模块之间会互相依赖,在配置的时候需要 exclude 那些通用的依赖项,而依赖项要在文件里一个个检查。
  • 所有的第三方库都要自己一个个的下载,解压,放到某个目录下,更别提更新有多麻烦了。虽然可以用 npm 包管理工具,但 npm 的包都是 CommonJS 规范的,给后端 Node.js 用的,只有部分支持 AMD 规范,而且在 npm 3 之前,这些包有依赖项的话也是不能用的。后来有个 bower 包管理工具是专门的 web 前端仓库,这里的包一般都支持 AMD 规范。
  • AMD 规范定义和引用模块的语法太麻烦,上面介绍的 AMD 语法仅是最简单通用的语法,API 文档里面还有很多变异的写法,特别是当发生循环引用的时候(a 依赖 b,b 依赖 a),需要使用其他的 语法 解决这个问题。而且 npm 上很多前后端通用的库都是 CommonJS 的语法。后来很多人又开始尝试使用 ES6 模块规范,如何引用 ES6 模块又是一个大问题。
  • 项目的文件结构不合理,因为 grunt/gulp 是按照文件格式批量处理的,所以一般会把 js、html、css、图片分别放在不同的目录下,所以同一个模块的文件会散落在不同的目录下,开发的时候找文件是个麻烦的事情。code review 时想知道一个文件是哪个模块的也很麻烦,解决办法比如又要在 imgs 目录下建立按模块命名的文件夹,里面再放图片。

到了这里,我们的主角 webpack 登场了(2012 年)(此处应有掌声)。

和 webpack 差不多同期登场的还有 Browserify。这里简单介绍一下 Browserify。Browserify 的目的是让前端也能用 CommonJS 的语法 require('module') 来加载 js。它会从入口 js 文件开始,把所有的 require() 调用的文件打包合并到一个文件,这样就解决了异步加载的问题。那么 Browserify 有什么不足之处导致我不推荐使用它呢? 主要原因有下面几点:

  • 最主要的一点,Browserify 不支持把代码打包成多个文件,在有需要的时候加载。这就意味着访问任何一个页面都会全量加载所有文件。
  • Browserify 对其他非 js 文件的加载不够完善,因为它主要解决的是 require() js 模块的问题,其他文件不是它关心的部分。比如 html 文件里的 img 标签,它只能转成 Data URI 的形式,而不能替换为打包后的路径。
  • 因为上面一点 Browserify 对资源文件的加载支持不够完善,导致打包时一般都要配合 gulp 或 grunt 一块使用,无谓地增加了打包的难度。
  • Browserify 只支持 CommonJS 模块规范,不支持 AMD 和 ES6 模块规范,这意味旧的 AMD 模块和将来的 ES6 模块不能使用。

基于以上几点,Browserify 并不是一个理想的选择。那么 webpack 是否解决了以上的几个问题呢? 废话,不然介绍它干嘛。那么下面章节我们用实战的方式来说明 webpack 是怎么解决上述的问题的。

上手先搞一个简单的 SPA 应用

一上来步子太大容易扯到蛋,让我们先弄个最简单的 webpack 配置来热一下身。

安装 Node.js

webpack 是基于我大 Node.js 的打包工具,上来第一件事自然是先安装 Node.js 了,传送门 ->

初始化一个项目

我们先随便找个地方,建一个文件夹叫 simple, 然后在这里面搭项目。完成品在 examples/simple 目录,大家搞的时候可以参照一下。我们先看一下目录结构:

├── dist                      打包输出目录,只需部署这个目录到生产环境
├── package.json              项目配置信息
├── node_modules              npm 安装的依赖包都在这里面
├── src                       我们的源代码
│   ├── components            可以复用的模块放在这里面
│   ├── index.html            入口 html
│   ├── index.js              入口 js
│   ├── shared                公共函数库
│   └── views                 页面放这里
└── webpack.config.js         webpack 配置文件

打开命令行窗口,cd 到刚才建的 simple 目录。然后执行这个命令初始化项目:

npm init

命令行会要你输入一些配置信息,我们这里一路按回车下去,生成一个默认的项目配置文件 package.json

给项目加上语法报错和代码规范检查

我们安装 eslint, 用来检查语法报错,当我们书写 js 时,有错误的地方会出现提示。

npm install eslint eslint-config-enough eslint-loader --save-dev

npm install 可以一条命令同时安装多个包,包之间用空格分隔。包会被安装进 node_modules 目录中。

--save-dev 会把安装的包和版本号记录到 package.json 中的 devDependencies 对象中,还有一个 --save, 会记录到 dependencies 对象中,它们的区别,我们可以先简单的理解为打包工具和测试工具用到的包使用 --save-dev 存到 devDependencies, 比如 eslint、webpack。浏览器中执行的 js 用到的包存到 dependencies, 比如 jQuery 等。那么它们用来干嘛的?

因为有些 npm 包安装是需要编译的,那么导致 windows / mac /linux 上编译出的可执行文件是不同的,也就是无法通用,因此我们在提交代码到 git 上去的时候,一般都会在 .gitignore 里指定忽略 node_modules 目录和里面的文件,这样其他人从 git 上拉下来的项目是没有 node_modules 目录的,这时我们需要运行

npm install

它会读取 package.json 中的 devDependencies 和 dependencies 字段,把记录的包的相应版本下载下来。

这里 eslint-config-enough 是配置文件,它规定了代码规范,要使它生效,我们要在 package.json 中添加内容:

{
  "eslintConfig": {
    "extends": "enough",
    "env": {
      "browser": true,
      "node": true
    }
  }
}

业界最有名的语法规范是 airbnb 出品的,但它规定的太死板了,比如不允许使用 for-of 和 for-in 等。感兴趣的同学可以参照 这里 安装使用。

eslint-loader 用于在 webpack 编译的时候检查代码,如果有错误,webpack 会报错。

项目里安装了 eslint 还没用,我们的 IDE 和编辑器也得要装 eslint 插件支持它。

Visual Studio Code 需要安装 ESLint 扩展

atom 需要安装 linter 和 linter-eslint 这两个插件,装好后重启生效。

WebStorm 需要在设置中打开 eslint 开关:

WebStorm ESLint Config

写几个页面

我们写一个最简单的 SPA 应用来介绍 SPA 应用的内部工作原理。首先,建立 src/index.html 文件,内容如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
  </head>

  <body>
  </body>
</html>

它是一个空白页面,注意这里我们不需要自己写 <script src="index.js"></script>, 因为打包后的文件名和路径可能会变,所以我们用 webpack 插件帮我们自动加上。

src/index.js:

// 引入 router
import router from './router'

// 启动 router
router.start()

src/router.js:

// 引入页面文件
import foo from './views/foo'
import bar from './views/bar'

const routes = {
  '/foo': foo,
  '/bar': bar
}

// Router 类,用来控制页面根据当前 URL 切换
class Router {
  start() {
    // 点击浏览器后退 / 前进按钮时会触发 window.onpopstate 事件,我们在这时切换到相应页面
    // https://developer.mozilla.org/en-US/docs/Web/Events/popstate
    window.addEventListener('popstate', () => {
      this.load(location.pathname)
    })

    // 打开页面时加载当前页面
    this.load(location.pathname)
  }

  // 前往 path,变更地址栏 URL,并加载相应页面
  go(path) {
    // 变更地址栏 URL
    history.pushState({}, '', path)
    // 加载页面
    this.load(path)
  }

  // 加载 path 路径的页面
  load(path) {
    // 首页
    if (path === '/') path = '/foo'
    // 创建页面实例
    const view = new routes[path]()
    // 调用页面方法,把页面加载到 document.body 中
    view.mount(document.body)
  }
}

// 导出 router 实例
export default new Router()

src/views/foo/index.js:

// 引入 router
import router from '../../router'

// 引入 html 模板,会被作为字符串引入
import template from './index.html'

// 引入 css, 会生成 <style> 块插入到 <head> 头中
import './style.css'

// 导出类
export default class {
  mount(container) {
    document.title = 'foo'
    container.innerHTML = template
    container.querySelector('.foo__gobar').addEventListener('click', () => {
      // 调用 router.go 方法加载 /bar 页面
      router.go('/bar')
    })
  }
}

src/views/bar/index.js:

// 引入 router
import router from '../../router'

// 引入 html 模板,会被作为字符串引入
import template from './index.html'

// 引入 css, 会生成 <style> 块插入到 <head> 头中
import './style.css'

// 导出类
export default class {
  mount(container) {
    document.title = 'bar'
    container.innerHTML = template
    container.querySelector('.bar__gofoo').addEventListener('click', () => {
      // 调用 router.go 方法加载 /foo 页面
      router.go('/foo')
    })
  }
}

借助 webpack 插件,我们可以 import html, css 等其他格式的文件,文本类的文件会被储存为变量打包进 js 文件,其他二进制类的文件,比如图片,可以自己配置,小图片作为 Data URI 打包进 js 文件,大文件打包为单独文件,我们稍后再讲这块。

其他的 src 目录下的文件大家自己浏览,拷贝一份到自己的工作目录,等会打包时会用到。

页面代码这样就差不多搞定了,接下来我们进入 webpack 的安装和配置阶段。现在我们还没有讲 webpack 配置所以页面还无法访问,等会弄好 webpack 配置后再看页面实际效果。

安装 webpack 和 Babel

我们把 webpack 和它的插件安装到项目:

npm install webpack webpack-cli webpack-serve html-webpack-plugin html-loader css-loader style-loader file-loader url-loader --save-dev

webpack 即 webpack 核心库。它提供了很多 API, 通过 Node.js 脚本中 require('webpack') 的方式来使用 webpack。

webpack-cli 是 webpack 的命令行工具。让我们可以不用写打包脚本,只需配置打包配置文件,然后在命令行输入 webpack-cli --config webpack.config.js 来使用 webpack, 简单很多。webpack 4 之前命令行工具是集成在 webpack 包中的,4.0 开始 webpack 包本身不再集成 cli。

webpack-serve 是 webpack 提供的用来开发调试的服务器,让你可以用 http://127.0.0.1:8080/ 这样的 url 打开页面来调试,有了它就不用配置 nginx 了,方便很多。

html-webpack-pluginhtml-loadercss-loaderstyle-loader 等看名字就知道是打包 html 文件,css 文件的插件,大家在这里可能会有疑问,html-webpack-plugin 和 html-loader 有什么区别,css-loader 和 style-loader 有什么区别,我们等会看配置文件的时候再讲。

file-loader 和 url-loader 是打包二进制文件的插件,具体也在配置文件章节讲解。

接下来,为了能让不支持 ES6 的浏览器 (比如 IE) 也能照常运行,我们需要安装 babel, 它会把我们写的 ES6 源代码转化成 ES5,这样我们源代码写 ES6,打包时生成 ES5。

npm install babel-core babel-preset-env babel-loader --save-dev

这里 babel-core 顾名思义是 babel 的核心编译器。babel-preset-env 是一个配置文件,我们可以使用这个配置文件转换 ES2015/ES2016/ES2017 到 ES5,是的,不只 ES6 哦。babel 还有 其他配置文件

光安装了 babel-preset-env,在打包时是不会生效的,需要在 package.json 加入 babel 配置:

{
  "babel": {
    "presets": ["env"]
  }
}

打包时 babel 会读取 package.json 中 babel 字段的内容,然后执行相应的转换。

babel-loader 是 webpack 的插件,我们下面章节再说。

配置 webpack

包都装好了,接下来总算可以进入正题了。我们来创建 webpack 配置文件 webpack.config.js,注意这个文件是在 node.js 中运行的,因此不支持 ES6 的 import 语法。我们来看文件内容:

const { resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const history = require('connect-history-api-fallback')
const convert = require('koa-connect')

// 使用 WEBPACK_SERVE 环境变量检测当前是否是在 webpack-server 启动的开发环境中
const dev = Boolean(process.env.WEBPACK_SERVE)

module.exports = {
  /*
  webpack 执行模式
  development:开发环境,它会在配置文件中插入调试相关的选项,比如 moduleId 使用文件路径方便调试
  production:生产环境,webpack 会将代码做压缩等优化
  */
  mode: dev ? 'development' : 'production',

  /*
  配置 source map
  开发模式下使用 cheap-module-eval-source-map, 生成的 source map 能和源码每行对应,方便打断点调试
  生产模式下使用 hidden-source-map, 生成独立的 source map 文件,并且不在 js 文件中插入 source map 路径,用于在 error report 工具中查看 (比如 Sentry)
  */
  devtool: dev ? 'cheap-module-eval-source-map' : 'hidden-source-map',

  // 配置页面入口 js 文件
  entry: './src/index.js',

  // 配置打包输出相关
  output: {
    // 打包输出目录
    path: resolve(__dirname, 'dist'),

    // 入口 js 的打包输出文件名
    filename: 'index.js'
  },

  module: {
    /*
    配置各种类型文件的加载器,称之为 loader
    webpack 当遇到 import ... 时,会调用这里配置的 loader 对引用的文件进行编译
    */
    rules: [
      {
        /*
        使用 babel 编译 ES6 / ES7 / ES8 为 ES5 代码
        使用正则表达式匹配后缀名为 .js 的文件
        */
        test: /\.js$/,

        // 排除 node_modules 目录下的文件,npm 安装的包不需要编译
        exclude: /node_modules/,

        /*
        use 指定该文件的 loader, 值可以是字符串或者数组。
        这里先使用 eslint-loader 处理,返回的结果交给 babel-loader 处理。loader 的处理顺序是从最后一个到第一个。
        eslint-loader 用来检查代码,如果有错误,编译的时候会报错。
        babel-loader 用来编译 js 文件。
        */
        use: ['babel-loader', 'eslint-loader']
      },

      {
        // 匹配 html 文件
        test: /\.html$/,
        /*
        使用 html-loader, 将 html 内容存为 js 字符串,比如当遇到
        import htmlString from './template.html';
        template.html 的文件内容会被转成一个 js 字符串,合并到 js 文件里。
        */
        use: 'html-loader'
      },

      {
        // 匹配 css 文件
        test: /\.css$/,

        /*
        先使用 css-loader 处理,返回的结果交给 style-loader 处理。
        css-loader 将 css 内容存为 js 字符串,并且会把 background, @font-face 等引用的图片,
        字体文件交给指定的 loader 打包,类似上面的 html-loader, 用什么 loader 同样在 loaders 对象中定义,等会下面就会看到。
        */
        use: ['style-loader', 'css-loader']
      },

      {
        /*
        匹配各种格式的图片和字体文件
        上面 html-loader 会把 html 中 <img> 标签的图片解析出来,文件名匹配到这里的 test 的正则表达式,
        css-loader 引用的图片和字体同样会匹配到这里的 test 条件
        */
        test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/,

        /*
        使用 url-loader, 它接受一个 limit 参数,单位为字节(byte)

        当文件体积小于 limit 时,url-loader 把文件转为 Data URI 的格式内联到引用的地方
        当文件大于 limit 时,url-loader 会调用 file-loader, 把文件储存到输出目录,并把引用的文件路径改写成输出后的路径

        比如 views/foo/index.html 中
        <img src="smallpic.png">
        会被编译成
        <img src="https://img-blog.csdnimg.cn/2022010710543663821.png">

        而
        <img src="largepic.png">
        会被编译成
        <img src="/f78661bef717cf2cc2c2e5158f196384.png">
        */
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10000
            }
          }
        ]
      }
    ]
  },

  /*
  配置 webpack 插件
  plugin 和 loader 的区别是,loader 是在 import 时根据不同的文件名,匹配不同的 loader 对这个文件做处理,
  而 plugin, 关注的不是文件的格式,而是在编译的各个阶段,会触发不同的事件,让你可以干预每个编译阶段。
  */
  plugins: [
    /*
    html-webpack-plugin 用来打包入口 html 文件
    entry 配置的入口是 js 文件,webpack 以 js 文件为入口,遇到 import, 用配置的 loader 加载引入文件
    但作为浏览器打开的入口 html, 是引用入口 js 的文件,它在整个编译过程的外面,
    所以,我们需要 html-webpack-plugin 来打包作为入口的 html 文件
    */
    new HtmlWebpackPlugin({
      /*
      template 参数指定入口 html 文件路径,插件会把这个文件交给 webpack 去编译,
      webpack 按照正常流程,找到 loaders 中 test 条件匹配的 loader 来编译,那么这里 html-loader 就是匹配的 loader
      html-loader 编译后产生的字符串,会由 html-webpack-plugin 储存为 html 文件到输出目录,默认文件名为 index.html
      可以通过 filename 参数指定输出的文件名
      html-webpack-plugin 也可以不指定 template 参数,它会使用默认的 html 模板。
      */
      template: './src/index.html',

      /*
      因为和 webpack 4 的兼容性问题,chunksSortMode 参数需要设置为 none
      https://github.com/jantimon/html-webpack-plugin/issues/870
      */
      chunksSortMode: 'none'
    })
  ]
}

/*
配置开发时用的服务器,让你可以用 http://127.0.0.1:8080/ 这样的 url 打开页面来调试
并且带有热更新的功能,打代码时保存一下文件,浏览器会自动刷新。比 nginx 方便很多
如果是修改 css, 甚至不需要刷新页面,直接生效。这让像弹框这种需要点击交互后才会出来的东西调试起来方便很多。

因为 webpack-cli 无法正确识别 serve 选项,使用 webpack-cli 执行打包时会报错。
因此我们在这里判断一下,仅当使用 webpack-serve 时插入 serve 选项。
issue:https://github.com/webpack-contrib/webpack-serve/issues/19
*/
if (dev) {
  module.exports.serve = {
    // 配置监听端口,默认值 8080
    port: 8080,

    // add: 用来给服务器的 koa 实例注入 middleware 增加功能
    add: app => {
      /*
      配置 SPA 入口

      SPA 的入口是一个统一的 html 文件,比如
      http://localhost:8080/foo
      我们要返回给它
      http://localhost:8080/index.html
      这个文件
      */
      app.use(convert(history()))
    }
  }
}

走一个

配置 OK 了,接下来我们就运行一下吧。我们先试一下开发环境用的 webpack-serve:

./node_modules/.bin/webpack-serve webpack.config.js

执行时需要指定配置文件。

上面的命令适用于 Mac / Linux 等 * nix 系统,也适用于 Windows 上的 PowerShell 和 bash/zsh 环境(Windows Subsystem for LinuxGit BashBabunMSYS2 等)。安利一下 Windows 同学使用 Ubuntu on Windows,可以避免很多跨平台的问题,比如设置环境变量。

如果使用 Windows 的 cmd.exe,请执行:

node_modules\.bin\webpack-serve webpack.config.js

npm 会把包的可执行文件安装到 ./node_modules/.bin/ 目录下,所以我们要在这个目录下执行命令。

命令执行后,控制台显示:

「wdm」: Compiled successfully。

这就代表编译成功了,我们可以在浏览器打开 http://localhost:8080/ 看看效果。如果有报错,那可能是什么地方没弄对?请自己仔细检查一下~

我们可以随意更改一下 src 目录下的源代码,保存后,浏览器里的页面应该很快会有相应变化。

要退出编译,按 ctrl+c

开发环境编译试过之后,我们试试看编译生产环境的代码,命令是:

./node_modules/.bin/webpack-cli

不需要指定配置文件,默认读取 webpack.config.js

执行脚本的命令有点麻烦,因此,我们可以利用 npm,把命令写在 package.json 中:

{
  "scripts": {
    "dev": "webpack-serve webpack.config.js",
    "build": "webpack-cli"
  }
}

package.json 中的 scripts 对象,可以用来写一些脚本命令,命令不需要前缀目录 ./node_modules/.bin/,npm 会自动寻找该目录下的命令。我们可以执行:

npm run dev

来启动开发环境。

执行

npm run build

来打包生产环境的代码。

进阶配置

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

  • 设置静态资源的 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 进来了。

因为 import() 还没有正式进入标准,需要使用 babel-preset-stage-2 来支持:

npm install 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...点击预览

但正如 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')()
  ]
}

使用 webpack 打包多页面应用(Multiple-Page Application)

多页面网站同样可以用 webpack 来打包,以便使用 npm 包,import()code splitting 等好处。

MPA 意味着并没不是一个单一的 html 入口和 js 入口,而是每个页面对应一个 html 和多个 js。那么我们可以把项目结构设计为:

├── dist
├── package.json
├── node_modules
├── src
│   ├── components
│   ├── shared
|   ├── favicon.png
│   └── pages                 页面放这里
|       ├── foo               编译后生成 http://localhost:8080/foo.html
|       |    ├── index.html
|       |    ├── index.js
|       |    ├── style.css
|       |    └── pic.png
|       └── bar                        http://localhost:8080/bar.html
|           ├── index.html
|           ├── index.js
|           ├── style.css
|           └── baz                    http://localhost:8080/bar/baz.html
|               ├── index.html
|               ├── index.js
|               └── style.css
└── webpack.config.js

这里每个页面的 index.html 是个完整的从 <!DOCTYPE html> 开头到 </html> 结束的页面,这些文件都要用 html-webpack-plugin 处理。index.js 是每个页面的业务逻辑,作为每个页面的入口 js 配置到 entry 中。这里我们需要用 glob 库来把这些文件都筛选出来批量操作。为了使用 webpack 4 的 optimization.splitChunks 和 optimization.runtimeChunk 功能,我写了 html-webpack-include-sibling-chunks-plugin 插件来配合使用。还要装几个插件把 css 压缩并放到 <head> 中。

npm install glob html-webpack-include-sibling-chunks-plugin uglifyjs-webpack-plugin mini-css-extract-plugin optimize-css-assets-webpack-plugin --save-dev

webpack.config.js 修改的地方:

// ...
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
const HtmlWebpackIncludeSiblingChunksPlugin = require('html-webpack-include-sibling-chunks-plugin')
const glob = require('glob')

const dev = Boolean(process.env.WEBPACK_SERVE)
const config = require('./config/' + (process.env.npm_config_config || 'default'))

const entries = glob.sync('./src/**/index.js')
const entry = {}
const htmlPlugins = []
for (const path of entries) {
  const template = path.replace('index.js', 'index.html')
  const chunkName = path.slice('./src/pages/'.length, -'/index.js'.length)
  entry[chunkName] = dev ? [path, template] : path
  htmlPlugins.push(new HtmlWebpackPlugin({
    template,
    filename: chunkName + '.html',
    chunksSortMode: 'none',
    chunks: [chunkName]
  }))
}

module.exports = {
  entry,

  output: {
    path: resolve(__dirname, 'dist'),
    // 我们不定义 publicPath,否则访问 html 时需要带上 publicPath 前缀
    filename: dev ? '[name].js' : '[chunkhash].js',
    chunkFilename: '[chunkhash].js'
  },

  optimization: {
    runtimeChunk: true,
    splitChunks: {
      chunks: 'all'
    },
    minimizer: dev ? [] : [
      new UglifyJsPlugin({
        cache: true,
        parallel: true,
        sourceMap: true
      }),
      new OptimizeCSSAssetsPlugin()
    ]
  },

  module: {
    rules: [
      // ...
      
      {
        test: /\.css$/,
        use: [dev ? 'style-loader' : MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
      },
      
      // ...
    ]
  },

  plugins: [
    // ...
    
    /*
    这里不使用 [chunkhash]
    因为从同一个 chunk 抽离出来的 css 共享同一个 [chunkhash]
    [contenthash] 你可以简单理解为 moduleId + content 生成的 hash
    因此一个 chunk 中的多个 module 有自己的 [contenthash]
    */
    new MiniCssExtractPlugin({
      filename: '[contenthash].css',
      chunkFilename: '[contenthash].css'
    }),

    // 必须放在html-webpack-plugin前面
    new HtmlWebpackIncludeSiblingChunksPlugin(),

    ...htmlPlugins
  ],

  // ...
}

entry 和 htmlPlugins 会通过遍历 pages 目录生成,比如:

entry:

{
  'bar/baz': './src/pages/bar/baz/index.js',
  bar: './src/pages/bar/index.js',
  foo: './src/pages/foo/index.js'
}

在开发环境中,为了能够修改 html 文件后网页能够自动刷新,我们还需要把 html 文件也加入 entry 中,比如:

{
  foo: ['./src/pages/foo/index.js', './src/pages/foo/index.html']
}

这样,当 foo 页面的 index.js 或 index.html 文件改动时,都会触发浏览器刷新该页面。虽然把 html 加入 entry 很奇怪,但放心,不会导致错误。记得不要在生产环境这么做,不然导致 chunk 文件包含了无用的 html 片段。

htmlPlugins:

[
  new HtmlWebpackPlugin({
    template: './src/pages/bar/baz/index.html',
    filename: 'bar/baz.html',
    chunksSortMode: 'none',
    chunks: ['bar/baz']
  },

  new HtmlWebpackPlugin({
    template: './src/pages/bar/index.html',
    filename: 'bar.html',
    chunksSortMode: 'none',
    chunks: ['bar']
  },

  new HtmlWebpackPlugin({
    template: './src/pages/foo/index.html',
    filename: 'foo.html',
    chunksSortMode: 'none',
    chunks: ['foo']
  }
]

代码在 examples/mpa 目录。

总结

通过这篇文章,我想大家应该学会了 webpack 的正确打开姿势。虽然我没有提及如何用 webpack 来编译 React 和 vue.js, 但大家可以想到,无非是安装一些 loader 和 plugin 来处理 jsx 和 vue 格式的文件,那时难度就不在于 webpack 了,而是代码架构组织的问题了。具体的大家自己去摸索一下。

原文:https://segmentfault.com/a/1190000007914129

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值