基于node.js平台的脚手架开发经历

2018-05-17 admin

前言

脚手架,不管是去年邀请在线分享还是前端早读课多多少少都有推荐过几期,但今天这篇算是分享的详细的。如果你准备给项目搞一个脚手架,这篇不要错过了。今日早读文章由阿里@张国钰授权分享。

正文从这开始~

前言

我们团队的前端项目是基于一套内部的后台框架进行开发的,这套框架是基于vue和ElementUI进行了一些定制化包装,并加入了一些自己团队设计的模块,可以进一步简化后台页面的开发工作。

这套框架拆分为基础组件模块,用户权限模块,数据图表模块三个模块,后台业务层的开发至少要基于基础组件模块,可以根据具体需要加入用户权限模块或者数据图表模块。尽管vue提供了一些脚手架工具vue-cli,但由于我们的项目是基于多页面的配置进行开发和打包,与vue-cli生成的项目结构和配置有些不一样,所以创建项目的时候,仍然需要人工去修改很多地方,甚至为了方便,直接从之前的项目copy过来然后进行魔改。表面上看问题不大,但其实存在很多问题:

  • 重复性工作,繁琐而且浪费时间

  • copy过来的模板容易存在无关的代码

  • 项目中有很多需要配置的地方,容易忽略一些配置点,进而埋坑

  • 人工操作永远都有可能犯错,建新项目时,总要花时间去排错

  • 内部框架也在不停的迭代,人工建项目往往不知道框架最新的版本号是多少,使用旧版本的框架可能会重新引入一些bug

针对以上问题,我开发了一个脚手架工具,可以根据交互动态生成项目结构,自动添加依赖和配置,并移除不需要的文件。

接下来整理一下我的整个开发经历。

基本思路

开始撸代码之前,先捋一捋思路。其实,在实现自己的脚手架之前,我反复整理分析了vue-cli的实现,发现很多有意思的模块,并从中借鉴了它的一些好的思想。

vue-cli是将项目模板作为资源独立发布在git上,然后在运行的时候将模板下载下来,经过模板引擎渲染,最后生成工程。这样将项目模板与工具分离的目的主要是,项目模板负责项目的结构和依赖配置,脚手架负责项目构建的流程,这两部分并没有太大的关联,通过分离,可以确保这两部分独立维护。假如项目的结构、依赖项或者配置有变动,只需要更新项目模板即可。

参照vue-cli的思路,我也将项目模板独立发布到git上,然后通过脚手架工具下载下来,经过与脚手架的交互获取新项目的信息,并将交互的输入作为元信息渲染项目模板,最终得到项目的基础结构。

工程结构

工程基于nodejs 8.4以及ES6进行开发,目录结构如下

/bin  # ------ 命令执行文件/lib  # ------ 工具模块package.json

下面的部分代码需要你先对Promise有一定的了解才更好的理解。

使用commander.js开发命令行工具

nodejs内置了对命令行操作的支持,node工程下package.json中的bin字段可以定义命令名和关联的执行文件。

{"name": "macaw-cli","version": "1.0.0","description": "我的cli","bin": {"macaw": "./bin/macaw.js"  }}

经过这样配置的nodejs项目,在使用-g选项进行全局安装的时候,会自动在系统的[prefix]/bin目录下创建相应的符号链接(symlink)关联到执行文件。如果是本地安装,这个符号链接会生成在./node_modules/.bin目录下。这样做的好处是可以直接在终端中像执行命令一样执行nodejs文件。关于 prefix,可以通过npm config get prefix获取。

hello, commander.js

在bin目录下创建一个macaw.js文件,用于处理命令行的逻辑。

touch ./bin/macaw.js

接下来就要用到github上一位神级人物——tj——开发的模块commander.js。commander.js可以自动的解析命令和参数,合并多选项,处理短参,等等,功能强大,上手简单。具体的使用方法可以参见项目的README。

macaw.js中编写命令行的入口逻辑

#!/usr/bin/env nodeconst program = require('commander')  // npm i commander -Dprogram.version('1.0.0')    .usage('<command> [项目名称]')    .command('hello', 'hello')    .parse(process.argv)

接着,在bin目录下创建macaw-hello.js,放一个打印语句

touch ./bin/macaw-hello.jsecho "console.log('hello, commander')" > ./bin/macaw-hello.js

这样,通过node命令测试一下

node ./bin/macaw.js hello

不出意外,可以在终端上看到一句话:hello, commander。

commander支持git风格的子命令处理,可以根据子命令自动引导到以特定格式命名的命令执行文件,文件名的格式是[command]-[subcommand],例如:

  • macaw hello => macaw-hello

  • macaw init => macaw-init

定义init子命令

我们需要通过一个命令来新建项目,按照常用的一些名词,我们可以定义一个名为init的子命令。

bin/macaw.js做一些改动。

const program = require('commander')program.version('1.0.0')    .usage('<command> [项目名称]')    .command('init', '创建新项目')    .parse(process.argv)

在bin目录下创建一个init命令关联的执行文件

touch ./bin/macaw-init.js

添加如下代码

#!/usr/bin/env nodeconst program = require('commander')program.usage('<project-name>').parse(process.argv)// 根据输入,获取项目名称let projectName = program.args[0]if (!projectName) {  // project-name 必填// 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项  program.help() return}go()functiongo () {// 预留,处理子命令  }

注意第一行#!/usr/bin/env node是干嘛的,有个关键词叫Shebang,不了解的可以去搜搜看

project-name是必填参数,不过,我想对project-name进行一些自动化的处理。

  • 当前目录为空,如果当前目录的名称和project-name一样,则直接在当前目录下创建工程,否则,在当前目录下创建以project-name作为名称的目录作为工程的根目录

  • 当前目录不为空,如果目录中不存在与project-name同名的目录,则创建以project-name作为名称的目录作为工程的根目录,否则提示项目已经存在,结束命令执行。

根据以上设定,再对执行文件做一些完善

#!/usr/bin/env nodeconst program = require('commander')const path = require('path')const fs = require('fs')const glob = require('glob') // npm i glob -Dprogram.usage('<project-name>')// 根据输入,获取项目名称let projectName = program.args[0]if (!projectName) {  // project-name 必填// 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项  program.help() return}const list = glob.sync('*')  // 遍历当前目录let rootName = path.basename(process.cwd())if (list.length) {  // 如果当前目录不为空if (list.filter(name => {const fileName = path.resolve(process.cwd(), path.join('.', name))const isDir = fs.stat(fileName).isDirectory()return name.indexOf(projectName) !== -1 && isDir    }).length !== 0) {console.log(`项目${projectName}已经存在`)return  }  rootName = projectName} elseif (rootName === projectName) {    rootName = '.'} else {    rootName = projectName}go()functiongo () {// 预留,处理子命令  console.log(path.resolve(process.cwd(), path.join('.', rootName)))}

随意找个路径下建一个空目录,然后在这个目录下执行咱们定义的初始化命令

node /[pathto]/macaw-cli/bin/macaw.js init hello-cli

正常的话,可以看到终端上打印出项目的路径。

使用download-git-repo下载模板

下载模板的工具用到另外一个node模块download-git-repo,参照项目的README,对下载工具进行简单的封装。

lib目录下创建一个download.js

const download = require('download-git-repo')module.exports = function (target) {  target = path.join(target || '.', '.download-temp')returnnewPromise(resolve, reject) {// 这里可以根据具体的模板地址设置下载的url,注意,如果是git,url后面的branch不能忽略    download('https://github.com:username/templates-repo.git#master',        target, { clone: true }, (err) => {if (err) {        reject(err)      } else {// 下载的模板存放在一个临时路径中,下载完成后,可以向下通知这个临时路径,以便后续处理        resolve(target)      }    })  }}

download-git-repo模块本质上就是一个方法,它遵循node.js的CPS,用回调的方式处理异步结果。如果熟悉node.js的话,应该都知道这样处理存在一个弊端,我把它进行了封装,转换成现在更加流行的Promise的风格处理异步。

再一次对之前的macaw-init.js进行修改

const download = require('./lib/download')... // 之前的省略functiongo () {  download(rootName)    .then(target =>console.log(target))    .catch(err =>console.log(err))}

下载完成之后,再将临时下载目录中的项目模板文件转移到项目目录中,一个简单的脚手架算是基本完成了。转移的具体实现方法就不细说了,可以参见node.js的API。你的node.js版本如果在8以下,可以用stream和pipe的方式实现,如果是8或者9,可以使用新的API——copyFile()或者copyFileSync()。

but…

这个世界并非我们想象的那么简单。我们可能会希望项目模板中有些文件或者代码可以动态处理。比如:

  • 新项目的名称版本号描述等信息等,可以通过脚手架的交互进行输入,然后将输入插入到模板中

  • 项目模板并非所有文件都会用到,可以通过脚手架提供的选项移除掉那些无用的文件或者目录。

对于这类情况,我们还需要借助其他工具包来完成。

使用inquirer.js处理命令行交互

对于命令行交互的功能,可以用inquirer.js来处理。用法其实很简单:

const inquirer = require('inquirer')  // npm i inquirer -Dinquirer.prompt([  {    name: 'projectName',    message: '请输入项目名称'  }]).then(answers => {console.log(`你输入的项目名称是:${answers.projectName}`)})

prompt()接受一个问题对象的数据,在用户与终端交互过程中,将用户的输入存放在一个答案对象中,然后返回一个Promise,通过then()获取到这个答案对象。so easy!

接下来继续对macaw-init.js进行完善。

// ...const inquirer = require('inquirer')const list = glob.sync('*')let next = undefinedif (list.length) {if (list.filter(name => {const fileName = path.resolve(process.cwd(), path.join('.', name))const isDir = fs.stat(fileName).isDirectory()return name.indexOf(projectName) !== -1 && isDir    }).length !== 0) {console.log(`项目${projectName}已经存在`)return  }  next = Promise.resolve(projectName)} elseif (rootName === projectName) {  next = inquirer.prompt([    {      name: 'buildInCurrent',      message: '当前目录为空,且目录名称和项目名称相同,是否直接在当前目录下创建新项目?'      type: 'confirm',default: true    }  ]).then(answer => {returnPromise.resolve(answer.buildInCurrent ? '.' : projectName)  })} else {  next = Promise.resolve(projectName)}next && go()functiongo () {  next.then(projectRoot => {if (projectRoot !== '.') {      fs.mkdirSync(projectRoot)    }return download(projectRoot).then(target => {return {        projectRoot,        downloadTemp: target      }    })  })}

如果当前目录是空的,并且目录名称和项目名称相同,那么就通过终端交互的方式确认是否直接在当前目录下创建项目,这样会让脚手架更加人性化。

前面提到,新项目的名称、版本号、描述等信息可以直接通过终端交互插入到项目模板中,那么再进一步完善交互流程。

// ...// 这个模块可以获取node包的最新版本const latestVersion = require('latest-version')  // npm i latest-version -D// ...functiongo () {  next.then(projectRoot => {if (projectRoot !== '.') {      fs.mkdirSync(projectRoot)    }return download(projectRoot).then(target => {return {        name: projectRoot,        root: projectRoot,        downloadTemp: target      }    })  }).then(context => {return inquirer.prompt([      {        name: 'projectName',        message: '项目的名称',default: context.name      }, {        name: 'projectVersion',        message: '项目的版本号',default: '1.0.0'      }, {        name: 'projectDescription',        message: '项目的简介',default: `A project named ${context.name}`      }    ]).then(answers => {return latestVersion('macaw-ui').then(version => {        answers.supportUiVersion = versionreturn {          ...context,          metadata: {            ...answers          }        }      }).catch(err => {returnPromise.reject(err)      })    })  }).then(context => {console.log(context)  }).catch(err => {console.error(err)  })}

下载完成后,提示用户输入新项目信息。当然,交互的问题不仅限于此,可以根据自己项目的情况,添加更多的交互问题。inquirer.js强大的地方在于,支持很多种交互类型,除了简单的input,还有confirmlistpasswordcheckbox等,具体可以参见项目的README。

然后,怎么把这些输入的内容插入到模板中呢,这时候又用到另外一个简单但又不简单的工具包——metalsmith。

使用metalsmith处理模板

引用官网的介绍:

An extremely simple, pluggable static site generator.

它就是一个静态网站生成器,可以用在批量处理模板的场景,类似的工具包还有Wintersmith、Assemble、Hexo。它最大的一个特点就是EVERYTHING IS PLUGIN,所以,metalsmith本质上就是一个胶水框架,通过黏合各种插件来完成生产工作。

给项目模板添加变量占位符

模板引擎我选择handlebars。当然,还可以有其他选择,例如ejs、jade、swig。

用handlebars的语法对模板做一些调整,例如修改模板中的package.json

{"name": "{{projectName}}","version": "{{projectVersion}}","description": "{{projectDescription}}","author": "Forcs Zhang","private": true,"scripts": {"dev": "node build/dev-server.js","start": "node build/dev-server.js","build": "node build/build.js","unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run","test": "npm run unit","lint": "eslint --ext .js,.vue src test/unit/specs"  },"dependencies": {"element-ui": "^2.0.7","macaw-ui": "{{supportUiVersion}}","vue": "^2.5.2","vue-router": "^2.3.1"  },  ...}

package.jsonnameversiondescription字段的内容被替换成了handlebar语法的占位符,模板中其他地方也做类似的替换,完成后重新提交模板的更新。

实现脚手架给模板插值的功能

lib目录下创建generator.js,封装metalsmith。

touch ./lib/generator.js// npm i handlebars metalsmith -Dconst Metalsmith = require('metalsmith')const Handlebars = require('handlebars')const rm = require('rimraf').syncmodule.exports = function (metadata = {}, src, dest = '.') {if (!src) {returnPromise.reject(newError(`无效的source:${src}`))  }returnnewPromise((resolve, reject) => {    Metalsmith(process.cwd())      .metadata(metadata)      .clean(false)      .source(src)      .destination(dest)      .use((files, metalsmith, done) => {const meta = metalsmith.metadata()Object.keys(files).forEach(fileName => {const t = files[fileName].contents.toString()          files[fileName].contents = new Buffer(Handlebars.compile(t)(meta))        })          done()      }).build(err => {          rm(src)          err ? reject(err) : resolve()      })  })}

macaw-init.jsgo()添加生成逻辑。

// ...const generator = require('../lib/generator')functiongo () {  next.then(projectRoot => {// ...  }).then(context => {// 添加生成的逻辑return generator(context)  }).then(context => {console.log('创建成功:)')  }).catch(err => {console.error(`创建失败:${err.message}`)  }) }

至此,一个带交互,可动态给模板插值的脚手架算是基本完成了。

tips:墙裂推荐一下tj的另一个工具包:consolidate.js,在vue-cli中发现的,感兴趣的话可以去了解一下。

美化我们的脚手架

通过一些工具包,让脚手架更加人性化。这里介绍两个在vue-cli中发现的工具包:

  • ora - 显示spinner

  • chalk - 给枯燥的终端界面添加一些色彩

这两个工具包用起来不复杂,用好了会让脚手架看起来更加高大上

用ora优化加载等待的交互

ora可以用在加载等待的场景中,比如脚手架中下载项目模板的时候可以使用,如果给模板插值生成项目的过程也有明显等待的话,也可以使用。

以下载为例,对download.js做一些改良:

npm i ora -D    const download = require('download-git-repo')const ora = require('ora')module.exports = function (target) {  target = path.join(target || '.', '.download-temp')returnnewPromise(resolve, reject) {const url = 'https://github.com:username/templates-repo.git#master'const spinner = ora(`正在下载项目模板,源地址:${url}`)    spinner.start()    download(url, target, { clone: true }, (err) => {if (err) {        spinner.fail() // wrong :(        reject(err)      } else {        spinner.succeed() // ok :)        resolve(target)      }    })  }}
用chalk优化终端信息的显示效果

chalk可以给终端文字设置颜色。

// ...const chalk = require('chalk')const logSymbols = require('log-symbols')// ...functiongo () {// ...  next.then(/* ... */)/* ... */      .then(context => {// 成功用绿色显示,给出积极的反馈console.log(logSymbols.success, chalk.green('创建成功:)'))console.log()console.log(chalk.green('cd ' + context.root + '\nnpm install\nnpm run dev'))    }).catch(err => {// 失败了用红色,增强提示console.error(logSymbols.error, chalk.red(`创建失败:${error.message}`))    }) }

根据输入项移除模板中不需要的文件

有时候,项目模板中并不是所有文件都是需要的。为了保证新生成的项目中尽可能的不存在脏代码,我们可能需要根据脚手架的输入项来确认最终生成的项目结构,将没用的文件或者目录移除。比如vue-cli,创建项目时会询问我们是否需要加入测试模块,如果不需要,最终生成的项目代码中是不包含测试相关的代码的。这个功能如何实现呢?

实现的思路

我参考了git的思路,定义个ignore文件,将需要被忽略的文件名列在这个ignore文件里,配上模板语法。脚手架在生成项目的时候,根据输入项先渲染这个ignore文件,然后根据 ignore文件的内容移除不需要的模板文件,然后再渲染真正会用到的项目模板,最终生成项目。

实现方案

根据以上思路,我先定义了属于我们项目自己的ignore文件,取名为templates.ignore

然后在这个ignore文件中添加需要被忽略的文件名。

{{#unless supportMacawAdmin}}# 如果不开启admin后台,登录页面和密码修改页面是不需要的src/entry/login.js      src/entry/password.js{{/unless}}# 最终生成的项目中不需要ignore文字自身templates.ignore

然后在lib/generator.js中添加对templates.ignore的处理逻辑

// ...const minimatch = require('minimatch')  // https://github.com/isaacs/minimatchmodule.exports = function (metadata = {}, src, dest = '.') {if (!src) {returnPromise.reject(newError(`无效的source:${src}`))  }returnnewPromise((resolve, reject) => {const metalsmith = Metalsmith(process.cwd())      .metadata(metadata)      .clean(false)      .source(src)      .destination(dest)// 判断下载的项目模板中是否有templates.ignoreconst ignoreFile = path.join(src, 'templates.ignore')if (fs.existsSync(ignoreFile)) {// 定义一个用于移除模板中被忽略文件的metalsmith插件      metalsmith.use((files, metalsmith, done) => {const meta = metalsmith.metadata()// 先对ignore文件进行渲染,然后按行切割ignore文件的内容,拿到被忽略清单const ignores = Handlebars.compile(fs.readFileSync(ignoreFile).toString())(meta)          .split('\n').filter(item => !!item.length)Object.keys(files).forEach(fileName => {// 移除被忽略的文件          ignores.forEach(ignorePattern => {if (minimatch(fileName, ignorePattern)) {delete files[fileName]            }          })        })        done()      })    }    metalsmith.use((files, metalsmith, done) => {const meta = metalsmith.metadata()Object.keys(files).forEach(fileName => {const t = files[fileName].contents.toString()        files[fileName].contents = new Buffer(Handlebars.compile(t)(meta))      })      done()    }).build(err => {      rm(src)      err ? reject(err) : resolve()    })  })}

基于插件思想的metalsmith很好扩展,实现也不复杂,具体过程可参见代码中的注释。

总结

经过对vue-cli的整理,借助了很多node模块,整个脚手架的实现并不复杂。

  • 将项目模板与脚手架工具分离,可以更好的维护模板和脚手架工具。

  • 通过commander.js处理命令行

  • 通过download-git-repo处理下载

  • 通过inquirer.js处理终端交互

  • 通过metalsmith和模板引擎将交互输入项插入到项目模板中

  • 参考了git的ignore的思路,利用自定义的templates.ignore动态化的移除不必要的文件和目录

以上就是我开发脚手架的主要经历,中间还有很多不足的地方,今后再慢慢完善吧。

最后说一下,其实vue-cli能做的事情还有很多,具体的可以看看项目的README和源码。关于脚手架的开发,不一定要完全造个轮子,可以看看另外一个很强大的模块YEOMAN,借助这个模块也可以很快的实现自己的脚手架工具。

关于本文作者:@张国钰原文:http://zhangguoyu.org/2017/12/10/developing-a-cli-on-nodejs/

最后,为你推荐

【第1272期】从零开始搭建脚手架

【第672期】教你从零开始搭建一款前端脚手架工具

每日一句

越是忙越是累的人,就越不愿意学习时间管理和人生规划,更不愿意安心学习,只愿意蒙头干活

原文链接:https://mp.weixin.qq.com/s/MvP3GelJzbnY6ct-5u9bZA

本站文章除注明转载外,均为本站原创或编译。欢迎任何形式的转载,但请务必注明出处。

转载请注明:文章转载自 JavaScript中文网 [https://www.javascriptcn.com]

本文地址:https://www.javascriptcn.com/read-32392.html

文章标题:基于node.js平台的脚手架开发经历

相关文章
从2014年的发展来展望JS的未来将会如何
&lt;font face=&quot;寰�杞�闆呴粦, Arial, sans-serif &quot;&gt;2014骞达紝杞�浠惰�屼笟鍙戝睍杩呴€燂紝鍚勭�嶈��瑷€灞傚嚭涓嶇┓锛屼互婊¤冻鐢ㄦ埛涓嶆柇鍙樺寲鐨勯渶姹傘€傝繖浜涜��...
2015-11-12
12个你未必知道的CSS小知识
虽然CSS并不是一种很复杂的技术,但就算你是一个使用CSS多年的高手,仍然会有很多CSS用法/属性/属性值你从来没使用过,甚至从来没听说过。 1.CSS的color属性并非只能用于文本显示 对于CSS的color属性,相信所有Web开发人员...
2015-11-12
ajax为什么令人惊异?ajax的优缺点
使用Ajax的最大优点,就是能在不更新整个页面的前提下维护数据。这使得Web应用程序更为迅捷地回应用户动作,并避免了在网络上发送那些没有改变的信息。 Ajax不需要任何浏览器插件,但需要用户允许JavaScript在浏览器上执行。就像DHT...
2015-11-12
HTML5的5个不错的开发工具推荐
HTML5规范终于在今年正式定稿,对于从事多年HTML5开发的人员来说绝对是一个重大新闻。数字天堂董事长,DCloud CEO王安也发表了文章,从开发者和用户两个角度分析了HTML对两个人群的优势。其实,关于HTML5的开发工具,我们以往的...
2015-11-12
JavaScript教程:JS中的原型
Keith Peters 几年前发表的一篇博文,关于学习没有“new”的世界,其中解释了使用原型继承代替构造函数。两者都是纯粹的原型编码。 标准方法(The Standard Way) 一直以来,我们学习的在 JavaScript 里创建对...
2015-11-12
AJAX的浏览器支持
AJAX 的要点是 XMLHttpRequest 对象。 不同的浏览器创建 XMLHttpRequest 对象的方法是有差异的。 IE 浏览器使用 ActiveXObject,而其他的浏览器使用名为 XMLHttpRequest 的 Jav...
2015-11-12
Riot.js:不足1KB的MVP客户端框架
Riot.js是一款MVP(模型-视图-呈现)开源客户端框架,其最大的特点就是体积非常小,不足1KB,虽然体积小,但它可以帮助用户构建大规模的Web应用程序。 Riot.js是由Moot公司开发,目前最新版本为v0.9.2,遵循MIT开源许...
2016-03-11
typeof、instanceof和contructor的区别
typeof:以字符串的形式返回变量的原始类型,typeof在两种情况下会返回&quot;undefined&quot;:一个变量没有被声明的时候,和一个变量的值是undefined的时候,注意,typeof null也会返回object,...
2015-11-12
Node.js学习(1)----HTTP服务器与客户端
Node.js 标准库提供了 http 模块,其中封装了一个高效的 HTTP 服务器和一个简易的HTTP 客户端。http.Server 是一个基于事件的 HTTP 服务器,它的核心由 Node.js 下层 C++部分实现,而接口由 Jav...
2015-11-12
2014年最流行前端开发框架对比评测
如今,各种开发框架层出不穷,各有千秋。哪些是去年较受开发者关注的呢?前不久,云适配根据Github上的流行程度整理了2014年最受欢迎的6个前端开发框架,并进行对比说明,希望帮助有需要的朋友选择合适自己的前端框架。 1. Bootstrap...
2015-11-12
回到顶部