前端性能优化之运行篇

842 阅读17分钟

欢迎关注公众号【码上出击】,更多精彩内容敬请关注公众号最新消息。

前言

在上一篇《前端性能优化之网络篇》中,我们从网络的角度分析了性能的卡点在什么地方,如何处理。在本章中,我们将介绍从接收到请求数据之后,到渲染使用过程中的运行期优化。

背景知识:浏览器的渲染流程

运行期间的性能优化需要知道浏览器接收到html之后都做了哪些事情,因此这里有必要普及下浏览器都渲染流程。

1. 基础概念:

CSS解析: css样式解析为CSS Tree的过程。
DOM解析: 页面上所有html标签,生成一个DOM Tree的过程;(此时会触发DOMContentLoaded事件,也就是我们常说的dom ready)
DOM渲染: 指DOM Tree 和 CSS Tree 结合到一起,生成一个Render Tree,呈现出一个带有样式的页面。

2. webkit渲染过程:

结论与共识:
1. js的下载运行会阻塞dom的解析和渲染
2. css的下载和解析不会阻塞dom的解析 , 从图上可以看到DOM的解析与CSS的解析没有依赖关系(可视为 并行
3. css的下载和解析会阻塞js的运行,但是不会阻塞js的下载

此处说到的js,都是指普通的非defer和async类型的js。

HTML优化

1. 网页开启gzip压缩

启用gzip对网页进行压缩,大约可以减少70%左右的大小,效果还是比较可观的。

2. 减少html标签的嵌套

html标签的嵌套不仅会导致dom的解析和渲染增加负担,而且对dom的查找及css选择器的查找也有性能上的损耗。

比如经常会见到没有意义的嵌套(多见于form和ul元素)

<div class="list">
    <ul>
        <li></li>
    </ul>
</div>

可以优化为:

<ul class="list">
    <li></li>
</ul>

3. 减少无意义的标签

减少dom的数量和层级对性能可以减少dom解析和渲染的压力。

常见的有:
空标签来清楚浮动(不过目前都通过伪元素来实现了),以及修饰用的标签(图片或者修饰性文字)

<div class="parent">
    <p>很多内容xxxx</p>
    <div class="ft_bg"><img src="xxxxx" /></div>
</div>

可以优化为

<div class="parent">
    <p>很多内容xxxx</p>
</div>
<style>
.parent:after{
    background: url(xxxxx) no-repeat;
}
</style>

4. 删除html内容过中多余的空格、换行符

建议仅对html内嵌的css代码(style标签)和内嵌js代码(script标签)做空白符的压缩。
由于html标签间的空白符号在渲染的时候会按照一个空格内容来渲染,因此可能会对布局和样式带来一定的影响。现在的网站多采用js来渲染,html直接输出的内容比较少,因此大多网站没有这么做。

我们一般会配置在开发过程中不会删除空白符号,而在打上线包的时候删除空白符,不一致常会给页面带来bug,BAT公司的网站普遍没有做html空白符的压缩应该也是基于这个原因。

如下图所示,淘宝首页将内联的css和js进行了压缩,而html依然格式化。

5. 修饰性的UI使用background属性来替代img标签

  • 可以利用打包工具将css图片做css sprite处理,减少http的请求。
  • 利用css文件的加载和渲染不会阻塞dom的解析的特点,化串行任务为并行任务,可能会加快dom渲染的速度。

6. 减少js文件的加载对于dom解析和渲染的阻塞

由于js文件的加载会对dom阻塞解析和渲染,如非必要,尽量让js的引入靠后。

7. 避免图片填写空的src

当图片的src为空时,浏览器会认为这是一个缺省值,默认是当前网页路径,那么就会进行二次载入当前网页。

8. 避免重设图片大小

图片大小的改变会带来回流和重绘,影响性能。

CSS优化

1. 内联首屏内容的css

大家都习惯css内容通过link标签引入,这样可能会在dom解析完毕后准备渲染的时候,css内容还没有下载完毕,延迟dom渲染的时间,从而影响给用户对展现。

> 这样做有一个弊端是,无法利用http的缓存,每次都会重新下载,不过将首屏内容控制在一定大小的情况下,这些不是问题。目前看BAT的网站基本都会做此优化。

2. 利用继承,减少代码量

css中,很多属性是可以继承的,比如line-heightcolorfont等,减少冗余代码的书写。
```
<div class="cont">
    <p>……</p>
</div>
```
```
.cont{color: #999}
.cont p{color: #999}
```

3. 使用css的复合属性,减少代码量

尽量使用css的复合属性(borderfontbackgroundmargin等),减少代码量,比如
```
p{
    background-image: url(xxxxx);
    background-repeat: no-repeat;
    background-postion: center center;
}
// 优化为
p{
    background: urr(xxx) no-repeat center center;
}
```
>使用一些工具可以在打包阶段可以将属性构建为复合属性的写法。

4. css选择器的优化

**`CSS选择器的匹配是从右向左进行的`**,因此,不同的写法会影响css选择器匹配的性能。需要在以下几点注意:
1. 选择器不要嵌套的太长  
	比如 .content .list .item a{ }的性能会远低于'.content_item'。
2. 尽量避免通配符和属性选择器的使用  
    由于通配符和属性选择器匹配的元素太多,使用的时候会造成浏览器要从众多的元素中再去配置,因此尽量避免使用
3. id选择器单独用  
	不要使用.content #title这种写法,直接使用#title。

>现代浏览器在选择器的匹配上做了很多优化,因此不同的选择器在性能上的差别并没有多大。

5. 避免不必要的回流和重绘

合理利用一些css的属性可以避免不需要的绘制或者回流。

比如在css动画中,我们要对元素进行移动,使用top/left结合position:absolute进行绘制,那么就会触发回流;如果使用transform控制元素的位移,由于利用了合成线程的GPU进行绘制,并不会触发回流。

6. 减少不必要的渲染

displayvisibility都可以控制元素的隐藏,使用display:none时元素不会被渲染,但是使用visibility:hidden时元素依然会被渲染,效果等同于opacity:0

7. 优先使用flex来代替float和position

flex的性能优于float和position,因此尽量使用flex。

JS优化

1. 减少reflow和repaint

reflow,一般叫回流或重排,是页面排版的重新计算。

回流是比较消耗性能的,因为某个元素的改变会导致该元素后面所有元素的重新排版和绘制。

从定义上看,所有导致元素占据的位置变化,以及导致其周围元素绝对位置变化的行为都会导致reflow。

哪些行为会造成回流呢?

<1>. 频繁操作dom

比如你要删除/修改某几个节点,给某个父元素增加子元素,这类操作都会引起回流。

应对措施:
可以使用DocumentFragment,准备好之后再将DocumentFragment操作进dom。

var elFragment = document.createDocumentFragment();

for(var i = 0 ; i < 10; i ++) {
    var p = document.createElement("p");
    var text = document.createTextNode(`段落${i}`);
    p.appendChild(text);
    elFragment.appendChild(p);
}

document.body.appendChild(elFragment);
<2>. 几何属性(位置、大小)的变化

比如元素的宽高变了;比如元素的margin、padding、border等改变导致页面布局变化。

应对措施:
当需要修改多个属性时,应该将他们放到一个class中,只操作这个class,可以减少回流。或者使用cssText一次操作多个css属性。

document.body.style.color = '#fff';
document.body.style.border = 'solid 1px red';

// 优化为
document.body.style.cssText = 'color: #fff;border: solid 1px red;'
<3>.获取元素的偏移量属性

比如获取一个元素的scrollTop、scrollWidth、offsetTop、offsetWidth之类的属性,浏览器为了保证值的正确会回流取得最新的值。

应对措施:
获取的值做下缓存,下次再用的时候直接使用缓存数据。

repaint,一般叫重绘。

由于单纯的repaint只针对自己重新绘制,因此repaint想比reflow对性能的消耗要小很多,只需要避免重复频繁操作css属性的修改。

2. 防抖和节流

在进行resize、scroll、mousemove等事件的处理时,短时间内会触发很多次,但是我们并不希望他们执行很多次,因此就需要一定的判断逻辑来保证短时间内或者一定阈值下只执行一次或者保证以一定的频率来执行。

函数的节流和防抖,都是通过优化高频率执行函数,从而达到节省浏览器cpu资源和优化页面流畅度的方法。

节流:指有节奏的执行函数,而不是事件触发一次就执行一次函数。

常见场景:懒加载;鼠标移动商品放大镜效果;

document.onscroll = function(){
	console.log('hello')
}

上面代码鼠标滚轮滚动一下,会输出几十上百个“hello”。

我希望鼠标滚动时,每100ms执行一次函数就可以满足需求。优化如下:

var canIRun = true;
document.onscroll = function(){
	if(canIRun){
    	canIRun = false;
        
      	setTimeout(function(){
          	console.log('hello')
          	canIRun = true;
      	}, 100)
    }
}

我们可以使用loadsh的防抖动的函数

_.throttle(function(){
	console.log('hello')
}, 100)

防抖:当事件持续触发时,函数一直不执行,直到一次触发后再过一段时间不再触发事件时才执行函数。

常见场景:邮箱或手机格式输入时的实时正确性校验;鼠标在页面上移动时,停顿的时候才做某些操作;搜索的时候按键输入出suggest等。

document.onscroll = function(){
	console.log('hello')
}

上面代码鼠标滚轮滚动一下,会输出几十上百个“hello”。

我希望鼠标停止滚动后,过一段时间稳定了再输出。优化如下:

var timer = null;
document.onscroll = function(){
    clearTimeout(timer);
    timer = setTimeout(function(){
        console.log('hello')
    }, 100)
}

我们可以使用loadsh的防抖动的函数

_.debounce(function(){
	console.log('hello')
}, 100)

3. 图片懒加载

懒加载就是延时加载,当需要用到的时候再去加载。
一般对于非首屏的图片,用一个默认的图片地址来代替,在进入可视区域之后再加载真实的图片地址。

原理:监听scroll事件,判断页面滚动的长度和懒加载内容所在页面中的位置,在两者有交叉或者即将交叉时,把图片的真实url,写入src属性,从而达到懒加载的目的。

4. 按需加载【打包内容拆包】

单页应用默认将js和css各导出一个文件,这样项目大了之后会导致静态文件特别大,可以根据页面进行代码分割,打包成多个js和css,按照页面路由进行按需加载使用。

    1. 通过多入口配置来进行分割
// webpack.config.js

module.exports = {
	entry:['./index.js','./second.js'],
	// ...
}

缺点:这种方式最简单,但是造成重复引用的模块被重复打进包中。

    1. 通过动态代码加载来分割代码(在代码中使用import() )。

react中可以使用React.lazy(() => import('./Demo'))

    1. 使用optimizition.splitchunks进行代码的分割。
// webpack.config.js

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 20000,
      minRemainingSize: 0,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      enforceSizeThreshold: 50000,
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

5. 定时器的清理

由于定时器是延时触发,当我们不再需要的时候要及时将定时器进行清理,否则会造成无用的任务依然在占用资源。

尤其是当前很多应用为单页应用,不及时清理会使得页面无用任务会越来越多。

应对措施
在组件销毁时,执行clearTimeout、clearInterval对定时器进行清理。

// vue
beforeDestroy(){
	clearInterval(this.timer);
    clearTimeout(this.timer);
}

// react
componentWillUnmount(){
	clearInterval(this.timer);
    clearTimeout(this.timer);
}

6. 使用requestAnimationFrame替代setInterval/setTimeout

  1. 多个requestAnimationFrame可以同时进行,而setTimeout需要独立绘制;
  2. 当页面失去焦点时,浏览器不再绘制该页面,requestAnimationFrame也停止了绘制,与浏览器同步,资源很省。
  3. requestAnimationFrame的执行与浏览器帧率同步,不需要考虑执行事件间隔。 参考MDN
const element = document.getElementById('some-element-you-want-to-animate');
let start;
let count = 0;
function step(timestamp) {
    if (start === undefined) {
        start = timestamp;
    }

    const elapsed = timestamp - start;

    //这里使用`Math.min()`确保元素刚好停在200px的位置。
    element.style.transform = 'translateX(' + Math.min(0.1 * elapsed, 200) + 'px)';

    if (elapsed < 1000) { // 在1秒后停止动画
    	console.log(count); // 统计下1秒浏览器大概渲染多少帧
        window.requestAnimationFrame(step);
    }
}

window.requestAnimationFrame(step);

上述代码中,使用了window.requestAnimationFrame(step), step函数会按照浏览器的帧率来执行,当然使用window.setTimeout(step, 30)也是可以的,但是效果会差一些。

7. 利用js线程的空闲期

window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。
借助于requestIdleCallback这个api,可以将js线程空闲时期充分利用起来,来处理低优先级的任务。 参考MDN

在React 16.x版本下,使用来新的调度策略-Fiber,就是利用了这个api做了重要优化。

8. Web Worker

由于Worker线程独立于主线程之外,可以跟主线程同时运行,因此可以将一些与dom操作无关的重计算性的任务交给Worker线程计算,不阻塞主线程渲染的同时,提高计算的效率。 MDN参考

比如有一个需要循环1千次的任务,就可以交给worker去做,做完了之后再告诉主线程进行展示。(通过postMessage和addEventListener通信)

// main.js

var worker = new Worker('http://localhost:8080/work.js');
worker.postMessage('hello');

worker.onmessage = function (event) {
  window.alert('Received message ' + event.data);
}

// worker.js
this.addEventListener('message', function (e) {
  if(e.data == 'hello'){
  	doLoop();
  }
}, false);

function doLoop(){
	let str = '';
	let count = 1000;
	while(count > 0){
    	str += 1;
        count++;
    }

    this.postMessage(str);
}

9. 请求的预加载

与上一篇中提到的preload和prefetch不同,这里是在合适的时机提前请求数据,然后在真正使用的时候就可以利用缓存进行计算了。
比如:pc页面上点击一个按钮弹窗显示一个列表,那么就可以在鼠标hover到按钮的时候就请求接口数据了;页面滚动到某个位置后开始发起请求接口的动作。

10. 客户端渲染(CSR)

现在很多页面是运行在自家app的webview上,可以利用客户端渲染首屏内容,将webview启动与页面加载的串行任务优化为webview启动与页面加载的并行任务。

需要客户端进行支持。

11. 框架类代码单独使用cdn方式引入

我们在对应用构建时,将react、vue、axios等不需要修改的内容通过webpack的externals属性排除在打包内容外,通过独立cdn的方式引入html中。这样就可以有效利用cdn资源的http缓存。

// index.html

<script crossorigin src="//unpkg.com/react@17/umd/react.production.min.js"></script>
<script crossorigin src="//unpkg.com/react-dom@17/umd/react-dom.production.min.js"></script>
<script src="//cdn.bootcdn.net/ajax/libs/axios/0.21.0/axios.min.js"></script>
// webpack.config.prod.js

module.exports = {
	...
	externals: {
    	'react': 'React',
        'react-dom': 'ReactDOM',
        'axios': 'Axios'
    }
}

12.WebAssembly

官方描述:做为一个可移植、体积小、加载快并且兼容 Web 的全新格式。有兴趣的同学可以进行一些探索。

13. 长列表的优化--虚拟列表

长列表的渲染对于现在MVVM框架的渲染是个灾难,尤其现在移动端信息流类产品渲染个几百上千条很常见。社区有比较成熟的方案--虚拟列表。 虚拟列表优化的本质是减少渲染的节点,只渲染当前可视窗口的可见元素。在此借助网络上的一张图来说明。

应用场景:无限滚动列表,聊天窗口,时间轴等。

14.框架的使用优化--react

一. react

1. 利用pureComponent来优化浅比较的更新
import {PureComponent} from 'react'

class Demo extends PureComponent{

}

PureComponent 通过props和state的浅对比来实现 shouldComponentUpate()。

2. 利用shouldComponentUpate优化React.Component
import {Component} from 'react'

class Demo extends Component{
	shouldComponentUpate(nextProps, nextState){
    	// do something
    }
}

PureComponent与Component的区别就是,PureComponent 对props和state做了浅对比,不一致达到时候才会update组件。

3. key的使用

react根据key是否变化来决定是销毁重新创建组件还是更新组件

	arr.map(item => {
		return <div key={item.title}>
	        <p>{item.title}</p>
        </div>
	})

Key的值必须保证其唯一和稳定性。

4. 使用useMemo和useCallback

react 从16.8版本开始支持hooks功能,由于函数式组件没有shouldComponentUpdate,函数组件的每一次调用都会执行其内部的所有逻辑,组件内声明的函数会带来较大的性能损耗。

  • 1. 使用useCallback对函数进行缓存 函数式组件每次任何一个 props或state 的变化 会使整个组件 都会被重新刷新,一些函数是没有必要被重新创建的,此时就应该缓存起来。
import React, { useState } from 'react';
function Parent() {
    const [count, setCount] = useState(1);
    const [count2, setCount2] = useState(1);
 
    const callback = () => {
        return count;
    }
    return <>
    	<Child callback={callback}/>
        <button onClick={() => setCount(count + 1)}> count1+ </button>
        <button onClick={() => setCount(count2 + 1)}> count2+ </button>
	</>
}
 
function Child({ callback }) {
    
    const count = callback();
    return <div>{count}</div>
}

上述代码,当我们点击count2+的时候,Parent组件更新,同时callback重新创建,造成Child组件更新。但是callback没变,Child也不应该更新。

使用useCallback优化如下

import React, { useState, useCallback } from 'react';
function Parent() {
    const [count, setCount] = useState(1);
    const [count2, setCount2] = useState(1);
 
    const callback = useCallback(() => {
        return count;
    }, [count])
    
    return <>
    	<Child callback={callback}/>
        <button onClick={() => setCount(count + 1)}> count1+ </button>
        <button onClick={() => setCount(count2 + 1)}> count2+ </button>
	</>
}
 
function Child({ callback }) {
    
    const count = callback();
    return <div>{count}</div>
}

优化后,当点击count2+的时候,Parent组件更新,由于count没有变,因此callback还是用的上一次创建的函数,因此Child不会更新,从而达到了优化的效果。

使用场景:组件间通信传递函数时。

    1. 使用useMemo优化 当我们需要在组件内执行函数的时候,如果函数内的依赖项不变时,计算结果也是不变的,这样我们就希望只有在依赖项改变时重新计算。
import React, { useState } from 'react';
function Parent() {
    const [count, setCount] = useState(1);
    const [count2, setCount2] = useState(1);
 
    const doubleCount = (() => {
        return count * 2;
    })()
    
    return <>
    	<p>{doubleCount}</p>
        <button onClick={() => setCount(count + 1)}> count1+ </button>
        <button onClick={() => setCount(count2 + 1)}> count2+ </button>
	</>
}

使用useMemo可以缓存计算后的值,Parent更新时,只有当依赖项变化的时候再重新计算,否则使用缓存的值。

import React, { useState, useMemo } from 'react';
function Parent() {
    const [count, setCount] = useState(1);
    const [count2, setCount2] = useState(1);
 
    const doubleCount = useMemo(() => {
        return count * 2;
    }, [count])
    
    return <>
    	<p>{doubleCount}</p>
        <button onClick={() => setCount(count + 1)}> count1+ </button>
        <button onClick={() => setCount(count2 + 1)}> count2+ </button>
	</>
}

有兴趣的同学可以了解下函数式组件使用React.Memo缓存组件的优化。

5. jsx中使用函数的优化

有的时候我们为了省事会在jsx代码中直接使用函数声明,这样虽然简单,但是由于组件每次更新时,都会重新创建函数,导致Child组件的重新渲染。

class Demo extends React.Component {
	constructor(props){
    this.state = {
      count: 0
    }
  }
	render() {
		return <>
          <Child callback={()=>{console.log('hello 无束')}}/>
          <button onClick={() => setCount(count + 1)}> + </button>
      </>
	}
}

可以优化为将函数的引用传给自组件,这样只有Child自身的props变化时,Child组件才更新。

class Demo extends React.Component {
	constructor(props){
      this.state = {
        count: 0
      }
    }
    handle(){
    	console.log('hello 无束')
    }
	render() {
		return <>
          <Child callback={this.handle}/>
          <button onClick={() => setCount(count + 1)}> + </button>
      </>
	}
}
6. 给state瘦身

只把需要数据驱动UI变化的状态或者需要组件响应它的变动的状态放进state中。
有同学习惯把所有的状态都放进state中,这样会造成一些不必要的渲染。

二. Vue

1. vue中合理使用v-show和v-if

v-show的本质是display:none,dom还是会渲染的。 v-if用来判断是否要渲染dom的。

2. 避免v-for和v-if的同时使用

因为当Vue处理指令时,v-for比v-if具有更高的优先级,意味着v-if 将分别重复运行于每个v-for循环中。尽量提前处理好数据,比如在computed中进行if逻辑的处理。f

2. 模版中不要写过长的表达式,或者data属性的计算

将一些表达式或者计算合理的放进method或者computed中,同时使用computed还可以利用缓存,避免重复性计算,另外还可以代码复用。

v-if="isShow && isAdmin && (a || b)" 
{count*2}
3. key的使用

同react一样,vue会根据key是否变化来决定是销毁重新创建组件还是更新组件

4. 减少watch的使用

使用watch可以监听相应的数据变更并进行逻辑处理。由于监听的数据变化后就会触发watch的监听,因此当watch的数据比较大时,会增加消耗从而影响性能。

能用computed的就不用watch。

5. 使用keep-alive对组件进行缓存

有时会想保持这些组件的状态,可以使用keep-alive来缓存组件,以避免反复重渲染导致的性能问题。

<keep-alive>
  <component v-bind:is="currentTabComponent"></component>
</keep-alive>

扩展

前端性能优化之网络篇

参考链接:www.ruanyifeng.com/blog/2018/0…

欢迎关注作者的公众号「码上出击」