基于webpack搭建前端工程解決方案探索
作者:dmyang
來源:SegmentFault 思否社區(qū)
本篇主要介紹webpack的基本原理以及基于webpack搭建純靜態(tài)頁面型前端項目工程化解決方案的思路。
關于前端工程
下面是百科關于“軟件工程”的名詞解釋:
軟件工程是一門研究用工程化方法構建和維護有效的、實用的和高質(zhì)量的軟件的學科。
其中,工程化是方法,是將軟件研發(fā)的各個鏈路串接起來的工具。
對于軟件“工程化”,個人以為至少應當有如下特點:
有IDE的支持,負責初始化工程、工程結構組織、debug、編譯、打包等工作 有固定或者約定的工程結構,規(guī)定軟件所依賴的不同類別的資源的存放路徑甚至代碼的寫法等 軟件依賴的資源可能來自軟件開發(fā)者,也有可能是第三方,工程化需要集成對資源的獲取、打包、發(fā)布、版本管理等能力 和其他系統(tǒng)的集成,如CI系統(tǒng)、運維系統(tǒng)、監(jiān)控系統(tǒng)等
廣泛意義上講,前端也屬于軟件工程的范疇。
但前端沒有Eclipse、Visual Studio等為特定語言量身打造的IDE。因為前端不需要編譯,即改即生效,在開發(fā)和調(diào)試時足夠方便,只需要打開個瀏覽器即可完成,所以前端一般不會扯到“工程”這個概念。
在很長一段時間里,前端很簡單,比如下面簡單的幾行代碼就能夠成一個可運行前端應用:
????webapp
????"stylesheet"?href="app.css">
????app?title
????
但隨著webapp的復雜程度不斷在增加,前端也在變得很龐大和復雜,按照傳統(tǒng)的開發(fā)方式會讓前端失控:代碼龐大難以維護、性能優(yōu)化難做、開發(fā)成本變高。
感謝Node.js,使得JavaScript這門前端的主力語言突破了瀏覽器環(huán)境的限制可以獨立運行在OS之上,這讓JavaScript擁有了文件IO、網(wǎng)絡IO的能力,前端可以根據(jù)需要任意定制研發(fā)輔助工具。
一時間出現(xiàn)了以Grunt、Gulp為代表的一批前端構建工具,“前端工程”這個概念逐漸被強調(diào)和重視。但是由于前端的復雜性和特殊性,前端工程化一直很難做,構建工具有太多局限性。
誠如 張云龍@fouber?所言:
前端是一種特殊的GUI軟件,它有兩個特殊性:一是前端由三種編程語言組成,二是前端代碼在用戶端運行時增量安裝。
html、css和js的配合才能保證webapp的運行,增量安裝是按需加載的需要。開發(fā)完成后輸出三種以上不同格式的靜態(tài)資源,靜態(tài)資源之間有可能存在互相依賴關系,最終構成一個復雜的資源依賴樹(甚至網(wǎng))。
所以,前端工程,最起碼需要解決以下問題:
提供開發(fā)所需的一整套運行環(huán)境,這和IDE作用類似 資源管理,包括資源獲取、依賴處理、實時更新、按需加載、公共模塊管理等 打通研發(fā)鏈路的各個環(huán)節(jié),debug、mock、proxy、test、build、deploy等
其中,資源管理是前端最需要也是最難做的一個環(huán)節(jié)。
注:個人以為,與前端工程化對應的另一個重要的領域是前端組件化,前者屬于工具,解決研發(fā)效率問題,后者屬于前端生態(tài),解決代碼復用的問題,本篇對于后者不做深入。
在此以開發(fā)一個多頁面型webapp為例,給出上面所提出的問題的解決方案。
前端開發(fā)環(huán)境搭建
主要目錄結構
-?webapp/???????????????#?webapp根目錄
??-?src/????????????????#?開發(fā)目錄
????+?css/??????????????#?css資源目錄
????+?img/??????????????#?webapp圖片資源目錄
????-?js/???????????????#?webapp?js&jsx資源目錄
??????-?components/?????#?標準組件存放目錄
??????????-?foo/????????#?組件foo
????????????+?css/??????#?組件foo的樣式
????????????+?js/???????#?組件foo的邏輯
????????????+?tmpl/?????#?組件foo的模板
????????????index.js????#?組件foo的入口
??????????+?bar/????????#?組件bar
??????+?lib/????????????#?第三方純js庫
??????...???????????????#?根據(jù)項目需要任意添加的代碼目錄
????+?tmpl/?????????????#?webapp前端模板資源目錄
????a.html??????????????#?webapp入口文件a
????b.html??????????????#?webapp入口文件b
??-?assets/?????????????#?編譯輸出目錄,即發(fā)布目錄
????+?js/???????????????#?編譯輸出的js目錄
????+?img/??????????????#?編譯輸出的圖片目錄
????+?css/??????????????#?編譯輸出的css目錄
????a.html??????????????#?編譯輸出的入口a
????b.html??????????????#?編譯處理后的入口b
??+?mock/???????????????#?假數(shù)據(jù)目錄
??app.js????????????????#?本地server入口
??routes.js?????????????#?本地路由配置
??webpack.config.js?????#?webpack配置文件
??gulpfile.js???????????#?gulp任務配置
??package.json??????????#?項目配置
??README.md?????????????#?項目說明
這是個經(jīng)典的前端項目目錄結構,項目目結構在一定程度上約定了開發(fā)規(guī)范。業(yè)務開發(fā)的同學只需關注src目錄即可,開發(fā)時盡可能最小化模塊粒度,這是異步加載的需要。assets是整個工程的產(chǎn)出,無需關注里邊的內(nèi)容是什么,至于怎么打包和解決資源依賴的,往下看。
本地開發(fā)環(huán)境
我們使用開源web框架搭建一個webserver,便于本地開發(fā)和調(diào)試,以及靈活地處理前端路由,以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';
//?開發(fā)環(huán)境和生產(chǎn)環(huán)境對應不同的目錄
var?viewDir?=?debug???'src'?:?'assets';
//?處理靜態(tài)資源和入口文件
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:3005/a.html即可看到頁面內(nèi)容,最基本的環(huán)境就算搭建完成。
如果只是處理靜態(tài)資源請求,可以有很多的替代方案,如Fiddler替換文件、本地起Nginx服務器等等。搭建一個Web服務器,個性化地定制開發(fā)環(huán)境用于提升開發(fā)效率,如處理動態(tài)請求、dnsproxy(多用于解決移動端配置host的問題)等,總之local webserver擁有無限的可能。
定制動態(tài)請求
我們的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
????//?可以根據(jù)需要任意定制接口的返回
????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之前,前端的模塊化一直沒有統(tǒng)一的標準,僅前端包管理系統(tǒng)就有好幾個。所以任何一個庫實現(xiàn)的loader都不得不去兼容基于多種模塊化標準開發(fā)的模塊。
webpack同時提供了對CommonJS、AMD和ES6模塊化標準的支持,對于非前三種標準開發(fā)的模塊,webpack提供了shimming modules的功能。
受Node.js的影響,越來越多的前端開發(fā)者開始采用CommonJS作為模塊開發(fā)標準,npm已經(jīng)逐漸成為前端模塊的托管平臺,這大大降低了前后端模塊復用的難度。
在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';
資源引用
根據(jù)webpack的設計理念,所有資源都是“模塊”,webpack內(nèi)部實現(xiàn)了一套資源加載機制,這與Requirejs、Sea.js、Browserify等實現(xiàn)有所不同,除了借助插件體系加載不同類型的資源文件之外,webpack還對輸出結果提供了非常精細的控制能力,開發(fā)者只需要根據(jù)需要調(diào)整參數(shù)即可:
//?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。
對于開發(fā)者來說,使用loader很簡單,最好先配置好特定類型的資源對應的loaders,在業(yè)務代碼直接使用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機制,可以不需要做額外的轉(zhuǎn)換即可加載瀏覽器不直接支持的資源類型,如.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內(nèi)部實現(xiàn)了一個全局的webpackJsonp()用于加載處理后的資源,并且webpack把資源進行重新編號,每一個資源成為一個模塊,對應一個id,后邊是模塊的內(nèi)部實現(xiàn),而這些操作都是webpack內(nèi)部處理的,使用者無需關心內(nèi)部細節(jié)甚至輸出結果。
上面的輸出代碼,因篇幅限制刪除了其他模塊的內(nèi)部實現(xiàn)細節(jié),完整的輸出請看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]',
????]
}
意思是,圖片資源在加載時先壓縮,然后當內(nèi)容size小于~10KB時,會自動轉(zhuǎn)成base64的方式內(nèi)嵌進去,這樣可以減少一個HTTP的請求。當圖片大于10KB時,則會在img/下生成壓縮后的圖片,命名是[hash:8].[name].[ext]的形式。hash:8的意思是取圖片內(nèi)容hashsum值的前8位,這樣做能夠保證引用的是圖片資源的最新修改版本,保證瀏覽器端能夠即時更新。
對于css文件,默認情況下webpack會把css content內(nèi)嵌到js里邊,運行時會使用style標簽內(nèi)聯(lián)。如果希望將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的內(nèi)容是否發(fā)生變化。
細心的同學可能會有疑問,entry表示入口文件,需要手動指定,那么chunk到底是什么,chunk是怎么生成的?
在開發(fā)webapp時,總會有一些功能是使用過程中才會用到的,出于性能優(yōu)化的需要,對于這部分資源我們希望做成異步加載,所以這部分的代碼一般不用打包到入口文件里邊。
對于這一點,webpack提供了非常好的支持,即code splitting,即使用require.ensure()作為代碼分割的標識。
例如某個需求場景,根據(jù)url參數(shù),加載不同的兩個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分別輸入不同的參數(shù)后得到瀑布圖:


webpack將require.ensure()包裹的部分單獨打包了,即圖中看到的[hash].chunk.js,既解決了異步加載的問題,又保證了加載到的是最新的chunk的內(nèi)容。
假設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語法)。但是在開發(fā)階段不可能改一次編譯一次,webpack提供了強大的熱更新支持,即HMR(hot module replace)。
HMR簡單說就是webpack啟動一個本地webserver(webpack-dev-server),負責處理由webpack生成的靜態(tài)資源請求。注意webpack-dev-server是把所有資源存儲在內(nèi)存的,所以你會發(fā)現(xiàn)在本地沒有生成對應的chunk訪問卻正常。
下面這張來自webpack官網(wǎng)的圖片,可以很清晰地說明module、entry、chunk三者的關系以及webpack如何實現(xiàn)熱更新的:

enter0表示入口文件,chunk1~4分別是提取公共模塊所生成的資源塊,當模塊4和9發(fā)生改變時,因為模塊4被打包在chunk1中,模塊9打包在chunk3中,所以HMR runtime會將變更部分同步到chunk1和chunk3中對應的模塊,從而達到hot replace。
webpack-dev-server的啟動很簡單,配置完成之后可以通過cli啟動,然后在頁面引入入口文件時添加webpack-dev-server的host即可將HMR集成到已有服務器:
...
????...
????
????
...
因為我們的local server就是基于Node.js的webserver,這里可以更進一步,將webpack開發(fā)服務器以中間件的形式集成到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來管理,大大降低了維護成本。
對于新開發(fā)的組件或library,建議推送到npm倉庫進行共享。如果需要支持其他加載方式(如RequireJS或標簽直接引入),可以參考webpack提供的externals項。
資源路徑切換
由于入口文件是手動使用script引入的,在webpack編譯之后入口文件的名稱和路徑一般會改變,即開發(fā)環(huán)境和生產(chǎn)環(huán)境引用的路徑不同:
//?開發(fā)環(huán)境
//?a.html//?生產(chǎn)環(huán)境
//?a.html
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解決本地開發(fā)環(huán)境的問題,webpack解決開發(fā)和生產(chǎn)環(huán)境資源依賴管理的問題。在項目開發(fā)中,可能會有許多額外的任務需要完成,比如對于使用compass生成sprites的項目,因目前webpack還不直接支持sprites,所以還需要compass watch,再比如工程的遠程部署等,所以需要使用一些構建工具或者腳本的配合,打通研發(fā)的鏈路。
因為每個團隊在部署代碼、單元測試、自動化測試、發(fā)布等方面做法都不同,前端需要遵循公司的標準進行自動化的整合,這部分不深入了。
對比&綜述
前端工程化的建設,早期的做法是使用Grunt、Gulp等構建工具。但本質(zhì)上它們只是一個任務調(diào)度器,將功能獨立的任務拆解出來,按需組合運行任務。如果要完成前端工程化,這兩者配置門檻很高,每一個任務都需要開發(fā)者自行使用插件解決,而且對于資源的依賴管理能力太弱。
在國內(nèi),百度出品的fis也是一種不錯的工程化工具的選擇,fis內(nèi)部也解決了資源依賴管理的問題。因筆者沒有在項目中實踐過fis,所以不進行更多的評價。
webpack以一種非常優(yōu)雅的方式解決了前端資源依賴管理的問題,它在內(nèi)部已經(jīng)集成了許多資源依賴處理的細節(jié),但是對于使用者而言只需要做少量的配置,再結合構建工具,很容易搭建一套前端工程解決方案。
基于webpack的前端自動化工具,可以自由組合各種開源技術棧(Koa/Express/其他web框架、webpack、Sass/Less/Stylus、Gulp/Grunt等),沒有復雜的資源依賴配置,工程結構也相對簡單和靈活。
附上筆者根據(jù)本篇的理論所完成的一個前端自動化解決方案項目模板:
webpack-seed :?https://github.com/chemdemo/webpack-seed

