# ❓ 如何实现骨架屏,说说你的思路?

# 手写骨架屏

  • 通过 html 和 css 手写
  • 骨架屏样式完全复刻页面真实样式

# 弊端

  • 维护成本高(每次需求变更我们不仅需要修改业务代码,同时也要去修改骨架屏的样式布局)

# SSR 服务端渲染

# 优点

  • SEO 优化

  • 加快内容呈现

# 缺点

  • 服务端构建,部署
  • 大流量网站要考虑服务器的负载,以及相应的缓存策略
  • 以及“千人千面”(外面行业的由于地理位置不同用户看到的页面也是不一样的)

# 预渲染 pretender

我们所说的预渲染,就是在项目的构建过程中,通过一些渲染机制,比如说 puppeteer 或者 jsdom 将页面在构建的过程中就渲染好,然后插入到 html 中,这样在页面启动之前用户首先看到的就是预渲染的页面了。

# 缺点

  • 真实页面数据有可能和真实数据有很大的出入
  • 预渲染的页面不是一个可以交互的页面,在真实页面没有渲染之前,用户无法和预渲染的页面进行任何的交互
  • 同时预渲染的数据有可能也会影响到用户获取真实的信息(地理位置,金额,价钱等)

# 最终方案 自动化生成骨架屏

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

# 实现

页面模块划分:

  • 文本块:仅包含文本节点(NodeType = Node.TEXT_NODE)的元素(NodeType = Node.ELEMENT_NODE),一个文本块可能是一个 P 元素或者一个 div 等,文本块都会转化为灰色条纹

  • 图片块:图片块是很好区分的,任何 img 元素都将被视为图片块,图片块的颜色将被处理成配置的颜色,形状也被修改为配置的矩形或者圆形

  • 按钮块:任何 button 元素,typebuttoninput 元素,role 为 buttona 元素,都将被视为按钮块。按钮块中的文本块不再处理

  • svg 块:任何最外层是 svg 的元素都被视为 svg

  • 伪类元素块:任何伪类元素都将视为伪类元素块,如 ::before 或者 ::after

  • ...

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

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

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

# 打包

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

webpack 在整个打包的过程中提供了众多生命周期事件,比如 compilationafter-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()
  })
  // 其他代码
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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()
    })
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

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