簡介
本文會從零開始配置一個monorepo類型的組件庫,包括規(guī)范化配置、打包配置、組件庫文檔配置及開發(fā)一些提升效率的腳本等,monorepo不熟悉的話這里一句話介紹一下,就是在一個git倉庫里包含多個獨立發(fā)布的模塊/包。
ps.本文涉及到的工具配置其實在平時開發(fā)中一般都不需要自己配置,我們使用的各種腳手架都幫我們搞定了,但是我們至少得大概知道都是什么意思以及為什么,說來慚愧,筆者作為一個三四年工齡的前端老人,基本沒有自己動手配過,甚至沒有去了解過,所以以下大部分工具都是筆者第一次使用,除了介紹如何配置也會講到遇到的一些坑及解決方法,另外也會盡量去搞清楚每一個參數(shù)的意思及原理,有興趣的請繼續(xù)閱讀吧~
使用lerna管理項目
首先每個組件都是一個獨立的npm包,但是某個組件可能又依賴了另一個組件,這樣如果這個組件有bug修改完后發(fā)布了新版本,需要手動到依賴它的組件里挨個進(jìn)行升級再進(jìn)行發(fā)布,這是一個繁瑣且效率不高的過程,所以可以使用leran工具來進(jìn)行管理,lerna是一個專門用于管理帶有多個包的JavaScript項目的工具,可以幫助進(jìn)行npm發(fā)布及git上傳。
首先全局安裝lerna:
npm i -g lerna
然后進(jìn)入倉庫目錄執(zhí)行:
lerna init
這個命令用來創(chuàng)建一個新的lerna倉庫或者升級一個現(xiàn)有倉庫的lerna版本,lerna有兩種使用模式:
1.固定模式,默認(rèn)固定模式下所有包的主版本號和次版本都會使用lerna.json配置里的version字段定義的版本號,如果某一次只修改了其中一個或幾個包,但修改了配置文件里的主版本號或次版本號,那么發(fā)布時所有的包都會統(tǒng)一升級到該版本并進(jìn)行發(fā)布,單個的包如果想要發(fā)布只能修改修訂版本號進(jìn)行發(fā)布;
2.獨立模式就是每個包使用獨立的版本號。
自動生成的目錄如下:

可以看到?jīng)]有.gitignore文件,所以手動創(chuàng)建一下,目前只需要忽略node_modules目錄。
我們所有的包都會放在packages文件夾下,添加新包可以使用lerna create xxx命令(后面會通過腳本來生成),組件庫推薦給包名增加一個統(tǒng)一的作用域scope,可以避免命名沖突,比如常見的@vue/xxx、@babel/xxx等,npm從2.0版本開始支持發(fā)布帶作用域的包,默認(rèn)的作用域是你的npm用戶名,比如:@username/package-name,也可以使用npm config set @scope-name:registry http://reg.example.com來給你使用的npm倉庫關(guān)聯(lián)一個作用域。
給包添加依賴可以使用lerna add module-1 --scope=module-2命令,表示將module-1安裝到module-2的依賴?yán)铮?code>learn檢查到如果依賴的包是本項目中的會直接鏈接過去:

可以看到有個鏈接標(biāo)志,lerna add默認(rèn)也會執(zhí)行lerna bootstrap的操作,即給所有的包安裝依賴項。
當(dāng)修改完成后需要發(fā)布時可以使用lerna publish命令,該命令會完成模塊的發(fā)布及git上傳工作,有個需要注意的點是帶作用域的包使用npm發(fā)布時需要添加--access public參數(shù),但是lerna publish不支持該參數(shù),一個解決方法是在所有包的package.json文件里添加:
{
// ...
"publishConfig": {
"access": "publish"
}
}

規(guī)范化配置
eslint
eslint是一個配置化的JavaScript代碼檢查工具,通過該工具可以約束代碼風(fēng)格,以及檢測一些潛在錯誤,做到在不同的開發(fā)者下能有一個統(tǒng)一風(fēng)格的代碼,常見的比如是否允許使用==、語句結(jié)尾是否去掉;等等,eslint的規(guī)則非常多,可以在這里查看https://eslint.bootcss.com/docs/rules/ 。
eslint的所有規(guī)則都可單獨配置是否開啟,并且默認(rèn)都是禁用的,所以如果要自己來挨個配置是比較麻煩的,但是它有個繼承的配置,可以很方便的使用別人的配置,先來安裝一下:
npm i eslint --save-dev
然后在package.json文件里加一個命令:
{
"scripts": {
"lint:init": "eslint --init"
}
}
之后在命令行輸入npm run lint:init來創(chuàng)建一個eslint配置文件,根據(jù)你的情況回答完一些問題后就會生成一個默認(rèn)配置,我生成的內(nèi)容如下:

簡單看一下各個字段的意思:
env字段用來指定你代碼所要運行的環(huán)境,比如是在瀏覽器環(huán)境下,還是node環(huán)境下,不同的環(huán)境下所對應(yīng)的全局變量不一樣,因為后續(xù)還要寫node腳本,所以把node:true也加上;parserOptions表示所支持的語言選項,比如JavaScript的版本、是否啟用JSX等,設(shè)置正確的語言選項可以讓eslint確定什么是解析錯誤;plugins顧名思義是插件列表,比如你使用的是react,那么需要使用react的插件來支持react的語法,因為我用的是vue,所以使用了vue的插件,可以用來檢測單文件的語法問題,插件的命名規(guī)則為eslint-plugin-xxxx,配置時前綴可以省略;rules就是規(guī)則配置列表,可以單獨配置某個規(guī)則啟用與否;extends就是上文所說的繼承,這里使用了官方推薦的配置以及vue插件順帶提供的配置,配置命名一般為eslint-config-xxx,使用時前綴也可以省略,并且插件也可以順帶提供配置功能,引入規(guī)則一般為plugin:plugin-name/xxx,此外也可以選擇使用其他一些比較出名的配置如eslint-config-airbnb;
和.gitignore一樣,eslint也可以創(chuàng)建一個忽略配置文件.eslintignore,每一行都是一個glob模式來表示哪些路徑要忽略:
node_modules
docs
dist
assets
接下來再去package.json文件里加上運行檢查的命令:
"scripts": {
"lint": "eslint ./ --fix"
}
意思是檢查當(dāng)前目錄下的所有文件,--fix表示允許eslint進(jìn)行修復(fù),但是能修自動復(fù)的問題很少,執(zhí)行npm run lint,結(jié)果如下:

husky
目前只能手動去運行eslint檢查,就算能約束自己每次提交代碼前檢查一下,也不一定能約束到其他人,沒有強(qiáng)制的規(guī)范和沒有規(guī)范沒啥區(qū)別,所以最好在git提交前采取強(qiáng)制措施,這可以使用Husky,這個工具可以方便的讓我們在執(zhí)行某個git命令前先執(zhí)行特定的命令,我們的需求是在git commit之前進(jìn)行eslint檢查,這需要使用pre-commit鉤子,git還有很多其他的鉤子:https://git-scm.com/docs/githooks。
國際慣例,先安裝:
npm i husky@4 --save-dev
然后在package.json文件里添加:
{
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
}
}
接著我嘗試git commit,但是,沒有效果。。。檢查了node、npm和git的版本,均沒有問題,然后我打開git的隱藏文件夾.git/hooks:

發(fā)現(xiàn)目前的這些鉤子文件后面還是帶著sample后綴,如果想要某個鉤子生效,這個后綴要去掉才行,但是這種操作顯然不應(yīng)該讓我手動來干,那么只能重裝husky試試,經(jīng)過簡單的測試,我發(fā)現(xiàn)v5.x版本也是不行的,但是v3.0.0及v1.1.1兩個版本是生效的(筆者系統(tǒng)是windows10,可能和筆者電腦環(huán)境有關(guān)):


這樣如果檢查到有錯誤就會終止commit操作,不過目前一般還會使用另外一個包lint-staged,這個包顧名思義,只檢查staged狀態(tài)下的文件,其他本次提交沒有變動的文件就不用檢查了,這是合理的也能提高檢查速度,先安裝:npm i lint-staged --save-dev,然后去package.json里配置一下:
{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,vue}": [
"eslint --fix"
]
}
}
首先git鉤子執(zhí)行的命令改成lint-staged,lint-staged字段的值是個對象,對象的key也是glob匹配模式,value可以是字符串或字符串?dāng)?shù)組,每個字符串代表一個可執(zhí)行的命令,如果lint-staged發(fā)現(xiàn)當(dāng)前存在staged狀態(tài)的文件會進(jìn)行匹配,如果某個規(guī)則匹配到了文件那么就會執(zhí)行這個規(guī)則對應(yīng)的命令,在執(zhí)行命令的時候會把匹配到的文件作為參數(shù)列表傳給此命令,比如:exlint --fix xxx.js xxx.vue ...,所以上面配置的意思就是如果在已暫存的文件里匹配到了js或vue文件就執(zhí)行eslint --fix xxx.js ...,為啥命令不直接寫npm run lint呢,因為lint命令里我們配置了./路徑,那么仍將會檢查所有文件。
執(zhí)行效果如下,在上文的截圖中可以看到一共有14個錯誤,但是本次我只修改了一個文件,所以只檢查了這一個文件:

stylelint
stylelint和eslint十分相似,只不過是用來檢查css語法的,除了css文件,同時也支持scss、less等css預(yù)處理語言,stylelint可能沒eslint那么流行,不過本著學(xué)習(xí)的目的,咱們也嘗試一下,畢竟組件庫肯定少不了寫樣式,依舊先安裝:npm i stylelint stylelint-config-standard --save-dev,stylelint-config-standard是推薦的配置文件,和eslint-config-xxx一樣,也可以拿來繼承,不喜歡這個規(guī)則也可以換其他的,接著創(chuàng)建一個配置文件.stylelintrc,輸入以下內(nèi)容:
{
"extends": "stylelint-config-standard"
}
創(chuàng)建一個忽略配置文件.stylelintignore,輸入:
node_modules
最后在package.json中添加一行命令:
{
"scripts": {
"style:lint": "stylelint packages/**/*.{css,less} --fix"
}
}
檢查packages目錄下所有以css或less結(jié)尾的文件,并且可以的話自動進(jìn)行修復(fù),執(zhí)行命令效果如下:

最后的最后和eslint一樣,在git commit之前也加上自動進(jìn)行檢查,package.json文件修改如下:
{
"lint-staged": {
"*.{css,less}": [
"stylelint --fix"
]
}
}
commitlint
commit的內(nèi)容對于了解一次提交做了什么來說是很重要的,git commit內(nèi)容的標(biāo)準(zhǔn)格式其實是包含三部分的:Header、Body、Footer,其中Header部分是必填的,但是說實話對于我來說Header部分都懶得認(rèn)真寫,更不用說其他幾部分了,所以靠自覺不行還是上工具吧,讓我們在git的commit-msg鉤子上加上對commit內(nèi)容的檢查功能,不符合規(guī)則就打回重寫,安裝一下校驗工具commitlint:
npm i --save-dev @commitlint/config-conventional @commitlint/cli
同樣也是一個工具,一個配置,通過繼承的方式來使用,嚴(yán)重懷疑這些工具的開發(fā)者都是同一批人,接下來創(chuàng)建一個配置文件commitlint.config.js,輸入如下內(nèi)容:
module.exports = {
extends: ['@commitlint/config-conventional']
}
當(dāng)然你也可以再單獨配置你需要的規(guī)則,然后去package.json的husky部分配置鉤子:
{
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
}
commitlint命令需要有輸入?yún)?shù),也就是我們輸入的commit message,-E參數(shù)的意思如下:

大意就是從環(huán)境變量里給定的文件里獲取輸入內(nèi)容,這個環(huán)境變量看名字就知道是husky提供的,具體它是啥呢,咱們來簡單看一下,首先打開.git/hooks/commit-msg文件,這個就是commit-msg鉤子執(zhí)行的bash腳本:

可以看到最后執(zhí)行了run.js,參數(shù)分別為hookName及gitParams,baseName "$0"代表當(dāng)前執(zhí)行的腳本名稱,也就是文件名commit-msg,"$*"代表所有的參數(shù),run.js里又輾轉(zhuǎn)反側(cè)的最后調(diào)用了一個run方法:
function run([, scriptPath, hookName = '', HUSKY_GIT_PARAMS], getStdinFn = get_stdin_1.default) {
console.log('攔截', scriptPath, hookName, HUSKY_GIT_PARAMS)
// ...
}
我們打印看一下參數(shù)都是啥:

可以看到HUSKY_GIT_PARAMS就是一個文件路徑,這個文件里保存著我們這次輸入的commit message的內(nèi)容,接著husky會把它設(shè)置到環(huán)境變量里:
const env = {};
if (HUSKY_GIT_PARAMS) {
env.HUSKY_GIT_PARAMS = HUSKY_GIT_PARAMS;
}
if (['pre-push', 'pre-receive', 'post-receive', 'post-rewrite'].includes(hookName)) {
// Wait for stdin
env.HUSKY_GIT_STDIN = yield getStdinFn();
}
if (command) {
console.log(`husky > ${hookName} (node ${process.version})`);
execa_1.default.shellSync(command, { cwd, env, stdio: 'inherit' });
return 0;
}
現(xiàn)在再看commitlint -E HUSKY_GIT_PARAMS就很容易理解了,commitlint會去讀取.git/COMMIT_EDITMSG文件內(nèi)容來檢查我們輸入的commit message是否符合規(guī)范。

可以看到我們只輸入了一個1的話就報錯了。
commitizen
上面提到一個標(biāo)準(zhǔn)的commit message是包含三部分的,詳細(xì)看就是這樣的:
<type>(<scope>): <subject>
空行
<body>
空行
<footer>
當(dāng)你輸入git commit時,就會出現(xiàn)一個命令行編輯器讓你來輸入,但是這個編輯器很不好用,沒用過的話怎么保存都是個問題,所以可以使用commitizen來進(jìn)行交互式的輸入,依次執(zhí)行下列命令:
npm install commitizen -g
commitizen init cz-conventional-changelog --save-dev --save-exact
執(zhí)行完后應(yīng)該會自動在你的package.json文件里加上下列配置:
{
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}
然后你就可以使用git cz命令來代替git commit命令了,它會給你一些選項,以及詢問你一些問題,如實輸入即可:

但是這樣git commit命令仍然是可用的,文檔上說可以進(jìn)行如下配置來將git commit轉(zhuǎn)換為git cz:
{
"husky": {
"hooks": {
"prepare-commit-msg": "exec < /dev/tty && git cz --hook || true",
}
}
}
但是我嘗試了不行,報系統(tǒng)找不到指定的路徑。的錯誤,沒找到原因和解決方法,如果你知道如何解決的話評論區(qū)見吧~強(qiáng)制不了,那只能加一句卑微的提示了:
{
"husky": {
"hooks": {
"prepare-commit-msg": "echo ----------------please use [git cz] command instead of [git commit]----------------"
}
}
}

規(guī)范化的暫且就配置這么多,其他的比如代碼美化可以使用prettier、生成提交日志的可以使用conventional-changelog或standard-version,有需要的可以自行嘗試。
打包配置
目前每個組件的結(jié)構(gòu)都是類似下面這樣的:

index.js返回一個帶install方法的對象,作為vue的插件,使用這個組件的方式如下:
import ModuleX from 'module-x'
Vue.use(ModuleX)
組件庫其實直接這么發(fā)布就可以了,如果js文件里使用了最新的語法,那么需要在使用該組件的項目里的vue.config.js里添加一下如下配置:
{
transpileDependencies: [
'module-x'
]
}
因為默認(rèn)情況下 babel-loader 會忽略所有 node_modules 中的文件,添加這個配置可以讓Babel 顯式轉(zhuǎn)譯這個依賴。
不過如果你硬想要打包后再進(jìn)行發(fā)布也是可以的,我們增加一下打包的配置。
先安裝一下相關(guān)的工具:
npm i webpack less less-loader css-loader style-loader vue-loader vue-template-compiler babel-loader @babel/core @babel/cli @babel/preset-env url-loader clean-webpack-plugin -D
因為比較多,就不挨個介紹了,應(yīng)該還是比較清晰的,分別是用來解析樣式文件、vue單文件、js文件及其他文件,可以根據(jù)你的實際情況增減。
先說一下打包目標(biāo),分別給每個包進(jìn)行打包,打包結(jié)果輸出到各自文件夾的dist目錄下,我們使用webpack的node API來做:
// ./bin/buildModule.js
const webpack = require('webpack')
const path = require('path')
const fs = require('fs-extra')
const {
CleanWebpackPlugin
} = require('clean-webpack-plugin')
const {
VueLoaderPlugin
} = require('vue-loader')
// 獲取命令行參數(shù),用來打包指定的包,否則打包packages目錄下的所有包
const args = process.argv.slice(2)
// 生成webpack配置
const createConfigList = () => {
const pkgPath = path.join(__dirname, '../', 'packages')
// 根據(jù)是否傳入了參數(shù)來判斷要打的包
const dirs = args.length > 0 ? args : fs.readdirSync(pkgPath)
// 給每個包生成一個webpack配置
return dirs.map((item) => {
return {
// 入口文件為每個包里的index.js文件
entry: path.join(pkgPath, item, 'index.js'),
output: {
filename: 'index.js',
path: path.resolve(pkgPath, item, 'dist'),// 打包刪除到dist文件夾下
library: item,
libraryTarget: 'umd',// 打包成umd模塊
libraryExport: 'default'
},
target: ['web', 'es5'],// webpack5默認(rèn)打包生成的代碼是包含const、let、箭頭函數(shù)等es6語法的,所以需要設(shè)置一下生成es5的代碼
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
},
{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'less-loader']
},
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
loader: 'babel-loader'
},
{
test: /\.(png|jpe?g|gif)$/i,
loader: 'url-loader',
options: {
esModule: false// 最新版本的file-loader默認(rèn)使用es module的方式引入圖片,最終生成的鏈接是個對象,所以如果是通過require方式引入圖片就訪問不了,可以通過該配置關(guān)掉
}
}
]
},
plugins: [
new VueLoaderPlugin(),
new CleanWebpackPlugin()
]
}
})
}
// 開始打包
webpack(createConfigList(), (err, stats) => {
// 處理和結(jié)果處理...
})
然后運行命令node ./bin/buildModule.js 即可打所有的包,或者node ./bin/buildModule.js xxx xxx2 ...來打你指定的包。
當(dāng)然,這只是最簡單的配置,實際上肯定還會遇到很多特定問題,比如:
如果依賴了其他基礎(chǔ)組件庫的話會比較麻煩,推薦這種情況就不要打包了,直接源碼發(fā)布;
尋找文件時缺少
vue擴(kuò)展名,那么需要配置一下webpack的resolve.extensions;使用了某些比較新的
JavaScript語法或者用到jsx等,那么需要配置一下對應(yīng)的babel插件或預(yù)設(shè);引用了
vue、jquery等外部庫,不可能直接打包進(jìn)去,所以需要配置一下webpack的externals;某個包可能有多個入口,換句話說也就是個別的包可能有特定的配置,那么可以在該包下面添加一個配置文件,然后上述生成配置的代碼里可以讀取該文件進(jìn)行配置合并;
這些問題解決都不難,看一下報的錯然后去搜索一下基本很容易就能解決,有興趣的話也可以去本文的源碼查看。
接下來做個小優(yōu)化,因為webpack打包不是同時進(jìn)行的,所以包的數(shù)量多了的話總時間就很慢,可以使用parallel-webpack這個插件來讓它并行打包:
npm i parallel-webpack -D
因為它的api使用的是配置的文件路徑,不能直接傳遞對象類型,所以需要修改一下上述的代碼,改成導(dǎo)出一個配置的方式:
// 文件名改成config.js
// ...
// 刪除
// webpack(createConfigList(), (err, stats) => {
// 處理和結(jié)果處理...
// })
// 增加導(dǎo)出語句
module.exports = createConfigList()
另外創(chuàng)建一個文件:
// run.js
const run = require('parallel-webpack').run
const configPath = require.resolve('./config.js')
run(configPath, {
watch: false,
maxRetries: 1,
stats: true
})
執(zhí)行node ./bin/run.js即可執(zhí)行,我簡單計時了一下,節(jié)省了大約一半的時間。
組件文檔配置
組件文檔工具使用的是VuePress,如果跟我一樣遇到了webpack版本沖突問題,可以選擇在./docs目錄下單獨安裝:
cd ./docs
npm init
npm install -D vuepress
vuepress的基本配置很簡單,使用默認(rèn)主題按照教程配置即可,這里就不細(xì)說了,只說一下如何在文檔里使用packages里的組件,先看一下當(dāng)前目錄結(jié)構(gòu):

config.js文件是vuepress的默認(rèn)配置文件,打包選項、導(dǎo)航欄、側(cè)邊欄等等都在這里配置,enhanceApp是客戶端應(yīng)用的增強(qiáng),在這里可以獲取到vue實例,可以做一些應(yīng)用啟動的工作,比如注冊組件等。
zh/rate是我添加的一個組件的文檔,文檔及示例內(nèi)容都在文件夾下的README.md文件里,vuepress對markdown做了擴(kuò)展,所以在markdown文件里可以使用像vue單文件一樣包含template、script、style三個塊,方便在文檔里進(jìn)行示例開發(fā),組件需要先在enhanceApp.js文件里進(jìn)行導(dǎo)入及注冊,那么問題來了,我們是導(dǎo)入開發(fā)中的還是打包后的呢,小朋友才做選擇,成年人都要,比如開發(fā)階段我們就導(dǎo)入開發(fā)中的,開發(fā)完成了就導(dǎo)入打包后的,區(qū)別只是在于package.json里的main入口字段指向不同而已,比如我們先指向開發(fā)中的:
// package.json
{
"main": "index.js"
}
接下來去enhanceApp.js里導(dǎo)入及注冊:
import Rate from '@zf/rate'
export default ({
Vue
}) => {
Vue.use(Rate)
}
如果直接這樣的話默認(rèn)是會報錯的,因為找不到這個包,此時我們的包也還沒發(fā)布,所以也不能直接安裝,那怎么辦呢,辦法應(yīng)該有好幾個,比如可以使用npm link來將包鏈接到這里,但是這樣太麻煩,所以我選擇修改一下vuepress的webpack配置,讓它尋找包的時候順便去找packages目錄下找,另外也需要給@zf設(shè)置一下別名,顯然我們的目錄里并沒有@zf,修改webpack的配置需要在config.js文件里操作:
const path = require('path')
module.exports = {
chainWebpack: (config) => {
// 我們包存放的位置
const pkgPath = path.resolve(__dirname, '../../../', 'packages')
// 修改webpack的resolve.modules配置,解析模塊時應(yīng)該搜索的目錄,先去packages,再去node_modules
config.resolve
.modules
.add(pkgPath)
.add('node_modules')
// 修改別名resolve.alias配置
config.resolve
.alias
.set('@zf', pkgPath)
}
}
這樣在vuepress里就可以正常使用我們的組件了,當(dāng)你開發(fā)完成后就可以把這個包package.json的入口字段改成打包后的目錄:
// package.json
{
"main": "dist/index.js"
}
其他基本信息、導(dǎo)航欄、側(cè)邊欄等可以根據(jù)你的需求進(jìn)行配置,效果如下:


使用腳本新增組件
現(xiàn)在讓我們來看一下新增一個組件都有哪些步驟:
1.給要新增的組件取個名字,然后使用npm search xxx來檢查一下是否已存在,存在就換個名字;
2.在packages目錄下創(chuàng)建文件夾,新建幾個基本文件,通常來說是復(fù)制粘貼其他組件然后修改;
3.在docs目錄下創(chuàng)建文檔文件夾,新建README.md文件,文件內(nèi)容一般也是通過復(fù)制粘貼;
4.修改config.js進(jìn)行側(cè)邊欄配置(如果配置了側(cè)邊欄的話)、修改enhanceApp.js導(dǎo)入及注冊組件;
這一套步驟下來雖然不難,但是繁瑣,很容易漏掉某一步,上述這些事情其實特別適合讓腳本來干,接下來就實現(xiàn)一下。
初始化工作
先在./bin目錄下新建一個add.js文件,這個就是咱們要執(zhí)行的腳本,首先它肯定要接收一些參數(shù),簡單起見這里只需要輸入一個組件名,但是為了后續(xù)擴(kuò)展方便,我們使用inquirer來處理命令行輸入,接收到輸入的組件名稱后自動進(jìn)行一下是否已存在的校驗:
// add.js
const {
exec
} = require('child_process')
const inquirer = require('inquirer')
const ora = require('ora')// ora是一個命令行l(wèi)oading工具
const scope = '@zf/'// 包的作用域,如果你的包沒有作用域,那么則不需要
inquirer
.prompt([{
type: 'input',
name: 'name',
message: '請輸入組件名稱',
validate(input) {
// 異步驗證需要調(diào)用這個方法來告訴inquirer是否校驗完成
const done = this.async();
input = String(input).trim()
if (!input) {
return done('請輸入組件名稱')
}
const spinner = ora('正在檢查包名是否存在').start()
exec(`npm search ${scope + input}`, (err, stdout) => {
spinner.stop()
if (err) {
done('檢查包名是否存在失敗,請重試')
} else {
if (/No matches/.test(stdout)) {
done(null, true)
} else {
done('該包名已存在,請修改')
}
}
})
}
}
])
.then(answers => {
// 命令行輸入完成,進(jìn)行其他操作
console.log(answers)
})
.catch(error => {
// 錯誤處理
});
執(zhí)行后效果如下:

使用模板創(chuàng)建
接下來在packages目錄下自動生成文件夾及文件,在【打包配置】一節(jié)中可以看到一個基本的包一共有四個文件:index.js、package.json、index.vue以及style.less,首先在./bin目錄下創(chuàng)建一個template文件夾,然后再新建這四個文件,基本內(nèi)容可以先復(fù)制粘貼進(jìn)去,其中index.js和style.less的內(nèi)容不需要修改,所以直接復(fù)制到新組件的目錄下即可:
// add.js
const upperCamelCase = require('uppercamelcase')// 字符串-風(fēng)格的轉(zhuǎn)駝峰
const fs = require('fs-extra')
const templateDir = path.join(__dirname, 'template')// 模板路徑
// 這個方法在上述inquirer的then方法里調(diào)用,參數(shù)為命令行輸入的信息
const create = ({
name
}) => {
// 組件目錄
const destDir = path.join(__dirname, '../', 'packages', name)
const srcDir = path.join(destDir, 'src')
// 創(chuàng)建目錄
fs.ensureDirSync(destDir)
fs.ensureDirSync(srcDir)
// 復(fù)制index.js和style.less
fs.copySync(path.join(templateDir, 'index.js'), path.join(destDir, 'index.js'))
fs.copySync(path.join(templateDir, 'style.less'), path.join(srcDir, 'style.less'))
}
index.vue和package.json內(nèi)容的部分信息需要動態(tài)注入,比如index.vue的組件名、package.json的包名,我們可以使用一個很簡單的庫json-templater來以雙大括號插值的方法來注入數(shù)據(jù),以package.json為例:
// ./bin/template/package.json
{
"name": "{{name}}",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"author": "",
"license": "ISC"
}
name是我們要注入的數(shù)據(jù),接下來讀取模板的內(nèi)容,然后注入并渲染,最后創(chuàng)建文件:
// add.js
const upperCamelCase = require('uppercamelcase')// 字符串-風(fēng)格的轉(zhuǎn)駝峰
const render = require('json-templater/string')
// 渲染模板及創(chuàng)建文件
const renderTemplateAndCreate = (file, data = {}, dest) => {
const templateContent = fs.readFileSync(path.join(templateDir, file), {
encoding: 'utf-8'
})
const fileContent = render(templateContent, data)
fs.writeFileSync(path.join(dest, file), fileContent, {
encoding: 'utf-8'
})
}
const create = ({
name
}) => {
// 組件目錄
// ...
// 創(chuàng)建package.json
renderTemplateAndCreate('package.json', {
name: scope + name
}, destDir)
// index.vue
renderTemplateAndCreate('index.vue', {
name: upperCamelCase(name)
}, srcDir)
}
到這里組件的目錄及文件就創(chuàng)建完成了,文檔的目錄及文件也是一樣,這里就不貼代碼了。
使用AST修改
最后要修改的兩個文件是config.js和enhanceApp.js,這兩個文件雖然也可以向上面一樣使用模板注入的方式,但是考慮到這兩個文件修改的頻率可能比較頻繁,所以每次都得去模板里修改不太方便,所以我們換一種方式,使用AST,這樣就不需要模板的占位符了。
先看enhanceApp.js,每增加一個組件,我們都需要在這里導(dǎo)入和注冊:
import Rate from '@zf/rate'
export default ({
Vue
}) => {
Vue.use(Rate)
console.log(1)
}
思路很簡單,把這個文件的源代碼先轉(zhuǎn)換成AST,然后在最后一個import語句后面插入新組件的導(dǎo)入語句,以及在最后一條Vue.use語句和console.log語句之間插入新組件的注冊語句,最后再轉(zhuǎn)換回源碼寫回到這個文件里,AST相關(guān)的操作可以使用babel的工具包:@babel/parser、@babel/traverse、@babel/generator、@babel/types。
@babel/parser
把源代碼轉(zhuǎn)換成AST很簡單:
// add.js
const parse = require('@babel/parser').parse
// 更新enhanceApp.js
const updateEnhanceApp = ({
name
}) => {
// 讀取文件內(nèi)容
const filePath = path.join(__dirname, '../', 'docs', 'docs', '.vuepress', 'enhanceApp.js')
const code = fs.readFileSync(filePath, {
encoding: 'utf-8'
})
// 轉(zhuǎn)換成AST
const ast = parse(code, {
sourceType: "module"http:// 因為用到了`import`語法,所以指明把代碼解析成module模式
})
console.log(ast)
}
生成的數(shù)據(jù)很多,所以命令行一般都顯示不下去,可以去https://astexplorer.net/這個網(wǎng)站上查看,選擇@babel/parser的解析器即可。

@babel/traverse
得到了AST樹之后就需要修改這顆樹,@babel/traverse用來遍歷和修改樹節(jié)點,這是整個過程中相對麻煩的一個步驟,如果不熟悉AST的基礎(chǔ)知識和操作的話推薦先閱讀一下這篇文檔babel-handbook。
接下來我們對著上面解析的截圖來寫一下添加import語句的代碼:
// add.js
const traverse = require('@babel/traverse').default
const t = require("@babel/types")// 這個包是一個工具包,用來檢測某個節(jié)點的類型、創(chuàng)建新節(jié)點等
const updateEnhanceApp = ({
name
}) => {
// ...
// traverse的第一個參數(shù)是ast對象,第二個是一個訪問器,當(dāng)遍歷到某種類型的節(jié)點后會調(diào)用對應(yīng)的函數(shù)
traverse(ast, {
// 遍歷到了Program節(jié)點會執(zhí)行該函數(shù)
// 函數(shù)的第一個參數(shù)并不是節(jié)點本身,而是代表節(jié)點的路徑,路徑上會包含該節(jié)點和其他節(jié)點之間的關(guān)系信息,后續(xù)的一些操作也都是在路徑上進(jìn)行,如果要訪問節(jié)點本身,可以訪問path.node
Program(nodePath) {
let bodyNodesList = nodePath.node.body // 通過上圖可以看到是個數(shù)組
// 遍歷節(jié)點找到最后一個import節(jié)點
let lastImportIndex = -1
for (let i = 0; i < bodyNodesList.length; i++) {
if (t.isImportDeclaration(bodyNodesList[i])) {
lastImportIndex = i
}
}
// 構(gòu)建即將要插入的import語句的AST節(jié)點:import name from @zf/name
// 節(jié)點類型及需要的參數(shù)可以在這里查看:https://babeljs.io/docs/en/babel-types
// 如果不確定使用哪個類型的話可以在上述的https://astexplorer.net/網(wǎng)站上看一下某個語句對應(yīng)的是什么
const newImportNode = t.importDeclaration(
[ t.ImportDefaultSpecifier(t.Identifier(upperCamelCase(name))) ], // name
t.StringLiteral(scope + name)
)
// 當(dāng)前沒有import節(jié)點,則在第一個節(jié)點之前插入import節(jié)點
if (lastImportIndex === -1) {
let firstPath = nodePath.get('body.0')// 獲取body的第一個節(jié)點的path
firstPath.insertBefore(newImportNode)// 在該節(jié)點之前插入節(jié)點
} else { // 當(dāng)前存在import節(jié)點,則在最后一個import節(jié)點之后插入import節(jié)點
let lastImportPath = nodePath.get(`body.${lastImportIndex}`)
lastImportPath.insertAfter(newImportNode)
}
}
});
}
接下來看一下添加Vue.use的代碼,因為生成的AST樹太長了,所以不方便截圖,大家可以打開上面的網(wǎng)站輸入示例代碼后看生成的結(jié)構(gòu):
// add.js
// ...
traverse(ast, {
Program(nodePath) {},
// 遍歷到ExportDefaultDeclaration節(jié)點
ExportDefaultDeclaration(nodePath) {
let bodyNodesList = nodePath.node.declaration.body.body // 找到箭頭函數(shù)節(jié)點的body,目前存在兩個表達(dá)式節(jié)點
// 下面的邏輯和添加import語句的邏輯基本一致,遍歷節(jié)點找到最后一個vue.use類型的節(jié)點,然后添加一個新節(jié)點
let lastIndex = -1
for (let i = 0; i < bodyNodesList.length; i++) {
let node = bodyNodesList[i]
// 找到vue.use類型的節(jié)點
if (
t.isExpressionStatement(node) &&
t.isCallExpression(node.expression) &&
t.isMemberExpression(node.expression.callee) &&
node.expression.callee.object.name === 'Vue' &&
node.expression.callee.property.name === 'use'
) {
lastIndex = i
}
}
// 構(gòu)建新節(jié)點:Vue.use(name)
const newNode = t.expressionStatement(
t.callExpression(
t.memberExpression(
t.identifier('Vue'),
t.identifier('use')
),
[ t.identifier(upperCamelCase(name))]
)
)
// 插入新節(jié)點
if (lastIndex === -1) {
if (bodyNodesList.length > 0) {
let firstPath = nodePath.get('declaration.body.body.0')
firstPath.insertBefore(newNode)
} else {// body為空的話需要調(diào)用`body`節(jié)點的pushContainer方法追加節(jié)點
let bodyPath = nodePath.get('declaration.body')
bodyPath.pushContainer('body', newNode)
}
} else {
let lastPath = nodePath.get(`declaration.body.body.${lastIndex}`)
lastPath.insertAfter(newNode)
}
}
});
@babel/generator
AST樹修改完成接下來就可以轉(zhuǎn)回源代碼了:
// add.js
const generate = require('@babel/generator').default
const updateEnhanceApp = ({
name
}) => {
// ...
// 生成源代碼
const newCode = generate(ast)
}
效果如下:

可以看到使用AST進(jìn)行簡單的操作并不難,關(guān)鍵是要細(xì)心及耐心,找對節(jié)點。另外對config.js的修改也是大同小異,有興趣的可以直接看源碼。
到這里我們只要使用npm run add命令就可以自動化的創(chuàng)建一個新組件,可以直接進(jìn)行組件開發(fā)了~
結(jié)尾
本文是筆者在改造自己組件庫的一些過程記錄,因為是第一次實踐,難免會有錯誤或不合理的地方,歡迎指出,感謝閱讀,再會~