JavaScript:性能

高效添加页面内容

使用循环添加内容

for (let i = 1; i <= 200; i++) {
    // 创建一个段落元素
    const newElement = document.createElement('p');
    // 向段落添加一些文本
    newElement.textContent = 'This is paragraph number ' + i;
    // 将段落添加到页面上
    document.body.appendChild(newElement);
}

重复性操作使用 for 循环是对的,但是对上述的 for 循环使用还可以优化一下

// 在循环外创建一些父容器元素
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);
}
// 将父容器附加到 <body> 元素
document.body.appendChild(myCustomDiv);

相比之前,现在是先将生成的 200 个子元素循环添加到父元素,到循环完成后再将带有 200 个子元素的父元素加入页面 <body> 元素内,这样操作比不断的操作 DOM 向页面插入元素的性能要好。

测试代码性能

你可能会想「你说好就好?有什么数据证吗?」,有的,测量代码运行时间的标准方法是使用 performance.now(),它会返回一个以毫秒为单位的时间戳,因此它非常精确。有多精确呢?以下是其文档页面的说明:

精确到千分之五毫秒(5 微秒)

以下是使用 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 和 j :', i, j);
  }
}

const endingTime = performance.now();
console.log('这段代码运行了 ' + (endingTime - startingTime) + ' 毫秒');

因为 performance.now() 计算的时间是从页面加载时开始测量,所以需要在测量的代码前后将时间存入变量,然后以结束时间减去开始时间就能得到运行时间。

现在自行试试使用 performance.now() 测量那两段生成两百个元素的时间吧。

使用文档片段

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

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

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

这就是要使用 DocumentFragment 的原因!根据相关文档,DocumentFragment

表示没有父项的最小文档对象。它可以用作文档的简化版本,存储文档结构的一部分,并像标准文档一样由节点组成。

所以,这就像另外创建一个简化版的 DOM 树一样。这样做的好处如下所述:

关键区别在于,由于这个文档片段并不是活动文档树结构的一部分,因此对这个片段所作的更改不会影响文档、导致重排,或在进行更改时引起任何性能影响。

换句话说,对 DocumentFragment 所作的更改发生在屏幕外;在构建这个片段时,没有重排和重绘成本,而这正是我们所需要的!

我们可以使用 .createDocumentFragment() 方法创建一个空的 DocumentFragment 对象。你对这段代码应该非常熟悉,因为它看起来和 document.createElement() 十分相似。

const myDocFrag = document.createDocumentFragment();

接着重写原有代码来使用 DocumentFragment 而不是 <div>

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)--仅此一次!

小结

在本部分简要介绍了所编写的代码的性能影响。

首先,分析了一个特定的代码块并提出了一些改进方法,通过重新安排代码运行的时间(将初始化代码移出 for 循环),即可提高代码的性能。

此外,研究了如何使用 performance.now()来测量代码运行需要多长时间。

最后,学习了使用 DocumentFragment 来防止性能问题,并防止向 DOM 中添加不必要的元素。

延伸

重排和重绘

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

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

例如将一个 CSS 类添加到一个元素中,浏览器往往会重新计算整个页面的布局——即执行一次重排和一次重绘!

为什么添加一点 CSS 更改会导致重排?如果添加类改变了元素的位置或者使其浮动呢?因为浏览器无法确切知道(而完全计算一项更改的影响可能比执行重排还要费时!)

<ul id="comments">
  <li class="comment"> <!-- some content --> </li>
  <li class="comment"> <!-- some content --> </li>
  <li class="comment"> <!-- some content --> </li>
</ul>

假设这是一个评论系统,当运行垃圾信息过滤器时发现评论 1 和 2 必须被移除。如果为每个需要移除的评论简单地调用 .removeChild(),就要为每项更改执行一次重排和一次重绘(总共 2 次重排和 2 次重绘)。所以可以在 DocumentFragment 中重建所有代码并替换 #comments——这是重建的时间(可能涉及读取文件或数据),再加上至少一次重排和一次重绘。

或者,我们可以隐藏 #comments,删除垃圾信息,然后再次显示——这个速度出奇地快,只需要一次重排和两次重绘(以及其他一些操作)。这样做很快速,因为隐藏并不会改变布局,而只会擦除屏幕的一部分(1 次重绘)。当你将更改后的部分再次显示时,则有一次重排和一次重绘。

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

虚拟 DOM

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

小结

重排是计算页面元素的尺寸和位置的过程。这是一个计算密集(缓慢)的任务。重绘是将像素绘制到屏幕上的过程。它比重排要快,但仍然不是一个快速的过程,所以要确保你的代码尽可能少地引起重排。

延伸

调用堆栈

单线程

根据维基百科,单线程是指:

the processing of one command at a time(一次处理一个命令)

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();

牢记 JavaScript 的单线程特性(意味着它一次只能执行一个任务),让我们将这个代码按照运行顺序进行分解:

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

首先这段代码的运行至完成模式。当 addParagraph() 在第 13 行被调用时,该函数中的所有代码都会被执行:它不会仅执行某些行,而留下其他行稍后再执行。相反,整个代码块都会运行。

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

所以一旦 addParagraph() 被调用,并在 addParagraph() 函数内运行代码行,它如何知道要返回到 appendNewMessage()?它如何进行跟踪呢?

如果我们稍微改变这个代码来创建嵌套函数呢?

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();

请注意,对 appendNewMessage() 的调用位于 addParagraph()函数内部。

首先,addParagraph() 被调用。然后,appendNewMessage() 在第 5 行被调用。当 appendNewMessage() 完成运行后,执行返回并结束运行 addParagraph() 函数中的最后一行代码...但它怎么知道如何做到这一点呢?JavaScript 引擎如何知道它停止的位置以及怎样回到该位置?

调用堆栈

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

function quiz () {
  var y = 'yes';
  questions();
  fun();
}
function questions () {
  var y = 'no';
  return 7;
}
function are () {
  return 3;
}
function fun () {
  are();
  // stop here
}

quiz()

调用上述文件会向堆栈中添加一个指示符叫做帧(fream)告诉我们正在运行的是什么,一般使用 Main、anonymous、global 或者其他单词表示,在此基础上没运行到一个函数就会添加到调用堆栈的列表里

那么当代码运行到注释「// stop here」时除了指示符还剩多少项目(框架)?

答案是 2 个,首先执行 quiz() 由此执行了 questions() ,但 questions() 执行完毕后就移除了堆栈列表,接着执行 fun() 并由此执行 are () 并在其结束后也移除了堆栈列表,所以还剩 quiz()fun()

小结

JavaScript 是一种单线程编程语言,这意味着它一次只能执行一个任务。并了解了 JavaScript 如何使用调用堆栈来跟踪正在运行的函数。

延伸

事件循环

代码同步性

在前面关于调用堆栈的部分中,我们使用了以下术语:

  • 运行至完成
  • 单线程

另一个相关术语是同步。根据定义,「同步」是指:

同时存在或发生

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

console.log('111');
document.addEventListener('click', function numbers() {
    console.log('222');
};
console.log('333');

其中的 console.log('222'); 就不会马上执行,那么包含它的 numbers() 函数在哪里?

稍后运行代码

这个代码也存在与 .addEventListener() 代码相同的问题:

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

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

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

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

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

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

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

console.log('111');
document.addEventListener('click', function numbers() {
    console.log('222');
};
console.log('333');

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

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

请记住!

  1. 当前的同步代码将会运行至完成,而且
  2. 事件将在浏览器不繁忙时进行处理。异步代码(如加载图像)在此循环之外运行,并在完成时发送事件。

也就是说,当这段代码运行时,numbers() 函数会被赋给浏览器。当点击发生时,此代码将移至队列。然后,当调用堆栈为空时(也就是 111 和 333 执行完清除队列后),numbers() 将移至调用堆栈并被调用。

小结

JavaScript 是一种单线程编程语言,这意味着它一次只能执行一个任务。我们研究了 JavaScript 如何使用调用堆栈来跟踪正在运行的函数。此外还学习了如何处理异步代码。

异步代码使用 JavaScript 事件循环。任何异步代码(如 setTimeout 或传递给 .addEventListener() 的函数)均由浏览器处理。当异步代码准备好执行时,它会被移到队列中,等待直到调用堆栈为空。一旦调用堆栈为空,代码将从队列移到调用堆栈,并被执行。

了解 JavaScript 和事件循环如何运作可以帮助我们编写更高效的代码。

延伸

setTimeout

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

  • 一个稍后运行的函数
  • 运行函数之前代码应该等待的毫秒数
setTimeout(function sayHi() {
    console.log('Hi');
}, 1000); // 在大约 1000 毫秒或者说大约 1 秒后执行

setTimeout() 延迟为 0

setTimeout(function sayHi() {
    console.log('Hi');
}, 0);

很特别的一点是,还可以向它传递一个 0 毫秒的延迟。

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

这对我们有何帮助呢?答案是,这项技能可以帮助我们将可能会运行很久的代码转换为多段代码,以便让浏览器可以处理用户交互!

拆解运行较久的代码

还记得之前生成两百个 P 元素么,如果现在不是向页面中添加两百个段落,而是添加两万个呢?我们将需要在页面上创建、附加和插入很多元素!

请记住重排和重绘对于应用程序的性能有何影响。我们在编写 JavaScript 代码时要考虑到重排和重绘,并尽量减少它们的次数。

不过,我们也希望确保我们的应用程序能对用户交互作出响应。当 JavaScript 正在运行时,页面处于「繁忙」状态,用户将无法与页面进行交互(例如点击按钮、填写表单)。请记住,这是因为 JavaScript 是同步运行的。因此,它会运行至完成(创建、附加和插入所有两万个元素),要等这些完成之后,它才能够响应用户采取的任何行动。该函数会创建所有这些元素,并将其添加到页面上。它会一直在调用堆栈中,直到彻底完成。

让用户有机会与页面进行交互的一种方式,就是将添加内容的任务拆解为小块。让我们使用 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() 函数。

如果你尝试在页面上运行此代码,则在代码运行时仍可与页面进行交互。这样做并不会锁定或冻结页面。之所以不会锁定或冻结,得益于 setTimeout() 调用。

小结

浏览器所提供的 setTimeout() 函数会使用另一个函数和一个延迟,并在延迟过后调用函数。

知道 JavaScript 事件循环如何运作之后,我们可以使用 setTimeout() 方法来编写代码,以便让浏览器可以处理用户交互。

延伸

Conners Hua

这个家伙很懒,什么都没有留下。

您可能还喜欢...

发表评论

电子邮件地址不会被公开。 必填项已用*标注