基於node.js的腳手架工具開發經歷

張國鈺發表於2019-03-04

前言

我們團隊的前端專案是基於一套內部的後臺框架進行開發的,這套框架是基於vue和ElementUI進行了一些定製化包裝,並加入了一些自己團隊設計的模組,可以進一步簡化後臺頁面的開發工作。

這套框架拆分為基礎元件模組,使用者許可權模組,資料圖表模組三個模組,後臺業務層的開發至少要基於基礎元件模組,可以根據具體需要加入使用者許可權模組或者資料圖表模組。儘管vue提供了一些腳手架工具vue-cli,但由於我們的專案是基於多頁面的配置進行開發和打包,與vue-cli生成的專案結構和配置有些不一樣,所以建立專案的時候,仍然需要人工去修改很多地方,甚至為了方便,直接從之前的專案copy過來然後進行魔改。表面上看問題不大,但其實存在很多問題:

  • 重複性工作,繁瑣而且浪費時間

  • copy過來的模板容易存在無關的程式碼

  • 專案中有很多需要配置的地方,容易忽略一些配置點,進而埋坑

  • 人工操作永遠都有可能犯錯,建新專案時,總要花時間去排錯

  • 內部框架也在不停的迭代,人工建專案往往不知道框架最新的版本號是多少,使用舊版本的框架可能會重新引入一些bug

針對以上問題,我開發了一個腳手架工具,可以根據互動動態生成專案結構,自動新增依賴和配置,並移除不需要的檔案。

接下來整理一下我的整個開發經歷。

基本思路

開始擼程式碼之前,先捋一捋思路。其實,在實現自己的腳手架之前,我反覆整理分析了vue-cli的實現,發現很多有意思的模組,並從中借鑑了它的一些好的思想。

基於node.js的腳手架工具開發經歷

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 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支援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 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
複製程式碼

正常的話,可以看到終端上列印出專案的路徑。

基於node.js的腳手架工具開發經歷

使用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')
  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的API。你的node.js版本如果在8以下,可以用stream和pipe的方式實現,如果是8或者9,可以使用新的API——copyFile()或者copyFileSync()

but...

這個世界並非我們想象的那麼簡單。我們可能會希望專案模板中有些檔案或者程式碼可以動態處理。比如:

  • 新專案的名稱版本號描述等資訊等,可以通過腳手架的互動進行輸入,然後將輸入插入到模板中
  • 專案模板並非所有檔案都會用到,可以通過腳手架提供的選項移除掉那些無用的檔案或者目錄。

對於這類情況,我們還需要藉助其他工具包來完成。

使用inquirer.js處理命令列互動

對於命令列互動的功能,可以用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,還有confirmlistpasswordcheckbox等,具體可以參見專案的README

然後,怎麼把這些輸入的內容插入到模板中呢,這時候又用到另外一個簡單但又不簡單的工具包——metalsmith

使用metalsmith處理模板

引用官網的介紹:

An extremely simple, pluggable static site generator.

它就是一個靜態網站生成器,可以用在批量處理模板的場景,類似的工具包還有WintersmithAssembleHexo。它最大的一個特點就是EVERYTHING IS PLUGIN,所以,metalsmith本質上就是一個膠水框架,通過黏合各種外掛來完成生產工作。

給專案模板新增變數佔位符

模板引擎我選擇handlebars。當然,還可以有其他選擇,例如ejsjadeswig

用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 -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.jsgo()新增生成邏輯。

// ...
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的另一個工具包: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')
  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}`))
    }) 
}
複製程式碼

基於node.js的腳手架工具開發經歷

根據輸入項移除模板中不需要的檔案

有時候,專案模板中並不是所有檔案都是需要的。為了保證新生成的專案中儘可能的不存在髒程式碼,我們可能需要根據腳手架的輸入項來確認最終生成的專案結構,將沒用的檔案或者目錄移除。比如vue-cli,建立專案時會詢問我們是否需要加入測試模組,如果不需要,最終生成的專案程式碼中是不包含測試相關的程式碼的。這個功能如何實現呢?

實現的思路

我參考了git的思路,定義個ignore檔案,將需要被忽略的檔名列在這個ignore檔案裡,配上模板語法。腳手架在生成專案的時候,根據輸入項先渲染這個ignore檔案,然後根據ignore檔案的內容移除不需要的模板檔案,然後再渲染真正會用到的專案模板,最終生成專案。

基於node.js的腳手架工具開發經歷

實現方案

根據以上思路,我先定義了屬於我們專案自己的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和原始碼。關於腳手架的開發,不一定要完全造個輪子,可以看看另外一個很強大的模組YEOMAN,藉助這個模組也可以很快的實現自己的腳手架工具。

文中有不足的地方,歡迎指正和討論:)

相關文章