構(gòu)建一個(gè)用于創(chuàng)建組件庫的項(xiàng)目腳手架工具(類 Vue-cli3)

緣起

最近公司內(nèi)部想搭建一個(gè)私有的 npm 倉庫坤塞,用于將平時(shí)用到次數(shù)相當(dāng)頻繁的工具或者組件獨(dú)立出來焕济,方便單獨(dú)管理慢味,隨著項(xiàng)目的規(guī)模變大场梆,數(shù)量變多,單純的復(fù)制粘粘無疑在優(yōu)雅以及實(shí)用性上都無法滿足我們的需求贮缕,所以進(jìn)一步模塊化是必然的辙谜。

但是一個(gè)組件庫的建立其實(shí)是一個(gè)非常麻煩的過程,基礎(chǔ) webpack 的配置不用多說感昼,接著你還要配合增加一些 es-lint 之類的工具來規(guī)范化團(tuán)隊(duì)成員的代碼装哆。在開發(fā)過程中,你自然需要一個(gè)目錄來承載使用示例定嗓,方便 dev 這個(gè)組件蜕琴,隨后呢,你還得建立一個(gè)打包規(guī)范宵溅,發(fā)布到私有 npm 倉庫中凌简。

如此一來,必然大大降低我們的積極性恃逻,所以不如創(chuàng)建一個(gè)用于建立模塊包的腳手架工具雏搂,方便我們項(xiàng)目的初始化。

tips:最終成品在底部

私有 NPM

這里簡單提及一下 私有 npm 的搭建寇损。

npm i verdaccio -g
pm2 start verdaccio

推薦配合 nrm 使用 快速切換倉庫地址

verdaccio github

還整個(gè)意大利名凸郑,屬實(shí)洋氣。

工具

在進(jìn)入正題之前矛市,我先介紹一些要點(diǎn)和工具芙沥,有了這寫關(guān)鍵點(diǎn),寫起來其實(shí)就相當(dāng)簡單了浊吏。

npm bin

大家有沒有想過一些全局安裝的工具而昨,他是如何做到在命令行里面自由調(diào)用的呢?

事實(shí)上這個(gè)東西是 npm 提供的鏈接功能

// package.json
{
  "name": "lucky-for-you",
  "bin": {
    "lucky": "bin/lucky"
  }
}

當(dāng)這樣的一個(gè)模塊被發(fā)布之后,一旦有人使用 -g 參數(shù)全局安裝

sudo npm i luck-for-you -g

/usr/local/bin/lucky -> /usr/local/lib/node_modules/luckytiger-package-cli/bin/lucky # npm 幫你進(jìn)行鏈接

npm 事實(shí)上會(huì)幫你進(jìn)行一次鏈接,鏈接到你操作系統(tǒng)的 Path 之中找田,從而但你敲出 Lucky 這個(gè)命令的時(shí)候歌憨,能從 path 中成功找到對應(yīng)的程序

另外一點(diǎn)就是用于鏈接執(zhí)行的文件 一般在開頭都要加上如下內(nèi)容,讓 bash 能夠正確識別該文件應(yīng)該如何執(zhí)行

#!/usr/bin/env node
// 意味使用 node 運(yùn)行該文件
// next script

Commander.js

tj 大神的作品午阵,可以方便的書寫命令行工具躺孝。能夠自動(dòng)生成幫助命令

const program = require('commander');

program.version('0.0.1').usage('<command> [options]');

program
  .command('create <app-name>')
  .description('創(chuàng)建一個(gè)全新的 npm 組件模塊')
  .action((name, cmd) => {
    const options = cleanArgs(cmd);
    require('../lib/create')(name, options);
  });

// 用戶未輸入完整命令 輸出幫助
if (!process.argv.slice(2).length) {
  program.outputHelp();
}

program.parse(process.argv);

Commander.js github

inquirer

事實(shí)上當(dāng)我第一次使用 vue-cli3.0 的時(shí)候享扔,里面的命令行表單真是非常驚艷底桂,翻了 vue-cli3 的源碼 找到了這款工具植袍,用于命令行的表單。能夠更加直觀的配置選項(xiàng)籽懦。

image
inquirer
  .prompt([
    {
      type: 'list',
      name: 'template',
      message: 'template: 請選擇項(xiàng)目起始模板',
      choices: [
        {
          key: '1',
          name: 'JavaScript Library - 適用于普通 JS 庫',
          value: 'js-lib',
        },
        {
          key: '2',
          name: 'Vue-components - 適用于 Vue 組件庫',
          value: 'vue-component',
        },
      ],
    },
    {
      type: 'input',
      name: 'author',
      message: 'author: 請輸入你的名字',
      validate: function(value) {
        return !!value;
      },
    },
    {
      type: 'input',
      name: 'desc',
      message: 'desc: 請輸入項(xiàng)目描述',
      validate: function(value) {
        return !!value;
      },
    },
    {
      type: 'confirm',
      name: 'confirm',
      message: 'confirm: 完成配置了?',
      default: false,
    },
  ])
  .then(answers => {
    console.log(answers.template);
    console.log(answers.author);
    console.log(answers.desc);
  });

還有很多的表單類型于个,我這里幾個(gè)最簡單的 list + input + confirm 就足夠了。

inquire github

開始構(gòu)建

現(xiàn)在開始分享我的構(gòu)建流程暮顺。由于代碼量比較大厅篓,挨個(gè)文件帖出來沒有什么必要,所以我這里只做簡單介紹捶码,具體的可以查看我的 github項(xiàng)目羽氮。

我把我的 cli 工具大致分為兩部分 template模板 + 創(chuàng)建器
z
創(chuàng)建器的主要功能是吸收用戶的可選項(xiàng),基于模板進(jìn)行復(fù)制+渲染。Vue-cli3.0對于這部分操作會(huì)更加復(fù)雜惫恼,他把模板里面具體的功能都抽象成了一個(gè) Plugin档押,可以按需組建模板,對于面向普遍大眾當(dāng)然是更好的祈纯。

但是我這個(gè)項(xiàng)目因?yàn)槭枪緝?nèi)部用令宿,所以不太需要太過泛化的設(shè)計(jì),一個(gè)模板直接解決一個(gè)問題腕窥,簡化模型就可以了粒没。比如一個(gè)模板用于創(chuàng)建 Vue 的組件庫,一個(gè)模板用于創(chuàng)建 React 的組件庫簇爆,還有一個(gè)模板用于創(chuàng)建JavaScript 的工具函數(shù)類庫癞松。

如此一來我們的 template模板 創(chuàng)建器在一定程度上可以做到解耦,也就是說日后需要更多類型的模板入蛆,不需要修改創(chuàng)建器部分的代碼响蓉。

目錄結(jié)構(gòu)

├── README.md
├── bin
│   └── lucky #主程序
├── lib
│   ├── copy.js #復(fù)制
│   └── create.js #主創(chuàng)建器
├── package-lock.json
├── package.json
├── templates
│   ├── config.js #模板配置 解耦
│   ├── js-lib #預(yù)設(shè)模板1
│   └── vue-component #預(yù)設(shè)模板2
├── utils # 工具目錄
│   └── dir.js

package.json

{
  "name": "luckytiger-package-cli",
  "version": "1.1.14",
  "description": "package-cli",
  "bin": {
    "lucky": "bin/lucky"
  },
  "scripts": {
    "lucky": "node bin/lucky",
    "bootstarp": "cnpm i && cd ./templates/js-lib/ &&  cnpm i   && cd ../vue-component/ && cnpm i  ",
    "dev:js-lib": "cd templates/js-lib  && npm run dev",
    "dev:vue-component": "cd templates/vue-component && npm run dev",
    "dev:create": "rm -rf test-app && node bin/lucky create test-app",
    "clear": "sudo rm -rf node_modules && sudo rm -rf templates/js-lib/node_modules && sudo rm -rf templates/vue-component/node_modules"
  },
  "author": "zhangzhengyi",
  "license": "ISC",
  "dependencies": {
    "chalk": "^2.4.2",
    "commander": "^2.20.0",
    "ejs": "^2.6.2",
    "inquirer": "^6.4.1",
    "validate-npm-package-name": "^3.0.0"
  }
}

配置了一些腳本 方便快速 DEV 模板的效果。

這樣運(yùn)行

npm run dev:js-lib

就能查看和開發(fā) js-lib 這個(gè)模板

主程序

bin/lucky

#!/usr/bin/env node

const program = require('commander')

program.version('0.0.1').usage('<command> [options]')

program
  .command('create <app-name>')
  .description('創(chuàng)建一個(gè)全新的 npm 組件模塊')
  .action((name, cmd) => {
    const options = cleanArgs(cmd)
    require('../lib/create')(name, options)
  })

if (!process.argv.slice(2).length) {
  program.outputHelp()
}

program.parse(process.argv)

// commander passes the Command object itself as options,
// extract only actual options into a fresh object.
function cleanArgs(cmd) {
  const args = {}
  cmd.options.forEach(o => {
    const key = camelize(o.long.replace(/^--/, ''))
    // if an option is not present and Command has a method with the same name
    // it should not be copied
    if (typeof cmd[key] !== 'function' && typeof cmd[key] !== 'undefined') {
      args[key] = cmd[key]
    }
  })
  return args
}

這個(gè)文件主要是做一下基本的命令設(shè)置 利用了 commander這個(gè)庫

如果用戶調(diào)用了創(chuàng)建命令安寺,就會(huì)轉(zhuǎn)發(fā)給 lib/create.js 處理

主創(chuàng)建器

lib/cerate.js

const path = require('path')
const inquirer = require('inquirer')
const validateProjectName = require('validate-npm-package-name')
const chalk = require('chalk')
const copy = require('./copy')
const fs = require('fs')
const dir = require('../utils/dir')
const templates = require('../templates/config')

async function create(projectName, options) {
  const cwd = options.cwd || process.cwd()
  const inCurrent = projectName === '.'
  const name = inCurrent ? path.relative('../', cwd) : projectName
  const targetDir = path.resolve(cwd, projectName || '.')

  const result = validateProjectName(name)
  if (!result.validForNewPackages) {
    console.error(chalk.red(`無效的項(xiàng)目名: "${name}"`))
    result.errors &&
      result.errors.forEach(err => {
        console.error(chalk.red.dim('Error: ' + err))
      })
    result.warnings &&
      result.warnings.forEach(warn => {
        console.error(chalk.red.dim('Warning: ' + warn))
      })
    return
  }

  if (!dir.isDir(targetDir)) {
    fs.mkdirSync(targetDir)
  } else {
    console.error(chalk.red(`該目錄下已經(jīng)存在該文件夾 請刪除或者修改項(xiàng)目名`))
    return
  }

  const answers = await inquirer.prompt([
    {
      type: 'list',
      name: 'template',
      message: 'template: 請選擇項(xiàng)目模板',
      choices: templates.map((v, i) => ({
        key: i,
        name: v.name,
        value: v.dir
      }))
    },
    {
      type: 'input',
      name: 'author',
      message: 'author: 請輸入你的名字',
      validate: function(value) {
        return !!value
      }
    },
    {
      type: 'input',
      name: 'desc',
      message: 'desc: 請輸入項(xiàng)目描述',
      validate: function(value) {
        return !!value
      }
    },
    {
      type: 'confirm',
      name: 'confirm',
      message: 'confirm: 完成配置了?',
      default: false
    }
  ])

  // 啟動(dòng)復(fù)制流程
  const sourceDir = path.resolve(__dirname, '..', 'templates', answers.template)
  console.log(chalk.blue(`??    開始創(chuàng)建...`))

  try {
    await copy({
      from: sourceDir,
      to: targetDir,
      renderData: {
        desc: answers.desc,
        author: answers.author,
        name: projectName
      },
      ignore: ['node_modules', 'package.json']
    })
  } catch (e) {
    console.error(chalk.red(e))
    return
  }

  console.log(chalk.green('??    創(chuàng)建完畢!'))
  console.log()
  console.log(chalk.cyan(` $ cd ${projectName}`))
  console.log(chalk.cyan(` $ npm i && npm run dev`))
}

module.exports = create

這里主要做了幾件事

  1. 保證項(xiàng)目名合法厕妖。
  2. 確認(rèn)項(xiàng)目在當(dāng)前目錄不存在
  3. 收集用戶的填寫信息
  4. 啟動(dòng)復(fù)制流程

這里面 chalk 這個(gè)庫能夠輸出帶顏色的命令行,美觀一點(diǎn)挑庶。

我把模板的一些配置信息都放到了 templates/config.js 中言秸,目的是為了解耦

//templates/config.js
module.exports = [
  {
    name: 'JavaScript Library - 適用于普通 JS 庫',
    dir: 'js-lib'
  },
  {
    name: 'Vue-components - 適用于 Vue 組件庫',
    dir: 'vue-component'
  }
]

接下來讓我們看看復(fù)制流程

復(fù)制

lib/copy

const fs = require('fs')
const path = require('path')
const dir = require('../utils/dir')
const ejs = require('ejs')

async function copy({ from, to, renderData, ignore = [] }) {
  let files = fs.readdirSync(from)
  // 區(qū)分 文件 和 目錄
  let rFiles = []
  let dirs = []
  for (const fileName of files) {
    if (dir.isDir(path.resolve(from, fileName))) {
      dirs.push(fileName)
    } else {
      rFiles.push(fileName)
    }
  }

  // 復(fù)制并編譯文件
  rFiles.forEach(fileName => {
    // 需要忽略
    if (ignore.some(v => v === fileName)) {
      return
    }
    let content = fs.readFileSync(path.resolve(from, fileName), 'utf-8')
    // 該文件需要調(diào)用 ejs 模板引擎進(jìn)行編譯
    if (/ejs$/.test(fileName)) {
      content = ejs.render(content, renderData)
      fileName = fileName.replace('.ejs', '')
    }
    fs.writeFileSync(path.resolve(to, fileName), content)
  })

  // 遞歸復(fù)制 目錄
  dirs.forEach(dirName => {
    // 需要忽略
    if (ignore.some(v => v === dirName)) {
      return
    }
    const fromDir = path.resolve(from, dirName)
    const toDir = path.resolve(to, dirName)
    if (!dir.isDir(toDir)) {
      fs.mkdirSync(toDir)
    }
    copy({ from: fromDir, to: toDir, renderData, ignore })
  })
}

module.exports = copy

copy 是一個(gè)遞歸復(fù)制文件和目錄的結(jié)構(gòu),深度優(yōu)先迎捺。

其中他擁有四個(gè)參數(shù)源文件夾举畸,目標(biāo)文件夾,渲染數(shù)據(jù)凳枝,忽略列表抄沮。

我們的模板其實(shí)是需要一些按需渲染內(nèi)容的能力的跋核,比如生成的 package.json 應(yīng)該擁有用戶創(chuàng)建時(shí)填寫的項(xiàng)目名,創(chuàng)建者叛买,描述等等信息砂代。所以我這里采用了 EJS 模板引擎進(jìn)行渲染,所有以.ejs 結(jié)尾的文件率挣,都將經(jīng)過引擎+渲染數(shù)據(jù)的渲染刻伊,接著再輸出,比如package.json.ejs

另外做了一些忽略的設(shè)計(jì),原因是某些文件在開發(fā)模板的過程中需要椒功,實(shí)際生成的時(shí)候需要進(jìn)行過濾捶箱。

全部采用同步 API,因?yàn)槲覀兊奈募际潜容^小的动漾,并且不是服務(wù)器上用丁屎,阻塞一下也沒有問題。

模板的構(gòu)建

我的這里設(shè)計(jì)了兩個(gè)預(yù)設(shè)模板旱眯,分別是 Vue-component 組件庫模板 另外一個(gè)是 JS 庫的模板(示例同樣基于 Vue)晨川。如果你們有類似的 需求可以去看看。這兩個(gè)模板都是先用 vue-cli3.0生成之后進(jìn)行改裝键思。

改裝的目的就是為了更加契合組件庫這一需求础爬,跟普通的項(xiàng)目不太一樣,組件庫需要在 DEV 模式下對組件進(jìn)行測試和開發(fā)吼鳞,然后必須擁有單獨(dú)打包這個(gè)組件的能力看蚜,接著進(jìn)行發(fā)布。

具體可以直接看代碼

構(gòu)建的過程中有些坑需要注意

模板內(nèi)部應(yīng)該擁有兩個(gè) package.json 文件

package.json 用于模板的 DEV 模式

package.json.ejs 用于創(chuàng)建時(shí)的最終導(dǎo)出

并且不要在 package.json 里面使用 files 字段做文件 publish 白名單赔桌,這會(huì)導(dǎo)致你的 cli 工具無法正常發(fā)布整個(gè)模板(這個(gè)應(yīng)該是模板內(nèi)部的 package.json 與整個(gè) cli 工具的 package.json 產(chǎn)生了覆蓋關(guān)系)供炎。

模板內(nèi)部的.gitignore文件加個(gè).ejs

同樣是 cli publish 的時(shí)候無法正常 上傳模板里面的.gitignore 文件,所以加個(gè) ejs 可以讓他偽裝成普通文件疾党。

所以我覺得 npm包 的嵌套是不是太容易產(chǎn)生干擾了一點(diǎn)音诫。

types 推薦

這里推薦大家寫組件庫的時(shí)候,可以手寫一下 TS 的類型聲明 types雪位,在 VSCode 下能獲得非常好的代碼提示效果竭钝。

首先你需要在組件庫的 package.json 里面添加一個(gè)屬性

{
  "typings": "types/index.d.ts",
}

我這里寫一個(gè)簡單的函數(shù)

// 最終導(dǎo)出
export default {
  say (name) {
    return `your name: ${name}`
  }
}
// index.d.ts
function say(name: String): String

export default {
  say
}

這樣 VSCode 就能在你使用這個(gè)模塊的時(shí)候,給你更加健全的提示雹洗。

image

這里額外提醒下香罐,經(jīng)過我的研究,element-ui 這樣的組件庫时肿,能有 props 的提示是因?yàn)槿思?vetur 組件專門給開的后門庇茫,寫 types 只能擁有 JS 層面的提示,寫 Vue-template 的時(shí)候依舊沒有螃成,期待后續(xù)能夠支持旦签。

參考

vue-cli

Vue cli3 庫模式搭建組件庫并發(fā)布到 npm的流程

element-ui

我的個(gè)人博客

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末查坪,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子宁炫,更是在濱河造成了極大的恐慌偿曙,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件淋淀,死亡現(xiàn)場離奇詭異遥昧,居然都是意外死亡覆醇,警方通過查閱死者的電腦和手機(jī)朵纷,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來永脓,“玉大人袍辞,你說我怎么就攤上這事〕4荩” “怎么了搅吁?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長落午。 經(jīng)常有香客問我谎懦,道長,這世上最難降的妖魔是什么溃斋? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任界拦,我火速辦了婚禮,結(jié)果婚禮上梗劫,老公的妹妹穿的比我還像新娘享甸。我一直安慰自己,他們只是感情好梳侨,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布蛉威。 她就那樣靜靜地躺著,像睡著了一般走哺。 火紅的嫁衣襯著肌膚如雪蚯嫌。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天丙躏,我揣著相機(jī)與錄音择示,去河邊找鬼。 笑死彼哼,一個(gè)胖子當(dāng)著我的面吹牛对妄,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播敢朱,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼剪菱,長吁一口氣:“原來是場噩夢啊……” “哼摩瞎!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起孝常,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤旗们,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后构灸,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體上渴,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年喜颁,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了稠氮。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,117評論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡半开,死狀恐怖隔披,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情寂拆,我是刑警寧澤奢米,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站纠永,受9級特大地震影響鬓长,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜尝江,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一涉波、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧茂装,春花似錦怠蹂、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至彼妻,卻和暖如春嫌佑,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背侨歉。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工屋摇, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人幽邓。 一個(gè)月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓炮温,卻偏偏與公主長得像,于是被迫代替她去往敵國和親牵舵。 傳聞我的和親對象是個(gè)殘疾皇子柒啤,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,877評論 2 345

推薦閱讀更多精彩內(nèi)容