看一位老司機的博文,分享一下。

簡介

詳情頁也叫作單品頁,域名以「item.jd.com/skuid.html」爲格式的頁面。是負責展現京東商品 SKU 的落地頁面。主要任務是展現商品相關信息,如價格、促銷、庫存、推薦,從而引導用戶進入購買流程。同時單品頁有不少版本。通常分爲兩類。一類咱們一般看到的「通用類目詳情頁」—— 全部類目均可以使用,一類是不常常看到的「垂直屬性詳情頁」—— 一些有特殊屬性的商品集合javascript

 

首先,因爲詳情頁量大(SKU數十億)、高併發(日PV數十億)等特性,在很長的一段時間裏,單品頁都是後端程序生成靜態頁面使用 CDN 來解決量大、高併發的問題。css

 

其次。單品頁涉及的「三方」系統特別多,好比促銷、庫存、合約、秒殺、預售、推薦、IM、店鋪、評價社區等。而單品頁的主要任務就是展現這些系統的信息,而且適當的處理他們之間的邏輯關係,而這些系統的接口通常都使用異步Ajax來完成,由於其一CDN沒法作到頁面的動態化,其二一些系統的信息對實時性要求特別高(價格、秒殺),即便使用後端動態渲染也很難作到無緩存零延遲。html

 

基於上面兩個緣由,註定了單品頁是一種重多系統業務邏輯展現型頁面,重前端頁面。我大概彙總了一下頁面上異步接口,總共約有 30 個,頁首屏的接口特別重要,接口之間幾乎都有耦合關係:前端

單品頁前端模塊的結構與劃分

概覽

上圖能夠看出,基本上最核心的模塊都在首屏。每一個模塊都有單獨的一/多個腳本。代碼行數(LOC)由 230+ ~ 1200+ 不等。一般來講代碼行數越多代碼複雜性就越高,邏輯越複雜。很難想象「購買方式」這種只有一行屬性選擇功能的代碼行數卻 高達 1200 多行。其主要緣由就在於購買方式所在的系統和其它首屏核心系統(庫存、促銷、地址選擇、白條)都有邏輯上的耦合。java

 

看着不錯,然而在一個前端工程師眼裏至少應該是這樣的(我只取了一些典型的模塊,並非所有):webpack


 

這就能夠解釋爲何有的時候只是加一個很小的東西咱們都爲考慮再三而後經過 AB 測試提取相關數據,最後後再進行決策。單品頁的首屏能夠說是寸土寸金。git

 

按什麼維度劃分模塊

起初我按模塊的屬性劃分,好比核心、公共腳本、模塊腳本。但用了一段時候之後發現這樣劃分在單品頁這種大型系統中並不科學,由於這樣劃分出來的代碼只有劃分的人知道是什麼規則,其它人接手代碼很難快速掌握代碼架構,並且尤爲在模塊比較多的時候不方便維護。github

 

後來我嘗試徹底以功能模塊在頁面上出現的位置維度劃分。這樣以來維護起來方便多了,須要修改某個模塊代碼只須要對照着圖裏面標識的模塊信息就能輕易找到代碼。web

 

總體核心模塊

咱們按頁面上的模塊結構首屏劃分出來這幾個核心模塊:npm

  • curmb - 麪包屑

  • concat - 聯繫諮詢相關店鋪信息

  • prom - 價格促銷信息

  • address - 地區庫存選擇,配送服務

  • color - 顏色尺碼

  • buytype - 合約機購買方式

  • suits - 套裝購買

  • jdservice - 增值服務

  • baitiao - 白條支付

  • buybtn - 購買按鈕

  • info - 地區提示信息

 

項目的總體樹形結構是這樣的:

 

模塊內部結構

好比下面這個大圖預覽的功能,我所有放在一個文件夾裏面維護,可是邏輯上的 JavaScript 模塊是分離的,只是說文件夾(preview)就表明頁面上的某一部分功能集合:


 

注意文件夾的命名有必定的規則:

  • 模塊腳本與樣式名必須同樣;

  • 須要製做 sprite 的圖片統一放在 module/i  目錄下面,生成的 sprite 圖片也在其中;

  • 生成的 mixin 在模塊根目錄下,便於其它樣式文件調用;

 

咱們再來看下自動生成生成的 __sprite.scss 是什麼內容:

 

/*__sprite.scss 自動生成 */

@mixinsprite-arrow-next{

    width:22px;

    height:32px;

    background-image:url(i/__sprite.png);

    background-position:-0px-30px;

}

 

/*preview.scss 手動添加 */

@import"./__sprite";

.sprite-arrow-next{

    @includesprite-arrow-next;

}

 

注意引用的 mixin 名稱和咱們須要手動添加的樣式類名一致。固然也能夠直接生成一個類名對應的樣式,可是靈活性很差。好比 hover 的時候是另一張圖片就無法自動生成了。

 

前端技能樹

1、HTML

DOM 節點數

與重業務邏輯的頁面不一樣,重展現的頁面通常具備很高的 DOM 節點數。好比京東首頁,正常狀況加載完頁面一共有 3500 多個 DOM 節點,基本上所有用於展現商品信息、廣告圖和內容佈局,頁面上的三方異步服務也比較少。尤爲像頻道頁基本上沒有什麼業務上的邏輯,所有是靜態頁面。這種頁面的特色是更新換代頻率高,一年兩三次改版很正常,CMS 作模塊化後兩天換個皮膚都是沒問題的。可是這種思路並不適合單品頁。單品頁更重業務邏輯,同時展現層 UI 邏輯也有不少關係。

 

我本身的經驗是:頁面上的 DOM 節點數絕對不能超過 5000 個,不然頁面滾動的時候就會出現卡頓的狀況,尤爲是移動端。

 

同步渲染仍是異步加載

理論狀況下最好的作法是後端同步動態渲染頁面,可是因爲 Web 應用中不少功能都是用戶行爲驅動的。同步加載不可避免的消耗了後端服務資源。好比非首屏模塊(公共頭尾、評價)、點擊事件觸發的 DOM 內容(異步 tab)。

 

因此個人經驗是:能放到後端作判斷渲染的 DOM 就儘可能放在後端(尤爲是首屏)。這樣作的好處有四點好處:

  1. 後端渲染頁面相對穩定,不像前端 JavaScript 動態渲染 DOM,可能由於腳本報錯或者不可用形成模塊都沒法展現;

  2. 可訪問性、SEO 及用戶體驗也比較好。不會產生腳本的渲染抖動問題;

  3. 必定程度上減小了前端渲染頁面的複雜性,減小前端代碼複雜度;

  4. 邏輯統一到一個地方維護起來也方便,並且後端應該爲業務邏輯負責,前端應該爲展現UI 交互負責;

 

對於異步渲染的模塊來講,後端一般須要判斷 「頁面有什麼元素」,以及元素之間的依賴對應關係;而前端須要專一於 「元素應該怎麼展現」,UI 層面的交互以及模塊與模塊以前的邏輯關係。

 

其實更多的時候異步是一種沒有辦法的辦法,也就是說異步是其它方案都解決不了的狀況下才考慮的。

 

外鏈靜態資源

儘可能使用外鏈 CSS 和 JavaScript 資源,一方面便於緩存,減小服務同步輸出的資源浪費。IE 6 裏面會有一些可怪的 bug,好比有內聯樣式 style 標籤的頁面 A 若是在另一個頁面 B 中的 link 標籤中引用,那麼這段 style 會在 B 頁面也起做用。

 

使用雙協議的 URL

使用 // 來代替http: 和 https: 瀏覽器會自動適應兩種協議的資源訪問,兼容性較好。注意 IE 8 下使用腳本更新 src 爲雙協議時會出現 bug,建議使用 location.protocol 來判斷而後作兼容處理。

 

刪除元素默認屬性

好比 script 標籤默認的 type 就是 text/javascript,若是 script 裏面的內容是 JavaScript 時能夠不用寫 type。另外若是要在頁面裏面插入一段不須要瀏覽器解析的 HTML 片斷時能夠將 type 寫成text/x-template(任意不存在的 type)用於放置模板文件,一般用來在腳本中獲取其 innerHTML 而無任何負做用。

 

給腳本控制元素加上類鉤子

在腳本中取頁面元素使用 J- 前綴類名,與普通樣式類分離。這樣作會生成不少冗餘的類名,但卻很好的下降了樣式和腳本的耦合,而且在重構和腳本職位分開團隊裏會是一條最佳實踐。

 

2、CSS

樣式分類

全部頁面只共享一個 sass Mixin,裏面包含了基礎的 sass 語法糖、經常使用類(清浮動、頁面總體顏色字體等)。

 

模塊級的樣式分爲兩類: 

  1. 與腳本無關的公共樣式,單獨在模塊文件夾中組織。好比:按鈕、標籤頁。所有放在 common 模塊中維護;

  2. 與腳本相關的模塊級樣式,與對應模塊腳本放在一塊兒,能夠引用 common 中的公共樣式,但不能夠被其它模塊引用;

 

雪碧圖

關於雪碧圖我經驗是:永遠不要想把全部的圖標拼合在一塊兒。按模塊而不是按頁面去拼 sprite 更合理,更方便維護,而後配合構建工具自動接合生成樣式文件纔是最好的解決方案。固然若是你的頁面比較簡單,那這條規則並不適用。說到這個問題我就得把珍藏多年的圖片拿出來 show 一把,用事實來講明爲何把全部圖片都拼在一張圖上就必定是對的。早期因爲年輕篤信將全部的 icon 拼在一張圖上纔是完美的(圖 1)

後來維護起來實在不方便,就把按鈕所有單獨接合起來。注意,當時的按鈕都是圖片,設計方面要求的很嚴格。加入購物車按鈕作的也很是漂亮(圖 2)

 

而後這些都不是最典型的,下面這個 promise icon 纔是(圖 3)


 

從圖裏面能夠看到,這個功能在第一個版本的時候只有 7 個 icon,後來不斷增長,最多的時候達到 77 個。以致於當時每週都會添加兩個的頻率。

 

同時這個 icon 當時接合的時候技術上也有問題:不該該把文字也切到圖片裏面,主要緣由是早期 icon 比較少加上外邊框樣式對齊的問題綜合選擇了直接使用圖片。

 

後來我就以爲這樣是不對的。而後經過和產品的溝通,說明個人考慮以及新的解決方案後獲得了認同。結果就是對圖片不進行拼合,後臺上傳通過審覈的不帶文字 icon,文字由接口輸出,而後在產品上作了約定:icon 最多不能超過 4 個,代碼裏也作了相應限制。這樣就能保證頁面上的請求數不會太多同時方便系統維護,問題獲得瞭解決。

 

適當使用 DataURI

這個在一些小圖片場景方面特別適合,好比 1*1 的佔位圖、loading 圖等,不過 IE 6 並不支持這種寫法,須要的時候能夠加上一些兼容寫法:

.ELazy-loading{

    background:url(data:image/gif;base64,R0lGODlhKwAeAJEAAP///93d3Xq9VAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQFFAAAACwDAA0AJQADAAACEpSPAhDtHxacqcr5Lm416f1hBQAh+QQJFAAAACwDAA0AJQADAAACFIyPAcLtDKKcMtn1Mt3RJpw53FYAACH5BAkUAAAALAMADQAlAAMAAAIUjI8BkL0CoxQtrYrenPjcrgDbVAAAOw==)centercenterno-repeat;

    *background-image:url(//misc.360buyimg.com/lib/skin/e/i/loading-jd.gif);

}

 

關於兼容性

兼容性能夠說是前端工程師在日常開發中花費很大量無心義工做的地方。關於兼容性我想說的是若是你不肯意去說服周圍的人放棄或者讓他們意識到兼容性是個不可能徹底解決的問題,那麼你就得爲那些低級瀏覽器給你帶來的痛苦埋單。

 

其實更好的辦法是你和設計、產品溝通而後給出一種分級支持的方案。把每種瀏覽器定義一個級別。而後在開發功能的時候以「漸進加強」的方式。一般來說咱們的解決方案是在低級瀏覽器裏面保證流程正常進行、模塊可使用,但忽略一些可有可無的錯位、不透明等問題,在高級瀏覽器裏面須要對設計稿進行精確還原,適當的加上一些井上添花在細節。好比微小的動畫、邏輯細節上的處理等。

 

舉個例子吧,下面這個進度條表示預定的人數,它是接口異步加載完才展現的。若是加載完就當即設置進度條寬度會顯得生硬無趣,可是若是加上一點動畫效果的話就好多了。然而問題又來了,若是加上動畫那麼邏輯上這個進度條應該是一點點的增長,對應的人數也應該是逐個增長。因而我就作了個優化,讓人數在這段時間內均勻的增長。這個細節並非很容易被人發現,可是這種設計會讓用戶感受很用心並且有意思:

 

3、JavaScript


單品頁的腳本加載/執行順序:

  1. 等待頁面準備就緒(DOM Ready);

  2. 準備就緒後加載入口腳本(main.js),腳本負責其它功能模塊的調度,動態接合模塊經過 seajs 的 require.async 方法異步調用;

  3. 公共模塊(common.js)負責初始化全局變量並掛載到 pageConfig 命名空間;

  4. 動態模塊數組,這個是後端經過程序判斷處理生成的一個模塊名列表。通常只包含首屏須要加載的模塊;

  5. 後加載模塊(lazyinit.js)初始化,這個腳本只作一些頁面滾動才加載的模塊事件綁定。當模塊出如今視口內再使用 require.async 異步加載模塊的資源及初始化;

 

入口腳本

大體代碼以下:

 

/**

* 模塊入口(1. 公共腳本 2. 首屏模塊資源 3. 非首屏「後加載模塊」)

*/

var entries=[];

// 頁面公共腳本樣式

entries.push('common');

// 頁面使用到的首屏模塊(後端開發根據頁面不一樣配置須要調用的模塊)

entries=entries.concat(config.modules);

// 非首屏「後加載模塊」

entries.push('lazyinit');

 

for(var i=0;i<entries.length;i++){

    entries[i]='MOD_ROOT/'+entries[i]+'/'+entries[i];

}

 

if(/debug=show_modules/.test(location.href))console.log(entries);

 

require.async(entries,function(){

    var modules=Array.prototype.slice.call(arguments);

    var len=modules.length;

 

    for(var i=0;i<len;i++){

        var module=modules[i];

 

        if(module&&typeof module.init==='function'){

            module.init(config);

        }else{

            console.warn('Module[%s]must be exports a init function.',entries[i]);

        }

    }

});

 

注意模塊路徑中的 MOD_ROOT 是提早在頁面定義好的一個 seajs path。目的是爲了把前端版本號更新的控制權釋放給後端,從而解決了先後端依賴上線不一樣步形成的緩存延遲問題,配置腳本中只有幾個定義好的路徑:

seajs.config({

    paths:{

        'MISC':'//misc.360buyimg.com',

        'MOD_ROOT':'//static.360buyimg.com/item/default/1.0.12/components',

        'PLG_ROOT':'//static.360buyimg.com/item/default/1.0.12/components/common/plugins',

        'JDF_UI'   :'//misc.360buyimg.com/jdf/1.0.0/ui',

        'JDF_UNIT':'//misc.360buyimg.com/jdf/1.0.0/unit'

    }

});

 

還有一點,在測試環境的頁面中版本號(上面代碼中的 1.0.12 是一個全量的版本號)是後端從 URL 上動態讀取的(使用參數訪問就能夠命中對應版本 item.jd.com/sku.html?version=1.0.12)。這樣以來測試環境上就能夠並行測試不一樣版本的需求,並且互不影響。固然若是不一樣版本的後端代碼也有修改的話這樣是不行的,由於後端代碼也須要有個對應的版本號。

 

不過咱們已經解決了這個問題。後端會在測試環境裏 動態加載後端模板 而且能夠作到版本號與前端一致。這樣以來配合 git 方便的分支策略就能夠同時並行開發測試多個需求,不用單獨配多個測試環境。什麼?你還在使用 SVN!哦。那當我沒說過。

 

事件處理模型

客戶端的 JavaScript 代碼基本上都是事件驅動的,代碼的加載解析依賴於瀏覽器提供的 DOM 事件。好比 onload, mouseover, scroll 等。

 

事件驅動的的模型特別適用於異步編程,而 JavaScript 天生就是異步,全部的異步操做行爲都最終會在一個回調函數(callback)中觸發。

 

好比單品頁中價格接口,加載完成後須要更新 DOM 元素來展現實時價格;地區選擇接口加載完成後會更新配送信息、庫存/商品狀態等,僞代碼以下:

/*onPriceReady 和onAreaChange 能夠認爲都是一個 Ajax 異步函數調用

 * code 1 和 code 2 執行到的時間是不肯定前後順序的

 */

/* prom.js*/

onPriceReady(function(price){

    // code 1

    $('#price').html(price);

});

 

/*address.js */

onAreaChange(function(area){

    // code 2

    $('#stock').html(area.stockInfo);

});

 

上面的兩段代碼分別在兩個腳本中維護,由於他們的邏輯相對獨立。早期並無關聯關係。後來需求有變,他們之間須要共享一些對方的數據(切換地區後須要從新獲取價格數據並展現)。可是物理上又不能放在一塊兒經過使用全局變量的方式共享,並且它們都是異步加載接口後才取到數據的,並很差肯定誰先誰後(非要作到那就只能用全局變量雙向判斷)。因此這樣並不能很好的解決問題,並且代碼的耦合度會成倍增長。

 

這時候咱們引入了一種設計模式來解決這種問題 —— 發佈者/訂閱者,咱們把這種模式抽象成了自定義事件代碼來解決這一問題。這段代碼是由 YUI 核心開發者 NicholasC. Zakas 實現的。代碼很簡單,事件對象主要有兩個方法 addListener(type, listener) 和 fire(event)。因而咱們重構了上面的僞代碼:

/* prom.js*/

// 在代碼中註冊一個地區變化事件,獲取變化後的地區 id

// 而後從新請求價格接口並展現

Event.addListener('onAreaChange',function(data){

    getAreaPrice(data.areaIds)

});

 

onPriceReady(function(price){

    $('#price').html(price);

 

    Event.fire({

        type:'onPriceReady',

        data:'Any datayou want'

    })

});

 

/*address.js */

onAreaChange(function(area){

    $('#stock').html(area.stockInfo);

 

    // 在地區變化後除了作本身該作的事情之外

    // 觸發一個名爲onAreaChange 的事件,用來

    // 通知其它訂閱者事件完成,並傳遞地區相關參數

    // 這個時候在onAreaChange Ajax 回調函數

    // 中就只須要關心本身的邏輯,其它模塊的耦合關係

    // 交給它們本身經過訂閱事件來處理

    Event.fire({

        type:'onAreaChange',

        data:area.ids

    })

});

 

須要注意的一點是,必須確保事件先註冊後觸發執行,也就是說先 addListener,再 fire。

 

一些典型的性能優化點

基本上客戶端的 JavaScript 性能問題都來自於 DOM 查找和遍歷,在使用的時候必定要當心,可能不經意的一個操做就會損失不少性能,尤爲在低端瀏覽器中。順便多說一點,現代的 JavaScript 解釋器自己是很快的,語言層面的性能問題不多遇到。DOM 查找慢是由於瀏覽器給 JavaScript 訪問頁面提供的一套 DOM API 自己慢: 

  1. 緩存 DOM 查找,同時 DOM 查找不要超過 2000 個,低級瀏覽器會卡頓;

  2. 不要使用鏈式調用 find,如:find('li').find('a') 而是 find('li a');

  3. 在切換元素顯示狀態的時候,若是元素不少。優先使用 show()/hide() 方法,而不是 css('display',  'block/none') 前者有緩存,後者會強制觸發     reflow;

  4. 給節點添加 data-xx 屬性在存放一些數據,經過使用 jQuery 的 data('xx') 方法取更高效,減小 DOM 屬性訪問;

  5. 高密度事件(scroll, mousemove)觸發場景請使用節流方法;

  6. 使用事件代理,而不是直接綁定。若是不肯定代碼被調用次數,能夠先解除綁定再綁定具備命名空間的事件處理函數;

  7. 儘可能少用 DOM 動畫,使用 CSS 3 動畫代替;

 

前端工程化

起因

前端工程化其實並非最近兩年纔有的概念。大約在 2013 年的時候 Grunt 問世的時候就已經有所涉及。這類打包工具主要的目的是自動化一些開發流程,我最先使用 Grunt 來構建代碼的時候只解決了三個問題:

  1. 合併壓縮優化樣式腳本;

  2. 上線完自動備份;

  3. 單個文件打包到多目錄(歷史緣由一個文件線上的路徑有兩種,須要傳兩個目錄);

 

當時我還在組內作過一個分享,有興趣的能夠去圍觀一下 Best WorkflowWith Grunt

 

其實這些工具出現的緣由是:當時前端領域的各類基礎設施很缺少,而前端的工做內容又相對零散。工做時須要開不少的軟件。再加上 JavaScript 語言自己也很弱,就連包管理這種基礎的東西也沒有內置,以致於模塊化要經過一些第三方類庫來實現,好比:RequireJS, SesJS。

 

工具的重要性能夠在我以前的一個分享中找到 前端開發工具系列

 

現狀

現在前端工程的生態環境因爲 NodeJS 的出現已經變得很好了。你能夠根據本身的需求選一個合適的直接用到項目裏面。像 Grunt, Gulp, browserify, webpack 等。不過要明白這些工具的出現從另外一方面證實了前端開發天生存在不少的問題: 

  • HTML 從誕生到 HTML 5 以前幾乎沒有任何變化,DOM 性能天生缺失。因此纔有了 Virtual DOM 這種東西;

  • CSS 只是一門描述型的語言,沒有變量、邏輯控制、語句。因此纔出現了 Sass, Less 這種預編譯工具;

  • JavaScript 號稱「高階的(high-level)、動態的(dynamic)、弱類型的(untyped)、解釋型(interpreted)編程語言,適合面向對象(oop)和函數式的(functional)編程風格」的編程語言,可是語言自己有不少問題(ES 6 以前)。不適合大型項目的開發、沒有一些高級特性的支持、同時被其它語言詬病的 callback 風格、單線程執行等。因此纔出現了像 TypeScript, Babel 這種編譯成 JavaScript 代碼的語言;

 

這些問題幾乎都是歷史性的緣由和兼容性因素形成的。做爲一名好的前端工程師要看清楚現狀,而後按本身項目的需求去定製一些前端工程化的方案,而不是隨波逐流。

 

選擇

其實如今本身開發一套前端工程化/自動化流程的成本已經很低了,你只須要學習一些 NodeJS 的知識,配合 NPM 包管理機制,隨手就搞出一個構建工具出來。由於並不須要你去實現什麼東西,全部的東西都有現成的包。腳本壓縮有 UglifyJS,CSS 優化有 CSS-min,圖片壓縮優化有 PNG-quant 等等。你只須要想清楚本身要達到什麼目的,解決什麼問題就能夠抄傢伙本身寫一套工做流出來。

 

我本身的經歷也從 Grunt, GulpJS 到如今自造輪子。本身根據需求開發出來一套集成的打包工具,有興趣的能夠去圍觀一下 Wooo

 

固然你也能夠不用任何打包工具,本身寫一些 NPM Script 來徹底定製化項目開發/測試/打包流程。我猜這也是爲何如今相似 Grunt 再也不那麼火,Gulp 遲遲沒有發佈 4.0 版本的緣由。寫一個構建工具的成本過低了,並且這種集成的工具很難知足差別的開發需求。君不知已有人意識到了這一點麼why-i-left-gulp-and-grunt-for-npm-scripts

 

程序、設計、產品

我始終認爲程序、設計是爲了產品服務的。好的產品是要重視設計的,好的(前端)工程師是要有一些審美素養。

 

其實不少時候技術解決方案都是要根據產品的定位來設計的,瞭解產品需求之後才能定製出真正合適的高效的解決方案。比如前面講到的那個 sprite 案例,若是一開始就和產品討論好方案後來也不可能有那種失控的狀況發生。在產品造成/上線前期能發現問題比上線後發現問題更容易解決。

 

這部份內容和代碼無關,就很少說了。然而早年我還有一次分享關於前端、改變

 

總結

關於單品頁的前端開發本篇文章只是冰山一角,還有不少沒有說起。原創做者:周琪力,前端工程師,網絡經常使用暱稱「keelii」。在過去的4年裏主要負責京東網站商品詳情頁的前端系統架構和開發,平時主要寫 JavaScript 偶爾寫點NodeJS,Python。琪力博客: https://keelii.github.io/。

相關文章
相關標籤/搜索