输入url到页面渲染后半段:回流,重绘,优化【一次性带你搞明白】

回顾

简单回顾一下url到页面渲染上半段:

url输入到页面渲染整个过程清楚的透彻与否,可以代表你对前端领域开发的熟悉与否

前端向后端要数据的过程:

  1. DNS解析:谁告诉你服务器在哪里
  2. 建立连接,三次握手,数据传输
  3. 断开连接,四次挥手
    http的各个版本以及每个版本解决了什么问题都是需要去认识的,以后再出期文章专门聊聊

等断开连接之后才是我们今天要聊的话题,此时浏览器已经拿到了数据

我们可以以访问百度为例,刷新百度页面的时候,检查其网络,点击全部,查看名称,可以看到所有的网络请求

这些全部都是前端向后端发送的请求,不过并不是所有的请求都是需要向服务器去要的,有些图片是缓存起来的,从本地读取,这个以后再聊。

比如我们看下第一个接口www.baidu.com

这个就是百度后端提供出来的接口,我们请求的数据就是这个样子,肯定有小伙伴就要疑惑了,数据不是一般都是json对象啥的吗,怎么是html结构。没有人规定数据只能是对象数组,百度的本地就是后端写的,这是个手段,可以解决vue首屏加载过慢问题

我们访问任何一个网站,浏览器会先拿到html代码,无外乎两种情况,一种是前后端分离式开发 :前端代码部署到了服务器上,我们访问网站时,先从前端服务器上把前端的代码加载到,该过程中会触发ajax请求,又继续向后端要数据;另一种就是前后端不分离式开发:比如jsp,把模板塞到java中,直接向浏览器输出

这时浏览器已经拿到了html代码,然后还需要拿到css代码,js代码。一般html,css,js都是分文件写的,还有图片需要加载。

好,现在我们浏览器已经拿到了html,css,js代码,网页又是如何被整成布局这么精良的样子给你看的呢,这个过程就是页面渲染

浏览器加载到了资源(html,css......)

过程如下:

html是负责结构的,css是负责样式的

  1. 解析html代码,生成一个dom树
  2. 解析css代码,生成CSSOM树
  3. 将dom树和CSSOM树结合,去除不可见的元素,生成Render Tree
  4. 计算布局,(回流 | 重排)根据Render Tree进行布局计算,得到每一个节点的几何信息
  5. 绘制页面,(重绘)GPU根据布局信息绘制

为了方便大家理解树,这里写一个简版的dom树,用js模拟下,(肯定不是这样子,只是为了帮助理解)

python 复制代码
<div id="app">
    <div class="name">Tom</div>
    <div class="age">18</div>
</div>

// 上面的这段html理解成树大致如下:

let node = {
    tag: 'div',
    id: 'app',
    class: '',
    x: 0,
    y: 100,
    children: [
        {
            tag: 'div',
            id: '',
            class: 'name',
            text: 'Tom',
            ......
        },
        {
            tag: 'div',
            id: '',
            class: 'age',
            text: '18',
            ......
        }
    ]
}

为何要转换成dom树?

屏幕的物理发光点想要发光就必须清楚在哪里发光,也就是结构,转换成树结构就方便去绘制页面,清楚这个了你就会理解转换成CSSOM树的意义,这样就方便绘制颜色等等

知道了两棵树还必须联合起来,属性,类名之间进行对应,这个过程非常消耗性能

dom树和cssom树结合形成的树称之为渲染树,GPU会控制物理发光点亮与否,亮什么颜色,在绘制之前会计算下布局,就像是画画上色之前,我们需要素描一下。最后才是GPU绘制整个页面

以上就是当你输入url到页面渲染的整个后过程,别看页面出现的很快,其实浏览器在背后做出了非常多的努力。

接下来,我们详细谈下第四个和第五个过程,计算布局我们称之为回流 或者重排 ,绘制页面我们称之为重绘

前三个步骤无法进行优化,所以我们单独领出来后两个步骤

噢!不对,css是可以进行优化,这里穿插一下,我们看下下面两个写法哪个性能更高

arduino 复制代码
// 写法一
.a .b .c {

}
// 写法二
.c {

}

对于开发人员,肯定是会觉得写法一更好,更优雅,因为直接从a定位到b,再定位到c,写法二会影响所有的c这个类名的容器。但是对于生成CSSOM树而言,写法一需要先找到a容器,再找到b容器,最后才找到c容器,而写法二直接找到c容器,写法二更好。讨论这个问题之前,类名必须是唯一的。

其实这个优化微乎其微,下面我们重新认识下步骤四和步骤五,分别对应着回流和重绘,这两个过程开销的性能比较大

回流

浏览器计算页面布局的过程就叫做回流

只要页面有容器几何信息发生变更就会发生回流,也就是影响了它的排版,所以回流太常见了,有以下几种:

  1. 改变窗口的尺寸
  2. 改变元素的尺寸
  3. display: none | block;(增加或删除可见元素)
  4. 页面初次渲染

容器脱离文档流是不会发生回流的,当然这是针对影响其他元素而言,比如你增删一个可见元素,是会影响下面容器的几何信息的。对于脱离文档流容器本身而言,肯定是发生回流的

重绘

GPU将已经计算好几何信息的容器在屏幕上亮起来就是重绘

所以只要元素的非几何属性发生变化时,就会发生重绘

  1. 修改背景颜色
  2. 修改背景图片
  3. 边框颜色
  4. 字体颜色
  5. 回流

注意,既然发生了回流,就一定会带来重绘,重绘不一定带来回流

如何减少回流,重绘

回流重绘这两个过程回流因为涉及到计算,所以它开销的性能会更多

我们看下面这个案例

ini 复制代码
<div id="app">Hello</div>

<script>
    let app = document.getElementById('app')

    app.style.position = 'relative';
    app.style.width = '100px'
    app.style.height = '200px'
    app.style.left = '10px'
    app.style.top = '10px'
</script>

请问以上这段js代码执行会有几次回流?(不算浏览器初次加载)

发生四次回流,因为只有四行代码影响了几何信息。没错,以前老版本浏览器是这样的,现在浏览器执行这个只会发生一次回流。

老版本浏览器一般为了减少回流就会这样写

css 复制代码
app.style.cssText = 'width: 100px; height: 200px; left: 10px; top: 10px'

放在一起执行就相当于一次性回流所有,或者也可以动态绑定一个类名,发生某个事件的时候给类名放上去,这样也是一次回流

浏览器的优化策略

相比较以前的浏览器,现在的浏览器是有一个优化策略的,它执行js的时候会维护一个渲染队列,改变一个容器的样式,导致需要发生回流的时候,这个操作会进入渲染队列,如果还有相同行为,继续进入队列,直到下面没有样式修改,浏览器会批量化地执行渲染队列中的回流过程,这只发生一次回流

那我对上面的那个栗子进行修改一下,每个样式下面都将属性值打印出来,这样会有几次回流

ini 复制代码
app.style.width = '100px'
console.log(app.offsetWidth) 
app.style.height = '200px'
console.log(app.offsetHeight)
app.style.left = '10px'
console.log(app.offsetLeft)
app.style.top = '10px'
console.log(app.offsetTop)

offsetWidth就是读取容器的宽度,clientWidth也是读取容器的高度,不过前者会包含边框,innerWidth是获取window的宽度

这种情况浏览器会发生四次回流,这些代码都是同步代码,因此从上往下执行,执行打印语句的时候无法跳过它,并且当你要读取几何信息的时候是会打断它入队列的,因为读取几何信息就是一个计算布局,也就是回流或重排,重排是非常昂贵的,因此浏览器需要专注此时的重排,所以它会暂停渲染队列的任务,等待重排完毕后再去执行此时的渲染队列,因此碰到计算布局的时候一定会带来一次渲染队列的执行或刷新,也就是带来一次回流

只要是读取几何信息都会引起渲染队列的强制执行,如下方法都是:

offsetWidth, offsetHeight, offsetTop, offsetLeft 
clientWidth, clientHeight, clientTop, clientLeft 
scrollWidth, scrollHeight, scrollTop, scrollLeft 

所以上面的那个写法,我可以把所有的读取几何信息的方法全部丢到后面去,这样就是一次回流了

ini 复制代码
app.style.width = '100px'
app.style.height = '200px'
app.style.left = '10px'
app.style.top = '10px'

console.log(app.offsetLeft)
console.log(app.offsetHeight)
console.log(app.offsetWidth) 
console.log(app.offsetTop)

执行第一个打印语句的时候,渲染队列就空了,空了的队列无法引起回流,读取值是不引起回流的,读取值引起的执行队列才会引起回流

所以说,我们为了减少回流,重绘,可以合理利用浏览器的优化策略,少去读取几何信息,或者统一放到最后读取

那么还有什么办法减少回流重绘呢?

既然都是队列了,那么它一定有大小,所以只要队列满了就一定会执行一遍,这样才有空间去存后面的几何样式,当然这不是方法

我们可以观察页面渲染的第三步,生成Render Tree是不会带入display: none;的元素,所以我们可以先将一个元素display: none;后统一修改其样式,然后再将其display: block;回来。这也是个方法,这样就是发生两次回流,none,block分别一次。

优化实例

给你一段代码你来进行优化

ini 复制代码
<ul id="box"></ul>

<script>
    const ul = document.getElementById("box");
    
    for (let i = 1; i<=100; i++) {
        let li = document.createElement("li");
        let text = document.createTextNode(i) // 创建文本节点
        li.appendChild(text) // 这个不算回流,因为text添加到li上时,li还没出现在页面上
        ul.appendChild(li);  
    }
</script>

这段代码就是循环创建100个li,并且每个li都有文本,这其实就是新建一个元素,每次新建一个就是回流一次,不算页面初次加载就是100次回流。

none,block优化

现在我们用none,block方法进行优化

ini 复制代码
<script>
    const ul = document.getElementById("box");
    ul.style.display = "none"; 
    
    for (let i = 1; i<=100; i++) {
        let li = document.createElement("li");
        let text = document.createTextNode(i) // 创建文本节点
        li.appendChild(text) 
        ul.appendChild(li);  // 100次
    }
    ul.style.display = "block"  
</script>

这样就是两次回流了

Fragment文档碎片优化

Fragment是一种机制,用于在内存中创建一个轻量级的文档碎片,这个文档碎片可以包含多个节点,它不涉及dom结构的实际插入,因此不会触发回流,最后带有批量节点的文档碎片插入到文档中,这样可以减少回流的次数

ini 复制代码
<script>
    const ul = document.getElementById("box");
    const fragment = document.createDocumentFragment(); // 虚拟的文档片段

    for (let i = 1; i<=100; i++) {
        let li = document.createElement("li");
        let text = document.createTextNode(i) 
        li.appendChild(text)
        fragment.appendChild(li); // 虚拟片段不会回流
    }

    ul.appendChild(fragment) 
</script>

这样就只会引起一次回流

clone克隆优化

这里的克隆是克隆节点,克隆过程发生在内存中,不会影响到文档布局,因此我们对副本进行操作,不引起回流,并且克隆是深拷贝,不会影响原体,克隆节点插入到文档中才会引起回流

ini 复制代码
<script>
    const ul = document.getElementById("box");
    const clone = ul.cloneNode(true) // 克隆一份ul,必定是深拷贝,原ul不受影响

    for (let i = 1; i<=100; i++) {
        let li = document.createElement("li");
        let text = document.createTextNode(i) // 创建文本节点
        li.appendChild(text)
        clone.appendChild(li);
    }

    ul.parentNode.replaceChild(clone, ul) // (替代品, 被替代品)
</script>

这样只会引起一次回流

最后来个字节面试题收尾

字节面试题

字节面试要是问到回流重绘问题,你就应该开心了,非常简单

ini 复制代码
<div id="app"></div>

<script>
    let el = document.getElementById('app')
    el.style.width = (el.offsetWidth + 1) + 'px' 
    el.style.width = 1 + 'px'
</script>

面试官:请问这三行js代码发生了几次回流

第一行获取dom结构不造成回流,第二行第三行的设置宽高一定会有回流。看第二行,从左往右执行,左边会进入渲染队列,但是右边会获取几何信息,因此这里按道理也会发生回流,但是这里情况特殊一点,offsetWidth是强制触发渲染队列的执行,而非强制回流。也就是说渲染队列里面没有东西去让你执行,因此执行第二行,第三行会连续入渲染队列,最后只发生一次回流,当然,如果是老版本浏览器,就是两次回流,因为两次设置width

一般字节一面大概30-45min,如果能一直聊到1h,基本上一面就稳了,30min内会问js基础问题3-5个,进阶问题(项目难点,大文件上传......),回答问题的时候最好把该知识能聊的东西全给聊一遍

最后

清楚回流重绘,那么浏览器后半段的过程你基本上就全部掌握了,后半段整个过程就是五个阶段,生成dom树,生成cssom树,两树合并,然后回流,重绘,回流因为要计算属性,需要考虑到如何去对他进行优化,浏览器有个优化策略,就是渲染队列,只要没有计算信息的干预,会一直进行入队列,批量执行,计算信息的出现就会导致一次刷新队列,也就是回流,当然前提队列是有东西的,文章最后的字节面试题就是来坑你这里的。本文还给大家介绍了三种优化策略,none,block文档碎片节点克隆

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请"点赞+评论+收藏"一键三连,感谢支持!

本次学习代码已上传至本人GitHub学习仓库:github.com/DolphinFeng...

相关推荐
前端百草阁5 分钟前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜5 分钟前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4046 分钟前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish7 分钟前
Token刷新机制
前端·javascript·vue.js·typescript·vue
zwjapple7 分钟前
typescript里面正则的使用
开发语言·javascript·正则表达式
小五Five8 分钟前
TypeScript项目中Axios的封装
开发语言·前端·javascript
小曲程序8 分钟前
vue3 封装request请求
java·前端·typescript·vue
临枫5419 分钟前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript
酷酷的威朗普10 分钟前
医院绩效考核系统
javascript·css·vue.js·typescript·node.js·echarts·html5
前端每日三省10 分钟前
面试题-TS(八):什么是装饰器(decorators)?如何在 TypeScript 中使用它们?
开发语言·前端·javascript