这是我参与11月更文挑战的第2天,活动详情查看: 2021最后一次更文挑战
写在前面
上一篇文章介绍过 用css+html实现骨架屏的方案,这种方式需要在开发时抽离出一部分代码,形成页面的骨骼,增加了一部分开发量,对于一些可以用循环搞定的页面来说,比如列表页增加的开发量还好,但是对于首页这种元素较多的页面就不是很友好了,所以萌生了写一个自动生成骨架屏代码的想法。
因为我之前在一直在使用css+html的方案,发现其实只需要保留元素最后一层的位置布局就可以了实现基本的骨架,,它的父级元素基本都只是为它提供一种嵌套关系。比如下图这个页面,我只需要保留它img和p元素的相对位置即可搭建出这个页面的骨架。
并且js为我们提供了 getBoundingClientRect()方法,可以获取到元素相对于可视窗口的位置以及宽高。所以我实现脚本的思路是简化所有元素,对所有元素都一视同仁,统一用div去代替,只需要渲染最后一个层级,以定位的方式设置其相对于视窗的位置,形成骨架屏。
Element.getBoundingClientRect()方法返回元素的大小及其相对于视口的位置。如果是标准盒子模型,元素的尺寸等于width/height+padding+border-width的总和。如果box-sizing: border-box,元素的的尺寸等于width/height。
手操实现
这时我第一版的思路已经成型,开始按照这个思路开始开发。
1.实现idea
let skeletonHtml = "<style>.skeleton {position: fixed;background: #eee;animation: opacity 2s ease infinite;} @keyframes opacity {0%{opacity: 1;}50%{opacity: 0.4;}100%{opacity: 1;}} </style>"
function getDom() {
const dom = document.body
const nodes = dom.childNodes
deepNode(nodes)
return skeletonHtml
}
function deepNode(nodes) {
for (let i = 0; i < nodes.length; i++) {
let node = nodes[i]
if (node.childNodes.length==0) {
console.log(node)
createDiv(node)
}
if (node.childNodes.length) {
deepNode(node.childNodes)
}
}
}
function createDiv(node) {
let { width, height, top, left } = node.getBoundingClientRect()
const { innerWidth, innerHeight } = window//可视区域宽高
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 += `<div class="skeleton" style="width:${width};height:${height};left:${left};top:${top};"></div>`
}
console.log(getDom())
因为考虑到了机型适配问题,渲染出的div宽高以百分比方式做适配。
2.发现问题解决问题
理想是美好的,很快碰到了第一个坎node.getBoundingClientRect is not a function,然后通过打印node节点发现childNodes返回的节点不只有元素节点,还有其他一大堆类型。所以,只需要取nodeType==1的元素节点就行。
childNodes包含了哪些节点? 由childNodes属性返回的数组中包含着所有类型的节点,所有的属性节点和文本节点也包含在其中。
if (node.childNodes.length==0 && node.nodeType == 1 ) {
createDiv(node)
}
再次运行,结果又发现了新的问题:
-
script标签也被抓取出来; -
只有
img标签的被渲染出来了。
分析后得出结果,问题1:不被显示出来的元素都应该忽略掉,并且只需要渲染首屏能看到的元素即可;问题2:因为img是单标签,不会存在子节点,而p这种双标签并不是最后一层节点,其还会存在文本等子节点。
function deepNode(nodes) {
for (let i = 0; i < nodes.length; i++) {
let node = nodes[i]
if(isHide(node))continue
let flag = false
for (let j = 0; j < node.childNodes.length; j++) {
let childNode = node.childNodes[j]
if (childNode.nodeType == 1) {
flag = true
}
}
if ((node.nodeType == 1 && !flag) || (node.nodeType == 1 && node.childNodes.length == 0)) {
createDiv(node)
}
if (node.childNodes.length) {
deepNode(node.childNodes)
}
}
}
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'
}
function createDiv(node) {
let { width, height, top, left } = node.getBoundingClientRect()
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 += `<div class="skeleton" style="width:${width};height:${height};left:${left};top:${top};border-radius:${borderRadius};"></div>`
}
}
解决问题1:在deepNode()刚开始时对节点进行一个过滤,不可见的元素节点及其子结点直接跳过。然后createDiv创建骨架屏元素时对节点进行判断:只有拥有一定的宽高(宽高大于5px)并且在可视范围内的元素才进行渲染。
解决问题2:循环当前节点的子节点,如果子节点存在任意一个元素节点,就代表当前节点还需要递归一次,如果不存在元素节点,就说明它是最后一层元素节点,可以被渲染。
实现到这里,获取页面的骨架屏代码已经实现完成了,使用时只需要引入js文件,或者将方法粘贴到控制台都可以获取到页面骨架屏。
执行前:
执行后:
3.优化--支持过滤
当我在一些复杂的页面(商城首页,个人中心)执行脚本时,发现会多出来许多布局较乱的模块,这些模块大都是一些fixed定位的运营活动广告位、回到首页等增强用户的快捷按钮,或者是一些小的icon一两个字符。所以,有增加了一层过滤判断,支持传入当前节点的class或者id,忽略这个节点。
function getDom(options = { removeElements: [] }) {
const { removeElements } = options
for (var i = 0; i < removeElements.length; i++) {
let el = removeElements[i]
let 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)
return skeletonHtml
}
function isRemove(node) {
let { 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
}
function createDiv(node) {
//...
let nodeClassName = node.className ? `node-class=${node.className}`:""
let nodeId = node.id ? `node-id=${node.id}`:""
//...
skeletonHtml += `<div class="skeleton" ${nodeClassName} ${nodeId} //...`
}
function deepNode(nodes) {
for (let i = 0; i < nodes.length; i++) {
//...
if (isHide(node) || isRemove(node)) continue
//...
}
}
在createDiv()创建元素时将当前节点的class和id写入元素节点的一个属性,方便定位要删除的节点,然后getDom()传入要删除的节点的集合。
getDom({ removeElements: [".removeClass","#removeId"] })
结尾
写到这里生成骨架屏的脚本就全部完成了,从我开始有这个构思,到今天完成只有3天的时间,期间还有工作日。代码并没有经过大量数据的测试,在我用到的项目里已经完全能够满足需求了,后续时间充裕了会继续优化。最后附上完整的流程图,代码,以及在京东商城的运行效果。
- 流程图
-
完整代码 github
-
项目内运行结果
- 京东商城运行结果