基於page-skeleton-webpack-plugin分析自動生成骨架屏原理

Woo不想說話發表於2019-03-25

一、page-skeleton-webpack-plugin

page-skeleton-webpack-plugin是一款由ElemeFE團隊開發的webpack 外掛,該外掛的目的是根據你專案中不同的路由頁面生成相應的骨架屏頁面,並將骨架屏頁面通過 webpack 打包到對應的靜態路由頁面中。

二、外掛自動生成骨架屏的主要原理

基於page-skeleton-webpack-plugin分析自動生成骨架屏原理

  1. 通過無頭瀏覽器puppeteer開啟要生成骨架屏的頁面
  2. 等待頁面渲染完後注入提取骨架屏的指令碼(注意:一定要等頁面完全渲染完,不然提取的DOM不完整)
  3. 對頁面中元素進行刪減或增添,對已有元素通過層疊樣式進行覆蓋,這樣達到在不改變頁面佈局下,隱藏圖片和文字,通過樣式覆蓋,使得其展示為灰色塊。然後將修改後的 HTML 和 CSS 樣式提取出來生成骨架屏。
先demo展示一下如何自動生成骨架屏,後續再通過程式碼具體分析如何生成骨架屏:

基於page-skeleton-webpack-plugin分析自動生成骨架屏原理

安裝執行環境
依賴環境:
  • puppeteer
  • nodejs v8.x
安裝puppeteer可參考:www.jianshu.com/p/a9a55c03f…
啟動puppeteer並開啟要生成骨架屏的頁面

const puppeteer = require('puppeteer');
const devices = require('puppeteer/DeviceDescriptors');
const iPhone = devices['iPhone 6'];
const { Skeleton } = require('page-skeleton-webpack-plugin');

let skeleton = new Skeleton();

(async () => {
    const browser = await (puppeteer.launch({
        //設定超時時間
        timeout: 15000,
        //如果是訪問https頁面 此屬性會忽略https錯誤
        ignoreHTTPSErrors: true,
        // 開啟開發者工具, 當此值為true時, headless總為false
        devtools: true,
        // 非headless模式,為了能直觀看到頁面生成骨架屏的過程
        headless: false
    }));
    const page = await browser.newPage();
    // 因為是移動端,設定模擬iphone6
    await page.emulate(iPhone);
    // 開啟m站首頁
    await page.goto('https://m.to8to.com/sz');
    // 等待首屏bannar載入完成
    await page.waitForSelector('.ad-data-report-carousel');
    // 開始build骨架屏
    await skeleton.makeSkeleton(page);
})();
複製程式碼

接下來分析makeSkeleton是如何生成骨架屏程式碼
入口程式碼在page-skeleton-webpack-plugin/src/skeleton.js
  1. 通過page.addScriptTag向puppeteer注入指令碼並初始化,指令碼路徑在page-skeleton-webpack-plugin/src/script/index.js。
  2. 執行genSkeleton方法生成骨架屏

async makeSkeleton(page) {
    const {defer} = this.options
    // 把生成骨架屏程式碼注入puppeteer同時執行初始化
    await page.addScriptTag({content: this.scriptContent})
    // 延遲邏輯,用於等待某些非同步操作,圖1我已經使用waitForSelector,所以這個可以不用管
    await sleep(defer)
    // 執行genSkeleton方法
    await page.evaluate((options) => {
      Skeleton.genSkeleton(options)
    }, this.options)
  }
複製程式碼

初始化核心邏輯:
  • 初始化引數說明:

const pluginDefaultConfig = {
    port: '8989',
    // 該配置物件可以配置一個 color 欄位,用於決定骨架頁面中文字塊的的顏色,顏色值支援16進位制、RGB等。
    text: {
        color: '#EEEEEE'
    },
    // 該配置接受 3 個欄位,color、shape、shapeOpposite。color 和 shape 用於確定骨架頁面中圖片塊的顏色和形狀,
    // 顏色值支援16 進位制和 RGB等,形狀支援兩個列舉值,circle (矩形)和 rect(圓形)。
    // shapeOpposite 欄位接受一個陣列,陣列中每個元素是一個 DOM 選擇器,用於選擇 DOM 元素,
    // 被選擇 DOM 的形狀將和配置的 shape 形狀相反,例如,配置的是 rect那麼,
    // shapeOpposite 中的圖片塊將在骨架頁面中顯示成 circle 形狀(圓形),具體怎麼配置可以參考該部分末尾的預設配置。
    image: {
        shape: 'rect', // `rect` | `circle`
        color: '#EFEFEF',
        shapeOpposite: []
    },
    // 該配置接受兩個欄位,color 和 excludes。color 用來確定骨架頁面中被視為按鈕塊的顏色,
    // excludes 接受一個陣列,陣列中元素是 DOM 選擇器,用來選擇元素,該陣列中的元素將不被視為按鈕塊
    button: {
        color: '#EFEFEF',
        excludes: []
    },
    // 該配置接受 3 個欄位,color、shape、shapeOpposite。color 和 shape 用於確定骨架頁面中 svg 塊的顏色和形狀,
    // 顏色值支援16 進位制和 RGB等,同時也支援 transparent 列舉值,設定為 transparent 後,
    // svg 塊將是透明塊。形狀支援兩個列舉值,circle (矩形)和 rect(圓形)。
    // shapeOpposite 欄位接受一個陣列,陣列中每個元素是一個 DOM 選擇器,用於選擇 DOM 元素,
    // 被選擇 DOM 的形狀將和配置的 shape 形狀相反,例如,配置的是 rect那麼,
    // shapeOpposite 中的 svg 塊將在骨架頁面中顯示成 circle 形狀(圓形),具體怎麼配置可以參考該部分末尾的預設配置。
    svg: {
        color: '#EFEFEF',
        shape: 'circle', // circle | rect
        shapeOpposite: []
    },
    // 該配置接受兩個欄位,color 和 shape。color 用來確定骨架頁面中被視為偽元素塊的顏色,
    // shape 用來設定偽元素塊的形狀,接受兩個列舉值:circle 和 rect。
    pseudo: {
        color: '#EFEFEF', // or transparent
        shape: 'circle' // circle | rect
    },
    device: 'iPhone 6',
    debug: false,
    minify: {
        minifyCSS: { level: 2 },
        removeComments: true,
        removeAttributeQuotes: true,
        removeEmptyAttributes: false
    },
    defer: 5000,
    // 如果你有不需要進行骨架處理的元素,那麼將該元素的 CSS 選擇器寫入該陣列。
    excludes: [],
    // 不需要生成頁面骨架,且需要從 DOM 中移除的元素,配置值為移除元素的 CSS 選擇器。
    remove: [],
    // 不需要移除,但是通過設定其透明度為 0,來隱藏該元素,配置值為隱藏元素的 CSS 選擇器。
    hide: [],
    // 該陣列中元素是 CSS 選擇器,被選擇的元素將被被外掛處理成一個色塊,色塊的顏色和按鈕塊顏色一致。內部元素將不再做特殊處理,文字將隱藏。
    grayBlock: [],
    cookies: [],
    // 其接受的列舉值rem, vw, vh, vmin, vmax。
    cssUnit: 'rem',
    // 生成骨架頁面(shell.html)中 css 值保留的小數位數,預設值是 4。
    decimal: 4,
    logLevel: 'info',
    quiet: false,
    noInfo: false,
    logTime: true
};
複製程式碼

  • 遞迴遍歷DOM樹,將DOM分類成文字塊、按鈕塊、圖片塊、SVG塊、偽類元素塊等。

// ele 為 document.documentElement; 遞迴遍歷DOM樹
;(function preTraverse(ele) {
  // styles為元素中所有可用的css屬性列表
  const styles = getComputedStyle(ele);
  // 檢查元素是否有偽元素
  const hasPseudoEle = checkHasPseudoEle(ele);

  // 判斷元素是否在可視區域內(是否是首屏元素),非首屏元素將要移除
  if (!inViewPort(ele) || DISPLAY_NONE.test(ele.getAttribute('style'))) {
	return toRemove.push(ele)
  }

  // 自定義要處理為色塊的元素
  if (~grayEle.indexOf(ele)) { // eslint-disable-line no-bitwise
	return grayBlocks.push(ele)
  }

  // 自定義不需要處理為骨架的元素
  if (~excludesEle.indexOf(ele)) return false // eslint-disable-line no-bitwise

  if (hasPseudoEle) {
	pseudos.push(hasPseudoEle);
  }

  if (checkHasBorder(styles)) {
	ele.style.border = 'none';
  }

  // 列表元素統一處理為預設樣式
  if (ele.children.length > 0 && /UL|OL/.test(ele.tagName)) {
	listHandle(ele);
  }

  // 有子節點遍歷處理
  if (ele.children && ele.children.length > 0) {
	Array.from(ele.children).forEach(child => preTraverse(child));
  }

  // 將所有擁有 textChildNode 子元素的元素的文字顏色設定成背景色,這樣就不會在顯示文字了。
  if (ele.childNodes && Array.from(ele.childNodes).some(n => n.nodeType === Node.TEXT_NODE)) {
	transparent(ele);
  }

  // 統一文字下劃線的顏色
  if (checkHasTextDecoration(styles)) {
	ele.style.textDecorationColor = TRANSPARENT;
  }
  // 隱藏所有 svg 元素
  if (ele.tagName === 'svg') {
	return svgs.push(ele)
  }

  // 有背景色或背景圖的元素
  if (EXT_REG.test(styles.background) || EXT_REG.test(styles.backgroundImage)) {
	return hasImageBackEles.push(ele)
  }
  // 背景漸變元素
  if (GRADIENT_REG.test(styles.background) || GRADIENT_REG.test(styles.backgroundImage)) {
	return gradientBackEles.push(ele)
  }
  if (ele.tagName === 'IMG' || isBase64Img(ele)) {
	return imgs.push(ele)
  }
  if (
	ele.nodeType === Node.ELEMENT_NODE &&
	(ele.tagName === 'BUTTON' || (ele.tagName === 'A' && ele.getAttribute('role') === 'button'))
  ) {
	return buttons.push(ele)
  }
  if (
	ele.childNodes &&
	ele.childNodes.length === 1 &&
	ele.childNodes[0].nodeType === Node.TEXT_NODE &&
	/\S/.test(ele.childNodes[0].textContent)
  ) {
	return texts.push(ele)
  }
}(rootElement));
複製程式碼

  • 將分類好的文字塊、圖片塊等處理生成骨架結構程式碼

svgs.forEach(e => svgHandler(e, svg, cssUnit, decimal));
texts.forEach(e => {
    textHandler(e, text, cssUnit, decimal)
});
buttons.forEach(e => buttonHandler(e, button));
hasImageBackEles.forEach(e => backgroundHandler(e, image));
imgs.forEach(e => imgHandler(e, image));
pseudos.forEach(e => pseudosHandler(e, pseudo));
gradientBackEles.forEach(e => backgroundHandler(e, image));
grayBlocks.forEach(e => grayHandler(e, button));
複製程式碼

具體各塊的骨架結構如何生成的接下來會一一分析

1、SVG塊生成骨架結構

  • 判斷svg元素是否不可見,不可見則直接刪除元素

// 寬高為0或設定隱藏的元素直接移除(aria是為殘疾人士等提供無障礙訪問動態、可互動Web內容的技術規範)
if (width === 0 || height === 0 || ele.getAttribute('aria-hidden') === 'true') {
   return removeElement(ele)
}
複製程式碼

非隱藏的元素,會把 svg 元素內部所有元素刪除,減少最終生成的骨架頁面體積,其次,設定svg 元素的寬、高和形狀等。

// 設定shapeOpposite的元素的最終形狀和shape配置的相反
const finalShape = shapeOpposite.indexOf(ele) > -1 ? getOppositeShape(shape) : shape;

// 清空元素的內部結構  innerHTML = ''
emptyElement(ele);

const shapeClassName = CLASS_NAME_PREFEX + shape;
// 根據rect or cirle設定border-radius屬性,同時set到styleCache
shapeStyle(shape);

Object.assign(ele.style, {
  width: px2relativeUtil(width, cssUnit, decimal),
  height: px2relativeUtil(height, cssUnit, decimal),
});

addClassName(ele, [shapeClassName]);

// color是自定義svg配置中的color屬性,可設定16進位制設定及transparent列舉值
if (color === TRANSPARENT) {
  // 設定為透明塊
  setOpacity(ele);
} else {
  // 設定背景色
  const className = CLASS_NAME_PREFEX + 'svg';
  const rule = `{
  background: ${color} !important;
}`;
  addStyle(`.${className}`, rule);
  ele.classList.add(className);
}
複製程式碼

2、按鈕塊生成骨架結構

button塊的處理相對比較簡單,去除邊框和陰影,設定好統一的背景色和文字,按鈕塊就處理完成了。

function buttonHandler(ele, {color, excludes}) {
    if (excludes.indexOf(ele) > -1) return false
    const classname = CLASS_NAME_PREFEX + 'button';
    const rule = `{
        color: ${color} !important;
        background: ${color} !important;
        border: none !important;
        box-shadow: none !important;
    }`;
    addStyle(`.${classname}`, rule);
    ele.classList.add(classname);
}
複製程式碼

 3、背景塊生成骨架結構

背景塊指有背景圖或者背景色的元素。統一設定背景色即可。

function backgroundHandler(ele, {color, shape}) {
    const imageClass = CLASS_NAME_PREFEX + 'image';
    const shapeClass = CLASS_NAME_PREFEX + shape;
    const rule = `{
        background: ${color} !important;
    }`;

    addStyle(`.${imageClass}`, rule);

    shapeStyle(shape);

    addClassName(ele, [imageClass, shapeClass]);
}
複製程式碼

4、圖片塊生成骨架結構

  • 設定元素寬高、1*1畫素透明gif圖的base64編碼值填充圖片
  • 設定背景色、形狀
  • 去除無用屬性(alt)

function imgHandler(ele, {color, shape, shapeOpposite}) {
    const {width, height} = ele.getBoundingClientRect();
    const attrs = {
        width,
        height,
        src: SMALLEST_BASE64 // 1*1畫素透明gif圖
    };

    const finalShape = shapeOpposite.indexOf(ele) > -1 ? getOppositeShape(shape) : shape;

    setAttributes(ele, attrs);

    const className = CLASS_NAME_PREFEX + 'image';
    const shapeName = CLASS_NAME_PREFEX + finalShape;
    const rule = `{
    background: ${color} !important;
    }`;
    addStyle(`.${className}`, rule);
    shapeStyle(finalShape);

    addClassName(ele, [className, shapeName]);

    if (ele.hasAttribute('alt')) {
        ele.removeAttribute('alt');
    }
}
複製程式碼

5、偽元素塊處理骨架結構

  • 偽元素::before和::after去除背景圖、統一為透明背景色
  • 設定形狀(矩形or圓角)

function pseudosHandler({ele, hasBefore, hasAfter}, {color, shape, shapeOpposite}) {
    if (!shapeOpposite) shapeOpposite = []
    const finalShape = shapeOpposite.indexOf(ele) > -1 ? getOppositeShape(shape) : shape;
    const PSEUDO_CLASS = `${CLASS_NAME_PREFEX}pseudo`;
    const PSEUDO_RECT_CLASS = `${CLASS_NAME_PREFEX}pseudo-rect`;
    const PSEUDO_CIRCLE_CLASS = `${CLASS_NAME_PREFEX}pseudo-circle`;

    const rules = {
        [`.${PSEUDO_CLASS}::before, .${PSEUDO_CLASS}::after`]: `{
      background: ${color} !important;
      background-image: none !important;
      color: transparent !important;
      border-color: transparent !important;
    }`,
        [`.${PSEUDO_RECT_CLASS}::before, .${PSEUDO_RECT_CLASS}::after`]: `{
      border-radius: 0 !important;
    }`,
        [`.${PSEUDO_CIRCLE_CLASS}::before, .${PSEUDO_CIRCLE_CLASS}::after`]: `{
      border-radius: 50% !important;
    }`
    };

    Object.keys(rules).forEach(key => {
        addStyle(key, rules[key]);
    });

    addClassName(ele, [PSEUDO_CLASS, finalShape === 'circle' ? PSEUDO_CIRCLE_CLASS : PSEUDO_RECT_CLASS]);
}
複製程式碼

6、文字塊處理骨架結構

文字塊相對處理起來會比較複雜些,所以放到最後來講。
文字塊定義:任何包含文字節點的元素都是文字塊。
計算文字塊的文字行數、文字高度(即要繪製的文字塊高度=fontSize):
  • 計算文字行數 ( 元素高度 - 上下padding ) / 行高
  • 計算文字高度比 = 字型高度/行高(預設1 / 1.4)

// 文字行數 =( 高度 - 上下padding ) / 行高
const lineCount = (height - parseFloat(paddingTop, 10) - parseFloat(paddingBottom, 10)) / parseFloat(lineHeight, 10) | 0; // eslint-disable-line no-bitwise

// 文字高度比 = 字型高度/行高
let textHeightRatio = parseFloat(fontSize, 10) / parseFloat(lineHeight, 10);
if (Number.isNaN(textHeightRatio)) {
  textHeightRatio = 1 / 1.4; // default number
}
複製程式碼

 通過線性漸變生成條紋背景的文字塊:

const firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(decimal);
const secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(decimal);
const backgroundSize = `100% ${px2relativeUtil(lineHeight, cssUnit, decimal)}`;
const className = CLASS_NAME_PREFEX + 'text-' + firstColorPoint.toString(32).replace(/\./g, '-');

const rule = `{
    background-image: linear-gradient(transparent ${firstColorPoint}%, ${color} 0%, ${color} ${secondColorPoint}%, transparent 0%) !important;
    background-size: ${backgroundSize};
    position: ${position} !important;
}`;
複製程式碼

單行文字需要計算文字寬度和text-aligin屬性

const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10));
	ele.style.backgroundSize = `${(textWidthPercent > 1 ? 1 : textWidthPercent) * 100}% ${px2relativeUtil(lineHeight, cssUnit, decimal)}`;
	switch (textAlign) {
	case 'left': // do nothing
		break
	case 'center':
		  ele.style.backgroundPositionX = '50%';
		  break
	case 'right':
		  ele.style.backgroundPositionX = '100%';
		  break
}
複製程式碼

以上就是elementUI開源的骨架屏外掛的主要邏輯啦。當然還有涉及工程化相關的邏輯這裡就沒貼出來了,後續可以再慢慢探討。

我抽空把生成骨架屏的邏輯單獨抽出來,方便大家定製對骨架屏的工程化處理及除錯

github.com/wookaoer/pa…


參考文章:


相關文章