Skip to content

26如何设计一个“万能”项目脚手架?

脚手架是工程化中不可缺少的一环。究竟什么是脚手架呢?广义上来说,脚手架就是为了保证各施工过程顺利进行而搭设的工作平台。

编程领域的脚手架主要为了完成新项目的启动和搭建,能够帮助开发者提升效率和开发体验。对于前端来说,从零开始建立一个项目是复杂的,因此也就存在了较多类型的脚手架:

  • Vue/React 框架类脚手架

  • Webpack 等构建配置类脚手架

  • 混合脚手架,比如大家熟悉的 Vue-cli 或者 create-react-app

这一讲我们就深入这些脚手架的原理进行讲解。

命令行工具原理和实现

现代脚手架离不开命令行工具,命令行工具即 Command-line interfaces(CLIs) ,是编程领域的重要概念,也是我们开发中经常接触到的工具之一。

比如 Webpack、Babel、npm、Yarn 等都是典型的命令行。此外,流畅的命令行能够迅速启动一个脚手架,实现自动化和智能化流程。这一部分,我们就使用 Node.js 来开发一个命令行。

我们先来看几个开发命令行工具的关键依赖。

  • inquirerenquirerprompts:可以处理复杂的用户输入,完成命令行输入交互。

  • chalkkleur:使终端可以输出彩色信息文案。

  • ora:可以让命令行出现好看的 Spinners。

  • boxen:可以在命令行中画出 Boxes 区块。

  • listr:可以在命令行中画出进度列表。

  • meowarg:可以进行基础的命令行参数解析。

  • commanderyargs:可以进行更加复杂的命令行参数解析。

我们的目标是支持以下面这种启动方式,建立我们的项目,如下代码:

java
npm init @lucas/project

npm 6.1 及以上版本,我们都可以使用npm inityarn create来启动我们的项目,比如下面两个命令就是等价的:

java
# 使用 Node.js
npm init @lucas/project
# 使用 Yarn
yarn create @lucas/project

启动命令行项目

下面开始进入开发,首先我们创建项目:

java
mkdir create-project && cd create-project
npm init --yes

接着进入create-project文件中,创建src目录及src/cli.js文件,cli.js文件内容如下:

java
export function cli(args) {
 console.log(args);
}

接下来,为了使我们的命令行可以在终端执行,我们新建bin/目录,并在其下创建一个create-project文件,代码为:

java
#!/usr/bin/env node
require = require('esm')(module /*, options*/);
require('../src/cli').cli(process.argv);

上述代码中,我们使用了esm 模块,这样就可以在其他文件中使用import关键字,即 ESM 模块规范了。我们在该入口文件中,引入cli.js并将命令行参数process.argv传给cli函数执行。

当然,为了能够正常使用esm 模块,我们需要先安装,执行npm install esm

此时 package.json 内容如下:

java
{
 "name": "@lucas/create-project",
 "version": "1.0.0",
 "description": "A CLI to bootstrap my new projects",
 "main": "src/index.js",
 "bin": {
   "@lucas/create-project": "bin/create-project",
   "create-project": "bin/create-project"
 },
 "publishConfig": {
   "access": "public"
 },
 "scripts": {
   "test": "echo \"Error: no test specified\" && exit 1"
 },
 "keywords": [
   "cli",
   "create-project"
 ],
 "author": "YOUR_AUTHOR",
 "license": "MIT",
 "dependencies": {
   "esm": "^3.2.18"
 }
}

这里需要注意的是 bin字段,我们注册了两个可用命令:一个是带有 npm 命名 scope 的,一个是常规的create-project命令。

为了调试方便,我们使用npm link命令进行调试,在终端中项目目录下执行:

java
npm link

上述命令可以在全局范围内添加一个软链到当前项目中。我们执行:

java
create-project --yes

就会得到下面这样的输出:

java
[ '/usr/local/Cellar/node/11.6.0/bin/node',
  '/Users/dkundel/dev/create-project/bin/create-project',
  '--yes' ]

该输出,就对应了代码中的process.argv

解析处理命令行输入

在解析处理命令行输入之前,我们需要设计命令行支持的几个选项,如下。

  • [template]:支持默认的几种模板类型,用户可以通过 select 进行选择。

  • --git:等同于git init去创建一个新的 Git 项目。

  • --install:支持自动下载项目依赖。

  • --yes:跳过命令行交互,直接使用默认配置。

我们利用inquirer使得命令行支持用户交互,同时使用arg来解析命令行参数,安装相关依赖命令:

java
npm install inquirer arg

接下来编写命令行参数解析逻辑,在cli.js中添加:

java
import arg from 'arg';
// 解析命令行参数为 options
function parseArgumentsIntoOptions(rawArgs) {
 // 使用 arg 进行解析
 const args = arg(
   {
     '--git': Boolean,
     '--yes': Boolean,
     '--install': Boolean,
     '-g': '--git',
     '-y': '--yes',
     '-i': '--install',
   },
   {
     argv: rawArgs.slice(2),
   }
 );
 return {
   skipPrompts: args['--yes'] || false,
   git: args['--git'] || false,
   template: args._[0],
   runInstall: args['--install'] || false,
 }
}
export function cli(args) {
 // 获取命令行配置
 let options = parseArgumentsIntoOptions(args);
 console.log(options);
}

上述代码很好理解,我已经加入了相关注释。接下来,我们实现使用默认配置和交互式配置选择逻辑,如下代码:

java
import arg from 'arg';
import inquirer from 'inquirer';
function parseArgumentsIntoOptions(rawArgs) {
	// ...
}
async function promptForMissingOptions(options) {
 // 默认使用名为 JavaScript 的模板
 const defaultTemplate = 'JavaScript';
 // 使用默认模板则直接返回
 if (options.skipPrompts) {
   return {
     ...options,
     template: options.template || defaultTemplate,
   };
 }
 // 准备交互式问题 
 const questions = [];
 if (!options.template) {
   questions.push({
     type: 'list',
     name: 'template',
     message: 'Please choose which project template to use',
     choices: ['JavaScript', 'TypeScript'],
     default: defaultTemplate,
   });
 }
 if (!options.git) {
   questions.push({
     type: 'confirm',
     name: 'git',
     message: 'Initialize a git repository?',
     default: false,
   });
 }
 // 使用 inquirer 进行交互式查询,并获取用户答案选项
 const answers = await inquirer.prompt(questions);
 return {
   ...options,
   template: options.template || answers.template,
   git: options.git || answers.git,
 };
}
export async function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 options = await promptForMissingOptions(options);
 console.log(options);
}

这样一来,我们就可以获取到类似:

java
{ 
	skipPrompts: false,
    git: false,
    template: 'JavaScript',
    runInstall: false 
}

相关的配置了。

下面我们需要完成下载模板到本地的逻辑,我们事先准备好两种名为typescriptjavascript的模板,并将相关的模板存储在项目的根目录中。当然你在实际开发应用中,可以内置更多的模板。

我们使用ncp包实现跨平台递归拷贝文件,使用chalk做个性化输出。安装相关依赖如下:

java
npm install ncp chalk

src/目录下,创建新的文件main.js,代码如下:

java
import chalk from 'chalk';
import fs from 'fs';
import ncp from 'ncp';
import path from 'path';
import { promisify } from 'util';
const access = promisify(fs.access);
const copy = promisify(ncp);
// 递归拷贝文件
async function copyTemplateFiles(options) {
 return copy(options.templateDirectory, options.targetDirectory, {
   clobber: false,
 });
}
// 创建项目
export async function createProject(options) {
 options = {
   ...options,
   targetDirectory: options.targetDirectory || process.cwd(),
 };
 const currentFileUrl = import.meta.url;
 const templateDir = path.resolve(
   new URL(currentFileUrl).pathname,
   '../../templates',
   options.template.toLowerCase()
 );
 options.templateDirectory = templateDir;
 try {
 	 // 判断模板是否存在
   await access(templateDir, fs.constants.R_OK);
 } catch (err) {
 	 // 模板不存在 
   console.error('%s Invalid template name', chalk.red.bold('ERROR'));
   process.exit(1);
 }
 // 拷贝模板
 await copyTemplateFiles(options);
 console.log('%s Project ready', chalk.green.bold('DONE'));
 return true;
}

上述代码我们通过import.meta.url来获取当前模块的 URL 路径,并通过fs.constants.R_OK判断对应模板是否存在。此时cli.js关键内容为:

java
import arg from 'arg';
import inquirer from 'inquirer';
import { createProject } from './main';
function parseArgumentsIntoOptions(rawArgs) {
// ...
}
async function promptForMissingOptions(options) {
// ...
}
export async function cli(args) {
 let options = parseArgumentsIntoOptions(args);
 options = await promptForMissingOptions(options);
 await createProject(options);
}

接下来,我们需要完成git的初始化以及依赖安装工作,这时候需要用到以下内容。

  • execa:允许开发中使用类似git的外部命令。

  • pkg-install:使用yarn installnpm install安装依赖。

  • listr:给出当前进度 progress。

执行安装依赖:

java
npm install execa pkg-install listr

更新main.js为:

java
import chalk from 'chalk';
import fs from 'fs';
import ncp from 'ncp';
import path from 'path';
import { promisify } from 'util';
import execa from 'execa';
import Listr from 'listr';
import { projectInstall } from 'pkg-install';
const access = promisify(fs.access);
const copy = promisify(ncp);
// 拷贝模板
async function copyTemplateFiles(options) {
 return copy(options.templateDirectory, options.targetDirectory, {
   clobber: false,
 });
}
// 初始化 git
async function initGit(options) {
 // 执行 git init
 const result = await execa('git', ['init'], {
   cwd: options.targetDirectory,
 });
 if (result.failed) {
   return Promise.reject(new Error('Failed to initialize git'));
 }
 return;
}
// 创建项目
export async function createProject(options) {
 options = {
   ...options,
   targetDirectory: options.targetDirectory || process.cwd()
 };
 const templateDir = path.resolve(
   new URL(import.meta.url).pathname,
   '../../templates',
   options.template
 );
 options.templateDirectory = templateDir;
 try {
 	 // 判断模板是否存在
   await access(templateDir, fs.constants.R_OK);
 } catch (err) {
   console.error('%s Invalid template name', chalk.red.bold('ERROR'));
   process.exit(1);
 }
 // 声明 tasks
 const tasks = new Listr([
   {
     title: 'Copy project files',
     task: () => copyTemplateFiles(options),
   },
   {
     title: 'Initialize git',
     task: () => initGit(options),
     enabled: () => options.git,
   },
   {
     title: 'Install dependencies',
     task: () =>
       projectInstall({
         cwd: options.targetDirectory,
       }),
     skip: () =>
       !options.runInstall
         ? 'Pass --install to automatically install dependencies'
         : undefined,
   },
 ]);
 // 并行执行 tasks
 await tasks.run();
 console.log('%s Project ready', chalk.green.bold('DONE'));
 return true;
}

这样一来,我们的命令行就大功告成了。

接下来我们主要谈谈模板维护问题,上述实现中,模板维护在了本地。为了更大范围的合作,模板可以共享到 GitHub 中。我们可以在 package.json 文件中声明 files 字段,如下所示:

java
},
 "files": [
   "bin/",
   "src/",
   "templates/"
 ]
}

以此来声明哪些文件可以被npm publish出去。

另外一种做法是将模板单独维护到一个 GitHub 仓库当中。在创建一个项目时,我们使用 download-git-repo来下载模板。

从命令行到万能脚手架

前面我们分析了一个命令行的实现和开发原理,这些内容并不复杂。但如何从一个命令行升级到一个万能脚手架呢?我们继续探讨。

使用命令行启动并创建一个基于模板的项目只能说是一个脚手架的雏形。对比大家熟悉的vue-clicreate-react-app@tarojs/cliumi等,我们还需要从可伸缩性、用户友好性方面考虑:

  • 如何使模板支持版本管理

  • 模板如何进行扩展

  • 如何进行版本检查和更新

  • 如何自定义构建

下面我们分别来讨论。

模板支持版本管理可以使用 npm 维护模板,这样借助 npm 的版本管理,我们可以天然地支持不同版本的模板。当然在脚手架的设计中,要加入对版本的选择和处理

如前文所说,模板扩展可以借助中心化手段,集成开发者力量,提供模板市场。这里需要注意的是,针对不同模板或功能区块的可插拔性是非常重要的。下面我们会具体展开。

版本检查可以使用 npm view @lucas/create-project version\ 来进行版本检查,并根据环境版本,提示用户更新。

构建是一个老大难问题,不同项目的构建需求是不同的。参照第 23 讲"npm scripts:打造一体化的构建和部署流程"所讲,不同构建脚本可以考虑单独抽象,提供可插拔式封装 。比如jslib-base这个库的设计,这也是一个"万能脚手架"。

我们具体来看,使用脚手架初始化一个项目的过程,本质是根据输入信息进行模板填充。比如,如果开发者选择使用 TypeScript 以及英语环境构建项目,并使用 rollup 进行构建。那么核心流程中在初始化 rollup.config.js 文件时,我们读取 rollup.js.tmpl,并将相关信息(比如对 TypeScript 的编译)填写到模板中。

类似的情况还有初始化 .eslintrc.ts.json、package.json、CHANGELOG.en.md、README.en.md,以及 doc.en.md 等。

所有这些文件的生成过程都需要可插拔,更理想的是,这些插件是一个独立的运行时。因此我们可以将每一个脚手架文件(即模板文件)的初始化视作一个独立的应用,由命令行统一指挥调度。

比如 jslib-base 这个库对于 rollup 构建的处理,支持开发者传入 option,由命令行处理函数,结合不同的配置版本进行自定义分配。具体代码如下:

java
const path = require('path');
const util = require('@js-lib/util');
function init(cmdPath, name, option) {
    // type 为 js 和 ts 两种
    const type = option.type;
		// module 分为:umd/esm/commonjs
    const module = option.module = option.module.reduce((prev, name) => (prev[name] = name, prev), ({}));
		// rollup 基本配置
    util.copyTmpl(
        path.resolve(__dirname, `./template/${type}/rollup.js.tmpl`),
        path.resolve(cmdPath, name, 'config/rollup.js'),
        option,
    );
		// umd 模式
    if (module.umd) {
        util.copyFile(
            path.resolve(__dirname, `./template/${type}/rollup.config.aio.js`),
            path.resolve(cmdPath, name, 'config/rollup.config.aio.js')
        );
    }
    // esm 模式
    if (module.esm) {
        util.copyFile(
            path.resolve(__dirname, `./template/${type}/rollup.config.esm.js`),
            path.resolve(cmdPath, name, 'config/rollup.config.esm.js')
        );
    }
    // commonjs 模式
    if (module.commonjs) {
        util.copyFile(
            path.resolve(__dirname, `./template/${type}/rollup.config.js`),
            path.resolve(cmdPath, name, 'config/rollup.config.js')
        );
    }

    util.mergeTmpl2JSON(
        path.resolve(__dirname, `./template/${type}/package.json.tmpl`),
        path.resolve(cmdPath, name, 'package.json'),
        option,
    );
    if (type === 'js') {
        util.copyFile(
            path.resolve(__dirname, `./template/js/.babelrc`),
            path.resolve(cmdPath, name, '.babelrc')
        );
    } else if (type === 'ts') {
        util.copyFile(
            path.resolve(__dirname, `./template/ts/tsconfig.json`),
            path.resolve(cmdPath, name, 'tsconfig.json')
        );
    }
}
module.exports = {
    init: init,
}

如上代码,根据用户输入,使用了不同版本的 rollup 构建内容。

相信你了解了这些内容,对于实现一个自己的 create-react-app、vue-cli 会更有心得和启发。

总结

这一讲我们从开发一个命令行入手,分析了实现一个脚手架的方方面面。实现一个企业级脚手架需要不断打磨和优化,不断增强用户体验和可操作性,比如处理边界情况、终端提示等。更重要的是,对构建逻辑的抽象和封装,根据业务需求,不断扩展命令和模板。

本讲内容总结如下:

从 0 到 1 简单,但是从 1 开始出发,就需要开发者不断思考和总结。下一讲我们将开启 Node.js 的学习,来实现一个 SSR 应用。我们会直入正题,不再过多学习 Node.js 的基础内容,也请你提前做好准备。