手把手教你從零配置一個(gè)vue組件庫

來源 | https://www.cnblogs.com/wanglinmantan/p/15092010.html
簡介
使用lerna管理項(xiàng)目
npm i -g lerna
然后進(jìn)入倉庫目錄執(zhí)行:
lerna init這個(gè)命令用來創(chuàng)建一個(gè)新的lerna倉庫或者升級一個(gè)現(xiàn)有倉庫的lerna版本,lerna有兩種使用模式:
1、固定模式,默認(rèn)固定模式下所有包的主版本號和次版本都會使用lerna.json配置里的version字段定義的版本號,如果某一次只修改了其中一個(gè)或幾個(gè)包,但修改了配置文件里的主版本號或次版本號,那么發(fā)布時(shí)所有的包都會統(tǒng)一升級到該版本并進(jìn)行發(fā)布,單個(gè)的包如果想要發(fā)布只能修改修訂版本號進(jìn)行發(fā)布;
2、獨(dú)立模式就是每個(gè)包使用獨(dú)立的版本號。
自動生成的目錄如下:

可以看到?jīng)]有.gitignore文件,所以手動創(chuàng)建一下,目前只需要忽略node_modules目錄。
我們所有的包都會放在packages文件夾下,添加新包可以使用lerna create xxx命令(后面會通過腳本來生成),組件庫推薦給包名增加一個(gè)統(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)一個(gè)作用域。
給包添加依賴可以使用lerna add module-1 --scope=module-2命令,表示將module-1安裝到module-2的依賴?yán)铮琹earn檢查到如果依賴的包是本項(xiàng)目中的會直接鏈接過去:

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

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

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

husky
目前只能手動去運(yùn)行eslint檢查,就算能約束自己每次提交代碼前檢查一下,也不一定能約束到其他人,沒有強(qiáng)制的規(guī)范和沒有規(guī)范沒啥區(qū)別,所以最好在git提交前采取強(qiáng)制措施,這可以使用Husky,這個(gè)工具可以方便的讓我們在執(zhí)行某個(gè)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后綴,如果想要某個(gè)鉤子生效,這個(gè)后綴要去掉才行,但是這種操作顯然不應(yīng)該讓我手動來干,那么只能重裝husky試試,經(jīng)過簡單的測試,我發(fā)現(xiàn)v5.x版本也是不行的,但是v3.0.0及v1.1.1兩個(gè)版本是生效的(筆者系統(tǒng)是windows10,可能和筆者電腦環(huán)境有關(guān)):


這樣如果檢查到有錯(cuò)誤就會終止commit操作,不過目前一般還會使用另外一個(gè)包lint-staged,這個(gè)包顧名思義,只檢查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字段的值是個(gè)對象,對象的key也是glob匹配模式,value可以是字符串或字符串?dāng)?shù)組,每個(gè)字符串代表一個(gè)可執(zhí)行的命令。
如果lint-staged發(fā)現(xiàn)當(dāng)前存在staged狀態(tài)的文件會進(jìn)行匹配,如果某個(gè)規(guī)則匹配到了文件那么就會執(zhí)行這個(gè)規(guī)則對應(yīng)的命令,在執(zhí)行命令的時(shí)候會把匹配到的文件作為參數(shù)列表傳給此命令。
比如:exlint --fix xxx.js xxx.vue ...,所以上面配置的意思就是如果在已暫存的文件里匹配到了js或vue文件就執(zhí)行eslint --fix xxx.js ... ,為啥命令不直接寫npm run lint呢,因?yàn)閘int命令里我們配置了./路徑,那么仍將會檢查所有文件。
執(zhí)行效果如下,在上文的截圖中可以看到一共有14個(gè)錯(cuò)誤,但是本次我只修改了一個(gè)文件,所以只檢查了這一個(gè)文件:

stylelint
stylelint和eslint十分相似,只不過是用來檢查css語法的,除了css文件,同時(shí)也支持scss、less等css預(yù)處理語言,stylelint可能沒eslint那么流行,不過本著學(xué)習(xí)的目的,咱們也嘗試一下,畢竟組件庫肯定少不了寫樣式,依舊先安裝:npm i stylelint stylelint-config-standard --save-dev,stylelint-config-standard是推薦的配置文件,和eslint-config-xxx一樣,也可以拿來繼承,不喜歡這個(gè)規(guī)則也可以換其他的,接著創(chuàng)建一個(gè)配置文件.stylelintrc,輸入以下內(nèi)容:
{"extends": "stylelint-config-standard"}
創(chuàng)建一個(gè)忽略配置文件.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)格式其實(shí)是包含三部分的:Header、Body、Footer,其中Header部分是必填的。
但是說實(shí)話對于我來說Header部分都懶得認(rèn)真寫,更不用說其他幾部分了,所以靠自覺不行還是上工具吧,讓我們在git的commit-msg鉤子上加上對commit內(nèi)容的檢查功能,不符合規(guī)則就打回重寫,安裝一下校驗(yàn)工具commitlint:
npm i --save-dev @commitlint/config-conventional @commitlint/cli
同樣也是一個(gè)工具,一個(gè)配置,通過繼承的方式來使用,嚴(yán)重懷疑這些工具的開發(fā)者都是同一批人,接下來創(chuàng)建一個(gè)配置文件commitlint.config.js,輸入如下內(nèi)容:
module.exports = {extends: ['@commitlint/config-conventional']}
當(dāng)然你也可以再單獨(dú)配置你需要的規(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)容,這個(gè)環(huán)境變量看名字就知道是husky提供的,具體它是啥呢,咱們來簡單看一下。
首先打開.git/hooks/commit-msg文件,這個(gè)就是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)用了一個(gè)run方法:
function run([, scriptPath, hookName = '', HUSKY_GIT_PARAMS], getStdinFn = get_stdin_1.default) {console.log('攔截', scriptPath, hookName, HUSKY_GIT_PARAMS)// ...}
我們打印看一下參數(shù)都是啥:

可以看到HUSKY_GIT_PARAMS就是一個(gè)文件路徑,這個(gè)文件里保存著我們這次輸入的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 stdinenv.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ī)范。

可以看到我們只輸入了一個(gè)1的話就報(bào)錯(cuò)了。
commitizen
上面提到一個(gè)標(biāo)準(zhǔn)的commit message是包含三部分的,詳細(xì)看就是這樣的:
<type>(<scope>): <subject>空行<body>空行<footer>
當(dāng)你輸入git commit時(shí),就會出現(xiàn)一個(gè)命令行編輯器讓你來輸入,但是這個(gè)編輯器很不好用,沒用過的話怎么保存都是個(gè)問題,所以可以使用commitizen來進(jìn)行交互式的輸入,依次執(zhí)行下列命令:
npm install commitizen -gcommitizen 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命令了,它會給你一些選項(xiàng),以及詢問你一些問題,如實(shí)輸入即可:

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

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

index.js返回一個(gè)帶install方法的對象,作為vue的插件,使用這個(gè)組件的方式如下:
import ModuleX from 'module-x'Vue.use(ModuleX)
組件庫其實(shí)直接這么發(fā)布就可以了,如果js文件里使用了最新的語法,那么需要在使用該組件的項(xiàng)目里的vue.config.js里添加一下如下配置:
{transpileDependencies: ['module-x']}
因?yàn)槟J(rèn)情況下 babel-loader 會忽略所有 node_modules 中的文件,添加這個(gè)配置可以讓Babel 顯式轉(zhuǎn)譯這個(gè)依賴。
不過如果你硬想要打包后再進(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àn)楸容^多,就不挨個(gè)介紹了,應(yīng)該還是比較清晰的,分別是用來解析樣式文件、vue單文件、js文件及其他文件,可以根據(jù)你的實(shí)際情況增減。
先說一下打包目標(biāo),分別給每個(gè)包進(jìn)行打包,打包結(jié)果輸出到各自文件夾的dist目錄下,我們使用webpack的node API來做:
// ./bin/buildModule.jsconst 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)// 給每個(gè)包生成一個(gè)webpack配置return dirs.map((item) => {return {// 入口文件為每個(gè)包里的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的方式引入圖片,最終生成的鏈接是個(gè)對象,所以如果是通過require方式引入圖片就訪問不了,可以通過該配置關(guān)掉}}]},plugins: [new VueLoaderPlugin(),new CleanWebpackPlugin()]}})}// 開始打包webpack(createConfigList(), (err, stats) => {// 處理和結(jié)果處理...})
然后運(yùn)行命令node ./bin/buildModule.js 即可打所有的包,或者node ./bin/buildModule.js xxx xxx2 ...來打你指定的包。
當(dāng)然,這只是最簡單的配置,實(shí)際上肯定還會遇到很多特定問題,比如:
如果依賴了其他基礎(chǔ)組件庫的話會比較麻煩,推薦這種情況就不要打包了,直接源碼發(fā)布;
尋找文件時(shí)缺少vue擴(kuò)展名,那么需要配置一下webpack的resolve.extensions;
使用了某些比較新的JavaScript語法或者用到j(luò)sx等,那么需要配置一下對應(yīng)的babel插件或預(yù)設(shè);
引用了vue、jquery等外部庫,不可能直接打包進(jìn)去,所以需要配置一下webpack的externals;
某個(gè)包可能有多個(gè)入口,換句話說也就是個(gè)別的包可能有特定的配置,那么可以在該包下面添加一個(gè)配置文件,然后上述生成配置的代碼里可以讀取該文件進(jìn)行配置合并;
這些問題解決都不難,看一下報(bào)的錯(cuò)然后去搜索一下基本很容易就能解決,有興趣的話也可以去本文的源碼查看。
接下來做個(gè)小優(yōu)化,因?yàn)閣ebpack打包不是同時(shí)進(jìn)行的,所以包的數(shù)量多了的話總時(shí)間就很慢,可以使用parallel-webpack這個(gè)插件來讓它并行打包:
npm i parallel-webpack -D
因?yàn)樗腶pi使用的是配置的文件路徑,不能直接傳遞對象類型,所以需要修改一下上述的代碼,改成導(dǎo)出一個(gè)配置的方式:
// 文件名改成config.js// ...// 刪除// webpack(createConfigList(), (err, stats) => {// 處理和結(jié)果處理...// })// 增加導(dǎo)出語句module.exports = createConfigList()
另外創(chuàng)建一個(gè)文件:
// run.jsconst run = require('parallel-webpack').runconst configPath = require.resolve('./config.js')run(configPath, {watch: false,maxRetries: 1,stats: true})
執(zhí)行node ./bin/run.js即可執(zhí)行,我簡單計(jì)時(shí)了一下,節(jié)省了大約一半的時(shí)間。
組件文檔配置
組件文檔工具使用的是VuePress,如果跟我一樣遇到了webpack版本沖突問題,可以選擇在./docs目錄下單獨(dú)安裝:
cd ./docsnpm initnpm install -D vuepress
vuepress的基本配置很簡單,使用默認(rèn)主題按照教程配置即可,這里就不細(xì)說了,只說一下如何在文檔里使用packages里的組件,先看一下當(dāng)前目錄結(jié)構(gòu):

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


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

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

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

可以看到使用AST進(jìn)行簡單的操作并不難,關(guān)鍵是要細(xì)心及耐心,找對節(jié)點(diǎn)。另外對config.js的修改也是大同小異,有興趣的可以直接看源碼。
到這里我們只要使用npm run add命令就可以自動化的創(chuàng)建一個(gè)新組件,可以直接進(jìn)行組件開發(fā)了~
結(jié)尾
本文是筆者在改造自己組件庫的一些過程記錄,因?yàn)槭堑谝淮螌?shí)踐,難免會有錯(cuò)誤或不合理的地方,歡迎指出,感謝閱讀,再會~
示例代碼倉庫:https://github.com/wanglin2/vue_components。
學(xué)習(xí)更多技能
請點(diǎn)擊下方公眾號
![]()

