跳到主要内容

探索脚手架 cli 工作原理

· 阅读需 17 分钟

前端开发中,我们经常看见有一些全局脚手架包通过 xx create app-name 或者 npx xx app-name 等命令,就可以快速生成一个对应的模板项目,他们是怎么做到的呢?

项目创建

演示环境:

  • NodeJS: > 16.13.0
  • npm: 6.x

初始化

首先尝试去初始化你的目录

mkdir my-cli

进入目录

cd my-cli

初始化 npm

npm init -y

设置入口文件

echo "console.log('hello my-cli')" >> index.js

设置 bin

因为我们的包是可执行文件,因为需要在 package.json 中设置一下 bin

bin 字段是命令名到本地文件名的映射。通过 npm 安装后,在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。

package.json
{
"name": "my-cli",
"version": "1.0.0",
"description": "",
"main": "index.js",
+ "bin": {
+ "my-cli": "./index.js"
+ },
+ "type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

可以配置多个 bin,key 是可执行文件命令名称,值是文件路径。

"type": "modue 可以让我们直接使用 ES Module 语法

如何测试我们的包?我们还没有发布,因此无法下载。也不可能每次修改都要通过 npm 发布吧?

npm link 命令会在 nodejs 安装目录下的 node_modules 帮我们生成一个符号链接(windows 下则是快捷键),这样我们就可以在全局的任意位置使用这个 npm 包了。调试 npm 插件通常也都这么做。

一定要在 包目录下执行 npm link

执行完 npm link 之后,我们就可以直接在命令行执行我们的全局包了

my-cli

如果没有正确的输出 'hello my-cli',并且抛了个错误,说明你的当前系统可能是 Mac OS 或者 linux

通过在文件顶部添加 #! /usr/bin/env node,告诉 类 unix(Mac OS、Linux) 系统,声明这是一个可执行的 node 脚本。

index.js
+ #!/usr/bin/env node

console.log('hello my-cli')

然后再次执行 my-cli,然后应该就会正常输出 'hello my-cli'

配置模板

因为我们此次的目录是为了生成模板项目,因此我们需要先提供模板文件到项目中,创建 template 目录

mkdir template

这里我们先使用 vite 生成一个模板

cd  template

react-js

yarn create vite react --template react

准备好了模板,如何生成?

复制模板

用户执行命令的时候,通过 node api 去复制模板目录到用户的当前目录下

index.js
#! /usr/bin/env node

import fs from 'fs';
import path from 'path';

const currentRoot = new URL('.', import.meta.url).pathname;
const templateDir = path.resolve(currentRoot, './template/react');

fs.cpSync(templateDir, './react-app', { recursive: true });

然后我们可以在任意的地方执行 my-cli,这里选择在当前根目录执行,会发现根目录多了一个名为 react-app 目录,说明我们的模板已经安装成功了。

进阶

上述已经完成了一个最基本的脚手架功能了,但是暴露出来的问题也很多,需要进一步优化。

动态目录名称

由于生成出来的目录名称被我们写死,但是一般情况下,生成的目录名称应该由用户决定,这样就不需要再二次修改了。

minimist

process.argv 这个属性可以帮我提取出用户提供的命令行参数,但是使用起来不是很直观,这里我们选择 minimist 这个工具库。

npm i minimist
index.js
  #! /usr/bin/env node

import fs from 'fs';
import path from 'path';
+ import minimist from 'minimist';

+ const args = minimist(process.argv.slice(2));
+ console.log(args);

const currentRoot = new URL('.', import.meta.url).pathname;
const templateDir = path.resolve(currentRoot, './template/react');

fs.cpSync(templateDir, './react-app', { recursive: true });

接下来修改我们的命令脚本

my-cli my-react-app

将会输出

{ _: [ 'my-react-app' ] }

拿到了动态的目录名称,就可以作为复制的目标目录了

index.js
  #! /usr/bin/env node

import fs from 'fs';
import path from 'path';
import minimist from 'minimist';

const args = minimist(process.argv.slice(2));
+ const projectName = args._[0]

const currentRoot = new URL('.', import.meta.url).pathname;
const templateDir = path.resolve(currentRoot, './template/react');

- fs.cpSync(templateDir, './', { recursive: true });
+ fs.cpSync(templateDir, `./${projectName}`, { recursive: true });

重新执行 my-cli my-react-app,则会看到新名为 my-react-app 的目录出来了

动态内容

虽然目录名称修改了,但是会发现 package.json 中的 name 字段还是写死的,这里应该也要是动态生成的。

因此我们需要复制出这段 json,然后删掉这个文件,配置动态 projectName

  #! /usr/bin/env node

import fs from 'fs';
import path from 'path';
import minimist from 'minimist';

const args = minimist(process.argv.slice(2),);
const projectName = args._[0];
+ const workSpaceRoot = path.resolve(projectName);

const currentRoot = new URL('.', import.meta.url).pathname;
const templateDir = path.resolve('./template/react');

fs.cpSync(templateDir, `./${projectName}`, { recursive: true });

+ const packageJsonContent = {
+ "name": projectName,
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.15",
+ "@types/react-dom": "^18.0.6",
+ "@vitejs/plugin-react": "^2.0.0",
+ "vite": "^3.0.0"
+ }
+ }
+
+ // 写入新的 package.json
+ fs.writeFileSync(path.join(workSpaceRoot, 'package.json'), JSON.stringify(packageJsonContent, null, 4));

这里我们设置了自己配置的 package.json 内容,二次写入到了 projectName 根目录下

JSON.stringify(packageJsonContent, null, 4) 最后一个参数 代表缩进,可以自行选择

其他文件内容也可以参考这种方式,比如 index.html<title> 标签,README.md 的标题内容 等等...

多模板

我们目前只提供了一个模板,但是大多数情况往往需要多种模板提供用户选择,比如 js 还是 ts,vue 还是 react

再添加一个 react-ts 模板

yarn create vite react-ts --template react-ts

记得也要删除 package.json

用户通过命令行选项 --template xx 选择生成特定模板

index.js
  #! /usr/bin/env node

import fs from 'fs';
import path from 'path';
import minimist from 'minimist';
+ import chalk from 'chalk';

const args = minimist(process.argv.slice(2),);
const projectName = args._[0];
const workSpaceRoot = path.resolve(projectName);
+ const templateName = args.template || 'react';
const currentRoot = new URL('.', import.meta.url).pathname;
+ const templateDir = path.resolve(currentRoot, `./template/${templateName}`);
+ const templateNameOptions = fs.readdirSync(path.resolve(currentRoot, './template'));

+ if (!templateNameOptions.includes(templateName)) {
+ console.log(chalk.red(`${templateName} 不是一个合法的模板选项,请使用以下值:${templateNameOptions.join(', ')}`));
+ process.exit(1);
+ }

fs.cpSync(templateDir, `./${projectName}`, { recursive: true });

+ const isTS = templateName === 'react-ts';

const packageJsonContent = {
"name": projectName,
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
- "build": "vite build",
+ "build": `${isTS ? 'tsc && ' : ''}vite build`,
"preview": vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.0",
"vite": "^3.0.0"
}
}
+ // 如果是 ts 模板,则添加 typescript 依赖
+ if (isTS) {
+ packageJsonContent.devDependencies.typescript = "^4.6.4"
+ }

fs.writeFileSync(path.join(workSpaceRoot, 'package.json'), JSON.stringify(packageJsonContent, null, 2));

我们新增了 chalk 这个库来对命令行输出信息美化

并设置了 react 为默认模板选项,通过读取 template 下的子目录来提示用户设置合法的模板值

现在,命令行添加 --template react-ts 选项,可以为我们生成一个 React + TypeScript 的模板了

my-cli my-react-app --template react-ts

目录已存在校验

目前每次执行命令都会强制替换的之前的目录,这样其实很不安全,缺少一个已存在校验

我们可以在执行复制命令之前进行判断,通过 fs.existsSync API

if (fs.existsSync(workSpaceRoot)) {
console.log(chalk.red(`${projectName} 文件夹已存在,请先删除`));
process.exit(1);
}

初始化 git

为了优化体验,我们可以提前初始化好项目的 git

在代码的最后,添加以下代码

import { execSync } from 'child_process';

process.chdir(workSpaceRoot);

execSync(`git init`, { stdio: 'ignore' });

console.log(`\n${projectName} 创建完成 \n\n 路径:${chalk.green(workSpaceRoot)}\n\n`);
console.log(`进入目录: ${chalk.blue(`cd ${projectName}`)}`);
console.log(`安装依赖: ${chalk.blue(`npm install`)}\n\n`);

process.chdir 是 node 的全局 API,可以帮助我们修改当前 node 工作区,为什么要做这一步?

当前的执行目录是在项目目录的上一层,如果在这里执行 git 初始化,那肯定是不对的,我们应该进入到项目内部执行 git 命令

execSync 是 node 子进程 child_process 的一个 API,可以用来执行 shell 脚本,第二个参数我们用到了 stdio: 'ignore' 选项,目的是告诉它不要输出 git 初始化的日志

最后,我们添加了一些创建完成的成功提示信息

发布

以上大致功能开发完成,可以发布到 npm 公共仓库了

发布之前,我们需要登录,如果没有注册的话,需要到 https://www.npmjs.com/ 进行账号注册

登陆之前,请检查当前 npm 的源是否为官方源,如果不是,可能是设置了镜像,需要先切换到官方源

npm config get registry

如果输出的地址不是 https://registry.npmjs.org/,则设置为

npm config set registry https://registry.npmjs.org/

接着可以开始登录了,按照提示输入账号密码以及邮箱地址

如果出现需要验证码的提示,则打开你注册的邮箱,应该会收到一封 npm 发来的邮件,里面有登录需要的验证码

npm login

登录成功之后,可以开始发布了

npm publish

首先会出现的第一个错误应该是

npm ERR! This package has been marked as private
npm ERR! Remove the 'private' field from the package.json to publish it.

原因是我们的 package.json 中默认是 "private": true,需要改成 "private": false 或删除这个配置

再次执行 npm publish 应该会遇到第二个错误: 403 Forbidden,因为我们使用的 name 字段 my-cli 太大众化了,这个插件包名已经被别人注册过了

我们可以使用 scope 的组织命名法 @你的npm用户名/你的包名,例如: @vue/cli 这也是目前 npm 插件包的流行命名方式

  • 因为 npm 用户名是唯一的,这可以完全阻止了与别人命名冲突的问题
  • 其次这种命名方式在 node_modules 中也会将你的个人组织下的包分类在一起,方便快速找到

唯一的缺点可能就是这类名称方式最终看起来不太好看:)

需要注意的是,每次发布新版本时,都应该设置更高的版本号,package.json 中 version 字段

全局安装

不出意外的话,到此已经成功发布到 npm 公共镜像了,现在可以通过 npm 的方式全局安装我们的包了。

值得一题的是,安装之前我们需要先解除之前 npm link 的包全局路径(已存在),以免发生冲突

npm unlink my-cli

然后正式全局安装

npm i @你的npm用户名/你的包名 -g

现在,我们可以全局的任意地方执行生成模板的命令了

my-cli my-react-app

或者 生成 ts 模板

my-cli my-react-app --template react-ts

毫秒之内,就提示我们创建完成了。打开 my-react-app,检查一下文件,是否都正常显示。

.gitignore 无法上传问题

细心的朋友,可能会发现,之前模板里的 .gitignore git 忽略文件消失了。

原因是因为 npm 发布的时候,它会自动过滤掉 .gitignore npmrc 这类文件,所以永远上传不到 npm 服务器上,所以下载下来也是没有的。

如何解决?我们可以换个思路,把模板文件里的 .gitignore 取个其他的名字(比如去掉 . 开头,叫 gitignore),先让它成功上传,然后我们在执行安装脚本的时候(生成好目录之后),再去把名字修改回来。

通过 fs.renameSync API 可以帮我我们实现重命名的功能

fs.renameSync(path.join(root, 'gitignore'), path.join(root, '.gitignore'));

npx 远程安装

目前的这种安装方式要求我们必须先把 npm 包安装到本地的存储当中,如果全局包过多,就会导致本地的包吃掉的硬盘内容特别多。

目前更推荐的做法是,通过 npx 命令执行全局脚本, 一次性调用,无需本地安装

执行之前,我们先删除本地的全局包

npm uninstall @winme/my-cli -g

通过npx 重新执行

npx @winme/my-cli

如果有多个 bin,可以添加 -p 参数,用于指定执行某个 bin 名称,如果只有一个 bin 可以省略这个参数

npx @winme/my-cli -p my-cli

npx --ignore-existing

也可以通过 npx --ignore-existing 忽略本地的同名模块,强制安装使用远程模块

npx --no-install

也可以通过 npx --no-install 强制使用本地模块,不下载远程模块


至此,脚手架工具的功能已经基本开发完成,对于许多公司来说,许多项目用到的技术栈,配置,甚至基本布局样式都是可复用的,这个时候我们完全可以通过脚手架的模板预先配置好,以免过多的复制粘贴还容易出错

这样一来就可以大大节省时间成本了。

tips

过滤发布内容

默认发布时会把我们所有的文件和目录都上传,但通常我们需要排除一些文件或者指定一些文件,有 2 种方式可以做到

  1. 根目录下创建 .npmignore 文件,会排除掉你列出的文件和目录,就像写 .gitignore 一样

  2. package.join 中 添加 files 字段,是一个数组,可以添加你需要指定上传的文件和目录,也可以作为 .npmignore 的白名单






码字不易,如遇书写错误,烦请指出~