Webpack 4 和单页应用入门

github:github.com/fenivana/we…javascript

webpack 更新到了 4.0,官网尚未更新文档。所以把教程更新一下,方便你们用起 webpack 4。php

webpack

写在开头

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

WTF

和这样的:html

You Couldn't Handle Me

是的,即便是外国佬也在吐槽这文档不是人能看的。回想起当年本身啃 webpack 文档的血与泪的往事,以为有必要整一个教程,可让你们看完后愉悦地搭建起一个 webpack 打包方案的项目。前端

官网新的 webpack 文档如今写的很详细了,能看英文的小伙伴能够直接去看官网。vue

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

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

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

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

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

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

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

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

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

var b = require('./b')
var c = require('./c')
复制代码

那么变量 bc 会是什么呢?那就是 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 中的 devDependenciesdependencies 字段,把记录的包的相应版本下载下来。

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

{
  "eslintConfig": {
    "extends": "enough",
    "env": {
      "browser": true,
      "node": true
    }
  }
}
复制代码

业界最有名的语法规范是 airbnb 出品的,但它规定的太死板了,好比不容许使用 for-offor-in 等。感兴趣的同窗能够参照 这里 安装使用。

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

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

Visual Studio Code 须要安装 ESLint 扩展

atom 须要安装 linterlinter-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-plugin, html-loader, css-loader, style-loader 等看名字就知道是打包 html 文件,css 文件的插件,你们在这里可能会有疑问,html-webpack-pluginhtml-loader 有什么区别,css-loaderstyle-loader 有什么区别,咱们等会看配置文件的时候再讲。

file-loaderurl-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.jsonbabel 字段的内容,而后执行相应的转换。

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="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAA..."> 而 <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 Linux, Git 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/ awaitdynamic 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/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.versionJSON.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 本身能够处理 importexport, 并且 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.splitChunksoptimization.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
  ],

  // ...
}
复制代码

entryhtmlPlugins 会经过遍历 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 来编译 Reactvue.js, 但你们能够想到,无非是安装一些 loader 和 plugin 来处理 jsxvue 格式的文件,那时难度就不在于 webpack 了,而是代码架构组织的问题了。具体的你们本身去摸索一下。

版权许可

知识共享许可协议
本做品采用 知识共享署名 - 非商业性使用 4.0 国际许可协议 进行许可。

相关文章
相关标签/搜索