早在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
參考資料: