前言

在我们团队,刚开始创建项目,是直接使用框架的 cli 进行创建项目,并修改相关配置。随着项目的增多,沉淀了两套模板,平台端及移动端。后来,我们自己写了一个简单的 cli,并提供了 create 及 lint 命令。但由于模板的问题,一直没有派上用场。
最近,我们正在进一步完善团队的基础设施。因此,期望将创建项目的功能独立出来,并做得更加简单易用。

实现方案

目前社区主流的创建项目主要有两种方案。一种是集成在 cli 当中,全局安装后进行创建项目,另外一种是使用 npm 或 yarn 提供的 create 方案,这也是我们这次选择的方案。
使用方式如下:

  1. $ npm init company-app [appName]
  2. or
  3. $ yarn create company-app [appName]

一般是执行 create 命令后,输入项目名称及选择相应模板即可创建项目。在我们的团队,是有约定项目命名 admin 结尾为平台端项目,mobile 结尾为移动端项目。因此,可以通过判断输入的目录名称判断是否可以直接自动选择模板。

梳理下来的方案流程图如下:
image.png

技术选型

在确定我们的方案后,通过阅读社区的一些相关项目源码,了解到在命令行及其交互方面,是有挺多的选择的。在了解相关类库后,可以通过 NPM Trends 可以查询相关类库的下载量、stars、forks、issues、updated、created、size 等数据比较。

命令行相关类库比较:
image.png

命令行交互相关类库比较:
前端工程化之创建项目 - 图3

在类库选择方面,这次我们的主要考量因素有:主流、维护情况好、体积小。因此,命令行类库选择了 commanderprompts
另外,还使用 chalk 做命令行文案样式处理、cross-spawn 做跨平台执行命令、@zeit/ncc 来打包构建项目。
值得一提的是,@zeit/ncc 会将整个项目及相关依赖打包成一个文件。这使得我们的创建项目时,非常快速。因为只需要安装一个包,而无需对包相关的依赖进行分析、下载、执行等。

代码实现

  1. 初始化项目,并安装依赖。
    目录结构如下:
  1. ├── src
  2. ├── create/ # create 逻辑目录
  3. ├── utils/ # 工具函数目录
  4. └── index.ts # 命令入口
  5. ├── templates/ # 模板目录
  6. ├── package.json
  7. └── tsconfig.json

package.json 如下:

  1. {
  2. "name": "create-company-app",
  3. "version": "0.0.1",
  4. "description": "Create apps with one command",
  5. "bin": {
  6. "create-company-app": "./dist/index.js"
  7. },
  8. "files": [
  9. "dist"
  10. ],
  11. "scripts": {
  12. "clean": "rimraf ./dist/",
  13. "dev": "yarn run clean && ncc build ./src/index.ts -o dist/ -w",
  14. "build": "yarn run clean && ncc build ./src/index.ts -o ./dist/ --minify --no-cache --no-source-map-register"
  15. },
  16. "devDependencies": {
  17. "@types/fs-extra": "^9.0.0",
  18. "@types/node": "^14.0.1",
  19. "@types/prompts": "^2.0.8",
  20. "@types/rimraf": "^3.0.0",
  21. "@types/validate-npm-package-name": "^3.0.0",
  22. "@zeit/ncc": "^0.22.1",
  23. "chalk": "^4.0.0",
  24. "commander": "^5.1.0",
  25. "cross-spawn": "^7.0.2",
  26. "fs-extra": "^9.0.0",
  27. "prompts": "^2.3.2",
  28. "rimraf": "^3.0.2",
  29. "typescript": "^3.9.2",
  30. "validate-npm-package-name": "^3.0.0"
  31. }
  32. }

tsconfig.json 如下:

  1. {
  2. "compilerOptions": {
  3. "target": "es2015",
  4. "moduleResolution": "node",
  5. "strict": true,
  6. "resolveJsonModule": true,
  7. "esModuleInterop": true,
  8. "skipLibCheck": false
  9. },
  10. "include": ["./src"]
  11. }
  1. 写一个简单的文件夹判断函数,及从 create-next-app 复制几个工具函数,主要是项目名校验及判断 npm 包管理。
    /utils/is-folder-exists.ts 判断文件夹是否为空:
  1. import { existsSync } from 'fs';
  2. import chalk from 'chalk';
  3. export default function isFolderExists(appPath: string, appName: string) {
  4. if (existsSync(appPath)) {
  5. console.log(`The folder ${chalk.green(appName)} already exists.`);
  6. console.log('Either try using a new directory name, or remove it.');
  7. return true;
  8. }
  9. return false;
  10. }

/utils/should-use-yarn.ts 判断是否使用 yarn:

  1. import { execSync } from 'child_process';
  2. export default function shouldUseYarn(): boolean {
  3. try {
  4. const userAgent = process.env.npm_config_user_agent;
  5. if (userAgent) {
  6. return Boolean(userAgent && userAgent.startsWith('yarn'));
  7. }
  8. execSync('yarnpkg --version', { stdio: 'ignore' });
  9. return true;
  10. } catch (e) {
  11. return false;
  12. }
  13. }

/utils/validate-pkg.ts 验证包名是否合法:

  1. import validateProjectName from 'validate-npm-package-name';
  2. export function validateNpmName(
  3. name: string
  4. ): { valid: boolean; problems?: string[] } {
  5. const nameValidation = validateProjectName(name);
  6. if (nameValidation.validForNewPackages) {
  7. return { valid: true };
  8. }
  9. return {
  10. valid: false,
  11. problems: [
  12. ...(nameValidation.errors || []),
  13. ...(nameValidation.warnings || []),
  14. ],
  15. }
  16. }
  1. 编写命令行的入口文件 /src/index.ts 。需要注意的是,文件前面的 #!/usr/bin/env node 是必须的,具体原因可见:What exactly does “/usr/bin/env node” do at the beginning of node files?
  1. #!/usr/bin/env node
  2. import chalk from 'chalk';
  3. import { Command } from 'commander';
  4. import create from './create';
  5. import packageJson from '../package.json';
  6. new Command(packageJson.name)
  7. .version(packageJson.version)
  8. .arguments('[project-directory]')
  9. .usage(chalk.green('<project-directory>'))
  10. .action(create)
  11. .allowUnknownOption()
  12. .parse(process.argv);
  1. 实现创建项目核心逻辑
    /src/create/index.ts 创建项目流程入口文件:
  1. import path from 'path';
  2. import chalk from 'chalk';
  3. import resolvePath from './resolve-path';
  4. import resolveType from './resolve-type';
  5. import copyTemplate from './copy-template';
  6. import installPkg from './install-pkg';
  7. import shouldUseYarn from '../utils/should-use-yarn';
  8. import isFolderExists from '../utils/is-folder-exists';
  9. export default async function create(inputPath: any) {
  10. const useYarn = shouldUseYarn();
  11. const originalDirectory = process.cwd();
  12. const displayedCommand = useYarn ? 'yarn' : 'npm run';
  13. const appPath = await resolvePath(inputPath);
  14. const appType = await resolveType(appPath);
  15. const appName = path.basename(appPath);
  16. const cdPath = path.join(originalDirectory, appName) === appPath ? appName : appPath;
  17. if (isFolderExists(appPath, appName)) {
  18. process.exit(1);
  19. }
  20. console.log(`Creating a new app in ${chalk.green(appPath)}.`);
  21. console.log();
  22. await copyTemplate({
  23. appPath,
  24. appType,
  25. });
  26. console.log('Installing packages. This might take a couple of minutes.');
  27. console.log();
  28. await installPkg({
  29. appPath,
  30. useYarn,
  31. });
  32. console.log(`${chalk.green('Success!')} Created ${appName} at ${appPath}`);
  33. console.log('Inside that directory, you can run several commands:');
  34. console.log();
  35. console.log(chalk.cyan(` ${displayedCommand} dev`));
  36. console.log(' Starts the development server.');
  37. console.log();
  38. console.log(chalk.cyan(` ${displayedCommand} build`));
  39. console.log(' Builds the app for production.');
  40. console.log();
  41. console.log('We suggest that you begin by typing:');
  42. console.log();
  43. console.log(chalk.cyan(' cd'), cdPath);
  44. console.log(
  45. ` ${chalk.cyan(`${displayedCommand} dev`)}`
  46. );
  47. console.log();
  48. }

/src/create/resolve-path.ts 解析项目名称:

  1. import path from 'path';
  2. import chalk from 'chalk';
  3. import prompts from 'prompts';
  4. import packageJson from '../../package.json';
  5. import { validateNpmName } from '../utils/validate-pkg';
  6. const commandName = packageJson.name;
  7. export default async function resolvePath(input: string): Promise<string> {
  8. let name = input?.trim();
  9. if (!name) {
  10. const { answer } = await prompts({
  11. type: 'text',
  12. name: 'answer',
  13. message: 'What is your project named?',
  14. validate: name => {
  15. const validation = validateNpmName(path.basename(path.resolve(name)));
  16. if (validation.valid) {
  17. return true;
  18. }
  19. return 'Invalid project name: ' + validation.problems![0];
  20. },
  21. });
  22. console.log(answer);
  23. if (typeof answer === 'string') {
  24. name = answer.trim();
  25. }
  26. }
  27. if (!name) {
  28. console.log()
  29. console.log('Please specify the project directory:')
  30. console.log(
  31. ` ${chalk.cyan(commandName)} ${chalk.green('<project-directory>')}`
  32. )
  33. console.log()
  34. console.log('For example:')
  35. console.log(` ${chalk.cyan(commandName)} ${chalk.green('app-admin')}`)
  36. console.log()
  37. console.log(
  38. `Run ${chalk.cyan(`${commandName} --help`)} to see all options.`
  39. )
  40. process.exit(1);
  41. }
  42. const projectPath = path.resolve(name);
  43. const projectName = path.basename(projectPath);
  44. const { valid, problems } = validateNpmName(projectName);
  45. if (!valid) {
  46. console.error(
  47. `Could not create a project called ${chalk.red(
  48. `"${projectName}"`
  49. )} because of npm naming restrictions:`
  50. )
  51. problems!.forEach(p => console.error(` ${chalk.red.bold('*')} ${p}`))
  52. process.exit(1)
  53. }
  54. return projectPath;
  55. }

/src/create/resolve-type.ts 解析项目模板类型:

  1. import * as path from 'path';
  2. import prompts from 'prompts';
  3. const appTypeList = ['admin', 'mobile'];
  4. export default async function resolveType(input: string): Promise<string> {
  5. let appType;
  6. const projectPath = path.resolve(input);
  7. const lastStr = path.basename(projectPath).split('-').pop();
  8. if (lastStr && appTypeList.includes(lastStr)) {
  9. appType = lastStr;
  10. } else {
  11. const { answer } = await prompts({
  12. type: 'select',
  13. name: 'answer',
  14. message: 'Pick a template',
  15. choices: appTypeList.map(i => ({ title: i, value: i })),
  16. });
  17. appType = answer;
  18. }
  19. return appType;
  20. }

/src/create/copy-template.ts 复制模板并创建项目(需要自行准备一些模板):

  1. import { copySync, readFileSync, writeFileSync } from 'fs-extra';
  2. import path from 'path';
  3. type Params = {
  4. appName: string;
  5. appType: string;
  6. appPath: string;
  7. };
  8. export default async function copyTemplate({ appName, appPath, appType }: Params) {
  9. const templatePath = path.join(__dirname, `../../templates/${appType}`);
  10. copySync(templatePath, appPath);
  11. const pkgPath = path.join(appPath, 'package.json');
  12. const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
  13. pkg.name = appName;
  14. writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
  15. }

/src/create/install-pkg.ts 安装项目依赖:

  1. import spawn from 'cross-spawn';
  2. type Params = {
  3. appPath: string;
  4. useYarn: boolean;
  5. };
  6. export default async function installPkg({ appPath, useYarn }: Params): Promise<void> {
  7. return new Promise((resolve, reject) => {
  8. process.chdir(appPath);
  9. const command = useYarn ? 'yarn' : 'npm';
  10. const args = ['install'];
  11. const child = spawn(command, args, {
  12. stdio: 'inherit',
  13. env: { ...process.env, ADBLOCK: '1', DISABLE_OPENCOLLECTIVE: '1' },
  14. });
  15. child.on('close', code => {
  16. if (code !== 0) {
  17. reject({ command: `${command} ${args.join(' ')}` });
  18. return;
  19. }
  20. resolve();
  21. })
  22. });
  23. }
  1. 调试发包,本地可以使用 link 进行调试。
  1. $ yarn run dev
  2. $ yarn link

结语

以上就是一个简单的创建项目命令行库的代码实现。包括模板,构建打包后,gzip 体积不到 100kb。不算安装依赖,创建项目非常快。
随着业务的发展,我们可能会增加更多功能。比如集成在 Gitlab 创建项目、在 Jenkins 上做好相关配置等。

参考资料

create-next-app
create-react-native-app
create-react-app
create-umi
commander vs yargs vs @oclif/command vs cac vs func
inquirer vs enquirer vs prompts