首页 / 文章 / 专用线程、共享线程与服务线程:选择合适的浏览器线程类型

专用线程、共享线程与服务线程:选择合适的浏览器线程类型

了解专注型、共享型和服务型工作在范围、信息传递及目标上的差异,同时掌握每种类型何时才能真正提升网页应用的功能。

2425 词

页面的 JavaScript 在一个主线程上运行,但现代应用在处理大文件时仍能保持响应,可在离线状态下工作,并在空闲时接收推送通知。这一切很大程度上要归功于在工作线程中运行的脚本。不过“工作线程”实际上包含三种不同的工具,选择不当会白费力气。本指南将说明每种工具的用途、它们之间的通信方式,以及何时不适合使用。

这类工具一共有三种:

Web Workers
     │
     ├── Dedicated Worker
     │
     ├── Shared Worker
     │
     └── Service Worker

为何主线程需要辅助

默认情况下,你的代码会与 DOM 更新、输入处理、渲染以及动画共享同一个线程:

JavaScript
   ↓
DOM updates
   ↓
User interactions
   ↓
Rendering
   ↓
Animations

由于这些任务需要轮流执行,像下面这样的长时间同步调用会占用线程直到其返回:

const result = expensiveCalculation();

在它完成之前,浏览器无法处理输入或绘制画面。用户会遇到这样的情况:

User clicks button
        ↓
Heavy JavaScript starts
        ↓
Main thread is busy
        ↓
UI becomes sluggish
        ↓
Calculation finishes
        ↓
UI becomes responsive again

为解决此问题,工作线程会在独立的上下文中执行 JavaScript,这样主线程就能腾出时间处理界面相关任务。

页面与工作线程如何协作

想象有两个通过消息通道相连的线程:主线程负责 UI、DOM、事件处理及渲染,而工作线程则承担计算、解析和数据处理的工作:

                 Browser
                    │
          ┌─────────┴─────────┐
          │                   │
          ↓                   ↓
     Main Thread          Worker Thread
          │                   │
     UI / DOM             Heavy Work
     Events               Calculations
     Rendering             Parsing
     Interaction           Processing
          │                   │
          └──── Messages ─────┘

页面通过调用工作线程对象上的 postMessage 方法将数据发送给工作线程:

worker.postMessage(data);

在工作线程内部,全局的 postMessage 方法会将处理结果发送回页面:

postMessage(result);

双方不会共享普通变量;所有数据都以消息的形式传递。

为何工作线程无法操作 DOM

工作线程的全局作用域无法访问这些元素:

document
window
DOM elements

在 worker 文件中使用这样的代码行会直接失败,因为那里没有定义 document 变量:

// worker.js

document.querySelector("#app");

正确的做法是在 worker 中进行计算,然后将结果发送出去,再由主线程将其应用到页面上:

Main Thread
     │
     │ postMessage(data)
     ↓
   Worker
     │
     │ performs calculation
     ↓
   Worker
     │
     │ postMessage(result)
     ↓
Main Thread
     │
     ↓
Update DOM

将 DOM 的控制权保留在单个线程上,意味着后台代码永远无法在渲染过程中更改界面。

三种 worker 类型概览

它们的作用域各不相同:一个创建者、多个同源上下文,或是网络层:

┌──────────────────────────────┐
│        Web Workers           │
├──────────────────────────────┤
│                              │
│  Dedicated Worker            │
│  → One script/client         │
│                              │
│  Shared Worker               │
│  → Multiple same-origin      │
│    browsing contexts         │
│                              │
│  Service Worker              │
│  → Network / caching /       │
│    offline / background      │
│    capabilities              │
│                              │
└──────────────────────────────┘

用于复杂计算的专用 worker

专用 worker 由创建它的脚本所拥有。当团队说“把那项计算移到 worker 中”时,指的就是这种类型。

在页面端,你需要通过脚本 URL 创建 worker,向其发送一个数值,并记录返回的结果:

const worker = new Worker("worker.js");
worker.postMessage(1000000);
worker.onmessage = (event) => {
    console.log("Result:", event.data);
};

该工作进程会监听消息,将接收到的数值以下的所有整数相加,然后返回总和:

// worker.js

self.onmessage = (event) => {
    const number = event.data;
    let result = 0;
    for (let i = 0; i < number; i++) {
        result += i;
    }
    self.postMessage(result);
};

这种往返处理过程非常简单:

Main Thread
     │
     │ 1000000
     ↓
Dedicated Worker
     │
     │ Calculate
     ↓
   Result
     │
     ↓
Main Thread

该工作进程与其创建者绑定,不会与无关页面共享;每个标签页都会有两个独立的工作进程。

适合使用专用工作进程的场景

在单个应用程序中,那些需要大量CPU算力的任务非常适合使用专用工作进程。例如处理大型API数据包时,工作进程负责重新组织数据结构,而主线程则仅负责将其渲染出来。

API Response
     ↓
Large JSON
     ↓
   Worker
     ↓
Parse / Transform
     ↓
Main Thread
     ↓
Render UI

图片缩放和过滤操作也遵循相同的处理方式:

Image
  ↓
Worker
  ↓
Resize / Transform
  ↓
Result
  ↓
 UI

数据集的过滤、排序和聚合操作也是如此:

Large Dataset
      ↓
    Worker
      ↓
  Filtering
   Sorting
 Aggregation
      ↓
     UI

加密、压缩、大文件解析以及复杂的计算任务也同样适用。原则是:如果CPU密集型任务导致界面响应迟缓,就应该考虑使用专用工作进程。

多个标签页的共享工作线程

现在假设同一个应用程序同时在多个浏览上下文中打开:

Tab A
Tab B
Tab C

只要具有相同的源地址,多个窗口、标签页或iframe中的脚本就可以通过共享工作线程相互连接:

             Shared Worker
              /    |    \
             /     |     \
          Tab A   Tab B   Tab C

如果没有共享工作线程,每个标签页都会创建自己的工作线程:

Tab A → Worker A
Tab B → Worker B
Tab C → Worker C

有了共享工作线程后,所有标签页都可以连接到同一个实例:

Tab A ─┐
Tab B ─┼──> Shared Worker
Tab C ─┘

通过端口进行通信

可以通过明确的MessagePort来访问共享工作线程。页面会启动该端口并发送消息:

const worker = new SharedWorker("worker.js");
worker.port.start();
worker.port.postMessage("Hello");

每个新客户端都会在工作线程中触发connect事件,而工作线程的处理器则会监听该客户端的端口:

self.onconnect = (event) => {
    const port = event.ports[0];
    port.onmessage = (event) => {
        console.log(event.data);
    };
};

端口是与专用工作进程的主要实际区别:当客户端数量较多时,每个连接都需要独立的通道,而回复则通过对应标签页的端口返回。

多标签页场景

想象一个内部工具,不同的标签页显示不同的视图:

Tab 1 → Dashboard
Tab 2 → Reports
Tab 3 → Analytics

一个单独的背景组件可以存储所有标签页共用的状态或协调它们之间的通信:

              Shared Worker
                   │
        ┌──────────┼──────────┐
        ↓          ↓          ↓
     Tab 1       Tab 2      Tab 3
        │          │          │
        └──────────┼──────────┘
                   ↓
             Shared State

这样无需为每个标签页创建独立的工作进程实例,但所有上下文必须共享同一个起源。从历史情况来看,共享工作进程的支持一直落后于专用工作进程,尤其是在某些移动浏览器上,因此请先查看当前的兼容性数据。

用于处理网络及离线状态的服务工作进程

服务工作进程是最容易被误解的类型。它并非专用工作进程的别名,而是位于你的应用程序、浏览器与网络之间:

Browser
   │
   ↓
Service Worker
   │
   ├── Cache
   │
   ├── Network
   │
   ├── Offline response
   │
   └── Background capabilities

由于它能拦截请求,因此为离线功能、缓存、自定义请求处理、推送通知以及后台同步提供了基础支持。

位于页面与服务器之间

假设用户访问了您的网站:

https://example.com

如果没有服务工作器,每个请求都会直接从浏览器发送到服务器:

Browser
   ↓
Internet
   ↓
Server

一旦注册了服务工作器,请求会首先经过它处理,它可以从缓存中提供响应,或者将请求转发到网络:

Browser
   ↓
Service Worker
   ↓
   ├── Cache
   │
   └── Network

它决定了每个请求的处理方式。采用缓存优先策略时,能够立即返回已缓存的响应,否则则进行获取并在适当情况下进行缓存:

Request
   ↓
Is it cached?
   │
   ├── YES → Return cached response
   │
   └── NO
       ↓
     Network
       ↓
     Response
       ↓
     Cache if appropriate

具备离线功能的应用程序就是基于这一逻辑构建的。如需了解详细流程,请参阅使用服务工作器为网页应用启用离线支持

注册与生命周期

浏览器会管理一个生命周期,此处简化说明:

Register
   ↓
Download
   ↓
Install
   ↓
Activate
   ↓
Control Pages

首先在 API 可用时注册脚本:

if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/sw.js");
}

随后浏览器负责处理安装、激活和更新操作。而专用工作线程则是通过直接的构造函数调用创建的,这种情况并不适用于服务工作线程:

new Worker(...)

它们需要安全的上下文(HTTPS,开发环境中允许使用 localhost)。除非新工作线程主动接管,否则在页面重新加载之前它无法控制已打开的页面,这常常会给测试带来困惑。

拦截请求

最简单的服务工作线程会监听 fetch 事件;这个示例仅负责记录每个 URL:

self.addEventListener("fetch", (event) => {
    console.log("Request:", event.request.url);
});

真正的缓存功能则是基于该机制构建的。对于 app.js,如果缓存中存在则直接从缓存提供内容,否则进行获取、存储后再返回:

User requests app.js
        ↓
Service Worker
        ↓
Is app.js cached?
     /       \
   YES        NO
    ↓          ↓
 Return      Network
 Cache          ↓
              Cache
                ↓
             Return

因此它们在渐进式 Web 应用(PWA)及离线优先的设计中起着核心作用。

三者并列比较

专用工作线程

简而言之就是“属于某个页面的背景线程”:

 Page
  │
  ↓
Dedicated Worker

适用于需要大量 CPU 计算、解析、图像处理及数据处理的场景。

共享工作线程

简而言之就是“多个同源页面可以使用的同一个工作线程”:

Tab A ─┐
Tab B ─┼──> Shared Worker
Tab C ─┘

适用于需要在不同浏览上下文之间共享的背景任务,以及共享通信或状态。

服务工作线程

简而言之就是“应用程序与网络之间的中间层”:

App
 ↓
Service Worker
 ↓
Cache / Network

适用于离线应用、缓存、请求拦截、推送通知以及后台同步功能。

每种的简短总结

用一句话概括每种类型:

Dedicated Worker
        ↓
"Do this heavy computation for me."

Shared Worker
        ↓
"Let multiple pages use this worker."

Service Worker
        ↓
"Help my web application interact with
the network and browser capabilities."

以这种方式划分后,这三种类型就很难混淆了。

作用域、消息传递及典型用途

专用型:单个脚本,后台计算,使用postMessage(),无需DOM:

Scope:
One script

Main purpose:
Background computation

Communication:
postMessage()

DOM access:
❌ No

Common use:
Heavy computation

共享型:多个同源上下文,使用MessagePort,无需DOM,可实现标签页间协调:

Scope:
Multiple same-origin contexts

Main purpose:
Shared background work

Communication:
MessagePort

DOM access:
❌ No

Common use:
Cross-tab/shared worker communication

服务型:在其所在源地址和路径作用域内的页面,支持事件处理与平台API调用,无需DOM,具备缓存功能、离线支持以及推送和同步能力:

Scope:
Origin/path controlled pages

Main purpose:
Network + background capabilities

Communication:
Events / messaging / APIs

DOM access:
❌ No

Common use:
Caching, offline, push, background sync

当工作线程反而降低效率时

工作线程并不一定会提升性能。启动它们需要消耗资源,而且数据还必须在不同上下文之间传输:

 Main Thread
      ↕
    Worker

消息传递本身也需要时间。对于简单的任务,这些开销可能会超过任务本身的处理时间:

Small Task
   ↓
Worker overhead
   ↓
Communication
   ↓
Actual calculation

这样一来,工作线程的处理速度可能会慢于内联代码。只有当任务足够繁重,通过将任务交由工作线程处理能带来明显性能提升时,才应使用工作线程。

究竟什么会跨越边界

消息是通过结构化克隆算法进行复制的,因此较大的数据量会增加复制时间。像ArrayBuffer这样的可传输对象无需复制即可传递,但发送方将失去对其的访问权。SharedArrayBuffer只有在满足额外的安全要求(跨源隔离)的情况下才能实现真正的共享内存。对于大多数应用而言,还是使用显式消息更为合适:

Main Thread
    ↓
postMessage()
    ↓
  Worker
    ↓
postMessage()
    ↓
Main Thread

在 React 中使用专用工作线程

如果 React 应用在渲染过程中或处理函数中处理大量数据集,界面就会卡住:

React UI
   ↓
Large calculation
   ↓
Main thread blocked
   ↓
UI becomes sluggish

使用专用工作线程时,组件会发送数据,待结果返回后再更新状态:

React UI
   │
   ├──────────────> Worker
   │                  │
   │                  ↓
   │              Calculation
   │                  │
   │                  ↓
   │<──────────── Result
   │
   ↓
Update UI

在 worker 计算期间,React 会持续渲染。应在效应中创建 worker,并在其清理函数中调用 terminate(),以避免其生命周期超过组件的生命周期。这种方法适用于数据可视化、图像编辑器、音频和视频处理、大文件处理以及客户端分析。

选择合适的 worker

需要大量 CPU 资源的 JavaScript 任务应使用专用 worker:

Do you have CPU-heavy JavaScript?
          │
         YES
          ↓
   Use Dedicated Worker

如果需求如下:

Multiple tabs/pages need
the same worker

那么需要评估的类型为:

Shared Worker

而如果需求涉及以下任何情况:

Caching
Offline support
Network interception
Push notifications
Background sync

那么答案是:

Service Worker

常见错误

将所有功能都放入 worker 中

仅在 worker 能解决实际问题的情况下使用它们。

期望能够访问 DOM

这是行不通的:

worker.document.querySelector(...);

工作线程将数据传回;主线程负责更新DOM。

使用服务工作线程进行计算

服务工作线程负责处理网络请求及应用生命周期,浏览器在空闲时可能会暂停它们。对于纯粹的数值计算而言:

1 million records
       ↓
complex calculation

应从专用工作线程开始。

忽视消息传递的开销

每次数据交换都会产生额外开销:

Main Thread
     ↕
Messaging
     ↕
  Worker

仅传输足够处理当前任务的数据量。

总结

核心原则是:昂贵的JavaScript操作不应无故阻塞主线程。这三种类型对应着三类问题:

                    Web Workers
                         │
          ┌──────────────┼──────────────┐
          ↓              ↓              ↓
     Dedicated         Shared         Service
       Worker          Worker          Worker
          │              │              │
     One script      Multiple        Network /
     uses it         contexts        offline
  • 专用工作线程:用于单个页面的复杂计算。
  • 共享工作线程:供多个同源上下文共享的后台任务。
  • 服务工作线程:具备网络拦截、缓存以及离线运行功能。
  • 在采用之前,需先测量阻塞任务的时间,确认其耗时超过消息传递的开销,并检查浏览器的支持情况。从这个角度看,服务工作线程其实是针对三个不同问题的三种具体解决方案。

    相关阅读