简单粗暴的骨架屏实现

简介: 简单粗暴的骨架屏实现

 早在2013年Luke Wroblewski就提出了骨架屏(Skeleton Screen)的概念,他认为骨架屏是一个页面的空白版本,通过这个空白版本来传递一种信息,即页面正在渐进式的加载中。骨架屏的布局能与页面的视觉呈现保持一致,这样就能引导用户的关注点聚焦到感兴趣的位置。如下图所示,左边是数据渲染后的页面,右边是骨架屏,可以看到相应的位置都能对起来。

C1.png

  在网上阅读了一些骨架屏原理的资料后,就自己想尝试一下,练练手,制作一个极简版本的骨架屏插件。因为简单,所以未来如要扩展,成本也会很低。上图是通过自己写的骨架屏插件得到的效果,对于公司简单结构的项目,还是游刃有余的。在编写插件时,参考了网上多篇资料分享的代码,站在巨人的肩膀上整合代码,省力了很多。插件的完整代码已上传至GitHub中,下面是其中的构造函数,以及三个常量,用到了ES6的一些概念,如对此不熟悉,可参考我之前整理的《ES6躬行记》。


const NODE_ELEMENT = 1,     //元素类型的节点常量
  NODE_TEXT = 3,            //文本类型的节点常量
  NODE_COMMENT = 8;         //注释类型的节点常量
/**
 * @param color         字体的背景色
 * @param bgColor       带背景图模块的背景色
 * @param rectHeight    指定区域的高度,默认为视口高度
 * @param formFn        自定义表单着色规则
 * @constructor
*/
function Skeleton({
  color = "#DCDCDC",
  bgColor = "#F6F8FA",
  rectHeight = global.innerHeight,
  formFn = function() {}
} = {}) {
  this.container = document.body;   //骨架容器
  this.color = color;
  this.bgColor = bgColor;
  this.rectHeight = rectHeight;
  this.formFn = formFn;
}



一、绘制骨架屏


  由于对Node.js不熟,所以采用纯原生的JavaScript来绘制骨架屏。首先将页面中的元素分成三类:图像、文本和表单。

1)图像

  图像也就是元素,其src属性会被替换成一张灰色(色素是#EEE)的1*1的gif图。为了避免引入额外的请求,将该gif图转换成base64格式,写死在替换函数image()中,如下所示,呈现的效果如下图所示。


image(element, isImage = true) {
  const { width, height } = getRect(element);
  //图像颜色 #EEE
  const src = "data:image/gif;base64,R0lGODlhAQABAPAAAPT09AA....";
  if (isImage) 
    element.src = src;
  else
    element.style.background = this.bgColor;
  element.width = width;
  element.height = height;
}

C2.png

  由于image()函数声明在原型(prototype)之上,因此省略了function关键字。isImage是一个布尔值,表示是否是一个元素。当传入非元素时,就需要将其背景替换成初始化时的纯色。getRect()是一个辅助函数,用于获取元素的尺寸和坐标。

function getRect(element) {
  return element.getBoundingClientRect();
}

2)文本

  处理文本是比较复杂的,因为文本长度是不定的,如下图所示,左边的文本是两行,骨架屏中也要变成两行,并且第二行不是满行的。

C4.png

  网上的资料对于最后一行都会做遮罩处理,也就是用一个白底的块定位到相应位置,把多余的灰底遮掉。当文本只有一行时,还需要做特殊处理。

  而我在设计骨架屏插件的时候,采用了一个简单粗暴的方法,能够避免遮罩和单行的处理,那就是为所有文本节点添加元素。对于我这边不太复杂的HTML结构而言,能够大大简化代码的复杂度。具体方法如下所示,采用递归的方式逐个访问子节点,当节点是文本类型并且有内容时,就为其包裹标签。


appendTextNode(parent) {
  //避免<span>中嵌套<span>
  if ( parent.childNodes.length <= 1 &&
    parent.nodeName.toLowerCase() == "span" ) {
    return;
  }
  parent.childNodes.forEach(node => {
    if (node.nodeType === NODE_TEXT && node.nodeValue.trim().length > 0) {
      let span = document.createElement("span");
      span.textContent = node.nodeValue;
      parent.replaceChild(span, node);
    } else {
      this.appendTextNode(node);
    }
  });
}


  下面的第一个

元素在调用了appendTextNode()方法后,就变成了第二个

元素。

<p>本活动最终解释权归上海易点时空网络有限公司所有</p>
<!-- 骨架屏结构 -->
<p><span>本活动最终解释权归上海易点时空网络有限公司所有</span></p>

  为了让多行文本能呈现灰白相间的效果,就得借助CSS3的linear-gradient渐变属性来实现。如果对其不熟悉,可以参考之前的《CSS3中惊艳的gradient》一文。

  下面的计算方式照搬了饿了么的page-skeleton-webpack-plugin插件,其中getStyle()函数用于获取元素的CSS属性或属性对象(CSSStyleDeclaration)。


calculate(element) {
  let { fontSize, lineHeight } = getStyle(element);
  lineHeight = parseFloat(lineHeight);                     //解析浮点数
  fontSize = parseFloat(fontSize);
  const textHeightRatio = fontSize / lineHeight,           //字体占行高的比值
    firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(2),                         //渐变的第一个位置,小数点后两位四舍五入
    secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(2);    //渐变的第二个位置
  return `
        background-image: linear-gradient(
            transparent ${firstColorPoint}%, ${this.color} 0,
            ${this.color} ${secondColorPoint}%, transparent 0);
        background-size: 100% ${lineHeight};
        position: relative;
        color: transparent;
    `;
}
function getStyle(element, name) {
  const style = global.getComputedStyle(element);
  return name ? style[name] : style;
}
  首先读取字体大小和行高,然后计算字体占行高的比值(textHeightRatio),接着计算出渐变的两个位置(firstColorPoint和secondColorPoint),最后通过模板字面量输出文本的样式,字体颜色被设为了透明。
  绘制文本的逻辑都封装到了text()方法中,具体如下所示。


text(element) {
  //判断是否是只包含文本的节点
  const isText =
    element.childNodes &&
    element.childNodes.length === 1 &&
    element.childNodes[0].nodeType === NODE_TEXT &&
    /\S/.test(element.childNodes[0].textContent);
  if (!isText) {
    return;
  }
  const rule = this.calculate(element);     //计算样式
  element.setAttribute("style", rule);
}


3)表单

  表单控件目前只处理了input、select和button,它们中的文本会变透明,添加背景色,placeholder属性变空,如下所示。

form(element) {
  element.style.color = "transparent";             //内容透明
  element.style.background = this.color;           //重置背景
  element.setAttribute("placeholder", "");         //清除提示
  this.formFn && this.formFn.call(this, element);         //执行自定义着色规则
}

  formFn是一个特殊的参数,在插件初始化时可传递进来,因为表单比较复杂,所以要自定义着色规则。例如一些页面的表单结构是下面这样的,那么就需要将

  • 也添加背景色。

  • <ul>
      <li class="ui-flex">
        <input type="text" />
      li>
      <li class="ui-flex">
        <input type="text" />
      li>
    ul>


      自定义的着色规则如下所示,其中matches()是一个选择器匹配方法。


    new Skeleton({
      formFn: function(element) {
        while(element && !this.matches(element, "li.ui-flex"))
          element = element.parentNode;
        element && (element.style.background = this.color);
      }
    });
    matches(element, selector) {
      if (!selector || !element || element.nodeType !== NODE_ELEMENT)
        return false;
      const matchesSelector = element.webkitMatchesSelector || element.matchesSelector;
      return matchesSelector.call(element, selector);

    }


    4)移除

      因为骨架屏的特点是快速,所以在生成时需要移除多余的元素,例如指定区域外的元素、隐藏的元素和脚本元素,如下所示,其中isHideStyle()函数可判断是否是隐藏元素。


    removeElement(parent) {
      if (parent.children.length == 0) return;
      //有移除操作,所以未用Array.from()遍历
      for (let i = 0; i < parent.children.length; i++) {
        const element = parent.children[i],
          { top } = getRect(element);
        if (
          isHideStyle(element) ||                           //隐藏元素
          top >= this.rectHeight ||                         //超出指定高度
          element.nodeName.toLowerCase() == "script"        //脚本元素
        ) {
          element.remove();
          i--;
          continue;
        }
        this.removeElement(element);
      }
    }
    function isHideStyle(element) {
      return (
        getStyle(element, "display") == "none" ||
        getStyle(element, "visibility") == "hidden" ||
        getStyle(element, "opacity") == 0 ||
        element.hidden
      );
    }


      本来是想用Array.from()遍历元素,但删除后会影响迭代逻辑,因此改成了for循环语句。

      除了这三类元素之外,还得将注释节点也一并删除,如下所示。注意,childNodes与上面的children属性不同,它能够通过forEach()遍历。


    removeNode(parent) {
      if (parent.childNodes.length == 0) return;
      for (let i = 0; i < parent.childNodes.length; i++) {
        const node = parent.childNodes[i];
        if (node.nodeType === NODE_COMMENT) {
          node.remove();
          i--;
          continue;
        }
        this.removeNode(node);
      }
    }


    5)绘制

      绘制就是调用上面所提到的方法,包括移除元素、着色、替换图像等,具体如下所示。


    function draw() {
      this.container.style.background = "#FFF";         //容器背景重置
      //移除元素和节点
      this.removeElement(this.container);
      this.removeNode(this.container);
      //为文本添加
      this.appendTextNode(this.container);
      //处理普通元素
      Array.from(
        this.container.querySelectorAll(
          "div,section,footer,header,a,p,span,form,label,li"
        )
      ).map(element => {
        //背景图或背景颜色的处理
        const hasBg =
          getStyle(element, "background-image") != "none" ||
          getStyle(element, "background-color") != "rgba(0, 0, 0, 0)";
        if (hasBg) {
          this.image(element, false);
        }
        //文本处理
        this.text(element);
      });
      //处理表单中的控件
      Array.from(this.container.querySelectorAll("input,select,button")).map(
        element => {
          this.form(element);
        }
      );
      //元素处理
      Array.from(this.container.querySelectorAll("img")).map(img => {
        this.image(img);
      });
    }


    二、Puppeteer


      插件完成后,没有做到自动化,即需要在浏览器的控制台中手工执行骨架屏插件。翻阅资料后,大家都推荐使用Puppeteer。Puppeteer是一个Node库,它提供了一个高级API来通过DevTools协议控制Chromium或Chrome。也就是说,它是一个无头(headless)浏览器。

      一边翻资料,一边查看demo,尝试着写Node.js,后面跌跌撞撞的写出了可以执行的脚本。

      原理就是先打开无头浏览器;然后输入视口参数和页面地址,并添加插件地址;然后在打开的页面中执行插件,返回document.body中的HTML代码;最后将HTML写入到一个txt文件中。


    const puppeteer = require('puppeteer'),
        fs = require('fs');
    (async () => {
        const browser = await puppeteer.launch();
        const page = await browser.newPage();
        //视口参数
        await page.setViewport({width: 375, height: 667});
        // 事件监听,可用于调试
        page.on('console', msg => console.log('PAGE LOG:', msg.text()));
        // waitUntil 参数有四个关键字:load、domcontentload、networkidle0和networkidle2
        await page.goto('http://www.pwstrick.com/index.html', {waitUntil: 'networkidle2'});
        await page.addScriptTag({url: 'http://www.pwstrick.com/js/skeleton.js'});
        // 对打开的页面进行操作
        const html = await page.evaluate(() => {
            let sk = new Skeleton();
            sk.draw();
            return document.body.innerHTML;
        });
        //将骨架屏代码添加到content.txt文件中
        fs.writeFileSync('content.txt', html);
        await browser.close();
    })();


      本来是想在page.evaluate()中将插件以参数的形式传入,但一直不成功,后面就改成了page.addScriptTag(),引用插件的脚本。

      到目前为止,只能算是半自动化。要做到自动化,就得编写webpack插件,在打包的时候,将生成的HTML代码嵌入到页面中的指定位置,并且还要做到参数可配置化,以适合更多的场景。

      整个骨架屏插件只有200多行代码,去掉注释和空行只有160多行,本插件主要用于学习。

     


    相关文章
    |
    4月前
    |
    前端开发
    【threejs教程】终于搞明白了!原来threejs中的透视相机这么简单!
    【8月更文挑战第5天】深入学习threejs中的透视相机!
    141 2
    |
    6月前
    鬼刀画风扁平化粒子炫动引导页美化源码
    鬼刀画风扁平化粒子炫动引导页美化源码
    45 5
    鬼刀画风扁平化粒子炫动引导页美化源码
    |
    5月前
    |
    JavaScript 前端开发 开发者
    uniapp实战 —— 骨架屏
    uniapp实战 —— 骨架屏
    168 0
    |
    7月前
    |
    前端开发 JavaScript
    瀑布流布局怎样实现
    瀑布流布局怎样实现
    |
    7月前
    简约火箭发射静态404错误页面源码
    简约火箭发射静态404错误页面源码
    63 0
    简约火箭发射静态404错误页面源码
    |
    7月前
    |
    移动开发 小程序 前端开发
    【经验分享】如何实现在支付宝小程序内的骨架屏效果
    【经验分享】如何实现在支付宝小程序内的骨架屏效果
    92 6
    |
    7月前
    |
    前端开发 JavaScript 小程序
    轻量级骨架屏设计,让你的页面“薄荷清新”
    轻量级骨架屏设计,让你的页面“薄荷清新”
    |
    7月前
    只用一个背景图片实现九宫格抽奖(uniapp纯代码)
    只用一个背景图片实现九宫格抽奖(uniapp纯代码)
    85 0
    Photoshop怎么实现图片局部马赛克
    Photoshop怎么实现图片局部马赛克
    102 0
    |
    移动开发 前端开发

    热门文章

    最新文章