自動化生成骨架屏的技術方案設計與落地
個人文章集:https://github.com/Nealyang/PersonalBlog
主筆公衆號:全棧前端精選
背景
性能優化,減少頁面加載等待時間一直是前端領域永恆的話題。如今大部分業務合作模式都是前後端分離方案,便利性的同時也帶來了非常多的弊端,比如 FCP 時間顯著增加(多了更多的 HTTP 請求往返的時間消耗),這也就造成了我們所說的白屏時間較長,用戶體驗較差的情況。
當然,對此我們可以有很多種優化手段,即便是此文介紹的骨架屏也只是用戶體驗的優化而已,對性能優化的數據沒有任何提升,但是其必要性,依然是不言而喻的。
本文主要介紹應用在拍賣源碼工作臺 BeeMa 架構中的骨架屏自動生成方案。有一定的定製型,但是基本原理是相通的。
骨架屏 Skeleton
骨架屏其實就是在頁面加載內容之前,先給用戶展示出頁面的大致結構,再等拿到接口數據後在將內容替換,較傳統的菊花 loading
效果會給用戶一種 “已經渲染一部分出來了” 的錯覺,在效果上可以一定程度的提升用戶體驗。本質上就是視覺過渡的一個效果,以此來降低用戶在等待時候的焦灼情緒。
方案調研
骨架屏技術方案上從實現上來說大致可以三類:
-
手動維護骨架屏的代碼(
HTML
、css or vue
、React
) -
使用圖片作爲骨架屏
-
自動生成骨架屏
對於前兩種方案有一定的維護成本比較費人力,這裏主要介紹下自動生成骨架屏的方案。
目前市面上主要使用的是餓了麼開源的 webpack
插件:page-skeleton-webpack-plugin
。它根據項目中不同的路由頁面生成相應的骨架屏頁面,並將骨架屏頁面通過 webpack
打包到對應的靜態路由頁面中。這種方式將骨架屏代碼與業務代碼隔離,通過 webpack
注入的方式骨架屏代碼(圖片)注入到項目中。優勢非常明顯但是缺點也顯而易見:webpack
配置成本(還依賴html-webpack-plugin
)。
技術方案
綜合如上的技術調研,我們還是決定採用最低侵入業務代碼且降低配置成本的骨架屏自動生成的方案。參考餓了麼的設計思路,基於 BeeMa
架構和vscode
插件來實現一個新的骨架屏生成方案。
設計原則
參考目前使用骨架屏的業務團隊,我們首先要明確下我們的骨架屏需要具有的一些原則:
-
骨架屏基於
BeeMa
架構 -
自動生成
-
維護成本低
-
可配置
-
還原度高(適配能力強)
-
性能影響低
-
支持用戶二次修訂
基於如上原則和 beema 架構 vscode 插件的特性,如下使我們最終的技術方案設計:
-
基於 BeeMa framework 插件,提供骨架屏生成配置界面
-
選擇基於
BeeMa
架構的頁面,支持 SkeletonScreen height、ignoreHeight/width
、通用頭和背景色保留等 -
基於
Puppeteer
獲取預發頁面(支持登陸) -
功能封裝到
BeeMa Framework
:https://marketplace.visualstudio.com/items?itemName=nealyang.devworks-beema 插件中 -
骨架屏只吐出
HTML
結構,樣式基於用戶自動以的CSSInModel
的樣式 -
骨架屏樣式,沉澱到項目
global.scss
中,避免行內樣式重複體積增大
流程圖
技術細節
校驗 Puppeteer、
/**
* 檢查本地 puppeteer
* @param localPath 本地路徑
*/
export const checkLocalPuppeteer = (localPath: string): Promise<string> => {
const extensionPuppeteerDir = 'mac-901912';
return new Promise(async (resolve, reject) => {
try {
// /puppeteer/.local-chromium
if (fse.existsSync(path.join(localPath, extensionPuppeteerDir))) {
// 本地存在 mac-901912
console.log('插件內存在 chromium');
resolve(localPath);
} else {
// 本地不存在,找全局 node 中的 node_modules
nodeExec('tnpm config get prefix', function (error, stdout) {
// /Users/nealyang/.nvm/versions/node/v16.3.0
if (stdout) {
console.log('globalNpmPath:', stdout);
stdout = stdout.replace(/[\r\n]/g, '').trim();
let localPuppeteerNpmPath = '';
if (fse.existsSync(path.join(stdout, 'node_modules', 'puppeteer'))) {
// 未使用nvm,則全局包就在 prefix 下的 node_modules 內
localPuppeteerNpmPath = path.join(stdout, 'node_modules', 'puppeteer');
}
if (fse.existsSync(path.join(stdout, 'lib', 'node_modules', 'puppeteer'))) {
// 使用nvm,則全局包就在 prefix 下的lib 下的 node_modules 內
localPuppeteerNpmPath = path.join(stdout, 'lib', 'node_modules', 'puppeteer');
}
if (localPuppeteerNpmPath) {
const globalPuppeteerPath = path.join(localPuppeteerNpmPath, '.local-chromium');
if (fse.existsSync(globalPuppeteerPath)) {
console.log('本地 puppeteer 查找成功!');
fse.copySync(globalPuppeteerPath, localPath);
resolve(localPuppeteerNpmPath);
} else {
resolve('');
}
} else {
resolve('');
}
} else {
resolve('');
return;
}
});
}
} catch (error: any) {
showErrorMsg(error);
resolve('');
}
});
};
webView 打開後,立即校驗本地 Puppeteer
useEffect(() => {
(async () => {
const localPuppeteerPath = await callService('skeleton', 'checkLocalPuppeteerPath');
if(localPuppeteerPath){
setState("success");
setValue(localPuppeteerPath);
}else{
setState('error')
}
})();
}, []);
❝
「Puppeteer 安裝到項目內,webpack 打包並不會處理 Chromium 的二進制文件,可以將 Chromium copy 到 vscode extension 的 build 中。」
「但是!!!導致 build 過大,下載插件會超時!!!所以只能考慮將 Puppeteer 要求在用戶本地全局安裝。」
❞
puppeteer
/**
* 獲取骨架屏 HTML 內容
* @param pageUrl 需要生成骨架屏的頁面 url
* @param cookies 登陸所需的 cookies
* @param skeletonHeight 所需骨架屏最大高度(高度越大,生成的骨架屏 HTML 大小越大)
* @param ignoreHeight 忽略元素的最大高度(高度低於此則從骨架屏中刪除)
* @param ignoreWidth 忽略元素的最大寬度(寬度低於此則從骨架屏中刪除)
* @param rootSelectId beema 架構中 renderID,默認爲 root
* @param context vscode Extension context
* @param progress 進度實例
* @param totalProgress 總進度佔比
* @returns
*/
export const genSkeletonHtmlContent = (
pageUrl: string,
cookies: string = '[]',
skeletonHeight: number = 800,
ignoreHeight: number = 10,
ignoreWidth: number = 10,
rootId: string = 'root',
retainNav: boolean,
retainGradient: boolean,
context: vscode.ExtensionContext,
progress: vscode.Progress<{
message?: string | undefined;
increment?: number | undefined;
}>,
totalProgress: number = 30,
): Promise<string> => {
const reportProgress = (percent: number, message = '骨架屏 HTML 生成中') => {
progress.report({ increment: percent * totalProgress, message });
};
return new Promise(async (resolve, reject) => {
try {
let content = '';
let url = pageUrl;
if (skeletonHeight) {
url = addParameterToURL(`skeletonHeight=${skeletonHeight}`, url);
}
if (ignoreHeight) {
url = addParameterToURL(`ignoreHeight=${ignoreHeight}`, url);
}
if (ignoreWidth) {
url = addParameterToURL(`ignoreWidth=${ignoreWidth}`, url);
}
if (rootId) {
url = addParameterToURL(`rootId=${rootId}`, url);
}
if (isTrue(retainGradient)) {
url = addParameterToURL(`retainGradient=${'true'}`, url);
}
if (isTrue(retainNav)) {
url = addParameterToURL(`retainNav=${'true'}`, url);
}
const extensionPath = (context as vscode.ExtensionContext).extensionPath;
const jsPath = path.join(extensionPath, 'dist', 'skeleton.js');
const browser = await puppeteer.launch({
headless: true,
executablePath: path.join(
extensionPath,
'/mac-901912/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
),
// /Users/nealyang/Documents/code/work/beeDev/dev-works/extensions/devworks-beema/node_modules/puppeteer/.local-chromium/mac-901912/chrome-mac/Chromium.app/Contents/MacOS/Chromium
});
const page = await browser.newPage();
reportProgress(0.2, '啓動BeeMa內置瀏覽器');
page.on('console', (msg: any) => console.log('PAGE LOG:', msg.text()));
page.on('error', (msg: any) => console.log('PAGE ERR:', ...msg.args));
await page.emulate(iPhone);
if (cookies && Array.isArray(JSON.parse(cookies))) {
await page.setCookie(...JSON.parse(cookies));
reportProgress(0.4, '注入 cookies');
}
await page.goto(url, { waitUntil: 'networkidle2' });
reportProgress(0.5, '打開對應頁面');
await sleep(2300);
if (fse.existsSync(jsPath)) {
const jsContent = fse.readFileSync(jsPath, { encoding: 'utf-8' });
progress.report({ increment: 50, message: '注入內置JavaScript腳本' });
await page.addScriptTag({ content: jsContent });
}
content = await page.content();
content = content.replace(/<!---->/g, '');
// fse.writeFileSync('/Users/nealyang/Documents/code/work/beeDev/dev-works/extensions/devworks-beema/src/index.html', content, { encoding: 'utf-8' })
reportProgress(0.9, '獲取頁面 HTML 架構');
await browser.close();
resolve(getBodyContent(content));
} catch (error: any) {
showErrorMsg(error);
}
});
};
❝
vscode 中的配置,需要寫入到即將注入到 Chromium 中 p
age 加載的 js 中,這裏採用的方案是將配置信息寫入到要打開頁面的 url 的查詢參數中
❞
webView & vscode 通信(配置)
詳見基於 monorepo 的 vscode 插件及其相關 packages 開發架構實踐總結
vscode
export default (context: vscode.ExtensionContext) => () => {
const { extensionPath } = context;
let pageHelperPanel: vscode.WebviewPanel | undefined;
const columnToShowIn = vscode.window.activeTextEditord
? vscode.window.activeTextEditor.viewColumn
: undefined;
if (pageHelperPanel) {
pageHelperPanel.reveal(columnToShowIn);
} else {
pageHelperPanel = vscode.window.createWebviewPanel(
'BeeDev',
'骨架屏',
columnToShowIn || vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
},
);
}
pageHelperPanel.webview.html = getHtmlFroWebview(extensionPath, 'skeleton', false);
pageHelperPanel.iconPath = vscode.Uri.parse(DEV_WORKS_ICON);
pageHelperPanel.onDidDispose(
() => {
pageHelperPanel = undefined;
},
null,
context.subscriptions,
);
connectService(pageHelperPanel, context, { services });
};
connectSeervice
export function connectService(
webviewPanel: vscode.WebviewPanel,
context: vscode.ExtensionContext,
options: IConnectServiceOptions,
) {
const { subscriptions } = context;
const { webview } = webviewPanel;
const { services } = options;
webview.onDidReceiveMessage(
async (message: IMessage) => {
const { service, method, eventId, args } = message;
const api = services && services[service] && services[service][method];
console.log('onDidReceiveMessage', message, { api });
if (api) {
try {
const fillApiArgLength = api.length - args.length;
const newArgs =
fillApiArgLength > 0 ? args.concat(Array(fillApiArgLength).fill(undefined)) : args;
const result = await api(...newArgs, context, webviewPanel);
console.log('invoke service result', result);
webview.postMessage({ eventId, result });
} catch (err) {
console.error('invoke service error', err);
webview.postMessage({ eventId, errorMessage: err.message });
}
} else {
vscode.window.showErrorMessage(`invalid command ${message}`);
}
},
undefined,
subscriptions,
);
}
Webview 中調用 callService
// @ts-ignore
export const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : null;
export const callService = function (service: string, method: string, ...args) {
return new Promise((resolve, reject) => {
const eventId = setTimeout(() => {});
console.log(`WebView call vscode extension service:${service} ${method} ${eventId} ${args}`);
const handler = (event) => {
const msg = event.data;
console.log(`webview receive vscode message:}`, msg);
if (msg.eventId === eventId) {
window.removeEventListener('message', handler);
msg.errorMessage ? reject(new Error(msg.errorMessage)) : resolve(msg.result);
}
};
// webview 接受 vscode 發來的消息
window.addEventListener('message', handler);
// WebView 向 vscode 發送消息
vscode.postMessage({
service,
method,
eventId,
args,
});
});
};
const localPuppeteerPath = await callService('skeleton', 'checkLocalPuppeteerPath');
launchJs
本地 js 通過 rollup 打包
src
rollupConfig
export default {
input: 'src/skeleton/scripts/index.js',
output: {
file: 'dist/skeleton.js',
format: 'iife',
},
};
文本處理
❝
這裏我們統一將行內元素作爲文本處理方式
❞
import { addClass } from '../util';
import { SKELETON_TEXT_CLASS } from '../constants';
export default function (node) {
let { lineHeight, fontSize } = getComputedStyle(node);
if (lineHeight === 'normal') {
lineHeight = parseFloat(fontSize) * 1.5;
lineHeight = isNaN(lineHeight) ? '18px' : `${lineHeight}px`;
}
node.style.lineHeight = lineHeight;
node.style.backgroundSize = `${lineHeight} ${lineHeight}`;
addClass(node, SKELETON_TEXT_CLASS);
}
SKELETON_TEXT_CLASS
的樣式作爲 beema 架構中的 global.scss 中。
const SKELETON_SCSS = `
// beema skeleton
.beema-skeleton-text-class {
background-color: transparent !important;
color: transparent !important;
background-image: linear-gradient(transparent 20%, #e2e2e280 20%, #e2e2e280 80%, transparent 0%) !important;
}
.beema-skeleton-pseudo::before,
.beema-skeleton-pseudo::after {
background: #f7f7f7 !important;
background-image: none !important;
color: transparent !important;
border-color: transparent !important;
border-radius: 0 !important;
}
`;
/**
*
* @param proPath 項目路徑
*/
export const addSkeletonSCSS = (proPath: string) => {
const globalScssPath = path.join(proPath, 'src', 'global.scss');
if (fse.existsSync(globalScssPath)) {
let fileContent = fse.readFileSync(globalScssPath, { encoding: 'utf-8' });
if (fileContent.indexOf('beema-skeleton') === -1) {
// 本地沒有骨架屏的樣式
fileContent += SKELETON_SCSS;
fse.writeFileSync(globalScssPath, fileContent, { encoding: 'utf-8' });
}
}
};
如果 global.scss
中沒有相應骨架屏的樣式 class,則自動注入進去
「這是因爲如果作爲行內元素的話,生成的骨架屏代碼會比較大,重複代碼多,這裏是爲了提及優化做的事情」
圖片處理
import { MAIN_COLOR, SMALLEST_BASE64 } from '../constants';
import { setAttributes } from '../util';
function imgHandler(node) {
const { width, height } = node.getBoundingClientRect();
setAttributes(node, {
width,
height,
src: SMALLEST_BASE64,
});
node.style.backgroundColor = MAIN_COLOR;
}
export default imgHandler;
export const SMALLEST_BASE64 =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
超鏈接處理
function aHandler(node) {
node.href = 'javascript:void(0);';
}
export default aHandler;
僞元素處理
// Check the element pseudo-class to return the corresponding element and width
export const checkHasPseudoEle = (ele) => {
if (!ele) return false;
const beforeComputedStyle = getComputedStyle(ele, '::before');
const beforeContent = beforeComputedStyle.getPropertyValue('content');
const beforeWidth = parseFloat(beforeComputedStyle.getPropertyValue('width'), 10) || 0;
const hasBefore = beforeContent && beforeContent !== 'none';
const afterComputedStyle = getComputedStyle(ele, '::after');
const afterContent = afterComputedStyle.getPropertyValue('content');
const afterWidth = parseFloat(afterComputedStyle.getPropertyValue('width'), 10) || 0;
const hasAfter = afterContent && afterContent !== 'none';
const width = Math.max(beforeWidth, afterWidth);
if (hasBefore || hasAfter) {
return { hasBefore, hasAfter, ele, width };
}
return false;
};
import { checkHasPseudoEle, addClass } from '../util';
import { PSEUDO_CLASS } from '../constants';
function pseudoHandler(node) {
if (!node.tagName) return;
const pseudo = checkHasPseudoEle(node);
if (!pseudo || !pseudo.ele) return;
const { ele } = pseudo;
addClass(ele, PSEUDO_CLASS);
}
export default pseudoHandler;
❝
僞元素的樣式代碼已經在上面 global.scss 中展示了
❞
通用處理
// 移除不需要的元素
Array.from($$(REMOVE_TAGS.join(','))).forEach((ele) => removeElement(ele));
// 移除容器外的所有 dom
Array.from(document.body.childNodes).map((node) => {
if (node.id !== ROOT_SELECTOR_ID) {
removeElement(node);
}
});
// 移除容器內非模塊 element
Array.from($$(`#${ROOT_SELECTOR_ID} .contentWrap`)).map((node) => {
Array.from(node.childNodes).map((comp) => {
if (comp.classList && Array.from(comp.classList).includes('compContainer')) {
// 模塊設置白色背景色
comp.style.setProperty('background', '#fff', 'important');
} else if (
comp.classList &&
Array.from(comp.classList).includes('headContainer') &&
RETAIN_NAV
) {
console.log('保留通用頭');
} else if (
comp.classList &&
Array.from(comp.classList).join().includes('gradient-bg') &&
RETAIN_GRADIENT
) {
console.log('保留了漸變背景色');
} else {
removeElement(comp);
}
});
});
// 移除屏幕外的node
let totalHeight = 0;
Array.from($$(`#${ROOT_SELECTOR_ID} .compContainer`)).map((node) => {
const { height } = getComputedStyle(node);
console.log(totalHeight);
if (totalHeight > DEVICE_HEIGHT) {
// DEVICE_HEIGHT 高度以後的node全部刪除
console.log(totalHeight);
removeElement(node);
}
totalHeight += parseFloat(height);
});
// 移除 ignore 元素
Array.from($$(`.${IGNORE_CLASS_NAME}`)).map(removeElement);
❝
這裏有個計算屏幕外的 node,也就是通過用戶自定義的最大高度,取到 BeeMa 中每一個模塊的高度,然後相加計算,如果超過這個高度,則後續的模塊直接 remove 掉,一次來減少生成出的 HTML 代碼的大小問題
❞
使用
基本使用
beema
約束
需全局安裝 「puppeteer@10.4.0 : tnpm i puppeteer@10.4.0 --g」
local Puppeteer
全局安裝後,插件會自動查找本地的 puppeteer
路徑,如果找到插件,則進行 copy 到插件內的過程,否則需要用戶自己手動填寫路徑puppeteer
地址。(一旦查找成功後,後續則無需填寫地址,全局 puppeteer
包也可刪除)
目前僅支持 beema 架構源碼開發
VSCode 插件
注意⚠️
如果生成出來的代碼片段較大,如下兩種**「優化方案」**
「1、減少骨架屏的高度(配置界面中最大高度)」
「2、在源碼開發中,對於首屏代碼但是非首屏展示的元素添加beema-skeleton-ignore
的類名(例如輪播圖的後面幾張圖甚至視頻)」
效果演示
普通效果
生成的代碼大小:
5.37kb
帶有通用頭和漸變背景色
❝
拍賣通用設計元素,在頁面新建空頁面配置中即可看到配置
❞
通用配置
效果如下:
帶頭部和背景色
6.93
複雜元素的頁面效果展示
默認全屏骨架屏
生成代碼大小
20kb
❝
未做
skeleton-ignore
侵入式優化,略大🥺❞
另一種優化手段是減小生成骨架屏的高度!
半屏骨架屏
半屏
❝
Fast 3G
和no throttling
的網絡情況下, 公衆號中 gif 幀數限制,只能放圖片展示效果了。❞
生成代碼大小
7kb
後續優化
-
增加通用頭樣式定製型
-
支持骨架屏樣式配置(顏色等)
-
減少生成代碼的提及大小
-
...
-
持續解決團隊內使用反饋
參考資料
-
page-skeleton-webpack-plugin:https://github.com/ElemeFE/page-skeleton-webpack-plugin
-
awesome-skeleton:https://github.com/kaola-fed/awesome-skeleton
-
Building Skeleton Screens with CSS Custom Properties:https://css-tricks.com/building-skeleton-screens-css-custom-properties/
-
Vue 頁面骨架屏注入實踐:https://segmentfault.com/a/1190000014832185?spm=ata.21736010.0.0.1273641fkJNOGV
-
BeeMa:https://marketplace.visualstudio.com/search?term=beema&target=VSCode&category=All%20categories&sortBy=Relevance
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/0yTb4ZjniaunxOOPcfs6UA