前端开发中,我们经常看见有一些全局脚手架包通过 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
安装后,在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。
{
"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
link
如何测试我们的包?我们还没有发布,因此无法下载。也不可能每次修改都要通过 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
脚本。
+ #!/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 去复制模板目录到用户的当前目录下
#! /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
#! /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' ] }
拿到了动态的目录名称,就可以作为复制的目标目录了
#! /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 选择生成特定模板
#! /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
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 种方式可以做到
根目录下创建
.npmignore
文件,会排除掉你列出的文件和目录,就像写.gitignore
一样在
package.join
中 添加files
字段,是一个数组,可以添加你需要指定上传的文件和目录,也可以作为.npmignore
的白名单
码字不易,如遇书写错误,烦请指出~