龙空技术网

饿了么开源的骨架屏插件原理分析

辛弃疾弃疾 2120

前言:

现时我们对“js判断元素是否在可视区域”大概比较珍视,看官们都想要剖析一些“js判断元素是否在可视区域”的相关知识。那么小编也在网上汇集了一些对于“js判断元素是否在可视区域””的相关内容,希望我们能喜欢,你们一起来学习一下吧!

Skeleton Screen(骨架屏)指的在页面数据尚未加载前先给用户展示出页面的大致结构,直到请求数据返回后再渲染页面,补充进需要显示的数据内容。常用于内容列表页。

一、page-skeleton-webpack-plugin

page-skeleton-webpack-plugin是一款由ElemeFE团队开发的webpack 插件,该插件的目的是根据你项目中不同的路由页面生成相应的骨架屏页面,并将骨架屏页面通过 webpack 打包到对应的静态路由页面中。

二、插件自动生成骨架屏的主要原理

通过无头浏览器puppeteer打开要生成骨架屏的页面等待页面渲染完后注入提取骨架屏的脚本(注意:一定要等页面完全渲染完,不然提取的DOM不完整)对页面中元素进行删减或增添,对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片和文字,通过样式覆盖,使得其展示为灰色块。然后将修改后的 HTML 和 CSS 样式提取出来生成骨架屏。

先demo展示一下如何自动生成骨架屏,后续再通过代码具体分析如何生成骨架屏:

安装运行环境

依赖环境:

puppeteernodejs v8.x

安装puppeteer可参考:

启动puppeteer并打开要生成骨架屏的页面

const puppeteer = require('puppeteer');const devices = require('puppeteer/DeviceDescriptors');const iPhone = devices['iPhone 6'];const { Skeleton } = require('page-skeleton-webpack-plugin');let skeleton = new Skeleton();(async () => { const browser = await (puppeteer.launch({ //设置超时时间 timeout: 15000, //如果是访问https页面 此属性会忽略https错误 ignoreHTTPSErrors: true, // 打开开发者工具, 当此值为true时, headless总为false devtools: true, // 非headless模式,为了能直观看到页面生成骨架屏的过程 headless: false })); const page = await browser.newPage(); // 因为是移动端,设置模拟iphone6 await page.emulate(iPhone); // 打开m站首页 await page.goto(''); // 等待首屏bannar加载完成 await page.waitForSelector('.ad-data-report-carousel'); // 开始build骨架屏 await skeleton.makeSkeleton(page);})();

接下来分析makeSkeleton是如何生成骨架屏代码

入口代码在page-skeleton-webpack-plugin/src/skeleton.js

通过page.addScriptTag向puppeteer注入脚本并初始化,脚本路径在page-skeleton-webpack-plugin/src/script/index.js。执行genSkeleton方法生成骨架屏

async makeSkeleton(page) { const {defer} = this.options // 把生成骨架屏代码注入puppeteer同时执行初始化 await page.addScriptTag({content: this.scriptContent}) // 延迟逻辑,用于等待某些异步操作,图1我已经使用waitForSelector,所以这个可以不用管 await sleep(defer) // 执行genSkeleton方法 await page.evaluate((options) => { Skeleton.genSkeleton(options) }, this.options) }

初始化核心逻辑:

初始化参数说明:

const pluginDefaultConfig = { port: '8989', // 该配置对象可以配置一个 color 字段,用于决定骨架页面中文字块的的颜色,颜色值支持16进制、RGB等。 text: { color: '#EEEEEE' }, // 该配置接受 3 个字段,color、shape、shapeOpposite。color 和 shape 用于确定骨架页面中图片块的颜色和形状, // 颜色值支持16 进制和 RGB等,形状支持两个枚举值,circle (矩形)和 rect(圆形)。 // shapeOpposite 字段接受一个数组,数组中每个元素是一个 DOM 选择器,用于选择 DOM 元素, // 被选择 DOM 的形状将和配置的 shape 形状相反,例如,配置的是 rect那么, // shapeOpposite 中的图片块将在骨架页面中显示成 circle 形状(圆形),具体怎么配置可以参考该部分末尾的默认配置。 image: { shape: 'rect', // `rect` | `circle` color: '#EFEFEF', shapeOpposite: [] }, // 该配置接受两个字段,color 和 excludes。color 用来确定骨架页面中被视为按钮块的颜色, // excludes 接受一个数组,数组中元素是 DOM 选择器,用来选择元素,该数组中的元素将不被视为按钮块 button: { color: '#EFEFEF', excludes: [] }, // 该配置接受 3 个字段,color、shape、shapeOpposite。color 和 shape 用于确定骨架页面中 svg 块的颜色和形状, // 颜色值支持16 进制和 RGB等,同时也支持 transparent 枚举值,设置为 transparent 后, // svg 块将是透明块。形状支持两个枚举值,circle (矩形)和 rect(圆形)。 // shapeOpposite 字段接受一个数组,数组中每个元素是一个 DOM 选择器,用于选择 DOM 元素, // 被选择 DOM 的形状将和配置的 shape 形状相反,例如,配置的是 rect那么, // shapeOpposite 中的 svg 块将在骨架页面中显示成 circle 形状(圆形),具体怎么配置可以参考该部分末尾的默认配置。 svg: { color: '#EFEFEF', shape: 'circle', // circle | rect shapeOpposite: [] }, // 该配置接受两个字段,color 和 shape。color 用来确定骨架页面中被视为伪元素块的颜色, // shape 用来设置伪元素块的形状,接受两个枚举值:circle 和 rect。 pseudo: { color: '#EFEFEF', // or transparent shape: 'circle' // circle | rect }, device: 'iPhone 6', debug: false, minify: { minifyCSS: { level: 2 }, removeComments: true, removeAttributeQuotes: true, removeEmptyAttributes: false }, defer: 5000, // 如果你有不需要进行骨架处理的元素,那么将该元素的 CSS 选择器写入该数组。 excludes: [], // 不需要生成页面骨架,且需要从 DOM 中移除的元素,配置值为移除元素的 CSS 选择器。 remove: [], // 不需要移除,但是通过设置其透明度为 0,来隐藏该元素,配置值为隐藏元素的 CSS 选择器。 hide: [], // 该数组中元素是 CSS 选择器,被选择的元素将被被插件处理成一个色块,色块的颜色和按钮块颜色一致。内部元素将不再做特殊处理,文字将隐藏。 grayBlock: [], cookies: [], // 其接受的枚举值rem, vw, vh, vmin, vmax。 cssUnit: 'rem', // 生成骨架页面(shell.html)中 css 值保留的小数位数,默认值是 4。 decimal: 4, logLevel: 'info', quiet: false, noInfo: false, logTime: true};
递归遍历DOM树,将DOM分类成文本块、按钮块、图片块、SVG块、伪类元素块等。
// ele 为 document.documentElement; 递归遍历DOM树;(function preTraverse(ele) { // styles为元素中所有可用的css属性列表 const styles = getComputedStyle(ele); // 检查元素是否有伪元素 const hasPseudoEle = checkHasPseudoEle(ele); // 判断元素是否在可视区域内(是否是首屏元素),非首屏元素将要移除 if (!inViewPort(ele) || DISPLAY_NONE.test(ele.getAttribute('style'))) {	return toRemove.push(ele) } // 自定义要处理为色块的元素 if (~grayEle.indexOf(ele)) { // eslint-disable-line no-bitwise	return grayBlocks.push(ele) } // 自定义不需要处理为骨架的元素 if (~excludesEle.indexOf(ele)) return false // eslint-disable-line no-bitwise if (hasPseudoEle) {	pseudos.push(hasPseudoEle); } if (checkHasBorder(styles)) {	ele.style.border = 'none'; } // 列表元素统一处理为默认样式 if (ele.children.length > 0 && /UL|OL/.test(ele.tagName)) {	listHandle(ele); } // 有子节点遍历处理 if (ele.children && ele.children.length > 0) {	Array.from(ele.children).forEach(child => preTraverse(child)); } // 将所有拥有 textChildNode 子元素的元素的文字颜色设置成背景色,这样就不会在显示文字了。 if (ele.childNodes && Array.from(ele.childNodes).some(n => n.nodeType === Node.TEXT_NODE)) {	transparent(ele); } // 统一文本下划线的颜色 if (checkHasTextDecoration(styles)) {	ele.style.textDecorationColor = TRANSPARENT; } // 隐藏所有 svg 元素 if (ele.tagName === 'svg') {	return svgs.push(ele) } // 有背景色或背景图的元素 if (EXT_REG.test(styles.background) || EXT_REG.test(styles.backgroundImage)) {	return hasImageBackEles.push(ele) } // 背景渐变元素 if (GRADIENT_REG.test(styles.background) || GRADIENT_REG.test(styles.backgroundImage)) {	return gradientBackEles.push(ele) } if (ele.tagName === 'IMG' || isBase64Img(ele)) {	return imgs.push(ele) } if (	ele.nodeType === Node.ELEMENT_NODE &&	(ele.tagName === 'BUTTON' || (ele.tagName === 'A' && ele.getAttribute('role') === 'button')) ) {	return buttons.push(ele) } if (	ele.childNodes &&	ele.childNodes.length === 1 &&	ele.childNodes[0].nodeType === Node.TEXT_NODE &&	/\S/.test(ele.childNodes[0].textContent) ) {	return texts.push(ele) }}(rootElement));
将分类好的文本块、图片块等处理生成骨架结构代码
svgs.forEach(e => svgHandler(e, svg, cssUnit, decimal));texts.forEach(e => { textHandler(e, text, cssUnit, decimal)});buttons.forEach(e => buttonHandler(e, button));hasImageBackEles.forEach(e => backgroundHandler(e, image));imgs.forEach(e => imgHandler(e, image));pseudos.forEach(e => pseudosHandler(e, pseudo));gradientBackEles.forEach(e => backgroundHandler(e, image));grayBlocks.forEach(e => grayHandler(e, button));

具体各块的骨架结构如何生成的接下来会一一分析

1、SVG块生成骨架结构

判断svg元素是否不可见,不可见则直接删除元素

// 宽高为0或设置隐藏的元素直接移除(aria是为残疾人士等提供无障碍访问动态、可交互Web内容的技术规范)if (width === 0 || height === 0 || ele.getAttribute('aria-hidden') === 'true') { return removeElement(ele)}

非隐藏的元素,会把 svg 元素内部所有元素删除,减少最终生成的骨架页面体积,其次,设置svg 元素的宽、高和形状等。

// 设置shapeOpposite的元素的最终形状和shape配置的相反const finalShape = shapeOpposite.indexOf(ele) > -1 ? getOppositeShape(shape) : shape;// 清空元素的内部结构 innerHTML = ''emptyElement(ele);const shapeClassName = CLASS_NAME_PREFEX + shape;// 根据rect or cirle设置border-radius属性,同时set到styleCacheshapeStyle(shape);Object.assign(ele.style, { width: px2relativeUtil(width, cssUnit, decimal), height: px2relativeUtil(height, cssUnit, decimal),});addClassName(ele, [shapeClassName]);// color是自定义svg配置中的color属性,可设置16进制设置及transparent枚举值if (color === TRANSPARENT) { // 设置为透明块 setOpacity(ele);} else { // 设置背景色 const className = CLASS_NAME_PREFEX + 'svg'; const rule = `{ background: ${color} !important;}`; addStyle(`.${className}`, rule); ele.classList.add(className);}

2、按钮块生成骨架结构

button块的处理相对比较简单,去除边框和阴影,设定好统一的背景色和文字,按钮块就处理完成了。

function buttonHandler(ele, {color, excludes}) { if (excludes.indexOf(ele) > -1) return false const classname = CLASS_NAME_PREFEX + 'button'; const rule = `{ color: ${color} !important; background: ${color} !important; border: none !important; box-shadow: none !important; }`; addStyle(`.${classname}`, rule); ele.classList.add(classname);}

3、背景块生成骨架结构

背景块指有背景图或者背景色的元素。统一设置背景色即可。

function backgroundHandler(ele, {color, shape}) { const imageClass = CLASS_NAME_PREFEX + 'image'; const shapeClass = CLASS_NAME_PREFEX + shape; const rule = `{ background: ${color} !important; }`; addStyle(`.${imageClass}`, rule); shapeStyle(shape); addClassName(ele, [imageClass, shapeClass]);}

4、图片块生成骨架结构

设置元素宽高、1*1像素透明gif图的base64编码值填充图片设置背景色、形状去除无用属性(alt)

function imgHandler(ele, {color, shape, shapeOpposite}) { const {width, height} = ele.getBoundingClientRect(); const attrs = { width, height, src: SMALLEST_BASE64 // 1*1像素透明gif图 }; const finalShape = shapeOpposite.indexOf(ele) > -1 ? getOppositeShape(shape) : shape; setAttributes(ele, attrs); const className = CLASS_NAME_PREFEX + 'image'; const shapeName = CLASS_NAME_PREFEX + finalShape; const rule = `{ background: ${color} !important; }`; addStyle(`.${className}`, rule); shapeStyle(finalShape); addClassName(ele, [className, shapeName]); if (ele.hasAttribute('alt')) { ele.removeAttribute('alt'); }}

5、伪元素块处理骨架结构

伪元素::before和::after去除背景图、统一为透明背景色设置形状(矩形or圆角)

function pseudosHandler({ele, hasBefore, hasAfter}, {color, shape, shapeOpposite}) { if (!shapeOpposite) shapeOpposite = [] const finalShape = shapeOpposite.indexOf(ele) > -1 ? getOppositeShape(shape) : shape; const PSEUDO_CLASS = `${CLASS_NAME_PREFEX}pseudo`; const PSEUDO_RECT_CLASS = `${CLASS_NAME_PREFEX}pseudo-rect`; const PSEUDO_CIRCLE_CLASS = `${CLASS_NAME_PREFEX}pseudo-circle`; const rules = { [`.${PSEUDO_CLASS}::before, .${PSEUDO_CLASS}::after`]: `{ background: ${color} !important; background-image: none !important; color: transparent !important; border-color: transparent !important; }`, [`.${PSEUDO_RECT_CLASS}::before, .${PSEUDO_RECT_CLASS}::after`]: `{ border-radius: 0 !important; }`, [`.${PSEUDO_CIRCLE_CLASS}::before, .${PSEUDO_CIRCLE_CLASS}::after`]: `{ border-radius: 50% !important; }` }; Object.keys(rules).forEach(key => { addStyle(key, rules[key]); }); addClassName(ele, [PSEUDO_CLASS, finalShape === 'circle' ? PSEUDO_CIRCLE_CLASS : PSEUDO_RECT_CLASS]);}

6、文本块处理骨架结构

文本块相对处理起来会比较复杂些,所以放到最后来讲。

文本块定义:任何包含文本节点的元素都是文本块。

计算文本块的文本行数、文字高度(即要绘制的文本块高度=fontSize):

计算文本行数 ( 元素高度 - 上下padding ) / 行高计算文本高度比 = 字体高度/行高(默认1 / 1.4)

// 文本行数 =( 高度 - 上下padding ) / 行高const lineCount = (height - parseFloat(paddingTop, 10) - parseFloat(paddingBottom, 10)) / parseFloat(lineHeight, 10) | 0; // eslint-disable-line no-bitwise// 文本高度比 = 字体高度/行高let textHeightRatio = parseFloat(fontSize, 10) / parseFloat(lineHeight, 10);if (Number.isNaN(textHeightRatio)) { textHeightRatio = 1 / 1.4; // default number}

通过线性渐变生成条纹背景的文本块:

const firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(decimal);const secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(decimal);const backgroundSize = `100% ${px2relativeUtil(lineHeight, cssUnit, decimal)}`;const className = CLASS_NAME_PREFEX + 'text-' + firstColorPoint.toString(32).replace(/\./g, '-');const rule = `{ background-image: linear-gradient(transparent ${firstColorPoint}%, ${color} 0%, ${color} ${secondColorPoint}%, transparent 0%) !important; background-size: ${backgroundSize}; position: ${position} !important;}`;

单行文本需要计算文本宽度和text-aligin属性

const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10));	ele.style.backgroundSize = `${(textWidthPercent > 1 ? 1 : textWidthPercent) * 100}% ${px2relativeUtil(lineHeight, cssUnit, decimal)}`;	switch (textAlign) {	case 'left': // do nothing		break	case 'center':		 ele.style.backgroundPositionX = '50%';		 break	case 'right':		 ele.style.backgroundPositionX = '100%';		 break}

以上就是elementUI开源的骨架屏插件的主要逻辑啦。当然还有涉及工程化相关的逻辑这里就没贴出来了,后续可以再慢慢探讨。

我抽空把生成骨架屏的逻辑单独抽出来,方便大家定制对骨架屏的工程化处理及调试

标签: #js判断元素是否在可视区域