前言:
这几天和好哥哥在做开源的脚手架create-neat(以下简称cn) 读源码的时候发现该脚手架对于生成的文件与文件夹的处理的核心思想是 File Tree(文件树)思想。 本篇文章将会剖析File Tree思想,探讨一下在手撸一个脚手架时,如何去管理以及处理那些生成的文件以及文件夹的准确性。 如果对该CN脚手架感兴趣的,可以到仓库feat/generator-upgrade 分支fork一下代码。点点star。
想一起探讨技术的也可以加我的微信:xhytdsb775218
为什么我们需要文件树
CN本身支持预设和自定义我们想要的脚手架类型。我们可以先来看看CN是如何DIV一个脚手架的
渲染内容 我们可以自己选择使用何种框架,何种打包工具,何种插件。就比如我使用React+TS的脚手架。根据命令行的选择就会生成我们的项目框架。

那么从命令行到生成文件到指定地方。之间就必须要有一个自定义数据结构,去描述文件夹与文件夹的嵌套,文件内容的存储,并且要能够准确的将所需的项目文件夹放入正确的位置。这就是文件树最重要的地方。
拿上图为例,我们选择了对应的命令。需要生成public src文件夹。还需要生成对应的配置文件。我们就需要有一个文件树,完整的去描述我们生成的整个项目文件的整体结构,在统一生成到指定路径
那我们总结一下为何要使用文件树呢?
- 命令行结束后,脚手架自行运行脚本,我们需要一个数据结构存储运行脚本后输出的文件文件夹
- 由于CN脚手架较为灵活,所以需要一个结构清晰明了,不会被污染的数据结构去抽象存储我们将要生成的项目文件夹。
- 由于我们需要灵活去注入想要的内容,比如webpack.config.js里面的内容根据选择的不同的插件去注入Plugin。所以我们需要能够EJS模版渲染的方法。
什么是文件树
我们介绍完了为什么需要文件树。那么文件树究竟是什么呢?
我们得从俩个方面来看:
- 项目文件输出来看:文件树是抽象存储以及描述项目文件夹的文件,文件内容,以及嵌套关系的一种对象。
- 从类层面来看:文件树是能够统一处理文件内容(比如EJS模版渲染),创建文件节点,将各种文件节点进行处理,形成我们需要渲染的文件树对象结构,在进行整体渲染的一个类。
从项目文件输出来看
yaml
fileData = {
path: rootDirectory,
type: "dir",
children: [],
describe: {
fileName: path.basename(rootDirectory),
},
};
这个是我们的文件树的根基,其渲染出来就是我们的项目总文件夹
path:传过来的路径,渲染文件夹时可以将文件夹放入指定的路径中 type:判断是否为文件夹或者是文件,应用为我们之后递归到文件 children:存入我们的子文件夹或子文件的地方 describe:该层节点的描述信息,文件的内容content也放入此进行统一渲染
将我们要创建的项目文件夹以对象这种形式实现抽象存储。将子文件等放入children数组。children文件夹也可以将自己的子文件夹或子文件存入自己的children中,实现嵌套结构,使得我们的项目文件结构能够清晰且严谨。每层的节点都有自己的属性,里面的信息也不会被污染。
具体如何实施呢?那我们就得从类层面来看看,怎么将文件树统一存储,统一处理呢?
我将源代码的**文件树类(File Tree)**进行了简化处理,方便读者理解:
typescript
class FileTree {
private rootDirectory: string;
private fileData: any;
constructor(rootDirectory: string) {
this.rootDirectory = rootDirectory;
this.fileData = {
path: rootDirectory,
type: "dir",
children: [],
describe: { fileName: path.basename(rootDirectory) },
};
}
}
看着好像很简单,当我们的命令行选择完毕后,除了运行原本预设的用作于我们生成所需文件的脚本以外,也会帮我们new一个File Tree 并且传入rootDirectory地址
rootDirectory也就是的渲染项目文件夹的地址,目前源码传的是的我们的CN根目录 。这样子,我们创建最开始的fileData时,它的创建路径就自动锁定了,之后我们想要添加项目文件,就在其children里面填。
举个例子
当我们运行

这个命令去启动脚手架时,脚手架就会根据你的命令。new 一个 File Tree类。并且将我们想要项目文件夹输出到哪里就传什么样的rootDirectory进去。之后再将我们的项目名称text放入我们的fileName。 这只是创建我们的项目根目录。如果直接渲染的话,就只会变成这样

只是在某个地方空空放入一个text文件夹。而假设最后我们想要形成这样的项目结构

那么就得创建叶子结点,将这些文件分情况去抽象成一个叶子,并传入我们的fIleData的children里面嵌套,等着渲染。
那么这些复杂的文件与文件夹是如何去进行处理,并抽象成一个叶子结点呢?
文件抽象处理
我们可以看到上图的文件结构。我们需要处理的文件一共分为三类。 1.被public或src等文件夹包裹着的不需要被EJS模版渲染的文件:如index.css等固定文件 2.被public或src等文件夹包裹着的需要被EJS模版渲染的文件,如APP.JS,index.jsx等,需要判断一些插件(比如React Router)是否被选择,再进行EJS动态渲染。 3.在text根目录创建的根目录配置文件。比如webpack.config.js,package.json等
我们就需要根据不同文件的特殊性,去在File Tree类里面写下特定的方法,等待被调用。
处理配置文件
我们先来看看源码是如何写的:
ini
addToTreeByFile(fullFileName: string, fileContent: string) {
const fullPath = path.resolve(this.rootDirectory, fullFileName);
const fileNameSplit = fullFileName.split(".");
let fileName; // 文件名称(不包含文件后缀)
if (fileNameSplit.length <= 2) {
fileName = fileNameSplit[0];
} else {
fileName = fileNameSplit.slice(0, -1).join(".");
}
// 全文件名.分割的最后一位作为拓展名(babel.config.js、.browserslistrc、.eslintrc.js等等)
const fileExtension = fileNameSplit[fileNameSplit.length - 1];
this.fileData.children.push({
path: fullPath,
children: [],
type: "file",
describe: { fileName, fileContent, fileExtension },
});
}
假设我们要抽象webpack的配置文件,那么我们传入webpack.config.js这个文件名和文件内容到这个处理函数 它会fullPath就会被组装成为text/webpack.config.js,作为我们要生成的文件的路径。并将文件名,文件内容,文件后缀名,作为文件的描述信息存入describe内。并且type直接定义为文件,因为这个方法只是用来处理根目录配置文件,所以只处理文件。
抽象组装成为叶子结点后,就会将其直接push进children中,也就是作为text的根目录配置文件,不会再去不断嵌套children中。
处理项目文件(不需要EJS渲染)
老规矩我们得先看看源码是如何编写的:
typescript
addToTreeByTemplateDirPath(url: string, parentDir: string) {
if (path.basename(url) === "template") {
const entries = fs.readdirSync(url, {
withFileTypes: true,
});
for (const entry of entries) {
const subTree = this.buildFileData(path.join(url, entry.name), parentDir);
this.fileData.children.push(subTree);
}
}
}
传入我们原本预设好的文件夹和文件内容,以及我们text的路径。我们会读取我们预设的文件夹的整体结构,再将我们我们预设的内容路径和text目录路径传入构建叶子结点的方法内。
ini
private buildFileData(src: string, parentDir?: string) {
const baseName = path.basename(src);
const file: any = {
path: path.resolve(parentDir, baseName),
children: [],
describe: {},
};
if (isDirectoryOrFile(src)) {
file.type = "dir";
file.describe.fileName = baseName;
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const subTree = this.buildFileData(
path.join(src, entry.name),
path.relative(parentDir, baseName),
);
file.children?.push(subTree);
}
} else {
const fileContent = fs.readFileSync(src, "utf8");
file.type = "file";
file.describe = {
fileName: path.basename(src).split(".")[0],
fileExtension: path.extname(src).slice(1),//文件扩展名
fileContent,
};
}
return file;
}
在这里,我们先创建一个空白的叶子结点 file。然后就是会检查我们传进去的原本写好的路径, 举个例子,我们源码的一个预设模版,

那么我们src就是/template-react/genrator/template/之前遍历的目录结构 该处理函数会判断这个是文件夹还是文件,如果是文件夹,就会将type设定为dir,就会调整path,以及添加描述信息。并进行递归。并将递归后的内容统一放入file的children中,这样就可以不断形成嵌套结构类似于文件夹结构。 一直到递归处理到文件。递归到文件后,就会读取文件内容,放入fileContent中。后缀名,文件名也是一起放入describe中。
这样,我们的叶子节点就创建好了,只需要push到fileData的children中,我们这一部分的文件树就构建完毕。
处理项目文件(需要EJS渲染)
typescript
addToTreeByTemplateDirPathAndEjs(url: string, parentDir: string, options: any) {
if (path.basename(url) === "template") {
const entries = fs.readdirSync(url, {
withFileTypes: true,
});
for (const entry of entries) {
const subTree = this.buildFileDataByEjs(path.join(url, entry.name), parentDir, options);
this.fileData.children.push(subTree);
}
}
}
整体和我们上一个处理方式一样,但是我们要渲染的内容,就需要使用option传进去。
ini
private buildFileDataByEjs(src: string, parentDir: string, options: any) {
const baseName = path.basename(src);
const file: any = {
path: "",
children: [],
describe: {},
};
if (isDirectoryOrFile(src)) {
file.type = "dir";
file.path = path.resolve(parentDir, baseName);
file.describe.fileName = baseName;
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const subTree = this.buildFileDataByEjs(
path.join(src, entry.name),
path.relative(parentDir, baseName),
options,
);
file.children?.push(subTree);
}
} else {
let fileContent = fs.readFileSync(src, "utf8");
// EJS 渲染
fileContent = ejs.render(fileContent, options);
file.type = "file";
file.describe = {
fileName: path.basename(src).split(".")[0],
fileExtension: path.extname(src).slice(1),
fileContent,
};
file.path = path.resolve(
parentDir,
`${file.describe.fileName}.${file.describe.fileExtension}`,
);
}
return file;
}
总体来看是一样的,但是我们获取内容后需要添加fileContent = ejs.render(fileContent, options);去渲染内容,在传入fileContent中。
通过这三种方法,可以处理大部分要生成的文件抽象成一个文件树。并且通过自己写放渲染方法。将文件渲染到指定的地方。这样子写的文件树,避免了复杂的文件处理以及文件复杂嵌套问题。文件嵌套只需要用children即可实现,难以处理的文件内容,我们处理完后,直接放入该层节点的fileContent中。结构清晰明了。
总结
文件树思想。其实就是面对各种文件夹与文件之间的各种复杂关系时。我们需要设定一个自己的数据结构,这种数据结构就可以帮助我们能够好好整理文件内容。这对于我们手撸脚手架可以说很重要。
当然,这只是介绍了CN的一部分思想。CN对于插件的处理,如何灵活选择框架以及其对应的插件,并进行统一内容注入等。内容繁多。篇幅有限只能介绍到这,感兴趣的同学们可以去看看源代码。