从经典面试题“用户输入一个URL到页面呈现在用户面前”的全流程来讲一讲我对性能优化的一些理解

784 阅读18分钟

作为一个从业了多年的前端工程师,我最近开始了新一轮的面试,在一次面试过程中,被问到了我在性能优化时的思路是怎样的,当时回答的不好,虽然工作中做过很多对应的工作,有专门去做过性能相关的优化专项,也开发过接入了数百个系统的性能、行为分析探针。但是没有好好地沉淀出一套方法论,很多东西,做过,思考过,但是没有整理出来,接着这次准备面试,我把自己过去的工作与学习中的一些想法给整理一下,也给其他的前端开发同学们参考一下。

思路

性能优化,其实不是一件简单的事情,设及的内容很广,是一个系统化的工程。

性能优化,要做的事情是什么?一个页面的生命周期,其实是开始的加载阶段,运行时的交互响应阶段,以及页面的关闭阶段。

我们的重点其实是集中在前面两个阶段。

加载时,让用户更快地看到页面呈现的效果,运行时,更快地响应用户操作,页面更新时不要出现卡顿。

但是在具体去做这件事情的时候,我们到底应该怎么去做呢?

还是从前面提到的两个阶段来说,首先是加载阶段。

从一个经典面试题入手,看看这个过程中,有哪些优化性能的手段

从用户输入一个URL到页面呈现在用户面前,这个过程中发生了什么?这是一个经典的面试题,而我们的性能优化思路,也可以跟着这个面试题往下走。整个过程其实可以看做两个流程,分别是从输入页面url到页面渲染之前的导航流程,然后是页面的渲染流程。

首先是页面的导航流程

image.png

  • 首先,用户输入字符到浏览器的搜索框,触发搜索之后,浏览器进程会先对输入的数据进行一个判断,当前是一个关键字的搜索还是一个url路径的提交?如果是一个关键字的搜索,那么浏览器会使用默认搜索引擎的url拼接规则,将关键字拼接为搜索引擎的搜索url。如果当前判断输入的字符的是一个url路径,那么就会给它加上协议,拼接出一个请求url。接着浏览器会将该url路径提交给网络进程,由网络进程去发起真正的网络请求
    • 当然,在发送到网络请求之前,当前的页面如果注册了unload事件,还会给当前页面提供一个执行方法的机会,决定是不是继续进行接下来的动作。
  • 接下来是网络进程开始根据url去向服务端发起真正的网络请求了,在这个过程中,网络进程会优先去浏览器缓存中查找,当前请求是否在缓存中找到,如果找到了,那么将会直接将缓存中的资源返回给浏览器进程,这样,请求流程到此就结束了。如果未命中缓存,那么网络进程将开始DNS解析,获取到url的ip地址,准备请求行,请求头。并将cookie放入请求头中,如果当前请求的资源命中了协商缓存的策略,协商缓存的头,if-match、last-modify-since等也将放入请求头中,然后向服务端发起请求。
    • 针对这个过程的优化手段
      • 首先可以加上缓存策略,强缓存与协商缓存策略等。
      • 然后是prefetch,dns可以做预解析。
      • 其次,将cookie精简,毕竟每次http请求都会将该域下的cookie携带上。影响传输效率
  • 服务端接收到网络进程发送的请求后,解析对应的请求头信息,如果命中了协商缓存策略,将会返回304状态码,告诉浏览器,请使用协商缓存的资源。当然,服务器也可以返回301或者302,加上返回头中的location告诉浏览器需要重定向到其他页面。
    • 针对这个过程的优化手段
      • 尽量避免发生重定向,因为重定向后,浏览器会重复上面的过程。
  • 网络进程接到服务端发送会的数据后,将会解析返回行和返回头(为了行为效率,后面直接说返回头),如果是301或者302,那么将触发重定向,如果是304,将会命中协商缓存资源,将缓存资源返回给浏览器进程。否则的话,将会根据content-type的值来做接下来的处理,当值为application/octet-stream时会将当前请求当做下载任务移交给浏览器的文件下载器,导航流程到此结束。如果当前值为text/html时,那么将会继续导航流程。接下来就是准备渲染进程了。
    • 优化手段:
      • 静态资源开启gzip压缩,减少数据传输的时间。
      • 减少关键资源数量(同步运行的js文件、css文件、html文件)。
      • 对不会操作dom和cssom树的js资源尽量加上defer或者async(这样就不是关键资源了)
      • 使用cdn,减少RTT时间。
      • 图片的优化,使用webp代替jpg、gif、jpeg这些图片。
      • 使用雪碧图减少tcp链接数。
      • 使用webpack插件自动生成雪碧图。
      • 使用intesctionObserver实现图片的懒加载。
      • 使用icon-font替代小图标。
      • 静态资源放在不同的cdn上,规避一个域下6个tcp连接的规则。
      • 使用http2,多路复用,直接不需要雪碧图和不用cdn规避6个tcp限制了。服务端推送需要优先用到的静态资源等。
      • 开发阶段使用esmodule规范,开启tree-shaking,使用的各种第三方库,如lodash等也改用lodash-es这种,实现按需加载。
      • code-split,首页不需要使用的资源另外打包。
  • 准备渲染进程,在这里要提一下,默认情况下,在chrome中,每个tab会是一个进程,但是如果满足以下两个条件,那么新打开的页面会使用当前父页面的渲染进程进行渲染。这两个条件是:
    • 在当前页面打开的新页面
    • 新打开的页面和当前页面属于同一个站点(根域名相同、协议相同)
    • tips:在页面插入的iframe也和新打开的页面一样,它的渲染进程规则也支持同站点规则。
    • 优化手段:
      • 当当前页面跳转的是某些功能场景复杂,容易出现异常的页面时,可以考虑在跳转连接上加上rel=“noopener”,这会让新打开的页面,使用新的渲染进程,防止某一个页面崩溃了,影响到其他页面的场景。阿里云的网站有使用这个技术,不知道是不是也是基于这个原因加上的。
  • 接下来是提交文档阶段,渲染进程虽然已经准备好了接收和解析html文件了,但是具体的数据还在网络进程中,这时候,浏览器进程向渲染进程发送“提交文档”消息,渲染进程与网络进程建立传输数据的管道,当文档数据传输完毕之后,渲染进程向浏览器进程发送“确认提交”信息,浏览器进程接收到消息后,开始更新浏览器界面状态,包括安全状态、地址栏的url、前进后退的历史等,并开始更新页面。到这里,一个完整的导航流程就结束了。接下来是渲染阶段了
  • 一旦文档被提交,渲染进程便开始页面的解析和子资源的加载了。渲染过程我在后面继续,这里先提一下,当渲染流程结束后,渲染进程会发送一个消息给到浏览器进程,然后浏览器进程接收到消息后,会停止标签图标上的加载动画。

页面的渲染流程

接下来就是页面的渲染流程了

image.png

  • dom树的构建,浏览器其实无法直接解析html文件,这时候,渲染引擎会将html文件序列化为浏览器能够识别的内容,也就是将html文件解析生成dom树,在dom树的构建过程中,如果遇到了同步的js代码,那么渲染引擎将会停止dom树的构建,去解析js文件并运行,运行完后,再继续dom树的解析。如果遇到了css文件,虽然css文件不会阻塞dom树的解析,但是如果js文件有对样式做了改变,那么js的运行会需要等css文件下载完之后才运行。
    • 优化手段:
      • css文件放到head内,js文件放到body前面。这样css文件会提前加载,避免阻塞渲染
      • css、js文件内容压缩,移除注释
      • 没有操作css和dom的js文件加上defer或者async,防止阻塞dom树的构建。
      • 这里还有一些流程没有提到,比如说v8的jit优化,这个后续会说,这里可以先提一下,我们可以使用ts,做好类型定义,这样v8在运行js代码时,会做一部分优化,对于热点代码,会将字节码转化成二进制码,提高运行时的效率。
      • 减少dom数量,使用虚拟列表等。
      • 使用canvas,webgl或者视频做特殊动画。
      • 减少表格的使用。
  • 样式计算,dom树构建之后是样式计算阶段,样式计算分为了3个步骤。
    1. 解析css文件、style标签内的样式、行内样式生成styleSheets
    2. 标准化css的值,比如将font-weight:bold转化为font-weight:700,将em转换为对应的px。
    3. 根据css的继承规则和层叠规则,计算每个dom节点具体的样式。具体的计算结果会保存在computedStyle结构内。
    • 优化手段:
      • 由于css样式匹配,从右往左匹配,所以尽量在写样式时,尽量层级不要太深(em,实际测试下来,感觉浏览器其实已经优化的很好了,这块不做也没啥关系)
  • 布局阶段,现在我们有了dom树和computedStyle了,然后浏览器根据dom树和computedStyle开始计算布局树,首先渲染引擎会将dom树进行遍历,对于显示的dom会添加到layout树上,并计算出对应节点的几何信息。对于像是head内的哪些标签、js标签、display:none的dom都不会添加到layout树上,但是对于visibility:hidden的节点,还是会放到layout树上。而我们经常提到的重排其实就是在这一阶段和下一阶段分层阶段进行的。
    • 优化手段:
      • 减少页面的重排,使用css3的transofrom:translate去代替元素设置的top,left动画。
      • 函数的节流和防抖。
      • 在修改了dom之后,不要立即去查询dom的几何属性,防止出现强制布局的场景,这里提一下强制布局,现代浏览器对于渲染其实已经做了很多的优化了,正常流程中,js修改dom,浏览器一般会将这些dom操作放在一个task中去做,等所有的dom操作都处理完之后,再在下一个task里面去做样式计算和布局操作,但是如果我们的代码中在操作了dom之后,立刻进行了查询dom几何属性的操作,比如获取offsetHeight之类的,那么渲染引擎必须触发一次样式计算和布局计算,计算出当前dom的几何信息。这就是强制布局,会导致重排的发生。
      • 避免布局抖动:布局抖动其实就是复数个强制布局的场景,一般常见在循环中,在循环中修改dom,然后获取dom的几何信息,导致在一个task中多次的重排,这会造成性能的极大浪费.
      • react中,防止重复渲染,做好缓存。react.memo或者pureComponent等。
  • 分层阶段,有了布局树,并不是说就直接开始做渲染了,因为页面中有很多的复杂的效果,如复杂的3D变换,页面滚动,或者使用z-index的z轴排序等,为了更方便的实现这些效果,渲染引擎还会将特定的节点生成专用的图层,并生成一棵对应的图层树(这个layer树大家可以打开控制台来看到)。那么如何生成对应的图层呢?渲染引擎对于层的生成,遵循了两条规则。
    • 层的生成规则:
      1. 拥有层叠上下文属性的元素会被单独提升为一层。
      2. 需要剪裁(clip)的地方也会被创建为图层。
    • 优化手段:
      • 对于一些频繁要修改dom的部分,可以将其父级提升为一层,这样重排的时候,计算量会减少。
      • 对于知道的,将会发生样式变化的场景,可以设置css属性will-change。
      • 虽然分层有这些优势,但是他有一个问题,就是会占用更多的内存。所以在使用这个技巧时也要注意对内存的占用。
      • 同时还需要注意一个,层爆炸的场景,比如在一个单独层的子元素,比他index大的层级,会被强制提升为一个单独的层,这个问题在最新的chrome已经修复,但是在某些浏览器中可能会出现,比如我在项目中有遇到过这样一个场景,我们要在一个高斯模糊的背景图上面,播放弹幕,高斯模糊我是使用css滤镜来写的,弹幕的话,设置了position:absolute,以及z-index。在我的开发机上,浏览器上都运行的很好,但是在ui的安卓手机上,十分的卡顿。这就是隐式提升层的一个场景。
  • 绘制阶段,在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。渲染引擎实现涂层的绘制和人绘图很像,都是一层一层绘制上去的,渲染引擎会将一个图层的绘制拆分成很多小的绘制指令,然后在把这些指令按照顺序组成一个绘制列表。而我们经常提到的重绘其实就是在绘制阶段发生的。一些像是背景颜色,border-radius这些不改变dom几何属性只改变绘制属性的css,就会跳过之前的阶段,直接在这个阶段进行。
    • 优化手段:
      • 尽量减少重排,虽然重绘其实性能消耗也很大,但毕竟比重排还是好多了。
  • 合成线程,从构建dom树到绘制阶段,其实这些阶段都是在主线程进行的,而之后的几个阶段,分块、光栅化、合成,这几个阶段其实是在另一个线程,合成线程中去实现的。
  • 分块阶段,说到分块,那么就必须先讲一下什么是视口,屏幕上可见区域就叫视口通常一个页面会很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个不分叫做视口。合成线程会将图层划分为图块,这些图块一般是256*256或者512*512大小,合成线程会优先视口附近的图块来生成位图。
  • 栅格化,所谓栅格化就是指将图块转换为位图。而图块就是栅格化的最小单位。渲染进程维护了一个栅格化的线程池,所有的栅格化都是在线程池内执行的,通常,栅格化过程都会使用GPU来加速生成,通过GPU来加速栅格化的过程,一般叫做快速栅格化。而GPU操作是运行在GPU进程中的,如果栅格化操作使用了GPU,那么最终生成位图的操作就是在GPU中完成的,这就涉及到了跨进程操作。是不是听起来很熟悉?大家听到过的硬件加速,GPU加速,就是在这个过程中进行的。
    • 优化手段:
      • 使用translateZ开启硬件加速
      • 使用transfrom:translate来做动画效果
      • 使用keyframe来做动画
      • 因为这些过程都不在主线程中进行,不会阻塞页面的渲染,就算主线程因为js运行卡住了,也不影响动画的播放。
  • 合成和显示:一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令"DrawQuad"命令,然后根据“DrawQuad”命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
  • 浏览器进程里面有一个叫做viz的组件,用来接收合成线程发过来的命令,然后根据命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

到这里,经过一系列的阶段,编写好的HTML、CSS、JS等文件,经过浏览器就会显示出漂亮的页面了。

这就够了吗?

这样就够了吗?性能优化的手段就讲完了吗?其实不然,这里讲的还少了很多东西。

我们知道了这些东西会影响性能,但是开发过程中就能完全避免出现性能问题吗?其实不然,知道了,但是不一定可以完全的去做到。

  • 这需要搭配其他的一些工具帮助我们一起优化我们的项目。比如接入性能探针,对页面的性能进行性能数据监控。对性能存在问题的地方进行修复。引入sonar、eslint,对代码做检测,减少代码坏味道。强制修复代码坏味道后才允许合并上线。
  • 引入插件,对于构建的生产环境的代码,移除console.log(打印出的对象不会被垃圾回收,造成内存泄漏)。
  • 项目中使用了定时器,需要记得取消,防止定时任务堆积导致的内存泄漏。
  • 现代前端框架其实帮我们做了很多事情,但是有时候我们使用了原生的事件委托,需要记得手动移除,否则注册的事件可能会伴随到页面关闭之前都一直存在。
    • dom已经被移除了,但是事件委托没有被取消,导致注册的事件一直存在等。
  • 页面白屏的检测,告警。
  • 现代前端框架对于异常的捕获,需要捕获处理,比如react的errorBoundary。防止因为未捕获的异常导致页面白屏。
  • 接入sentry或者自研的异常监控sdk,做好异常报警,快速修复线上bug。
  • 还有其他很多特定的现代前端框架的优化等。

关于性能优化的指标RAIL

在这里其实有一个指导的思想,之前讲的都是术的方面。二这一块其实就是道的层面,我们做性能优化,还是要向着这一块努力去做。性能优化的指标RAIL

  • R是Response,更快地响应用户的操作,尽量让用户操作在100ms内得到响应。
  • A是Animation,页面的动画效果要尽可能的连贯,不出现卡顿。也就是尽量的保持页面的60帧渲染。
  • I是Idle,最大限度的增加系统的空闲时间,以提高页面在50ms内对用户的行为作出响应的可能。
  • L是Load,表示了页面资源加载的速度,期望当然是越快越好,比较理想的是,在3g网络的情况下,页面的首次加载在5s以内,再次加载在2s以内。

结语

性能优化其实很多时候挺有用的,需要大家在日常开发中多加注意,才能带来更好的用户体用。

都看到这里了,要求给点个赞不过分吧?

image.png

参考资料

太多了,我后面再补吧,已经是2023.10.17的02:55了。

《用户输入一个URL到页面呈现在用户面前》

《图解Google V8》

brbr