• 构建mono-repo风格的脚手架库


    前段时间阅读了 https://juejin.cn/post/7260144602471776311#heading-25 这篇文章;本文做一个梳理和笔记;

    主要聚焦的知识点如下:

    • 如何搭建脚手架工程
    • 如何开发调试
    • 如何处理命令行参数
    • 如何实现用户交互
    • 如何拷贝文件夹或文件
    • 如何动态生成文件
    • 如何处理路径
    • 如何自动安装依赖

    step1:初始化工程

    推荐使用pnpm搭建mono-repo风格的工程;

    mono-repo工程可以包含多个子工程,并且每个子工程都可以独立编译打包,并将打包的产物发成npm包;

    这样在同一个工程中,我们可以写库,也可以写demo;

    步骤如下:

    1. 执行 pnpm init 命令,生成package.json文件;
    2. 新建 pnpm-workspace.yaml 文件,添加如下配置:
    1. packages:
    2. - 'packages/*'
    3. - 'demos/*'

    配置后,声明了 packages 和 demos 文件夹中子工程是同属一个工作空间的,工作空间中的子工程编译打包的产物都可以被其它子工程引用。

    1. 在packages文件夹下 新建zy-cli文件夹;
    2. cd 到 zy-cli文件夹下,运行 pnpm init 初始化;
    3. zy-cli 下的 packages.json 中声明 bin 命令 zy-script;
    1. "bin": {
    2. "zy-script": "./bin/index.js"
    3. },
    1. 添加 bin文件夹,添加index.js文件;写入如下代码
    1. #!/usr/bin/env node
    2. console.log('hellow, zy-cli');
    1. demos 文件夹中存放使用脚手架的演示项目,我们先建一个app文件夹;
    2. 执行 pnpm init 命令,生成package.json文件;
    3. 在app中依赖 @zy/zy-cli ( 此处依赖名称与zy-cli 的package.json 中 name一致 )

    pnpm add @zy/zy-cli -F app 会在dependencies自动添加依赖;

    1. 并添加script指令 zy (与zy-cli中声明的指令一致);
    1. "scripts": {
    2. "zy": "zy-script"
    3. },
    4. "dependencies": {
    5. "@zy/zy-cli": "workspace:^"
    6. },
    1. 执行pnpm -i 安装依赖;

    1. 在app目录下运行:pnpm zy;成功输出了 hellow, zy-cli

    小节:

    到目前为止,我们成功创建了mono-repo风格的项目结构;

    packages > zy-cli 是我们脚手架工程,在bin中自定义了指令;

    demos > app 是使用 zy-cli 脚手架的示例工程,利用pnpm的workspace,指定了工作区中zy-cli依赖,在script中自定义使用 zy-cli中声明的命令;

    整个工程结构如下:

    1. |-- my-cli
    2. |-- package.json
    3. |-- pnpm-lock.yaml
    4. |-- pnpm-workspace.yaml
    5. |-- demos
    6. | |-- app
    7. | |-- package.json
    8. |-- packages
    9. |-- zy-cli
    10. |-- package.json
    11. |-- bin
    12. |-- index.js

    现在,我们思考一下,一个脚手架工程需要哪些模块?

    • 命令参数模块
    • 用户交互模块
    • 文件拷贝模块
    • 动态文件生成模块
    • 自动安装依赖模块

    接下来,我们一步一步实现他们;

    step2:命令参数模块

    当我们执行命令的时候,经常会带一些参数,如何获取并利用这些参数;

    nodeJS 中 process 模块,可以获取当前进程相关的全局环境信息 - 命令行参数,环境变量,命令运行路径等;

    利用 progress.argv 获取;

    1. const process = require('process');
    2. // 获取命令参数
    3. console.log(process.argv);

    或者可以采用更便捷的方案 yargs 开源库;

    我们为zy-cli 安装 yargs:

    pnpm add yargs --F zy-cli

    注意:--F zy-cli 指的是指定给该工程安装;-W 是全局安装的意思

    我们在zy-cli - bin - index.js 中写入如下测试代码

    1. #!/usr/bin/env node
    2. // 此处遵循CommonJS规范
    3. const yargs = require('yargs');
    4. console.log('name', yargs.argv.name);

    在demos - app 目录下执行 pnpm zy --name=zhang

    打印输出如下:

    step3:创建子命令

    我们在使用vue-cli的时候,都用过 vue creat app 之类的命令;creat就是子命令;

    我们通过 yarg.command 来实现;

    yargs.command(cmd, desc, builder, handler)

    • cmd:字符串,子命令名称,也可以传递数组,如 ['create', 'c'],表示子命令叫 create,其别名是 c;
    • desc:字符串,子命令描述信息;
    • builder:子命令参数信息配置,比如可以设置参数(builder也可以是一个函数):
      • alias:别名;
      • demand:是否必填;
      • default:默认值;
      • describe:描述信息;
      • type:参数类型,string | boolean | number。
    • handler: 函数,可以在这个函数中专门处理该子命令参数。

    下面我们定义一个creat命令:

    1. #!/usr/bin/env node
    2. const yargs = require('yargs');
    3. console.log('name', yargs.argv.name);
    4. yargs.command({
    5. // 字符串,子命令名称,也可以传递数组,如 ['create', 'c'],表示子命令叫 create,其别名是 c
    6. command: 'create ',
    7. // 字符串,子命令描述信息;
    8. describe: 'create a new project',
    9. // 对象,子命令的配置项;builder也可以是一个函数
    10. builder: {
    11. name: {
    12. alias: 'n', // 别名
    13. demandOption: true, // 是否必填
    14. describe: 'name of a project', // 描述
    15. default: 'app' // 默认
    16. }
    17. },
    18. // 函数形式的
    19. // builder: (yargs) => {
    20. // return yargs.option('name', {
    21. // alias: 'n',
    22. // demand: true,
    23. // describe: 'name of a project',
    24. // type: 'string'
    25. // })
    26. // },
    27. handler: (argv) => {
    28. console.log('argv', argv);
    29. }
    30. });

    我们运行一下这个命令:pnpm zy create my-app

    输出如下:

    step4:增加用户交互

    当我们使用vue create xxx 的时候,命令行会出现选项式的交互,让我们选择配置;

    这里我们也实现一下;使用 inquirer 库;

    运行命令安装 pnpm add inquirer@8.2.5 --F zy-cli

    inquirer主要做了三件事情:

    1. 询问用户问题
    2. 获取用户输入
    3. 校验用户输入
    1. const inquirer = require('inquirer');
    2. function inquirerPrompt(argv) {
    3. // 先获取到了命令行中的name
    4. const { name } = argv;
    5. return new Promise((resolve, reject) => {
    6. inquirer.prompt([
    7. {
    8. type: 'input',
    9. name: 'name',
    10. message: '模板名称',
    11. default: name,
    12. validate: function (val) {
    13. if (!/^[a-zA-Z]+$/.test(val)) {
    14. return "模板名称只能含有英文";
    15. }
    16. if (!/^[A-Z]/.test(val)) {
    17. return "模板名称首字母必须大写"
    18. }
    19. return true;
    20. },
    21. },
    22. {
    23. type: 'list',
    24. name: 'type',
    25. message: '模板类型',
    26. choices: ['表单', '动态表单', '嵌套表单'],
    27. filter: function (value) {
    28. return {
    29. '表单': "form",
    30. '动态表单': "dynamicForm",
    31. '嵌套表单': "nestedForm",
    32. }[value];
    33. },
    34. },
    35. {
    36. type: 'list',
    37. message: '使用什么框架开发',
    38. choices: ['react', 'vue'],
    39. name: 'frame',
    40. }
    41. ]).then(answers => {
    42. const { frame } = answers;
    43. if (frame === 'react') {
    44. inquirer.prompt([
    45. {
    46. type: 'list',
    47. message: '使用什么UI组件库开发',
    48. choices: [
    49. 'Ant Design',
    50. ],
    51. name: 'library',
    52. }
    53. ]).then(answers1 => {
    54. resolve({
    55. ...answers,
    56. ...answers1,
    57. })
    58. }).catch(error => {
    59. reject(error)
    60. })
    61. }
    62. if (frame === 'vue') {
    63. inquirer.prompt([
    64. {
    65. type: 'list',
    66. message: '使用什么UI组件库开发',
    67. choices: [ 'Element'],
    68. name: 'library',
    69. }
    70. ]).then(answers2 => {
    71. resolve({
    72. ...answers,
    73. ...answers2,
    74. })
    75. }).catch(error => {
    76. reject(error)
    77. })
    78. }
    79. }).catch(error => {
    80. reject(error)
    81. })
    82. })
    83. }
    84. exports.inquirerPrompt = inquirerPrompt;

    其中 inquirer.prompt() 返回的是一个 Promise,我们可以用 then 获取上个询问的答案,根据答案再发起对应的内容。

    在index.js 中引入并使用

    1. #!/usr/bin/env node
    2. const yargs = require('yargs');
    3. const { inquirerPrompt } = require('./inquirer');
    4. // 命令配置
    5. yargs.command({
    6. // 字符串,子命令名称,也可以传递数组,如 ['create', 'c'],表示子命令叫 create,其别名是 c
    7. command: 'create ',
    8. // 字符串,子命令描述信息;
    9. describe: 'create a new project',
    10. // 对象,子命令的配置项;builder也可以是一个函数
    11. builder: {
    12. name: {
    13. alias: 'n', // 别名
    14. demandOption: true, // 是否必填
    15. describe: 'name of a project', // 描述
    16. default: 'app' // 默认
    17. }
    18. },
    19. // 函数形式的
    20. // builder: (yargs) => {
    21. // return yargs.option('name', {
    22. // alias: 'n',
    23. // demand: true,
    24. // describe: 'name of a project',
    25. // type: 'string'
    26. // })
    27. // },
    28. handler: (argv) => {
    29. inquirerPrompt(argv).then((answers) => {
    30. console.log(answers);
    31. });
    32. }
    33. }).argv;

    我们运行 pnpm zy create my-app 试试:

    step5:文件夹拷贝

    前几节我们实现了一个可以读取命令行的cli工程配置;

    接下来,我们就要深入到cli脚手架的构建;

    首先是文件夹的拷贝。我们使用 copy-dir 库来实现文件夹拷贝;

    安装:pnpm add copy-dir --F zy-cli

    bin中创建copy.js,实现简单的copy函数,check函数

    1. const copydir = require('copy-dir')
    2. const fs = require('fs');
    3. function copyDir (from, to, option) {
    4. copydir.sync(from, to, option);
    5. }
    6. /**
    7. * Checks if a directory or file exists at the given path.
    8. * @param {string} path - The path to check for existence.
    9. * @returns {boolean} - Returns true if the directory or file exists, false otherwise.
    10. */
    11. function checkMkdirExists(path){
    12. return fs.existsSync(path);
    13. }
    14. exports.copyDir = copyDir;
    15. exports.checkMkdirExists = checkMkdirExists;

    这几个函数比较简单,但是主要难点在于使用;具体来说就是 from,to参数;

    先定个需求,我们运行 creat 选择 form类型 命令的时候,需要将 zy-cli > src > form 文件夹拷贝到 demos > app > src > 中;

    1. 我们分析一下,如何获取当前模板的位置;也就是 copyDir 的 from 参数;

    __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}')

    1. 接下来,我们确定 copyDir 的 to 参数;也就是目标文件夹

    我们运行脚手架命令是在 app 目录下;pnpm zy 执行的是 app > packages.json ,所以在node脚本中,可以使用 process.cwd() 获取文件路径;

    那我们拷贝的目标路径就是:path.resolve(process.cwd(), './src/${}')

    1. handler: (argv) => {
    2. inquirerPrompt(argv).then((answers) => {
    3. // 此处已经获取到了完整的模版参数;开始进行文件处理
    4. const { name, type, frame, library } = answers;
    5. // 判断是否存在该项目文件夹
    6. const isMkdirExists = checkMkdirExists(
    7. path.resolve(process.cwd(),`./${name}`)
    8. );
    9. if (isMkdirExists) {
    10. console.log( `${name}文件夹已经存在`);
    11. } else {
    12. const templatePath = path.resolve(__dirname, `../src/${type}`);
    13. const targetPath = path.resolve(process.cwd(), `./${name}`);
    14. copyDir(templatePath, targetPath);
    15. }
    16. });
    17. }

    运行一下命令:pnpm zy create my-app,选择表单类型;回车,拷贝成功;

    step6:目录守卫

    如果我需要将文件拷贝到 app > pages > 下,由于没有pages目录,命令会报错;

    我们简单实现一个目录守卫,帮我们创建不存在的目录;

    1. const copydir = require('copy-dir')
    2. const fs = require('fs');
    3. function copyDir (from, to, option) {
    4. // 目录守卫,不存在的目录结构会去创建
    5. mkdirGuard(to);
    6. copydir.sync(from, to, option);
    7. }
    8. /**
    9. * Checks if a directory or file exists at the given path.
    10. * @param {string} path - The path to check for existence.
    11. * @returns {boolean} - Returns true if the directory or file exists, false otherwise.
    12. */
    13. function checkMkdirExists(path){
    14. return fs.existsSync(path);
    15. }
    16. // 目录守卫
    17. function mkdirGuard(target) {
    18. try {
    19. fs.mkdirSync(target, { recursive: true });
    20. } catch (e) {
    21. mkdirp(target)
    22. function mkdirp(dir) {
    23. if (fs.existsSync(dir)) { return true }
    24. const dirname = path.dirname(dir);
    25. mkdirp(dirname);
    26. fs.mkdirSync(dir);
    27. }
    28. }
    29. }
    30. exports.copyDir = copyDir;
    31. exports.checkMkdirExists = checkMkdirExists;
    32. exports.mkdirGuard = mkdirGuard;

    step7:文件拷贝

    文件操作,主要使用 fs.readFileSync 读取被拷贝的文件内容,然后创建一个文件,再使用 fs.writeFileSync 写入文件内容;这两个api都是比较熟悉的老朋友了;不做过多介绍;

    我们定义一个 copyFile函数:

    1. function copyFile(from, to) {
    2. const buffer = fs.readFileSync(from);
    3. const parentPath = path.dirname(to);
    4. mkdirGuard(parentPath)
    5. fs.writeFileSync(to, buffer);
    6. }
    7. exports.copyFile = copyFile;

    使用方法与copyDir 类似,只不过需要精确到文件;这里就不演示了;

    step8:动态文件生成

    我们在定义脚手架的时候,会获取很多类型的命令参数,有些参数可能对我们模板文件产生影响。

    例如,根据命令行中的name,动态修改packages中的name;

    这里,我们需要依赖 mustache ;安装:pnpm add mustache --F zy-cli

    我们增加一个 renderTemplate 函数:

    接受动态模板的path路径,data:动态模版的配置数据;

    Mustache.render(str, data) 接受动态模版和配置数据;

    Mustache.render('{{name}}',{name:'张三'})

    1. const Mustache = require('mustache');
    2. function renderTemplate(path, data = {}) {
    3. const str = fs.readFileSync(path, { encoding: 'utf8' })
    4. return Mustache.render(str, data);
    5. }
    6. exports.renderTemplate = renderTemplate;

    再定义一个copyTemplate 函数:

    path.extname 获取文件扩展名,如果不是tpl类型的,直接当做文件处理;

    1. function copyTemplate(from, to, data = {}) {
    2. if (path.extname(from) !== '.tpl') {
    3. return copyFile(from, to);
    4. }
    5. const parentToPath = path.dirname(to);
    6. // 目录守卫
    7. mkdirGuard(parentToPath);
    8. // 写入文件
    9. fs.writeFileSync(to, renderTemplate(from, data));
    10. }

    在index.js中试验一下:

    1. const templatePath = path.resolve(__dirname, `../src/${type}/packages.tpl`);
    2. const targetPath = path.resolve(process.cwd(), `./${name}/packages.json`);
    3. copyTemplate(templatePath, targetPath, {name: name})
    1. {
    2. "name": "{{name}}",
    3. "version": "1.0.0",
    4. "description": "",
    5. "main": "index.js",
    6. "scripts": {
    7. },
    8. "keywords": [],
    9. "author": "",
    10. "license": "ISC"
    11. }

    运行完创建命令后,成功生成packages.json 文件,并且将 name字段替换成功;

    扩展:mustache 一些用法补充:

    基础绑定:

    Mustache.render('<span>{{name}}span>',{name:'张三'})

    绑定子属性

    Mustache.render('<span>{{ifno.name}}span>', { ifno: { name: '张三' } })

    循环渲染

    1. // {{#key}} {{/key}} 开启和结束循环
    2. Mustache.render(
    3. '<span>{{#list}}{{name}}{{/list}}span>',
    4. {
    5. list: [
    6. { name: '张三' },
    7. { name: '李四' },
    8. { name: '王五' },
    9. ]
    10. }
    11. )

    循环渲染 + 二次处理

    1. Mustache.render(
    2. '{{#list}}{{info}}{{/list}}',
    3. {
    4. list: [
    5. { name: '张三' },
    6. { name: '李四' },
    7. { name: '王五' },
    8. ],
    9. info() {
    10. return this.name + ',';
    11. }
    12. }
    13. )

    条件渲染

    1. // 使用 {{#key}} {{/key}} 语法 和 {{^key}} {{/key}} 语法来实现条件渲染,
    2. // 当 key 为 false、0、[]、{}、null,既是 key == false 为真,
    3. // {{#key}} {{/key}} 包裹的内容不渲染,
    4. // {{^key}} {{/key}} 包裹的内容渲染
    5. Mustache.render(
    6. '<span>{{#show}}显示{{/show}}{{^show}}隐藏{{/show}}span>',
    7. {
    8. show: false
    9. }
    10. )

    step9:实现自动安装依赖

    我们在选择完框架和UI库的时候,可以帮助目标项目自动安装依赖;

    我们使用 node 中提供的 child_process 子进程来实现;

    • child_process.exec(command, options, callback)
      • command:命令,比如 pnpm install
      • options:参数
        • cwd:设置命令运行环境的路径
        • env:环境变量
        • timeout:运行执行现在
      • callback:运行命令结束回调,(error, stdout, stderr) =>{ },执行成功后 error 为 null,执行失败后 error 为 Error 实例,stdout、stderr 为标准输出、标准错误,其格式默认是字符串。

    我们定义一个 manager 函数;

    1. const path = require('path');
    2. const { exec } = require('child_process');
    3. // 组件库映射,前面是用户输入/选择,后面是目标安装的组件库
    4. const LibraryMap = {
    5. 'Ant Design': 'antd',
    6. 'iView': 'view-ui-plus',
    7. 'Ant Design Vue': 'ant-design-vue',
    8. 'Element': 'element-plus',
    9. }
    10. function install(cmdPath, options) {
    11. // 用户选择的框架 和 组件库
    12. const { frame, library } = options;
    13. // 安装命令
    14. const command = `pnpm add ${frame} && pnpm add ${LibraryMap[library]}`
    15. return new Promise(function (resolve, reject) {
    16. // 执行安装命令
    17. exec(
    18. command,
    19. {
    20. // 命令执行的目录
    21. cwd: path.resolve(cmdPath),
    22. },
    23. function (error, stdout, stderr) {
    24. console.log('error', error);
    25. console.log('stdout', stdout);
    26. console.log('stderr', stderr)
    27. }
    28. )
    29. })
    30. }
    31. exports.install = install;

    使用:

    1. // 传入当前进程的目录,以及用户选择的配置
    2. install(process.cwd(), answers)

    试验一下:pnpm create xxxx;成功安装;

    但是安装过程没有进度展示;我们使用 ora 来丰富安装加载动画;

    安装:pnpm add ora@5.4.1 --F zy-cli

    使用:

    1. const path = require('path');
    2. const { exec } = require('child_process');
    3. const ora = require("ora");
    4. // 组件库映射,前面是用户输入/选择,后面是目标安装的组件库
    5. const LibraryMap = {
    6. 'Ant Design': 'antd',
    7. 'iView': 'view-ui-plus',
    8. 'Ant Design Vue': 'ant-design-vue',
    9. 'Element': 'element-plus',
    10. }
    11. function install(cmdPath, options) {
    12. // 用户选择的框架 和 组件库
    13. const { frame, library } = options;
    14. // 串行安装命令
    15. const command = `pnpm add ${frame} && pnpm add ${LibraryMap[library]}`
    16. return new Promise(function (resolve, reject) {
    17. const spinner = ora();
    18. spinner.start(
    19. `正在安装依赖,请稍等`
    20. );
    21. // 执行安装命令
    22. exec(
    23. command,
    24. {
    25. // 命令执行的目录
    26. cwd: path.resolve(cmdPath),
    27. },
    28. function (error) {
    29. if (error) {
    30. reject();
    31. spinner.fail(`依赖安装失败`);
    32. return;
    33. }
    34. spinner.succeed(`依赖安装成功`);
    35. resolve()
    36. }
    37. )
    38. })
    39. }
    40. exports.install = install;

    再次执行,已经有状态提示了;

    step10:推送到私有npm仓库

    使用verdaccio搭建私有npm仓库的步骤本文不赘述,可以参考这篇文章;搭建自己的私有npm库

    // TODO 部署过程中使用docker-compose,遇到一些问题,预计单独开一篇文章去记录;

    假设我们已经有了npm私库;ip:http://xxxxxx:4873/

    我们使用 nrm 去管理 npm 的仓库地址

    1. // 全局安装
    2. npm install -g nrm
    3. // 查看所有的仓库
    4. nrm ls
    5. // 切换仓库
    6. nrm use <name>
    7. // 添加仓库
    8. nrm add <name> <address>

    推送之前,我们需要修改 packages.json 中的信息:

    1. {
    2. "name": "@zy/zy-cli",
    3. "version": "1.0.0",
    4. "description": "",
    5. "main": "index.js",
    6. "bin": {
    7. "zy-script": "./bin/index.js"
    8. },
    9. "scripts": {
    10. "test": "echo \"Error: no test specified\" && exit 1"
    11. },
    12. // 规定上传到npm包中的文件
    13. "files": [
    14. "bin",
    15. "src"
    16. ],
    17. "keywords": [],
    18. "author": "",
    19. "license": "ISC",
    20. "dependencies": {
    21. "copy-dir": "^1.3.0",
    22. "inquirer": "8.2.5",
    23. "mustache": "^4.2.0",
    24. "ora": "5.4.1",
    25. "yargs": "^17.7.2"
    26. }
    27. }

    推送:

    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 中添加命令

    1. "scripts": {
    2. "zy-script": "zy-script"
    3. },

    执行 pnpm zy-script create Myapp

    成功安装所有依赖并拷贝文件;

    总结:

    1. 我们搭建了一个mono-repo风格的工程;包含了一个zy-cli脚手架工程,和demos-app的测试工程;
    2. zy-cli实现了用户交互式的命令行,命令行参数获取,文件拷贝,动态文件生成,自动安装依赖;
    3. 我们将zy-cli推送到了npm私有仓库上,并另开了一个工程,切换私库源,成功安装并且运行;

    展望:

    目前初步实现了mono-repo工程,还需要添加统一的publish脚本,包含版本自增等;

    cli 脚手架不需要打包,所以还需要为这个工程添加一个 组件库,工具函数库等类型的包;

  • 相关阅读:
    【网页前端】CSS样式表进阶之伪元素
    Java面试题(每天10题)-------连载(36)
    算法设计与分析作业——递归循环
    iNFTnews | 元宇宙为企业发展带来新思路
    c++ virtual base class
    JavaScript——变量
    C#(二) C#高级进阶
    直线段扫描算法
    webpack和vite的区别
    JAVA如何处理各种批量数据入库(BlockingQueue)
  • 原文地址:https://blog.csdn.net/zhangyun1107892254/article/details/134209445