緣起
最近公司內(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 使用 快速切換倉庫地址
還整個(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);
inquirer
事實(shí)上當(dāng)我第一次使用 vue-cli3.0 的時(shí)候享扔,里面的命令行表單真是非常驚艷底桂,翻了 vue-cli3 的源碼 找到了這款工具植袍,用于命令行的表單。能夠更加直觀的配置選項(xiàng)籽懦。
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 就足夠了。
開始構(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
這里主要做了幾件事
- 保證項(xiàng)目名合法厕妖。
- 確認(rèn)項(xiàng)目在當(dāng)前目錄不存在
- 收集用戶的填寫信息
- 啟動(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í)候,給你更加健全的提示雹洗。
這里額外提醒下香罐,經(jīng)過我的研究,element-ui 這樣的組件庫时肿,能有 props 的提示是因?yàn)槿思?vetur 組件專門給開的后門庇茫,寫 types 只能擁有 JS 層面的提示,寫 Vue-template 的時(shí)候依舊沒有螃成,期待后續(xù)能夠支持旦签。