Sakurairo

加载中...

首页文章专题归档关于友链

主题

返回文章列表
前端开发

前端性能优化

一、分析 1.瀑布图:分析网路链路 重要的点有三个: 1.Initial Connection [橙色] 这个过程仅仅发生在瀑布图中的开头几行, 否则这就是个性能问题。 2.Time To First Byte (TTFB) [绿色] -...

2024-07-0212 分钟 127 阅读

发布于 2024-07-02 14:13

·

作者:八云澈

一、分析

1.瀑布图:分析网路链路

重要的点有三个: 1.Initial Connection [橙色] 这个过程仅仅发生在瀑布图中的开头几行, 否则这就是个性能问题。 2.Time To First Byte (TTFB) [绿色] - TTFB 是浏览器请求发送到服务器的时间+服务器处理请求时间+响应报文的第一字节到达浏览器的时间. 我们用这个指标来判断你的web服务器是否性能不够, 或者说你是否需要使用CDN。 3.Downloading (蓝色) - 这是浏览器用来下载资源所用的时间. 这段时间越长, 说明资源越大. 理想情况下, 你可以通过控制资源的大小来控制这段时间的长度。

2.包分析:webpack-bundle-analyzer

其中模块面积占的越大说明在bundle包中size越大。

它能够排查出来的信息有

  • 显示包中所有打入的模块
  • 显示模块size 及 gzip后的size

3.chrome 自带的性能分析工具 performance

它能排查出来的信息有:

  • FCP/LCP 时间是否过长?
  • 请求并发情况 是否并发频繁?
  • 请求发起顺序 请求发起顺序是否不对?
  • javascript执行情况 javascript执行是否过慢?

主要的性能指标如下:

  • First Paint 首次绘制(FP)
    这个指标用于记录页面第一次绘制像素的时间,如显示页面背景色。

    FP不包含默认背景绘制,但包含非默认的背景绘制。

  • First contentful paint 首次内容绘制 (FCP)
    LCP是指页面开始加载到最大文本块内容或图片显示在页面中的时间。如果 FP 及 FCP 两指标在 2 秒内完成的话我们的页面就算体验优秀。

  • Largest contentful paint 最大内容绘制 (LCP)
    用于记录视窗内最大的元素绘制的时间,该时间会随着页面渲染变化而变化,因为页面中的最大元素在渲染过程中可能会发生改变,另外该指标会在用户第一次交互后停止记录。官方推荐的时间区间,在 2.5 秒内表示体验优秀

  • First input delay 首次输入延迟 (FID)
    首次输入延迟,FID(First Input Delay),记录在 FCP 和 TTI 之间用户首次与页面交互时响应的延迟。

  • Time to Interactive 可交互时间 (TTI)
    首次可交互时间,TTI(Time to Interactive)。这个指标计算过程略微复杂,它需要满足以下几个条件:

    1. 从 FCP 指标后开始计算
    2. 持续 5 秒内无长任务(执行时间超过 50 ms)且无两个以上正在进行中的 GET 请求
    3. 往前回溯至 5 秒前的最后一个长任务结束的时间

    对于用户交互(比如点击事件),推荐的响应时间是 100ms 以内。那么为了达成这个目标,推荐在空闲时间里执行任务不超过 50ms( W3C 也有这样的标准规定),这样能在用户无感知的情况下响应用户的交互,否则就会造成延迟感。

  • Total blocking time 总阻塞时间 (TBT)
    阻塞总时间,TBT(Total Blocking Time),记录在 FCP 到 TTI 之间所有长任务的阻塞时间总和。

  • Cumulative layout shift 累积布局偏移 (CLS)
    累计位移偏移,CLS(Cumulative Layout Shift),记录了页面上非预期的位移波动。页面渲染过程中突然插入一张巨大的图片或者说点击了某个按钮突然动态插入了一块内容等等相当影响用户体验的网站。这个指标就是为这种情况而生的,计算方式为:位移影响的面积 * 位移距离。

4.进阶获取各个阶段的响应时间可以通过 PerformanceNavigationTiming获取

JavaScript
1function showNavigationDetails() {2 const [entry] =   performance.getEntriesByType("navigation"); 3 console.table(entry.toJSON()); 4 }

这个函数能获取到各个阶段的响应时间。 具体的参数为: navigationStart 加载起始时间 redirectStart 重定向开始时间(如果发生了HTTP重定向,每次重定向都和当前文档同域的话,就返回开始重定向的fetchStart的值。其他情况,则返回0) redirectEnd 重定向结束时间(如果发生了HTTP重定向,每次重定向都和当前文档同域的话,就返回最后一次重定向接受完数据的时间。其他情况则返回0) fetchStart 浏览器发起资源请求时,如果有缓存,则返回读取缓存的开始时间 domainLookupStart 查询DNS的开始时间。如果请求没有发起DNS请求,如keep-alive,缓存等,则返回fetchStart domainLookupEnd 查询DNS的结束时间。如果没有发起DNS请求,同上 connectStart 开始建立TCP请求的时间。如果请求是keep-alive,缓存等,则返回domainLookupEnd (secureConnectionStart) 如果在进行TLS或SSL,则返回握手时间 connectEnd 完成TCP链接的时间。如果是keep-alive,缓存等,同connectStart requestStart 发起请求的时间 responseStart 服务器开始响应的时间 domLoading 是开始渲染dom的时间,具体未知 domInteractive 未知 domContentLoadedEventStart 开始触发DomContentLoadedEvent事件的时间 domContentLoadedEventEnd DomContentLoadedEvent事件结束的时间 domComplete 是dom渲染完成时间,具体未知 loadEventStart 触发load的时间,如没有则返回0 loadEventEnd load事件执行完的时间,如没有则返回0 unloadEventStart unload事件触发的时间 unloadEventEnd unload事件执行完的时间

关于Web性能,会用到的时间参数:

DNS解析时间: domainLookupEnd - domainLookupStart TCP建立连接时间: connectEnd - connectStart 白屏时间: responseStart - navigationStart dom渲染完成时间: domContentLoadedEventEnd - navigationStart 页面onload时间: loadEventEnd - navigationStart

根据这些时间参数,我们就可以判断哪一阶段对性能有影响。

二、优化

1.经典的tree shaking

webpack4.x默认对tree shaking进行了支持,如果项目中使用的还是webpack3.x或2.x可以使用如下方式配置tree shaking.

bable.config:

JSON
1{2  "presets": [3    ["env", {4      "loose": true,5      "modules": false6    }]7  ]8}

Webpack.config:

JSON
1module: {2  rules: [3    { test: /\.js$/, loader: 'babel-loader' }4  ]5},6 7plugins: [8  new webpack.LoaderOptionsPlugin({9    minimize: true,10    debug: false11  }),12  new webpack.optimize.UglifyJsPlugin({13    compress: {14      warnings: true15    },16    output: {17      comments: false18    },19    sourceMap: false20  })21]

2.split chunks(分包)

在没配置任何东西的情况下,webpack 4 就智能的帮你做了代码分包。入口文件依赖的文件都被打包进了main.js,那些大于 30kb 的第三方包,如:echarts、xlsx、dropzone等都被单独打包成了一个个独立 bundle。

它内置的代码分割策略是这样的:

  • 新的 chunk 是否被共享或者是来自 node_modules 的模块
  • 新的 chunk 体积在压缩之前是否大于 30kb
  • 按需加载 chunk 的并发请求数量小于等于 5 个
  • 页面初始加载时的并发请求数量小于等于 3 个

想通过这个实现性能优化,只使用分包是不够的,还需要增加下文的拆包。

3.拆包

假设:原本bundle包为2M,一次请求拉取。拆分为 bundle(1M) + react桶(CDN)(1M) 两次请求并发拉取。

从这个角度来看,1+1的模式拉取资源更快。

换一个角度来说,全量部署项目的情况,每次部署bundle包都将重新拉取。比较浪费资源。react桶的方式可以命中强缓存,这样的化,就算全量部署也只需要重新拉取左侧1M的bundle包即可,节省了服务器资源。优化了加载速度。

注意:在本地开发过程中,react等资源建议不要引入CDN,开发过程中刷新频繁,会增加CDN服务其压力,走本地就好

4.默认的Gzip

一般在现在直接创建的项目中都会启用gzip对代码和文件进行压缩,注意的点是gzip需要运维老哥那边编写nginx配置以实现gzip压缩。

Nginx配置方式:

nginx
1http { 2gzip on;3 gzip_buffers 32 4K;4  gzip_comp_level 6;5   gzip_min_length 100; 6   gzip_types application/javascript text/css text/xml; 7   gzip_disable "MSIE [1-6]\."; 8   gzip_vary on; 9   }

5.图片压缩

图片压缩也是老生长谈的问题了,需要注意的点就是一定和pm和ui沟通好,需要压缩自带图片的(icon,banner等)让pm和ui进行确认该图片显示效果是否正常符合预期。或者直接让ui把压缩了的图片发给你。

如果要自己压的话推荐tinypng

6.雪碧图

在网站中有很多小图片的时候,一定要把这些小图片合并为一张大的图片,然后通过background分割到需要展示的图片。

因为浏览器请求规则的问题,一次最多并发六个请求,超过6个会将它分为多次并发。就比如你的页面上有10个相同CDN域名小图片,那么需要发起10次请求去拉取,分两次并发。第一次并发请求回来后,发起第二次并发。

如果你把10个小图片合并为一张大图片的画,那么只用一次请求即可拉取下来10个小图片的资源。减少服务器压力,减少并发,减少请求次数。

7.CDN

网上一问优化就是cdn部署静态资源,一问怎么做啥也不会

第一先选cdn:阿里、腾讯、七牛

这里拿七牛作为例子:

Bash
1npm install qiniu

安装完七牛的sdk之后创建qiniu.js文件

js
1 2//引入模块3const readline = require('readline'); 4const colors = require( "colors"); 5const FS = require('fs');6const Join = require('path').join; // 路径片段连接到一起,并规范化生成的路径 7const QiNiu = require('qiniu');8 9//初始化配置10const accessKey = '*******'; // 七牛秘钥11const secretKey = '*******'; // 七牛秘钥12const bucket = '***' // 七牛空间名(筒名)13const prefix = '***' // 七牛目录名称(前缀)14const limit = 10 // 分页请求 每页数量15var uploadNore =  ['index.html'] // 忽略文件数组(可以为文件或文件夹)忽略文件数组(可以为文件或文件夹)16// 鉴权对象17const mac = new QiNiu.auth.digest.Mac(accessKey, secretKey);18// 获取七牛配置19const config = new QiNiu.conf.Config();20// 是否使用https域名21// config.useHttpsDomain = true;22// 上传是否使用cdn加速23// config.useCdnDomain = true;24// 空间对应的机房 Zone_z0(华东)25config.zone = QiNiu.zone.Zone_z0;26// 资源管理相关的操作首先要构建BucketManager对象27const bucketManager = new QiNiu.rs.BucketManager(mac, config);28// 相关颜色配置 console颜色主题29colors.setTheme({30  silly: 'rainbow',31  input: 'grey',32  verbose: 'cyan',33  prompt: 'grey',34  info: 'green',35  data: 'grey',36  help: 'cyan',37  warn: 'yellow',38  debug: 'blue',39  error: 'red'40});41//定义相关方法42// 这里采用异步方法操作 获取远程列表的目的只是为了删除 但只能是获取到列表后 回调里再删除43// 获取远程七牛 指定前缀 文件列表44async function getQiniuList() {45  var options = {46    limit: limit,47    prefix: prefix,48  }49  var array = []50  var list = await getList()51  // marker 上一次列举返回的位置标记,作为本次列举的起点信息52  async function getList(mark=false) {53    if(mark){54      var options = {55        limit: options.limit,56        prefix: options.prefix,57        mark: mark58      }59    }60    return new Promise(function(resolve, reject){61      bucketManager.listPrefix(bucket, options, function(err, respBody, respInfo) {62        if (err) {63          console.log(err);64          throw err;65        }66        if (respInfo.statusCode == 200) {67          //如果这个nextMarker不为空,那么还有未列举完毕的文件列表,下次调用listPrefix的时候,指定options里面的marker为这个值68          var nextMarker = respBody.marker;69          var commonPrefixes = respBody.commonPrefixes;70          var items = respBody.items;71          items.forEach(function(item) {72            array.push(QiNiu.rs.deleteOp(bucket, item.key))73          });74          if(respBody.marker){75            getList(respBody.marker)76          } else{77            resolve(array)78          }79        } else {80          console.log(respInfo.statusCode);81          console.log(respBody);82        }83      });84    })85  }86  return list87}88// 批量删除远程七牛 指定列表 所有文件89async function delAll(){90  async function delQiniuAll() {91    return new Promise(function(resolve, reject){92      // 获取七牛远程列表数据93      getQiniuList().then(res => {94        if (res.length!==0){95          console.log('远程列表为空'.debug);96          del(res, resolve)97        } else {98          resolve()99        }100      })101    })102  }103  await delQiniuAll()104}105function del(deleteOperations, resolve) {106  bucketManager.batch(deleteOperations, function(err, respBody, respInfo) {107    if (err) {108      console.log(err);109      //throw err;110    } else {111      // 200 is success, 298 is part success112      if (parseInt(respInfo.statusCode / 100) == 2) {113        respBody.forEach(function(item, index) {114          if (item.code == 200) {115            resolve(index)116            console.log('删除成功'+'第'+(parseInt(index)+1)+'个文件'.info)117          } else {118            console.log('删除失败'.error);119            console.log(item.code + "\t" + item.data.error.error);120            resolve(index)121          }122        });123      } else {124        console.log(respInfo.deleteusCode);125        console.log(respBody);126      }127    }128  });129}130// 上传所有文件到骑牛131function upAllToQiniu(){132  console.log('开时删除七牛远程资源列表'.debug);133  // 先删除所有 再上传134  delAll().then(res => {135    console.log('开时上传资源到七牛'.debug);136    var files = FS.readdirSync('dist/'); // 文件目录137    var localFile = findSync('dist/')138    // key 为远程 七牛目录文件名139    // localFile[key] 为本地完成路径+文件名称140    for(var key in localFile){141      upOneToQiniu(localFile[key], key)142    }143  })144}145 146// 上传单文件到骑牛 localFile为本地完成路径+文件名称 key为远程 七牛目录文件名147function upOneToQiniu(localFile, key) {148  var mac = new QiNiu.auth.digest.Mac(accessKey, secretKey);149  var options = {150    scope: bucket,151  };152  var putPolicy = new QiNiu.rs.PutPolicy(options);153  var uploadToken = putPolicy.uploadToken(mac);154  var formUploader = new QiNiu.form_up.FormUploader(config)155  var putExtra = new QiNiu.form_up.PutExtra()156  // 文件上传157  formUploader.putFile(uploadToken, key, localFile, putExtra, function(respErr,158    respBody, respInfo) {159    if (respErr) {160      throw respErr161    }162    if (respInfo.statusCode == 200) {163      console.log(localFile.info+'=>'+respBody.key.info + '上传成功')164    } else {165      console.log('上传失败' + respInfo.statusCode.error);166      console.log('上传失败' + respBody.error)167    }168  })169}170// 拿到文件 目录路径 startPath 根目录名称171function findSync(startPath) {172  let targetObj={};173  function finder(path) {174    // 获取当前目录下的 文件或文件夹175    let files=FS.readdirSync(path);176    // 循环获 当前目录下的所有文件177    files.forEach((val,index) => {178      let fPath=Join(path,val);179      let stats=FS.statSync(fPath);180      if(stats.isDirectory()) {181        finder(fPath);182      }183      if(stats.isFile() && isNore(fPath)) {184        targetObj[fPath.replace(startPath, prefix)] = fPath;185      }186    });187  }188  finder(startPath);189  return targetObj;190}191/**192 * 判断当前路径是否在忽略文件数组中193 * @param {String} path 路径194 */195function isNore(path) {196  for( var item of uploadNore) { // 遍历忽略数组197    if (path.indexOf(item) !== -1) {198      return false199    }200  }201  return true202}203// process 对象是一个全局变量,它提供当前 Node.js 进程的有关信息,以及控制当前 Node.js 进程 因为是全局变量,所以无需使用 require()。204var rl = readline.createInterface({205  input: process.stdin, // 要监听的可读流206  output: process.stdout, // 要写入逐行读取数据的可写流207  prompt: ('是否进行远程部署> (Y/N)').warn208});209rl.prompt();210// 每当 input 流接收到接收行结束符(\n、\r 或 \r\n)时触发 'line' 事件。 通常发生在用户按下 <Enter> 键或 <Return> 键。监听器函数被调用时会带上一个包含接收的那一行输入的字符串。211rl.on('line', (line) => {212  switch (line.trim()) {213    case 'y':214    case 'Y':215      console.log('开始执行远程部署'.help);216      // 上传217      upAllToQiniu()218      rl.close();219      break;220    case 'n':221    case 'N':222      console.log('您取消了远程部署'.help);223      rl.close();224      break;225    default:226      console.log(`你输入的:'${line.trim()}'为无效命令,请重新输入`.warn);227      rl.prompt();228      break;229  }230})

最后如何调用呢,很简单,在packge.json中添加

JSON
1"script":{2"qiniu": "node build/qiniu.js",3}

然后在打包完之后执行这个:

Bash
1yarn run qiniu

8. 图片懒加载

用这个npm包就行

layzr.js

9.SSR

如果项目中将会出现大量的detail页面和交互跳转非常频繁,可以考虑使用SSR框架构建项目。 优点就是SEO,首屏优化。缺点就是占用服务器资源

10.抽离css

借助mini-css-extract-plugin:本插件会将 CSS 提取到单独的文件中,为每个包含 CSS 的 JS 文件创建一个 CSS 文件,并且支持 CSS 和 SourceMaps 的按需加载。

js
1const MiniCssExtractPlugin = require("mini-css-extract-plugin");2 {3 test: /\.less$/,4 use: [5 // "style-loader", // 不再需要style-loader,⽤MiniCssExtractPlugin.loader代替6  MiniCssExtractPlugin.loader,7  "css-loader", // 编译css8  "postcss-loader",9  "less-loader" // 编译less10 ]11 },12plugins: [13  new MiniCssExtractPlugin({14   filename: "css/[name]_[contenthash:6].css",15   chunkFilename: "[id].css"16  })17 ]

11.避免回流和重绘

重绘 (Repaint)

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:color、background-color、visibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

回流

当Render Tree中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。

回流必将引起重绘,重绘不一定会引起回流,回流比重绘的代价要更高

避免方式:

CSS

  • 避免使用table布局。
  • 尽可能在DOM树的最末端改变class。
  • 避免设置多层内联样式。
  • 将动画效果应用到position属性为absolute或fixed的元素上。
  • 避免使用CSS表达式(例如:calc())。

JavaScript

  • 避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并一次性更改class属性。
  • 避免频繁操作DOM,创建一个documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中。
  • 也可以先为元素设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。
  • 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。
  • 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流。

三、针对vue的特殊性能优化方式

1. v-show与v-if

在真正需要dom条件性渲染的情况下才使用v-if,只控制dom展示和隐藏的最好使用v-show降低性能开销。

2. 使用computed和watch

在需要使用一个近似于不变的变量同时该变量依赖于其他变量进行计算等操作时,最好使用computed,可以保证在依赖变量不改变时获取缓存的computed值。

3. v-for 遍历避免同时使用 v-if

v-for 比 v-if 优先级高,如果每一次都需要遍历整个数组,将会影响速度,尤其是当之需要渲染很小一部分的时候,必要情况下应该替换成 computed 属性.

4. 路由懒加载

Vue 是单页面应用,可能会有很多的路由引入 ,这样使用 webpcak 打包后的文件很大,当进入首页时,加载的资源过多,页面会出现白屏的情况,不利于用户体验。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应的组件,这样就更加高效了。这样会大大提高首屏显示的速度,但是可能其他的页面的速度就会降下来。

示例:

js
1const Foo = () => import(‘./Foo.vue’)2const router = new VueRouter({3  routes: [4    { path: ‘/foo’, component: Foo }5  ]6})

四、针对React的优化方案

react优化方案太多了,这里只挑用的最多的讲一讲

1.优化Render过程

PureComponent、React.memo

在 React 工作流中,如果只有父组件发生状态更新,即使父组件传给子组件的所有 Props 都没有修改,也会引起子组件的 Render 过程。

从 React 的声明式设计理念来看,如果子组件的 Props 和 State 都没有改变,那么其生成的 DOM 结构和副作用也不应该发生改变。当子组件符合声明式设计理念时,就可以忽略子组件本次的 Render 过程。

PureComponent 和 React.memo 就是应对这种场景的,PureComponent 是对类组件的 Props 和 State 进行浅比较,React.memo 是对函数组件的 Props 进行浅比较。

useMemo、useCallback 实现稳定的 Props 值

如果传给子组件的派生状态或函数,每次都是新的引用,那么 PureComponent 和 React.memo 优化就会失效。所以需要使用 useMemo 和 useCallback 来生成稳定值,并结合 PureComponent 或 React.memo 避免子组件重新 Render。

useMemo 减少组件 Render 过程耗时

useMemo 是一种缓存机制,当它的依赖未发生改变时,就不会触发重新计算。一般用在「计算派生状态的代码」非常耗时的场景中,如:遍历大列表做统计信息。

利用Suspense 和 lazy

JSX
1import React, { lazy, Suspense } from "react";2 3export default class UserSalutation extends React.Component {4 5    render() {6        if(this.props.username !== "") {7          const WelcomeComponent = lazy(() => import("./welcomeComponent"));8          return (9              <div>10                  <Suspense fallback={<div>Loading...</div>}>11                      <WelcomeComponent />12                  </Suspense>13              </div>14          )15        } else {16            const GuestComponent = lazy(() => import("./guestComponent"));17            return (18                <div>19                    <Suspense fallback={<div>Loading...</div>}>20                        <GuestComponent />21                    </Suspense>22                </div>23            )24        }25    }26}

添加index和key

在使用列表和map渲染元素的时候记得添加index和key以降低react自动添加可能带来的性能损失。

数据流转结构超过子父祖三代推荐使用状态管理库的发布订阅模式

超过子父祖三代的数据流转会导致公共祖先传递数据需要层层向下传递,给不需要使用到该状态的组件带来了不必要的render。使用状态管理库或发布订阅模式可以有效的降低render次数。

八云澈

Writer · Recorder

八云澈(Bayunche)

开发者 / 写作者 / 普通生活记录者 · 中国 · 深圳

喜欢把工作里的技术问题、读书时的触动、生活中的琐碎观察慢慢写下来。既想把复杂问题讲清楚,也想留住那些很快会被忘掉的日常。

Java 后端Go 工程化读书札记生活记录日常随笔

Continue Reading

相关文章

更多文章

上一篇

Go 并发安全和锁

2024-06-25

下一篇

网安入门笔记

2024-09-12

评论区

读完这篇文章后,如果你也有类似经验或不同看法,欢迎继续交流。

💬 评论