前端性能优化(二)

846 阅读10分钟

服务器端渲染

在传统 web 开发中,网页内容都是在服务器端渲染好,再传输至浏览器;而单页应用凭借着其优秀的用户体验,逐渐成为主流,在单页应用(SPA)中,页面具体内容由 JS 渲染出来,一般被成为客户端渲染。而 SPA 有两个显著缺点: SEO 不友好(页面上呈现的内容,无法在 html 源文件中找到);首屏加载过慢(必须等到必要的 JS 文件加载执行完后才开始渲染)。

而 SSR 解决方案是从后端渲染出完整的首屏 DOM 结构,前端只需要激活应用就可以继续以 SPA 的方式运行。

可以看一个 Vue SSR 指南给出的例子,可以重点注意一下 renderToString 方法,它负责生成静态 HTML 字符串:

const Vue = require('vue')
// 创建一个express应用
const server = require('express')()
// 提取出renderer实例
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
  // 编写Vue实例(虚拟DOM节点)
  const app = new Vue({
    data: {
      url: req.url
    },
    // 编写模板HTML的内容
    template: `<div>访问的 URL 是: {{ url }}</div>`
  })
    
  // renderToString 是把Vue实例转化为真实DOM的关键方法
  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    // 把渲染出来的真实DOM字符串插入HTML模板中
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `)
  })
})

server.listen(8080)

可能看到代码后会与传统的 JSP 开发方式混淆,这里需要区分开来,因为 SSR 并不是简单粗暴的将字符串拼接起来,更确切的说,它是将 Vue/React 代码先在 Node 上跑一遍,将虚拟 DOM 转换为真实 DOM 。之后再在客户端激活,继续以单页应用的方式运行,这是传统 JSP 开发做不到的。

虽然应用端渲染看起来很厉害的样子,但是为什么还是有很多网站没有采用 SSR?为什么我们依然需要预渲染(prerender-spa-plugin)?

服务器渲染的本质是 将浏览器应该完成的事情分担给服务器去做 。虽然服务器性能比浏览器高上很多,但是还需要考虑,几乎每个用户都有一个浏览器,而服务器的数量是有限的,当渲染压力集中起来,服务器肯定得出问题。

所以,采用 SSR 来优化性能或 SEO 需要慎重考虑,先把能用的方法都试一遍(预渲染),还达不到性能要求,再考虑服务器渲染。

浏览器渲染优化

要从浏览器层面做前端性能优化,就必须先了解浏览器的基本工作原理。

先从最重要的一个部分开始:浏览器内核。它分为两部分:渲染引擎和 JS 引擎。渲染引擎又包括很多部分:HTML 解释器、CSS 解释器、布局、网络、存储、图形、图片解码器等等。常见的浏览器内核主要可以分为:Trident(IE),Gecko(火狐),Blink(Chrome,Opera),Webkit(Safari)。我们以 Webkit 为例对浏览器渲染过程做一个具体的分析。

首先思考,浏览器是怎样将 HTML/CSS/JS 资源转为可以看到的图像的?

这是浏览器内核内部各个模块一同协作的结果:

  • HTML 解释器:将 HTML 经过词法分析输出为 DOM 树
  • CSS 解释器:解析 CSS 文档,生成样式规则
  • 图层布局计算模块:布局计算每个对象的精确位置和大小
  • 视图绘制模块:进行具体节点的图像绘制,将像素渲染到屏幕上
  • JS 引擎:编译执行 JS 代码

现在可以梳理一遍整个渲染过程:

  1. 首先 HTML 解释器开始解析 HTML,生成 DOM 树,并在解析过程中,发起对页面渲染需要的各种外部资源的请求
  2. 浏览器解析并加载 CSS 样式信息生成 CSSOM 树,并与 DOM 树合并,生成 render 树
  3. 计算图层布局,从根节点开始递归调用,计算每一个元素的大小,位置,给出每个节点应该出现的屏幕上的具体坐标,最终得到基于 render 树的布局渲染树(Layout of the render tree)
  4. 绘制图层,将每一个图层都转换为像素,并对媒体文件解码
  5. 整合图层,将数据由 CPU 输出给 GPU 绘制到屏幕上

用一句话来总结的话,就是基于 HTML 的 DOM 树和基于 CSS 的 CSSOM 树相结合,生成布局渲染树,浏览器再基于此树绘制图像,显示在屏幕上。

另外需要注意的是,DOM 树的解析和 CSSOM 树的解析是并行的。

渲染过程中的 CSS 优化

DOM 树上每新增一个元素,都会通过 CSS 引擎遍历 CSS 样式表,找到匹配到该元素的样式规则应用到该元素上。此处就可以开始通过编写合理的 CSS 规则针对 CSS 做优化。

CSS 引擎查找样式表时有一个让很多人想不到的规则:CSS 选择符是从右到左匹配的

#app div {
	color: red
}		

按照惯性思维看,浏览器应该先找到 id 为 app 的元素,再查找它的后代中是 div 的元素。

然鹅,事情并不是酱紫的。

CSS 引擎会先查找所有 tag 是 div 的元素,再确认它的父元素是不是 #app。显然这样带来的查询开销会相当大。

所以在清除浏览器默认样式的时候请不要直接用通配符 *,这样会导致浏览器必须遍历每一个元素,如果是个大型应用,可能带来相当程度的性能损耗。

有了上面的知识储备,就可以提出下面几条常用的性能提升方案:

  • 避免使用通配符,针对需要的元素做特定的默认样式清除
  • 关注 CSS 属性的继承,避免某些样式的重复定义
  • 少用标签选择器,想想一个页面里有多少 divspan,尽量用类选择器代替
  • 减少标签嵌套层数,后代选择器开销是很高的

针对 CSS 文件和 JS 文件加载顺序的优化

  • CSS 阻塞

    前面提到 CSSOM 和 DOM 树一起生成渲染树,显然二者缺一不可。所以,默认情况下,CSS 是会阻塞 DOM 渲染的(但是不阻塞 DOM 的解析)。只有当 CSS 解析完毕生成 CSSOM 树之后才能进入渲染过程(否则用户看到的就是一个没有样式的丑陋页面)。而在解析到 HTML 的时候,只有解析到 link 标签或是 style 标签时,才开始 CSSOM 树的构建。大多数情况下,DOM树的构建都快与 CSSOM 树。

    所以,需要尽快(用 CDN 实现静态资源加载速度的优化),尽早(放在 head 标签中)的将 CSS 资源下载到客户端,以缩短首次渲染时间。

  • JS 阻塞

    JS 阻塞有别于 CSS 阻塞,前面提到过,JS 引擎是独立于渲染引擎存在的。当解析到 script 标签时,浏览器会将控制权交给 JS 引擎,那么 DOM 的解析和渲染都会被阻塞。直到脚本执行完毕,控制权还给渲染引擎,继续构建。这样做的原因,也是出于不清楚 JS 文件中会不会对 DOM 做操作,导致渲染出现混乱。

    考虑一个场景,某个特别大的 JS 文件,内部不依赖 DOM 元素,还需要阻塞渲染嘛?

    显然是不需要的。

    针对类似的场景,JS 提供了三种加载方式:

    • 正常模式:阻塞浏览器的渲染和加载

      <script src="index.js"></script>
      
    • defer 模式:JS 异步加载,执行被推迟到 DOM 解析完毕,DOMContentLoaded 事件即将被触发前,标记为 defer 的 JS 文件依次开始执行

      <script defer src="index.js"></script>
      
    • async 模式:加载是异步的,加载结束后立即执行

      <script async src="index.js"></script>
      

    一般来说,当脚本与其他脚本和 DOM 依赖不强的的时候,可以选用 async;当依赖于脚本执行顺序和其他脚本执行结果时,可以选用 defer。

DOM 优化

前端开发者大都清楚,针对DOM的操作是十分昂贵的。如今优秀的前端框架也都针对这个痛点做了处理,比如 Vue.js 中的虚拟 DOM,其中的 patch 算法通过 JS 运算尽可能的减少 DOM 操作。

在研究如何优化之前,先思考为什么 DOM 操作这么慢?

把 DOM 和 JavaScript 各自想象成一个岛屿,它们之间用收费桥梁连接。——《高性能 JavaScript》

当我们在 JS 中操作 DOM 时,其实是在 JS 引擎和渲染引擎之间做沟通,而前面提过,这两个引擎是独立实现的。出于这个原因,两个引擎之间的沟通是需要消耗相当性能的,沟通次数一多,自然就带来了性能问题。所以减少 DOM 操作并不是无的放矢。

减少回流与重绘

当我们操作 DOM 并引发了其样式的更改,从而导致渲染树的改变,最终触发浏览器的回流与重绘。

  • 回流:指对 DOM 的修改造成了几何尺寸的改变,那么渲染引擎就需要重新计算布局(其他位置的尺寸或位置会受到影响),再将计算结果绘制出来。
  • 重绘:指对 DOM 的修改并未影响几何尺寸,只是显示效果上的变化,此时,不需要重新计算布局,直接绘制新的样式即可,造成的性能影响不如重绘大。也可以说,回流一定会导致重绘,但是重绘不一定会导致回流。

常见的会触发回流的操作有:改变 DOM 元素的几何特性,改变 DOM 树的结构,获取一些DOM属性(offsetHeight、scrollTop等)值。

很多时候虽然无法避免造成回流或重绘的操作,但是针对某些场景,存在一些常见的优化方案:

  • 将“导火索”缓存起来,避免频繁触发

    const el = document.getElementById('app')
    for (let i = 0; i < 10; i++) {
    	el.style.top = el.offsetTop  + 10 + "px";
    }
    // 优化后,在计算得出结果后的值应用到 DOM 上
    const el = document.getElementById('app')
    let offTop = el.offsetTop
    for (let i = 0; i < 10; i++) {
    	offTop +=  10;
    }
    el.style.top = offTop + 'px'
    
  • 避免逐条改变样式,使用类名合并样式改变

    el.style.color = "red"
    el.style.border = "1px solid"
    el.style.padding = "10px"
    
    // 优化后,直接修改类名,在css中定义样式
    el.classList.add('special')
    
  • 将 DOM 离线,完成修改后再上线

    const container = document.getElementById('container')
    container.style.width = '100px'
    container.style.height = '200px'
    container.style.border = '10px solid red'
    container.style.color = 'red'
    
    // 优化后,离线后之后的改动是不会出发回流或重绘的
    let container = document.getElementById('container')
    container.style.display = 'none'
    container.style.width = '100px'
    container.style.height = '200px'
    container.style.border = '10px solid red'
    container.style.color = 'red'
    container.style.display = 'block'
    
    

减少不必要的 DOM 操作

// 只获取一次container
let container = document.getElementById('container')
for(let count=0;count<10000;count++){ 
  container.innerHTML += '<span>我是一个小测试</span>'
} 

在上面的例子中,在 10000 次循环中,我们一共修改了10000 次 DOM 树。这种时候就应该选择使用 JS 计算代替 DOM 操作:

let container = document.getElementById('container')
let content = ''
for(let count=0;count<10000;count++){ 
  // 先对内容进行操作
  content += '<span>我是一个小测试</span>'
} 
// 内容处理好了,最后再触发DOM的更改
container.innerHTML = content

异步更新策略

当我们使用 Vue 或 React 提供的接口去修改数据的时候,视图并不会马上更新,而是推入一个队列中,之后再批量更新。这就称为异步更新策略,避免了过度渲染。

<div>{{content}}</div>
this.content = 'aaa'
this.content = 'bbb'
this.content = 'ccc'
// 并不会渲染三次,而是使用 JS 将需要更新的组件推入队列,最终只执行一次组件的渲染函数

需要了解 Vue 中具体如何实现异步更新的,可以移步这个文章