zoukankan      html  css  js  c++  java
  • 简单粗暴的骨架屏实现

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

      在网上阅读了一些骨架屏原理的资料后,就自己想尝试一下,练练手,制作一个极简版本的骨架屏插件。因为简单,所以未来如要扩展,成本也会很低。上图是通过自己写的骨架屏插件得到的效果,对于公司简单结构的项目,还是游刃有余的。在编写插件时,参考了网上多篇资料分享的代码,站在巨人的肩膀上整合代码,省力了很多。插件的完整代码已上传至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)图像

      图像也就是<img>元素,其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;
    }

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

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

    2)文本

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

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

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

    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);
        }
      });
    }

      下面的第一个<p>元素在调用了appendTextNode()方法后,就变成了第二个<p>元素。

    <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是一个特殊的参数,在插件初始化时可传递进来,因为表单比较复杂,所以要自定义着色规则。例如一些页面的表单结构是下面这样的,那么就需要将<li>也添加背景色。

    <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);
      //为文本添加<span>
      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);
        }
      );
      //<img>元素处理
      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({ 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多行,本插件主要用于学习。

    GitHub地址如下:

    https://github.com/pwstrick/skeleton

    参考资料:

    一种自动化生成骨架屏的方案

    前端骨架屏方案小结

    网页骨架屏自动生成方案(dps)

    一个前端非侵入式骨架屏自动生成方案

    puppeteer

    puppeteer中文

    编写一个webpack插件

  • 相关阅读:
    85. Maximal Rectangle
    120. Triangle
    72. Edit Distance
    39. Combination Sum
    44. Wildcard Matching
    138. Copy List with Random Pointer
    91. Decode Ways
    142. Linked List Cycle II
    异或的性质及应用
    64. Minimum Path Sum
  • 原文地址:https://www.cnblogs.com/strick/p/12175534.html
Copyright © 2011-2022 走看看