fs-extra 是 Node.js 原生 fs 模块的增强版,提供了更安全的文件操作方法和额外功能(如递归删除、确保目录存在)。memfs 和 memory-fs 提供了内存中的文件系统实现,常用于构建工具或沙箱环境,其中 memfs 是现代替代品。mock-fs 专注于单元测试,用于模拟 fs 模块的行为以隔离测试环境。这四个包分别解决了生产文件操作、运行时内存文件系统以及测试隔离的不同需求。
在 Node.js 开发生态中,处理文件系统(File System)是常见需求。无论是构建工具、服务器端渲染,还是单元测试,我们都需要可靠的方式来读写文件。fs-extra、memfs、memory-fs 和 mock-fs 是四个常被提及的包,但它们的目标场景截然不同。本文将从架构设计、API 特性及适用场景进行深度剖析。
这四个包虽然都涉及“文件系统”,但解决的问题域不同。
fs-extra 是原生 fs 的超集。
// fs-extra: 确保目录存在并写入文件
const fs = require('fs-extra');
async function writeConfig() {
// 如果 /tmp/config 不存在,会自动创建,不会报错
await fs.outputFile('/tmp/config/app.json', '{"theme":"dark"}');
}
memfs 是运行时的内存文件系统。
fs 模块,适合沙箱或构建工具。// memfs: 创建内存卷并写入
const { Volume } = require('memfs');
const vol = new Volume();
vol.fromJSON({ '/app/config.json': '{"debug":true}' });
// 可以像普通 fs 一样使用,但操作的是内存
const data = vol.readFileSync('/app/config.json', 'utf8');
memory-fs 是旧版的内存文件系统。
memfs 类似,但架构较老。// memory-fs: 实例化并写入(旧式 API)
const MemoryFileSystem = require('memory-fs');
const fs = new MemoryFileSystem();
fs.mkdirpSync('/tmp');
fs.writeFileSync('/tmp/file.txt', 'Hello');
// 读取文件内容
const content = fs.readFileSync('/tmp/file.txt', 'utf8');
mock-fs 是测试专用的模拟工具。
fs 的调用,返回虚构的数据。fs,防止污染环境。// mock-fs: 在测试中模拟文件结构
const mock = require('mock-fs');
mock({
'/fake/dir': {
'file.txt': 'file content here'
}
});
// 代码以为在读写真实磁盘,实际是在内存模拟中
const data = require('fs').readFileSync('/fake/dir/file.txt');
// 测试完后必须恢复
mock.restore();
在架构选型中,库的维护状态至关重要。
fs-extra:🟢 活跃维护。社区标准,广泛用于生产环境,API 稳定。memfs:🟢 活跃维护。现代内存文件系统首选,支持 Node.js 新特性。memory-fs:🔴 遗留/不推荐。GitHub 和 npm 上已无明显活跃维护,Webpack 5 已移除对其的依赖。新项目应避免使用。mock-fs:🟡 维护中。主要用于测试,但在复杂场景下(如与其他 mock 库混用)可能不稳定,部分团队转向使用 memfs 进行测试模拟。处理嵌套目录是常见痛点。原生 fs 在旧版本中不支持递归创建或删除,而这些库提供了不同解决方案。
fs-extra 提供了最直观的递归方法。
// fs-extra: 一键递归创建或删除
await fs.ensureDir('/tmp/complex/nested/path');
await fs.remove('/tmp/complex'); // 递归删除非空目录
memfs 在内存卷上支持类似操作,但需通过 Volume 实例调用。
// memfs: 内存卷上的递归操作
const vol = require('memfs').Volume.fromJSON({});
vol.mkdirSync('/tmp/complex/nested/path', { recursive: true });
vol.rmdirSync('/tmp/complex', { recursive: true });
memory-fs 也支持,但 API 风格较旧。
// memory-fs: 旧式递归创建
const fs = new (require('memory-fs'))();
fs.mkdirpSync('/tmp/complex/nested/path');
mock-fs 通过配置对象定义结构,隐式支持目录树。
// mock-fs: 通过对象结构定义目录树
mock({
'/tmp': {
'complex': {
'nested': {
'path': {} // 空目录
}
}
}
});
现代 Node.js 开发高度依赖 async/await。
fs-extra 原生支持 Promise。
// fs-extra: 直接返回 Promise
try {
await fs.copy('/src/file.txt', '/dest/file.txt');
} catch (err) {
console.error(err);
}
memfs 同样支持 Promise API。
// memfs: 支持 async/await
const vol = new (require('memfs')).Volume();
await vol.promises.writeFile('/test.txt', 'data');
memory-fs 主要是同步或回调风格,Promise 支持较弱或需包装。
// memory-fs: 主要是同步方法或回调
fs.writeFile('/test.txt', 'data', (err) => {
if (err) throw err;
});
mock-fs 模拟的是原生 fs,因此继承原生 fs 的 Promise 支持(Node.js 10+)。
// mock-fs: 依赖原生 fs.promises
const fs = require('fs').promises;
await fs.writeFile('/mocked/file.txt', 'data');
在单元测试中,我们通常不希望测试代码污染开发者的真实磁盘。
mock-fs 是专门为测试设计的。
require('fs') 实现的,可能与某些深层依赖原生绑定的库冲突。// mock-fs 测试示例
describe('File Processor', () => {
beforeEach(() => {
mock({ 'data.json': '{"valid":true}' });
});
afterEach(() => {
mock.restore();
});
it('should read mocked file', async () => {
const result = await processFile('data.json');
expect(result.valid).toBe(true);
});
});
memfs 也可以用于测试,提供更底层的控制。
// memfs 测试示例
describe('File Processor with Memfs', () => {
let vol;
beforeEach(() => {
vol = require('memfs').Volume.fromJSON({ 'data.json': '{"valid":true}' });
});
it('should read from volume', () => {
const content = vol.readFileSync('data.json', 'utf8');
expect(JSON.parse(content).valid).toBe(true);
});
});
fs-extra 不适合纯单元测试隔离,因为它操作真实磁盘。
tmp)使用,而不是单独用于模拟。// fs-extra 配合临时目录(非模拟,是真实写入)
const tmp = require('tmp');
const fs = require('fs-extra');
const dir = tmp.dirSync();
await fs.writeFile(dir.name + '/test.txt', 'data');
// 测试完需手动清理
dir.removeCallback();
memory-fs 由于维护状态不佳,不推荐用于新测试套件。
memfs 和 memory-fs 都在内存中运行,速度极快,但受限于服务器内存。
memfs 对内存管理做了优化,支持更大的文件树。fs-extra 和 mock-fs(模拟时)涉及磁盘 I/O 或模拟开销。
fs-extra 性能取决于磁盘速度。mock-fs 在大量文件模拟时可能会增加测试启动时间。| 特性 | fs-extra | memfs | memory-fs | mock-fs |
|---|---|---|---|---|
| 主要用途 | 生产环境文件操作 | 运行时内存文件系统 | 遗留内存文件系统 | 单元测试模拟 |
| 存储介质 | 真实磁盘 | 内存 | 内存 | 内存(模拟) |
| 维护状态 | 🟢 活跃 | 🟢 活跃 | 🔴 遗留/弃用 | 🟡 维护中 |
| Promise 支持 | ✅ 原生支持 | ✅ 支持 | ⚠️ 有限 | ✅ 继承原生 |
| 递归操作 | ✅ 便捷方法 | ✅ 支持 | ✅ 支持 | ✅ 配置定义 |
| 推荐场景 | 后端服务、CLI 工具 | 构建工具、沙箱 | 旧项目维护 | 单元测试 |
1. 生产环境文件操作:首选 fs-extra
不要重复造轮子。处理上传、日志轮转、配置读写时,fs-extra 的 ensureDir 和 move 能避免大量边界条件错误。它是 Node.js 后端开发的“标准库”之一。
2. 构建工具与沙箱:首选 memfs
如果你正在开发类似 Webpack、Vite 的构建工具,或者需要在无磁盘环境(如某些 Serverless 环境或浏览器端 Node 模拟)运行代码,memfs 是唯一现代化的选择。它可以挂载到 Node 的全局 fs,透明地替换底层实现。
3. 单元测试:mock-fs 或 memfs
对于简单的文件读取测试,mock-fs 设置最快。但如果你的测试涉及复杂的文件流或需要更底层的控制,memfs 提供更稳定的行为。避免在测试中使用真实磁盘,除非测试的就是磁盘 I/O 性能。
4. 避坑指南:远离 memory-fs
除非你在维护一个 5 年前的 Webpack 配置,否则不要在新项目中引入 memory-fs。它的 API 设计已过时,且缺乏对新 Node.js 版本特性的支持。迁移到 memfs 通常只需少量代码调整。
尽管用途不同,这些库在生态中常配合使用:
fs 接口所有库都尽量保持与原生 fs 模块的 API 一致性,降低学习成本。
// 所有库都支持类似的读取签名
fs.readFile(path, options, callback);
// 或 Promise
await fs.readFile(path, options);
处理大文件时,流是关键。fs-extra 和 memfs 都支持流式读写。
// fs-extra: 创建读取流
const readStream = fs.createReadStream('large-file.log');
// memfs: 内存卷创建流
const vol = new (require('memfs')).Volume();
const readStream = vol.createReadStream('/large-file.log');
都遵循 Node.js 标准错误优先回调或 Promise 拒绝模式。
// 统一的错误捕获模式
try {
await fs.access('/protected/file');
} catch (err) {
if (err.code === 'ENOENT') {
// 处理文件不存在
}
}
在 Node.js 架构设计中,文件系统抽象层的选择直接影响系统的可测试性和运行效率。
fs-extra 是生产力工具,让磁盘操作更安全、更简单。memfs 是架构工具,让文件系统在内存中运行,解耦硬件依赖。mock-fs 是测试工具,确保单元测试的纯净与隔离。memory-fs 是历史包袱,应尽快从依赖中移除。根据具体场景组合使用这些工具,例如用 fs-extra 处理生产日志,用 memfs 运行构建管道,用 mock-fs 验证配置加载逻辑,是构建健壮 Node.js 应用的最佳实践。
选择 mock-fs 如果你主要需要在单元测试中模拟文件系统行为,而不想实际写入磁盘。它允许你定义虚构的文件结构,测试代码对 fs 的调用,适合隔离测试用例,但注意它可能与其他依赖 fs 的库冲突。
选择 fs-extra 如果你在生产环境中需要更稳健的文件操作,例如递归创建目录、移动文件或复制文件夹。它是行业标准,API 与原生 fs 高度兼容,适合需要额外安全保障和便捷方法的服务器端应用。
选择 memfs 如果你需要在运行时拥有一个完全在内存中的文件系统,例如在浏览器中运行 Node 代码、构建工具插件或需要快速读写的临时沙箱。它是 memory-fs 的现代维护版本,支持挂载到 Node 的 fs 模块。
不建议在新项目中使用 memory-fs。它已被视为遗留库,维护频率低,且 Webpack 等主流工具已转向 memfs。仅在维护依赖此库的旧版构建配置时考虑使用,否则应迁移到 memfs。
mock-fsThe mock-fs module allows Node's built-in fs module to be backed temporarily by an in-memory, mock file system. This lets you run tests against a set of mock files and directories instead of lugging around a bunch of test fixtures.
The code below makes it so the fs module is temporarily backed by a mock file system with a few files and directories.
const mock = require('mock-fs');
mock({
'path/to/fake/dir': {
'some-file.txt': 'file content here',
'empty-dir': {/** empty directory */}
},
'path/to/some.png': Buffer.from([8, 6, 7, 5, 3, 0, 9]),
'some/other/path': {/** another empty directory */}
});
When you are ready to restore the fs module (so that it is backed by your real file system), call mock.restore(). Note that calling this may be mandatory in some cases. See istanbuljs/nyc#324
// after a test runs
mock.restore();
Instead of overriding all methods of the built-in fs module, the library now overrides process.binding('fs'). The purpose of this change is to avoid conflicts with other libraries that override fs methods (e.g. graceful-fs) and to make it possible to work with multiple Node releases without maintaining copied and slightly modified versions of Node's fs module.
Breaking changes:
mock.fs() function has been removed. This returned an object with fs-like methods without overriding the built-in fs module.fs.Stats is no longer an instance of fs.Stats (though it has all the same properties and methods).require() do not use the real filesystem.Some of these breaking changes may be restored in a future release.
mock(config, options)Configure the fs module so it is backed by an in-memory file system.
Calling mock sets up a mock file system with two directories by default: process.cwd() and os.tmpdir() (or os.tmpDir() for older Node). When called with no arguments, just these two directories are created. When called with a config object, additional files, directories, and symlinks are created. To avoid creating a directory for process.cwd() and os.tmpdir(), see the options below.
Property names of the config object are interpreted as relative paths to resources (relative from process.cwd()). Property values of the config object are interpreted as content or configuration for the generated resources.
Note that paths should always use forward slashes (/) - even on Windows.
optionsThe second (optional) argument may include the properties below.
createCwd - boolean Create a directory for process.cwd(). This is true by default.createTmp - boolean Create a directory for os.tmpdir(). This is true by default.You can load real files and directories into the mock system using mock.load()
{lazy: false} option| Option | Type | Default | Description |
|---|---|---|---|
| lazy | boolean | true | File content isn't loaded until explicitly read |
| recursive | boolean | true | Load all files and directories recursively |
mock.load(path, options)mock({
// Lazy-load file
'my-file.txt': mock.load(path.resolve(__dirname, 'assets/special-file.txt')),
// Pre-load js file
'ready.js': mock.load(path.resolve(__dirname, 'scripts/ready.js'), {lazy: false}),
// Recursively loads all node_modules
'node_modules': mock.load(path.resolve(__dirname, '../node_modules')),
// Creates a directory named /tmp with only the files in /tmp/special_tmp_files (no subdirectories), pre-loading all content
'/tmp': mock.load('/tmp/special_tmp_files', {recursive: false, lazy:false}),
'fakefile.txt': 'content here'
});
When config property values are a string or Buffer, a file is created with the provided content. For example, the following configuration creates a single file with string content (in addition to the two default directories).
mock({
'path/to/file.txt': 'file content here'
});
To create a file with additional properties (owner, permissions, atime, etc.), use the mock.file() function described below.
mock.file(properties)Create a factory for new files. Supported properties:
string|Buffer File contents.number File mode (permission and sticky bits). Defaults to 0666.number The user id. Defaults to process.getuid().number The group id. Defaults to process.getgid().Date The last file access time. Defaults to new Date(). Updated when file contents are accessed.Date The last file change time. Defaults to new Date(). Updated when file owner or permissions change.Date The last file modification time. Defaults to new Date(). Updated when file contents change.Date The time of file creation. Defaults to new Date().To create a mock filesystem with a very old file named foo, you could do something like this:
mock({
foo: mock.file({
content: 'file content here',
ctime: new Date(1),
mtime: new Date(1)
})
});
Note that if you want to create a file with the default properties, you can provide a string or Buffer directly instead of calling mock.file().
When config property values are an Object, a directory is created. The structure of the object is the same as the config object itself. So an empty directory can be created with a simple object literal ({}). The following configuration creates a directory containing two files (in addition to the two default directories):
// note that this could also be written as
// mock({'path/to/dir': { /** config */ }})
mock({
path: {
to: {
dir: {
file1: 'text content',
file2: Buffer.from([1, 2, 3, 4])
}
}
}
});
To create a directory with additional properties (owner, permissions, atime, etc.), use the mock.directory() function described below.
mock.directory(properties)Create a factory for new directories. Supported properties:
number Directory mode (permission and sticky bits). Defaults to 0777.number The user id. Defaults to process.getuid().number The group id. Defaults to process.getgid().Date The last directory access time. Defaults to new Date().Date The last directory change time. Defaults to new Date(). Updated when owner or permissions change.Date The last directory modification time. Defaults to new Date(). Updated when an item is added, removed, or renamed.Date The time of directory creation. Defaults to new Date().Object Directory contents. Members will generate additional files, directories, or symlinks.To create a mock filesystem with a directory with the relative path some/dir that has a mode of 0755 and two child files, you could do something like this:
mock({
'some/dir': mock.directory({
mode: 0755,
items: {
file1: 'file one content',
file2: Buffer.from([8, 6, 7, 5, 3, 0, 9])
}
})
});
Note that if you want to create a directory with the default properties, you can provide an Object directly instead of calling mock.directory().
Using a string or a Buffer is a shortcut for creating files with default properties. Using an Object is a shortcut for creating a directory with default properties. There is no shortcut for creating symlinks. To create a symlink, you need to call the mock.symlink() function described below.
mock.symlink(properties)Create a factory for new symlinks. Supported properties:
string Path to the source (required).number Symlink mode (permission and sticky bits). Defaults to 0666.number The user id. Defaults to process.getuid().number The group id. Defaults to process.getgid().Date The last symlink access time. Defaults to new Date().Date The last symlink change time. Defaults to new Date().Date The last symlink modification time. Defaults to new Date().Date The time of symlink creation. Defaults to new Date().To create a mock filesystem with a file and a symlink, you could do something like this:
mock({
'some/dir': {
'regular-file': 'file contents',
'a-symlink': mock.symlink({
path: 'regular-file'
})
}
});
mock.restore()Restore the fs binding to the real file system. This undoes the effect of calling mock(). Typically, you would set up a mock file system before running a test and restore the original after. Using a test runner with beforeEach and afterEach hooks, this might look like the following:
beforeEach(function() {
mock({
'fake-file': 'file contents'
});
});
afterEach(mock.restore);
mock.bypass(fn)Execute calls to the real filesystem with mock.bypass()
// This file exists only on the real FS, not on the mocked FS
const realFilePath = '/path/to/real/file.txt';
const myData = mock.bypass(() => fs.readFileSync(realFilePath, 'utf-8'));
If you pass an asynchronous function or a promise-returning function to bypass(), a promise will be returned.
Asynchronous calls are supported, however, they are not recommended as they could produce unintended consequences if anything else tries to access the mocked filesystem before they've completed.
async function getFileInfo(fileName) {
return await mock.bypass(async () => {
const stats = await fs.promises.stat(fileName);
const data = await fs.promises.readFile(fileName);
return {stats, data};
});
}
Using npm:
npm install mock-fs --save-dev
When you require mock-fs, Node's own fs module is patched to allow the binding to the underlying file system to be swapped out. If you require mock-fs before any other modules that modify fs (e.g. graceful-fs), the mock should behave as expected.
Note mock-fs is not compatible with graceful-fs@3.x but works with graceful-fs@4.x.
Mock file access is controlled based on file mode where process.getuid() and process.getgid() are available (POSIX systems). On other systems (e.g. Windows) the file mode has no effect.
Tested on Linux, OSX, and Windows using Node 18 through 22. Check the tickets for a list of known issues.
.toMatchSnapshot in Jest uses fs to load existing snapshots.
If mockFs is active, Jest isn't able to load existing snapshots. In such case it accepts all snapshots
without diffing the old ones, which breaks the concept of snapshot testing.
Calling mock.restore() in afterEach is too late and it's necessary to call it before snapshot matching:
const actual = testedFunction()
mock.restore()
expect(actual).toMatchSnapshot()
Note: it's safe to call mock.restore multiple times, so it can still be called in afterEach and then manually
in test cases which use snapshot testing.