前言
脚手架, 不管是去年邀请在线分享还是前端早读课多多少少都有推荐过几期, 但今天这篇算是分享的详细的. 如果你准备给项目搞一个脚手架, 这篇不要错过了. 今日早读文章由阿里 @张国钰授权分享.
正文从这开始~
前言
我们团队的前端项目是基于一套内部的后台框架进行开发的, 这套框架是基于 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, 还有 confirm,list, password,checkbox 等, 具体可以参见项目的 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.json 的 name,version,description 字段的内容被替换成了 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.js 的 go()添加生成逻辑.
// ...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, 借助这个模块也可以很快的实现自己的脚手架工具.
来源: https://juejin.im/entry/5afcb827f265da0b7868b509