chiyan-lin / Blog

welcome gaidy Blog
6 stars 3 forks source link

通过预渲染和骨架屏提升用户体验 #14

Open chiyan-lin opened 3 years ago

chiyan-lin commented 3 years ago

通过预渲染和骨架屏提神提升用户体验

作为使用 vue 这种 SPA 框架的我们来说,最大的难受点莫过于每次都得等 js 逻辑执行完成再渲染页面

对 seo 是极度不友好,而且首屏时间也会有一定的影响

解决方案有两个 1、使用预渲染,2、使用 ssr

预渲染

预渲染就是使用一个无头浏览器,把页面在浏览器里面跑一下,然后把页面拿出来,生成出 html string

核心思路如下

async renderRoutes (routes, Prerenderer) {
    const rootOptions = Prerenderer.getOptions()
    const options = this._rendererOptions

    const limiter = promiseLimit(this._rendererOptions.maxConcurrentRoutes)

    const pagePromises = Promise.all(
      routes.map(
        // 限制 promise 数量的一个小方法,防止太多页面导致内存爆炸
        (route, index) => limiter(
          async () => {
            const page = await this._puppeteer.newPage()
            // 注入 window 全局对象,标记当前处于预渲染环境
            if (options.inject) {
              await page.evaluateOnNewDocument(`(function () { window['${options.injectProperty}'] = ${JSON.stringify(options.inject)}; })();`)
            }
            const baseURL = `http://localhost:${rootOptions.server.port}`
            // 设置视窗大小
            if (options.viewport) await page.setViewport(options.viewport)
            // 由使用方配置 renderAfterDocumentEvent 并在合适的时候 new Event(renderAfterDocumentEvent)
            if (options.renderAfterDocumentEvent) {
              page.evaluateOnNewDocument(function (options) {
                window['__PRERENDER_STATUS'] = {}
                // https://developer.mozilla.org/zh-CN/docs/Web/API/Event
                // 使用浏览器的原生事件方式,
                document.addEventListener(options.renderAfterDocumentEvent, () => {
                  // 可以准备把内容导出了
                  window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED = true
                })
              }, this._rendererOptions)
            }
            // 设置一下网络配置
            const navigationOptions = (options.navigationOptions) ? { waituntil: 'networkidle0', ...options.navigationOptions } : { waituntil: 'networkidle0' }
            // 打开页面
            await page.goto(`${baseURL}${route}`, navigationOptions)
            // Wait for some specific element exists
            const { renderAfterElementExists } = this._rendererOptions
            if (renderAfterElementExists && typeof renderAfterElementExists === 'string') {
              await page.waitForSelector(renderAfterElementExists)
            }
            // 在页面注入一个方法,因为在前面注入了一个
            // 当触发生成的时候,window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED
            // 会被标记为 true ,然后会触发下面这个 promise ,在这个页面执行完成,就有页面内容了
            await page.evaluate(function (options) {
              options = options || {}
              return new Promise((resolve, reject) => {
                if (options.renderAfterDocumentEvent) {
                  // 如果很快就产生了文件,就可以直接 resolve 了
                  if (window['__PRERENDER_STATUS'] && window['__PRERENDER_STATUS'].__DOCUMENT_EVENT_RESOLVED){
                    resolve()
                  }
                  // 不然就自己再监听一遍
                  document.addEventListener(options.renderAfterDocumentEvent, () => resolve())
                }
              })
            }, this._rendererOptions)
            // 产出页面的基本内容
            const result = {
              originalRoute: route,
              route: await page.evaluate('window.location.pathname'),
              html: await page.content()
            }
            await page.close()
            return result
          }
        )
      )
    )

    return pagePromises
  }

配合 webpack 在产出文件里面加上 输出文件 就行了

PrerenderSPAPlugin.prototype.apply = function (compiler) {
  const compilerFS = compiler.outputFileSystem
  // From https://github.com/ahmadnassri/mkdirp-promise/blob/master/lib/index.js
  const mkdirp = function (dir, opts) {
    return new Promise((resolve, reject) => {
      compilerFS.mkdirp(dir, opts, (err, made) => err === null ? resolve(made) : reject(err))
    })
  }

  const afterEmit = (compilation, done) => {
    const PrerendererInstance = new Prerenderer(this._options)
    PrerendererInstance.initialize()
      .then(() => {
        return PrerendererInstance.renderRoutes(this._options.routes || [])
      })
      // 设置导出文件路径,这里应该是跟生成页面 html plugin 里面设置的地址一致
      .then(renderedRoutes => {
        renderedRoutes.forEach(rendered => {
          if (!rendered.outputPath) {
            rendered.outputPath = path.join(this._options.outputDir || this._options.staticDir, rendered.route, 'index.html')
          }
        })
        return renderedRoutes
      })
      // 准备文件写入,这个过程应该是用了 webpack 的内存文件系统
      .then(processedRoutes => {
        const promises = Promise.all(processedRoutes.map(processedRoute => {
          return mkdirp(path.dirname(processedRoute.outputPath))
            .then(() => {
              return new Promise((resolve, reject) => {
                compilerFS.writeFile(processedRoute.outputPath, processedRoute.html.trim(), err => {
                  if (err) reject(`[prerender-spa-plugin] Unable to write rendered route to file "${processedRoute.outputPath}" \n ${err}.`)
                  else resolve()
                })
              })
            })
            .catch(err => {
              if (typeof err === 'string') {
                err = `[prerender-spa-plugin] Unable to create directory ${path.dirname(processedRoute.outputPath)} for route ${processedRoute.route}. \n ${err}`
              }
              throw err
            })
        }))
        return promises
      })
      // 最后清掉文件,关闭一切
      .then(r => {
        PrerendererInstance.destroy()
        done()
      })
      .catch(() => {
        PrerendererInstance.destroy()
        const msg = '[prerender-spa-plugin] Unable to prerender all routes!'
        console.error(msg)
        compilation.errors.push(new Error(msg))
        done()
      })
  }

  if (compiler.hooks) {
    const plugin = { name: 'PrerenderSPAPlugin' }
    compiler.hooks.afterEmit.tapAsync(plugin, afterEmit)
  } else {
    compiler.plugin('after-emit', afterEmit)
  }
}

官方推荐的形式大概就是这个样子了,依赖 poputeer 来做这种操作

但是存在的问题是,页面上的数据是需要自己构造的,比如说一个列表

一般来说,我们的页面在请求接口的时候,是需要做 cookie 校验,那么在执行页面打开这个操作的时候

最好是要进行一次登录,或者通过配置或者在页面检测是预渲染的时候做一些有意义内容的展示

带着骨架屏的预渲染

why

  1. 安装麻烦,本地开发过程,需要安装 poputeer 来调试,有点麻烦
  2. 生成的内容需要自己来处理,就是要自己来构建假数据
  3. 预渲染内容不够高级

目的

骨架页面是在页面真正解析和应用启动之前给用户展示页面的 CSS 样式和页面布局,并通过骨架页面的明暗变化, 告知用户页面正在努力加载中,让用户感知页面似乎加载得比以前快了。

简单的 loading 对用户的感知太弱了,骨架页面和真实页面样式布局完全一致, 在用户视觉感知上,骨架页面可以平滑的切换到真实数据渲染的页面。

原理

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

具体方案

  1. 侵入业务式手写代码,就是通过人工开发骨架屏的方式,跟业务组件耦合到一起
  2. 非侵入业务式手写代码,也是通过人工开发骨架屏的方式,然后骨架屏组件引入到项目中,业务组件区分
  3. 非侵入式骨架屏代码自动生成,无须手写骨架屏代码,由工具生成结构

选择方案

我们觉得一个比较好的接入方案应该是:

骨架屏可以自动生成,使用、维护和调整成本低,在不影响性能的前提下提升用户体验

所以最终决定使用 非侵入式骨架屏代码自动生成,生成对应 vue 组件形态的骨架屏

通过 slot 的方式将需要骨架屏渲染的部分传入,全面去除 loading 转圈圈这种用户体验较差的等待方式

所以其实本方案是对 ElemeFE/page-skeleton 的一个魔改

介绍下 ElemeFE/page-skeleton

不得不说,这个项目是真的 kpi 项目,连 demo 都跑不起来。。。

pop.png

流程上还是清晰的

  1. 打开页面,等待渲染完后注入提取骨架屏的脚本
  2. 等页面完全渲染完,在浏览器输入一个指令,触发骨架屏的运行
// 处理 htmlWebpackPlugin 在 htmlWebpackPlugin.getHooks(compilation).afterTemplateExecution 之后作一次注入
htmlWebpackPluginBeforeHtmlProcessing.tapAsync(PLUGIN_NAME, (htmlPluginData, callback) => {
  const clientEntry = `http://localhost:${port}/${staticPath}/index.bundle.js`
  const oldHtml = htmlPluginData.html
  htmlPluginData.html = addScriptTag(oldHtml, clientEntry, port)
  callback(null, htmlPluginData)
})
// 引入 SockJS,并且启动一个后台服务来通信
import SockJS from 'sockjs-client'

const port = window._pageSkeletonSocketPort // eslint-disable-line no-underscore-dangle

const sock = new SockJS(`http://localhost:${port}/socket`)

window.sock = sock

function createView(sock) {
  return new Vue({
    created() {
      this.$nextTick(() => {
        Object.defineProperty(window, 'toggleBar', {
          get() {
            log('toggle the preview control bar.')
          }
        })
      })
    },
    methods: {
      handleClick() {
        // 触发之后,告诉后台可以生成啦,并且把页面发过去
        sock.send(JSON.stringify({ type: 'generate', data: window.location.origin }))
      }
    }
  })
}
  1. 通过 puppeteer 打开要生成骨架屏的页面,提取完整的 DOM
  2. 打开一个中间页 preview 来预览,通过修改 css 和 html 对页面中元素进行改动
if (!msg.data) return log.info(msg)
sockWrite(this.sockets, 'console', preGenMsg)
try {
  const skeletonScreens = await this.skeleton.renderRoutes(msg.data.origin)
  this.routesData = {}
  /* eslint-disable no-await-in-loop */
  for (const { route, html } of skeletonScreens) {
    const fileName = await this.writeMagicHtml(html)
    const skeletonPageUrl = `http://${this.host}:${this.port}/${fileName}`
    this.routesData[route] = {
      url: origin + route,
      skeletonPageUrl,
      qrCode: await generateQR(skeletonPageUrl),
      html
    }
  }
  sockWrite(this.sockets, 'console', afterGenMsg)
  if (this.previewSocket) {
    sockWrite([this.previewSocket], 'url', JSON.stringify(this.routesData))
  } else {
    const openMsg = 'Browser open another page...'
    open(this.previewPageUrl, { app: [appName, '--incognito'] })
  }
}
  1. 对已有元素通过层叠样式进行覆盖,在达到不改变页面布局下,隐藏图片和文字,通过样式覆盖,使得其展示为灰色块
// 核心逻辑 genHtml 【 劫持 css -> css-tree parse to cssTree -> 在页面注入一段新的js用于处理dom&css -> getHtmlAndStyle 获取生成的html和css  】
// css-tree parse to cssTree
const ast = parse(text, {
  parseValue: false,
  parseRulePrelude: false
})
stylesheetAstObjects[requestUrl] = toPlainObject(ast)
stylesheetContents[requestUrl] = text
  1. 然后将修改后的 HTML 和 CSS 样式提取出来生成骨架屏 shell
  2. 最后在 webpack 启动构建的时候,把 shell 插入到页面中

关于整个处理我贴出了比较重要的部分 就是对文本进行处理

核心 traverse 处理

// 对标签进行分类处理
if (ele.tagName === 'svg') {
  return svgs.push(ele)
}
if (EXT_REG.test(styles.background) || EXT_REG.test(styles.backgroundImage)) {
  return hasImageBackEles.push(ele)
}
if (GRADIENT_REG.test(styles.background) || GRADIENT_REG.test(styles.backgroundImage)) {
  return gradientBackEles.push(ele)
}
if (ele.tagName === 'IMG' || isBase64Img(ele)) {
  return imgs.push(ele)
}
if (
  ele.nodeType === Node.ELEMENT_NODE &&
  (ele.tagName === 'BUTTON' || (ele.tagName === 'A' && ele.getAttribute('role') === 'button'))
) {
  return buttons.push(ele)
}

// 对各个标签进行分类处理
svgs.forEach(e => svgHandler(e, svg, cssUnit, decimal));
texts.forEach(e => textHandler(e, text, cssUnit, decimal));
buttons.forEach(e => buttonHandler(e, button));
hasImageBackEles.forEach(e => backgroundHandler(e, image));
imgs.forEach(e => imgHandler(e, image));
pseudos.forEach(e => pseudosHandler(e, pseudo));
gradientBackEles.forEach(e => backgroundHandler(e, image));
grayBlocks.forEach(e => grayHandler(e, button));

核心 text 处理

function textHandler(ele, { color }, cssUnit, decimal) {
  const { width } = ele.getBoundingClientRect()
  // if the text block's width is less than 50, just set it to transparent.
  if (width <= 50) {
    return setOpacity(ele)
  }
  const comStyle = getComputedStyle(ele)
  const text = ele.textContent
  let {
    lineHeight,
    paddingTop,
    paddingRight,
    paddingBottom,
    paddingLeft,
    position: pos,
    fontSize,
    textAlign,
    wordSpacing,
    wordBreak
  } = comStyle
  // 没有设置 line-height 的情况,后续的计算需要用到这个
  if (!/\d/.test(lineHeight)) {
    const fontSizeNum = parseFloat(fontSize, 10) || 14
    lineHeight = `${fontSizeNum * 1.4}px`
  }
  // 防止设置了其他奇怪的属性 position 比如 sticky
  const position = ['fixed', 'absolute', 'flex'].find(p => p === pos) ? pos : 'relative'
  const height = ele.offsetHeight
  // 计算行数,高 / 行高
  const lineCount = (height - parseFloat(paddingTop, 10) - parseFloat(paddingBottom, 10)) / parseFloat(lineHeight, 10) | 0 // eslint-disable-line no-bitwise

  let textHeightRatio = parseFloat(fontSize, 10) / parseFloat(lineHeight, 10)
  if (Number.isNaN(textHeightRatio)) {
    textHeightRatio = 1 / 1.4 // default number
  }
  /* eslint-disable no-mixed-operators */
  const firstColorPoint = ((1 - textHeightRatio) / 2 * 100).toFixed(decimal)
  const secondColorPoint = (((1 - textHeightRatio) / 2 + textHeightRatio) * 100).toFixed(decimal)
  const backgroundSize = `100% ${px2relativeUtil(lineHeight, cssUnit, decimal)}`
  const className = CLASS_NAME_PREFEX + 'text-' + firstColorPoint.toString(32).replace(/\./g, '-')

  const rule = `{
    background-image: linear-gradient(transparent ${firstColorPoint}%, ${color} 0%, ${color} ${secondColorPoint}%, transparent 0%) !important;
    background-size: ${backgroundSize};
    position: ${position} !important;
  }`

  const invariableClassName = CLASS_NAME_PREFEX + 'text'

  const invariableRule = `{
    background-origin: content-box !important;
    background-clip: content-box !important;
    background-color: transparent !important;
    color: transparent !important;
    background-repeat: repeat-y !important;
  }`

  addStyle(`.${className}`, rule)
  addStyle(`.${invariableClassName}`, invariableRule)
  addClassName(ele, [className, invariableClassName])
  // 对于超过两行的文本元素需要特殊处理
  // background-image: linear-gradient 产生作用的,span 是文字的区域,div 是文字所在的行
  // 所以 addTextMask 就是给 div 加上一个偏移的白色的 span
  if (lineCount > 1) {
    addTextMask(ele, comStyle)
  } else {
    const textWidth = getTextWidth(text, {
      fontSize,
      lineHeight,
      wordBreak,
      wordSpacing
    })
    const textWidthPercent = textWidth / (width - parseInt(paddingRight, 10) - parseInt(paddingLeft, 10))
    ele.style.backgroundSize = `${(textWidthPercent > 1 ? 1 : textWidthPercent) * 100}% ${px2relativeUtil(lineHeight, cssUnit, decimal)}`
    switch (textAlign) {
      case 'left': // do nothing
        break
      case 'center':
        ele.style.backgroundPositionX = '50%'
        break
      case 'right':
        ele.style.backgroundPositionX = '100%'
        break
    }
  }
}

example

<div class="wrap">文本文本文本文本文本文本<span class="wrap-mask"></span></div>
.wrap{
  background-image: linear-gradient(transparent 14.2857%, #EFEFEF 0%, #EFEFEF 85.7143%, transparent 0%) !important;
  background-size: 100% 0.4200rem;
  position: relative !important;
}

.wrap-mask{
  display: inline-block;
  width: 50%;
  height: 21px;
  background: rgb(255, 255, 255);
  position: absolute;
  bottom: 14px;
  right: 14px;
}

tyy.png

缺陷:

上面展示了这个工具基本脉络,说说一些有问题的地方

  1. 基于 puppteer 没有只获取页面中当屏的 html, 不过这里不好处理,因为屏幕高度都是不一样的 所以这里只能折中了,能想到的方式是 遍历 html 树,计算每个节点的距离顶部的高度 offsetTop 是否和大于等于当前浏览器高度 然后抛弃这个节点以下的其他节点
  2. preview 这个页面和 socket 还有 puppteer 这几个比较重的东西,感觉有点麻烦, 其实只是想获取节点而已
  3. 没办法只获取某个指定的小块或者说某个组件,因为是基于 puppteer 来进行的

思考

一切从用户出发,我们想做的事情是,把loading干掉,并且让用户产生页面已经在生成的感觉

并且不能过分影响效率

  1. 使用 chrome 浏览器插件来代替骨架屏的生成,去掉 server ,socker,puppteer 三个重量级的工具
  2. 因为使用插件了,获取什么就由开发者来指定了

落地

最后决定使用 chrome插件 + prerender + ElemeFE/page-skeleton的生成skeleton的思路

chrome 浏览器基本介绍

messagingarc.7d9a5f1f.png

获取在浏览器开发者模式下的节点选择

window.$0 通过这个属性就可以获取到在浏览器的选择的dom节点

然后将选择的dom配合上上述的骨架屏生成就可以生成骨架屏了

基础代码

// 设置 content script 监听命令,收到命令将结果发送出去
document.addEventListener('output', async ({ detail }) => {
  if (!isDOM(window.$0)) {
    chrome.runtime.sendMessage({
      name: 'log',
      data: 'Page elements has not selected'
    })
    return
  }
  const { html, style } = await outputSkeleton(window.$0, option)
  SKELETON_CACHE.html = html
  SKELETON_CACHE.style = style
  SKELETON_CACHE['currentSkeletonNode'] = html
  chrome.runtime.sendMessage({
    name: 'getSkeletonInfo',
    data: { html: html.outerHTML, style: style.outerHTML }
  })
})
// devtool script 通过 background 转接过来的数据再通过调用 devtool 页面上的方法 contentScriptReceiver 把数据转发到页面
port.onMessage.addListener(message => {
  _window.contentScriptReceiver(message)
})
// devtool 页面的代码
window.contentScriptReceiver = (data: any) => {
  // data 就是就是获取上面第一步生成出来的数据
}
// 将 html 和 style 装进 函数式 vue 容器里面
// 使用时,将对应组件作为slot传进骨架组件
// 在恰当的时间,把骨架组件的show这个prop设置为false
// 从此loading是路人
<script>
  import Vue from 'vue'
  const skeletonLoader = {
    name: 'skeletocnLoader',
    functional: true,
    props: {
      show: {
        type: Boolean,
        default: false
      }
    },
    render(h, context) {
      // 定义一个 props 当 show 为 false 的时候
      // 展示 slot 里面的节点
      const { show } = context.props
      if (!show) {
        const component = Vue.compile(html)
        return h(component)
      } else {
        return context.children[0]
      }
    }
  }
  export default skeletonLoader
  </script>
  <style>
    ${styles}
  </style>

总结

以上就是整个预渲染和骨架屏的优化和总结

最后再复述下整个流程

  1. 使用正常的工作流打开开发中的页面
  2. 给页面接入测试数据或者直接连接测试环境,构造最佳的展示页面
  3. 选取要进行预渲染的模块,将起生成出来生成一个 vue-skeleton 并将原组件包裹进去
  4. 在构建流程阶段,使用预渲染生成初始节点(启动 poputeer-pre ,因为生成的 vue-skeleton 有一个处理,处于 poputeer-pre 下会展示骨架而不是数据)
  5. 最后结合旗面说到的生成多了一个新入口跟原本的入口区分开来

skt.png js disable skeleton page


puppeteer-renderer

prerender-spa-plugin

自动化生成 H5 骨架页面

chrome 插件

函数式组件

robbiemie commented 2 years ago

yyds

janceChun commented 1 year ago