超详细的前端脚手架入门篇

脚手架初认识

其实,我们通过平时使用的脚手架就不难推测得出脚手架的基本作用了。

比如: Vue-cli 就是一个脚手架,能够帮助我们快速搭建和配置 vuejs 项目,它提供了基本的项目结构、构建流程和开发工具链,让开发者更专注于业务的开发,提高开发和部署效率。

又比如:npm 一款依赖管理工具,简化了依赖安装和打包构建的过程。

又比如:commitizen 能够帮助我们去规范 git commit 的信息和结构,提高项目的规范性。

所以,前端脚手架的有以下优点:

  • 快速初始化:脚手架可以自动生成项目所需的基本文件结构、配置文件和示例代码,使项目的初始化过程变得简单和快速。
  • 规范项目结构:脚手架定义了一套标准的项目结构和文件组织方式,使团队成员能够在不同项目中保持一致的开发风格和规范,提高团队协作效率。
  • 依赖管理和构建流程:脚手架通常集成了依赖管理工具(如npm、yarn)和构建工具(如Webpack、Rollup)。
  • 可扩展性:脚手架提供了一些自定义配置的选项,允许开发人员根据项目需求进行灵活的配置和扩展,满足特定的需求。

你将学到什么

本文是一篇脚手架入门文,适合想要搭建自己的脚手架却不知道从何入手的开发者。

  • 如何搭建一个脚手架工程。
  • 如何开发和调试一个脚手架
  • 脚手架中如何接收和处理命令参数。
  • 如何实现文件的拷贝。
  • 如何创建询问式的交互。
  • 如何处理路径问题。

本文完整代码已上传至 github

实现

环境

  • node 16.14+
  • 包管理工具:pnpm 8.5+

第三方依赖

  • yargs node 命令解决方案。
  • inquirer 在 shell 命令行中建立询问式交互
  • copy-dir 实现文件拷贝
  • mustache 动态更改文件
  • ora 在 shell 命令行中实现加载动画
  • fs-extra 操作文件的库,比 node 自带的 fs 更强大一些
  • download-git-repo 下载 git 仓库

如何搭建一个脚手架工程

  • 先创建一个脚手架的文件夹,如:simple-cli,并执行初始化。
shell 复制代码
# 创建文件夹
mkdir simple-cil
# 进入文件夹
cd simple-cli
# 初始化
npm init -y
  • 创建bin文件夹,添加index.js。
js 复制代码
#! /usr/bin/env node

console.log('hello cli');
  • 在package.json中指定执行命令和执行的文件。
json 复制代码
# package.json
"scripts": {
    "cli": "node ./bin/index.js"
  },
  "bin": {
    "cli": "./bin/index.js"
  },
  • 在当前根目录下,执行 pnpm cli,得到以下结果:

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

/bin 放置的是入口文件,我们新建一个 /src 目录来放置业务逻辑的代码。

nodejs中,默认是使用 CommonJs 规范去编写代码的, 但是如果想用 es6 语法,就必须添加一些插件去做转译工作。

我们可以试试不使用babel转译工具,直接使用es语法会怎么样。

💥试错环节开始

新建 /src/test/index.js, 添加

js 复制代码
const test = async () => {
  console.log('hello test');
}
console.log('hello cli');

export default test

/bin/index.js

js 复制代码
#! /usr/bin/env node

var cli = require('../src/index.js');
module.exports = cli;

根目录下,执行 pnpm cli 发现以下报错:无法识别 export 语法

💥试错环节结束

所以使用es6语法,需要有以下步骤:

  • 安装babel相关插件:
shell 复制代码
pnpm add @babel/cli @babel/core @babel/plugin-proposal-object-rest-spread @babel/preset-env -D
  • 根目录下,新建 babel.config.js,并添加以下内容:
js 复制代码
module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "10",
        },
      },
    ],
  ],
  plugins: ["@babel/plugin-proposal-object-rest-spread"],
};
  • 根目录下,新建 jsconfig.json,并添加以下内容:
json 复制代码
{
  "compilerOptions": {
      "target": "ES6",
      "module": "commonjs",
      "experimentalDecorators" : true
  }
}

这样就支持用 ESM 的方式去编写了。 但是!我们还需要将 ESM 编译成 CommonJs 的形式,让nodejs去执行。

  • 编译:在 pacakge.json 中,添加打包命令,打包后的产物放在输出目录 /dist 下:
json 复制代码
"scripts": {
    ....
    "build": "babel src --out-dir dist",
    "build:watch": "babel --watch src --out-dir dist"
  },
  • 执行打包命: npm run build:watch。命令会一直开启着,监听本地文件变化,实现自动打包输出。发现根目录下多了 /dist 这个目录,就是打包的产物。

  • 修改 /bin/index.js 里文件的引用路径:

js 复制代码
#! /usr/bin/env node

var cli = require('../dist/index.js');
module.exports = cli;

这时候再来试试效果吧。

✌✌成功了!接着探索吧!

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

  • nodejs中的 process 模块提供了当前进程相关的全局环境信息,如:命令参数、环境变量、命令运行路径等。我们把它打印出来看一下:

修改 /src/index.js

js 复制代码
const process = require('process');

console.log(process.argv);

cli 自定义命令后面可以设置自定义变量,标准命令参数有两种格式:

shell 复制代码
pnpm run cli --name=project --open
pnpm run cli --name project --open

结果:

可以看到:可以获取到相关的全局环境信息。

通过 process.argv 来获取,要额外处理两种不同的命令参数格式,不方便。yargs 提供了一套node命令解决方案。

  • 使用 yargs 进行命令参数的解析。
shell 复制代码
pnpm add yargs -S

/src/index.js

js 复制代码
import yargs from 'yargs'
console.log(yargs.argv)

已经把命令参数解析好了,可以通过 yargs.argv.name 获取到name的值。

  • 设置子命令

脚手架要对外提供多个功能,不能将所有的功能都集中在 cli 命令中实现,不同的功能分发给不同的子命令。

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

yargs.command 的用法是 yargs.command(cmd, desc, builder, handler)具体用法在此

  • cmd:字符串,子命令名称,也可以传递数组,如 ['create', 'c'],表示子命令叫 create,其别名是 c
  • desc:字符串,子命令描述信息;
  • builder:一个返回数组的函数,子命令参数信息配置;
  • handler: 函数,可以在这个函数中专门处理该子命令参数;

接下来 通过一个子命令 实现文件的拷贝。

实现文件的拷贝

前提

  • 新建一个模版文件 /src/copy/template/index.js
  • 根目录下安装 copy-dir (实现文件拷贝)、 fs-extra (操作文件的库)。

/src/index.js 中处理命中子命令['copy']、获取命令参数 argv,并将参数交给 /src/copy/index.js 去处理。

js 复制代码
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'

yargs(hideBin(process.argv))
  .command(
    ['copy'],
    'Copy a new template from local file',
    function (yargs) {
      return yargs.option('name', {
        alias: 'n',
        demand: true,
        describe: '模板名称',
        type: 'string'
      })
    },
    (argv) => {
      console.log(argv)
      // import('./copy/index.js').then(({ default: parseOptions }) => {
      //   parseOptions(argv);
      // });
    }
  )
  .parse()

执行 pnpm cli copy --name=project,可以看到,命令参数已经解析好了

如果我们输入不存在的命令参数呢,试试:

可以看到会有报错,并且有提示信息。

/src/copy/index.js 中,实现拷贝逻辑的处理。

js 复制代码
import path from 'path'
import copydir from 'copy-dir';
import fs from 'fs-extra';

const parseOptions = async (argv) => {
   const { name } = argv;
   const isMkdirExists = checkMkdirExists(
     path.resolve(process.cwd(), `./${name}`)
   );
   if (isMkdirExists) {
     console.log(`${name}文件夹已经存在`)
   } else {
     // 拷贝文件夹
     copyDir(
       path.resolve(__dirname, `./template`),
       path.resolve(process.cwd(), `./${name}`)
     )
   }
}

// 拷贝文件夹
function copyDir (from, to, options) {
 copydir.sync(from, to, options);
}

// 路径判断
function checkMkdirExists (path) {
 return fs.existsSync(path)
};

export default parseOptions

根据获取到的name参数,判断当前操作目录下是否存在同名的文件夹,如果存在提示并退出,如果不存在则写入。

执行 pnpm cli copy --name=project: 可以看到已经生成了project文件夹,里面的内容是从/src/copy/template 里拷贝过来的。

💥 process.cwd() 和 __dirname 的区别:

process.cwd() 代表当前 Node.js 进程 执行时 的文件所属目录的绝对路径。比如,在simple-cli根目录下 去执行命令,那 process.cwd() 得到的就是 /Users/wisdom/Documents/simple-cli

__dirname 动态 获取当前命令正在操作 的文件所属目录的绝对路径。比如:执行 /copy/index.js 中的方法时,那 __dirname 得到的就是 /Users/wisdom/Documents/simple-cli/dist/copy

path 模块提供的 path.resolve( [from...], to ) 方法将路径转成绝对路径:

  • 从后向前拼接路径;

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

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

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

打印出来验证一下:

/copy/index.js

js 复制代码
...
const parseOptions = (argv) => {
  console.log('process.cwd()', process.cwd())
  console.log('__dirname', __dirname)
  ...
  }
...
  

目前实现的都是直接对命令参数的解析,忽略了与用户的交互,所以接下来学习怎么建立询问时的交互。

如何创建询问式的交互

询问式是比较友好的交互形式,当功能越来越多,需要的命令参数也越来越多,我们不可以一股脑的都显式的写在命令参数上,第一是太冗长且不灵活,第二是对用户来说是个心智负担。

这种询问式的交互就超级友好!那我们就开始吧!

这里推荐 inquirer 开源库来实现:通过向用户提问问题,获取用户的输入,检验用户输入是否合法。

shell 复制代码
pnpm add inquirer@8.2.5 -S

通过inquirer.prompt() 来实现,接收一个数组,数组的每一项都是一个问题,有以下配置项:

  • type:提问的类型,有 input、confirm、list、checkbox
  • name:存储当前问题答案的变量。
  • message: 问题的描述。
  • default: 默认值。
  • choices: 选项列表。
  • validate:对用户答案进行校验。
  • filter:对用户答案进行过滤,并返回处理后的值。

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

新建 /src/copy/inquirer.js

js 复制代码
import inquirer from 'inquirer';
import path from 'path';

// 交互式询问列表
function inquirerPrompt (argv) {
  const { name } = argv;
  return new Promise((resolve, reject) => {
    inquirer.prompt([
      {
        type: 'input',
        name: 'name',
        message: 'Project name',
        default: name,
        validate: function (val) {
          if (!/^[a-zA-Z]+$/.test(val)) {
            return "The template name can only contain English";
          }
          if (!/^[A-Z]/.test(val)) {
            return "The first letter of the template name must be capitalized"
          }
          return true;
        },
      },
      {
        type: 'list',
        name: 'type',
        message: 'Choose Template type',
        choices: ['form', 'dynamicForm', 'nestedForm'],
        filter: function (value) {
          return {
            'form': "form",
            'dynamicForm': "dynamicForm",
            'nestedForm': "nestedForm",
          }[value];
        },
      },
      {
        type: 'list',
        message: 'Choose Frame type',
        choices: ['vue', 'react'],
        name: 'frame',
      }
    ]).then(answers => {
      const { frame } = answers;
      if (frame === 'react') {
        inquirer.prompt([
          {
            type: 'list',
            message: 'Choose UI Library',
            choices: [
              'Ant Design',
            ],
            name: 'library',
          }
        ]).then(answers1 => {
          resolve({
            ...answers,
            ...answers1,
          })
        }).catch(error => {
          reject(error)
        })
      }

      if (frame === 'vue') {
        inquirer.prompt([
          {
            type: 'list',
            message: 'Choose UI Library',
            choices: ['Element'],
            name: 'library',
          }
        ]).then(answers2 => {
          resolve({
            ...answers,
            ...answers2,
          })
        }).catch(error => {
          reject(error)
        })
      }
    }).catch(error => {
      reject(error)
    })
  })
}

export { inquirerPrompt }

inquirerPrompt 返回的是一个 Promise, 我们用then去获取用户输入的所有的答案,再根据具体的答案去做具体的处理。

接着改造一下 /src/copy/index.js, 加入询问式交互:

js 复制代码
import { inquirerPrompt } from './inquirer'

...
const parseOptions = (argv) => {
  inquirerPrompt(argv).then(answers => {
    const { name, type } = answers;
    ...
  })
}


export default parseOptions

既然我们已经向用户询问了模版名称,那就没必要在命令参数中要求输入模版名称了,冗余了,所以把yargs.command中的 builder函数 删除。

执行 pnpm cli copy

建立询问式交互,目的达成!

如何处理路径问题

如果项目名称已存在,应该再询问一次当前文件夹已存在,是否需要覆盖,而不是直接结束进程,没有给用户选择的余地。

/src/copy/inquirer.js 添加 isOverride 方法

js 复制代码
const isOverride = async (name, targetDir) => {
 return new Promise((resolve, reject) => {
   inquirer.prompt([
     {
       name: 'action',
       type: 'list',
       message: `${name} is existed, do you want to overwrite this directory`,
       choices: [
         { name: 'overwrite', value: true },
         { name: 'cancel', value: false },
       ],
     },
   ]).then(options => {
     const { action } = options
     resolve(action)
   })
 })
}

export { inquirerPrompt, isOverride }

改造 /src/copy/index.js parseOptions方法

js 复制代码
import { inquirerPrompt, isOverride } from './inquirer'
import fs from 'fs-extra';

const parseOptions = (argv) => {
 inquirerPrompt(argv).then(answers => {
   const { name, type } = answers;
   const targetDir = path.resolve(process.cwd(), `./${name}`)
   const isMkdirExists = checkMkdirExists(targetDir);
   if (isMkdirExists) {
     isOverride(name, targetDir).then(async action => {
       console.log('action', action)
       if (!action) {
         return;
       } else {
         console.log('\r\noverwriting...');
         await fs.remove(targetDir);
         console.log('overwrite done');
         copyDir(
           path.resolve(__dirname, `./template/${type}`),
           path.resolve(process.cwd(), `./${name}`)
         )
       }
     })
   } else {
     // 拷贝文件夹
     copyDir(
       path.resolve(__dirname, `./template/${type}`),
       path.resolve(process.cwd(), `./${name}`)
     )
   }
 })
}

执行 pnpm cli copy

到此,就结束了!

结语

本文完整代码已上传至 github。这只是一个入门级别的脚手架,需要好好研究再精进一下。平时看到实操性强的文章可以试试跟着练一练,实战才能检验真理。

参考文章:juejin.cn/post/726014...

相关推荐
mldong5 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排5 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60616 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角7 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!7 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰7 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi9 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen9 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马10 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒10 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端