github:github.com/fenivana/we…javascript
webpack 更新到了 4.0,官网尚未更新文档。所以把教程更新一下,方便你们用起 webpack 4。php
先说说为何要写这篇文章,最初的缘由是组里的小朋友们看了 webpack 文档后,表情都是这样的:摘自 webpack 一篇文档的评论区)css
和这样的:html
是的,即便是外国佬也在吐槽这文档不是人能看的。回想起当年本身啃 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, 这是一个使用方便的,兼容性良好的服务器通讯接口。今后开始,咱们的页面开始玩出各类花来了,前端一会儿出现了各类各样的库,Prototype、Dojo、MooTools、Ext JS、jQuery…… 咱们开始往页面里插入各类库和插件,咱们的 js 文件也就爆炸了。react
随着 js 能作的事情愈来愈多,引用愈来愈多,文件愈来愈大,加上当时大约只有 2Mbps 左右的网速,下载速度还不如 3G 网络,对 js 文件的压缩和合并的需求愈来愈强烈,固然这里面也有把代码混淆了不容易被盗用等其余因素在里面。JSMin、YUI Compressor、Closure Compiler、UglifyJS 等 js 文件压缩合并工具陆陆续续诞生了。压缩工具是有了,但咱们得要执行它,最简单的办法呢,就是 windows 上搞个 bat 脚本,mac / linux 上搞个 bash 脚本,哪几个文件要合并在一块的,哪几个要压缩的,发布的时候运行一下脚本,生成压缩后的文件。
基于合并压缩技术,项目越作越大,问题也愈来愈多,大概就是如下这些问题:
刚好就在这个时候(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 也没闲着。什么 less,sass,stylus 的 css 预处理器横空出世,说能帮咱们简化 css 的写法,自动给你加 vendor prefix。html 在这期间也出现了一堆模板语言,什么 handlebars,ejs,jade,能够把 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),好比下图:
布局会把 header、nav、footer 的内容填上,但 main 区域是个空的容器。这个做为入口的 html 最主要的工做是加载启动 SPA 的 js 文件,而后由 js 驱动,根据当前浏览器地址进行路由分发,加载对应的 AMD 模块,而后该 AMD 模块执行,渲染对应的 html 到页面指定的容器内(好比图中的 main)。在点击连接等交互时,页面不会跳转,而是由 js 路由加载对应的 AMD 模块,而后该 AMD 模块渲染对应的 html 到容器内。
虽然 AMD 模块让 SPA 更容易地实现,但小问题仍是不少的:
shim
,很麻烦。<img>
的路径是个问题,须要使用绝对路径而且保持打包后的图片路径和打包前的路径不变,或者使用 html 模板语言把 src
写成变量,在运行时生成。到了这里,咱们的主角 webpack 登场了(2012 年)(此处应有掌声)。
和 webpack 差很少同期登场的还有 Browserify。这里简单介绍一下 Browserify。Browserify 的目的是让前端也能用 CommonJS 的语法 require('module')
来加载 js。它会从入口 js 文件开始,把全部的 require()
调用的文件打包合并到一个文件,这样就解决了异步加载的问题。那么 Browserify 有什么不足之处致使我不推荐使用它呢? 主要缘由有下面几点:
require()
js 模块的问题,其余文件不是它关心的部分。好比 html 文件里的 img 标签,它只能转成 Data URI 的形式,而不能替换为打包后的路径。基于以上几点,Browserify 并非一个理想的选择。那么 webpack 是否解决了以上的几个问题呢? 废话,否则介绍它干吗。那么下面章节咱们用实战的方式来讲明 webpack 是怎么解决上述的问题的。
一上来步子太大容易扯到蛋,让咱们先弄个最简单的 webpack 配置来热一下身。
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 开关:
咱们写一个最简单的 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 和它的插件安装到项目:
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-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.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 Bash、Babun、MSYS2 等)。安利一下 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
复制代码
来打包生产环境的代码。
上面的项目虽然能够跑起来了,但有几个点咱们尚未考虑到:
那么,让咱们在上面的配置的基础上继续完善,下面的代码咱们只写出改变的部分。代码在 examples/advanced 目录。
如今咱们的资源文件的 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 就会被拆分:
通常状况只需配置这几个参数便可:
{
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 文档中所说,默认配置已经足够优化,在没有测试的状况下不要盲目手动优化。
上面咱们提到了 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]
意义不大。
咱们注意到运行开发环境是命令行会报一段 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'
}
}
复制代码
在 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 个配置文件:
module.exports = {
publicPath: 'http://cdn.example.com/assets/'
}
复制代码
module.exports = {
publicPath: '/assets/',
serve: {
port: 8090
}
}
复制代码
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
不支持。
当处理带后缀名的请求时,好比 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')
复制代码
文件 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 提供了一个 loose 选项,使编译后的代码不须要彻底遵循 ES6 规定,简化编译后的代码,提升代码执行效率:
package.json:
{
"babel": {
"presets": [
[
"env",
{
"loose": true
}
],
"stage-2"
]
}
}
复制代码
但这么作会有兼容性的风险,可能会致使 ES6 源码理应的执行结果和编译后的 ES5 代码的实际结果并不一致。若是代码没有遇到实际的效率瓶颈,官方 不建议 使用 loose
模式。
咱们目前的配置,babel 会把 ES6 模块定义转为 CommonJS 定义,但 webpack 本身能够处理 import
和 export
, 并且 webpack 处理 import
时会作代码优化,把没用到的部分代码删除掉。所以咱们经过 babel 提供的 modules: false
选项把 ES6 模块转为 CommonJS 模块的功能给关闭掉。
package.json:
{
"babel": {
"presets": [
[
"env",
{
"loose": true,
"modules": false
}
],
"stage-2"
]
}
}
复制代码
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 来打包,以便使用 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 了,而是代码架构组织的问题了。具体的你们本身去摸索一下。
本做品采用 知识共享署名 - 非商业性使用 4.0 国际许可协议 进行许可。