渲染优化
# 服务端渲染
服务端渲染(SSR)并非什么新鲜的事情,早期的网页都是基本使用SSR来实现的。如今之所以又提起服务端渲染,是因为目前的客户端渲染(CSR)的模式遇到了一些问题。
目前的最常用的前端框架Vue和React在构建前端项目的时候,最终打包出来的入口文件index.html
里面基本是“空”的,几乎看不到网站内容相关的信息。这个文件内部只做了一件事,加载js资源
<!doctype html>
<html>
<head>
<title>客户端渲染</title>
</head>
<body>
<div id='root'></div>
<script src='index.js'></script>
</body>
</html>
也就是说,客户端看见的内容都是在js资源加载并执行后看见的。这样做的问题就是SEO优化非常差,搜索引擎的爬虫抓取不到任何网站的有用信息,导致用户搜索时找不到我们的网站。SSR就是为了解决这个SEO优化问题而被提起的,相比客户端渲染,服务端渲染先在服务端将网站首页信息渲染成HTML字符串并交给客户端,客户端无需加载执行js,直接将HTML字符串渲染即可,搜索引擎的爬虫在这个过程中也能抓取到更多有用的网站信息。
使用SSR的另一个原因是首屏性能优化,毕竟直接渲染HTML字符串相比下载执行js肯定快得多。不过对应的代价就是消费更多的服务器资源,首屏性能优化策略有很多,SSR带来的首屏性能优化只是“顺带”,它主要还是用来解决SEO优化问题。
# 浏览器渲染原理
关于浏览器的渲染过程可简单分为下面几个步骤:
- 解析HTML,生成DOM Tree。
- 解析CSS,生成CSSOM Tree。
- 将DOM Tree和CSSOM Tree合成,生成render Tree。
- 布局,根据render Tree,计算页面中每个元素的大小及其所处的精确位置。
- 绘制,将元素渲染为真正的像素展示在浏览器上。
这个过程中,CSS和JS都有可能造成渲染流程的阻塞,从而影响页面的渲染速度。
# CSS阻塞
实际上在浏览器中,HTML和CSS的解析工作是并行的,两者之间并不干扰。CSS能够阻塞渲染是因为render Tree的生成必须要HTML和CSS都解析完毕才可以,在此之前浏览器不会展示任何的内容,这是为了避免没有任何样式的光秃秃的HTML直接展示到页面上。当然,如果CSS长期未能加载,浏览器会动用默认样式来进行渲染。
基于这个特性,实际生产中都会采用把CSS放到header标签或者CDN服务器上这种方式,来实现“CSS提前”,正是为了避免CSS造成的阻塞。
# JS阻塞
JS的阻塞要好理解的多,因为渲染线程和JS引擎是互斥的,即同一个时间只有其中一个可以工作。
在渲染页面的过程中,如果遇到<script>
标签,浏览器会暂停页面的渲染流程,转而去下载并执行js文件。因为js具备着修改DOM结构的能力,所以浏览器会优先执行js。这就是阻塞的来源。作为开发者来说,浏览器不知道js是否会改变DOM,但是我们自己知道,因此可以手动调整js的执行时机,比如使用async和defer。
<script async src='index.js'>
async告知浏览器应该尽快执行当前脚本。浏览器一边渲染一遍加载js,当js加载完毕,无论渲染过程是否完成,都会立即去执行js。这种方法还是有可能阻塞渲染流程的。
<script defer src='index.js'>
defer告知浏览器,本次加载的js脚本需要页面渲染完成之后才执行。浏览器一遍渲染一遍加载js文件,等到页面渲染完毕才会执行js。
# 渲染性能优化
# 提高DOM性能
对于DOM的操作是昂贵的。 这是所有的前端开发者几乎都听过的话,至于原因可以用《高性能JavaScript》书中的一句话来解释:
把 DOM 和 JavaScript 各自想象成一个岛屿,它们之间用收费桥梁连接。
简单来说就是,浏览器中的渲染引擎和js引擎是独立存在的,如果需要与对方交流就要被收一次过桥费。交流次数稍微一多,就会带来昂贵的性能问题。除此以外,由于操作DOM而引起的回流和重绘问题还会导致额外的性能消耗。这也是这条准则存在的原因,谨慎操作DOM。
比如一个需要在一个DOM元素中加入10000条语句,按照最简单直接的方法:
for(var count=0;count<10000;count++){
document.getElementById('container').innerHTML+='<span>我是一个小测试</span>'
}
这个过程中被收了10000次过桥费,是一个性能极差的代码。更好的解决方式是使用一个变量先把需要添加到DOM元素中的语句存储起来,然后一次性添加到DOM元素中。或者使用DOM Fragment:
let container = document.getElementById('container')
// 创建一个DOM Fragment对象作为容器
let content = document.createDocumentFragment()
for(let count=0;count<10000;count++){
// span此时可以通过DOM API去创建
let oSpan = document.createElement("span")
oSpan.innerHTML = '我是一个小测试'
// 像操作真实DOM一样操作DOM Fragment对象
content.appendChild(oSpan)
}
// 内容处理好了,最后再触发真实DOM的更改
container.appendChild(content)
DOM Fragment可以允许开发者像真实DOM一样操作API,但是它并非真实的DOM元素,不会存在回流和重绘。可以将其想象成一个临时的容器。
# Event Loop和异步更新策略
事件循环在学习js的时候已经有过了解,这里主要添加一下页面渲染在视觉循环中的位置,正常的一轮事件循环如下:
- 处理一个宏任务。
- 处理一队微任务。
- 执行渲染操作,更新视图界面。
- 处理Web Worker相关的任务。
上面的流程不断循环执行,直到任务队列中没有新的任务。
Vue和React中采用的异步更新策略正是对事件循环一个非常经典的应用。以Vue为例,所有事件更新相关的任务会被Vue放到一个数组中存储起来,有一个专门的函数flushcallbakc
负责执行数组中每个任务以更新界面。而这个flushcallbacks
函数会被包装成一个微任务,在下一轮事件循环中执行,由此实现的就是异步更新,因为对数据的更改在本轮事件循环中不会生效,界面暂时不会变化。上面说到的这个异步更新就是Vue中的nextTick
API的实现原理,也是为什么能够使用nextTick
获取到更新后的DOM元素。
因为更新页面这个微任务总是比nextTick
中的回调函数先一步被放入队列执行,所以执行nextTick
中的回调函数的时候页面已经更新完成了,DOM已经是更新之后的样子了。
# 回流和重绘
回流:我们对DOM的修改引发了DOM几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。
重绘:当我们对DOM的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘。
回流一定会导致重绘,但是重绘不一定导致回流。
总结一下导致回流的情况:
- 更改DOM元素的几何尺寸。
- 操作DOM树的结构。
- 获取特定属性值。如offsetTop、clientLeft等。 最容易被忽略的操作,因为浏览器需要重新计算才能得到这些属性,所以获取它们也会导致回流。
减少回流和重绘的措施:
- 将尺寸相关的计算在js中执行后,一次性更新到DOM元素上。
- 避免逐条修改样式,使用类名将样式合并后应用于DOM元素。
- "离线"DOM元素。先把一个DOM元素变为不可见,然后更改其样子后又将其变为可见。
- 使用CSS3提供的动画,如transform opacity等属性来更改DOM元素样式,使用动画提供的硬件加速特性减少回流和重绘次数。
现代浏览器内部尽管也内置了对于回流和重绘操作的优化,比如使用flush
队列来合并等,但是我们不知道用户使用的浏览器是否支持优化,所以最好还是从代码层面直接进行优化而不是指望浏览器来完成这个工作。