只需百来行代码,为你的Web页面增加本地文件操作能力,确定不试试吗?

7,339 阅读5分钟

笔者开源了一个Web思维导图mind-map,数据默认是存储在localstorage里,如果想保存到本地文件,需要使用导出功能,下次打开再使用导入功能,编辑完如果又想保存到文件,那么又需要从重新导出覆盖原来的文件,不得不说,可以但不优雅,所以最近增加了直接编辑本地文件的能力,体验了一下,还是不错的,并且就是调调API的事情,很简单,何乐而不为。

主角就是showOpenFilePickershowSaveFilePicker两个API,笔者基于它俩开发了三个功能:

新建另存为其实一样的,只不过一个保存的是空数据,一个是当前的数据,当创建或打开文件成功后,操作的时候数据会直接保存到本地文件里,不再需要进行手动的导出,这种体验其实就和本地编辑器没什么区别了。

打开

先来看看打开文件,调用的是showSaveFilePicker方法,返回一个Promise,选择文件成功了那么Promise的结果是一个数组,每一项代表一个文件的操作句柄:

如果要获取某个文件的内容或写入某个文件就需要通过这些文件句柄对象。如果没有选择或选择失败了Promise则会出错:

这个方法接收一个选项对象作为参数:

  • options.multiple

布尔值,设置是否可以选择多个文件。

  • options.types

一个数组,设置允许被选择的文件类型,数组每一项都是一个对象:

{
	description: '',
	accept: {
		'': []
	}
}

description用于说明,好像没什么用,accept是个对象,keyMIME typevalue为一个数组,代表允许的文件扩展名。

如果MIME type设置的很具体,比如application/json,那么value不传的话只能选择文件后缀为.json的文件,如果value设置了扩展名的话,则在默认的.json文件外还允许选择设置的扩展名的文件,比如设置为['.smm'],那么.json.smm为后缀的文件都可以选择:

如果MIME type设置的比较宽泛的话,比如application/*,那么所有MIME typeapplication类型的文件都可以选择,就算value只设置了一个.json,其他类型的文件也是可以选择的,所以value的作用不是限制,而是扩充。

但是呢,这种限制可以轻松突破,只要点击扩展名打开下拉列表选择所有文件选项,那么还是想选什么文件就选什么文件,有朋友知道怎么解决的欢迎评论区留言。

  • options.excludeAcceptAllOption

布尔值,默认为false,即允许不配置types选项,支持选择所有文件,如果设为true,那么types选项不能为空,必须要限制一种文件类型。

笔者的思维导图文件格式使用的是.json,并且吃饱了撑的自己定义了一个格式.smm,其实就是json,并且同一时间只能编辑一个文件,那么打开文件的代码如下所示:

let fileHandle = null
async openLocalFile() {
    try {
        let [ _fileHandle ] = await window.showOpenFilePicker({
            types: [
                {
                    description: '',
                    accept: {
                        'application/json': ['.smm']
                    }
                },
            ],
            excludeAcceptAllOption: true,
            multiple: false
        });
        if (!_fileHandle) {
            return
        }
        fileHandle = _fileHandle
        if (fileHandle.kind === 'directory') {
            this.$message.warning('请选择文件')
            return
        }
        this.readFile()
    } catch (error) {
        if (error.toString().includes('aborted')) {
            return
        }
        this.$message.warning('你的浏览器可能不支持哦')
    }
}

将文件句柄保存起来,接下来都会基于它来操作文件,先来看看文件句柄对象,它存在两个方法:

  • getFile()

返回一个Promise,获取该句柄所对应的文件对象,其实就是我们常见的File对象:

  • createWritable()

返回也是一个Promise,创建一个可以写入文件的文件流对象:

基于这两个方法我们就可以读取打开文件的内容及把新内容写入文件:

// 读取文件
async readFile() {
    let file = await fileHandle.getFile();
    let fileReader = new FileReader();
    fileReader.onload = async () => {
        // fileReader.result
    }
    fileReader.readAsText(file);
}

// 写入文件
async writeLocalFile(content) {
    if (!fileHandle) {
        return;
    }
    let string = JSON.stringify(content);
    const writable = await fileHandle.createWritable();
    await writable.write(string);
    await writable.close();
}

页面内第一次调用createWritable方法浏览器会弹个窗询问用户是否允许:

每调用一次createWritable方法都会在你的本地创建一个.crswap文件:

相当于一个临时文件,没有调用写入流writableclose方法前,调用它的write方法写入的内容默认都保存在这个文件,只有调用close以后才会更新到源文件,并且自动删除这个临时文件,另外页面关闭,也会删除这些文件。

写入流默认是空的,每调用一次write方法,都会在.crswap中追加内容,但是可以指定写入的位置:

await writable.write({ type: "write", position: 0, data: string });

这样会从指定的字节数开始写入,注意是替换,而不是插入。

所以为了方便起见,最好还是创建、写入就关闭,再写再创建。

新建

新建调用的是showSaveFilePicker方法,也接收一个选项对象为参数,有两个选项和showOpenFilePicker方法是一样的,即typesexcludeAcceptAllOption,之外还有一个选项:

  • suggestedName

默认填充的文件名称,为空则创建文件时输入框就是空的。

可以直接输入文件名创建新文件,也可以点击已经存在的文件进行替换。

创建成功返回的也是一个文件句柄,那么创建文件就很简单了:

async createLocalFile(content) {
    try {
        let _fileHandle = await window.showSaveFilePicker({
            types: [{
                description: '',
                accept: {'application/json': ['.smm']},
            }],
            suggestedName: '思维导图'
        });
        if (!_fileHandle) {
            return;
        }
        const loading = this.$loading({
            lock: true,
            text: '正在创建文件',
            spinner: 'el-icon-loading',
            background: 'rgba(0, 0, 0, 0.7)'
        });
        fileHandle = _fileHandle;
        await this.writeLocalFile(content);
        await this.readFile();
        loading.close();
    } catch (error) {
        if (error.toString().includes('aborted')) {
            return
        }
        this.$message.warning('你的浏览器可能不支持哦');
    }
}

来看看实际效果:

总结

最后再来看看兼容性:

因为目前还是实验性质,所以可以看到是一片红,但是因为我的本身也只是一个示例项目,所以问题不大,有胜于无。

另外这个特性目前也只能在HTTPS协议或localhost下才可用,其他情况下window对象是不存在这两个API的,所以需要做好错误处理。