前言

小伟是个特地负责的切图崽,最近他恋爱了。

风和日丽的一天,离上班还有半个小时,小伟神往着上班和小美的约会,这时产品经理大明找到小伟: 竞品app的H5页面都有占位符(骨架屏),而咱们的只有一个菊花图,在网络不好的状况下,用户看到菊花图都不违心期待就敞开页面了,很影响咱们的页面的uv,小伟你赶快安顿一下,他人有的咱们也不能少(️)。

于是,在大明义正严辞(威逼利诱)的要求下,负(卑) 责(微)的小伟开始了他的骨架屏开发之旅。

计划调研

用菊花图的页面有10多个,并且根本都是多路由页面,一个一个写?显然不是一个好的计划。

聪慧(懈怠)的小伟本着不反复造轮子(白嫖️)的思维,调研了以下几个骨架屏自动化计划.

百度 - vue-skeleton-webpack-plugin

实现原理

通过 vueSSR (vue 服务端渲染)联合 webpack 在构建时渲染写好的 vue 骨架屏组件,将预渲染生成的 DOM 节点和相干款式插入到最终输入的 html 中

有余

  1. 预渲染的骨架屏组件须要开发者编写(对于想偷懒的小伟来说显著不是最优解️)
  2. 计划只实用于vue我的项目(小伟的H5我的项目既有react也有vue)

京东 - dps

实现原理

通过 puppeteer 在服务端操控 headless Chrome 关上开发中的须要生成骨架页面的页面,在期待页面加载渲染实现之后, 执行遍历dom树的脚本代码,通过单纯的 DOM 操作,筛选指标节点,生成骨架屏html和css代码

有余

  1. 无奈抉择生成骨架屏的机会。当页面存在着重定向(H5须要鉴权)的时候,生成的骨架屏和预期相差比拟大
  2. 外部实现并不欠缺,某些元素比方伪元素等无奈生成骨架屏
  3. 某些依赖浏览器jsbridge接口的页面,工具无奈应用

饿了么 - page-skeleton-webpack-plugin

实现原理

通过 puppeteer 在服务端操控 headless Chrome 关上开发中的须要生成骨架页面的页面,在期待页面加载渲染实现之后,在保留页面布局款式的前提下,通过对页面中元素进行删减或削减,对已有元素通过层叠款式进行笼罩,这样达到在不扭转页面布局下,暗藏图片、文字和图片的展示,通过款式笼罩,使得其展现为灰色块。并且将批改后的 HTML 和 CSS 款式提取进去,通过 webpack 插件的模式注入最初生成的html中,并且还能够启动 UI 界面专门调整骨架屏代码。

有余

  1. 因为生成的骨架屏节点是基于页面自身的构造和款式,在某些嵌套比拟深的页面,骨架屏代码体积不会很小,并且对于多路由的页面,生成的代码就更加宏大了
  2. 无奈抉择生成骨架屏的机会。当页面存在着重定向(H5须要鉴权)的时候,生成的骨架屏和预期相差比拟大
  3. 某些依赖浏览器jsbridge接口的页面,工具无奈应用
  4. 只反对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节点转化成骨架屏代码

如何筛选指标节点

筛选指标节点要遵循两个准则

  1. 精准筛选节点(要保障骨架屏代码要尽可能小,咱们只筛选用户首屏可见的节点)
  2. 节点用户可自定义(要保障最初生成都骨架屏代码是用户想要的)

骨架屏代码要尽可能小

  1. 只遍历首屏可见的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;}
  1. 只筛选指标节点
    只筛选无效内容节点:(背景)图片、文字、表单项、音频视频、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));  }

遍历形式

树的遍历形式抉择抉择有两种:

  1. bfs(广度优先算法)
  2. 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页面)的减少,小伟发现了新的问题:

  1. 生成的骨架屏尽管很还原实在的页面构造,然而不够难看
  2. 骨架屏动画的切换也很麻烦,每次切换都须要配置插件中的动画参数从新生成
  3. 批改生成的骨架屏代码之后都须要刷新浏览器能力看到成果
  4. 比拟骨架屏页面和实在的H5页面须要来回切换着看,并且对于半屏H5页面来说,也很难还原场景
  5. 开发者不称心的dom节点,须要通过增加节点黑名单从新生成,操作很繁琐

这些问题的呈现让小伟这个完满主义者陷入了深思,他抬了低头看了看工夫,曾经是夜里的12点。小伟因为工具的研发曾经一周没有和小美一起吃晚饭了,想到此处,一股淡淡的哀伤袭上他的心头。

等等,如果工具有一个编辑器,那么是不是就能够很快的实现这些调优操作了?

想到此处,小伟开始了他的编辑器开发之旅。

骨架屏编辑器

需要收集

  1. 能够对生成的骨架屏节点进行拖拽,删除,和复制。
  2. 一键切换动画
  3. 一键保留
  4. 实时预览: 反对hot reload,保留骨架屏幕代码之后编辑器页面立马刷新
  5. 提供比照窗口: 可直观的看出原页面和骨架屏页面的差别
  6. 成果预览
  7. 可间接通过浏览器提供的devtool批改款式代码,点击保留之后款式代码会同步更新到我的项目中

计划实现

如何对骨架屏dom节点进行拖拽

拖拽很容易实现,不外乎对dom元素绑定mouse事件,计算挪动间隔,实现元素的挪动。
这里咱们要留神两个点:

  1. 因为骨架屏节点可能会有很多,所以咱们采取事件委托的形式解决mouse事件。
  2. 后面咱们说过: 骨架屏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>

写在最初

工具近期会开源,大家感兴趣的话能够珍藏我这篇文章,我代表小伟谢谢大家