在圖檔與前端體驗優化中,最重要的莫過于「骨架屏」了,因為它和“首屏體驗”息息相關。
目前來說骨架屏基本上有兩種方式:
- HTML + CSS:主流。基本是自己在項目中以侵入式方式圍繞html“定制”;微信小程式的骨架屏生成方案本質上也是這種。
- 自動生成。利用一些手段在業務代碼之外生成骨架屏,但最終還要依托架構插入到業務中。
CSS實作骨架屏
在近期的業務中,我遇到了一個場景:

圖中紅色框内容在接口中分為三種級别。首先每個級别的活動都是固定的,後端隻傳回狀态值。是以前端是三個數組。
其次需要考慮一個問題:是預設展示第一級别,如果狀态發生改變,再切換到第二/三級别?還是預設空白,等到接口拿到資料後根據狀态展示級别?
需要明确的是,這個頁面并不隻有這一個接口。而且這個接口的“優先級”是低級别的。
後者效果展示:
不管是從視覺上還是我想采用的技術手段上,我都認為這個場景應該選擇前者 —— 這樣的話,骨架屏就有了“基準”。我就不需要采用額外的元素去實作,隻需要用僞元素覆寫預設文案并展示動效即可:
/** 給所有需要展示骨架的元素都添加這行代碼,變量預設為false,待接口拿到資料後變為true */
:class="{'cate-skeleton': !showPOSTData}"
.cate-skeleton {
position: relative;
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: lightgray;
background: linear-gradient(100deg, rgba(220, 220, 220, 0) 40%, rgba(255, 255, 255, 0.2) 50%, rgba(220, 220, 220, 0) 60%) gainsboro;
background-size: 200% 100%;
background-position-x: 150%;
animation: 1.5s loading ease-in-out infinite;
opacity: 1 !important;
z-index: 2;
}
}
@keyframes {
to {
background-position-x: -50%;
}
}
這段代碼中最重要的就是
linear-gradient
了。它其實就是将 background 分為三段。然後延長其
width
,并不斷改變位置
position-x
。
因為有了骨架屏,使用者就知道這段時間内頁面并不是什麼也沒做。體驗也就提升了。對我們來說,我們甚至可以把接口放到元件
created
裡面處理(筆者所在組已然按照筆者之前提的“大小元件原則”封裝業務代碼) —— 這裡還會有一個問題:如果網絡和接口實在給力,而你什麼也不做,可能會出現骨架閃動的效果。這可不是什麼小問題,它甚至比“從空白到資料突然展示”更加令人難受。
let res = await this.$http({
//參數
})
if(!res.data && !res.result) {
// 兜底
}
// 延時300ms,不然瞬間灰色閃動更難受了
await this.promiseTimeout(300);
為此,筆者決定故意延長loading的時間,給使用者更好的體驗:
async promiseTimeout (time) {
return await new Promise(function(resolve,reject){
setTimeout(function(){
console.log('骨架屏加載ing');
resolve(time);
},time);
});
},
微任務的異步和請求的異步不同(機制就不一樣)。setTimeout 不能直接觸發
setTimeout
async-await
node實作非侵入式骨架屏生成
在複雜場景下,我們可以把業務和骨架屏分離。比如在某種身份下其實進來是B布局,如果你在開發業務時采用第一種方案的話要麼骨架固定,要麼CV兩份 HTML 代碼去書寫樣式。
這好麼?這不好。
我們可以以頁面為基準“自動”生成骨架屏,然後通過配置注入到項目源碼中。
這樣就可以在頁面生成之後再去對指定
class/id
進行骨架樣式生成。對其餘元素可以采取定制化生成,或是直接隐藏。
這是一種“後處理”。
既如此,我們應當要求:
- 使用和維護成本低
- 配置靈活
- 還原度高
- 盡量不影響加載性能
node中的
puppeteer
給我們提供了很好的方案:通過 puppeteer 擷取頁面、做骨架處理、截屏或擷取源碼、預設采用 base64 輸出。
Puppeteer 是一個控制 headless Chrome 的 Node.js API 。它是一個 Node.js 庫,通過 DevTools 協定提供了一個進階的 API 來控制 headless Chrome。它還可以配置為使用完整的(非 headless)Chrome。
我們可以通過 puppeteer 操作網頁:觸發事件、截屏、爬取資料、檢索 SPA 并生成預渲染内容(即 “SSR”)、甚至是建立一個能運作最新js特性的自動測試環境(浏覽器)。
npm install puppeteer
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({width: 骨架屏寬, height: 骨架屏高});
// 事件監聽,可用于事件通信
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
page.on('warning', msg => console.log('PAGE WARN:', JSON.stringify(msg)));
page.on('error', msg => console.log('PAGE ERR:', ...msg.args));
// waitUntil:load/domcontentload/networkidle0/networkidle2
await page.goto('頁面的url!!!', {waitUntil: 'networkidle2'});
// 對打開的頁面進行操作
// 将頁面截圖,輸出為 pdf 或 圖檔
await page.pdf({path: 'hn.pdf', format: 'A4'});
await page.screenshot({path: 'example.png'});
await browser.close();
})();
這種方案簡化的并不是代碼層面 —— 當然,你也可以封裝成可視化。我們的處理思路和上面大緻相同 —— 因為不是操作原頁面,這裡直接替換即可。以 Img 為例:
Array.from(document.body.querySelectorAll('img')).map(img => {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
img.style.backgroundColor = '#EEEEEE';
});
對于文字來說,也是如此:
await page.$eval('.xxx/#xxx(按class/id查找)',
(el,)=> el.setAttribute('style', value),
'backgroundImage: linear-gradient(to bottom, #070b21, rgba(7, 11, 33, 0.5))'
)
或插入指定文案:
await page.$$eval('nav>ul>li>.wired-rendered',
nodes => nodes.map(n => {
n.innerHTML = `<span class="eval-puppeteer-bg" style="background-image: #EEEEEE">${n.innerHTML}</span>`
// return n;
}))
function inViewPort(ele) {
try {
const rect = ele.getBoundingClientRect()
return rect.top < window.innerHeight &&
rect.left < window.innerWidth
} catch (e) {
return true;
}
}
const styles = Array.from(document.querySelectorAll('style')).map(style => style.innerHTML || style.innerText);
// 移除非首屏樣式
function handleStyles(styles,) {
const ast = cssTree.parse(styles);
const dom = new JSDOM(html);
const document = dom.window.document;
const cleanedChildren = [];
let index = 0;
ast && ast.children && ast.children.map((style) => {
let slectorExisted = false,
selector;
switch (style.prelude && style.prelude.type) {
case 'Raw':
selector = style.prelude.value && style.prelude.value.replace(/,|\n/g, '');
slectorExisted = selectorExistedInHtml(selector, document);
break;
case 'SelectorList':
style.prelude.children && style.prelude.children.map(child => {
const children = child && child.children;
selector = getSelector(children);
if (selectorExistedInHtml(selector, document)) {
slectorExisted = true;
}
});
break;
}
if (slectorExisted) {
cleanedChildren.push(style);
}
});
ast.children = cleanedChildren;
let outputStyles = cssTree.generate(ast);
outputStyles = outputStyles.replace(/},+/g, '}');
return outputStyles;
}
function selectorExistedInHtml(selector,) {
if (!selector) {
return false;
}
// 查詢目前樣式在 html 中是否用到
let selectorResult, slectorExisted = false;
try {
selectorResult = document.querySelectorAll(selector);
} catch (e) {
console.log('selector query error: ' + selector);
}
if (selectorResult && selectorResult.length) {
slectorExisted = true;
}
return slectorExisted;
}