Collection of personal articles: Nealyang/PersonalBlog
The main author of the official account: full stack front-end selection
background
Performance optimization and reducing page load waiting time have always been an eternal topic in the front-end field. Nowadays, most of the business cooperation models are front-end and back-end separation solutions. The convenience also brings a lot of drawbacks. For example, the FCP time is significantly increased (more HTTP request round-trip time consumption), which also causes What we call a situation where the white screen time is longer and the user experience is poor.
Of course, we can have many optimization methods for this. Even the skeleton screen introduced in this article is only the optimization of the user experience, and there is no improvement in the performance optimization data, but its necessity is still self-evident.
This article mainly introduces the automatic skeleton screen generation scheme used in the BeeMa architecture of the auction source code workbench. There are certain customized models, but the basic principles are the same.
Skeleton screen
The skeleton screen actually shows the user the general structure of the page before loading the content on the page, and then replaces the content after getting the interface data. The more traditional chrysanthemum loading
effect will give the user a "part of it has been rendered". The illusion, in effect, can improve the user experience to a certain extent. In essence, it is an effect of visual transition to reduce the anxiety of the user while waiting.
Program research
In terms of implementation, the technical scheme of the skeleton screen can be roughly divided into three categories:
- Manually maintain the code of the skeleton screen (
HTML
,css or vue
,React
) - Use pictures as a skeleton screen
- Automatically generate skeleton screen
For the first two schemes, there is a certain maintenance cost and labor-intensive. Here we mainly introduce the scheme of automatically generating skeleton screens.
webpack
open source 061970c41eaeeb plug-in: page-skeleton-webpack-plugin
is mainly used on the market. It generates corresponding skeleton screen pages according to different routing pages in the project, and packs the skeleton screen pages into the corresponding static routing pages webpack
This method isolates the skeleton screen code from the business code, and injects the skeleton screen code (picture) into the project webpack
The advantages are obvious but the disadvantages are also obvious: webpack
configuration cost (also dependent on html-webpack-plugin
).
Technical solutions
Based on the above technical research, we still decided to adopt a scheme of automatic generation of skeleton screens with the lowest intrusion into business code and lower configuration costs. Refer to the design idea of BeeMa
, based on the 061970c41eaf56 architecture and the vscode
plug-in to implement a new skeleton screen generation scheme.
Design Principles
Referring to the current business team using skeleton screens, we must first clarify some principles that our skeleton screens need to have:
- The skeleton screen is based on the
BeeMa
architecture - Automatic generated
- Low maintenance cost
- Configurable
- High degree of reduction (strong adaptability)
- Low performance impact
- Support users for second revision
Based on the above principles and the characteristics of the beema architecture vscode plug-in, the following makes our final technical solution design:
- Based on BeeMa framework 1 plug-in, providing skeleton screen generation configuration interface
- Choose a page based on
BeeMa
architecture, support SkeletonScreen height,ignoreHeight/width
, general header and background color retention, etc. - Get pre-release page based on
Puppeteer
- The function is encapsulated in the BeeMa Framework plug-in
- The skeleton screen only spit out the
HTML
, and the style is based on the styleCSSInModel
- Skeleton screen style, settled into project
global.scss
, avoiding repeated volume increase of in-line style
flow chart
technical details
Verify 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('');
}
});
};
After webView is opened, verify the local Puppeteer immediately
useEffect(() => {
(async () => {
const localPuppeteerPath = await callService('skeleton', 'checkLocalPuppeteerPath');
if(localPuppeteerPath){
setState("success");
setValue(localPuppeteerPath);
}else{
setState('error')
}
})();
}, []);
Puppeteer is installed into the project. The webpack package does not process Chromium binary files. You can copy Chromium to the build of the vscode extension.
But! ! ! As a result, the build is too large and the download of the plug-in will time out! ! ! So you can only consider requiring Puppeteer to be installed locally and globally.
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);
}
});
};
The configuration in vscode needs to be written to be injected into Chromium.
In the js loaded by age, the solution used here is to write the configuration information into the query parameters of the url of the page to be opened
webView & vscode communication (configuration)
For details, see based vscode plug-in and related packages development architecture practice summary
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,
);
}
Call callService in Webview
// @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
Local js is packaged through rollup
rollupConfig
export default {
input: 'src/skeleton/scripts/index.js',
output: {
file: 'dist/skeleton.js',
format: 'iife',
},
};
Text processing
Here we uniformly treat inline elements as text processing methods
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
style as beema architecture global.scss in.
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' });
}
}
};
If global.scss
is no style class of the corresponding skeleton screen in 061970c41eb7fd, it will be injected into it automatically
This is because if it is used as an in-line element, the generated skeleton screen code will be relatively large, and the code will be repeated. This is to mention the optimization done
Picture processing
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';
Hyperlink handling
function aHandler(node) {
node.href = 'javascript:void(0);';
}
export default aHandler;
Pseudo-element processing
// 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;
The style code of the pseudo-element has been shown in global.scss above
General handling
// 移除不需要的元素
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);
Here is a calculation of the node outside the screen, that is, through the user-defined maximum height, the height of each module in BeeMa is taken, and then added together. If it exceeds this height, the subsequent modules will be directly removed and reduced at one time. The size of the generated HTML code
use
Basic use
constraint
Need to install globally: tnpm i puppeteer@10.4.0 --g
After the global installation, the plug-in will automatically search for the local puppeteer
path. If the plug-in is found, copy it to the plug-in process, otherwise the user needs to manually fill in the path puppeteer
address. (Once the search is successful, there is no need to fill in the address later, and the global puppeteer
package can also be deleted)
Currently only supports beema architecture source code development
Attention⚠️
If the code is generated out of the larger fragment, the following two optimization
1. Reduce the height of the skeleton screen (the maximum height in the configuration interface)
beema-skeleton-ignore
the elements that are displayed on the first screen but not the first screen code (for example, the next few pictures of the carousel and even the video)
Effect demonstration
Normal effect
Generated code size:
With universal header and gradient background color
General design elements of the auction, you can see the configuration in the new empty page configuration on the page
The effect is as follows:
Page effect display of complex elements
Default full screen skeleton screen
Generated code size
skeleton-ignore
not been invasively optimized, slightly larger 🥺
Another optimization method is to reduce the height of the generated skeleton screen!
Half screen skeleton screen
Fast 3G
case of 061970c41ec056 andno throttling
Generated code size
Follow-up optimization
- Add custom head styles
- Support skeleton screen style configuration (color, etc.)
- Reduce the mention size of generated code
- ...
- Continuously resolve usage feedback within the team
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。