搞不清WebSocket和Web Worker,这三者到底有啥不一样?

5,177字
22–33 分钟
in

前端圈里经常听到WebSocket、Web Worker、Service Worker这三个词,长得像发音也像,动不动就搞混。WebSocket负责浏览器和服务器之间的实时双向聊天,Web Worker开个后台线程干重活不卡界面,Service Worker则是网络请求的中间人,断网也能玩。这篇直接把三者的区别、适用场景和选择流程掰开揉碎,附带实操步骤和避坑经验,看完再也不怕面试官追问。

目录

扒一扒WebSocket

WebSocket是一种全双工通信协议,维持一条浏览器和服务器之间的长连接。好比打电话,拨通后两边随时都能说话,直到一方挂断。这条连接一旦建立,服务器可以主动推数据给浏览器,不用等浏览器先问。

实现一个实时位置同步的场景,代码大致长这样:

// 建立WebSocket连接
const socket = new WebSocket('wss://example.com/location');

// 连接打开后发送初始数据
socket.onopen = () => {
  socket.send(JSON.stringify({ action: 'startTracking', userId: 123 }));
};

// 接收服务器推送的位置更新
socket.onmessage = (event) => {
  const locationData = JSON.parse(event.data);
  // 直接操作DOM更新地图上的标记
  document.getElementById('map').updateMarker(locationData);
};

写这段代码时有几个坑点需要留意。如果服务器地址写成了ws://而非wss://,在某些网络环境下会被运营商劫持或断开。另外onmessage里做大量DOM操作可能导致界面卡顿,因为WebSocket跑在主线程上,消息一多主线程就堵了。还有一点容易忽略:网络切换(比如WiFi切5G)会导致连接中断,代码里必须监听oncloseonerror并实现自动重连机制,否则用户会发现位置突然不动了。

WebSocket不强制要求HTTPS,但生产环境强烈建议用wss://,否则混合内容浏览器会报警告。它适合实时聊天、股票报价、多人游戏这类需要频繁双向推送的场景。但对于纯数据计算或离线缓存,WebSocket就帮不上忙了,这时候得看另外两位。

扒一扒Web Worker

Web Worker在后台开一个独立线程跑脚本,主线程该干嘛干嘛,两者互不阻塞。好比餐厅里前台服务员负责点菜上菜,后厨厨师专门做菜,谁也不用等谁。这样就算后厨算一百道菜的配料,前台的界面照样丝滑响应。

下面是一个用专用Worker处理大规模数组排序的例子。

主线程代码:

// 创建Worker实例
const sortWorker = new Worker('heavy-sort.js');

// 发送大数据给Worker处理
const bigArray = new Array(1000000).fill().map(() => Math.random());
sortWorker.postMessage({ array: bigArray });

// 接收处理结果
sortWorker.onmessage = (e) => {
  console.log('排序完成', e.data.sortedArray.slice(0, 10));
  // 这里才能安全地更新DOM
  document.getElementById('result').innerText = '排序搞定';
};

Worker线程脚本(heavy-sort.js):

self.onmessage = function(e) {
  const arr = e.data.array;
  // 执行耗时排序
  arr.sort((a, b) => a - b);
  // 把结果传回主线程
  self.postMessage({ sortedArray: arr });
};

使用Worker时要注意,Worker内部无法访问DOM,不能操作documentwindow的大部分属性和方法。如果试图在Worker里写document.getElementById,代码直接报错。另外Worker加载的脚本必须同源,不能跨域引用CDN上的文件。还有一点经常翻车:Worker线程里抛出的异常不会在主线程的控制台直接显示,需要在onerror回调里捕获并记录日志。对于需要多个页面共享的Worker,可以考虑共享Worker,但它的调试复杂度比专用Worker高出一截。

Web Worker特别适合图像处理、加密解密、大文件哈希计算这类CPU密集型任务。但它跟网络请求没有半毛钱关系,也管不了离线访问。

扒一扒Service Worker

Service Worker是Web Worker的一种特殊形式,充当浏览器和服务器之间的中间人。可以把它想象成公司前台,所有快递(网络请求)都得经过它手里,前台可以在没快递员(断网)的时候从仓库(缓存)里直接拿货给你。

注册一个Service Worker并拦截请求的典型流程:

// 检查浏览器是否支持
if ('serviceWorker' in navigator) {
  // 注册脚本,作用域默认为脚本所在目录
  navigator.serviceWorker.register('/sw-cache.js')
    .then(reg => console.log('注册成功,作用域', reg.scope))
    .catch(err => console.log('注册失败', err));
}

Service Worker脚本(sw-cache.js):

// 安装阶段缓存核心资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open('v1-static').then(cache => {
      return cache.addAll([
        '/',
        '/index.html',
        '/style.css',
        '/app.js'
      ]);
    })
  );
});

// 拦截fetch请求,优先从缓存读取
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      return response || fetch(event.request);
    })
  );
});

部署Service Worker时有几个致命细节。第一,脚本必须通过HTTPS提供,本地开发可以用localhost例外,但线上没有HTTPS直接罢工。第二,Service Worker的作用域由脚本路径决定,如果把脚本放在/js/sw.js,它只能控制/js/及其子路径下的页面,想控制全站必须放在根目录。第三,更新Service Worker脚本后,旧Worker不会立刻替换,需要等到所有使用旧Worker的页面关闭再打开,新Worker才会接管。这导致调试更新逻辑时经常出现“明明改了代码却不生效”的错觉,解决办法是在install事件里调用skipWaiting(),并在activate事件里清理旧缓存。另外Service Worker拦截了所有fetch请求,如果代码写得不严谨,很容易把非GET请求也错误地缓存起来,造成接口数据永久陈旧。

Service Worker是实现PWA离线访问的核心技术,也用于做后台同步、推送通知。但它同样无法访问DOM,而且生命周期比普通Worker复杂得多。

实战选型流程

开发一个前端项目时,面对这三个东西经常不知道翻谁的牌子。下面按需求类型给出两套决策流程。

方案一:实时双向通信需求

碰到需要服务器随时推送数据、浏览器也能随时发消息的场景,比如在线客服、协作白板、直播弹幕。

具体步骤:

  1. 确认项目是否真的需要双向实时。如果只是浏览器定期拉取新数据(比如每5秒查一次订单状态),用普通HTTP轮询或WebSocket反而大材小用,增加服务器负担。
  2. 确定需要WebSocket后,先申请一个安全的WebSocket地址。本地测试可以用ws://,但部署到公网必须换成wss://,否则现代浏览器会报安全警告甚至拒绝连接。
  3. 编写连接管理模块。除了监听onopenonmessageoncloseonerror,还要实现指数退避重连。当onclose触发时,设置一个定时器,等待1秒、2秒、4秒……直到重新连上。
  4. 处理心跳包。很多网关或负载均衡器会把长时间没活动的TCP连接切断,所以每隔30秒发一个空消息(比如{type:'ping'})保持连接活跃。
  5. 如果页面需要同时展示实时数据又做大量图表计算,别把计算逻辑塞进onmessage里,应该把原始数据通过postMessage丢给Web Worker处理,处理完再回到主线程更新DOM。

这套流程跑通后,就能得到一个稳定不掉线的实时通信功能。

方案二:离线优先或请求拦截需求

需要让网页在断网时还能显示内容,或者想劫持所有网络请求做自定义缓存策略,比如新闻阅读器、离线笔记、自定义图片裁剪后返回。

具体步骤:

  1. 先确认网站已部署HTTPS。没有证书的话去免费申请Let‘s Encrypt或者用云服务商提供的免费SSL。
  2. 在项目根目录创建一个Service Worker脚本文件,比如sw.js。注意不要放在/assets/之类的子目录下,否则作用域受限。
  3. install事件中打开一个指定版本的缓存空间,把关键的HTML、CSS、JS以及离线页面的fallback资源加进去。缓存列表写死路径,不要动态生成,防止缓存了不稳定的资源。
  4. fetch事件里编写策略。最简单的策略是“缓存优先”:先尝试从caches.match拿,拿不到再fetch网络。如果需要网络优先(比如接口数据),那就先fetch网络,成功后再更新缓存,失败时降级返回旧缓存。
  5. 注册Service Worker时注意作用域。如果想控制整个站点,register的第二个参数可以指定{scope: './'},但脚本本身必须位于根目录。
  6. 调试阶段在Chrome的Application -> Service Workers面板里勾选“Update on reload”,这样每次刷新页面都会强制更新Worker,避免缓存不更新的玄学问题。
  7. 上线后务必写清楚缓存版本号。每次改sw.js里的缓存名(比如'v2-static'),旧版本的缓存会在activate事件中被清理掉,否则用户磁盘会被各种旧缓存撑爆。

按照这个步骤操作,哪怕把网线拔了,刷新页面照样能看到之前缓存的界面。

方案三:繁重计算不卡界面

遇到前端需要跑大量数据运算、加密解密、图像滤镜,又不想让页面滚动掉帧或者点击没反应。

具体步骤:

  1. 把耗时逻辑单独抽成一个JavaScript文件。比如fib-worker.js专门算斐波那契数列,image-worker.js专门处理像素矩阵。
  2. 在主线程中通过new Worker('路径')创建实例。路径建议用绝对路径或者相对于根目录的写法,避免因为页面路由变化导致404。
  3. 设计主线程和Worker之间的消息协议。例如约定消息格式为{task: 'resize', width: 800, data: imageData},Worker收到后根据task字段执行不同操作,然后postMessage返回结果。
  4. 注意传递大对象时的性能。postMessage默认使用结构化克隆算法,对于几百MB的ArrayBuffer会触发内存拷贝。更好的做法是使用Transferable objects,在postMessage的第二个参数里把缓冲区的所有权转移给Worker,这样拷贝几乎零开销。写法是worker.postMessage({buf: arrayBuffer}, [arrayBuffer])
  5. 如果同一时间需要处理多个计算任务,可以维护一个Worker池,而不是频繁创建销毁Worker。创建一个Worker开销不小,复用能明显提升响应速度。
  6. 当页面关闭时,记得调用worker.terminate()释放线程资源,否则后台Worker会一直活着吃掉内存。

这套流程跑下来,原本会卡成PPT的页面,现在丝滑得跟德芙一样。

三者速查对照

类型核心作用多线程强制HTTPSDOM访问
WebSocket双向实时通信否(主线程)
Web Worker后台计算防卡顿不能
Service Worker网络代理+离线缓存不能

搞混这三兄弟的时候,就记住一句话:想跟服务器实时聊天找WebSocket,不想让复杂计算拖慢界面找Web Worker,想让断网也能打开页面找Service Worker。每个都有自己的专属地盘,选对了事半功倍,选错了要么缓存不了要么卡成狗。