前端脚手架教程

什么是前端脚手架?

脚手架指的就是:有人帮你把这个开发过程中要用到的工具、环境都配置好了,你就可以方便地直接开始做开发,专注你的业务,而不用再花时间去配置这个开发环境,这个开发环境就是脚手架。

比如vue.js就有个vue-cli脚手架,基于node.js的开发环境,作者帮你把开发环境大部分东西都配置好了,你把脚手架下载下来就可以直接开发了,不用再考虑搭建这些工具环境。

再比如react.js也有个create-react-app脚手架,它的优势在于省略了很多涉及配置的地方,能够更加容易上手。

如何用 Node.js 运行 .js 文件

创建一个 index.js 文件, 里面写一段很简单的内容

console.log('Hello, Judson')

在该文件的命令窗口中运行 node index.js, 我们可以看到输出

Hello, Judson

声明自己的命令

做vue开发的一定都知道 vue-cli 这个脚手架, 我们通常运行 vue create myapp 命令来创建一个 vue 工程.

如果我们没有npm install -g @vue/cli安装vue-cli脚手架,在命令窗口直接运行 vue create myapp, 会报错 “ vue 不是内部或外部命令,也不是可运行的程序或批处理文件.”

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

接下来我们自己来做一个类似的脚手架命令.

实现一个自己的脚手架

我这里脚手架名称叫 judson, 创建 judson-cli 目录并 npm init 初始化, 如下图所示

初始化工程

打开 package.json文件并添加 bin 配置来声明一个命令, 添加后的代码如下所示

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

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

接下来在项目目录下添加 bin 文件夹, 并在该文件夹下创建一个 index.js 文件, 文件内添加如下代码:

#!/usr/bin/env node
console.log('Welcome to Judson World');

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

这样我们就完成了一个最简单的脚手架工程, 接下来在命令行窗口输入 judson 命令

运行命令

系统提示 judson 命令没有找到. 因为我们还未发布和安装此命令

当我们把这个脚手架发布到 npm 上后,由于 judson-cli/package.json 中的 name 值为 judson-cli, 所以我们运行 npm install -g judson-cli将脚手架安装到本机后,再运行 judson-cli 命令是否发布成功.

在实际开发脚手架过程中我们不会这么做, 因为每次发布到 npm 后再本机上安装后再运行命令进行测试,效率很低. 所以我们需要使用 npm link 命令来进行快速映射.

npm link 可以帮助我们模拟包安装后的状态,它会在系统中做一个快捷方式映射,让本地的包就好像 install 过一样,可以直接使用。

npm link 的弊端

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

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

搭建 monorepo 风格的脚手架工程

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

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

npm install -g pnpm

重新创建文件夹,我这里创建 judson 文件夹, 进入该文件夹后, 输入pnpm init --yes 初始化工程, pnpm 是使用 workspace来搭建一个monorepo风格的工程.

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

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

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

packages 文件夹中新建 judson-cli 文件夹, 并使用命令进入该文件夹运行pnpm init --yes来初始化一个工程, 执行完成后会在该文件夹中生产一个 package.json.

修改 package.json 内容,添加 bin 字段来声明 judson命令, 添加后的代码如下所示:

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

packages/judson-cli 文件夹中新建 bin 文件夹,在 bin 文件夹中新建 index.js 文件,并在该文件中添加如下代码:

#!/usr/bin/env node
console.log('Welcome to Judson World');

examples 文件夹中新建 app 文件夹,命令行进入该目录, 运行 pnpm init --yes 命令来初始化一个工程,运行成功后,会在该文件夹中生成一个 pakeage.json 文件。

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

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

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

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


目录结构

脚手架必备模块

一个简单的脚手架通常包含以下几个模块

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

接下来我们一一将他们实现

命令参数模块

  • 获取命令参数
    Node.js中的process模块提供了当前Node.js进程相关的全局环境信息,比如命令参数, 环境变量, 命令运行路径等
const process = require('process')

// 获取命令参数
console.log(process.argv)

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

judson --name=Login // 等于号赋值
judson --name Login // 空格赋值

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

这里推荐 yargs 开源库来解析命令参数.

运行以下命令安装 yargs

pnpm add yargs --F judson-cli

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

注意 judson-cli 是取 judson-cli 子工程中 package.jsonname 字段的值,而不是 judson-cli 子工程文件夹的名称。

yargs的使用非常简单,其提供的 argv 属性是对两个格式的命令
参数的处理结果。

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

#!/usr/bin/env node
const yargs = require('yargs');
console.log('name', yargs.argv.name);

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

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

在 app 文件夹目录下运行 pnpm judson -- --name=LoginPage

使用=或空格均可赋值

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

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

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

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

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

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

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

#!/usr/bin/env node

const yargs = require('yargs');

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

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

运行create命令后

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

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

运行有误的子命令参数

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

用户交互模块

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

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

pnpm add inquirer@^8.0.0 --F judson-cli

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

官方发布的 inquirer@9.x 是esm模式的。如果想了解更多原生ESM,点击这里

这里我们主要使用了 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)
                        })
                } else 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 获取上个询问的答案,根据答案再发起对应的内容。

bin/index.js 中引入 inquirerPrompt

#!/usr/bin/env node

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

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

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

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

文件拷贝模块

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

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

运行以下命令安装 copy-dir

pnpm add copy-dir --F judson-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,在里面随便写些内容。其目录结构如下所示:

目录结构

下面来实现把 packages/judson/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(yarg) {
        return yarg.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 的难点是参数 fromto 的赋值。其中 from 表示要拷贝文件的路径,to 表示要把文件拷贝到那里的路径。

脚手架中的路径处理

我们可以用 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 文件所属目录的绝对路径 ~/Downloads/judson/packages/judson-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') 得到的绝对路径是 ~/Downloads/judson/packages/judson-cli/bin/template/form

copyDir 的参数 to 设置为 path.resolve(process.cwd(), '${name}')
其中 process.cwd() 当前 Node.js 进程执行时的文件所属目录的绝对路径。比如在 bin 文件夹目录下运行 node index.js 时,process.cwd() 得到的是 ~/Downloads/judson/packages/judson-cli/bin

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

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

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

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

目录守卫

app 文件夹目录下运行 pnpm judson create -- --name=OrderPage,看能不能成功得把 packages/judson/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(from, to, options) {
    mkdirGuard(to)
    copydir.sync(from, to, options)
}

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

exports.mkdirGuard = mkdirGuard
exports.checkMkdirExists = checkMkdirExists
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 judson create -- --name=OrderPage,看这次能不能成功得把 packages/judson/bin/template/form 这个文件夹拷贝到 examples/app/src/pages/OrderPage 中。

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


添加成功

然后再运行 pnpm judson 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, checkMkdirExists, copyFile } = require('./copy');

yargs.command(
    ['create', 'c'],
    '新建一个模版',
    function(yarg) {
        return yarg.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}`)
                    // )
                    copyFile(
                        path.resolve(__dirname, `./template/${type}/index.js`),
                        path.resolve(process.cwd(), `./src/pages/${name}/index.js`)
                    )
                }
            })
    }
).argv;

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

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

现在我们修改下模版文件内的内容, 模拟实际业务中的代码,修改bin/template/form/index.js 代码如下:

import React from 'react'

const App = () => {
    return (
        <div></div>
    )
}

export default App

动态文件生成模块

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

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

import React from 'react'

const App = () => { // App 不应写死,应该为用户自定义的名称
    return (
        <div></div>
    )
}

export default App

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

pnpm add mustache --F judson-cli

我们在 packages/judson-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 { copyDir, checkMkdirExists, copyFile, copyTemplate } = require('./copy');

yargs.command(
    ['create', 'c'],
    '新建一个模版',
    function(yarg) {
        return yarg.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}`)
                    // )

                    // copyFile(
                    //  path.resolve(__dirname, `./template/${type}/index.js`),
                    //  path.resolve(process.cwd(), `./src/pages/${name}/index.js`)
                    // )

                    copyTemplate(
                        path.resolve(__dirname, `./template/${type}/index.tpl`),
                        path.resolve(process.cwd(), `./src/pages/${name}/index.js`),
                        {
                            name
                        }
                    )
                }
            })
    }
).argv;

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

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}} 语法来实现条件渲染,当 keyfalse、0、[]、{}、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>

自动安装依赖模块

我们现在新增动态表单模版,创建文件bin/template/dynamicForm/index.tpl, 文件内容代码如下:

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

const {{name}} = () => {
  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>
  );
};

export default {{name}};

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

我们使用 Nodechild_process 子进程这个模块来实现。

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

child_process.exec(command, options, callback)

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

新增文件 bin/manager.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, studot, stderr) {
                console.log('error', error)
                console.log('stdout', studot)
                console.log('stderr', stderr)
            }
        )
    })
}

exports.install = install

install 方法中 exec 的参数 commandpnpm 安装依赖命令,安装多个依赖时使用 && 拼接。参数 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 { copyDir, checkMkdirExists, copyFile, copyTemplate } = require('./copy');
const { install } = require('./manager')

yargs.command(
    ['create', 'c'],
    '新建一个模版',
    function(yarg) {
        return yarg.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}`)
                    // )

                    // copyFile(
                    //  path.resolve(__dirname, `./template/${type}/index.js`),
                    //  path.resolve(process.cwd(), `./src/pages/${name}/index.js`)
                    // )

                    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 judson create -- --name=AutoInstallPage,看能不能自动安装依赖。

这里在选择模版类型的时候记得选动态表单

动态表单模版

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

自动安装了依赖

此外,我们在执行命令中会发现命令窗口无输出信息,好像卡住了,其中是依赖在安装。这里我们要引入一个加载动画,来解决这个不友好的现象。

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

运行以下命令安装 ora

pnpm add ora@5.4.1 --F judson-cli

bin/manager.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, studot, stderr) {
                if (error) {
          reject();
          spinner.fail(`依赖安装失败`);
          return;
        }
        spinner.succeed(`依赖安装成功`);
        resolve()
            }
        )
    })
}

exports.install = install

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

加入加载动画后

发布和安装

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

pnpm publish --F judson-cli
pnpm publish发布到npm

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

验证

全局安装
npm install judson-cli -g
然后运行命令,如下所示.

验证

结语

上面只教大家实现一个最最简单的脚手架。其功能就只有一个模板文件生成。虽然简单,但是这些都是脚手架的入门功,代码已经上传到 GitHub,大家可以下载下来,自己实践一下,光看不练永远学不会。

学会了,可以总结一些平时的业务代码,形成最佳实践,使用脚手架作为载体展现出来,提升自己的职场竞争力。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,911评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,014评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 142,129评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,283评论 1 264
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,159评论 4 357
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,161评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,565评论 3 382
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,251评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,531评论 1 292
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,619评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,383评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,255评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,624评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,916评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,199评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,553评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,756评论 2 335

推荐阅读更多精彩内容