web 前端web

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

2021-12-17  本文已影响0人  子瞻是也

今天分享的主题是:「一种自动化生成骨架屏的方案」, 先看下市场上常见的骨架屏优化效果。

image 淘宝 PC端 京东 PC端 image 微博 APP

今天的分享主要分为三个部分:

  1. 首屏加载状态演进
  2. 如何构建骨架屏
  3. 将骨架屏打包的项目中

首屏加载的演进

我们先来看一些权威机构所做的研究报告。

一份是 Akamai 的研究报告,当时总共采访了大约 1048 名网上购物者,得出了这样的结论:

image

这是 TagMan 和眼镜零售商 Glasses Direct 合作进行的测试,研究页面加载速度和最终转化率的关系:


image

在这份测试报告中,发现了网页加载速度和转化率呈现明显的负相关性,在页面加载时间为1~2 秒时的转化率是最高的,而当加载时间继续增长,转化率开始呈现一个下降的趋势,大约页面加载时间每增加 1s 转化率下降6.7个百分点。

另外一份研究报告是 MIT 神经科学家在 2014 年做的研究,人类可以在 13ms 内感知到离散图片的存在,并将图片的大概信息传输到我们的大脑中,在接下来的 100 到 140ms 之间,大脑会决定我们的眼睛具体关注图片的什么位置,也就是获取图片的关注焦点。从另一个角度来看,如果用户进行某项交互(比如点击某按钮),要让用户感知不到延迟或者数据加载,我们大概有 200 ms 的时间来准备新的界面信息呈现给用户。

在 200ms 到 1s 之间,用户似乎还感知不到自己处在交互等待状态,当一秒钟后依然得不到任何反馈,用户将会把其关注的焦点移到其他地方,如果等待超过 10s,用户将对网站失去兴趣,并浏览其他网站。

那么我们需要做些什么来留住用户呢?

通常方案,我们会在首屏、或者获取数据时,在页面中展现一个进度条,或者转动的 Spinner。

有了进度条或者 Spinner,至少告诉了用户两点内容:

除此之外,进度条和 Spinner 并不能带来其他任何作用,既无法让用户感知到页面加载得更快,也无法给用户一个焦点,让用户将关注集中到这个焦点上,并且知道这个焦点即将呈现用户感兴趣的内容。

那么有没有比进度条和 Spinner 更好的方案呢?也许我们需要的是骨架屏。

image
其实,骨架屏(Skeleton Screen)已经不是什么新奇的概念了,Luke Wroblewski 早在 2013 年就首次提出了骨架屏的概念,并将这一概念成功得运用到他当时的产品「Polar app」中,2014 年,「Polar」加入 Google,Luke Wroblewski 本人也成为了Google 的一位产品总监。

A skeleton screen is essentially a blank version of a page into which information is gradually loaded.

他是这样定义骨架屏的,他认为骨架屏是一个页面的空白版本,通过这个空白版本传递信息,我们的页面正在渐进式的加载过程中。

苹果公司已经将骨架屏写入到了 iOS Human Interface Guidelines ,只是在该手册中,其用了一个新的概念「launch images」。在该手册中,其推荐在应用首屏中包含文本或者元素基本的轮廓。

2015 年,Facebook 也首次在其移动端 App 中使用了骨架屏的设计来预览页面的加载状态。


image

随后,Twitter,Medium,YouTube 也都在其产品设计中添加了骨架屏,骨架屏一时成为了首屏加载的新趋势,国内一些公司也紧随其后,饿了么、知乎、掘金、腾讯新闻等也都在其 PC 端或者移动端加入了骨架屏设计。

<b>为什么需要骨架屏?</b>

如何构建骨架屏

饿了么移动 web 页面在 2016 年开始引入骨架屏,是完全通过 HTML 和 CSS 手写的,手写骨架屏当然可以完全复刻页面的真实样式,但也有弊端:

举个例子,突然有一天,产品经理跑到了我面前,这个页面布局需要调整一下,然后这一块推广内容可以去掉了,我当时的心情可能是这样的。


image

手写骨架屏带来的问题就是,每次需求的变更我们不仅需要修改业务代码, 同时也要去修改骨架屏的样式和布局,这往往是比较机械重复的工作,手写骨架屏增加了维护成本。

因此饿了么前端团队一直在寻找一种更好、更快的将数据呈现到用户面前的方案。

在选择骨架屏之前,我们也调研了其他两种备选方案:服务端渲染(ssr)和预渲染(prerender)。

image
现在,前端领域,不同框架下,服务端渲染的技术已经相当成熟,开箱即用的方案也有,比如 VueNuxt.js。那么为什么不直接使用服务端渲染来加快内容展现?

首先我们了解到,服务端渲染主要有两个目的,一是 SEO,二是加快内容展现。在带来这两个好处的同时,我们也需要评估服务端渲染的成本,首先我们需要服务端的支持,因此涉及到了到了服务构建、部署等,同时我们的 web 项目是一个流量较大的网站,也需要考虑服务器的负载,以及相应的缓存策略,特别是一些外卖行业,由于地理位置的不同,不同用户看到的页面也是不一样的,也就是所谓的千人千面,这也为缓存造成了一定困难。


image

其次,预渲染(prerender),所谓预渲染,就是在项目的构建过程中,通过一些渲染机制,比如 puppeteer 或则 jsdom 将页面在构建的过程中就渲染好,然后插入到 html 中,这样在页面启动之前首先看到的就是预渲染的页面了。但是该方案最终也抛弃了,预渲染渲染的页面数据是在构建过程中就已经打包到了 html 中, 当真实访问页面的时候,真实数据可能已经和预渲染的数据有了很大的出入,而且预渲染的页面也是一个不可交互的页面,在页面没有启动之前,用户无法和预渲染的页面进行任何交互,预渲染页面中的数据反而会影响到用户获取真实的信息,当涉及到一些价格、金额、地理位置的地方甚至会导致用户做出一些错误的决定。因此我们最终没有选择预渲染方案。

生成骨架屏基本方案

通过 puppeteer 在服务端操控 headless Chrome 打开开发中的需要生成骨架屏的页面,在等待页面加载渲染完成之后,在保留页面布局样式的前提下,通过对页面中元素进行删减或增添,对已有元素通过层叠样式进行覆盖,这样达到在不改变页面布局下,隐藏图片和文字,通过样式覆盖,使得其展示为灰色块。然后将修改后的 HTMLCSS 样式提取出来,这样就是骨架屏了。

下面我将通过 page-skeleton-webpack-plugin 工具中的代码,来展示骨架屏的具体生成过程。

正如上面基本方案所描述的那样,我们将页面分成了不同的块:

首先,我们为什么要把页面划分为不同的块呢?

将页面划分为不同的块,然后分别对每个块进行处理,这样不会破坏页面整体的样式和布局,当我们最终生成骨架屏后,骨架屏的布局样式将和真实页面的布局样式完全一致,这样就达到了复用样式及页面布局的目的。

在所有分开处理之前,我们需要完成一项工作,就是将我们生成骨架屏的脚本,插入到 puppeteer 打开的页面中,这样我们才能够执行脚本,并最终生成骨架屏。

值得庆幸的是,puppeteer 在其生成的 page 实例中提供了一个原生的方法。

  page.addScriptTag(options)
    options<Object>
        url
        path
        content
        type(Use 'module' in order to load a Javascript ES6 module.)

有了这种方法,我们可以插入一段 js 脚本的 url 或者是相对/绝对路径,也可以直接是 js 脚本的内容,在我们的实践过程中,我们直接插入的脚本内容。

  async makeSkeleton(page) {
    const { defer } = this.options
    await page.addScriptTag({ content: this.scriptContent })
    await sleep(defer)
    await page.evaluate((options) => {
      Skeleton.genSkeleton(options)
    }, this.options)
  }

有了上面插入的脚本,并且我们在脚本中提供了一个全局对象 Skeleton,这样我们就可以直接通过 page.evaluate 方法来执行脚本内容并最终生成骨架页面了。

由于时间有限,这儿不会对每个块的生成骨架结构进行详尽分析,这儿可能会重点阐述下文本块、图片块、svg 块如何生成骨架结构的,然后再谈谈如何对骨架结构进行优化。

文本块的骨架结构生成

文本块可以算是骨架屏生成中最复杂的一个区块了,正如上面也说的,任何只包含文本节点的元素都将视为文本块,在确定某个元素是文本块后,下一步就是通过一些 CSS 样式,以及元素的增减将其修改为骨架样式。


image

在这张图中,图左边虚线框内是一个 p 元素,可以看到其内部有 4 行文本,右图是一个已经生成好的带有 4 行文本的骨架屏。在生成文本块骨架屏之前,我们首先需要了解一些基本的参数。

在这些参数中,fontSize、lineHeight、paddingTop、paddingBottom 都可以通过 getComputedStyle 获取到,而元素的高度 height 可以通过 getBoundingClientRect 获取到,有了这些参数后我们就能够绘制文本块的骨架屏了。

image
相信很多人都读过 @Lea Verou 的 CSS Secrets 这本书,书中有一篇专门阐述怎么通过线性渐变生成条纹背景的文章,而在绘制文本块骨架屏方案,正是受到了这篇文章的启发,文本块的骨架屏也是通过线性渐变来绘制的。核心简化代码:
const textHeightRatio = parseFloat(fontSize, 10) / parseFloat(lineHeight, 10)
const firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(decimal)
const secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(decimal)

const rule = `{
  background-image: linear-gradient(
    transparent ${firstColorPoint}%, ${color} 0%,
    ${color} ${secondColorPoint}%, transparent 0%);
  background-size: 100% ${lineHeight};
  position: ${position};
  background-origin: content-box;
  background-clip: content-box;
  background-color: transparent;
  color: transparent;
  background-repeat: repeat-y;
}`

我们首先计算了 lineHeight 和 fontSize 等一些样式参数,通过这些参数我们计算出了文本占整个行高的比值,也就是 textHeightRadio,有了这一比值,就可以知道灰色条纹的分界点,正如 @Lea Verou 所说:

摘自:CSS Secrets
“If a color stop has a position that is less than the specied position of any color stop before it in the list, set its position to be equal to the largest speci ed position of any color stop before it.”
— CSS Images Level 3 (http://w3.org/TR/css3-images)

也就是说,在线性渐变中,如果我们将线性渐变的起始点设置小于前一个颜色点的起始值,或者设置为0 %,那么线性渐变将会消失,取而代之的将是两条颜色分明的条纹,也就是说不再有线性渐变。

在我们绘制文本块的时候,backgroundSize 宽度为 100%, 高度为 lineHeight,也就是灰色条纹加透明条纹的高度是 lineHeight。虽然我们把灰色条纹绘制出来了,但是,我们的文字依然显示,在最终骨架样式效果出现之前,我们还需要隐藏文字,设置 color:‘transparent’ 这样我们的文字就和背景色一致,最终显示得也就是灰色条纹了。

根据 lineCount 我们可以判断文本块是单行文本还是多行,在处理单行文本的时候,由于文本的宽度并没有整行宽度,因此,针对单行文本,我们还需要计算出文本的宽度,然后设置灰色条纹的宽度为文本宽度,这样骨架样式的效果才能够更加接近文本样式。

图片块的骨架生成

图片块的绘制比文本块要相对简单很多,但是在订方案的过程中也踩了一些坑,这儿简单分享下采坑经历。

最初订的方案是通过一个 DIV 元素来替换 IMG 元素,然后设置 DIV 元素背景为灰色,DIV 的宽高等同于原来 IMG 元素的宽高,这种方案有一个严重的弊端就是,原来通过元素选择器设置到 IMG 元素上的样式无法运用到 DIV 元素上面,导致最终图片块的骨架效果和真实的图片在页面样式上有出入,特别是没法适配不同的移动端设备,因为 DIV 的宽高被硬编码。

接下来我们又尝试了一种看似「高级」的方法,通过 Canvas 来绘制和原来图片大小相同的灰色块,然后将 Canvas 转化为 dataUrl 赋予给 IMG 元素的 src 特性上,这样 IMG 元素就显示成了一个灰色块了,看似完美,当我们将生成的骨架页面生成 HTML 文件时,一下就傻眼了,文件大小尽然有 200 多 kb,我们做骨架页面渲染的一个重要原因就是希望用户在感知上感觉页面加载快了,如果骨架页面都有 200 多 kb,必将导致页面加载比之前要慢一些,违背了我们的初衷,因此该方案也只能够放弃。

最终方案,我们选择了将一张1 * 1 像素的 gif 透明图片,转化成 dataUrl ,然后将其赋予给 IMG 元素的 src 特性上,同时设置图片的 width 和 height 特性为之前图片的宽高,将背景色调至为骨架样式所配置的颜色值,完美解决了所有问题。

// 最小 1 * 1 像素的透明 gif 图片
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'

这是1 * 1像素的 base64 格式的图片,总共只有几十个字节,明显比之前通过 Canvas 绘制的图片小很多。

代码:

function imgHandler(ele, { color, shape, shapeOpposite }) {
  const { width, height } = ele.getBoundingClientRect()
  const attrs = {
    width,
    height,
    src
  }

  const finalShape = shapeOpposite.indexOf(ele) > -1 ? getOppositeShape(shape) : shape

  setAttributes(ele, attrs)

  const className = CLASS_NAME_PREFEX + 'image'
  const shapeName = CLASS_NAME_PREFEX + finalShape
  const rule = `{
    background: ${color} !important;
  }`
  addStyle(`.${className}`, rule)
  shapeStyle(finalShape)

  addClassName(ele, [className, shapeName])

  if (ele.hasAttribute('alt')) {
    ele.removeAttribute('alt')
  }
}

svg 块骨架结构

svg 块处理起来也比较简单,首先我们需要判断 svg 元素 hidden 属性是否为 true,如果为 true,说明该元素不展示的,所以我们可以直接删除该元素。

if (width === 0 || height === 0 || ele.getAttribute('hidden') === 'true') {
  return removeElement(ele)
}

如果不是隐藏的元素,那么我们将会把 svg 元素内部所有元素删除,减少最终生成的骨架页面体积,其次,设置svg 元素的宽、高和形状等。

const shapeClassName = CLASS_NAME_PREFEX + shape
shapeStyle(shape)

Object.assign(ele.style, {
  width: px2relativeUtil(width, cssUnit, decimal),
  height: px2relativeUtil(height, cssUnit, decimal),
})

addClassName(ele, [shapeClassName])

if (color === TRANSPARENT) {
  setOpacity(ele)
} else {
  const className = CLASS_NAME_PREFEX + 'svg'
  const rule = `{
    background: ${color} !important;
  }`
  addStyle(`.${className}`, rule)
  ele.classList.add(className)
}

一些优化的细节

关键代码大致是这样的:

const checker = (selector) => {
  if (DEAD_OBVIOUS.has(selector)) {
    return true
  }
  if (/:-(ms|moz)-/.test(selector)) {
     return true
  }
  if (/:{1,2}(before|after)/.test(selector)) {
    return true
  }
  try {
    const keep = !!document.querySelector(selector)
    return keep
  } catch (err) {
    const exception = err.toString()
    console.log(`Unable to querySelector('${selector}') [${exception}]`, 'error')
    return false
  }
}

可以看出,我们主要通过 document.querySelector 方法来判断该 CSS 是否被使用到,如果该 CSS 选择器能够选择上元素,说明该 CSS 样式是有用的,保留。如果没有选择上元素,说明该 CSS 样式没有用到,所以移除。

在后面的一些 slides 中,我们来聊聊怎讲将构建骨架屏和 webpack 开发、打包结合起来,最终将我们的骨架屏打包到实际项目中。

通过 webpack 将骨架屏打包到项目中

在上一个部分,我们分析了怎么去生成骨架屏,在这一部分,我们将探讨如何通过 webpack 将骨架屏打包的项目中。在这过程中,思考了以下一些问题:

为什么在开发过程中生成骨架屏?

其主要原因还是为了骨架屏的可编辑。

在上一个部分,我们通过一些样式和元素的修改生成了骨架屏页面,但是我们并没有马上将其写入到配置的输出文件夹中,在写入骨架页面到项目之前。我们通过 memory-fs 将骨架屏写入到内存中,以便我们能够通过预览页面进行访问。同时我们也将骨架屏源码发送到了预览页面,这样我们就可以通过修改源码,对骨架屏进行二次编辑。

正如这张图片,这张图是插件打开的骨架屏的预览页面,从左到右依次是开发中的真实页面、骨架屏、骨架屏可编辑源码。


image

这样我们就可以在开发过程中对骨架屏进行编辑,修改部分样式,中部骨架屏可以进行实时预览,这之间的通信都是通过websocket 来完成的。当我们对生成的骨架屏满意后,并点击右上角写入骨架屏按钮,将骨架屏写入到项目中,在最后项目构建时,将骨架屏打包到项目中。

如果我们同时在构建的过程中生成骨架屏,并打包到项目中,这时的骨架屏我们是无法预览的,因此我们对此时的骨架屏一无所知,也不能够做任何修改,这就是我们在开发中生成骨架屏的原因所在。

演讲最开始已经提到,目前流行的前端框架基本都是 JS 驱动,也就是说,在最初的 index.html 中我们不用写太多的 html 内容,而是等框架启动完成后,通过运行时将内容填充到 html 中,通常我们会在 html 模板中添加一个根元素:

<div id="app"></div>

当应用启动后,会将真实的内容填充到上面的元素中。这也就给了我们一个展示骨架屏的机会,我们将骨架屏在页面启动之前添加到上面元素内:

<div id="app"><!-- shell.html --></div>

怎样将骨架屏打包到项目中

Webpack 是一款优秀的前端打包工具,其也提供了一些丰富的 API 让我们可以自己编写一些插件来让 webpack 完成更多的工作,比如在构建过程中,将骨架屏打包到项目中。

Webpack 在整个打包的过程中提供了众多生命周期事件,比如compilation 、after-emit 等,比如我们最终将骨架屏插入到 html 中就是在after-emit 钩子函数中进行的,简单的代码:

SkeletonPlugin.prototype.apply = function (compiler) {
  // 其他代码
  compiler.plugin('after-emit', async (compilation, done) => {
    try {
      await outputSkeletonScreen(this.originalHtml, this.options, this.server.log.info)
    } catch (err) {
      this.server.log.warn(err.toString())
    }
    done()
  })
  // 其他代码
}

我们再来看看 outputSkeletonScreen 是如何将骨架屏插入到原始的 HTML 中,并且写入到配置的输入文件夹的。

const outputSkeletonScreen = async (originHtml, options, log) => {
  const { pathname, staticDir, routes } = options
  return Promise.all(routes.map(async (route) => {
    const trimedRoute = route.replace(/\//g, '')
    const filePath = path.join(pathname, trimedRoute ? `${trimedRoute}.html` : 'index.html')
    const html = await promisify(fs.readFile)(filePath, 'utf-8')
    const finalHtml = originHtml.replace('<!-- shell -->', html)
    const outputDir = path.join(staticDir, route)
    const outputFile = path.join(outputDir, 'index.html')
    await fse.ensureDir(outputDir)
    await promisify(fs.writeFile)(outputFile, finalHtml, 'utf-8')
    log(`write ${outputFile} successfully in ${route}`)
    return Promise.resolve()
  }))
}

更多思考

Page Skeleton webpack 插件在我们内部团队已经开始使用,在使用的过程中我们也得到了一些反馈信息。

首先是对 SPA 多路由的支持,其实现在插件已经支持多路由了,只是还没有用到真实项目中,我们针对每一个路由页面生成一个单独的 index.html,也就是静态路由。然后将每个路由生成的骨架屏插入到不同的静态路由的 html 中。

其次,玩过服务端渲染的同学都知道,在 React 和 Vue 服务端渲染中有一种称为 Client-side Hydration 的技术,指的是在 Vue 在浏览器接管由服务端发送来的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

在我们构建骨架屏的过程中,其 DOM 结构和真实页面的 DOM 结构基本相同,只是添加了一些行内样式和 classname,我们也在思考这些 DOM 能够被复用,也就是在应用启动时重新创建所有 DOM。我们只用激活这些骨架屏 DOM,让其能够相应数据的变化,这似乎就可以使骨架屏和真实页面更好的融合。

还有,在页面启动后,我们可能还是会通过 AJAX 获取后端数据,这时候我们也可以通过 骨架屏 来作为一种加载状态。也就是说,其实我们可以在「非首屏骨架屏」上做一些工作。

最后,在项目中可能会有一些性能监控的需求,比如骨架屏什么时候创建,什么时候被销毁,这些我们可能都希望通过一些性能监控的工具记录下来,以便将来做一些性能上面的分析。因此将来也会提供一些骨架屏的生命周期函数,或者提供相应的自定义事件,在生命周期不同阶段,调用相应的生命周期钩子函数或监听相应事件,这样就可以将骨架屏的一些数据记录到性能监控软件中。

本文摘自:一种自动化生成骨架屏的方案

推荐阅读

上一篇下一篇

猜你喜欢

热点阅读