【前端工程化】脚手架篇 - 模板引擎 & 动态依赖管理脚手架

🧑‍💻 写在开头

点赞 + 收藏 === 学会🤣🤣🤣

在日常工作中,我们经常为会遇到需要创建新项目的需求,为了统计代码风格,项目配置,提升效率,我们可以创建一个cli工具,帮助我们实现这样的功能。你也可以搭建一个自己用,毕竟省下来的时间都是自己的

🥑 你能学到什么?

希望在你阅读本篇文章之后,不会觉得浪费了时间。如果你跟着读下来,你将会学到:

  • cli工具的基本搭建流程
  • 如何通过模板引擎实现可选依赖
  • 模板系统如何设计
  • 如何根据模板引擎生成所需项目
  • 熟悉一个组件库的基本结构
  • 熟悉一个类型库的基本结构
  • 熟悉一个cli项目的基本结构

后续你可以在此项目ObjectX-CLI的基础上,扩展项目支持的技术栈,和项目类型,以此了解各种项目的搭建流程

实现效果

🍎 系列文章

本系列是一个从0到1的实现过程,如果您有耐心跟着实现,您可以实现一个完整的react18 + ts5 + webpack5 + 代码质量&代码风格检测&自动修复 + storybook8 + rollup + git action实现的一个完整的组件库模板项目。如果您不打算自己配置,也可以直接clone组件库仓库切换到rollup_comp分支即是完整的项目,当前实现已经足够个人使用,后续我们会新增webpack5优化、按需加载组件、实现一些常见的组件封装:包括但不限于拖拽排序、瀑布流、穿梭框、弹窗等

项目概述

ObjectX-CLI 是一个现代化的前端项目脚手架工具,支持快速创建以下三种类型的项目:

  • 组件库项目 (component-lib):基于 React + TypeScript + Vite
  • 工具包项目 (tool-lib):通用 JavaScript/TypeScript 工具库
  • 类型包项目 (types-lib):纯 TypeScript 类型定义包

核心特性

  • 🚀 零配置启动:开箱即用,一键创建项目
  • 📦 模板化设计:基于 EJS 模板引擎,支持灵活配置
  • 🎨 多样式方案:支持 Less、Tailwind CSS、CSS Modules
  • 📚 文档集成:可选 Storybook 组件文档
  • 🧩 Monorepo 支持:使用 pnpm workspace
  • 现代化打包:Rollup + TypeScript

核心架构设计

整体架构图

python 复制代码
objectx-cli/
├── bin/                      # CLI 入口
│   └── index.js             # 可执行文件入口
├── src/                     # 源代码
│   ├── index.ts             # 主程序入口
│   ├── commands/            # 命令实现
│   │   └── create.ts        # create 命令
│   └── utils/               # 工具函数
│       ├── generate.ts      # 项目生成逻辑
│       └── validate.ts      # 验证逻辑
├── templates/               # 项目模板
│   ├── component-lib/       # 组件库模板
│   ├── tool-lib/            # 工具库模板
│   └── types-lib/           # 类型库模板
├── lib/                     # 编译输出(发布到 npm)
├── package.json             # 包配置
├── rollup.config.js         # 打包配置
└── tsconfig.json            # TypeScript 配置

核心流程图

bash 复制代码
用户执行命令
    ↓
bin/index.js(可执行入口)
    ↓
src/index.ts(初始化 Commander)
    ↓
src/commands/create.ts(处理 create 命令)
    ↓
├─ validate.ts(验证项目名)
├─ inquirer 交互式问答
└─ generate.ts(生成项目文件)
    ↓
├─ 读取模板文件
├─ EJS 模板渲染
├─ 条件文件过滤
└─ 生成 package.json
    ↓
Git 初始化
    ↓
完成提示

技术栈分析

1. CLI 框架层

Commander.js - 命令行框架
typescript 复制代码
// src/index.ts
import { Command } from 'commander';

const cli = new Command();

cli
  .name('objectx-cli')
  .description('前端项目脚手架工具')
  .version(pkg.version);

cli
  .command('create <project-name>')
  .description('创建一个新的项目')
  .option('-t, --template <template>', '指定项目模板')
  .action(create);

作用

  • 定义 CLI 命令和参数
  • 自动生成 --help--version
  • 参数解析和验证

2. 交互层

@inquirer/prompts - 交互式问答
typescript 复制代码
// src/commands/create.ts
import * as inquirer from '@inquirer/prompts';

// 确认覆盖
const overwriteResult = await inquirer.confirm({
  message: `目标目录 ${chalk.cyan(projectName)} 已存在。是否覆盖?`,
  default: false
});

// 单选
const projectType = await inquirer.select({
  message: '请选择项目类型:',
  choices: [
    { value: 'component-lib', name: '组件库项目' },
    { value: 'tool-lib', name: '工具包项目' },
    { value: 'types-lib', name: '类型包项目' }
  ]
});

// 多选
const styles = await inquirer.checkbox({
  message: '选择样式解决方案 (可多选):',
  choices: [
    { value: 'less', name: 'Less', checked: true },
    { value: 'tailwind', name: 'Tailwind CSS' },
    { value: 'css-modules', name: 'CSS Modules', checked: true }
  ]
});

3. 用户体验层

Chalk - 终端颜色
typescript 复制代码
import chalk from 'chalk';

console.log(`${chalk.bgBlue('OBJECTX CLI ')} 🚀 创建新项目...`);
console.log(chalk.green('✔') + ' 项目创建成功!');
console.log(chalk.red('✖') + ' 项目名称不能为空');
console.log(`  cd ${chalk.cyan(projectName)}`);
Ora - 加载动画
typescript 复制代码
import ora from 'ora';

const spinner = ora('正在生成项目文件...').start();
await generateProject(targetDir, projectOptions);
spinner.succeed('项目文件生成完成');

// 失败情况
spinner.fail('项目文件生成失败');

4. 文件处理层

fs-extra - 增强的文件系统
typescript 复制代码
import fs from 'fs-extra';

// 确保目录存在
await fs.ensureDir(targetDir);

// 删除目录
await fs.remove(targetDir);

// 输出文件(自动创建父目录)
await fs.outputFile(targetPath, content);

// 检查文件是否存在
if (fs.existsSync(targetDir)) { ... }
fast-glob - 文件搜索
typescript 复制代码
import glob from 'fast-glob';

// 读取所有模板文件(包括隐藏文件)
const templateFiles = await glob('**/*', {
  cwd: templateDir,
  dot: true,  // 包括 .gitignore 等
  ignore: ['**/node_modules/**', '**/.git/**']
});

5. 模板引擎层【核心】

EJS - 模板渲染
typescript 复制代码
import ejs from 'ejs';

// 模板数据
const templateData = {
  projectName: 'my-component',
  hasLess: true,
  hasTailwind: false,
  year: 2024
};

// 渲染模板
const content = await fs.readFile(sourcePath, 'utf8');
const renderedContent = ejs.render(content, templateData);

EJS 模板示例

typescript 复制代码
// Button.tsx.ejs
<% if (hasCssModules) { %>
import styles from './Button.module.css';
<% } else if (hasLess) { %>
import './Button.less';
<% } else if (hasTailwind) { %>
// 使用Tailwind类名
<% } %>

export const Button: React.FC<ButtonProps> = ({ children }) => {
  <% if (hasCssModules) { %>
  return <button className={styles.button}>{children}</button>;
  <% } else if (hasTailwind) { %>
  return <button className="bg-blue-500 text-white">{children}</button>;
  <% } %>
};

6. 子进程管理

execa - 执行外部命令
typescript 复制代码
import { execa } from 'execa';

// 初始化 Git
await execa('git', ['init'], { cwd: targetDir });

实现原理深度解析【核心】

1. 可执行入口实现

package.json 配置
json 复制代码
{
  "name": "objectx-cli",
  "type": "module",
  "bin": {
    "objectx-cli": "./bin/index.js"
  },
  "files": ["bin", "lib", "templates"]
}
  • bin 字段:指定命令名称和执行文件
  • type: "module":使用 ES Module
  • files 字段:指定发布到 npm 的文件
bin/index.js
javascript 复制代码
#!/usr/bin/env node

import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');

import '../lib/index.js';

关键点

  1. #!/usr/bin/env node:Shebang,告诉系统用 Node.js 执行
  2. createRequire:在 ESM 中使用 require 加载 JSON
  3. 引入编译后的 lib/index.js(不是源码 src/index.ts

2. 项目名称验证

typescript 复制代码
// src/utils/validate.ts

export function validateProjectName(projectName: string): void {
  // 1. 空值检查
  if (!projectName) {
    console.log(chalk.red('✖') + ' 项目名称不能为空');
    process.exit(1);
  }

  // 2. 空格检查
  if (projectName.trim() !== projectName) {
    console.log(chalk.red('✖') + ' 项目名称不能以空格开头或结尾');
    process.exit(1);
  }

  // 3. npm 包名验证
  const npmNameValidation = validateNpmPackageName(projectName);
  if (!npmNameValidation.validForNewPackages) {
    npmNameValidation.errors.forEach(err => {
      console.log('  ' + chalk.red('✖') + ' ' + err);
    });
    process.exit(1);
  }

  // 4. 保留关键字检查
  const RESERVED_KEYWORDS = ['node_modules', 'favicon.ico', '.git'];
  if (RESERVED_KEYWORDS.includes(projectName.toLowerCase())) {
    console.log(chalk.red('✖') + ` 项目名称不能使用保留关键字`);
    process.exit(1);
  }
}

function validateNpmPackageName(name: string) {
  const errors: string[] = [];

  if (name.length > 214) {
    errors.push('名称不能超过214个字符');
  }

  if (name.match(/^[._]/)) {
    errors.push('名称不能以 . 或 _ 开头');
  }

  if (name.match(/[/\\]/)) {
    errors.push('名称不能包含斜杠');
  }

  return {
    validForNewPackages: errors.length === 0,
    errors
  };
}

3. 目录覆盖处理

typescript 复制代码
// src/commands/create.ts

const targetDir = path.join(process.cwd(), projectName);

// 检查目录是否已存在
if (fs.existsSync(targetDir)) {
  const overwriteResult = await inquirer.confirm({
    message: `目标目录 ${chalk.cyan(projectName)} 已存在。是否覆盖?`,
    default: false
  });
  
  if (!overwriteResult) {
    console.log(chalk.red('✖') + ' 操作取消');
    return;
  }
  
  const spinner = ora(`正在删除 ${chalk.cyan(targetDir)}...`).start();
  await fs.remove(targetDir);
  spinner.succeed(`已删除 ${chalk.cyan(targetDir)}`);
}

await fs.ensureDir(targetDir);

安全性考虑

  • 先询问用户确认
  • 显示加载动画,提升体验
  • 使用 fs.remove 完全删除旧目录

模板系统设计【核心】

1. 模板目录结构

css 复制代码
templates/
├── component-lib/              # 组件库模板
│   ├── _gitignore.ejs         # .gitignore(以 _ 开头)
│   ├── demo/                   # 示例应用(无 Storybook 时使用)
│   │   ├── App.tsx.ejs
│   │   ├── index.html.ejs
│   │   └── main.tsx.ejs
│   ├── pnpm-workspace.yaml.ejs
│   ├── README.md.ejs
│   ├── src/
│   │   ├── components/
│   │   │   ├── Button/
│   │   │   │   ├── Button.tsx.ejs
│   │   │   │   ├── Button.module.css.ejs
│   │   │   │   ├── Button.module.less.ejs
│   │   │   │   └── Button.stories.tsx.ejs
│   │   │   └── Card/
│   │   │       ├── Card.tsx.ejs
│   │   │       └── Card.stories.tsx.ejs
│   │   ├── index.ts
│   │   ├── styles/
│   │   │   └── tailwind.css.ejs
│   │   └── types/
│   │       └── css.d.ts
│   ├── tailwind.config.js.ejs
│   ├── tsconfig.json.ejs
│   └── vite.config.ts.ejs
│
├── tool-lib/                   # 工具库模板
│   ├── README.md.ejs
│   ├── src/
│   │   ├── index.ts
│   │   └── utils/
│   │       └── string.ts
│   ├── tsconfig.json.ejs
│   └── vite.config.ts.ejs
│
└── types-lib/                  # 类型库模板
    ├── README.md.ejs
    ├── src/
    │   ├── index.ts
    │   └── types/
    │       ├── api.ts
    │       └── common.ts
    ├── tests/
    │   └── api.test-d.ts
    └── tsconfig.json.ejs

2. 特殊文件命名约定

隐藏文件处理
typescript 复制代码
// src/utils/generate.ts

function getTargetPath(file: string, targetDir: string, options: ProjectOptions): string {
  let filename = file;
  
  // 处理点文件(如 .gitignore)
  // 模板中命名为 _gitignore.ejs,生成时转为 .gitignore
  if (filename.startsWith('_')) {
    filename = `.${filename.slice(1)}`;
  }
  
  return path.join(targetDir, filename);
}

为什么这样做?

  • npm 发布时会忽略 .gitignore 文件
  • 使用 _gitignore 绕过这个限制
  • 生成项目时再重命名为 .gitignore

3. 条件文件生成

typescript 复制代码
function getTargetPath(file: string, targetDir: string, options: ProjectOptions): string {
  // ...文件名处理...
  
  // 如果选择了Storybook,跳过demo目录
  if (filename.startsWith('demo') && options.needDocs) {
    return '';  // 返回空字符串表示跳过
  }
  
  // 根据样式选择过滤文件
  if (filename.endsWith('.less') && !options.styles.includes('less')) {
    return '';
  }
  
  if (filename.includes('tailwind') && !options.styles.includes('tailwind')) {
    return '';
  }
  
  // Card组件只在选择了tailwind时生成
  if (filename.includes('components/Card') && !options.styles.includes('tailwind')) {
    return '';
  }
  
  return path.join(targetDir, filename);
}

设计思想

  • 根据用户选择动态生成文件
  • 避免生成无用文件
  • 保持项目清爽

文件生成机制【核心】

1. 核心生成流程

typescript 复制代码
// src/utils/generate.ts

export async function generateProject(
  targetDir: string, 
  options: ProjectOptions
): Promise<void> {
  
  // 1. 定位模板目录
  const templateDir = path.resolve(
    __dirname,
    '../../templates',
    options.projectType
  );

  // 2. 确保模板目录存在
  if (!fs.existsSync(templateDir)) {
    throw new Error(`找不到模板目录:${templateDir}`);
  }

  // 3. 读取所有模板文件
  const templateFiles = await glob('**/*', {
    cwd: templateDir,
    dot: true,
    ignore: ['**/node_modules/**', '**/.git/**']
  });

  // 4. 准备模板数据
  const templateData: TemplateData = {
    projectName: options.projectName,
    needDocs: options.needDocs,
    hasLess: options.styles.includes('less'),
    hasTailwind: options.styles.includes('tailwind'),
    hasCssModules: options.styles.includes('css-modules'),
    year: new Date().getFullYear(),
    packageManager: options.packageManager
  };

  // 5. 遍历并处理每个文件
  for (const file of templateFiles) {
    const sourcePath = path.join(templateDir, file);
    const targetPath = getTargetPath(file, targetDir, options);
    
    // 跳过不需要的文件
    if (targetPath === '') continue;
    
    // 处理目录
    if (fs.statSync(sourcePath).isDirectory()) {
      await fs.ensureDir(targetPath);
      continue;
    }

    // 读取文件内容
    const content = await fs.readFile(sourcePath, 'utf8');
    
    // 6. EJS 模板渲染
    if (file.endsWith('.ejs')) {
      const renderedContent = ejs.render(content, templateData);
      // 去掉 .ejs 后缀
      await fs.outputFile(
        targetPath.replace(/\.ejs$/, ''),
        renderedContent
      );
    } else {
      // 非模板文件直接复制
      await fs.outputFile(targetPath, content);
    }
  }

  // 7. 生成 package.json
  await generatePackageJson(targetDir, options);
}

2. package.json 动态生成

typescript 复制代码
async function generatePackageJson(
  targetDir: string, 
  options: ProjectOptions
): Promise<void> {
  
  // 基础配置
  const packageJson: any = {
    name: options.projectName,
    version: '0.1.0',
    private: false,
    scripts: {
      dev: 'vite',
      build: 'vite build && tsc --emitDeclarationOnly',
      lint: 'eslint src --ext .ts,.tsx',
      test: 'jest'
    },
    files: ['dist'],
    devDependencies: {
      "husky": "^9.0.7",
      "typescript": "^5.2.2",
      "eslint": "^8.52.0",
      "terser": "^5.24.0"
    }
  };
  
  // 根据项目类型定制
  switch (options.projectType) {
    case 'component-lib':
      // 组件库配置
      packageJson.type = 'module';
      packageJson.main = './dist/index.js';
      packageJson.module = './dist/index.js';
      packageJson.types = './dist/index.d.ts';
      packageJson.exports = {
        '.': {
          import: './dist/index.js',
          require: './dist/index.cjs'
        }
      };
      
      packageJson.peerDependencies = {
        react: "^18.0.0",
        "react-dom": "^18.0.0"
      };
      
      packageJson.devDependencies = {
        ...packageJson.devDependencies,
        "vite": "^5.0.0",
        "@vitejs/plugin-react": "^4.2.0",
        "@types/react": "^18.2.0",
        "@types/react-dom": "^18.2.0",
        "react": "^18.2.0",
        "react-dom": "^18.2.0",
        "autoprefixer": "^10.4.16",
        "postcss": "^8.4.31"
      };
      
      // Storybook 支持
      if (options.needDocs) {
        packageJson.scripts.dev = 'storybook dev -p 6006';
        packageJson.scripts.storybook = 'storybook dev -p 6006';
        packageJson.scripts['build-storybook'] = 'storybook build';
        
        packageJson.devDependencies = {
          ...packageJson.devDependencies,
          "@storybook/addon-essentials": "^7.5.3",
          "@storybook/react": "^7.5.3",
          "@storybook/react-vite": "^7.5.3",
          "storybook": "^7.5.3"
        };
      } else {
        // 使用 demo 作为开发环境
        packageJson.scripts.dev = 'vite demo --open';
      }
      
      // 样式依赖
      if (options.styles.includes('less')) {
        packageJson.devDependencies.less = "^4.2.0";
      }
      
      if (options.styles.includes('tailwind')) {
        packageJson.devDependencies.tailwindcss = "^3.3.5";
      }
      break;
      
    case 'tool-lib':
      // 工具库配置
      packageJson.type = 'module';
      packageJson.main = './dist/index.js';
      packageJson.module = './dist/index.js';
      packageJson.types = './dist/index.d.ts';
      
      packageJson.scripts.dev = 'vite build --mode watch';
      packageJson.scripts.docs = 'typedoc --out docs src/index.ts';
      
      packageJson.devDependencies = {
        ...packageJson.devDependencies,
        "vite": "^5.0.0",
        "typedoc": "^0.25.3"
      };
      break;
      
    case 'types-lib':
      // 类型库配置
      packageJson.scripts.build = 'tsc --emitDeclarationOnly';
      packageJson.scripts.dev = 'tsc --emitDeclarationOnly --watch';
      packageJson.scripts['test:types'] = 'tsc --noEmit';
      
      packageJson.main = './dist/index.d.ts';
      packageJson.types = './dist/index.d.ts';
      
      packageJson.devDependencies = {
        "husky": "^9.0.7",
        "typescript": "^5.2.2",
        "eslint": "^8.52.0",
        "tsd": "^0.30.0",
        "@types/node": "^20.8.10"
      };
      break;
  }
  
  // 写入文件
  await fs.writeFile(
    path.join(targetDir, 'package.json'),
    JSON.stringify(packageJson, null, 2)
  );
}

亮点

  • 动态配置:根据项目类型和用户选择生成不同的依赖和脚本
  • 双模式支持:同时支持 ESM 和 CommonJS
  • 按需加载:只添加用户选择的功能的依赖

打包与发布

1. Rollup 打包配置

javascript 复制代码
// rollup.config.js

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';

export default {
  // 入口文件
  input: 'src/index.ts',
  
  // 输出配置
  output: [{
    dir: 'lib',              // 输出目录
    format: 'esm',           // ES Module 格式
    sourcemap: true,         // 生成 source map
    preserveModules: true,   // 保留模块结构
    entryFileNames: '[name].js'
  }],
  
  // 外部依赖(不打包)
  external: [
    'fs', 'path', 'os', 'util', 'child_process',  // Node.js 内置模块
    'commander', 'chalk', 'ora',                   // CLI 依赖
    '@inquirer/prompts', 'fs-extra', 'ejs',       // 工具库
    'fast-glob', 'execa', 'semver'
  ],
  
  // 插件
  plugins: [
    resolve({
      extensions: ['.ts', '.js']
    }),
    commonjs(),
    json(),
    typescript({
      tsconfig: './tsconfig.json',
      outputToFilesystem: true
    }),
    terser()  // 压缩代码
  ]
};

为什么使用 Rollup?

  • Tree Shaking:更好的死代码消除
  • 模块保留preserveModules: true 保持源码结构
  • 更小的包体积:相比 Webpack 更适合库的打包

2. TypeScript 配置

json 复制代码
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "NodeNext",           // Node.js ESM 支持
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "declaration": true,            // 生成 .d.ts
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "outDir": "lib",                // 输出到 lib 目录
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*", "bin/**/*"],
  "exclude": ["node_modules", "src/templates/**/*"]
}

3. npm 发布配置

package.json 关键字段
json 复制代码
{
  "name": "objectx-cli",
  "version": "0.1.0",
  "type": "module",
  
  "bin": {
    "objectx-cli": "./bin/index.js"
  },
  
  "files": [
    "bin",
    "lib",
    "templates"
  ],
  
  "scripts": {
    "build": "rollup -c",
    "release": "bumpp && npm publish"
  },
  
  "keywords": [
    "cli", "react", "typescript", "vite",
    "component-library", "tooling"
  ],
  
  "engines": {
    "node": ">=16.0.0"
  }
}

关键点解析

  1. files 字段

    • 只发布 binlibtemplates 目录
    • 不发布源码 src/,减小包体积
  2. engines 字段

    • 限制 Node.js 版本 >= 16
    • 确保 ESM 特性可用
  3. keywords 字段

    • 提升 npm 搜索排名
发布流程
bash 复制代码
# 1. 构建
pnpm build

# 2. 版本管理(使用 bumpp)
pnpm release

# bumpp 会自动:
# - 提示选择版本号(patch/minor/major)
# - 更新 package.json
# - 创建 git tag
# - 推送到远程
# - 发布到 npm

后续

好了,这就是一个完整的企业级脚手架搭建流程,后续我还会介绍webpack、vite的实现原理,eslint插件开发,babel实现原理,babel插件开发,感兴趣的可以关注下

相关推荐
GISer_Jing2 小时前
前端GIS篇——WebGIS、WebGL、Java后端篇
java·前端·webgl
excel2 小时前
Vue3 EffectScope 源码解析与理解
前端·javascript·面试
不老刘4 小时前
Base UI:一款极简主义的「无样式」组件库
前端·ui
祈祷苍天赐我java之术4 小时前
Redis 有序集合解析
java·前端·windows·redis·缓存·bootstrap·html
ObjectX前端实验室4 小时前
【react18原理探究实践】React Effect List 构建与 Commit 阶段详解
前端·react.js
用户1456775610375 小时前
文件太大传不了?用它一压,秒变合格尺寸!
前端
用户1456775610375 小时前
再也不用一张张处理了!批量压缩神器来了,快收藏
前端
心.c5 小时前
一套完整的前端“白屏”问题分析与解决方案(性能优化)
前端·javascript·性能优化·html
white-persist5 小时前
【burp手机真机抓包】Burp Suite 在真机(Android and IOS)抓包手机APP + 微信小程序详细教程
android·前端·ios·智能手机·微信小程序·小程序·原型模式