实战篇 - 如何把性能优化的颗粒度做的更细

2,383 阅读9分钟

前言

之前我也研究过很多性能相关的文档和博客,发现现在的性能相关的文章 90% 都是之前有过的东西,但是目前的性能优化只能做到如今的样子了吗?

很显然,肯定不是的,技术本来就是个逐渐进步的过程,但是现在更多的是把当前的内容去翻来覆去的卷,我表示卷不动了,所以我准备寻找新的出路了

想法的诞生

其实我们现在的性能优化的检测及性能优化的方案已经有了很多了,从开发到用户体验的各个角度来说,都有不同的检测和处理方案,目前市面上流传最多的就是以下这些:

  • 开发阶段(公共变量、公共样式、组件提取、数据处理算法、影响页面渲染速度和用户响应的使用worker(元素除外)等)
  • 打包构建(gzip 压缩、去log、去 sourcemap、按需引入、按需加载、图片样式合并、减少打包时间和打包体积、添加缓存等)
  • 发布阶段(CI、CD)
  • 资源优化(强缓存、协商缓存、资源预加载、异步加载、service-worker等)

当然了不止这么多东西,我只是把常用的一些东西列了一下,比如我之前写过的一个实战篇 - 如何实现和淘宝移动端一样的模块化加载 (task-silce)解析篇 - Task-slice实现淘宝移动端方式加载这就是在开发阶段比较细节的用户体验方面的性能优化,当然我们还可以基于 performance api 来做性能优化前的检测,这方面正好之前我也整理过部分内容性能优化篇 - Performance(工具 & api)

基于这些东西我想了想,我还是觉得性能优化做的不够细不够具体,这样有很多的弊端:

  • 伪性能优化(这样就代表着性能优化做的不够彻底)
  • 不能完全的掌握页面dom渲染相关的数据(火焰图看的太复杂,没有数据化)
  • 通过 performance.mark 植入的方式,可能对于项目来说是个很大的成本,会在业务里面植入很多无效代码来做用户体验的检测,而且可能在某些情况下会影响到业务,或者业务的某些条件导致 performance.mark 无法准确抓取,这样整体来说就无法真正达到完美的目的了

这时候我就考虑要如何可以规避这些问题,还能准确的捕捉到有关当前元素的渲染时间呢,baidu、google 查了一段时间后发现了一个api好像可以解决这个问题,于是我开始入手了

timg.gif

想法的实现

实现上述想法时,我们需要梳理一下我们的需求:

  • 捕捉当前元素的渲染时间(何时开始、渲染多久、渲染位置)
  • 不把性能检测相关的代码植入到业务当中,实现上述需求
  • 捕捉到的这些信息在何处预览(在公司没有性能检测平台的情况下,我们是否要为了这种做优化相关的需求去在搭建一个性能检测平台)
  • 是否可以通过浏览器插件来展示这些数据(这样方便预览,还不影响各个方向的业务)

有了想法,剩下的就是实现即可了

捕捉当前元素的渲染时间

其实本文所述的功能,最主要就是基于这个 api 来实现的,它就是元素的 elementtiming 属性

使用方法也很简单就是给当前要检测的元素添加该属性:

   <div elementtiming="text">
       测试text
   </div>

然后在通过 PerformanceObserver对象获取相应的数据:

    const observer = new PerformanceObserver((list) => {
      console.log(list.getEntries())
    });
    observer.observe({ entryTypes: ["element"] });

log 里面就可以获取到 elementtiming 值为 text 的元素的相关信息: loadTime(加载时间)renderTime(渲染时间)等,这里简单介绍一下不做过多的详解,大家知道我用它做了什么就好

当然,这个 api 在该元素只包含其他元素(无文本),就不会生成 PerformanceEntry,这个问题是我在网上百度不到,但是看了 MDN 的案例发现效果不准确,在给 chromium 提了 issue后,官方回复给的答案

issue 链接:vue or react local server, new PerformanceObserver().obserbe({ entryTypes: ['element'] }) Incomplete acquisition, but build after the project unstable

这个过程是很复杂的,在了解到官方的答复后,我觉得这样的 api 它是不完善的,本来还想继续在上面链接的评论区继续讨论,但是抵不住老外手快直接把 bug 给关了

好吧,那我只能重新起一个需求出来,和他们讨论了:

issue 链接:PerformanceObserver api result not what i expected

提了这个需求后,我还等着讨论一下我的这个需求呢,但是还是很利索的告诉我这里不负责这个,让我去 WICG 那边提需求。。。

然后我就过去了:

image.png

大致的意思就是我想要的是一个完整的树状数据表,这样我可以知道我每一层数据的渲染时间和对应子级的渲染,但是老外没明白我的意思,跟我说直接获取到目标 img 或者含有文本的元素不好吗,这样还节省性能:

image.png

这明显是无法满足我的需求的,我也只能给他在详细的解释一遍了:

image.png

不知道我解释的清楚不,或者是我的需求是否也是大家需要的欢迎讨论,底部会留联系方式或者在该 issue 中讨论也行

issue 链接:PerformanceObserver api return result not what i need

好了,有关该 api 在调研和使用阶段出现的问题及我的解决办法表述先到此为止,重点是整体功能,大家会用就够

不把性能检测相关的代码植入到业务当中,实现上述需求

如题,我不想把这方面的代码嵌入到项目当中,因为如果是一个特别大的项目,我要是写一堆 performance.mark 我得写哭了,很显然这个方式是不现实的,然后我就想到是否可以通过 webpack 实现该需求呢?

那必须可以啊,解析当前的内容,然后通过拿到对应的资源去添加该属性,但是不建议直接通过内容去匹配,比如内容是这样的:

    <div class="a">
        this is <div class="a"> element
    </div>

哇嘎理工啊,如果直接把 loader 添加到 webpack 的配置当中,那么对于整个项目来说当前 loader 访问到的是当前打包文件内的所有内容,能写吗?肯定是不可以的,正则让你写到死啊

image.png

那通过 babel 解析 ast 去做渲染呢,这样可以准确的拿到对应的属性了啊,这样不就可以了吗?大概的方向对了,但是直接使用的情况下,babel 会对当前所有的内容资源进行转译,这明显不是我所需要的:

// unitl.js
export const fn1 = function() {
    return 1
}

// component.js

export default function() {
    return <div>this is <div class="a"> element </div>
}

直接只用 babel 转译的话,上述的文件都会通过 babel 转译一遍,那么这样对于我们来说并不是合理的,不能因为为了检测元素性能而导致页面构建速度变慢吗?更何况这还不是最优解

这时候我想到了一个办法,也是我目前使用的一个办法,大家可以看看是否真的是最优解,我目前是考虑到这里了:

通过 webpack plugin 在 build 前,给当前模块添加一个 loader,在当前的 loader 内去通过 babel 转译添加 elementtiming

知道了如何做就开始撸代码了,下面是调用方式:

// webpack.config.js

const ElementRenderingWebpackPlugin = require('element-rendering-webpack-plugin')
module.exports = {
    plugin: [
        new ElementRenderingWebpackPlugin()
    ]
}

plugin 的实现也比较简单,主要的工作是在 loader 部分:

// element-rendering-webpack-plugin.js

class MyPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
      compilation.hooks.buildModule.tap('SourceMapDevToolModuleOptionsPlugin', module => {
        if (module.resource) {
          if (/(\.((j|t)sx?)$)/.test(module.resource) && 
          !/node_modules/.test(module.resource)) {
            if (module.loaders) {
              module.loaders.push({
                loader: 'element-rendering-webpack-loader'
              })
            }
          }
        }
      })      
    })  
  }
}
module.exports = MyPlugin

上面代码就是在 compilation 生成后,就在模块 build 前去做模块的确认,只对我自己的业务和需要的代码添加该 loader,这样就可以绕过上面直接使用 babel 方法导致的构建速度问题

在此要对文件做一些过滤,因为是 1.0 的出版,所以还有一些东西没有完全考虑,还需要继续优化,这里提示一下暂时是不支持 vue 使用的,vue 模块的 loader 太多了,我要多做测试才敢上线,还希望大家体谅

// element-rendering-webpack-loader.js


const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const { transformFromAstSync } = require('@babel/core');
const t = require('@babel/types');
let randomSet = new Set();

function UpdateAssets(asset) {
  let code = asset
  try {
    const ast = parser.parse(asset, {
      sourceType: 'module',
      plugins: [
        'flow',
        'jsx'
      ]
    });
    traverse(ast, {
      JSXElement(nodePath) {
        if (nodePath.node.type === 'JSXElement' && nodePath.node.openingElement.name.name === 'img') {
          return
        }
        updateAttr(nodePath.node);
      }
    })
    code = transformFromAstSync(ast).code;
  } catch(e) {
    console.log(e)
  }
  return code;
}

function updateAttr(node) {
  if (node.type === 'JSXElement') {
    let { openingElement, children } = node;
    let name = openingElement.name.name || openingElement.type
    let className = openingElement.attributes.filter(attr => {
      if (attr.type === 'JSXSpreadAttribute') return false
      return /class(Name)?/.test(attr.name.name)
    })
    if (className.length) {
      name = className[0].value.value
    }
    if (!openingElement) return
    const elementtimingList = openingElement.attributes.filter(attr => {
      if (attr.type !== 'JSXSpreadAttribute' && attr.name.name === 'elementtiming') {
        return true
      }
    })
    if (!elementtimingList.length) {
      openingElement.attributes.push(addElementttiming(name + '-' + Math.ceil(Math.random() * 100000)));
    }
    const markList = openingElement.attributes.filter(attr => {
      if (attr.type !== 'JSXSpreadAttribute' && attr.name.name === 'data-mark') {
        return true
      }
    })
    if (!markList.length) {
      openingElement.attributes.push(addMark());
    }
    children.map(childNode => updateAttr(childNode));
  }
}

function addElementttiming(name) {
  return t.jsxAttribute(t.jsxIdentifier('elementtiming'), t.stringLiteral(name));
}

function addMark() {
  let randomStatus = true;
  let markRandom = 0;
  while(randomStatus) {
    markRandom = Math.ceil(Math.random() * 100000);
    randomStatus = randomSet.has(markRandom);
    if (!randomStatus) {
      randomSet.add(markRandom);
    }
  }
  return t.jsxAttribute(t.jsxIdentifier('data-mark'), t.stringLiteral(markRandom + ''));
}

module.exports = UpdateAssets;

这里直接上代码了,东西太多就不一行一行解释了,代码会开源,链接在底部自取慢慢看

大概做的就是把当前跑进来的代码通过 ast 转译,拿到 ast 对象后添加 elementtiming 属性,data-mark 是用来做数据去重的

好了,这时候最基础的 捕获数据不把性能检测相关的代码植入到业务当中,实现上述需求,那么接下来就该通过浏览器插件来展示这些数据

通过浏览器插件来展示这些数据

由于之前是真心没写过 chrome-extension ,可踩了不少坑,很多 version 2 可以用的东西 version 3 不支持

43a7d933c895d143b579154f7cf082025aaf074a.gif

这里我直接就上核心部分的代码了,剩下一些基础配置类的大家自己到时候看代码吧:

// contentScript.js

chrome.runtime.onMessage.addListener(function(request) {
  const { type, data } = request.data
  switch(type) {
    case 'selectedElement':
      createMask(data)
      break;
    case 'cancelElement':
      cancelMask()
      break;
  }
})

function createMask(data) {
  cancelMask()
  const div = document.createElement('div')
  Object.keys(data).map(styleKey => div.style[styleKey] = data[styleKey] + 'px')
  div.style.position = 'absolute'
  div.style.background = 'rgba(109, 187, 220, 0.5)'
  div.style.zIndex = '9999'
  div.id = 'mask-element'
  document.body.appendChild(div)
}

function cancelMask() {
  const maskElement = document.querySelector('#mask-element')
  if (maskElement !== null) {
    document.body.removeChild(maskElement)
  }
}

function getElementTreeData(element, elementTreeData, performanceElementTimingObj) {
  let children = element.children
  for (let i = 0; i < children.length; ++i) {
    let childElement = children[i]
    let argObj = {}
    let nodeValue = ''
    let parsePerformanceElementTiming = {}
    if ('elementtiming' in childElement.attributes) {
      nodeValue = childElement.attributes.elementtiming.nodeValue
      argObj['elementtiming'] = true
      argObj['key'] = childElement.dataset.mark
      let performanceElementTiming = performanceElementTimingObj[argObj['key']]
      if (performanceElementTiming) {
        parsePerformanceElementTiming = JSON.parse(JSON.stringify(performanceElementTiming))
      }
    } else {
      nodeValue = childElement.nodeName
      argObj['key'] = Math.ceil(Math.random() * 100000)
    }
    argObj = Object.assign({}, argObj, parsePerformanceElementTiming, {
      intersectionRect: childElement.getBoundingClientRect()
    })
    if (/(NO)?SCRIPT/.test(nodeValue)) continue
    argObj['children'] = childElement.children.length ? getElementTreeData(childElement, [], performanceElementTimingObj) : []
    argObj['title'] = nodeValue.replace(/-([0-9]*)$/, '')
    elementTreeData.push(argObj)
  }
  return elementTreeData
}

let performanceElementTimingList = []
const observer = new PerformanceObserver((list) => {
  let elementTree = []
  let performanceElementTimingObj = {}
  performanceElementTimingList = performanceElementTimingList.concat(list.getEntries())
  performanceElementTimingList.map(performanceTimingItem => {
    if (performanceTimingItem.element !== null) {
      return performanceElementTimingObj[performanceTimingItem.element.dataset.mark] = performanceTimingItem
    }
  })
  chrome.runtime.sendMessage(
    {
      type: 'performanceTree',
      data: getElementTreeData(document.body, elementTree, performanceElementTimingObj)
    }
  )
});
observer.observe({ entryTypes: ["element"] });

contentScriptchrome-extension 内访问页面元素的一个配置文件,当然文件名自己随便取,为了方便阅读和理解,我直接跟着官方文档的节奏走的,这里大家可以发现我上面有一个方法是 createMark 里面有创建元素和定位,这里是配合 devtools 里面的树来使用的:


// app.js 
import { useState, useEffect } from 'react';
import { Tree } from 'antd';
import './App.css';
function App() {
  const [treeData, setTreeData] = useState([]) 
  window.addEventListener('message', msg => {
    const { type, data } = msg.data
    if (type === 'performanceTree') {
      setTreeData(data)
    }
  })
  useEffect(() => {
  }, [treeData])
  return (
    <div className="App">
      <Tree
        showLine
        titleRender={
          nodeData => {
            return (
              <div onMouseOver={() => { selectedElement(nodeData) }} onMouseOut={cancelElement}>
                {nodeData.title}{updateTime(nodeData)}
              </div>
            )
          }
        }
        treeData={treeData}
      />
    </div>
  );
}

function updateTime(nodeData) {
  let str = ' - '
  if (nodeData.renderTime) {
    str += Math.round(nodeData.renderTime)
  } else {
    str += '该元素下非元素外不存在文本'
  }
  return str
}

function selectedElement(nodeData) {
  console.log('selectedElement')
  if (!nodeData.disabled) {
    postMessage(
      {
        type: 'selectedElement',
        data: nodeData.intersectionRect
      },
      '*'
    )
  }
}

function cancelElement () {
  console.log('cancelElement')
  postMessage(
    {
      type: 'cancelElement'
    },
    '*'
  )
}

export default App;

为了页面的美观度,我用了 antd 去对页面ui进行优化的,当点击某一个树的时候,会画一个框出来,标明当前元素的时间和对应的元素在哪里:

QQ20210607-174438-HD.gif

这就是最后的效果,我是直接 react 脚手架搭完直接安装的

尾声

大概的实现思路和思考的过程,基本上我都描述的差不多了,过程当中有很多次想过放弃,但是又不忍心抛弃自己之前的付出所以就坚持下来了,也算是做出来的了,但是 elementtiming api 那里那个问题,还是需要我继续研究和解决的,我会继续和 WICG 那边沟通,争取可以让它变得更好

可能有大佬看见会说这东西很简单啊,没什么值得思考地方,那我只想说dddd,我比较菜,得一步一步的学,你们轻点喷哈

代码开源了已经,欢迎大家互相讨论学习,也希望大家给点点 star,多提 issue,如果有兴趣的朋友我还希望大家一起来维护这个东西:

公众号:全球互联网技术分享,关注后会发送入群二维码