7

性能优化(渲染层面)

这里主要是针对浏览器渲染进程的工作流程做出的一些优化, 有个宏观的概念...
我们来回顾一下渲染进程的工作流

渲染进程工作流

  • 1.DOM Tree 构建
当渲染进程接收到导航的确认信息,开始接受HTML数据时,主线程会解析文本字符串为 DOM;
这里依靠 HTMl 解析器: 
接受字节流 -> 维护 token 栈 -> 生成节点node -> 组成 DOM;

遇到内嵌 script 时, DOM解析工作停止; js引擎介入执行(可能会修改dom结构);
执行完 js 后恢复解析工作, 所以 js 会阻塞 dom 解析.

遇到其他内联资源时(css,img)会通知网络进程去下载, 特别是 css;
js 在操作dom 样式时会依赖cssom,生成 layoutTree也需要 cssom; 
所以 css 又会阻塞 js 的执行
  • 2.样式计算, 构建cssom(css规则树)
这里会基于 CSS 选择器解析 CSS 获取每一个节点的最终的计算样式值; 
与 DOM 树构建并行;
对应的就是styleSheets
  • 3.计算布局, 生成layout tree
想要渲染一个完整的页面,除了获知每个节点的具体样式,还需要获知每一个节点在页面上的位置,
布局其实是找到所有元素的几何关系的过程。

这里通过遍历 DOM 及相关元素的计算样式,主线程会构建出包含每个元素的坐标信息及盒子大小的布局树。
布局树和 DOM 树类似,但是其中只包含页面可见的元素,如果一个元素设置了 `display:none` ,
这个元素不会出现在布局树上,伪元素虽然在 DOM 树上不可见,但是在布局树上是可见的。
  • 4.分层,绘制(layer -> paint)
为特定的节点生成专用图层(will-change属性), 生成 图层树;
为图层生成绘制表(记录了绘制指令和顺序), 提交到合成线程
  • 5.分块,光栅化
合成线程将图层分为图块, 通过光栅化生成位图(GPU 进程)
  • 6.合成,显示
图块被光栅化后会生成一个绘制命令, 通过 IPC 提交给浏览器进程去执行,
绘制到内存中然后展示在显示器上

这里我们针对每一步进行分析, 并尽可能提炼出一些优化手段

1.首屏优化(让浏览器接收到尽可能完整的 HTML 字符串)

目前我们都习惯了使用 vue,react 这些框架,大多数情况下我们都是基于 spa 搭建的单页应用(即纯客户端渲染csp),全靠js运行后输出渲染页面, 这样在首次加载的时候需要等待资源全部加载完毕才会呈现出页面, 增加等待时间, 对于 C端业务(特别是有 SEO 要求的)显然是不可取的; 所以后续又提出来了服务端渲染(ssr同构), 服务端给出完整的 html, 客户端接管后续操作.

优化措施1: 服务端渲染

可以参考 vue nuxt.jsvue 官网的ssr 指南

优化措施2: 预渲染

服务端渲染多服务器开销要求很大, 如果服务器成本预算不足的话, 尽量还是不要用了吧,或者少数页面使用 SSR 就行; 这时不妨考虑下预渲染.
主要是针对一些纯静态页面
prerender-spa-plugin

优化措施3: 增加请求资源过程中的交互体验

比如loading, 转场过渡动画, 骨架屏等

2. DOM解析优化(让 html 的解析流畅)

解析 HTML 字符串时又穿插了 CSS 与 JS, 我们知道 js线程与渲染线程同一时间只有一个在工作, js 的执行会阻塞 DOM 解析;
另外 js 的执行会依赖 cssom(js 可能操作样式), 所以 css 的加载又会阻塞 js;
而且, 布局树最终的合成也依赖于 cssom 和 dom;
因此, js 和 css 的加载顺序也是一个能优化的点.

css 加载

1.css 引入

css一般由head 中的link和style标签引入,
或者内联到标签中,尽量提前放置,尽快加载

2.css 书写(CSS选择符是从右到左进行匹配的)

规则尽量简单,避免层级嵌套;
少用*,标签选择器等;
考虑属性继承
js 加载

1.正常加载

<script src="index.js"></script>
<!-- 浏览器必须等待 index.js 加载和执行完毕才能去做其它事情 -->

1.async 模式加载

<script async src="index.js"></script>
<!-- 加载是异步的,当它加载结束,JS脚本会立即执行(执行还是会阻塞) -->

1.defer 模式加载

<script defer src="index.js"></script>
<!-- 加载是异步的,执行是被推迟的。
等整个文档解析完成、DOMContentLoaded 事件即将被触发时,
被标记了 defer 的 JS 文件才会开始依次执行 
-->

从应用的角度来说:
当我们的脚本与 DOM 元素和其它脚本之间的依赖关系不强时,我们会选用 async;
当脚本依赖于 DOM 元素和其它脚本的执行结果时,我们会选用 defer

3.DOM 操作优化一

当我们用 JS 去操作 DOM 时,本质上是 JS 引擎和渲染引擎之间进行了通信交流, 这样也会消耗性能;
另外,我们对 DOM 的操作都不会局限于访问,而是为了修改它。当我们对 DOM 的修改会引发它外观(样式)上的改变时,就会触发重排重绘。导致布局树的改变...

重排: 当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是重排(也叫回流)。

重绘: 当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的重排环节)。这个过程叫做重绘。

优化措施1: 减少 DOM 操作

类似虚拟 DOM 的手法, js 批量操作需要更新的 dom, 然后一次性更新到 DOM 树中
利用DocumentFragment

优化措施2: 减少重绘重排
  • 减少改变DOM元素的几何属性
当一个DOM元素的几何属性发生变化时,所有和它相关的节点(比如父子节点、兄弟节点等)
的几何属性都需要进行重新计算,它会带来巨大的计算量...
常见的几何属性有 width、height、padding、margin、left、top、border 等等。
此处不一一列举了

我们可以一次性赋值:
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'

// 优化, 切换类名而不是设置值
.basic_style {
  width: 100px;
  height: 200px;
  border: 10px solid red;
  color: red;
}
const container = document.getElementById('container')
container.classList.add('basic_style')
  • 减少位置值的获取
当你要用到像这样的属性:offsetTop、offsetLeft、 offsetWidth、offsetHeight、
scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、
clientLeft、clientWidth、clientHeight 时; 
这些属性值需要通过即时计算得到。因此浏览器为了获取这些值,也会进行重排。

// 我们可以缓存这些值:
// 缓存offsetLeft与offsetTop的值
const el = document.getElementById('el') 
let offLeft = el.offsetLeft, offTop = el.offsetTop

// 在JS层面进行计算
for(let i=0;i<10;i++) {
  offLeft += 10
  offTop  += 10
}

// 一次性将计算结果应用到DOM上
el.style.left = offLeft + "px"
el.style.top = offTop  + "px"
  • 使用 GPU 图层
比如使用心得 css3属性 transform 等, 利用GPU进程去处理

4.DOM 操作优化二(异步更新策略)

利用事件循环(Event Loop)尽可能早的更新DOM;

事件循环下的任务队列

事件循环中的异步队列有两种: macro(宏任务: 浏览器维护)队列和 micro(微任务: js引擎维护)队列。

  • Macro-Task宏任务
setTimeout,setInterval,setImmediate,
script(整体代码), I/O 操作, UI 渲染等
  • Micro-Task
process.nextTick、Promise、MutationObserver等
事件循环运行过程
1.初始状态: 调用栈为空
micro 队列空,macro 队列里有且只有一个 script 脚本(整体的全局代码)

2.全局上下文(script 标签)被推入调用栈,同步代码执行。
在执行的过程中,通过对一些Web API接口的调用,可以产生新的 macro-task 与 micro-task,
它们会分别被推入各自的任务队列里。
同步代码执行完了,script 脚本会被移出 macro 队列,
这个过程本质上是队列的 macro-task 的执行和出队列的过程。

3.前面我们出队列的是一个 macro-task,这一步我们处理的是 micro-task。
但需要注意的是: 当 macro-task 出队时,任务是一个一个执行的;
而 micro-task 出队时,任务是一队一队执行的。(当前出队的宏任务下的微任务队列)
因此,我们处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。

4.执行渲染操作,更新界面(重点)。

5.检查是否存在 Web worker 任务,如果有,则对其进行处理 。

6.上述过程循环往复,直到两个队列都清空
DOM 异步更新的时刻

假如我想要在异步任务里进行DOM更新,我该把它包装成 micro 还是 macro 呢?

// 假设包装成宏任务
// task是一个用于修改DOM的回调
setTimeout(task, 0)
/*
现在 task 被推入的 macro 队列。但因为 script 脚本本身是一个 macro 任务,
所以本次执行完 script 脚本之后,下一个步骤就要去处理 micro 队列了,
再往下就去执行了一次渲染操作了, 
进入下一轮的事件循环...
我们的 task 此时执行的时机是下一轮的事件循环中
*/

// 假设包装成微任务
Promise.resolve().then(task)
/*
 script 脚本的执行完, 紧接着就去处理 micro-task 队列;
 micro-task 处理完,DOM 修改好了;
 紧接着就可以走渲染流程了...
 所以不需要再消耗多余的一次渲染,不需要再等待一轮事件循环,直接为用户呈现最即时的更新结果。
*/

所以当我们需要在异步任务中实现DOM修改时,要它包装成 micro 任务。这样可以减少实际的渲染次数

vue的批量异步状态更新: nextTick

具体见我之前写的 nextTick 理解, 虽然不全;
至少我们知道了可以利用微任务来优化 DOM 的操作更新

5.懒加载方式

// lazy-loading
// 获取所有的图片标签
const imgs = document.getElementsByTagName('img')
// 获取可视区域的高度
const viewHeight = window.innerHeight || document.documentElement.clientHeight
// num用于统计当前显示到了哪一张图片,避免每次都从第一张图片开始检查是否露出
let num = 0
function lazyload(){
    for(let i=num; i<imgs.length; i++) {
        // 用可视区域高度减去元素顶部距离可视区域顶部的高度
        let distance = viewHeight - imgs[i].getBoundingClientRect().top
        // 如果可视区域高度大于等于元素顶部距离可视区域顶部的高度,说明元素露出
        if(distance >= 0 ){
            // 给元素写入真实的src,展示图片
            imgs[i].src = imgs[i].getAttribute('data-src')
            // 前i张图片已经加载完毕,下次从第i+1张开始检查是否露出
            num = i + 1
        }
    }
}
// 监听Scroll事件
window.addEventListener('scroll', lazyload, false);

6.事件的防抖与节流

像scroll 事件,resize 事件、鼠标事件(比如 mousemove、mouseover 等)、键盘事件(keyup、keydown 等)都可能被用户频繁触发;
频繁触发回调导致的大量计算会引发页面的抖动甚至卡顿。为了规避这种情况,我们需要一些手段来控制事件被触发的频率。这样就出现了throttle(事件节流)和 debounce(事件防抖);
这两个函数都是以闭包的形式存在。
它们通过对事件对应的回调函数进行包裹、以自由变量的形式缓存时间信息,最后用 setTimeout 来控制事件的触发频率

throttle

一段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时才调用

// fn是我们需要包装的事件回调, interval是时间间隔的阈值
function throttle(fn, interval) {
  // last为上一次触发回调的时间
  let last = 0
  
  // 将throttle处理结果当作函数返回
  return function () {
      // 保留调用时的this上下文
      let context = this
      // 保留调用时传入的参数
      let args = arguments
      // 记录本次触发回调的时间
      let now = +new Date()
      
      // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
      if (now - last >= interval) {
      // 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
          last = now;
          fn.apply(context, args);
      }
    }
}

// 用throttle来包装scroll的回调, 间隔 1s 触发第一次
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)
debounce

一段时间内,不管你触发了多少次回调,我都只认最后一次

// fn是我们需要包装的事件回调, delay是每次推迟执行的等待时间
function debounce(fn, delay) {
  // 定时器
  let timer = null
  
  // 将debounce处理结果当作函数返回
  return function () {
    // 保留调用时的this上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments

    // 每次事件被触发时,都去清除之前的旧定时器
    if(timer) {
        clearTimeout(timer)
    }
    // 设立新定时器
    timer = setTimeout(function () {
      fn.apply(context, args)
    }, delay)
  }
}

// 用debounce来包装scroll的回调
// 触发事件后, 1s 内再次触发都不会执行
const better_scroll = debounce(() => console.log('触发了滚动事件'), 1000)

document.addEventListener('scroll', better_scroll)

参考

DocumentFragment MDN


a_dodo
2.4k 声望1k 粉丝

天下事有难易乎?