?
个人文章集:https://github.com/Nealyang/PersonalBlog
主笔公众号:全栈前端精选
?
背景
性能优化,减少页面加载等待时间一直是前端领域永恒的话题。如今大部分业务合作模式都是前后端分离方案,便利性的同时也带来了非常多的弊端,比如 FCP 时间显著增加(多了更多的 HTTP 请求往返的时间消耗),这也就造成了我们所说的白屏时间较长,用户体验较差的情况。
当然,对此我们可以有很多种优化手段,即便是此文介绍的骨架屏也只是用户体验的优化而已,对性能优化的数据没有任何提升,但是其必要性,依然是不言而喻的。
本文主要介绍应用在拍卖源码工作台BeeMa 架构中的骨架屏自动生成方案。有一定的定制型,但是基本原理是相通的。
骨架屏 Skeleton
Skeleton
骨架屏其实就是在页面加载内容之前,先给用户展示出页面的大致结构,再等拿到接口数据后在将内容替换,较传统的菊花 loading
效果会给用户一种“已经渲染一部分出来了”的错觉,在效果上可以一定程度的提升用户体验。本质上就是视觉过渡的一个效果,以此来降低用户在等待时候的焦灼情绪。
方案调研
骨架屏技术方案上从实现上来说大致可以三类:
对于前两种方案有一定的维护成本比较费人力,这里主要介绍下自动生成骨架屏的方案。
目前市面上主要使用的是饿了么开源的 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 的查询参数中
?
scriptIndex
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',
??},
};
addScriptTag
文本处理
?
这里我们统一将行内元素作为文本处理方式
?
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