热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

自定义简单的脚手架工具

自定义简单的脚手架工具-引子在前端项目中,脚手架可以理解为自动为我们创建项目基础文件的一种工具。除了创建文件外,更重要的是提供给开发者一些约定和规范。通常在去开发相同类型的项目时

引子

在前端项目中,脚手架可以理解为自动为我们创建项目基础文件的一种工具。除了创建文件外,更重要的是提供给开发者一些约定和规范。通常在去开发相同类型的项目时都会有一些相同的约定,例如:

  • 相同的组织规范
  • 相同的开发范式
  • 相同的模块依赖
  • 相同的工具配置
  • 相同的基础代码

有时创建新项目时,会有大量的重复工作要做,脚手架工具就是用来解决这样一类问题的。我们可以通过脚手架工具去快速的搭建特定类型的项目骨架,然后基于这个骨架进行后续开发工作。

在前端项目创建过程中,由于前端技术选型的多样性,加上没有一个统一的标准,所以前端项目的脚手架工具不会集成在同一个当中。在前端项目中有很多成熟的脚手架工具,但是大多数都是为了特定项目类型而服务的。例如使用create-react-app创建react项目、使用vue-cli创建vue项目、使用angular-cli创建angular项目。它们的实现方式大同小异,通过开发者提供的一些信息去主动生成一些项目文件以及一些配置。还有一些脚手架工具可以生成通用型项目,比如yeoman,它可以根据一套模板生成对应的项目结构。这类型的工具一般都很灵活而且易于扩展。除了上面这些在创建项目时才会用到的脚手架工具,还有一些脚手架工具也特别好用,比如plop,他们可以在项目创建过程中用来创建特定类型的文件,例如可以在项目中创建一个组件或者模块所需要的文件,这些文件一般都是需要几个特定的文件组成的,而且每个文件都有一些特定的代码。相比于开发者手动创建这些文件,脚手架会以更为稳定的一种方式创建文件。

对比以上的脚手架工具,我们也可以自己自定义一个满足自己需求的脚手架工具。

初始化脚手架文件结构

创建一个空文件夹并通过npm init或者yarn init创建一个package.json文件,这里使用yarn

  yarn init --yes

package.json文件中再加一项bin属性:

  {
      ...,
      "bin": "cli.js",
      ...
  }

现在package.json文件中大概是这样:

name属性为脚手架的名称,以上图为例,在后续可以使用my-cli的命令来启动脚手架

再创建一个cli.js文件,此文件为该脚手架的入口文件,在文件的开头应加上:

    #!/usr/bin/env node

Node CLI应用入口文件必须要这样的开头

再创建一个template文件夹,用于存放模板文件。

TIPS:模板文件中存放着要生成的项目基本结构以及文件,以下以vue项目大致的结构为例的template

└───template/.........................模板目录
    ├───public/.........................public目录
    ├───src/............................src目录
    |	└───api/..........................api目录
    │   └───assets/.......................资源目录
    │       └───fonts/......................字体资源目录
    │       └───icon/.......................图标目录
    │       └───img/........................图片目录
    │       └───styles/.....................公共样式目录
    │   └───components/.................组件目录
    │   └───config/.....................配置文件目录
    │   └───derectives/.................指令文件目录
    │   └───filters/....................过滤器文件目录
    │   └───layouts/....................页面布局文件目录
    │   └───mixins/.....................混合文件目录
    │   └───mock/.......................mock文件目录
    │   └───plugins/....................插件目录
    │   └───router/.....................路由文件目录
    │   └───store/......................vuex文件目录
    │   └───themes/.....................主题文件目录
    │   └───utils/......................其他文件目录
    │   └───views/......................vue文件目录
    │   └───App.vue.....................入口vue文件
    │   └───main.js.....................入口js文件
    └───.env.development/.............开发环境配置文件
    └───.env.production/..............生产环境配置文件
    └───.eslintrc.js..................eslint配置文件
    └───.gitignore....................git忽略文件
    └───babel.config.js...............babel配置文件
    └───jsconfig.json.................文件指定根目录和Javascript服务提供的功能选项文件
    └───package.json..................模块包配置文件

在模板文件中,可以通过ejs的语法为模板渲染预留位置,例如给template中的package.jsonname属性值替换成"<%= name %>",这样在后续可以通过询问用户项目名称然后将用户输入的用户名称渲染在此处。

vue项目中index.html文件中也会自带的有这样的语法,如: 而我们只想让它自己的ejs语法原样输出,所以可以在<%后再加上一个%,让其能够原样输出

然后在命令行内输入npm link或者yarn link命令,将其存于npm或者yarn的内存之中,以便后续使用脚手架。

cli.js内输入

#!/usr/bin/env node

console.log('cli working~')

然后在命令行之中输入my-cli运行脚手架,会有打印出cli working,这样就说明脚手架可以成功运行起来了。

至此,脚手架的文件结构基本上就完成了,接下来就开始实现脚手架的工作流程。

脚手架工作流程

一般来说脚手架的工作流程分为这两步:

  • 通过命令行交互询问用户问题
  • 根据用户回答的结果生成文件

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

使用npm i inquireryarn add inquirer命令安装inquirer模块,此模块可以帮助我们实现与用户的命令行交互、获取用户输入的内容。

cli.js内引入并使用该模块:

#!/usr/bin/env node

// console.log('cli working~')

const inquirer = require('inquirer')

inquirer.prompt([
  {
    type: 'input',
    name: 'name',
    message: 'Project Name?'
  }
]).then(answer => {
  console.log(answer)
})

inquirerprompt方法可以实现在命令行中与用户交互,promptthen方法传入一个回调函数,回调函数中的参数为用户输入的内容。

命令行中使用my-cli运行脚手架,将会询问项目名称。输入名称按下回车后将会打印刚刚所输入的内容:

{ name: 项目名称 }

接下来将要考虑如何根据用户的输入来生成对应的项目文件

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

根据生成文件这样的需求,我们大致可以想到这个需求需要读取模板文件渲染模板文件生成目标文件这样的操作,所以需要引入nodefspath这两个核心模块并且安装引入ejs模块来渲染模板文件。

// 安装ejs
yarn add ejs
or
npm i ejs
#!/usr/bin/env node

// console.log('cli working~')

// 引入
const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const ejs = require('ejs')

inquirer.prompt([
  {
    type: 'input',
    name: 'name',
    message: 'Project Name?'
  }
]).then(answer => {
  console.log(answer)
})

这样基本工作就做完了,现在开始完善promptthen方法中的逻辑。

then方法中首先定义一个变量来保存模板文件的路径:

// 模板路径
const tempDir = path.join(__dirname, 'templates')  // __dirname为当前文件(cli.js)的路径

再定义一个变量来保存目标文件的路径:

// 目标路径
const destDir = path.join(process.cwd(), answer.name) 
// process.cwd()为脚手架工作时的路径,将其与用户输入的项目名称拼接起来作为目标路径

判断当前文件夹下是否有目标路径的目录,若有则抛出一个错误,否则就创建目标文件夹:

// 判断当前文件夹下是否有目标路径的目录
  if (fs.existsSync(destDir)) {
    throw Error(`Folder named '${answer.name}' is already existed`)
  }

  // 创建文件夹
  fs.mkdir(destDir, {recursive: true}, (err) => {
    if (err) throw err
  })

接下来开始开始编写读取、渲染以及生成的代码。

为了方便以及代码美观,我们将封装成一个函数,接下来这些操作的逻辑将在rw函数中书写:

/**
 * @description 模板文件的读取、渲染以及生成
 * @param tempDir 模板路径
 * @param destDir 目标路径
 * @param answer 用户输入的内容
 */
const rw = (tempDir, destDir, answer) => { }

整理一下cli.js中的代码:

#!/usr/bin/env node

// 引入
const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const ejs = require('ejs')

inquirer.prompt([
  {
    type: 'input',
    name: 'name',
    message: 'Project Name?'
  }
]).then(answer => {
  // 模板路径
  const tempDir = path.join(__dirname, 'templates')

  // 目标路径
  const destDir = path.join(process.cwd(), answer.name)

  // 判断当前文件夹下是否有目标路径的目录
  if (fs.existsSync(destDir)) {
    throw Error(`Folder named '${answer.name}' is already existed`)
  }

  // 创建文件夹
  fs.mkdir(destDir, {recursive: true}, (err) => {
    if (err) throw err
  })
  
  // 将模板下的文件全部转换到目标目录
  rw(tempDir, destDir, answer)
  
  // 成功后的打印
  console.log(`Congratulations! Project '${answer.name}' has been created successfully~`)
  
})

/**
 * @description 模板文件的读取、渲染以及生成
 * @param tempDir 模板路径
 * @param destDir 目标路径
 * @param answer 用户输入的内容
 */
const rw = (tempDir, destDir, answer) => { }

读取模板文件

const rw = (tempDir, destDir, answer) => {
  // 使用fs.readdir方法读取文件夹下所以文件
  fs.readdir(tempDir, (err, files) => {
    if (err) throw err

    // 遍历文件数组
    files.forEach(file => {

      // 得到当前文件的路径
      const filePath = path.join(tempDir, file)

      // 读取文件的状态
      fs.stat(filePath, (err1, stats) => {
        if (err1) throw err1

        // 通过stats中的isFile方法判断当前文件是文件还是文件夹
        if (stats.isFile()) {
          // 判断当前文件夹是否为图片、字体文件,若是则直接读写
          if (destDir.includes('img') || destDir.includes('fonts') || destDir.includes('icon') || destDir.includes('.ico')) {
            const readStream = fs.createReadStream(filePath)
            const writeStream = fs.createWriteStream(path.join(destDir, file))
            readStream.pipe(writeStream)
          } else {
            ...渲染
          }
        } else {
          // 如果当前的'file'是文件夹,就创建该文件夹再递归调用rw方法
          const targetDir = path.join(destDir, file)
          fs.mkdir(targetDir, { recursive: true }, err2 => {
            if (err2) throw err2
            rw(filePath, targetDir, answer)
          })
        }
      })
    })
  })
}

渲染模板文件

// 通过模板引擎渲染文件,回调内的第二个参数是渲染完成后的结果
ejs.renderFile(filePath, answer, (err2, res) => {
  if (err2) throw err2
  ...写入
})

生成目标文件

// 将渲染完成后的结果写入目标路径
fs.writeFileSync(path.join(destDir, file), res)

有点乱-_-|||整理一下rw函数的代码:

/**
 * @description 模板文件的读取、渲染以及生成
 * @param tempDir 模板路径
 * @param destDir 目标路径
 * @param answer 用户输入的内容
 */
const rw = (tempDir, destDir, answer) => {
  fs.readdir(tempDir, (err, files) => {
    if (err) throw err

    files.forEach(file => {

      // 得到当前文件的路径
      const filePath = path.join(tempDir, file)

      // 读取文件的状态
      fs.stat(filePath, (err1, stats) => {
        if (err1) throw err1

        // 通过stats中的isFile方法判断当前文件是文件还是文件夹
        if (stats.isFile()) {
          // 判断当前文件夹是否为图片文件,若是则直接读写
          if (destDir.includes('imgs')) {
            const readStream = fs.createReadStream(filePath)
            const writeStream = fs.createWriteStream(path.join(destDir, file))
            readStream.pipe(writeStream)
          } else {
            // 通过模板引擎渲染文件,回调内的第二个参数是渲染完成后的结果
            ejs.renderFile(filePath, answer, (err2, res) => {
              if (err2) throw err2
              // 将渲染完成后的结果写入目标路径
              fs.writeFileSync(path.join(destDir, file), res)
            })
          }
        } else {
          // 如果当前的'file'是文件夹,就创建该文件夹再递归调用rw方法
          const targetDir = path.join(destDir, file)
          fs.mkdir(targetDir, { recursive: true }, err2 => {
            if (err2) throw err2
            rw(filePath, targetDir, answer)
          })
        }
      })
    })
  })
}

看这一层接一层的回调函数实在头疼,再将带有回调的异步操作改成它同步的API叭:

// optimization--rw方法中过多回调函数,改成同步的api
/**
 * @description 模板文件的读取、渲染以及生成
 * @param tempDir 模板路径
 * @param destDir 目标路径
 * @param answer 用户输入的内容
 */
const rw = (tempDir, destDir, answer) => {
  try {
    const files = fs.readdirSync(tempDir)

    files.forEach(file => {
      const filePath = path.join(tempDir, file)
      const targetDir = path.join(destDir, file)

      const stats = fs.statSync(filePath)

      if (stats.isFile()) {
        if (destDir.includes('img') || destDir.includes('fonts') || destDir.includes('icon') || destDir.includes('.ico')) {
          const readStream = fs.createReadStream(filePath)
          const writeStream = fs.createWriteStream(targetDir)
          readStream.pipe(writeStream)
        } else {
          ejs.renderFile(filePath, answer, (err2, res) => {
            if (err2) throw err2
            // 将渲染完成后的结果写入目标路径
            fs.writeFileSync(path.join(destDir, file), res)
          })
        }
      } else {
        fs.mkdirSync(targetDir)
        rw(filePath, targetDir, answer)
      }
    })

  } catch (e) {
    throw e
  }
}

ejs.renderFile方法本来想用ejs.render替换的,但是一直有点问题,还望先生救我还望大佬指点。

代码总结及测试

来整理一下整个cli.js的代码叭:

#!/usr/bin/env node
// node cli应用入口文件必须要这样的开头?

const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const ejs = require('ejs')


inquirer.prompt([
  {
    type: 'input',
    name: 'name',
    message: 'Project Name?',
  }
]).then(answer => {
  // console.log(answer)

  // 模板路径
  const tempDir = path.join(__dirname, 'templates')

  // 目标路径
  const destDir = path.join(process.cwd(), answer.name)

  // 判断当前文件夹下是否有目标路径的目录
  if (fs.existsSync(destDir)) {
    throw Error(`Folder named '${answer.name}' is already existed`)
  }

  // 创建文件夹
  fs.mkdir(destDir, {recursive: true}, (err) => {
    if (err) throw err
  })

  // 将模板下的文件全部转换到目标目录
  rw(tempDir, destDir, answer)

  console.log(`Congratulations! Project '${answer.name}' has been created successfully~`)
})

/**
 * @description 模板文件的读取、渲染以及生成
 * @param tempDir 模板路径
 * @param destDir 目标路径
 * @param answer 用户输入的内容
 */
const rw = (tempDir, destDir, answer) => {
  try {
    const files = fs.readdirSync(tempDir)

    files.forEach(file => {
      const filePath = path.join(tempDir, file)
      const targetDir = path.join(destDir, file)

      const stats = fs.statSync(filePath)

      if (stats.isFile()) {
        if (destDir.includes('img') || destDir.includes('fonts') || destDir.includes('icon') || destDir.includes('.ico')) {
          const readStream = fs.createReadStream(filePath)
          const writeStream = fs.createWriteStream(targetDir)
          readStream.pipe(writeStream)
        } else {
          ejs.renderFile(filePath, answer, (err2, res) => {
            if (err2) throw err2
            // 将渲染完成后的结果写入目标路径
            fs.writeFileSync(path.join(destDir, file), res)
          })
        }
      } else {
        fs.mkdirSync(targetDir)
        rw(filePath, targetDir, answer)
      }
    })

  } catch (e) {
    throw e
  }
}

找一个自己喜欢的文件夹,找个空文件夹,打开命令行,输入my-cli,提示输入项目名称,按下回车:

这样就成功创建了一个基础项目,打开之前给ejs预留的位置看看有没有把输入的项目名称成功渲染上:

看到这样子大致就完成了一个简易的脚手架。当然也可以通过更多的交互来形成更好的效果,这里就不再演示了~

自动为基础项目安装node_modules

引入child-process(子进程)下的spawn方法,它来执行cmd命令:

const spawn = require('child_process').spawn

inquirer.prompt方法中再加一项问题:

{
      type: 'rawlist',
      name: 'method',
      choices: [
        { name: 'I\'ll do it by myself', value: 'either' },
        { name: 'install by yarn', value: 'yarn' },
        { name: 'install by npm', value: 'npm' },
      ],
      message: 'Do you want Node_Modules installed automatically?',
}

在打印项目创建成功之后来处理用户的这条输入的结果,处理方法可以封装为一个函数:

// 整理命令并返回需要的结构结果
const formatCMD = (method, destDir) => {
  switch (method) {
    case 'npm':
      return [process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install'], destDir]
    case 'yarn':
      return [process.platform === 'win32' ? 'yarn.cmd' : 'yarn', [], destDir]
    case 'either':
      return
  }
}

// 运行命令
const runCmd = (command, args, destDir) => {
  spawn(command, args, { stdio: 'inherit', cwd: destDir }, err => {
    if (err) {
      throw err
    } else {
      console.log('All done')
    }
  })
}

整理一下代码:

#!/usr/bin/env node

const path = require('path')
const fs = require('fs')
const inquirer = require('inquirer')
const ejs = require('ejs')


const spawn = require('child_process').spawn

inquirer.prompt([
  {
    type: 'input',
    name: 'name',
    message: 'Project Name?',
  },
  {
    type: 'rawlist',
    name: 'method',
    choices: [
      { name: 'I\'ll do it by myself', value: 'either' },
      { name: 'install by yarn', value: 'yarn' },
      { name: 'install by npm', value: 'npm' },
    ],
    message: 'Do you want Node_Modules installed automatically?',
  }
]).then(answer => {
  // console.log(answer)

  // 模板路径
  const tempDir = path.join(__dirname, 'templates')

  // 目标路径
  const destDir = path.join(process.cwd(), answer.name)

  // 判断当前文件夹下是否有目标路径的目录
  if (fs.existsSync(destDir)) {
    throw Error(`Folder named '${answer.name}' is already existed`)
  }

  // 创建文件夹
  fs.mkdir(destDir, {recursive: true}, (err) => {
    if (err) throw err
  })

  // 将模板下的文件全部转换到目标目录
  rw(tempDir, destDir, answer)

  const cmd = formatCMD(answer.method, destDir)

  if (!cmd) {
    console.log(`Congratulations! Project '${answer.name}' has been created successfully~`)
  } else {
    runCmd(...cmd)
  }


})

/**
 * @description 模板文件的读取、渲染以及生成
 * @param tempDir 模板路径
 * @param destDir 目标路径
 * @param answer 用户输入的内容
 */
const rw = (tempDir, destDir, answer) => {
  try {
    const files = fs.readdirSync(tempDir)

    files.forEach(file => {
      const filePath = path.join(tempDir, file)
      const targetDir = path.join(destDir, file)

      const stats = fs.statSync(filePath)

      if (stats.isFile()) {
        if (destDir.includes('img') || destDir.includes('fonts') || destDir.includes('icon') || destDir.includes('.ico')) {
          const readStream = fs.createReadStream(filePath)
          const writeStream = fs.createWriteStream(targetDir)
          readStream.pipe(writeStream)
        } else {
          ejs.renderFile(filePath, answer, (err2, res) => {
            if (err2) throw err2
            // 将渲染完成后的结果写入目标路径
            fs.writeFileSync(path.join(destDir, file), res)
          })
        }
      } else {
        fs.mkdirSync(targetDir)
        rw(filePath, targetDir, answer)
      }
    })

  } catch (e) {
    throw e
  }
}

/**
 * @description 运行命令
 * @param command 指令
 * @param args 指令参数
 * @param destDir 目标路径
 */
const runCmd = (command, args, destDir) => {
  spawn(command, args, { stdio: 'inherit', cwd: destDir }, err => {
    if (err) {
      throw err
    } else {
      console.log('All done')
    }
  })
}

/**
 * @description 整理命令
 * @param method 用户输入的方法
 * @param destDir 目标路径
 * @returns {[(string), *[], undefined]|[(string), [string], undefined]}
 */
const formatCMD = (method, destDir) => {
  switch (method) {
    case 'npm':
      return [process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install'], destDir]
    case 'yarn':
      return [process.platform === 'win32' ? 'yarn.cmd' : 'yarn', [], destDir]
    case 'either':
      return
  }
}

运行来试验一下:

成功运行没啥大问题了


推荐阅读
  • Vue基础一、什么是Vue1.1概念Vue(读音vjuː,类似于view)是一套用于构建用户界面的渐进式JavaScript框架,与其它大型框架不 ... [详细]
  • 本文介绍了一个Magento模块,其主要功能是实现前台用户利用表单给管理员发送邮件。通过阅读该模块的代码,可以了解到一些有关Magento的细节,例如如何获取系统标签id、如何使用Magento默认的提示信息以及如何使用smtp服务等。文章还提到了安装SMTP Pro插件的方法,并给出了前台页面的代码示例。 ... [详细]
  • React 小白初入门
    推荐学习:React官方文档:https:react.docschina.orgReact菜鸟教程:https:www.runoob.c ... [详细]
  • RN即ReactNative基于React框架针对移动端的跨平台框架,在学习RN前建议最好熟悉下html,css,js,当然如果比较急,那就直接上手吧,毕竟用学习前面基础的时间,R ... [详细]
  • 本文介绍了在Linux下安装Perl的步骤,并提供了一个简单的Perl程序示例。同时,还展示了运行该程序的结果。 ... [详细]
  • 后台获取视图对应的字符串
    1.帮助类后台获取视图对应的字符串publicclassViewHelper{将View输出为字符串(注:不会执行对应的ac ... [详细]
  • React基础篇一 - JSX语法扩展与使用
    本文介绍了React基础篇一中的JSX语法扩展与使用。JSX是一种JavaScript的语法扩展,用于描述React中的用户界面。文章详细介绍了在JSX中使用表达式的方法,并给出了一个示例代码。最后,提到了JSX在编译后会被转化为普通的JavaScript对象。 ... [详细]
  • 本文介绍了JavaScript进化到TypeScript的历史和背景,解释了TypeScript相对于JavaScript的优势和特点。作者分享了自己对TypeScript的观察和认识,并提到了在项目开发中使用TypeScript的好处。最后,作者表示对TypeScript进行尝试和探索的态度。 ... [详细]
  • 微信小程序导航跟随的实现方法
    本文介绍了在微信小程序中实现导航跟随的方法。通过设置导航的position属性和绑定滚动事件,可以实现页面向下滚动到导航位置时,导航固定在页面最上方;页面向上滚动到导航位置时,导航恢复到原始位置;点击导航可以平滑跳转到相应位置。代码示例也给出了具体实现方法。 ... [详细]
  • 本文讨论了将HashRouter改为Router后,页面全部变为空白页且没有报错的问题。作者提到了在实际部署中需要在服务端进行配置以避免刷新404的问题,并分享了route/index.js中hash模式的配置。文章还提到了在vueJs项目中遇到过类似的问题。 ... [详细]
  • 【前端工具】nodejs+npm+vue 安装(windows)
    预备先看看这几个是干嘛的,相互的关系是啥。nodejs是语言,类比到php。npm是个包管理,类比到composer。vue是个框架&# ... [详细]
  • 动态多点××× 单云双HUB
    动态多点是一个高扩展的IPSEC解决方案传统的ipsecS2S有如下劣势1.中心站点配置量大,无论是采用经典ipsec***还是采用greoveripsec多一个分支 ... [详细]
  • 1.移除consol.log()的babel插件安装:npmibabel-plugin-transform-remove-console-D配置:babel.config.js:这 ... [详细]
  • ReactJSUIAnt设计空组件原文:https://w ... [详细]
  • npminstall-Dbabelcorebabelpreset-envbabelplugin-transform-runtimebabelpolyfillbabel-loader ... [详细]
author-avatar
手机用户2602897571
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有