js加载和长任务
本文将讲解以下浏览器如何加载js
,并介绍一些可以提高网页加载速度的方法。
Evaluate Script
如果我们在devtools
的performance
中分析过网站的加载性能,可能会看到一个很长的任务,叫做Evaluate Script
.
在这种情况下,该工作足以导致长时间任务,从而阻止主线程承担其他工作(包括驱动用户交互的任务).
Evaluate Script
是在浏览器中执行 JavaScript
的必要部分,因为 JavaScript
在执行前即时编译。当评估脚本时,首先会分析它是否有错误。如果解析器没有发现错误,则脚本将被编译为字节码,然后可以继续执行。
因为用户可能会在页面最初显示后不久就尝试与页面进行交互=,这样就会导致Evaluate Script
出现问题(页面已呈现并不意味着页面已完成加载。由于页面正忙于评估脚本,因此加载期间发生的交互可能会延迟)。
总阻塞时间 (
TBT
)是一个可以让我们深入了解页面加载期间是否发生过多脚本评估的指标,因为它是一种负载响应指标。
script和评估它们的任务之间的关系
负责脚本评估的任务如何启动取决于网站正在加载的脚本是否是通过常规<script>
元素加载的,或者脚本是否是使用type=module
. 由于浏览器倾向于以不同的方式处理事物,因此主要浏览器引擎如何处理脚本评估将涉及它们之间的脚本评估行为的不同之处。
使用script元素
分派评估脚本的任务数量通常与页面上的<script>
元素数量有直接关系。每个<script>
元素都会启动一个任务来评估所请求的脚本,以便可以对其进行解析、编译和执行。
我们可以通过避免加载大块 JavaScript
来分解脚本评估工作,并使用其他<script>
元素加载更多单独的、更小的脚本。
由于设备的功能各不相同,因此很难为单个脚本的大小定义一个设定的限制。为了在压缩效率、下载时间和脚本评估时间之间取得良好的平衡,每个脚本的大小限制为
100 KB
是一个不错的指标。
因此我们在页面加载期间应该加载尽可能少的 JavaScript
,通过拆分脚本可确保我们拥有大量不会阻塞主线程的较小任务,而不是一个可能阻塞主线程的大型任务。
由于页面 HTML
中存在多个<script>
元素,因此产生了多个任务来评估脚本。这比向用户单独发送一个体积非常大的js
包更好,因为这样更有可能阻塞主线程。
在script元素添加type=module属性
通过在script
元素上添加type=module
属性可以在浏览器中本地加载 ES
模块。这种脚本加载方法具有一些开发人员体验优势,例如在生产环境中不需要转换代码就可以使用。但是,以这种方式加载脚本会根据浏览器的不同而具有不同的任务。
基于 Chromium 的浏览器
在 Chrome
等浏览器(或衍生自 Chrome
的浏览器)中,使用type=module
属性加载 ES
模块会产生与不使用时看到不同类型的任务。例如,每个将执行的模块脚本(携带type=module
)的任务,都呗标记为Compile module tasks
。
每个模块脚本都会生成一个编译模块任务,在评估之前编译其内容。
模块编译完成后,随后在其中运行的任何代码都标记为Evaluate module tasks
。
从上图可以看见使用type=module
需要承担一些不可避免的成本。虽然我们应该努力提供尽可能少的 JavaScript
,但使用 ES
模块(无论浏览器如何)都可以提供以下好处:
- 所有模块代码都会在严格模式下自动运行,这允许
JavaScript
引擎进行潜在的优化,而这些优化在非严格上下文中是无法实现的。 - 使用
type=module
属性在加载时会默认当作为defer
。可以在ES
加载的脚本上使用设置async
来更改此行为。
基于Safari 和 Firefox 的浏览器
当在 Safari
和 Firefox
中加载type=module
模块时,每个模块都会在单独的任务中进行评估。这意味着理论上我们可以将仅包含静态import
语句的单个顶级模块加载到其他模块中,并且加载的每个模块都会产生单独的网络请求和任务来进行评估。
使用动态import()
动态import()
是加载脚本的另一种方法。import
与需要位于 ES
模块顶部的静态语句不同,动态import()
调用可以出现在脚本中的任何位置,以按需加载 JavaScript
块。这种技术称为代码分割。
使用动态import()
有两个好处:
- 推迟加载的模块(设置
defer
)通过减少当前加载的JavaScript
量来减少启动期间的主线程争用。这释放了主线程,因此它可以更好地响应用户交互。 - 当进行动态
import()
调用时,每次调用都会有效地将每个模块的编译和评估分离到自己的任务中。当然,import()
加载非常大的模块的动态将启动相当大的脚本评估任务,并且如果交互与动态调用同时发生,import()
则可能会干扰主线程响应用户输入的能力。因此,加载尽可能少的JavaScript
还是非常重要的。
动态import()
调用在所有主要浏览器引擎中的行为都类似:结果的脚本评估任务将与动态导入的模块数量相同。
在web worker中加载js
Web Worker
是一个特殊的 JavaScript
用例。Web Worker
在主线程上注册,然后 Worker
中的代码会在自己的线程上运行。这可以减少主线程拥塞,并有助于保持主线程对用户交互的响应更加灵敏。
除了减少主线程工作之外,Web Worker
本身还可以importScripts
或者静态import
来加载外部js
,这样通过 Web Worker
请求的任何脚本都会在主线程之外进行评估。
总结
虽然将脚本分解为单独的较小文件有助于限制长时间任务,但是在决定如何分解脚本时也需要考虑以下几点:
压缩效率
压缩是分解脚本的一个因素。当脚本较小时,压缩效率会有所降低。较大的脚本将从压缩中受益更多。虽然提高压缩效率有助于尽可能缩短脚本的加载时间,但确保将脚本分解为足够小的块以在启动期间促进更好的交互性也是需要权衡决定的。
打包工具是一个非常理想的工具,可以用来管理我们的js
打包结果:
- 比如说
webpack
,可以通过SplitChunksPlugin
插件来管理打包后的js
的大小。 - 对于
Rollup
和esbuild
而言,可以通过在代码中使用动态import()
来管理脚本文件大小。这些打包工具会自动将动态import()
的资源分解到其他的文件中,从而避免生成较大的文件。
缓存
缓存对于一些需要重复访问时页面加载的速度起着重要作用。当我们的js
包非常大时,每次更新,之前的一些打包文件都会失效(多个文件都打包在一个js
文件里,当修改里边的一些代码时,整个打包文件都是更新过的),必须要重新下载。
通过分解脚本,我们不仅可以将脚本评估工作分解为较小的任务,还可以让用户尽量从浏览器的缓存中获取之前的页面内容,而不是需要重新下载文件。这意味着整体页面加载速度更快。
为了使缓存既高效又可以避免从缓存中提供过时的资源,可以在打包出的文件设置哈希值作为文件名。
嵌套模块和加载性能
我们要是在生产环境中使用 ES
模块并使用type=module
属性加载它们,则需要了解模块嵌套如何影响启动时间。模块嵌套是指一个 ES
模块静态导入另一个 ES
模块,而另一个 ES
模块又静态导入另一个 ES
模块:
// a.js
import {b} from './b.js';
// b.js
import {c} from './c.js';
如果这些模块没有被打包在一起,那么就会导致生成一个请求链:当一个script
元素中包含了a.js
的代码的时候,就会发送一个请求去请求b.js
文件,然后b.js
文件又会发送一个请求去请求c.js
文件。这种情况下除了修改代码将这些打包在一起外,还可以通过 modulepreload方式提前预加载 ES
模块以避免网络请求链(仅支持基于 Chromium
的浏览器).