File System Access API 浅析

前言

最近在用pythontkinter GUI库)做一个小工具时,选择文件后可以获得其真实路径。而前端(浏览器)出于安全和性能等方面的考虑,对文件的操作是非常局限的。

HTML5标准的File API之前,纯前端几乎都无法完成图片预览的功能。即使有了File API,我们也不能直接读取本地文件,创建文件也只能通过下载的方式,更别说修改文件内容了。

如果我们想实现下载的时候弹出文件框,让用户自行选择保存的位置,又该怎么处理?

借此,梳理一下前端文件相关的方式方法,开帖记录(本篇为公司内部分享文章,同步发布到相关平台)。

File API

可以让用户在网页中选择本地文件,并读取这些文件信息。

最常见的可能就是上传功能了,一般通过htmlinput标签实现,示例如下

<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对象)

FileReader

一般用于读取文件,参数为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 读取操作完成时调用,无论成功,失败或取消

简单示例如下
File System Access API 浅析_第1张图片

File System API

HTML5新增了文件系统API,可以创建一个独立的沙箱文件系统,让开发者在此系统中进行创建、读写、移动、删除、索引文件等操作。它基于文件写入 APIFile Writer API),可以用于缓存和处理大量数据。

当然,此特性是非标准的(目前只有Google Chrome支持),尽量不要在生产环境中使用。

应用场景

应用场景

  • 上传中断(网络问题或浏览器崩溃等)后重新上传
  • 应用后台下载资源,无需等待完成才能进行后续动作
  • 配合IndexedDb或其它缓存方案,实现高效读写
  • 其它

一些说明

  • 此沙箱是一个虚拟的文件系统,不能读写用户硬盘中的文件
  • 浏览器会给每一个应用限定配额并分配存储(防止应用占满磁盘,可使用配额管理API申请合理的空间)
  • 文件系统支持异步和同步方法
  • 可以配合XMLHttpRequestDrop APIWeb Workerinput-file等使用,传递文件对象
  • 同源策略

下面介绍相关API和简单使用示例

基本使用

FileEntry/DirectoryEntry

沙盒环境的文件通过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);
}

File System Access API 浅析_第2张图片

读写文件

这里我们创建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);
}

File System Access API 浅析_第3张图片

使用目录

请求文件统成功后,我们可以创建文件(方法与文件一致,可参考使用文件部分)

// 创建目录
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)
}

File System Access API 浅析_第4张图片

创建子目录

需要注意的是不能直接创建其直接父目录不存在的目录,一般通过递归依次创建各级目录,示例如下。

// 错误示例如下
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('/'));

File System Access API 浅析_第5张图片

读取目录

需要注意的是只能直接读取当前目录的直属子目录,一般通过递归依次读取各级目录,示例如下(示例中提前创建了一些目录和文件)。

// 读取文件夹和文件
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)
}

File System Access API 浅析_第6张图片

其它操作

文件拷贝、删除、移动
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)

File System Access API 浅析_第7张图片

目录拷贝、删除、移动
// 普通删除:若文件夹下存在文件,则无法删除
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 Access API 浅析_第8张图片

到这里我们就使用File System API完成了创建、写入、读取、拷贝、删除文件(夹)等基本操作。关于上面的应用场景的具体实现,这里不做赘述,感兴趣的可以自行查阅相关资料。

回到一开始我们说的问题:此沙箱是一个虚拟的文件系统,不能读写用户硬盘中的文件,也无法做到一些自定义交互效果。接下来我们来看一种更加稳妥且安全的交互API

File System Access API

文件系统访问 APIFile System Access API)允许与用户本地设备或用户可访问的网络文件系统上的文件进行交互。此API的核心功能包括读取文件、写入或保存文件以及对目录结构的访问。

相比于File System API,它提供了更强大的文件读写能力。

应用场景

  • 将文件从本地文件系统上传到Web应用程序
  • Web应用程序中的数据写入到本地文件系统中
  • 在用户的本地文件系统上创建、重命名和删除文件
  • 读取本地文件系统上的文件内容
  • 其它…

一些说明

  • 需用户授权访问相应的文件或目录
  • 文件系统支持异步方法
  • 同源策略

下面介绍相关API和简单使用示例

基本使用

此API提供3个基本(异步)方法,可配合async/await/then使用。

  • 打开文件选取窗口

    window.showOpenFilePicker

  • 打开文件保存窗口

    window.showSaveFilePicker

  • 打开目录选取窗口

    window.showDirectoryPicker

FileSystemFileHandle/FileSystemDirectoryHandle

选择文件或目录后,可以获取到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 System Access API 浅析_第9张图片

选择文件回调中可以获取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}`);
};

File System Access API 浅析_第10张图片

选择目录

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);
}

File System Access API 浅析_第11张图片

保存文件

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}`);
}

File System Access API 浅析_第12张图片

当然,也可以配合Web WorkercreateSyncAccessHandle来实现高性能的文件读写操作,这里不做示例说明。

其它示例

API有很多有意思的方法,下面给出几个简单示例。

1、强制选择指定类型文件

一般在使用input-file时,即使我们指定上传文件类型,还会有一个默认的“所有文件”的选项,可以选择其它类型的文件

File System Access API 浅析_第13张图片

通过API调用,设置excludeAcceptAllOption则可手动控制隐藏这个选项

File System Access API 浅析_第14张图片

2、操作本地文件

我们可以模拟以下功能场景:

  • 选择一个目录,自动写入文件(夹)
  • 选择一个文件,再选择一个目录,自动将文件移动到所选目录中

首先在桌面创建两个文件夹testDirtestDir2备用。

选择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 })
    }

File System Access API 浅析_第15张图片

选择testDir文件夹,并读取其内容句柄(刚才创建的文件和文件夹)

async function readDir() {
    let dirHandle = await window.showDirectoryPicker({
        mode: "read"
    });
    for await (const item of dirHandle.values()) {
        console.log(item)
    }
}

File System Access API 浅析_第16张图片

移动testDirtxt文本到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)
}

File System Access API 浅析_第17张图片

选择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 });
        }
    }
}
3、自定义换肤

一般多主题方案是固定的(由最终产品提供固定的几种配色或布局),灵活一点的可能会允许用户针对基础配色进行调整,这种需要依赖前后端配置。

如果我们不依赖后台,而是考虑通过纯前端方式,在本地生成相应的“皮肤文件”,允许用户选择这些文件从而动态完成应用多主题,是不是显得(闲的)高大上呢?

这里不做赘述,有兴趣的可以继续深究。

兼容性

可以看到现代浏览器是部分支持(或支持部分API)。

File System Access API 浅析_第18张图片

后记

  • 浏览器对于本地文件的操作一直都是很局限的,其实也限制了一部分前端的发展,很多功能需要依赖后台或者其他插件
  • File System API只有Chrome支持实现,其最大贡献在于突破原有定式,赋予浏览器直接操作文件和目录的能力,这一步如果成功,可极大提升浏览器在应用系统中的地位(然而步子太大,GG了)
  • File System Access API真正意义上让浏览器可以操作本地文件。但出于安全考虑,浏览器不允许我们访问一些敏感目录(如包含系统文件的目录或者组策略不允许访问的目录)
  • File System Access API提供了更加强大的Web应用程序功能。我们可以利用它完成如文本/图形在线编辑器、导入导出优化、加载用户本地脚本、大文件切片缓存等功能
  • File System Access API功能很强大,但兼容性是当下比较现实的问题。基于安全和性能考量,各浏览器厂商并未完全纳入自有规范中,但可以看到主流浏览器都在朝着这个方向去发展(兼容了部分操作)
  • 为了方便理解,以上给出的示例都是比较简单的,实际上还有很多复杂的API和功能,感兴趣的话可自行查阅相关资料(有封装好的三方库,如browser-fs-access等)
  • 当然我们还是期待此标准能够尽快沉淀和完善起来,可以给前端(浏览器)的生态带来一些变化,也许我们可以摆脱inputa标签的束缚,做出更灵活的交互

参考资料

File_System_Access_API

file-system-access

HTML5 本地文件操作之FileSystemAPI实例

浏览器读写本地文件 File System Access API

FileSystemHandle

你可能感兴趣的:(前端,前端,技术,chrome)