起因
最近隔壁的有个同事突然发问,我们有个商店页面可以用来上传 apk ,他们部门内部其实是有个 npkg 平台用来上传 apk 测试、生产包的。所以提出了一个问题:
问题!
有没有办法可以实现点击上传时候打开 npkg 平台选择内容后直接把 apk 包传到商店页面
让商店页面来适配 npkg 平台是 不现实的,因为这块的业务 是对外的,即使是一方的应用也不可能以这个理由提供支持,但是他们提出可以考虑让运营安装 chrome 插件来 绕过一些安全策略,这个想法 非常有意思
,我认为理论上 应该是可行的。
于是尝试写个 demo 试试。
实现
抽象行为
首先我们需要抽象一下行为,这个里面的步骤大致可以分为如下这几个步骤:
- 识别 商店上传input,并在上面绘制一个触发按钮
- 点击触发 按钮,弹出 npkg 页面
- 在 npkg 中点击列表内容
- 将文件添加到 input
我们分步骤来看,第一步是最容易的,无非是找一下 input 特征即可。
第二步应该是不难做的,弹出 npkg 页面可以用 window.open 来实现,然后基于窗口通讯即可,当然也可以直接使用 iframe 嵌入,然后利用 postMessage 实现,也都不难。
第三步点击列表内容需要他们自己适配一下
比较有问题的是第四步,其中有两个比较主要的问题。
- pkg 包不会小,由于某些原因,这些游戏包的大小可能会很大,譬如可能
达到 2G 以上
- 怎么样给 Input 设置 file ,并触发响应
过大的 pkg 包
由于是游戏 app,所以大小不可控,有的会到 2G 以上,很重要的一点是 2G 是一个分水岭,因为 chrome 对 blob 大小的限制是有规定的,具体可以前往 源码 查看:
// CrOS:
// * Ram - 20%
// * Disk - 50%
// Note: The disk is the user partition, so the operating system can still
// function if this is full.
// Android:
// * RAM - 1%
// * Disk - 6%
// Desktop:
// * Ram - 20%, or 2 GB if x64.
// * Disk - 10%
BlobStorageLimits CalculateBlobStorageLimitsImpl(
const FilePath& storage_dir,
bool disk_enabled,
std::optional<uint64_t> optional_memory_size_for_testing) {
这里只截取了注释:一旦 blob 超过 2G 那么就无法正确的分配内存了。
不过我们可以考虑不把 blob 放置到内存里面,因为很简单,基于 file 的 blob 的最大可以用 disk 的 10% 这个是一个非常巨大的数字。
写一个 demo
下面我们需要写一个 demo 实现一下。
图快这里用 vscode 的 live-server插件
随后使用如下命令创建一个超大的文件:
# 创建一个 10g 的文件
dd if=/dev/zero of=10g.dat bs=1M count=10240
设置下载
设置下载很简单, 首先我们要获取元素的大小,然后预分配:
const url = '/10g.dat';
// 首先获取 /download 文件大小
const response = await fetch(url);
// 获取文件名
const size = parseInt(response.headers.get('Content-Length'));
const reader = response.body.getReader();
// 建议提前关闭,节省资源
await reader.cancel();
// 创建一个文件
const newHandle = await window.showSaveFilePicker({
suggestedName: url.split('/').pop()
});
const writableStream = await newHandle.createWritable();
// 预分配大小
await writableStream.truncate(size);
循环下载
警告
由于文件太大,所以不能一次性写入,需要分块写入,且不能使用同一个 fetch 请求, 因为同一个 fetch 请求也会载入到内存,突破 chrome tab 占用内存的上限
let receivedLength = 0;
// 100MB
const chunkSize = 1024 * 1024 * 100;
while (receivedLength < size) {
const start = receivedLength;
// 防止超出长度
const end = Math.min(size, start + chunkSize);
const response = await fetch(url, {
headers: {
Range: `bytes=${start}-${end - 1}`
}
});
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
await writableStream.write(value);
receivedLength += value.length;
}
// 主动关闭读取流
await reader.cancel()
}
// 关闭写入流
await writableStream.close();
触发change 事件
const file = await newHandle.getFile();
let container = new DataTransfer();
container.items.add(file);
testInput.files = container.files;
testInput.dispatchEvent(new Event('change'));
虽然 FileList 是不能直接修改的1,因为本身 FileList 是一个 attempt to create an unmodifiable list
2 的行为,所以不能修改。
而 FileList 没有提供对外的 new 方法,所以也同样不能创建。
但是我们有 DataTransfer,他 items 是一个 FileList 所以可以用它来生成 input 需要的 files 字段,随后利用自定义事件触发 event 即可。
完整测试代码
完整代码使用 image 来模拟,用来模拟成功效果:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div>测试页面</div>
<input id="testInput" type="file" />
<button id="startSetup">开始设置</button>
<div id="progress"></div>
<script>
const testInput = document.getElementById('testInput');
testInput.addEventListener('change', function (e) {
const file = e.target.files[0];
alert('file changed');
// 在页面上显示图片
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
document.body.appendChild(img);
});
const startSetup = document.getElementById('startSetup');
startSetup.addEventListener('click', async function () {
const url = '/test.jpg';
// 首先获取 /download 文件大小
const response = await fetch(url);
// 获取文件名
const size = parseInt(response.headers.get('Content-Length'));
const reader = response.body.getReader();
await reader.cancel();
console.log('size', size);
// 创建一个文件
const newHandle = await window.showSaveFilePicker({
suggestedName: url.split('/').pop()
});
const writableStream = await newHandle.createWritable();
await writableStream.truncate(size);
let receivedLength = 0;
// 由于文件太大,所以不能一次性写入,需要分块写入,且不能使用同一个 fetch 请求
const chunkSize = 1024 * 1024 * 100; // 100MB
while (receivedLength < size) {
const start = receivedLength;
const end = Math.min(size, start + chunkSize);
const response = await fetch(url, {
headers: {
Range: `bytes=${start}-${end - 1}`
}
});
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
await writableStream.write(value);
receivedLength += value.length;
refreshUI(receivedLength, size);
}
await reader.cancel()
}
// 关闭写入流
await writableStream.close();
// 设置 input file 的值,触发 change 事件
testInput.value = '';
const file = await newHandle.getFile();
let container = new DataTransfer();
container.items.add(file);
testInput.files = container.files;
// 触发 input 的 change 事件
testInput.dispatchEvent(new Event('change'));
});
function refreshUI(receivedLength, size) {
const progress = document.getElementById('progress');
progress.innerText = `${receivedLength} / ${size} (${(receivedLength / size * 100).toFixed(2)}%)`;
}
</script>
</body>
</html>
脚注
本文标题:前端流式下载文件并设置到 input 上
永久链接:https://iceprosurface.com/code/web-frontend/stream-download-to-input/
作者授权:本文由 icepro 原创编译并授权刊载发布。
版权声明:本文使用「署名-非商业性使用-相同方式共享 4.0 国际」创作共享协议,转载或使用请遵守署名协议。