找回密码
 立即注册
首页 业界区 业界 使用Node.js打造交互式脚手架,简化模板下载与项目创建 ...

使用Node.js打造交互式脚手架,简化模板下载与项目创建

赐度虻 2025-6-4 22:27:20
在上一篇文章中,我们探讨了如何构建一个通用的脚手架框架。今天,我们将在此基础上进一步扩展脚手架的功能,赋予它下载项目模板的能力。
通常情况下,我们可以将项目模板发布到 npm 上,或者在公司内部利用私有 npm 仓库进行托管。通过交互式命令行界面,开发者可以轻松选择项目类型、项目名称以及所需的项目模板。脚手架将自动下载并生成对应的项目结构,为开发者提供便捷的项目初始化体验。
本文的内容基于上一篇文章中定义的基础脚手架。简单回顾一下目录结构:在 ice-basic-cli 项目中,存在一个 packages 文件夹,其中包含 4 个子包。cli 文件夹存放与命令行相关的内容,command 文件夹封装了 commander 类,init 文件夹包含了初始化 init 指令的实现,而 utils 文件夹则涵盖了工具函数和方法。
获取项目创建类型

首先,我们需要获取用户需要创建的项目类型。例如,用户可能需要基于 React 框架的模板,或者是 Vue 的模板;是 PC 端的项目,还是 H5 端的项目。这些信息都需要通过问答的形式让用户选择。
在命令行工具中实现问答交互,我们可以使用 inquirer 库。在 packages/utils 子包中新增 inquirer.js 文件,对 inquirer 进行封装,并导出上下键盘筛选和输入方法。
  1. import inquirer from "inquirer";
  2. function make({
  3.   choices,
  4.   defaultValue,
  5.   message = "请选择",
  6.   type = "list",
  7.   require = true,
  8.   mask = "*",
  9.   validate,
  10.   pageSize,
  11.   loop,
  12. }) {
  13.   const options = {
  14.     name: "name",
  15.     default: defaultValue,
  16.     message,
  17.     type,
  18.     require,
  19.     mask,
  20.     validate,
  21.     pageSize,
  22.     loop,
  23.   };
  24.   if (type === "list") {
  25.     options.choices = choices;
  26.   }
  27.   return inquirer.prompt(options).then((answer) => answer.name);
  28. }
  29. export function makeList(params) {
  30.   return make({ ...params });
  31. }
复制代码
在同层级的 index.js 文件中,将需要使用的 makeList 方法导出。
  1. import { makeList } from "./inquirer.js";
  2. export {
  3.   makeList,
  4. };
复制代码
接下来,我们在 packages/init/lib 文件夹下创建 createTemplate.js 文件,用于获取项目创建类型。用户可以根据需要选择是创建项目还是创建页面。
  1. import { makList } from "@ice-basic-cli/utils";
  2. const ADD_TYPE_PROJECT = "project";
  3. const ADD_TYPE_PAGE = "page";
  4. const ADD_TYPE = [
  5.   {
  6.     name: "项目",
  7.     value: ADD_TYPE_PROJECT,
  8.   },
  9.   {
  10.     name: "页面",
  11.     value: ADD_TYPE_PAGE,
  12.   },
  13. ];
  14. function getAddType() {
  15.   return makList({
  16.     choices: ADD_TYPE,
  17.     message: "请选择初始化类型",
  18.     defaultValue: ADD_TYPE_PROJECT,
  19.   });
  20. }
  21. export default async function createTemplate() {
  22.   const addType = await getAddType();
  23. }
复制代码
接下来,我们在 init.js 文件中引入 createTemplate 方法。
  1. import createTemplate from "./createTemplate.js";
  2. async action() {
  3.     await createTemplate()
  4. }
复制代码
当用户执行 npx @ice-basic-cli/cli 命令时,init 文件中的 action 方法会被执行,触发交互式问答流程,用户可以根据提示选择初始化类型(例如创建项目或页面)。
获取项目名称及项目模板

接下来,我们需要用户输入新建项目的名称。首先,在 utils/lib/inquirer.js 文件中定义 makeInput 方法并导出,用于封装输入项。
  1. export function makeInput(params) {
  2.   return make({
  3.     type: "input",
  4.     ...params,
  5.   });
  6. }
复制代码
再次回到 packages/init/lib/createTemplate.js 文件中,定义输入项目名称的函数。如果用户在上一步选择了“项目”作为初始化类型,则进行下一步的输入项目名称操作。
  1. import { makeList, makeInput } from "@ice-basic-cli/utils";
  2. function getAddName() {
  3.   return makeInput({
  4.     message: "请输入项目名称",
  5.     defaultValue: "",
  6.     validate: (v) => {
  7.       if (v.length > 0) return true;
  8.       return "项目名称必填";
  9.     },
  10.   });
  11. }
  12. export default async function createTemplate() {
  13.   const addType = await getAddType();
  14.   if (addType === ADD_TYPE_PROJECT) {
  15.     const addName = await getAddName();
  16.   }
  17. }
复制代码
完成项目名称输入后,紧接着让用户选择项目模板,比如 React 或 Vue 模板。这些模板应在 npm 上可查询且可下载。在使用之前,请确保已将自己的模板上传至npm 仓库。
这里以两个示例模板为例,它们是我之前上传到 npm 上的测试模板。在 packages/init/lib/createTemplate.js 中定义选择模板的代码如下:
  1. const ADD_TEMPLATE = [
  2.   {
  3.     name: "vue3项目模板",
  4.     value: "vue-template",
  5.     npmName: "ice-ts-app",
  6.     version: "0.0.1",
  7.   },
  8.   {
  9.     name: "react项目模板",
  10.     value: "react-template",
  11.     npmName: "table-brush-copy",
  12.     version: "1.0.1",
  13.   },
  14. ];
  15. function getAddTemplate() {
  16.   return makeList({
  17.     choices: ADD_TEMPLATE,
  18.     message: "请选择项目模板",
  19.   });
  20. }
  21. export default async function createTemplate() {
  22.   const addType = await getAddType();
  23.   if (addType === ADD_TYPE_PROJECT) {
  24.     const addName = await getAddName();
  25.     const addTemplate = await getAddTemplate();
  26.   }
  27. }
复制代码
当你在命令行输入 npx @ice-basic-cli/cli init 指令后,程序会依次提出预设的问题,包括选择初始化类型、输入项目名称以及选择项目模板。
1.png

npm API接入和封装

当用户完成了上述问答流程后,接下来我们可以通过 npm 来完成下载任务。首先,我们需要对 npm 的 API 进行一些封装以便于使用。
npm 提供了获取包信息的功能,通过访问 https://registry.npmjs.org/${npmName} 可以查看资源的最新版本、开发者信息、所需 Node 版本等详细信息。
例如,以 @ice-basic-cli/cli 为例,通过访问其 npm 页面可以看到最新版本号为 0.0.3。
2.png

现在,我们将这一功能封装到代码中。在 packages/utils/lib 下创建一个名为 npm.js 的文件,并定义获取 npm 包信息和获取最新版本号的方法。考虑到国内访问 https://registry.npmjs.org 可能较慢,可以将其替换为淘宝镜像 https://registry.npmmirror.com。
  1. import log from "./log.js";
  2. import urlJoin from "url-join";
  3. import axios from "axios";
  4. function getNpmInfo(npmName) {
  5.   const registry = "https://registry.npmmirror.com/";
  6.   const url = urlJoin(registry, npmName);
  7.   return axios.get(url).then((response) => {
  8.     try {
  9.       return response.data;
  10.     } catch (err) {
  11.       return Promise.reject(response);
  12.     }
  13.   });
  14. }
  15. export function getLatestVersion(npmName) {
  16.   return getNpmInfo(npmName).then((data) => {
  17.     if (!data["dist-tags"] || !data["dist-tags"].latest) {
  18.       log.error("没有 latest 版本号");
  19.       return Promise.reject(new Error("没有 latest 版本号"));
  20.     }
  21.     return data["dist-tags"].latest;
  22.   });
  23. }
复制代码
在同层级的 index.js 文件中导出 getLatestVersion 函数,以便其他模块调用。然后,在 packages/init/lib/createTemplate.js 文件中定义获取用户选择的 npm 包的最新版本号的逻辑。
  1. import { getLatestVersion } from "@ice-basic-cli/utils";
  2. export default async function createTemplate() {
  3.   const addType = await getAddType();
  4.   if (addType === ADD_TYPE_PROJECT) {
  5.     const addName = await getAddName();
  6.     const addTemplate = await getAddTemplate();
  7.     const selectedTemplate = ADD_TEMPLATE.find((_) => _.value === addTemplate);
  8.     const latestVersion = await getLatestVersion(selectedTemplate.npmName);
  9.     selectedTemplate.version = latestVersion;
  10.   }
  11. }
复制代码
下载项目模板

在获取到用户选择的模板之后,我们需要建立缓存目录以下载 npm 包。这可以通过编辑 packages/init/lib/createTemplate.js 文件来实现。
  1. import { homedir } from "node:os";
  2. import path from "node:path";
  3. const TEMP_HOME = ".ice-cli";
  4. function makeTargetPath() {
  5.   return path.resolve(`${homedir()}/${TEMP_HOME}`);
  6. }
  7. export default async function createTemplate() {
  8.   const addType = await getAddType();
  9.   if (addType === ADD_TYPE_PROJECT) {
  10.     const addName = await getAddName();
  11.     const addTemplate = await getAddTemplate();
  12.     const selectedTemplate = ADD_TEMPLATE.find((_) => _.value === addTemplate);
  13.     const latestVersion = await getLatestVersion(selectedTemplate.npmName);
  14.     selectedTemplate.version = latestVersion;
  15.     const targetPath = makeTargetPath();
  16.     return {
  17.       type: addType,
  18.       name: addName,
  19.       template: selectedTemplate,
  20.       targetPath,
  21.     };
  22.   }
  23. }
复制代码
接下来,在 createTemplate.js 的同级目录下创建 downloadTemplate.js 文件,用于定义将模板下载到缓存目录的逻辑。这里有几个关键点需要注意:

  • 使用 path-exists 来判断文件是否存在,并通过 fs-extra 创建不存在的目录。
  • 通过 execa 执行如 npm install {npmName}@{npmVersion} 这样的 npm 命令。
  • 由于下载过程可能耗时较长,我们使用 ora 库展示下载进度条。
  1. import path from "node:path";
  2. import { pathExistsSync } from "path-exists";
  3. import fse from "fs-extra";
  4. import ora from "ora";
  5. import { execa } from "execa";
  6. function getCacheDir(targetPath) {
  7.   return path.resolve(targetPath, "node_modules");
  8. }
  9. function makeCacheDir(targetPath) {
  10.   const cacheDir = getCacheDir(targetPath);
  11.   if (!pathExistsSync(cacheDir)) {
  12.     fse.mkdirpSync(cacheDir);
  13.   }
  14. }
  15. async function downloadAddTemplate(targetPath, selectedTemplate) {
  16.   const { npmName, version } = selectedTemplate;
  17.   const installCommand = "cnpm";
  18.   const installArgs = ["install", `${npmName}@${version}`];
  19.   const cwd = targetPath;
  20.   await execa(installCommand, installArgs, { cwd });
  21. }
  22. export default async function downloadTemplate(selectedTemplate) {
  23.   const { targetPath, template } = selectedTemplate;
  24.   makeCacheDir(targetPath);
  25.   const spinner = ora("正在下载模板...").start();
  26.   try {
  27.     await downloadAddTemplate(targetPath, template);
  28.     spinner.stop();
  29.   } catch (e) {
  30.     spinner.stop();
  31.   }
  32. }
复制代码
拷贝项目模板

完成模板下载后,下一步是将其从缓存目录复制到用户希望创建的项目位置。这涉及到以下逻辑:

  • 获取用户输入的文件夹名称并检查该名称是否已存在。如果存在且指定了 --force 参数,则移除原有文件夹并创建新文件夹;否则提示错误信息。
  • 将缓存目录下的内容复制到用户新建的项目文件夹内。
  1. import fse from "fs-extra";
  2. import { pathExistsSync } from "path-exists";
  3. import { log } from "@ice-basic-cli/utils";
  4. import ora from "ora";
  5. import path from "path";
  6. export default function installTemplate(selectedTemplate, opts = {}) {
  7.   const { force = false } = opts;
  8.   const { targetPath, template, name } = selectedTemplate;
  9.   const rootDir = process.cwd();
  10.   fse.ensureDirSync(targetPath);
  11.   const installDir = path.resolve(`${rootDir}/${name}`);
  12.   if (pathExistsSync(installDir)) {
  13.     if (!force) {
  14.       log.error(`当前目录下已存在 ${installDir} 文件夹`);
  15.     } else {
  16.       fse.removeSync(installDir);
  17.       fse.ensureDirSync();
  18.     }
  19.   } else {
  20.     fse.ensureDirSync(installDir);
  21.   }
  22.   copyFile(targetPath, template, installDir);
  23. }
  24. function getCacheFilePath(targetPath, template) {
  25.   return path.resolve(targetPath, "node_modules", template.npmName);
  26. }
  27. function copyFile(targetPath, template, installDir) {
  28.   const originFile = getCacheFilePath(targetPath, template);
  29.   const fileList = fse.readdirSync(originFile);
  30.   const spinner = ora("正在拷贝文件").start();
  31.   fileList.map((file) => {
  32.     fse.copySync(`${originFile}/${file}`, `${installDir}/${file}`);
  33.   });
  34.   spinner.stop();
  35.   log.success("模板拷贝成功");
  36. }
复制代码
再次回到 init.js 文件中,导入 downloadTemplate.js 以及其他必要的模块,确保整个流程能够顺利运行。
  1. import createTemplate from "./createTemplate.js";
  2. import downloadTemplate from "./downloadTemplate.js";
  3. import installTemplate from "./installTemplate.js";
  4. class InitCommand extends Command {
  5.    async action(name, opts) {
  6.     const selectedTemplate = await createTemplate(name, opts);
  7.     await downloadTemplate(selectedTemplate);
  8.    // 安装项目模板至项目目录
  9.     await installTemplate(selectedTemplate, opts);
  10.   }
  11. }
复制代码
到这里,一个交互式命令行下载模板的流程已经构建完成。
非交互式项目创建

为了提供更大的灵活性,我们还需要支持非交互式的命令行创建项目方法,因为单元测试时需要通过命令而非交互方式来创建项目。
首先,在 init.js 文件中增加接收非交互式命令的指令集。
  1.  get options() {
  2.     return [
  3.       ["-f, --force", "是否强制更新", false],
  4.       ["-t, --type <type>", "项目类型(值:project/page)"],
  5.       ["-p, --template <template>", "模板名称"],
  6.     ];
  7.   }
复制代码
然后,在 createTemplate.js 文件中处理这些参数,并根据不同的输入参数执行相应的流程。
  1. export default async function createTemplate(name, opts) {
  2.   const { type = null, template = null } = opts;
  3.   let addType;
  4.   let addName;
  5.   let selectedTemplate;
  6.   if (type) {
  7.     addType = type;
  8.   } else {
  9.     addType = await getAddType();
  10.   }
  11.   if (addType === ADD_TYPE_PROJECT) {
  12.     if (name) {
  13.       addName = name;
  14.     } else {
  15.       addName = await getAddName();
  16.     }
  17.     if (template) {
  18.       selectedTemplate = ADD_TEMPLATE.find((tp) => tp.value === template);
  19.       if (!selectedTemplate) {
  20.         throw new Error(`项目模板 ${template} 不存在!`);
  21.       }
  22.     } else {
  23.       const addTemplate = await getAddTemplate();
  24.       selectedTemplate = ADD_TEMPLATE.find((_) => _.value === addTemplate);
  25.     }
  26.     const latestVersion = await getLatestVersion(selectedTemplate.npmName);
  27.     selectedTemplate.version = latestVersion;
  28.     const targetPath = makeTargetPath();
  29.     return {
  30.       type: addType,
  31.       name: addName,
  32.       template: selectedTemplate,
  33.       targetPath,
  34.     };
  35.   } else {
  36.     throw new Error(`创建的项目类型不支持`);
  37.   }
  38. }
复制代码
现在,在命令行工具中输入 npx @ice-basic-cli/cli init testProject --type project --template vue-template --force --debug 即可直接下载模板到 testProject 文件夹中。
通过上述步骤,我们已经详细介绍了如何使用 Node.js 实现一个从模板下载到项目创建的完整流程。这个过程不仅涵盖了交互式的命令行工具开发,还考虑到了非交互式的应用场景,确保了在各种情况下都能灵活高效地创建项目。
如果你对前端工程化有兴趣,或者想了解更多相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享~

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册