Remix file-storage 包完整演进与实现解析:从 LocalFileStorage 到 createFsFileStorage 的文件存储 API 迁移指南
【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix
@remix-run/file-storage是 Remix 生态中专用于服务端File对象的 key/value 存储库,为本地磁盘与内存提供统一的存储后端。本文以 packages/file-storage/CHANGELOG.md 的版本演进为骨架,结合当前仓库源码逐一拆解其 API 设计、分页列举、哈希分片与 LazyFile 流式读取等底层实现,并给出从旧版LocalFileStorage/MemoryFileStorage类到新版工厂函数createFsFileStorage()/createMemoryFileStorage()的完整迁移路径。读完本文,你将掌握该库全部核心操作(get/set/put/remove/has/list)的用法、参数语义、默认值以及版本升级时的破坏性变更清单。
一、包定位:面向服务端 File 对象的 key/value 存储
packages/file-storage/README.md 对该包的定位做了清晰描述:它提供面向服务端File对象的 key/value 存储接口,让 Remix 应用能够在本地磁盘与内存两种后端之间使用同一套 API。其核心特性包括:
- 简单 API:直观的 key/value 接口(类似 Web Storage,但存储的是
File而非字符串); - 多后端:内置文件系统后端与内存后端,另有独立的 file-storage-s3 提供 S3 后端;
- 流式支持:可从存储中流式读取与写入文件内容;
- 元数据保留:完整保留
file.name、file.type、file.size、file.lastModified等File元数据。
从 package.json 的exports字段可以看到当前包的三个公开入口:@remix-run/file-storage(类型定义)、@remix-run/file-storage/fs(文件系统后端)与@remix-run/file-storage/memory(内存后端),对应的源码文件分别为 src/index.ts、src/fs.ts 与 src/memory.ts。
二、当前 API 面貌:FileStorage 接口与两种后端
CHANGELOG.md 中 v0.13.0 是一次里程碑式的破坏性变更:LocalFileStorage类被createFsFileStorage(directory)工厂函数取代,MemoryFileStorage类被createMemoryFileStorage()工厂函数取代,随后 v0.13.5 又引入FileLike别名并让FileStorage接口对具体后端返回的File值类型泛型化。当前版本的完整接口定义位于 src/lib/file-storage.ts,包含六个方法:
| 方法 | 签名 | 语义 |
|---|---|---|
get | get(key): file \| null | 按 key 读取文件,不存在时返回null |
has | has(key): boolean | 判断某 key 是否存在 |
set | set(key, file): void | 将文件写入指定 key |
put | put(key, file): file | 写入并立即返回由该存储支撑的新文件(set+get的便捷组合) |
remove | remove(key): void | 删除指定 key 的文件 |
list | list(options?): ListResult | 按条件列举存储中的文件,支持分页 |
2.1 文件系统后端:createFsFileStorage
src/lib/backends/fs.ts 实现了createFsFileStorage(directory)。创建时会校验传入路径:
- 若路径已存在但不是目录,抛出
Path "..." is not a directory错误; - 若路径不存在,则递归创建目录(
fs.mkdirSync(rootDir, { recursive: true }))。
源码注释还强调了两个重要约定:
- 不做覆盖防护:实现“不会尝试避免覆盖已有文件”,因此传入的目录应是一个专门为本次存储对象新建、独占使用的目录;
- key 与磁盘文件名无关:key 可以是任意字符串(包括文件系统不允许的字符),多个同名
File也可以存进同一个存储对象——实际落盘路径由 key 的哈希决定,而非文件名。
一个完整的读写示例(来自 README.md):
import { createFsFileStorage } from 'remix/file-storage/fs' let storage = createFsFileStorage('./user/files') let file = new File(['hello world'], 'hello.txt', { type: 'text/plain' }) let key = 'hello-key' // 将文件写入存储 await storage.set(key, file) // 稍后读取 let fileFromStorage = await storage.get(key) if (fileFromStorage != null) { // 原文件元数据完整保留 fileFromStorage.name // 'hello.txt' fileFromStorage.type // 'text/plain' // 文件系统后端返回 LazyFile,可直接流式读取 let response = new Response(fileFromStorage.stream()) } // 从存储中删除 await storage.remove(key)2.2 内存后端:createMemoryFileStorage
src/lib/backends/memory.ts 用Map<string, File>实现同名接口。值得注意的实现细节:putFile在写入时会通过file.arrayBuffer()将内容缓冲为独立副本,再以new File([buffer], name, { lastModified, type })重建一个新File存入 Map——这既是 v0.6.0 中"缓冲 MemoryFileStorage 中文件内容"的延续,也保证了存入的文件不会因外部File的后续变更而受影响。
三、list():前缀过滤、元数据列举与游标分页
storage.list(options)是 v0.6.0 加入的核心能力,其options在 src/lib/file-storage.ts 中有精确定义,CHANGELOG.md v0.6.0 一节给出了完整的参数语义:
| 选项 | 类型 | 说明 |
|---|---|---|
cursor | string | 不透明的分页游标,用于在存储的 key 间翻页 |
includeMetadata | boolean | 为true时在结果中包含文件元数据 |
limit | number | 返回文件的最大数量 |
prefix | string | 只返回 key 以该字符串开头的文件 |
3.1 基础列举与元数据
不带任何选项时,result.files是{ key: string }对象的数组:
let result = await storage.list({ prefix: 'user123/' }) console.log(result.files) // [ // { key: "user123/..." }, // { key: "user123/..." }, // ... // ]传入includeMetadata: true后,每个条目扩展为完整的 FileMetadata(lastModified、name、size、type均为毫秒时间戳 / 文件名 / 字节数 / MIME 类型):
let result = await storage.list({ prefix: 'user123/', includeMetadata: true }) console.log(result.files) // [ // { // key: "user123/...", // lastModified: 1737955705270, // name: "hello.txt", // size: 16, // type: "text/plain" // }, // ... // ]3.2 游标分页
分页通过结果对象中的不透明cursor属性完成:若cursor不为undefined,说明还有更多文件,将其原样传回下次调用的options即可取得下一页。完整遍历整个存储的惯用写法:
let result = await storage.list() console.log(result.files) while (result.cursor !== undefined) { result = await storage.list({ cursor: result.cursor }) console.log(result.files) }limit用于控制每次返回的条数。两个后端的默认值有所不同(来自源码):
- 文件系统后端 fs.ts:
limit默认32; - 内存后端 memory.ts:
limit默认Infinity。
fs.test.ts 的lists files with pagination用例验证了完整分页闭环:limit: 2返回 2 条并给出非空游标,用该游标继续list取回剩余 3 条,两次结果合并后与全部 5 个 key 完全一致;limit: 0则返回空数组且无游标。lists files by key prefix(fs.test.ts)验证了prefix: 'b'只返回b与b/c两个 key。
四、底层实现原理:哈希分片、元数据文件与 LazyFile
4.1 SHA-256 哈希与两级目录分片
文件系统后端在落盘前会对 key 计算哈希(fs.ts 的computeHash,默认SHA-256),并以哈希的前 2 个十六进制字符作为子目录名、完整哈希作为文件名基础:
- 文件本体:
<rootDir>/<hash前2位>/<hash>.dat - 元数据:
<rootDir>/<hash前2位>/<hash>.meta.json
async function getPaths(key: string) { let hash = await computeHash(key) let directory = path.join(rootDir, hash.slice(0, 2)) return { directory, filePath: path.join(directory, `${hash}.dat`), metaPath: path.join(directory, `${hash}.meta.json`), } }这正是 CHANGELOG 中两条演进的核心内容:
- v0.4.0:引入"分片存储目录"(shards storage directories),将文件分散到多个子目录以提升文件系统扩展性,并修复了并发
set的竞态问题; - v0.6.0:分片目录名从8 个字符缩减为 2 个字符(BREAKING CHANGE),在可扩展性与目录数量之间取得平衡——2 位十六进制最多 256 个分片目录;
- v0.9.0:
remove删除文件后,若所在分片目录已空则一并移除(fs.ts 中readdir判空后rmdir),fs.test.ts 的removes empty hash directories after removing files用例专门覆盖此行为。
4.2 元数据持久化与文件大小入元数据
写入时(putFile),后端会将key、lastModified、name、size、type序列化为 JSON 写入.meta.json:
let meta: FileMetadata = { key, lastModified: file.lastModified, name: file.name, size: file.size, type: file.type, } await fsp.writeFile(metaPath, JSON.stringify(meta))v0.13.5的变更点是:文件系统存储改为把文件大小持久化进元数据,而不是在带元数据列举时另行推导 size——fs.test.ts 的stores file size in metadata用例直接读取磁盘上的.meta.json断言size字段与源文件一致,验证了这一点。
4.3 返回 LazyFile,支持流式读取
get与put返回的并不是内存中的普通File,而是通过openLazyFile(filePath, { lastModified, name, type })(来自@remix-run/fs)打开的LazyFile(见 fs.ts)。LazyFile 是 lazy-file 提供的流式File实现,内容按需从磁盘流式读取,因此可以直接:
let response = new Response(fileFromStorage.stream())而不会先把整个文件读入内存。这正是 README "Streaming Support" 特性的底层支撑,也是 v0.13.1 起改用@remix-run/fs的openLazyFile()新 API、v0.12.0 将@remix-run/fs引入依赖关系的原因。
4.4 旧文件自动清理
v0.2.1起,LocalFileStorage在向同一 key 写入新文件时会自动清理旧文件。当前实现中,remove采用Promise.all([unlink(filePath), unlink(metaPath)])同时删除.dat与.meta.json,并以isNoEntityError(ENOENT)容错,保证对不存在文件的删除不会抛错。
五、put():写入后立即取得可读文件
v0.5.0新增storage.put(key, file),作为set(key, file)+get(key)这一高频组合的便捷封装。使用前后对照:
// 之前 await storage.set(key, file) let newFile = await storage.get(key)! // 之后 let newFile = await storage.put(key, file)对文件系统后端而言,put内部走同一套putFile流程:写文件 → 写元数据 →openLazyFile返回可直接流式读取的LazyFile(fs.ts);内存后端则返回缓冲后的新File(memory.ts)。fs.test.ts 的puts files用例验证了put返回文件在name、type、lastModified、size上与源文件一致,且has(key)立即为真。
六、版本演进时间线:从 v0.1.0 到 v0.13.7
综合 CHANGELOG.md 全部条目,可梳理出该包从 2024-08 到 2025-11 的关键演进:
| 版本 | 日期 | 关键变更 | 类型 |
|---|---|---|---|
| v0.1.0 | 2024-08-24 | 初始发布 | — |
| v0.2.0 | 2024-08-26 | LocalFileStorage/MemoryFileStorage分别移到file-storage/local、file-storage/memory导出 | 破坏性 |
| v0.2.1 | 2024-09-04 | 同 key 写入新文件时自动清理旧文件 | 修复 |
| v0.3.0 | 2024-11-14 | 新增 CommonJS 构建;升级 lazy-file@3.1.0 | 增强 |
| v0.4.0 | 2025-01-08 | 修复并发set竞态;存储目录分片 | 增强/修复 |
| v0.4.1 | 2025-01-10 | 修复 npm 包中file-storage/local类型缺失 | 修复 |
| v0.5.0 | 2025-01-25 | 新增storage.put(key, file) | 增强 |
| v0.6.0 | 2025-02-04 | 分片目录名 8 字符改 2 字符;内存后端缓冲文件内容;新增storage.list(options) | 破坏性 |
| v0.6.1 | 2025-02-06 | 修复与form-data-parser配合使用的回归 | 修复 |
| v0.7.0 | 2025-06-10 | 将/src打入 npm 包("go to definition" 直达源码);统一类型;esbuild 直接构建 | 增强 |
| v0.8.0 | 2025-07-21 | 包名从@mjackson/file-storage改名为@remix-run/file-storage | 破坏性 |
| v0.9.0 | 2025-07-25 | LocalFileStorage删除文件后移除空的分片目录 | 增强 |
| v0.10.0 | 2025-10-22 | 移除 CommonJS 构建,仅保留 ESM | 破坏性 |
| v0.11.0 | 2025-11-05 | @remix-run/lazy-file移入peerDependencies;改用tsc构建,dist目录镜像src布局 | 构建 |
| v0.12.0 | 2025-11-20 | 新增@remix-run/fspeer dependency,改从@remix-run/fs导入 | 依赖 |
| v0.13.0 | 2025-11-25 | 类改工厂函数(见下节) | 破坏性 |
| v0.13.1 | — | 升级@remix-run/fspeer 依赖,使用新openLazyFile()API | 依赖 |
| v0.13.2 | — | @remix-run/*peer 依赖改为普通依赖 | 依赖 |
| v0.13.3 ~ v0.13.4 | — | 滚动升级fs与lazy-file依赖 | 依赖 |
| v0.13.5 | — | 新增FileLike别名;FileStorage泛型化;createFsFileStorage()暴露LazyFile返回类型;文件大小持久化进元数据 | 增强 |
| v0.13.6 ~ v0.13.7 | — | 滚动升级fs与lazy-file依赖 | 依赖 |
七、v0.13.0 迁移指南:类改为工厂函数
v0.13.0 是迁移成本最高的一次破坏性变更,CHANGELOG.md 给出了标准的前后对照。变更前:
import { LocalFileStorage } from '@remix-run/file-storage/local' import { MemoryFileStorage } from '@remix-run/file-storage/memory' let fsStorage = new LocalFileStorage('./files') let memoryStorage = new MemoryFileStorage()变更后:
import { createFsFileStorage } from '@remix-run/file-storage/fs' import { createMemoryFileStorage } from '@remix-run/file-storage/memory' let fsStorage = createFsFileStorage('./files') let memoryStorage = createMemoryFileStorage()注意两点变化:
- 导入路径变化:
file-storage/local变为file-storage/fs(同时入口也从旧版导出路径迁移为 package.json 中声明的./fs、./memory子路径); - 实例化方式变化:
new关键字不再使用,改由工厂函数直接返回存储对象;调用点不再需要new,其余方法签名不变,因此业务代码中set/get/list/remove的调用无需改动。
在 v0.13.5 之后,工厂函数还有类型层面的收益:createFsFileStorage()返回FileStorage<LazyFile>,createMemoryFileStorage()返回FileStorage<File>,编译器能在调用get()/put()时就明确你拿到的具体文件类型。fs.test.ts 顶部就有一段"编译期 API 契约检查",断言原生File与LazyFile均满足FileLike,且createFsFileStorage的返回类型满足FileStorage<LazyFile>——一旦 API 契约漂移,TypeScript 会直接让测试文件编译失败。
八、工程化与依赖演进:ESM-only、tsc 构建与依赖策略
CHANGELOG 中还有一组不改变 API 但影响使用方式的工程化变更,升级时同样需要留意:
- v0.10.0 起仅支持 ESM:CommonJS 构建被移除。若项目仍处于 CommonJS 环境,需要使用动态
import()引入该包; - v0.7.0 起 npm 包包含
/src:类型定义与源码布局一致,IDE 的 "go to definition" 可以直接跳到真实源码,便于阅读与调试; - v0.11.0 起用
tsc构建:dist目录镜像src目录布局,替代此前 esbuild/tsup 的扁平化输出,模块间相对路径在构建后保持一致; - 依赖策略的两次转向:v0.11.0 将
@remix-run/lazy-file移入 peerDependencies,v0.12.0 新增@remix-run/fspeer dependency,而v0.13.2 又将@remix-run/*peer 依赖改回普通 dependencies(见 package.json:@remix-run/fs与@remix-run/lazy-file均为直接依赖)——这意味着安装@remix-run/file-storage时会自动带上这两个依赖,无需手动安装 peer 依赖。
九、生态协作:与 form-data-parser、lazy-file、S3 的关系
README.md 列出了三个关联包,构成完整的文件上传-存储链路:
- form-data-parser:解析
multipart/form-data请求中的FileUpload,与 file-storage 配合即可"边解析边入库"。v0.6.1 修复的正是二者配合时的回归问题;fs.test.ts 的集成用例演示了完整流程:parseFormData(request, async (file) => { await storage.set('hello', file) })之后,storage.list({ includeMetadata: true })能取回 key、文件名、大小、MIME 类型与时间戳全部元数据; - lazy-file:file-storage 内部使用的流式
File实现,文件系统后端的get/put返回的就是LazyFile; - file-storage-s3:同一
FileStorage接口的 S3 后端,业务代码可在本地磁盘、内存与 S3 之间无痛切换。
十、总结与升级建议
@remix-run/file-storage的演进史本质上是一个"API 从类到工厂、后端从单一到可插拔、底层从整读整写到流式分片"的收敛过程。对使用方而言,当前版本最重要的三点结论:
- 只用工厂函数创建存储:文件系统用
createFsFileStorage('./dir'),内存用createMemoryFileStorage(),二者共用同一FileStorage接口(src/lib/file-storage.ts); - 利用 list() 的能力:
prefix做目录式筛选、includeMetadata拿完整元数据、cursor+limit做分页,文件系统后端默认每页 32 条; - 升级时对照破坏性清单:v0.13.0(类→工厂)、v0.10.0(ESM-only)、v0.8.0(包改名)、v0.6.0(分片目录 2 字符 + list API)、v0.2.0(local/memory 子路径导出)——若从 0.x 早期版本直升,需要一次性处理这些迁移点,而 CHANGELOG.md 本身即是最完整的迁移手册。
【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考