《轮子是怎么跑起来的》从0到1教你开发一款脚手架

简介: 现在市面上已经有这么多成熟的脚手架,我们还有必要开发一个脚手架呢?如果我们处在应用的角度,像vue-cli、create-react-app等这些脚手架已经够用了;但是我们在开发的过程中,需要对很多项目模板进行二开,但是往往这样的二开并不是一次;作为一个成熟的程序猿,如果进行大量的重复工作肯定是拒绝的,这个时候就需要自己开发一个脚手架自己用,也可以上传的Github开源给大家一起用。
Hi~,我是 一碗周,一个在舒适区垂死挣扎的前端,如果写的文章有幸可以得到你的青睐,万分有幸~

写在前面

现在市面上已经有这么多成熟的脚手架,我们还有必要开发一个脚手架呢?如果我们处在应用的角度,像vue-cli、create-react-app等这些脚手架已经够用了;但是我们在开发的过程中,需要对很多项目模板进行二开,但是往往这样的二开并不是一次;作为一个成熟的程序猿,如果进行大量的重复工作肯定是拒绝的,这个时候就需要自己开发一个脚手架自己用,也可以上传的Github开源给大家一起用。

还有就是如果站在学习的角度,我们创建项目如果只是使用脚手架,我们永远不知道如何搭建一个项目。

这篇文章将手把手教你如何开发一个脚手架。

准备工作

首先创建一个项目,使用npm init -y命令初始化一个Node项目,然后创建项目目录结构,如下所示:

├── node_modules           # 项目依赖资源
├── bin                    # 脚手架入口。
│   └── ywz.js             # 入口文件。
├── lib                    # 项目的主要逻辑代码
│   └── index.js           # 逻辑处理的js文件
├── .gitignore             # Git推送忽略列表配置文件
├── .prettierrc            # Prettier格式化配置文件
└── package.json           # 项目所需要的各种模块,以及项目的配置信息

现在来介绍一下脚手架中使用到的一些插件:

  • commander:完整的 node.js 命令行解决方案,中文文档
  • axios :拉取数据
  • ora :实现loading效果
  • inquirer:通用交互式命令行用户界面的集合
  • chalk :实现彩色终端字体
  • download-git-repo :基于Node下载并提取Git仓库
  • metalsmith 一个非常简单,可以插入的static站点生成器
  • ncp :用于copy文件
  • consolidate :模板引擎的集合
  • handlebars :模板引擎

安装命令如下:

npm i commander axios ora inquirer chalk download-git-repo metalsmith ncp consolidate handlebars -D

现在来修改一下我们的package.json文件中的内容,内容如下:

{
  "name": "ywz",
  "version": "1.0.0",
  "description": "",
  "main": "lib/index.js",
  "directories": {
    "lib": "lib"
  },
  "bin": {
    "ywz": "bin/ywz.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "ywanzhou",
  "license": "ISC",
  "devDependencies": {
    "axios": "^0.24.0",
    "chalk": "^5.0.0",
    "commander": "^8.3.0",
    "consolidate": "^0.16.0",
    "download-git-repo": "^3.0.2",
    "handlebars": "^4.7.7",
    "inquirer": "^8.2.0",
    "metalsmith": "^2.3.0",
    "ncp": "^2.0.0",
    "ora": "^5.4.1"
  }
}

这里主要修改了如下内容:

  • main:项目的入口文件
  • bin:可执行命令文件

然后在bin/ywz.js文件中写入如下代码:

#! /usr/bin/env node
console.log('脚手架')
#! /usr/bin/env node就是告诉系统可以在PATH目录中查找,然后使用Node运行

然后通过link命令将ywz链接到全局,方便我们测试,示例代码如下:

npm link
这个链接就相当于Linux中的软链接,如果测试完毕可以通过 npm unlink取消链接。

值得注意的的就是使用npm link命令的时候,一定是在项目的根目录中。

现在我们可以在命令行中键入ywz即可看到命令行提示的脚手架

目前为止,我们的准备工作就完成了,现在开始进入正题。

配置命令

process.argv属性

手写脚手架的第一步,就是配置我们的命令,只有配置完毕命令,才才可以进行开发。

首先介绍process.argv属性,该属性返回一个数组,其中包含当启动Node.js进程时传入的命令行参数。 第一个元素是process.execPath,即Node.js的安装路径。 第二个元素将是正在执行的JavaScript文件的路径。 其余元素将是任何其他命令行参数。

假如我们的bin/ywz.js中是这样的

#! /usr/bin/env node

console.log(process.argv)

然后在命令行中输入下面这段命令

ywz create node

即可看到下面这段内容

[
  'C:\\Program Files\\nodejs\\node.exe',
  'E:\\nodejs\\npm-global\\node_modules\\ywz\\bin\\ywz.js',
  'create',
  'node'
]

如果这里通过原生Node提供的这个属性去操作命令行中的参数,那肯定是非常的麻烦的,所以我们使用第三方提供包,也就是commander

commander的使用

首先我们先写入如下代码:

#! /usr/bin/env node
const { program } = require('commander')
const { version } = require('../package.json')

// .version() 方法用于设置版本号,当在命令行中执行 --version 或者 -V 时,显示的版本
// .parse() 用于解析命名行参数,默认值为 process.argv * 重要
program.version(version).parse()

然后在命令行中输入ywz -V即可看到版本号。

现在我们就可以通过.command()方法来定义命令了,该方法的第一个参数为命令名称,后面可以跟命令参数,命令参数也可以使用.argument()方法单独指定。

该方法接受的参数有三种,如下所示:

  • 必选参数:尖括号表示
  • 可选参数:方括号表示
  • 可变参数:在参数名后加上...,例如<dirs...>,如果有可变参数,必须在最后。

还可以通过.alias()方法设置别名,使用.action()方法对命令进行处理。

示例代码如下:

#! /usr/bin/env node
const { program } = require('commander')
const { version } = require('../package.json')

program
  // 定义命令
  .command('create')
  // 定义别名
  .alias('crt')
  // 定义参数
  .argument('<projectName>')
  // 定义命令处理方法
  .action(projectName => {
    // 该方法接受一个回调函数,回调函数的参数名称就是我们前面定义的参数
    console.log(projectName)
  })

program.version(version).parse()

而且我们使用commander会自动帮助我们生成help选项,测试如下:

ywz --help

结果如下:

Usage: ywz [options] [command]

Options:
  -V, --version             output the version number
  -h, --help                display help for command

Commands:
  create|crt <projectName>
  help [command]            display help for command

优化命令

现在,对应我们这个项目来说,目前这个程度的使用commander就够了,然后改造一下代码

#! /usr/bin/env node
const { program } = require('commander')
const { version } = require('../package.json')
const creatProject = require('..')

program
  .command('create')
  .alias('crt')
  .argument('<projectName>')
  .action(projectName => {
    // 处理函数,定义在外部
    creatProject(projectName)
  })

program.version(version).parse()

require(..)表示的上级目录中的index.js,由于我们的package.json中的main字段中的入口在lib/index.js中,所以require(..)中引入的就是lib/index.js文件,在该文件中写入如下代码

module.exports = function (name) {
  console.log(name)
}

然后在命令行中输入ywz create node,即可看到命令中输出的node,即项目的名称。

获取远端模板

概述

这里我们将远端仓库存储在Github,介绍两个Github提供的两个API,分别如下:

  • 获取指定用户的仓库列表
`https://api.github.com/users/${username}/repos`
  • 获取指定仓库的分支列表
`https://api.github.com/repos/${username}/${repositoriesName}/branches`

我们使用测试的模板参考地址是:pacpc/node-template: node仓库模板 (github.com)

基础库的使用

首先我们介绍一下orainquirer库的使用。

inquirer库用于在命令行交互,它的语法结构如下所示:

const inquirer = require('inquirer')
module.exports = async name => {
  let { projectName } = await inquirer.prompt({
    /* Pass your questions in here */
  })
  console.log(projectName)
}

inquirer.prompt()方法的返回值是一个Promise,这里我们使用async/await语法糖。

方法接受两个参数,两个都是对象,通常我们使用第一个就够了,具体语法内容可以参考这里。如下代码展示了inquirer库的基本用法

const inquirer = require('inquirer')
module.exports = async name => {
  let { projectName } = await inquirer.prompt({
    // 问题的类型,input 表示输入
    type: 'input',
    // 答案的 key
    name: 'projectName',
    // 问题是什么
    message: 'The project name is it?',
    // 默认值
    default: name,
  })
  let { license } = await inquirer.prompt({
    // 问题的类型,list 表示可以选择
    type: 'list',
    // 答案的 key
    name: 'license',
    // 问题是什么
    message: 'Choose a license',
    // 支持选择的选项
    choices: ['LGPL', 'Mozilla', 'GPL', 'BSD', 'MIT', 'Apache'],
    // 默认值
    default: 'MIT',
  })
  console.log(projectName, license)
}

现在我们在命令行中输入ywz create node-test,运行结果如下所示:

01_inquirer库的应用.gif

ora库用于实现loading效果,该库的使用比较简单,直接调用ora()方法,可以传入一个字符串作为显示的内容,该方法返回一个实例对象,可以调用start()方法开始旋转、stop()停止旋转、succeed()成功并停止旋转、fail()失败停止旋转。还有很多实例方法,具体可以参考文档,点击这里

示例代码如下:

const inquirer = require('inquirer')
const ora = require('ora')

module.exports = async name => {
  let { projectName } = await inquirer.prompt({
    type: 'input',
    name: 'projectName',
    message: 'The project name is it?',
    default: name,
  })
  const spinner = ora('开始加载...').start()
  setTimeout(() => {
    console.log('\n项目名称是:' + projectName)
    spinner.succeed('加载完毕')
  }, 3000)
}

测试结果如下:

02_ora库的应用.gif

获取远端模板

现在就可以通过axios库获取我们的具体仓库名称,然后根据仓库名称获取对应的仓库分支,选择分支直接下载即可。

首先我们封装一个loading方法,该方法可以为axios的请求增加一个loading效果,具体实现代码如下:

/**
 * @description: 为一个Promise函数添加一个loading效果
 * @param {Function} callback 返回Promise且需要被loading修饰的函数
 * @returns {Function} 被修饰后的方法
 */
const loading = callback => {
  return async (...args) => {
    // 开始
    let spinner = ora('start...').start()
    try {
      // 没有异常即成功
      let res = await callback(...args)
      spinner.succeed('success')
      return res
    } catch (error) {
      spinner.fail('fail')
      return error
    }
  }
}

然将我们前面提到的两个API封装为方法,代码如下:

/**
 * @description: 获取仓库列表
 * @param {string} username 被获取的用户名
 * @returns {Array} 仓库列表
 */
const fetchRepoList = async username => {
  let { data } = await axios.get(
    `https://api.github.com/users/${username}/repos`,
  )
  return data.map(item => item.name)
}

/**
 * @description: 获取 branches 列表
 * @param {string} username 需要获取的用户名
 * @param {string} repoName 需要获取的仓库名称
 * @returns {Array} branches 列表
 */
const fetchTagList = async (username, repoName) => {
  let { data } = await axios.get(
    `https://api.github.com/repos/${username}/${repoName}/branches`,
  )
  return data.map(item => item.name)
}

实现获取远端模板代码如下:

module.exports = async name => {
  let { projectName } = await inquirer.prompt({
    // 问题的类型,input 表示输入
    type: 'input',
    // 答案的 key
    name: 'projectName',
    // 问题是什么
    message: 'The project name is it?',
    // 默认值
    default: name,
  })
  // 获取仓库列表
  let repos = await loading(fetchRepoList)('pacpc')
  // 选择仓库列表
  let { repoName } = await inquirer.prompt({
    type: 'list',
    name: 'repoName',
    message: 'Choose a template',
    choices: repos,
  })
  // 获取所有 branches
  let branches = await loading(fetchTagList)('pacpc', repoName)

  // 如果有多个分支,用户选择多个分支,没有多个分支可以直接下载
  if (branches.length > 1) {
    // 存在
    let { checkout } = await inquirer.prompt({
      type: 'list',
      name: 'checkout',
      message: 'Choose the target version',
      choices: branches,
    })
    repoName += `#${checkout}`
  } else {
    repoName += `#${branches[0]}`
  }
}

现在我们可以通过命令行来测试这个代码的可行性了。

下载模板

download-git-repo库

download-git-repo库可以下载Github中的存储库,使用方式也比较简单,直接将用户名/仓库名作为参数传递即可;这里我们通过Node.js提供promisify()方法将download-git-repo库提供的方法转换为Promise,示例代码如下:

const { promisify } = require('util')
const download = promisify(require('download-git-repo'))

缓存处理

如果我们每次创建一个项目都要进行模板的下载的话,其实是不必要的,我们可以在第一次下载的时候进行一下缓存,以后如果有需要的话,我们可以直接使用,不需要下载。

一般我们将缓存存储在用户目录下的.tmp目录下,在Node.js中获取用户目录通过process.env.USERPROFILE来获取Windows下的用户目录,通过process.env.HOME来获取macOS下的用户目录。还可以通过process.platform属性来获取当前是不是Windows系统。示例代码如下:

// win32 表示 Windows 系统
console.log(process.platform) // win32
const user = process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME']
console.log(user) // C:\Users\Administrator

定义下载函数

现在我们知道了如何下载一个Github上的模板,以及获取存储模板的目录,现在我们就来定义一个下载函数,实现代码如下:

/**
 * @description: 下载具体仓库中的内容
 * @param {string} username 仓库拥有者的名称
 * @param {string} repoName 仓库名称 + 分支名称, # 号拼接
 * @returns {string} 下载的临时目录
 */
const downloadGithub = async (username, repoName) => {
  const cacheDir = `${
    process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME']
  }/.tmp`
  // 拼接一个下载后的目录
  let dest = path.join(cacheDir, repoName)
  // fs 模块提供的 existsSync 方法用于判断目录是否存在,如果存在,说明无需下载
  let flag = existsSync(dest)
  let url = `${username}/${repoName}`
  if (!flag) {
    // 需要下载 则执行下载
    await loading(download)(url, dest)
  }
  return dest
}

该函数的使用如下:

let dest = await downloadGithub('pacpc', repoName)

渲染模板数据

现在我们就将模板已经下载到本地了,现在我们就开始对模板中的数据进行处理。

确定模板数据

我们的一个脚手架可能用来使用很多个模板,但是每个模板可能都有一些个性化的内容,我们我们在每个模板中增加一个question.js,用来存储每个模板的问题,从而生成对应的内容。

这里测试的question.js的内容如下:

module.exports = [
  {
    type: 'input',
    name: 'version',
    message: 'version?',
    default: '0.1.0',
  },
  {
    type: 'input',
    name: 'description',
    message: 'description',
  },
  {
    type: 'input',
    name: 'author',
    message: 'author?',
  },
  {
    type: 'input',
    name: 'email',
    message: 'email?',
  },
  {
    type: 'input',
    name: 'github',
    message: 'github?',
  },
  {
    type: 'list',
    name: 'license',
    message: 'Choose a license',
    choices: ['LGPL', 'Mozilla', 'GPL', 'BSD', 'MIT', 'Apache'],
    default: 'MIT',
  },
]

模板引擎

这里我们使用的模板引擎是Handlebars (handlebarsjs.com),我们通过consolidate来统一管理模板引擎,使用方式也比较简单,示例代码如下:

const { render } = require('consolidate').handlebars
content = await render(content, data)

上面代码中content表示原始数据,返回值是将原始数据中的模板语法替换为data中的数据内容。

metalsmith库的应用

这里介绍一下metalsmith库的应用,该库是一个静态站点生成器,用法比较简单,如下代码所示:

Metalsmith(__dirname)
  // 源目录 默认值 src
  .source() 
  // 目标目录 默认值 build
  .destination()
  // 中间处理方法
  .use(async (files, metal, done) => {
    // files 就是需要渲染的模板目录下的所有类型的文件
    // metal.metadata() 可以来保存所有的数据,交给下一个use 使用
    // done() 执行完毕调用
    done()
  })
  // 处理方法可以有多个
  .use((files, metal, done) => {
    // 获取上一个 use 中拿到的用户填写的数据
    done()
  })
  // 处理完毕
  .build((err) => {
    if (err) {
      // 失败了
    } else {
      // 成功了
    }
  })

上面就是metalsmith库的一个基本应用。

渲染数据

我们我们已经知道了渲染数据前的一些工具库的应用,以及每个模板中的提问内容,开始编写我们的这个主要代码,代码如下:

  // 下载模板到临时目录
  let dest = await downloadGithub('pacpc', repoName)
  // 判断下载的模板中是否包含 question.js 如果包含则进行模板的替换,否则直接复制到目标仓库
  if (existsSync(path.join(dest, 'question.js'))) {
    await new Promise((resolve, reject) => {
      Metalsmith(__dirname)
        .source(dest)
        .destination(path.resolve(projectName))
        .use(async (files, metal, done) => {
          // files 就是需要渲染的模板目录下的所有类型的文件
          // 加载 question 文件
          const quesList = require(path.join(dest, 'question.js'))
          // 依据问题数据,定义交互问题
          let answers = await inquirer.prompt(quesList)

          // 当前 answers 保存的是用户传递的数据,我们通过 metal.metadata() 将其保存给下一个 use 中使用
          let meta = metal.metadata()
          Object.assign(meta, answers, { projectName })

          // 删除 question.js 文件,避免拷贝的用户模板
          // 可以通过 delete 关键字删除的原因是因为 files 中存在的全部都是 buffer,我们直接删除这个 key,对应的 value 也就被删除了
          delete files['question.js']
          done()
        })
        .use((files, metal, done) => {
          // 获取上一个 use 中存储的数据
          let data = metal.metadata()

          // 将 files 中的所有自有属性制作为一个数据
          let arr = Reflect.ownKeys(files)
          // 通过遍历数组,将所有的 buffer 转换为字符串,然后通过模板引擎进行替换,最后转换为 buffer 存储即可
          arr.forEach(async file => {
            // 只对 js 或者 json 文件进行替换
            if (file.includes('js') || file.includes('json')) {
              let content = files[file].contents.toString()
              // 如果包含模板引擎语法就进行替换
              if (content.includes('{{')) {
                content = await render(content, data)
                files[file].contents = Buffer.from(content)
              }
            }
          })
          done()
        })
        // 如果有异常 Promise 调用 reject
        .build(err => {
          if (err) {
            reject(err)
          } else {
            resolve()
          }
        })
    })
    console.log('\nsuccess~')
  } else {
    // 如果不需要模板进行处理的直接拷贝至项目目录
    ncp(dest, projectName)
  }

到这里我们的基本代码就全部完成了,现在就可以测试带个代码了,命令行输入ywz create node-test,测试结果如下:

03_脚手架测试.gif

写在最后

这篇文章这样就结束了,大概用了一下午的时间写了这篇文章,希望可以对你有所帮助。

这是 《轮子是怎么跑起来的》专栏的第一篇文章,该专栏持续输出一些轮子原理以及怎么造轮子的文章,如果和你的胃口,可以三连支持一下。
目录
相关文章
|
存储 JSON 资源调度
10分钟带你从0到1搭建monorepo 工程化项目(一)
前言 大家好,我是Fly哥, 之前写博客的仓库,还是用的原生的html 和js 也没有引入 ts , 和一些工程化的东西, 所以自己重新搭建了一套前端项目架构 基于 lerna + yarn 的 monrepo的仓库, 主要是后面会学习输出的一些东西, 整个架子先搭建起来。 2d 和 3d 公共 util 的封装 个人 npm 包的发布 (rollup) 2d react 项目 搭建(vite) 3d react 项目 搭建 (webpack) 搭建一套基于webpack 5 的cli 每个项目都有一些特定的依赖, 但是也会有一些相同的依赖。比如eslint、 babel 的一些基础配置,
10分钟带你从0到1搭建monorepo 工程化项目(一)
|
5月前
|
JavaScript 前端开发 jenkins
【开发脚手架】系列教程 -1- 脚手架的价值,功能,命令详解,执行原理图解
【开发脚手架】系列教程 -1- 脚手架的价值,功能,命令详解,执行原理图解
55 2
|
6月前
|
前端开发 JavaScript 数据安全/隐私保护
我为什么还要造一个前端轮子?
该文档介绍了一个新的前端框架,创建原因是现有框架多关注技术实现,缺乏具体业务场景的应用。此框架基于vue-element-admin,采用VUE和ElementUI,提供了如账号密码登录、手机短信登录、注册、找回密码等实际业务功能模块。还包括图形验证码、机构选择等组件,支持子模块集成。附有截图预览,并提供了演示地址:[VUE前端开发框架演示](http://vue-template.dayuan.link/),用户可以体验完整功能,后端接口可替换。
|
6月前
|
前端开发 开发工具 git
[巨详细]使用HBuilder-X启动uniapp项目教程
【6月更文挑战第6天】使用HBuilder-X启动uniapp项目教程 先用HBuilder-X打开本地的uniapp项目
788 0
|
缓存 JavaScript 前端开发
【前端架构必备】手摸手带你搭建一个属于自己的脚手架
【前端架构必备】手摸手带你搭建一个属于自己的脚手架
1389 0
【前端架构必备】手摸手带你搭建一个属于自己的脚手架
|
前端开发
前端学习笔记202304学习笔记第九天-快速搭建一个脚手架
前端学习笔记202304学习笔记第九天-快速搭建一个脚手架
71 0
前端学习笔记202304学习笔记第九天-快速搭建一个脚手架
|
前端开发 BI 程序员
因为懒,我用了“低代码”打下手
因为懒,我用了“低代码”打下手
|
JavaScript 前端开发 开发工具
【从零到一手撕脚手架 | 第一节】配置基础项目结构 Vite + TypeScrpit + Vue3 初始化项目
今天为大家带来一套教程,教大家入门“脚手架”,相信你一定会有所收获。 目前项目已开源且仍处于开发阶段,后续会更新更多内容,如有不正确的地方请大家指正,我会及时更新并纠正我的错误。
550 0
【从零到一手撕脚手架 | 第一节】配置基础项目结构 Vite + TypeScrpit + Vue3 初始化项目
|
存储 资源调度 JavaScript
基于 Yeoman 脚手架技术构建前端项目的实践
基于 Yeoman 脚手架技术构建前端项目的实践
180 0
|
前端开发
前端学习笔记202303学习笔记第五天-了解vite项目的运行流程1
前端学习笔记202303学习笔记第五天-了解vite项目的运行流程1
73 0