实现一个自己的脚手架教程

news2024/9/22 13:40:52

前言

脚手架并不实现,难的是最佳实践的整理和沉淀。本文不会涉及到最佳实践方面的内容,只是教会你如何实现一个最基础的脚手架,以此作为展示最佳实践的载体。

  • 如何搭建一个脚手架的工程

  • 如何开发和调试一个脚手架

  • 脚手架中如何接收和处理命令参数

  • 脚手架中如何和用户交互

  • 脚手架中如何拷贝一个文件夹或文件

  • 脚手架中如何动态生成一个文件

  • 脚手架中如何处理路径问题

  • 脚手架中如何自动安装模板所需依赖

搭建一个 menorepo 风格的脚手架工程

如何用 Node.js 运行 js

为了避免 Node.js 版本差异导致奇怪的问题,建议安装 16 以上版本的 Node.js 。

随便找个地方建个一个 index.js 文件,并在该文件中添加如下代码:

console.log('Welcome to Mortal World');

在该文件的地址栏上输出 cmd 打开命令行窗口,输入 node index.js 命令,回车运行该命令。

图片

可以在命令行窗口中看到打印了 Welcome to Mortals World

声明自己的命令

如果你熟悉 Vue , 肯定对 vue-cli 这个脚手架有一定了解,比如运行 vue create myapp 命令来创建一个 Vue 工程。

如果我们没有运行 npm install -g vue-cli 安装 vue-cli 脚手架,在命令行窗口中直接运行 vue create myapp,会报错,报错如下图所示:

图片

可见 vue 不是系统命令, vue 只是 vue-cli 脚手架声明的一个命令。

那怎么给脚手架声明一个命令,其实非常简单,跟我来操作。

随便找个地方创建 mortal-cli  的文件夹,进入该文件夹后,在其地址栏上输出 cmd 打开命令行窗口,

图片

运行 npm init 命令来初始化一个脚手架工程,运行成功后,会在该文件夹中生成一个 pakeage.json 文件。

我们在 pakeage.json 中添加 bin 字段,来声明一个命令,添加后的代码如下所示:

{
  "name": "mortal-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "bin":{
    "mortal": "./bin/index.js"
  }
}

这样我们就声明了一个 mortal 的命令,另外 ./bin/index.js 是运行 mortal 命令后会运行的 js 文件的相对路径。

接着我们在 mortal-cli   的文件夹中创建一个 bin 文件夹,在 bin 文件夹中创建一个 index.js 文件,并在该文件中添加如下代码:

#!/usr/bin/env node

console.log('Welcome to Mortal World');

注意在文件头部添加 #!/usr/bin/env node,否则运行后会报错!!!

这样就完成了一个最基础的脚手架工程,接下来在命令行窗口输入 mortal 命令,运行该命令。

图片

会发现,还是报错,提示 mortal 命令不是系统命令,当然不是声明命令的方法错误。

假如你把这个脚手架发布到 npm 上后。由于 mortal-cli/pakeage.json 中 name 的值为 mortal-cli,所以我们运行 npm install -g mortal-cli 将脚手架安装到本地后,再运行 mortal 命令,就会发现运行成功。

在实际开发脚手架过程中不可能这么做,所以我们还要实现本地调试脚手架的能力,实现起来也非常简单,一个命令搞定。

这个命令就是 npm link,哈哈想不到吧,在这里就不讲述其原理,如果你们需要的话,可以留言,我开个单章讲述一下。

输入 npm link 命令运行后,再输入 mortal 命令,回车运行,看到的结果如下图所示。

图片

到此,我们成功的声明了一个 mortal 命令。

npm link 的弊端

使用 npm link 来实现本地调试有一个弊端。比如在本地有多个版本的脚手架仓库,在仓库A中修改代码后,运行 mortal 命令后,发现更改的代码不生效。这是因为已经在仓库B的脚手架工程中运行 npm link,导致我们在运行 mortal 命令后是执行仓库B中的代码,在仓库A中修改代码能生效才怪。要先在仓库B的脚手架工程中运行 npm unlink 后,然后在仓库A中的脚手架工程中运行 npm link 后,修改仓库A中的代码才能生效。

为了解决这个弊端,我们使用 pnpm 来搭建 menorepo 风格的脚手架工程。

在 menorepo 风格的工程中可以含有多个子工程,且每个子工程都可以独立编译打包后将产物发成 npm 包,故又称 menorepo 为多包工程。

由于脚手架发布到 npm 上的包名为 mortal-cli ,修改调试子工程的 package.json 文件中的代码,代码修改部分如下所示:

{
  "scripts": {
    "mortal": "mortal --help"
  },
  "dependencies": {
    "mortal-cli": "workspace:*"
  }
}

注意,在 dependencies 字段中声明 mortal-cli 依赖包的版本,要用workspace:* 来定义,而不是具体版本号来定义。

在 pnpm 中使用 workspace: 协议定义某个依赖包版本号时,pnpm 将只解析存在工作空间内的依赖包,不会去下载解析 npm 上的依赖包。

把 mortal-cli 依赖包引入,执行 pnpm i 安装依赖,其效果就跟执行 npm install -g mortal-cli一样,只不过不是全局安装而已,只在调试子工程内安装 mortal-cli 脚手架。然后调试子工程就直接引用脚手架子工程本地编译打包后的产物,而不是发布到 npm 上的产物,彻底做到本地调试。

另外脚手架子工程和调试子工程是在同一个工程中,这样就做一对一的调试,从而解决了使用 npm link 来实现本地调试的弊端。

同时在 scripts 定义了脚本命令,在调试工程中执行 pnpm mortal 既是执行了 mortal 命令,不用脚手架工程中执行 npm link 就可以运行 mortal 命令。

menorepo 风格的脚手架工程

下面开始使用 pnpm 搭建 menorepo 风格的脚手架工程,首先在命令行窗口中输入以下代码,执行安装 pnpm 。

iwr https://get.pnpm.io/install.ps1 -useb | iex

然后重新找个地方创建 mortal 文件夹,进入该文件夹后,在其地址栏上输出 cmd 打开命令行窗口。

输入 pnpm init 初始化工程,pnpm 是使用 workspace (工作空间) 来搭建一个 menorepo 风格的工程。

所以我们要 mortal 文件夹中创建 pnpm-workspace.yaml 工作空间配置文件,并在该文件中添加如下配置代码。

packages: 
  - 'packages/*' 
  - 'examples/*'

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

在 packages 文件夹中新建 mortal-cli 文件夹,在其地址栏上输出 cmd 打开命令行窗口。

输入 pnpm init 命令,执行来初始化一个工程,执行成功后,会在该文件夹中生成一个 pakeage.json 文件。

我们在 pakeage.json 中添加 bin 字段,来声明 mortal 命令,添加后的代码如下所示:

{
  "name": "mortal-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "bin":{
    "mortal": "./bin/index.js"
  }
}

在 mortal文件夹中新建 bin 文件夹,在 bin 文件夹中新建 index.js 文件,并在该文件中添加如下代码:

#!/usr/bin/env node

console.log('Welcome to Mortal World');

在 examples 文件夹中新建 app 文件夹,在其地址栏上输出 cmd 打开命令行窗口, 运行 pnpm init 命令来初始化一个工程,运行成功后,会在该文件夹中生成一个 pakeage.json 文件。

我们在 pakeage.json 中添加 dependencies 字段,来添加 mortal-cli 依赖。再给 scripts 增加一条自定义脚本命令。添加后的代码如下所示:

{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "mortal": "mortal"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "mortal-cli": "workspace:*"
  }
}

然后在 mortal 文件夹目录下运行 pnpm i 命令,安装依赖。安装成功后,在 app 文件夹目录下运行 pnpm mortal,会发现命令行窗口打印出 Welcome to Mortal World,说明你的 menorepo 风格的脚手架工程的搭建成功了。

图片

此时整个工程的目录结构如下所示

 |-- mortal
      |-- package.json
      |-- pnpm-lock.yaml
      |-- pnpm-workspace.yaml
      |-- examples
      |   |-- app
      |       |-- package.json
      |-- packages
          |-- mortal
              |-- package.json
              |-- bin
                  |-- index.js

脚手架必备的模块

一个最简单的脚手架包含以下几个模块。

  • 命令参数模块

  • 用户交互模块

  • 文件拷贝模块

  • 动态文件生成模块

  • 自动安装依赖模块

下面我们来一一将他们实现。

命令参数模块

获取命令参数

Node.js 中的 process 模块提供了当前 Node.js 进程相关的全局环境信息,比如命令参数、环境变量、命令运行路径等等。

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

脚手架提供的 mortal 命令后面还可以设置参数,标准的脚手架命令参数需要支持两种格式,比如:

mortal --name=orderPage
mortal --name orderPage

如果通过 process.argv 来获取,要额外处理两种不同的命令参数格式,不方便。

这里推荐 yargs 开源库来解析命令参数。运行以下命令安装 yargs:

pnpm add yargs --F mortal-cli

pnpm add 是 pnpm 中安装依赖包的命令, --F mortal-cli,是指定依赖安装到 mortal-cli 子工程中。

这里要注意,mortal-cli 是取 mortal-cli 子工程中 package.json 中 name 字段的值,而不是 mortal-cli 子工程文件夹的名称。

在 bin/index.js 添加如下代码:

#!/usr/bin/env node
const yargs = require('yargs');

console.log('name', yargs.argv.name);

注意,以上代码是在 Node.js 环境中运行,Node.js 的模块是遵循 CommonJS 规范的,如果要依赖一个模块,要使用 Node.js 内置 require 系统函数引用模块使用。

在 app 文件夹目录下运行 pnpm mortal -- --name=orderPage ,

注意,在 pnpm mortal 后面需要加上两个连字符(--),这是为了告诉 pnpm 后面的参数是传递给命令mortal 本身的,而不是传递给 pnpm 的。

结果如下图所示:

图片

可以通过 yargs.argv.name 获取命令参数 name 的值。

设置子命令

假如脚手架要对外提供多个功能,不能将所有的功能都集中在 mortal 命令中实现。

可以通过 yargs 提供的 command 方法来设置一些子命令,让每个子命令对应各自功能,各司其职。

yargs.command 的用法是 yargs.command(cmd, desc, builder, handler)。

alias:别名;

demand:是否必填;

default:默认值;

describe:描述信息;

type:参数类型,string | boolean | number

  • cmd:字符串,子命令名称,也可以传递数组,如 ['create', 'c'],表示子命令叫 create,其别名是 c

  • desc:字符串,子命令描述信息;

  • builder:一个返回数组的函数,子命令参数信息配置,比如可以设置参数:

    • handler: 函数,可以在这个函数中专门处理该子命令参数。

下面我们来设置一个用来生成一个模板的子命令,把这个子命令命名为create

修改在 bin/index.js 文件中的代码,如下所示:

#!/usr/bin/env node

const yargs = require('yargs');
yargs.command(
  ['create', 'c'],
  '新建一个模板',
  function (yargs) {
    return yargs.option('name', {
      alias: 'n',
      demand: true,
      describe: '模板名称',
      type: 'string'
    })
  },
  function (argv) {
    console.log('argv', argv);
  }
).argv;

在 app 文件夹目录下分别运行 pnpm mortal create -- --name=orderPage 和 pnpm mortal c -- --name=orderPage 命令,执行结果如下图所示:

图片

在上面我们配置了子命令 create 的参数 name 的一些参数信息。那这些要怎么展示给用户看呢?其实只要我们输入子命令的参数有错误,就会在命令行窗口中显示这些参数信息。

在 app 文件夹目录下运行 pnpm mortal c -- --abc 命令,执行结果如下图所示:

图片

到此为止,我们最简单地实现了脚手架和用户之间的交互能力,但是如果自定义参数过多,那么命令行参数的交互方法对于用户来说是非常不友好的。所以我们还要实现一个用户交互模块,如何实现请看下一小节。

用户交互模块

比较好的用户交互方式是讯问式的交互,比如我们在运行 npm init,通过询问式的交互完成 package.json 文件内容的填充。

图片

这里推荐使用 inquirer 开源库来实现询问式的交互,运行以下命令安装 inquirer:

 pnpm add inquirer@8.2.5 --F mortal-cli

为了使用 require 引入 inquirer ,要使用 8.2.5 版本的 inquirer。

这里我们主要使用了 inquirer 开源库的三个方面的能力:

  • 询问用户问题

  • 获取并解析用户的输入

  • 检测用户的答案是否合法

主要通过 inquirer.prompt() 来实现。prompt 函数接收一个数组,数组的每一项都是一个询问项,询问项有很多配置参数,下面是常用的配置项。

type:提问的类型,常用的有

    输入框:input

    确认:confirm

    单选组:list

    多选组:checkbox

  • name:存储当前问题答案的变量;

  • message:问题的描述;

  • default:默认值;

  • choices:列表选项,在某些type下可用;

  • validate:对用户的答案进行校验;

  • filter:对用户的答案进行过滤处理,返回处理后的值。

比如我们创建一个模板文件,大概会询问用户:模板文件名称、模板类型、使用什么框架开发、使用框架对应的哪个组件库开发等等。下面我们来实现这个功能。

在 bin 文件夹中新建 inquirer.js 文件夹,在里面添加如下代码:

const inquirer = require('inquirer');

function inquirerPrompt(argv) {
  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)
    })
  })

}

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

在 bin/index.js 中引入 inquirerPrompt 。

#!/usr/bin/env node

const yargs = require('yargs');
const { inquirerPrompt } = require("./inquirer");

yargs.command(
  ['create', 'c'],
  '新建一个模板',
  function (yargs) {
    return yargs.option('name', {
      alias: 'n',
      demand: true,
      describe: '模板名称',
      type: 'string'
    })
  },
  function (argv) {
    inquirerPrompt(argv).then(answers =>{
      console.log(answers)
    })
  }
).argv;

在 app 文件夹目录下运行 pnpm mortal c -- --n Input 命令,执行结果如下图所示:

图片

图片

可以很清楚地看到“在使用什么框架开发”的询问中回答不同,下一个“使用什么UI组件库的开发”的询问可选项不一样。

回答完成后,可以在下图中清楚地看到答案格式

图片

文件夹拷贝模块

要生成一个模板文件,最简单的做法就是执行脚手架提供的命令后,把脚手架中的模板文件,拷贝到对应的地方。模板文件可以是单个文件,也可以是一个文件夹。本小节先介绍一下模板文件是文件夹时候如何拷贝。

在 Node.js 中拷贝文件夹并不简单,需要用到递归,这里推荐使用开源库copy-dir来实现拷贝文件。

运行以下命令安装 copy-dir 。

pnpm add copy-dir --F mortal-cli

在 bin 文件夹中新建 copy.js 文件,在里面添加如下代码:

const copydir = require('copy-dir');
const fs = require('fs');

function copyDir(from, to, options) {
  copydir.sync(from, to, options);
}

function checkMkdirExists(path) {
  return fs.existsSync(path)
};

exports.checkMkdirExists = checkMkdirExists;
exports.copyDir = copyDir;

copyDir 方法实现非常简单,难的是如何使用,下面创建一个场景来介绍一下如何使用。

我们在 bin 文件夹中新建 template 文件夹,用来存放模板文件,比如在 template 文件夹中创建一个 form 文件夹来存放表单模板,这里不介绍表单模板的内容,我们随意在 form 文件夹中创建一个 index.js,在里面随便写些内容。其目录结构如下所示:

 |-- mortal
      |-- package.json
      |-- pnpm-lock.yaml
      |-- pnpm-workspace.yaml
      |-- examples
      |   |-- app
      |       |-- package.json
      |-- packages
          |-- mortal
              |-- package.json
              |-- bin
                  |-- template
                      |-- form
                          |-- index.js
                  |-- copy.js
                  |-- index.js
                  |-- inquirer.js

下面来实现把 packages/mortal/bin/template/form 这个文件夹拷贝到 examples/app/src/pages/OrderPage 中 。

在 bin/index.js 修改代码,修改后的代码如下所示:

#!/usr/bin/env node

const yargs = require('yargs');
const path = require('path');
const { inquirerPrompt } = require("./inquirer");
const { copyDir, checkMkdirExists } = require("./copy");

yargs.command(
  ['create', 'c'],
  '新建一个模板',
  function (yargs) {
    return yargs.option('name', {
      alias: 'n',
      demand: true,
      describe: '模板名称',
      type: 'string'
    })
  },
  function (argv) {
    inquirerPrompt(argv).then(answers => {
      const { name, type } = answers;
      const isMkdirExists = checkMkdirExists(
        path.resolve(process.cwd(),`./src/pages/${name}`)
      );
      if (isMkdirExists) {
        console.log(`${name}文件夹已经存在`)
      } else {
        copyDir(
          path.resolve(__dirname, `./template/${type}`),
          path.resolve(process.cwd(), `./src/pages/${name}`)
        )
      }
    })
  }
).argv;

使用拷贝文件方法 copyDir 的难点是参数 from 和 to 的赋值。其中 from 表示要拷贝文件的路径,to 表示要把文件拷贝到那里的路径。这里的路径最好使用绝对路径,因为在 Node.js 中使用相对路径会出现一系列奇奇怪怪的问题。

脚手架中的路径处理

我们可以用 Node.js 中的 path 模块提供的 path.resolve( [from…], to ) 方法将路径转成绝对路径,就是将参数 to 拼接成一个绝对路径,[from … ] 为选填项,可以设置多个路径,如 path.resolve('./aaa', './bbb', './ccc') ,使用时要注意path.resolve 的路径拼接规则:

  • 从后向前拼接路径;

  • 若 to 以 / 开头,不会拼接到前面的路径;

  • 若 to 以 ../ 开头,拼接前面的路径,且不含最后一节路径;

  • 若 to 以 ./ 开头或者没有符号,则拼接前面路径。

从以上拼接规则来看,使用 path.resolve 时,要特别注意参数 to 的设置。

下面来介绍一下,使用 copyDir 方法时,参数如何设置:

  • 将 copyDir 的参数 from 设置为 path.resolve(__dirname, `./template/${type}`)

其中 __dirname 是用来动态获取当前文件模块所属目录的绝对路径。比如在 bin/index.js 文件中使用 __dirname ,__dirname 表示就是 bin/index.js 文件所属目录的绝对路径D:\mortal\packages\mortal-cli\bin

因为模板文件存放在 bin/template 文件夹中 ,copyDir 是在 bin/index.js 中使用,bin/template 文件夹相对 bin/index.js 文件的路径是 ./template,所以把 path.resolve 的参数 to 设置为 ./template/${type},其中 type 是用户所选的模板类型。

假设 type 的模板类型是 form,那么 path.resolve(__dirname, `./template/form`) 得到的绝对路径是 D:\mortal\packages\mortal-cli\bin\template\form

  • 将 copyDir 的参数 to 设置为 path.resolve(process.cwd(), `${name}`)

其中 process.cwd() 当前 Node.js 进程执行时的文件所属目录的绝对路径。比如在 bin 文件夹目录下运行 node index.js 时,process.cwd() 得到的是 D:\mortal\packages\mortal-cli\bin

运行 node index.js 相当运行 mortal 命令。而在现代前端工程中都是在 package.json 文件中scripts 定义了脚本命令,如下所示:

{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "mortal": "mortal"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "mortal-cli": "workspace:*"
  }
}

运行 pnpm mortal 就相当运行 mortal 命令,那么执行 pnpm mortal 时,当前 Node.js 进程执行时的文件是 package.json 文件。那么 process.cwd() 得到的是 D:\mortal\examples\app

因为要把 packages/mortal/bin/template/form 这个文件夹拷贝到examples/app/src/pages/OrderPage 中,且 process.cwd() 的值是D:\mortal\examples\appsrc/pages 文件夹相对 examples/app 的路径是 ./src/pages ,所以把 path.resolve 的参数 to 设置为 ./src/pages/${name},其中 name 是用户所输入的模板名称。

目录守卫

在 app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,看能不能成功得把 packages/mortal/bin/template/form 这个文件夹拷贝到examples/app/src/pages/OrderPage 中。

图片

报错了,提示 examples/app/src/pages 文件夹不存在。为了防止这种报错出现,我们要实现一个目录守护的方法 mkdirGuard ,比如 examples/app/src/pages 文件夹不存在,就创建一个 examples/app/src/pages 文件夹。

在 bin/copy.js 文件中,修改代码,如下所示:

const copydir = require('copy-dir');
const fs = require('fs');
const path = require('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);
    }
  }
}

function copyDir(form, to, options) {
  mkdirGuard(to);
  copydir.sync(form, to, options);
}

function checkMkdirExists(path) {
  return fs.existsSync(path)
};

exports.checkMkdirExists = checkMkdirExists;
exports.mkdirGuard = mkdirGuard;
exports.copyDir = copyDir;

fs.mkdirSync 的语法格式:fs.mkdirSync(path[, options]),创建文件夹目录。

  • path:文件夹目录路径;

  • optionsrecursive 表示是否要创建父目录,true 要。

fs.existsSync 的语法格式:fs.existsSync(pach),检测目录是否存在,如果目录存在返回 true ,如果目录不存在返回false

  • path:文件夹目录路径。

path.dirname 的语法格式:path.dirname(path),用于获取给定路径的目录名。

  • path:文件路径。

在 mkdirGuard 方法内部,当要创建的目录 target 父级目录不存在时,调用fs.mkdirSync(target),会报错走 catch 部分逻辑,在其中递归创建父级目录,使用 fs.existsSync(dir) 来判断父级目录是否存在,来终止递归。这里要特别注意 fs.mkdirSync(dir) 创建父级目录要在 mkdirp(dirname) 之前调用,才能形成一个正确的创建顺序,否则创建父级目录过程会因父级目录的父级目录不存在报错。

我们再次在 app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,看这次能不能成功得把 packages/mortal/bin/template/form 这个文件夹拷贝到 examples/app/src/pages/OrderPage 中。

成功添加,添加结果如下所示:

图片

然后再运行 pnpm mortal create -- --name=OrderPage 命令,会发现控制台打印出模板已经存在在提示。

图片

这是为了防止用户修改后的模板文件,运行命令后被重新覆盖到初始状态。所以我们引入一个校验模板文件是否存在的 checkMkdirExists 方法,内部采用 fs.existsSync 来实现。

文件拷贝模块

文件拷贝分三步来实现,使用 fs.readFileSync 读取被拷贝的文件内容,然后创建一个文件,再使用 fs.writeFileSync 写入文件内容。

在 bin/copy.js 文件,在里面添加如下代码:

function copyFile(from, to) {
  const buffer = fs.readFileSync(from);
  const parentPath = path.dirname(to);

  mkdirGuard(parentPath)

  fs.writeFileSync(to, buffer);
}

exports.copyFile = copyFile;

接下来我们使用 copyFile 方法,在 bin/index.js 修改代码,修改后的代码如下所示:

#!/usr/bin/env node

const yargs = require('yargs');
const path = require('path');
const { inquirerPrompt } = require("./inquirer");
const { copyDir } = require("./copy");

yargs.command(
  ['create', 'c'],
  '新建一个模板',
  function (yargs) {
    return yargs.option('name', {
      alias: 'n',
      demand: true,
      describe: '模板名称',
      type: 'string'
    })
  },
  function (argv) {
    inquirerPrompt(argv).then(answers => {
      const { name, type } = answers;
      const isMkdirExists = checkMkdirExists(
        path.resolve(process.cwd(),`./src/pages/${name}/index.js`)
      );
      if (isMkdirExists) {
        console.log(`${name}/index.js文件已经存在`)
      } else {
        copyFile(
          path.resolve(__dirname, `./template/${type}/index.js`),
          path.resolve(process.cwd(), `./src/pages/${name}/index.js`),
          {
            name,
          }
        )
      }
    })
  }
).argv;

copyFile 和 copyDir 使用的区别在参数,copyFile 要求参数 from 和参数 to 都精确到文件路径。

在 app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,执行结果如下图所示:

图片

动态文件生成模块

假设脚手架中提供的模板文件中某些信息需要根据用户输入的命令参数来动态生成对应的模板文件。

比如下面模板文件中 App 要动态替换成用户输入的命令参数 name 的值,该如何实现呢?

import React from 'react';
const App = () => {
  return (
    <div></div>
  );
};
export default App;

这里推荐使用开源库mustache来实现,运行以下命令安装 copy-dir 。

pnpm add mustache --F mortal-cli

我们在 packages/mortal-cli/bin/template/form 文件夹中创建一个 index.tpl 文件,内容如下:

import React from 'react';
const {{name}} = () => {
  return (
    <div></div>
  );
};
export default {{name}};

先写一个 readTemplate 方法来读取这个 index.tpl 动态模板文件内容。在 bin/copy.js 文件,在里面添加如下代码:

const Mustache = require('mustache');

function readTemplate(path, data = {}) {
  const str = fs.readFileSync(path, { encoding: 'utf8' })
  return Mustache.render(str, data);
}

exports.readTemplate = readTemplate;

readTemplate 方法接收两个参数,path 动态模板文件的相对路径,data 动态模板文件的配置数据。

使用 Mustache.render(str, data) 生成模板文件内容返回,因为 Mustache.render 的第一个参数类型是个字符串,所以在调用 fs.readFileSync 时要指定 encoding 类型为 utf8,否则 fs.readFileSync 返回 Buffer 类型数据。

在写一个 copyTemplate 方法来拷贝模板文件到对应的地方,跟 copyFile 方法非常相似。在 bin/copy.js 文件,在里面添加如下代码:

function copyTemplate(from, to, data = {}) {
  if (path.extname(from) !== '.tpl') {
    return copyFile(from, to);
  }
  const parentToPath = path.dirname(to);
  mkdirGuard(parentToPath);
  fs.writeFileSync(to, readTemplate(from, data));
}

path.extname(from) 返回文件扩展名,比如 path.extname(index.tpl) 返回 .tpl

在 bin/index.js 修改代码,修改后的代码如下所示:

#!/usr/bin/env node

const yargs = require('yargs');
const path = require('path');
const { inquirerPrompt } = require("./inquirer");
const { copyTemplate } = require("./copy");

yargs.command(
  ['create', 'c'],
  '新建一个模板',
  function (yargs) {
    return yargs.option('name', {
      alias: 'n',
      demand: true,
      describe: '模板名称',
      type: 'string'
    })
  },
  function (argv) {
    inquirerPrompt(argv).then(answers => {
      const { name, type } = answers;
      const isMkdirExists = checkMkdirExists(
        path.resolve(process.cwd(),`./src/pages/${name}/index.js`)
      );
      if (isMkdirExists) {
        console.log(`${name}/index.js文件已经存在`)
      } else {
        copyTemplate(
          path.resolve(__dirname, `./template/${type}/index.tpl`),
          path.resolve(process.cwd(), `./src/pages/${name}/index.js`),
          {
            name,
          }
        )
      }
    })
  }
).argv;

在 app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,执行结果如下图所示:

图片

mustache 简介

以上的案例是 mustache 最简单的使用,下面来额外介绍一些常用的使用场景。

首先来熟悉一下 mustache 的语法,下面来介绍一些场景来使用这些语法

  • {{key}}

  • {{#key}} {{/key}}

  • {{^key}} {{/key}}

  • {{.}}

  • {{&key}}

简单绑定

使用 {{key}} 语法,key 要和 Mustache.render 方法中的第二个参数(一个对象)的属性名一致。

例如:

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

输出:

<span>张三</span>
绑定子属性

例如:

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

输出:

<span>张三</span>
循环渲染

如果 key 属性值是一个数组,则可以使用 {{#key}} {{/key}} 语法来循环展示。其中 {{#}} 标记表示从该标记以后的内容全部都要循环展示,{{/}}标记表示循环结束。

例如:

Mustache.render(
  '<span>{{#list}}{{name}}{{/list}}</span>',
  {
    list: [
      { name: '张三' },
      { name: '李四' },
      { name: '王五' },
    ]
  }
)

输出:

<span>张三李四王五</span>

如果 list 的值是 ['张三','李四','王五'],要把 {{name}} 替换成 {{.}} 才可以渲染。

Mustache.render(
  '<span>{{#list}}{{.}}{{/list}}</span>',
  {
    list: ['张三','李四','王五']
  }
)
循环中二次处理数据

Mustache.render 方法中的第二个参数是个对象,其属性值可以是一个函数,渲染时候会执行函数输出返回值,函数中可以用 this 获取第二个参数的上下文。

例如:

Mustache.render(
  '<span>{{#list}}{{info}}{{/list}}</span>',
  {
    list: [
      { name: '张三' },
      { name: '李四' },
      { name: '王五' },
    ],
    info() {
      return this.name + ',';
    }
  }
)

输出:

<span>张三,李四,王五,</span>
条件渲染

使用 {{#key}} {{/key}} 语法 和 {{^key}} {{/key}} 语法来实现条件渲染,当 key 为 false0[]{}null,既是 key == false 为真,{{#key}} {{/key}} 包裹的内容不渲染,{{^key}} {{/key}} 包裹的内容渲染

例如:

Mustache.render(
  '<span>{{#show}}显示{{/show}}{{^show}}隐藏{{/show}}</span>',
  {
    show: false
  }
)

输出:

<span>隐藏</span>
不转义 HTML 标签

使用 {{&key}} 语法来实现。

例如:

Mustache.render(
  '<span>{{&key}}</span>',
  {
    key: '<span>标题</span>'
  }
)

输出:

<span><span>标题</span></span>

自动安装依赖模块

假设模板是这样的:

import React from 'react';
import { Button, Form, Input } from 'antd';

const App = () => {
  const onFinish = (values) => {
    console.log('Success:', values);
  };
  return (
    <Form onFinish={onFinish} autoComplete="off">
      <Form.Item label="Username" name="username">
        <Input />
      </Form.Item>
      <Form.Item>
        <Button type="primary" htmlType="submit">提交</Button>
      </Form.Item>
    </Form>
  );
};

可以看到模板中使用了 react 和 antd 这两个第三方依赖,假如使用模板的工程中没有安装这两个依赖,我们要实现在生成模板过程中就自动安装这两个依赖。

我们使用 Node 中 child_process 子进程这个模块来实现。

在 child_process 子进程中的最常用的语法是:

child_process.exec(command, options, callback)

  • command:命令,比如 pnpm install

  • options:参数

    • cwd:设置命令运行环境的路径

    • env:环境变量

    • timeout:运行执行现在

  • callback:运行命令结束回调,(error, stdout, stderr) =>{ },执行成功后 error 为 null,执行失败后 error 为 Error 实例,stdoutstderr 为标准输出、标准错误,其格式默认是字符串。

在 bin 文件夹中新建 inquirer.js 文件夹,在里面添加如下代码:

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 方法中 exec 的参数 command 是 pnpm 安装依赖命令,安装多个依赖时使用 && 拼接。参数 cwd 是所安装依赖工程的 package.json 文件路径,我们可以使用 process.cwd() 获取。已经在上文提到过,process.cwd() 是当前Node.js 进程执行时的文件所属目录的绝对路径。

接下来使用,在 bin/index.js 修改代码,修改后的代码如下所示:

#!/usr/bin/env node

const yargs = require('yargs');
const path = require('path');
const { inquirerPrompt } = require("./inquirer");
const { copyTemplate, checkMkdirExists } = require("./copy");
const { install } = require('./manager');

yargs.command(
  ['create', 'c'],
  '新建一个模板',
  function (yargs) {
    return yargs.option('name', {
      alias: 'n',
      demand: true,
      describe: '模板名称',
      type: 'string'
    })
  },
  function (argv) {
    inquirerPrompt(argv).then(answers => {
      const { name, type } = answers;
      const isMkdirExists = checkMkdirExists(
        path.resolve(process.cwd(),`./src/pages/${name}/index.js`)
      );
      if (isMkdirExists) {
        console.log(`${name}/index.js文件已经存在`)
      } else {
        copyTemplate(
          path.resolve(__dirname, `./template/${type}/index.tpl`),
          path.resolve(process.cwd(), `./src/pages/${name}/index.js`),
          {
            name,
          }
        )
        install(process.cwd(), answers);
      }
    })
  }
).argv;

当执行完 copyTemplate 方法后,就开始执行 install(process.cwd(), answers) 自动安装模板中所需的依赖。

在 app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,看能不能自动安装依赖。

等命令执行完成后,观察 examples\app\package.json 文件中的 dependencies 值是不是添加了 antd 和 react 依赖。

图片

此外,我们在执行命令中会发现,如下图所示的现象,光标一直在闪烁,好像卡住了,其中是依赖在安装。这里我们要引入一个加载动画,来解决这个不友好的现象。

图片

这里推荐使用开源库ora来实现加载动画。

运行以下命令安装 ora 。

pnpm add ora@5.4.1 --F mortal-cli

在 bin/inquirer.js 修改代码,修改后的代码如下所示:

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;

在 app 文件夹目录下运行 pnpm mortal create -- --name=OrderPage,看一下执行效果。

图片

图片

发布和安装

在 packages/mortal 文件夹目录下运行,运行以下命令安装将脚手架发布到 npm 上。

pnpm publish --F mortal-cli

发布成功后。我们在一个任意工程中,执行 pnpm add mortal-cli -D 安装 mortal-cli 脚手架依赖成功后,在工程中执行 pnpm mortal create -- --name=OrderPage 命令即可。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1080246.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

C++教程(2)

C 环境设置 本地环境设置 如果您想要设置 C 语言环境&#xff0c;您需要确保电脑上有以下两款可用的软件&#xff0c;文本编辑器和 C 编译器。 文本编辑器 这将用于输入您的程序。文本编辑器包括 Windows Notepad、OS Edit command、Brief、Epsilon、EMACS 和 vim/vi。 文…

零代码编程:用ChatGPT批量调整文件名称中的词汇顺序

文件夹里面很多文件&#xff0c;需要批量挑战标题中的一些词组顺序&#xff1a;“Peppa Pig - Kylie Kangaroo (14 episode _ 4 season) [HD].mp4”这个文件名改成“14 episode _ 4 season _ Peppa Pig - Kylie Kangaroo.mp4”&#xff0c;可以在ChatGPT中输入提示词&#xff1…

加盐加密详解

一、什么是加盐加密 加盐加密就是后端在存储一个密码的时候&#xff0c;为了提高安全性&#xff0c;随机生成一个盐值&#xff08;随机值&#xff09;&#xff0c;将盐值和密码进行有规则的结合&#xff0c;然后将结合过后的数据使用加密算法&#xff08;一般是md5加密&#x…

数据中心供配电及能效管理系统的设计要点

摘要&#xff1a;现代的数据中心中都包括大量的计算机&#xff0c;对于这种场所的电力供应&#xff0c;都要求供电系统需要在所有的时间都有效&#xff0c;这就不同于一般建筑的供配电系统&#xff0c;它是一个交叉的系统&#xff0c;涉及到市电供电、防雷接地、防静电、UPS不间…

CAN和CANFD通信介绍

CAN&#xff08;Controller Area Network&#xff0c;控制器局域网&#xff09;是一种串行通信技术&#xff0c;专门用于在汽车电子控制单元&#xff08;ECU&#xff09;之间实现可靠的数据交换。 CAN协议介绍 电子化 汽车近年来的发展呈现出以电子化为主的特点。电子化的主…

深度学习基础知识 Dataset 与 DataLoade的用法解析

深度学习基础知识 Dataset 与 DataLoade的用法解析 1、Dataset2、DataLoader参数设置&#xff1a;1、pin_memory2、num_workers3、collate_fn分类任务目标检测任务 1、Dataset 代码&#xff1a; import torch from torch.utils import dataclass MyDataset(torch.utils.data.D…

c#-特殊的集合

位数组 可观察的集合 private ObservableCollection<string> strList new ObservableCollection<string>();// Start is called before the first frame updatevoid Start(){strList.CollectionChanged Change;strList.Add("ssss");strList.Add("…

uTools使用技巧

uTools 提高工作效率、学习效率。 启动uTools Alt 空格 关键词 任何系统文件&#xff0c;软件&#xff0c;插件&#xff0c;都可以通过 关键词 快速跳转。 快速打开文件/软件 输入"控制面板"&#xff0c;选中后就能跳转到 “控制面板” 同样的 “程序与功能…

软件测试工程师是做什么的?这是我的理解...

近年来&#xff0c;由于IT行业的人才稀缺&#xff0c;越来越多人转向了IT相关职业。 由于IT软件业的薪资较高&#xff0c;吸引了不少人选择了IT软件专业&#xff0c;IT软件专业包括软件开发、Java工程师、软件测试工程师等。 软件测试工程师主要是负责理解产品的功能要求&…

RESIDUAL INTERPOLATION FOR COLOR IMAGE DEMOSAICKING

ABSTRACT 一种广泛应用于彩色图像马赛克的色差插值技术。在本文中&#xff0c;我们提出残差插值作为一种替代颜色差插值&#xff0c;其中残差是一个观察和一个初步估计的像素值之间的差异。我们将所提出的残差插值方法引入到基于梯度的无阈值(GBTF)算法中&#xff0c;该算法是…

速腾激光雷达 RS M1 slam 建图

速腾激光雷达 RS M1 slam 建图

【QT】Ubuntu 编译安装 QT 5.12.7 源码

直接通过源码来编译安装 QT&#xff0c;难度太大&#xff0c;耗时较长&#xff0c;一般不是特别推荐使用源码安装。 目录 1、下载 QT 源码包 2、搭建安装环境(下载依赖库) 3、编译QT源码的脚本 1、下载 QT 源码包 QT5.12.7源码下载地址: download | QT 5.12.7 选择任意一…

vscode虚拟环境使用jupyter

在某虚拟环境内安装torch&#xff0c;但是ipyn文件保存后无法正常导入torch 1.conda环境下安装Jupyter等一切配置&#xff0c;进入虚拟环境 2.conda install nb_conda_kernels 3.安装完成后重新打开VSCode&#xff0c;在运行Jupyter notebook中的代码之前&#xff0c;在右上…

虹科案例 | 瑞士苏黎世联邦理工学院ETH Zurich:记录马匹不同步态和活动测量

瑞士苏黎世联邦理工学院ETH Zurich&#xff1a;记录马匹不同步态和活动测量 ——用于动物测量研究的数据记录仪&#xff1a;虹科MSR145 对马匹的运动活动和休息行为的观察通常用于评估其饲养和管理的安全与健康。研究测试了虹科MSR145数据记录仪在判别马匹步态方面的适用性。 …

如何限制word文件中部分内容无法编辑

工作中我们经常会用到Word制作一些文件&#xff0c;文件中有一部分内容不想他人编辑&#xff0c;我们可以设置限制编辑&#xff0c;可以对一部分内容设置限制编辑&#xff0c;具体方法如下&#xff1a; 我们将需要将可以编辑的地方选中&#xff0c;然后打开限制编辑功能 然后勾…

Jenkins安装多个jdk版本,并在项目中选择对应jdk版本

下载jdk版本&#xff1a;进入oracle官网下载官方jdk Java Downloads | Oracle 例&#xff1a;比如项目需要使用java8.341的版本&#xff0c;而jenkins用的是java11的版本&#xff0c;这里就需要下载多个jdk版本。进入下载网址&#xff0c;Java Archive Downloads - Java SE 8u…

不写注释就是在耍流氓

从前&#xff0c;在一个繁忙的城市里&#xff0c;住着一群才华横溢的程序员。他们以出色的编程技巧和能力在制作复杂的软件系统方面闻名。其中有一位年轻的程序员叫做小明。 小明在编写代码方面非常有天赋。他热爱逻辑思维、问题解决&#xff0c;并追求让事物运作的激情。然而…

关于:未同意隐私政策,应用获取ANDROID ID问题2

一、环境 Unity2018 4.21f1、Android Studio、Windows10 二、问题描述 在发布应用到华为应用市场时&#xff0c;提示“在用户同意隐私政策前&#xff0c;您的应用获取了用户的ANDROID ID&#xff0c;不符合华为应用市场审核标准。” 如果你想去掉获取ANDROID ID的代码可以参…

佳音通讯400电话在线选号服务

随着互联网的快速发展&#xff0c;越来越多的企业开始意识到电话营销的重要性。作为一种直接、高效的沟通方式&#xff0c;电话营销在企业推广和销售中发挥着重要的作用。然而&#xff0c;很多企业在选择电话号码时遇到了困扰&#xff0c;不知道如何选择一个好记、好用的号码。…

工作相关----《系统部署相关操作》

(1)修改带外IP地址 修改完成后&#xff0c;再重新使用新的带外IP登录下试试&#xff0c;确保IP修改成功&#xff1b; (2)确认物理端口对应的配置文件&#xff1b; 进入远程控制台&#xff0c;确认“业务主&#xff0c;业务备&#xff0c;备份主&#xff0c;备份备”网络端口&…