Web Worker 前端多线程

130 阅读8分钟

Web Worker定义

Web Worker 是浏览器提供的多线程能力,允许在主线程之外运行 JavaScript。

它的目标只有一个: 👉 把耗时任务从 UI 线程中挪走,让页面保持流畅。

Web Worker作用

解决主线程的现实困境,主线程同时负责:

  • JS 执行
  • 页面渲染
  • 用户交互
  • 事件响应

一旦遇到👇

  • 大量计算
  • JSON 解析
  • 文件处理
  • 图片 / 视频处理
  • 高频轮询 / 长时间任务

👉 UI 就会卡,页面就会“假死”。

工作原理

1. 线程模型

主线程(UI / React)
   │
   ├── postMessage
   ▼
Worker 线程(独立事件循环)
   │
   └── postMessage
  • Worker 运行在 独立线程
  • 拥有自己的事件循环
  • 不会阻塞 UI

2. 通信机制

window.postMessage 是异步的。调用该方法时,消息不会立即传递给接收方,而是被放入消息队列,等待当前所有同步代码执行完毕后,接收方才通过 message 事件回调处理。

这种设计避免了阻塞主线程,保持了JS单线程事件循环机制的高效。

🌎 异步是针对哪一方?

答案是:针对发送方。

  • 发送方视角(异步):postMessage 函数本身会立即返回,不会阻塞后续代码执行。但“消息送达”这个行为是异步的,发送方无法立刻知道对方是否已收到。

  • 接收方视角(事件驱动):接收方是被动触发回调,无所谓同步异步。

🍄 是放入接收方的消息队列吗?

严格来说,消息会先进入接收窗口所属进程/线程的消息队列,但这个过程涉及浏览器跨进程/线程的通信机制。

📦 数据到底怎么传的?

很多人以为postMessage传的是引用,其实不是!它用的是结构化克隆算法(Structured Clone Algorithm),整个过程是这样的:

  1. 序列化(发送方)

    postMessage 调用时,浏览器立即对数据进行结构化克隆,生成一个独立的内存快照。这一过程是同步完成的,如果对象无法序列化(如包含函数),会立即抛出异常。

  2. 跨线程传递

    序列化后的数据(现在是二进制快照)通过浏览器内部机制(如IPC)传递给接收方进程。

  3. 入队(接收方)

    接收方进程将任务放入其消息队列。注意,此时任务携带的是二进制数据包,还不是可操作的JavaScript对象。

  4. 反序列化(接收方-执行前)

    当接收方事件循环准备执行该任务时,进入“执行前”阶段:浏览器会同步地将二进制快照通过结构化克隆算法重新构造为接收方进程可用的JavaScript对象。关键:这是懒执行的,不会提前反序列化。

  5. 执行(接收方)

    反序列化完成后,浏览器创建 MessageEvent 对象,将新对象赋给 data 属性,最后触发回调函数。

// main.js
const worker = new Worker('worker.js');

worker.onmessage = (e) => {
  console.log('收到 Worker 消息:', e.data);
};

worker.postMessage('Hello Worker'); // 异步发送
console.log('这行会先打印');

输出顺序:

这行会先打印
收到 Worker 消息: HELLO WORKER

🔍 为什么这样设计?

问得好!这完全是内存和性能的考量

如果接收方积压了100个消息还没处理,提前反序列化所有消息会把内存撑爆!而且反序列化很消耗CPU,放在执行前一刻做,可以分摊开销,避免主线程长时间阻塞

🤔 异步怎么保证数据可靠?

因为数据是序列化后的快照,完全独立于原始对象。即使你发完消息立刻修改原始对象,已经发出去的消息完全不受影响!

这就实现了强隔离性,再也不用担心竞态条件了~

💪 怎么实现“同步效果”?(外卖例子来了!)

postMessage本身没有同步模式,但我们可以用Promise+MessageChannel封装!

先看原始异步写法(像在家傻等外卖):

const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
    // 3. 外卖终于到了,才能开始吃
    console.log('收到回复:', event.data);
};
// 1. 点外卖
targetWindow.postMessage('请求数据', '*', [channel.port2]);
// 2. 打印日志(外卖还没到,代码先跑了)
console.log('这条日志会先打印');

问题:代码执行顺序(2→3)和阅读顺序(1→3→2)是反的,逻辑复杂后直接变成回调地狱😱

用Promise封装(给外卖加个智能通知):

function sendMessage(data) {
    return new Promise((resolve) => {
        const channel = new MessageChannel();
        channel.port1.onmessage = (event) => resolve(event.data);
        targetWindow.postMessage(data, '*', [channel.port2]);
    });
}

用await调用(像外卖自动提醒):

async function main() {
    console.log('1. 准备点外卖');
    
    // await在这里暂停了函数,但没阻塞线程!
    const reply = await sendMessage('请求数据');
    
    // 3. 外卖送达提醒响起,开始吃
    console.log('3. 收到回复:', reply);
    
    // 可以继续"同步"地发下一个请求
    const reply2 = await sendMessage('第二个请求');
    console.log('5. 收到第二个回复:', reply2);
}

main();
console.log('2. 这行代码会在await之前执行(继续刷手机等外卖)');

await到底做了什么?

  • 执行到await时,main函数暂停在这一行,让出线程去执行其他代码(比如打印上面的2)

  • 等到resolve(event.data)被调用(外卖送达),再把main函数恢复,把结果赋给reply

这就像你点外卖用了自动通知:你不用一直盯着手机(不阻塞线程),但可以按顺序写下"点餐→等送达→吃饭→再点餐"的逻辑,手机到点会提醒你!

🔥 终极一问:反序列化到底什么时候发生?

入队之后,执行之前!

具体来说:

  1. 发送方调用postMessage → 序列化
  2. 跨进程传递
  3. 接收方消息队列 入队(此时还是二进制)
  4. 事件循环准备执行该任务 → 反序列化
  5. 创建MessageEvent,赋值data属性
  6. 触发回调执行

💎 总结一下

· 可靠性 → 源于序列化,数据快照与原始对象隔离

· 异步性 → 发送方不阻塞,接收方事件驱动

· 同步感 → 靠Promise/async/await封装,像外卖自动提醒一样直观

· 反序列化时机 → 执行前一刻,懒加载的思路

3. 不能访问DOM

Worker 不访问 DOM 是浏览器为了线程安全、性能优化而设计的约束。它专注“计算”,主线程专注“渲染”。

  1. Web Worker 的本质
  • Web Worker 是浏览器提供的多线程环境,让 JS 能够在主线程之外运行。
  • 它有自己的全局上下文(WorkerGlobalScope),并不是 window,所以不包含 DOM API。
  • 目的是为了避免阻塞主线程,尤其是渲染线程。浏览器 UI 渲染和 DOM 操作都在主线程。
  1. DOM 不是线程安全的
  • DOM 树是共享资源,如果多个线程直接操作,会产生竞态条件和不确定行为。
  • 比如:一个 Worker 修改了 DOM 节点的属性,主线程同时在渲染这个节点,会出现不可预期的渲染结果或浏览器崩溃。

简单理解:DOM 就像一个昂贵的共享数据库,不能被多个线程同时写入,否则数据容易“炸”。

  1. Worker 与主线程的通信机制
  • Worker 和主线程通过 postMessage 进行通信,消息会被序列化(structured clone)
  • Worker 可以处理数据、计算、加密等“纯逻辑”,然后把结果发送给主线程,由主线程更新 DOM。
// main.js
const worker = new Worker('worker.js');

worker.onmessage = (e) => {
  document.getElementById('output').textContent = e.data;
};

worker.postMessage('Hello Worker');

// worker.js
onmessage = (e) => {
  const result = e.data.toUpperCase(); // 模拟耗时计算
  postMessage(result);
};
  • 这样既保证了 DOM 操作在主线程安全进行,又利用 Worker 提升计算性能。
  • SharedWorkerService Worker 等都是类似的设计:它们也不能直接操作 DOM。
  • 如果真的想“间接操作”,Worker 可以返回数据,主线程再去渲染。
┌───────────────┐
│   主线程 UI   │
│ ┌───────────┐│
│ │   DOM     ││  ← 只能主线程操作
│ └───────────┘│
└─────┬────────┘
      │ postMessage / onmessage
      │
      ▼
┌───────────────┐
│   Web Worker  │  ← 只能做计算、数据处理
│               │
│ 复杂逻辑 / AI │
│ 数据处理 / 加密│
└───────────────┘
      ▲
      │ postMessage / onmessage
      │
┌─────┴────────┐
│ 主线程接收数据│
│ 更新 DOM      │
└──────────────┘

Web Worker分类

类型名称特点使用场景
Dedicated Worker专用 Worker一对一、最常用计算、解析、后台任务
Shared Worker共享 Worker多页面共享多标签页共享状态
Service Workerservice Worker拦截网络请求PWA / 离线缓存
Worklet——低延迟音频 / 渲染

👉 日常业务中 90% 用 Dedicated Worker

Shared Worker 的作用就是 同源下多窗口或多个脚本共享同一个 Worker,适合一些需要跨标签页共享状态或逻辑的场景。

场景原因 / 优势
跨标签页共享 WebSocket比如你网站在多个标签页同时打开,要共享同一个 WebSocket 连接,避免每个标签页都建立一个。
全局状态管理多标签页共享登录状态、实时消息状态、计数器等。
缓存数据对一些计算结果或请求结果进行缓存,所有标签页可直接复用,不必重复计算或请求。
后台任务跨标签页执行定时任务、轮询接口、同步数据等。
// shared-worker.js
onconnect = (e) => {
  const port = e.ports[0];
  port.onmessage = (msg) => {
    port.postMessage(`收到: ${msg.data}`);
  };
};

// main.js
const worker = new SharedWorker('shared-worker.js');
worker.port.start(); // 必须启动 port
worker.port.postMessage('Hello SharedWorker');
worker.port.onmessage = (e) => console.log(e.data);

当你需要多个同源上下文共享同一份逻辑、状态或连接时,选择 Shared Worker
如果只在一个页面里使用,普通 Worker 就够了。

Web Worker特点

  • ✅ 真正的并行执行
  • ✅ 避免主线程阻塞
  • ❌ 不能操作 DOM
  • ❌ 创建和通信有成本
  • ⚠️ 数据传输需要考虑性能
  1. ✅ 优点
  • 提升页面响应速度
  • 适合 CPU 密集型任务
  • 可作为后台“计算引擎”
  • 可发送异步请求
  • 可运行 fetch / WebSocket / IndexedDB
  1. ❌ 缺点
  • 无法操作 DOM
  • 通信存在数据拷贝成本
  • 创建/销毁开销不小
  • 调试、打包需要额外配置

适用场景

  1. 🔥 强烈推荐使用
  • 大数据计算 / 排序 / 过滤
  • 图片处理、压缩、滤镜
  • 视频转码、音频分析
  • 文件解析(CSV / Excel / JSON)
  • 加密、解密、哈希计算
  1. ⚠️ 谨慎使用
  • 轻量逻辑
  • 高频短任务
  • 强依赖 DOM 的操作

前端使用

  1. ❌ 常见误区
  • 每个组件 new 一个 Worker
  • Worker 生命周期不清理
  • 主线程/Worker 强耦合
  1. ✅ 推荐模式:Hook + 单例 Worker
const { post } = useWorker() // React Hook 库
const result = await post('compute', payload)

特点:

  • Worker 只创建一次
  • Promise 化调用
  • 组件只关心结果

Web Worker 是全局资源,而不是组件资源。

内存泄漏

Worker 使用不当确实容易造成 内存泄漏,尤其是长期运行的 Worker。

为什么会内存泄漏

  1. Worker 占用独立线程内存

    • Worker 有自己的全局作用域(WorkerGlobalScope),所有变量和闭包都在这个线程中存在。
    • 如果 Worker 长时间不结束,它占用的内存不会被主线程回收。
  2. 未解除事件监听

    • 主线程给 Worker 注册了 onmessageaddEventListener,但 Worker 已不再需要或标签页关闭,引用仍存在。
  3. 引用外部资源

    • Worker 持有大对象或数据(比如缓存、ArrayBuffer、图片等),不会被 GC 回收,导致内存增长。

正确终止 Worker

1:主线程主动终止

const worker = new Worker('worker.js');

// 使用完毕后
worker.terminate(); // 立即停止 Worker 线程
  • terminate()同步操作,立即停止 Worker。
  • Worker 内部不会再执行任何代码,也不会触发 onclosefinally,要提前处理清理逻辑。

2:Worker 自行关闭

// worker.js
self.close(); // Worker 自己可以调用关闭自己
  • 功能等同于 terminate(),在 Worker 内部调用。
  • 可在完成任务或遇到异常时使用。

3:解除事件监听

  • 主线程应移除绑定的事件,避免保留引用:
worker.onmessage = null;
worker.onerror = null;
  • 对使用 addEventListener 的,记得 removeEventListener

使用建议:

  1. ** 短生命周期 **:尽量不要让 Worker 长时间挂起,任务完成立即 terminate。
  2. ** 数据传输 **:用 Transferable Objects(如 ArrayBuffer)避免复制大数据,提高性能同时减少内存占用。
  3. ** SharedWorker **:如果是跨标签页共享,要确保 最后一个标签页关闭后释放 Worker,避免长期占用。

Worker 占用独立线程内存,如果不 terminate 或解除事件引用,就容易泄漏。使用完毕及时 worker.terminate() 或在 Worker 内部 self.close(),并解除事件监听,是保证内存安全的关键。

Web Worker + WebSocket

传统的医疗影像渲染方案往往面临两个致命问题:

问题1:大文件解析阻塞UI

直接把DICOM文件扔到浏览器解析,遇到大尺寸影像(如乳腺摄影MG,可能达到数千万像素)时,JavaScript主线程会被解析操作长时间占用,页面直接卡死,用户体验极差。

问题2:内存爆炸

如果一次性加载数百帧CT/MR影像,浏览器内存很容易被撑爆,导致标签页崩溃。

┌─────────────┐      ┌──────────────┐      ┌─────────────┐
│   WebSocket │─────▶│  Web Worker  │─────▶│  主线程渲染  │
│  (接收数据)  │      │ (解析/计算)   │      │  (Canvas画)  │
└─────────────┘      └──────────────┘      └─────────────┘
        ▲                    │                      │
        │                    │                      │
┌─────────────┐      ┌───────▼──────┐      ┌───────▼──────┐
│   Node.js   │      │  影像数据     │      │  用户交互     │
│   服务端     │      │  处理中...    │      │  缩放/平移    │
└─────────────┘      └──────────────┘      └─────────────┘

核心思路:

  • WebSocket:负责从服务端流式接收影像数据

  • Web Worker:在独立线程解析DICOM、计算窗宽窗位,不阻塞UI

  • 主线程:只负责接收渲染好的像素数据,绘制到Canvas

在开始编码前,需要了解几个关键技术点:

  1. 结构化克隆与Transferable Objects

postMessage默认使用结构化克隆算法深拷贝数据。但对于大型二进制数据(如ArrayBuffer),可以用Transferable Objects实现零拷贝传输:

// 发送方:数据所有权转移给接收方
worker.postMessage({ buffer: arrayBuffer }, [arrayBuffer]);

// 接收方可以直接使用,发送方不再拥有该数据
  1. DICOM影像特点
  • 像素深度通常是16位(普通图片是8位)

  • 需要窗宽窗位(Window Level)转换才能显示

  • 可能包含多帧(如CT/MR序列)

  1. WebSocket二进制模式

医疗影像传输建议开启二进制模式,避免Base64编码带来的30%体积膨胀:

ws.binaryType = 'arraybuffer';  // 接收二进制数据

📦 第一步:主线程代码

// main.js - 主线程负责WebSocket连接和最终渲染
class MedicalImageViewer {
  constructor(canvasId) {
    this.canvas = document.getElementById(canvasId);
    this.ctx = this.canvas.getContext('2d');
    this.worker = new Worker('worker.js');  // 启动Web Worker
    this.ws = null;
    this.initWebSocket();
    this.setupWorker();
  }

  initWebSocket() {
    this.ws = new WebSocket('wss://your-server.com/medical-stream');
    
    // 关键:设置为二进制模式
    this.ws.binaryType = 'arraybuffer';
    
    this.ws.onopen = () => {
      console.log('WebSocket连接成功');
      // 发送请求参数,告诉服务端需要哪些影像
      this.ws.send(JSON.stringify({
        type: 'request',
        studyId: 'STUDY_12345',      // 检查ID
        seriesId: 'SERIES_67890',     // 序列ID
        frameStart: 0,                 // 起始帧
        frameCount: 100,               // 请求帧数
        windowSize: {
          width: this.canvas.width,
          height: this.canvas.height
        }
      }));
    };
    
    this.ws.onmessage = (event) => {
      if (event.data instanceof ArrayBuffer) {
        // 二进制数据:DICOM文件内容
        // 使用Transferable Objects转移所有权,避免拷贝
        this.worker.postMessage({
          type: 'image-data',
          data: event.data,
          frameIndex: this.currentFrameIndex++
        }, [event.data]);  // 第二个参数指定转移哪些对象
      } else {
        // JSON数据:元数据或控制信息
        const metadata = JSON.parse(event.data);
        this.worker.postMessage({
          type: 'metadata',
          data: metadata
        });
      }
    };
    
    this.ws.onerror = (error) => {
      console.error('WebSocket错误:', error);
      // 实际生产环境需要添加重连机制
    };
  }
  
  setupWorker() {
    this.worker.onmessage = (event) => {
      const { type, imageData, width, height, frameIndex } = event.data;
      
      if (type === 'render-ready') {
        // Worker已经计算好像素数据,直接渲染
        const imageBitmap = new ImageData(
          new Uint8ClampedArray(imageData),
          width,
          height
        );
        
        // 如果是多帧序列,可以根据frameIndex决定显示哪一帧
        this.ctx.putImageData(imageBitmap, 0, 0);
        
        // 更新UI显示当前帧信息
        this.updateFrameInfo(frameIndex);
      }
    };
  }
  
  // 用户交互:调整窗宽窗位
  adjustWindow(center, width) {
    // 把调整参数发给Worker重新计算
    this.worker.postMessage({
      type: 'adjust-window',
      center,
      width
    });
  }
  
  // 切换到指定帧(用于CT/MR序列)
  goToFrame(frameIndex) {
    this.currentFrameIndex = frameIndex;
    // 通知Worker重新渲染该帧
    this.worker.postMessage({
      type: 'render-frame',
      frameIndex
    });
  }
  
  updateFrameInfo(frameIndex) {
    document.getElementById('frame-info').textContent = 
      `帧: ${frameIndex + 1}/${this.totalFrames}`;
  }
}

// 使用示例
const viewer = new MedicalImageViewer('medical-canvas');

⚙️ 第二步:Web Worker代码

// worker.js - 独立线程处理医疗影像
let imageFrames = new Map();      // 存储所有帧的原始数据
let currentMetadata = null;       // 当前元数据
let currentFrameIndex = 0;        // 当前显示帧
let currentWindowCenter = null;   // 当前窗位
let currentWindowWidth = null;    // 当前窗宽

// 简化的DICOM解析函数
// 生产环境建议使用cornerstone-wado-image-loader或dicomParser
function parseDICOM(arrayBuffer) {
  const dv = new DataView(arrayBuffer);
  
  // 这里只是示例,实际DICOM解析要复杂得多
  // 需要按照DICOM标准读取各种tag
  return {
    pixelData: new Uint16Array(arrayBuffer),  // 16位像素数据
    rows: 512,                                 // 影像高度
    columns: 512,                              // 影像宽度
    bitsAllocated: 16,
    windowCenter: 40,                          // 默认窗位
    windowWidth: 400,                          // 默认窗宽
    rescaleSlope: 1,                           // 灰度变换参数
    rescaleIntercept: 0
  };
}

// 窗宽窗位转换算法(核心)
function applyWindowLevel(pixelData, center, width, rows, cols) {
  // 计算映射范围
  const min = center - width / 2;
  const max = center + width / 2;
  const range = max - min;
  
  // 输出RGBA数组(每个像素4个字节)
  const result = new Uint8ClampedArray(rows * cols * 4);
  
  for (let i = 0; i < rows * cols; i++) {
    let pixel = pixelData[i];
    
    // 应用 rescale(如果有)
    if (currentMetadata?.rescaleSlope) {
      pixel = pixel * currentMetadata.rescaleSlope + 
              currentMetadata.rescaleIntercept;
    }
    
    // 映射到0-255范围
    let normalized = ((pixel - min) / range) * 255;
    normalized = Math.max(0, Math.min(255, normalized));
    
    // 灰度图:R=G=B
    result[i * 4] = normalized;      // R
    result[i * 4 + 1] = normalized;  // G
    result[i * 4 + 2] = normalized;  // B
    result[i * 4 + 3] = 255;         // A(完全不透明)
  }
  
  return result;
}

// 解析并存储一帧影像
function processImageFrame(arrayBuffer, frameIndex) {
  const metadata = parseDICOM(arrayBuffer);
  
  // 存储元数据(如果是第一帧,保存作为参考)
  if (!currentMetadata) {
    currentMetadata = metadata;
    currentWindowCenter = metadata.windowCenter;
    currentWindowWidth = metadata.windowWidth;
  }
  
  // 存储像素数据
  imageFrames.set(frameIndex, metadata.pixelData);
  
  // 如果是当前要显示的帧,立即渲染
  if (frameIndex === currentFrameIndex) {
    renderCurrentFrame();
  }
}

// 渲染当前帧
function renderCurrentFrame() {
  const pixelData = imageFrames.get(currentFrameIndex);
  if (!pixelData || !currentMetadata) return;
  
  // 应用窗宽窗位
  const imageData = applyWindowLevel(
    pixelData,
    currentWindowCenter,
    currentWindowWidth,
    currentMetadata.rows,
    currentMetadata.columns
  );
  
  // 发回主线程渲染
  self.postMessage({
    type: 'render-ready',
    imageData: imageData.buffer,
    width: currentMetadata.columns,
    height: currentMetadata.rows,
    frameIndex: currentFrameIndex
  }, [imageData.buffer]);  // 转移所有权,避免拷贝
}

// 监听主线程消息
self.onmessage = function(event) {
  const { type, data, frameIndex } = event.data;
  
  switch(type) {
    case 'image-data':
      // 收到新的DICOM数据
      processImageFrame(data, frameIndex);
      break;
      
    case 'metadata':
      // 更新元数据(如序列信息)
      currentMetadata = { ...currentMetadata, ...data };
      break;
      
    case 'adjust-window':
      // 调整窗宽窗位
      currentWindowCenter = data.center;
      currentWindowWidth = data.width;
      renderCurrentFrame();  // 重新渲染
      break;
      
    case 'render-frame':
      // 切换到指定帧
      currentFrameIndex = data;
      renderCurrentFrame();
      break;
  }
};

🖥️ 第三步:服务端代码(Node.js + ws库)

// server.js
const WebSocket = require('ws');
const fs = require('fs').promises;
const path = require('path');

const wss = new WebSocket.Server({ port: 8080 });

// 模拟DICOM影像服务器
class DICOMServer {
  constructor() {
    this.studyCache = new Map();  // 简单缓存
  }
  
  async getImageData(studyId, seriesId, frameIndex) {
    // 实际实现:从文件系统或PACS读取DICOM文件
    // 这里模拟从本地读取
    const filePath = path.join(
      __dirname, 
      'images', 
      studyId, 
      seriesId, 
      `frame_${frameIndex}.dcm`
    );
    
    try {
      const buffer = await fs.readFile(filePath);
      return buffer;
    } catch (err) {
      console.error(`读取影像失败: ${filePath}`);
      return null;
    }
  }
  
  async getStudyInfo(studyId) {
    // 获取检查信息(总帧数、影像尺寸等)
    // 实际从数据库查询
    return {
      totalFrames: 100,
      rows: 512,
      columns: 512,
      seriesDescription: 'CT Chest'
    };
  }
}

const server = new DICOMServer();

wss.on('connection', (ws) => {
  console.log('客户端已连接');
  
  ws.on('message', async (message) => {
    try {
      // 先尝试解析JSON
      let req;
      try {
        req = JSON.parse(message);
      } catch (err) {
        // 不是JSON,可能是二进制数据(如控制命令)
        return;
      }
      
      if (req.type === 'request') {
        console.log(`请求影像: ${req.studyId}/${req.seriesId}`);
        
        // 先发送元数据
        const studyInfo = await server.getStudyInfo(req.studyId);
        ws.send(JSON.stringify({
          type: 'series-info',
          ...studyInfo
        }));
        
        // 流式发送所有帧
        for (let i = req.frameStart; i < req.frameStart + req.frameCount; i++) {
          const imageBuffer = await server.getImageData(
            req.studyId, 
            req.seriesId, 
            i
          );
          
          if (imageBuffer) {
            // 直接发送二进制数据
            ws.send(imageBuffer);
            
            // 可选:添加进度信息
            if (i % 10 === 0) {
              ws.send(JSON.stringify({
                type: 'progress',
                current: i - req.frameStart + 1,
                total: req.frameCount
              }));
            }
          }
          
          // 模拟网络延迟,避免压垮客户端
          await new Promise(resolve => setTimeout(resolve, 20));
        }
        
        // 发送完成通知
        ws.send(JSON.stringify({
          type: 'transfer-complete',
          frameCount: req.frameCount
        }));
      }
    } catch (err) {
      console.error('处理请求失败:', err);
      ws.send(JSON.stringify({
        type: 'error',
        message: '服务器内部错误'
      }));
    }
  });
  
  ws.on('close', () => {
    console.log('客户端断开连接');
  });
});

wss.on('error', (err) => {
  console.error('WebSocket服务器错误:', err);
});

console.log('WebSocket服务器运行在 ws://localhost:8080');

🔄 第四步:理解数据流

还记得之前的外卖例子吗?整个数据流转过程和点外卖一模一样:

// 原始异步写法(像在家傻等外卖)
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
    // 3. 外卖终于到了,才能开始吃
    console.log('收到回复:', event.data);
};
// 1. 点外卖
targetWindow.postMessage('请求数据', '*', [channel.port2]);
// 2. 打印日志(外卖还没到,代码先跑了)
console.log('这条日志会先打印');

用Promise封装后(给外卖加个智能通知):

function sendMessage(data) {
    return new Promise((resolve) => {
        const channel = new MessageChannel();
        channel.port1.onmessage = (event) => resolve(event.data);
        targetWindow.postMessage(data, '*', [channel.port2]);
    });
}

用await调用(像外卖自动提醒):

async function main() {
    console.log('1. 准备点外卖');
    
    // await在这里暂停了函数,但没阻塞线程!
    const reply = await sendMessage('请求数据');
    
    // 3. 外卖送达提醒响起,开始吃
    console.log('3. 收到回复:', reply);
}

main();
console.log('2. 这行代码会在await之前执行(继续刷手机等外卖)');

在医疗影像场景中:

· 点外卖 = 通过WebSocket请求影像 · 等外卖 = Web Worker在后台解析数据 · 外卖送达 = Worker把渲染好的数据发回主线程 · 吃饭 = Canvas绘制影像

整个过程异步但不阻塞,用户可以在等待时继续交互!

🚀 第五步:性能优化技巧

技巧1:使用Transferable Objects

// ✅ 正确做法:转移所有权
worker.postMessage({ buffer: arrayBuffer }, [arrayBuffer]);

// ❌ 错误做法:会导致一次内存拷贝
worker.postMessage({ buffer: arrayBuffer });

技巧2:分块传输大影像

对于超大尺寸影像(如MG乳腺摄影),可以分块传输和渲染:

// 服务端:分块读取
async function sendImageInTiles(ws, filePath, tileSize = 256) {
  const imageInfo = await getImageInfo(filePath);
  const tilesX = Math.ceil(imageInfo.width / tileSize);
  const tilesY = Math.ceil(imageInfo.height / tileSize);
  
  for (let y = 0; y < tilesY; y++) {
    for (let x = 0; x < tilesX; x++) {
      const tileData = extractTile(filePath, x, y, tileSize);
      ws.send(JSON.stringify({
        type: 'tile',
        x, y,
        data: Array.from(tileData)  // 简化,实际用二进制
      }));
    }
  }
}

// 客户端:拼贴渲染
class TileRenderer {
  constructor(canvas) {
    this.tiles = new Map();
    this.canvas = canvas;
  }
  
  addTile(x, y, data) {
    this.tiles.set(`${x},${y}`, data);
    this.renderTile(x, y);
  }
  
  renderTile(x, y) {
    // 只渲染可见区域的tile
    if (this.isTileVisible(x, y)) {
      // 绘制到Canvas
    }
  }
}

技巧3:内存管理

// 限制缓存帧数
const MAX_CACHED_FRAMES = 50;

function cacheFrame(frameIndex, pixelData) {
  if (imageFrames.size >= MAX_CACHED_FRAMES) {
    // 删除最久未使用的帧
    const oldestFrame = Array.from(imageFrames.keys())[0];
    imageFrames.delete(oldestFrame);
  }
  imageFrames.set(frameIndex, pixelData);
}

📊 性能对比

指标 传统方案 本方案 首帧显示时间 :3-5秒(解析+渲染阻塞) 1-2秒(流式+Worker并行) UI响应性 :解析时完全卡死 始终流畅(60fps) 内存占用 :全部加载,容易OOM 按需缓存,可控 多帧切换 :重新解析,卡顿 已缓存,瞬间切换 窗宽窗位调整 :重新解析,卡顿 Worker实时计算,不卡UI

⚠️ 注意事项

  1. WebSocket重连机制
class ReconnectingWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.reconnectInterval = options.reconnectInterval || 1000;
    this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
    this.reconnectAttempts = 0;
    this.connect();
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    this.ws.binaryType = 'arraybuffer';
    
    this.ws.onopen = (event) => {
      this.reconnectAttempts = 0;
      this.onopen?.(event);
    };
    
    this.ws.onclose = (event) => {
      if (this.reconnectAttempts < this.maxReconnectAttempts) {
        setTimeout(() => {
          this.reconnectAttempts++;
          this.connect();
        }, this.reconnectInterval);
      }
      this.onclose?.(event);
    };
    
    this.ws.onmessage = (event) => this.onmessage?.(event);
    this.ws.onerror = (event) => this.onerror?.(event);
  }
  
  send(data) {
    this.ws.send(data);
  }
}
  1. 窗宽窗位预设
const WINDOW_PRESETS = {
  // 不同部位的标准窗宽窗位
  LUNG: { center: -500, width: 1500 },      // 肺窗
  BONE: { center: 300, width: 1500 },       // 骨窗
  SOFT_TISSUE: { center: 40, width: 400 },  // 软组织窗
  BRAIN: { center: 40, width: 80 },         // 脑窗
  MEDIASTINUM: { center: 50, width: 350 },  // 纵隔窗
  
  // 快速切换函数
  applyPreset(presetName) {
    const preset = this[presetName];
    if (preset) {
      viewer.adjustWindow(preset.center, preset.width);
    }
  }
};

// 添加UI按钮
Object.keys(WINDOW_PRESETS).forEach(name => {
  const btn = document.createElement('button');
  btn.textContent = name;
  btn.onclick = () => WINDOW_PRESETS.applyPreset(name);
  document.getElementById('presets').appendChild(btn);
});

🔮 3. 多帧序列播放

class CinePlayer {
  constructor(viewer) {
    this.viewer = viewer;
    this.playing = false;
    this.fps = 30;
    this.currentFrame = 0;
    this.totalFrames = 100;
  }
  
  play() {
    this.playing = true;
    this.loop();
  }
  
  loop() {
    if (!this.playing) return;
    
    this.viewer.goToFrame(this.currentFrame);
    this.currentFrame = (this.currentFrame + 1) % this.totalFrames;
    
    setTimeout(() => this.loop(), 1000 / this.fps);
  }
  
  stop() {
    this.playing = false;
  }
}
  1. 测量工具
class MeasurementTool {
  constructor(canvas) {
    this.canvas = canvas;
    this.startPoint = null;
    this.endPoint = null;
    this.measuring = false;
    
    canvas.addEventListener('mousedown', this.start.bind(this));
    canvas.addEventListener('mousemove', this.move.bind(this));
    canvas.addEventListener('mouseup', this.end.bind(this));
  }
  
  start(e) {
    this.measuring = true;
    this.startPoint = this.getCanvasCoordinates(e);
  }
  
  move(e) {
    if (!this.measuring) return;
    this.endPoint = this.getCanvasCoordinates(e);
    this.drawMeasurement();
  }
  
  end(e) {
    this.measuring = false;
    this.calculateDistance();
  }
  
  drawMeasurement() {
    // 在Canvas上绘制测量线
    const ctx = this.canvas.getContext('2d');
    ctx.beginPath();
    ctx.moveTo(this.startPoint.x, this.startPoint.y);
    ctx.lineTo(this.endPoint.x, this.endPoint.y);
    ctx.strokeStyle = '#00ff00';
    ctx.lineWidth = 2;
    ctx.stroke();
  }
  
  calculateDistance() {
    const dx = this.endPoint.x - this.startPoint.x;
    const dy = this.endPoint.y - this.startPoint.y;
    const pixels = Math.sqrt(dx * dx + dy * dy);
    
    // 转换为实际尺寸(需要像素间距信息)
    const pixelSpacing = this.viewer.getPixelSpacing();
    const distance = pixels * pixelSpacing;
    
    console.log(`距离: ${distance.toFixed(2)} mm`);
  }
}

💎 总结

  1. WebSocket负责流式传输,支持大影像分块加载
  2. Web Worker负责解析和计算,不阻塞UI
  3. Transferable Objects实现零拷贝数据传输
  4. 窗宽窗位算法实时调整影像显示效果
  5. 内存管理策略避免内存溢出