Skeleton Screen(骨架屏)指的在頁面資料尚未加載前先給使用者展示出頁面的大緻結構,直到請求資料傳回後再渲染頁面,補充進需要顯示的資料内容。常用于内容清單頁。
一、page-skeleton-webpack-plugin
page-skeleton-webpack-plugin是一款由ElemeFE團隊開發的webpack 插件,該插件的目的是根據你項目中不同的路由頁面生成相應的骨架屏頁面,并将骨架屏頁面通過 webpack 打包到對應的靜态路由頁面中。
二、插件自動生成骨架屏的主要原理
- 通過無頭浏覽器puppeteer打開要生成骨架屏的頁面
- 等待頁面渲染完後注入提取骨架屏的腳本(注意:一定要等頁面完全渲染完,不然提取的DOM不完整)
- 對頁面中元素進行删減或增添,對已有元素通過層疊樣式進行覆寫,這樣達到在不改變頁面布局下,隐藏圖檔和文字,通過樣式覆寫,使得其展示為灰色塊。然後将修改後的 HTML 和 CSS 樣式提取出來生成骨架屏。
先demo展示一下如何自動生成骨架屏,後續再通過代碼具體分析如何生成骨架屏:
安裝運作環境
依賴環境:
- puppeteer
- nodejs v8.x
安裝puppeteer可參考:https://www.jianshu.com/p/a9a55c03f768
啟動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
- 通過page.addScriptTag向puppeteer注入腳本并初始化,腳本路徑在page-skeleton-webpack-plugin/src/script/index.js。
- 執行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-bitwisereturn 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到styleCacheshapeStyle(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 nothingbreakcase 'center': ele.style.backgroundPositionX = '50%'; breakcase 'right': ele.style.backgroundPositionX = '100%'; break}
以上就是elementUI開源的骨架屏插件的主要邏輯啦。當然還有涉及工程化相關的邏輯這裡就沒貼出來了,後續可以再慢慢探讨。
我抽空把生成骨架屏的邏輯單獨抽出來,友善大家定制對骨架屏的工程化處理及調試
https://github.com/wookaoer/page-skeleton-core