基于webpack搭建前端工程解決方案探索
本篇主要介紹webpack的基本原理以及基于webpack搭建 純靜態頁面型 前端項目工程化解決方案的思路。
下篇(還沒寫完)探討下對于Node.js作為后端的項目工程化、模塊化、前后端共享代碼、自動化部署的做法。
關于前端工程
下面是百科關于“ 軟件工程 ”的名詞解釋:
軟件工程是一門研究用工程化方法構建和維護有效的、實用的和高質量的軟件的學科。
其中,工程化是方法,是將軟件研發的各個鏈路串接起來的工具。
對于軟件“工程化”,個人以為至少應當有如下特點:
-
有IDE的支持,負責初始化工程、工程結構組織、debug、編譯、打包等工作
-
有固定或者約定的工程結構,規定軟件所依賴的不同類別的資源的存放路徑甚至代碼的寫法等
-
軟件依賴的資源可能來自軟件開發者,也有可能是第三方,工程化需要集成對資源的獲取、打包、發布、版本管理等能力
-
和其他系統的集成,如CI系統、運維系統、監控系統等
廣泛意義上講,前端也屬于軟件工程的范疇。
但前端沒有Eclipse、Visual Studio等為特定語言量身打造的IDE。因為前端不需要編譯,即改即生效,在開發和調試時足夠方便,只需要打開個瀏覽器即可完成,所以前端一般不會扯到“工程”這個概念。
在很長一段時間里,前端很簡單,比如下面簡單的幾行代碼就能夠成一個可運行前端應用:
<!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為例,給出上面所提出的問題的解決方案。
前端開發環境搭建
主要目錄結構
- 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為例,主要代碼如下:
// 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的功能。
// app.js var router = require('koa-router')(); var routes = require('./routes'); routes(router, app); app.use(router.routes());
// 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模塊了:
// webpack.config.js resolve: { root: [process.cwd() + '/src', process.cwd() + '/node_modules'], alias: {}, extensions: ['', '.js', '.css', '.scss', '.ejs', '.png', '.jpg'] },
$ npm install jquery react --save
// page-x.js import $ from 'jquery'; import React from 'react';
資源引用
根據webpack的設計理念,所有資源都是“模塊”,webpack內部實現了一套資源加載機制,這與Requirejs、Sea.js、Browserify等實現有所不同,除了借助插件體系加載不同類型的資源文件之外,webpack還對輸出結果提供了非常精細的控制能力,開發者只需要根據需要調整參數即可:
// 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)接口即可:
// 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])形式:
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的輸出的結果:
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 ,來看看圖片的輸出:
/***/24: /***/function(module, exports) { module.exports = "data:image/png;base64,..."; /***/ }
注意到圖片資源的loader配置:
{ 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項的配置示例:
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組件,示例代碼如下:
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進行公共模塊的提取,下面代碼示例演示提取所有入口文件公用的模塊,將其獨立打包:
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集成到已有服務器:
... <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):
// 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解決了資源依賴的問題,這使得封裝組件變得很容易,例如:
// js/components/component-x.js require('./component-x.css'); // @see https://github.com/okonet/ejs-loader var template = require('./component-x.ejs'); var str = template({foo: 'bar'}); function someMethod() {} exports.someMethod = someMethod;
使用:
// js/a.js import {someMethod} from "./components/component-x"; someMethod();
正如開頭所說,將三種語言、多種資源合并成js來管理,大大降低了維護成本。
對于新開發的組件或library,建議推送到npm倉庫進行共享。如果需要支持其他加載方式(如RequireJS或標簽直接引入),可以參考webpack提供的 externals 項。
資源路徑切換
由于入口文件是手動使用script引入的,在webpack編譯之后入口文件的名稱和路徑一般會改變,即開發環境和生產環境引用的路徑不同:
// 開發環境 // a.html <script src="/__build/vendors.js"></script> <script src="/__build/a.js"></script>
// 生產環境 // 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打包之后的路徑、文件名問題,配置示例:
// webpack.config.js plugins: [ new HtmlWebpackPlugin({ template: './src/a.html', filename: 'a', inject: 'body', chunks: ['vendors', 'a'] }) ]
這里資源根路徑的配置在output項:
// 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(完)。