如何衡量代码速度?如何编写合理高效的代码?全面学习 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”),而且该库还会计算更新屏幕进行匹配的最佳方式。美中不足的是,你必须重新编写代码来使用你正在采用的库,有时你自己来更新屏幕反而会更好(因为你了解自己的独特情况)。
调用堆栈
单线程
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
函数里面,然后在该函数中往下移动。
当它抵达该函数的底部时,执行完成,该函数从调用堆栈中删除了。回到离开的位置并继续。
同样当它抵达该函数的底部时,执行完毕。该函数从调用堆栈中被删除,然后从离开的位置继续,抵达文件的末尾并结束。
事件循环
代码同步性
我们所看到的所有代码都是按顺序、同时运行的。函数被添加到调用堆栈中,并在完成时从调用堆栈中移除。但是,有些代码并不同步——也就是说,虽然该代码的编写方式与其他代码一样,但它会在稍后的时间点执行。
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()
函数。