性能优化

最近几年我出去看机会,基本上每次面试都会问到此问题,本文总结一下常问的一些点。

DOM 为什么这么慢?

JS 引擎和渲染引擎(浏览器内核)是独立实现的。当我们用 JS 去操作 DOM 时,本质上是 JS 引擎和渲染引擎之间进行了“跨界交流”。

很多时候,我们对 DOM 的操作都不会局限于访问,而是为了修改它。当我们对 DOM 的修改会引发它外观(样式)上的改变时,就会触发回流或重绘,这两个都是很吃性能的。

当一个DOM元素的几何属性发生变化时,所有和它相关的节点(比如父子节点、兄弟节点等)的几何属性都需要进行重新计算,它会带来巨大的计算量。

考虑JS 的运行速度,比 DOM 快得多这个特性。我们减少 DOM 操作的核心思路,就是让 JS 去给 DOM 分压,也是虚拟DOM近几年流行了起来。

输入 URL 到页面展示发生了什么?

  • 数据请求的通信

    • 输入url:如http://www.baidu.com。其中http为协议,www.baidu.com为网络地址,一般网络地址可以为域名或IP地址,此处为域名。使用域名是为了方便记忆,但是为了让计算机理解这个地址还需要把它解析为IP地址。
    • 应用层DNS解析域名:客户端先检查本地是否有对应的IP地址,若找到则返回响应的IP地址。若没找到则请求上级DNS服务器,直至找到或到根节点。
    • 应用层发送HTTP请求:HTTP请求包括请求报头和请求主体两个部分,其中请求报头包含了至关重要的信息,包括请求的方法(GET / POST)、目标url、遵循的协议(http / https / ftp…),返回的信息是否需要缓存,以及客户端是否发送cookie等。
    • 传输层TCP传输报文:TCP协议为传输报文提供可靠的字节流服务,它为了方便传输,将大块的数据分割成以报文段为单位的数据包进行管理,并为它们编号,方便服务器接收时能准确地还原报文信息,TCP协议通过“三次握手”等方法保证传输的安全可靠。
    • 网络层IP协议查询MAC地址: IP协议的作用是把TCP分割好的各种数据包传送给接收方。而要保证确实能传到接收方还需要接收方的MAC地址,也就是物理地址。IP地址和MAC地址是一一对应的关系,一个网络设备的IP地址可以更换,但是MAC地址一般是固定不变的。ARP协议可以将IP地址解析成对应的MAC地址。
    • 数据到达数据链路层: 在找到对方的MAC地址后,就将数据发送到数据链路层传输。
    • 服务器接收数据: 接收端的服务器在链路层接收到数据包,再层层向上直到应用层, 这过程中包括在运输层通过TCP协议讲分段的数据包重新组成原来的HTTP请求报文。
    • 服务器响应请求: 服务接收到客户端发送的HTTP请求后,查找客户端请求的资源,并返回响应报文,响应报文中包括一个重要的信息——状态码。状态码由三位数字组成,其中比较常见的是200 OK表示请求成功。301表示永久重定向,即请求的资源已经永久转移到新的位置。在返回301状态码的同时,响应报文也会附带重定向的url,客户端接收到后将http请求的url做相应的改变再重新发送。404 not found 表示客户端请求的资源找不到。
    • 服务器返回相应文件:请求成功后,服务器会返回相应的HTML文件。接下来就到了页面的渲染阶段了。
  • 浏览器渲染

    • 解析HTML以构建DOM树 –> 构建渲染树 –> 布局渲染树 –> 绘制渲染树。
    • DOM树是由HTML文件中的标签排列组成,渲染树是在DOM树中加入CSS或HTML中的style样式而形成。渲染树只包含需要显示在页面中的DOM元素,像 head 元素或display属性值为none的元素都不在渲染树中。
    • 在浏览器还没接收到完整的HTML文件时,它就开始渲染页面了,在遇到外部链入的脚本标签或样式标签或图片时,会再次发送HTTP请求重复上述的步骤。在收到CSS文件后会对已经渲染的页面重新渲染,加入它们应有的样式,图片文件加载完立刻显示在相应位置。在这一过程中可能会触发页面的重绘或重排。

如何进行优化?

对于web应用,仅仅从提升首屏渲染速度和首次可交互时间来看,可以从以下六个方面着手:

  • 减少文件大小:开启打包压缩,去掉冗余代码,进行合理文件拆分,避免单个文件过大等;
  • 减少请求数量:进行适当的文件合并,尽量避免使用重定向,如果一定要使用重定向,如http重定向到https,要使用301永久重定向,将图片的内容以Base64格式内嵌到HTML中和使用字体图标来代替图片等;
  • 优化网络连接:使用CDN,使用DNS预解析,使用多个域名并行连接(在HTTP1.1协议下,chrome每个域名的最大并发数是6个),在HTTP2协议中,可以开启管道化连接;
  • 优化资源加载:JS文件放在body底部和CSS文件放在head中,先外链,后本页,异步script标签和模块按需加载,使用资源懒加载、资源预加载preload和资源预读取prefetch;
  • 减少重绘回流:避免使用层级较深的选择器和CSS表达式,给图片设置尺寸,素适当地定义高度或最小高度,缓存DOM,防抖和节流,及时清理环境,特别是定时器和全局变量;
  • 用好打包工具:选择性能更好的打包工具,及时更新打包工具,选用合适的插件拓展功能,合适的loader解析文件;

优化需要注意什么?

性能优化很重要,但也有很多需要注意的地方,把握不好,反而达不到预期,甚至影响大局。结合我个人的经验,主要由以下需要注意的:

  • 在做任何优化之前,都要做充分的调研和技术验证,然后和有关人员进行必要的沟通;
  • 尽量将优化与业务迭代剥离开,在此基础上还要把优化进一步区分为性能优化和代码重构,一般性能优化涉及面少,测试容易验证,有时可以课需求迭代一起上线,而代码重构则一定要和性能优化和需求代码分离开,拉新分支,单独排期;
  • 性能优化是一项长期工程,不要一次做太多优化,否则不利于回滚,容易让优化夭折;
  • 在优化的过程中,对于版本升级一定要慎重,在调研中,要充分考虑到兼容性,不仅要看官方更新日志,更要看官方仓库中查看issues。

谈谈浏览器缓存?

缓存可以减少网络 IO 消耗,提高访问速度。浏览器缓存是一种操作简单、效果显著的前端性能优化手段。

对于一个数据请求来说,可以分为发起网络请求、后端处理、浏览器响应三个步骤。浏览器缓存可以帮助我们在第一和第三步骤中优化性能。

浏览器缓存机制有四个方面,它们按照获取资源时请求的优先级依次排列如下:

  • Memory Cache:存在内存中的缓存,它是浏览器最先尝试去命中的一种缓存是响应速度最快的一种缓存;
  • Service Worker Cache:自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的;
  • HTTP Cache:最主要、最具有代表性的缓存策略,分为强缓存和协商缓存,通过设置 HTTP Header 来实现的。
  • Push Cache:HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(Session)中存在,一旦会话结束就被释放。

如何对图片优化?

雅虎军规和 Google 官方的最佳实践也都将图片优化列为前端性能优化必不可少的环节——图片优化的优先级可见一斑。

就图片这块来说,与其说我们是在做“优化”,不如说我们是在做“权衡”。因为我们要做的事情,就是去压缩图片的体积。

但这个优化操作,是以牺牲一部分成像质量为代价的。因此我们的主要任务,是尽可能地去寻求一个质量与性能之间的平衡点。

时下应用较为广泛的 Web 图片格式有 JPEG/JPG、PNG、WebP、Base64、SVG 等。

  • JPEG/JPG:有损压缩、体积小、加载快、不支持透明,适用于呈现色彩丰富的图片,经常作为大的背景图、轮播图或 Banner 图出现。
  • PNG-8 与 PNG-24:无损压缩、质量高、体积大、支持透明,用它来呈现小的 Logo、颜色简单且对比强烈的图片或背景等。
  • SVG:文本文件、体积小、不失真、兼容性好,可以像写代码一样定义 SVG,把它写在 HTML 里、成为 DOM 的一部分,也可以把对图形的描述写入以 .svg 为后缀的独立文件;
  • Base64:文本文件、依赖编码、小图标解决方案,作为雪碧图的补充而存在的,适用于图片的实际尺寸很小,无法以雪碧图的形式与其它小图结合,且图片的更新频率非常低;
  • WebP:年轻的全能型选手,存在兼容性问题,于 2010 年被提出, 是 Google 专为 Web 开发的一种旨在加快图片加载速度的图片格式,它支持有损压缩和无损压缩。

总结来看:

  • 能不用图片就尽量不用,比如适用字体图标等;
  • 选择正确的图片格式;
  • 小图使用 base64 格式;
  • 将多个图标文件整合到一张图片中(雪碧图;

事件的节流(throttle)与防抖(debounce)

频繁触发回调导致的大量计算会引发页面的抖动甚至卡顿。为了规避这种情况,我们需要一些手段来控制事件被触发的频率。就是在这样的背景下,throttle(事件节流)和 debounce(事件防抖)出现了。

滚动事件中会发起网络请求,但是我们并不希望用户在滚动过程中一直发起请求,而是隔一段时间发起一次,对于这种情况我们就可以使用节流。

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的回调
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)

document.addEventListener('scroll', better_scroll)

有一个按钮点击会触发网络请求,但是我们并不希望每次点击都发起网络请求,而是当用户点击按钮一段时间后没有再次点击的情况才去发起网络请求,对于这种情况我们就可以使用防抖。

防抖的中心思想在于:我会等你到底。在某段时间内,不管你触发了多少次回调,我都只认最后一次。

// 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的回调
const better_scroll = debounce(() => console.log('触发了滚动事件'), 1000)

document.addEventListener('scroll', better_scroll)

什么是预加载?

有些资源不需要马上用到,但是希望尽早获取,这时候就可以使用预加载。

预加载其实是声明式的 fetch ,强制浏览器请求资源,并且不会阻塞 onload 事件,可以使用以下代码开启预加载。

<link rel="preload" href="http://example.com">

预加载可以一定程度上降低首屏的加载时间,因为可以将一些不影响首屏但重要的文件延后加载,唯一缺点就是兼容性不好。

什么是懒加载?

懒加载就是将不关键的资源延后加载。

懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。

对于图片来说,先设置图片标签的 src 属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src 属性,这样图片就会去下载资源,实现了图片懒加载。

懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等等。

在懒加载的实现中,有两个关键的数值:一个是当前可视区域的高度,另一个是元素距离可视区域顶部的高度。

手动实现:

<script>
    // 获取所有的图片标签
    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);
</script>

为何需要服务端渲染?

服务端渲染是一个相对的概念,它的对立面是“客户端渲染”,服务端渲染的实践往往与当下流行的前端技术(譬如 Vue,React,Redux 等)紧密结合。

客户端渲染模式下,服务端会把渲染需要的静态文件发送给客户端,客户端加载过来之后,自己在浏览器里跑一遍 JS,根据 JS 的运行结果,生成相应的 DOM。

服务端渲染的模式下,当用户第一次请求页面时,由服务器把需要的组件或页面渲染成 HTML 字符串,然后把它返回给客户端。客户端拿到手的,是可以直接渲染然后呈现给用户的 HTML 内容,不需要为了生成 DOM 内容自己再去跑一遍 JS 代码。

服务端渲染主要用于解决单页应用首屏渲染慢以及SEO问题,但同时也提高了服务器压力,吃CPU、内存等资源。

服务端渲染主要干了两件事:

  • 一是 renderToString() 方法;
  • 二是把转化结果“塞”进模板里。

CDN 的缓存与回源机制?

CDN (Content Delivery Network,即内容分发网络)指的是一组分布在各个地区的服务器,原理是尽可能的在各个地方分布机房缓存数据,这些服务器存储着数据的副本,因此服务器可以根据哪些服务器与用户距离最近,来满足数据的请求。

CDN能大大提升首次请求的响应能力,核心点有两个,一个是缓存,一个是回源。

“缓存”就是说我们把资源 copy 一份到 CDN 服务器上这个过程,“回源”就是说 CDN 发现自己没有这个资源(一般是缓存的数据过期了),转头向根服务器(或者它的上层服务器)去要这个资源的过程。

CDN 往往被用来存放静态资源, 静态资源本身具有访问频率高、承接流量大的特点,因此静态资源加载速度始终是前端性能的一个非常关键的指标。

CDN 是静态资源提速的重要手段,在许多一线的互联网公司,“静态资源走 CDN”并不是一个建议,而是一个规定。

小结

现在已经进入了互联网行业的后期了,各个方向的头部公司都已经站稳脚跟了,所以接下来通过性能优化提升用户体验就成了重中之重,因此面试需要着重准备。