性能

如何衡量代码速度?如何编写合理高效的代码?全面学习 JS 事件循环以及它会如何影响代码编写过程?

高效添加页面内容

使用循环添加内容

for (let i = 1; i <= 200; i++) {
    const newElement = document.createElement('p');
    newElement.textContent = 'This is paragraph number ' + i;

    document.body.appendChild(newElement);
}

上述代码的运行过程是:

  • 创建一个段落元素
  • 向段落中添加一些文本
  • 将段落添加到页面上
  • 循环执行两百次这样的操作

改进上述代码:

  • 在循环外创建一个父容器元素
  • 将所有新的段落元素附加到这个父容器
  • 将这个父容器附加到 <body> 元素,而不是每次都通过循环来附加

改进后的代码如下:

const myCustomDiv = document.createElement('div');

for (let i = 1; i <= 200; i++) {
  const newElement = document.createElement('p');
  newElement.innerText = 'This is paragraph number ' + i;

  myCustomDiv.appendChild(newElement);
}

document.body.appendChild(myCustomDiv);

我们可以测试实际运行此代码所需的时间!

测试代码性能

使用 performance.now() 来测量代码速度的步骤:

  • 使用 performance.now() 获取代码的初始启动时间
  • 运行你想测试的代码
  • 执行 performance.now() 再次进行时间测量
  • 从最终时间中减去初始时间

我们添加 performance.now() 代码来测量这些循环的运行时间:

const startingTime = performance.now();

for (let i = 1; i <= 100; i++) { 
  for (let j = 1; j <= 100; j++) {
    console.log('i and j are ', i, j);
  }
}

const endingTime = performance.now();
console.log('This code took ' + (endingTime - startingTime) + ' milliseconds.');

比较改进前和改进后的代码运行时长:

这里写图片描述

使用文档片段

到目前为止,我们已经对此代码进行了一些改进。但是,仍有一点似乎不尽人意;我们必须创建一个额外的 <div>元素,仅仅为了储存所有 <p> 标签,以便一次性添加它们,然后再将这个 <div> 附加到 <body> 元素中。因此,我们最终会有一个并非真正需要的多余的 <div>。它的存在只是因为我们想把每个新的 <p> 添加到它,而不是 <body> 中。

我们为什么要这样做呢?浏览器一直在努力使屏幕与 DOM 相匹配。当我们添加一个新元素时,浏览器必须运行重排计算(以确定新的屏幕布局),然后“重绘”屏幕,这需要时间。

如果我们将每个新段落添加到 body 元素中,那么代码将会慢很多,因为这会导致浏览器为每个段落进行重排和重绘过程。我们只想让浏览器执行一次这个操作,所以我们需要将每个新段落附加到某个地方,但我们又不想将一个多余的、并不需要的元素添加到 DOM 中。

DocumentFragment,表示没有父项的最小文档对象。它可以用作文档的简化版本,存储文档结构的一部分,并像标准文档一样由节点组成。关键区别在于,由于这个文档片段并不是活动文档树结构的一部分,因此对这个片段所作的更改不会影响文档、导致重排,或在进行更改时引起任何性能影响

我们可以使用 .createDocumentFragment() 方法创建一个空的 DocumentFragment 对象

const fragment = document.createDocumentFragment();  // ← 使用 DocumentFragment 而不是 <div>

for (let i = 0; i < 200; i++) {
    const newElement = document.createElement('p');
    newElement.innerText = 'This is paragraph number ' + i;

    fragment.appendChild(newElement);
}

document.body.appendChild(fragment); // 在这里重排(reflow)与重绘(repaint)--仅此一次!

MDN 上的性能API
MDN 上的 DocumentFragment API

重排和重绘

重排是指浏览器布置页面(计算页面元素的尺寸和位置)的过程,这是一个计算密集(缓慢)的任务。当你第一次显示 DOM 时(通常是在 DOM 和 CSS 加载之后)会发生重排,而且每当某个操作会导致布局改变时,都会再次发生重排。这是一个代价很高(缓慢)的过程

重绘发生在重排之后,是指浏览器将新布局(像素)绘制到屏幕上的过程。它相对较快,但我们还是想限制它发生的频率

仅仅添加一个 CSS 类也可能会触发重排?

一般来说,如果你需要进行一组更改,而且所作的更改相对有限,那么隐藏/更改所有/显示将是一个很好用的模式。

虚拟 DOM

这正是 React 和其他“虚拟 DOM”库如此受欢迎的原因。你不会更改 DOM,而是更改另一个结构(一个“虚拟 DOM”),而且该库还会计算更新屏幕进行匹配的最佳方式。美中不足的是,你必须重新编写代码来使用你正在采用的库,有时你自己来更新屏幕反而会更好(因为你了解自己的独特情况)。

网站性能优化

PageSpeed 工具指南中的最小化浏览器重排

Google 网络基础指南中的避免大型复杂布局和布局抖动

调用堆栈

单线程

JavaScript 是单线程,即一次可以“处理”一个命令。我们将查看 JavaScript 的单线程模型,探讨为什么应该编写代码来利用它,以及如何做到这一点。

function addParagraph() {
    const para = document.createElement('p');
    para.textContent = 'JavaScript is single threaded!';
    document.body.appendChild(para);
}

function appendNewMessage() {
    const para = document.createElement('p');
    para.textContent = "Isn't that cool?";
    document.body.appendChild(para);
}

addParagraph();
appendNewMessage();

上述代码按照运行顺序进行分解:

  • 声明 addParagraph() 函数
  • 声明 appendNewMessage() 函数
  • 调用 addParagraph(),执行进入函数并按顺序执行所有三行,函数完成后,执行返回它被调用的位置
  • 调用 appendNewMessage() 函数,执行进入函数并按顺序执行所有三行,函数完成后,执行返回它被调用的位置
  • 程序结束,因为所有代码行均已被执行

addParagraph() 被调用、运行和完成先于 appendNewMessage() 被调用(包括可能的重排和重绘);JavaScript 不会同时执行多个代码行/函数(这就是单线程…一次处理一个命令!)。

调用堆栈

JavaScript 引擎会保持一个正在运行函数的调用堆栈(基本上就是一个列表)。当一个函数被调用时,它会被添加到列表中。当一个函数中的所有代码均已运行时,该函数就会从调用堆栈中移除。调用堆栈很棒的一点是,现有函数不必完成,即可将另一个函数添加到调用堆栈中。

让我们来看看它是如何运作的!

代码示例:

function addParagraph() {
    const para = document.createElement('p');
    para.textContent = 'JavaScript is single threaded!';

    appendNewMessage();
    document.body.appendChild(para);
}

function appendNewMessage() {
    const para = document.createElement('p');

    para.textContent = 'Isn't that cool?';
    document.body.appendChild(para);
}

addParagraph();

我们打开调用堆栈跟踪函数调用,现在运行文件。调用此文件会向堆栈中添加一个指示符,叫做,告诉我们正在运行的是什么,我用文本 main 进行标记。但有些 JS 引擎使用单词 anonymous global 或类似单词。

这里写图片描述

第一行是声明 addParagraph 函数,继续是声明 appendNewMessage 函数,最后是调用 addParagraph 函数。调用该函数会将其添加到调用堆栈中,执行操作移到该函数中。

这里写图片描述

然后在该函数中继续,直到第 4 行调用了 appendNewMessage 函数 ,它被添加到了调用堆栈中。

这里写图片描述

执行操作移到 appendNewMessage 函数里面,然后在该函数中往下移动。

这里写图片描述

当它抵达该函数的底部时,执行完成,该函数从调用堆栈中删除了。回到离开的位置并继续。

这里写图片描述

同样当它抵达该函数的底部时,执行完毕。该函数从调用堆栈中被删除,然后从离开的位置继续,抵达文件的末尾并结束。

这里写图片描述

MDN 上的调用堆栈

事件循环

代码同步性

我们所看到的所有代码都是按顺序、同时运行的。函数被添加到调用堆栈中,并在完成时从调用堆栈中移除。但是,有些代码并不同步——也就是说,虽然该代码的编写方式与其他代码一样,但它会在稍后的时间点执行。

const links = document.querySelector('input');
const thirdField = items[2];

thirdField.addEventListener('keypress', function handleKeyPresses(event) {
    console.log('a key was pressed');
});

事件监听器的函数 handleKeyPresses 不会立即被调用,而是会在稍后的时间点被调用。

.addEventListener() 代码相同的问题:

  • 函数在哪里等待运行?
  • 函数如何在需要时运行?

这种情况是因为有 JavaScript 事件循环!

JavaScript 事件循环

Javascript 的并发模型最简单的解释是使用两条规则: 如果某些 Javascript 正在运行,则让其运行,直到其完成(“运行至完成”)。 如果没有 Javascript 正在运行,则运行任何等待的事件处理器。

由于大多数 Javascript 都是为了响应事件而运行的,因此这被称为事件循环:获取下一个事件,运行其处理器,然后重复。

围绕事件循环,你必须考虑三个部分:

  • 调用堆栈
  • Web API/浏览器
  • 事件队列

这里写图片描述

我们所编写的代码并非都是 100% 的 JavaScript 代码。有些代码会与 Web API(也称为“浏览器 API”)进行交互。这样的例子有很多,比如 .addEventListener()setTimeout() 就是 Web API。

console.log('howdy'); // 1
document.addEventListener('click', // 2
  function numbers() {
    console.log('123');
});
console.log('ice cream is tasty'); // 3

首先,浏览器会运行这个代码块直到完成,即步骤 1、2 和 3。第 2 步会传递一个事件处理器 (numbers) 到浏览器中,以备后用:浏览器将保留这个函数,直到发生点击事件。

如果在这段代码完成之前有人点击页面,会发生什么?当有一个点击事件,同时仍有代码正在运行时,numbers 函数无法被直接添加到调用堆栈中,这是由于 JavaScript 的运行至完成特性;我们不能打断任何可能正在发生的代码。因此,这个函数将被放置在队列中。当调用堆栈中的所有函数均已完成时(也称为空闲时间),则会检查队列,以查看是否有任何内容正在等待。如果队列中有任务,则会运行,并在调用堆栈上创建一个条目。

重要提示:这里要记住的关键点是 1) 当前的同步代码将会运行至完成,而且 2) 事件将在浏览器不繁忙时进行处理。异步代码(如加载图像)在此循环之外运行,并在完成时发送事件。

首先是调用堆栈,然后是浏览器和队列。开始代码执行并逐步讲解整个流程。

这里写图片描述

从第一行开始运行 console.log 并将其添加到调用堆栈中。

这里写图片描述

运行完毕后,从调用堆栈中删除。继续,到了事件监听器。被添加到调用堆栈中,因为代码正在被评估,然后转到浏览器。浏览器内部状况对我们来说不可见,但是当浏览器被点击时,它知道运行 numbers 函数。

这里写图片描述

运行 console.log 并结束。

这里写图片描述

每当浏览器被点击时,numbers 函数就被移动到队列中,等待调用堆栈被清空。

这里写图片描述

然后 numbers 函数被移到调用堆栈中并被调用。调用 numbers 函数将调用另一个 cobsole.log

这里写图片描述

结束后,会从列表中被移除。numbers 函数结束并被删除。

并发模型和事件循环
事件和处理器概述

稍后运行代码

与延后运行的 .addEventListener() 代码类似,setTimeout() 函数也可以在稍后的时间点运行代码。setTimeout() 函数需要:

  • 一个稍后运行的函数
  • 运行函数之前代码应该等待的毫秒数
// 字符串 'Howdy' 将在大约 1000 毫秒或大约 1 秒后出现在控制台中
setTimeout(function sayHi() {
    console.log('Howdy');
}, 1000);

由于 setTimeout() 是浏览器提供的一个 API,因此对 setTimeout() 的调用会将 sayHi() 函数传递给浏览器,并将启动定时器。定时器完成后,sayHi() 函数将被移到队列中。如果调用堆栈为空,则 sayHi() 函数将被移到调用堆栈中,并被执行。

setTimeout() 延迟为 0

setTimeout() 很特别的一点是,我们可以向它传递一个 0 毫秒的延迟。

setTimeout(function sayHi() {
    console.log('Howdy');
}, 0);  // ← 0 毫秒!

你可能会认为,既然它的延迟为 0 毫秒,那么 sayHi 函数将会立即运行。然而,它仍然会经过 JavaScript 事件循环。因此,这个函数会被传递给浏览器,然后浏览器会启动一个 0 毫秒的定时器。由于该定时器会立即结束,因此 sayHi 函数将被移到队列中,并在调用堆栈完成执行任何当前任务之后,再被移到调用堆栈中。

拆解运行较久的代码

让用户有机会与页面进行交互的一种方式,就是将添加内容的任务拆解为小块。让我们使用 setTimeout() 来实现这一点:

let count = 1

function generateParagraphs() {
    const fragment = document.createDocumentFragment();

    for (let i = 1; i <= 500; i++) {
        const newElement = document.createElement('p');
        newElement.textContent = 'This is paragraph number ' + count;
        count = count + 1;

        fragment.appendChild(newElement);
    }

    document.body.appendChild(fragment);

    if (count < 20000) {
        setTimeout(generateParagraphs, 0);
    }
}

generateParagraphs();

这段代码首先将 count 变量设置为 1。它将跟踪已添加段落的数量。每当 generateParagraphs() 函数被调用时,它都会在页面中添加 500 个段落。有趣的是,generateParagraphs() 函数末尾有一个 setTimeout() 调用。如果有不到两万个元素,则会使用 setTimeout() 来调用 generateParagraphs() 函数。

MDN 上的 setTimeout 文档
并发模型和事件循环

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值