作者:dmyang
來源:SegmentFault 思否社區
本篇主要介紹webpack的基本原理以及基於webpack搭建純靜態頁面型前端項目工程化解決方案的思路。
關於前端工程下面是百科關於「軟體工程」的名詞解釋:
軟體工程是一門研究用工程化方法構建和維護有效的、實用的和高質量的軟體的學科。
其中,工程化是方法,是將軟體研發的各個鏈路串接起來的工具。
對於軟體「工程化」,個人以為至少應當有如下特點:
有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的運行,增量安裝是按需加載的需要。開發完成後輸出三種以上不同格式的靜態資源,靜態資源之間有可能存在互相依賴關係,最終構成一個複雜的資源依賴樹(甚至網)。
所以,前端工程,最起碼需要解決以下問題:
資源管理,包括資源獲取、依賴處理、實時更新、按需加載、公共模塊管理等打通研發鏈路的各個環節,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:3005/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:3005/__build/vendors.js"></script>
<script src="http://localhost:3005/__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等),沒有複雜的資源依賴配置,工程結構也相對簡單和靈活。
附上筆者根據本篇的理論所完成的一個前端自動化解決方案項目模板:
點擊左下角閱讀原文,到 SegmentFault 思否社區 和文章作者展開更多互動和交流。
webpack-seed : https://github.com/chemdemo/webpack-seed