前言
小伟是个特别负责的切图崽,最近他恋爱了。
风和日丽的一天,离下班还有半个小时,小伟憧憬着下班和小美的约会,这时产品经理大明找到小伟: 竞品app的H5页面都有占位符(骨架屏),而我们的只有一个菊花图,在网络不好的情况下,用户看到菊花图都不愿意等待就关闭页面了,很影响我们的页面的uv,小伟你赶快安排一下,别人有的我们也不能少(🙄️)。
于是,在大明义正严辞(威逼利诱)的要求下,负(卑) 责(微)的小伟开始了他的骨架屏开发之旅。
方案调研
用菊花图的页面有10多个,并且基本都是多路由页面,一个一个写?显然不是一个好的方案。
聪明(懒惰)的小伟本着不重复造轮子(白嫖🙄️)的思想,调研了以下几个骨架屏自动化方案.
百度 - vue-skeleton-webpack-plugin
实现原理
通过 vueSSR (vue 服务端渲染)结合 webpack 在构建时渲染写好的 vue 骨架屏组件,将预渲染生成的 DOM 节点和相关样式插入到最终输出的 html 中
不足
- 预渲染的骨架屏组件需要开发者编写(对于想偷懒的小伟来说明显不是最优解🙄️)
- 方案只适用于vue项目(小伟的H5项目既有react也有vue)
实现原理
通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架页面的页面,在等待页面加载渲染完成之后, 执行遍历dom树的脚本代码,通过单纯的 DOM 操作,挑选目标节点,生成骨架屏html和css代码
不足
- 无法选择生成骨架屏的时机。当页面存在着重定向(H5需要鉴权)的时候,生成的骨架屏和预期相差比较大
- 内部实现并不完善,某些元素比如伪元素等无法生成骨架屏
- 某些依赖浏览器jsbridge接口的页面,工具无法使用
饿了么 - page-skeleton-webpack-plugin
实现原理
通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架页面的页面,在等待页面加载渲染完成之后,在保留页面布局样式的前提下,通过对页面中元素进行删减或增添,对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片、文字和图片的展现,通过样式覆盖,使得其展示为灰色块。并且将修改后的 HTML 和 CSS 样式提取出来,通过 webpack 插件的形式注入最后生成的html中,并且还可以启动 UI 界面专门调整骨架屏代码。
不足
- 由于生成的骨架屏节点是基于页面本身的结构和样式,在某些嵌套比较深的页面,骨架屏代码体积不会很小,并且对于多路由的页面,生成的代码就更加庞大了
- 无法选择生成骨架屏的时机。当页面存在着重定向(H5需要鉴权)的时候,生成的骨架屏和预期相差比较大
- 某些依赖浏览器jsbridge接口的页面,工具无法使用
- 只支持history路由
插曲
调研之后小伟很苦恼,业界方案或多或少都有些问题。 抬头一看已经是晚上11点了,小伟已经错过和小美的晚餐,而且看起来也没有现成的方案可以完全放心使用。对于认真负责的小伟来说,这件事就像一根刺一样扎在小伟的心上。于是小伟心一横,开始了工具的自研之路。
需求收集
1. 用户可以掌控骨架屏的生成时机(保证当前页面为目标页面)
2. 不和某种框架强耦合
3. 生成的骨架屏代码要尽可能小
4. 自动集成(生成的骨架屏代码不需要手动复制到html文件中)
5. 支持页面多路由(包括hash 路由和 history 路由)
6. 可在真机上触发生成开关(服务于某些严重依赖服务端接口或者客户端jsbridge环境的页面)
方案实现
项目如何接入工具
要自动生成骨架屏,就必须拿到真实的dom结构,并且要让开发者可以自由选择生成时机,就需要有一个"开关", 最好这个开关是可见的。这样就涉及到开关和骨架屏生成脚本如何集成到用户项目中。
非侵入式的接入最好的方式肯定还是和构建工具结合,这样可以保证开发代码和工具代码不耦合在一起。
具体实现代码(以webpack plugin为例子)
// webpack v4/v5 compatibility:
// https://github.com/webpack/webpack/issues/11425#issuecomment-690387207
if (webpack.version.startsWith('5.')) { // 如果是webpack 5
compiler.hooks.compilation.tap(TAP_NAME, (compilation) => {
compilation.hooks.processAssets.tapAsync(
{
name: TAP_NAME,
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
},
(assets, callback) => {
this.replaceCode(compilation, assets);
callback();
},
);
});
} else { // webpack 4
compiler.hooks.emit.tapAsync(TAP_NAME, (compilation, callback) => {
this.replaceCode(compilation, compilation.assets);
callback();
});
}
replaceCode(compilation, assets) {
const { options = {} } = this;
const { htmlName = DEFAULT_HTML_NAME } = options;
Object.keys(assets).forEach((name) => {
if (name === htmlName) { // 发现是目标html,执行脚本插入
const content = this.getReplaceCode(compilation, name);
updateAsset(compilation, name, content);
}
});
}
骨架屏代码如何生成
在我们拿到完整dom结构之后,接下来需要考虑的点是骨架屏幕代码如何生成。
生成的步骤不外乎一下两步:
1. 挑选目标dom节点
2. 将目标dom节点转化成骨架屏代码
如何挑选目标节点
挑选目标节点要遵循两个原则
- 精准挑选节点(要保证骨架屏代码要尽可能小,我们只挑选用户首屏可见的节点)
- 节点用户可自定义(要保障最后生成都骨架屏代码是用户想要的)
骨架屏代码要尽可能小
- 只遍历首屏可见的dom节点
/**
* 元素是否隐藏
*/
function isHidden(node) {
const computedStyle = getComputedStyle(node);
const { display, visibility, opacity } = computedStyle;
return display === 'none' || visibility === 'hidden' || opacity === '0' || node.hidden;
}
/**
* 元素是否出现在可视窗口中
* @param { Object } node HTML element节点
* @return { Boolean } 元素是否出现在可视窗口中
*/
function isInViewPort(node) {
const { top, right, bottom, left } = node.getBoundingClientRect();
return !isHidden(node) && bottom >= 0 && right >= 0 && left <= WINDOW_WIDTH && top <= WINDOW_HEIGHT;
}
- 只挑选目标节点
只挑选有效内容节点:(背景)图片、文字、表单项、音频视频、Canvas、伪元素
const TARGET_TAG_NAME = [
'audio',
'button',
'canvas',
'code',
'img',
'input',
'pre',
'svg',
'textarea',
'video',
'xmp',
];
/**
* dom节点是否包含某个标签
* @param { Object } node HTML Node节点
*/
const hasTargetLabel = (node) => TARGET_TAG_NAME.includes(node.tagName.toLowerCase());
/**
* 判断dom节点css属性backgroundImage中是否有url参数,并且作为全局占满全屏
* @param { Object } node HTML Node节点
*/
const backgroundHasurl = (node) => {
const hasBackgroundImage = /url\(.+?\)/.test(getComputedStyle(node).backgroundImage);
const { width, height } = node.getBoundingClientRect();
return hasBackgroundImage && !(width === WINDOW_WIDTH && height === WINDOW_HEIGHT);
};
/**
* 判断dom节点子节点中是否有有效内容节点
* @param { Object } node HTML Node节点
*/
const hasTextNode = (node) => Array.prototype.some.call(node.childNodes, (v) => isTextNode(v));
目标节点用户可自定义
任何工具不管多完美,在复杂的页面场景中都可能有某些缺陷。所以需要留一个入口让用户可自己新增或者删除目标节点。
设定黑名单和白名单
/**
* @param { String } attName
* @param { Object } node node节点
* @return { Boolean } node 节点中是否包含attName
*/
const curryCheckNode = (attName) => (node) => node.hasAttribute(attName);
/**
* 是否在黑名单中
*/
const isInBlackList = curryCheckNode('unneed-node');
/**
* 是否在白名单中
*/
const isInWhiteList = curryCheckNode('need-node');
dom节点转化成骨架屏代码
这里我们借鉴了京东 - dps的生成方式。<br/>
对于符合条件的区域,”一视同仁”生成相应区域的颜色块。”一视同仁”即对于符合条件的区域不区分具体元素、不考虑结构层级、不考虑样式,统一根据该区域与视口的绝对距离值生成 div 的颜色块。
只需要获取到dom节点到视口的距离,元素的宽高和圆角,即可生成骨架屏代码。并且将距离和宽高都转化成百分比,这样也可解决骨架屏代码在不同机型下的兼容问题。
/**
* 根据node节点生成html
* @param { Object } node
*/
generateHtml(node) {
const computedStyle = getComputedStyle(node);
let { top, left, width, height } = node.getBoundingClientRect();
const { boxSizing, paddingTop, paddingLeft, paddingBottom, paddingRight, borderRadius } = computedStyle;
const isStandardBoxModel = boxSizing === 'border-box';
width = isStandardBoxModel ? width : width - parseInt(paddingLeft, 10) - parseInt(paddingRight, 10);
height = isStandardBoxModel ? height : height - parseInt(paddingTop, 10) - parseInt(paddingBottom, 10);
top = isStandardBoxModel ? top : top + parseInt(paddingTop, 10);
left = isStandardBoxModel ? left : left + parseInt(paddingLeft, 10);
this.htmlQueue.push(drawBlock(width, height, top, left, borderRadius));
}
遍历方式
树的遍历方式选择选择有两种:
- bfs(广度优先算法)
- dfs(深度优先算法)
这个时候可能有小伙伴会有疑问,这两种方式对最终生成对代码有什么区别嘛?反正在用户视角上看起来其实都是一样的,因为只要目标节点确定了,对于单个dom节点来说生成的骨架屏代码是一样的。。
对于单个目标节点来说,生成的骨架屏代码确实是一样的,但是这两种方式对于节点和节点之间组合的顺序是有很大区别的。
由于我们是以pick的形式去挑选节点,所以生成的骨架屏代码和之前的页面代码的结构是会有很大差异。
使用dfs深度遍历可以最大可能的保证生成的节点的在dom树的上下顺序和之前页面的结构一致
traversal() {
while (this.queue.length) {
const node = this.queue.shift();
if (isTextNode(node) || node.id === INSERT_IMG_ID) {
continue;
}
// 非目标节点或者非可视窗口可见元素不做处理
if ((node.nodeType === 3 && node.textContent.trim().length === 0) || !isTargetNode(node) || !isInViewPort(node))
continue;
// 目标节点
if (isAppointed(node) || hasTargetLabel(node) || backgroundHasurl(node) || hasTextNode(node)) {
this.generateHtml(node);
continue;
}
this.queue.unshift(...Array.from(node.childNodes));
}
}
如何支持多路由
当用户自己可以掌控骨架屏的生成时机,多路由就不算问题了。只要用户在不同路由中点击生成开关,工具通过url链接获取当前路由,执行骨架屏生成脚本之后,再把路由标识一起传给工具的server,工具再根据标识作为文件名保存下来即可。
如何将生成的骨架屏代码插件集成到项目中
当我们把骨架屏代码保存到用户项目之后,我们一样可以借用构建工具插件的功能,遍历骨架屏文件夹,获取所有骨架屏代码,再插入到项目的html中,其实就是一个简版的HtmlWebpackPlugin。
工具插件主流程代码如下(webpack-plugin):
apply(compiler) {
assert(compiler.hooks, 'Please upgrade the webpack version to 4 or above!');
// 生产环境 -> 插入骨架屏幕,开发环境 -> 启动服务,插入生成骨架屏所需代码
if (isProd(compiler)) {
this.insertSkeleton(compiler);
return;
}
// 启动服务
this.startServer(compiler);
// 编译出错或者watch完成之后关闭服务
this.watchCompile(compiler);
// 替换资源
this.replaceSource(compiler);
}
圆满结束?
小伟在完成骨架屏自动化工具之后,兴高采烈的开始了工具的实践,随着测试用例(H5页面)的增加,小伟发现了新的问题:
- 生成的骨架屏虽然很还原真实的页面结构,但是不够好看
- 骨架屏动画的切换也很麻烦,每次切换都需要配置插件中的动画参数重新生成
- 修改生成的骨架屏代码之后都需要刷新浏览器才能看到效果
- 比较骨架屏页面和真实的H5页面需要来回切换着看,并且对于半屏H5页面来说,也很难还原场景
- 开发者不满意的dom节点,需要通过添加节点黑名单重新生成,操作很繁琐
这些问题的出现让小伟这个完美主义者陷入了沉思,他抬了抬头看了看时间,已经是夜里的12点。小伟因为工具的研发已经一周没有和小美一起吃晚饭了,想到此处,一股淡淡的哀伤袭上他的心头。
等等,如果工具有一个编辑器,那么是不是就可以很快的完成这些调优操作了?
想到此处,小伟开始了他的编辑器开发之旅。
骨架屏编辑器
需求收集
- 可以对生成的骨架屏节点进行拖拽,删除,和复制。
- 一键切换动画
- 一键保存
- 实时预览: 支持hot reload,保存骨架屏幕代码之后编辑器页面立马刷新
- 提供对比窗口: 可直观的看出原页面和骨架屏页面的差异
- 效果预览
- 可直接通过浏览器提供的devtool修改样式代码,点击保存之后样式代码会同步更新到项目中
方案实现
如何对骨架屏dom节点进行拖拽
拖拽很容易实现,不外乎对dom元素绑定mouse事件,计算移动距离,完成元素的移动。
这里我们要注意两个点:
- 由于骨架屏节点可能会有很多,所以我们采取事件委托的方式处理mouse事件。
- 前面我们说过: 骨架屏dom节点距离视口的长度是是百分比为单位。所以我们在拖拽过程中转化的转化的单位也应该是百分比。我们在窗口内移动节点,计算离视口的距离也应该是距离预览窗口的距离,所以我们需要使用iframe作为骨架屏预览窗口。
<div
class="edit-wrap"
:style="{ width: width + 'px', height: height + 'px' }"
>
<iframe
ref="code"
id="code"
:width="width"
:height="height"
frameborder="0"
scrolling="no"
marginheight="0"
marginwidth="0"
:src="iframeUrl"
></iframe>
</div>
如何实现对比窗口
先来看一张效果图
在上图中我们可以清晰的看到左边的真实页面。我们在用户点击开关生成骨架屏的时候,也同时对当前页面进行了截图,并且将图片转化成base64传给工具server,工具server再把图片保存到用户工程项目中,具体流程如下:
截图我们采用的是html2canvas(站在巨人的肩膀上😄),但是在使用的过程中我们发现一个问题: 图片跨域
最后我们在工具server中提供了一层proxy,才解决了这个问题。
如何实时预览
在编辑器启动的时候,工具server会和编辑器建立长连接,并且会实时监听用户项目中的骨架屏文件中文件的变化,当文件改变时,push消息到编辑器,编辑器刷新页面。
// 工具server代码
initSocket(server) {
const { log, pathname } = this;
const io = socketIo(server);
io.on('connection', (socket) => {
socket.emit('open');
});
chokidar.watch(pathname).on('change', (path) => {
log.info(`${path} is change!`);
io.sockets.emit('reload');
});
}
// 编辑器代码
socket.on('reload', () => {
window.location.reload();
});
在浏览器的devtool中修改代码,如何同步修改结果<br/>
做为前端小伙伴,对devtool肯定是再熟悉不过了, 那么编辑器是怎么做到可以同步devtool中的修改?
这个和我们骨架屏代码特点有关系,前面我们也说了我们骨架屏的dom节点的样式中有一个白名单: width, height, top, left, borderRadius,background, animation.
当用户在devtool修改完代码样式之后,我们只需要遍历iframe中的骨架屏节点,通过getComputedStyle获取白名单样式,生成修改后的代码,通过工具server保存到项目中即可。
整体架构图
编辑器总览图
无痛接入smart-skeleton-screen
安装插件包 (根据项目构建工具选择相关的插件包,以webpack为例)
tnpm install @tencent/smart-skeleton-screen
构建工具引入
const SmartSkeletonScreen = require('@tencent/smart-skeleton-screen').plugin;
new SmartSkeletonScreen({
background: '#33333324',
serverUrl: 'https://server.qq.com',
port: 4001,
pathname: path.join(__dirname, 'src/pages/fans/skeleton'),
}),
html文件中插入替换标识符
<div id="app"><% smart-skeleton %></div>
写在最后
工具近期会开源,大家感兴趣的话可以收藏我这篇文章,我代表小伟谢谢大家😁
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。