Webpack 4 和單頁應用入門

fenivana發表於2018-04-18

github:github.com/fenivana/we…

webpack 更新到了 4.0,官網還沒有更新文件。因此把教程更新一下,方便大家用起 webpack 4。

webpack

寫在開頭

先說說為什麼要寫這篇文章,最初的原因是組裡的小朋友們看了 webpack 文件後,表情都是這樣的:摘自 webpack 一篇文件的評論區)

WTF

和這樣的:

You Couldn't Handle Me

是的,即使是外國佬也在吐槽這文件不是人能看的。回想起當年自己啃 webpack 文件的血與淚的往事,覺得有必要整一個教程,可以讓大家看完後愉悅地搭建起一個 webpack 打包方案的專案。

官網新的 webpack 文件現在寫的很詳細了,能看英文的小夥伴可以直接去看官網。

可能會有人問 webpack 到底有什麼用,你不能上來就糊我一臉程式碼讓我馬上搞,我照著搞了一遍結果根本沒什麼用,都是騙人的。所以,在說 webpack 之前,我想先談一下前端打包方案這幾年的演進歷程,在什麼場景下,我們遇到了什麼問題,催生出了應對這些問題的工具。瞭解了需求和目的之後,你就知道什麼時候 webpack 可以幫到你。我希望我用完之後很爽,你們用完之後也是。

先說說前端打包方案的黑暗歷史

在很長的一段前端歷史裡,是不存在打包這個說法的。那個時候頁面基本是純靜態的或者服務端輸出的,沒有 AJAX,也沒有 jQuery。那個時候的 JavaScript 就像個玩具,用處大概就是在側欄弄個時鐘,用 media player 放個 mp3 之類的指令碼,程式碼量不是很多,直接放在 <script> 標籤裡或者弄個 js 檔案引一下就行,日子過得很輕鬆愉快。

隨後的幾年,人們開始嘗試在一個頁面裡做更多的事情。容器的顯示,隱藏,切換。用 css 寫的彈層,圖片輪播等等。但如果一個頁面內不能向伺服器請求資料,能做的事情畢竟有限的,程式碼的量也能維持在頁面互動邏輯範圍內。這時候很多人開始突破一個頁面能做的事情的範圍,使用隱藏的 iframe 和 flash 等作為和伺服器通訊的橋樑,新世界的大門慢慢地被開啟,在一個頁面內和伺服器進行資料互動,意味著以前需要跳轉多個頁面的事情現在可以用一個頁面搞定。但由於 iframe 和 flash 技術過於 tricky 和複雜,並沒能得到廣泛的推廣。

直到 Google 推出 Gmail 的時候(2004 年),人們意識到了一個被忽略的介面,XMLHttpRequest, 也就是我們俗稱的 AJAX, 這是一個使用方便的,相容性良好的伺服器通訊介面。從此開始,我們的頁面開始玩出各種花來了,前端一下子出現了各種各樣的庫,PrototypeDojoMooToolsExt JSjQuery…… 我們開始往頁面裡插入各種庫和外掛,我們的 js 檔案也就爆炸了。

隨著 js 能做的事情越來越多,引用越來越多,檔案越來越大,加上當時大約只有 2Mbps 左右的網速,下載速度還不如 3G 網路,對 js 檔案的壓縮和合並的需求越來越強烈,當然這裡面也有把程式碼混淆了不容易被盜用等其他因素在裡面。JSMinYUI CompressorClosure CompilerUglifyJS 等 js 檔案壓縮合並工具陸陸續續誕生了。壓縮工具是有了,但我們得要執行它,最簡單的辦法呢,就是 windows 上搞個 bat 指令碼,mac / linux 上搞個 bash 指令碼,哪幾個檔案要合併在一塊的,哪幾個要壓縮的,釋出的時候執行一下指令碼,生成壓縮後的檔案。

基於合併壓縮技術,專案越做越大,問題也越來越多,大概就是以下這些問題:

  • 庫和外掛為了要給他人呼叫,肯定要找個地方註冊,一般就是在 window 下申明一個全域性的函式或物件。難保哪天用的兩個庫在全域性用同樣的名字,那就衝突了。
  • 庫和外掛如果還依賴其他的庫和外掛,就要告知使用人,需要先引哪些依賴庫,那些依賴庫也有自己的依賴庫的話,就要先引依賴庫的依賴庫,以此類推。

恰好就在這個時候(2009 年),隨著後端 JavaScript 技術的發展,人們提出了 CommonJS 的模組化規範,大概的語法是: 如果 a.js 依賴 b.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 國際許可協議 進行許可。

相關文章