封装一个自己的前端脚手架cli工具(一)

封装一个自己的前端脚手架cli工具

一、创建 cli 项目

1.创建一个文件夹存放 cli 项目文件

给这个 cli 文件夹取名(这里我们取名mycli),cd 到 cli文件夹中,使用 npm init 简单的创建一个项目结构。创建 bin 文件夹,添加启动文件 cli.js,这个文件主要写 Commander.js 的命令。

mycli
|— bin
	|— cli.js
|README.md
|package.json
|— node_modules
|.gitignore
2.编辑 package.json 文件

补充版本、启动入口、作者、规范等(如果要发 npm 包,需要再配置一下keywordsrepositoryhomepage

{
  "name": "mycli",
  "version": "1.0.0",
  "description": "脚手架",
  "main": "index.js",
  "bin": {
    "mycli": "./bin/cli.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": {
    "name": "ShayCormac"
  },
  "license": "MIT",
  "keywords": ["mycli"],
  "repository": "[email protected]:shay_cormac/mycli.git",
  "homepage": "[email protected]:shay_cormac/mycli.git"
}

3.创建本地调试链接

在mycli项目文件夹使用 yarn link 命令创建链接。在需要使用mycli的其他文件夹是用命令 yarn link mycli 进行本地依赖链接。

需要断开链接时,先在目标文件夹使用 yarn unlink mycli 命令,即可断开本地依赖。需要关闭mycli项目的链接,可以在 mycli 文件夹中使用 yarn unlink 命令关闭。

接下来则进行cli命令的编写(注意:在 cli.js 文件开头要加上识别 #! /usr/bin/env node

二、编写脚手架cli命令

1.编写命令

我们使用 Commander.js 插件来完成命令的编写。首先安装 Command.js

$ npm install commander

然后就可以进行命令的编写了

#! /usr/bin/env node

const {Command} = require('commander');

const program = new Command()

// 定义创建项目
program
.command('create ')
.description('create a new project, 创建一个新项目')
.option('-f, --force', '如果创建的目录存在则直接覆盖')
.action((name, option) => {
    // 打印结果,输出用户手动输入的项目名字 
    console.log('name:',name, 'option:', option)
})


// 配置版本信息
program
.version(`v${require('../package.json').version}`)
.description('使用说明')
.usage(' [option]')


// 解析用户执行命令传入参数
program.parse(process.argv)

输出可以拿到

> mycli create projectname123
name: projectname123 option: {}		// 执行结果

> mycli create projectname123 -f
name: projectname123 option: { force: true }	// 执行结果

> mycli create projectname123 --force
name: projectname123 option: { force: true }	// 执行结果
2.编写 create 逻辑

在跟目录下创建 lib 文件夹,cd 到 lib 文件夹中,创建 create.js 文件

mycli
|— bin
	|— cli.js
|— lib
	|— create.js
|README.md
|package.json
|— node_modules
|.gitignore

在 create.js 文件中接收上一步的值

module.exports = async function (name, options) {
    // 验证是否正常取到值
    console.log('create success:', name, options);
}

在 bin/cli.js 中使用 create.js

// 定义创建项目
program
.command('create ')
.description('create a new project, 创建一个新项目')
.option('-f, --force', '如果创建的目录存在则直接覆盖')
.action((name, option) => {
    // 引入create.js 模块并传递参数
    require('../lib/create')(name, option)
})
> mycli create projectname123 -f
create success: projectname123 { force: true }	// 执行结果

在拿到用户提供的答案后,我们需要判断当前目录下是否已有同名文件夹,如果已有同名文件夹,那么需要进行什么样的操作(删除已有、替换、取消…)。这里我们需要两个依赖,一个是询问用户的依赖 inquirer,一个是对文件、文件夹的操作 node.jsfs 模块,对于 fs 模块的使用,我们知道稍微有点不方便,这里我们使用 fs-extra 依赖,它是 fs 模块的扩展。

先安装这两个依赖

$ npm install fs-extra
$ npm install inquirer

在 lib/create.js 文件中开始写逻辑

const path = require('path');
const extra = require('fs-extra');  // fs-extra 是 node fs 的扩展
const inquirer = require('inquirer');  // 命令行交互

module.exports = async function (name, options) {
    // 验证是否正常取到值
    // console.log('create success', name, options);
    const cwd = process.cwd(); // 执行目录
    const targetAir = path.join(cwd, name); // 需要创建的目录地址
    // 判断目录是否已经存在?
    if (extra.existsSync(targetAir)) {
        // 是否为强制创建?
        if (options.force) {
            await extra.remove(targetAir)
            // TODO
        }
        else {
            // 在终端输出询问用户是否覆盖:
            const inquirerParams = [{
                name: 'aboutProject',
                type: 'list',
                message: '目标文件目录已经存在,请选择如下操作:',
                choices: [
                    { name: '替换当前目录', value: 'replace' },
                    { name: '移除已有目录', value: 'remove' },
                    { name: '取消当前操作', value: 'cancel' }
                ]
            }]
            let inquirerData = await inquirer.prompt(inquirerParams);
            // console.log('inquirerData', inquirerData)
            if (!inquirerData?.aboutProject) {
                return
            }
            else if (inquirerData.aboutProject === 'remove') {
                // 移除已存在的目录
                await extra.remove(targetAir)
                console.log('目录 <' + name + '> 已移除')
            }
            else if (inquirerData.aboutProject === 'replace') {
                await extra.remove(targetAir)
                // TODO
            }
        }
    }
};

:这里我们需要滤清一下逻辑和地址,在使用 extra.remove() 方法时,文件地址要弄清,process.cwd() 方法可以拿到当前执行命令的地址。

此时我们可以创建一个名称为 projectname123 的文件夹,再使用 mycli create projectname123 来查看命令行中的交互询问

3.创建 CreateGenerator.js

在 lib 目录下创建 CreateGenerator.js 文件,写创建项目时具体的操作,例如下载模板地址、对模板进行替换处理等。

选修 )这里我们可以使用一个加载的动画效果,使用 ora 依赖,在控制台显示当前加载状态的。

$ npm install ora

在 CreateGenerator.js 中定义 wrapLoading() 方法,来使用 ora 的动画效果

// lib/CreateGenerator.js

const ora = require('ora');     // Ora 在控制台显示当前加载状态的(下载5.x版本)

// 使用 ora 初始化,传入提示信息 message
const spinner = ora()

/**
 * @wrapLoading 交互加载动画
 * @param {*} fn 在 wrapLoading 函数中执行的方法
 * @param {*} message 执行动画时的提示信息
 * @param  {...any} args 传递给 fn 方法的参数
 * @returns 
 */
async function wrapLoading(fn, message, ...args) {
    spinner.text = message.loadingMsg
    // 开始加载动画
    spinner.start()

    try {
        // 执行传入的方法 fn
        const result = await fn(...args)
        // 动画修改为成功
        spinner.succeed(message.seccessfulMsg)
        return result
    } catch (error) {
        // 动画修改为失败
        spinner.fail(message.failedMsg + ': ', error)
    }
}

注意orachalk 等依赖最新版只支持ESModule格式,是使用export default导出的之前的版本支持CommomJs格式,是使用module.exports导出的。最新版使用 require引入所以报错。

动画函数可以单独封装,这里暂时放在 lib/CreateGenerator.js 中

必修)接下来我们就正式开始写 CreateGenerator
创建 CreateGenerator 构造函数,在 constructor 中定义我们需要的 项目名称( this.name )、目标地址( this.targetDir )、拉取代码的方法( this.downloadGitRepo )。定义 download() 方法在里面写拉取仓库代码的逻辑

// lib/CreateGenerator.js

const ora = require('ora');     // Ora 在控制台显示当前加载状态的(下载5.x版本)
const inquirer = require('inquirer');   // 用户与命令行交互
const chalk = require('chalk');     // ‘粉笔’ 用于设置终端字体颜色的库(下载4.x版本)
const path = require('path');
const child_process = require('child_process');
const extra = require('fs-extra');  // fs-extra 是 node fs 的扩展

/**
 *ora、chalk 等依赖最新版只支持ESModule格式,是使用export default导出的,
 *之前的版本支持CommomJs格式,是使用module.exports导出的。
 *最新版使用 require引入所以报错。
 */

// 使用 ora 初始化,传入提示信息 message
const spinner = ora()


/**
 * @wrapLoading 交互加载动画
 * @param {*} fn 在 wrapLoading 函数中执行的方法
 * @param {*} message 执行动画时的提示信息
 * @param  {...any} args 传递给 fn 方法的参数
 * @returns 
 */
async function wrapLoading(fn, message, ...args) {
    spinner.text = message.loadingMsg
    // 开始加载动画
    spinner.start()

    try {
        // 执行传入的方法 fn
        const result = await fn(...args)
        // 动画修改为成功
        spinner.succeed(message.seccessfulMsg)
        return result
    } catch (error) {
        // 动画修改为失败
        spinner.fail(message.failedMsg + ': ', error)
    }
}

class CreateGenerator {
    constructor(name, targetDir) {
        // 目录名称
        this.name = name;
        // 创建位置
        this.targetDir = targetDir;
        /** 
         * 对 download-git-repo 进行 promise 化改造
         * 使用 child_process 的 execSync 方法拉取仓库模板
        */
        this.downloadGitRepo = child_process.execSync

    }

    /**
     * @download 下载远程模板
     */
    async download() {
        // 设置模板下载地址
        const modelUrl = `https://gitee.com/shay_cormac/react-antd-tempy.git`

        // child_process.spawn 参数
        /**
         * @param masterBranch master分支
         * @param projectBranch project-demo分支
         * @param pagesBranch pages-demo分支
         */
        const masterBranch = `git clone -b master ${modelUrl} ${path.resolve(process.cwd(), this.targetDir)}`
        const projectBranch = `git clone -b project-demo ${modelUrl} ${path.resolve(process.cwd(), this.targetDir, 'src', 'pages', this.name)}`

        // 调用动画加载效果,加载master分支
        await wrapLoading(
            this.downloadGitRepo,
            { loadingMsg: '加载模板中...', seccessfulMsg: 'master加载成功', failedMsg: 'master加载失败' },
            masterBranch
            // modelUrl,
            // path.resolve(process.cwd(), this.targetDir),

        )

        // 调用动画加载效果,加载project-demo分支
        await wrapLoading(
            this.downloadGitRepo,
            { loadingMsg: '加载模板中...', seccessfulMsg: 'project-demo加载成功', failedMsg: 'project-demo加载失败' },
            projectBranch
            // modelUrl,
            // path.resolve(process.cwd(), this.targetDir),
        )
    }

    // 核心创建逻辑
    async create() {
        // console.log(chalk.yellow(`name: ${this.name}`))
        // console.log(chalk.yellow(`targetDir: ${this.targetDir}`))

        await this.download()

        // 写环境变量文件(用在模板编译启动、打包时使用)
        extra.outputFileSync(path.resolve(this.targetDir, '.env'), `PROJECT = ${this.name}`)
    }
}

module.exports = CreateGenerator;

注释:child_process 中的 execSync 方法,可以使我们在代码中使用命令,这样我们就可以使用 git 命令来拉取代码了

child_process.execSync(`git clone -b 分支 仓库地址 输出地址`)

讨论:模板可以放在线上仓库管理,也可以将模板放在cli项目中管理。如果放在项目中,则不需要使用 child_process.execSync

三、babel 编译修改模板内容

这个部分根据各自使用的模板,或各自的思路不同,会有不同的处理方式。我们在这里使用babel 的方式,解析代码进行修改。

这里我们以模板中的路由文件为例(模板文件为umi创建的项目)

1.读取文件

使用fs-extrareadFile() 方法读取目标文件内容readFile(路径,编码,回调),参考 nodefs 模块

// lib/CreateGenerator.js

const ora = require('ora');     // Ora 在控制台显示当前加载状态的(下载5.x版本)
const inquirer = require('inquirer');   // 用户与命令行交互
const chalk = require('chalk');     // ‘粉笔’ 用于设置终端字体颜色的库(下载4.x版本)
const path = require('path');
const child_process = require('child_process');
const extra = require('fs-extra');  // fs-extra 是 node fs 的扩展
const babelparser = require('@babel/parser');   // 将JS源码转换成语法树
const traverse = require('@babel/traverse').default;    // 遍历和更新节点
const generator = require('@babel/generator').default;  // 把AST抽象语法树反解,生成我们常规的代码

/**
 *ora、chalk 等依赖最新版只支持ESModule格式,是使用export default导出的,
 *之前的版本支持CommomJs格式,是使用module.exports导出的。
 *最新版使用 require引入所以报错。
 */

// 使用 ora 初始化,传入提示信息 message
const spinner = ora()


/**
 * @wrapLoading 交互加载动画
 * @param {*} fn 在 wrapLoading 函数中执行的方法
 * @param {*} message 执行动画时的提示信息
 * @param  {...any} args 传递给 fn 方法的参数
 * @returns 
 */
async function wrapLoading(fn, message, ...args) {
    ...
}

class CreateGenerator {
    constructor(name, targetDir) {
        // 目录名称
        this.name = name;
        // 创建位置
        this.targetDir = targetDir;
        /** 
         * 对 download-git-repo 进行 promise 化改造
         * 使用 child_process 的 execSync 方法拉取仓库模板
        */
        this.downloadGitRepo = child_process.execSync

    }

    /**
     * @download 下载远程模板
     */
    async download() {
        ...
    }


    // 核心创建逻辑
    async create() {
        await this.download()
        // 写环境变量文件
        extra.outputFileSync(path.resolve(this.targetDir, '.env'), `PROJECT = ${this.name}`)

        this.readRoute()
    }

    /**
     * 读取路由
     * 统一修改路由中项目路径
     */
    readRoute = async () => {
        const routePath = path.resolve(this.targetDir, 'src', 'pages', this.name, 'route', 'index.ts')
        extra.readFile(routePath, 'utf8', (err, data) => {
            if (err) {
                throw err
            }
            console.log(data)	// 读取的文件内容
        })
    }
}

module.exports = CreateGenerator;
2.使用 @babel/parser 将JS代码转换成语法树

先安装依赖

$ npm install --save @babel/parser

babelParser.parse(code, [options]) 具体使用请参照 babel 文档

将读取到的JS代码转换成语法树

// lib/CreateGenerator.js

	/**
     * 读取路由
     * 统一修改路由中项目路径
     */
    readRoute = async () => {
        const routePath = path.resolve(this.targetDir, 'src', 'pages', this.name, 'route', 'index.ts')
        extra.readFile(routePath, 'utf8', (err, data) => {
            if (err) {
                throw err
            }
            // 将JS源码转换成语法树
            let routeDataTree = babelparser.parse(data, {
                sourceType: 'module',
                plugins: [
                    "typescript",   // 编译tsx文件
                    // "jsx",         // 编译jsx文件
                    // "flow",     // 流通过静态类型注释检查代码中的错误。这些类型允许您告诉Flow您希望您的代码如何工作,而Flow将确保它按照这种方式工作。
                ]
            })
        })
    }

@babel/parse 会将读取的代码转变为语法树,打印到控制台可以看到语法树结构。这里推荐一个在线 AST 语法树解析 AST Explorer,可以将需要转译的代码放上去,查看解析后的语法树结构。

3.使用 @babel/traverse 遍历和更新节点

先安装依赖

$ npm install --save @babel/traverse

traverse(AST, {options}) ,第一个参数为数据流语法树,第二个参数是属性方法,例如 enter: (path, state) =>{}exit:(path) => {}

@babel/traverse 会遍历输出每一个node节点,可以通过控制台 console.log() 打印 path 查看每一个节点。

这里我们的 route.ts 文件内容为:

// 模板中 route.ts 内容

module.exports = [
  { path: '/', redirect: '/adjustTheRecord' },
  {
    path: '/adjustTheRecord',
    exact: true,
    name: '列表',
    component: '@/pages/pricing/pages/adjustTheRecord',
    layout: {
      hideNav: true,
    }
  },
  // {
  //   path: '/nested',
  //   name: '嵌套页面',
  //   component: '@/pages/pricing/pages/nested',
  //   routes: [
  //     { path: '/nested/new', component: '@/pages/pricing/pages/nested/new' },
  //     { path: '/nested/war', component: '@/pages/pricing/pages/nested/war' },
  //     { path: '/nested/tech', component: '@/pages/pricing/pages/nested/tech' },
  //     { path: '/nested/auto', component: '@/pages/pricing/pages/nested/auto' },
  //     { path: '/nested/ent', component: '@/pages/pricing/pages/nested/ents' },
  //     { path: '/nested/money', component: '@/pages/pricing/pages/nested/money' },
  //     {
  //       path: '/nested/jiankang',
  //       component: '@/pages/pricing/pages/nested/jiankang',
  //     },
  //     { path: '/nested/travel', component: '@/pages/pricing/pages/nested/travel' },
  //   ],
  // },
];

我们使用 @babel/traverse 来解析,打印查看 node 节点信息,观察以什么条件选出我们要用的 node 节点(node 节点会比较多,需要耐心查看)。也可以使用 AST Explorer 来查看结构

// lib/CreateGenerator.js

    /**
     * 读取路由
     * 统一修改路由中项目路径
     */
    readRoute = async () => {
        const routePath = path.resolve(this.targetDir, 'src', 'pages', this.name, 'route', 'index.ts')
        extra.readFile(routePath, 'utf8', (err, data) => {
            if (err) {
                throw err
            }
            // 将JS源码转换成语法树
            let routeDataTree = babelparser.parse(data, {
                sourceType: 'module',
                plugins: [
                    "typescript",   // 编译tsx文件
                    // "jsx",         // 编译jsx文件
                    // "flow",     // 流通过静态类型注释检查代码中的错误。这些类型允许您告诉Flow您希望您的代码如何工作,而Flow将确保它按照这种方式工作。

                ]
            })
            // 遍历和更新节点
            traverse(routeDataTree, {
                ObjectProperty: (path, state) => {
                    console.log(path)
                }
            })
        })
    }

我们这里修改 route.ts 里 路由的 component 属性的值,找到 node 节点中的 key 来选择出需要修改的节点 path.node.key.name === ‘component’

// 遍历和更新节点
traverse(routeDataTree, {
	ObjectProperty: (path, state) => {
		if (path.node.key.name === 'component') {
			path.node.value.value = `@/pages/${this.name}/pages/adjustTheRecord`
		}
	}
})
4.使用 @babel/generator 把AST抽象语法树反解,生成我们常规的代码

上一步中修改了 node 节点的值,修改之后我们需要将AST语法树反解为常规代码,并写入到 route.ts 文件中

先安装依赖

npm install @babel/generator
    /**
     * 读取路由
     * 统一修改路由中项目路径
     */
    readRoute = async () => {
        const routePath = path.resolve(this.targetDir, 'src', 'pages', this.name, 'route', 'index.ts')
        extra.readFile(routePath, 'utf8', (err, data) => {
            if (err) {
                throw err
            }
            // 将JS源码转换成语法树
            let routeDataTree = babelparser.parse(data, {
                sourceType: 'module',
                plugins: [
                    "typescript",   // 编译tsx文件
                    // "jsx",         // 编译jsx文件
                    // "flow",     // 流通过静态类型注释检查代码中的错误。这些类型允许您告诉Flow您希望您的代码如何工作,而Flow将确保它按照这种方式工作。

                ]
            })
            // 遍历和更新节点
            traverse(routeDataTree, {
                ObjectProperty: (path, state) => {
                    if (path.node.key.name === 'component') {
                        path.node.value.value = `@/pages/${this.name}/pages/adjustTheRecord`
                    }
                }
            })
            // 把AST抽象语法树反解,生成我们常规的代码
            const routeCode = generator(routeDataTree).code
            extra.outputFileSync(routePath, routeCode)
            // console.log(routeCode)
        })
    }

在使用 fs-extra.outputFileSync() 写入文件前,可以打印查看反译后的代码

四、将 CreateGenerator.js 引入到 create.js 使用

const path = require('path');
const extra = require('fs-extra');  // fs-extra 是 node fs 的扩展
const inquirer = require('inquirer');  // 命令行交互
const chalk = require('chalk');     // ‘粉笔’
const CreateGenerator = require('./CreateGenerator')

module.exports = async function (name, options) {
    // 验证是否正常取到值
    // console.log('create success', name, options);
    const cwd = process.cwd(); // 执行目录
    const targetAir = path.join(cwd, name); // 需要创建的目录地址
    // 判断目录是否已经存在?
    if (extra.existsSync(targetAir)) {
        // 是否为强制创建?
        if (options.force) {
            await extra.remove(targetAir)
            makeGenerator(name, targetAir)
        }
        else {
            // 在终端输出询问用户是否覆盖:
            const inquirerParams = [{
                name: 'aboutProject',
                type: 'list',
                message: '目标文件目录已经存在,请选择如下操作:',
                choices: [
                    { name: '替换当前目录', value: 'replace' },
                    { name: '移除已有目录', value: 'remove' },
                    { name: '取消当前操作', value: 'cancel' }
                ]
            }]
            let inquirerData = await inquirer.prompt(inquirerParams);
            // console.log('inquirerData', inquirerData)
            if (!inquirerData?.aboutProject) {
                return
            }
            else if (inquirerData.aboutProject === 'remove') {
                // 移除已存在的目录
                await extra.remove(targetAir)
                console.log('目录 <' + chalk.green(name) + '> 已移除')
            }
            else if (inquirerData.aboutProject === 'replace') {
                await extra.remove(targetAir)
                makeGenerator(name, targetAir)
            }
        }
    }
    else {
        makeGenerator(name, targetAir)
    }
};

/**
 * 创建项目
 * @param {string} name 项目名称
 * @param {string} targetAir 需要创建的目录地址
 */
const makeGenerator = (name, targetAir) => {
    const generator = new CreateGenerator(name, targetAir)

    generator.create()
}

到此,mycli create 的命令就已经完成了。

下一节 分享 mycli newPage 命令的编写

你可能感兴趣的:(模块化,npm,cli,前端框架初步搭建,前端,javascript,cli)