如手摸手教你写一个脚手架

2022/05/12 功能实现 共 4477 字,约 13 分钟

背景

最近连续需要创建两个新的项目,两个项目的框架都是长得差不多的。copy 是可以解决问题,但是我在想能不能通过工具解决问题呢?

于是我有了一个想法,我想要有一个属于我的脚手架。

减少重复性的工作,不再从零创建一个项目,或者复制粘贴另一个项目的代码 。

根据动态交互生成项目结构和配置文件,具备更高的灵活性和人性化定制的能力 。

可以集成多套开发模板,根据项目需要选择合适的模板。

基于 vite 了解脚手架的简单应用

我们在运行vite命令行创建项目的时候会询问一些简单的问题,并且用户选择的结果去渲染对应的模板文件,基本工作流程如下:

  1. 全局安装 vite

  2. 通过命令行交互询问用户问题

  3. 根据用户回答的结果生成文件

  4. 创建项目

也许你和我一样会好奇

vite 是如何实现的呢?通过简单的选择就能快速的搭建一个最简单的项目

如果我也想有一个类似 vite 这样的工具,让我可以快速创建耦合通用业务代码的项目我该如何实现呢?

搭建自己的脚手架

实现一个最简单的脚手架,通常需要以下工具

commander: 命令行工具

download-git-repo: 用来下载远程模板

inquirer: 交互式命令行工具,询问用户的选择

构建步骤

  1. 新建一个文件夹,命名为 my-cli(我的脚手架命名),在该目录下执行 npm init -y 进行初始化,创建简单的文件夹
my-cli          
├─ bin                
│  └─ cli  # 启动文件
├─ lib
│  └─ create.js  # 逻辑文件             
└─ package.json       

再配置脚手架的 package.json 文件

{
  "name": "项目名字",
  "version": "1.0.0",
  "description": "项目内容",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "bin": {
    "template": "./bin/cli" // 配置启动文件路径,template 为别名,

  },
  "author": "",
  "license": "ISC",
}
  1. 安装我上面说的那些最简单工具

  2. 简单编辑一下我们的 cli

#! /usr/bin/env node
console.log('~ working ~');

在终端执行命令 npm link 链接到全局方便调试。执行命令 template 可以输出打印结果:~ working ~

大概效果如下:

到此为止我们的工作已经完成了60%了,剩下的就是填充我们自己的逻辑了

创建脚手架启动命令

我想当我执行 template create vue 的时候,可以创建vue模板的项目,那么我该如何实现呢?

这是一个命令行,所以我们需要一个命令行工具,这里我选择 commander

编辑 cli 内容:

#! /usr/bin/env node

const program = require('commander')

program
  // 定义命令和参数
  .command('create [name]')
  .description('create a new project')
  // -f or --force 为强制创建,如果创建的目录存在则直接覆盖
  .option('-f, --force', 'overwrite target directory if it exist')
  .action((name, options) => {
    // 打印结果,输出用户手动输入的项目名字 
    console.log('我要创建项目的文件夹名字:',name,'剩余的参数',options)
  })
  
// 解析用户执行命令传入参数
program.parse(process.argv);

如下图,在终端输入相关命令验证:

在 create.js 文件内实现主要的业务逻辑

  1. 询问用户的选择的模板
    const inquirerParams = [{
      name:"action",
      type:"list",
      message:"请选择如下模板:",
      choices:[
     {name:"vue2模板",value:"vue2"},
     {name:"vue3模板",value:"vue3"},
      ]
    }]
    let inquirerTlp = await inquirer.prompt(inquirerParams)
    
  2. 当项目名重复的时候询问用户是否确定要覆盖
const inquirerParams = [{
  name:"action",
  type:"list",
  message:"目标文件目录已经存在,请选择如下操作:",
  choices:[
    {name:"替换当前目录",value:"replace"},
    {name:"移除已有目录",value:"remove"},
    {name:"取消当前操作",value:"cancel"}

  ]
}]
let inquirerData = await inquirer.prompt(inquirerParams)
if(inquirerData.action ==='cancel') return
  // 移除已存在的目录
  await deleteFiles(path.join(targetAir))
  1. 下载模板
    download(`direct:地址`,  targetAir, { clone: true }, async (err)=>{
      await deleteFiles(path.join(targetAir,'.git'))
    })
    

至此,一个最简单的前端脚手架就正式完成了。项目没有发 npm 上面。如果你有兴趣的想试试的话,可以自己写一个

const path = require('path')
const inquirer = require('inquirer');
const fs = require('fs-extra')
const download = require('download-git-repo')
const { join } = require("path");

async function deleteFiles(path) {
  // 判断一下路径是否真实存在
  if (!fs.existsSync(path)) return

  const file = fs.lstatSync(path);

  // 是文件,直接删除
  if (file.isFile()) {
    fs.unlinkSync(path);
    return;
  }

  // 是文件夹,遍历下面的所有文件
  if (file.isDirectory()) {
    const files = await fs.readdirSync(path);
    if (files && files.length) {

      for (const fileName of files) {
        const p = join(path, fileName);
        const f = fs.lstatSync(p);
        // 是文件,直接删除
        if (f.isFile()) {
          fs.unlinkSync(p);
        }
        // 是文件夹,递归调用 deleteFiles
        if (f.isDirectory()) {
          await deleteFiles(p);
          // 文件夹内部文件删除完成之后,删除文件夹
          fs.rmdirSync(p);
        }
      }
    }
    return;
  }
};
module.exports = async function (name,options){
  // 询问用户选用的模板
  const inquirerParams = [{
    name:"action",
    type:"list",
    message:"请选择如下模板:",
    choices:[
      {name:"vue2模板",value:"vue2"},
      {name:"vue3模板",value:"vue3"},
    ]
  }]
  let inquirerTlp = await inquirer.prompt(inquirerParams)
  if(!inquirerTlp.action) return

  const cwd = process.cwd();// 选择目录
  const targetAir = path.join(cwd,name)// 需要创建的目录地址
  // 判断目录是否已经存在?
  if(fs.existsSync(targetAir)){
    // 是否为强制创建?
    if(options.force){
      await fs.remove(targetAir)
    }else{
      // 询问用户是否确定要覆盖
      const inquirerParams = [{
        name:"action",
        type:"list",
        message:"目标文件目录已经存在,请选择如下操作:",
        choices:[
          {name:"替换当前目录",value:"replace"},
          {name:"移除已有目录",value:"remove"},
          {name:"取消当前操作",value:"cancel"}

        ]
      }]
      let inquirerData = await inquirer.prompt(inquirerParams)
      if(inquirerData.action ==='cancel') return
        // 移除已存在的目录
        await deleteFiles(path.join(targetAir))
    }
  }
  download(`direct:地址`,  targetAir, { clone: true }, async (err)=>{
    await deleteFiles(path.join(targetAir,'.git'))
  })
}

如果你觉得这个命令行太简陋了,你可以继续美化它,比如说

ora: 显示 loading 动画 chalk: 修改控制台输出内容样式 log-symbols: 显示出 √ 或 × 等的图标 handlebars.js 用户提交的信息动态填充到文件中

commander 的用法

usage(): 设置 usage 值
command(): 定义一个命令名字
description(): 设置 description 值
option(): 定义参数,需要设置“关键字”和“描述”,关键字包括“简写”和“全写”两部分,以”,”,”|”,”空格”做分隔。
parse(): 解析命令行参数 argv
action(): 注册一个 callback 函数
version() : 终端输出版本号

inquirer 的用法,它有以下参数可以配置

type:表示提问的类型,包括:input, confirm, list, rawlist, expand, checkbox, password, editor;
name: 存储当前问题回答的变量;
message:问题的描述;
default:默认值;
choices:列表选项,在某些 type 下可用,并且包含一个分隔符(separator);
validate:对用户的回答进行校验;
filter:对用户的回答进行过滤处理,返回处理后的值;
when:根据前面问题的回答,判断当前问题是否需要被回答;
prefix:修改 message 默认前缀;
suffix:修改 message 默认后缀。

总结

保持对这个世界的好奇心。看到新科技与新思想,先认同它,去体会它,理解它产生的需求背景与技术脉络,以此融入自己的知识体系。

养成一种习惯,每周一小时或者每天一小时,让自己坚持做一件事情,多年后你就会喜欢上当年一直坚持到现在的你。


在技术的历史长河中,虽然我们素未谋面,却已相识已久,很微妙也很知足。互联网让世界变得更小,你我之间更近。

在逝去的青葱岁月中,虽然我们未曾相遇,却共同经历着一样的情愫。谁的青春不曾迷茫或焦虑亦是无奈,谁不曾年少过

在未来的日子里,让我们共享好的文章,共同学习进步。有不错的文章记得分享给我,我不会写好的文章,所以我只能做一个搬运工

我叫 sunseekers(张敏) ,千千万万个张敏与你同在,18年电子商务专业毕业,毕业后在前端搬砖

如果喜欢我的话,恰巧我也喜欢你的话,让我们手拉手,肩并肩共同前行,相互学习,互相鼓励

文档信息

Search

    Table of Contents