前言
小伟是个特地负责的切图崽,最近他恋爱了。
风和日丽的一天,离上班还有半个小时,小伟神往着上班和小美的约会,这时产品经理大明找到小伟: 竞品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-690387207if (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>
写在最初
工具近期会开源,大家感兴趣的话能够珍藏我这篇文章,我代表小伟谢谢大家