web前端性能优化(1)

2,736 阅读1小时+

为了方便描述,本文会将“web前端性能优化”简称为“性能优化”。

在阅读英文文章时,偶尔会发现有小标题显示 TL;DR 或者 tl;dr。网上英文解释有两种,一种是Too long;Don't read,另一种是Too long;Didn't read。意思是:“文章太长了,读不下去了”。常用在英文长文中的摘要标题,显示整篇文章的精华或总结。

什么是性能优化?

显然,性能是相对功能而言的,也就是在实现功能的基础上进一步考量这个实现的优劣。web前端应用的功能是提供一个界面供用户来浏览和交互,故web前端性能优化就是指如何使得用户更快地看到页面和更快地与之交互的话题。当然,业内有些观点把性能跟用户体验混为一谈,这是见仁见智的事了。但是,无论如何,【更快地看到】和【更快地可交互】显然是性能优化的两大主体。

从技术的角度来说,性能优化是围绕性能指标来做的。所以,要想对性能优化有一个立体而全面的理解,那就是必须对性能指标有了解。下面的小节会介绍到性能指标相关的东西。

为什么要做性能优化?

我相信,99%的人做任何事情都是有动机的。那我我们为什么要做性能优化呢?答案是:“因为有好处”。具体地来说,有以下好处:

  • 能提高用户留存率
  • 能提到转化率
  • 能提升用户体验
  • 能够帮助到用户

1. 能提高用户留存率

在互联网行业中,用户在某段时间内开始使用应用,经过一段时间后,仍然继续使用该应用的用户,被认作是留存用户。

这部分用户占当时新增用户的比例即是留存率,会按照每隔1个单位时间(例日、周、月)来进行统计。顾名思义,留存指的就是“有多少用户留下来了”。留存用户和留存率体现了应用的质量和保留用户的能力。

我们构建网站的目的就是希望能留住用户,使得他们能够与我们构建的内容进行互动。比如说,如果我们构建的是博客,那么我们希望用户阅读博文;如果是电商网站的话,我们希望用户在上面搜索商品,最后购买商品;如果是社交网站的话,我们希望用户能在上面发生一些社交行为。

Google在开发者文档《为什么性能优化如此重要》中给出了两个正面影响的案例:

于此同时,它也出给了两个负面影响的案例:

我们可以通过speed scorecard这个工具来了解自己网站与竞争对手的性能对比(应该只是针对美国网站有效)。

当然,如果想继续了解更多性能是如何影响用户体验和商业行为的案例,可以到WPO Status上面看看。

2. 能提到转化率

不用强调的是,只有留住了用户,才能将用户的行为转化为相应的商业收入。反之,未必如此。也就是说,留住用户是产生商业收入的必要不充分条件。同样地,在上面提到的那篇文档里面,Google也给出了三个性能是如何影响转化率,进而影响商业收入的案例:

值得一提的是,缓慢的加载不利于搜索引擎优化(SEO),因此,Google在2018年将网站速度作为其移动搜索中排名的依据。作为结果,缓慢的加载速度会降低您网站的排名,从而减少访问次数,阅读次数和转化率。

如果你想了解性能是如何影响你的商业收入的话,你可以到Impact Calculator 上面玩玩。

3. 能提升用户体验

时间就是生命,每个人都希望花最少的时间获取最多的东西,这是人性使然。人们在浏览网页的时候也是如此。我们希望页面加载得越快越好,浏览体验越完美越好(更流畅,更沉浸,更贴心)。随着社会变得越来越浮躁,人们对于等待的耐心度也在下降。根据BBC研究表明,当用户等待时间超过3秒,有53%的用户会立即转向别的网站。而性能无疑是创造良好用户体验的基石。同时,良好的用户体验也有助于产生更多的“回头客”,增加网站的浏览量和用户活跃度。

4. 能够帮助到用户

随着时间的推移,人们越来越依赖各种各种的互联网服务(线上订餐,线上出行,线上娱乐,线上支付等)。与此同时,越来越多的web页面的大小也在稳步增加。假如我们能够做到在服务质量不变的前提下,提高我们的性能,这无疑是帮助用户在节省流量费。

还要一种情况是,假如用户是通过网页来请求相应的传统服务的话(紧急求助,医院就诊等),如果我们能以尽快的速度呈现页面,提供相应的服务的话,那无疑是救用户与水火中。

性能优化的目标

简而言之,前端性能优化的目标就两点:

  • 更快地看见页面内容
  • 更快地与页面交互

了解性能指标

何为“更快”?假如是主观判断的话,这肯定是仁者见仁,智者见智的事情。比如:

  • 同一个网站在不同的网络环境(快速/慢速)和不同硬件条件(高端/低端手机)上会有不同的表现。一个用户在高速网络用高端手机访问的话,可能他会觉得页面“很快”。而另外一个用户在低速网络上用低端手机访问,可能他会觉得页面“很慢”。那这个网站到底是快还是慢呢?
  • 两个网站使用相同的时间完成了页面的完全显示。但是A网站使用渐进式的显示方式,B网站则等到页面所需要的资源都准备好再一口气把页面渲染出来。如果拿完全显示时间来衡量的话,两个网站一样快,是吧?但是,实际上大部分用户会觉得A网站比较“快”。
  • 一个网站可能很快加载并显示完了,但是用户却迟迟不能跟它进行交互(也即是所谓的页面点不动),那这个网站到底是“快”还是“慢”呢?

所以,我们必须以一种大家公认的客观标准来量化性能。这就是性能指标(performance metric)的由来。

相比页面显示相关的性能指标而言,网络性能指标已经存在差不多十年了。前端性能指标可以划分为两个方面:基于页面构成的和基于加载里程碑的。在W3C性能工作组( W3C Web Performance Working Group )积极介入以前,这两个方面的发展是十分之不均衡的。基于页面构成的性能指标都已经很完善了。这里面包括有HTTP请求数,样式表的大小,DOM结构层级的深度等。而基于加载时间里程碑方面的性能指标,我们只有页面资源加载完毕的时间(通过监听window.onload事件来获得)。但是,这里有一个很棘手的问题的,事实上,页面资源加载完毕所用的时间长度并不能准确地反应页面加载性能的好坏。因为某些情况下,这个性能指标是跟真实的用户体验相差甚远的。举个例子,一个静态资源很少的页面很快就加载完了,于是load事件也触发了。但是,此时通过AJax来获取并填充到页面上的大部分内容并没有加载完,页面还是处于不可交互的状态中。从页面静态资源加载时间来看,页面确实是很“快”了,但是实际上用户并没有感觉到真正的快。业界同行早已经在2013年的时候提出了“Moving beyond window.onload()”这样的口号了,详见这篇文章

基于以上事实,Chrome团队跟W3C性能工作组一起合作,推出了很多在页面加载里程碑方面的性能指标和相关的API。

他们基于一个以用户为中心的方针来制定了这方面的性能指标。这个方针大概可以用以下几个个问题来描述:

问题具体含义
有东西发生了没?页面跳转是否正常启动,服务器有东西返回了没?
页面是否可用?屏幕上是否显示足够多的内容没?
页面是否交互?用户可以跟页面的这部分内容交互没?还是说页面处于“忙碌”状态
交互是否流畅交互是否流程自然,没有卡顿和滞后吧?

Time To First Byte(TTFB)

从页面开始加载到用户浏览器接收到字节流中的第一个字节所需要的时间。该指标主要用于衡量服务端的响应速度,次要衡量当时的网络环境。

First Paint(FP)

它是指从页面开始加载到页面开始绘制(paint)的这个时间段(绘制出来的东西不一定是有意义的)。这个绘制不包括运行在后台的那些绘制工作。

First contentful paint(FCP)

它是指从页面开始加载到页面的一部分内容显示出来(从技术上讲,是开始绘制的时候(paint))的这个时间段。这里的“一部分内容”指的是任何的文本,图片(包括背景图片),非空白的canvas和SVG等(绘制出来的东西是有意义的)。这是一个标志着用户可以开始使用页面的时间点。

通过浏览器提供的performance.getEntriesByType('paint')方法,我们可以获取FCP的值:

 <script>
        function showPaintTimings() {
            if (window.performance) {
                let performance = window.performance;
                let performanceEntries = performance.getEntriesByType('paint');
                performanceEntries.forEach( (performanceEntry, i, entries) => {
                console.log("The time to " + performanceEntry.name + " was " + performanceEntry.startTime + " milliseconds.");
                });
            } else {
                console.log('Performance timing is not supported.');
            }
        }
        // 注意showPaintTimings要在load事情发生之后才行
        window.onload = function log() {
            showPaintTimings()
        }
    </script>

打印结果是:

Largest contentful paint(LCP)

这个指标反应的是从页面开始加载到最大的文本区块或最大的图片元素显示出来所需要的时间。

First Meaningful Paint(FMP)

这个指标用于测量从页面开始加载到服务端返回任何数据并显示到界面的这段时间。FCP时间过长,可能有两个原因:

  • javascript的加载或者执行阻塞了主线程
  • 服务端响应出了问题

又因为在大概20%的情况下,这个指标是不够准确的。所以,它已经被废弃了。在Lighthouse的下一个版本中,这个指标已经不被支持了

First input delay (FID)

从你跟页面开始做交互(比如,点击了按钮,链接或者某些自定义的UI组件等)的时候算起,到浏览器开始对此作出响应所需要的时间。

Time to Interactive(TTI)

用于测量从页面开始加载到页面可见,初始化的脚本也已经加载完毕(不一定执行完),有能力稳定地响应用户的交互行为的这段时间长度。

Total blocking time(TBT)

用于测量FCP跟TTI之间的时间段,看看主线程处于阻塞,不可交互状态的时间到底有多长。

Cumulative layout shift(CLS)

用于测量从页面开始加载到页面生命周期状态(lifecycle state)发生变化这个时间段里面所发生的布局抖动严重程度。该指标用得分来表示,得分越高,表示布局抖动越严重。

Speed Index

速度指数,从视觉体验的角度来测量一个页面填充内容的“速度”。用分数来评估,分数越低越好。速度指数得分是根据视觉进度进行计算的,但这仅仅是一个计算值。 它还对视口大小敏感,因此您需要定义一系列与目标受众匹配的测试配置。

请注意,随着LCP作为新指标的加入,它变得越来越不重要了。

下面可以结合几张图来了解某些指标的具体含义:

其他非主流性能指标

除了这些主流的性能指标之外,某个性能测量和评估工具/平台还自己制定了一些性能指标。比如说,WebPageTes和SpeedCurve 。

WebPageTes作为渲染性能测量的先驱,它额外地提供了一些非主流的性能指标:

SpeedCurve提供了一个叫“Hero Rendering Times”。

还有所谓的挫折指数用于衡量连续发生的不同时间里程碑之间的时间差,用此来对标在体验和使用页面时候的挫折感。

自定义性能指标

有些时候,主流的性能指标是不能完全满足开发者的需求的。比如说,LCP这个指标测量的是你页面中最大区块元素显示所需要的时间。但是,在你自己的具体页面上,具体的业务场景下,最大区块的元素并不是你最关心的,最重要的内容。这个时候,我们就需要自定义性能指标了。比如,Twitter就有一个叫“Time To First Tweet”的自定义性能指标,Pinterest有一个叫“PinnerWaitTime”的自定义性能指标。

自定义一般是通过使用比较新的性能测量API来完成的。可用的API如下:

如何使用这些API来自定义性能指标,可以查看这篇文章。使用这些API来编写性能测试的代码后,我们可以放在WebPagetst平台上去运行并收集相关的性能指数值

性能优化指导模型

不是围绕具体的性能指标,而是围绕性能优化的几大版块来决策的指导方针。

RAIL

RAIL是一种以用户为视角,依据用户感知能力而确定的一种模型。这个模型将web应用的生命周期划分为四个区域:

  • R - Response。在100ms内响应用户的输入(这个输入不是单单指输入框的输入,而是指用户使用外部设备来跟界面交互所产生的输入)
  • A - Animation。在动画帧中,尽快以10ms生成一帧(这意味着开发者有10ms去执行自己的(js)代码),剩余6ms留给浏览器去做渲染。
  • I - Idle。最大化主线程的空闲时间。在主线程空闲时间里面,最多占用50ms。
  • L - Load。5秒内完成可交互的网页内容。

其实按照实际的发生过程,正确的顺序应该是L -> I -> A -> R。但是提出者为了让人便于记忆,调整为了一个单词“RAIL”。

PRPL

PRPL是另外一个优化模型。同样,它也是志在使得页面加载更快,以便更快地进入可交互状态(浏览器主线程空闲的时候,可交互状态最佳)。

  • P - Perload/Push。预加载一些重要的资源
  • R - Render。尽可能快地渲染第一屏。
  • P - Pre-cache。pre-cache剩下的静态资源。
  • L - Lazy load。对其他路由页面和不重要的静态资源采用按需加载的策略。

制定性能预算

什么是性能预算

性能预算(performance budget)是由不同性能指标的阈值所组成的一组数据。它主要用于性能优化过程中的参照,提醒和警告。一个好的性能预算甚至能够为设计,技术实现和功能添加提供很好的决策参考。比如说,有了性能预算摆在那里的话,那么UI设计师在交付图片的时候,就不能为了高清而高清了,我们要在确保图片基本质量的前提下来舍弃部分像素。假如,当UI设计师和前端开发争执不下的时候,性能预算表是很好的解决分歧的依据。

不同的技术团队会根据不同的性能指标做预算。故制定性能预算不能一概而论。一般而言,性能预算可以通过两种途径来确定:

  • 一. 整合业界推荐的预算方案,基于上面做取舍来确定。

比如,基于RAIL性能优化指导模型,我们可以得出以下的性能预算:

指标名时间值
FID< 100ms
动画刷新率60FPS + 10ms生成一帧
持续占用主线程时间< 50ms
FCP< 5000ms

又或者综合网络环境和物理设备情况的所推荐的预算方案:

  • 二. 通过亲身分析研究,再综合团队的性能优化文化,应用的业务类型和竞争对手的性能表现来确定。

RUM其实就是利用浏览器原生API在真实的用户使用环境下去收集页面的性能,即实时的性能监控

显然,在团队人力和物力皆有限的情况下,途径一肯定是明智之选。假如你的团队规模比较大,并且上层领导也愿意分配人力物力去做性能优化的话,那么我们就可以使用RUM(real user monitoring)来在真实的用户环境下去做性能数据的收集,并基于对收集回来的数据的性能分析结果制定性能预算。比如,移动4G网络上,当前首屏平均FCP为6s,那么我们根据【20%规则】把预算定为提升个20%,即5s;当前首页的TTI值过大,我觉得有提升的空间,同样先把目标定为提升10%等等。性能优化工作可以是渐进式的,包括制定性能预算也是一样的。比如,我们可以先针对最重要,流量最大的页面先做性能预算,然后才具体地去实施性能优化的措施;也可以同时制定短期和长期预算,然后分期去实现。

一个完整的预算制定案例可以参详这里

选择测试基准线

当今世界上,一半的互联网流量是发生在移动网络上。于此同时,我们也即将迈入5G时代。但是如果基准线设置得严苛点,我们就能在比较宽松的桌面电脑,4G/5G宽带或者WIFI上取得更加好的性能表现。所以,业内推荐的baseline为:在slow 3G的网络环境/中端移动手机,TTI在5秒以内,关键(渲染路径)资源压缩(gzipped)混淆后保持在170KB之内。

slow 3G用RTT TCP(round-trips time,网络请求往返时间)400ms,传输速度400kb/s来模拟。

选择恰当的性能指标

在众多的性能指标做选择之前,我们不妨依对它们进行分类。依据不同性能指标的测量的侧重点,我们可以将它分为三大类:

  • 数量相关的指标(quantity-based metrics)。比如页面总体静态资源的大小,关键渲染路径资源的大小和HTTP请求的数量等。

  • 时间里程碑相关的指标(milestone timings)。在页面显示过程中,广为人知的里程碑事件就是“DOMContentLoaded”和“load”事件了。除此之外,就是以用户为中心(user-centric performance metrics )的性能指标了。业内比较推荐的两个里程碑是First Contentful PaintTime to Interactive。一个用来衡量开始显示页面的时间,一个用来衡量页面进入稳定的可交互状态的快慢。

  • 基于最佳实践给出的评分指标(rule-based metrics)。Lighthouse和WebPageTest都会基于通用的最佳实践来给某次测评打分,一般是100分制。分数越高,则代表综合性能越好。

我们从上面的三个大类里面挑一个指标出来,就可以组成一个通用的性能指标选择方案

  • (首屏的)关键渲染资源压缩后的大小;
  • FCP;
  • TTI;
  • Lighthouse的综合评分。

然后在我们设定的这个测试基准线,这些指标我们制定的预算为:

  • 170KB
  • 3s
  • 5s
  • 85分

性能优化的理论基础/依据

页面访问的全链路

所谓的页面访问的全链路就是指老生常态的问题:“当用户在浏览器地址栏输入url,并回车的时候,到底发生什么?”。涉及到这个问题,业内有一篇技术文章对此进行了非常细致的介绍。过早地深入细节,会把人弄得晕头转向的。所以,我更趋向于概括性理解。下面,我总结出了页面访问所经历的八个环节:

  1. 用户在浏览器地址栏输入URL;
  2. 浏览器对URL中的域名进行DNS解析;
  3. 浏览器跟服务器建立TCP连接
  4. 浏览器向服务器发送获取HTML文件的HTTP请求;
  5. 服务器向浏览器返回HTML文件;
  6. 浏览器解析HTML文件,渲染页面;
  7. 当在渲染的过程中遇到其他资源(javascript,css,image等)的加载请求时,重复步骤4~6(只不过资源对象不是HTML文件而是相应类型的资源而已);
  8. 一旦页面完成初始渲染,浏览器就会接着处理其他的异步请求。

如果考虑https的话,那就得把SSL/TLS协商考虑进来的。

纵观整个链路,可以这么说:

浏览器显示一个页面所需要的时间 =  网络加载时间 + 页面渲染时间。

网络加载时间

当前话题是出于HTTP/1.1下面,暂不讨论HTTP/2

页面资源的网络加载时间包括了DNS解析,建立TCP连接和各种静态资源加载所需要的时间。

下面罗列的是影响网络加载时间的各个因素:

  • DNS解析
  • TCP连接
  • 资源的大小
  • 请求的数量
  • 服务器响应速度

DNS解析

在DNS解析方面,前端能够介入的手段仿佛也只有dns-prefetch指令(也有人称之为“resource hint”)。我们可以通过这个指令来告诉浏览器我们想从某个域名的服务器上获取资源,麻烦你提前帮我解析这个域名。语法如下:

<head>
    <link rel="dns-prefetch" href="https://example.com">
</head>

这样一来,我们就不会像以前那样,需要等到HTML解析器解析到带有src属性的标签的时候,才开始DNS解析,并接着走后面的流程。我们通过很低的成本就能够节省掉DNS解析时间(20到120ms左右)。关于dns-prefetch有四点值得说的的:

  1. 第一点是,dns-prefetch指令只会对【跨域的域名】起作用。为什么不会对本域起作用呢?那是先有HTML文件请求,再有dns-prefetch指令执行。而本域早就在请求HTML文件的时候解析过了,解析结果早就被浏览器缓存起来了。

  2. 第二点是,HTTP请求头-HTTP Link field能起到相同的作用。它的语法如下:

    Link: <https://fonts.gstatic.com/>; rel=dns-prefetch
    
  3. preconnectdns-prefetch搭配起来使用更好。虽然preconnect包括DNS解析,建立TCP连接,但是它的浏览器兼容性要比preconnect差。两者搭配使用的时候,即使preconnect失效了,但是dns-prefetch能继续生效,好歹也为我们节省点DNS解析的时间。语法:

    <link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin>
    <link rel="dns-prefetch" href="https://fonts.gstatic.com/">
    
  4. 这些指令之所以也称之为“resource hint”,这是因为它们都不是强制性指令。一个带有这些指令的标签只是表达你(开发者)想尽快与该域名的服务器建立TCP连接的意愿而已。是否会真的提前连接,这个决定权在于浏览器。浏览器有可能会执行它,也有可能忽略它,这些都是视具体情况而定。

TCP连接

我们想在TCP连接环节上节省时间,一般而言就有两件事可以做:

  • http之keep-alive
  • HTML中的preconnect指令
http之keep-alive

在早期的HTTP/1.0中,每次http请求之前都要通过三次握手重新创建一个TCP连接,随着网页HTTP请求数量的激增,这种做法所带来的网络资源开销(硬件资源和时间资源)也在大幅增加。为了解决这个问题,在HTTP/1.1中,协议规范就引入了keep-alive。当前浏览器和服务端普遍支持HTTP/1.1,并且默认是采用这种长连接,所以客户端不显式去声明这个请求头也是可以的。鉴于此,我们就不对此进行过多讨论了。语法如下:

Connection: Keep-alive
Keep-Alive: timeout=5, max=1000
preconnect指令

preconnect指令跟上面提到的dns-prefetch的初衷是一样的,都是想提前做点东西。preconnect指令跟dns-prefetch唯一不同的是,前者是后者的父集dns-prefetch只是对应于DNS解析环节,而preconnect却包含DNS解析和TCP的三次握手连接。

preconnect指令大概能为我们省掉100–500 ms的加载时间。

资源的大小

在带宽,网络时延(发送时延+传播时延),发送速率和传播速率一定的情况下,传输的静态资源越大,所需要的时间必定是越长。所以,我们得想法设法去缩减所要传送静态资源的大小。

请求的次数

在HTTP/1.1下面,请求次数越多,网络开销(等待被处理(queueing),DNS查找/解析,TCP的三次握手)就越大。尤其是在每一次传输的数据量很小,但是发送了多次请求的情况下,网络传输性价比最低(传输的数据量/传输的次数)。所以,基于当前浏览和服务器还是在使用HTTP/1.1的事实上,我们需要适当地降低HTTP的请求数量。

服务器响应时间

  1. 浏览器发起请求
  2. 网络传输请求数据给服务器
  3. 服务器响应,并返回数据
  4. 传输响应数据给浏览器

这是一次网络往返所要经历的几个环节。可以看出,服务器的响应速度还是会影响到某一次网络传输所需要的总时间的。假如,服务器响应速度优化能在前端开发者的管理范围的话,我们也可以着手去优化一下。

页面渲染时间

经过一次或者多次网络往返,浏览器终于首先拿到服务器响应的HTML文件了。对于web(前端)应用而言,HTML文件就是整个应用的“main函数”。而页面渲染时间就是指浏览器从拿到字节流第一个字节(也就是对应于TTFB)开始到整个页面被完整地显示出来所需要的时间。从技术上讲,这就是讲字节流转化为屏幕像素点的过程,这个过程也称之为关键渲染路径(critical rendering path)。整个过程可以划分为六个阶段:

  1. 构建DOM树
  2. 构建CSSOM树
  3. 构建render树
  4. 布局(layout/reflow)
  5. 绘制(paint/rasterizing)
  6. 图层合成(composite layers)

只有理解了浏览器中页面渲染的原理,才能更好地编排我们的页面代码,使得它能够满足即定的性能指标和渲染表现。比如,我们在熟悉渲染原理之后,将页面优化为一个拥有渐进式加载体验的页面:

相比于一开始白屏,过了1.5s之后,一次性显示整个页面,渐进式加载体验更佳。

编排,即通过调整页面中相关代码的位置前后,资源加载的方式(外部,内部,inline)和加载顺序/优先级指令来达到我们所想要的运行时加载顺序。

1. 构建DOM树

众所周知,DOM就是“document object model”。解析HTML的过程就是构建DOM树的过程。构建DOM树的过程可以划分为以下几个步骤:

  1. 编码。浏览器从网络或者硬盘上读到的数据是字节流格式的。浏览器需要根据一定的编码表(例如:UTF-8)来将字节流转化为字符流/字符串。

  2. 词法分析。词法分析就是一个根据 W3C HTML5 standard规范去解析字符串的过程。通过词法分析,我们能够在HTML解析的语境下抽取出相关的“标识符”。每个“标识符”都有其特殊的含义和自己的规则集。

  3. 语法分析。在这一步中,就是根据每个“标识符”所具有的特殊的含义和自己的规则集,将它们转化为对应的“对象”形式。“对象”都有自己的属性和方法。

  4. 节点关联。因为我们的HTML文档是根据层级关系(父子关系,兄弟关系)来组织起来的,所以我们会在语法分析的时候把这种层级关系记录下来。然后在这一步的时候就利用这些关系把所有刚创建的节点关联起来,形成一颗DOM树。

举个例子,如果我们以下的HTML文档:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

那么具象化的DOM树构建过程大概就是这样的:

完整的DOM树一旦构建完毕,那么DOMContentLoaded事件就会触发。

2. 构建CSSOM树

解析css的过程就是构建CSSOM树的过程。这个过程跟解析HTML的过程几乎是一致的:

不同的是,1)CSSOM树的节点的数据结构跟DOM节点的数据结构是不一样的。在浏览器内核比如webkit里面,CSSOM节点名称叫做“StyleSheetContents对象”;2)如果遭遇的是内部样式的话,那么从字节流到字符串的过程就省掉了,而HTML的解析总是从字节流开始。

比如说,我们有以下的样式表:

body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }

那么最终得到的CSSOM树是这样的:

因为受制于css的级联/层叠特性(后来者居上特性),浏览器必须等到所有的样式都被解析完成后,才能进入下一个构建render树阶段。因此,CSS被认为是“render blocking resource(渲染阻塞型资源)”。值得一提的是,只有应用到当前物理设备的样式才会被认定为渲染阻塞型资源。比如,我们一个标签:<link rel="stylesheet" href="xxx.css" media="print"> ,但是我们当前并没有打印这个网页,那么这个资源就不会在页面初始加载的时候被认为是render blocking resource。

同时,CSS也被认为是“script blocking resource”。那是因为在浏览器的实现里面,javascript的执行得等到CSSOM树构建完成才行。

3. 构建render树

值得一提的是,DOM树和CSSOM树是两个独立的数据结构。而构建render树过程就是将这两棵树合并起来,形成一颗render(Object)树。

构建render树的过程大概如下:

  1. 从DOM树的根节点开始遍历,只遍历那些会在页面产生视觉内容(visible)的节点。如果该节点可见,则将它映射到render树上。这句话包含有两个细节:1)不会在页面产生视觉内容的节点,比如说<head>,<meta>,<link><script>等等标签是不会遍历的。2)假如遍历到某个节点,比如说某个div#test(id值为test),但是在查找CSSOM的时候,结果发现这个元素所匹配到的样式有“display:none”这么一条样式规则的话,那么render树不会把这个节点映射到自己的树上。总结来说,render树只关那些能被用户看的见的内容。

  2. 在遍历过程中,对于每一个visible的DOM节点,浏览器都会去CSSOM树上查找一番,看看有那些样式组能匹配到这个DOM节点。在CSSOM树找到所有命中这个DOM节点的样式组(rule set)的过程就叫做“样式匹配(style matching)”。匹配完之后,根据层叠/级联(inherit cascading)规则,计算出最后会应用到这个DOM节点上的样式规则有哪些,样式规则的值是什么。这个过程叫做“样式计算(style computing)”。最后把计算得到的样式规则关联到已经挂载在render树上的相应节点(renderObject)上,这个过程叫做“样式应用”。样式应用完毕,一颗生鲜热辣的render树就呈现在内存当中,等待下一步的处理。

假如我们将上面给出的DOM树和CSSOM树合并起来,生成的render树是这样的:

也许你会奇怪,为什么到目前为止,我们并没有提及Javascript。那么现在就来说说说它。javascrpt的生命周期会经历三个阶段:加载,解析和执行。而浏览器通过DOM和CSSOM来分别为javascript提供了访问页面结构和样式的能力以及方式。也就是说javascrpt的执行是存在操作DOM树和CSSOM树的可能性的。CSS的层叠/级联特性决定了它必须阻塞浏览器的渲染进程,即使javascript的执行也会被阻塞掉。同理,如果某个javascript代码文件的执行存在操作DOM树的可能性,浏览器必须得等到这类型javascript代码执行完毕,才会继续去构建DOM(试想一下,如果不等待javascript的执行,继续构建DOM树的话,而后javascript执行了又去操作了DOM,那么之前的工作成果不就白白浪费掉了吗?)。所以,javascript又被称为“parser blocking resource”。意思就是它是一种阻塞HTML解析的静态资源。javascript代码量越大,解析和执行所需要的时间就越长,阻塞HTML解析的时间就越长,从而导致整个页面的渲染时间就越长。其实,javascript不但阻塞DOM树的构建,而且阻塞CSSOM树的构建。因为javascript的执行和CSSOM的构建都需要占用主线程。它们之间是互斥的。

总体来说,构建DOM树,构建CSSOM树和(同步的)javascript执行三者的优先级是这样的:

构建CSSOM树 > javascript执行 > 构建DOM树

javascript在DOM,CSSOM和它自己的执行之间引入了许多新的依赖关系。浏览器会花很多的时间在处理这种依赖关系上,从而导致页面的初始显示出现严重的延迟。

可以这么说,“优化关键渲染路径”在很大程度上是指理解和优化HTML,CSS和Javascript之间的依赖关系图。

4. 布局(layout/reflow)

布局是浏览器从根元素开始遍历render树,计算各元素【几何信息】的过程:元素的大小(size)以及在viewport中的位置(position)。 计算过程中,所有相对测量值都将转换为屏幕上的绝对像素。根据所用的 CSS、元素的内容或父级元素,每个元素都将有显式或隐含的大小信息。此过程在 Chrome、Opera、Safari 和 Internet Explorer 中称为布局 (Layout)。 在 Firefox 中称为自动重排 (Reflow),但实际上其过程是一样的。

布局过程最终的产物是由各个renderObject所对应的盒子模型(boxmodel)组成的layout tree。接下来就是在物理屏幕上把这颗树绘制出来。

因为布局是一个先计算子节点再计算父节点的递归过程,所以,render树越是复杂(深度和广度),所消耗的时间就越长。

5. 绘制(paint/rasterizing)

绘制就是浏览器将render树转换为bitmap image,然后调用相应的绘制引擎将它们渲染到物理屏幕上。从技术上讲,这里面可以分两步:1)创建一系列的draw call 2)在屏幕上填充物理像素。我们有时候将绘制成为“栅格化”,就是因为完成第二步后面的技术就叫做“栅格化”。 。浏览器刷新物理屏幕的频率称为“刷新频率”。目前大多数设备的屏幕刷新率为60 FPS(frames per/second)。也就是说,浏览器需要在16.66ms(毫秒)内完成界面的绘制。那是不是就意味着我们有16.66ms来完成我们代码的执行呢?答案是否定的。实际上,浏览器也是需要时间去做别的事情,这个时间大概是6毫秒。也就是说,我们需要做两件事情:

  • 把大的代码块切片,分别放在每一帧里面执行
  • 每一帧代码的执行时间需要保证在10毫秒内完成。否则的话,帧率就会下降(因为浏览器必须要完成你代码的执行),严重的话,屏幕上会出现肉眼可见的卡顿。

6. 图层合成(composite layers)

从技术上来讲,绘制就是图层合成的特例,即页面只有一个图层。不过,为了提高绘制的效率,浏览器一般都会将render树分成多个图层,renderObject会被在绘制的时候被分配到不同的图层里面。这些分配到不同图层的renderObject就组成了renderLayer树。一般而言,浏览器会在绘制阶段将从renderLayer树转换而成的bitmap image保存在CPU内存当中,当需要进行图层合成的时候,就是将不同的图层所对应的位图合成一个位图,然后传输到GPU的内存中,交由GPU来合成。当然这只是主流的混合图层合成方案而已。还有一种是把生成的位图保存在GPU中,全程由GPU来合成图层。凡是有GPU参与的图层合成的技术都叫做“硬件加速合成(Accelerated Compositing)”。从图层合成的角度来看渲染技术的话,当前浏览器主要有三种渲染技术方案:

适当地使用图层合成技术,有助于提成页面的渲染性能。其原理就是在绘制的语境下,通过分层来实现局部绘制的可能性。这个视频以物理模拟的方式解释了图层合成技术给渲染性能所带来好处。

既然适当地使用图层合成技术,有助于提成页面的渲染性能。我们不禁问,在编码过程中,怎样能使得一个元素获得自己的图层呢?以chrome为例。虽然 Chrome 的启发式方法(heuristic)随着时间在不断发展进步,但是从目前来说,满足以下任意情况便会创建图层:

  • 3D 或透视变换(perspective transform) CSS 属性
  • 使用加速式视频解码的 <video> 元素
  • 使用了3D context或者加速式的2D context绘制而成的<canvas>
  • 使用opacity或者transform样式属性来做动画的元素
  • 具有不同的z-index值的兄弟元素会处在不同的图层中

图层虽好,但是创建过多的图层却会反作用于渲染性能。那是因为图层的创建和维护都是需要内存开销的,图层越多,内存开销就越大。所以,我们要严格控制图层的数量。

小结

运行时的性能优化

待续

性能测量/评估手段和工具

性能测量/评估的手段

我们可以将目前业内的性能测评的手段划分为两大类:综合测评和用户实时监控

  • 综合测评(Synthetic testing )。这个测量方式也被称为“in the lab”。是指使用工具在一个一致,受测试者控制的环境(预设网络环境和物理设备)下去模拟加载页面。
  • 用户实时监控 (Real User Monitoring)。这个测量方式也被称之为“in the field”。它是指在真实用户的真实的手机上,通过跟踪真实的使用过程中去做性能测量的一种测量方式。

通过综合评测得到的性能数据称之为“Lab data”,而通过用户实时监控得到的数据称之为“field data”。这两种类型的数据各有各的优点和缺点。

Lab data

优点:

  • 十分适合性能调试(比如Chrome Developer Tools)
  • 能够帮助开发者打造一个可重现的测试和调试环境
  • 获取成本低

缺点:

  • 无法捕获真实使用情况下的性能瓶颈
  • 因为数据带有实验性质且数据覆盖率不高,无法跟具体的商业指标准确挂钩

工具比如Lighthouse和WebPageTest收集的就是这类型的数据。

Field data

优点:

  • 准确捕获真实世界中的用户体验
  • 能够准确地将前端性能跟商业KPI(key performance indicators)挂钩
  • 自定义性能指标的空间大

缺点:

  • 无法实现某些综合测评才能覆盖的性能指标
  • 无法准确地复现性能问题,无法调试

公共数据服务Chrome User Experience获取的就是这类型的数据。还有PageSpeedInsights的“speed score”就是基于这种数据来评分的。当然,我们通过使用的web performance API来捕获的数据也是这类型的数据。

性能测量/评估的Lab工具

  1. Lighthouse。能够测评你的页面在性能,PWA,可访问性(accessibility),SEO和最佳实践等方面的表现,最终给出一份包含测评结果和优化建议的报告书。
  2. WebPageTest。使您可以在受控实验室环境中比较一页或多页的性能,并深入研究性能统计信息并在真实设备上测试性能。 您也可以在WebPageTest上运行Lighthouse。
  3. TestMySite。允许您诊断跨设备的网页性能,并提供修复列表,以改善Webpagetest和PageSpeed Insights的体验。
  4. PageSpeed Insights。显示您网站的速度字段数据,以及常见优化建议。这个工具跟Lighthouse差不多,只是它多了“实测数据”和Origin Summary“”这么两个大面板。但是如果你的用户并不是通过Chrome浏览器来访问你的页面的话(比如说,你页面是在APP的webview里面,在微信浏览器里面,在各种小程序容器里面等等),那么这两个面板实际上是没用。因为它的数据统计是基于Chrome用户体验报告而来的。
  5. Speed Scorecard。使您可以将移动网站的速度与10多个国家/地区的同行进行比较。 移动网站的速度基于“ Chrome用户体验报告”中的真实数据。(对于有墙的国家的服务器,该网站会报Oops! We couldn't find that domain.)
  6. Impact Calculator。可以让你根据Google Analytics中的基准数据来估算提高移动网站速度所带来的潜在收入。
  7. Chrome Developer Tool。允许你分析当前页面的初始渲染和运行时,帮助你识别和调试性能瓶颈。

以上是Google在开发者文档中给出了获取Lab data的七个工具。但是,由于国情不一样(国内浏览器市场划分,天朝是firewall geoblocking countries,国内前端应用技术趋势等等),很多工具对我们来说都是没啥用的。最有用的无非是Chrome Developer Tool,其次是Lighthouse,最后是WebPageTest 。

下面是这三个工具的使用教程:

性能测量/评估的Field工具

参考这篇文章

性能优化技巧

在掌握了一定的理论基础后,我们可以依据它们来梳理出相对应的性能优化技巧。下面我们按照【时间】的维度来梳理一下优化技巧。

网络加载时间

影响网页加载速度的网络因素可以划分为两大因素:

  • 外在因素。外在因素是指那些不在我们控制范围内的变量,比如网络延迟和带宽变化等等。
  • 内在因素。内在因素是指那些处在我们控制范围内的变量,比如服务端和客户端方面的代码架构,资源的大小等。因此,我们可以通过优化客户端应用代码待实现网络加载时间上的优化。

为了减少页面在网络加载方面的时间消耗,我们可以围绕以下策略来做:

  • 加速网络连接
  • 减少传输资源的大小
  • 减少网络请求的数量
  • 加速服务端响应速度

加速网络连接

加速网络连接,前端方面能做的好像只用“dns-prefetch”和“preconnect”这个两个指令(或者说是resource hint)了。值得重复指出的是,它们都只是针对第三方域才有用,并且结合到一块使用效果最佳。具体原因在【性能优化的理论基础/依据】一节就说过了。因为实际开发中,用到第三方域的次数也不多,我们可以用这个指令对它们全数进行DNS预解析(当然,最紧急的连接需要放在第一位),比如:

<head>
    <link rel="preconnect" href="https://otherDomain1.com/" crossorigin>
    <link rel="dns-prefetch" href="https://otherDomain1.com/">
    
    <link rel="preconnect" href="https://otherDomain2.com/" crossorigin>
    <link rel="dns-prefetch" href="https://otherDomain2.com/">
    
    <link rel="preconnect" href="https://otherDomain3.com/" crossorigin>
    <link rel="dns-prefetch" href="https://otherDomain3.com/">
</head>

网络资源的预加载/抓取

浏览器会赋予静态资源相应的加载优先级,并根据这个顺序来加载资源。除了浏览器默认的优先级设定之外,浏览器为我们提供了两个相关指令来调整资源的加载优先级:预加载-preload,预抓取-prefetch。虽然两者是本质都是“预加载”,但是实际含义还是不一样的。

跟之前提到的“preconnect”和“dns-prefetch”不同,preload是强制性指令。它是浏览器必须执行的指令,而不是可选的resource hint。所以,根据关键渲染路径的原理,我们可以对参与首屏渲染的css和javascript实施预加载策略

<link rel="preload" as="script" href="super-important.js">
<link rel="preload" as="style" href="critical.css">

使用 提取的资源如果 3 秒内未被当前页面使用,将在 Chrome 开发者工具的控制台中触发警告:

可以看得出,chrome认为使用preload指令加载的资源必须是十分重要的,并且在页面的初始渲染中要用到的。

在webpack中,存在一个叫“webpackPreload”内联指令来供我们使用。但是实际上出来的效果跟不使用的效果差不多,也就是说并不是原生规范中的“preload”。这个时候,我们可以考虑一个名叫“preload-webpack-plugin”的webpack插件:

const PreloadWebpackPlugin = require("preload-webpack-plugin");

// .....
plugins: [
  // Other plugins omitted...
  new PreloadWebpackPlugin({
    rel: "preload",
    include: ["main", "vendors"]
  })
]

通过上面的配置,我们可以通过中元素为vendors和main代码块提供预加载提示:


跟preload不同的是,prefetch并不是强制性指令。它只是告诉浏览器,我认为某些资源在未来的页面导航或用户交互中需要用到,我希望你有空的情况下能提前帮我加载。事实上,浏览器在遇到这个指令后,会在当前页面完成加载后,且带宽可用的情况下,以Lowest的优先级进行加载。基于浏览器对prefetch指令的解析实现,我们可以用它来预抓取下一个路由页面所需要的静态资源

<link rel="prefetch" href="page-2.html">

记住,浏览器只是预抓取资源,并不会预解析。比如说,page-2.html页面中其他css和js资源不会预抓取,因为当前的html页面只是下载了而已,并没有解析。如果,你想对page-2.html所依赖的资源进行预抓取,则需要在当前页面显式地对它们设置预提取:

<link rel="prefetch" href="page-2.html">
<link rel="prefetch" as="script" href="page-2.js">

可以看出,浏览器引入prefetch的目的是为了尽可能地利用自己的空闲时间来做一些有意义的事情。

在webpack中,有一个叫做“webpackPrefetch”的内联指令,我们可以用它来结合动态import来在构建工作流中实现资源的预抓取:

render(
    <Router>
      <Search path="/" default/>
      <AsyncRoute path="/favorites" getComponent={() => import(/* webpackPrefetch: true, webpackChunkName: "Favorites" */ "./components/Favorites/Favorites").then(module => module.default)}/>
    </Router>, document.getElementById("app"));

减少传输资源的大小

减少传输资源的大小方法有:

  • 压缩
  • javascript的code split(代码拆分)
  • 去除未使用的代码/数据
  • 去除不必要的资源下载
  • lazy load
  • 响应式图片
压缩

对所有静态资源而言,通用的减少传输资源大小的技术方案就是压缩。对于文本类型的文件而言,压缩的基本操作就是去除注释,制表符,换行符和多余空格。

  • css压缩。在线压缩工具多不胜举。比如:CSS MinifierCSS Compressor等等。如果我们是使用webpack来构建我们的应用的话,那么我们可以结合mini-css-extract-pluginoptimize-css-assets-webpack-plugin来做css的压缩

  • javascript压缩。跟javascript压缩如影随形的一个术语叫做“混淆”。混淆一般是指通过一定的编码算法把用户自定义的变量名和函数名改为毫无意义的名字(比如,a,b,c,d等等),以防止他人窥视和传播。与此同时,混淆也具备一定的压缩效果。所以,针对javascript的压缩,一般都会说“混淆压缩”。在线的javascrit混淆压缩工具也是多不胜举。比较有名的是uglifyjs

    如果再考虑加密的话,功能比较强大的两款工具叫做javascript obfuscator和HDS JSObfuscator。更多工具,可查看10 Javascript Compression Tools and Libraries for 2019

如果是结合webpack的构建工作流的话,那么基于uglify-js这个npm包来实现的uglifyjs-webpack-plugin就是最出名的那个了。

  • 图片压缩。图片压缩是有减少图片大小的有效手段。由于现代摄像设备的分辨率越来越高了,网络上传播的图片也越来越大。当我们想要显著地减少页面初始渲染时所传输图片的大小时候,我们可以从图片压缩入手。图片压缩可以分【有损压缩】和【无损压缩】。比如说,PNG就是无损压缩,而JPEG就是有损压缩。无损压缩的基本后果是舍弃一部分人眼观察不到的像素点,而有损压缩是在压缩大小的情况下,保留原始图片的所有像素数据。一般而言,典型的图片压缩过程可以由这两个高级算法步骤组成:
    • 使用有损压缩算法处理图像,去除某些不太重要的像素数据;
    • 使用有损压缩算法在处理图像,对像素数据进行压缩。

是单独使用这两者中的一个还是说结合起来使用,取决于当下的图片质量要求和应用场景。同样在线的图片压缩工具也很多,具体可以参详这篇文章。在压缩图片的时候,工具一般都会提供一个叫“压缩等级”或者“质量等级”给我们。为了获得最佳效果,请为你的图像实验不同的质设置,不要害怕低质量,有时候因为原始图像太高清了,调低后,文件大大减小的同时也未必见得图片的视觉效果有多差。

  • 音频与视频的压缩。由于平时所用不多,且压缩动机跟上面提到的静态资源是一样的,网络也有很多的工具可供使用,在此就不展开讨论了。

javascript的code split(代码拆分)

为了缩减初始加载时间,尽快地显示页面,我们需要保证只加载跟首屏显示和交互相关的js代码。而这就是代码拆分目的。

按照【拆分点】来看,我们可以将代码拆分分为以下的三个类型:

  • entry拆分(入口拆分)
  • vendor拆分。
  • 动态拆分

而从【拆分粒度】来看,我们可以将代码拆分分为以下三个类型(拆分粒度太小,反而增加网络时延,意义不大。所以三种类型就够了):

  • 文件拆分
  • 路由拆分
  • 组件拆分

其实这两种分类都是说的一件事,只不过是从不同的角度来阐述而已。下面我们第一个说法为准。

entry拆分:

entry拆分一般是针对多页面应用而使用的。把其他页面所需要用到的js都放在首屏来加载,这是一个不应该犯的错误。多页面应用中,根据入口进行拆分,这是最基本的操作。比如我们现在有三个页面index.html,detail.html和favorites.html,那么webpack配置应该是这样的:

module.exports = {
  // ...
  entry: {
    main: path.join(__dirname, "src", "index.js"),
    detail: path.join(__dirname, "src", "detail.js"),
    favorites: path.join(__dirname, "src", "favorites.js")
  },
  // ...
};

最后打包结果是这样:

                   Asset       Size  Chunks             Chunk Names
js/favorites.15793084.js   37.1 KiB       0  [emitted]  favorites
   js/detail.47980e29.js   44.8 KiB       1  [emitted]  detail
     js/main.7ce05625.js   49.4 KiB       2  [emitted]  main
              index.html  955 bytes          [emitted]
             detail.html  957 bytes          [emitted]
          favorites.html  960 bytes          [emitted]

vendor拆分:

显然,单单根据入口进行拆分是不够的。每个入口js之间肯定是存在某些共用代码的。我们可以启用webpack中的source maps,并使用Bundle Buddy或者webpack-bundle-analyzer等工具来看看我们代码包之间的代码重复情况。

第一份共用的代码是第三方类库/框架代码。vendor拆分就是将第三方类库/框架代码(例如,React,vue,lodash等)从程序代码中分离出来。目的是为了利用缓存策略来加速资源的加载速度的同时并大大提高缓存的命中率(因为第三方类库/框架在应用开发中变动频率较低)。这个策略跟单页面应用还是多页面应用无关,这是每个前端应用程序都应该做的事情。我们可以使用optimization.splitChunks配置对象来告诉webpack,我们想把这个多个页面所共同依赖的第三方类库打包到一块:

module.exports = {
  // ...
  entry: {
    main: path.join(__dirname, "src", "index.js"),
    detail: path.join(__dirname, "src", "detail.js"),
    favorites: path.join(__dirname, "src", "favorites.js")
  },
  opimization: {
      splitChunks:{
          cacheGroups: {
              vendors: {
                  test:/[\\/]node_modules[\\/]/i,
                  chunks:"all"
              }
          }
      }
  }
  // ...
};

打包结果是:

                                       Asset      Size  Chunks             Chunk Names
js/vendors~detail~favorites~main.29eb30bb.js  30.1 KiB       0  [emitted]  vendors~detail~favorites~main
                         js/main.06d0afde.js  16.5 KiB       2  [emitted]  main
                       js/detail.1acdbb27.js  13.4 KiB       3  [emitted]  detail
                    js/favorites.230214a7.js  5.52 KiB       4  [emitted]  favorites vendors~detail~favorites~main
                                  index.html   1.1 KiB          [emitted]
                                 detail.html   1.1 KiB          [emitted]
                              favorites.html   1.1 KiB          [emitted]

webpack运行时是指那些由webpack实现的,帮助模块化开发应用程序在它的运行时去对webpack打包后代码进行模块管理(模块加载,模块解析,模块依赖管理,模块赖记载等等)的代码。

第二份公用代码是webpack的运行时代码。我们可以使用runtimeChunk配置项webpack代码包在运行时所需要的代码(简称为webpack运行时)也把它抽离出来,成为一个单独的包:

module.exports = {
  // ...
  entry: {
    main: path.join(__dirname, "src", "index.js"),
    detail: path.join(__dirname, "src", "detail.js"),
    favorites: path.join(__dirname, "src", "favorites.js")
  },
  opimization: {
      splitChunks:{
          cacheGroups: {
              vendors: {
                  test:/[\\/]node_modules[\\/]/i,
                  chunks:"all"
              }
          }
      }
  },
  runtimeChunk: { // 将webpack运行时抽出来,成为一个单独的包
    name: "runtime"
   }
  // ...
};

那么最后打包的时候,我们就会多出一个runtime chunk:

                                      Asset      Size  Chunks             Chunk Names
js/vendors~detail~favorites~main.29eb30bb.js  30.1 KiB       0  [emitted]  vendors~detail~favorites~main
                         js/main.06d0afde.js  16.5 KiB       2  [emitted]  main
                       js/detail.1acdbb27.js  13.4 KiB       3  [emitted]  detail
                    js/favorites.230214a7.js  5.52 KiB       4  [emitted]  favorites vendors~detail~favorites~main
                      js/runtime.2642dc2d.js  1.46 KiB       1  [emitted]  runtime
                                  index.html   1.1 KiB          [emitted]
                                 detail.html   1.1 KiB          [emitted]
                              favorites.html   1.1 KiB          [emitted]

最后我们可以更进一步,把多个入口路由js中的共用的我们自己写的模块分离到一个单独的chunk里面去:

module.exports = {
  // ...
  entry: {
    main: path.join(__dirname, "src", "index.js"),
    detail: path.join(__dirname, "src", "detail.js"),
    favorites: path.join(__dirname, "src", "favorites.js")
  },
  opimization: {
      splitChunks:{
          cacheGroups: {
              vendors: {
                  test:/[\\/]node_modules[\\/]/i,
                  chunks:"all"
              },
              commons: {
                  name: "commons",
                  chunks: "initial",
                  minChunks: 2
              }
          }
      }
  },
  runtimeChunk: { // 将webpack运行时抽出来,成为一个单独的包
    name: "runtime"
   }
  // ...
};

最终的打包结果是:

                   Asset      Size  Chunks             Chunk Names
  js/commons.e039cc73.js    40 KiB       0  [emitted]  commons
     js/main.5b71b65c.js  7.82 KiB       2  [emitted]  main
   js/detail.b3ac6f73.js  5.17 KiB       3  [emitted]  detail
js/favorites.8da9eb04.js  2.18 KiB       4  [emitted]  favorites
  js/runtime.2642dc2d.js  1.46 KiB       1  [emitted]  runtime
              index.html  1.08 KiB          [emitted]
             detail.html  1.08 KiB          [emitted]
          favorites.html  1.08 KiB          [emitted]

当我们重新运行Bundle Buddy的时候,我们应该会被告知我们打包出来的chunk之间已经没有重复代码了。

不过话说回来,代码拆分必定是会增加HTTP请求数,从而增加了网络时延。当前这是在HTTP/1.x时代所下的结论。不过,相比通过缓存命中来增加的性能收益,增加些许的网络时延还是可以接受的。

动态拆分:

对于SPA应用来说,它只有一个应用入口。但是视觉上,SPA也是有路由概念的。也就是说SPA的路由就是MPA的某一页。MPA有在webpack配置文件中,有针对入口/页面来做代码拆分的配置项。那么,在SPA中,有什么办法来实现基于路由的拆分吗?答案是:“动态代码拆分(Dynamic splitting)”。

在webpack中,它支持ES提案的动态import(目前处于stage 4)和自己实现的require.ensure来做动态代码拆分。在webpack3和react中,我们可以使用动态import和react-loadable.js来实现:

import Loadable from "react-loadable";

const LoadableFavorites = Loadable({
    loader:()=> import('./components/Favorites/Favorites')
});

render(
    <Router>
        <Route path="/favorites" component={LoadableFavorites} /> 
    </Router>, document.getElementById("app"));

在webpack4+,我们可以单独使用动态import即可;

render(
    <Router>
      <Search path="/" default/>
      <AsyncRoute path="/favorites" getComponent={() => import(/* webpackPrefetch: true, webpackChunkName: "Favorites" */ "./components/Favorites/Favorites").then(module => module.default)}/>
    </Router>, document.getElementById("app"));

又或者结合react在最近版本中提供的Suspense组件和lazy方法来做基于路由组件的代码拆分:

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

// lazy作用过的组件必须要用<Suspense />组件来包裹
const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Switch>
        <Route exact path="/" component={Home}/>
        <Route path="/about" component={About}/>
      </Switch>
    </Suspense>
  </Router>
);

除了基于路由去做动态代码拆分之外,我们可以基于组件/模块这个粒度去做动态代码拆分(其实,路由组件也是组件。从这个角度来说,基于路由去做动态代码拆分只是基于组件去做代码拆分的一个特例。因此,这里说的组件是指除了路由级别外的其他组件)。

Create-React-App构建工具中,我们可以开箱即用动态import来加载模块:

import React, { Component } from 'react';
class App extends Component {
  handleClick = () => {
    import('./moduleA')
      .then(({ moduleA }) => {
        // Use moduleA
      })
      .catch(err => {
        // Handle failure
      });
  };
  render() {
    return (
      <div>
        <button onClick={this.handleClick}>Load</button>
      </div>
    );
  }
}
export default App;

基于组件/模块去做代码拆分要注意其中的“度”。因为拆分粒度太细会因为带来过多的即时加载,从而影响到用户体验。

lazy loading

能做lazy loading一般就是指javascript,image和video。javascript的lazy loading其实就是指使用动态import来加载模块和UI组件,也就是上面code spliting所讲到的动态拆分部分,在这里就不赘述了。而image和video的lazy loading本质是一样的,这里我们只侧重对图片的lazy loading进行阐述。

什么是lazy loading呢?lazy loading其实就是指延迟某些对【页面初始显示和交互】而言不太重要资源的加载的这么一个技术。相反,这类型资源只需要在用户需要它的时候才加载。例如,对于图片资源而言,那些首屏视口 看不到的图片就是【不太重要的资源】。

为什么要做lazy loading?因为假如我们不这么做的话,那么我们实际上在浪费资源:

  • 浪费用户的流量。如果你花费了用户的流量来加载了某些资源,但是用户自己却不会消费到的话,那么就是浪费用户的流量。而流量就是等于钱。你是在浪费用户的钱。
  • 浪费处理时间,浪费电池和其他系统资源。因为多媒体资源一旦被下载下来后,就会被decode,然后将它的内容显示到屏幕上。而多媒体资源越大,decode所需要的时间就越长。

图片的lazy loading的基本原理是:标签初始加载的时候,我们首先会让它的src属性值为空或者显示一个纯色的背景。然后,使用js去检查元素当前是否处于视口当用。如果是,我们就会把真正的图片的url赋值给的src值,发起一个加载请求。

到目前为止,实现图片lazy loading大概有三种方案:

  1. 使用浏览器原生支持的loading属性
  2. 基于Intersection Observer API来检测元素是否出现在viewport来实现;
  3. 基于scroll,resizeorientationchange事件监听和element.getBoundingClientRect()方法来实现。

方案1:

在Chrome 76+,浏览器支持通过loading属性来完成图片或者iframe的lazy loading,详情可查看这篇文章

<img src="image.png" loading="lazy" alt="…" width="200" height="200">
<iframe src="https://example.com" loading="lazy"></iframe>

这个原生特性是非常之新的。可想而知,浏览器兼容问题肯定突出,不建议在生产环境使用。

方案2:

该方案是基于比较新的API-intersection Observer来实现的。通过调用IntersectionObserver实例的observe方法,我们能够对某个元素进行监察,监察这个元素是否出现在可视区域中:

<img class="lazy" src="placeholder-image.jpg" data-src="image-to-lazy-load-1x.jpg" data-srcset="image-to-lazy-load-2x.jpg 2x, image-to-lazy-load-1x.jpg 1x" alt="I'm an image!">

document.addEventListener("DOMContentLoaded", function() {
  var lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));

  if ("IntersectionObserver" in window) {
    let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          let lazyImage = entry.target;
          lazyImage.src = lazyImage.dataset.src;
          lazyImage.srcset = lazyImage.dataset.srcset;
          lazyImage.classList.remove("lazy");
          lazyImageObserver.unobserve(lazyImage);
        }
      });
    });

    lazyImages.forEach(function(lazyImage) {
      lazyImageObserver.observe(lazyImage);
    });
  } else {
    // Possibly fall back to a more compatible method here
  }
});

这个API是一个处于试验状态的API。尽管如此,它的浏览器兼容问题还是要比loading属性要好:

如果实际生产中,需要兼容到大部分浏览器的大部分版本的话,那么加上一个polyfill来做向后兼容可能是个不错的选择。

方案3:

方案3就是目前市面上大部分图片lazy loading实现所采纳的方案。即,基于几乎不会有浏览器兼容问题的scroll,resizeorientationchangeelement.getBoundingClientRect()等API来实现:

<img class="lazy" src="placeholder-image.jpg" data-src="image-to-lazy-load-1x.jpg" data-srcset="image-to-lazy-load-2x.jpg 2x, image-to-lazy-load-1x.jpg 1x" alt="I'm an image!">

document.addEventListener("DOMContentLoaded", function() {
  let lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));
  let active = false;

  const lazyLoad = function() {
    if (active === false) {
      active = true;

      setTimeout(function() {
        lazyImages.forEach(function(lazyImage) {
          // TODO:这里只是判断垂直方向的可视,水平方向的可视判断呢?
          if ((lazyImage.getBoundingClientRect().top <= window.innerHeight && lazyImage.getBoundingClientRect().bottom >= 0) && getComputedStyle(lazyImage).display !== "none") {
            lazyImage.src = lazyImage.dataset.src;
            lazyImage.srcset = lazyImage.dataset.srcset;
            lazyImage.classList.remove("lazy");

            lazyImages = lazyImages.filter(function(image) {
              return image !== lazyImage;
            });

            if (lazyImages.length === 0) {
              document.removeEventListener("scroll", lazyLoad);
              window.removeEventListener("resize", lazyLoad);
              window.removeEventListener("orientationchange", lazyLoad);
            }
          }
        });

        active = false;
      }, 200);
    }
  };

  document.addEventListener("scroll", lazyLoad);
  window.addEventListener("resize", lazyLoad);
  window.addEventListener("orientationchange", lazyLoad);
});

以上对应的是通过标签来加载图片的场景,针对通过css的“background-image”来加载的话,只需要从对src属性的设置变为添加css类即可。

以上讨论的是图片的lazy loading。video元素的lazy loading可以参考这篇文章

去除未使用的代码

未使用的代码主要是指css和javascript。一旦加载了未使用的css和javascript,这无疑是网络传输时间和关键渲染时间上的浪费。

去除首页未使用的css代码:

通过Lighthouse和Chrome dev tool都可以找出包含为使用代码的文件有哪些。相比于Lighthouse只能找到相应的文件,Chrome dev tool还能给出该文件的代码的利用率,并且能精确到文件中每一行代码。

使用Chrome dev tool来鉴定当前代码有哪些未被使用的步骤如下:

  1. Command+Shift+P (Mac)或者Control+Shift+P (Windows, Linux, Chrome OS) 打开命令输入面板;
  2. 在命令输入面板输入“show coverage”
  3. 在coverage面板中,我们点击“重新加载并开始测量”按钮
  4. 在测量过程过,我们随时都可以点击“停止测量并显示结果”按钮。

因为我们目前只关心首屏加载过程中未使用的代码,所以,一旦页面加载完成,我们就可以点击“停止测量并显示结果”按钮。最后我们会看到以下结果:

注意一提的是,每个文件的代码利用率都有可能随着instrument(测量)的时间的推移和交互动作的作用而发生变化。我们看到的代码使用率仅仅代表我们按下停止测量并显示结果”按钮那一刻为止的代码使用率。所以我们不能简单粗暴地把首屏测量结果中显示为未使用的那些代码删除掉。

对于javascript,把一些未使用的代码单独分离出来,这个优化成本太高了。针对去掉真正未使用的javascript代码,有一个更好的技术:tree shaking。所以,这里我们只针对css就行。我们的目的就是为首屏的css加载减负。具体做法是:

  1. 从coverage面板的测量结果中,把(目前)未使用到的css代码手动分离到另外一个文件中,使用prefetch指令来与抓取就好。
  2. 如果更进一步的话,我们可以对测量结果中未曾使用到的css选择器进行一一确认(采用全局搜索来确认),一旦确定其在应用的整个生命周期都不会用到的话,那么我们就可以把它彻底地删除掉。

javascript的tree shaking:

tree shaking这个术语是由Rollup提出来的,是一种用于去除dead code的打包技术。而dead code这个概念是早就有的了。关于tree shaking,有一个形象的比喻:我们应用源代码就是一棵树。树上绿色的,充满生命力的叶子代表的就是将来会在应用运行时会执行到的代码;而树上黄色的,毫无生命力的叶子就是在运行时不会用到的代码。为了加速应用的首屏加载速度,你需要做的就是大力摇晃这颗树,抖掉那些没用的叶子,让代码这颗树在运行时能够轻装上阵。

在当今这个富客户端的时代,以SPA为形态的前端应用更是大行其道。而SPA的本质是什么呢?它的本质就是围绕javascript来渲染页面。相比之下,在web1.0时代,我们是围绕HTML来渲染页面的。在SPA应用中,HTML是个空坛子,里面的东西全靠javascript来填充。如今,javascript所扮演的角色也越来越重要了。与此同时,网络需要传输的和浏览器需要解析,执行的js文件越来越大。javascript也因此被称为当今前端应用重最昂贵的资源。而tree shaking 能够帮助我们从源头上去减小js文件的大小,从而为我们节省了js的传输,解析和执行时间。

很多打包工具都支持对js进行tree shaking。下面只讨论在主流的webpack中,如何进行tree shaking。在webpack中,使用tree shaking的步骤如下:

  1. 使用ES6 Module来进行模块化开发。因为webpack的tree shaking是基于我们如何使用静态import语句来引入ES6模块的特定部分来实现的。所以,为了应用tree shaking,我们必须使用ES6模块来组织我们的应用代码。于此同时,被webpack接手的也必须是ES6模块。这就意味着,如果你使用的是babel的话,那么我们的ES6模块不能被babel-preset-env转化为commonjs模块。我们通过以下的配置就能告诉babel远离我们的ES6模块:

    {
      "presets": [
        ["env", {
          "modules": false
        }]
      ]
    }
    
  2. 采用解构式的import方式。 为了配合webpack的tree shaking,导入某个模块的具体API的时候,我们要采用解构式的import方式,而不是全量式的import方式。通过这样,可以帮助webpack来对代码进行依赖分析,最终实现tree shaking。比如:

    // good
    import {Component, suspense} from react;
    
    // bad
    import React from react;
    class Foo extends React.Component {
        // ....
    }
    
  3. 把不需要进行tree shaking的模块文件标志位为sideEffect。为了安全地移除无用代码,我们需要配合webpack来区分哪些是有side effect的模块,哪些是没有side effect模块。tree shaking过程中,webpack会跳过有side effect的模块,只会处理那些pure(没有side effect)的模块。

    什么样的模块是有side effect的呢?如果一个模块会被import进来,但是模块本身却没有export提供给外界的。这种有着特殊行为的模块就被认为是有side effect的。比如说polyfill模块就是有side effect的。它会影响到全局,但是本身是没有提供export给外界的。又比如,我们经常把一个css文件当做模块import进来:import "../someStyle.css"。css文件是没有export的,那么它就是有side effect的。

    通过package.json的“sideEffects”字段,我们可以显式地告诉webpack哪些文件是有side effect的:

    {
      "name": "your-project",
      "sideEffects": [
        "./src/some-side-effectful-file.js",
        "**/*.css",
        "**/*.scss"
      ]
    }
    

    当然,我们也可以通过webpack本身的配置文件的module.rules字段来达到这个目的。

    除了文件级别的side effect配置,我们也可以进行更细粒度的side-effect-free配置,具体参详Mark a function call as side-effect-free

  4. 把打包模式设置为“生产模式”。 以上几个步骤只是在告诉webpack哪些代码是dead code,具体的shake动作是发生在生产模式下的打包过程。所以,我们要把打包模式调整为生产模式:

    const path = require('path');
    
    module.exports = {
      entry: './src/index.js',
      output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist'),
      },
    - mode: 'development',
    - optimization: {
    -   usedExports: true,
    - }
    + mode: 'production',
    };
    

    生产模式下的打包优化已经包括optimization.usedExports = true所激活的tree shaking和混淆压缩(minification)。

以上说的是在webpack4+进行tree shaking的一般流程。但是有时候情况不太顺利。比如说,使用Lodash。因为Lodash(旧)包架构问题,我们必须启用新包:

  1. 改用Lodash的新包:lodash-es;
  2. 采用特殊的import语法:
// 虽然采用了解构式的导入,还是会整包被打包进去
import {sortBy} from "lodash"

// 要手动导入
import sortBy from 'lodash-es/sortBy'

如果你不想采用这种手动导入方式,我们也可以结合babel-plugin-lodash这个babel插件外保持import语法的统一性。想这种需要特殊处理的包还拥有很多,比如antd-mobile,它也是需要这种处理。

如果,我们想进一步想在webpack的tree shaking下使用commonJS模块的话,我们可以使用webpack-common-shake插件来尝试去commonJS模块进行tree shaking。为什么说是尝试呢?因为某些情况下,它是不起作用的

javascript的scope hoisting:

在最后版本的webapack里面使用ModuleConcatenationPlugin插件:

var webpack = require('webpack');
module.exports = {
// your config
plugins: [
new webpack.optimize.ModuleConcatenationPlugin()
]
};

响应式图片

在视口宽度不同的物理设备加载不同的尺寸的图片,能够为了节省不少的网络加载时间。通过使用srcset,size属性和picture标签,我们可以根据不同的视口宽度来加载不同大小的图片:

<picture>
    <source media="(max-width:799px)" srcset="dog-480w.jpg">
    <source media="(min-width:800px)" srcset="dog-800w.jpg">
    <img src="dog-800w.jpg" alt="A picture of a dog">
    <>
</picture>

更多图片响应式技术可以查看这里

去除不必要的资源下载

有时候,页面很容易包含着一些随着时间推移而被证实是多余的代码。比如你一开始是用了GA(Google Analytics)来统计页面分析数据的,但是你的领导决定用百度统计来收集的话,这个时候记得把GA的相关代码去掉,免得页面在重复下载不必要的资源。又比如,网站为了改善社交互动体验或者提供某种其他服务而引入第三方插件/工具。但是,引入之后,我们的统计数据却表明,几乎没有用户使用它。这个时候,我们可以考虑是否需要把该插件/脚本去除掉了。

当然,决定是否需要去除不必要的资源下载前,是需要大量周密的思考和评估的。一般可以通过以下步骤来进行:

  1. 清点页面上自有资源和第三方资源;
  2. 通过具体的数据来评估每个资源的表现:它的实际价值和在性能上的开销;
  3. 最后与团队一起讨论,作出删除或者保留的决策。

缩短通讯距离

假如我们能够在保证提供功能一致的情况下,缩短客户端和服务端的物理线路的距离,那么我们的页面加载时间肯定也会缩短。那有什么方案呢?答案是:“CDN(Content Delivery Network),内容分发网路”。

什么CDN?百度百科如是说:

CDN是构建在现有网络基础之上的智能虚拟网络。依靠部署在各地的边缘服务器,通过中心平台的分容分发,负载均衡,网络调度等功能模块,使得永辉就近获取其所需内容,从而降低网络拥堵,提高用户访问响应速度和命中率。

CDN的基本原理是广泛采用各种缓存服务器,架构这些缓存服务器部署大用户访问相对集中的地区或者网络中。在用户访问网站的时候,利用全局负载技术将用户的访问指向离用户物理距离最近的工作正常的缓存服务器上,此后由缓存服务器直接响应用户请求。所以,使用过了CDN后,用户客户端与我们的服务器之间的物理通讯距离就能缩短了,页面资源的下载时间也因此大大降低。

在实际开发中,到底是企业自建CDN还是接入市场上众多的CDN运营商呢?这需要企业自己结合自身需求,业务规模,自身实力以及不同方案的成本来做决策了。

想要了更多自建CDN的信息,可以查阅这里知乎上的问题

减少网络请求的数量

网络请求越少,尤其是关键资源(css,javascript)请求越少,我们就能越早进入页面渲染阶段。为了减少网络请求的数量,我们一般会做文件合并:

1.合并css文件。传统开发模式下,单纯手动合并css文件,比较简单,这里就不多讲。我们主要看在使用webpack进行打包的模块化开发模式下,我们是如何进行css合并的。在webpack4之前,我们是使用extract-text-webpack-plugin来将所有的js文件导入的css来合并成一个css文件。在webpack4+中,官方已经建议使用mini-css-extract-plugin来代替它了。官方给出的理由是:

  • 异步加载
  • 不会重复编译
  • 易于使用
  • 单独针对css文件

mini-css-extract-plugin默认将一个js模块导入的所有css文件合并成一个css文件。如果在SPA+ “css in js”(因为css in js样式最好不要抽取出来)的应用中,这是合适的。在这种情况下,我们只要这样配置就行:

webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  plugins: [new MiniCssExtractPlugin()],
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};

但是,假如我们是开发多页面应用,我们可能就需要基于页面入口来进行css文件合并了,具体配置如下:

webpack.config.js

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

function recursiveIssuer(m) {
  if (m.issuer) {
    return recursiveIssuer(m.issuer);
  } else if (m.name) {
    return m.name;
  } else {
    return false;
  }
}

module.exports = {
  entry: {
    foo: path.resolve(__dirname, 'src/foo'),
    bar: path.resolve(__dirname, 'src/bar'),
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        fooStyles: {
          name: 'foo',
          test: (m, c, entry = 'foo') =>
            m.constructor.name === 'CssModule' && recursiveIssuer(m) === entry,
          chunks: 'all',
          enforce: true,
        },
        barStyles: {
          name: 'bar',
          test: (m, c, entry = 'bar') =>
            m.constructor.name === 'CssModule' && recursiveIssuer(m) === entry,
          chunks: 'all',
          enforce: true,
        },
      },
    },
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};

如果你想更进一步,将应用中所有的css打包到一个文件,webpack可以支持的:

webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        styles: {
          name: 'styles',
          test: /\.css$/,
          chunks: 'all',
          enforce: true,
        },
      },
    },
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};

更多信息,参考mini-css-extract-plugin文档

  1. 合并javascript文件。传统开发模式下,单纯手动合并javascript文件,比较简单,这里也不多讲。在模块化开发模式下,合并javascript的需求已经淡化到几乎没有了。对于javascript来说,更多的code splitting。

  2. 合并图片。合并图片一般是针对小图片而言的。在传统的开发模式下,一般都是UI设计师手动地将小图片合并到一张大图里面,然后前端开发者通过css属性background-position来偏移一定的距离值来引用相应的图片。当我们使用webpack来打包我们的图片的时候,也有相应的第三方plugin供我们使用,比如:image-sprite-webpack-plugin。但是,一般主流的做法是通过将小图片转化为base64编码格式来将小图片请求省略掉。所以,模块化开发模式下,合并图片的这个需求跟合并javascript文件的需求一样,已经被淡化了。

下一篇:web前端性能优化(2)