我是如何把性能優(yōu)化的顆粒度做的更細(xì)
本文轉(zhuǎn)載自微信公眾號(hào)「前端壹棧」,作者花開富貴。轉(zhuǎn)載本文請聯(lián)系前端壹棧公眾號(hào)。
前言
之前我也研究過很多性能相關(guān)的文檔和博客,發(fā)現(xiàn)現(xiàn)在的性能相關(guān)的文章 90% 都是之前有過的東西,但是目前的性能優(yōu)化只能做到如今的樣子了嗎?
很顯然,肯定不是的,技術(shù)本來就是個(gè)逐漸進(jìn)步的過程,但是現(xiàn)在更多的是把當(dāng)前的內(nèi)容去翻來覆去的卷,我表示卷不動(dòng)了,所以我準(zhǔn)備尋找新的出路了
想法的誕生
其實(shí)我們現(xiàn)在的性能優(yōu)化的檢測及性能優(yōu)化的方案已經(jīng)有了很多了,從開發(fā)到用戶體驗(yàn)的各個(gè)角度來說,都有不同的檢測和處理方案,目前市面上流傳最多的就是以下這些:
- 開發(fā)階段(公共變量、公共樣式、組件提取、數(shù)據(jù)處理算法、影響頁面渲染速度和用戶響應(yīng)的使用worker(元素除外)等)
- 打包構(gòu)建(gzip 壓縮、去log、去 sourcemap、按需引入、按需加載、圖片樣式合并、減少打包時(shí)間和打包體積、添加緩存等)
- 發(fā)布階段(CI、CD)
- 資源優(yōu)化(強(qiáng)緩存、協(xié)商緩存、資源預(yù)加載、異步加載、service-worker等)
當(dāng)然了不止這么多東西,我只是把常用的一些東西列了一下,比如我之前寫過的一個(gè)實(shí)戰(zhàn)篇 - 如何實(shí)現(xiàn)和淘寶移動(dòng)端一樣的模塊化加載 (task-silce)和 解析篇 - Task-slice實(shí)現(xiàn)淘寶移動(dòng)端方式加載這就是在開發(fā)階段比較細(xì)節(jié)的用戶體驗(yàn)方面的性能優(yōu)化,當(dāng)然我們還可以基于 performance api 來做性能優(yōu)化前的檢測,這方面正好之前我也整理過部分內(nèi)容性能優(yōu)化篇 - Performance(工具 & api)
基于這些東西我想了想,我還是覺得性能優(yōu)化做的不夠細(xì)不夠具體,這樣有很多的弊端:
- 偽性能優(yōu)化(這樣就代表著性能優(yōu)化做的不夠徹底)
- 不能完全的掌握頁面dom渲染相關(guān)的數(shù)據(jù)(火焰圖看的太復(fù)雜,沒有數(shù)據(jù)化)
- 通過 performance.mark 植入的方式,可能對于項(xiàng)目來說是個(gè)很大的成本,會(huì)在業(yè)務(wù)里面植入很多無效代碼來做用戶體驗(yàn)的檢測,而且可能在某些情況下會(huì)影響到業(yè)務(wù),或者業(yè)務(wù)的某些條件導(dǎo)致 performance.mark 無法準(zhǔn)確抓取,這樣整體來說就無法真正達(dá)到完美的目的了
這時(shí)候我就考慮要如何可以規(guī)避這些問題,還能準(zhǔn)確的捕捉到有關(guān)當(dāng)前元素的渲染時(shí)間呢,baidu、google 查了一段時(shí)間后發(fā)現(xiàn)了一個(gè)api好像可以解決這個(gè)問題,于是我開始入手了
想法的實(shí)現(xiàn)
實(shí)現(xiàn)上述想法時(shí),我們需要梳理一下我們的需求:
捕捉當(dāng)前元素的渲染時(shí)間(何時(shí)開始、渲染多久、渲染位置)
不把性能檢測相關(guān)的代碼植入到業(yè)務(wù)當(dāng)中,實(shí)現(xiàn)上述需求
捕捉到的這些信息在何處預(yù)覽(在公司沒有性能檢測平臺(tái)的情況下,我們是否要為了這種做優(yōu)化相關(guān)的需求去在搭建一個(gè)性能檢測平臺(tái))
是否可以通過瀏覽器插件來展示這些數(shù)據(jù)(這樣方便預(yù)覽,還不影響各個(gè)方向的業(yè)務(wù))
有了想法,剩下的就是實(shí)現(xiàn)即可了
捕捉當(dāng)前元素的渲染時(shí)間
其實(shí)本文所述的功能,最主要就是基于這個(gè) api 來實(shí)現(xiàn)的,它就是元素的 elementtiming 屬性
使用方法也很簡單就是給當(dāng)前要檢測的元素添加該屬性:
- <div elementtiming="text">
- 測試text
- </div>
然后在通過 PerformanceObserver對象獲取相應(yīng)的數(shù)據(jù):
- const observer = new PerformanceObserver((list) => {
- console.log(list.getEntries())
- });
- observer.observe({ entryTypes: ["element"] });
log 里面就可以獲取到 elementtiming 值為 text 的元素的相關(guān)信息:loadTime(加載時(shí)間)、 renderTime(渲染時(shí)間)等,這里簡單介紹一下不做過多的詳解,大家知道我用它做了什么就好
當(dāng)然,這個(gè) api 在該元素只包含其他元素(無文本),就不會(huì)生成 PerformanceEntry,這個(gè)問題是我在網(wǎng)上百度不到,但是看了 MDN 的案例發(fā)現(xiàn)效果不準(zhǔn)確,在給 chromium 提了 issue后,官方回復(fù)給的答案
issue 鏈接:vue or react local server, new PerformanceObserver().obserbe({ entryTypes: ['element'] }) Incomplete acquisition, but build after the project unstable
這個(gè)過程是很復(fù)雜的,在了解到官方的答復(fù)后,我覺得這樣的 api 它是不完善的,本來還想繼續(xù)在上面鏈接的評論區(qū)繼續(xù)討論,但是抵不住老外手快直接把 bug 給關(guān)了
好吧,那我只能重新起一個(gè)需求出來,和他們討論了:
issue 鏈接:PerformanceObserver api result not what i expected
提了這個(gè)需求后,我還等著討論一下我的這個(gè)需求呢,但是還是很利索的告訴我這里不負(fù)責(zé)這個(gè),讓我去 WICG 那邊提需求。。。
然后我就過去了:
大致的意思就是我想要的是一個(gè)完整的樹狀數(shù)據(jù)表,這樣我可以知道我每一層數(shù)據(jù)的渲染時(shí)間和對應(yīng)子級(jí)的渲染,但是老外沒明白我的意思,跟我說直接獲取到目標(biāo) img 或者含有文本的元素不好嗎,這樣還節(jié)省性能:
這明顯是無法滿足我的需求的,我也只能給他在詳細(xì)的解釋一遍了:
不知道我解釋的清楚不,或者是我的需求是否也是大家需要的歡迎討論,底部會(huì)留聯(lián)系方式或者在該 issue 中討論也行
issue 鏈接:PerformanceObserver api return result not what i need
好了,有關(guān)該 api 在調(diào)研和使用階段出現(xiàn)的問題及我的解決辦法表述先到此為止,重點(diǎn)是整體功能,大家會(huì)用就夠
不把性能檢測相關(guān)的代碼植入到業(yè)務(wù)當(dāng)中,實(shí)現(xiàn)上述需求
如題,我不想把這方面的代碼嵌入到項(xiàng)目當(dāng)中,因?yàn)槿绻且粋€(gè)特別大的項(xiàng)目,我要是寫一堆 performance.mark 我得寫哭了,很顯然這個(gè)方式是不現(xiàn)實(shí)的,然后我就想到是否可以通過 webpack 實(shí)現(xiàn)該需求呢?
那必須可以啊,解析當(dāng)前的內(nèi)容,然后通過拿到對應(yīng)的資源去添加該屬性,但是不建議直接通過內(nèi)容去匹配,比如內(nèi)容是這樣的:
- <div class="a">
- this is <div class="a"> element
- </div>
哇嘎理工啊,如果直接把 loader 添加到 webpack 的配置當(dāng)中,那么對于整個(gè)項(xiàng)目來說當(dāng)前 loader 訪問到的是當(dāng)前打包文件內(nèi)的所有內(nèi)容,能寫嗎?肯定是不可以的,正則讓你寫到死啊
那通過 babel 解析 ast 去做渲染呢,這樣可以準(zhǔn)確的拿到對應(yīng)的屬性了啊,這樣不就可以了嗎?大概的方向?qū)α?,但是直接使用的情況下,babel 會(huì)對當(dāng)前所有的內(nèi)容資源進(jìn)行轉(zhuǎn)譯,這明顯不是我所需要的:
- // unitl.js
- export const fn1 = function() {
- return 1
- }
- // component.js
- export default function() {
- return <div>this is <div class="a"> element </div>
- }
直接只用 babel 轉(zhuǎn)譯的話,上述的文件都會(huì)通過 babel 轉(zhuǎn)譯一遍,那么這樣對于我們來說并不是合理的,不能因?yàn)闉榱藱z測元素性能而導(dǎo)致頁面構(gòu)建速度變慢嗎?更何況這還不是最優(yōu)解
這時(shí)候我想到了一個(gè)辦法,也是我目前使用的一個(gè)辦法,大家可以看看是否真的是最優(yōu)解,我目前是考慮到這里了:
「通過 webpack plugin 在 build 前,給當(dāng)前模塊添加一個(gè) loader,在當(dāng)前的 loader 內(nèi)去通過 babel 轉(zhuǎn)譯添加 elementtiming」
知道了如何做就開始擼代碼了,下面是調(diào)用方式:
- // webpack.config.js
- const ElementRenderingWebpackPlugin = require('element-rendering-webpack-plugin')
- module.exports = {
- plugin: [
- new ElementRenderingWebpackPlugin()
- ]
- }
plugin 的實(shí)現(xiàn)也比較簡單,主要的工作是在 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 前去做模塊的確認(rèn),只對我自己的業(yè)務(wù)和需要的代碼添加該 loader,這樣就可以繞過上面直接使用 babel 方法導(dǎo)致的構(gòu)建速度問題」
在此要對文件做一些過濾,因?yàn)槭?1.0 的出版,所以還有一些東西沒有完全考慮,還需要繼續(xù)優(yōu)化,這里提示一下暫時(shí)是不支持 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;
這里直接上代碼了,東西太多就不一行一行解釋了,代碼會(huì)開源,鏈接在底部自取慢慢看
「大概做的就是把當(dāng)前跑進(jìn)來的代碼通過 ast 轉(zhuǎn)譯,拿到 ast 對象后添加 elementtiming 屬性,data-mark 是用來做數(shù)據(jù)去重的」
好了,這時(shí)候最基礎(chǔ)的 「捕獲數(shù)據(jù)」 和 「不把性能檢測相關(guān)的代碼植入到業(yè)務(wù)當(dāng)中,實(shí)現(xiàn)上述需求」,那么接下來就該通過瀏覽器插件來展示這些數(shù)據(jù)
通過瀏覽器插件來展示這些數(shù)據(jù)
由于之前是真心沒寫過 chrome-extension ,可踩了不少坑,很多 version 2 可以用的東西 version 3 不支持
這里我直接就上核心部分的代碼了,剩下一些基礎(chǔ)配置類的大家自己到時(shí)候看代碼吧:
- // 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"] });
contentScript 是 chrome-extension 內(nèi)訪問頁面元素的一個(gè)配置文件,當(dāng)然文件名自己隨便取,為了方便閱讀和理解,我直接跟著官方文檔的節(jié)奏走的,這里大家可以發(fā)現(xiàn)我上面有一個(gè)方法是 createMark 里面有創(chuàng)建元素和定位,這里是配合 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進(jìn)行優(yōu)化的,當(dāng)點(diǎn)擊某一個(gè)樹的時(shí)候,會(huì)畫一個(gè)框出來,標(biāo)明當(dāng)前元素的時(shí)間和對應(yīng)的元素在哪里:
這就是最后的效果,我是直接 react 腳手架搭完直接安裝的
尾聲
大概的實(shí)現(xiàn)思路和思考的過程,基本上我都描述的差不多了,過程當(dāng)中有很多次想過放棄,但是又不忍心拋棄自己之前的付出所以就堅(jiān)持下來了,也算是做出來的了,但是 elementtiming api 那里那個(gè)問題,還是需要我繼續(xù)研究和解決的,我會(huì)繼續(xù)和 WICG 那邊溝通,爭取可以讓它變得更好
可能有大佬看見會(huì)說這東西很簡單啊,沒什么值得思考地方,那我只想說dddd,我比較菜,得一步一步的學(xué),你們輕點(diǎn)噴哈
代碼開源了已經(jīng),歡迎大家互相討論學(xué)習(xí),也希望大家給點(diǎn)點(diǎn) star,多提 issue,如果有興趣的朋友我還希望大家一起來維護(hù)這個(gè)東西:
plugin: element-rendering-webpack-plugin
loader: element-rendering-webpack-loader
extension: element-rendering-extension