web前端性能优化(2)

1,030 阅读28分钟

上一篇:web前端性能优化(1)

将小图片转化为DataURLs

相比于对小图进行iamge sprite,在模块化开发模式下,当前更主流的方案是将小图转化为DataURLs。

什么是Data URLs? Data URLs是一种以data:为前缀的协议(scheme)。通过这个协议,内容创作者可以向文档中嵌入小文件。所以,Data URLs也被称为“文件中的文件”。Data URLs之前被称为“Data URIs”, 后来这个名字被WHATWG废弃了,才正式改名“Data URLs”。

Data URLs的语法如下:

// 注意,里面的“,”是不可或缺的
data:[<mediatype>][;base64],<data>
  • mediatype是一个MIME类型的字符串,比如“image/jpeg”。它的默认值为“text/plain;charset=US-ASCII”。
  • base64。一种编码格式。
  • data。经过原始二进制数据经过base64编码后得到的数据。

通过Data URLs,我们能够节省了一次网络请求中DNS解析和TCP连接的时间开销。它足够简单,所以,我们能以比较低的成本去缩减我们的页面加载时间。在线的DataURL生成工具很多,这里罗列一个在线web工具和一个Mac OS X桌面应用:

在模块化开发模式下,webpack也提供相应的loader供我们使用。比如:url-loader。

index.js

import img from "./image.png";

webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
            },
          },
        ],
      },
    ],
  },
};

在这里,我们对于png/jpg/gif格式的图片,一旦它的大小不大于8192B的话,那么我们就把这个图片转化为DataURLs。

使用DataURLs来处理图片固然是简单便利且对页面加载时间有益处。但是这种技术也是有自己的缺陷的:

  1. 导致图片(解码结果)无法被缓存。也就是说如果一张图片被反复用到的话,那么浏览器要重复对相同的base64数据进行解码行为。
  2. 通过base64编码得到的数据是原始数据的三分之四(4/3)。也就是说通过Data URL上来表示的图片比二进制格式的图片体积要大1/3。

要解决第一个问题,我们可以通过在CSS文件来使用图片来解决。也即将图片作为HTML元素的背景,然后在CSS文件中实现相应的css类来提供给该元素挂载。css类中,我们使用css属性-“background-image”来引入这个图片。然后,我们配合webpack提供给我们的url-loader就可以达到图片缓存的效果。

这其中的原理是,相比于在HTML中对DataURLs重复解码,在css文件中通过css类来使用DataURLs,我们只需要解码一次就行,这种效果就是缓存效果。

至于第二个问题,貌似无法解决。我们只能通过把握“度”来降低base64编码后所带来的体积膨胀程度。这个“度”,就是原数据大小的阈值。这个阈值越小,那么增多的那部分数据的体积越小。这就是为什么在使用url-loader的时候,我们需要指定一个limit值,只处理那些体积小于该limit值的图片。因为如果处理体积过大的图片,那将会导致网络传输的数据大大增加,而出现性能回退现象。

更多详情可以参考MDN这篇文章

HTTP缓存

准确地说,缓存对于用户设备真正的第一次访问时的页面加载性能是没有帮助的。缓存,缓存,顾名思义,它只会在除了第一次访问之后的访问起作用,并且能使得页面的加载性能得到质的提升。它是如此重要,我们不得不深入点讨论它。

通过网络请求来获取静态资源,需要经过页面访问的全链路。对于体积比较大的资源,客户端与服务器之间还要进行多次往返通信,网络时延增加,页面加载时间增加。同时,访问者的流量费用也在增加。总的来说,通过网络请求来获取静态资源,速度缓慢且开销巨大。所以,如果能省掉这些网络通信,就近地或者从本地来直接获取我们需要的静态资源,这对于前端性能提升无非是巨大的提升。而这就是HTTP缓存目的之所在。

跟HTTP缓存相关的header无非就以下几个:

  • Expires(响应头-response header)
  • Cache-Control(响应头) 而Cache-Control的值有可以包括多个指令:
    • public
    • private
    • no-cache
    • no-store
    • max-age=xxx(秒为单位)
  • ETag(响应头)
  • Last-Modified(响应头)
  • Cache-Control(请求头-request header)
  • If-Modified-Since(请求头)
  • If-None-Match(请求头)

注意,MDN上面说:“A given directive in a request does not mean the same directive should be in the response.”也即是说,相同的cache-control在请求头中的意思跟跟响应头中的意思是不一样的。响应头中的cache-control的作用和取值是耳熟能详了,但是请求头中的cache-control呢?详情可参考stackoverflow上的讨论

业界从【浏览器使用缓存之前是否需要向服务器进行缓存有效性确认的】角度来将HTTP缓存策略划分为强制缓存(force cache)和协商缓存(negotiated cache)。如果需要确认的话,那么就是协商缓存,否则就是强制缓存。我们也不妨从这个角度去对帮助我们对众多的缓存HTTP头进行分类理解和记忆。

不过,其实还有一种缓存策略,那就是显式地禁用缓存。下面我们围绕这三种缓存策略进行讨论。

禁用缓存

通过将响应头Cache-control的值设置为“no-store”,我们就可以将某个静态资源请求设置为禁用缓存,也就是说每一次都需要发起一个网络请求,从源服务器中获取该静态资源。

Cache-Control: no-store

假如我们确切地需要对某个静态资源请求禁用缓存的话,我们必须这么做。因为,假如我们什么都不做的话,浏览器默认会根据实际情况,有可能地启用缓存

强制缓存

HTTP/1.1中,两个响应头可以被服务器用来告诉浏览器使用强制缓存策略:

  • Expires
  • Cache-Control:max-age=xxx

Expires: Expires是HTTP/1.0引入的响应头字段。它的值为一个HTTP-data时间戳,比如:

Expires: Thu, 01 Dec 1994 16:00:00 GMT

注意,正常情况下,该值是一个时间点,表示当客户端时间过了这个“时间点”的时候,缓存就失效了。不过,标准规范中指出,当它的值为0的时候也是“合法”的,代表着“已过期”。

Cache-Control:max-age=xxx。就像上面提到的,Cache-Control响应头的值可以包含多个指令。在这几个指令中,只有max-age指令跟强制缓存有关。而max-age指令也是有值的。它的值为一个以秒为单位的时间段,比如:

Cache-Control:max-age=31536000

1年 = 60 * 60 * 24 * 365 =31536000(秒)。上面给出的示例代码中就是指定某种资源缓存的有效期为一年。同时值得指出的是,max-age最大的有效值也就是31536000。

与此同时,我们还可以结合使用public和private指令来控制在请求链路中各个缓存服务器的行为。具体如下:

  • public。这个指令表明,当前的数据是公开的,网络请求链路中所有的缓存客户端(比如CDN,代理服务器等,也包括浏览器)都可以对源服务器(origin server)的响应进行缓存。一般情况下,缓存客户端默认的就是“public”,所以该指令不是必须的。
  • private。在网络请求链路中,像浏览器这样的本地客户端中的缓存称为“local cache”(本地缓存客户端),除了本地缓存客户端和源服务器,其他的缓存客户端被成为“intermediate cache”(中间缓存客户端)。private指令表示的就是只有本地缓存客户端才可以缓存源服务器所返回的响应内容,不允许中间缓存客户端对其进行缓存。因为某些包含私人信息的页面是跟单个用户密切相关的,CDN作为一个公共访问源,我们不希望它存储这样的信息。

上述的两个指令也可以跟其他指令一起搭配使用,比如no-store,no-cache来使用,这里就不多说了。

到了这里,我们也许会问,既然两个响应头都可以实现强制缓存的,那我们到底该使用哪一个呢?答案是:“两个都得用”。

为什么?因为Expires有一个缺点是:浏览器会拿客户端的时间跟它的值相比,如果客户端时间大于Expires值,则表示缓存已经过期了,需要向源服务器发请求重新获取。这里面就有一个问题,那如果客户端和服务器的时间不一致呢?这里面可以分两种情况:

  • 客户端时间比服务器时间快。那么缓存就会过早失效,降低页面的加载性能。
  • 客户端时间比服务器时间慢。那么就会出现了服务器资源已经更新了,但是客户端还是认为资源是freshness,依然在使用,从而导致缓存无法更新。

为了解决这个问题,HTTP/1.1引入了Cache-Control:max-age=xxx这个响应头和指令。相比于Expires,max-age的值是一个以秒为单位时间段,所以不会出现Expires响应头所出现的问题。引入Cache-Control后,标准文档RFC 7234, section 5.3: Expires规定:

If a response includes a Cache-Control field with the max-age directive (Section 5.2.2.8), a recipient MUST ignore the Expires field. Likewise, if a response includes the s-maxage directive (Section 5.2.2.9), a shared cache recipient MUST ignore the Expires field. In both these cases, the value in Expires is only intended for recipients that have not yet implemented the Cache-Control field.

意思就是,max-age和s-maxage指令的优先级比Expires的优先级要高。如果两者同时出现得话,标准规范要求实现HTTP/1.1的user agent需要忽略Expries。正如上面规范文档所指出的那样,Expires的存在只是为了向后兼容那些没有实现Cache-Control的HTTP user agent。综上所述,在强制缓存策略中,我们需要同时设置Expires和Cache-Control这两个响应头,以使我们的强制缓存具备更好的兼容性。

协商缓存

实现协商缓存有三个头字段:

  • Cache-Contror:no-cache
  • ETag(响应头)/If-None-Match(请求头)
  • Last-Modified(响应头)/If-Modified-Since(请求头)

Cache-Contror:no-cache。“no-cache”,从字面意思来看,好像是“禁用缓存”的意思。其实,这是一个由来已久的误导。该指令的意思是,可以使用缓存,但是每次使用前麻烦你去跟源服务器确认一下再使用。而这不就是符合我们对协商缓存的定义吗?“no-store”才是真正的“禁用缓存”。很多人都很容易把这两者记混了,这真的不能怪开发者啊。这个命名不当的锅应该由IETF(The Internet Engineering Task Force)来背。

ETag/If-None-Match。我们上面不停地提到,协商缓存的核心要义的在使用缓存之前需要跟源服务器进行确认。那如何确认法呢?这就是ETag的意义之所在了。正如上面所标注的那样,ETag是一个响应头,它的值是由源服务器生成的。一般而言,就是根据资源的文件内容来生成的hash值或者某种具备唯一标志性质的fingerprint(指纹)。当客户端需要去跟服务器确认某个缓存是否有效之前,客户端就会从当前缓存的响应内容中取出这个ETag值,把它的值赋给If-None-Match这个请求头,再把请求发给服务端。服务端收到请求后,会把请求头If-None-Match的值跟最新生成的hash值做比较。如果两者是一致的话,那么服务器就会返回HTTP状态码为304(Not Modified),响应体为空的响应给客户端。客户端收到304响应后,就会继续使用之前的缓存;如果两者不一致的话,那么服务器就会返回HTTP状态码为200(OK),响应体为最新内容的响应给客户端。客户端收到200响应后,于是会使用最新的资源并更新缓存。

注意,上面把ETag跟If-None-Match写在一块是为了强调两者是搭配发挥作用的。而实际上,If-None-Match是不需要我们手动这设置的,客户端会在发请求之前自动在“If-None-Match”HTTP请求头内设置相应的ETag值。我们唯一需要做的是确保服务端提供必要的ETag响应头即可。

Last-Modified/If-Modified-Since。这种响应头跟请求头的搭配跟上面所说的ETag/If-None-Match的原理是差不多的。只不过,响应头Last-Modified的值是一个HTTP-date类型的时间戳,用来表示该静态资源上一次更新是在什么时候。客户端在发出缓存有效性确认请求之前,一样会把缓存的响应头Last-Modified的值赋值给请求头If-Modified-Since,然后才把请求发给服务端。服务端接收到请求后,会从If-Modified-Since读取这个时间戳,拿它跟当前资源文件的最后更改时间点相比。对相比的结果的处理跟If-None-Match一样的,在这里,我就不赘述了。

实现协商缓存有两组HTTP头,那么问题又来了,我们应该怎么设置呢?答案是:“优先考虑使用ETag/If-None-Match”。为什么优先考虑ETag/If-None-Match呢?因为它的验证准确性更高。使用Last-Modified会有以下两个问题;

  • 有些文件资源只是其他的文件meta信息改了,但是内容没有改。这个时候文件的最后修改时间是会变的。这种情况下去做缓存有效性验证,其实,我们希望的是返回HTTP 304给我们。但是实际上,使用Last-Modified去验证,服务器会返回有响应实体的HTTP 200。
  • 当文件修改过于频繁的时候(比如说,1秒内修改了N次),值为秒级的时间戳的Last-Modified无法反应出这种变化。因此,服务器是无法判断出内容已经更改的,从而还是返回HTTP 304给客户端。

因为我们99%的情况下只关心文件的内容是否真正发生了改变,所以,ETag是真真正正地切合我们的需求的。以文件内容为输入,经过hash算法,计算出hash值。这个hash值的变化能够准确地反映出文件内容的变化,因此也就解决了Last-Modified把文件更改时间作为验证文件是否发生更改的依据所带来问题。这就是为什么我们在设置协商缓存的时候需要优先考虑ETag的原因。

也许你会问,我两个都使用行不行啊?行,我们也可以把Last-Modified/If-Modified-Since作为一个向后兼容的方案。不过,如果缓存客户端的HTTP实现都支持这两者的话,那么只有ETag生效。因为,ETag/If-None-Match的优先级要比Last-Modified/If-Modified-Since要高。

也许你也注意到了,使用了协商缓存之后,每次在获取具体静态资源之前还是会发出一次网络请求。你也许会有点疑惑:“那这样子的话,跟不使用缓存有什么区别吗?”答案是:“有区别”。在最坏的情况下,也就是客户端缓存失效的情况下,使用协商缓存跟禁用缓存的效果是一样的。但是,你不要忘了还有一种情况,那就是,经过服务器确认之后,发现客户端的缓存没有失效,还可以继续用。这种情况下,服务器返回的是一个只有响应头而没有响应体的HTTP响应。“没有响应体”,这个短语十分重要。因为一个响应如果没有响应体的话,那么整个HTTP响应paload就会降低了一大半,从而不会因为需要传输的数据过大而导致增加网络往返的次数,大大降低乐乐网络时延。所以说,协商缓存跟不用缓存还是有区别的,在某种情况下,能够为我们带来更好的性能提升。

主动更新缓存

协商缓存策略中,因为有了ETag/If-None-Match,我们是能够主动更新缓存的。我们只需要把更改后的资源重新部署到源服务器即可。那对于强制缓存呢?比如说,我们对某个js脚本设置了24小时(Cache-Control:max-age=86400)的强制缓存,但是我们突然发现它上面存在一个致命的bug,绩效的压力,上司的催促,一切都使得你心急如焚。那有什么办法主动去更改缓存在用户终端机器上的js文呢?在不改变静态资源的URL或者用户不主动去清除宿主容器(浏览器或者APP WebView)缓存的情况下,答案是:“做不到(谷歌开发者文档上言之凿凿如是说)”。

因为,用户是否会主动去清除缓存不在我们的控制范围内,故略去不表。假如能改变静态资源的URL的情况下,我们能主动去更新用户终端里面的缓存吗?答案是:“能”。

主流的解决方案是,通过在文件名中嵌入文件指纹或者版本号来实现(某种程度上的)缓存更新。为什么说是“某种程度上”呢?因为,不同的资源URL对于浏览器而言,其实会产生不同的缓存,两者是一一对应的。当我们改变了文件的URL,对我们开发者来说是同一份资源,但是对浏览器而言,这是两份不同的网络资源。所以,从浏览器的角度而言,我们这种解决方案其实是通过废弃老缓存,启用新缓存的曲线救国方案。不管怎么说,黑猫白猫,抓到老鼠就是好猫。这确实是很巧妙地解决了上面所提到的问题。

通过在文件名中嵌入文件指纹或者版本号来实现(某种程度上的)缓存更新,这就是这里所说的“主动更新缓存”。

如何做缓存决策

正如上面所说的,Expires和Last-Modified/If-Modified-Since的功能是跟Cache-Control和ETag/If-None-Match的功能重叠的,但是后者的优先更高。所以,一般情况下,使用后者就够了。前者可以作为向后兼容方案而存在。如果光是针对Cache-Control和ETag/If-None-Match,缓存决策流程图可以是这样的:

实践证明,没有一个固定的缓存策略能够普遍适用于所有场景下的所有资源的缓存需求。在制定整个web应用的缓存策略的时候,我们得根据实际的开发和运维状况,针对每一样资源一一来制定相应的缓存策略。不过,对于当今web前端应用而言,静态资源无非也就是四大件:HTML,CSS,Javascript,Image。针对这四个静态资源,主流的缓存决策还是可以供我们参考的:

  • 对HTML使用协商缓存策略,将HTML标记为“no-cache”。这就意味着浏览器每次请求的时候始终会重新验证文档,并在HTML内容变化的时候,更新缓存。此外,在HTML文档,引用的都是带fingerprint的CSS和javascrpt的URL。由于HTML是web应用的入口,这就决定了我们能通过这种方式来准确地更新用户终端上的CSS和javascript。
  • 对CSS文件使用强制缓存,同时允许中间缓存服务器对它进行缓存。因为有了fingerprint技术,我们可放心地把CSS的缓存有效期设置为最大值:1年,即max-age=31536000。
  • 对javascript文件使用强制缓存,只允许用户本地缓存客户端对它进行缓存(因为js代码包含私密性信息的可能性比较大)。同时,我们可放心地把javascript文件的缓存有效期设置为最大值1年。
  • 图片使用协商缓存,并不使用fingerprint技术,将缓存有效期设置为1天。

这里面值得一提的是,相比传统模式下使用fingerprint技术的繁琐(手动地为文件生成hash值,手动地更新引用该文件的所有URL),在webpack这个自动化构建时代,这些变得简单了。我们只需要简单的配置,就可以为css和javascript生成其相应的hash值,并自动地更新引入这份资源的所有URL。就这样,我们就可以通过较低的成本来兼得“鱼”和“熊掌”:客户端缓存和随时随地的主动更新

 const path = require('path');
  const { CleanWebpackPlugin } = require('clean-webpack-plugin');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: './src/index.js',
    plugins: [
      // new CleanWebpackPlugin(['dist/*']) for < v2 versions of CleanWebpackPlugin
      new CleanWebpackPlugin(),
      new HtmlWebpackPlugin({
          title: 'Caching',
      }),
    ],
    output: {
        filename: '[name].[contenthash].js',
        path: path.resolve(__dirname, 'dist'),
    }
  };

Service Worker缓存

使用Service Worker不但能够将我们的web APP打造成更好的离线应用,而且能够在页面的二次访问的时候大大提升页面的加载速度。

server-side-render其实就是加快了first contentful paint所需要的时间而已。页面初始显示后,脚本还是要经历fetch,parse,execute这三个环节。又或者是它显著地增加了html文件的大小。无论这两个的哪一个,都会降低TTI(Time To Interactive)这个性能指标。

service worker不但满足你的开发离线应用的问题,还能加快页面二次访问时候的速度。下面是它的原理图:

减少网络往返

为了缩短关键渲染路径的长度,减少网络往返是很有必要。以下两个做法能够减少不必要的网络往返:

  • 因为在样式文件使用@import指令会导致一个新的网路往返,所以不要随意在样式文件中随意使用该指令。
  • 把首屏渲染的样式内联到html文档中,避免一个新的网路往返。

服务器的响应速度

在HTTP/1.1的背景下,还有一种叫做域分片(domain sharding)。通过把原先放在一个域名下的资源分散在不同的域名下(但是会被解析到同一个服务器),来激活服务器创建更多的TCP连接。在多核计算机上,更多的TCP连接则意味着更快的响应速度。更多信息,可以查看MDN上的介绍

前端有一个场景是需要优化服务器的响应速度的。那就是在页面unload或者beforeunload事件发生的时候向服务器发送请求。有时候,我们为了在页面关闭或者跳转的时候向服务器上报点统计数据,这个时候,你可能会想到用XMLHttpRequest对象来发送异步ajax请求。但是不幸的是,大部分浏览器会忽略掉写在unload和beforedunload事件处理器里面的异步ajax请求。所以,为了解决这个问题,你只能使用同步ajax请求。那么问题就来了,这就会导致下一个页面(如果是页面跳转的话)的加载被迫延迟。一切都是因为我们发出的是同步请求。

有两个解决方法:

  • 使用navigator.sendBeacon()去代替XMLHttpRequest对象;这个API的提案就是为了上面的那种类型的问题,专门是为了异步请求而设计的,并且兼容性问题小,所以是最佳选择。
  • 使用新API-fetch来代替XMLHttpRequest对象。浏览器不会忽略写在unload和beforedunload事件处理器里面的fetch()请求。而fetch API也是默认是异步的,虽然在浏览器兼容性上面比navigator.sendBeacon要大点,但是我们也是可以通过合适的polyfill解决这个问题。

更多介绍,请查看避免同步服务器调用

通过负载均衡和优化SQL语句来提高服务器的响应速度,这纯后端的工作。这已经超出本文的研究范围了,所以就深入探讨了。

页面初次渲染时间

css资源加载的位置

前面性能优化理论部分已经说明过了,由于css资源会阻塞javascript的加载和执行,而javascript的加载和执行又会阻塞HTML的解析,最终会影响页面的渲染速度。所以,越早加载首屏所需的css资源越好。因此,css资源的<link>标签需要放在页面头部(<header>标签内)中的首位。

javascript资源加载的位置

因为我们的目的是让用户快速地看到页面内容。通过css+HTML的快速加载和解析就可以达到这个目的。因为javascrpt的加载和执行会阻塞HTML的解析,所以,此时javascript资源就不要添乱了。这就是为什么,为了性能优化,我们提倡把javascript的加载放在结束标签</body>之前的原因。

对于不参与首屏渲染和之后的交互能力提供的javascript脚本(比如埋点脚本,页面监控脚本等),我们可以通过外部文件 + async/defer指令来异步加载而影响页面的FCP和TTI两大指标值。

注意,带async/defet指令的<script>应该放在<head>标签里面,以便浏览器能够更快地发现它,并在后台线程加载和解析它。

至于async指令个defer指令有什么区别呢?一图胜万言:

从上面的示意图,我们可以看出,为了不阻塞HTML的解析,defer指令无疑是最佳的。至于更多详情,请查看我的技术文章:浅谈首屏渲染速度及defer和async的异同

对long task进行任务切片

以上讨论的是不参与首屏渲染和之后的交互能力提供的javascript脚本,那万一我的脚本是一个long task,并且需要参与首屏渲染和之后的交互能力提供呢?在这里举个例子,我们需要从1开始,累加到1e6,并实时将累加结果显示出来呢?(很多的P2P应用需要在首页以数字动画效果显示有xxx人注册了该平台,这个示例可以对应到这种场景)如果,我们想都不想,可能会写出这样的代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>对耗时很长的大任务切片</title>
</head>
<body>
    <input type="text" placeholder="输入框放在这里是为了验证UI可交互" style="width: 100%;"/>
    <div>当前注册人数为:<span id="container"></span></div>
    <script>
        window.onload = function(){
            const container = document.getElementById('container');
            let i = 0;
            
            function count() {
            
              // do a heavy job
              for (let j = 0; j < 1e6; j++) {
                i++;
                container.innerText = i;
              }
            }
            
            count();
        }
    </script>
</body>
</html>

上面的页面一加载,输入框虽然渲染出来的了,但是我们想要实时更新的数字动画效果却没有看到,并且输入框也是处于不可交互的状态。直到累计完成,页面才一次性把计算结果显示出来。这是因为当前的long-task长时间占用了call stack的缘故。

这个时候,我们就需要对这个long-task任务进行切片了。因为当前示例涉及到实时DOM操作,所以,我们不能把这个long-task放到web worker来做,我们只能利用浏览器的event loop机制来实现切片了。我们不妨用macrotask-requestAnimationFrame来实现任务切片:

<script>
    window.onload = function(){
        const container = document.getElementById('container');
        let i = 0;
        
        function count() {          

          do{
            i++;
            container.innerText = i;
          }while(i % 1e3 !== 0)


          if(i < 1e6){
            window.requestAnimationFrame(count);
          }           
        }
        
        count();
    }
</script>

以上代码保存,页面一刷新,你就会发现我们期盼已经的数字动画效果已经出来了,并且输入框也可以接受交互(能够获得焦点)。原理是,我们通过将从1累计到1e6的long-task,分成了1000(1e6 / 1e3 = 1000)个小任务,每个小任务只负责累加1000次。然后,当前小任务执行完毕,我们把下一个小任务入队到macrotask queue里面,这个时候event loop就会去执行render callback queue里面的callback(也就是更新UI界面)。总体的执行流是这样的:

小任务 -> 执行render callback -> 小任务 ->  执行render callback -> ....

通过任务切片,从某种程度上我们即缩短了页面显示所需要的时间,也同时保证了界面的可交互性,益处多多。

也许你想通过microtask来进行任务切片,但是在本示例中,是不可行的。为什么?因为microtask callback的优先级比render callback的优先级要高,所以如果使用microtask来进行任务切片的话,界面也是无法得到实时更新的。当event loop发现microtask queue和render callback queue都有callback的时候,它会优先执行microtask queue。如果在考虑上macrotask queue的话,它们三者的优先级是这样的:

microtask > render callback > macrotask

对于纯计算类型的且不需要实时将计算结果通知UI界面的long task,我们还有三种可供选择的方案:

  • 将计算任务抽离浏览器主线程,放在单独的线程里面来执行-web workder
  • 把计算任务放在浏览器event loop空闲时候来执行-window.requestIdleCallback()
  • 把计算密集型任务的执行放到WebAssembly来执行。

以上所有的一切,都是为了使得长时间运行的javascript代码不要阻塞HTML的解析,从而加快页面的渲染速度。

HTTP2

当然,以上性能优化技巧都是围绕HTTP/1.1而提出的,尤其是合并HTTP请求部分已经是不太适用HTTP/2了。

HTTP2在保持HTTP/1.1核心概念(比如HTTP方法,HTTP状态码,URI和头部字段等)不变的基础上,通过更改本身的传输架构,从而使得客户端和服务端之间的传输更高效:

  • 同一个域名只需要为其开启一个TCP连接,随时不断地复用这个连接。
  • 一个TCP连接上可以并行交错地发送多个请求,请求之间互不影响;
  • 一个TCP连接上可以并行交错地发送多个响应,响应之间互不影响;
  • 一个TCP连接上并行发送多个请求和响应
  • 对发送/响应头部进行压缩,使得数据包更加的轻量
  • 引入新的二进制分帧层来解决了HTTP/1.X时代的队首阻塞问题。
  • 通过为数据流引入依赖关系和权重来让客户端的关键资源能得到优先加载完成的机会
  • 支持服务器推送

HTTP2通过【多路复用】更好地支持了请求和响应的高并发,无论是客户端和服务端都能够很好地节省了计算机和网络资源。所以,在这种技术背景下,我们前面围绕基于HTTP/1.1而提到的性能优化技巧(比如说,减少请求数量)的意义就不大了。

更多关于HTTP/2的信息,可以查阅Introduction to HTTP/2

参考资料

  1. Deep dive into the murky waters of script loading
  2. User-centric performance metrics
  3. Understanding the Critical Rendering Path;
  4. http-caching;
  5. Internet Engineering Task Force (IETF) specifications;
  6. A Tale of Four Caches
  7. Caching best practices & max-age gotchas;
  8. User centric performance metric
  9. Get Started with Analyze Network Performance
  10. in-depth explainer on resource hints:rel = prefetch and rel = preload
  11. Official webpack code splitting docs
  12. Official React code splitting docs.
  13. google developer: code splitting;
  14. es6-modules-in-depth
  15. Preload critical assets guide
  16. Optimize CSS Delivery;
  17. What is Server-Side Rendering?;
  18. 浏览器资源加载优先级:preload, prefetch & priorities in chrome
  19. web性能对用户体验和商业绩效的影响的研究调查案例
  20. 渲染性能评估
  21. Front-End Performance Checklist 2020
  22. 为什么性能优化这么重要
  23. W3C Web Performance Working Group
  24. user-centric-performance-metrics
  25. google开发者文档-术语表
  26. 查看css属性是在layout -> paint -> composite管道中的表现
  27. udacity-Critical Rendering Path课程
  28. udacity-RAIL课程
  29. MDN: Using dns-prefetch
  30. Establish network connections early to improve perceived page speed
  31. Accelerated Rendering in Chrome
  32. http-archive
  33. HTTP缓存之协商缓存和强制缓存