前段时间阅读了 https://juejin.cn/post/7260144602471776311#heading-25 这篇文章;本文做一个梳理和笔记;
主要聚焦的知识点如下:
推荐使用pnpm搭建mono-repo风格的工程;
mono-repo工程可以包含多个子工程,并且每个子工程都可以独立编译打包,并将打包的产物发成npm包;
这样在同一个工程中,我们可以写库,也可以写demo;
步骤如下:
pnpm init 命令,生成package.json文件;- packages:
- - 'packages/*'
- - 'demos/*'
配置后,声明了 packages 和 demos 文件夹中子工程是同属一个工作空间的,工作空间中的子工程编译打包的产物都可以被其它子工程引用。
- "bin": {
- "zy-script": "./bin/index.js"
- },
- #!/usr/bin/env node
- console.log('hellow, zy-cli');
pnpm init 命令,生成package.json文件;pnpm add @zy/zy-cli -F app 会在dependencies自动添加依赖;
- "scripts": {
- "zy": "zy-script"
- },
- "dependencies": {
- "@zy/zy-cli": "workspace:^"
- },

hellow, zy-cli
小节:
到目前为止,我们成功创建了mono-repo风格的项目结构;
packages > zy-cli 是我们脚手架工程,在bin中自定义了指令;
demos > app 是使用 zy-cli 脚手架的示例工程,利用pnpm的workspace,指定了工作区中zy-cli依赖,在script中自定义使用 zy-cli中声明的命令;
整个工程结构如下:
- |-- my-cli
- |-- package.json
- |-- pnpm-lock.yaml
- |-- pnpm-workspace.yaml
- |-- demos
- | |-- app
- | |-- package.json
- |-- packages
- |-- zy-cli
- |-- package.json
- |-- bin
- |-- index.js
现在,我们思考一下,一个脚手架工程需要哪些模块?
接下来,我们一步一步实现他们;
当我们执行命令的时候,经常会带一些参数,如何获取并利用这些参数;
nodeJS 中 process 模块,可以获取当前进程相关的全局环境信息 - 命令行参数,环境变量,命令运行路径等;
利用 progress.argv 获取;
- const process = require('process');
- // 获取命令参数
- console.log(process.argv);
或者可以采用更便捷的方案 yargs 开源库;
我们为zy-cli 安装 yargs:
pnpm add yargs --F zy-cli
注意:--F zy-cli 指的是指定给该工程安装;-W 是全局安装的意思
我们在zy-cli - bin - index.js 中写入如下测试代码
- #!/usr/bin/env node
- // 此处遵循CommonJS规范
- const yargs = require('yargs');
-
- console.log('name', yargs.argv.name);
在demos - app 目录下执行 pnpm zy --name=zhang
打印输出如下:

我们在使用vue-cli的时候,都用过 vue creat app 之类的命令;creat就是子命令;
我们通过 yarg.command 来实现;
yargs.command(cmd, desc, builder, handler)
下面我们定义一个creat命令:
- #!/usr/bin/env node
- const yargs = require('yargs');
- console.log('name', yargs.argv.name);
- yargs.command({
- // 字符串,子命令名称,也可以传递数组,如 ['create', 'c'],表示子命令叫 create,其别名是 c
- command: 'create
' , - // 字符串,子命令描述信息;
- describe: 'create a new project',
- // 对象,子命令的配置项;builder也可以是一个函数
- builder: {
- name: {
- alias: 'n', // 别名
- demandOption: true, // 是否必填
- describe: 'name of a project', // 描述
- default: 'app' // 默认
- }
- },
- // 函数形式的
- // builder: (yargs) => {
- // return yargs.option('name', {
- // alias: 'n',
- // demand: true,
- // describe: 'name of a project',
- // type: 'string'
- // })
- // },
- handler: (argv) => {
- console.log('argv', argv);
- }
- });
我们运行一下这个命令:pnpm zy create my-app
输出如下:

当我们使用vue create xxx 的时候,命令行会出现选项式的交互,让我们选择配置;
这里我们也实现一下;使用 inquirer 库;
运行命令安装 pnpm add inquirer@8.2.5 --F zy-cli
inquirer主要做了三件事情:
- const inquirer = require('inquirer');
-
- function inquirerPrompt(argv) {
- // 先获取到了命令行中的name
- const { name } = argv;
- return new Promise((resolve, reject) => {
- inquirer.prompt([
- {
- type: 'input',
- name: 'name',
- message: '模板名称',
- default: name,
- validate: function (val) {
- if (!/^[a-zA-Z]+$/.test(val)) {
- return "模板名称只能含有英文";
- }
- if (!/^[A-Z]/.test(val)) {
- return "模板名称首字母必须大写"
- }
- return true;
- },
- },
- {
- type: 'list',
- name: 'type',
- message: '模板类型',
- choices: ['表单', '动态表单', '嵌套表单'],
- filter: function (value) {
- return {
- '表单': "form",
- '动态表单': "dynamicForm",
- '嵌套表单': "nestedForm",
- }[value];
- },
- },
- {
- type: 'list',
- message: '使用什么框架开发',
- choices: ['react', 'vue'],
- name: 'frame',
- }
- ]).then(answers => {
- const { frame } = answers;
- if (frame === 'react') {
- inquirer.prompt([
- {
- type: 'list',
- message: '使用什么UI组件库开发',
- choices: [
- 'Ant Design',
- ],
- name: 'library',
- }
- ]).then(answers1 => {
- resolve({
- ...answers,
- ...answers1,
- })
- }).catch(error => {
- reject(error)
- })
- }
-
- if (frame === 'vue') {
- inquirer.prompt([
- {
- type: 'list',
- message: '使用什么UI组件库开发',
- choices: [ 'Element'],
- name: 'library',
- }
- ]).then(answers2 => {
- resolve({
- ...answers,
- ...answers2,
- })
- }).catch(error => {
- reject(error)
- })
- }
- }).catch(error => {
- reject(error)
- })
- })
-
- }
-
- exports.inquirerPrompt = inquirerPrompt;
其中 inquirer.prompt() 返回的是一个 Promise,我们可以用 then 获取上个询问的答案,根据答案再发起对应的内容。
在index.js 中引入并使用
- #!/usr/bin/env node
-
- const yargs = require('yargs');
- const { inquirerPrompt } = require('./inquirer');
-
- // 命令配置
- yargs.command({
- // 字符串,子命令名称,也可以传递数组,如 ['create', 'c'],表示子命令叫 create,其别名是 c
- command: 'create
' , - // 字符串,子命令描述信息;
- describe: 'create a new project',
- // 对象,子命令的配置项;builder也可以是一个函数
- builder: {
- name: {
- alias: 'n', // 别名
- demandOption: true, // 是否必填
- describe: 'name of a project', // 描述
- default: 'app' // 默认
- }
- },
- // 函数形式的
- // builder: (yargs) => {
- // return yargs.option('name', {
- // alias: 'n',
- // demand: true,
- // describe: 'name of a project',
- // type: 'string'
- // })
- // },
- handler: (argv) => {
- inquirerPrompt(argv).then((answers) => {
- console.log(answers);
- });
- }
- }).argv;
我们运行 pnpm zy create my-app 试试:

前几节我们实现了一个可以读取命令行的cli工程配置;
接下来,我们就要深入到cli脚手架的构建;
首先是文件夹的拷贝。我们使用 copy-dir 库来实现文件夹拷贝;
安装:pnpm add copy-dir --F zy-cli
bin中创建copy.js,实现简单的copy函数,check函数
- const copydir = require('copy-dir')
- const fs = require('fs');
-
- function copyDir (from, to, option) {
- copydir.sync(from, to, option);
- }
-
- /**
- * Checks if a directory or file exists at the given path.
- * @param {string} path - The path to check for existence.
- * @returns {boolean} - Returns true if the directory or file exists, false otherwise.
- */
- function checkMkdirExists(path){
- return fs.existsSync(path);
- }
-
- exports.copyDir = copyDir;
- exports.checkMkdirExists = checkMkdirExists;
这几个函数比较简单,但是主要难点在于使用;具体来说就是 from,to参数;
先定个需求,我们运行 creat 选择 form类型 命令的时候,需要将 zy-cli > src > form 文件夹拷贝到 demos > app > src > 中;
__dirname 是用来动态获取当前文件模块所属目录的绝对路径。比如在 bin/index.js 文件中使用 __dirname ,__dirname 表示就是 bin/index.js 文件所属目录的绝对路径 ~/Desktop/my-cli/zy-cli/bin。
使用 path.resolve( [from…], to )将相对路径转成绝对路径;
那我们模板的路径就是:path.resolve( __dirname,'../src/form' );或者path.resolve( __dirname,'../src/${type}')
我们运行脚手架命令是在 app 目录下;pnpm zy 执行的是 app > packages.json ,所以在node脚本中,可以使用 process.cwd() 获取文件路径;
那我们拷贝的目标路径就是:path.resolve(process.cwd(), './src/${
- handler: (argv) => {
- inquirerPrompt(argv).then((answers) => {
- // 此处已经获取到了完整的模版参数;开始进行文件处理
- const { name, type, frame, library } = answers;
-
- // 判断是否存在该项目文件夹
- const isMkdirExists = checkMkdirExists(
- path.resolve(process.cwd(),`./${name}`)
- );
-
- if (isMkdirExists) {
- console.log( `${name}文件夹已经存在`);
- } else {
- const templatePath = path.resolve(__dirname, `../src/${type}`);
- const targetPath = path.resolve(process.cwd(), `./${name}`);
- copyDir(templatePath, targetPath);
- }
- });
- }
运行一下命令:pnpm zy create my-app,选择表单类型;回车,拷贝成功;

如果我需要将文件拷贝到 app > pages >
我们简单实现一个目录守卫,帮我们创建不存在的目录;
- const copydir = require('copy-dir')
- const fs = require('fs');
-
- function copyDir (from, to, option) {
- // 目录守卫,不存在的目录结构会去创建
- mkdirGuard(to);
- copydir.sync(from, to, option);
- }
-
- /**
- * Checks if a directory or file exists at the given path.
- * @param {string} path - The path to check for existence.
- * @returns {boolean} - Returns true if the directory or file exists, false otherwise.
- */
- function checkMkdirExists(path){
- return fs.existsSync(path);
- }
-
- // 目录守卫
- function mkdirGuard(target) {
- try {
- fs.mkdirSync(target, { recursive: true });
- } catch (e) {
- mkdirp(target)
- function mkdirp(dir) {
- if (fs.existsSync(dir)) { return true }
- const dirname = path.dirname(dir);
- mkdirp(dirname);
- fs.mkdirSync(dir);
- }
- }
- }
-
- exports.copyDir = copyDir;
- exports.checkMkdirExists = checkMkdirExists;
- exports.mkdirGuard = mkdirGuard;
文件操作,主要使用 fs.readFileSync 读取被拷贝的文件内容,然后创建一个文件,再使用 fs.writeFileSync 写入文件内容;这两个api都是比较熟悉的老朋友了;不做过多介绍;
我们定义一个 copyFile函数:
- function copyFile(from, to) {
- const buffer = fs.readFileSync(from);
- const parentPath = path.dirname(to);
- mkdirGuard(parentPath)
- fs.writeFileSync(to, buffer);
- }
-
- exports.copyFile = copyFile;
使用方法与copyDir 类似,只不过需要精确到文件;这里就不演示了;
我们在定义脚手架的时候,会获取很多类型的命令参数,有些参数可能对我们模板文件产生影响。
例如,根据命令行中的name,动态修改packages中的name;
这里,我们需要依赖 mustache ;安装:pnpm add mustache --F zy-cli
我们增加一个 renderTemplate 函数:
接受动态模板的path路径,data:动态模版的配置数据;
Mustache.render(str, data) 接受动态模版和配置数据;
Mustache.render('{{name}}',{name:'张三'})
- const Mustache = require('mustache');
- function renderTemplate(path, data = {}) {
- const str = fs.readFileSync(path, { encoding: 'utf8' })
- return Mustache.render(str, data);
- }
- exports.renderTemplate = renderTemplate;
再定义一个copyTemplate 函数:
path.extname 获取文件扩展名,如果不是tpl类型的,直接当做文件处理;
- function copyTemplate(from, to, data = {}) {
- if (path.extname(from) !== '.tpl') {
- return copyFile(from, to);
- }
- const parentToPath = path.dirname(to);
- // 目录守卫
- mkdirGuard(parentToPath);
- // 写入文件
- fs.writeFileSync(to, renderTemplate(from, data));
- }
在index.js中试验一下:
- const templatePath = path.resolve(__dirname, `../src/${type}/packages.tpl`);
- const targetPath = path.resolve(process.cwd(), `./${name}/packages.json`);
- copyTemplate(templatePath, targetPath, {name: name})
- {
- "name": "{{name}}",
- "version": "1.0.0",
- "description": "",
- "main": "index.js",
- "scripts": {
- },
- "keywords": [],
- "author": "",
- "license": "ISC"
- }
运行完创建命令后,成功生成packages.json 文件,并且将 name字段替换成功;

基础绑定:
Mustache.render('<span>{{name}}span>',{name:'张三'})
绑定子属性
Mustache.render('<span>{{ifno.name}}span>', { ifno: { name: '张三' } })
循环渲染
- // {{#key}} {{/key}} 开启和结束循环
- Mustache.render(
- '<span>{{#list}}{{name}}{{/list}}span>',
- {
- list: [
- { name: '张三' },
- { name: '李四' },
- { name: '王五' },
- ]
- }
- )
循环渲染 + 二次处理
- Mustache.render(
- '{{#list}}{{info}}{{/list}}',
- {
- list: [
- { name: '张三' },
- { name: '李四' },
- { name: '王五' },
- ],
- info() {
- return this.name + ',';
- }
- }
- )
条件渲染
- // 使用 {{#key}} {{/key}} 语法 和 {{^key}} {{/key}} 语法来实现条件渲染,
- // 当 key 为 false、0、[]、{}、null,既是 key == false 为真,
- // {{#key}} {{/key}} 包裹的内容不渲染,
- // {{^key}} {{/key}} 包裹的内容渲染
- Mustache.render(
- '<span>{{#show}}显示{{/show}}{{^show}}隐藏{{/show}}span>',
- {
- show: false
- }
- )
我们在选择完框架和UI库的时候,可以帮助目标项目自动安装依赖;
我们使用 node 中提供的 child_process 子进程来实现;
我们定义一个 manager 函数;
- const path = require('path');
- const { exec } = require('child_process');
-
- // 组件库映射,前面是用户输入/选择,后面是目标安装的组件库
- const LibraryMap = {
- 'Ant Design': 'antd',
- 'iView': 'view-ui-plus',
- 'Ant Design Vue': 'ant-design-vue',
- 'Element': 'element-plus',
- }
-
- function install(cmdPath, options) {
- // 用户选择的框架 和 组件库
- const { frame, library } = options;
- // 安装命令
- const command = `pnpm add ${frame} && pnpm add ${LibraryMap[library]}`
- return new Promise(function (resolve, reject) {
- // 执行安装命令
- exec(
- command,
- {
- // 命令执行的目录
- cwd: path.resolve(cmdPath),
- },
- function (error, stdout, stderr) {
- console.log('error', error);
- console.log('stdout', stdout);
- console.log('stderr', stderr)
- }
- )
- })
- }
- exports.install = install;
使用:
- // 传入当前进程的目录,以及用户选择的配置
- install(process.cwd(), answers)
试验一下:pnpm create xxxx;成功安装;

但是安装过程没有进度展示;我们使用 ora 来丰富安装加载动画;
安装:pnpm add ora@5.4.1 --F zy-cli
使用:
- const path = require('path');
- const { exec } = require('child_process');
- const ora = require("ora");
-
- // 组件库映射,前面是用户输入/选择,后面是目标安装的组件库
- const LibraryMap = {
- 'Ant Design': 'antd',
- 'iView': 'view-ui-plus',
- 'Ant Design Vue': 'ant-design-vue',
- 'Element': 'element-plus',
- }
-
- function install(cmdPath, options) {
- // 用户选择的框架 和 组件库
- const { frame, library } = options;
- // 串行安装命令
- const command = `pnpm add ${frame} && pnpm add ${LibraryMap[library]}`
- return new Promise(function (resolve, reject) {
- const spinner = ora();
- spinner.start(
- `正在安装依赖,请稍等`
- );
- // 执行安装命令
- exec(
- command,
- {
- // 命令执行的目录
- cwd: path.resolve(cmdPath),
- },
- function (error) {
- if (error) {
- reject();
- spinner.fail(`依赖安装失败`);
- return;
- }
- spinner.succeed(`依赖安装成功`);
- resolve()
- }
- )
- })
- }
- exports.install = install;
再次执行,已经有状态提示了;

使用verdaccio搭建私有npm仓库的步骤本文不赘述,可以参考这篇文章;搭建自己的私有npm库
// TODO 部署过程中使用docker-compose,遇到一些问题,预计单独开一篇文章去记录;
假设我们已经有了npm私库;ip:http://xxxxxx:4873/
我们使用 nrm 去管理 npm 的仓库地址
- // 全局安装
- npm install -g nrm
- // 查看所有的仓库
- nrm ls
- // 切换仓库
- nrm use <name>
- // 添加仓库
- nrm add <name> <address>
推送之前,我们需要修改 packages.json 中的信息:
- {
- "name": "@zy/zy-cli",
- "version": "1.0.0",
- "description": "",
- "main": "index.js",
- "bin": {
- "zy-script": "./bin/index.js"
- },
- "scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
- },
- // 规定上传到npm包中的文件
- "files": [
- "bin",
- "src"
- ],
- "keywords": [],
- "author": "",
- "license": "ISC",
- "dependencies": {
- "copy-dir": "^1.3.0",
- "inquirer": "8.2.5",
- "mustache": "^4.2.0",
- "ora": "5.4.1",
- "yargs": "^17.7.2"
- }
- }
推送:
pnpm publish --registry http://xxxxx:4873/
刷新我们的 vedaccio,已经存在这个包了

使用:
我们在Desktop中新建一个空白文件夹;
mkdir cli-test
cd cli-test
pnpm init
nrm use zy
pnpm i @zy/zy-cli
此时,我们的cli-test项目已经成功安装了私有npm仓库的 zy-cli 项目
在packages.json 中添加命令
- "scripts": {
- "zy-script": "zy-script"
- },
执行 pnpm zy-script create Myapp
成功安装所有依赖并拷贝文件;
目前初步实现了mono-repo工程,还需要添加统一的publish脚本,包含版本自增等;
cli 脚手架不需要打包,所以还需要为这个工程添加一个 组件库,工具函数库等类型的包;