webpack 2 打包實(shí)戰(zhàn)

寫在開(kāi)頭

先說(shuō)說(shuō)為什么要寫這篇文章, 最初的原因是組里的小朋友們看了webpack文檔后, 表情都是這樣的: (摘自webpack一篇文檔的評(píng)論區(qū))

和這樣的:

是的, 即使是外國(guó)佬也在吐槽這文檔不是人能看的. 回想起當(dāng)年自己啃webpack文檔的血與淚的往事, 覺(jué)得有必要整一個(gè)教程, 可以讓大家看完后愉悅地搭建起一個(gè)webpack打包方案的項(xiàng)目.

可能會(huì)有人問(wèn)webpack到底有什么用, 你不能上來(lái)就糊我一臉代碼讓我馬上搞, 我照著搞了一遍結(jié)果根本沒(méi)什么naizi用, 都是騙人的. 所以, 在說(shuō)webpack之前, 我想先談一下前端打包方案這幾年的演進(jìn)歷程, 在什么場(chǎng)景下, 我們遇到了什么問(wèn)題, 催生出了應(yīng)對(duì)這些問(wèn)題的工具. 了解了需求和目的之后, 你就知道什么時(shí)候webpack可以幫到你. 我希望我用完之后很爽, 你們用完之后也是.

先說(shuō)說(shuō)前端打包方案的黑暗歷史

在很長(zhǎng)的一段前端歷史里, 是不存在打包這個(gè)說(shuō)法的. 那個(gè)時(shí)候頁(yè)面基本是純靜態(tài)的或者服務(wù)端輸出的, 沒(méi)有AJAX, 也沒(méi)有jQuery. 那個(gè)時(shí)候的JavaScript就像個(gè)玩具, 用處大概就是在側(cè)欄弄個(gè)時(shí)鐘, 用media player放個(gè)mp3之類的腳本, 代碼量不是很多, 直接放在<script>標(biāo)簽里或者弄個(gè)js文件引一下就行, 日子過(guò)得很輕松愉快.

隨后的幾年, 人們開(kāi)始嘗試在一個(gè)頁(yè)面里做更多的事情. 容器的顯示, 隱藏, 切換. 用css寫的彈層, 圖片輪播等等. 但如果一個(gè)頁(yè)面內(nèi)不能向服務(wù)器請(qǐng)求數(shù)據(jù), 能做的事情畢竟有限的, 代碼的量也能維持在頁(yè)面交互邏輯范圍內(nèi). 這時(shí)候很多人開(kāi)始突破一個(gè)頁(yè)面能做的事情的范圍, 使用隱藏的iframe和flash等作為和服務(wù)器通信的橋梁, 新世界的大門慢慢地被打開(kāi), 在一個(gè)頁(yè)面內(nèi)和服務(wù)器進(jìn)行數(shù)據(jù)交互, 意味著以前需要跳轉(zhuǎn)多個(gè)頁(yè)面的事情現(xiàn)在可以用一個(gè)頁(yè)面搞定. 但由于iframe和flash技術(shù)過(guò)于tricky和復(fù)雜, 并沒(méi)能得到廣泛的推廣.

直到Google推出Gmail的時(shí)候(2004年), 人們意識(shí)到了一個(gè)被忽略的接口, XMLHttpRequest, 也就是我們俗稱的AJAX, 這是一個(gè)使用方便的, 兼容性良好的服務(wù)器通信接口. 從此開(kāi)始, 我們的頁(yè)面開(kāi)始玩出各種花來(lái)了, 前端一下子出現(xiàn)了各種各樣的庫(kù), Prototype, Dojo, MooTools, Ext JS, jQuery... 我們開(kāi)始往頁(yè)面里插入各種庫(kù)和插件, 我們的js文件也就爆炸了...

隨著js能做的事情越來(lái)越多, 引用越來(lái)越多, 文件越來(lái)越大, 加上當(dāng)時(shí)大約只有2Mbps左右的網(wǎng)速, 下載速度還不如3G網(wǎng)絡(luò), 對(duì)js文件的壓縮和合并的需求越來(lái)越強(qiáng)烈, 當(dāng)然這里面也有把代碼混淆了不容易被盜用等其他因素在里面. JSMin, YUI Compressor, Closure Compiler, UglifyJS 等js文件壓縮合并工具陸陸續(xù)續(xù)誕生了. 壓縮工具是有了, 但我們得要執(zhí)行它, 最簡(jiǎn)單的辦法呢, 就是windows上搞個(gè)bat腳本, mac/linux上搞個(gè)bash腳本, 哪幾個(gè)文件要合并在一塊的, 哪幾個(gè)要壓縮的, 發(fā)布的時(shí)候運(yùn)行一下腳本, 生成壓縮后的文件.

基于合并壓縮技術(shù), 項(xiàng)目越做越大, 問(wèn)題也越來(lái)越多, 大概就是以下這些問(wèn)題:

  • 庫(kù)和插件為了要給他人調(diào)用, 肯定要找個(gè)地方注冊(cè), 一般就是在window下申明一個(gè)全局的函數(shù)或?qū)ο? 難保哪天用的兩個(gè)庫(kù)在全局用同樣的名字, 那就沖突了.
  • 庫(kù)和插件如果還依賴其他的庫(kù)和插件, 就要告知使用人, 需要先引哪些依賴庫(kù), 那些依賴庫(kù)也有自己的依賴庫(kù)的話, 就要先引依賴庫(kù)的依賴庫(kù), 以此類推...

恰好就在這個(gè)時(shí)候(2009年), 隨著后端JavaScript技術(shù)的發(fā)展, 人們提出了CommonJS的模塊化規(guī)范, 大概的語(yǔ)法是: 如果a.js依賴b.jsc.js, 那么就在a.js的頭部, 引入這些依賴文件:

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

那么變量bc會(huì)是什么呢? 那就是b.jsc.js導(dǎo)出的東西, 比如b.js可以這樣導(dǎo)出:

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

然后就可以在a.js使用這個(gè)square方法:

var n = b.square(2)

如果c.js依賴d.js, 導(dǎo)出的是一個(gè)Number, 那么可以這樣寫:

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

那么a.js中的變量c就是數(shù)字3.14159, 具體的語(yǔ)法規(guī)范可以查看Node.js的文檔.

但是CommonJS在瀏覽器內(nèi)并不適用. 因?yàn)?code>require()的返回是同步的, 意味著有多個(gè)依賴的話需要一個(gè)一個(gè)依次下載, 堵塞了js腳本的執(zhí)行. 所以人們就在CommonJS的基礎(chǔ)上定義了Asynchronous Module Definition (AMD)規(guī)范(2011年), 使用了異步回調(diào)的語(yǔ)法來(lái)并行下載多個(gè)依賴項(xiàng), 比如作為入口的a.js可以這樣寫:

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

相應(yīng)的導(dǎo)出語(yǔ)法也是異步回調(diào)方式, 比如c.js依賴d.js, 就寫成這樣:

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

可以看到, 定義一個(gè)模塊是使用define()函數(shù), define()require()的區(qū)別是, define()必須要在回調(diào)函數(shù)中返回一個(gè)值作為導(dǎo)出的東西, require()不需要導(dǎo)出東西, 因此回調(diào)函數(shù)中不需要返回值, 也無(wú)法作為被依賴項(xiàng)被其他文件導(dǎo)入, 因此一般用于入口文件, 比如頁(yè)面中這樣加載a.js:

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

以上是AMD規(guī)范的基本用法, 更詳細(xì)的就不多說(shuō)了(反正也淘汰了~), 有興趣的可以看這里.

js模塊化問(wèn)題基本解決了, css和html也沒(méi)閑著. 什么less, sass, stylus的css預(yù)處理器橫空出世, 說(shuō)能幫我們簡(jiǎn)化css的寫法, 自動(dòng)給你加vendor prefix. html在這期間也出現(xiàn)了一堆模板語(yǔ)言, 什么handlebars, ejs, jade, 可以把a(bǔ)jax拿到的數(shù)據(jù)插入到模板中, 然后用innerHTML顯示到頁(yè)面上.

托AMD和CSS預(yù)處理和模板語(yǔ)言的福, 我們的編譯腳本也洋洋灑灑寫了百來(lái)行. 命令行腳本有個(gè)不好的地方, 就是windows和mac/linux是不通用的, 如果有跨平臺(tái)需求的話, windows要裝個(gè)可以執(zhí)行bash腳本的命令行工具, 比如msys(目前最新的是msys2), 或者使用php或python等其他語(yǔ)言的腳本來(lái)編寫, 對(duì)于非全棧型的前端程序員來(lái)說(shuō), 寫bash/php/python還是很生澀的. 因此我們需要一個(gè)簡(jiǎn)單的打包工具, 可以利用各種編譯工具, 編譯/壓縮js, css, html, 圖片等資源. 然后Grunt產(chǎn)生了(2012年), 配置文件格式是我們最愛(ài)的js, 寫法也很簡(jiǎn)單, 社區(qū)有非常多的插件支持各種編譯, lint, 測(cè)試工具. 一年多后另一個(gè)打包工具gulp誕生了, 擴(kuò)展性更強(qiáng), 采用流式處理效率更高.

依托AMD模塊化編程, SPA(Single-page application)的實(shí)現(xiàn)方式更為簡(jiǎn)單清晰, 一個(gè)網(wǎng)頁(yè)不再是傳統(tǒng)的類似word文檔的頁(yè)面, 而是一個(gè)完整的應(yīng)用程序. SPA應(yīng)用有一個(gè)總的入口頁(yè)面, 我們通常把它命名為index.html, app.html, main.html, 這個(gè)html的<body>一般是空的, 或者只有總的布局(layout), 比如下圖:

布局會(huì)把header, nav, footer的內(nèi)容填上, 但main區(qū)域是個(gè)空的容器. 這個(gè)作為入口的html最主要的工作是加載啟動(dòng)SPA的js文件, 然后由js驅(qū)動(dòng), 根據(jù)當(dāng)前瀏覽器地址進(jìn)行路由分發(fā), 加載對(duì)應(yīng)的AMD模塊, 然后該AMD模塊執(zhí)行, 渲染對(duì)應(yīng)的html到頁(yè)面指定的容器內(nèi)(比如圖中的main). 在點(diǎn)擊鏈接等交互時(shí), 頁(yè)面不會(huì)跳轉(zhuǎn), 而是由js路由加載對(duì)應(yīng)的AMD模塊, 然后該AMD模塊渲染對(duì)應(yīng)的html到容器內(nèi).

雖然AMD模塊讓SPA更容易地實(shí)現(xiàn), 但小問(wèn)題還是很多的:

  • 不是所有的第三方庫(kù)都是AMD規(guī)范的, 這時(shí)候要配置shim, 很麻煩.
  • 雖然RequireJS支持插件的形式通過(guò)把html作為依賴加載, 但html里面的<img>的路徑是個(gè)問(wèn)題, 需要使用絕對(duì)路徑并且保持打包后的圖片路徑和打包前的路徑不變, 或者使用html模板語(yǔ)言把src寫成變量, 在運(yùn)行時(shí)生成.
  • 不支持動(dòng)態(tài)加載css, 變通的方法是把所有的css文件合并壓縮成一個(gè)文件, 在入口的html頁(yè)面一次性加載.
  • SPA項(xiàng)目越做越大, 一個(gè)應(yīng)用打包后的js文件到了幾MB的大小. 雖然r.js支持分模塊打包, 但配置很麻煩, 因?yàn)槟K之間會(huì)互相依賴, 在配置的時(shí)候需要exclude那些通用的依賴項(xiàng), 而依賴項(xiàng)要在文件里一個(gè)個(gè)檢查.
  • 所有的第三方庫(kù)都要自己一個(gè)個(gè)的下載, 解壓, 放到某個(gè)目錄下, 更別提更新有多麻煩了. 雖然可以用npm包管理工具, 但npm的包都是CommonJS規(guī)范的, 給后端Node.js用的, 只有部分支持AMD規(guī)范, 而且在npm3.0之前, 這些包有依賴項(xiàng)的話也是不能用的. 后來(lái)有個(gè)bower包管理工具是專門的web前端倉(cāng)庫(kù), 這里的包一般都支持AMD規(guī)范.
  • AMD規(guī)范定義和引用模塊的語(yǔ)法太麻煩, 上面介紹的AMD語(yǔ)法僅是最簡(jiǎn)單通用的語(yǔ)法, API文檔里面還有很多變異的寫法, 特別是當(dāng)發(fā)生循環(huán)引用的時(shí)候(a依賴b, b依賴a), 需要使用其他的語(yǔ)法解決這個(gè)問(wèn)題. 而且npm上很多前后端通用的庫(kù)都是CommonJS的語(yǔ)法. 后來(lái)很多人又開(kāi)始嘗試使用ES6模塊規(guī)范, 如何引用ES6模塊又是一個(gè)大問(wèn)題.
  • 項(xiàng)目的文件結(jié)構(gòu)不合理, 因?yàn)間runt/gulp是按照文件格式批量處理的, 所以一般會(huì)把js, html, css, 圖片分別放在不同的目錄下, 所以同一個(gè)模塊的文件會(huì)散落在不同的目錄下, 開(kāi)發(fā)的時(shí)候找文件是個(gè)麻煩的事情. code review時(shí)想知道一個(gè)文件是哪個(gè)模塊的也很麻煩, 解決辦法比如又要在imgs目錄下建立按模塊命名的文件夾, 里面再放圖片.

到了這里, 我們的主角webpack登場(chǎng)了(2012年)(此處應(yīng)有掌聲).

和webpack差不多同期登場(chǎng)的還有Browserify. 這里簡(jiǎn)單介紹一下Browserify, Browserify的目的是讓前端也能用CommonJS的語(yǔ)法require('module')來(lái)加載js. 它會(huì)從入口js文件開(kāi)始, 把所有的require()調(diào)用的文件打包合并到一個(gè)文件, 這樣就解決了異步加載的問(wèn)題. 那么Browserify有什么不足之處導(dǎo)致我不推薦使用它呢? 主要原因有下面幾點(diǎn):

  • 最主要的一點(diǎn), Browserify不支持把代碼打包成多個(gè)文件, 在有需要的時(shí)候加載. 這就意味著訪問(wèn)任何一個(gè)頁(yè)面都會(huì)全量加載所有文件.
  • Browserify對(duì)其他非js文件的加載不夠完善, 因?yàn)樗饕鉀Q的是require()js模塊的問(wèn)題, 其他文件不是它關(guān)心的部分. 比如html文件里的img標(biāo)簽, 它只能轉(zhuǎn)成Data URI的形式, 而不能替換為打包后的路徑.
  • 因?yàn)樯厦嬉稽c(diǎn)Browserify對(duì)資源文件的加載支持不夠完善, 導(dǎo)致打包時(shí)一般都要配合gulp或grunt一塊使用, 無(wú)謂地增加了打包的難度.
  • Browserify只支持CommonJS模塊規(guī)范, 不支持AMD和ES6模塊規(guī)范, 這意味舊的AMD模塊和將來(lái)的ES6模塊不能使用.

基于以上幾點(diǎn), Browserify并不是一個(gè)理想的選擇. 那么webpack是否解決了以上的幾個(gè)問(wèn)題呢? 廢話, 不然介紹它干嘛. 那么下面章節(jié)我們用實(shí)戰(zhàn)的方式來(lái)說(shuō)明webpack是怎么解決上述的問(wèn)題的.

上手先搞一個(gè)簡(jiǎn)單的SPA應(yīng)用

一上來(lái)步子太大容易扯到蛋, 讓我們先弄個(gè)最簡(jiǎn)單的webpack配置來(lái)熱一下身.

安裝Node.js

webpack是基于我大Node.js的打包工具, 上來(lái)第一件事自然是先安裝Node.js了, 傳送門->.

初始化一個(gè)項(xiàng)目

我們先隨便找個(gè)地方, 建一個(gè)文件夾叫simple, 然后在這里面搭項(xiàng)目. 完成品在examples/simple目錄, 大家搞的時(shí)候可以參照一下. 我們先看一下目錄結(jié)構(gòu):

├── dist                      打包輸出目錄, 只需部署這個(gè)目錄到生產(chǎn)環(huán)境
├── package.json              項(xiàng)目配置信息
├── node_modules              npm安裝的依賴包都在這里面
├── src                       我們的源代碼
│   ├── components            可以復(fù)用的模塊放在這里面
│   ├── index.html            入口html
│   ├── index.js              入口js
│   ├── libs                  不在npm和git上的庫(kù)扔這里
│   └── views                 頁(yè)面放這里
└── webpack.config.js         webpack配置文件

打開(kāi)命令行窗口, cd到剛才建的simple目錄. 然后執(zhí)行這個(gè)命令初始化項(xiàng)目:

npm init

命令行會(huì)要你輸入一些配置信息, 我們這里一路按回車下去, 生成一個(gè)默認(rèn)的項(xiàng)目配置文件package.json.

給項(xiàng)目加上語(yǔ)法報(bào)錯(cuò)和代碼規(guī)范檢查

我們安裝eslint, 用來(lái)檢查語(yǔ)法報(bào)錯(cuò), 當(dāng)我們書寫js時(shí), 有錯(cuò)誤的地方會(huì)出現(xiàn)提示.

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

npm install可以一條命令同時(shí)安裝多個(gè)包, 包之間用空格分隔. 包會(huì)被安裝進(jìn)node_modules目錄中.

--save-dev會(huì)把安裝的包和版本號(hào)記錄到package.json中的devDependencies對(duì)象中, 還有一個(gè)--save, 會(huì)記錄到dependencies對(duì)象中, 它們的區(qū)別, 我們可以先簡(jiǎn)單的理解為打包工具和測(cè)試工具用到的包使用--save-dev存到devDependencies, 比如eslint, webpack. 瀏覽器中執(zhí)行的js用到的包存到dependencies, 比如jQuery等. 那么它們用來(lái)干嘛的?

因?yàn)橛行﹏pm包安裝是需要編譯的, 那么導(dǎo)致windows/mac/linux上編譯出的可執(zhí)行文件是不同的, 也就是無(wú)法通用, 因此我們?cè)谔峤淮a到git上去的時(shí)候, 一般都會(huì)在.gitignore里指定忽略node_modules目錄和里面的文件, 這樣其他人從git上拉下來(lái)的項(xiàng)目是沒(méi)有node_modules目錄的, 這時(shí)我們需要運(yùn)行

npm install

它會(huì)讀取package.json中的devDependenciesdependencies字段, 把記錄的包的相應(yīng)版本下載下來(lái).

這里eslint-config-enough是配置文件, 它規(guī)定了代碼規(guī)范, 要使它生效, 我們要在package.json中添加內(nèi)容:

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

業(yè)界最有名的語(yǔ)法規(guī)范是airbnb出品的, 但它規(guī)定的太死板了, 比如不允許使用for-offor-in等. 感興趣的同學(xué)可以參照這里安裝使用.

eslint-loader用于在webpack編譯的時(shí)候檢查代碼, 如果有錯(cuò)誤, webpack會(huì)報(bào)錯(cuò).

項(xiàng)目里安裝了eslint還沒(méi)用, 我們的IDE和編輯器也得要裝eslint插件支持它.

Visual Studio Code需要安裝ESLint擴(kuò)展

atom需要安裝linterlinter-eslint這兩個(gè)插件, 裝好后重啟生效.

WebStorm需要在設(shè)置中打開(kāi)eslint開(kāi)關(guān):

寫幾個(gè)頁(yè)面

我們寫一個(gè)最簡(jiǎn)單的SPA應(yīng)用來(lái)介紹SPA應(yīng)用的內(nèi)部工作原理. 首先, 建立src/index.html文件, 內(nèi)容如下:

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

  <body>
  </body>
</html>

它是一個(gè)空白頁(yè)面, 注意這里我們不需要自己寫<script src="index.js"></script>, 因?yàn)榇虬蟮奈募吐窂娇赡軙?huì)變, 所以我們用webpack插件幫我們自動(dòng)加上.

然后重點(diǎn)是src/index.js:

// 引入作為全局對(duì)象儲(chǔ)存空間的global.js, js文件可以省略后綴
import g from './global'

// 引入頁(yè)面文件
import foo from './views/foo'
import bar from './views/bar'

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

// Router類, 用來(lái)控制頁(yè)面根據(jù)當(dāng)前URL切換
class Router {
  start() {
    // 點(diǎn)擊瀏覽器后退/前進(jìn)按鈕時(shí)會(huì)觸發(fā)window.onpopstate事件, 我們?cè)谶@時(shí)切換到相應(yīng)頁(yè)面
    // https://developer.mozilla.org/en-US/docs/Web/Events/popstate
    window.addEventListener('popstate', () => {
      this.load(location.pathname)
    })

    // 打開(kāi)頁(yè)面時(shí)加載當(dāng)前頁(yè)面
    this.load(location.pathname)
  }

  // 前往path, 會(huì)變更地址欄URL, 并加載相應(yīng)頁(yè)面
  go(path) {
    // 變更地址欄URL
    history.pushState({}, '', path)
    // 加載頁(yè)面
    this.load(path)
  }

  // 加載path路徑的頁(yè)面
  load(path) {
    // 創(chuàng)建頁(yè)面實(shí)例
    const view = new routes[path]()
    // 調(diào)用頁(yè)面方法, 把頁(yè)面加載到document.body中
    view.mount(document.body)
  }
}

// new一個(gè)路由對(duì)象, 賦值為g.router, 這樣我們?cè)谄渌鹙s文件中可以引用到
g.router = new Router()
// 啟動(dòng)
g.router.start()

現(xiàn)在我們還沒(méi)有講webpack配置所以頁(yè)面還無(wú)法訪問(wèn), 我們先從理論上講解一下, 等會(huì)弄好webpack配置后再實(shí)際看頁(yè)面效果. 當(dāng)我們?cè)L問(wèn) http://localhost:8100/foo 的時(shí)候, 路由會(huì)加載 ./views/foo/index.js文件, 我們來(lái)看看這個(gè)文件:

// 引入全局對(duì)象
import g from '../../global'

// 引入html模板, 會(huì)被作為字符串引入
import template from './index.html'

// 引入css, 會(huì)生成<style>塊插入到<head>頭中
import './style.css'

// 導(dǎo)出類
export default class {
  mount(container) {
    document.title = 'foo'
    container.innerHTML = template
    container.querySelector('.foo__gobar').addEventListener('click', () => {
      // 調(diào)用router.go方法加載 /bar 頁(yè)面
      g.router.go('/bar')
    })
  }
}

借助webpack插件, 我們可以import html, css等其他格式的文件, 文本類的文件會(huì)被儲(chǔ)存為變量打包進(jìn)js文件, 其他二進(jìn)制類的文件, 比如圖片, 可以自己配置, 小圖片作為Data URI打包進(jìn)js文件, 大文件打包為單獨(dú)文件, 我們稍后再講這塊.

其他的src目錄下的文件大家自己瀏覽, 拷貝一份到自己的工作目錄, 等會(huì)打包時(shí)會(huì)用到.

頁(yè)面代碼這樣就差不多搞定了, 接下來(lái)我們進(jìn)入webpack的安裝和配置階段.

安裝webpack和Babel

我們把webpack和它的插件安裝到項(xiàng)目:

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

webpack-dev-server是webpack提供的用來(lái)開(kāi)發(fā)調(diào)試的服務(wù)器, 讓你可以用 http://127.0.0.1:8080/ 這樣的url打開(kāi)頁(yè)面來(lái)調(diào)試, 有了它就不用配置nginx了, 方便很多.

html-webpack-plugin, html-loader, css-loader, style-loader等看名字就知道是打包html文件, css文件的插件, 大家在這里可能會(huì)有疑問(wèn), html-webpack-pluginhtml-loader有什么區(qū)別, css-loaderstyle-loader有什么區(qū)別, 我們等會(huì)看配置文件的時(shí)候再講.

file-loaderurl-loader是打包二進(jìn)制文件的插件, 具體也在配置文件章節(jié)講解.

接下來(lái), 為了能讓不支持ES6的瀏覽器(比如IE)也能照常運(yùn)行, 我們需要安裝babel, 它會(huì)把我們寫的ES6源代碼轉(zhuǎn)化成ES5, 這樣我們?cè)创a寫ES6, 打包時(shí)生成ES5.

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

這里babel-core顧名思義是babel的核心編譯器. babel-preset-env是一個(gè)配置文件, 我們可以使用這個(gè)配置文件轉(zhuǎn)換ES2015/ES2016/ES2017到ES5, 是的, 不只ES6哦. babel還有其他配置文件. 如果只想用ES6, 可以安裝babel-preset-es2015:

npm install babel-preset-es2015 --save-dev

但是光安裝了babel-preset-env, 在打包時(shí)是不會(huì)生效的, 需要在package.json加入babel配置:

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

打包時(shí)babel會(huì)讀取package.jsonbabel字段的內(nèi)容, 然后執(zhí)行相應(yīng)的轉(zhuǎn)換.

如果使用babel-preset-es2015, 這里相應(yīng)的也要修改為:

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

babel-loader是webpack的插件, 我們下面章節(jié)再說(shuō).

配置webpack

包都裝好了, 接下來(lái), 總算可以進(jìn)入正題了, 是不是有點(diǎn)心累...呵呵. 我們來(lái)創(chuàng)建webpack配置文件webpack.config.js, 注意這個(gè)文件是在node.js中運(yùn)行的, 因此不支持ES6的import語(yǔ)法. 我們來(lái)看文件內(nèi)容:

const { resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  // 配置頁(yè)面入口js文件
  entry: './src/index.js',

  // 配置打包輸出相關(guān)
  output: {
    // 打包輸出目錄
    path: resolve(__dirname, 'dist'),

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

  module: {
    /*
    配置各種類型文件的加載器, 稱之為loader
    webpack當(dāng)遇到import ... 時(shí), 會(huì)調(diào)用這里配置的loader對(duì)引用的文件進(jìn)行編譯
    */
    rules: [
      {
        /*
        使用babel編譯ES6/ES7/ES8為ES5代碼
        使用正則表達(dá)式匹配后綴名為.js的文件
        */
        test: /\.js$/,

        // 排除node_modules目錄下的文件, npm安裝的包不需要編譯
        exclude: /node_modules/,

        /*
        use指定該文件的loader, 值可以是字符串或者數(shù)組.
        這里先使用eslint-loader處理, 返回的結(jié)果交給babel-loader處理. loader的處理順序是從最后一個(gè)到第一個(gè).
        eslint-loader用來(lái)檢查代碼, 如果有錯(cuò)誤, 編譯的時(shí)候會(huì)報(bào)錯(cuò).
        babel-loader用來(lái)編譯js文件.
        */
        use: ['babel-loader', 'eslint-loader']
      },

      {
        // 匹配.html文件
        test: /\.html$/,
        /*
        使用html-loader, 將html內(nèi)容存為js字符串, 比如當(dāng)遇到
        import htmlString from './template.html'
        template.html的文件內(nèi)容會(huì)被轉(zhuǎn)成一個(gè)js字符串, 合并到j(luò)s文件里.
        */
        use: 'html-loader'
      },

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

        /*
        先使用css-loader處理, 返回的結(jié)果交給style-loader處理.
        css-loader將css內(nèi)容存為js字符串, 并且會(huì)把background, @font-face等引用的圖片,
        字體文件交給指定的loader打包, 類似上面的html-loader, 用什么loader同樣在loaders對(duì)象中定義, 等會(huì)下面就會(huì)看到.
        */
        use: ['style-loader', 'css-loader']
      },

      {
        /*
        匹配各種格式的圖片和字體文件
        上面html-loader會(huì)把html中<img>標(biāo)簽的圖片解析出來(lái), 文件名匹配到這里的test的正則表達(dá)式,
        css-loader引用的圖片和字體同樣會(huì)匹配到這里的test條件
        */
        test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/,

        /*
        使用url-loader, 它接受一個(gè)limit參數(shù), 單位為字節(jié)(byte)

        當(dāng)文件體積小于limit時(shí), url-loader把文件轉(zhuǎn)為Data URI的格式內(nèi)聯(lián)到引用的地方
        當(dāng)文件大于limit時(shí), url-loader會(huì)調(diào)用file-loader, 把文件儲(chǔ)存到輸出目錄, 并把引用的文件路徑改寫成輸出后的路徑

        比如 views/foo/index.html中
        ![](smallpic.png)
        會(huì)被編譯成
        ![](smallpic.png)

        而
        [站外圖片上傳中……(8)]
        會(huì)被編譯成
        [站外圖片上傳中……(9)]
        */
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10000
            }
          }
        ]
      }
    ]
  },

  /*
  配置webpack插件
  plugin和loader的區(qū)別是, loader是在import時(shí)根據(jù)不同的文件名, 匹配不同的loader對(duì)這個(gè)文件做處理,
  而plugin, 關(guān)注的不是文件的格式, 而是在編譯的各個(gè)階段, 會(huì)觸發(fā)不同的事件, 讓你可以干預(yù)每個(gè)編譯階段.
  */
  plugins: [
    /*
    html-webpack-plugin用來(lái)打包入口html文件
    entry配置的入口是js文件, webpack以js文件為入口, 遇到import, 用配置的loader加載引入文件
    但作為瀏覽器打開(kāi)的入口html, 是引用入口js的文件, 它在整個(gè)編譯過(guò)程的外面,
    所以, 我們需要html-webpack-plugin來(lái)打包作為入口的html文件
    */
    new HtmlWebpackPlugin({
      /*
      template參數(shù)指定入口html文件路徑, 插件會(huì)把這個(gè)文件交給webpack去編譯,
      webpack按照正常流程, 找到loaders中test條件匹配的loader來(lái)編譯, 那么這里html-loader就是匹配的loader
      html-loader編譯后產(chǎn)生的字符串, 會(huì)由html-webpack-plugin儲(chǔ)存為html文件到輸出目錄, 默認(rèn)文件名為index.html
      可以通過(guò)filename參數(shù)指定輸出的文件名
      html-webpack-plugin也可以不指定template參數(shù), 它會(huì)使用默認(rèn)的html模板.
      */
      template: './src/index.html'
    })
  ],

  /*
  配置開(kāi)發(fā)時(shí)用的服務(wù)器, 讓你可以用 http://127.0.0.1:8080/ 這樣的url打開(kāi)頁(yè)面來(lái)調(diào)試
  并且?guī)в袩岣碌墓δ? 打代碼時(shí)保存一下文件, 瀏覽器會(huì)自動(dòng)刷新. 比nginx方便很多
  如果是修改css, 甚至不需要刷新頁(yè)面, 直接生效. 這讓像彈框這種需要點(diǎn)擊交互后才會(huì)出來(lái)的東西調(diào)試起來(lái)方便很多.
  */
  devServer: {
    // 配置監(jiān)聽(tīng)端口, 因?yàn)?080很常用, 為了避免和其他程序沖突, 我們配個(gè)其他的端口號(hào)
    port: 8100,

    /*
    historyApiFallback用來(lái)配置頁(yè)面的重定向

    SPA的入口是一個(gè)統(tǒng)一的html文件, 比如
    http://localhost:8010/foo
    我們要返回給它
    http://localhost:8010/index.html
    這個(gè)文件

    配置為true, 當(dāng)訪問(wèn)的文件不存在時(shí), 返回根目錄下的index.html文件
    */
    historyApiFallback: true
  }
}

走一個(gè)

配置OK了, 接下來(lái)我們就運(yùn)行一下吧. 我們先試一下開(kāi)發(fā)環(huán)境用的webpack-dev-server:

./node_modules/.bin/webpack-dev-server -d --hot

上面的命令適用于Mac/Linux等*nix系統(tǒng), 也適用于Windows上的PowerShell和bash/zsh環(huán)境(Bash on Wbuntu on Windows, Git Bash, Babun, MSYS2等).

如果使用Windows的cmd.exe, 請(qǐng)執(zhí)行:

node_modules\.bin\webpack-dev-server -d --hot

我在這里安利Windows同學(xué)使用Bash on Ubuntu on Windows, 可以避免很多跨平臺(tái)的問(wèn)題, 比如設(shè)置環(huán)境變量.

npm會(huì)把包的可執(zhí)行文件安裝到./node_modules/.bin/目錄下, 所以我們要在這個(gè)目錄下執(zhí)行命令.

-d參數(shù)是開(kāi)發(fā)環(huán)境(Development)的意思, 它會(huì)在我們的配置文件中插入調(diào)試相關(guān)的選項(xiàng), 比如打開(kāi)debug, 打開(kāi)sourceMap, 代碼中插入源文件路徑注釋.

--hot開(kāi)啟熱更新功能, 參數(shù)會(huì)幫我們往配置里添加HotModuleReplacementPlugin插件, 雖然可以在配置里自己寫, 但有點(diǎn)麻煩, 用命令行參數(shù)方便很多.

命令執(zhí)行后, 控制臺(tái)的最后一行應(yīng)該是

webpack: bundle is now VALID.

這就代表編譯成功了, 我們可以在瀏覽器打開(kāi) http://localhost:8100/foo 看看效果. 如果有報(bào)錯(cuò), 那可能是什么地方?jīng)]弄對(duì)? 請(qǐng)自己仔細(xì)檢查一下~

我們可以隨意更改一下src目錄下的源代碼, 保存后, 瀏覽器里的頁(yè)面應(yīng)該很快會(huì)有相應(yīng)變化.

要退出編譯, 按ctrl+c.

開(kāi)發(fā)環(huán)境編譯試過(guò)之后, 我們?cè)囋嚳淳幾g生產(chǎn)環(huán)境的代碼, 命令是:

./node_modules/.bin/webpack -p

-p參數(shù)會(huì)開(kāi)啟生產(chǎn)環(huán)境模式, 這個(gè)模式下webpack會(huì)將代碼做壓縮等優(yōu)化.

大家可能會(huì)發(fā)現(xiàn), 執(zhí)行腳本的命令有點(diǎn)麻煩. 因此, 我們可以利用npm的特性, 把命令寫在package.json中:

{
  "scripts": {
    "dev": "webpack-dev-server -d --hot --env.dev",
    "build": "webpack -p"
  }
}

package.json中的scripts對(duì)象, 可以用來(lái)寫一些腳本命令, 命令不需要前綴目錄./node_modules/.bin/, npm會(huì)自動(dòng)尋找該目錄下的命令. 我們可以執(zhí)行:

npm run dev

來(lái)啟動(dòng)開(kāi)發(fā)環(huán)境.

執(zhí)行

npm run build

來(lái)打包生產(chǎn)環(huán)境的代碼.

進(jìn)階配置

上面的項(xiàng)目雖然可以跑起來(lái)了, 但有幾個(gè)點(diǎn)我們還沒(méi)有考慮到:

  • 指定靜態(tài)資源的url路徑前綴
  • 各個(gè)頁(yè)面分開(kāi)打包
  • 打包時(shí)區(qū)分開(kāi)發(fā)環(huán)境和生產(chǎn)環(huán)境
  • 輸出的entry文件加上hash
  • 第三方庫(kù)和業(yè)務(wù)代碼分開(kāi)打包
  • 開(kāi)發(fā)環(huán)境關(guān)閉performance.hints
  • 配置favicon
  • 開(kāi)發(fā)環(huán)境允許其他電腦訪問(wèn)
  • 打包時(shí)自定義部分參數(shù)
  • webpack-dev-server處理帶后綴名的文件的特殊規(guī)則
  • 代碼中插入環(huán)境變量
  • 簡(jiǎn)化import路徑
  • 優(yōu)化babel編譯后的代碼性能
  • 使用webpack 2自帶的ES6模塊處理功能
  • 使用autoprefixer自動(dòng)創(chuàng)建css的vendor prefixes
  • 編譯前清空dist目錄

那么, 讓我們?cè)谏厦娴呐渲玫幕A(chǔ)上繼續(xù)完善, 下面的代碼我們只寫出改變的部分. 代碼在examples/advanced目錄.

指定靜態(tài)資源的url路徑前綴

現(xiàn)在我們的資源文件的url直接在根目錄, 比如http://127.0.0.1:8100/index.js, 這樣做緩存控制和CDN都不方便, 我們需要給資源文件的url加一個(gè)前綴, 比如 http://127.0.0.1:8100/assets/index.js這樣. 我們來(lái)修改一下webpack配置:

{
  output: {
    publicPath: '/assets/'
  },

  devServer: {
    // 指定index.html文件的url路徑
    historyApiFallback: {
      index: '/assets/'
    }
  }
}

各個(gè)頁(yè)面分開(kāi)打包

這樣瀏覽器只需加載當(dāng)前訪問(wèn)的頁(yè)面的代碼.

webpack可以使用異步加載文件的方式引用模塊, webpack 1的API是require.ensure(), webpack 2開(kāi)始支持TC39的dynamic import. 我們這里就使用新的import()來(lái)實(shí)現(xiàn)頁(yè)面分開(kāi)打包異步加載. 話不多說(shuō), 上代碼.

src/index.js:

load(path) {
  import('./views' + path + '/index.js').then(module => {
    // export default ... 的內(nèi)容通過(guò)module.default訪問(wèn)
    const View = module.default
    const view = new View()
    view.mount(document.body)
  })
}

這樣我們就不需要在開(kāi)頭把所有頁(yè)面文件都import進(jìn)來(lái)了.

因?yàn)?code>import()還沒(méi)有正式進(jìn)入標(biāo)準(zhǔn), 因此babel和eslint需要插件來(lái)支持它:

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

package.json改一下:

{
  "babel": {
    "presets": [
      "env",
      "stage-2"
    ]
  },
  "eslintConfig": {
    "parser": "babel-eslint",
    "extends": "enough",
    "env": {
      "browser": true,
      "node": true
    }
  }
}

然后修改webpack配置:

{
  output: {
    /*
    import()加載的文件會(huì)被分開(kāi)打包, 我們稱這個(gè)包為chunk, chunkFilename用來(lái)配置這個(gè)chunk輸出的文件名.

    [id]: 編譯時(shí)每個(gè)chunk會(huì)有一個(gè)id.
    [chunkhash]: 這個(gè)chunk的hash值, 文件發(fā)生變化時(shí)該值也會(huì)變. 文件名加上該值可以防止瀏覽器讀取舊的緩存文件.
    */
    chunkFilename: '[id].js?[chunkhash]',
  }
}

打包時(shí)區(qū)分開(kāi)發(fā)環(huán)境和生產(chǎn)環(huán)境

如果webpack.config.js導(dǎo)出的是一個(gè)function, 那么webpack會(huì)執(zhí)行它, 并把返回的結(jié)果作為配置對(duì)象.

module.exports = (options = {}) => {
  return {
    // 配置內(nèi)容
  }
}

該function接受一個(gè)參數(shù), 這個(gè)參數(shù)的值是由命令行傳入的. 比如當(dāng)我們?cè)诿钚兄袌?zhí)行:

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

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

該參數(shù)對(duì) webpack-dev-server 命令同樣有效.

我們修改一下package.json, 給dev腳本加上env.dev:

{
  "scripts": {
    "dev": "webpack-dev-server -d --hot --env.dev",
  }
}

輸出的entry文件加上hash

上面我們提到了chunkFilename可以加上[chunkhash]防止瀏覽器讀取錯(cuò)誤緩存, 那么entry同樣需要加上hash. 但使用webpack-dev-server啟動(dòng)開(kāi)發(fā)環(huán)境時(shí), entry文件是沒(méi)有[chunkhash]的, 用了會(huì)報(bào)錯(cuò). 因此我們需要利用上面提到的區(qū)分開(kāi)發(fā)環(huán)境和生產(chǎn)環(huán)境的功能, 只在打包生產(chǎn)環(huán)境代碼時(shí)加上[chunkhash]

module.exports = (options = {}) => {
  return {
    /*
    這里entry我們改用對(duì)象來(lái)定義
    屬性名在下面的output.filename中使用, 值為文件路徑
    */
    entry: {
      index: './src/index',
    },

    output: {
      /*
      entry字段配置的入口js的打包輸出文件名
      [name]作為占位符, 在輸出時(shí)會(huì)被替換為entry里定義的屬性名, 比如這里會(huì)被替換為"index"
      [chunkhash]是打包后輸出文件的hash值的占位符, 把?[chunkhash]跟在文件名后面可以防止瀏覽器使用緩存的過(guò)期內(nèi)容,
      這里, webpack會(huì)生成以下代碼插入到index.html中:
      <script type="text/javascript" src="/assets/index.js?d835352892e6aac768bf"></script>
      這里/assets/目錄前綴是output.publicPath配置的

      options.dev是命令行傳入的參數(shù). 這里是由于使用webpack-dev-server啟動(dòng)開(kāi)發(fā)環(huán)境時(shí), 是沒(méi)有[chunkhash]的, 用了會(huì)報(bào)錯(cuò)
      因此我們不得已在使用webpack-dev-server啟動(dòng)項(xiàng)目時(shí), 命令行跟上--env.dev參數(shù), 當(dāng)有該參數(shù)時(shí), 不在后面跟[chunkhash]
      */
      filename: options.dev ? '[name].js' : '[name].js?[chunkhash]',
    }
  }
}

有人可能注意到官網(wǎng)文檔中還有一個(gè)[hash]占位符, 這個(gè)hash是整個(gè)編譯過(guò)程產(chǎn)生的一個(gè)總的hash值, 而不是單個(gè)文件的hash值, 項(xiàng)目中任何一個(gè)文件的改動(dòng), 都會(huì)造成這個(gè)hash值的改變. [hash]占位符是始終存在的, 但我們不希望修改一個(gè)文件導(dǎo)致所有輸出的文件hash都改變, 這樣就無(wú)法利用瀏覽器緩存了. 因此這個(gè)[hash]意義不大.

第三方庫(kù)和業(yè)務(wù)代碼分開(kāi)打包

這樣更新業(yè)務(wù)代碼時(shí)可以借助瀏覽器緩存, 用戶不需要重新下載沒(méi)有發(fā)生變化的第三方庫(kù).

我們的思路是, 入口的html文件引兩個(gè)js, vendor.jsindex.js. vendor.js用來(lái)引用第三方庫(kù), 比如這兒我們引入一個(gè)第三方庫(kù)來(lái)做路由, 我們先安裝它:

npm install spa-history --save

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

import 'spa-history/PathHistory'

我們import它但不需要做什么, 這樣webpack打包的時(shí)候會(huì)把這個(gè)第三方庫(kù)打包進(jìn)vendor.js.

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

import PathHistory from 'spa-history/PathHistory'

const history = new PathHistory({
  change(location) {
    // 使用import()將加載的js文件分開(kāi)打包, 這樣實(shí)現(xiàn)了僅加載訪問(wèn)的頁(yè)面
    import('./views' + location.path + '/index.js').then(module => {
      // export default ... 的內(nèi)容通過(guò)module.default訪問(wèn)
      const View = module.default
      const view = new View()
      view.mount(document.body)
    })
  }
})

history.hookAnchorElements()
history.start()

頁(yè)面foobar的js和html文件因?yàn)槁酚傻母淖円惨鲂┪⒄{(diào).

src/views/foo/index.js:

import template from './index.html'
import './style.css'

export default class {
  mount(container) {
    document.title = 'foo'
    container.innerHTML = template
  }
}

src/views/foo/index.html:

<div class="foo">
  <h1>Page Foo</h1>
  <a href="/bar">goto bar</a>

  <p>
    ![](smallpic.png)
  </p>

  <p>
    [站外圖片上傳中……(11)]
  </p>
</div>

src/views/bar/index.js:

import template from './index.html'
import './style.css'

export default class {
  mount(container) {
    document.title = 'bar'
    container.innerHTML = template
  }
}

src/views/bar/index.html:

<div class="bar">
  <h1>Page Bar</h1>
  <a href="/foo">goto foo</a>
</div>

然后最重要的webpack的配置需要修改一下:

// 引入webpack, 等會(huì)需要用
const webpack = require('webpack')

module.exports = (options = {}) => {
  return {
    // entry中加入vendor
    entry: {
      vendor: './src/vendor',
      index: './src/index'
    },

    plugins: [
      /*
      使用CommonsChunkPlugin插件來(lái)處理重復(fù)代碼
      因?yàn)関endor.js和index.js都引用了spa-history, 如果不處理的話, 兩個(gè)文件里都會(huì)有spa-history包的代碼,
      我們用CommonsChunkPlugin插件來(lái)使共同引用的文件只打包進(jìn)vendor.js
      */
      new webpack.optimize.CommonsChunkPlugin({
        /*
        names: 將entry文件中引用的相同文件打包進(jìn)指定的文件, 可以是新建文件, 也可以是entry中已存在的文件
        這里我們指定打包進(jìn)vendor.js

        但這樣還不夠, 還記得那個(gè)chunkFilename參數(shù)嗎? 這個(gè)參數(shù)指定了chunk的打包輸出的名字,
        我們?cè)O(shè)置為 [id].js?[chunkhash] 的格式. 那么打包時(shí)這個(gè)文件名存在哪里的呢?
        它就存在引用它的文件中. 這就意味著被引用的文件發(fā)生改變, 會(huì)導(dǎo)致引用的它文件也發(fā)生改變.

        然后CommonsChunkPlugin有個(gè)附加效果, 會(huì)把所有chunk的文件名記錄到names指定的文件中.
        那么這時(shí)當(dāng)我們修改頁(yè)面foo或者bar時(shí), vendor.js也會(huì)跟著改變, 而index.js不會(huì)變.
        那么怎么處理這些chunk, 使得修改頁(yè)面代碼而不會(huì)導(dǎo)致entry文件改變呢?

        這里我們用了一點(diǎn)小技巧. names參數(shù)可以是一個(gè)數(shù)組, 意思相當(dāng)于調(diào)用多次CommonsChunkPlugin,
        比如:

        plugins: [
          new webpack.optimize.CommonsChunkPlugin({
            names: ['vendor', 'manifest']
          })
        ]

        相當(dāng)于

        plugins: [
          new webpack.optimize.CommonsChunkPlugin({
            names: 'vendor'
          }),

          new webpack.optimize.CommonsChunkPlugin({
            names: 'manifest'
          })
        ]

        首先把重復(fù)引用的庫(kù)打包進(jìn)vendor.js, 這時(shí)候我們的代碼里已經(jīng)沒(méi)有重復(fù)引用了, chunk文件名存在vendor.js中,
        然后我們?cè)趫?zhí)行一次CommonsChunkPlugin, 把所有chunk的文件名打包到manifest.js中.
        這樣我們就實(shí)現(xiàn)了chunk文件名和代碼的分離. 這樣修改一個(gè)js文件不會(huì)導(dǎo)致其他js文件在打包時(shí)發(fā)生改變, 只有manifest.js會(huì)改變.
        */
        names: ['vendor', 'manifest']
      })
    ]
  }
}

開(kāi)發(fā)環(huán)境關(guān)閉performance.hints

我們注意到運(yùn)行開(kāi)發(fā)環(huán)境是命令行會(huì)報(bào)一段warning:

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

這是說(shuō)建議每個(gè)輸出的js文件的大小不要超過(guò)250k. 但開(kāi)發(fā)環(huán)境因?yàn)榘藄ourcemap并且代碼未壓縮所以一般都會(huì)超過(guò)這個(gè)大小, 所以我們可以在開(kāi)發(fā)環(huán)境把這個(gè)warning關(guān)閉.

webpack配置中加入:

{
  performance: {
    hints: options.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參數(shù), 表示什么標(biāo)簽的什么屬性需要調(diào)用webpack的loader進(jìn)行打包.
              比如<img>標(biāo)簽的src屬性, webpack會(huì)把<img>引用的圖片打包, 然后src的屬性值替換為打包后的路徑.
              使用什么loader代碼, 同樣是在module.rules定義中使用匹配的規(guī)則.

              如果html-loader不指定attrs參數(shù), 默認(rèn)值是img:src, 意味著會(huì)默認(rèn)打包<img>標(biāo)簽的圖片.
              這里我們加上<link>標(biāo)簽的href屬性, 用來(lái)打包入口index.html引入的favicon.png文件.
              */
              attrs: ['img:src', 'link:href']
            }
          }
        ]
      },

      {
        /*
        匹配favicon.png
        上面的html-loader會(huì)把入口index.html引用的favicon.png圖標(biāo)文件解析出來(lái)進(jìn)行打包
        打包規(guī)則就按照這里指定的loader執(zhí)行
        */
        test: /favicon\.png$/,

        use: [
          {
            // 使用file-loader
            loader: 'file-loader',
            options: {
              /*
              name: 指定文件輸出名
              [name]是源文件名, 不包含后綴. [ext]為后綴. [hash]為源文件的hash值,
              這里我們保持文件名, 在后面跟上hash, 防止瀏覽器讀取過(guò)期的緩存文件.
              */
              name: '[name].[ext]?[hash]'
            }
          }
        ]
      },

      // 圖片文件的加載配置增加一個(gè)exclude參數(shù)
      {
        test: /\.(png|jpg|jpeg|gif|eot|ttf|woff|woff2|svg|svgz)(\?.+)?$/,

        // 排除favicon.png, 因?yàn)樗呀?jīng)由上面的loader處理了. 如果不排除掉, 它會(huì)被這個(gè)loader再處理一遍
        exclude: /favicon\.png$/,

        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10000
            }
          }
        ]
      }
    ]
  }
}

其實(shí)html-webpack-plugin接受一個(gè)favicon參數(shù), 可以指定favicon文件路徑, 會(huì)自動(dòng)打包插入到html文件中. 但它有個(gè)bug, 打包后的文件名路徑不帶hash, 就算有hash, 它也是[hash], 而不是[chunkhash], 導(dǎo)致修改代碼也會(huì)改變favicon打包輸出的文件名. issue中提到的favicons-webpack-plugin倒是可以用, 但它依賴PhantomJS, 非常大.

開(kāi)發(fā)環(huán)境允許其他電腦訪問(wèn)

webpack配置devServer.host'0.0.0.0'即可.

打包時(shí)自定義部分參數(shù)

在多人開(kāi)發(fā)時(shí), 每個(gè)人可能需要有自己的配置, 比如說(shuō)webpack-dev-server監(jiān)聽(tīng)的端口號(hào), 如果寫死在webpack配置里, 而那個(gè)端口號(hào)在某個(gè)同學(xué)的電腦上被其他進(jìn)程占用了, 簡(jiǎn)單粗暴的修改webpack.config.js會(huì)導(dǎo)致提交代碼后其他同學(xué)的端口也被改掉.

還有一點(diǎn)就是開(kāi)發(fā)環(huán)境/測(cè)試環(huán)境/生產(chǎn)環(huán)境的部分webpack配置是不同的, 比如publicPath在生產(chǎn)環(huán)境可能要配置一個(gè)CDN地址.

我們?cè)诟夸浗⒁粋€(gè)文件夾config, 里面創(chuàng)建3個(gè)配置文件:

  • default.js: 生產(chǎn)環(huán)境
module.exports = {
  publicPath: 'http://cdn.example.com/assets/'
}
  • dev.js: 默認(rèn)開(kāi)發(fā)環(huán)境
module.exports = {
  publicPath: '/assets/',

  devServer: {
    port: 8100,
    proxy: {
      '/api/auth/': {
        target: 'http://api.example.dev',
        changeOrigin: true,
        pathRewrite: { '^/api': '' }
      },

      '/api/pay/': {
        target: 'http://pay.example.dev',
        changeOrigin: true,
        pathRewrite: { '^/api': '' }
      }
    }
  }
}
  • local.js: 個(gè)人本地環(huán)境, 在dev.js基礎(chǔ)上修改部分參數(shù).
const config = require('./dev')
config.devServer.port = 8200
module.exports = config

package.json修改scripts:

{
  "scripts": {
    "local": "npm run dev --config=local",
    "dev": "webpack-dev-server -d --hot --env.dev --env.config dev",
    "build": "rimraf dist && webpack -p"
  }
}

webpack配置修改:

// ...
const url = require('url')

module.exports = (options = {}) => {
  const config = require('./config/' + (process.env.npm_config_config || options.config || 'default'))

  return {
    // ...
    devServer: config.devServer ? {
      host: '0.0.0.0',
      port: config.devServer.port,
      proxy: config.devServer.proxy,
      historyApiFallback: {
        index: url.parse(config.publicPath).pathname
      }
    } : undefined,
  }
}

這里的關(guān)鍵是npm run傳進(jìn)來(lái)的自定義參數(shù)可以通過(guò)process.env.npm_config_*獲得. 參數(shù)中如果有-會(huì)被轉(zhuǎn)成_

--env.*傳進(jìn)來(lái)的參數(shù)可以通過(guò)options.*獲得. 我們優(yōu)先使用npm run指定的配置文件. 這樣我們可以在命令行覆蓋scripts中指定的配置文件:

npm run dev --config=CONFIG_NAME

local命令就是這樣做的.

這樣, 當(dāng)我們執(zhí)行npm run dev時(shí)使用的是dev.js, 執(zhí)行npm run local使用local.js, 執(zhí)行npm run build使用default.js.

config.devServer.proxy用來(lái)配置后端api的反向代理, ajax /api/auth/*的請(qǐng)求會(huì)被轉(zhuǎn)發(fā)到 http://api.example.dev/auth/*, /api/pay/*的請(qǐng)求會(huì)被轉(zhuǎn)發(fā)到 http://api.example.dev/pay/*.

changeOrigin會(huì)修改HTTP請(qǐng)求頭中的Hosttarget的域名, 這里會(huì)被改為api.example.dev

pathRewrite用來(lái)改寫URL, 這里我們把/api前綴去掉.

還有一點(diǎn), 我們不需要把自己個(gè)人用的配置文件提交到git, 所以我們?cè)?gitignore中加入:

config/*
!config/default.js
!config/dev.js

config目錄排除掉, 但是保留生產(chǎn)環(huán)境和dev默認(rèn)配置文件.

webpack-dev-server處理帶后綴名的文件的特殊規(guī)則

當(dāng)處理帶后綴名的請(qǐng)求時(shí), 比如 http://localhost:8100/bar.do , webpack-dev-server會(huì)認(rèn)為它應(yīng)該是一個(gè)實(shí)際存在的文件, 就算找不到該文件, 也不會(huì)fallback到index.html, 而是返回404. 但在SPA應(yīng)用中這不是我們希望的. 幸好webpack-dev-server有一個(gè)配置選項(xiàng)disableDotRule: true可以禁用這個(gè)規(guī)則, 使帶后綴的文件當(dāng)不存在時(shí)也能fallback到index.html

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

代碼中插入環(huán)境變量

在業(yè)務(wù)代碼中, 有些變量在開(kāi)發(fā)環(huán)境和生產(chǎn)環(huán)境是不同的, 比如域名, 后臺(tái)API地址等. 還有開(kāi)發(fā)環(huán)境可能需要打印調(diào)試信息等.

我們可以使用DefinePlugin插件在打包時(shí)往代碼中插入需要的環(huán)境變量,

// ...
const pkgInfo = require('./package.json')

module.exports = (options = {}) => {
  const config = require('./config/' + (process.env.npm_config_config || options.config || 'default')).default

  return {
    // ...
    plugins: [
      new webpack.DefinePlugin({
        DEBUG: Boolean(options.dev),
        VERSION: JSON.stringify(pkgInfo.version),
        CONFIG: JSON.stringify(config.runtimeConfig)
      })
    ]
  }
}

DefinePlugin插件的原理很簡(jiǎn)單, 如果我們?cè)诖a中寫:

console.log(DEBUG)

它會(huì)做類似這樣的處理:

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

最后生成:

console.log(true)

這里有一點(diǎn)需要注意, 像這里的VERSION, 如果我們不對(duì)pkgInfo.versionJSON.stringify(),

console.log(VERSION)

然后做替換操作:

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

最后生成:

console.log(1.0.0)

這樣語(yǔ)法就錯(cuò)誤了. 所以, 我們需要JSON.stringify(pkgInfo.version)轉(zhuǎn)一下變成'"1.0.0"', 替換的時(shí)候才會(huì)帶引號(hào).

還有一點(diǎn), webpack打包壓縮的時(shí)候, 會(huì)把代碼進(jìn)行優(yōu)化, 比如:

if (DEBUG) {
  console.log('debug mode')
} else {
  console.log('production mode')
}

會(huì)被編譯成:

if (false) {
  console.log('debug mode')
} else {
  console.log('production mode')
}

然后壓縮優(yōu)化為:

console.log('production mode')

簡(jiǎn)化import路徑

文件a引入文件b時(shí), b的路徑是相對(duì)于a文件所在目錄的. 如果a和b在不同的目錄, 藏得又深, 寫起來(lái)就會(huì)很麻煩:

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

為了方便, 我們可以定義一個(gè)路徑別名(alias):

resolve: {
  alias: {
    '~': resolve(__dirname, 'src')
  }
}

這樣, 我們可以以src目錄為基礎(chǔ)路徑來(lái)import文件:

import b from '~/components/b'

html中的<img>標(biāo)簽沒(méi)法使用這個(gè)別名功能, 但html-loader有一個(gè)root參數(shù), 可以使/開(kāi)頭的文件相對(duì)于root目錄解析.

{
  test: /\.html$/,
  use: [
    {
      loader: 'html-loader',
      options: {
        root: resolve(__dirname, 'src'),
        attrs: ['img:src', 'link:href']
      }
    }
  ]
}

那么, [站外圖片上傳中……(12)]就能順利指向到src目錄下的favicon.png文件, 不需要關(guān)心當(dāng)前文件和目標(biāo)文件的相對(duì)路徑.

PS: 在調(diào)試<img>標(biāo)簽的時(shí)候遇到一個(gè)坑, html-loader會(huì)解析``注釋中的內(nèi)容, 之前在注釋中寫的

<!--
大于10kb的圖片, 圖片會(huì)被儲(chǔ)存到輸出目錄, src會(huì)被替換為打包后的路徑
[站外圖片上傳中……(13)]
-->

之前因?yàn)闆](méi)有加root參數(shù), 所以/開(kāi)頭的文件名不會(huì)被解析, 加了root導(dǎo)致編譯時(shí)報(bào)錯(cuò), 找不到該文件. 大家記住這一點(diǎn).

優(yōu)化babel編譯后的代碼性能

babel編譯后的代碼一般會(huì)造成性能損失, babel提供了一個(gè)loose選項(xiàng), 使編譯后的代碼不需要完全遵循ES6規(guī)定, 簡(jiǎn)化編譯后的代碼, 提高代碼執(zhí)行效率:

package.json:

{
  "babel": {
    "presets": [
      [
        "env",
        {
          "loose": true
        }
      ],
      "stage-2"
    ]
  }
}

但這么做會(huì)有兼容性的風(fēng)險(xiǎn), 可能會(huì)導(dǎo)致ES6源碼理應(yīng)的執(zhí)行結(jié)果和編譯后的ES5代碼的實(shí)際結(jié)果并不一致. 如果代碼沒(méi)有遇到實(shí)際的效率瓶頸, 官方不建議使用loose模式.

使用webpack 2自帶的ES6模塊處理功能

我們目前的配置, babel會(huì)把ES6模塊定義轉(zhuǎn)為CommonJS定義, 但webpack自己可以處理importexport, 而且webpack處理import時(shí)會(huì)做代碼優(yōu)化, 把沒(méi)用到的部分代碼刪除掉. 因此我們通過(guò)babel提供的modules: false選項(xiàng)把ES6模塊轉(zhuǎn)為CommonJS模塊的功能給關(guān)閉掉.

package.json:

{
  "babel": {
    "presets": [
      [
        "env",
        {
          "loose": true,
          "modules": false
        }
      ],
      "stage-2"
    ]
  }
}

使用autoprefixer自動(dòng)創(chuàng)建css的vendor prefixes

css有一個(gè)很麻煩的問(wèn)題就是比較新的css屬性在各個(gè)瀏覽器里是要加前綴的, 我們可以使用autoprefixer工具自動(dòng)創(chuàng)建這些瀏覽器規(guī)則, 那么我們的css中只需要寫:

:fullscreen a {
    display: flex
}

autoprefixer會(huì)編譯成:

:-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的一個(gè)插件, 所以我們也要安裝postcss的webpack loader.

修改一下webpack的css rule:

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

然后創(chuàng)建文件postcss.config.js:

module.exports = {
  plugins: [
    require('autoprefixer')()
  ]
}

編譯前清空dist目錄

不清空的話上次編譯生成的文件會(huì)遺留在dist目錄中, 我們最好先把目錄清空一下. macOS/Linux下可以用rm -rf dist搞定, 考慮到跨平臺(tái)的需求, 我們可以用rimraf:

npm install rimraf --save-dev

package.json修改一下:

{
  "scripts": {
    "build": "rimraf dist && webpack -p --env.config production"
  },
}

傳統(tǒng)的多頁(yè)面網(wǎng)站(MPA)能否用webpack打包?

對(duì)于多頁(yè)面網(wǎng)站, 我們最多的是用Grunt或Gulp來(lái)打包, 因?yàn)檫@種簡(jiǎn)單的頁(yè)面對(duì)模塊化編程的需求不高. 但如果你喜歡上使用import來(lái)引入庫(kù), 那么我們?nèi)匀豢梢允褂脀ebpack來(lái)打包.

MPA意味著并沒(méi)不是一個(gè)單一的html入口和js入口, 而是每個(gè)頁(yè)面對(duì)應(yīng)一個(gè)html和多個(gè)js. 那么我們可以把項(xiàng)目結(jié)構(gòu)設(shè)計(jì)為:

├── dist
├── package.json
├── node_modules
├── src
│   ├── components
│   ├── libs
|   ├── favicon.png
|   ├── vendor.js             所有頁(yè)面公用的第三方庫(kù)
│   └── pages                 頁(yè)面放這里
|       ├── foo               編譯后生成 http://localhost:8100/foo.html
|       |    ├── index.html
|       |    ├── index.js
|       |    ├── style.css
|       |    └── pic.png
|       └── bar               http://localhost:8100/bar.html
|           ├── index.html
|           ├── index.js
|           ├── style.css
|           └── baz           http://localhost:8100/bar/baz.html
|               ├── index.html
|               ├── index.js
|               └── style.css
└── webpack.config.js

這里每個(gè)頁(yè)面的index.html是個(gè)完整的從<!DOCTYPE html>開(kāi)頭到</html>結(jié)束的頁(yè)面, 這些文件都要用html-webpack-plugin處理. index.js是每個(gè)頁(yè)面的業(yè)務(wù)邏輯, 全部作為入口js配置到entry中. 頁(yè)面公用的第三方庫(kù)仍然打包進(jìn)vendor.js. 這里我們需要用glob庫(kù)來(lái)把這些文件都篩選出來(lái)批量操作.

npm install glob --save-dev

webpack.config.js修改的地方:

// ...
const glob = require('glob')

module.exports = (options = {}) => {
  // ...

  const entries = glob.sync('./src/**/index.js')
  const entryJsList = {}
  const entryHtmlList = []
  for (const path of entries) {
    const chunkName = path.slice('./src/pages/'.length, -'/index.js'.length)
    entryJsList[chunkName] = path
    entryHtmlList.push(new HtmlWebpackPlugin({
      template: path.replace('index.js', 'index.html'),
      filename: chunkName + '.html',
      chunks: ['manifest', 'vendor', chunkName]
    }))
  }

  return {
    entry: Object.assign({
      vendor: './src/vendor'
    }, entryJsList),

    // ...

    plugins: [
      ...entryHtmlList,
      // ...
    ]
  }
}

代碼在examples/mpa目錄.

其他問(wèn)題

為什么不使用webpack.config.babel.js

部分同學(xué)可能知道webpack可以讀取webpack.config.babel.js, 它會(huì)先調(diào)用babel將文件編譯后再執(zhí)行. 但這里有兩個(gè)坑:

  1. 由于我們的package.json中的babel配置指定了modules: false, 所以babel并不會(huì)轉(zhuǎn)碼import, 這導(dǎo)致編譯后的webpack配置文件仍然無(wú)法在node.js中執(zhí)行, 解決方案是package.json不指定modules: false, 而在babel-loader中的options中配置babel. 這樣webpack.config.babel.js會(huì)使用package.json的babel配置編譯, 而webpack編譯的js會(huì)使用babel-loader指定的配置編譯.
{
  test: /\.js$/,
  exclude: /node_modules/,
  use: [
    {
      loader: 'babel-loader',
      options: {
        presets: [
          ['env', {
            loose: true,
            modules: false
          }],
          'stage-2'
        ]
      }
    },

    'eslint-loader'
  ]
}
  1. postcss的配置不支持先用babel轉(zhuǎn)碼, 這導(dǎo)致了我們的配置文件格式的不統(tǒng)一.

綜上, 還是只在src目錄中的文件使用ES6模塊規(guī)范會(huì)比較方便一點(diǎn).

總結(jié)

通過(guò)這篇文章, 我想大家應(yīng)該學(xué)會(huì)了webpack的正確打開(kāi)姿勢(shì). 雖然我沒(méi)有提及如何用webpack來(lái)編譯Reactvue.js, 但大家可以想到, 無(wú)非是安裝一些loader和plugin來(lái)處理jsxvue格式的文件, 那時(shí)難度就不在于webpack了, 而是代碼架構(gòu)組織的問(wèn)題了. 具體的大家自己去摸索一下. 以后有時(shí)間我會(huì)把腳手架整理一下放到github上, 供大家參考.

幾個(gè)腳手架

版權(quán)許可

<a rel="license" >[站外圖片上傳中……(14)]</a><br />本作品采用<a rel="license" >知識(shí)共享署名-非商業(yè)性使用 4.0 國(guó)際許可協(xié)議</a>進(jìn)行許可.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市笨使,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 223,207評(píng)論 6 521
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異厢塘,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)肌幽,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,455評(píng)論 3 400
  • 文/潘曉璐 我一進(jìn)店門晚碾,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人喂急,你說(shuō)我怎么就攤上這事格嘁。” “怎么了廊移?”我有些...
    開(kāi)封第一講書人閱讀 170,031評(píng)論 0 366
  • 文/不壞的土叔 我叫張陵糕簿,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我狡孔,道長(zhǎng)懂诗,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書人閱讀 60,334評(píng)論 1 300
  • 正文 為了忘掉前任苗膝,我火速辦了婚禮殃恒,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘辱揭。我一直安慰自己离唐,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,322評(píng)論 6 398
  • 文/花漫 我一把揭開(kāi)白布问窃。 她就那樣靜靜地躺著亥鬓,像睡著了一般。 火紅的嫁衣襯著肌膚如雪泡躯。 梳的紋絲不亂的頭發(fā)上贮竟,一...
    開(kāi)封第一講書人閱讀 52,895評(píng)論 1 314
  • 那天,我揣著相機(jī)與錄音较剃,去河邊找鬼咕别。 笑死,一個(gè)胖子當(dāng)著我的面吹牛写穴,可吹牛的內(nèi)容都是我干的惰拱。 我是一名探鬼主播,決...
    沈念sama閱讀 41,300評(píng)論 3 424
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼啊送,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼偿短!你這毒婦竟也來(lái)了欣孤?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書人閱讀 40,264評(píng)論 0 277
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤昔逗,失蹤者是張志新(化名)和其女友劉穎降传,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體勾怒,經(jīng)...
    沈念sama閱讀 46,784評(píng)論 1 321
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡婆排,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,870評(píng)論 3 343
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了笔链。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片段只。...
    茶點(diǎn)故事閱讀 40,989評(píng)論 1 354
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖鉴扫,靈堂內(nèi)的尸體忽然破棺而出赞枕,到底是詐尸還是另有隱情,我是刑警寧澤坪创,帶...
    沈念sama閱讀 36,649評(píng)論 5 351
  • 正文 年R本政府宣布炕婶,位于F島的核電站,受9級(jí)特大地震影響误堡,放射性物質(zhì)發(fā)生泄漏古话。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,331評(píng)論 3 336
  • 文/蒙蒙 一锁施、第九天 我趴在偏房一處隱蔽的房頂上張望陪踩。 院中可真熱鬧,春花似錦悉抵、人聲如沸肩狂。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 32,814評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)傻谁。三九已至,卻和暖如春列粪,著一層夾襖步出監(jiān)牢的瞬間审磁,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 33,940評(píng)論 1 275
  • 我被黑心中介騙來(lái)泰國(guó)打工岂座, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留态蒂,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 49,452評(píng)論 3 379
  • 正文 我出身青樓费什,卻偏偏與公主長(zhǎng)得像钾恢,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,995評(píng)論 2 361

推薦閱讀更多精彩內(nèi)容