无需安装,纯浏览器实现跨平台文件、文本传输,支持断点续传、二维码、房间码加入、粘贴传输、拖拽传输、多文件传输

无需安装,纯浏览器实现跨平台文件、文本传输,支持断点续传、二维码、房间码加入、粘贴传输、拖拽传输、多文件传输

这是一个 MonoRepo 搭建的全栈项目,包含前端、后端、公共包

✨ 核心功能亮点

🚫 无需安装应用,纯浏览器运行

  • 🖱️ 拖拽传输
  • 📋 粘贴传输
  • 🖼️ 图片文件预览
  • 🔢 扫码连接
  • 🔑 密钥连接

📖 阅读本文,你将学会

这篇文章将带你深入我开发的开源项目 Web-Share,但我们不只是走马观花。读完本文,你将收获的不仅仅是一个文件传输工具的实现过程,更是一套解决复杂 Web 应用问题的实战思路:

  • WebRTC 实战:从连接建立到数据传输,掌握 WebRTC 的核心流程与关键 API。
  • 二进制数据处理 :学习如何巧妙地将元数据与二进制数据打包,解决 RTCDataChannel 的一大限制。
  • 性能优化:理解并实现"背压"机制,防止数据发送过快导致连接崩溃。
  • 前端架构解耦:通过发布订阅和 Promise,优雅地处理复杂异步流程和 UI 交互。
  • 断点续传设计:从零设计一套可靠的断点续传机制,并保证数据的完整性。
  • 房间码、二维码管理房间:通过房间码、二维码管理房间,实现多对多的文件传输。
  • 流式下载解决大文件下载内存溢出 :使用 Service Worker 或 File System Access API 进行流式下载,避免传统的 Blob 下载方式导致内存溢出。需要浏览器支持,手机端 Chrome 可用

准备好了吗?让我们一起,让浏览器再次伟大!


🔬 WebRTC 原理概述:浏览器之间的"鹊桥相会"

这个项目是基于 WebRTC 技术实现的。WebRTC (Web Real-Time Communication) 允许浏览器之间建立点对点(P2P)的直接连接,实现音视频或任意数据的实时传输。

但这里有一个"鸡生蛋,蛋生鸡"的问题:两个浏览器都不知道对方的网络地址(IP、端口),怎么建立连接呢?

这时候就需要一个"婚姻介绍所 "------我们称之为信令服务器 (Signaling Server)

它的作用很简单:

  1. 浏览器 A 告诉"介绍所":"我想认识 B,这是我的联系方式。"
  2. "介绍所"把 A 的联系方式转交给 B
  3. B 同意后,也把自己的联系方式通过"介绍所"告诉 A
  4. 双方交换联系方式后,"介绍所"功成身退。AB 开始私下直接通信,传输文件。
sequenceDiagram participant A as Client A (发起方) participant S as Signal Server participant B as Client B (接收方) A->>S: 1. 创建 Offer S->>B: 2. 转发 Offer B->>S: 3. 创建 Answer S->>A: 4. 转发 Answer A->>S: 5. 发送 ICE Candidates S->>B: 6. 转发 ICE Candidates B->>S: 7. 发送 ICE Candidates S->>A: 8. 转发 ICE Candidates A->>B: 9. 建立 P2P 连接

这个过程保证了我们的文件数据,自始至终都只在用户的设备之间进行端到端加密传输,服务器完全碰不到,实现了极致的高效安全


搭建服务器(简化版本)

1. 安装 express、ws 开发服务器

bash 复制代码
pnpm add express ws

2. 创建简单的服务器入口

ts 复制代码
import express from 'express'
import { WSServer } from '@/WSServer'

const PORT = process.env.PORT || 3001
// 服务器
const app = express()

const server = app.listen(PORT, () => {
  console.log(`信令服务器运行在端口 ${PORT}`)
})

// 创建 WebSocket 信令服务器
new WSServer(server)

3. 创建信令服务器处理简单的事件

核心在于 onMessage 方法,它通过不同的事件类型,使用不同的方式处理每个事件,就像状态机一样 比如:

  • Ping 心跳检测
  • JoinRoom 加入房间
  • LeaveRoom 离开房间
  • Relay 转发消息
  • Offer 发送 Offer
  • Answer 发送 Answer
  • Candidate 发送 Candidate
  • Text 发送文本
  • ...
ts 复制代码
import { WebSocket, WebSocketServer } from 'ws'

export class WSServer {
  // ...

  constructor(
    server: Server,
    opts: WSServerOpts = {},
  ) {
    this.ws = new WebSocketServer({ server })

    // 连接成功处理
    this.ws.on('connection', (socket, request) => {
      const peer = new Peer(socket, request)

      /** 将用户添加到对应房间 */
      this.addPeerToRoom(peer)
      this.onConnection(peer)
    })

    /** 定期清理过期记录 */
    this.keepAliveClear()
  }

  private onConnection = (peer: Peer) => {
    const { socket } = peer
    socket.on('message', data => this.onMessage(peer, data))

    /** 监听WebSocket关闭事件 */
    socket.on('close', (code, reason) => {
      console.log(`${peer.name.displayName} WebSocket连接关闭 (code: ${code}, reason: ${reason})`)
      this.handlePeerDisconnect(peer)
    })

    /** 监听WebSocket错误事件 */
    socket.on('error', (error) => {
      console.log(`${peer.name.displayName} WebSocket连接错误:`, error)
      this.handlePeerDisconnect(peer)
    })
  }

  private onMessage = (sender: Peer, data: RawData) => {
    let msg: SendData
    try {
      msg = JSON.parse(data.toString())
    }
    catch (e) {
      console.warn('WS: Received JSON is malformed')
      return
    }

    switch (msg.type) {
      case Action.Close:
        sender.socket.terminate()
        break
      case Action.Ping:
        sender[HEART_BEAT] = Date.now()
        /** 更新设备映射的最后活跃时间 */
        this.updateDeviceLastSeen(sender)
        this.send(sender, { type: Action.Ping, data: null })
        break
      // ...
    }
  }
}

💡 探索之旅:编码中的挑战与解决方案

这部分是文章的核心。我将分享在开发过程中遇到的几个关键问题,以及我是如何思考并解决它们的。

💡 问题一:茫茫人海(局域网)中,如何让设备发现彼此?

当你和朋友连着同一个 Wi-Fi,打开这个应用,你们会神奇地出现在对方的设备列表里(仅限本地开发局域网环境)。

这背后是一个简单而巧妙的"房间"机制。

解决方案:IP 网段分组

当一个设备连接到信令服务器时,服务器会获取它的 IP 地址

typescript 复制代码
class Peer {
  // ...
  private getRoomIdFromIP(ip: string): string {
    // ... (本地回环地址处理)

    /** 私有网络地址 - 按网段划分 */
    if (Peer.ipIsPrivate(ip)) {
      const parts = ip.split('.')
      // 192.168.x.x 网络 - 按前三段划分
      if (parts[0] === '192' && parts[1] === '168') {
        return `lan_192_168_${parts[2]}`
      }
      // 10.x.x.x 网络 - 按前两段划分
      if (parts[0] === '10') {
        return `lan_10_${parts[1]}`
      }
      // ... (其他私有网段处理)
    }

    /** 公网IP - 每个IP一个房间 */
    return `public_${ip.replace(/\./g, '_')}`
  }
  // ...
}

这个方法会分析客户端的 IP 地址,将处于同一个局域网子网(例如 192.168.1.x)的所有设备,都分配到同一个 roomId。这样,当有新设备加入时,服务器只需将它的信息广播给这个"房间"里的所有其他设备,就实现了自动发现。

当然,为了应对复杂的网络环境,项目还支持通过"二维码"或"6位房间码"来创建和加入临时房间,其本质也是为一次性会话创建一个唯一的 roomId

💡 问题二:WebRTC 连接建立的"三步走"

WebRTC 的连接过程充满了术语:SDP、Offer、Answer、ICE。听起来很复杂,但我们可以把它简化为"连接三步走"。

解决方案:Offer / Answer / ICE 模型

  1. 发起方(A)发送连接申请 (Offer) :本着有枣没枣搂一竿子的原则,用户 A 点击 B 的头像,申请连接,浏览器会生成一份 Offer。这份 Offer 是一个符合 SDP (Session Description Protocol) 规范的文本,详细描述了 A 的通信能力(支持的编解码器、加密算法等)。然后 A 通过信令服务器将 Offer 发给 B

  2. 接收方(B)的"回信" (Answer)B 收到 Offer 后,如果接收方也压抑了,点击同意连接,就会生成一份对应的 Answer,同样是 SDP 格式,表示"你的条件我接受,我们可以连接"。Answer 也通过信令服务器回传给 A

  3. 寻找"最佳连接路线" (ICE Candidates) :在交换"信件"的同时,AB 也在各自探索所有可能的网络路径,比如自己的内网IP、通过 STUN 服务器发现的公网IP等。这些路径被称为 ICE Candidate。双方会不断地通过信令服务器交换这些"候选路线",并尝试互相"打洞"(NAT Traversal),就好像狡兔三窟,从多个洞穴中寻找最近的路径,直到找到一条能成功连接的最佳路径。

一旦路径建立,RTCDataChannel(数据通道)就会被打开,信令服务器的任务彻底结束,真正的文件传输开始了。

💡 问题三:元数据和二进制,如何"一石二鸟"?

这是一个非常经典的问题。RTCDataChannelsend() 方法一次只能发送一种类型的数据:要么是字符串,要么是二进制(ArrayBuffer 等)。

当我需要发送一个文件块(二进制)时,如何同时告诉接收方这个块的元数据,比如:它属于哪个文件?是第几个块?偏移量是多少?

一个直观但糟糕的方案是"交叉发送":先 send() 一个 JSON 字符串描述元数据,再 send() 文件块的二进制数据。这会导致发送次数翻倍,性能低下,且接收方需要维护复杂的状态机来配对元数据和数据,极易出错。

解决方案:像打包快递一样,将元数据和二进制"粘"在一起

我的思路是:在发送前,将元数据和二进制数据打包成一个单一的 ArrayBuffer 进行发送。 就像是把"货物"和"运单"一起放进一个快递包裹里。这个过程由 BinaryMetadataEncoder.ts 完成,它的工作流程如下:

  1. 准备"运单"(Metadata) :首先,我们将包含文件哈希、偏移量等信息的 JavaScript 对象,通过 JSON.stringify() 转换成字符串,再用 TextEncoder 转换成二进制的字节流(Uint8Array)。这就是我们的"运单"。
  2. 计算尺寸,准备"包裹" :我们精确计算出最终"包裹"需要多大空间。总大小 = "运单"长度所需空间 + "运单"本身大小 + "货物"本身大小。然后创建一个这么大的 ArrayBuffer 作为包裹。
  3. 贴上"包裹说明"(Metadata Length) :这是最关键的一步。我们在"包裹"的最前面,用固定的2个字节(Uint16)写入"运单"的实际长度。这2个字节就像是贴在快递箱上的说明,告诉收货员:"运单"有多长,从哪里开始是真正的"货物"。
  4. 打包:依次将"运单"的二进制内容和"货物"(文件块)的二进制内容,填充到"包裹"中相应的位置。
  5. 发送 :将这个包含了所有信息的、结构完整的"包裹"(ArrayBuffer) 一次性发送出去。

这样,我们发送的每一个数据包都自带了"说明书",其结构是: ["运单"长度 (2字节)] + ["运单"JSON字符串 (n字节)] + ["货物"文件块 (m字节)]

接收方收到数据后,解码过程就是打包的逆操作:

  1. 先读取前2个字节,得到"运单"的长度 n
  2. 再读取后面 n 个字节,就能得到完整的"运单"信息,并解析成元数据对象。
  3. 剩下的所有内容,就是纯粹的"货物"------文件块数据。

这个方案将两次发送合并为一次,既高效又可靠,极大地简化了数据处理逻辑。

详细源码在:github.com/beixiyo/jl-...

我已经把我常用的工具发布到 npm 了,www.npmjs.com/package/@jl...

ts 复制代码
export class BinaryMetadataEncoder {
  // ...

  /**
   * @param metadata 元数据对象(可JSON序列化)
   * @param buffer 二进制数据(ArrayBuffer或TypedArray)
   * @param metaLengthType 元数据长度存储类型(默认Uint16)
   * @returns 合并后的ArrayBuffer
   */
  public static encode(
    metadata: any,
    buffer: ArrayBuffer | ArrayBufferView,
    metaLengthType: MetaLengthType = 'Uint16',
  ): ArrayBuffer {
    // 1. 序列化元数据
    const metaStr = JSON.stringify(metadata)
    const metaBuffer = new TextEncoder().encode(metaStr)
    /** 元数据长度 */
    const metaLength = metaBuffer.length

    // 2. 验证元数据长度是否超出范围
    const maxMetaLength = BinaryMetadataEncoder.getMaxMetaLength(metaLengthType)
    if (metaLength > maxMetaLength) {
      throw new Error(`Metadata too large (${metaLength} > ${maxMetaLength}). Consider increasing metaLengthType.`)
    }

    // 3. 获取二进制数据(处理ArrayBufferView)
    const dataBuffer = buffer instanceof ArrayBuffer
      ? buffer
      : buffer.buffer

    // 4. 创建总Buffer

    /** 类型长度 */
    const lengthBytes = BinaryMetadataEncoder.Config.MetaLengthType[metaLengthType]
    /**
     * 总长度
     * 类型长度 + 元数据长度 + 二进制数据长度
     */
    const totalBuffer = new Uint8Array(lengthBytes + metaLength + dataBuffer.byteLength)
    const view = new DataView(totalBuffer.buffer)

    // 5. 写入元数据长度
    switch (metaLengthType) {
      case 'Uint8':
        view.setUint8(0, metaLength)
        break
      case 'Uint16':
        view.setUint16(0, metaLength, false) // false表示大端序
        break
      case 'Uint32':
        view.setUint32(0, metaLength, false)
        break
      default:
        throw new Error(`Unsupported metaLengthType: ${metaLengthType}`)
    }

    // 6. 写入元数据和二进制数据
    totalBuffer.set(metaBuffer, lengthBytes)
    totalBuffer.set(new Uint8Array(dataBuffer), lengthBytes + metaLength)

    return totalBuffer.buffer
  }

  /**
   * 解码混合数据
   * @param combinedBuffer 编码后的ArrayBuffer
   * @param metaLengthType 元数据长度存储类型(需与编码时一致)
   * @returns 解构出的元数据和二进制数据
   */
  public static decode<T extends Record<string, any>>(
    combinedBuffer: ArrayBuffer,
    metaLengthType: MetaLengthType = 'Uint16',
  ): { metadata: T, buffer: ArrayBuffer } {
    // ... 反向处理
  }
}

💡 问题四:如何防止"快车"拖垮"慢车"?

想象一个场景:发送方是一台性能强劲的电脑,网络飞快;接收方是一台老旧的手机,处理速度慢。如果发送方不加节制地疯狂发送数据块,会发生什么?

数据并不会立刻通过网络发送出去,而是会先进入浏览器内部的一个"待发送缓冲区"。如果这个缓冲区被塞满(因为发送速度远大于网络传输和接收方处理速度),新的数据块就会无处安放,最终可能导致浏览器崩溃或连接中断。这就是所谓的"背压"(Back-pressure)问题。

这个问题困扰了我很久,因为我写 Demo 时都是发送小数据,每次都没问题,一写大项目就各种异常

更重要的是,我翻遍了网站关于 WebRTC 的文章(包括 AI Deep Research),没有找到任何关于 RTCDataChannel 的背压传输说明

最终只能看 MDN 文档慢慢找了

解决方案:基于 bufferedAmount 的智能流量控制

为了解决这个问题,我利用了 RTCDataChannel 提供的一个属性:bufferedAmount

  • 它是什么? bufferedAmount 告诉我们当前有多少数据字节堆积在"待发送缓冲区"里,还没来得及通过网络发出去。
  • 为什么要检查? 它可以实时反映网络的拥堵情况和接收方的处理能力。如果这个数值持续增高,说明我们发送得太快了。
  • 什么时候检查? 在发送每一个文件块之前,我都会检查这个值。
  • 如何应对? 当我发现 bufferedAmount 超过了一个我设定的安全阈值时,我会暂停发送。不要用轮询,而是监听一个更智能的事件:bufferedamountlow。这个事件在浏览器成功发送掉一部分缓冲区数据,使其降低到阈值以下时自动触发。我用一个 Promise 来"等待"这个事件,一旦触发,Promise 完成,发送循环才会继续。
ts 复制代码
class RTCConnect {
  /**
   * 通道缓冲区是否过高
   */
  get channelAmountIsHigh(): boolean {
    if (!this.channelIsReady(this.channel))
      return false
    return this.channel.bufferedAmount > this.channel.bufferedAmountLowThreshold
  }

  /**
   * 等待通道空闲
   * 当通道缓冲区过高时,等待直到缓冲区降低到阈值以下
   *
   * @returns Promise<void> 当通道空闲时 resolve
   *
   * @example
   * ```typescript
   * if (rtcConnect.channelAmountIsHigh) {
   *   await rtcConnect.waitUntilChannelIdle()
   * }
   * rtcConnect.send(largeData)
   * ```
   */
  waitUntilChannelIdle(): Promise<void> {
    return new Promise<void>((resolve) => {
      if (!this.channelIsReady(this.channel)) {
        resolve()
        return
      }

      this.channel.onbufferedamountlow = () => {
        resolve()
      }
    })
  }
}

这套机制就像一个智能的水龙头,能感知下水道的排水速度。当下水道快堵塞时,会自动关小水流,等通畅后再开大,从而动态地将发送速度匹配到接收方的处理能力,极大地保证了文件传输的稳定性和可靠性。

💡 问题五:文件预览是如何实现的?它和异步UI交互有何关联?

在发送文件前,接收方会弹出一个确认框,上面不仅有文件名、大小,甚至还有图片的预览图。用户点击"接受"或"拒绝"后,发送方才会继续或中止。

文件预览的"取巧"实现

你可能会想,预览图是不是通过 WebRTC 发送的?最初我也是这么想的,但很快发现一个问题:RTCDataChannel 对单次发送的数据大小有限制。一张经过压缩的 Base64 缩略图也可能有几十KB,很容易超出限制,导致数据发送失败。

因此,更稳妥的方案是:通过信令服务器来传递这份初始元数据。

FileSendManager 中,当用户选择文件后,我会先为图片生成一个低质量的 Base64 缩略图,然后将它连同文件名、大小等元数据一起,通过更可靠的 WebSocket 信令服务器发送给接收方。当接收方用户点击"接受"后,我们才开始通过高速的 WebRTC DataChannel 传输真正的、巨大的文件块。

这正是"好钢用在刀刃上":用信令服务器保证关键控制信息的可靠送达,用 WebRTC 保证海量数据的传输效率。

compressImg 压缩图片函数核心是利用 canvas 的能力实现的,同样来自我的工具库

ts 复制代码
const metaPromises = files.map((file) => {
  return new Promise<FileMeta>(async (resolve) => {
    const res: FileMeta = getMeta(file)

    /**
     * 为首张可被浏览器解码的图片生成预览
     * 1. 仅尝试常见浏览器支持的格式,排除 HEIC/HEIF 等
     * 2. 若生成失败,记录 warning 并继续,不再 reject,避免整个 Promise.all 失败
     */
    if (res.type.startsWith('image/') && !hasImg) {
      hasImg = true
      try {
        const url = URL.createObjectURL(file)
        const img = await getImg(url)

        if (img) {
          const base64 = await compressImg(img, 'base64', 0.1, 'image/webp')
          res.base64 = base64
        }
        else {
          console.warn('无法加载图片,跳过预览: ', file.name)
        }

        URL.revokeObjectURL(url)
      }
      catch (error) {
        console.warn('生成预览图失败,将继续发送文件而不附带预览:', error)
      }
    }

    /** 无论预览是否成功,都正常 resolve,确保元数据发送流程不中断 */
    resolve(res)
  })
})

const data = await Promise.all(metaPromises)
this.fileMetaCache = data

/** 通过 WebSocket 中转,因为 WebRTC 接收文件大小有限 */
this.config.relay({
  data,
  toId,
  fromId: this.config.getPeerId(),
  type: Action.FileMetas,
})

💡 问题六:如何优雅的实现异步UI交互?

这里还有一个挑战:底层的 WebRTC 传输逻辑,如何"暂停"下来,等待用户的 UI 操作(点击"接受"或"拒绝")?

我的解决方案是,利用 Promise 来创建一个"遥控器",完美地解耦了数据逻辑与UI展现。

  1. 逻辑层需要"许可" :当接收方的 RTCPeer(逻辑层)收到文件元数据后,它知道需要用户确认。 但它不关心UI长什么样,它只做一件事:**创建一个"遥控器"(Promise.withResolvers),并把这个遥控器(包含 resolvereject 按钮)递交给上层(UI层)。 Promise.withResolvers 是一个新的 Api,他能立刻获取 resolvereject 函数,很方便 然后,它就在原地 await 这个 Promise,相当于暂停等待遥控器的指令。 在相当多的地方,我都用到了这种方式,他非常方便

    ts 复制代码
    const { promise, resolve } = Promise.withResolvers()
    me.value.sendOffer(selectedPeer.value.peerId, resolve)
    promise.then(() => console.log('Offer sent to room owner after scan join'))
    
    /**
    * 等待 RTC 通道连接
    */
    async function connectRTCChannel(peer: UserInfo) {
      if (!selectedPeer.value || !me.value)
        return
    
      const { promise, resolve } = Promise.withResolvers()
      await me.value.sendOffer(peer.peerId, resolve)
      await promise
    }

    你也不用担心兼容性,这个 Api 很简单,完全可以自己实现一个

    ts 复制代码
    export function PromisePolyfill() {
      // @ts-ignore
      if (Promise.withResolvers) {
        return
      }
    
      Promise.withResolvers = function <T>() {
        let resolve: (value: T | PromiseLike<T>) => void,
          reject: (reason?: any) => void
    
        const promise = new Promise<T>((_res, _rej) => {
          resolve = _res
          reject = _rej
        })
    
        return {
          resolve: resolve!,
          reject: reject!,
          promise,
        }
      }
    }
  2. UI层接收"遥控器"并展示:(UI层)拿到了这个"遥控器",它的任务是弹出一个漂亮的确认框给用户看,然后静静地保管好这个遥控器。

  3. 用户按下"按钮" :当用户在界面上点击"接受"按钮时,UI层的代码就会按下遥控器上的 resolve 按钮。如果用户点击"拒绝",就按下 reject 按钮。

  4. 逻辑层响应"指令" :逻辑层 await 的那个 Promise 终于收到了信号,从暂停状态中恢复过来。根据是 resolve 还是 reject,它就知道是该继续准备接收文件,还是通知对方传输已取消。

这个模式,就像给异步流程安装了一个可由UI控制的"暂停/播放开关",使得底层的网络逻辑可以完全独立于上层的UI实现,极大地提升了代码的可读性和可维护性。

💡 问题七:如何实现可靠的断点续传并保证数据完整性?

网络抖动、手滑关闭页面,是大文件传输的噩梦。断点续传是必备功能。

解决方案:哈希识别 + IndexedDB 缓存 + 偏移量协商与校验

  1. 哈希识别 :传输开始前,使用"文件名 + 文件大小"为任务生成一个唯一的 fileHash,作为"身份证"。

  2. 本地缓存 :接收方每收到一个带有元数据 的数据块,就会将其存入浏览器的 IndexedDB 中。ResumeManager.ts 负责管理这些缓存,它像一个高效的仓库管理员。

  3. 偏移量协商 :当连接恢复,准备再次接收同一文件时(通过fileHash识别),接收方先查 IndexedDB,计算出已下载的最大连续偏移量。然后通过 ResumeInfo 消息告诉发送方:"请从第 10485761 个字节开始发。" 发送方收到后,就会从指定位置继续传输。

数据完整性如何保证?

这是断点续传的灵魂。如果缓存的数据是错乱的、有"空洞"的,那么最后拼接出的文件就是损坏的。

我在 ResumeManager.ts 中做了关键处理:

  • 利用生成器,一点一点的流式输出数据,避免一次性加载大量数据,导致浏览器卡顿
    • 简单说一下生成器,他就是数组的核心方法,当一个对象实现了生成器,就能用 for of 迭代它
    • 每次调用 next 方法,都会返回一个 { value, done } 对象,value 是当前迭代的值,done 是是否迭代完成
    • 生成器是可以暂停的,所以无需一次加载所有数据,性能非常好。你可以像数组一样直接使用它即可
  • 利用偏移量校验,确保数据块的连续性
  • 利用 IndexedDB 缓存,来存储大量的二进制数据
  • 利用 fileHash 识别,确保数据块的唯一性
  • 利用 ResumeInfo 消息,确保数据块的连续性

连续性校验 在从缓存中恢复数据时,我并不仅仅是读出所有数据块。我会对所有缓存块的偏移量进行连续性校验 。如果发现偏移量不连续(例如,0, 16384, 49152,中间丢失了 32768),我会认为从断点处开始的缓存是不可靠的,果断丢弃所有不连续的数据,并只从最后一个连续的位置请求数据。

这种"宁缺毋滥"的设计,确保了最终文件的完整性和正确性。

ts 复制代码
class ResumeManager {
  // ...
  async* getCachedChunksStream(fileHash: string): AsyncGenerator<ChunkInfo, void, unknown> {
    // ... 获取所有缓存块的 key,并按偏移量排序
    let sortedOffsets = chunkKeys
      .map(key => ({ key, offset: Number.parseInt(key.split('_').pop() || '0', 10) }))
      .sort((a, b) => a.offset - b.offset)

    // ======================
    // * 校验数据偏移连续性
    // ======================
    let offsetIndex = 0
    for (const { offset } of sortedOffsets) {
      if (offset !== offsetIndex++ * CHUNK_SIZE) {
        /** 数据块不连续! */
        Log.error(`数据块不连续...放弃部分数据`)
        /** 只保留连续的部分 */
        sortedOffsets = sortedOffsets.slice(0, offsetIndex - 1)
        /** 删除无效的缓存 */
        this.deleteResumeCache(fileHash)
        break
      }
    }
    // ...
    /** 之后只 yield 连续部分的数据块 */
  }
  // ...
}

使用生成器一点点读取

ts 复制代码
class FileDownloadManager {
  /**
   * 恢复缓存的数据到下载缓冲区(优化版本 - 流式处理)
   */
  private async restoreCachedData(): Promise<number> {
    if (!this.currentFileHash) {
      return -1
    }

    /** 使用流式处理,避免一次性加载所有数据到内存 */
    const chunkStream = this.resumeManager.getCachedChunksStream(this.currentFileHash)
    let chunkCount = 0
    let lastOffset = 0

    /** 流式处理每个数据块 */
    for await (const chunk of chunkStream) {
      lastOffset = chunk.offset
      await this.writeFileBuffer(chunk.data)
      chunkCount++

      /** 每处理100个数据块输出一次进度 */
      if (chunkCount % 100 === 0) {
        console.warn(`恢复缓存数据进度: ${this.currentFileHash}, 已处理: ${chunkCount} 个数据块`)
      }
    }

    if (chunkCount > 0) {
      Log.info(`恢复数据偏移量: ${lastOffset}`)
      Log.success(`缓存数据恢复完成: ${this.currentFileHash}, 总计: ${chunkCount} 个数据块`)
      return lastOffset
    }

    return -1
  }
}

💡 问题八:扫码加入房间如何实现

原理其实非常简单

  1. 后端生成一个随机码,发给前端
  2. 前端拼接 ${URL}?roomId=${roomId} 通过 qrcode 库转为二维码
  3. 用户扫码后,前端解析二维码,识别为 URL,自动打开浏览器
  4. URL 附带的 roomId、peerId 可以被前端解析,前端再把数据通过 WebSocket 发给后端,后端根据 roomId、peerId 找到对应的房间,将用户添加到房间
ts 复制代码
import QRCode from 'qrcode'

/**
 * 前端处理URL查询参数
 */
function handleQuery(route: RouteLocationNormalized) {
  const { roomId, peerId } = route.query as { roomId: string, peerId: string }
  if (!roomId || !peerId) {
    return
  }

  server.joinDirectRoom(roomId, peerId)
}

/**
 * 处理服务器创建二维码
 */
async function handleQRCodeRommCreate(
  data: RoomInfo,
  info: UserInfo | undefined,
  setLoading: (state: boolean) => void,
) {
  if (data.roomId) {
    if (info) {
      info.roomId = data.roomId
      server.saveUserInfoToSession(info)
    }
    const { peerId } = data.peerInfo

    qrData = `${ServerConnection.getUrl().href}/fileTransfer/?roomId=${encodeURIComponent(data.roomId)}&peerId=${encodeURIComponent(peerId)}`
    console.log('创建房间成功:', qrData)
    try {
      qrCodeValue.value = await QRCode.toDataURL(qrData, { errorCorrectionLevel: 'H', width: 300 })
      return true // 表示应该显示二维码模态框
    }
    catch (err) {
      console.error('生成二维码失败:', err)
      Message.error('生成二维码失败,请稍后再试')
    }
  }

  setLoading(false)
}

💡 问题九:如何实现大文件下载

我看了很多类似项目,它们无一例外,都是把所有数据加载到内存里,这样浏览器很容易内存溢出

你们有注意过吗?比如你在网上点击一个 URL,他会一点点下载,无论多大,都不会内存溢出。而我正是要实现这种方式

传统下载方式
ts 复制代码
const blobs = [...无数个 Blob]
/**
 * 合并成一个 Blob,转成 URL,然后下载
 * 此时如果数据过大,浏览器会崩溃,因为内存溢出
 */
const url = URL.createObjectURL(new Blob(blobs))
const a = document.createElement('a')
a.href = url
a.download = 'file.zip'
a.click()
URL.revokeObjectURL(url)
流式下载方式
  1. 使用 Service Worker,但是需要 Https 环境,且兼容性不佳,尤其是移动端
  2. 使用 File System Access API,但是需要用户手动授权,且兼容性不佳,尤其是移动端

为了解决兼容性,我实现了一个优雅降级的下载器(如果不支持上面两种方式,会自动使用 Blob 下载),同样在我的工具库有提供

使用方式如下,我写了一个简单的 demo,你可以参考 github.com/beixiyo/jl-...

你需要在下载的 node_modules 中找到 @jl-org/tool 包,复制 streamDownload.js 到你的项目中根目录中,他是我打包的 Service Worker 文件

ts 复制代码
const swDownloader = await createStreamDownloader(fileName, {
  swPath: '/streamDownload.js',
})

for (let i = 0; i < 10; i++) {
  const data = new Blob(['anything'])
  const buffer = await data.arrayBuffer()
  await swDownloader.append(new Uint8Array(buffer))
}

console.log('Service Worker Stream Download Complete')
await swDownloader.complete()

🚀 开发与部署

本地开发

得益于 pnpmMonorepo 架构,本地开发非常简单。

bash 复制代码
# 克隆项目
git clone https://github.com/beixiyo/web-share.git
cd web-share
# 安装依赖
pnpm i

# 一键启动所有服务 (client, server, common)
# 首次运行需要执行两次,构建 common 包
pnpm run dev

pnpm run dev 命令会并行启动客户端、信令服务器和公共模块的监视模式,任何代码改动都会实时生效。

Docker 部署

项目已提供 Dockerfiledocker-compose.yml,一键部署到你的服务器。

关键步骤:

  1. 修改 docker-compose.yml 文件。
  2. 找到 VITE_SERVER_URL 环境变量,将其值修改为你的服务器公网IP或域名 对应的 WebSocket 地址(例如 wss://your.domain.com)。这是为了让公网上的客户端能正确找到你的信令服务器。
  3. 执行 docker compose up -d

部署后,在两个不同设备上(同一局域网)访问客户端地址,即可开始测试。

🔍 代码流程标记说明

为了便于开发者理解复杂的断点续传流程,我们在关键代码位置添加了 @数字. 描述 格式的流程标记

如何查看流程标记

  1. 使用我定制的 VSCode Todo Tree Enhanced 插件(推荐):

    • 项目已配置 .vscode/settings.json,自动识别 @数字. 格式标记
    • 在 VSCode 侧边栏查看 "TODO TREE Enhanced" 面板
    • 点击标记可直接跳转到对应代码位置
  2. 手动搜索

    • 在项目中搜索 @01. @02. 等标记
    • 按数字顺序查看完整流程

💡 提示:使用 VSCode Todo Tree 插件可以快速浏览所有流程标记,点击即可跳转到对应代码位置。

相关推荐
一只鹿鹿鹿22 分钟前
【网络安全】等级保护2.0解决方案
运维·安全·web安全·架构·信息化
杨DaB24 分钟前
【SpringMVC】拦截器,实现小型登录验证
java·开发语言·后端·servlet·mvc
奕辰杰2 小时前
关于npm前端项目编译时栈溢出 Maximum call stack size exceeded的处理方案
前端·npm·node.js
zandy10112 小时前
解构衡石嵌入式BI:统一语义层与API网关的原子化封装架构
架构
JiaLin_Denny4 小时前
如何在NPM上发布自己的React组件(包)
前端·react.js·npm·npm包·npm发布组件·npm发布包
路光.5 小时前
触发事件,按钮loading状态,封装hooks
前端·typescript·vue3hooks
我爱996!5 小时前
SpringMVC——响应
java·服务器·前端
咔咔一顿操作6 小时前
Vue 3 入门教程7 - 状态管理工具 Pinia
前端·javascript·vue.js·vue3
kk爱闹6 小时前
用el-table实现的可编辑的动态表格组件
前端·vue.js
漂流瓶jz7 小时前
JavaScript语法树简介:AST/CST/词法/语法分析/ESTree/生成工具
前端·javascript·编译原理