Jason Pan

Web Worker 的构成元素 + 5个使用场景

潘忠显 / 2021-04-07


“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显

img

这是致力于探索 JavaScript 及其构建组件的系列文章的第7部分,本文将拆解分析 Web Workers:首先是概述,然后讨论不同类型的Workers,它们的建筑组件如何一起发挥作用,以及它们在不同情况下提供的优势和局限性。最后,我们将提供5个用例,其中Web Workers将是正确的选择。

This time we’ll be taking apart Web Workers: we’ll offer an overview, discuss the different types of workers, how their building components come to play together, and what advantages and limitations they offer in different scenarios. Finally, we’ll provide 5 use cases in which Web Workers will be the right choice.

[TOC]

您应该已经熟悉 JavaScript 在单个线程上运行这一事实,正如我们之前详细讨论过的那样。但是,JavaScript 也为开发人员提供了编写异步代码的机会。

异步编程的局限性

我们已经讨论了异步编程以及它的使用场景。异步编程能够“调度”部分代码在事件循环中稍后执行,通过这种方式使应用程序 UI 能够作响应,从而允许首先执行 UI 渲染。

异步编程的一个很好的用例是发出 AJAX 请求。由于请求可能要花费大量时间,因此在异步发出请求、等待响应时,客户端可以执行其他代码。

// This is assuming that you're using jQuery
jQuery.ajax({
    url: 'https://api.example.com/endpoint',
    success: function(response) {
        // Code to be executed when a response arrives.
    }
});

但是,这带来了一个问题:请求由浏览器的 WEB API 处理,但是如何使其他代码异步?例如,如果成功回调中的代码占用大量 CPU 资源,该怎么办:

var result = performCPUIntensiveCalculation();

如果 performCPUIntensiveCalculation 不是HTTP请求,而是阻塞代码(例如,巨大的 for 循环),则无法释放事件循环,也无法解除对浏览器的用户界面的阻塞。相反,用户感受到的是,浏览器冻结且无法响应。这意味着异步函数仅解决 JavaScript 语言的单线程的一小部分限制。

在某些情况下,使用 setTimeout 可以使 UI 不受长时间运行的计算的阻塞。例如,通过将负责的计算拆解成批量独立的 setTimeout 调用,可以将拆分的计算放在单独的“位置”,这种方式可以为 UI 的渲染和响应争取到时间。

让我们看一个简单的函数,该函数可以计算数字数组的平均值:

function average(numbers) {
    var len = numbers.length,
        sum = 0,
        i;

    if (len === 0) {
        return 0;
    } 
    
    for (i = 0; i < len; i++) {
        sum += numbers[i];
    }
   
    return sum / len;
}

可以将上面的代码以“模拟”异步性的方式进行重写:

function averageAsync(numbers, callback) {
    var len = numbers.length,
        sum = 0;

    if (len === 0) {
        return 0;
    } 

    function calculateSumAsync(i) {
        if (i < len) {
            // Put the next function call on the event loop.
            setTimeout(function() {
                sum += numbers[i];
                calculateSumAsync(i + 1);
            }, 0);
        } else {
            // The end of the array is reached so we're invoking the callback.
            callback(sum / len);
        }
    }

    calculateSumAsync(0);
}

上边代码用到的 setTimeout 函数,会将每一步计算添加到事件循环。在每次计算之间,将会有足够的时间进行其他计算,而这是解冻浏览器所必须的。

Web Worker 可以节省时间

HTML5 为我们带来了很多很棒的东西,包括:

Web Worker 是浏览器中的“线程”,可用于执行 JavaScript 代码而不会阻塞事件循环。JavaScript 的整个范例都是基于单线程环境的思想,但 Web Worker 能够在一定程度上,消除单线程的限制。

Web Worker 允许开发这将长时间运行且计算量大的任务放在后台,而不会阻塞UI,从而使您的应用程序具有更高的响应速度。更重要的是,无需使用上面提到的setTimeout 技巧,就可以绕开事件循环。

这是一个简单的 demo,显示了使用Web Workers和不使用Web Workers对数组进行排序之间的区别。(译注:without web worker 会卡住页面,而with web worker 进度条会动)

Web Worker 概述

Web Workers 允许您执行一些操作而又不会阻塞 UI,这个操作可以是一个长时间运行、计算量大的脚本任务。实际上,Web Worker 是真正的多线程,这些操作都是并行进行的。

您可能会反问:“ JavaScript 不是单线程语言吗?”

JavaScript 是一种没有定义线程模型的语言,但 Web Worker 不是 JavaScript 的一部分,而是一种可通过J avaScript 访问的浏览器特性。

历史上,大多数浏览器曾经都是单线程的(当然,这点目前已经发生了变化),而且大多数 JavaScript 的实现都发生在浏览器当中。Web Workers 没有被 Node.js 实现:Node.js 中有“集群”或“子进程”的概念,这有点不同。

值得注意的是, 规范 提到了三种类型的Web Worker:

专用 (dedicated) worker

专用的 Web Workers 由主进程实例化,且只能与主进程进行通信。

Dedicated Workers browser support

共享 (shared) worker

共享 worker 可以被相同来源 (origin) 的所有进程访问。这里的来源是指不同的浏览器标签、iframe或者其他共享worker(??different browser tabs, iframes or other shared workers)

Shared Workers browser support

服务 (service) worker

服务worker是事件驱动的、已针对原点和路径进行注册的 worker。它可以控制与之关联的网页/站点,拦截和修改导航和资源请求,以非常精细的方式缓存资源,从而使开发者可以控制应用在特定情况下的行为方式(例如,当网络不正常时)。

Service Workers browser support

在本文中,我们将重点关注“专用工作者 (Dedicated Worker)”,并将其称为“Web Workers”或“Workers”。

Web Worker 的工作原理

Web Workers 被实现为 .js 文件,这些文件通过异步 HTTP 请求包含在页面中,而这些请求被 Web Worker API 完全地隐藏了。

Web Worker 利用类似线程的消息传递来实现并行,这非常适合使用户界面保持最新、高效且能及时响应用户。

Web Worker 在浏览器的单独隔离的线程中运行,因此,它们执行的代码需要包含在单独的文件中,这一点非常重要。

让我们看看如何创建一个基础的 worker:

var worker = new Worker('task.js');

如果 “task.js” 文件存在且可以访问,则浏览器将生成一个新线程,该线程异步下载该文件。下载完成后,将立即执行下载并开始工作。如果提供的文件路径返回 404,则工作程序将静默失败。

为了启动已创建的 worker,您需要调用 postMessage 方法:

worker.postMessage();

Web Worker 间通信

为了在 Web Worker 和创建它的页面之间进行通信,您需要使用 postMessage 方法或广播频道 (Broadcast Channel)

postMessage 方法

较新的浏览器支持将 JSON 对象作为第一个参数传入 postMessage 函数,而较旧的浏览器仅支持 string

传递字符串与传递 JSON 类似,但 JSON 更复杂一些。以下示例是部分 HTML 页面,将会展示创建 worker 的页面是如何通过传递 JSON 对象,与之进行来回通信的:

<button onclick="startComputation()">Start computation</button>

<script>
  function startComputation() {
    worker.postMessage({'cmd': 'average', 'data': [1, 2, 3, 4]});
  }

  var worker = new Worker('doWork.js');

  worker.addEventListener('message', function(e) {
    console.log(e.data);
  }, false);
  
</script>

这就是我们的 worker 脚本 doWork.js 的样子:

self.addEventListener('message', function(e) {
  var data = e.data;
  switch (data.cmd) {
    case 'average':
      var result = calculateAverage(data); // Some function that calculates the average from the numeric array.
      self.postMessage(result);
      break;
    default:
      self.postMessage('Unknown command');
  }
}, false);

单击该按钮后,将从主页面调用 postMessageworker.postMessageJSON 对象传递给 worker,并添加 cmddata 键及其各自的值。worker 通过已定义的 message 处理程序处理该消息。

消息到达后,将在 worker 中执行实际计算,而不会阻塞事件循环。worker 检查传递的事件e,并像标准的 JavaScript 函数一样执行,message 执行完成后,结果将传递回主页。

在一个工作者的上下文中,selfthis 都引用了 worker 的全局范围。

有两种方法可以停止 worker:通过在主页上调用 worker.terminate() 或在 worker 本身内部调用 self.close()

广播频道 (Broadcast Channel)

广播频道 (Broadcast Channel) 是一种更通用的通信 API,利用广播频道,我们可以将消息广播到共享相同来源的所有上下文。来自相同来源的所有浏览器标签、iframe、服务的 worker 可以发出和接收消息:

// Connection to a broadcast channel
var bc = new BroadcastChannel('test_channel');

// Example of sending of a simple message
bc.postMessage('This is a test message.');

// Example of a simple event handler that only
// logs the message to the console
bc.onmessage = function (e) { 
  console.log(e.data); 
}

// Disconnect the channel
bc.close()

广播频道视觉化之后,看起来更加清晰:

img

然而,浏览器对广播频道的支持更为有限:

img

消息大小的影响

有两种向 Web Worker 发送消息的方法:

Web Worker 可用的 JS 功能

由于其多线程性质,Web Workers 仅能访问部分 JavaScript 的功能。以下是功能列表:

Web Worker的局限性

可悲的是,Web Workers 无法访问一些非常关键的 JavaScript 功能:

这意味着 Web Worker 无法操纵 DOM,因此也无法操纵UI,有时这可能会很棘手。但是一旦您了解了如何正确使用 Web Workers,您便会开始将它们用作单独的“计算机器”,而所有 UI 更改都将在您的页面代码中进行。worker 将为您完成计算繁重的任务,完成后会将结果传递给页面,该页面将对用户界面进行必要的更改。

错误处理

与任何 JavaScript 代码一样,您将需要处理 Web Worker 中抛出的所有错误。如果在执行 worker 时发生错误,则会触发 ErrorEvent。该界面包含三个有用的属性,用于找到问题所在:

这有一个例子:

function onError(e) {
  console.log('Line: ' + e.lineno);
  console.log('In: ' + e.filename);
  console.log('Message: ' + e.message);
}

var worker = new Worker('workerWithError.js');
worker.addEventListener('error', onError, false);
worker.postMessage(); // Start worker without a message.
self.addEventListener('message', function(e) {
  postMessage(x * 2); // Intentional error. 'x' is not defined.
};

您可以看到,我们在这里创建了一个 worker 并开始侦听 error 事件。

在 worker 脚本 workerWithError.js 中,该作用域内未定义 x,我们通过将 x 乘以 2 来创建故意异常。异常会传播到初始脚本,并且会调用 onError 并将错误信息传递进去。

5 个 Web Workers 用例

我们已经列出了 Web Workers 的优点和缺点。接下来,让我们来看看有哪些最强的用例:

参考资料