写点什么

前端工程化之创建项目

用户头像
春生
关注
发布于: 2020 年 05 月 28 日
前端工程化之创建项目

前言

在我们团队,刚开始创建项目,是直接使用框架的 cli 进行创建项目,并修改相关配置。随着项目的增多,沉淀了两套模板,平台端及移动端。后来,我们自己写了一个简单的 cli,并提供了 create 及 lint 命令。但由于模板的问题,一直没有派上用场。

最近,我们正在进一步完善团队的基础设施。因此,期望将创建项目的功能独立出来,并做得更加简单易用。


实现方案

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

使用方式如下:


$ npm init company-app [appName]or$ yarn create company-app [appName]
复制代码


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


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


技术选型

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


命令行相关类库比较:


命令行交互相关类库比较:



在类库选择方面,这次我们的主要考量因素有:主流、维护情况好、体积小。因此,命令行类库选择了 commander、[prompts](https://github.com/terkelg/prompts)

另外,还使用 chalk 做命令行文案样式处理、[cross-spawn](https://github.com/moxystudio/node-cross-spawn) 做跨平台执行命令、[@zeit/ncc](https://github.com/zeit/ncc) 来打包构建项目。

值得一提的是,@zeit/ncc 会将整个项目及相关依赖打包成一个文件。这使得我们的创建项目时,非常快速。因为只需要安装一个包,而无需对包相关的依赖进行分析、下载、执行等。


代码实现

  1. 初始化项目,并安装依赖。

目录结构如下:

├── src│   ├── create/              # create 逻辑目录│   ├── utils/               # 工具函数目录│   └── index.ts             # 命令入口├── templates/               # 模板目录├── package.json└── tsconfig.json
复制代码


package.json 如下:

{  "name": "create-company-app",  "version": "0.0.1",  "description": "Create apps with one command",  "bin": {    "create-company-app": "./dist/index.js"  },  "files": [    "dist"  ],  "scripts": {    "clean": "rimraf ./dist/",    "dev": "yarn run clean && ncc build ./src/index.ts -o dist/ -w",    "build": "yarn run clean && ncc build ./src/index.ts -o ./dist/ --minify --no-cache --no-source-map-register"  },  "devDependencies": {    "@types/fs-extra": "^9.0.0",    "@types/node": "^14.0.1",    "@types/prompts": "^2.0.8",    "@types/rimraf": "^3.0.0",    "@types/validate-npm-package-name": "^3.0.0",    "@zeit/ncc": "^0.22.1",    "chalk": "^4.0.0",    "commander": "^5.1.0",    "cross-spawn": "^7.0.2",    "fs-extra": "^9.0.0",    "prompts": "^2.3.2",    "rimraf": "^3.0.2",    "typescript": "^3.9.2",    "validate-npm-package-name": "^3.0.0"  }}
复制代码


tsconfig.json 如下:

{  "compilerOptions": {    "target": "es2015",    "moduleResolution": "node",    "strict": true,    "resolveJsonModule": true,    "esModuleInterop": true,    "skipLibCheck": false  },  "include": ["./src"]}
复制代码


  1. 写一个简单的文件夹判断函数,及从 create-next-app 复制几个工具函数,主要是项目名校验及判断 npm 包管理。

/utils/is-folder-exists.ts 判断文件夹是否为空:

import { existsSync } from 'fs';import chalk from 'chalk';
export default function isFolderExists(appPath: string, appName: string) { if (existsSync(appPath)) { console.log(`The folder ${chalk.green(appName)} already exists.`); console.log('Either try using a new directory name, or remove it.'); return true; }
return false;}
复制代码


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

import { execSync } from 'child_process';
export default function shouldUseYarn(): boolean { try { const userAgent = process.env.npm_config_user_agent; if (userAgent) { return Boolean(userAgent && userAgent.startsWith('yarn')); } execSync('yarnpkg --version', { stdio: 'ignore' }); return true; } catch (e) { return false; }}
复制代码


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

import validateProjectName from 'validate-npm-package-name';
export function validateNpmName( name: string): { valid: boolean; problems?: string[] } { const nameValidation = validateProjectName(name); if (nameValidation.validForNewPackages) { return { valid: true }; }
return { valid: false, problems: [ ...(nameValidation.errors || []), ...(nameValidation.warnings || []), ], }}
复制代码


  1. 编写命令行的入口文件 /src/index.ts 。需要注意的是,文件前面的 #!/usr/bin/env node 是必须的,具体原因可见:What exactly does “/usr/bin/env node” do at the beginning of node files?

#!/usr/bin/env nodeimport chalk from 'chalk';import { Command } from 'commander';import create from './create';import packageJson from '../package.json';
new Command(packageJson.name) .version(packageJson.version) .arguments('[project-directory]') .usage(chalk.green('<project-directory>')) .action(create) .allowUnknownOption() .parse(process.argv);
复制代码


  1. 实现创建项目核心逻辑

/src/create/index.ts 创建项目流程入口文件:

import path from 'path';import chalk from 'chalk';import resolvePath from './resolve-path';import resolveType from './resolve-type';import copyTemplate from './copy-template';import installPkg from './install-pkg';import shouldUseYarn from '../utils/should-use-yarn';import isFolderExists from '../utils/is-folder-exists';
export default async function create(inputPath: any) { const useYarn = shouldUseYarn(); const originalDirectory = process.cwd(); const displayedCommand = useYarn ? 'yarn' : 'npm run'; const appPath = await resolvePath(inputPath); const appType = await resolveType(appPath); const appName = path.basename(appPath); const cdPath = path.join(originalDirectory, appName) === appPath ? appName : appPath;
if (isFolderExists(appPath, appName)) { process.exit(1); }
console.log(`Creating a new app in ${chalk.green(appPath)}.`); console.log();
await copyTemplate({ appPath, appType, });
console.log('Installing packages. This might take a couple of minutes.'); console.log();
await installPkg({ appPath, useYarn, });
console.log(`${chalk.green('Success!')} Created ${appName} at ${appPath}`); console.log('Inside that directory, you can run several commands:'); console.log(); console.log(chalk.cyan(` ${displayedCommand} dev`)); console.log(' Starts the development server.'); console.log(); console.log(chalk.cyan(` ${displayedCommand} build`)); console.log(' Builds the app for production.'); console.log(); console.log('We suggest that you begin by typing:'); console.log(); console.log(chalk.cyan(' cd'), cdPath); console.log( ` ${chalk.cyan(`${displayedCommand} dev`)}` ); console.log();}
复制代码


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

import path from 'path';import chalk from 'chalk';import prompts from 'prompts';import packageJson from '../../package.json';import { validateNpmName } from '../utils/validate-pkg';
const commandName = packageJson.name;
export default async function resolvePath(input: string): Promise<string> { let name = input?.trim();
if (!name) { const { answer } = await prompts({ type: 'text', name: 'answer', message: 'What is your project named?', validate: name => { const validation = validateNpmName(path.basename(path.resolve(name))); if (validation.valid) { return true; } return 'Invalid project name: ' + validation.problems![0]; }, });
console.log(answer);
if (typeof answer === 'string') { name = answer.trim(); } }
if (!name) { console.log() console.log('Please specify the project directory:') console.log( ` ${chalk.cyan(commandName)} ${chalk.green('<project-directory>')}` ) console.log() console.log('For example:') console.log(` ${chalk.cyan(commandName)} ${chalk.green('app-admin')}`) console.log() console.log( `Run ${chalk.cyan(`${commandName} --help`)} to see all options.` ) process.exit(1); }
const projectPath = path.resolve(name); const projectName = path.basename(projectPath);
const { valid, problems } = validateNpmName(projectName); if (!valid) { console.error( `Could not create a project called ${chalk.red( `"${projectName}"` )} because of npm naming restrictions:` )
problems!.forEach(p => console.error(` ${chalk.red.bold('*')} ${p}`)) process.exit(1) }
return projectPath;}
复制代码


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

import * as path from 'path';import prompts from 'prompts';
const appTypeList = ['admin', 'mobile'];
export default async function resolveType(input: string): Promise<string> { let appType; const projectPath = path.resolve(input); const lastStr = path.basename(projectPath).split('-').pop();
if (lastStr && appTypeList.includes(lastStr)) { appType = lastStr; } else { const { answer } = await prompts({ type: 'select', name: 'answer', message: 'Pick a template', choices: appTypeList.map(i => ({ title: i, value: i })), });
appType = answer; }
return appType;}
复制代码


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

import { copySync, readFileSync, writeFileSync } from 'fs-extra';import path from 'path';
type Params = { appName: string; appType: string; appPath: string;};
export default async function copyTemplate({ appName, appPath, appType }: Params) { const templatePath = path.join(__dirname, `../../templates/${appType}`);
copySync(templatePath, appPath);
const pkgPath = path.join(appPath, 'package.json'); const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
pkg.name = appName;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));}
复制代码


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

import spawn from 'cross-spawn';
type Params = { appPath: string; useYarn: boolean;};
export default async function installPkg({ appPath, useYarn }: Params): Promise<void> { return new Promise((resolve, reject) => { process.chdir(appPath);
const command = useYarn ? 'yarn' : 'npm'; const args = ['install']; const child = spawn(command, args, { stdio: 'inherit', env: { ...process.env, ADBLOCK: '1', DISABLE_OPENCOLLECTIVE: '1' }, });
child.on('close', code => { if (code !== 0) { reject({ command: `${command} ${args.join(' ')}` }); return; } resolve(); }) });}
复制代码


  1. 调试发包,本地可以使用 link 进行调试。

$ yarn run dev$ 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


发布于: 2020 年 05 月 28 日阅读数: 73
用户头像

春生

关注

还未添加个人签名 2018.02.12 加入

还未添加个人简介

评论

发布
暂无评论
前端工程化之创建项目