Script评估与长任务(译文)

833 阅读7分钟

前言

在加载脚本时, 浏览器需要在执行前进行评估, 这就会导致长任务. 这里可以学习Script如何评估工作, 并且为了避免长任务我们又可以做些什么.

正文

在优化 Interaction to Next Paint (INP)时, 很多人会建议你优化交互本身. 举个例子, 在 optimize long tasks guide中, 有例如 setTimeoutisInputPending等方案. 这些方案是有收益的, 例如他们可以避免长任务从而让主线程拥有空余时间, 从而让其他交互可以执行, 而不是让他们等待长任务执行.

附: Interaction to Next Paint (INP) 是一个 pending 核心指标 并且将会在2024年三月 替代 First Input Delay (FID) . INP 通过 事件事件API(Event Timing API)获取相应数据. 当一个交互导致页面无响应, 这就是一个坏的用户体验.

但是,加载脚本本身产生的长任务又如何呢?这些任务可能会干扰用户交互,并在加载期间影响页面的INP。本指南将探讨浏览器如何处理脚本评估启动的任务,并探讨如何分解脚本评估工作,以便在加载页面时,您的主线程能够对用户交互做出更快的响应。

什么是脚本评估? #

当你扫描一个拥有大量JS脚本的应用时, 可以能会发现问题来源于 Evaluate Script的长任务.

image.png

脚本评估是浏览器执行代码时的必要步骤, 因为js是JIT just-in-time before execution编译模式.当脚本进行评估时,会因为解析错误而停止. 如果解析器没有发现错误, 脚本就会被编译为 二进制代码后继续执行.

虽然很重要,脚本评估依然存在问题, 当页面初步渲染后,用户就会进行尝试操作. 但是,页面初步加载并不意味着页面已经完成渲染. 在这时候触发的交互将因为页面忙于脚本评估而导致推迟. 虽然不能保证所需的交互能在此时发生——因为负责它的脚本可能还没有加载——但可能存在依赖于JavaScript的交互,或者交互根本不依赖于JavaScript.

评估脚本任务和脚本本身的关系 #

负责脚本评估的任务是如何启动的,这取决于您加载的脚本是通过常规的<script>元素加载的,还是脚本是用type=module加载的模块。由于浏览器倾向于以不同的方式处理问题,主要的浏览器引擎如何处理脚本评估将取决于它们之间的脚本评估行为的差异。

使用 <script> 元素加载脚本(不带type=module

分派用于评估脚本的任务数量通常与页面上<script>元素的数量直接相关。每个<script>元素启动一个任务来评估请求的脚本,以便对其进行解析、编译和执行。基于Chromium的浏览器、Safari、Firefox就是这样

为什么这很重要?假设您正在使用bundler来管理生产脚本,并且您已将其配置为将页面运行所需的所有内容捆绑到一个脚本中。如果你的网站是这种情况,你可以预期会有一个单独的任务来评估该脚本。这是件坏事吗?不一定,——除非这个脚本是巨大的。

您可以通过避免加载大块JavaScript来分解脚本评估工作,并使用额外的<script>元素加载更多单独的、更小的脚本。

所以在页面加载时,应该是尽可能使用小的<script>模块。把大的脚本分成小块可以避免页面堵塞 ,至少避免在页面初始化时堵塞。

image.png

加载脚本通过 <script>元素,并且携带 type=module 属性 #

原生浏览器通过在<script>标签上携带 type=module attribute属性,总而原生支持es modules加载. 这对于一些开发者来说是是有利的, 例如配合 import maps,从而在生产环境不需要进行代码转化. 但是使用这个方法在不同浏览器中是存在差异的。

Chromium-based browsers(chromium内核浏览器) #

在Chrome等浏览器中,或者从Chrome派生的浏览器中,使用type=module属性加载ES模块会产生与不使用type=module时不同类型的任务。例如,每个模块脚本都将运行一个任务,该任务涉及标记为“编译模块”(Compile module)的任务。

Module compilation work in multiple tasks as visualized in Chrome DevTools.

在chromium内核的浏览器加载模块时, 所有的模块的会触发 Compile module任务在脚本评估前编译他们的内容.

一旦模块编译完毕,随后在其中运行的任何代码都将启动标记为Evaluate module的活动。

Just-in-time evaluation of a module as visualized in the performance panel of Chrome DevTools.

这里的效果——至少在Chrome和相关浏览器中——是在使用ES模块时,编译步骤被分解了。就管理长期任务而言,这是一个明显的胜利;然而,由此产生的模块评估工作仍然意味着您要承担一些不可避免的成本。虽然您应该尽可能少拆分JavaScript,但无论使用什么浏览器,使用ES模块都会带来以下好处

  • 所有模块代码将使用 严格模式, 这允许JavaScript引擎进行潜在的优化,否则在非严格上下文中无法进行这些优化
  • .
  • 使用 type=module的脚本将被默认视为使用 deferred标记. 它也可以使用 async 属性来改变 type=module模块加载的流程. 如下图所示:

image.png

Safari 和 Firefox #

在Safari和Firefox中加载模块时,每个模块都会在单独的任务中进行评估。这意味着您理论上可以加载一个仅由staticimport组成的顶级模块语句,并且加载的每个模块将产生单独的网络请求和任务来评估它。

使用 import() #动态加载脚本

动态 import() 是另一种加载脚本的方式,不像静态import声明必须被放在ES module的顶部, 动态import()可以出现在js脚本中任何所需要的地方. 这种技术叫做 代码分割(code splitting).

动态import() 对于提升INP,有两个好处:

  1. 延迟加载的模块通过减少启动时加载的JavaScript数量来减少启动期间的主线程占用,因此它可以更快的对用户交互做出的响应。
  2. 当进行动态import()调用时,每个调用将有效地将每个模块的编译和评估分离到自己的任务中(也就是在进入该模块,才会开始拉取并解析)。当然,加载一个非常大的模块的动态import()将启动一个相当大的脚本评估任务,如果交互与动态“import()”调用同时发生,这可能会干扰主线程响应用户输入的能力。因此,尽可能少地加载JavaScript仍然非常重要

动态import()调用在所有主要的浏览器引擎中表现相似:结果的脚本评估任务将与动态导入的模块数量相同。

结论

相对数量少但是内容大的文件,将你的脚本分隔成更小的文件更加有利。所以考虑如何拆分脚本文件就是非常重要的事情。

  • 当使用非type=module<script>,避免文件过大而阻塞主线程。可以将其进行拆分
  • 使用 type=module属性在浏览器中将启动单独的任务,以便对每个单独的模块脚本进行评估。.
  • 使用动态import() 减少初始依赖大小.
  • 使用没有进行打包的es module, 使用 modulepreload 进行优化.

原文出处