一个简易的预渲染自动骨架屏方案

1,500 阅读5分钟

前言

我们都知道,目前传统的 SPA 网页在完成脚本加载后,通常还需要进行接口请求,拿到远端数据后才能进行完整地内容呈现 而在接口请求的过程中,为了过渡无数据的空白场景,并提示用户“数据请求中”,常用的方法为做一个 loading 动画效果

而在用户胃口越来越刁的今天,一个简单的 loading 效果已经不太能安抚用户了,而骨架屏就是一种安抚用户的进阶方案

最终成品链接(懒人用):auto-skeleton-plugin

什么是骨架屏?

简单来说,骨架屏就是在还未产生可阅读内容时,先将网页的大致结构框架呈现给用户,以达到安抚用户等待过程中的不耐烦心理、提升用户存留的效果

骨架屏的实现,通常有两种方式

  1. 手动书写骨架
  2. 自动生成骨架

手动写骨架的方式,好处是可以做出高定制性的骨架效果,缺点是开发成本大,效率低,但本文不对此方式进行展开

那么如何实现自动骨架屏的效果呢?一个简单的方式是:将已有内容的样式进行调整,生成对应的骨架效果,例如以下代码,可以将所有文字内容,变成骨架条块

function generateSkeleton() {
  // 文字节点
  ;[...document.querySelectorAll('*')]
    .filter(
      (node) =>
        !['script', 'style', 'html', 'body', 'head', 'title'].includes(
          node.tagName.toLowerCase()
        )
    )
    .map((node) => [...node.childNodes].filter((node) => node instanceof Text))
    .flat(Infinity)
    .forEach((node) => {
      let span = document.createElement('span')
      node.parentNode.insertBefore(span, node)
      span.appendChild(node)
      span.style = `
        background: #f2f2f2;
        color: transparent !important;
      `
    })
}

这样,只要我们完善不同内容如图片、图标等元素的骨架化过程,就可以得到一个相对可用的内容骨架化效果

自动骨架化的好处是,生成骨架的效率高,开发成本很低,但缺点是定制性相对较差,需要根据已有内容来确定骨架效果

但这有一个问题,我们期望是在应用刚打开时,还未请求数据前就呈现骨架,目前显然是做不到的

而我们可以借助“预渲染”来实现期望的效果

什么是预渲染?

预渲染类似服务端渲染,它的过程大概是这样的:在应用完成打包后,立刻启动一个 headless 浏览器进行页面访问,再将访问的结果输出成 html 文件的渲染过程

通俗地说就是:打包完后本地先访问看一看,看到啥就“截个屏”存起来,然后输出一个 html 文件,覆盖原本构建生成的 index.html 这样,用户访问打包好的 index.html 时,看到的就是一个有内容的网页

那么,借助预渲染,我们可以将上述自动骨架屏的过程,放在 headless 浏览器加载出网页内容后,具备内容后再将内容骨架化,再输出成 html,就可以实现用户访问时,还未请求数据前,先呈现骨架的效果

自动骨架屏的过程实现

我们可以参考一个常用的预渲染的 webpack 插件 prerender-spa-plugin 来实现这个过程

查阅源码可知,这个插件并未实现核心渲染过程,其实只是将 prerenderer 包装成了 webpack 插件的形式,并承担了将最终结果输出成 html 产物文件的功能

关键源码:github.com/chrisvfritz…

...
const Prerenderer = require('@prerenderer/prerenderer')
...
function PrerenderSPAPlugin (...args) {
 ...
  const afterEmit = (compilation, done) => {
    const PrerendererInstance = new Prerenderer(this._options)

    PrerendererInstance.initialize()
      .then(() => {
        return PrerendererInstance.renderRoutes(this._options.routes || [])
      })
      ...
  }
  ...
}
...
module.exports = PrerenderSPAPlugin

prerenderer 承担的则是使用 headless 浏览器访问网页,并输出访问结果的功能,其官方内置了两种可选的 headless 浏览器:puppeteerjsdom

由于 puppeteer 需要下载的内容较大,我们考虑使用较轻量的 jsdom 来完成这个效果

在翻阅了部分 renderer-jsdom 的源码后,可以找到 headless 浏览器采集网页内容的部分

关键源码:github.com/JoshTheDerf…

我们只需要在采集网页内容前,对内容进行骨架化,就可以得到期望的效果

const JSDOM = require('jsdom/lib/old-api.js').jsdom
...
const getPageContents = function (window, options, originalRoute) {
  ...
  return new Promise((resolve, reject) => {
    ...
    function captureDocument () {
      // 此处可在输出 html 结果前,先对网页内容进行骨架化
      // generateSkeleton 就是上边咱们整理出来的 dom 操作实现自动骨架化过程
      generateSkeleton(window)

      const result = {
        ...
        html: serializeDocument(window.document)
      }
      ...
      return result
    }
    ...
  }
  ...
}

class JSDOMRenderer {
  ...
  async renderRoutes (routes, Prerenderer) {
    ...
    const results = Promise.all(routes.map(route => limiter(() => {
      return new Promise((resolve, reject) => {
        JSDOM.env({
          url: `http://127.0.0.1:${rootOptions.server.port}${route}`,
          ...
        })
      })
      .then(window => {
        return getPageContents(window, this._rendererOptions, route)
      })
    })))
    ...
    return results
  }
  ...
}

module.exports = JSDOMRenderer

至此,简易自动骨架屏效果的方案已经叙述完成,整个过程,需要我们自己动手的主要是骨架化过程的部分,其余之处,都可通过参考已有过程实现来完成,那么具体过程实现,此处就不再继续展开了,动手能力强的小伙伴,大概可以自己一把梭出来

结尾

预渲染方案待展开的功能还是有不少的,例如

  1. 如何内联样式?(这条比较容易做到,借助 jsdom 自身的 resourceLoader 足矣)
  2. 如何保留关键样式,去除无用样式?(有一定难度,可参考 uncss,配合 postcss 实现)
  3. 预渲染性能是否充足,能否用来做 SSR? (jsdom 渲染速度较快,此处进行了实践 santi

以下是上述方案的自动骨架插件实现,目前自动骨架化的过程比较简陋,只具备了基础的可用性,也希望能得到大家的帮助,共同完善自动骨架化的过程

auto-skeleton-plugin