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插件

  • 相关阅读:
    后台线程处理数据,如何实时更新UI(datagridview)多线程同步问题
    DataGridView设置行高
    C#打开外部文件,如txt文件
    20120621第一天_复习与测试\04continue
    关于C#正则表达式MatchCollection类的总结
    关于sqlite中的一个错误 “database is locked"
    UI中 加个timer 写个while true的方法 不断获取run的对象
    最近帮公司开发一个邮件营销软件 用到XPTable 但找了很多方法 摸索了很久都不知道如何更新进度条 不过在国外的一个网站 终于找到答案了
    C# 简单的往txt中写日志,调试时很有用 【转】
    输入要匹配的内容和正则表达式规则 返来单个匹配的内容 正则表达式方法 常用
  • 原文地址:https://www.cnblogs.com/strick/p/12175534.html
Copyright © 2011-2022 走看看