半自动生成骨架屏思路

原创
2021/12/06 09:44
阅读数 2.8K

前言

骨架屏并不是一个新概念,应该十年前就有了,只是大家生成的方式跟使用方式有点不同,本质目的是为了在不明显影响页面性能的前提下,提高用户体验,当然,用户体验是个主观观点,可能有的人觉得白屏时显示一个loading比骨架屏体验好,甚至宁愿就直接白屏直到页面开始渲染元素,ok,这里不扯了,步入主题

为什么是半自动?

在我看来,自动的前提是用户无需多余操作,只需引入工具/插件即可生成页面的骨架屏,但这种是不现实的,因为骨架屏是对页面主要布局的简单展示,主要布局本身就是由主观观点决定的,你让不同UI设计同个页面的骨架屏,或多或少会有点区别,无法达到每个人心中的一步到位,所以需要通过用户配置某些规则+通用的识别逻辑来生成骨架屏,所以称为半自动

思路

主体思路是遍历整个页面元素,并用事先定义好的规则去匹配当前元素,如识别成不同模块,如图片、按钮、文本...,然后使用模块的转换逻辑,将前面识别的模块转化成一个个色块(div),然后将色块按元素的几何位置拼接到一起,这样骨架屏就生成了,所以我们需要做以下事情:

  • 模块定义,即什么样的DOM元素归属什么模块
  • 模块处理逻辑,当前模块如何在还原原本DOM元素的几何属性的前提下,使用色块代替
  • 将上面的色块重新组合成一起,形成骨架屏

上面是识别的流程,在识别流程开始前,另一个问题就是我们的识别逻辑怎么开始?因为我们是分析页面DOM元素来生成骨架屏,所以得先获取到页面元素,所以需要提供一个可以将识别逻辑注入到页面中,最后,我们还需要提供一个可视化界面,展示前面的解析结果。接下来,我们一步步看具体过程

模块的识别与生成

市面上主流的模块识别都大同小异,本文也是同样的逻辑,逻辑下面会讲,但生成有点不同,饿了么(此插件给我了很多思路,感谢🙏开发的大佬)开源的插件的生成逻辑是复用CSS,即生成的色块会使用跟原本DOM元素相同的CSS,所以可以达到布局一致,但本文采用fixed布局的方式,我会计算元素的宽高、topleft,然后使用一个fixeddiv来代替原本元素,不使用原本的CSS,样式全部重写,个人觉得优劣如下:

  • 复用CSS:
    • 优点:样式生成比较简单,毕竟样式是现成的,所以能生成跟页面结构一致的骨架屏,在骨架屏生成后,如果想调试结果也比较友好,因为DOM结构跟CSS是跟页面一至,就跟调试自己页面一样简单。
    • 缺点:骨架块间联动严重,骨架屏并不需要转换页面的全部元素,所以会部分元素不转换,但元素的布局是互相依赖的,比如左右布局,右边的元素是靠左边元素挤到右边的,此时如果我们只想保留右边元素,那么左边元素被去除,导致右边元素变为左边元素,当然,这种可以通过同时是被左右元素,但给左边元素设置为透明状态,那么效果就ok了,但同时导致我们的骨架屏多了一些冗余的元素,导致骨架屏代码体积变大,CSS不好优化,我们很难比较彻底地去除那些不影响结果的样式,因为样式的不同组合导致的结果差别很大,很难识别哪些样式对于骨架屏时无用的
  • fixed
    • 优点:CSS优化简单,毕竟CSS是我们自己生成的,规则也是我们自己定义,可以针对提取公共class,比如当多个元素的top值一致时,可以单独写个如.top64 { top: 64vw }的样式,这样可以减少CSS的体积,元素间不联动,因为是基于fixed布局,所以不会出现上面左右布局缺失其一时导致的尴尬😅。
    • 缺点:对于整体移动的场景调试困难,比如我们想让某一排色块集体下移,但由于是fixed布局,所以只能一个一个改,不能像第一种方式一样,改下他们的父元素就行。定位元素也没第一种简单。

这里不讨论那种是最优,本文采用第二种,接下来举例三种代表性的场景

识别图片

图片的识别比较简单,当识别到标签时那他就是图片了,此时通过getBoundingClientRect()就可以获取图片的位置跟宽高,这样就可以使用一个div来替代图片,作为骨架屏的色块,伪代码如下

// 获取位置跟宽高
const { left, top, width, height } = imgNode.getBoundingClientRect()
let style = `
    position: fixed;
    top: ${top};
    left: ${left};
    width: ${width};
    height: ${height};
`
let div = document.createElement('div')
div.setAttribute('style', style)
复制代码

识别文本

文本算得上是最复杂的,毕竟分为单行文本跟多行文本,单行文本好理解,就是获取文案本身的几何属性来设置一个div替代,但多行文本预计的效果如下:

可以看到,多行文本不是简单的用一个色块替代,而是用类似斑马线的色条组合而成,色条的产生可以使用渐变实现,因为在线性渐变中,如果我们将线性渐变的起始点设置小于前一个颜色的起始点,那么渐变的效果会消失,取而代之的是两条不同颜色的色条,任一行文案其实是由如下三个部分组层:

所以我们需要对于任意一行文案,需要解析成三个色条,文字上面的空白是一条、文字本身一条、文字底部空白也是一条,所以得计算两个点,即文字顶部的位置跟文字底部的位置,这样就知道三个部分分别在一行里高度比例,从而将一行的高度分为三个部分。我们可以简单地认为上下空白的高度是一样的,文字的高度是fontSize,那么两个点就可以计算了:

const textHeightRatio = fontSize / parseFloat(lineHeight) // 文本占一行的比例
const firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(4)
const secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(4)

const commonRule = {
    'background-image': `linear-gradient(transparent ${firstColorPoint}%, #EFEFEF 0%, #EFEFEF  ${secondColorPoint}%, transparent 0%);`,
    'background-size': `100% ${lineHeight}` // lineHeight代表一行文案的高度,即上面三个部分的高度之和
}
复制代码

这样我们得到了background-imagebackground-size,接着在获取原本多行文本的高宽都设置到新的div上即可,这样多行文本的就生成了,下面是一段测试代码,可以直接看到效果:

.skeleton__text {
    width: 200px;
    height: 300px;
    background-image: linear-gradient(transparent 12%, #EFEFEF 0%, #EFEFEF 76%, transparent 0%);
    background-size: 100% 30px;
}

<div class="skeleton__text"></div>
复制代码

其他场景

其他诸如识别svg、伪元素、按钮、定义了背景图片、渐变等等,其实跟图片逻辑一致,只是识别规则不太一样,且生成的色块颜色根据个人主观要求设置成不一样罢了,比如对于使用了背景图片、渐变的元素,那么这种一般是作为背景存在,即他上面其实还有其他待识别元素,此时这个元素的作用是起一个划分区域的作用,让页面的结构足够清晰,所以识别到这种时,得用另外颜色的色块来代替,不然会和他上面的其他被解析到元素重叠。由于本文是讲思路,所以解析这块不再多介绍。

流程处理

注入解析脚本

解析的开始得先将我们的解析脚本注入到页面中,不然无法获取DOM,注入脚本可以通过监听html-webpack-pluginhtmlWebpackPluginBeforeHtmlProcessing事件来修改我们的入口文件的代码,如往index.html里注入我们的解析脚本,当然,直接把大段的js直接插入页面里,有点不优雅,我是通过插入脚本链接,页面打开后再加载脚本,那么就得提供响应请求脚本且返回脚本的服务,所以在本地开启一个简单的node服务,我是使用express搭的一个简单服务,当然,这个服务的用处不止这个,后续会介绍,目前伪代码如下

class Server {
    constructor(){
        ...
        server.listen() // 启动服务
    }
    listen() { // 启动服务
        this.app = express()
        this.listenServer = http.createServer(this.app)

        this.app.all('*', (req, res, next) => {
          res.header('Access-Control-Allow-Origin', '*')
          res.header('Access-Control-Allow-Headers', 'content-type')
          res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS')
          next()
        })
        this.initRouters()
        this.listenServer.listen(this.port, () => {
          this.log.info(`page-skeleton server listen at port: ${this.port}`)
        })
        return Promise.resolve()
    }
    initRouters() { // 设置路由
         const { app, staticPath } = this
         // 相应获取脚本请求
         app.get(`/${staticPath}/index.bundle.2.js`, (req, res) => {
              res.setHeader('Content-Type', 'application/javascript')
              fs.createReadStream(path.join(__dirname, '../', 'client', 'index.bundle.2.js')).pipe(res)
         })
    }
}
复制代码

上面就是开启一个node服务,目前接收/${staticPath}/index.bundle.2.js对这个的请求,然后返回本地的解析脚本。

结果可视化

通过上面,我们可以获取最终的骨架屏了,但是此时结果只是保存在解析脚本里面某个保存结果的变量里,我们看不到实际的样子,所以我们需要在解析后能够立马将结果自动地展示出来,先看下我这边的可视化结果:

如上,提供了骨架屏的可视化,跟右边的代码实时编辑功能,在右边编辑代码左边的骨架屏会实时更新,当然,编辑功能也可以不提供,你可以通过本地编辑器改代码也行,虽然少了实时更新,但调试体验会跟友好,毕竟有代码提示😁。左边的骨架屏展示其实是通过iframe实现的,上面脚本解析结束后,我会将解析结果传给本地的node服务保存到变量中,并且通过window.open打开这个可视化界面,打开可视化界面后,需要做一件事,那就是获取设置iframe的链接,跟右边的骨架屏代码,前面我们将解析结果传给node服务了,那么我们就可以直接在可视化界面里发起请求,获取保存在node服务里的骨架屏代码。 那链接呢?链接也是跟着骨架屏代码一起发送的,那问题就是生成链接,我的处理是我会将node保存的解析结果组装成一个完整的页面,然后通过memory-fs保存在内存中,文件名是通过hasha来根据内容生存的摘要,伪代码如下:

try {
      const pathName = path.join(__dirname, '__webpack_page_skeleton__/skeleton') // 设置目录
      let fileName = await hasha(html, { algorithm: 'md5' }) // 生成文件名
      fileName += '.html'
      myFs.mkdirpSync(pathName, fileName) // 创建目录
      await promisify(myFs.writeFile.bind(myFs))(path.join(pathName, fileName), html, 'utf8') // 把文件写入上面的目录中
      return `http://localhost:${this.port}/${this.staticPath}/skeleton/${fileName}` // 返回获取文件的链接
} catch (err) {
  console.log(err)
}
复制代码

ok,通过上面我们就将结果以.html的文件格式保存在内存中,那么我接下来只需要在node服务里多写一个响应上面链接的请求的就行,如下:

app.get(`/${staticPath}/skeleton/:filename`, async (req, res) => {
      const { filename } = req.params
      if (!/\.html$/.test(filename)) return false
      let html = ''
      try {
        html = await promisify(myFs.readFile.bind(myFs))(path.resolve(__dirname, `${staticPath}/skeleton/${filename}`), 'utf-8') // 读取内存保存的页面
      } catch (err) {
      }
      res.send(html)
 })
复制代码

可以看到就是简单的将文件返回罢了,其实上面可以简化流程,你保存在硬盘也行,然后直接用本地的绝对路径打开即可,不必通过node服务这一环节,只是我做了额外的一些操作才这样处理(比方我其实是通过websocket响应请求的),但这些跟骨架屏生成思路无关,所以不介绍。

实时更新

由于可视化界面是通过Vue写的,所以编辑器的插件是用vue-codemirror,那么如何做到修改代码左边实时更新,其实很简单,一开始我想得比较复杂,是通过监听编辑器内容变化,然后发送给node的websocket服务,燃火websocket服务按照上面的流程重新把结果保存在内存,生成html链接,然后发送新链接给可视化界面,最后更新iframe的src属性,这也是我上面说我在本地使用websocket的原因之一,因为我需要将结果主动推送到可视化界面,但这样会导致左边刷新,一闪一闪的,看着不友好,所以我想了一下,何不直接使用postMessage?通过这个API,我会将编辑的代码实时传给iframeiframe通过window.addEventListener('message', ()=>{}),响应结果,然后替换iframe的html内容即可,代码如下:

// 编辑器:
onCmCodeChange (newCode) {
      let reg = /\<head\>([\s\S])*\<\/body\>/ // 获取样式跟html
      let code = newCode.match(reg)
      // 获取iframe实例后发送
      document.getElementsByTagName('iframe')[0].contentWindow.postMessage(JSON.stringify({ code }), 'http://localhost:7006')
}
复制代码
// iframe
  window.addEventListener('message', (rsp) => {
    let data = JSON.parse(rsp.data)
    const { code } = data
    document.getElementsByTagName('html')[0].innerHTML = code // 替换html
  })
复制代码

这样iframe的内容就实时更新了,由于每次只是重新渲染十几个DOM,所以不会有渲染卡顿的问题。 到这里,骨架屏的代码注入->解析->可视化就完成了,后面你只要在可视化界面里提供几个按钮来复制对应的html跟css,供你拷贝到正式项目中即可。

结果

通过上面的生成的骨架屏的大小随着解析元素的增多而增大,上面可视化界面里的代码体积压缩+优化后是8k左右,对比以前直接使用图片(10.6k)小了24.5%,结果会因个人的优化情况而不同,优化主要是CSS的优化,如提取公共CSS。

至此,流程结束,下面提一下除了上面,我们还需要注意什么,这里就不展开讨论了:

  • 提供配置功能,比如用户可以通过在本地建一个配置文件,来支持可以将某些元素解析成特定骨架块、不解析哪些元素、解析的样式、node端口等等
  • 如何跨平台?如何将支持taro3、vue-cli等构建的项目,以及将结果转化成这些框架能识别的
  • 优化,仔细看生存的css,其实可以发现很多css是可以提取成公共css来减少代码体积的。所以得有优化规则。
  • 何时启动解析?我是在脚本里监听键盘组合事件来开启解析的
  • 注意解析的边界,比如上面的文本解析是基于文案默认左排序,那如果设置了text-align: center呢?

最后

如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163相互学习,我们会有专业的技术答疑解惑

如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点star:http://github.crmeb.net/u/defu不胜感激 !

展开阅读全文
打赏
0
1 收藏
分享
加载中
给前辈点赞!
2021/12/07 09:32
回复
举报
扒站已恐怖如斯
2021/12/07 09:04
回复
举报
更多评论
打赏
2 评论
1 收藏
0
分享
返回顶部
顶部