介绍了交互至下一次绘制(INP)取代了首次输入延迟(FID)作为核心 Web 性能指标。INP 衡量用户与页面元素交互时的顿挫感,考虑了交互的每个部分对性能的影响,包括输入延迟、处理时间和呈现延迟。还指出了如何理解 JavaScript 的主线程执行模型以及如何优化 INP 以提升网页性能。今日文章由 @飘飘翻译分享。
从 2023 年 3 月 12 日起,INP 将取代 "首次输入延迟"(FID),成为核心 Web Vital 指标。
FID 和 INP 在浏览器中衡量的是相同情况:当用户与页面上的元素交互时,感觉有多笨拙?对于 Web 及其用户来说,好消息是 INP 通过考虑交互的每个部分和渲染响应,提供了更好的真实性能表现。
对您来说这也是一个好消息:您为确保 FID 的良好分数而采取的措施,将使您在获得可靠的 INP 分数的道路上迈出坚实的一步。当然,任何数字 -- 不管是令人舒心的绿色还是令人担忧的红色 -- 在不知道其确切来源的情况下,都不会有任何特别的用处。事实上,理解替换的最佳方法是更好地理解被替换的内容。正如前端性能的许多方面一样,关键在于了解 JavaScript 如何使用主线程。正如您所想象的那样,每个浏览器管理和优化任务的方式都略有不同,因此本文会将简化一些概念,但请不要误会,您对 JavaScript 的事件循环了解得越深入,就越能更好地处理各种前端性能工作。
您可能在过去听说过将 JavaScript 描述为 "单线程" 的说法,虽然自 Web Worker 出现后,这种说法已不完全正确,但它仍然是描述 JavaScript 同步执行模式的有用方式。在一个给定的 "领域" 内,比如 iframe、浏览器标签页或 Web Worker,一次只能执行一个任务。在浏览器选项卡的上下文中,这种顺序执行被称为主线程,它与其他浏览器任务共享,如解析 HTML、某些 CSS 动画以及渲染和重新渲染页面的某些部分。
JavaScript 使用一种名为 "调用栈"(或简称 "栈")的数据结构来管理 "执行上下文"-- 即当前主线程正在执行的代码。当脚本启动时,JavaScript 解释器会创建一个 "全局上下文" 来执行代码的主体 -- 任何存在于 JavaScript 函数之外的代码。全局上下文被推送到调用栈,并在那里执行。
当解释器在全局上下文的执行过程中遇到函数调用时,它会暂停全局执行上下文,为该函数调用创建一个 "函数上下文"(有时也称为 "局部上下文"),并将其推送到堆栈顶部,然后执行该函数。如果该函数调用包含一个函数调用,则会为其创建一个新的函数上下文,将其推到栈顶并立即执行。栈中最高的上下文总是当前正在执行的上下文,当执行结束时,它会从栈中弹出,这样下一个最高的执行上下文就可以继续执行 --"后进先出"。最终,执行将回到全局上下文,要么遇到另一个函数调用,执行将通过该函数调用和调用所包含的任何函数逐次向上和向下执行,要么全局上下文结束,调用栈清空。
如果 "按照遇到的顺序一个一个地执行每个函数" 就是全部内容,那么执行任何异步任务(例如从服务器获取数据或触发事件处理程序的回调函数)的函数都将是性能灾难。该函数的执行上下文要么会阻塞执行,直到异步任务完成,该任务的回调函数启动,要么会突然中断任务完成时调用堆栈中的任何函数上下文。因此,除了堆栈之外,JavaScript 还使用了由 "事件循环" 和 "回调队列"(或 "消息队列")组成的事件驱动 "并发模型"。
当异步任务完成并调用其回调函数时,该回调函数的函数上下文会被放入回调队列,而不是调用栈的顶部 -- 它不会立即接管执行。位于回调队列和调用栈之间的是事件循环,它不断轮询回调队列中是否存在函数执行上下文,以及调用栈中是否有空位。如果回调队列中有函数执行上下文在等待,而事件循环确定调用堆栈是空的,那么该函数执行上下文就会被推送到调用堆栈,并像同步调用一样执行。
例如,我们有一个脚本,使用老式的 setTimeout 在 500 毫秒后向控制台记录日志:
setTimeout( function myCallback() {
console.log( "Done." );
}, 500 );
// Output: Done.
首先,为脚本正文创建一个全局上下文并执行。全局执行上下文会调用 setTimeout 方法,因此会在调用栈顶部创建 setTimeout 的函数上下文,并执行该函数 -- 这样计时器就开始滴答作响了。然而,myCallback 函数并没有被添加到堆栈中,因为它还没有被调用。由于 setTimeout 没有其他事情可做,它被从堆栈中弹出,全局执行上下文恢复。在全局上下文中没有其他事情可做,所以它从堆栈中弹出,而堆栈现在是空的。
现在,在这一系列事件中的任何时候,我们的定时器都会过期,从而调用 myCallback。此时,回调函数将被添加到回调队列中,而不是被添加到堆栈中并中断其他正在执行的操作。一旦调用栈为空,事件循环就会将 myCallback 的执行上下文推送到栈中执行。在这种情况下,主线程早在定时器结束前就完成了工作,而我们的回调函数会立即添加到空的调用栈中:
const rightNow = performance.now();
setTimeout( () => {
console.log( `The callback function was executed after ${ performance.now() - rightNow } milliseconds.` );
}, 500);
// Output: The callback function was executed after 501.7000000476837 milliseconds.
在主线程没有其他事情可做的情况下,我们的回调会准时触发,大约需要一两毫秒。但是,一个复杂的 JavaScript 应用程序在全局执行上下文结束之前,可能有数以万计的函数上下文需要执行,而浏览器的速度再快,这些事情也需要时间。因此,让全局执行上下文通过一个 while 循环保持忙碌,并快速计数到 5 亿 -- 这是一项漫长的任务,从而伪造出一个拥挤不堪的主线程。
const rightNow = performance.now();
let i = 0;
setTimeout( function myCallback() {
console.log( `The callback function was executed after ${ performance.now() - rightNow } milliseconds.`);
}, 500);
while( i < 500000000 ) {
i++;
}
// Output: The callback function was executed after 1119.5999999996275 milliseconds.
全局执行上下文再次创建并执行。几行代码后,它调用了 setTimeout 方法,因此在调用栈顶部创建了 setTimeout 的函数执行上下文,计时器开始滴答作响。setTimeout 的执行上下文完成并从堆栈中弹出,全局执行上下文恢复,我们的 while 循环开始计数。
与此同时,500 毫秒计时器计时结束,myCallback 被添加到回调队列中,但这次调用栈并不是空的,事件循环必须等待全局执行上下文的剩余时间,才能将 myCallback 移到栈中。与处理整个客户端渲染的网页所需的复杂处理相比,对于在现代笔记本电脑上运行的现代浏览器来说,"数到一个相当大的数字" 并不是最繁重的工作,但我们仍然可以看到结果上的巨大差异:在我的例子中,输出显示所需的时间是预期时间的两倍多。
现在,我们使用 setTimeout 是出于可预测性的考虑,但事件处理程序的工作方式也是一样的:当 JavaScript 解释器在全局或函数上下文中遇到事件处理程序时,事件就会被绑定,但与该事件监听器相关的回调函数不会被添加到调用堆栈中,因为该回调函数尚未被调用 -- 直到事件触发为止。一旦事件触发,该回调函数就会添加到回调队列中,就像我们的计时器耗尽一样。那么,如果一个事件回调开始,比如说,当主线程被埋在长任务中时,会发生什么?为了让一个 JavaScript 密集的页面启动并运行,需要进行数兆字节的函数调用。
如果用户立即点击了这个 button 元素,回调函数的执行上下文就会创建并添加到回调队列中,但在堆栈中有足够空间之前,它无法被移动到堆栈中。从纸面上看,几百毫秒似乎并不算什么,但用户交互与交互结果之间的任何延迟都会对感知性能造成巨大影响 -- 问问小时候玩过任天堂的人就知道了。这就是 "首次输入延迟":在主线程闲置的情况下,对用户触发事件处理程序的第一个点与调用事件处理程序回调函数的第一个机会之间的延迟进行测量。一个页面在解析和执行大量 JavaScript 以获得渲染和功能时会陷入困境,因此在调用堆栈中没有空间让事件处理程序的回调函数立即排队,这意味着用户交互与回调函数被调用之间的延迟会更长,页面也会感觉缓慢、滞后。
这就是 "首次输入延迟"-- 一个非常重要的指标,但它并不能反映用户体验页面的全部情况。
毫无疑问,事件与事件处理程序的回调函数执行之间的长时间延迟是不好的,但在现实世界中,"回调函数的执行上下文被移动到调用堆栈的机会" 并不是用户点击按钮时想要的结果。真正重要的是交互与交互的可见结果之间的延迟。
这就是 "下一次绘制的交互" 所要测量的内容:用户交互与浏览器下一次绘制之间的延迟,即向用户提供交互结果可视化反馈的最早机会。在用户访问页面期间测量的所有交互中,交互延迟最差的交互将作为 INP 分数显示,毕竟在追踪和修复性能问题时,我们最好先处理坏消息。
总而言之,交互有三个部分,所有这些部分都会影响页面的 INP:输入延迟、处理时间和呈现延迟。
事件处理程序的回调函数从回调队列到主线程需要多长时间?
现在你对这个问题已经了如指掌 -- 它与 FID 曾经捕捉到的指标是一样的。不过,INP 比 FID 更进一步:FID 仅基于用户的第一次交互,而 INP 则考虑了用户在页面上的所有交互,以便更准确地反映页面的总体响应速度。INP 跟踪硬件或屏幕键盘上的任何点击、敲击和按键操作 -- 这些互动最有可能促使页面发生可见的变化。
与事件相关的回调函数运行需要多长时间?
即使事件处理程序的回调函数立即启动,该回调函数也会调用更多的函数,从而填满调用堆栈,并与主线程上的其他工作竞争。
const myButton = document.querySelector( "button" );
const rightNow = performance.now();
myButton.addEventListener( "click", () => {
let i = 0;
console.log( `The button was clicked ${ performance.now() - rightNow } milliseconds after the page loaded.` );
while( i < 500000000 ) {
i++;
}
console.log( `The callback function was completed ${ performance.now() - rightNow } milliseconds after the page loaded.` );
});
// Output: The button was clicked 615.2000000001863 milliseconds after the page loaded.
// Output: The callback function was completed 927.1000000000931 milliseconds after the page loaded.
假设主线程中没有其他阻塞并妨碍该事件处理程序的回调函数,那么该点击处理程序的 FID 分数就会很高,但回调函数本身包含一个庞大而缓慢的任务,可能需要很长时间才能运行并向用户显示结果。缓慢的用户体验,用一个欢快的绿色结果来概括是不准确的。
与 FID 不同,INP 将这些延迟也考虑在内。用户交互会触发多个事件 -- 例如,键盘交互会触发按下、按上和按下事件。对于任何给定的交互,INP 都会捕捉 "交互延迟" 最长的事件的结果,即用户交互与渲染响应之间的延迟。
主线程进行渲染和合成工作的速度如何?
请记住,主线程不仅要处理 JavaScript,还要处理渲染。处理事件处理程序创建的所有任务所花费的时间现在都在与主线程的其他进程竞争,所有这些进程现在都在与绘制结果所需的布局和样式计算竞争。
现在,您已经对 INP 的测量方法有了更好的了解,是时候开始在现场收集数据和在实验室进行修补了。
对于 Chrome 浏览器用户体验报告数据集中包含的任何网站,PageSpeed Insights 都是开始了解网页 INP 的好地方。要从各种不可知的连接速度、设备能力和用户行为中收集真实世界的数据,Chrome 浏览器团队的网络生命周期 JavaScript 库(或专注于性能的第三方用户监控服务)可能是最好的选择。
然后,一旦您从现场测试中了解到您的网页最大的 INP 问题,Web Vitals Chrome 扩展就可以让您在浏览器中对交互进行测试、修补和重新测试 -- 虽然不如现场数据那样具有代表性,但对于处理现场测试中出现的任何棘手的时间问题却至关重要。
现在,您已经对 INP 在幕后的工作原理有了更好的了解,并能找出网页中最大的 INP 问题所在,是时候开始整顿了。从理论上讲,INP 的优化非常简单:去掉那些冗长的任务,避免复杂的布局重新计算让浏览器不堪重负。
遗憾的是,简单的概念在实践中并不能转化为快速、简单的技巧。就像大多数前端性能工作一样,优化 "Next Paint" 也是一个 "寸进" 的游戏 -- 测试、修补、重新测试,逐步将页面调整到更小、更快、更尊重用户时间和耐心的程度。
Next Paint 的交互 (INP) 是一个新的 Core Web Vital 指标,专注于响应能力,计划于 2024 年 3 月 12 日取代首次输入延迟。使用正确的工具来监控和跟踪 INP 可以更轻松地进行优化。
INP 衡量网站访问者在执行单击按钮或键入等操作后等待的时间以及网站提供视觉反馈所需的时间。INP 是一个指标,显示用户交互后视觉反馈被阻止的时间量。
广告
这个指标背后的想法是,一个无响应的网页是一种糟糕的用户体验。例如,将产品添加到购物车中应立即产生视觉反馈响应,向网站访问者显示交互已得到响应。在该特定示例中,INP 不测量将产品添加到购物车所需的时间,它仅测量该操作的视觉反馈被阻止的时间。
较低的 INP 分数意味着快速响应时间,这是目标。良好的 INP 分数是 200 毫秒以下的分数。
JavaScript 和 CSS 是 INP 优化的主要目标。
INP 测量以下用户交互:
广告
没有工具可以单枪匹马地解决 INP 问题,因为问题源于网页上使用的主题、插件、特性和额外功能所使用的 JavaScript 和 CSS。
例如,安装和使用图像轮播或动画效果将加载额外的 JavaScript 和 CSS 代码,这可能会对 INP 分数产生负面影响。缩小 JavaScript 和 CSS 并不总是解决方案,这意味着优化 Interaction To Next Paint 的一个关键步骤是审核代码并识别任何无助于网页和用户实现其目的的内容。
因此,INP 优化工具的关键功能是识别阻碍或延迟用户交互视觉反馈的原因。
广告
1. Google 的 Site Kit – Analytics、Search Console、AdSense、Speed
WordPress Plugin by Google
Google 的 Site Kit 拥有超过 400 万次 WordPress 安装,是将 Google 搜索数据集成到 WordPress 仪表板中的最强大方法之一,以便在 WordPress 中轻松访问。
此工具显示 PageSpeed Insights 和 Search Console 数据,包括有关改进措施的可操作建议。
2. DebugBear 与下一个画图工具的交互(免费版和付费版)
免费INP调试器
DebugBear 是一种流行的页面速度监控工具,它有一个专业版,提供计划测试、事件通知、性能测试,在实时部署之前预览影响,这是另一个好处。
但它也提供免费工具,例如这个出色的 Interaction to Next Paint 工具,该工具将抓取网页并诊断问题,并提供解决 Interaction To Next Paint 问题的可操作提示。
3. Web Vitals Chrome 扩展程序
此 Chrome 扩展程序提供核心 Web Vitals 指标,包括 INP。此扩展的一个有用功能是覆盖网页的独特平视显示器 (HUD),这在开发或更改网页时会很有帮助。
4. TREO网站速度
Treo 网站速度工具提供令人难以置信的快速页面速度工具,具有易于阅读和理解的有吸引力的用户界面。
Treo 的一个有用功能是用户可以调整选择以查看特定国家/地区的指标。
5. Chrome Web 指标库
有一个高级工具用于衡量来自实际网站访问者的核心 Web 生命指标,这些指标可以由各个发布者部署在他们自己的 Web 服务器上。此工具可以使发布者查看真实的核心 Web 指标分数,这些分数对于解决实际网页问题非常有用。此处提供了概述和解释。
Looker Studio(以前称为 Google Data Studio)是一种数据可视化工具,使用户能够连接到数据源并通过易于理解的报告和仪表板可视化信息。只需导航到此处即可找到 Chrome UX 报告连接器,该连接器可以将 Chrome UX (CrUX) 原始数据转换为报告,从而轻松查看真实世界的 CWV 趋势。
Google 在面向开发人员的 Chrome 页面中发布了一个解释器,展示了如何在 Looker 上设置 CrUX 仪表板。看看吧,使用这个工具很容易成为超级明星 SEO!
网站建设者平台 Duda 预测了 Next Paint 的互动,并且已经努力确保使用其平台的网站在新指标上得分很高。
根据平台战略总监Russ Jeffery的说法:
“网站性能对于依赖 Duda 为其客户网站提供服务的代理商和 SaaS 公司来说非常重要。帮助他们的客户在搜索中排名更高,提供更好的转化率和用户体验,并最终推动更好的结果。
在撰写本文时,在Duda上构建的所有网站中,约有75%已经在INP的“良好”范围内。
基于 Duda 构建的网站还可以使用 Duda App Store 中内置的 WooRank 等第三方工具跟踪和优化这些指标。客户可以设置自定义 Google Analytics/Google 跟踪代码管理器事件,将这些指标发送到 GA4 中。
虽然 INP 可能不是直接的排名因素,但 INP 仍然是创建最快页面体验的有用指标,因为众所周知,网站速度可以提高销售额、点击次数和广告浏览量,并且它与 Google 用于排名的信号一致。
情是这样的:大家都知道“内存泄露”这回事吧。它有几个常见的场景:
1.闭包使用不当引起内存泄漏
2.(未声明的)全局变量
3.分离的DOM节点
4.(随意的)控制台的打印
5.遗忘的定时器
6.循环引用
内存泄漏需要重视,它是如此严重甚至会导致页面卡顿,影响用户体验!
其中第 3 点引起了我的注意 —— 我当然清楚地知道它说的是比如:“假设你手动移除了某个dom节点,本应释放该dom节点所占用的内存,但却因为疏忽导致某处代码仍对该被移除节点有引用,最终导致该节点所占内存无法被释放”的情况
<div id="root">
<div class="child">我是子元素</div>
<button>移除</button>
</div>
<script>
let btn = document.querySelector('button')
let child = document.querySelector('.child')
let root = document.querySelector('#root')
btn.addEventListener('click', function() {
root.removeChild(child)
})
</script>
该代码所做的操作就是点击按钮后移除.child的节点,虽然点击后,该节点确实从dom被移除了,但全局变量child仍对该节点有引用,所以导致该节点的内存一直无法被释放。
解决办法:我们可以将对.child节点的引用移动到click事件的回调函数中,那么当移除节点并退出回调函数的执行上文后就会自动清除对该节点的引用,自然也就不会存在内存泄漏的情况了。(这实际上是在事件中实时检测该节点是否存在,如果不存在则浏览器必不会触发remove函数的执行)
<div id="root">
<div class="child">我是子元素</div>
<button>移除</button>
</div>
<script>
let btn = document.querySelector('button')
btn.addEventListener('click', function() {
let child = document.querySelector('.child')
let root = document.querySelector('#root')
root.removeChild(child)
})
</script>
这段代码很完美么?不。因为它在每次事件触发后都创建了对child和root节点的引用。消耗了内存(你完全可以想象一些人会狂点按钮的情况…)。
其实还有一种办法:我们在click中去判断当前root节点中是否还存在child的子节点,如果存在,则执行remove函数,否则什么也不做!
这就引发了标题中所说的行为。
怎么判断?
遍历?不,太过麻烦!
不知怎的,我突然想到了 for...in 中的 in 操作符,它可以基于原型链遍历对象!
我们来还原一下当时的场景:打开GitHub,随便找一个父节点,并获取它:
图中画红框的就是我们要取的父元素,橘红色框的就是要判断是否存在的子元素。
let parent=document.querySelector('.position-relative');
let child=document.querySelector('.progress-pjax-loader');
这里注意,因为获取到的是DOM节点(类数组对象),所以我们在操作前一定要先处理一下:
let p_child=[...parent.children];
然后
console.log(child in p_child);
为什么呢?(此时笔者还没有意识到事情的严重性)
我想,是不是哪里出了问题,用es6的includes API验证一下:
console.log(p_child.includes(child));
没错啊!
再用一般的数组验证一下:
再用一般的数组验证一下:
???
此时,笔者才想起到MDN上查阅一番:
进而我发现:in操作符单独使用时它检测的是左侧的值(作为索引)对应的值是否在右侧的对象内部(属性 & 原型上)!
回到上面的代码中,我们发现:
这验证了我们的结论。
很显然,“子元素”并不等同于“存在于原型链上” —— 这又引出了一个知识点:attribute和property的区别!
所以经过一番“折腾”,源代码还是应该直接这样写:
<div id="root">
<div class="child">我是子元素</div>
<button>移除</button>
</div>
<script>
let btn = document.querySelector('button')
let child = document.querySelector('.child')
let root = document.querySelector('#root')
let r_child = [...root.children]
btn.addEventListener('click', function() {
if(r_child.includes(child)){ // 或者你这里直接判断child是否为null也可以...吧
root.removeChild(child)
}
})
</script>
原文链接:https://blog.csdn.net/qq_43624878/article/details/115591008
*请认真填写需求信息,我们会在24小时内与您取得联系。