扩展:如何为 Electron 应用生成骨架屏

前言

骨架屏在很多应用中已经实现了,开源社区也有一些实现,但是有一些想要用的 Electron 还是比较困难。今天,我要给大家介绍如何为 Electron 应用生成 骨架屏。与其说是骨架屏,不如说是 骨架图

骨架屏实现的几种方式

  1. 使用 ssr 预渲染
  2. 加载 js 后,再请求服务器获取数据时,先使用 ui 库的 Skeleton 组件去渲染部分内容
  3. 通过 puppetter等工具去访问指定页面并截图,通过一定的算法识别后,使用纯html、css去组织一个骨架屏的内容,然后打包成离线包放在 App 中使用。

以上三种,应该涵盖了大多数实现骨架屏的方案。

Electron 应用的实际开发中,我基于第三种简化了下。总的思路是基于HtmlWebpackPluginPuppeteer。在 HtmlWebpackPlugin 处理 html 模板时,通过替换模板中的 <!-- skeleton-css --><!-- skeleton-js --> 标记, 把渲染骨架图的样式和 js 逻辑插入到页面中。

我把这套逻辑封装成了一个 webpack 插件 —— skeleton-webpack-plugin

HTML 模板

先来看一下使用这个插件,html 模板需要改哪些部分。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="icon" type="image/vnd.microsoft.icon" href="./favicon.ico">
  <title>Electron</title>
  <!-- skeleton-css -->
</head>

<body>
  <div id="root">
    <!-- skeleton-js -->
  </div>
</body>
</html>

只需要添加 2个比较,<!-- skeleton-css --><!-- skeleton-js -->,很简单吧。

然后我们看一下经过插件处理后的 html 模板

<!doctype html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <link rel="icon" type="image/vnd.microsoft.icon" href="./favicon.ico">
  <title>怡氧</title>
  <style>
    @keyframes skeletonLoading {
      0% {
        opacity: 0.5;
      }

      100% {
        opacity: 1;
      }
    }

    .skeleton-img {
      animation: skeletonLoading 1s infinite;
    }
  </style>
</head>

<body>
  <div id="root">
    <script>var hash = location.hash.substring(2);
      if (hash.includes("?")) {
        hash = hash.substring(0, hash.indexOf('?'))
      }
      var skeletonName = hash.replaceAll('/', '--').replaceAll('\\', '--');
      var image = document.createElement('img');
      image.setAttribute("style", "position: fixed; top:0; left: 0;")
      image.setAttribute("class", "skeleton-img")
      image.setAttribute('alt', "")
      image.src = './skeleton/' + skeletonName + '.png'
      document.getElementById('root').appendChild(image)</script>
  </div>
  <script src="./static/js/index.fc7a1aa3.bundle.js"></script>
</body>

</html>

从 js 的逻辑中,相信大家已经看出来吧,主要是通过插入图片来显示骨架屏的。那么这个骨架图是如何实现的呢?我们等一会儿再讲。

Webpack Plguin 配置

先来看一下如何在 webpack 中使用插件

config.plugin('skeleton-plugin').use(SkeletonWebpackPlugin, [
  {
    outputPath: path.join(paths.appRoot, 'public'),
    routes: ['/apps/recent', '/apps/new-file'],
  },
])

上面是 webpack-chain 配置 webpack,不熟悉的可以看一下文档。

插件是支持多路由的,访问不同的路由有不同的骨架图。

骨架图又是如何生成的?

看代码的话其实很简单,但也有些麻烦。

这个方案缺点是侵入性强,所有需要绘制在骨架图的 DOM 元素都要添加一个特定的类名 —— skeleton

const path = require('path')
const { ensureDir } = require('fs-extra')

const HtmlWebpackPlugin = require('html-webpack-plugin')

class SkeletonWebpackPlugin {
  constructor(options) {
    this.options = Object.assign(
      {},
      {
        fillStyle: '#eeeeee95',
        cssMatch: '<!-- skeleton-css -->',
        jsMatch: '<!-- skeleton-js -->',
        publicPath: 'skeleton',
        className: 'skeleton',
      },
      options
    )
  }
  apply(compiler) {
    this.options.context = compiler.context
    this.options.devServer = compiler.options.devServer
    this.options.mode = compiler.options.mode

    compiler.hooks.compilation.tap('skeleton-webpack-plugin', (compilation) => {
      HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tapAsync(
        'skeleton-webpack-plugin',
        (htmlPluginData, callback) => {
          if (htmlPluginData.html.includes(this.options.cssMatch)) {
            htmlPluginData.html = htmlPluginData.html.replace(
              this.options.cssMatch,
              `
              <style>
              @keyframes skeletonLoading {
                0% {
                  opacity: 0.5;
                }
        
                100% {
                  opacity: 1;
                }
              }
        
              .skeleton-img {
                animation: skeletonLoading 1s infinite;
              }
              </style>
              `
            )
          }
          if (htmlPluginData.html.includes(this.options.jsMatch)) {
            const _path = this.options.publicPath
            while (_path.startsWith('/') || _path.startsWith('.')) {
              _path = _path.substring(1)
            }
            htmlPluginData.html = htmlPluginData.html.replace(
              this.options.jsMatch,
              `
              <script>
                var hash = location.hash.substring(2);
                if (hash.includes("?")) {
                  hash = hash.substring(0, hash.indexOf('?'))
                }
                var skeletonName = hash.replaceAll('/', '--').replaceAll('\\\\', '--');
                var image = document.createElement('img');
                image.setAttribute("style", "position: fixed; top:0; left: 0;")
                image.setAttribute("class", "skeleton-img")
                image.setAttribute('alt', "")
                image.src = './${_path}/' + skeletonName + '.png'
                document.getElementById('root').appendChild(image)
              </script>
              `
            )
          }
          callback(null, htmlPluginData)
        }
      )
    })

    compiler.hooks.done.tapAsync('skeleton-plugin', (compilation, callback) => {
      if (this.options.mode == 'development') {
        this.genSkeleton()
      }
      callback()
    })
  }

  async sleep(time) {
    await new Promise((resolve) => {
      setTimeout(() => resolve(), time)
    })
  }

  async genSkeleton() {
    const browser = await require('puppeteer').launch({
      defaultViewport: this.options.viewport,
    })
    const page = await browser.newPage()
    let i = 1
    const _path = this.options.publicPath
    while (_path.startsWith('/') || _path.startsWith('.')) {
      _path = _path.substring(1)
    }
    const parentDir = path.join(this.options.outputPath, _path)
    await ensureDir(parentDir)
    for (var route of this.options.routes) {
      route = route.startsWith('/') ? route.substring(1) : route
      await page.goto(
        `http://localhost:${this.options.devServer.port}/#${route}`
      )
      await page.waitForNetworkIdle()
      await this.sleep(1500)
      if (!this.options.viewport) {
        const availWidth = await page.evaluate('window.screen.availWidth')
        const availHeight = await page.evaluate('window.screen.availHeight')
        await page.setViewport({
          width: availWidth,
          height: availHeight,
        })
      }
      const html = await page.evaluate(`
          var eles = Array.from(document.getElementsByClassName("${this.options.className}"));
          var rects = eles.map(function(n){
              var rect = n.getBoundingClientRect()
              return {
                  x: rect.x / window.innerWidth,
                  y: rect.y / window.innerHeight,
                  width: rect.width / window.innerWidth,
                  height: rect.height / window.innerHeight
              }
          })
          rects;
      `)

      const imgPath = path.join(
        parentDir,
        route.replaceAll('/', '--').replaceAll('\\', '--') + '.png'
      )

      await page.screenshot({
        path: imgPath,
      })
      await require('canvas')
        .loadImage(imgPath)
        .then((image) => {
          const canvas = require('canvas').createCanvas(
            image.width,
            image.height
          )
          const ctx = canvas.getContext('2d')
          ctx.fillStyle = this.options.fillStyle
          html.forEach((rect) => {
            ctx.fillRect(
              rect.x * image.width,
              rect.y * image.height,
              rect.width * image.width,
              rect.height * image.height
            )
          })
          require('fs').writeFileSync(imgPath, canvas.toBuffer())
        })
      i++
    }
    await browser.close()
  }
}

module.exports = SkeletonWebpackPlugin

总结

看一下实际使用的效果

skeleton.gif

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容