共计 8549 个字符,预计需要花费 22 分钟才能阅读完成。
前言
小伟是个特地负责的切图崽,最近他恋爱了。
风和日丽的一天,离上班还有半个小时,小伟神往着上班和小美的约会,这时产品经理大明找到小伟: 竞品 app 的 H5 页面都有占位符(骨架屏),而咱们的只有一个菊花图,在网络不好的状况下,用户看到菊花图都不违心期待就敞开页面了,很影响咱们的页面的 uv,小伟你赶快安顿一下,他人有的咱们也不能少(🙄️)。
于是,在大明义正严辞 (威逼利诱) 的要求下,负(卑) 责(微)的小伟开始了他的骨架屏开发之旅。
计划调研
用菊花图的页面有 10 多个,并且根本都是多路由页面,一个一个写?显然不是一个好的计划。
聪慧 (懈怠) 的小伟本着不反复造轮子 (白嫖🙄️) 的思维,调研了以下几个 骨架屏自动化计划.
百度 – vue-skeleton-webpack-plugin
实现原理
通过 vueSSR (vue 服务端渲染)联合 webpack 在构建时渲染写好的 vue 骨架屏组件,将预渲染生成的 DOM 节点和相干款式插入到最终输入的 html 中
有余
- 预渲染的骨架屏组件须要开发者编写(对于想偷懒的小伟来说显著不是最优解🙄️)
- 计划只实用于 vue 我的项目(小伟的 H5 我的项目既有 react 也有 vue)
京东 – dps
实现原理
通过 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>
写在最初
工具近期会开源,大家感兴趣的话能够珍藏我这篇文章,我代表小伟谢谢大家😁