前端项目优化:全自动生成骨架屏工程化配置方案(下篇)

833 阅读9分钟

骨架屏演进简介

最近看了很多骨架屏生成的方案,每种方案或多或少都有一些问题。总结下来,骨架屏的演进大概经历了下面的几个过程。

1.手写css+html生成当前页面的骨架屏样式。缺点:页面改动要重新写样式和骨架,浪费资源,太麻烦。

2.设计师做设计稿时,同时设计出骨架屏图片,然后加载到页面。缺点,页面改动要重新设计UI,浪费资源,太麻烦。

3.基于无头浏览器puppeteer,根据页面url自动生成骨架屏,把生成的骨架屏代码保存到某个文件下,然后在使用骨架屏的页面引用。缺点:还是有一定的开发量,并且下载无头浏览器puppeteer需要3分钟甚至更久,每次npm install都会有漫长的等待。当然,已经解决了自动生成骨架屏的问题,很棒了。

4.基于无头浏览器puppeteer,根据页面url自动生成骨架屏,然后通过webpack在构建时,打包到项目的html模板中。当然也有通过SSR注入的方式,和webpack打包到html中基本类似。缺点同第3种。优点就是自动化注入到html,更加自动化。

基于以上4个过程,站在前人的肩膀上,我开发了更加智能化,更高效的骨架屏生成和注入方案。

我的骨架屏方案技术思路需要下面5个步骤

  1. 基于puppeteer无头浏览器生成骨架屏
  2. 基于node服务搭建的骨架屏生成接口
  3. 基于express开发中间件
  4. 服务中引入中间件,使用生成和注入骨架屏的功能
  5. express+字符串模板语法注入html

下面会针对以上5点,详细阐述我的骨架屏开发思路。

1.基于puppeteer无头浏览器生成骨架屏

这一点可以参考我上一篇文章基于puppeteer自动生成骨架屏的方案介绍,也可以看我开发的npm包 auto-skeleton,相信你看完之后会有一定的收收获。

2. 基于node服务搭建的骨架屏生成接口

这一点可以参考我开发的一个demo项目。这个项目部署了一个简单的node服务,定义端口号为7001,然后引入上面提到的npm包auto-skeleton, 骨架屏自动生成服务

核心代码如下:

在router.js文件中定义post请求,路径路径是/skeleton/getContentByUrl,指向的处理函数是getContentByUrl

router.post('/skeleton/getContentByUrl', controller.skeleton.getContentByUrl);

在controller中定义的处理函数如下

const Controller = require('egg').Controller;
const getSkeleton = require('auto-skeleton');

const initOptions = {
  pageName: 'mySkeletonPage',
  pageUrl: '',
  openRepeatList: true,
  device: 'iPhone X',
  minGrayBlockWidth: 0,
  minGrayPseudoWidth: 0,
  writeFile: false,
  debug: false,
  debugTime: 100000,
  cookies: [],
};
class SkeletonController extends Controller {
 async getContentByUrl() {
    try {
      const { ctx } = this;
      // 接收参数为 pageUrl 和骨架屏的配置 options
      const { pageUrl, options = {} } = ctx.request.body;
      options.pageUrl = pageUrl;
      const finalOptions = {
        ...initOptions,
        ...options,
      };
      // 一般生成骨架屏都会比较久,此处的await大概有10s左右。不过,不用担心,后面会通过工程化手段规避掉这个等待问题。
      const res = await getSkeleton(finalOptions);
      // 此处的res对象包含三个字段,可以根据自己的需要选择使用下面三种骨架屏形式。
      // res = {
      //   minHtml: minifyContent, // 压缩后的骨架屏html
      //   html: content, // 未压缩的骨架屏html
      //   img: skeletonImageBase64, // 转化成base64图片的骨架屏代码,
      // }
      
      ctx.body = {
        code: '0',
        content: {
          ...res,
          message: '骨架屏生成成功,感谢使用',
        },
      };
    } catch (e) {
      this.body = {
        code: '500',
        content: { message: '生成失败,请重试' },
      };
    }
  }
}
module.exports = SkeletonController;

通过上面的的代码,我们可以使用使用下面的方法生成骨架屏,伪代码

{
    method: 'post',
    url: 'http://127.0.0.1:7001//skeleton/getContentByUrl',
    data: {
        pageUrl: 'www.xxxx.com?a=123', //任意需要生成骨架屏的链接
        options: {
            // 一些自定义的配置,根据配置生成自己想要的骨架屏。具体配置可参看https://www.npmjs.com/package/auto-skeleton
        }
    },
    success: (res) => {
        // 此处的res即为生成的骨架屏代码
        // res = {
        //   minHtml: minifyContent, // 压缩后的骨架屏html
        //   html: content, // 未压缩的骨架屏html
        //   img: skeletonImageBase64, // 转化成base64图片的骨架屏代码,
        // }
        
        // 这里可以写一些自己的处理逻辑,我在此处用的是一个中间件和模板语法,注入骨架屏到html
    }
}
3. 基于express开发中间件

有了上面两个步骤,我们的骨架屏方案已经完成了70%,下面就是通过工程化的手段,把骨架屏注入到页面,然后在请求页面时注入到html。

先来看我们的express中间件。这个中间件主要作用包括以下几个方面

1.获取当前项目请求的url

2.把当前请求的url作为参数,请求我们在第二步部署的骨架屏生成服务器

3.返回我们的骨架屏代码

4.如果是首次请求,生成一个文件夹,命名为auto-skeleton

5.根据url生成一个文件名,然后把请求返回的骨架屏代码放进去。此处生成的文件夹做了一点取巧的办法,截取url上的最后三位斜杠包含的字符串,然后把斜杠转为点作为文件名,比如url链接为 www.a/b/c?a=123,此时生成的文件名即为a.b.c,添加后缀html,最后生成的文件名即为 a.b.c.html。我们建立了一个文件名和url路径的映射关系,下次可以基于这个映射关系,直接读取文件。

6.如果下次请求已经有了当前文件,不再请求骨架屏接口,直接读取文件。

7.把读取的文件内容通过 res.locals.skeleton 注入到express请求的 res.locals中备用

8.这里也做了一些兼容处理,在express服务下,我们给链接拼了一个skeleton=skeleton参数作为标记。仔细想,如果不这样做,会造成无限次循环。因为通过无头浏览器打开时,还是会请求这里的代码,然后又打开一个新的无头浏览器,循环不止。

9.同时,如果生产环境自动生成生成的骨架屏发现不合适,可以在url参数上拼接 createSkeletonAgain=true再次生成一次,覆盖之前的。

10.更多优化手段,应该都可以通过这个中间件实现。

11.注意:无论任何形式的优化,最终都是为了拿到 res.locals.skeleton='骨架屏内容content',为注入html做准备。

此处执行request请求之前,运行用户先执行next()跳到下一个环节去,不会耽误正常的页面请求,执行完next()之后再请求request本质上即是一个异步的费阻塞IO,因此对原始的项目不会有任何等待的问题。这也就绕开了骨架屏生成需要10s的等待的问题。

下面为一个可用的代码范本,仅供参考。


const fs = require('fs');
const path = require('path');
const request = require('request');

/**
 * 递归创建目录
 */
const makeDirs = async (dirPath) => {
  if (!fs.existsSync(dirPath)) {
    await fs.mkdirSync(dirPath);
  }
  return Promise.resolve();
};

/**
 * 判断url是否有某个参数
 * @param url
 * @param string
 * @returns {boolean}
 */
const hasQuery = (url, string) => {
  return url.indexOf(string) > 0;
};

/**
 * url转为文件名称:以m.productForMixin.list.html命名的html文件
 * /m/productForMixin/list?userId=1173783329200144789 => m.productForMixin.list
 * @param url
 * @returns {string}
 *
 */
const formatSlashToDot = (url) => {
  return url
    .split('/')
    .slice(-3)
    .join('.');
};

const formatStringHasQuery = (str) => {
  // 根据是否有?处理参数
  if (hasQuery(str, '?')) {
    return formatSlashToDot(str.slice(0, str.indexOf('?')));
  }
  return formatSlashToDot(str);
};

// 这是个express中间件
export default async (req, res, next) => {
  res.locals.skeleton = '';
  const { url, headers } = req;
  // 如果url中有标记skeleton字段,表示是从生成骨架屏打开的链接,阻止循环调用
  if (hasQuery(url, 'skeleton')) {
    next();
    return;
  }

  const pagePath = formatStringHasQuery(url);

  const folder = path.join(process.cwd(), 'skeleton-output');

  const createSkeletonFunc = () => {
    let pageUrl = `${headers.host}${url}`;
    // 这里还需要再斟酌一下。看是否需要这个判断,判读是否合理。
    if (!hasQuery(pageUrl, 'http')) {
      pageUrl = `http://${pageUrl}`;
    }
    // url标记skeleton参数
    pageUrl = hasQuery(pageUrl, '?')
      ? `${pageUrl}&skeleton=skeleton`
      : `${pageUrl}?skeleton=skeleton`;
    const options = {
      url: 'http://127.0.0.1:7001/skeleton/getContentByUrl',
      method: 'POST',
      json: true,
      body: {
        pageUrl,
        options: {
          removeBodySkeletonClass: 'remove-body-skeleton',
        },
      },
    };
    request(options, async (err2, result) => {
      if (err2) {
        console.log(`options-request-error: ${err2}`);
        return;
      }
      const resData = result.body;
      if (String(resData.code) === '0') {
        await makeDirs(folder);
        const content = result.body.content.html;
        fs.writeFileSync(
          `${folder}/${pagePath}.html`,
          content,
          'utf8',
          (err3) => {
            if (err3) {
              console.error(`options-response-error: ${err3}`);
            }
          },
        );
      }
    });
  };

  try {
    /**
     * 如果链接有参数 createSkeletonAgain,再生成一次,覆盖之前的。
     * 如果用户自动生成的不合适,开发者可以手动输入链接,再生成一次
     */
    if (hasQuery(url, 'createSkeletonAgain')) {
      next();
      createSkeletonFunc();
      return;
    }
    // 判断是否已生成了骨架屏html
    await fs.readFile(
      `${folder}/${pagePath}.html`,
      'utf-8',
      async (err, data) => {
        // 已生成,读取骨架屏代码dom,塞入模板的html中
        if (!err) {
          // 会在res.render注入
          res.locals.skeleton = data;
          next();
        } else if (!hasQuery(url, 'null')) {
          // 重定向304或者爬虫robot会出现null。
          next();
          createSkeletonFunc();
        } else {
          // 会在res.render注入
          next();
        }
      },
    );
  } catch (e) {
    next();
  }
};


4. 在服务中引入中间件,使用生成和注入骨架屏的功能
import Express from 'express';
const app = Express();
// 引入骨架屏中间件,生产环境用
app.use(skeleton);

// 此处配置自己的页面路由

app.listen(8080, () => {
  console.info(`==> 🍺  Server running at localhost: 8080`);
});

5. express+字符串模板语法注入html

这个步骤相对来说就比较简单了

首先,我们需要定义一个模板html。此处我们在body中定义了两个div,第一个div是承载骨架屏的的代码,会用 <%= skeleton %>替换我们的 res.locals.skeleton='我是自动生成的骨架屏内容',那么最终展示到前端页面的代码即为 <div class="remove-body-skeleton">我是自动生成的骨架屏内容</div>

然后,我们知道在express中,可以通过res.render方法注入变量,基于这样的方法,很容易就实现了注入。核心代码如下。此处的skeleton: res.locals.skeleton 最终会结合下面的html模板,返回给前端。

 res.render(page, {
    skeleton: res.locals.skeleton,
  });

这里同样要注意,此处的 class="remove-body-skeleton" 需要和 request请求的options参数 removeBodySkeletonClass一致,最终获取真实业务内容后,删除时会获取removeBodySkeletonClass对应的class,删除注入的前端的骨架屏。

看到过一些方案,在vue或者react项目中,有些前端小伙伴把骨架屏注入到了 <div id="app"><%= html %></div>里面,我一开始也是这样做的。后来发现,一旦react或者vue的脚本加载完成后,会清空app里面注入的内容。造成的现象就是,骨架屏代码在ajax请求还没开始时就被react框架清除了,骨架屏一闪而过,几乎不可见,失去了骨架屏注入的意义。

  <body>
    <div class="remove-body-skeleton"><%= skeleton %></div>
    <div id="app"><%= html %></div>
  </body>

我们最终生成的骨架代码会包含下面的一段scriipt代码。这段代码会去读取class remove-body-skeleton,然后在window的load方法执行后删除骨架屏。load方法是在页面中所有资源加载完毕后才执行了,此时应该刚好生成了真实的页面,或者即将生成真实的页面,此时删除骨架屏,恰到好处。

<script class="${skeletonClass}">
      // Define hooks
      window.SKELETON = {
        destroy: function () {
          // 自定义需要删除的class类。
          var removeBodySkeleton = Array.from(document.body.querySelectorAll('.${removeBodySkeletonClass}'));
          if (removeBodySkeleton && removeBodySkeleton.length > 0) {
            document.body.removeChild(removeBodySkeleton[0]);
          } else {
            var removes = Array.from(document.body.querySelectorAll('.${skeletonClass}'));
            removes && removes.map(function(item){
              document.body.removeChild(item);
            });
          }
        }
      };
      // destroy after the onload event by default
      window.addEventListener('load', function(){
        setTimeout(function(){
          window.SKELETON && SKELETON.destroy()
        }, 0);
      });
    </script>

问题和总结

基于以上几个流程,我们的自动生成和自动注入骨架屏技术已经全部完成。这种方案真正实现了全自动生成和注入的方案。我们可以单独部署自动生成骨架屏的服务,其它任何业务项目都可以来调用我们的骨架屏生成服务,只需要我们在业务项目中配置对应的中间件即可。如果每个项目都引用一次无头浏览器,每次发版的 npm install 都要用大量的时间下载无头浏览器。(我所在的公司部署的有私有的npm镜像,即使是在内网的npm镜像下载无头浏览器还需要3分钟,可想而知会让人多崩溃)。

这种骨架屏生成技术在单页面应用程序上会有一些不愉快的体验,因为单页应用只有第一个路由会请求后端的html模板,其它请求都是只加载对应的js脚本生成页面内容。因此,无法实现自动注入。这里也提出一个不是很成熟的解决方案。

单页应用的跳转一般都是通过 router.push(url) 来实现的,这种方法是前端路由,只会请求js脚本。如果换成 window.location.href = url 这种方式实现跳转,每次请求都会经过后端路由,从而实现每次请求都会注入骨架屏代码到html中。这种方案已经在我们项目中有所应用,但是有时发现,通过router.push(url)有时候也会很快就生成页面,其实用不到骨架屏,加上骨架屏反而有点多此一举。所以我们会区别对待,对一一些加载比较快的页面,我们会通过 router.push(url) 的方法加载。加载比较慢的,需要用到骨架屏的地方,我们会用 window.location.href = url

一些发散性思考:对于单页应用,能否在生成骨架屏时和当前单页应用的js脚本之间建立一种映射关系,每次前端路由加载是,请求js脚本的同时也请求骨架屏,实现前端的注入。

或者,有其它一些手段,实现更加完善的骨架屏方案,欢迎大家讨论。