1. 程式人生 > >一種對開發更友好的前端骨架屏自動生成方案

一種對開發更友好的前端骨架屏自動生成方案

(馬蜂窩技術原創內容,公眾號 ID:mfwtech)

一份來自 Akamai 的研究報告顯示,在對 1048 名網購戶進行採訪後發現:

  • 約 47% 的使用者期望他們的頁面在兩秒之內載入完成。

  • 如果頁面載入時間超過 3s,約 40% 的使用者會選擇離開或關閉頁面。

一直以來,為了提升使用者在頁面載入時的體驗,無論是 Web 還是 iOS、Android 的應用中,前端開發工程師都做了許多工作。除了解決如何讓網頁展現速度更快的問題,還有很重要的一點就是提升使用者對載入等待時間的感知。「菊花圖」以及由其衍生出的各種載入動畫就是一類常見的解決方案,相信無論是開發者還是使用者對下面這個圖示都不會陌生:

本文要介紹的「骨架屏」則被視為菊花圖升級版的方案。受現有骨架屏方案的啟發,馬蜂窩電商前端研發團隊實現了一種自動化生成骨架屏的方法,並在馬蜂窩商城的多個頁面中實現應用,取得了不錯的效果。

 

一、什麼是骨架屏

骨架屏可以理解為在頁面資料尚未返回或頁面未完成完全渲染前,先給使用者呈現一個由灰白塊組成的當前頁面大致結構,讓使用者產生頁面正在逐漸渲染的感受,從而使載入過程從視覺上變得流暢。生成後的骨架屏頁面如下圖所示:

骨架屏的主要優勢為:

  1. 使用者避免看到長時間的白頁
  2. 可以獲知頁面的大體結構,減小使用者認為頁面出錯而離開的機率
  3. 與菊花圖相比視覺更加流暢

 

二、常見的前端骨架屏方案

在選擇骨架屏之前,我們也考慮了一些其他的方法,比如能否通過服務端渲染(SSR)的方式來避開前端白屏時間的問題。但發現需要涉及專案過多,還會涉及服務的構建與部署;或是通過 prerender-spa-plugin 提供簡單的預呈現,它對 SPA 支援友好,但需要額外的 webpack 配置,且因為包源的問題,下載時間過長,有時還會莫名失敗,等等,都因為種種原因最終放棄。

經過一系列調研後,我們對業界常見的幾種骨架屏解決方案,以及它們的優勢、不足進行了一個簡單的梳理。

1. UI 骨架屏圖

即通過 UI 提供符合頁面首頁樣式的圖來充當骨架屏,將骨架屏 base64 圖片插入 root 根節點,在 webpack 打包時嵌入專案中。

這是一種簡單粗暴的方法,實現起來比較容易。但缺點也很明顯,就是需要 UI 設計師支援和開發介入,不能自動生成。

2.  手寫骨架屏

即通過手寫 HTML、CSS 的方式為目標頁定製骨架屏。這種方式可以做到對頁面真實樣式的復刻。不過一旦由於各種原因導致頁面樣式發生改變,就需要再改一遍骨架屏的樣式和佈局,極大增加了維護的成本。

3. 自動生成靜態骨架屏

目前比較受關注的是餓了麼開源的外掛 page-skeleton-webpack-plugin,其具體實現原理為:

  • 生成骨架屏

通過 Puppeteer 操控 handless Chrome 開啟需要生成的骨架屏頁面,在等待頁面載入完成之後,保留頁面佈局樣式的前提下,通過對頁面中元素進行增刪,對已有元素通過層疊樣式進行覆蓋,使其展示為灰白塊。然後將修改後的 HTML 和 CSS 提取出來,將頁面分為不同的塊區域,例如文字塊、圖片塊、按鈕塊、SVG、偽類元素塊等,分別對每個塊進行處理,使其儘量與原頁面保持一致。這裡用到了 Puppeteer  page 例項的 addScriptTag 方法來將處理塊的指令碼插入到 headless Chrome 開啟的頁面當中。

實際生成的骨架屏頁面與原頁面可能還會存在差距,外掛通過 memory-fs 將骨架屏寫入記憶體中,可以通過預覽頁面對生成的骨架屏進行二次編輯和效果預覽,修改完成後點選生成按鈕就能生成一份新的骨架屏寫入到專案中。

借一張圖來說明:

  • 插入骨架屏

骨架屏的 DOM 結構和 CSS 通過離線生成後,在構建時注入模板 (EJS) 中的節點下面,插入到 HTML 是在 after-emit 鉤子函式中進行。

page-skeleton-webpack-plguin 生成骨架屏的方案可以根據專案中不同的路由頁面生成相應的骨架屏頁面,並將骨架屏頁面通過 webpack 打包到對應的靜態路由頁面中。

它的不足之處在於:

  • 實際使用過程中無法監聽介面返回導致生成骨架屏的時機是否準確

  • 生成的頁面與業務人員寫的結構質量有直接關係,經常出現需要手工二次調整的情況

在這樣的背景下,馬蜂窩電商研發前端團隊希望找一種在提升使用者體驗的同時,對開發更友好的骨架屏生成方式,能針對不同的業務場景自動生成出相似的骨架屏,並且實現自動注入。對於開發而言,只需要執行一條命令,或者簡單配置,就可以生成骨架屏,不需要再考慮後續的維護工作。

在方案調研過程中,draw-page-structure 為我們的設計提供了靈感。

4. draw-page-structure

  • 生成骨架屏:
// dps.config.js
{
  url: 'https://baidu.com',
  output: {
    filepath: '/Users/famanoder/DrawPageStructure/example/index.html',
    injectSelector: '#app'
  },
  background: '#eee',
  animation: 'opacity 1s linear infinite;',
  // ...
}

根據 URL 指定的線上地址,配合 Puppeteer 獲取當前頁面的 DOM 結構,並對其中元素節點生成骨架屏檔案到 filepath 指定的檔案裡面,就可以生成骨架屏頁面,結果如下圖所示:

  • 插入骨架屏

將上述生成的骨架屏檔案插入到頁面根節點下面一般為 id="app" 的節點,然後在通用工具裡提供主動銷燬骨架屏的方法,就可以幫助開發主動控制或銷燬骨架屏,顯示頁面真實內容。

draw-page-structure 的設計思想很大程度上可以滿足我們的需求,不足的是隻能對線上已經存在的 URL 生成骨架屏,不支援開發環境。另外由於是自動生成,當頁面存在重定向(如果未登入重定向到登入頁面)的情況時,生成的骨架屏可能與預期不一致。而且它的內部實現並不完善,可能導致某些結構複雜的頁面下生成的骨架屏需要二次優化調整。

於是,我們開始了進一步的探索。

三、對開發更友好的實現方案

1. 設計思路

基於對現有方案的借鑑,我們想到了在配置檔案中指定要生成骨架屏的頁面 URL 和檔案輸出的目錄,執行時讀取配置檔案中的配置項,通過 Puppeteer 開啟指定的頁面並注入 evalDom.js 的方法。因為此 JS 是在 Puppeteer 裡面執行的,所以可以獲取到當前頁面完整的 DOM 結構,這給我們留下了非常大的發揮空間。

最初我們是從獲取到的 DOM 結構中的 body 標籤出發,遞迴去處理頁面上的所有節點,處理完成後用生成的 DIV 替換原有元素的位置。第一版方案中通過 getBoundingClientRect 和 getComputedStyle 的方法來獲取元素所有計算屬性和相對於視口的寬高和位置,然後結合元素本身的樣式屬性遞迴渲染,保留頁面原始 DOM 巢狀層次。

但由於能夠決定元素位置的屬性實在太多,如 position,z-index、width、height、top、display、box-sizing、flex 等都需要考慮,導致無法聚焦對頁面 DOM 結構處理的邏輯,而且這些屬性在處理完成後還需要加到最終生成骨架屏節點的 style 上,這樣骨架屏檔案可能比原來完整的頁面結構還大,這肯定不是我們希望的。

優化後的方案是用 getBoundingClientRect 和 getComputedStyle 獲取元素相關屬性,然後直接通過絕對定位的方式來生成最終的骨架屏節點。這樣在頁面上最終需要的屬性主要是 position、z-index、top、left、width、height、background、border-radius。除了無法保證頁面原始的 DOM 結構,其它需求基本都可以滿足,也更加聚焦於節點的處理。

主要實現流程如下圖:

該方案目前主要應用於馬蜂窩電商業務的多頁面專案中,包括下單頁、簽證頁等,以下單頁為例,展示效果如下圖:

2. 實現方式

  • 生成骨架屏

(1) config.js 配置

const dpsConfig = {
   // 預設生成位置為當前專案目錄skeleton資料夾,已有骨架屏頁面不會再次生成,新頁面配置只需要新增新條目即可
    visa_guide: {
        url: 'https://w.mafengwo.cn/sfe-app/visa_guide.html?mdd_id=10083', // 必填項
    },
    call_charge: {
        url: 'http://localhost:8081/sfe-app/call_charge.html?rights_id=25', // 必填項 待生成骨架屏頁面的地址,用百度(https://baidu.com)試試也可以
        //url:'https://www.baidu.com',
        device: 'pc', // 非必填,預設mobile
        background: '#eee', // 非必填
        animation: 'opacity 1s linear infinite;', // 非必填
        headless:false, // 非必填
        customizeElement: function(node) { // 非必填
            //返回值列舉如果是true表示不會向下遞迴到這層為止,如果返回值是一個物件那麼節點的檔子就按照物件裡面的樣式來繪製
            //如果返回值為0表示正常遞迴渲染
            //如果返回值為1表示渲染當前節點不在向下遞迴
            //如果返回值為2表示對當前節點不作任何處理
            if(node.className === 'navs-bottom-bar'){
                return 2;
            }
            return 0;
        },
        showInitiativeBtn: true,// 非必填 如果此值設定為true表示開發需要主動觸發生成骨架屏了,此時headless需設定為false
        writePageStructure: function(html) { // 非必填
            // 自己處理生成的骨架屏
            // fs.writeFileSync(filepath, html);
            // console.log(html)
        },
        init: function() { // 非必填
            // 生成骨架屏之前的操作,比如刪除干擾節點
        }  
    }
}


module.exports = dpsConfig;

(2)Puppeteer 新開啟頁面並返回瀏覽器例項、openPage

const ppteer = require('puppeteer');
const { log, getAgrType } = require('./utils');
const insertBtn = require('../insertBtn');


const devices = {
  mobile: [375, 667, 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1'],
  ipad: [1024, 1366, 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1'],
  pc: [1200, 1000, 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1']
};

async function pp({device = 'mobile', headless = true, showInitiativeBtn = false}) {

  const browser = await ppteer.launch({headless});//返回browser例項

  async function openPage(url, extraHTTPHeaders) {
    const page = await browser.newPage();
    let timeHandle = null;

   if(showInitiativeBtn){
    browser.on('targetchanged', async ()=>{//監聽頁面路由變化,並獲取當前標籤頁的最新的頁面,在showInitiativeBtn為true時插入按鈕由開發控制主動生成骨架屏
      const targets = await browser.targets();
      const currentTarget = targets[targets.length - 1]
      const currentPage = await currentTarget.page();

      clearTimeout(timeHandle)
      setTimeout(()=>{
        if(currentPage){
          currentPage.evaluate(insertBtn);
        }
      },300)
    })
   }
    try{
      let deviceSet = devices[device];
      page.setUserAgent(deviceSet[2]);
      page.setViewport({width: deviceSet[0], height: deviceSet[1]});

      if(extraHTTPHeaders && getAgrType(extraHTTPHeaders) === 'object') {
        await page.setExtraHTTPHeaders(new Map(Object.entries(extraHTTPHeaders)));
      }
      await page.goto(url, {
        waitUntil: 'networkidle0'//不再有網路連線時觸發(至少500ms後)
      });
    }catch(e){
      console.log('\n');
      log.error(e.message);
    }
    return page;
  }
  return {
    browser,
    openPage
  }
};

module.exports = pp;

(3)在瀏覽器環境裡執行 evalDom.js 和 evalDom.js 中處理 node 節點的主要邏輯

agrs.unshift(evalScripts);//evalScripts = require('../evalDOM');在puppeteer裡執行evalDom.js並將config.js裡配置的引數傳遞給evalDom
html = await page.evaluate.apply(page, agrs);
//evalDom.js主要邏輯
startDraw: function () {
    const $this = this;
    const nodes = this.rootNode.childNodes;
    this.beforeRenderDomStyle();
    function childNodesStyleConcat(childNodes) {
        for (let i = 0; i < childNodes.length; i++) {
            const currentChildNode = childNodes[i];//當前子節點
            //有哪些節點要跳過繪製骨架屏的過程

            if ($this.shouldIgnoreCurrentElement(currentChildNode)) { //是否應該忽略當前節點,不採取任何措施。後續這個地方可以由使用者指定哪些節點應該被略去,todo
                continue;
            }

            const backgroundHasurl = analyseIfHadBackground(currentChildNode);
            const hasDirectTextChild = childrenNodesHasText(currentChildNode);//判斷當前元素是不是有直接的子元素並且此元素是Text
            if ($this.customizeElement && $this.customizeElement(currentChildNode) !== 0 && $this.customizeElement(currentChildNode) !==  undefined) {
                //開發者自定義節點需要渲染的樣子,預設返回false表示使用正常遞迴的演算法來處理。如果返回值是true表示不會在向下遞迴,如果返回值是一個物件那麼表示開發需要自定義樣式此時直接繪製就好。todo
                if (getArgtype($this.customizeElement(currentChildNode)) === 'object') {
                    console.log('object');
                    //此處如果返回一個物件表示物件要自定義最後繪製的物件
                } else if ($this.customizeElement(currentChildNode) === 1) {
                    //如果此時返回true,表示此節點要過濾
                    getRenderStyle(currentChildNode);
                } else if ($this.customizeElement(currentChildNode) === 2){
                    continue ;
                }
                continue;
            }
            if (backgroundHasurl || analyseIsEmptyElement(currentChildNode) || hasDirectTextChild || shouldDrawCurrentNode(currentChildNode)) { //如果當前元素是內聯元素或者當前元素非內聯元素,但是不包含子節點或者子節點都是內聯元素的話那麼我們就在當前的骨架屏上繪製此節點。                   
                getRenderStyle(currentChildNode, hasDirectTextChild);
            } else if (currentChildNode.childNodes && currentChildNode.childNodes.length) { //如果當前節點包含子節點
                //遞迴
                childNodesStyleConcat(currentChildNode.childNodes);
            }
        }
    }
    childNodesStyleConcat(nodes);
    return this.showBlocks();
},  
 
  • 上述 rootNode 為根節點,預設為 document.body 或者可以由開發指定

  • 主要邏輯為判斷當前節點是否需要忽略、是否設定了背景圖片、是否含有文字資訊、開發是否指定了當前節點的處理方式等,對滿足條件的渲染其對應的骨架屏節點,否則處理當前節點的子節點

  • 所有節點處理完成後,呼叫 showBlocks 將生成的骨架屏節點拼接位 HTML 字串,以便後續處理

(4) getRenderStyle 生成骨架屏樣式

const styles = [
    'position: fixed',
    `z-index: ${zIndex}`,
    `top: ${top}%`,
    `left: ${left}%`,
    `width: ${width}%`,
    `height: ${height}%`,
    'background: '+(background || '#eee'),
];
const radius = getStyle(node, 'border-radius');
radius && radius != '0px' && styles.push(`border-radius: ${radius}`);
blocks.push(`<div style="${styles.join(';')}"></div>`);
  • zIndex、top、left、width、height 為處理後的屬性,然後把所有骨架屏節點的字串都 push 進 blocks 這個陣列中。

(5) 最終生成骨架屏的 HTML 檔案如下:

<html><head></head>
    <body><div style="position: fixed;z-index: 999;top: 89.805%;left: 4.267%;width: 91.467%;height: 11.994%;background: #eee"></div></body></html>
  • 插入骨架屏

在專案入口 index.html 檔案內新增

<body>
    <div id="app">
    </div>
    <% if(htmlWebpackPlugin.options.hasSkeleton) { %>
        <div id="skeleton"><!-- 骨架屏通過htmlWebpackPlugin在啟動打包的時候自動注入 -->
            <%= htmlWebpackPlugin.options.loading.html %>
        </div>
    <% } %>
    <!-- built files will be auto injected -->
</body>

四、 總結

目前,該方案已經支援由開發主動控制骨架屏生成時間,這樣就避免了頁面重定向的過程中無法生成正確的骨架屏,同時可以支援在本地開發時生成骨架屏。未來我們將實現支援開發自定義生成骨架屏節點的樣式和元件骨架屏的生成,並優化 evalDom.js 內部節點過濾、處理的演算法。敬請期待!

最後,我們正在招聘資深前端開發工程師,歡迎感興趣的同學傳送簡歷至:[email protected]

本文作者:康岑波、孫昊男,馬蜂窩電商平臺前端研發工程師。

相關推薦

開發友好前端骨架自動生成方案

(馬蜂窩技術原創內容,公眾號 ID:mfwtech) 一份來自 Akamai 的研究報告顯示,在對 1048 名網購戶進行採訪後發現: 約 47% 的使用者期望他們的頁面在兩秒之內載入完成。 如果頁面載入時間超過 3s,約 40% 的使用者會選擇離開或關閉頁面。 一直以來,為了提升使用者在頁面

測試計劃驅動開發模式 TPDD:比 TDD 友好開發模式

什麽是 mha peewee 驅動開發 生產 datetime person 分開 參與   相信大部分開發團隊都在使用TDD,並且還有很多開發團隊都 對外聲明 在使用 TDD 開發模式。    之所以說是“對外聲明”,是因為很多開發團隊雖然號稱使用的是 TDD 開發模式,

中國人的精神,體現在在《易經》中,在《道德經》中,在《論語》和《春秋》中,那是現實生活深沈的熱愛

商業 印度 世界 人的 裏的 們的 mda 什麽是 現實 作者:粵若稽古鏈接:https://www.zhihu.com/question/36129534/answer/250725916來源:知乎著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請註明出處。

共享資源和獨享資源的檢查方法

技巧 參考 fine 數量 代碼檢測 void linker 移除 基本 一種對共享資源和獨享資源的檢查方法 1. 背景 當程序的子模塊數量和規模擴大之後,在開發階段,系統長時間允許後經常會碰到下面一些bug: 內存泄漏。隨著時間允許,系統可用的內存越來越少,最後kern

javascript - 象賦值方式

結果 bsp step clas class 賦值 讓我 console obj /** * step1: * 結果為:1,‘2‘ */ let opt = { num: 1, str: ‘2‘

試用半年後,我找到正確開發區塊鏈DApp的方式

作者:黃志華 編輯:PRS 佈道者 分散式的“合縱連橫” 區塊鏈技術自誕生以來,人們不斷嘗試各種可能性,值得注意的是,成功的區塊鏈專案,常常專注於一個核心功能。 比如說比特幣核心的功能就是“記賬”,以太坊的核心功能是“發行代幣”(智慧合約)。 目前市面上有很多

ngx_lua請求進行開關控制的實現

        為了提高效能,用OpenResty(nginx+lua)開發了一個輕量Web應用。主用用於提供統一高效能查詢介面(統一走該輕應用域名)和極大的減少瀏覽器的http連線數量(使用ngx.location.capture_multi )。         為了更

區塊鏈是技術,門學科

區塊鏈不只是一個加密技術或是數字貨幣,而是一門系統學科。區塊鏈不是某些特殊政治思想的烏托邦,或洗錢的工具,而是一門科學家和工程師可以研究的系統工程,而且這門學科是可以成為國家戰略,改變各行各業的流程以及基礎設施。區塊鏈(Blockchain)是分散式資料儲存、點對點傳輸、共識

前端骨架方案小結

骨架屏 最近在專案不時有用到骨架屏的需求,所以抽時間對骨架屏的方案作了一下調研,骨架屏的實踐已經有很多了,也有很多人對自己的方案作了介紹.在這裡按照個人的理解做了一個彙總和分類,分享給大家. 關於骨架屏(簡介) 骨架屏就是在頁面資料尚未載入前先給使用者展示出頁面的大致結構,直到請求資料返回後再渲染頁面,

物聯網應用中的數字孿生——實現物聯網數字孿生的全面的解決方案

原文連結:http://www.oracle.com/us/solutions/inter...twins-for-iot-apps-wp-3491953.pdf 轉載於:https://blog.csdn.net/steelren/article/details/79198165 簡介

如何讓網頁“看起來”展現地快?骨架二三事

讓網頁展現的更快,官方說法叫做首屏繪製,First Paint 或者簡稱 FP,直白的說法叫做白屏時間,就是從輸入 URL 到真的看到內容(不必可互動,那個叫 TTI, Time to Interactive)之間經歷的時間。當然這個時間越短越好。   但這裡要注意,和首屏相關的除

在ssm框架下時間互動的簡單解決方案

  總結基於ssm框架下的統一快速處理時間的簡單方案。 1 約定與頁面互動格式 後臺程式為前端頁面提供介面,統一使用時間字串互動:包含兩種字串格式:"yyyy-MM-dd HH:mm:ss"和"yyy

介面自動化測試框架開發 (pytest+allure+aiohttp+ 用例自動生成)

近期準備優先做介面測試的覆蓋,為此需要開發一個測試框架,經過思考,這次依然想做點兒不一樣的東西。 介面測試是比較講究效率的,測試人員會希望很快能得到結果反饋,然而介面的數量一般都很多,而且會越來越多,所以提高執行效率很有必要 介面測試的用例其實也可以用來兼做簡單的壓力測試,而壓力測

【Spring Security OAuth2筆記系列】- 【使用Spring MVC開發RESTful API】 使用swagger自動生成html文件

使用swagger自動生成html文件 本節內容 使用swagger自動生成html文件 使用WireMock快速偽造restful服務 前後分離並行開發的時候(當然不是一個人從前到後都幹那種);那麼提供文件就很有必要了。 光看文件不是那麼的直觀。偽

簡單的象賦值方法,定義實例後以{}賦值,比傳統方法簡潔

method ott static set num arr nbsp st2 () public class Rectangle { public Point TopLeft { get; set; } public Point Botto

你適合做web前端開發工程師嗎?看你是不是這5人中的

網際網路的發展瞬息萬變,前端開發行業發展喜人。這個新型的行業和新興的職位正在上升到技術的層面,Web2.0網站風光無限,而你跟隨時代的腳步有多緊,很大程度上決定了你將來會有多成功。一些想從事、或感興趣的人會問:我適合做前端工程師嗎? 相對於其他IT技術職位來說,Web前端工程師算是半個程式

成為前端開發人員的9個技巧

譯者注:本文作者給讀者支了9個技巧幫助讀者成為一名更好的前端工程師,如果你想對自己的職業生涯有一個好的規劃,或者看看自己還有哪些不足,不妨看一下這篇文章吧。以下為譯文。我應該使用這個外掛還是那個庫? 這個專案我應該是先計劃一下還是直接進入到編碼階段?我應該尋求幫

kotlin, 新的android平臺一級開發語言

程序 16px 語法 jvm ava lin 使用 ide 擁有 最近看到一則科技新聞, 大致內容是google將kotlin語言作為android應用開發的一級語言, 與java並駕齊驅, 這是一個開發界的大事件大新聞, 連google的親兒子go語言也沒有這

SpringBank 開發日誌 簡單的攔截器設計實現

exp bst 一個 pin factory span 之前 system request 當交易由Action進入Service之前,需要根據不同的Service實際負責業務的不同,真正執行Service的業務邏輯之前,做一些檢查工作。這樣的攔截器應該是基於配置的,與Se

SaltStack介紹——SaltStack是新的基礎設施管理方法開發軟件,簡單易部署,可伸縮的足以管理成千上萬的服務器,和足夠快的速度控制,與他們交流

con mar stack 通信 class 交流 ast 集中 速度 SaltStack介紹和架構解析 簡介 SaltStack是一種新的基礎設施管理方法開發軟件,簡單易部署,可伸縮的足以管理成千上萬的服務器,和足夠快的速度控制,與他們交流,以毫秒為單位。S