Node.js path 与 fs 练习参考答案(1~13)
对应题目:path-fs-exercises.md。示例面向 Node.js 22+、TypeScript 和 CommonJS 编译输出。为突出题目重点,各段代码可独立整理进模块;生产代码还应加入日志、授权、资源上限和测试。
通用辅助类型
function isErrno(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
第一部分:path 基础
1. 拼接上传文件路径
import * as path from "node:path";
function getUploadPath(fileName: string): string {
return path.join(process.cwd(), "uploads", fileName);
}
Windows 结果类似 D:\app\uploads\avatar.png,Linux 类似 /srv/app/uploads/avatar.png。这题只演示拼接;真实用户文件名还要按第 19~22 题处理。
2. 获取文件基本信息
const input = "/var/app/uploads/2026/avatar.png";
const parsed = path.posix.parse(input);
const result = {
fileName: path.posix.basename(input), // avatar.png
name: path.posix.basename(input, path.posix.extname(input)), // avatar
extension: path.posix.extname(input), // .png
directory: path.posix.dirname(input), // /var/app/uploads/2026
root: parsed.root, // /
};
输入明确是 POSIX 路径,所以使用 path.posix,避免运行平台改变解析结果。
3. 规范化路径
function normalizeInput(input: string): string {
return path.normalize(input);
}
结果通常为 uploads/temp/avatar.png(分隔符取决于平台)。normalize() 只做语法折叠;它不限制根目录、不处理授权,也不能防止符号链接把路径带出允许目录。
4. 相对路径与绝对路径
const project = "/home/node/app";
const file = "/home/node/app/uploads/avatar.png";
path.posix.relative(project, file); // uploads/avatar.png
path.posix.resolve(project, "uploads/avatar.png"); // /home/node/app/uploads/avatar.png
join() 拼接并规范化全部片段;resolve() 从右向左解析,遇到绝对片段后丢弃其左侧片段,并总是返回绝对路径:
path.posix.join("/app", "/tmp/a.txt"); // /app/tmp/a.txt
path.posix.resolve("/app", "/tmp/a.txt"); // /tmp/a.txt
5. 初步判断路径风格
type PathStyle = "windows" | "posix" | "unknown";
function detectPathStyle(input: string): PathStyle {
if (/^[A-Za-z]:[\\/]/.test(input) || /^\\\\[^\\]+\\[^\\]+/.test(input)) {
return "windows";
}
if (input.startsWith("/")) return "posix";
return "unknown";
}
C:\...、C:/... 和 UNC 路径属于 Windows;/home/... 属于 POSIX。src/index.ts 和 README.md 都是相对路径,无法仅凭字符串确定来源平台。
6. win32 与 posix
path.posix.parse("/var/log/my-app/error.log");
// { root: '/', dir: '/var/log/my-app', base: 'error.log', ext: '.log', name: 'error' }
path.win32.parse("/var/log/my-app/error.log");
// 反斜杠才是分隔符,因此解析语义不同
path.win32.parse("C:\\app\\logs\\error.log");
path.posix.parse("C:\\app\\logs\\error.log"); // POSIX 会把反斜杠当普通字符
处理“数据所属平台”的路径应显式选 win32/posix,不能总依赖程序当前操作系统。
第二部分:文件与目录
7. 递归创建日志目录
import { mkdir } from "node:fs/promises";
async function ensureLogDirectory(): Promise<string> {
const directory = path.join(process.cwd(), "logs", "2026", "07");
await mkdir(directory, { recursive: true });
return directory;
}
直接 mkdir(..., {recursive:true}) 可避免 access() 与创建之间的竞态。
8. 追加启动日志
import { appendFile, mkdir } from "node:fs/promises";
async function writeStartupLog(): Promise<void> {
const directory = path.join(process.cwd(), "logs");
await mkdir(directory, { recursive: true });
await appendFile(
path.join(directory, "app.log"),
`${new Date().toISOString()} server started\n`,
{ encoding: "utf8" },
);
}
多进程高频日志不应自行这样管理,应交给结构化日志库和日志轮转系统。
9. 读取并验证 JSON 配置
import { readFile } from "node:fs/promises";
interface AppConfig { port: number; host: string }
function isAppConfig(value: unknown): value is AppConfig {
if (typeof value !== "object" || value === null) return false;
const item = value as Record<string, unknown>;
return Number.isInteger(item.port)
&& typeof item.port === "number"
&& item.port > 0 && item.port <= 65_535
&& typeof item.host === "string";
}
async function readConfig(): Promise<AppConfig> {
const text = await readFile(path.join(process.cwd(), "config.json"), "utf8");
let value: unknown;
try { value = JSON.parse(text) as unknown; }
catch (error: unknown) { throw new Error("config.json 不是合法 JSON", { cause: error }); }
if (!isAppConfig(value)) throw new Error("config.json 结构不符合 AppConfig");
return value;
}
readFile 错误自然向上传播;SyntaxError 被包装为格式错误。TypeScript 接口只在编译期存在,必须运行时校验外部数据。
10. 判断文件、目录或不存在
import { stat } from "node:fs/promises";
type PathKind = "file" | "directory" | "other" | "not-found";
async function getPathKind(target: string): Promise<PathKind> {
try {
const info = await stat(target);
if (info.isFile()) return "file";
if (info.isDirectory()) return "directory";
return "other";
} catch (error: unknown) {
if (isErrno(error) && error.code === "ENOENT") return "not-found";
throw error;
}
}
若要判断符号链接本身,应改用 lstat();stat() 跟随链接。
11. 安全删除临时文件
import { unlink } from "node:fs/promises";
async function removeTempFile(target: string): Promise<void> {
try { await unlink(target); }
catch (error: unknown) {
if (isErrno(error) && error.code === "ENOENT") return;
throw error;
}
}
unlink() 对目录会失败;不要使用递归删除把类型错误隐藏掉。
12. 复制头像并拒绝覆盖
import { constants } from "node:fs";
import { copyFile, mkdir, stat } from "node:fs/promises";
async function copyAvatar(): Promise<void> {
const source = path.resolve("temp/avatar.png");
const target = path.resolve("uploads/users/1001/avatar.png");
await mkdir(path.dirname(target), { recursive: true });
await copyFile(source, target, constants.COPYFILE_EXCL);
if (!(await stat(target)).isFile()) throw new Error("复制结果不是普通文件");
}
这里选择拒绝覆盖,冲突返回 EEXIST。如果业务允许替换,应先明确原子发布和失败恢复,而不是静默覆盖。
13. 跨文件系统移动
import { copyFile, mkdir, rename, unlink } from "node:fs/promises";
interface MoveResult { moved: boolean; sourceCleanupPending: boolean }
async function moveFile(source: string, target: string): Promise<MoveResult> {
await mkdir(path.dirname(target), { recursive: true });
try {
await rename(source, target);
return { moved: true, sourceCleanupPending: false };
} catch (error: unknown) {
if (!isErrno(error) || error.code !== "EXDEV") throw error;
}
await copyFile(source, target, constants.COPYFILE_EXCL);
try {
await unlink(source);
return { moved: true, sourceCleanupPending: false };
} catch (error: unknown) {
return { moved: true, sourceCleanupPending: true };
}
}
复制成功、删除失败意味着目标可用但源仍存在,不能简单报告“完全失败”并盲目重试复制;应记录待清理状态。高可靠实现还应校验大小/Hash,并处理部分目标文件。