如何開始一個模組化可擴充套件的Web App

Allo發表於2013-04-06

雖然從沒有認為自己是一個前端開發者,但不知不覺中也積累下了一些前端開發的經驗。正巧之前碰到一道面試題,於是就順便梳理了一下自己關於Web App的一些思路並整理為本文。

對於很多簡單的網站或Web應用來說,引入jQuery以及一些外掛,在當前頁面內寫入簡單邏輯已經可以滿足大部分需要。但是如果一旦多人開發,應用的複雜程度上升,就會有很多問題開始暴露出來:

  1. 資料來源一般都與頁面分離,那麼App啟動一般都需要等待資料來源讀入。
  2. UI互動複雜時,需要將邏輯通過物件導向抽象後才能更好的複用。
  3. 功能間一般都存在依賴關係,需要引入支援依賴關係的模組載入器。

那麼如何解決這些問題,就以一個簡單的訂餐App為例,從零開始一個模組化可擴充套件Web App

這個簡單的App基於HTML5 Boilerplate、requireJS、jQuery Mobile、Underscore.js,後端邏輯用jStorage模擬實現。完成後的成品在此。所有程式碼可以在github檢視。下文將逐一介紹實現的思路與方法。

從選擇一個好模板開始

開始一個Web專案,HTML的書寫總是重中之重,一個好的HTML能從根源上規避大量潛在問題,所以Web App應該全部應用一個標準化的高質量HTML模板,而不是將所有頁面交由開發人員自由發揮。

這裡推薦使用HTML5 Boilerplate專案作為App的預設模板以及檔案路徑規範,無論是網站或者富UI的App,都可以採用這個模板作為起步。

可以使用

git clone git://github.com/h5bp/html5-boilerplate.git

或者直接下載HTML5 Boilerplate專案程式碼。HTML5 Boilerplate的檔案結構如下,

.
├── css
│   ├── main.css
│   └── normalize.css
├── doc
├── img
├── js
│   ├── main.js
│   ├── plugins.js
│   └── vendor
│       ├── jquery.min.js
│       └── modernizr.min.js
├── .htaccess
├── 404.html
├── index.html
├── humans.txt
├── robots.txt
├── crossdomain.xml
├── favicon.ico
└── [apple-touch-icons]

從上向下看

  • css 用於存放css檔案,並內建了Normalize.css作為預設CSS重置手段(其實Normalize.css不能算是CSS reset)。
  • doc 存放專案文件
  • img 存放專案圖片
  • js 存放javascript檔案,其中第三方類庫推薦放在vendor
  • .htaccess 內建了很多對於靜態檔案在Apache下的優化策略,如果Web伺服器不是Apache則可以參考其他Web伺服器配置優化
  • 404.html 預設的404頁面,
  • index.html 專案模板
  • humans.txt 相對於面向機器人的robots.txt,humans.txt更像是小幽默,這在裡可以寫關於專案/團隊的介紹,或者放置一些彩蛋給那些喜歡對你的應用刨根問底的使用者們。
  • robots.txt 用於告訴搜尋引擎蜘蛛爬行規則
  • crossdomain.xml 用於配置Flash的跨域策略
  • favicon.ico apple-touch-icon.png等小圖示。

如果是一個主要面向移動裝置,還有更具針對性的Mobile Boilerplate可供參考。

制定統一的編碼規範

在正式開始編碼之前,無論是多大規模的應用,多少人的團隊,一定要有一個統一的規範,才能保證後續的開發不會亂套。

前端規範其實又要分為三部分:HTML、CSS、Javascript應該分別由自己的規範。HTML/CSS主要約定id/class的命名規則、屬性的書寫順序。JavaScript可能需要細化到縮排、編碼風格、物件導向寫法等等。

最省事的方法當然還是參考已有的規範,比如Google的HTML/CSS風格指南Google的Javascript編碼指南等等。

HTML篇

HTML5 Boilerplate的模板核心部分不過30行,但是每一行都可謂千錘百煉,可以用最小的消耗解決一些前端的頑固問題:

使用條件註釋區分IE瀏覽器

<!DOCTYPE html>
<!--[if lt IE 7]>      <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
<!--[if IE 7]>         <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]>         <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->

之所以要這樣寫

  1. 可以使用class作為全域性條件區分低版本的IE瀏覽器並進行調整,這顯然要優於使用CSS Hack。
  2. 可以避免IE6條件註釋引起的高版本IE檔案阻塞問題,原文的解決方法是在前面加一個空白的條件註釋,但是這裡顯然將原本無用空白的條件註釋變得有意義了。
  3. 仍然可以通過HTML驗證。
  4. 與Modernizr等特徵檢測類庫使用相同的class,更具備通用性。

no-js標籤是需要與Modernizr等類庫配合使用的,如果你不想在專案中引入Modernizr,需要在Head部分加入一行使no-js標籤變為js,程式碼來自Avoiding the FOUC

 <script>(function(H){H.className=H.className.replace(/\bno-js\b/,'js')})(document.documentElement)</script>

通過上面的條件註釋,就可以在CSS中針對不同情況分別處理

.lt-ie7 {} /* IE6等版本時 */
.no-js {} /* JavaScript沒有啟用時 */

meta標籤的書寫順序

為了讓瀏覽器識別正確的編碼,meta charset標籤應該先於title標籤出現。

meta X-UA-Compatible標籤可以指定IE8以上版本瀏覽器以最高階模式渲染文件,同時如果已經安裝Google Chrome Frame則直接使用Chrome Frame渲染。而指定渲染模式的meta X-UA-Compatible標籤同樣需要優先出現

<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title></title>

設定移動裝置顯示視窗寬度

<meta name="viewport" content="width=device-width">

這是移動裝置專屬的標籤,具體設定需要根據專案實際情況調整。

使用Modernizr做瀏覽器差異檢測

Modernizr常做前端的應該都不陌生。引入Modernizr後,html標籤的no-js將會被自動替換為js,同時Modernizr會向html標籤新增代表版本檢測結果的class。

對於低版本瀏覽器的向上相容需要根據專案實際需求處理,Modernizr也非常周到的給出的絕大多數HTML5功能的相容方法

CSS篇

CSS重置及增強功能

HTML5 Boilerplate選擇Normalize.css重置CSS。如果專案計劃引入Twitter Bootstrap、YUI 3這些前端框架的話則可以移除,因為這些框架已經內建了Normalize.css。

同時HTML5 Boilerplate又引入了一個main.css,內建了一些基本的排版樣式以及列印樣式。

使用LESS或Sass生成CSS

在複雜應用中,如果還手寫CSS的話將是一件痛苦的事情,大量的class字首,複用樣式需要來回copy等等。為了更好的擴充套件性,這裡建議在專案中引入LESSSass。這代表著:

  • 支援變數與簡單運算
  • 支援CSS片段複用
  • class/id樣式巢狀

等一些更像是程式語言的特性。這對於提高開發效率是效果非常明顯的。

以LESS為例,簡單介紹一下LESS在Windows下如何應用到這個專案中:

  1. 下載Nodejs並安裝,nodejs會自動將自己加入系統路徑。
  2. 在cmd執行 npm install -g less
  3. 然後就可以通過lessc指令將less原始檔編譯為css lessc avnpc.less avnpc.css
  4. 如果不使用nodeJs作為後端,最好在寫LESS時採用watch模式,每次儲存自動編譯為css。這裡需要安裝一個輔助模組recess: npm install -g recess 然後執行watch recess avnpc.less:avnpc.css --watch

Javascript篇

使用requireJS按需載入

模組載入器的概念可能稍微接觸過前端開發的童鞋都不會陌生,通過模組載入器可以有效的解決這些問題:

  1. JS檔案的依賴關係。
  2. 通過非同步載入優化script標籤引起的阻塞問題
  3. 可以簡單的以檔案為單位將功能模組化並實現複用

主流的JS模組載入器有requireJSSeaJS等,載入器之間可能會因為遵循的規範不同有微妙的差別,從純使用者的角度出發,之所以選requireJS而不是SeaJS主要是因為:

  • 功能實現上兩者相差無幾,沒有明顯的效能差異或重大問題。
  • 文件豐富程度上,requireJS遠遠好於SeaJS,就拿最簡單的載入jQuery和jQuery外掛這回事,雖然兩者的實現方法相差無幾,但requireJS就有可以直接拿來用的Demo,SeaJS還要讀文件自己慢慢折騰。一些問題的解決上,requireJS為關鍵詞也更容易找到答案。

requireJS 載入jQuery + jQuery外掛

可能對於一般Web App來說,引入jQuery及相關外掛的概率是最大的,requireJS也親切的給出了相應的解決方案及動態載入jQuery及外掛的文件及例項程式碼

在最新的jQuery1.9.X中,jQuery已經在最後直接將自己註冊為一個AMD模組,即是說可以直接被requireJS作為模組載入。如果是載入舊版的jQuery有兩種方法:

1. 讓jQuery先於requireJS載入 2. 對jQuery程式碼稍做一點處理,在jQuery程式碼包裹一句:

define(["jquery"], function($) {
    // $ is guaranteed to be jQuery now */
});

requireJS的示例中,直接將requireJS與jQuery合併為一個檔案,如果是採用jQuery作為核心庫的話推薦這種做法。

同樣對於jQuery外掛來說也有兩種方法

1. 在外掛外包裹程式碼

define(["jquery"], function($){
     // Put here the plugin code. 
});

2. 在使用reuqireJS程式碼載入前註冊外掛(比如在main.js)中

requirejs.config({
    "shim": {
        "jquery-cookie"  : ["jquery"]
    }
});

requireJS載入第三方類庫

在例項的App中還用到了jQuery以外的第三方類庫,如果類庫不是一個標準的AMD模組而又不想更改這些類庫的程式碼,同樣需要提前進行定義:

require.config({
      paths: {
            'underscore': 'vendor/underscore'
      },
      shim: {
          underscore: {
              exports: '_'
          }
      }
});

CSS檔案的模組化處理

在requireJS中,模組的概念僅限於JS檔案,如果需要載入圖片、JSON等非JS檔案,requireJS實現了一系列載入外掛

但是遺憾的是requireJS官方沒有對CSS進行模組化處理,而我們在實際專案中卻往往能遇到一些場景,比如一個輪播的圖片展示欄,比如高階編輯器等等。幾乎所有的富UI元件都會由JS與CSS兩部分構成,而CSS之間也存在著模組的概念以及依賴關係。

為了更好的與requireJS整合,這裡採用require-css來解決CSS的模組化與依賴問題。

require-css是一個requireJS外掛,下載後將css.jsnormalize.js放於main.js同級即可預設被載入,比如在我們的專案中需要載入jQuery Mobile的css檔案,那麼可以直接這樣呼叫:

require(['jquery', 'css!../css/jquery.mobile-1.3.0.min.css'], function($) {
});

不過由於這個CSS本質上是屬於jQuery Mobile模組的一部分,更好的做法是將這個CSS檔案的定義放在jQuery Mobile的依賴關係中,最終我們的requireJS定義部分為:

require.config({
      paths: {
            'jquerymobile': 'vendor/jquery.mobile-1.3.0',
            'jstorage' : 'vendor/jstorage',
            'underscore': 'vendor/underscore'
      },
      shim: {
          jquerymobile : {
            deps: [
                'css!../css/jquery.mobile-1.3.0.min.css'
            ]
          },
          underscore: {
              exports: '_'
          }
      }
});

在使用模組時,只需要:

require(['jquery', 'underscore', 'jquerymobile', 'jstorage'], function($, _) {
});

jQuery Mobile的CSS檔案就會被自動載入,這樣CSS與JS就被整合為一個模組了。同理其他有複雜依賴關係的模組也可以做類似處理,requireJS會解決依賴關係的邏輯。

資料來源的載入與等待

Web App一般都會動態載入後端的資料,資料格式一般可以是JSON、JSONP也可以直接是一個JS變數。這裡以JS變數為例

var restaurants = [
    {
        "name": "KFC"
    },
    {
        "name": "7-11"
    },
    {
        "name": "成都小吃"
    }
]

載入這段資料:

$.getScript('data/restaurants.json', function(e){
    var data = window.restaurants;
    alert(data[0].name); //KFC
});

單一的資料來源確實很簡單,但是往往一個應用中會有多個資料來源,比如在這個例項App中UI就需要載入使用者資訊、餐廳資訊、訂餐資訊三種資料後才能工作。如果僅僅靠多層巢狀回撥函式的話,可能程式碼的耦合就非常重了。

為了解決多個資料載入的問題,我習慣的解決方法是構造一個dataReady事件響應機制。

var foodOrder = {

    //資料載入後要執行的函式暫存在這裡
    dataReadyFunc : []

    //資料來源URL及載入狀態
    , dataSource : [
        { url : 'data/restaurants.json', ready : false, data : null },
        { url : 'data/users.json', ready : false, data : null },
        { url : 'data/foods.json', ready : false, data : null }
    ]

    //檢查資料來源是否全部載入完畢
    , isReady : function(){
        var isReady = true;
        for(var key in this.dataSource){
            if(this.dataSource[key].ready !== true){
                isReady = false;
            }
        }
        return isReady;
    }

    //資料來源全部載入完畢,則逐一執行dataReadyFunc中存放的函式
    , callReady : function(){
        if(true === this.isReady()){
            for(var key in this.dataReadyFunc){
                this.dataReadyFunc[key]();
            }
        }
    }

    //供外部呼叫,會將外部輸入的函式暫存在dataReadyFunc中
    , dataReady : function(func){
        if (typeof func !== 'function') {
            return false;
        } 
        this.dataReadyFunc.push(func);
    }

    , init : function(){
        var self = this;
        var _initElement = function(key, url){
            $.getScript(url, function(e){
                //每次載入資料後,將資料存放於dataSource中,將ready狀態置為true,並呼叫callReady
                self.dataSource[key].data = window[key];
                self.dataSource[key].ready = true;
                self.callReady();
            });
        }
        for(var key in this.dataSource){
            _initElement(key, this.dataSource[key].url);
        }
    }
}

用法為

foodOrder.dataReady(function(){
   alert(1);     
});
foodOrder.init();

dataReady內的alert將會在所有資料載入完畢後開始執行。

這段處理的邏輯並不複雜,將所有要執行的方法通過dataReady暫存起來,等待資料全部載入完畢後再執行,更加複雜的場景此方法仍然通用。

使用JS模板引擎

資料載入後,最終都會以某種形式顯示在頁面上。簡單情況,我們可能會這樣做:

$('body').append('<div>' + data.name + '</div>');

如果頁面邏輯一旦複雜,比如需要有if判斷或者多層迴圈時,這種連線字串的方式就相形見絀了,而這也就催生出了JS模板引擎。

主流的JS模板引擎有underscore.jsJadeEJS等等,可以橫向對比一下這些JS模板引擎的優缺點

對於相對簡單的頁面邏輯(只需要支援if和for/each)來說,我更傾向選用輕巧的underscore.js或者JavaScript Templates

在當前例子中,使用underscore.js生成列表就非常簡單了,頁面模板為:

<ul data-role="listview" data-inset="true">
<script id="tmpl-restaurants" type="text/template">
    <% _.each(data, function(restaurant) { %>
        <li>
            <a href="#" data-rel="back" data-value="<%- restaurant.name%>"><%- restaurant.name%></a>
        </li>
    <% }); %>
</script>
</ul>

呼叫引擎

$("#tmpl-restaurants").replaceWith(
    _.template($("#tmpl-restaurants").html(), {
        data : restaurants
    })
);

物件導向與模組化

通過上面這些工具的組合,我們有了模組的概念,有了模板引擎,有資料的載入。最終還是要通過javascript將這一切組織在一起並加入應用所需要的邏輯。為了能最大限度的複用程式碼,用物件導向的方式去組織內容是比較好的選擇。

JavaScript雖然原生並不支援物件導向,但是依然可以通過很多方式模擬出物件導向的特性。例子中採用了我個人比較喜歡的一種方式是:

var foodOrder = function(ui, options){
    //建構函式
    this.init(ui, options);
}
foodOrder.prototype = {
   defaultUI :  {
       form : '#form-order'
   }
   , defaultOptions : {
       debug : false
   }
   , init : function(ui, options){
       this.ui = $.extend({}, this.defaultUI, ui);
       this.options = $.extend({}, this.defaultOptions, options);
   }
}
var order = new foodOrder({
    form : '#real-form'
}, {
    debug : true
});

將頁面的UI元素以及配置專案抽象出來,在實際構造物件時則可以通過入口引數複寫,可以分離整個專案的邏輯與UI,使處理的方式更加靈活。

相關文章