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里涉及到两种主要的路径表示法:

  1. 相对路径:比如 'config/settings.json'。这种路径是相对于你通过requestFileSystem申请的那个根目录(如PUBLIC_DOCUMENTS)的。我们封装的方法主要用这种。
  2. 本地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基础上,实现按名称、后缀名过滤文件。
  • 存储空间检查:在写入前,检查剩余存储空间是否充足。
  • 操作队列:对于可能并发的文件操作,引入一个简单的队列机制,防止冲突。

最后,我把这个工具类的核心部分整理成一个更清晰的表格,方便你快速回顾:

方法名主要参数返回值用途说明
readTextFilefilePath(相对路径), encodingPromise<string>读取文本文件内容
writeTextFilefilePath, data(字符串), seekPromise<void>写入文本,seek=0覆盖,默认追加
readJsonFilefilePathPromise<object | null>读取并解析JSON文件,文件不存在返null
writeJsonFilefilePath, jsonData, prettyPromise<void>将JSON对象写入文件,pretty控制格式化
ensureDirectorydirPath(相对路径)Promise<void>确保多级目录存在,不存在则创建
copyFilesrcUrl(URL), destDir(URL), newNamePromise<string>(新文件URL)复制文件到目标目录
removepath(URL)Promise<void>删除文件或空目录

把这个工具类放到你的项目里,下次再遇到需要离线存储、本地文件管理的需求时,你就不用再从头研究5+API那复杂的回调了,直接调用这几个简单的方法,省时省力。我自己在好几个需要复杂离线功能的生产项目里都用了类似的封装,稳定性非常好。记住,好的封装不是为了炫技,而是为了把复杂留给自己,把简单留给队友(和未来的自己)。

更多推荐