簡單粗暴的骨架屏實現


  早在2013年Luke Wroblewski就提出了骨架屏(Skeleton Screen)的概念,他認為骨架屏是一個頁面的空白版本,通過這個空白版本來傳遞一種信息,即頁面正在漸進式的加載中。骨架屏的布局能與頁面的視覺呈現保持一致,這樣就能引導用戶的關注點聚焦到感興趣的位置。如下圖所示,左邊是數據渲染后的頁面,右邊是骨架屏,可以看到相應的位置都能對起來。

  在網上閱讀了一些骨架屏原理的資料后,就自己想嘗試一下,練練手,制作一個極簡版本的骨架屏插件。因為簡單,所以未來如要擴展,成本也會很低。上圖是通過自己寫的骨架屏插件得到的效果,對於公司簡單結構的項目,還是游刃有余的。在編寫插件時,參考了網上多篇資料分享的代碼,站在巨人的肩膀上整合代碼,省力了很多。插件的完整代碼已上傳至GitHub中,下面是其中的構造函數,以及三個常量,用到了ES6的一些概念,如對此不熟悉,可參考我之前整理的《ES6躬行記》。

const NODE_ELEMENT = 1,     //元素類型的節點常量
  NODE_TEXT = 3,            //文本類型的節點常量
  NODE_COMMENT = 8;         //注釋類型的節點常量

/**
 * @param color         字體的背景色
 * @param bgColor       帶背景圖模塊的背景色
 * @param rectHeight    指定區域的高度,默認為視口高度
 * @param formFn        自定義表單着色規則
 * @constructor
 */
function Skeleton({
  color = "#DCDCDC",
  bgColor = "#F6F8FA",
  rectHeight = global.innerHeight,
  formFn = function() {}
} = {}) {
  this.container = document.body;   //骨架容器
  this.color = color;
  this.bgColor = bgColor;
  this.rectHeight = rectHeight;
  this.formFn = formFn;
}

一、繪制骨架屏

  由於對Node.js不熟,所以采用純原生的JavaScript來繪制骨架屏。首先將頁面中的元素分成三類:圖像、文本和表單。

1)圖像

  圖像也就是<img>元素,其src屬性會被替換成一張灰色(色素是#EEE)的1*1的gif圖。為了避免引入額外的請求,將該gif圖轉換成base64格式,寫死在替換函數image()中,如下所示,呈現的效果如下圖所示。

image(element, isImage = true) {
  const { width, height } = getRect(element);
  //圖像顏色 #EEE
  const src = "data:image/gif;base64,R0lGODlhAQABAPAAAPT09AA....";
  if (isImage) 
    element.src = src;
  else
    element.style.background = this.bgColor;
  element.width = width;
  element.height = height;
}

  由於image()函數聲明在原型(prototype)之上,因此省略了function關鍵字。isImage是一個布爾值,表示是否是一個<img>元素。當傳入非<img>元素時,就需要將其背景替換成初始化時的純色。getRect()是一個輔助函數,用於獲取元素的尺寸和坐標。

function getRect(element) {
  return element.getBoundingClientRect();
}

2)文本

  處理文本是比較復雜的,因為文本長度是不定的,如下圖所示,左邊的文本是兩行,骨架屏中也要變成兩行,並且第二行不是滿行的。

  網上的資料對於最后一行都會做遮罩處理,也就是用一個白底的塊定位到相應位置,把多余的灰底遮掉。當文本只有一行時,還需要做特殊處理。

  而我在設計骨架屏插件的時候,采用了一個簡單粗暴的方法,能夠避免遮罩和單行的處理,那就是為所有文本節點添加<span>元素。對於我這邊不太復雜的HTML結構而言,能夠大大簡化代碼的復雜度。具體方法如下所示,采用遞歸的方式逐個訪問子節點,當節點是文本類型並且有內容時,就為其包裹<span>標簽。

appendTextNode(parent) {
  //避免<span>中嵌套<span>
  if ( parent.childNodes.length <= 1 &&
    parent.nodeName.toLowerCase() == "span" ) {
    return;
  }
  parent.childNodes.forEach(node => {
    if (node.nodeType === NODE_TEXT && node.nodeValue.trim().length > 0) {
      let span = document.createElement("span");
      span.textContent = node.nodeValue;
      parent.replaceChild(span, node);
    } else {
      this.appendTextNode(node);
    }
  });
}

  下面的第一個<p>元素在調用了appendTextNode()方法后,就變成了第二個<p>元素。

<p>本活動最終解釋權歸上海易點時空網絡有限公司所有</p>
<!-- 骨架屏結構 -->
<p><span>本活動最終解釋權歸上海易點時空網絡有限公司所有</span></p>

  為了讓多行文本能呈現灰白相間的效果,就得借助CSS3的linear-gradient漸變屬性來實現。如果對其不熟悉,可以參考之前的《CSS3中驚艷的gradient》一文。

  下面的計算方式照搬了餓了么的page-skeleton-webpack-plugin插件,其中getStyle()函數用於獲取元素的CSS屬性或屬性對象(CSSStyleDeclaration)。

calculate(element) {
  let { fontSize, lineHeight } = getStyle(element);
  lineHeight = parseFloat(lineHeight);                     //解析浮點數
  fontSize = parseFloat(fontSize);
  const textHeightRatio = fontSize / lineHeight,           //字體占行高的比值
    firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(2),                         //漸變的第一個位置,小數點后兩位四舍五入
    secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(2);    //漸變的第二個位置
  return `
        background-image: linear-gradient(
            transparent ${firstColorPoint}%, ${this.color} 0,
            ${this.color} ${secondColorPoint}%, transparent 0);
        background-size: 100% ${lineHeight};
        position: relative;
        color: transparent;
    `;
}
function getStyle(element, name) {
  const style = global.getComputedStyle(element);
  return name ? style[name] : style;
}

  首先讀取字體大小和行高,然后計算字體占行高的比值(textHeightRatio),接着計算出漸變的兩個位置(firstColorPoint和secondColorPoint),最后通過模板字面量輸出文本的樣式,字體顏色被設為了透明。

  繪制文本的邏輯都封裝到了text()方法中,具體如下所示。

text(element) {
  //判斷是否是只包含文本的節點
  const isText =
    element.childNodes &&
    element.childNodes.length === 1 &&
    element.childNodes[0].nodeType === NODE_TEXT &&
    /\S/.test(element.childNodes[0].textContent);
  if (!isText) {
    return;
  }
  const rule = this.calculate(element);     //計算樣式
  element.setAttribute("style", rule);
}

3)表單

  表單控件目前只處理了input、select和button,它們中的文本會變透明,添加背景色,placeholder屬性變空,如下所示。

form(element) {
  element.style.color = "transparent";             //內容透明
  element.style.background = this.color;           //重置背景
  element.setAttribute("placeholder", "");         //清除提示
  this.formFn && this.formFn.call(this, element);         //執行自定義着色規則
}

  formFn是一個特殊的參數,在插件初始化時可傳遞進來,因為表單比較復雜,所以要自定義着色規則。例如一些頁面的表單結構是下面這樣的,那么就需要將<li>也添加背景色。

<ul>
  <li class="ui-flex">
    <input type="text" />
  </li>
  <li class="ui-flex">
    <input type="text" />
  </li>
</ul>

  自定義的着色規則如下所示,其中matches()是一個選擇器匹配方法。

new Skeleton({
  formFn: function(element) {
    while(element && !this.matches(element, "li.ui-flex"))
      element = element.parentNode;
    element && (element.style.background = this.color);
  }
});

matches(element, selector) {
  if (!selector || !element || element.nodeType !== NODE_ELEMENT)
    return false;
  const matchesSelector = element.webkitMatchesSelector || element.matchesSelector;
  return matchesSelector.call(element, selector);
}

4)移除

  因為骨架屏的特點是快速,所以在生成時需要移除多余的元素,例如指定區域外的元素、隱藏的元素和腳本元素,如下所示,其中isHideStyle()函數可判斷是否是隱藏元素。

removeElement(parent) {
  if (parent.children.length == 0) return;
  //有移除操作,所以未用Array.from()遍歷
  for (let i = 0; i < parent.children.length; i++) {
    const element = parent.children[i],
      { top } = getRect(element);
    if (
      isHideStyle(element) ||                           //隱藏元素
      top >= this.rectHeight ||                         //超出指定高度
      element.nodeName.toLowerCase() == "script"        //腳本元素
    ) {
      element.remove();
      i--;
      continue;
    }
    this.removeElement(element);
  }
}
function isHideStyle(element) {
  return (
    getStyle(element, "display") == "none" ||
    getStyle(element, "visibility") == "hidden" ||
    getStyle(element, "opacity") == 0 ||
    element.hidden
  );
}

  本來是想用Array.from()遍歷元素,但刪除后會影響迭代邏輯,因此改成了for循環語句。

  除了這三類元素之外,還得將注釋節點也一並刪除,如下所示。注意,childNodes與上面的children屬性不同,它能夠通過forEach()遍歷。

removeNode(parent) {
  if (parent.childNodes.length == 0) return;
  for (let i = 0; i < parent.childNodes.length; i++) {
    const node = parent.childNodes[i];
    if (node.nodeType === NODE_COMMENT) {
      node.remove();
      i--;
      continue;
    }
    this.removeNode(node);
  }
}

5)繪制

  繪制就是調用上面所提到的方法,包括移除元素、着色、替換圖像等,具體如下所示。

function draw() {
  this.container.style.background = "#FFF";         //容器背景重置
  //移除元素和節點
  this.removeElement(this.container);
  this.removeNode(this.container);
  //為文本添加<span>
  this.appendTextNode(this.container);
  //處理普通元素
  Array.from(
    this.container.querySelectorAll(
      "div,section,footer,header,a,p,span,form,label,li"
    )
  ).map(element => {
    //背景圖或背景顏色的處理
    const hasBg =
      getStyle(element, "background-image") != "none" ||
      getStyle(element, "background-color") != "rgba(0, 0, 0, 0)";
    if (hasBg) {
      this.image(element, false);
    }
    //文本處理
    this.text(element);
  });
  //處理表單中的控件
  Array.from(this.container.querySelectorAll("input,select,button")).map(
    element => {
      this.form(element);
    }
  );
  //<img>元素處理
  Array.from(this.container.querySelectorAll("img")).map(img => {
    this.image(img);
  });
}

二、Puppeteer

  插件完成后,沒有做到自動化,即需要在瀏覽器的控制台中手工執行骨架屏插件。翻閱資料后,大家都推薦使用Puppeteer。Puppeteer是一個Node庫,它提供了一個高級API來通過DevTools協議控制Chromium或Chrome。也就是說,它是一個無頭(headless)瀏覽器。

  一邊翻資料,一邊查看demo,嘗試着寫Node.js,后面跌跌撞撞的寫出了可以執行的腳本。

  原理就是先打開無頭瀏覽器;然后輸入視口參數和頁面地址,並添加插件地址;然后在打開的頁面中執行插件,返回document.body中的HTML代碼;最后將HTML寫入到一個txt文件中。

const puppeteer = require('puppeteer'),
    fs = require('fs');
(async () => {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
    //視口參數
    await page.setViewport({width: 375, height: 667});
    // 事件監聽,可用於調試
    page.on('console', msg => console.log('PAGE LOG:', msg.text()));
    // waitUntil 參數有四個關鍵字:load、domcontentload、networkidle0和networkidle2
    await page.goto('http://www.pwstrick.com/index.html', {waitUntil: 'networkidle2'});
    await page.addScriptTag({url: 'http://www.pwstrick.com/js/skeleton.js'});
    // 對打開的頁面進行操作
    const html = await page.evaluate(() => {
        let sk = new Skeleton();
        sk.draw();
        return document.body.innerHTML;
    });
    //將骨架屏代碼添加到content.txt文件中
    fs.writeFileSync('content.txt', html);
    await browser.close();
})();

  本來是想在page.evaluate()中將插件以參數的形式傳入,但一直不成功,后面就改成了page.addScriptTag(),引用插件的腳本。

  到目前為止,只能算是半自動化。要做到自動化,就得編寫webpack插件,在打包的時候,將生成的HTML代碼嵌入到頁面中的指定位置,並且還要做到參數可配置化,以適合更多的場景。

  整個骨架屏插件只有200多行代碼,去掉注釋和空行只有160多行,本插件主要用於學習。

 

GitHub地址如下:

https://github.com/pwstrick/skeleton

 

 

參考資料:

一種自動化生成骨架屏的方案

前端骨架屏方案小結

網頁骨架屏自動生成方案(dps)

一個前端非侵入式骨架屏自動生成方案

puppeteer

puppeteer中文

編寫一個webpack插件


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM