前端圈里经常听到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)会导致连接中断,代码里必须监听onclose和onerror并实现自动重连机制,否则用户会发现位置突然不动了。
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,不能操作document、window的大部分属性和方法。如果试图在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复杂得多。
实战选型流程
开发一个前端项目时,面对这三个东西经常不知道翻谁的牌子。下面按需求类型给出两套决策流程。
方案一:实时双向通信需求
碰到需要服务器随时推送数据、浏览器也能随时发消息的场景,比如在线客服、协作白板、直播弹幕。
具体步骤:
- 确认项目是否真的需要双向实时。如果只是浏览器定期拉取新数据(比如每5秒查一次订单状态),用普通HTTP轮询或WebSocket反而大材小用,增加服务器负担。
- 确定需要WebSocket后,先申请一个安全的WebSocket地址。本地测试可以用
ws://,但部署到公网必须换成wss://,否则现代浏览器会报安全警告甚至拒绝连接。 - 编写连接管理模块。除了监听
onopen、onmessage、onclose、onerror,还要实现指数退避重连。当onclose触发时,设置一个定时器,等待1秒、2秒、4秒……直到重新连上。 - 处理心跳包。很多网关或负载均衡器会把长时间没活动的TCP连接切断,所以每隔30秒发一个空消息(比如
{type:'ping'})保持连接活跃。 - 如果页面需要同时展示实时数据又做大量图表计算,别把计算逻辑塞进
onmessage里,应该把原始数据通过postMessage丢给Web Worker处理,处理完再回到主线程更新DOM。
这套流程跑通后,就能得到一个稳定不掉线的实时通信功能。
方案二:离线优先或请求拦截需求
需要让网页在断网时还能显示内容,或者想劫持所有网络请求做自定义缓存策略,比如新闻阅读器、离线笔记、自定义图片裁剪后返回。
具体步骤:
- 先确认网站已部署HTTPS。没有证书的话去免费申请Let‘s Encrypt或者用云服务商提供的免费SSL。
- 在项目根目录创建一个Service Worker脚本文件,比如
sw.js。注意不要放在/assets/之类的子目录下,否则作用域受限。 - 在
install事件中打开一个指定版本的缓存空间,把关键的HTML、CSS、JS以及离线页面的fallback资源加进去。缓存列表写死路径,不要动态生成,防止缓存了不稳定的资源。 - 在
fetch事件里编写策略。最简单的策略是“缓存优先”:先尝试从caches.match拿,拿不到再fetch网络。如果需要网络优先(比如接口数据),那就先fetch网络,成功后再更新缓存,失败时降级返回旧缓存。 - 注册Service Worker时注意作用域。如果想控制整个站点,
register的第二个参数可以指定{scope: './'},但脚本本身必须位于根目录。 - 调试阶段在Chrome的Application -> Service Workers面板里勾选“Update on reload”,这样每次刷新页面都会强制更新Worker,避免缓存不更新的玄学问题。
- 上线后务必写清楚缓存版本号。每次改
sw.js里的缓存名(比如'v2-static'),旧版本的缓存会在activate事件中被清理掉,否则用户磁盘会被各种旧缓存撑爆。
按照这个步骤操作,哪怕把网线拔了,刷新页面照样能看到之前缓存的界面。
方案三:繁重计算不卡界面
遇到前端需要跑大量数据运算、加密解密、图像滤镜,又不想让页面滚动掉帧或者点击没反应。
具体步骤:
- 把耗时逻辑单独抽成一个JavaScript文件。比如
fib-worker.js专门算斐波那契数列,image-worker.js专门处理像素矩阵。 - 在主线程中通过
new Worker('路径')创建实例。路径建议用绝对路径或者相对于根目录的写法,避免因为页面路由变化导致404。 - 设计主线程和Worker之间的消息协议。例如约定消息格式为
{task: 'resize', width: 800, data: imageData},Worker收到后根据task字段执行不同操作,然后postMessage返回结果。 - 注意传递大对象时的性能。
postMessage默认使用结构化克隆算法,对于几百MB的ArrayBuffer会触发内存拷贝。更好的做法是使用Transferable objects,在postMessage的第二个参数里把缓冲区的所有权转移给Worker,这样拷贝几乎零开销。写法是worker.postMessage({buf: arrayBuffer}, [arrayBuffer])。 - 如果同一时间需要处理多个计算任务,可以维护一个Worker池,而不是频繁创建销毁Worker。创建一个Worker开销不小,复用能明显提升响应速度。
- 当页面关闭时,记得调用
worker.terminate()释放线程资源,否则后台Worker会一直活着吃掉内存。
这套流程跑下来,原本会卡成PPT的页面,现在丝滑得跟德芙一样。
三者速查对照
| 类型 | 核心作用 | 多线程 | 强制HTTPS | DOM访问 |
|---|---|---|---|---|
| WebSocket | 双向实时通信 | 否(主线程) | 否 | 能 |
| Web Worker | 后台计算防卡顿 | 是 | 是 | 不能 |
| Service Worker | 网络代理+离线缓存 | 是 | 是 | 不能 |
搞混这三兄弟的时候,就记住一句话:想跟服务器实时聊天找WebSocket,不想让复杂计算拖慢界面找Web Worker,想让断网也能打开页面找Service Worker。每个都有自己的专属地盘,选对了事半功倍,选错了要么缓存不了要么卡成狗。
