使用 Web Workers 在 React 应用程序中管理长时间运行的任务

已发表: 2022-03-10
快速总结↬在本教程中,我们将通过构建一个利用 Web Worker 的示例 Web 应用程序来学习如何使用 Web Worker API 来管理 JavaScript 应用程序中的耗时和 UI 阻塞任务。 最后,我们将通过将所有内容转移到 React 应用程序来结束本文。

对于 Web 应用程序,响应时间很重要。 无论您的应用程序在做什么,用户都需要即时响应。 无论是仅显示一个人的姓名还是处理数字,Web 应用程序用户都要求您的应用程序每次都响应他们的命令。 鉴于 JavaScript 的单线程特性,有时这很难实现。 但在本文中,我们将学习如何利用 Web Worker API 来提供更好的体验。

在写这篇文章时,我做了以下假设:

  1. 为了能够继续学习,您至少应该对 JavaScript 和文档 API 有一定的了解;
  2. 您还应该具备 React 的工作知识,以便您可以使用 Create React App 成功启动一个新的 React 项目。

如果您需要对该主题的更多见解,我在“更多资源”部分中提供了一些链接,以帮助您快速了解。

首先,让我们开始使用 Web Workers。

什么是网络工作者?

要了解 Web Workers 及其要解决的问题,有必要了解 JavaScript 代码是如何在运行时执行的。 在运行时,JavaScript 代码按顺序依次执行。 一旦一段代码结束,则下一个代码开始运行,依此类推。 用技术术语来说,我们说 JavaScript 是单线程的。 这种行为意味着一旦某段代码开始运行,之后的每个代码都必须等待该代码完成执行。 因此,每一行代码都会“阻止”执行它之后的所有其他内容。 因此,希望每段代码尽快完成。 如果某些代码需要太长时间才能完成,我们的程序似乎已经停止工作。 在浏览器上,这表现为一个冻结的、无响应的页面。 在某些极端情况下,选项卡将完全冻结。

想象一下在单车道上行驶。 如果您前面的任何司机碰巧因任何原因停止行驶,那么您就遇到了交通堵塞。 使用像 Java 这样的程序,其他车道上的交通可能会继续。 因此,Java 被称为是多线程的。 Web Workers 尝试将多线程行为引入 JavaScript。

下面的截图显示了 Web Worker API 被许多浏览器支持,所以你应该对使用它有信心。

显示网络工作者的浏览器支持图表
Web Workers 浏览器支持。 (大预览)

Web Worker 在后台线程中运行,不会干扰 UI,它们通过事件处理程序与创建它们的代码进行通信。

Web Worker 的一个很好的定义来自 MDN:

“worker 是一个使用构造函数创建的对象(例如Worker() ,它运行一个命名的 JavaScript 文件——该文件包含将在工作线程中运行的代码;worker 在另一个不同于当前window的全局上下文中运行。因此, 使用window快捷方式获取当前全局范围(而不是Worker中的self将返回错误。”

工作人员是使用Worker构造函数创建的。

 const worker = new Worker('worker-file.js')

可以在 Web Worker 中运行大多数代码,但有一些例外。 例如,您不能从工作人员内部操作 DOM。 无法访问document API。

工作者和产生它们的线程使用postMessage()方法相互发送消息。 同样,它们使用onmessage事件处理程序响应消息。 获得这种差异很重要。 发送消息是使用方法实现的; 接收回消息需要事件处理程序。 正在接收的消息包含在事件的data属性中。 我们将在下一节中看到一个这样的例子。 但是让我快速提一下,我们一直在讨论的那种工人被称为“敬业的工人”。 这意味着工作者只能被调用它的脚本访问。 也可以有一个可以从多个脚本访问的工作人员。 这些称为共享工作者,并使用SharedWorker构造函数创建,如下所示。

 const sWorker = new SharedWorker('shared-worker-file.js')

要了解有关 Workers 的更多信息,请参阅此 MDN 文章。 本文的目的是让您开始使用 Web 工作者。 让我们通过计算第 n 个斐波那契数来解决它。

跳跃后更多! 继续往下看↓

计算第 N 个斐波那契数

注意:对于本节和接下来的两节,我使用 VSCode 上的 Live Server 来运行应用程序。 你当然可以使用别的东西。

这是您一直在等待的部分。 最后,我们将编写一些代码来查看 Web Workers 的运行情况。 嗯,没那么快。 除非我们遇到它解决的问题,否则我们不会欣赏 Web Worker 所做的工作。 在本节中,我们将看到一个示例问题,在下一节中,我们将看到 web worker 如何帮助我们做得更好。

想象一下,您正在构建一个允许用户计算第 n 个斐波那契数的 Web 应用程序。 如果您不熟悉“斐波那契数”这个术语,您可以在此处阅读更多相关信息,但总而言之,斐波那契数是一个数字序列,每个数字都是前面两个数字的总和。

在数学上,它表示为:

因此,序列的前几个数字是:

1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 ...

在某些来源中,序列从F 0 = 0开始,在这种情况下,下面的公式适用于n > 1

在本文中,我们将从 F 1 = 1 开始。从公式中我们可以立即看到的一件事是数字遵循递归模式。 现在手头的任务是编写一个递归函数来计算第 n 个斐波那契数(FN)。

经过几次尝试,相信您可以轻松想出下面的功能。

 const fib = n => { if (n < 2) { return n // or 1 } else { return fib(n - 1) + fib(n - 2) } }

功能很简单。 如果 n 小于 2,则返回 n(或 1),否则,返回n-1n-2 FN 之和。 使用箭头函数和三元运算符,我们可以提出单线。

 const fib = n => (n < 2 ? n : fib(n-1) + fib(n-2))

该函数的时间复杂度为0(2 n ) 。 这仅仅意味着随着 n 值的增加,计算总和所需的时间呈指数增长。 对于较大的 n 值,这会导致一个真正长时间运行的任务,可能会干扰我们的 UI。 让我们看看这个在行动。

注意这绝不是解决此特定问题的最佳方法。 我选择使用这种方法是为了本文的目的。

首先,创建一个新文件夹并将其命名为您喜欢的任何名称。 现在在该文件夹中创建一个src/文件夹。 另外,在根文件夹中创建一个index.html文件。 在src/文件夹中,创建一个名为index.js的文件。

打开index.html并添加以下 HTML 代码。

 <!DOCTYPE html> <html> <head> <link rel="stylesheet" href="styles.css"> </head> <body> <div class="heading-container"> <h1>Computing the nth Fibonnaci number</h1> </div> <div class="body-container"> <p id='error' class="error"></p> <div class="input-div"> <input id='number-input' class="number-input" type='number' placeholder="Enter a number" /> <button id='submit-btn' class="btn-submit">Calculate</button> </div> <div id='results-container' class="results"></div> </div> <script src="/src/index.js"></script> </body> </html>

这部分非常简单。 首先,我们有一个标题。 然后我们有一个带有输入和按钮的容器。 用户将输入一个数字,然后单击“计算”。 我们还有一个容器来保存计算结果。 最后,我们将src/index.js文件包含在script标记中。

您可以删除样式表链接。 但是如果你时间不够,我已经定义了一些你可以使用的 CSS。 只需在根文件夹中创建styles.css文件并添加以下样式:

 body { margin: 0; padding: 0; box-sizing: border-box; } .body-container, .heading-container { padding: 0 20px; } .heading-container { padding: 20px; color: white; background: #7a84dd; } .heading-container > h1 { margin: 0; } .body-container { width: 50% } .input-div { margin-top: 15px; margin-bottom: 15px; display: flex; align-items: center; } .results { width: 50vw; } .results>p { font-size: 24px; } .result-div { padding: 5px 10px; border-radius: 5px; margin: 10px 0; background-color: #e09bb7; } .result-div p { margin: 5px; } span.bold { font-weight: bold; } input { font-size: 25px; } p.error { color: red; } .number-input { padding: 7.5px 10px; } .btn-submit { padding: 10px; border-radius: 5px; border: none; background: #07f; font-size: 24px; color: white; cursor: pointer; margin: 0 10px; }

现在打开src/index.js让我们慢慢开发吧。 添加下面的代码。

 const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)); const ordinal_suffix = (num) => { // 1st, 2nd, 3rd, 4th, etc. const j = num % 10; const k = num % 100; switch (true) { case j === 1 && k !== 11: return num + "st"; case j === 2 && k !== 12: return num + "nd"; case j === 3 && k !== 13: return num + "rd"; default: return num + "th"; } }; const textCont = (n, fibNum, time) => { const nth = ordinal_suffix(n); return ` <p id='timer'>Time: <span class='bold'>${time} ms</span></p> <p><span class="bold" id='nth'>${nth}</span> fibonnaci number: <span class="bold" id='sum'>${fibNum}</span></p> `; };

这里我们有三个函数。 第一个是我们之前看到的计算第 n 个 FN 的函数。 第二个函数只是一个实用函数,用于将适当的后缀附加到整数。 第三个函数接受一些参数并输出一个标记,我们稍后将插入到 DOM 中。 第一个参数是正在计算其 FN 的数字。 第二个参数是计算的 FN。 最后一个参数是执行计算所需的时间。

仍然在src/index.js中,在前一个代码的下方添加以下代码。

 const errPar = document.getElementById("error"); const btn = document.getElementById("submit-btn"); const input = document.getElementById("number-input"); const resultsContainer = document.getElementById("results-container"); btn.addEventListener("click", (e) => { errPar.textContent = ''; const num = window.Number(input.value); if (num < 2) { errPar.textContent = "Please enter a number greater than 2"; return; } const startTime = new Date().getTime(); const sum = fib(num); const time = new Date().getTime() - startTime; const resultDiv = document.createElement("div"); resultDiv.innerHTML = textCont(num, sum, time); resultDiv.className = "result-div"; resultsContainer.appendChild(resultDiv); });

首先,我们使用document API 来获取 HTML 文件中的DOM节点。 我们得到了我们将显示错误消息的段落的引用; 输入; 计算按钮和我们将显示结果的容器。

接下来,我们将“click”事件处理程序附加到按钮。 当按钮被点击时,我们获取输入元素内的任何内容并将其转换为数字,如果我们得到小于 2 的任何内容,我们将显示错误消息并返回。 如果我们得到一个大于 2 的数字,我们继续。 首先,我们记录当前时间。 之后,我们计算 FN。 完成后,我们得到一个时间差,表示计算花费了多长时间。 在代码的其余部分,我们创建了一个新的div 。 然后我们将其内部 HTML 设置为我们之前定义的textCont()函数的输出。 最后,我们向它添加一个类(用于样式)并将其附加到结果容器中。 这样做的效果是每个计算将出现在前一个下方的单独div中。

显示最多 43 个计算出的斐波那契数
一些斐波那契数字。 (大预览)

我们可以看到,随着数量的增加,计算时间也增加(指数)。 例如,从 30 到 35,我们的计算时间从 13 毫秒跳到 130 毫秒。 我们仍然可以认为这些操作是“快速的”。 在 40 时,我们看到计算时间超过 1 秒。 在我的机器上,这是我开始注意到页面变得无响应的地方。 在这一点上,当计算正在进行时,我无法再与页面交互。 我不能专注于输入或做任何其他事情。

还记得我们谈到 JavaScript 是单线程的吗? 好吧,那个线程已经被这个长时间运行的计算“阻塞”了,所以其他的一切都必须“等待”它完成。 它可能从您机器上的较低或较高值开始,但您一定会达到这一点。 请注意,计算 44 需要将近 10 秒。如果您的 Web 应用程序上还有其他事情要做,那么用户必须等待 Fib(44) 完成才能继续。 但是,如果您部署了一个 Web Worker 来处理该计算,您的用户可以在运行时继续执行其他操作。

现在让我们看看网络工作者如何帮助我们克服这个问题。

Web Worker 示例

在本节中,我们会将计算第 n 个 FN 的工作委托给 Web Worker。 这将有助于释放主线程并在计算进行时保持我们的 UI 响应。

开始使用 Web Worker 非常简单。 让我们看看如何。 创建一个新文件src/fib-worker.js 。 并输入以下代码。

 const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)); onmessage = (e) => { const { num } = e.data; const startTime = new Date().getTime(); const fibNum = fib(num); postMessage({ fibNum, time: new Date().getTime() - startTime, }); };

请注意,我们已经将计算第 n 个斐波那契数的函数fib移到了这个文件中。 该文件将由我们的网络工作者运行。

回想一下什么是 web worker 一节,我们提到 web worker 和它们的父级使用onmessage事件处理程序和postMessage()方法进行通信。 在这里,我们使用onmessage事件处理程序来监听来自父脚本的消息。 一旦我们收到一条消息,我们就从事件的数据属性中解构数字。 接下来,我们获取当前时间并开始计算。 一旦结果准备好,我们使用postMessage()方法将结果发布回父脚本。

打开src/index.js让我们进行一些更改。

 ... const worker = new window.Worker("src/fib-worker.js"); btn.addEventListener("click", (e) => { errPar.textContent = ""; const num = window.Number(input.value); if (num < 2) { errPar.textContent = "Please enter a number greater than 2"; return; } worker.postMessage({ num }); worker.onerror = (err) => err; worker.onmessage = (e) => { const { time, fibNum } = e.data; const resultDiv = document.createElement("div"); resultDiv.innerHTML = textCont(num, fibNum, time); resultDiv.className = "result-div"; resultsContainer.appendChild(resultDiv); }; });

首先要做的是使用Worker构造函数创建 Web Worker。 然后在按钮的事件监听器中,我们使用worker.postMessage({ num })向工作人员发送一个数字。 之后,我们设置了一个函数来监听worker中的错误。 这里我们只是简单地返回错误。 如果你愿意,你当然可以做更多,比如在 DOM 中显示它。 接下来,我们监听来自worker的消息。 一旦我们收到一条消息,我们就解构timefibNum ,并继续在 DOM 中显示它们的过程。

请注意,在 web worker 内部, onmessage事件在 worker 范围内可用,因此我们可以将其编写为self.onmessageself.postMessage() 。 但是在父脚本中,我们必须将这些附加到工人本身。

在下面的屏幕截图中,您将在 Chrome 开发工具的源选项卡中看到 web worker 文件。 您应该注意的是,无论您输入什么数字,UI 都会保持响应。 这种行为是网络工作者的魔力。

活动 Web Worker 文件的视图
一个正在运行的网络工作者文件。 (大预览)

我们的网络应用程序取得了很大进展。 但是我们还可以做一些其他事情来让它变得更好。 我们当前的实现使用单个工作者来处理每个计算。 如果一条新消息在运行时出现,则旧消息将被替换。 为了解决这个问题,我们可以为每个调用创建一个新的 worker 来计算 FN。 让我们在下一节中看看如何做到这一点。

使用多个 Web Worker

目前,我们正在使用单个工作人员处理每个请求。 因此,传入的请求将替换尚未完成的先前请求。 我们现在想要做一个小的改变,为每个请求生成一个新的 web worker。 一旦完成,我们将杀死这个工人。

打开src/index.js并在按钮的单击事件处理程序中移动创建 web worker 的行。 现在事件处理程序应该如下所示。

 btn.addEventListener("click", (e) => { errPar.textContent = ""; const num = window.Number(input.value); if (num < 2) { errPar.textContent = "Please enter a number greater than 2"; return; } const worker = new window.Worker("src/fib-worker.js"); // this line has moved inside the event handler worker.postMessage({ num }); worker.onerror = (err) => err; worker.onmessage = (e) => { const { time, fibNum } = e.data; const resultDiv = document.createElement("div"); resultDiv.innerHTML = textCont(num, fibNum, time); resultDiv.className = "result-div"; resultsContainer.appendChild(resultDiv); worker.terminate() // this line terminates the worker }; });

我们做了两个改变。

  1. 我们将这一行const worker = new window.Worker("src/fib-worker.js")移到按钮的单击事件处理程序中。
  2. 我们添加了这条线worker.terminate()以在我们完成后丢弃工人。

因此,每次单击按钮,我们都会创建一个新的工作人员来处理计算。 因此我们可以不断改变输入,一旦计算完成,每个结果都会显示在屏幕上。 在下面的屏幕截图中,您可以看到 20 和 30 的值出现在 45 之前。但我先开始 45。 一旦函数返回 20 和 30,它们的结果就会被发布,并且 worker 终止。 当一切都完成后,我们不应该在源选项卡上有任何工作人员。

显示终止工人的斐波那契数
多个独立工作者的插图。 (大预览)

我们可以在这里结束这篇文章,但如果这是一个 React 应用程序,我们将如何将 Web Worker 引入其中。 这是下一节的重点。

React 中的 Web Worker

首先,使用 CRA 创建一个新的 React 应用程序。 将fib-worker.js文件复制到您的 react 应用程序的public/文件夹中。 将文件放在这里源于 React 应用程序是单页应用程序这一事实。 这是唯一特定于在反应应用程序中使用工作人员的事情。 从这里开始的一切都是纯粹的 React。

src/文件夹中创建一个文件helpers.js并从中导出ordinal_suffix()函数。

 // src/helpers.js export const ordinal_suffix = (num) => { // 1st, 2nd, 3rd, 4th, etc. const j = num % 10; const k = num % 100; switch (true) { case j === 1 && k !== 11: return num + "st"; case j === 2 && k !== 12: return num + "nd"; case j === 3 && k !== 13: return num + "rd"; default: return num + "th"; } };

我们的应用程序将要求我们维护一些状态,因此创建另一个文件src/reducer.js并粘贴到状态减速器中。

 // src/reducers.js export const reducer = (state = {}, action) => { switch (action.type) { case "SET_ERROR": return { ...state, err: action.err }; case "SET_NUMBER": return { ...state, num: action.num }; case "SET_FIBO": return { ...state, computedFibs: [ ...state.computedFibs, { id: action.id, nth: action.nth, loading: action.loading }, ], }; case "UPDATE_FIBO": { const curr = state.computedFibs.filter((c) => c.id === action.id)[0]; const idx = state.computedFibs.indexOf(curr); curr.loading = false; curr.time = action.time; curr.fibNum = action.fibNum; state.computedFibs[idx] = curr; return { ...state }; } default: return state; } };

让我们一个接一个地检查每个动作类型。

  1. SET_ERROR :触发时设置错误状态。
  2. SET_NUMBER :将我们输入框中的值设置为状态。
  3. SET_FIBO :向计算的 FN 数组添加一个新条目。
  4. UPDATE_FIBO :在这里我们寻找一个特定的条目并将其替换为一个新对象,该对象具有计算的 FN 和计算它所花费的时间。

我们很快就会使用这个减速器。 在此之前,让我们创建将显示计算出的 FN 的组件。 创建一个新文件src/Results.js并粘贴以下代码。

 // src/Results.js import React from "react"; export const Results = (props) => { const { results } = props; return ( <div className="results-container"> {results.map((fb) => { const { id, nth, time, fibNum, loading } = fb; return ( <div key={id} className="result-div"> {loading ? ( <p> Calculating the{" "} <span className="bold"> {nth} </span>{" "} Fibonacci number... </p> ) : ( <> <p> Time: <span className="bold">{time} ms</span> </p> <p> <span className="bold"> {nth} </span>{" "} fibonnaci number:{" "} <span className="bold"> {fibNum} </span> </p> </> )} </div> ); })} </div> ); };

通过这个更改,我们开始将之前的 index.html 文件转换为 jsx。 该文件有一个职责:获取表示计算的 FN 的对象数组并显示它们。 与我们之前的唯一区别是引入了加载状态。 所以现在当计算运行时,我们显示加载状态,让用户知道正在发生一些事情。

让我们通过更新src/App.js中的代码来完成最后的工作。 代码相当长,所以我们将分两步完成。 让我们添加第一个代码块。

 import React from "react"; import "./App.css"; import { ordinal_suffix } from "./helpers"; import { reducer } from './reducer' import { Results } from "./Results"; function App() { const [info, dispatch] = React.useReducer(reducer, { err: "", num: "", computedFibs: [], }); const runWorker = (num, id) => { dispatch({ type: "SET_ERROR", err: "" }); const worker = new window.Worker('./fib-worker.js') worker.postMessage({ num }); worker.onerror = (err) => err; worker.onmessage = (e) => { const { time, fibNum } = e.data; dispatch({ type: "UPDATE_FIBO", id, time, fibNum, }); worker.terminate(); }; }; return ( <div> <div className="heading-container"> <h1>Computing the nth Fibonnaci number</h1> </div> <div className="body-container"> <p className="error"> {info.err} </p> // ... next block of code goes here ... // <Results results={info.computedFibs} /> </div> </div> ); } export default App;

像往常一样,我们带来我们的进口。 然后我们用 useReducer 钩子实例化一个状态和更新函数。 然后我们定义一个函数runWorker() ,它接受一个数字和一个 ID,并开始调用一个 web worker 来计算该数字的 FN。

请注意,要创建工作者,我们将相对路径传递给工作者构造函数。 在运行时,我们的 React 代码会附加到public/index.html文件,因此它可以在同一目录中找到fib-worker.js文件。 当计算完成时(由worker.onmessage触发), UPDATE_FIBO操作被调度,并且工作人员随后终止。 我们现在所拥有的与我们以前所拥有的并没有太大的不同。

在这个组件的返回块中,我们渲染了与之前相同的 HTML。 我们还将计算出的数字数组传递给<Results />组件进行渲染。

让我们在return语句中添加最后的代码块。

 <div className="input-div"> <input type="number" value={info.num} className="number-input" placeholder="Enter a number" onChange={(e) => dispatch({ type: "SET_NUMBER", num: window.Number(e.target.value), }) } /> <button className="btn-submit" onClick={() => { if (info.num < 2) { dispatch({ type: "SET_ERROR", err: "Please enter a number greater than 2", }); return; } const id = info.computedFibs.length; dispatch({ type: "SET_FIBO", id, loading: true, nth: ordinal_suffix(info.num), }); runWorker(info.num, id); }} > Calculate </button> </div>

我们在输入上设置了一个onChange处理程序来更新info.num状态变量。 在按钮上,我们定义了一个onClick事件处理程序。 当按钮被点击时,我们检查数字是否大于 2。请注意,在调用runWorker()之前,我们首先调度一个动作以将一个条目添加到计算的 FN 数组中。 一旦工作人员完成其工作,该条目将被更新。 通过这种方式,每个条目都保持其在列表中的位置,这与我们以前不同。

最后,复制之前的styles.css的内容,替换App.css的内容。

我们现在一切就绪。 现在启动你的反应服务器并玩弄一些数字。 注意加载状态,这是一个 UX 改进。 此外,请注意,即使您输入高达 1000 的数字并单击“计算”,UI 也会保持响应。

在工作人员处于活动状态时显示加载状态。
显示加载状态和活跃的网络工作者。 (大预览)

注意加载状态和活动工作人员。 一旦计算出第 46 个值,worker 就会被杀死,并且加载状态会被最终结果替换。

  • 这个 React 应用程序的源代码在 Github 上可用,并且在 vercel 上有一个托管应用程序。

结论

呸! 走了很长一段路,所以让我们总结一下。 我鼓励您查看 MDN 条目 for web workers(请参阅下面的资源列表)以了解使用 web worker 的其他方式。

在本文中,我们了解了 Web Worker 是什么以及它们要解决的问题类型。 我们还看到了如何使用纯 JavaScript 来实现它们。 最后,我们看到了如何在 React 应用程序中实现 web worker。

我鼓励您利用这个出色的 API 为您的用户提供更好的体验。

更多资源

  • Console.time() , MDN 网络文档
  • {JSON}占位符,官网
  • 使用 Web Workers,MDN 网络文档
  • 斐波那契数,维基百科
  • 条件(三元)运算符,MDN 网络文档
  • Document 、Web API、MDN 网络文档
  • 入门,创建 React 应用程序(文档)
  • Function.prototype.toString() , MDN 网络文档
  • IIFE,MDN 网络文档
  • workerSetup.js ,很棒的全栈教程,GitHub
  • “使用 Web Workers 在 JavaScript 中进行并行编程”,Uday Hiwarale,Medium