DOM & BOM – Input File, Drag & Drop File, File Reader, Blob, ArrayBuffer, File, UTF-8 Encode/Decode, Download File
前言
之前写过 2 篇关于读写文件和二进制相关的文章 Bit, Byte, ASCII, Unicode, UTF, Base64 和 ASP.NET Core – Byte, Stream, Directory, File 基础,
不过是 ASP.NET Core 和 C# 的版本. 今天想介绍用 Browser 和 JavaScript 实现的读写文件.
从前写的文章 Drag & Drop and File Reader 发布于 2014-12-18 (8 年前...)
What is Blob, ArrayBuffer, File?
Blob 相等于 FileStream, 而 ArrayBuffer 相等于 MemoryStream. 顾名思义一个是文件 (IO) 的流, 另一个是缓存 (RAM) 的流.
File 继承了 Blob, 只是多了一些属性而已.
Input File & Drag & Drop File
Browser 是不可以直接访问用户的文件的. 没权限, 必须是用户在意识清楚的情况下提供给你.
有 2 个方式可以让用户提供文件.
Input File
<input type="file" multiple />
效果
Input File 还支持 Drag & Drop 哦
JavaScript
const input = document.querySelector<HTMLInputElement>('input')!; input.addEventListener('input', () => { const files = input.files!; const textFile = files[0]; // File 对象 });
accept
file input 有一个属性叫 accept,它可以用来限制 file 的类型 (MIME Type)
<input type="file" accept="image/*, video/mp4">
上面这个代表只能选择图片和视频,而且视频只能是 mp4 格式。
这个限制只是一个体验而已,并不能真的禁止用户选择。
用户可以通过右下方选择 All Files,这样就 by pass 了。
除了使用 MIME Type,extension 也可以。
<input type="file" accept=".jpg, .jpeg, video/*">
只能是 extension ".jpg" 或 ".jpeg" 或视频。
注:IOS Safari 不支持 extension 哦
Drag & Drop File
另一个方式是做一个 drop area.
效果
JavaScript
const dropArea = document.querySelector<HTMLElement>('.drop-area')!; dropArea.addEventListener('dragover', e => e.preventDefault()); dropArea.addEventListener('drop', e => { e.preventDefault(); const files = e.dataTransfer!.files; const file = files[0]; // File 对象 dropArea.querySelector('p')!.textContent = file.name; });
Input File & Drag & Drop File (for directory)
除了提供 multiple files, 甚至可以提供 directory (folder) 直接获取里面所有 files 哦.
Input File
<input type="file" webkitdirectory />
效果
不管 directory 里面有多少层, 它都会把所有的 files 全部放入 input 里.
不管是 click input to chose 还是 drag & drop 去 input, 一律不支持 multiple directory (一次只能选择 1 个 directory)
JavaScript
const input = document.querySelector<HTMLInputElement>('input')!; input.addEventListener('input', () => { const files = Array.from(input.files!); console.log(files.map(f => f.webkitRelativePath)); // ['root/root-text.txt', 'root/parent/parent-text.txt', 'root/parent/child/cihld-text.txt'] });
通过 webkitRelativePath 可以拿到完整路径.
Drag & Drop File
Drag & Drop file 比 input 厉害, 它支持 multiple directory, 甚至 1 file 1个 directory 混搭也可以.
它的 JavaScript 实现会比较复杂
参考: Stack Overflow – Does HTML5 allow drag-drop upload of folders or a folder tree?

const dropArea = document.querySelector<HTMLElement>('.drop-area')!; dropArea.addEventListener('dragover', e => e.preventDefault()); dropArea.addEventListener('drop', async e => { e.preventDefault(); const texts: string[] = []; // 必须先把所有 entry 拿出来, 因为 for loop 的时候会进入异步 const fileSystemEntries = Array.from(e.dataTransfer!.items).map(item => item.webkitGetAsEntry()!); for (const entry of fileSystemEntries) { const fileEntries = await recursiveGetAllFileEntries(entry); console.log(fileEntries.map(e => e.fullPath)); // 相等于 webkitRelativePath const files = await Promise.all(fileEntries.map(e => entryToFileAsync(e))); texts.push(entry.isFile ? `File: ${entry.name}` : `Directory: total ${files.length} files`); } dropArea.querySelector('p')!.textContent = texts.join('\n'); function recursiveGetAllFileEntries(entry: FileSystemEntry): Promise<FileSystemFileEntry[]> { return new Promise(async resolve => { if (entry.isFile) { const fileEntry = entry as FileSystemFileEntry; resolve([fileEntry]); } else { const directoryEntry = entry as FileSystemDirectoryEntry; // 强转成 interface const reader = directoryEntry.createReader(); reader.readEntries(async entries => { const childFiles: FileSystemFileEntry[] = []; for (const childEntry of entries) { childFiles.push(...(await recursiveGetAllFileEntries(childEntry))); } resolve(childFiles); }); } }); } function entryToFileAsync(entry: FileSystemFileEntry): Promise<File> { return new Promise(resolve => entry.file(resolve)); } });
有几个点要注意
1. webkitGetAsEntry() 调用的时机
dropArea.addEventListener('drop', e => { e.preventDefault(); const items = Array.from(e.dataTransfer!.items); setTimeout(() => { console.log(items[0].webkitGetAsEntry()); // null }, 1000); });
拿 webkitGetAsEntry 要快, 一旦 delay 了就拿不到了. 所以第一步就必须先把所以 item 的 entry 拿出来. 才一个一个 async 处理.
2. 强转 FileSystemDirectoryEntry
这里 directoryEntry 的 class 其实是 DirectoryEntry, 但是 TypeScript 却没有. 相关 issue: Github – Add type definitions for Files And Directories API
但幸好 TypeScript 有 interface FileSystemDirectoryEntry 也能用.
3. FileSystemFileEntry.file 返回的 file, 它的 webkitRelativePath 总是 empty string.
这点和 input file 不同, 它不会智能的写入 webkitRelativePath, 但幸好可以用 FileSystemFileEntry.fullPath 获取到和 webkitRelativePath 一样的 directory + file name.
Read File Text
通过 input 或者 drag & drop 我们获取到了 File 对象. 上面有提到 File 对象只是 Blob 的扩展. 我们把它当 Blob 来看就行了.
Blob 就是 FileStream.
text.txt
File.text()
const input = document.querySelector<HTMLInputElement>('input')!; input.addEventListener('input', async () => { const textFile = input.files!.item(0)!; const text = await textFile.text(); console.log(text); // Hello World });
调用 text 方法就可以了, 它返回的是一个 Promise.
FileReader.readAsText()
另一个方法是用 FileReader (比较 old school)
const input = document.querySelector<HTMLInputElement>('input')!; input.addEventListener('input', async () => { const textFile = input.files!.item(0)!; const fileReader = new FileReader(); fileReader.addEventListener('load', () => { console.log(fileReader.result); // Hello World fileReader.abort(); }); fileReader.readAsText(textFile, 'utf-8'); // can specify encoding fileReader.readAsBinaryString; });
比较常用的是 .text 方法, 毕竟返回 Promise 方便许多. 但是 .text 方法不能指定 encoding 它一定是用 utf-8.
File to ArrayBuffer
我们也可以把 File/Blob (FileStream) 转成 ArrayBuffer (MemoryStream).
const input = document.querySelector<HTMLInputElement>('input')!; input.addEventListener('input', async () => { const textFile = input.files!.item(0)!; // textFile value = Hello World const memoryStream = await textFile.arrayBuffer(); console.log('length', memoryStream.byteLength); // 11 bytes // 用 FileReader const fileReader = new FileReader(); fileReader.addEventListener('load', () => { const memoryStream = fileReader.result! as ArrayBuffer; console.log('length', memoryStream.byteLength); // 11 bytes fileReader.abort(); }); fileReader.readAsArrayBuffer(textFile); });
Read Bytes from ArrayBuffer
ArrayBuffer 里面就是一堆的 bytes, 1 byte = 8 bit (八个二进制).
我们看一个 C# 的例子
var value = "严"; var utf8 = Encoding.UTF8.GetBytes(value); // 3 bytes [11100100, 10111000, 10100101] var utf16 = Encoding.Unicode.GetBytes(value); // 2 bytes [100101, 1001110]
"严" 这个的 Unicode 是 100111000100101, 上面分别是它的 UTF-8 和 UTF-16 的 encode.
我们分别保存在 2 个 file 里, utf-8.txt 和 utf-16.txt
UTF-8
input.addEventListener('input', async () => { const textFile = input.files!.item(0)!; // textFile = 严 UTF-8 const memoryStream = await textFile.arrayBuffer(); console.log('length', memoryStream.byteLength); // 3 bytes const bytes = new Uint8Array(memoryStream); console.log( 'bytes', Array.from(bytes).map(b => b.toString(2)) ); // ['11100100', '10111000', '10100101'] });
UTF-16
input.addEventListener('input', async () => { const textFile = input.files!.item(0)!; // textFile = 严 UTF-16 const memoryStream = await textFile.arrayBuffer(); console.log('length', memoryStream.byteLength); // 2 bytes const bytes = new Uint8Array(memoryStream); console.log( 'bytes', Array.from(bytes).map(b => b.toString(2)) ); // ['100101', '1001110'] const bytes2 = new Uint16Array(memoryStream); console.log( 'bytes', Array.from(bytes2).map(b => b.toString(2)) ); // ['100111000100101'] });
memoryStream.byteLength 是以 1 byte = 8 bits 计算的.
如果是用 Uinit16Array 表示 array 里 1 item 可以记入 16 bits 哦
p.s. JavaScript 一般都是用 UTF-8 而已, 其它尽量不要用, 顺风水。
TextEncoder & TextDecoder
参考: Stack Overflow – Converting between strings and ArrayBuffers
随便介绍一下 encode 和 decode UTF-8
const value = '严'; const textEncoder = new TextEncoder(); const bytes = textEncoder.encode(value); console.log(Array.from(bytes).map(b => b.toString(2))); // ['11100100', '10111000', '10100101'] const textDecoder = new TextDecoder(); console.log(textDecoder.decode(bytes)); // 严
JavaScript 没有 build-in 的方法 decode to UTF-16 哦.
ArrayBuffer 和 Uint 8/16/32 Array 详解
Uint 8/16/32 Array
const bytes = new Uint8Array(1);
new Uint8Array(1) 创建了一个 Array (不是我们日常的那个 Array,是特别的 Array)。
这个 Array 的 length 是初始化时决定了,之后不能改了,例子中是 1,所以这个 Array 只有一个位置。
这个位置的大小是 Uint8 决定的,8 代表一个位置可以装 8 个二进制。比如说:
console.log((100).toString(2)); // 1100100 7 bit console.log((255).toString(2)); // 11111111 8 bit console.log((256).toString(2)); // 100000000 9 bit
十进制 100 需要 7 个二进制来表达,256 需要 9 个二进制,那么 Uint8Array 的位置就装不下 >= 256 (十进制) 的数。
我们测试看看 (提醒:读写 Uint8Array 用的是十进制,但是算 size 的时候使用二进制,这一点不要搞错)
const bytes = new Uint8Array(1); bytes.set([254]); console.log(bytes.toString()); // '254' bytes.set([255]); console.log(bytes.toString()); // '255' bytes.set([256]); console.log(bytes.toString()); // '0' bytes.set([257]); console.log(bytes.toString()); // '1'
当超过 255 时,它就自动跳数了。
如果我用 Uint16Array 就表示每一个位置可以装的下 16 个二进制。
const bytes = new Uint16Array(1); bytes.set([257]); console.log(bytes.toString()); // '257' bytes.set([65535]); console.log(bytes.toString()); // '65535' bytes.set([65536]); console.log(bytes.toString()); // '0' 过龙了
ArrayBuffer
ArrayBuffer 是一串长长的二进制,它不可以直接读写,我们需要用 UintArray 才能读写它。
在读之前,我们需要决定使用哪个 Uint 8/16/32 Array,比如说上面提到过的例子:
'严' 这个字的 UTF-8 是 [11100100, 10111000, 10100101] 有 3 个 bytes。
如果我们用 Uint8Array 来装,那就一个 byte 对应一个位置,3 个 bytes Uint8Array length 就是 3。
如果我们用 Uint16Array 来装,会报错,因为数量不对。
'严' 这个字的 UTF-16 是 [100101, 1001110] 有 2 个 bytes
如果我们用 Uint8Array 来装,那就一个 byte 对应一个位置,2 个 bytes Uint8Array length 就是 2。
如果我们用 Uint16Array 来装,那就一个 2 bytes 对应一个位置,Uint16Array[0] 的值是 100111000100101。
这个 100111000100101 来自 2 个 bytes 的组合 1001110 + 0 + 0 + 100101,它是逆序,然后它会补零。
提醒:
我只是拿 UTF 来举例子而已,不管是 UTF-8/16/32 通通都用 Uint8Array 就对了。
因为 UTF 规则也是一个一个 byte 解析来看的,并不是直接合并几个 bytes 做解析。
Example for TextEncoder,ArrayBuffer,Uint8Array
由于 Encode,Decode,ArrayBuffer 这些经常用于搞加密,所以这里给 2 个具体例子。
code_verifier (OAuth 2.0 PKCE Flow)
code_verifier 的要求是随机 43-128 Characters (只允许 [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~")
这个随机数要非常随机,所以我们不可以用 Math.random 只能用 window.crypto.getRandomValues。
首先创建一个 Uint8Array
const bytes = new Uint8Array(43);
它有 43 个 slot,每一个 slot 可以装 8 bit。
window.crypto.getRandomValues(bytes);
使用 window.crypto.getRandomValues 把这 43 个 slot 填满。
虽然每个 slot 号码最大只能到 255,但是已经足够随机了。
接着把数值强转成字符
const text = String.fromCharCode(...[...bytes]); // §-ÃØ£Ñù=)ÝmÆ[Æ£ýNk©ÅDñÔâzÝIòX¡#Û»æl
String.fromCharCode 可以把 Unicode 十进制代号转成字符,比如 20005 会转成 "严"。
此时我们就有了 43 个随机的 characters,随机满足了,还需要满足 length 和合格的字符。
接着把 43 characters convert to Base64 URL
const base64 = btoa(text); const base64Url = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); // py3D2K2j0fk9Kd1txlvGoxyQ_QVOa4mpxUTx1IHiet1J8lihI4Pbu-Zs
convert 的过程中字数会加长,字符也变得合格了。
code_challenge (OAuth 2.0 PKCE Flow)
code_challenge 需要把 code_verifier 拿去 SHA-256 然后 Base64 URL。

function toBase64(value: string): string { return window.btoa(value); } function base64ToBase64Url(base64: string): string { return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function toBase64Url(value: string): string { const base64 = toBase64(value); return base64ToBase64Url(base64); } function generateCodeVerifier(): string { const bytes = new Uint8Array(43); window.crypto.getRandomValues(bytes); let text = String.fromCharCode(...[...bytes]); return toBase64Url(text); }
首先 encode code_verifier
const codeVerifier: string = generateCodeVerifier(); const encoder = new TextEncoder(); const bytes: Uint8Array = encoder.encode(codeVerifier);
会得到 Uint8Array。
接着调用 window.crypto.subtle.digest
const hashArrayBuffer: ArrayBuffer = await window.crypto.subtle.digest('SHA-256', bytes);
它会返回一个 Promise<ArrayBuffer>。
接着用 Uint8Array 把 ArrayBuffer 的 bytes 读出来,然后强转成 string 再转 Base64 URL 就可以了。
const text = String.fromCharCode(...[...new Uint8Array(hashArrayBuffer)]); const base64Url = toBase64Url(text);
Create Blob and Download as File
参考: Stack Overflow – Download File Using JavaScript/jQuery
document.querySelector('button')!.addEventListener('click', () => { const value = 'Hello World 我爱她'; const textEncoder = new TextEncoder(); const bytes = textEncoder.encode(value); const blob = new Blob([bytes], { type: 'text/plain', }); // 或者做成 file 也可以 // const file = new File([bytes], 'text.txt', { // type: 'text/plain', // }); const blobUrl = window.URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = blobUrl; anchor.download = 'text.txt'; anchor.click(); window.URL.revokeObjectURL(blobUrl); });
3 个点
1. window.URL.createObjectURL 的用途是把任何 Blob / File / ArrayBuffer 变成一个可以被引用的 URL. 可以用于 img.src, anchor.href 等等地方.
2. 通过模拟 anchor download click 实现下载.
3. 游览器会组织恶意下载哦, 比如 setTimeout 之后下载, 一次可以, 多次就 alert 了.
所以真实项目中, 下载最好配搭用户操作行为. 这样才不容易被 block.
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
2021-10-06 HTML – W3Schools 学习笔记
2021-10-06 HTML – Naming Conversion
2021-10-06 HTML – 冷知识
2021-10-06 Figma 学习笔记 – Interactive Components
2021-10-06 Figma 学习笔记 – 黑科技
2021-10-06 Figma 学习笔记 – Prototype
2021-10-06 Figma 学习笔记 – Team Library Style and Component