基於webpack搭建前端工程解決方案探索

本篇主要介紹webpack的基本原理以及基於webpack搭建純靜態頁面型前端項目工程化解決方案的思路。css

下篇(還沒寫完)探討下對於Node.js做爲後端的項目工程化、模塊化、先後端共享代碼、自動化部署的作法。html

關於webpack的更多用法和前端工程的討論,能夠到github https://github.com/chemdemo/chemdemo.github.io/issues/10前端

關於前端工程

下面是百科關於「軟件工程」的名詞解釋:node

軟件工程是一門研究用工程化方法構建和維護有效的、實用的和高質量的軟件的學科。react

其中,工程化是方法,是將軟件研發的各個鏈路串接起來的工具。jquery

對於軟件「工程化」,我的覺得至少應當有以下特色:webpack

  • 有IDE的支持,負責初始化工程、工程結構組織、debug、編譯、打包等工做git

  • 有固定或者約定的工程結構,規定軟件所依賴的不一樣類別的資源的存放路徑甚至代碼的寫法等github

  • 軟件依賴的資源可能來自軟件開發者,也有多是第三方,工程化須要集成對資源的獲取、打包、發佈、版本管理等能力web

  • 和其餘系統的集成,如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: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項表示匹配的資源類型,loaderloaders項表示用來加載這種類型的資源的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分別輸入不一樣的參數後獲得瀑布圖:

code_splitting1

code_splitting2

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官網的圖片,能夠很清晰地說明moduleentrychunk三者的關係以及webpack如何實現熱更新的:

HMR

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,能夠很直觀地看到這一過程:

HMR build

公用代碼的處理:封裝組件

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-seed

(完)。

相關文章
相關標籤/搜索