最近在用python
(tkinter GUI
库)做一个小工具时,选择文件后可以获得其真实路径。而前端(浏览器)出于安全和性能等方面的考虑,对文件的操作是非常局限的。
在HTML5
标准的File API
之前,纯前端几乎都无法完成图片预览的功能。即使有了File API
,我们也不能直接读取本地文件,创建文件也只能通过下载的方式,更别说修改文件内容了。
如果我们想实现下载的时候弹出文件框,让用户自行选择保存的位置,又该怎么处理?
借此,梳理一下前端文件相关的方式方法,开帖记录(本篇为公司内部分享文章,同步发布到相关平台)。
可以让用户在网页中选择本地文件,并读取这些文件信息。
最常见的可能就是上传功能了,一般通过html
的input
标签实现,示例如下
<input type="file" multiple onchange="fileChange()">
function fileChange(){
console.log(event.target.files);
}
input-file
通常搭配下面两个参数使用
属性 | 值 | 描述 |
---|---|---|
accept | MIME_TYPE | 规定通过文件上传来提交的文件的类型 |
multiple | - | 是否可多选 |
读取到的File
信息一般包括
属性 | 描述 |
---|---|
name | 文件名 |
size | 文件大小 |
type | 文件MIME类型 |
lastModified | 文件上次修改时间(时间戳) |
lastModifiedDate | 文件上次修改时间(Date对象) |
一般用于读取文件,参数为File对象或Blob对象,示例如下
<input type="file" onchange="fileChange()"/>
function fileChange(){
const reader = new FileReader();
reader.readAsDataURL(event.target.files[0]);
reader.onload = function(e){
console.log(e.target.result)
}
}
此API
有如下方法和事件
方法 | 描述 |
---|---|
abort() | 终止读取操作 |
readAsArrayBuffer(Blob|File) | 返回ArrayBuffer对象 |
readAsBinaryString(Blob|File) | 返回二进制字符串 |
readAsDataURL(Blob|File) | 返回Base64编码的对象 |
readAsText(Blob|File,encoding) | 返回文本字符串 |
onloadstart | 读取操作开始时调用 |
onprogress | 读取数据过程中周期性调用 |
onabort | 读取操作被中止时调用 |
onerror | 读取操作发生错误时调用 |
onload | 读取操作成功完成时调用 |
onloadend | 读取操作完成时调用,无论成功,失败或取消 |
HTML5
新增了文件系统API
,可以创建一个独立的沙箱文件系统,让开发者在此系统中进行创建、读写、移动、删除、索引文件等操作。它基于文件写入 API
(File Writer API
),可以用于缓存和处理大量数据。
当然,此特性是非标准的(目前只有Google Chrome
支持),尽量不要在生产环境中使用。
IndexedDb
或其它缓存方案,实现高效读写XMLHttpRequest
、Drop API
、Web Worker
、input-file
等使用,传递文件对象下面介绍相关API
和简单使用示例
沙盒环境的文件通过FileEntry
句柄(目录为DirectoryEntry
)操作
属性/方法 | 说明 |
---|---|
name | 操作对象名称 |
isFile | 操作对象是否为文件 |
isDirectory | 操作对象是否为目录 |
fullPath | 完整路径,文件系统的绝对路径 |
filesystem | 文件系统对象(name/root),详见FileSystem |
getMetadata | 获取文件/目录信息 |
moveTo | 移动文件/目录 |
copyTo | 拷贝文件/目录 |
toURL | 完整路径,文件系统的绝对路径 |
remove | 删除文件/目录 |
getParent | 获取父目录 |
file | 获取文件数据对象(FileEntry) |
createWriter | 用于写入文件(FileEntry) |
createReader | 用于读取目录(DirectoryEntry) |
getDirectory | 创建目录(DirectoryEntry) |
getFile | 创建文件(DirectoryEntry) |
removeRecursively | 递归删除目录(DirectoryEntry) |
首先我们需要查询系统可使用的临时/持久空间大小
// 持久磁盘配额
navigator.webkitPersistentStorage.queryUsageAndQuota(successCallback,errorCallback)
// 临时磁盘配额
navigator.webkitTemporaryStorage.queryUsageAndQuota(successCallback,errorCallback)
属性 | 说明 |
---|---|
successCallback | 成功回调 |
errorCallback | 失败回调 |
// 查询临时配额
navigator.webkitTemporaryStorage.queryUsageAndQuota (
function(usedBytes, grantedBytes) {
console.log('已使用:',usedBytes,' 总量:',grantedBytes);
},
function(e) { console.log('Error', e); }
);
查询成功后,可以根据实际需求申请合适的配额空间
navigator.webkitTemporaryStorage.queryUsageAndQuota(requestedBytes,successCallback,errorCallback)
属性 | 说明 |
---|---|
requestedBytes | 申请配额空间大小(字节) |
successCallback | 成功回调 |
errorCallback | 失败回调 |
// 申请5M空间
var requestedBytes = 1024*1024*5;
navigator.webkitTemporaryStorage.requestQuota (
requestedBytes,
function(grantedBytes) {
console.log('请求成功的空间: ', gengerate(grantedBytes));
},
function(e) {
console.log('Error', e);
}
);
在请求配额成功后,可以请求访问文件系统
window.webkitRequestFileSystem(type,size,successCallback,errorCallback)
属性 | 说明 |
---|---|
type | window.TEMPORARY:临时存储空间;window.PERSISTENT:永久存储空间 |
size | 需要用于存储的大小(字节) |
successCallback | 成功回调 |
errorCallback | 失败或发生错误时回调 |
window.webkitRequestFileSystem(window.PERSISTENT, 1024*1024*5, initFsHanlder, errorHandler);
// 成功回调
function initFsHanlder(fs) {
console.log(fs);
}
// 错误回调
function errorHandler(e) {
console.log('错误:',e)
}
请求文件系统成功后,我们可以创建文件
// 创建文件
fs.root.getFile(name,opts,successCallback,errorCallback)
属性 | 说明 |
---|---|
name | 文件名/目录名 |
options(create/exclusive) | 文件操作的参数 |
successCallback(fileEntry|dirEntry) | 成功回调 |
errorCallback | 失败或发生错误时回调 |
使用getFile
查找或创建文件,成功回调传递FileSystem
对象(FileEntry
)。下面示例为在根目录创建test.txt
文本。
function initFsHanlder(fs) {
// 直接创建文件
// create:默认false,如果目标文件不存在是否创建
// exclusive:默认false,需配合create:true使用,如果目标文件不存在则创建,存在则覆盖
fs.root.getFile('test.txt', { create: true }, function (fileEntry) {
console.log('创建文件成功:',fileEntry);
}, errorHandler);
}
这里我们创建test.txt
文本,并向其中添加(追加)自定义内容。
// 创建文件
function createFile(fs, filename) {
fs.root.getFile(filename, { create: true },function (fileEntry) {
console.log(`创建文件${filename}成功`)
}, errorHandler);
}
// 读取内容
function readFile(fileEntry){
if (fileEntry.isFile) {
fileEntry.file(function (file) {
var reader = new FileReader();
reader.onloadend = function () {
console.log('读取文件成功:',reader)
}
reader.readAsText(file);
});
}
}
// 写入内容
function addContent(fs,filename) {
fs.root.getFile(filename, { create: true },function (fileEntry) {
if (fileEntry.isFile) {
fileEntry.createWriter(function (fileWriter) {
// 写入内容(File或Blob对象)
var blob = new Blob(['hello world,this is my first try'], {
type: 'text/plain'
});
// 写入结束
fileWriter.onwriteend = function (e) {
console.log('写入文件结束',e);
}
// 写入错误
fileWriter.onerror = function (e) {
console.log('写入异常:',e);
}
// 移动(光标)到指定位置,追加内容
// fileWriter.seek(fileWriter.length);
// 执行写入
fileWriter.write(blob);
}, errorHandler);
}
}, errorHandler);
}
请求文件统成功后,我们可以创建文件(方法与文件一致,可参考使用文件部分)
// 创建目录
fs.root.getDirectory(name,opts,successCallback,errorCallback)
使用getDirectory
查找或创建目录,成功回调传递FileSystem
对象(DirectoryEntry
)。下面示例为在根目录创建testDir
目录,并在testDir
目录下创建dir.txt
文本
function initFsHanlder(fs) {
// 直接创建目录
// create:默认false,如果目录不存在是否创建
// exclusive:默认false,需配合create:true使用,如果目录不存在则创建,存在则覆盖
fs.root.getDirectory('testDir', { create: true }, function (dirEntry) {
console.log('创建目录成功:', dirEntry)
}, errorHandler)
}
需要注意的是不能直接创建其直接父目录不存在的目录,一般通过递归依次创建各级目录,示例如下。
// 错误示例如下
fs.root.getDirectory('父文件夹/子文件夹', { create: true }, function (dirEntry) {
console.log('创建目录成功:', dirEntry)
}, errorHandler)
// 正确示例如下
function createDir(rootDirEntry, folders) {
rootDirEntry.getDirectory(folders[0], {create: true}, function(dirEntry) {
if (folders.length) {
createDir(dirEntry, folders.slice(1));
}
}, errorHandler);
};
var folderPath = '我的文档/资料/web前端';
createDir(fs.root, folderPath.split('/'));
需要注意的是只能直接读取当前目录的直属子目录,一般通过递归依次读取各级目录,示例如下(示例中提前创建了一些目录和文件)。
// 读取文件夹和文件
function readFolder(foldername){
fs.root.getDirectory(foldername, { create: true }, function (dirEntry) {
if(dirEntry.isDirectory){
var reader = dirEntry.createReader();
reader.readEntries(function(e) {
if (e.length) {
let list = Array.prototype.slice.call(e, 0);
list.map(v=>{
if(v.isDirectory){
readFolder(v.fullPath,v.name);
}
})
}
}, errorHandler);
}
}, errorHandler)
}
fs.root.getDirectory('testDir', { create: true }, function (dirEntry) {
console.log('创建目录testDir成功,路径:', dirEntry.fullPath)
// 拷贝文件并且重命名
fs.root.getFile('test.txt', { create: true }, function (fileEntry) {
console.log('创建文件test.txt成功,路径:', fileEntry.fullPath);
fileEntry.copyTo(
dirEntry,
'test_copy.txt',
(e) => { console.log('拷贝文件test.txt成功,新路径:', e.fullPath) },
(e) => { console.log('拷贝文件test.txt失败:', e) }
);
}, errorHandler);
// 移动文件并且重命名
fs.root.getFile('test1.txt', { create: true }, function (fileEntry) {
console.log('创建文件test1.txt成功,路径:', fileEntry.fullPath);
fileEntry.moveTo(
dirEntry,
'test1_move.txt',
(e) => { console.log('移动test1.txt成功,新路径:', e.fullPath) },
(e) => { console.log('移动test1.txt失败:', e) }
);
}, errorHandler);
// 删除文件
fs.root.getFile('test2.txt', { create: true }, function (fileEntry) {
console.log('创建文件test2.txt成功,路径:', fileEntry.fullPath);
fileEntry.remove(
(e) => { console.log('删除test2.txt成功:', e) },
(e) => { console.log('删除test2.txt失败:', e) }
);
}, errorHandler);
}, errorHandler)
// 普通删除:若文件夹下存在文件,则无法删除
fs.root.getDirectory('testDir/childDir', { create: true }, function (dirEntry) {
dirEntry.remove(function () {
console.log(`删除目录${dirEntry.fullPath}成功`);
}, errorHandler);
}, errorHandler)
// 递归删除:会删除所有子文件和子文件夹
fs.root.getDirectory('testDir/childDir', { create: true }, function (dirEntry) {
dirEntry.removeRecursively(function () {
console.log(`删除目录${dirEntry.fullPath}成功`);
}, errorHandler);
}, errorHandler)
// 移动、拷贝目录
fs.root.getDirectory('testDir/childDir1', { create: true }, function (dirEntry) {
console.log('创建目录成功,路径:',dirEntry.fullPath)
fs.root.getDirectory('testDir/childDir2', { create: true }, function (dirEntry2) {
console.log('创建目录成功,路径:',dirEntry2.fullPath)
// 拷贝到根目录,并重命名
dirEntry.copyTo(fs.root, 'childDir1_copy', function (dirEntiry3) {
console.log('复制目录成功,新路径:',dirEntiry3.fullPath);
}, errorHandler);
// 将childDir1移动到childDir2下,并重命名
setTimeout(()=>{
dirEntry.moveTo(dirEntry2, 'testDir_move', function (dirEntry4) {
console.log('移动目录成功,新路径:', dirEntry4.fullPath);
}, errorHandler);
},2000)
}, errorHandler)
}, errorHandler)
到这里我们就使用File System API
完成了创建、写入、读取、拷贝、删除文件(夹)等基本操作。关于上面的应用场景的具体实现,这里不做赘述,感兴趣的可以自行查阅相关资料。
回到一开始我们说的问题:此沙箱是一个虚拟的文件系统,不能读写用户硬盘中的文件,也无法做到一些自定义交互效果。接下来我们来看一种更加稳妥且安全的交互API
。
文件系统访问 API
(File System Access API
)允许与用户本地设备或用户可访问的网络文件系统上的文件进行交互。此API
的核心功能包括读取文件、写入或保存文件以及对目录结构的访问。
相比于File System API
,它提供了更强大的文件读写能力。
Web
应用程序Web
应用程序中的数据写入到本地文件系统中下面介绍相关API
和简单使用示例
此API提供3个基本(异步)方法,可配合async/await/then
使用。
打开文件选取窗口
window.showOpenFilePicker
打开文件保存窗口
window.showSaveFilePicker
打开目录选取窗口
window.showDirectoryPicker
选择文件或目录后,可以获取到FileSystemFileHandle
(目录为FileSystemDirectoryHandle
),后续操作由这个句柄进行(继承自FileSystemHandle)
属性/方法 | 说明 |
---|---|
kind | 类型(file/directory) |
name | 文件名/文件夹名 |
isSameEntry | 比较两个句柄关联的文件或目录是否匹配 |
queryPermission | 查询当前句柄的权限状态 |
requestPermission | 请求文件句柄的读/写权限 |
remove | 从文件系统中删除该句柄对应的文件或目录 |
move | 移动文件(FileHandle) |
getFile | 获取File对象(FileHandle) |
createWritable | 创建写入File对象(FileHandle) |
entries | 返回[key,value]形式的异步迭代器(DirectoryHandle) |
values | 返回键值异步迭代器(DirectoryHandle) |
getDirectoryHandle | 返回指定名称的目录的句柄(DirectoryHandle) |
getFileHandle | 返回指定名称的文件的句柄(DirectoryHandle) |
removeEntry | 删除指定名称的文件或目录(DirectoryHandle) |
resolve | 返回由从父句柄到指定子项的目录名数组,子项的名称作为最后一个数组项(DirectoryHandle) |
window.showOpenFilePicker()
:显示一个文件选择器,允许用户选择一个或多个文件并返回这些文件的句柄。
属性 | 说明 |
---|---|
multiple | 是否允许多选(默认false) |
excludeAcceptAllOption | 是否排除“接受所有”选项(默认 false) |
types | 允许选择的文件类型(description:描述/accept:MIME类型) |
async function openFile() {
let fileHandles = await window.showOpenFilePicker({
multiple: true,
excludeAcceptAllOption: true,
types: [
{
description: "选择图片",
accept: {
"image/*": [".png", ".gif", ".jpeg", ".jpg"],
},
},
],
});
console.log(fileHandles);
};
选择文件回调中可以获取File
对象(后续可以按照之前的方式处理即可)
async function openFileAndRead() {
let [fileHandle] = await window.showOpenFilePicker({
multiple: false, // 取消多选
excludeAcceptAllOption: true,
types: [
{
description: "选择文本文件",
accept: {
"text/plain": [".txt"],
},
},
],
});
let file = await fileHandle.getFile();
console.log(file);
let content = await file.text();
console.log(`打开文件: ${fileHandle.name}\n文件内容:\n${content}`);
};
window.showDirectoryPicker()
:显示一个目录选择器,允许用户选择一个目录。
属性 | 说明 |
---|---|
id | 通过指定 ID,浏览器能够记住不同 ID 所对应的目录。当使用相同的 ID 打开另一个目录选择器时,选择器会打开相同的目录 |
mode | 默认 read,可对目录进行只读访问。 readwrite 可对目录进行读写访问 |
startIn | 用于指定选择器的起始目录。可以是FileSystemHandle对象或者常见目录(如:“desktop”、“documents”、“downloads”、“music”、“pictures”、“videos”) |
// 默认从“下载”目录打开
async function openDir() {
let dirHandle = await window.showDirectoryPicker({
mode: "read",
startIn: "downloads",
});
console.log(dirHandle);
}
window.showSaveFilePicker()
:显示一个文件选择器,允许用户保存一个文件。可以选择一个已有文件覆盖保存,也可以新建一个文件。
属性 | 说明 |
---|---|
excludeAcceptAllOption | 是否排除“接受所有”选项(默认 false) |
suggestedName | 建议的文件名称 |
types | 允许保存文件类型的数组(description:描述/accept:MIME类型) |
// 写入文本到下载目录
async function writeFile() {
// 写入文本到下载目录
let writeHandle = await window.showSaveFilePicker({
suggestedName: "写入测试.txt", // 待写入的文件名
startIn: "downloads", //默认打开下载目录
});
const writableStream = await writeHandle.createWritable();
// 写入文件
await writableStream.write({
type: 'write',
position: 4,
data: "这里是通过API写入的文本内容,从第4位开始"
});
// 关闭流
await writableStream.close();
console.log(`保存成功: ${writeHandle.name}`);
}
当然,也可以配合Web Worker
和createSyncAccessHandle
来实现高性能的文件读写操作,这里不做示例说明。
此API
有很多有意思的方法,下面给出几个简单示例。
一般在使用input-file
时,即使我们指定上传文件类型,还会有一个默认的“所有文件”的选项,可以选择其它类型的文件
通过API
调用,设置excludeAcceptAllOption
则可手动控制隐藏这个选项
我们可以模拟以下功能场景:
首先在桌面创建两个文件夹testDir
,testDir2
备用。
选择testDir
文件夹,在其中自动创建一个文件夹和文本文件(create:true
表示不存在则创建)
async function createDir() {
let dirHandle = await window.showDirectoryPicker({
mode: "read",
startIn: 'desktop'
});
console.log(dirHandle);
// 创建默认文件夹
const dirName = "默认文件夹";
const subDir = await dirHandle.getDirectoryHandle(dirName, { create: true });
// 创建默认文件
const fileName = "默认文本.txt";
const subFile = await dirHandle.getFileHandle(fileName, { create: true })
}
选择testDir
文件夹,并读取其内容句柄(刚才创建的文件和文件夹)
async function readDir() {
let dirHandle = await window.showDirectoryPicker({
mode: "read"
});
for await (const item of dirHandle.values()) {
console.log(item)
}
}
移动testDir
中txt
文本到testDir2
文件夹中
async function moveTxt() {
// 选择目标文件
let [fileHandle] = await window.showOpenFilePicker({
multiple: false,
excludeAcceptAllOption: true,
types: [
{
description: "选择文本",
accept: {
"text/plain": [".txt"],
},
},
],
});
// 选择目标目录
let dirHandle = await window.showDirectoryPicker({
mode: "readwrite"
});
fileHandle.move(dirHandle)
}
选择testDir
,删除刚才创建的文件和文件夹
async function deleteDir() {
let dirHandle = await window.showDirectoryPicker({
mode: "read"
});
for await (const item of dirHandle.values()) {
console.log(item)
if(item.kind == 'file'){
// 删除文件
dirHandle.removeEntry(item.name);
// 也可以使用如下方法=>fileHandler.remove(fileHandler)
// item.remove(item)
}else{
// recursive:文件夹递归删除所有子目录和文件
dirHandle.removeEntry(item.name, { recursive: true });
}
}
}
一般多主题方案是固定的(由最终产品提供固定的几种配色或布局),灵活一点的可能会允许用户针对基础配色进行调整,这种需要依赖前后端配置。
如果我们不依赖后台,而是考虑通过纯前端方式,在本地生成相应的“皮肤文件”,允许用户选择这些文件从而动态完成应用多主题,是不是显得(闲的)高大上呢?
这里不做赘述,有兴趣的可以继续深究。
可以看到现代浏览器是部分支持(或支持部分API
)。
File System API
只有Chrome
支持实现,其最大贡献在于突破原有定式,赋予浏览器直接操作文件和目录的能力,这一步如果成功,可极大提升浏览器在应用系统中的地位(然而步子太大,GG了)File System Access API
真正意义上让浏览器可以操作本地文件。但出于安全考虑,浏览器不允许我们访问一些敏感目录(如包含系统文件的目录或者组策略不允许访问的目录)File System Access API
提供了更加强大的Web
应用程序功能。我们可以利用它完成如文本/图形在线编辑器、导入导出优化、加载用户本地脚本、大文件切片缓存等功能File System Access API
功能很强大,但兼容性是当下比较现实的问题。基于安全和性能考量,各浏览器厂商并未完全纳入自有规范中,但可以看到主流浏览器都在朝着这个方向去发展(兼容了部分操作)API
和功能,感兴趣的话可自行查阅相关资料(有封装好的三方库,如browser-fs-access
等)input
和a
标签的束缚,做出更灵活的交互File_System_Access_API
file-system-access
HTML5 本地文件操作之FileSystemAPI实例
浏览器读写本地文件 File System Access API
FileSystemHandle