文件与实时通信:字节、预览和持续到来的消息
前面用 fetch 接收了一份 JSON。实际页面还会处理用户选择的文件、临时预览,以及持续推送的进度。它们共享“异步取得数据”的背景,却有不同的权限、内存和连接生命周期。
Blob 与 File 保存什么
Blob 表示一段不可变的二进制数据,可以声明 MIME 类型;File 在 Blob 基础上增加 name、lastModified 等信息。文件通常来自用户选择或拖放,网页不能因为知道一个磁盘路径就任意读取它。
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 代码只使用三个字节就能观察这种问题:
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。
<!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。它不是一个随复制随可用的服务端。
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 各适合什么数据方向
| 能力 | WebSocket | Server-Sent Events |
|---|---|---|
| 方向 | 建立连接后双方都可发消息 | 服务器向客户端推送 |
| 浏览器入口 | WebSocket | EventSource |
| 常见数据 | 文本或二进制消息 | UTF-8 文本事件流 |
| 重连 | 应用自己设计策略 | EventSource 对可恢复断开有重连机制 |
| 常见用途 | 双向协作、交互消息 | 状态通知、任务进度 |
SSE 并不阻止客户端另外用 fetch 发命令;只是这条 EventSource 连接本身用于接收。WebSocket 也不自动提供消息持久化、恰好一次投递和业务顺序恢复。
下面函数需要真实 WebSocket 服务器地址;它定义一次连接的收发与关闭,不实现重连系统:
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,每条事件以空行分隔,例如:
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 解析器。
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 没有内置这套恢复约定,需要应用自己的序号、确认与重连规则。两者都不能靠无限重试凭空保证恰好一次业务执行。
练习
为什么 Blob.size 与字符串 length 可能不同?
为什么对象 URL 应释放,却不能在赋给图片后立刻无条件释放?
服务器只推送任务进度,客户端偶尔提交一个命令,应如何在 SSE、fetch 与 WebSocket 之间选择?
SSE连上以后收到重复编号和缺口,为什么不能直接认为恢复完成?分块UTF8为何要复用decoder?
给 makeProgressReceiver 写一个不连接服务器的恢复实验:先收到 1,再收到 3,随后补发 2、重发 3,最后重复 3。最终 last=3、percent=60,且重复消息不能改变状态。