uniapp中5+API文件操作实战:从读取到写入的完整封装
1. 为什么需要5+API的文件操作?
如果你用uniapp开发过需要离线存储的应用,比如一个本地记事本、一个离线数据采集工具,或者一个需要缓存大量配置文件的App,那你肯定遇到过一个问题:H5那套localStorage或者IndexedDB,在App端要么容量不够用,要么操作起来太麻烦,尤其是面对复杂的文件结构时。
这时候,5+API的IO模块就像一把瑞士军刀,直接让你能操作手机本地文件系统。你可以把它理解成给网页开发(H5)赋予了原生App的文件管理能力。我最早接触这个,是在做一个巡检App的时候,用户需要在没有网络的山里拍照、填表单,所有数据都得先存在手机里,等有网了再上传。如果只用uni.setStorage,存几张高清图片可能就爆了,而且也没法直接管理生成的图片文件。用上5+API的IO模块后,一切都变得清晰可控。
简单来说,当你需要:
- 存储大量数据:比如缓存视频、图片、数据库文件。
- 精细化管理文件:需要创建特定目录、移动、复制、重命名文件。
- 持久化存储结构化数据:比如用JSON文件保存用户配置、应用状态,比
localStorage更可靠,容量也大得多。 - 与手机本地文件交互:读取用户选择的文件,或者将生成的文件保存到用户指定的位置(注意权限)。
在这些场景下,5+API的IO模块就是你的不二之选。它底层调用的是原生能力,但通过JavaScript API暴露给我们,用起来的感觉很像Node.js里的fs模块,对于有后端经验的开发者来说非常亲切。
2. 核心API快速入门与理解
在开始封装之前,我们得先搞懂几个最核心的“积木块”。别怕,我用最直白的话给你解释。
第一个积木:plus.io.requestFileSystem
这是所有文件操作的起点。它的作用是“申请进入文件系统的某个区域”。想象一下,手机文件系统是一个大仓库,这个API就是向系统申请进入某个特定房间的钥匙。
plus.io.requestFileSystem(plus.io.PUBLIC_DOCUMENTS, function(fs) {
// 拿到钥匙了!fs就是那个房间(文件系统)的管理员对象
console.log('文件系统请求成功', fs);
}, function(e) {
console.log('进入房间失败', e.message);
});
这里的plus.io.PUBLIC_DOCUMENTS是一个常量,代表“应用私有文档目录”。这个目录最常用,因为应用卸载时这里的数据会被清除,符合沙盒安全规范。其他常见的还有plus.io.PRIVATE_WWW(应用资源目录,只读)、plus.io.PRIVATE_DOC(私有文档目录)等。对于App开发,我们绝大部分操作都在PUBLIC_DOCUMENTS里进行。
第二个积木:fs.root.getFile 和 fs.root.getDirectory
拿到“房间管理员”fs后,fs.root就代表这个房间的根目录。我们要在这个根目录下找(或创建)具体的文件或子文件夹。
getFile:用于获取或创建一个文件对象(FileEntry)。getDirectory:用于获取或创建一个目录对象(DirectoryEntry)。
它们都接受一个options参数,其中create: true是关键,意思是“如果不存在,就创建它”。我刚开始用的时候,老是忘记加这个参数,结果一直报“文件找不到”的错误,排查了半天。
第三个积木:FileEntry 和 FileWriter
当你通过getFile成功拿到一个FileEntry(文件条目对象)后,就可以对它进行读写。
fileEntry.file():获取文件的数据对象(File),主要用于读取。fileEntry.createWriter():获取一个FileWriter(文件写入器)对象,专门用于写入内容。
这里有个小坑要注意:读取和写入用的是两套不同的流程。读文件,是通过FileEntry拿到File对象,然后用FileReader去读。写文件,则是通过FileEntry拿到FileWriter对象,然后用它来写。别搞混了。
第四个积木:回调地狱与Promise化 你看官方示例或原始文章里的代码,是不是层层回调,看得人头大?像这样:
plus.io.requestFileSystem(..., function(fs) {
fs.root.getFile(..., function(fileEntry) {
fileEntry.createWriter(..., function(writer) {
writer.onwrite = function(e) { ... };
writer.write('内容');
}, errorCB);
}, errorCB);
}, errorCB);
这就是著名的“回调地狱”。在实战中,我们绝对不能用这种写法,否则代码维护起来会是噩梦。我们的首要任务,就是用Promise和async/await这把“梳子”,把这些嵌套的回调全部捋直,让代码变得清爽可读。这也是我们接下来封装的核心思想。
3. 从零开始封装:一个健壮的读写工具类
好了,理论铺垫完毕,我们动手封装一个真正能在项目里用的工具类。我会把每一步的思路和踩过的坑都讲清楚。
3.1 基础框架与错误处理
首先,我们创建一个单独的JS文件,比如fileManager.js。先搭建一个基础的、错误处理完善的Promise包装函数。
// fileManager.js
class FileManager {
constructor() {
// 可以在这里初始化一些默认配置,比如根目录类型
this.fsType = plus.io.PUBLIC_DOCUMENTS;
}
/**
* 通用方法:请求文件系统 (Promise封装)
* @returns {Promise} 返回一个Promise,成功时resolve文件系统对象fs
*/
requestFileSystem() {
return new Promise((resolve, reject) => {
plus.io.requestFileSystem(this.fsType, (fs) => {
resolve(fs);
}, (error) => {
console.error('[FileManager] 请求文件系统失败:', error.message);
reject(new Error(`请求文件系统失败: ${error.message}`));
});
});
}
/**
* 通用方法:获取文件Entry (Promise封装)
* @param {string} filePath - 相对于文件系统根目录的文件路径,如 'config/settings.json'
* @param {boolean} create - 如果文件不存在是否创建
* @returns {Promise} 成功时resolve FileEntry对象
*/
getFileEntry(filePath, create = false) {
return new Promise((resolve, reject) => {
this.requestFileSystem()
.then((fs) => {
fs.root.getFile(filePath, { create: create }, (fileEntry) => {
resolve(fileEntry);
}, (error) => {
console.error(`[FileManager] 获取文件Entry失败 [${filePath}]:`, error.message);
reject(new Error(`获取文件失败: ${error.message}`));
});
})
.catch(reject); // 将requestFileSystem的错误传递下去
});
}
}
export default new FileManager(); // 导出一个单例
你看,我们已经把两层回调(请求文件系统、获取文件)封装成了两个返回Promise的方法。这样在业务代码里,我们就可以用async/await来调用了,逻辑清晰得多。
3.2 实现核心的读取与写入功能
现在,基于上面的基础方法,我们来封装最常用的读和写。
读取文本文件(如JSON)
// 在FileManager类中添加方法
async readTextFile(filePath, encoding = 'utf-8') {
try {
const fileEntry = await this.getFileEntry(filePath, false); // 不创建,只读取已存在的
return new Promise((resolve, reject) => {
fileEntry.file((file) => {
const reader = new plus.io.FileReader();
reader.onloadend = (evt) => {
// 读取成功
resolve(evt.target.result);
};
reader.onerror = (e) => {
console.error(`[FileManager] 读取文件内容失败 [${filePath}]:`, reader.error);
reject(new Error(`读取文件失败: ${reader.error}`));
};
reader.readAsText(file, encoding);
}, (error) => {
reject(new Error(`获取文件数据对象失败: ${error.message}`));
});
});
} catch (error) {
// 统一捕获并向上抛出错误
throw error;
}
}
这个方法做了几件事:1. 获取文件Entry;2. 通过Entry拿到File对象;3. 用FileReader以文本形式读取。全部用Promise包装,外部调用时一句const content = await fileManager.readTextFile('data.json')就能搞定。
写入文本文件(覆盖或追加)
写入稍微复杂点,因为要处理FileWriter的异步事件,并且要考虑是覆盖写还是追加写。
async writeTextFile(filePath, data, seek = 0) {
// seek参数很关键:0表示从文件开头写(覆盖),-1或未提供表示追加到末尾
try {
const fileEntry = await this.getFileEntry(filePath, true); // 确保文件存在,不存在就创建
return new Promise((resolve, reject) => {
fileEntry.createWriter((writer) => {
writer.onwrite = (e) => {
// 写入成功回调
console.log(`[FileManager] 文件写入成功 [${filePath}]`);
resolve();
};
writer.onerror = (e) => {
console.error(`[FileManager] 文件写入失败 [${filePath}]:`, writer.error);
reject(new Error(`写入文件失败: ${writer.error.message}`));
};
// 根据seek参数定位写入位置
if (seek === 0) {
// 覆盖写:先截断文件长度为0,再从开头写
writer.truncate(0);
writer.seek(0);
} else {
// 追加写:定位到文件末尾
writer.seek(writer.length);
}
// 开始写入数据
writer.write(data);
}, (error) => {
reject(new Error(`创建文件写入器失败: ${error.message}`));
});
});
} catch (error) {
throw error;
}
}
这里有个非常重要的细节:writer.seek。它用来设置文件指针的位置。如果你想完全覆盖一个文件,需要先writer.truncate(0)清空文件,然后writer.seek(0)把指针挪到开头,再写入。如果是追加内容,就writer.seek(writer.length)把指针挪到文件末尾。原始文章里的changeData函数,seek参数传0就是这个道理。
3.3 处理JSON数据:读取与写入的实践
我们操作文件,很多时候是为了存JSON格式的配置或数据。所以,在基础读写之上,再封装一层专门处理JSON的方法会非常方便。
async readJsonFile(filePath) {
try {
const text = await this.readTextFile(filePath);
// 处理空文件的情况
if (!text || text.trim() === '') {
return null; // 或者返回一个默认空对象 {}
}
return JSON.parse(text);
} catch (error) {
// 如果文件不存在,readTextFile会报错,这里可以返回null或默认值
if (error.message.includes('未找到文件') || error.message.includes('NOT_FOUND_ERR')) {
console.warn(`[FileManager] JSON文件不存在,返回null [${filePath}]`);
return null;
}
console.error(`[FileManager] 解析JSON失败 [${filePath}]:`, error);
throw new Error(`解析JSON文件失败: ${error.message}`);
}
}
async writeJsonFile(filePath, jsonData, pretty = false) {
try {
// 将JSON对象序列化成字符串
const jsonString = pretty ? JSON.stringify(jsonData, null, 2) : JSON.stringify(jsonData);
// 调用基础的文本写入方法,从头覆盖写入
await this.writeTextFile(filePath, jsonString, 0);
console.log(`[FileManager] JSON数据已保存至 [${filePath}]`);
} catch (error) {
console.error(`[FileManager] 保存JSON文件失败 [${filePath}]:`, error);
throw error;
}
}
这样,在业务页面里,处理一个JSON配置文件就变得极其简单:
import fileManager from '@/utils/fileManager.js';
// 读取配置
const appConfig = await fileManager.readJsonFile('config/app.json');
if (!appConfig) {
// 文件不存在,使用默认配置并创建文件
const defaultConfig = { theme: 'light', fontSize: 14 };
await fileManager.writeJsonFile('config/app.json', defaultConfig, true); // pretty格式
}
// 更新配置
appConfig.theme = 'dark';
await fileManager.writeJsonFile('config/app.json', appConfig, true);
4. 进阶封装:目录操作与文件管理
只会读写单个文件还不够,一个健壮的文件管理模块还需要能处理目录。
4.1 创建多级目录
这是我从原始文章代码里提炼并优化的一个非常实用的功能。给定一个像 'logs/2023/10/25' 这样的路径,它能自动创建所有不存在的中间目录。
async ensureDirectory(dirPath) {
// 标准化路径,确保以'/'分隔,且不以'/'开头
const normalizedPath = dirPath.replace(/^\/+/, '').replace(/\/+$/, '');
const parts = normalizedPath.split('/');
let currentPath = '';
for (let i = 0; i < parts.length; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
try {
await this.createDirectory(currentPath);
} catch (error) {
// 如果目录已存在,忽略错误;其他错误则抛出
if (!error.message.includes('已存在')) {
throw error;
}
}
}
}
async createDirectory(dirName) {
return new Promise((resolve, reject) => {
this.requestFileSystem()
.then((fs) => {
fs.root.getDirectory(dirName, { create: true, exclusive: false }, (dirEntry) => {
resolve(dirEntry);
}, (error) => {
reject(new Error(`创建目录[${dirName}]失败: ${error.message}`));
});
})
.catch(reject);
});
}
ensureDirectory 方法非常有用。比如你想在 PUBLIC_DOCUMENTS 下保存用户上传的图片,路径是 uploads/images/avatar,直接调用 await fileManager.ensureDirectory('uploads/images/avatar'),就不用担心目录不存在了。
4.2 文件复制、移动与删除
基于 plus.io.resolveLocalFileSystemURL 这个API,我们可以实现更灵活的文件操作。它可以直接通过URL(比如 _doc/uploads/test.jpg)来获取文件或目录的Entry。
复制文件
async copyFile(srcUrl, destDir, newFileName = null) {
return new Promise((resolve, reject) => {
// 解析源文件
plus.io.resolveLocalFileSystemURL(srcUrl, async (srcEntry) => {
if (!srcEntry.isFile) {
reject(new Error('源路径不是一个文件'));
return;
}
// 解析或创建目标目录
plus.io.resolveLocalFileSystemURL(destDir, async (destDirEntry) => {
const fileName = newFileName || srcEntry.name;
// 执行复制
srcEntry.copyTo(destDirEntry, fileName, (newEntry) => {
console.log(`[FileManager] 文件复制成功: ${newEntry.fullPath}`);
resolve(newEntry.toURL()); // 返回新文件的URL
}, (error) => {
reject(new Error(`复制文件失败: ${error.message}`));
});
}, async (dirError) => {
// 目标目录不存在,尝试创建
console.warn(`目标目录不存在,尝试创建: ${destDir}`);
try {
// 这里需要先确保目标目录的父路径存在,可以调用上面封装的ensureDirectory
// 假设destDir是类似 `_doc/backups` 的路径
const dirPath = destDir.replace(/^_doc\//, ''); // 去掉前缀,得到相对路径
await this.ensureDirectory(dirPath);
// 再次尝试解析
plus.io.resolveLocalFileSystemURL(destDir, (destDirEntry) => {
// ... 复制逻辑同上
}, reject);
} catch (e) {
reject(e);
}
});
}, (error) => {
reject(new Error(`无法解析源文件路径: ${srcUrl}, 错误: ${error.message}`));
});
});
}
移动(重命名)文件
移动和复制的逻辑很像,只是把 copyTo 换成 moveTo。这个方法同时可以用来重命名文件(在同一目录下移动并指定新名字)。
删除文件或目录
删除操作相对简单,但要注意:删除目录时,如果目录非空,会失败。你可以用 removeRecursively 来递归删除整个目录树。
async remove(path) {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(path, (entry) => {
if (entry.isDirectory) {
entry.removeRecursively(resolve, (error) => reject(new Error(`删除目录失败: ${error.message}`)));
} else {
entry.remove(resolve, (error) => reject(new Error(`删除文件失败: ${error.message}`)));
}
}, (error) => reject(new Error(`解析路径失败: ${error.message}`)));
});
}
4.3 列出目录内容
有时候我们需要知道某个文件夹下有什么文件,比如做一个本地文件浏览器。
async listDirectory(dirPath) {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(dirPath, (dirEntry) => {
if (!dirEntry.isDirectory) {
reject(new Error('指定路径不是一个目录'));
return;
}
const directoryReader = dirEntry.createReader();
directoryReader.readEntries((entries) => {
const result = entries.map(entry => ({
name: entry.name,
isFile: entry.isFile,
isDirectory: entry.isDirectory,
fullPath: entry.fullPath,
toURL: entry.toURL()
}));
resolve(result);
}, (error) => reject(new Error(`读取目录列表失败: ${error.message}`)));
}, (error) => reject(new Error(`无法解析目录路径: ${dirPath}`)));
});
}
5. 在真实项目中集成与使用
封装得再好,最终还是要落地到项目里。我来分享几个实战中的集成技巧和常见问题。
5.1 在Vue页面中的优雅调用
首先,在你的工具类(如fileManager.js)最后,别忘了导出实例:
// fileManager.js 末尾
export default new FileManager();
然后在你的页面或组件中引入:
<script>
import fileManager from '@/common/utils/fileManager.js';
export default {
data() {
return {
userData: null,
config: {}
};
},
async onLoad() {
// 页面加载时,读取用户数据
await this.loadUserData();
},
methods: {
async loadUserData() {
try {
// 假设用户数据保存在 `user/profile.json`
this.userData = await fileManager.readJsonFile('user/profile.json');
if (!this.userData) {
// 首次使用,初始化数据
this.userData = { username: '新用户', level: 1 };
await fileManager.ensureDirectory('user'); // 确保目录存在
await fileManager.writeJsonFile('user/profile.json', this.userData, true);
}
uni.showToast({ title: '数据加载成功', icon: 'success' });
} catch (error) {
console.error('加载用户数据失败:', error);
uni.showToast({ title: '加载数据失败', icon: 'none' });
}
},
async saveFormData(formData) {
this.$tip.loading('保存中...'); // 显示加载提示
try {
// 将表单数据合并到全局或现有数据中
const allData = { ...this.userData, ...formData, lastUpdate: new Date().toISOString() };
// 保存回文件
await fileManager.writeJsonFile('user/profile.json', allData, true);
this.userData = allData;
uni.showToast({ title: '保存成功', icon: 'success' });
} catch (error) {
console.error('保存数据失败:', error);
uni.showToast({ title: '保存失败,请重试', icon: 'none' });
} finally {
this.$tip.loaded(); // 关闭加载提示
}
},
async exportData() {
// 示例:将数据复制到“下载”目录,方便用户找到
const sourcePath = '_doc/user/profile.json'; // 注意:这里是URL格式
const destDir = '_doc/Downloads/'; // 假设有个Downloads目录
try {
await fileManager.ensureDirectory('Downloads');
const newFilePath = await fileManager.copyFile(sourcePath, destDir, `profile_backup_${Date.now()}.json`);
uni.showToast({ title: `数据已导出至: ${newFilePath}`, icon: 'success' });
} catch (error) {
uni.showToast({ title: '导出失败', icon: 'none' });
}
}
}
};
</script>
5.2 路径处理的坑与最佳实践
这是新手最容易迷糊的地方。5+API里涉及到两种主要的路径表示法:
- 相对路径:比如
'config/settings.json'。这种路径是相对于你通过requestFileSystem申请的那个根目录(如PUBLIC_DOCUMENTS)的。我们封装的方法主要用这种。 - 本地URL路径:以
file://开头,或者5+特有的_doc/、_www/等前缀。比如'_doc/config/settings.json'或'file:///storage/emulated/0/Android/data/你的包名/documents/config/settings.json'。resolveLocalFileSystemURL方法主要用这种。
最佳实践建议:
- 内部管理用相对路径:在你的应用内部,需要自己管理的文件(如配置文件、缓存数据),统一使用相对路径。我们的封装方法就基于此。
- 用户文件用本地URL:当需要操作用户通过系统文件选择器选中的文件,或者要把文件保存到用户指定的公共目录(如下载目录)时,你会得到或需要使用本地URL路径。这时可以配合
resolveLocalFileSystemURL使用。 - 路径转换:
FileEntry对象有toURL()和toLocalURL()方法,可以在相对路径和本地URL之间转换。比如你通过相对路径创建了一个文件,想获取它的绝对路径给某个原生插件用,就可以fileEntry.toLocalURL()。
5.3 性能与异常处理要点
- 错误处理要彻底:文件操作失败的原因很多(权限不足、存储空间满、路径错误)。每个Promise的
catch块都要有合理的处理,至少要给用户一个友好的提示,而不是让应用白屏或卡死。 - 大文件操作要谨慎:读写大文件(比如几十MB的视频)时,要考虑内存和阻塞问题。读取大文件可以考虑分片(
File.slice),写入大文件也要注意不要一次性写入太多数据。 - 异步操作串行与并行:像
ensureDirectory这种创建多级目录的操作,步骤间有依赖,必须用await串行执行。而像批量读取多个不相关的小配置文件,可以用Promise.all()并行执行提升速度。 - iOS与Android的差异:虽然5+API做了统一,但底层权限模型略有不同。比如在Android上,访问
PUBLIC_DOCUMENTS以外的某些公共目录可能需要动态申请权限(WRITE_EXTERNAL_STORAGE)。如果你的应用有这类需求,一定要测试充分。
6. 封装成果与扩展思路
经过上面的步骤,我们得到了一个功能相对完整的文件管理工具类。它至少包含了:
readTextFile/writeTextFile: 基础文本读写readJsonFile/writeJsonFile: 便捷的JSON数据处理ensureDirectory: 创建多级目录copyFile/moveFile/remove: 文件管理listDirectory: 目录浏览
你可以根据项目需要继续扩展,比如:
- 图片/二进制文件处理:封装
readAsDataURL来读取图片为Base64,或者处理二进制数据。 - 文件搜索与过滤:在
listDirectory基础上,实现按名称、后缀名过滤文件。 - 存储空间检查:在写入前,检查剩余存储空间是否充足。
- 操作队列:对于可能并发的文件操作,引入一个简单的队列机制,防止冲突。
最后,我把这个工具类的核心部分整理成一个更清晰的表格,方便你快速回顾:
| 方法名 | 主要参数 | 返回值 | 用途说明 |
|---|---|---|---|
readTextFile | filePath(相对路径), encoding | Promise<string> | 读取文本文件内容 |
writeTextFile | filePath, data(字符串), seek | Promise<void> | 写入文本,seek=0覆盖,默认追加 |
readJsonFile | filePath | Promise<object | null> | 读取并解析JSON文件,文件不存在返null |
writeJsonFile | filePath, jsonData, pretty | Promise<void> | 将JSON对象写入文件,pretty控制格式化 |
ensureDirectory | dirPath(相对路径) | Promise<void> | 确保多级目录存在,不存在则创建 |
copyFile | srcUrl(URL), destDir(URL), newName | Promise<string>(新文件URL) | 复制文件到目标目录 |
remove | path(URL) | Promise<void> | 删除文件或空目录 |
把这个工具类放到你的项目里,下次再遇到需要离线存储、本地文件管理的需求时,你就不用再从头研究5+API那复杂的回调了,直接调用这几个简单的方法,省时省力。我自己在好几个需要复杂离线功能的生产项目里都用了类似的封装,稳定性非常好。记住,好的封装不是为了炫技,而是为了把复杂留给自己,把简单留给队友(和未来的自己)。
更多推荐



所有评论(0)