对于依赖接口渲染的页面,在拿到数据之前页面往往是空白的,为了提示用户当前正在加载中,往往会使用进度条、loading图标或骨架屏的方式。
对于前两种方案而言,实现比较简单;本文主要研究骨架屏的实现方案。
骨架屏(Skeleton Screen)是指在页面数据加载完成前,先给用户展示出页面的大致结构(灰色占位图),不会造成网页长时间白屏或者闪烁,在拿到接口数据后渲染出实际页面内容然后替换掉。Skeleton Screen 是近两年开始流行的加载控件,本质上是界面加载过程中的过渡效果。Skeleton Screen 给用户一种资源是逐渐加载呈现出来的感觉,使得加载过程变得流畅。
使用一张占位骨架图(svg / lottie / gif)来代替 loading 效果,当数据加载完成后对替换掉骨架图。
我们知道,浏览器加载资源的顺序是:
因此需要使用 preload 提高图片加载优先级,让骨架图更早的显示,其次需要尽量减少图片的体积以加快加载速度,还有就是由于浏览器对同一域名的请求有并发限制,骨架屏的图片尽量放在单独的域名上,最后获取数据后隐藏图图片,显示真实 DOM 元素。
link
元素rel
属性的属性值preload
能够让浏览器预先加载在和缓存对应的资源,as
属性可以指定预加载内容的类型。可以被预加载如下:
- audio: 音频文件;
- document: 一个将要被嵌入到
或
内部的
HTML
文档;- embed: 一个将要被嵌入到
元素内部的资源;
- fetch: 那些将要通过
fetch
和XHR
请求来获取的资源,比如一个ArrayBuffer
或JSON
文件;- font: 字体文件;
- image: 图片文件;
- object: 一个将会被嵌入到
元素内的文件;
- script:
JavaScript
文件;- style: 样式表;
- track:
WebVTT
文件;- worker: 一个
JavaScript
的web worker
或shared worker
;- video: 视频文件;
优点是实现简单,开发成本较低。缺点是维护成本较高,对于迭代比较频繁的页面,增大UI设计的工作量。
用 css + html 实现一个骨架屏的元素,当数据加载完成后替换掉。与利用图片切换实现相比较,这种方案易于维护。
首先实现一个会动的渐变效果
animation: loading 2s ease infinite;
控制背景移动实现从左到右的进度效果;animation: opacity 2s ease infinite;
控制透明度实现渐隐渐现的动画效果;.card {
...
overflow: hidden;
}
.skeleton .user-avatar-cont,
.skeleton .img-cont {
background: #eee;
}
.skeleton .user-name span,
.skeleton .user-profession span {
background: #eee;
color: #eee;
border-radius: 5px;
}
.skeleton::before {
content: '';
position: absolute;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.9),
transparent
);
width: 50%;
height: 100%;
top: 0;
left: 0;
/* 关键帧动画 */
animation: loading 0.6s infinite; /** opacity 2s ease infinite; */
}
@keyframes loading {
0% {
background-size: 300% 100%;
background-image: linear-gradient(100deg, #eee 40%, #fff 50%, #eee 60%);
background-position: 100% 50%;
}
100% {
background-size: 300% 100%;
background-image: linear-gradient(100deg, #eee 40%, #fff 50%, #eee 60%);
background-position: 0 50%;
}
}
@keyframes loading-transform {
0% {
transform: skewX(-10deg) translateX(-100%);
}
100% {
transform: skewX(-10deg) translateX(200%);
}
}
/**
@keyframes opacity {
0%{
opacity: 1;
}
50%{
opacity: 0.3;
}
100%{
opacity: 1;
}
}
*/
然后为需要显示骨架屏的元素添加选择器:
edemao & rrh
Front-end Blogger
由于将 overflow: hidden 值添加到 card 元素中,因此当 before 元素由于关键帧变换而超出 card 边界时,它在 card 边界之外是不可见的。最后,获取到数据后,去掉 skeleton 选择器即可。
优点是与第一种方案来比较的话这种方案相对更灵活地定制骨架屏UI和动画效果,更容易维护些,但是开发和维护成本仍然较高——仍然需要在开发时为每个元素添加背景,形成页面的骨架屏框架,增加了一部分开发量,对于元素多类别杂的首页,工作量也不小。比如,采用这类方案的 react-content-loader 也可以通过配置快速生成对应的骨架屏,但对于某些高度定制的业务需求页面不好满足。
因为只需要保留元素最后一层的位置布局就可以了实现基本的页面的骨架,父级元素基本是提供一种嵌套关系。而 getBoundingClientRect()
方法,可以获取到元素相对于可视窗口的位置以及宽高。
Element.getBoundingClientRect()
方法返回元素的大小及其相对于视口的位置。如果是标准盒子模型,元素的尺寸等于width/height
+padding
+border-width
的总和。如果box-sizing: border-box
,元素的的尺寸等于width/height
。
具体思路是简化所有元素,不考虑结构层级、不考虑样式,对所有元素统一用 div
去代替,而且在骨架中只需要渲染最后一个层级,以定位的方式设置每个元素其相对于视窗的位置,形成骨架屏。
这样生成的节点是扁平的,体积比较小,同时避免额外的读取样式表和不需要通过抽离样式维持骨架屏的外观,使得骨架屏的节点更可控。该方法生成的骨架屏是由纯 DOM 颜色块拼成的
首先,考虑到了机型适配问题,对渲染出的 div
宽高以百分比方式做适配;而且只有拥有一定的宽高(宽高大于5px)并且在可视范围内的元素才进行渲染。
/** 骨架屏样式 */
let skeletonHtml = "";
const removeClass = [];
const removeId = [];
function createDiv(node, customStyle) {
let { width, height, top, left } = node.getBoundingClientRect();
const nodeClassName = node.className ? `node-class=${node.className}`:"";
const nodeId = node.id ? `node-id=${node.id}`:"";
const { borderRadius } = getComputedStyle(node, null)
const { innerWidth, innerHeight } = window
// 必须符合要求的元素才渲染:有大小,并且在视图内;
if (width > 5 && height > 5 && top < innerHeight && left < innerWidth) {
width = ((width / innerWidth) * 100).toFixed(2) + '%'
height = ((height / innerHeight) * 100).toFixed(2) + '%'
left = ((left / innerWidth) * 100).toFixed(2) + '%'
top = ((top / innerHeight) * 100).toFixed(2) + '%'
skeletonHtml += ``
}
}
function getDom(options = { removeElements: [] }) {
const { removeElements } = options
for (let i = 0; i < removeElements.length; i++) {
const el = removeElements[i]
const reg = /^./
if (el.match(reg) == ".") {
removeClass.push(el.substr(1))
}
if (el.match(reg) == "#") {
removeId.push(el.substr(1))
}
}
const dom = document.body;
const nodes = dom.childNodes;
dom.style.overflow = "hidden";
deepNode(nodes); // 遍历节点生成骨架屏框架 html
return skeletonHtml;
}
function isRemove(node) {
const { className, id } = node;
if (className || id) {
for (let i = 0; i < removeClass.length; i++) {
if (className.indexOf(removeClass[i]) > -1) {
return true;
}
}
if (removeId.includes(id)) {
return true;
}
}
return false;
}
对于页面上多出来许多布局较乱的模块,需要移除或自定义绘制干扰节点,或者适当调整某个节点的样式,可以支持传入当前节点的 class或者id
,来忽略这个节模块点,在 createDiv()
创建元素时将当前节点的 class
和 id
写入元素节点的一个属性,方便定位要删除的节点,然后getDom()
传入要删除的节点的集合,或者在 getDom 内,遍历节点之前就将其删除。此外,对于自定义绘制可以在遍历到指定节点时,调用传入的 customCreateDiv 方法进行自定义绘制:
getDom({ removeElements: [".removeClass","#removeId"], customElemnts: {customCreateId: function() {}} })
然后对于 deepNodes,对节点进行一个过滤,不可见的元素节点及其子结点直接跳过。循环当前节点的子节点,如果子节点存在任意一个元素节点,就代表当前节点还需要递归一次,如果不存在元素节点,就说明它是最后一层元素节点,可以被渲染:
function isHide(node) {
if (node.nodeType !== 1) return false;
let style = getComputedStyle(node, null);
return node.nodeName == "SCRIPT"|| style.display == 'none' || style.opacity == 0 || style.visibility == 'hidden' || node.hidden;
}
nodeType === 1
)节点(包括没有子元素节点的元素节点和没有子节点的元素节点);function deepNode(nodes) {
for (let i = 0; i < nodes.length; i++) {
let node = nodes[i];
if (isHide(node) || isRemove(node)) continue; // 过滤掉不可见或主动要求过滤的节点
let isHasChildrenElementNode = false; // 判断是否有子元素节点
for (let j = 0; j < node.childNodes.length; j++) {
let childNode = node.childNodes[j]
if (childNode.nodeType === 1) {
isHasChildrenElementNode = true;
break;
}
}
// 没有子元素节点的元素节点 或 没有子节点的元素节点(单标签节点,比如 img)
if ((node.nodeType == 1 && !isHasChildrenElementNode) || (node.nodeType == 1 && node.childNodes.length == 0)) {
createDiv(node);
}
if (node.childNodes.length) {
deepNode(node.childNodes);
}
}
}
const config = {
url: '${conf.url}', // 待生成骨架屏页面的地址
output: {
filepath: '${conf.filepath.toString()}', // 生成骨架屏的存放页面,一般为项目的入口页面
injectSelector: '#app' // 生成的骨架屏插入页面的节点
},
// background: '#eee', // 骨架屏主题色
// animation: 'opacity 1s linear infinite;', // css3动画属性
}
CLI 实现如下:
#!/usr/bin/env node
const program = require('commander')
const prompts = require('prompts')
const path = require('path')
const fs = require('fs')
const pkg = require('../package.json')
const defConf = require('./default.config')
const getSkeletonHtml = require('../src') // 生成骨架屏(利用 puppeteer 和脚本生成)
const utils = require('../src/utils')
const currDir = process.cwd()
program
.version(pkg.version)
.usage(' [options]')
.option('-v, --version', 'latest version')
.option('-tar, --target ', 'same to the config of url@rootNode.');
program
.command('init')
.description('create a default edmi.config.js file')
.action(function(env, options) {
const edmiConfFile = path.resolve(currDir, defConf.filename)
if(fs.existsSync(edmiConfFile)) {
return console.log(`\n[${defConf.filename}] had been created! you can edit it and then run 'edmi start'\n`)
}
askForConfig().then(({url, filepath}) => {
const outputPath = filepath ? path.resolve(currDir, filepath).replace(/\\/g, '\\\\') : '';
prompts({
type: 'toggle',
name: 'value',
message: `Are you sure to create skeleton screen base on ${url}. \n and will output to ${utils.calcText(outputPath)}`,
initial: true,
active: 'Yes',
inactive: 'no'
}).then(res => {
if(res.value) {
fs.writeFile(
path.resolve(currDir, defConf.filename),
defConf.getTemplate({
url: url,
filepath: outputPath
}),
err => {
if(err) throw err;
console.log(`\n[${defConf.filename}] had been created! now, you can edit it and then run 'edmi start'\n`)
}
)
}
})
});
});
program
.command('start')
.description('start create a skeleton screen')
.action(function() {
// 生成骨架屏, 并默认注入到 #app 中;
getSkeletonHtml(getEdmiconfig());
});
program.parse(process.argv);
if (program.args.length < 1) program.help()
function getEdmiConfig() {
const edmiConfFile = path.resolve(currDir, defConf.filename)
if(!fs.existsSync(edmiConfFile)) {
return utils.log.error(`please run 'edmi init' to initialize a config file`, 1)
}
return require(edmiConfFile);
}
function askForConfig() {
const questions = [
{
type: 'text',
name: 'url',
message: "What's your page url ?",
validate: function(value) {
const urlReg = /^https?:\/\/.+/ig;
if (urlReg.test(value)) {
return true;
}
return 'Please enter a valid url';
}
},
{
type: 'text',
name: 'filepath',
message: "Enter a relative output filepath ? (optional)",
validate: function(value) {
const filepath = path.isAbsolute(value) ? value : path.join(__dirname, value);
const exists = fs.existsSync(filepath);
if(value && !exists) {
return 'Please enter a exists target';
}
return true;
}
}
];
return prompts(questions);
}
优点是自动化,降低重复编写骨架屏代码的成本,缺点是对于复杂的页面,可能受元素定位的影响较大,自动生成的时候存在不确定性。另外,只能是首次加载,对于加载完成后用户触发的动态数据不支持生成骨架屏。
将骨架屏看成路由组件,在构建时(webpack)使用预渲染功能(VueSSR),将骨架屏组件的渲染结果 HTML 片段插入 HTML 页面模版的挂载点中,将样式(