本篇主要介紹webpack的基本原理以及基於webpack搭建純靜態頁面型前端專案工程化解決方案的思路。
下篇(還沒寫完)探討下對於Node.js作為後端的專案工程化、模組化、前後端共享程式碼、自動化部署的做法。
關於webpack的更多用法和前端工程的討論,可以到github https://github.com/chemdemo/chemdemo.github.io/issues/10
關於前端工程
下面是百科關於“軟體工程”的名詞解釋:
軟體工程是一門研究用工程化方法構建和維護有效的、實用的和高質量的軟體的學科。
其中,工程化是方法,是將軟體研發的各個鏈路串接起來的工具。
對於軟體“工程化”,個人以為至少應當有如下特點:
- 有IDE的支援,負責初始化工程、工程結構組織、debug、編譯、打包等工作
- 有固定或者約定的工程結構,規定軟體所依賴的不同類別的資源的存放路徑甚至程式碼的寫法等
- 軟體依賴的資源可能來自軟體開發者,也有可能是第三方,工程化需要整合對資源的獲取、打包、釋出、版本管理等能力
- 和其他系統的整合,如CI系統、運維繫統、監控系統等
廣泛意義上講,前端也屬於軟體工程的範疇。
但前端沒有Eclipse、Visual Studio等為特定語言量身打造的IDE。因為前端不需要編譯,即改即生效,在開發和除錯時足夠方便,只需要開啟個瀏覽器即可完成,所以前端一般不會扯到“工程”這個概念。
在很長一段時間裡,前端很簡單,比如下面簡單的幾行程式碼就能夠成一個可執行前端應用:
1 2 3 4 5 6 7 8 9 10 11 |
<!DOCTYPE html> <html> <head> <title>webapp</title> <link rel="stylesheet" href="app.css"> </head> <body> <h1>app title</h1> <script src="app.js"></script> </body> </html> |
但隨著webapp的複雜程度不斷在增加,前端也在變得很龐大和複雜,按照傳統的開發方式會讓前端失控:程式碼龐大難以維護、效能優化難做、開發成本變高。
感謝Node.js,使得JavaScript這門前端的主力語言突破了瀏覽器環境的限制可以獨立執行在OS之上,這讓JavaScript擁有了檔案IO、網路IO的能力,前端可以根據需要任意定製研發輔助工具。
一時間出現了以Grunt、Gulp為代表的一批前端構建工具,“前端工程”這個概念逐漸被強調和重視。但是由於前端的複雜性和特殊性,前端工程化一直很難做,構建工具有太多侷限性。
誠如 張雲龍@fouber 所言:
前端是一種特殊的GUI軟體,它有兩個特殊性:一是前端由三種程式語言組成,二是前端程式碼在使用者端執行時增量安裝。
html、css和js的配合才能保證webapp的執行,增量安裝是按需載入的需要。開發完成後輸出三種以上不同格式的靜態資源,靜態資源之間有可能存在互相依賴關係,最終構成一個複雜的資源依賴樹(甚至網)。
所以,前端工程,最起碼需要解決以下問題:
- 提供開發所需的一整套執行環境,這和IDE作用類似
- 資源管理,包括資源獲取、依賴處理、實時更新、按需載入、公共模組管理等
- 打通研發鏈路的各個環節,debug、mock、proxy、test、build、deploy等
其中,資源管理是前端最需要也是最難做的一個環節。
注:個人以為,與前端工程化對應的另一個重要的領域是前端元件化,前者屬於工具,解決研發效率問題,後者屬於前端生態,解決程式碼複用的問題,本篇對於後者不做深入。
在此以開發一個多頁面型webapp為例,給出上面所提出的問題的解決方案。
前端開發環境搭建
主要目錄結構
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
- webapp/ # webapp根目錄 - src/ # 開發目錄 + css/ # css資源目錄 + img/ # webapp圖片資源目錄 - js/ # webapp js&jsx資源目錄 - components/ # 標準元件存放目錄 - foo/ # 元件foo + css/ # 元件foo的樣式 + js/ # 元件foo的邏輯 + tmpl/ # 元件foo的模板 index.js # 元件foo的入口 + bar/ # 元件bar + lib/ # 第三方純js庫 ... # 根據專案需要任意新增的程式碼目錄 + tmpl/ # webapp前端模板資源目錄 a.html # webapp入口檔案a b.html # webapp入口檔案b - assets/ # 編譯輸出目錄,即釋出目錄 + js/ # 編譯輸出的js目錄 + img/ # 編譯輸出的圖片目錄 + css/ # 編譯輸出的css目錄 a.html # 編譯輸出的入口a b.html # 編譯處理後的入口b + mock/ # 假資料目錄 app.js # 本地server入口 routes.js # 本地路由配置 webpack.config.js # webpack配置檔案 gulpfile.js # gulp任務配置 package.json # 專案配置 README.md # 專案說明 |
這是個經典的前端專案目錄結構,專案目結構在一定程度上約定了開發規範。業務開發的同學只需關注src
目錄即可,開發時儘可能最小化模組粒度,這是非同步載入的需要。assets
是整個工程的產出,無需關注裡邊的內容是什麼,至於怎麼打包和解決資源依賴的,往下看。
本地開發環境
我們使用開源web框架搭建一個webserver,便於本地開發和除錯,以及靈活地處理前端路由,以koa
為例,主要程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// app.js var http = require('http'); var koa = require('koa'); var serve = require('koa-static'); var app = koa(); var debug = process.env.NODE_ENV !== 'production'; // 開發環境和生產環境對應不同的目錄 var viewDir = debug ? 'src' : 'assets'; // 處理靜態資源和入口檔案 app.use(serve(path.resolve(__dirname, viewDir), { maxage: 0 })); app = http.createServer(app.callback()); app.listen(3005, '0.0.0.0', function() { console.log('app listen success.'); }); |
執行node app
啟動本地server,瀏覽器輸入
http://localhost:8080/a.html 即可看到頁面內容,最基本的環境就算搭建完成。
如果只是處理靜態資源請求,可以有很多的替代方案,如Fiddler替換檔案、本地起Nginx伺服器等等。搭建一個Web伺服器,個性化地定製開發環境用於提升開發效率,如處理動態請求、dnsproxy(多用於解決移動端配置host的問題)等,總之local webserver擁有無限的可能。
定製動態請求
我們的local server是localhost
域,在ajax請求時為了突破前端同源策略的限制,本地server需支援代理其他域下的api的功能,即proxy。同時還要支援對未完成的api進行mock的功能。
1 2 3 4 5 |
// app.js var router = require('koa-router')(); var routes = require('./routes'); routes(router, app); app.use(router.routes()); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// routes.js var proxy = require('koa-proxy'); var list = require('./mock/list'); module.exports = function(router, app) { // mock api // 可以根據需要任意定製介面的返回 router.get('/api/list', function*() { var query = this.query || {}; var offset = query.offset || 0; var limit = query.limit || 10; var diff = limit - list.length; if(diff <= 0) { this.body = {code: 0, data: list.slice(0, limit)}; } else { var arr = list.slice(0, list.length); var i = 0; while(diff--) arr.push(arr[i++]); this.body = {code: 0, data: arr}; } }); // proxy api router.get('/api/foo/bar', proxy({url: 'http://foo.bar.com'})); } |
webpack資源管理
資源的獲取
ECMAScript 6之前,前端的模組化一直沒有統一的標準,僅前端包管理系統就有好幾個。所以任何一個庫實現的loader都不得不去相容基於多種模組化標準開發的模組。
webpack同時提供了對CommonJS、AMD和ES6模組化標準的支援,對於非前三種標準開發的模組,webpack提供了shimming modules的功能。
受Node.js的影響,越來越多的前端開發者開始採用CommonJS作為模組開發標準,npm
已經逐漸成為前端模組的託管平臺,這大大降低了前後端模組複用的難度。
在webpack配置項裡,可以把node_modules路徑新增到resolve search root列表裡邊,這樣就可以直接load npm模組了:
1 2 3 4 5 6 |
// webpack.config.js resolve: { root: [process.cwd() + '/src', process.cwd() + '/node_modules'], alias: {}, extensions: ['', '.js', '.css', '.scss', '.ejs', '.png', '.jpg'] }, |
1 |
$ npm install jquery react --save |
1 2 3 |
// page-x.js import $ from 'jquery'; import React from 'react'; |
資源引用
根據webpack的設計理念,所有資源都是“模組”,webpack內部實現了一套資源載入機制,這與Requirejs、Sea.js、Browserify等實現有所不同,除了藉助外掛體系載入不同型別的資原始檔之外,webpack還對輸出結果提供了非常精細的控制能力,開發者只需要根據需要調整引數即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// webpack.config.js // webpack loaders的配置示例 ... loaders: [ { test: /\.(jpe?g|png|gif|svg)$/i, loaders: [ 'image?{bypassOnDebug: true, progressive:true, \ optimizationLevel: 3, pngquant:{quality: "65-80"}}', 'url?limit=10000&name=img/[hash:8].[name].[ext]', ] }, { test: /\.(woff|eot|ttf)$/i, loader: 'url?limit=10000&name=fonts/[hash:8].[name].[ext]' }, {test: /\.(tpl|ejs)$/, loader: 'ejs'}, {test: /\.js$/, loader: 'jsx'}, {test: /\.css$/, loader: 'style!css'}, {test: /\.scss$/, loader: 'style!css!scss'}, ] ... |
簡單解釋下上面的程式碼, test 項表示匹配的資源型別, loader 或 loaders 項表示用來載入這種型別的資源的loader,loader的使用可以參考using loaders,更多的loader可以參考list of loaders。
對於開發者來說,使用loader很簡單,最好先配置好特定型別的資源對應的loaders,在業務程式碼直接使用webpack提供的 require(source path) 介面即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// a.js // 載入css資源 require('../css/a.css'); // 載入其他js資源 var foo = require('./widgets/foo'); var bar = require('./widgets/bar'); // 載入圖片資源 var loadingImg = require('../img/loading.png'); var img = document.createElement('img'); img.src = loadingImg; |
注意,require()
還支援在資源path前面指定loader,即
require(![loaders list]![source path]) 形式:
1 2 3 4 |
require("!style!css!less!bootstrap/less/bootstrap.less"); // “bootstrap.less”這個資源會先被"less-loader"處理, // 其結果又會被"css-loader"處理,接著是"style-loader" // 可類比pipe操作 |
require() 時指定的loader會覆蓋配置檔案裡對應的loader配置項。
資源依賴處理
通過loader機制,可以不需要做額外的轉換即可載入瀏覽器不直接支援的資源型別,如 .scss 、 .less 、 .json 、 .ejs 等。
但是對於css、js和圖片,採用webpack載入和直接採用標籤引用載入,有何不同呢?
執行webpack的打包命令,可以得到 a.js 的輸出的結果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
webpackJsonp([0], { /***/0: /***/function(module, exports, __webpack_require__) { __webpack_require__(6); var foo = __webpack_require__(25); var bar = __webpack_require__(26); var loadingImg = __webpack_require__(24); var img = document.createElement('img'); img.src = loadingImg; }, /***/6: /***/function(module, exports, __webpack_require__) { ... }, /***/7: /***/function(module, exports, __webpack_require__) { ... }, /***/24: /***/function(module, exports) { ... }, /***/25: /***/function(module, exports) { ... }, /***/26: /***/function(module, exports) { ... } }); |
從輸出結果可以看到,webpack內部實現了一個全域性的webpackJsonp()
用於載入處理後的資源,並且webpack把資源進行重新編號,每一個資源成為一個模組,對應一個id,後邊是模組的內部實現,而這些操作都是webpack內部處理的,使用者無需關心內部細節甚至輸出結果。
上面的輸出程式碼,因篇幅限制刪除了其他模組的內部實現細節,完整的輸出請看a.out.js,來看看圖片的輸出:
1 2 3 4 5 6 7 |
/***/24: /***/function(module, exports) { module.exports = "data:image/png;base64,..."; /***/ } |
注意到圖片資源的loader配置:
1 2 3 4 5 6 7 |
{ test: /\.(jpe?g|png|gif|svg)$/i, loaders: [ 'image?...', 'url?limit=10000&name=img/[hash:8].[name].[ext]', ] } |
意思是,圖片資源在載入時先壓縮,然後當內容size小於~10KB時,會自動轉成base64的方式內嵌進去,這樣可以減少一個HTTP的請求。當圖片大於10KB時,則會在img/
下生成壓縮後的圖片,命名是
[hash:8].[name].[ext] 的形式。hash:8
的意思是取圖片內容hashsum值的前8位,這樣做能夠保證引用的是圖片資源的最新修改版本,保證瀏覽器端能夠即時更新。
對於css檔案,預設情況下webpack會把css content內嵌到js裡邊,執行時會使用 style 標籤內聯。如果希望將css使用 link 標籤引入,可以使用 ExtractTextPlugin 外掛進行提取。
資源的編譯輸出
webpack的三個概念:模組(module)、入口檔案(entry)、分塊(chunk)。
其中,module指各種資原始檔,如js、css、圖片、svg、scss、less等等,一切資源皆被當做模組。
webpack編譯輸出的檔案包括以下2種:
- entry:入口,可以是一個或者多個資源合併而成,由html通過script標籤引入
- chunk:被entry所依賴的額外的程式碼塊,同樣可以包含一個或者多個檔案
下面是一段entry和output項的配置示例:
1 2 3 4 5 6 7 8 9 |
entry: { a: './src/js/a.js' }, output: { path: path.resolve(debug ? '__build' : './assets/'), filename: debug ? '[name].js' : 'js/[chunkhash:8].[name].min.js', chunkFilename: debug ? '[chunkhash:8].chunk.js' : 'js/[chunkhash:8].chunk.min.js', publicPath: debug ? '/__build/' : '' } |
其中entry
項是入口檔案路徑對映表,output
項是對輸出檔案路徑和名稱的配置,佔位符如[id]
、[chunkhash]
、[name]
等分別代表編譯後的模組id、chunk的hashnum值、chunk名等,可以任意組合決定最終輸出的資源格式。hashnum的做法,基本上弱化了版本號的概念,版本迭代的時候chunk是否更新只取決於chnuk的內容是否發生變化。
細心的同學可能會有疑問,entry表示入口檔案,需要手動指定,那麼chunk到底是什麼,chunk是怎麼生成的?
在開發webapp時,總會有一些功能是使用過程中才會用到的,出於效能優化的需要,對於這部分資源我們希望做成非同步載入,所以這部分的程式碼一般不用打包到入口檔案裡邊。
對於這一點,webpack提供了非常好的支援,即code splitting,即使用 require.ensure() 作為程式碼分割的標識。
例如某個需求場景,根據url引數,載入不同的兩個UI元件,示例程式碼如下:.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
var component = getUrlQuery('component'); if('dialog' === component) { require.ensure([], function(require) { var dialog = require('./components/dialog'); // todo ... }); } if('toast' === component) { require.ensure([], function(require) { var toast = require('./components/toast'); // todo ... }); } |
url分別輸入不同的引數後得到瀑布圖:
webpack將 require.ensure() 包裹的部分單獨打包了,即圖中看到的 [hash].chunk.js ,既解決了非同步載入的問題,又保證了載入到的是最新的chunk的內容。
假設app還有一個入口頁面b.html
,那麼就需要相應的再增加一個入口檔案b.js
,直接在entry
項配置即可。多個入口檔案之間可能公用一個模組,可以使用
CommonsChunkPlugin 外掛對指定的chunks進行公共模組的提取,下面程式碼示例演示提取所有入口檔案公用的模組,將其獨立打包:
1 2 3 4 5 6 7 8 9 |
var chunks = Object.keys(entries); plugins: [ new CommonsChunkPlugin({ name: 'vendors', // 將公共模組提取,生成名為`vendors`的chunk chunks: chunks, minChunks: chunks.length // 提取所有entry共同依賴的模組 }) ], |
資源的實時更新
引用模組,webpack提供了 require() API(也可以通過新增bable外掛來支援ES6的 import 語法)。但是在開發階段不可能改一次編譯一次,webpack提供了強大的熱更新支援,即HMR(hot module replace)。
HMR簡單說就是webpack啟動一個本地webserver(webpack-dev-server),負責處理由webpack生成的靜態資源請求。注意webpack-dev-server是把所有資源儲存在記憶體的,所以你會發現在本地沒有生成對應的chunk訪問卻正常。
下面這張來自webpack官網的圖片,可以很清晰地說明module
、entry
、chunk
三者的關係以及webpack如何實現熱更新的:
enter0表示入口檔案,chunk1~4分別是提取公共模組所生成的資源塊,當模組4和9發生改變時,因為模組4被打包在chunk1中,模組9打包在chunk3中,所以HMR runtime會將變更部分同步到chunk1和chunk3中對應的模組,從而達到hot replace。
webpack-dev-server的啟動很簡單,配置完成之後可以通過cli啟動,然後在頁面引入入口檔案時新增webpack-dev-server的host即可將HMR整合到已有伺服器:
1 2 3 4 5 6 7 |
... <body> ... <script src="http://localhost:8080/__build/vendors.js"></script> <script src="http://localhost:8080/__build/a.js"></script> </body> ... |
因為我們的local server就是基於Node.js的webserver,這裡可以更進一步,將webpack開發伺服器以中介軟體的形式整合到local webserver,不需要cli方式啟動(少開一個cmd tab):
1 2 3 4 5 6 7 8 9 10 11 |
// app.js var webpackDevMiddleware = require('koa-webpack-dev-middleware'); var webpack = require('webpack'); var webpackConf = require('./webpack.config'); app.use(webpackDevMiddleware(webpack(webpackConf), { contentBase: webpackConf.output.path, publicPath: webpackConf.output.publicPath, hot: true, stats: webpackConf.devServer.stats })); |
啟動HMR之後,每次儲存都會重新編譯生成新的chnuk,通過控制檯的log,可以很直觀地看到這一過程:
公用程式碼的處理:封裝元件
webpack解決了資源依賴的問題,這使得封裝元件變得很容易,例如:
1 2 3 4 5 6 7 8 9 10 |
// js/components/component-x.js require('./component-x.css'); // <a href="http://www.jobbole.com/members/heydee@qq.com">@see</a> https://github.com/okonet/ejs-loader var template = require('./component-x.ejs'); var str = template({foo: 'bar'}); function someMethod() {} exports.someMethod = someMethod; |
使用:
1 2 3 |
// js/a.js import {someMethod} from "./components/component-x"; someMethod(); |
正如開頭所說,將三種語言、多種資源合併成js來管理,大大降低了維護成本。
對於新開發的元件或library,建議推送到 npm 倉庫進行共享。如果需要支援其他載入方式(如RequireJS或標籤直接引入),可以參考webpack提供的externals項。
資源路徑切換
由於入口檔案是手動使用script引入的,在webpack編譯之後入口檔案的名稱和路徑一般會改變,即開發環境和生產環境引用的路徑不同:
1 2 3 4 |
// 開發環境 // a.html <script src="/__build/vendors.js"></script> <script src="/__build/a.js"></script> |
1 2 3 4 |
// 生產環境 // a.html <script src="http://cdn.site.com/js/460de4b8.vendors.min.js"></script> <script src="http://cdn.site.com/js/e7d20340.a.min.js"></script> |
webpack提供了 HtmlWebpackPlugin 外掛來解決這個問題,HtmlWebpackPlugin支援從模板生成html檔案,生成的html裡邊可以正確解決js打包之後的路徑、檔名問題,配置示例:
1 2 3 4 5 6 7 8 9 |
// webpack.config.js plugins: [ new HtmlWebpackPlugin({ template: './src/a.html', filename: 'a', inject: 'body', chunks: ['vendors', 'a'] }) ] |
這裡資源根路徑的配置在 output 項:
1 2 3 4 5 |
// webpack.config.js output: { ... publicPath: debug ? '/__build/' : 'http://cdn.site.com/' } |
其他入口html檔案採用類似處理方式。
輔助工具整合
local server解決本地開發環境的問題,webpack解決開發和生產環境資源依賴管理的問題。在專案開發中,可能會有許多額外的任務需要完成,比如對於使用compass生成sprites的專案,因目前webpack還不直接支援sprites,所以還需要compass watch,再比如工程的遠端部署等,所以需要使用一些構建工具或者指令碼的配合,打通研發的鏈路。
因為每個團隊在部署程式碼、單元測試、自動化測試、釋出等方面做法都不同,前端需要遵循公司的標準進行自動化的整合,這部分不深入了。
對比&綜述
前端工程化的建設,早期的做法是使用Grunt、Gulp等構建工具。但本質上它們只是一個任務排程器,將功能獨立的任務拆解出來,按需組合執行任務。如果要完成前端工程化,這兩者配置門檻很高,每一個任務都需要開發者自行使用外掛解決,而且對於資源的依賴管理能力太弱。
在國內,百度出品的fis也是一種不錯的工程化工具的選擇,fis內部也解決了資源依賴管理的問題。因筆者沒有在專案中實踐過fis,所以不進行更多的評價。
webpack以一種非常優雅的方式解決了前端資源依賴管理的問題,它在內部已經整合了許多資源依賴處理的細節,但是對於使用者而言只需要做少量的配置,再結合構建工具,很容易搭建一套前端工程解決方案。
基於webpack的前端自動化工具,可以自由組合各種開源技術棧(Koa/Express/其他web框架、webpack、Sass/Less/Stylus、Gulp/Grunt等),沒有複雜的資源依賴配置,工程結構也相對簡單和靈活。
附上筆者根據本篇的理論所完成的一個前端自動化解決方案專案模板:
webpack-bootstrap
(完)。