脚手架的本质就是在终端输入命令,然后去github中拉去对应的模板到本地的过程。所以我们要开发一个脚手架的话,其实就是创建一个项目,然后将这个项目发布到npm的库中。在使用的时候,就是使用npm安装对应的库,然后键入对应的指令让库帮我们拉去设置好的模板到本地。

第一步,创建一个项目模板

一个项目重要有路口文件的和对应的依赖配置文件的。
所以我们在对面的文件夹下面创建index.js
在对应的文件夹下使用npm init生成对应的配置package.json文件。
image.png

第二步,电脑能识别我们的指令

在使用vue-cli脚手架创建对应的项目的时候,我们使用的命令是vue create,而我们电脑是如何知道这个命令对应的意思就是给我们创建一个vue开发模板的呢?这就是我们脚手架要定义的了。
首先在我们的项目的入口文件使用shebang来找到对应的解释器来解释我们的代码,这里我们使用node环境,所以在index.js的第一行,就是要输入以下代码。

  1. #!usr/bin/env node
  2. console.log('zzlw cli');

接着package.json的文件中的bin属性内部,创建对应的指令和要执行的文件。

  1. {
  2. "name": "create-mycli",
  3. "version": "1.0.0",
  4. "description": "",
  5. "main": "index.js",
  6. "bin": {
  7. "zlw": "index.js"
  8. },
  9. "scripts": {
  10. "test": "echo \"Error: no test specified\" && exit 1"
  11. },
  12. "author": "",
  13. "license": "ISC"
  14. }

最后使用npm link 将对应的指令管理在一起。
image.png

现在我们在命令行中输入,zlw命令,就会输出zzlw cli 了。其本质就是在输入zlw的时候,去找到对应的文件index.js 因为在index.js的第一行使用shebang声明了会使用node 执行以下的代码,所以输出了zzlw cli。所以现在zlw命令 就是类似于node index.js
ok,现在我们已经设置了一个指令,这个指令可以做对应的事情了,虽然我们还有原理不明白的地方,例如#!, npm link 的细节。but,anyway ,先这样呗。

第三步,丰富我们的指令的选项

目前,我们绑定了我们的主指令zlw,但是如果我们需要带入其它的附属参数【例如 -V 查看版本】的话,则还需要借助其它的工具来完成。这里使用到的工具是commander ,这个工具在vue-cli也被使用。
在这个库中,给我们分装了一个方法,通过对应的方法,我们可以自定义对应指令的返回值,和拿到指令传入的参数等。在这里,我们已参看版本的指令,help指令为例。看看这个库是怎么使用的。

https://github.com/tj/commander.js/blob/master/Readme_zh-CN.md commander 的仓库以及对应的教程

首先我们在index.js 文件下,导入对应的库,和编写对应的指令。

  1. //主要就是使用program.option 来定义命令后面的选项 -D 这些
  1. #!/usr/bin/env node
  2. const { program } = require('commander')
  3. //--version 定义显示模块的版本号。拿到json中的版本号
  4. program.version(require('./package.json').version);
  5. // Commander 使用.option()方法来定义选项,
  6. // 同时可以附加选项的简介。
  7. // 每个选项可以定义一个短选项名称
  8. // (-后面接单个字符)和一个长选项名称(--后面接一个或多个单词),
  9. // 使用逗号、空格或 | 分隔。
  10. // option 的简单使用
  11. program.option("-z", 'this is a description about this cli, iam a cli ')
  12. // 如果使用了这个指令会传入参数,这个参数被存在program.opts()下面
  13. program.option("-d --dest <type>", 'a destination folder ,for example :-d /src/components')
  14. // 自定义事件监听.
  15. program.on("--help", () => {
  16. console.log();
  17. console.log("Other: ");
  18. console.log(" design by myself!");
  19. })
  20. // 解析字符串数组,默认是process.argv;一般放在最后
  21. program.parse(process.argv);
  22. const options = program.opts()
  23. if (options.dest) {
  24. console.log('input dest = ', options.dest);
  25. }

image.png
为了简化index.js 下面的代码,我们将一些指令定义的代码放到其它的文件夹中,比如lib/core/help.js 中。以后要添加选项的代码都可以在这里面添加。

  1. const path = require('path')
  2. const { program } = require('commander')
  3. function defineOption() {
  4. //--version 定义显示模块的版本号。拿到json中的版本号
  5. const packageJsonFilePath = path.resolve(path.resolve(), './package.json');
  6. program.version(require(packageJsonFilePath).version);
  7. // Commander 使用.option()方法来定义选项,
  8. // 同时可以附加选项的简介。
  9. // 每个选项可以定义一个短选项名称
  10. // (-后面接单个字符)和一个长选项名称(--后面接一个或多个单词),
  11. // 使用逗号、空格或 | 分隔。
  12. // option 的简单使用
  13. program.option("-z", 'this is a description about this cli, iam a cli ')
  14. // 如果使用了这个指令会传入参数,这个参数被存在program.opts()下面
  15. program.option("-d --dest <type>", 'a destination folder ,for example :-d /src/components')
  16. // 自定义事件监听.
  17. program.on("--help", () => {
  18. console.log();
  19. console.log("Other: ");
  20. console.log(" design by myself!");
  21. })
  22. // 解析字符串数组,默认是process.argv;一般放在最后
  23. program.parse(process.argv);
  24. const options = program.opts()
  25. if (options.dest) {
  26. console.log('input dest = ', options.dest);
  27. }
  28. }
  29. module.exports = { defineOption }
  1. #!/usr/bin/env node
  2. const { program } = require('commander')
  3. const { defineOption } = require('./lib/core/help')
  4. defineOption()

第四步,创建clone模板的命令

在文章的最开始就说了,其实vue-cli给我们创建的模板就是定义了vue create 命令,然后在使用该命令去对应的地址把模板clone下来。所以现在我们就定义一个命令来帮我们clone对应的代码。
同样的操作方法和定义选项配置是类似的,在commander中都给我们封装好了对应的api,我们可以直接使用。

通过.command()或.addCommand()可以配置命令,有两种实现方式:为命令绑定处理函数,或者将命令单独写成一个可执行文件(详述见后文)。子命令支持嵌套(示例代码)。
.command()的第一个参数为命令名称。命令参数可以跟在名称后面,也可以用.argument()单独指定。参数可为必选的(尖括号表示)、可选的(方括号表示)或变长参数(点号表示,如果使用,只能是最后一个参数)。

  1. program
  2. .command('这里命令的名称')
  3. .description('这里是命令的描述')
  4. .action(()=>{'这里是该指令具体的执行,所有操作都写在这个函数中'})

ok 上面就是创建一个命令的基本方式,下面我们就简单写一下怎么创建命令把。
首先,我们将所有创建的命令的代码写在lib/core/createCommands下面,
而每个命令具体的做的事情(action)则都封装在lib/core/actions下面。
image.png

  1. const program = require('commander')
  2. const { cloneTemplateAction } = require('./actions')
  3. // 使用program.command()方法来创建指定的命令。例如:vue create 就是指令而 -D这就叫它选项
  4. const cloneTemplateCommand = () => {
  5. program
  6. .command("create <project> [others]")
  7. .description('clone a template from repository into a folder')
  8. .action(cloneTemplateAction)
  9. }
  10. module.exports = {
  11. cloneTemplateCommand
  12. }
  1. const cloneTemplateAction = (project, others) => {
  2. //到时候就在这里就行template的clone
  3. console.log('cloneTemplateAction');
  4. console.log(project);
  5. console.log(others);
  6. }
  7. module.exports = {
  8. cloneTemplateAction
  9. }

可以看到现在我们这个create 命令生效了。而且命令的第一个默认是zlw 开始的。
image.png

第五步,clone对应的模板

在前一步,我们已经创建了对应的命令zlw create命令, 现在我们就要使用这个命令来进行了下载模板。就像vue-cli帮我们做的一样,下载一个对应的模板到本地,然后安装对应的依赖,将对应的模板跑起来,然后打开浏览器。所以在具体的action的我们要的事情就是以下四步

  1. clone 项目模板
  2. 执行npm install 安装模板对应的依赖
  3. npm run serve 让模板跑在服务上
  4. 打开浏览器,显示主页面

这里我们先讲第一步,clone github上面的模板(这个模板也是我们传上去的),要做这件使用使用的是一个库
download-git-repo 可以帮助我们去对应的地址,将对应的内容clone下来。

https://www.npmjs.com/package/download-git-repo

  1. const download = require('download-git-repo') //导入的是一个函数
  2. //我们要使用这个函数直接对url链接进行下载其对应的凡是如下:
  3. download('direct:对应的url', '安装到该文件下的目录', function (err) {
  4. console.log('下载完成之后的回调)
  5. console.log(err ? 'Error' : 'Success')
  6. })
  7. /*可以看到download会自动将对应的url资源下载下来,下载完成之后,就会执行回调内的内容。
  8. 因为该方法比较老旧了,所以不支持promise 所以这里我们可以使用node 内置的库util中的promisify
  9. 方法将download转化成promise的形式。方式如下*/
  10. const { promisify } = require('util') //promisify可以将东西包裹成promise
  11. const download = promisify(require('download-git-repo'))
  12. //这样download返回的就是一个promise了,我们在对应的then中写对应的处理即可。
  1. const { promisify } = require('util') //promisify可以将东西包裹成promise
  2. // download-git-repo 导出的是一个函数,使用promisify包裹成promise,这样相较于回调更好使用
  3. // download的具体用法看官网教程
  4. const download = promisify(require('download-git-repo'))
  5. const { vueRepo } = require('../config/repo-config') //对应模板的github地址
  6. //这里我们使用async 和 await
  7. const cloneTemplateAction = async (project, others) => {
  8. // 1.clone项目
  9. // vueRepo是要clone的网址,project是clone到的位置,
  10. await download(vueRepo, project, { clone: true })
  11. // 2.执行npm install
  12. // 3.npm run serve
  13. // 4.打开浏览器
  14. }
  15. module.exports = {
  16. cloneTemplateAction
  17. }

第六步,安装依赖,开启服务,开启浏览器

在前面已经把对应的模板下载下来了,所以接下去,我们就是要执行npm install来帮我们将模板中的依赖进行安装,npm run serve让项目跑起来。而执行这些命令都是在终端中执行的,所以现在问题就是,我们如何在代码中执行这些命令呢? 其实使用的是node 的child_process 来开启对对应的子进程,在开启这些子进程的时候,我们就是使用命令开启的,我们在开启子进程的时候输入对应的命令即可。
所以现在我们要做的事情就是使用这么模块进行开启对应的子进程,依据我们封装的思想,我们在utils文件下创建一个对应的文件,来写开启子进程的代码。
lib/utils/terminal.js
image.png
先来看下,在child_process中开启子进程的spawn 方法.

  1. child_process.spawn(command[, args][, options])
  2. //使用给定的 command 和 args 中的命令行参数衍生新进程。 如果省略,args 默认为空数组。
  3. command <string> 要运行的命令。
  4. args <string[]> 字符串参数列表。
  5. options <Object>
  6. cwd <string> | <URL> 子进程的当前工作目录。
  7. 。。。还有很多,具体看node教程

所以我们在lib/utils/terminal.js下声明对应的开启子进程的代码

  1. /*
  2. 执行终端命令的方
  3. */
  4. const { spawn } = require('child_process')
  5. const { resolve } = require('path')
  6. const commandSpawn = (...args) => {
  7. return new Promise((resolve, reject) => {
  8. // 直接开启子进程
  9. /*
  10. spawn(command[, args][, options])
  11. command:要运行的命令,我们这里是npm
  12. [,args] 是我们要执行命令的字符串参数列表,我们这里应该是["install"] / ["run", "server"]
  13. */
  14. //也就是说我们传入参数的时候要按照spawn对应参数传入
  15. const childProcess = spawn(...args);
  16. // 现在我们在子进程中,我们用下面的方法,将子进程的输出传到主线程下
  17. childProcess.stdout.pipe(process.stdout)
  18. childProcess.stderr.pipe(process.stderr)
  19. childProcess.on('close', () => {
  20. //进程结束的时候promise fufilled ,此时await等到,可以继续往下执行
  21. resolve()
  22. })
  23. })
  24. }
  25. module.exports = {
  26. commandSpawn
  27. }

在actions中,执行对应的四步曲的后三步。

  1. const { promisify } = require('util') //promisify可以将东西包裹成promise
  2. const download = promisify(require('download-git-repo'))
  3. const open = require('open')
  4. const { vueRepo } = require('../config/repo-config')
  5. const { commandSpawn } = require('../utils/terminal')
  6. const cloneTemplateAction = async (project, others) => {
  7. // 1.clone项目
  8. await download(vueRepo, project, { clone: true })
  9. // 2.执行npm install
  10. const command = process.platform === "win32" ? "npm.cmd" : "npm"
  11. console.log("zlw starts to install demo dependencies ");
  12. await commandSpawn(command, ["install"], { cwd: `./${project}` });
  13. console.log('installed');
  14. // 3.npm run serve
  15. await commandSpawn(command, ["run", "serve"], { cwd: `./${project}` });
  16. // 4.打开浏览器
  17. open('localhost:8080/')
  18. }
  19. module.exports = {
  20. cloneTemplateAction
  21. }

因为在window下面,其实执行npm等指令的其本质是npm.cwd,所以我们 上面先判断了下平台,在决定是npm.cwd还是npm open是一个帮助开启浏览器的库,其实这里我是打不开浏览器的,因为第三步开启服务后,这个进程没有结束,所以await永远不会等到resolve,所以不会接着往下执行。

第七步,添加组件的命令

🆗 ,目前为止已经可以通过命令将对应的模板clone到本地了,也就是说我们这个脚手架的大致功能已经完成了。接下来为了丰富脚手架的功能,准备给脚手架添加vue组件的命令,添加page的命令,添加store的命令等。
其实添加的操作都是类似的,这里详细说下怎么添加vue组件。
我们的目的是通过命令来帮我们加载自动的生成组件,其实它的本质也是加载对应的模板。当使用者输入对应名字的时候,我们只是将这个模板中的一些关键字改为使用者传入的对应组件的名字。

这里我们使用的ejs 来编写vue组件的模板,可以看到大致的样式如下,只是我们使用ejs的时候,可以再编译的时候传入特定的一些参数,然后在模板中使用<% = %>,语法来拿到

  1. <template>
  2. <div class="<%= data.lowerName%>">
  3. {{ msg }}
  4. </div>
  5. </template>
  6. <script>
  7. export default {
  8. name: "<% = data.name %>",
  9. props: {},
  10. components: {},
  11. mixins: [],
  12. data() {
  13. return {
  14. msg: "<%= data.name %>",
  15. };
  16. },
  17. created() {},
  18. mounted() {},
  19. computed: {},
  20. methods: {},
  21. };
  22. </script>
  23. <style scoped>
  24. .<%= data.lowerName % > {
  25. }
  26. </style>

组件模板写好之后, 我们就可以通过ejs库来对模板进行编译。编译之后,在输出到对应的目录下,我们这里都将这些方法封装在utils.js文件夹下。

  1. const fs = require('fs');
  2. const path = require('path');
  3. const ejs = require('ejs');
  4. /**
  5. * 依据传入的模板名,拿到对应的模板,然后编译成html string
  6. * @param {string} templateName
  7. * @param {object} data
  8. * @returns html string
  9. */
  10. const compileTemplate = (templateName, data) => {
  11. return new Promise((resolve, reject) => {
  12. // 1.依据传入的文件名,拿到对应的模板路径
  13. const templatePath = path.resolve(__dirname, `../templates/${templateName}`);
  14. //2.使用对应的api将ejs转化为html 的string ; str => Rendered HTML string
  15. ejs.renderFile(templatePath, { data }, {}, (err, str) => {
  16. if (err) {
  17. reject(err)
  18. }
  19. resolve(str)
  20. })
  21. })
  22. }
  23. const writeToFile = (content, fileDestPath) => {
  24. return fs.promises.writeFile(fileDestPath, content)
  25. }
  26. module.exports = {
  27. compileTemplate,
  28. writeToFile,
  29. }

image.png

🆗 ,现在将这些方法都写好之后,就是在对应的action中进行调用了。对应的actions.js文件中,对应的action如下。

  1. /**
  2. *
  3. * @param {string} templateName 生成的模板的名称
  4. * @param {string} dest 生成的模板的位置
  5. */
  6. const addComponentAction = async (templateName, dest) => {
  7. // 1.通过编译拿到编译后的html string
  8. const result = await compileTemplate("vue-component.ejs", { name: templateName, lowerName: templateName.toLowerCase() })
  9. // 2.写入文件的操作
  10. const targetPath = path.resolve(dest, `${templateName}.vue`);
  11. writeToFile(result, targetPath)
  12. }

而action是在定义命令时候调用的,所以最外面其实还是在定义命令的代码。我们将这些定义的命令都封装在一个方法内,所以下面还有clone项目的代码。

  1. const cloneDemoCommand = () => {
  2. // 1.clone模板
  3. program
  4. .command("create <project> [others]")
  5. .description('clone a template from repository into a folder')
  6. .action(cloneDemoAction)
  7. // 2.添加vue组件命令
  8. program
  9. .command("addcpn <name>")
  10. .description('add vue component, 例如: zlw addcpn HelloWorld [-d src/components]')
  11. .action((name) => {
  12. addComponentAction(name, options.dest || 'src/components');
  13. })
  14. }

下面就是我们各个方法封装的位置。
image.png

第八步,添加其它的命令

到目前为止,我们已经完成了大部分的内容了,在第七步中说了,对于添加的命令我们不满足于对vue组件的添加,还要进行router,store的添加。但是总体来说和第七步是类似的。
首先我们需要模板,然后编译模板,然后输出到对于的位置上。
这里我们先定义对于的page和router的模板。这里page的意思是要添加到route的vue组件页面,所以我们直接使用上面vue组件的模板即可。

  1. // 普通加载路由
  2. // import <%= data.name %> from './<%= data.name %>.vue'
  3. // 懒加载路由
  4. const <%= data.name %> = () => import('./<%= data.name %>.vue')
  5. export default {
  6. path: '/<%= data.lowerName %>',
  7. name: '<%= data.name %>',
  8. component: <%= data.name %>,
  9. children: [
  10. ]
  11. }

store的模板和store 的types的模板

  1. import * as types from './types.js'
  2. export default {
  3. namespaced: true,
  4. state: {
  5. },
  6. mutations: {
  7. },
  8. actions: {
  9. },
  10. getters: {
  11. }
  12. }
  1. export {
  2. }

然后是action的文件

  1. const { promisify } = require('util') //promisify可以将东西包裹成promise
  2. const path = require('path')
  3. const download = promisify(require('download-git-repo'))
  4. const open = require('open')
  5. const { vueRepo } = require('../config/repo-config')
  6. const { commandSpawn } = require('../utils/terminal')
  7. const { compileTemplate, writeToFile, createDirSync } = require('../utils/utils')
  8. const cloneDemoAction = async (project, others) => {
  9. // 1.clone项目
  10. await download(vueRepo, project, { clone: true })
  11. // 2.执行npm install
  12. const command = process.platform === "win32" ? "npm.cmd" : "npm"
  13. console.log("zlw starts to install demo dependencies ");
  14. await commandSpawn(command, ["install"], { cwd: `./${project}` });
  15. console.log('installed');
  16. // 3.npm run serve
  17. await commandSpawn(command, ["run", "serve"], { cwd: `./${project}` });
  18. // 4.打开浏览器
  19. open('localhost:8080/')
  20. }
  21. /**
  22. *
  23. * @param {string} templateName 生成的模板的名称
  24. * @param {string} dest 生成的模板的位置
  25. */
  26. const addComponentAction = async (templateName, dest) => {
  27. // 1.通过编译拿到编译后的html string
  28. const result = await compileTemplate("vue-component.ejs", { name: templateName, lowerName: templateName.toLowerCase() })
  29. // 2.写入文件的操作
  30. const targetPath = path.resolve(dest, `${templateName}.vue`);
  31. writeToFile(result, targetPath)
  32. }
  33. const addPageAction = async (templateName, dest) => {
  34. // 1.在page中,我们需要编译两个模板,一个是vue组件,一个是对于的router
  35. const pageResult = await compileTemplate("vue-component.ejs", { name: templateName, lowerName: templateName.toLowerCase() })
  36. const routerResult = await compileTemplate("vue-router.ejs", { name: templateName })
  37. const targetPath = path.resolve(dest, templateName)
  38. createDirSync(targetPath);
  39. // console.log(targetPath);
  40. writeToFile(pageResult, `${targetPath}/${templateName}.vue`);
  41. writeToFile(routerResult, `${targetPath}/router.js`);
  42. }
  43. const addStoreAction = async (templateName, dest) => {
  44. const storeResult = await compileTemplate("vue-store.ejs", {})
  45. const typeResult = await compileTemplate("vue-types.ejs", {})
  46. const targetPath = path.resolve(dest, templateName)
  47. createDirSync(targetPath);
  48. writeToFile(storeResult, `${targetPath}/${templateName}.vue`);
  49. writeToFile(typeResult, `${targetPath}/types.js`);
  50. }
  51. module.exports = {
  52. cloneDemoAction,
  53. addComponentAction,
  54. addPageAction,
  55. addStoreAction
  56. }

可以看到这里我们使用了检查文件夹的函数,这个函数定义在utils/utils.js

  1. const fs = require('fs');
  2. const path = require('path');
  3. const ejs = require('ejs');
  4. /**
  5. * 依据传入的模板名,拿到对应的模板,然后编译成html string
  6. * @param {string} templateName
  7. * @param {object} data
  8. * @returns html string
  9. */
  10. const compileTemplate = (templateName, data) => {
  11. return new Promise((resolve, reject) => {
  12. // 1.依据传入的文件名,拿到对应的模板路径
  13. const templatePath = path.resolve(__dirname, `../templates/${templateName}`);
  14. //2.使用对应的api将ejs转化为html 的string ; str => Rendered HTML string
  15. ejs.renderFile(templatePath, { data }, {}, (err, str) => {
  16. if (err) {
  17. reject(err)
  18. }
  19. resolve(str)
  20. })
  21. })
  22. }
  23. /**
  24. * 可以递归的生成一个文件路径上的所有文件夹
  25. * @param {string} dest 文件的路径 ,例如 src/components/home.vue
  26. */
  27. const createDirSync = (dest) => {
  28. // 如果该文件的父级文件夹存在,则直接返回
  29. if (fs.existsSync(dest)) {
  30. return true
  31. }
  32. // 当不存在的时候,就会递归的看每一级的文件夹是否存在
  33. if (createDirSync(path.dirname(dest))) {
  34. // 找到父级存在的了
  35. fs.mkdirSync(dest)
  36. return true
  37. }
  38. }
  39. const writeToFile = (content, fileDestPath) => {
  40. return fs.promises.writeFile(fileDestPath, content)
  41. }
  42. module.exports = {
  43. compileTemplate,
  44. writeToFile,
  45. createDirSync
  46. }

最后外面就是定义命令的代码,在creatCommand

  1. const program = require('commander')
  2. const {
  3. cloneDemoAction,
  4. addComponentAction,
  5. addPageAction,
  6. addStoreAction,
  7. } = require('./actions')
  8. const options = program.opts();
  9. // 使用program.command()方法来创建指定的命令。例如:vue create 就是指令而 -D这就叫它选项
  10. const cloneDemoCommand = () => {
  11. // 1.clone模板
  12. program
  13. .command("create <project> [others]")
  14. .description('clone a template from repository into a folder')
  15. .action(cloneDemoAction)
  16. // 2.添加vue组件
  17. program
  18. .command("addcpn <name>")
  19. .description('add vue component, 例如: zlw addcpn HelloWorld [-d src/components]')
  20. .action((name) => {
  21. addComponentAction(name, options.dest || 'src/components');
  22. })
  23. // 3.添加page
  24. program
  25. .command("addpage <name>")
  26. .description('add vue page and router config, 例如: zlw addpage Home [-d src/pages]')
  27. .action((name) => {
  28. addPageAction(name, options.dest || 'src/pages')
  29. })
  30. // 4.添加store的
  31. program
  32. .command("addstore <name>")
  33. .description('add vue page and store config, 例如: zlw addstore Home [-d src/pages]')
  34. .action((name) => {
  35. addStoreAction(name, options.dest || 'src/store/modules')
  36. })
  37. }
  38. module.exports = {
  39. cloneDemoCommand
  40. }