第四章 文件系统操作:脚手架的核心引擎
说实话,脚手架工具最核心的能力是什么?
不是命令行交互,也不是配置解析。而是文件操作。
你想想看,一个脚手架最终要干的事,就是把模板文件复制到目标目录,替换掉里面的变量。这背后全靠 Node.js 的 fs 模块和 path 模块撑着。我在做第一个脚手架项目时,就因为对文件操作不熟,踩了不少坑。今天咱们就把这块彻底讲透。
4.1 fs 模块核心 API:读写删改一把梭
fs 模块是 Node.js 内置的文件系统模块。它提供了三种风格的 API:同步、回调和 Promise。我个人习惯用 Promise 版本,代码更干净。
核心原则:脚手架工具中,文件读取用同步,文件写入用异步。为什么?读取模板是一次性的,同步更简单;写入目标文件可能很多,异步更高效。
先看几个最常用的 API:
import fs from 'fs/promises';
import fsSync from 'fs';
// 1. 读取文件
const content = await fs.readFile('./template.txt', 'utf-8');
// 2. 写入文件
await fs.writeFile('./output.txt', 'Hello World', 'utf-8');
// 3. 检查文件是否存在(同步版本)
if (fsSync.existsSync('./template.txt')) {
console.log('文件存在');
}
// 4. 复制文件
await fs.copyFile('./source.txt', './dest.txt');
// 5. 删除文件
await fs.unlink('./temp.txt');
// 6. 读取目录
const files = await fs.readdir('./my-project');
嗯,这里要注意:fs.existsSync 是同步的,没有 Promise 版本。这是历史遗留问题,咱们就接受它吧。
我的习惯:在脚手架入口处,我会先用同步方法检查模板目录是否存在。如果不存在,直接报错退出,避免后面异步操作到一半才发现问题。
4.2 path 路径处理:别让路径坑了你
路径处理是文件操作中最容易出问题的地方。我见过太多人直接用字符串拼接路径,结果在 Windows 上跑崩了。
path 模块就是来解决这个问题的。它自动处理不同操作系统的路径分隔符(Windows 用 \,Linux/macOS 用 /)。
import path from 'path';
// 1. 路径拼接(推荐)
const fullPath = path.join('src', 'components', 'Button.tsx');
// 输出: src/components/Button.tsx (Linux)
// 输出: src\components\Button.tsx (Windows)
// 2. 获取当前文件所在目录
const dir = path.dirname('/user/local/file.txt');
// 输出: /user/local
// 3. 获取文件扩展名
const ext = path.extname('image.png');
// 输出: .png
// 4. 获取文件名(不含扩展名)
const name = path.basename('index.html', '.html');
// 输出: index
// 5. 解析路径
const parsed = path.parse('/user/local/file.txt');
// 输出: { root: '/', dir: '/user/local', base: 'file.txt', ext: '.txt', name: 'file' }
// 6. 获取相对路径
const relative = path.relative('/user/local', '/user/local/sub/file.txt');
// 输出: sub/file.txt
我曾经踩过的坑:在 Windows 上,path.join('src', 'components') 得到的是 src\components。如果你把这个路径硬编码到代码里,或者用字符串替换,很容易出问题。解决方案:始终用 path.join 或 path.resolve,别自己拼。
还有一个常用技巧:获取当前执行目录。脚手架通常需要知道用户是在哪个目录下运行的命令:
import { fileURLToPath } from 'url';
import path from 'path';
// 获取当前模块的绝对路径(ESM 模块)
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 获取用户执行命令的目录
const cwd = process.cwd();
4.3 递归创建目录:mkdir 的魔法
创建目录是脚手架最频繁的操作之一。你要创建 src/components/Button 这样的嵌套目录,总不能一层一层手动创建吧?
Node.js 从 v10.12.0 开始,fs.mkdir 支持 recursive: true 选项。这个选项会递归创建所有不存在的父目录。
import fs from 'fs/promises';
// 递归创建目录
await fs.mkdir('./my-project/src/components/Button', { recursive: true });
// 如果 my-project、src、components 都不存在,会一次性全部创建
// 检查目录是否存在,不存在则创建
async function ensureDir(dirPath) {
try {
await fs.access(dirPath);
} catch {
await fs.mkdir(dirPath, { recursive: true });
}
}
重要:recursive: true 不会在目录已存在时报错。所以你可以放心大胆地用,不用先检查。
我个人习惯封装一个 ensureDir 函数,在脚手架初始化时先创建好所有需要的目录结构:
async function createProjectStructure(basePath) {
const dirs = [
'src',
'src/components',
'src/pages',
'src/utils',
'public',
'public/assets',
];
for (const dir of dirs) {
await fs.mkdir(path.join(basePath, dir), { recursive: true });
}
}
4.4 文件读写与模板复制:脚手架的灵魂
模板复制是脚手架最核心的功能。说白了,就是把模板文件读进来,替换掉里面的占位符,然后写到目标位置。
先看一个最简单的模板引擎实现:
// 模板文件 template.txt
// 内容:Hello, {{name}}! Welcome to {{project}}.
// 模板渲染函数
function renderTemplate(template, data) {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return data[key] || match;
});
}
// 使用
const template = await fs.readFile('./template.txt', 'utf-8');
const output = renderTemplate(template, { name: '张三', project: 'MyApp' });
await fs.writeFile('./output.txt', output, 'utf-8');
实际项目中,模板复制通常涉及整个目录:
async function copyTemplateDir(srcDir, destDir, data) {
// 确保目标目录存在
await fs.mkdir(destDir, { recursive: true });
// 读取源目录所有文件
const entries = await fs.readdir(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
// 递归复制子目录
await copyTemplateDir(srcPath, destPath, data);
} else {
// 读取模板文件
let content = await fs.readFile(srcPath, 'utf-8');
// 渲染模板
content = renderTemplate(content, data);
// 写入目标文件
await fs.writeFile(destPath, content, 'utf-8');
}
}
}
避坑指南:我曾经在复制二进制文件(如图片、字体)时,用 utf-8 编码读取,结果文件损坏了。记住:二进制文件不要用 utf-8 编码,直接读 Buffer:await fs.readFile(srcPath)(不传编码参数)。
更完善的模板复制应该区分文件类型:
async function copyTemplateFile(srcPath, destPath, data) {
const ext = path.extname(srcPath).toLowerCase();
const textExts = ['.js', '.ts', '.jsx', '.tsx', '.html', '.css', '.json', '.md', '.txt', '.yml', '.yaml'];
if (textExts.includes(ext)) {
// 文本文件:读取、渲染、写入
let content = await fs.readFile(srcPath, 'utf-8');
content = renderTemplate(content, data);
await fs.writeFile(destPath, content, 'utf-8');
} else {
// 二进制文件:直接复制
await fs.copyFile(srcPath, destPath);
}
}
4.5 实战:完整的文件操作工具函数
最后,分享一个我在多个脚手架项目中使用的工具函数集合。这些函数经过实战检验,你可以直接拿去用:
import fs from 'fs/promises';
import path from 'path';
/**
* 确保目录存在,不存在则创建
*/
export async function ensureDir(dirPath) {
try {
await fs.access(dirPath);
} catch {
await fs.mkdir(dirPath, { recursive: true });
}
}
/**
* 读取 JSON 文件
*/
export async function readJSON(filePath) {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content);
}
/**
* 写入 JSON 文件(格式化输出)
*/
export async function writeJSON(filePath, data) {
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
/**
* 递归删除目录(慎用!)
*/
export async function removeDir(dirPath) {
await fs.rm(dirPath, { recursive: true, force: true });
}
/**
* 获取目录下所有文件的路径(扁平化)
*/
export async function getAllFiles(dirPath) {
const files = [];
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath);
files.push(...subFiles);
} else {
files.push(fullPath);
}
}
return files;
}
重要提醒:fs.rm 的 recursive: true 选项在 Node.js v14.14.0 之后才稳定。如果你要兼容老版本 Node,可以用 rimraf 这个 npm 包。
好了,文件系统操作这块就讲完了。说白了,脚手架的核心就是「读模板 -> 替换变量 -> 写文件」这个循环。你把这个循环搞明白了,任何脚手架工具在你眼里都只是模板和变量的组合。
下一章,咱们聊聊如何设计模板引擎,让模板更灵活、更强大。