详解基于node.js的脚手架工具开发经历
前言
我们团队的前端项目是基于一套内部的后台框架进行开发的,这套框架是基于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
狼蚁网站SEO优化的部分代码需要你先对 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上一位神级人物————开发的模块。commander.js可以自动的解析命令和参数,合并多选项,处理短参,等等,功能强大,上手简单。具体的使用方法可以参见项目的README。
在 macaw.js
中编写命令行的入口逻辑
#!/usr/bin/env node const program = require('commander') // npm i commander -D program.version('1.0.0') .usage('<command> [项目名称]') .command('hello', 'hello') .parse(process.argv)
接着,在 bin
目录下创建 macaw-hello.js
,放一个打印语句
touch ./bin/macaw-hello.js echo "console.log('hello, commander')" > ./bin/macaw-hello.js
这样,通过node命令测试一下
node ./bin/macaw.js hello
不出意外,可以在终端上看到一句话:hello, commander。
commander支持 ,可以根据子命令自动引导到以特定格式命名的命令执行文件,文件名的格式是 [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 node const 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() function go () { // 预留,处理子命令 }
注意第一行 #!/usr/bin/env node
是干嘛的,有个关键词叫Shebang,不了解的可以去搜搜看
project-name
是必填参数,不过,我想对 project-name
进行一些自动化的处理。
- 当前目录为空,如果当前目录的名称和
project-name
一样,则直接在当前目录下创建工程,否则,在当前目录下创建以project-name
作为名称的目录作为工程的根目录 - 当前目录不为空,如果目录中不存在与
project-name
同名的目录,则创建以project-name
作为名称的目录作为工程的根目录,否则提示项目已经存在,结束命令执行。
根据以上设定,再对执行文件做一些完善
#!/usr/bin/env node const program = require('commander') const path = require('path') const fs = require('fs') const glob = require('glob') // npm i glob -D program.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 } else if (rootName === projectName) { rootName = '.' } else { rootName = projectName } go() function go () { // 预留,处理子命令 console.log(path.resolve(process.cwd(), path.join('.', rootName))) }
随意找个路径下建一个空目录,然后在这个目录下执行咱们定义的初始化命令
node /[pathto]/macaw-cli/bin/macaw.js init hello-cli
正常的话,可以看到终端上打印出项目的路径。
使用download-git-repo下载模板
下载模板的工具用到另外一个node模块,参照项目的README,对下载工具进行简单的封装。
在 lib
目录下创建一个 download.js
const download = require('download-git-repo') module.exports = function (target) { target = path.join(target || '.', '.download-temp') return new Promise(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') ... // 之前的省略 function go () { download(rootName) .then(target => console.log(target)) .catch(err => console.log(err)) }
下载完成之后,再将临时下载目录中的项目模板文件转移到项目目录中,一个简单的脚手架算是基本完成了。转移的具体实现方法就不细说了,可以参见node.js的。你的node.js版本如果在8以下,可以用stream和pipe的方式实现,如果是8或者9,可以使用新的API——或者 。
but...
这个世界并非我们想象的那么简单。我们可能会希望项目模板中有些文件或者代码可以动态处理。比如:
- 新项目的 名称 、 版本号 、 描述 等信息等,可以通过脚手架的交互进行输入,然后将输入插入到模板中
- 项目模板并非所有文件都会用到,可以通过脚手架提供的选项移除掉那些无用的文件或者目录。
对于这类情况,我们还需要借助其他工具包来完成。
使用inquirer.js处理命令行交互
对于命令行交互的功能,可以用来处理。用法其实很简单:
const inquirer = require('inquirer') // npm i inquirer -D inquirer.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 = undefined 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 } next = Promise.resolve(projectName) } else if (rootName === projectName) { next = inquirer.prompt([ { name: 'buildInCurrent', message: '当前目录为空,且目录名称和项目名称相同,是否直接在当前目录下创建新项目?' type: 'confirm', default: true } ]).then(answer => { return Promise.resolve(answer.buildInCurrent ? '.' : projectName) }) } else { next = Promise.resolve(projectName) } next && go() function go () { 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 // ... function go () { 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 = version return { ...context, metadata: { ...answers } } }).catch(err => { return Promise.reject(err) }) }) }).then(context => { console.log(context) }).catch(err => { console.error(err) }) }
下载完成后,提示用户输入新项目信息。当然,交互的问题不仅限于此,可以根据自己项目的情况,添加更多的交互问题。inquirer.js强大的地方在于,支持很多种交互类型,除了简单的 input
,还有 confirm
、 list
、 password
、 checkbox
等,具体可以参见项目的 。
然后,怎么把这些输入的内容插入到模板中呢,这时候又用到另外一个简单但又不简单的工具包——。
使用metalsmith处理模板
引用官网的介绍:
An extremely simple, pluggable static site generator.
它就是一个静态网站生成器,可以用在批量处理模板的场景,类似的工具包还有、 、。它最大的一个特点就是 EVERYTHING IS PLUGIN ,所以,metalsmith本质上就是一个胶水框架,通过黏合各种插件来完成生产工作。
给项目模板添加变量占位符
模板引擎我选择handlebars。当然,还可以有其他选择,例如、 、 。
用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.json
的 name
、 version
、 description
字段的内容被替换成了handlebar语法的占位符,模板中其他地方也做类似的替换,完成后重新提交模板的更新。
实现脚手架给模板插值的功能
在 lib
目录下创建 generator.js
,封装metalsmith。
touch ./lib/generator.js
// npm i handlebars metalsmith -D const Metalsmith = require('metalsmith') const Handlebars = require('handlebars') const rm = require('rimraf').sync module.exports = function (metadata = {}, src, dest = '.') { if (!src) { return Promise.reject(new Error(`无效的source:${src}`)) } return new Promise((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.js
的 go()
添加生成逻辑。
// ... const generator = require('../lib/generator') function go () { next.then(projectRoot => { // ... }).then(context => { // 添加生成的逻辑 return generator(context) }).then(context => { console.log('创建成功:)') }).catch(err => { console.error(`创建失败:${err.message}`) }) }
至此,一个带交互,可动态给模板插值的脚手架算是基本完成了。
tips:墙裂推荐一下tj的另一个工具包: ,在vue-cli中发现的,感兴趣的话可以去了解一下。
美化我们的脚手架
通过一些工具包,让脚手架更加人性化。这里介绍两个在vue-cli中发现的工具包:
- 显示spinner
- 给枯燥的终端界面添加一些色彩
这两个工具包用起来不复杂,用好了会让脚手架看起来更加高大上
用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') return new Promise(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') // ... function go () { // ... 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/minimatch module.exports = function (metadata = {}, src, dest = '.') { if (!src) { return Promise.reject(new Error(`无效的source:${src}`)) } return new Promise((resolve, reject) => { const metalsmith = Metalsmith(process.cwd()) .metadata(metadata) .clean(false) .source(src) .destination(dest) // 判断下载的项目模板中是否有templates.ignore const 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和源码。关于脚手架的开发,不一定要完全造个轮子,可以看看另外一个很强大的模块,借助这个模块也可以很快的实现自己的脚手架工具。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持狼蚁SEO。