Skip to content

文件与实时通信:字节、预览和持续到来的消息 ​

前面用 fetch 接收了一份 JSON。实际页面还会处理用户选择的文件、临时预览,以及持续推送的进度。它们共享“异步取得数据”的背景,却有不同的权限、内存和连接生命周期。

Blob 与 File 保存什么 ​

Blob 表示一段不可变的二进制数据,可以声明 MIME 类型;File 在 Blob 基础上增加 name、lastModified 等信息。文件通常来自用户选择或拖放,网页不能因为知道一个磁盘路径就任意读取它。

js
const content = new Blob(['作用域\n', '闭包\n'], { type: 'text/plain;charset=utf-8' });
console.log(await content.text());
// 作用域
// 闭包
console.log(content.size); // 17:UTF-8 字节数,不是字符数

Blob 的 size 是字节数。text 按文本读取,arrayBuffer 适合二进制处理,stream 则允许逐段消费;把一个巨大文件全读成字符串会增加内存压力。FileReader 是事件式读取方式,也能处理 Blob,但并不意味着每次读取文件都必须先转成 base64。

file.type、扩展名、accept 属性只能提供类型线索和交互过滤,不能证明文件内容可信。服务器接收上传后仍要检查类型、大小、权限和存储方式。

字节块不能直接当成文本片段 ​

读大文件或响应流时,每次拿到的块可能切在 UTF-8 字符中间。以下 .mjs 代码只使用三个字节就能观察这种问题:

js
const bytes = new TextEncoder().encode('中');
const first = bytes.slice(0, 2);
const second = bytes.slice(2);
const broken = new TextDecoder().decode(first) + new TextDecoder().decode(second);
const decoder = new TextDecoder();
const correct = decoder.decode(first, { stream: true }) +
  decoder.decode(second, { stream: true }) + decoder.decode();
console.log(broken); // ��
console.log(correct); // 中

前两次独立解码都以为自己拿到了完整输入,遇到不完整序列就产生替代字符。共享 decoder 并开启 stream 模式时,它保留尚未完成的字节,下一块到来后拼成完整字符。最后不带 stream 的 decode 用于结束输入、处理尾部缓存。

因此文件分块后的三层边界不同:字节块可能不是完整字符;字符片段可能不是完整行;完整行又不一定是完整 JSON 或其他业务消息。先完成解码,再按相应文本协议拼接和解析,不能把每一块都直接 JSON.parse。

如果文件格式要求严格拒绝损坏文本,可以创建 new TextDecoder('utf-8', { fatal: true })。此时非法输入会失败,应把错误报告为文件编码问题,而不是悄悄显示替代字符。是否允许替代字符是业务取舍,需要提前决定。

图片预览和对象 URL 的寿命 ​

保存下面 HTML 并打开,选择一张小图片观察预览;换图和清除时会释放旧对象 URL。

html
<!doctype html>
<meta charset="utf-8">
<title>本地图片预览</title>
<label for="picture">选择图片</label>
<input id="picture" type="file" accept="image/*">
<button id="clear" type="button">清除预览</button>
<p id="status" role="status"></p>
<img id="preview" alt="所选图片的预览" hidden style="max-width:300px">
<script>
  const input = document.querySelector('#picture');
  const preview = document.querySelector('#preview');
  const status = document.querySelector('#status');
  let objectURL;
  function release() {
    preview.removeAttribute('src');
    preview.hidden = true;
    if (objectURL) URL.revokeObjectURL(objectURL);
    objectURL = undefined;
  }
  input.addEventListener('change', () => {
    release();
    const file = input.files[0];
    if (!file) return;
    if (!file.type.startsWith('image/') || file.size > 2 * 1024 * 1024) {
      status.textContent = '请选择不超过 2 MiB 的图片';
      return;
    }
    objectURL = URL.createObjectURL(file);
    preview.src = objectURL;
    preview.hidden = false;
    status.textContent = `${file.name},${file.size} 字节`;
  });
  preview.addEventListener('error', () => {
    release();
    status.textContent = '无法解码这张图片';
  });
  document.querySelector('#clear').addEventListener('click', () => {
    release();
    input.value = '';
    status.textContent = '已清除';
  });
</script>

对象 URL 是浏览器为 Blob 建立的临时引用地址,不是上传后的服务器文件地址,也不适合存进数据库准备下次重开页面使用。创建后立即 revoke 可能让资源来不及读取;图片仍展示且用户还可能另存时,也应保留使用机会。本例在替换和清除时释放,真正的组件还应在销毁时调用 release。

释放 URL 会解除这条映射,不是删除用户硬盘上的原文件。FileList 也不等于普通 Array,需要数组方法时可先 Array.from。

上传文件让浏览器生成 multipart 边界 ​

下面是函数定义,只有调用时才发请求;调用方必须提供真实上传接口与 File。它不是一个随复制随可用的服务端。

js
async function uploadFile(endpoint, file, signal) {
  const body = new FormData();
  body.append('attachment', file, file.name);
  body.append('category', 'reading');
  const response = await fetch(endpoint, {
    method: 'POST',
    body,
    signal
  });
  if (!response.ok) throw new Error(`上传失败:HTTP ${response.status}`);
  return response.text();
}

FormData 的 append 保留同名多个值,set 替换同名值。不要手写一个缺少 boundary 的 multipart Content-Type,浏览器需要把请求头与实际编码对应起来。默认 fetch 没有与 XHR upload progress 完全等价的简便上传进度事件;若需求依赖实时上传进度,应明确选择相应 API 与目标环境能力,而不是从下载字节进度推算。

WebSocket 与 SSE 各适合什么数据方向 ​

能力WebSocketServer-Sent Events
方向建立连接后双方都可发消息服务器向客户端推送
浏览器入口WebSocketEventSource
常见数据文本或二进制消息UTF-8 文本事件流
重连应用自己设计策略EventSource 对可恢复断开有重连机制
常见用途双向协作、交互消息状态通知、任务进度

SSE 并不阻止客户端另外用 fetch 发命令;只是这条 EventSource 连接本身用于接收。WebSocket 也不自动提供消息持久化、恰好一次投递和业务顺序恢复。

下面函数需要真实 WebSocket 服务器地址;它定义一次连接的收发与关闭,不实现重连系统:

js
function openChannel(url, receive, reportError) {
  const socket = new WebSocket(url);
  socket.addEventListener('message', event => receive(event.data));
  socket.addEventListener('error', reportError);
  return {
    send(text) {
      if (socket.readyState !== WebSocket.OPEN) return false;
      if (socket.bufferedAmount > 64 * 1024) return false;
      socket.send(text);
      return true;
    },
    close() { socket.close(1000, '离开页面'); }
  };
}

构造后通常仍在连接中,不能立即假定可以 send。bufferedAmount 表示尚待发送的字节,本例阈值只是演示主动限制发送积压;标准 WebSocket 接口并不会自动为应用提供完整背压,接收过快也需要队列上限或业务流控。生产环境通常使用 wss,并在服务器校验身份、Origin、消息结构及权限;WebSocket 不直接套用 fetch 的 CORS 响应头模型。

SSE 服务端需要返回 text/event-stream,每条事件以空行分隔,例如:

text
id: 18
event: progress
data: {"percent":60}

对应客户端用 source.addEventListener('progress', handler) 接收命名事件;没有 event 字段的普通消息使用 message。id 可用于断线恢复时的 Last-Event-ID,但服务器仍需真的支持按编号补发,客户端也要考虑重复事件。原生 EventSource 不提供任意自定义请求头配置,跨源和凭据仍须遵守对应规则。

使用结束调用 source.close(),不要把每次 error 都当作已永久结束后又额外创建新实例,否则可能与内置重连叠加多个连接。

重连成功为什么仍不能证明消息完整 ​

假设已经处理编号 1,网络断开,重连后再次收到 1,再收到 3。重复应用 1 可能重复更新状态;直接接受 3 可能漏掉 2。连接恢复只解决“能继续收数据”,没有自动完成应用状态恢复。

下面是一个独立的消息处理实验。它假设本应用协议承诺正整数编号严格连续;这个约定是我们为例子定义的,SSE 本身的 id 可以是其他字符串,不能把代码当通用 SSE 解析器。

js
function makeProgressReceiver() {
  let last = 0;
  let percent = 0;
  return {
    accept(message) {
      if (!message || typeof message !== 'object' ||
          !Number.isSafeInteger(message.id) || message.id < 1 ||
          !Number.isFinite(message.percent) ||
          message.percent < 0 || message.percent > 100) return 'invalid';
      if (message.id <= last) return 'duplicate';
      if (message.id !== last + 1) return 'gap';
      last = message.id;
      percent = message.percent;
      return 'accepted';
    },
    read() { return { last, percent }; }
  };
}
const receiver = makeProgressReceiver();
for (const message of [
  { id: 1, percent: 20 }, { id: 1, percent: 20 },
  { id: 3, percent: 60 }, { id: 2, percent: 40 }
]) console.log(receiver.accept(message));
// accepted → duplicate → gap → accepted
console.log(receiver.read()); // { last: 2, percent: 40 }

这个函数只检测重复与缺口,并没有伪装成已经修复缺口。收到 gap 后,真实应用可以按协议请求补发或重新取得完整快照;如果服务器只保留有限历史,则需要定义从哪种快照继续。消息确认也应对应“已应用到状态”,而不只是“网络层收到了字节”。

SSE 的 Last-Event-ID 帮助服务器知道浏览器记录的最后事件编号,但服务器必须实现补发策略。WebSocket 没有内置这套恢复约定,需要应用自己的序号、确认与重连规则。两者都不能靠无限重试凭空保证恰好一次业务执行。

练习 ​

  1. 为什么 Blob.size 与字符串 length 可能不同?

  2. 为什么对象 URL 应释放,却不能在赋给图片后立刻无条件释放?

  3. 服务器只推送任务进度,客户端偶尔提交一个命令,应如何在 SSE、fetch 与 WebSocket 之间选择?

  4. SSE连上以后收到重复编号和缺口,为什么不能直接认为恢复完成?分块UTF8为何要复用decoder?

  5. 给 makeProgressReceiver 写一个不连接服务器的恢复实验:先收到 1,再收到 3,随后补发 2、重发 3,最后重复 3。最终 last=3、percent=60,且重复消息不能改变状态。

查看本节答案

用心记录每一步成长