天天看點

手把手教你從零配置一個vue元件庫

手把手教你從零配置一個vue元件庫

簡介

本文會從零開始配置一個monorepo類型的元件庫,包括規範化配置、打包配置、元件庫文檔配置及開發一些提升效率的腳本等,monorepo 不熟悉的話這裡一句話介紹一下,就是在一個git倉庫裡包含多個獨立釋出的子產品/包。

PS:本文涉及到的工具配置其實在平時開發中一般都不需要自己配置,我們使用

的各種腳手架都幫我們搞定了,但是我們至少得大概知道都是什麼意思以及為什麼,說來慚愧,筆者作為一個三四年工齡的前端老人,基本沒有自己動手配過,甚至沒有去了解過,是以以下大部分工具都是筆者第一次使用,除了介紹如何配置也會講到遇到的一些坑及解決方法,另外也會盡量去搞清楚每一個參數的意思及原理,有興趣的請繼續閱讀吧~

使用lerna管理項目

首先每個元件都是一個獨立的npm包,但是某個元件可能又依賴了另一個元件,這樣如果這個元件有bug修改完後釋出了新版本,需要手動到依賴它的元件裡挨個進行更新再進行釋出,這是一個繁瑣且效率不高的過程,是以可以使用leran工具來進行管理,lerna是一個專門用于管理帶有多個包的JavaScript項目的工具,可以幫助進行npm釋出及git上傳。

首先全局安裝lerna:

npm i -g lerna      

然後進入倉庫目錄執行:

lerna init      

這個指令用來建立一個新的lerna倉庫或者更新一個現有倉庫的lerna版本,lerna有兩種使用模式:

1、固定模式,預設固定模式下所有包的主版本号和次版本都會使用lerna.json配置裡的version字段定義的版本号,如果某一次隻修改了其中一個或幾個包,但修改了配置檔案裡的主版本号或次版本号,那麼釋出時所有的包都會統一更新到該版本并進行釋出,單個的包如果想要釋出隻能修改修訂版本号進行釋出;

2、獨立模式就是每個包使用獨立的版本号。

自動生成的目錄如下:

手把手教你從零配置一個vue元件庫

可以看到沒有.gitignore檔案,是以手動建立一下,目前隻需要忽略node_modules目錄。

我們所有的包都會放在packages檔案夾下,添加新包可以使用lerna create xxx指令(後面會通過腳本來生成),元件庫推薦給包名增加一個統一的作用域scope,可以避免命名沖突,比如常見的@vue/xxx、@babel/xxx等,npm從2.0版本開始支援釋出帶作用域的包,預設的作用域是你的npm使用者名,比如:@username/package-name,也可以使用npm config set @scope-name:registry http://reg.example.com 來給你使用的npm倉庫關聯一個作用域。

給包添加依賴可以使用lerna add module-1 --scope=module-2指令,表示将module-1安裝到module-2的依賴裡,learn檢查到如果依賴的包是本項目中的會直接連結過去:

手把手教你從零配置一個vue元件庫

可以看到有個連結标志,lerna add預設也會執行lerna bootstrap的操作,即給所有的包安裝依賴項。

當修改完成後需要釋出時可以使用lerna publish指令,該指令會完成子產品的釋出及git上傳工作,有個需要注意的點是帶作用域的包使用npm釋出時需要添加--access public參數,但是lerna publish不支援該參數,一個解決方法是在所有包的package.json檔案裡添加:

{
    // ...
    "publishConfig": {
        "access": "publish" 
    }
}      
手把手教你從零配置一個vue元件庫

規範化配置

eslint

eslint是一個配置化的JavaScript代碼檢查工具,通過該工具可以限制代碼風格,以及檢測一些潛在錯誤,做到在不同的開發者下能有一個統一風格的代碼,常見的比如是否允許使用==、語句結尾是否去掉;等等,eslint的規則非常多,可以在這裡檢視https://eslint.bootcss.com/docs/rules/ 。

eslint的所有規則都可單獨配置是否開啟,并且預設都是禁用的,是以如果要自己來挨個配置是比較麻煩的,但是它有個繼承的配置,可以很友善的使用别人的配置,先來安裝一下:

npm i eslint --save-dev      

然後在package.json檔案裡加一個指令:

{
    "scripts": {
    "lint:init": "eslint --init"
    }
}      

之後在指令行輸入npm run lint:init 來建立一個eslint配置檔案,根據你的情況回答完一些問題後就會生成一個預設配置,我生成的内容如下:

手把手教你從零配置一個vue元件庫

簡單看一下各個字段的意思:

  • env字段用來指定你代碼所要運作的環境,比如是在浏覽器環境下,還是node環境下,不同的環境下所對應的全局變量不一樣,因為後續還要寫node腳本,是以把node:true也加上;
  • parserOptions表示所支援的語言選項,比如JavaScript的版本、是否啟用JSX等,設定正确的語言選項可以讓eslint确定什麼是解析錯誤;
  • plugins顧名思義是插件清單,比如你使用的是react,那麼需要使用react的插件來支援react的文法,因為我用的是vue,是以使用了vue的插件,可以用來檢測單檔案的文法問題,插件的命名規則為eslint-plugin-xxxx,配置時字首可以省略;
  • rules就是規則配置清單,可以單獨配置某個規則啟用與否;
  • extends就是上文所說的繼承,這裡使用了官方推薦的配置以及vue插件順帶提供的配置,配置命名一般為eslint-config-xxx,使用時字首也可以省略,并且插件也可以順帶提供配置功能,引入規則一般為plugin:plugin-name/xxx,此外也可以選擇使用其他一些比較出名的配置如eslint-config-airbnb;

和.gitignore一樣,eslint也可以建立一個忽略配置檔案.eslintignore,每一行都是一個glob模式來表示哪些路徑要忽略:

node_modules
docs
dist
assets      

接下來再去package.json檔案裡加上運作檢查的指令:

"scripts": {
    "lint": "eslint ./ --fix"
}      

意思是檢查目前目錄下的所有檔案,--fix表示允許eslint進行修複,但是能修自動複的問題很少,執行npm run lint,結果如下:

手把手教你從零配置一個vue元件庫

husky

目前隻能手動去運作eslint檢查,就算能限制自己每次送出代碼前檢查一下,也不一定能限制到其他人,沒有強制的規範和沒有規範沒啥差別,是以最好在git送出前采取強制措施,這可以使用Husky,這個工具可以友善的讓我們在執行某個git指令前先執行特定的指令,我們的需求是在git commit之前進行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:

手把手教你從零配置一個vue元件庫

發現目前的這些鈎子檔案後面還是帶着sample字尾,如果想要某個鈎子生效,這個字尾要去掉才行,但是這種操作顯然不應該讓我手動來幹,那麼隻能重裝husky試試,經過簡單的測試,我發現v5.x版本也是不行的,但是v3.0.0及v1.1.1兩個版本是生效的(筆者系統是windows10,可能和筆者電腦環境有關):

手把手教你從零配置一個vue元件庫
手把手教你從零配置一個vue元件庫

這樣如果檢查到有錯誤就會終止commit操作,不過目前一般還會使用另外一個包lint-staged,這個包顧名思義,隻檢查staged狀态下的檔案,其他本次送出沒有變動的檔案就不用檢查了,這是合理的也能提高檢查速度,先安裝:npm i lint-staged --save-dev,然後去package.json裡配置一下:

{
    "husky": {
        "hooks": {
            "pre-commit": "lint-staged"
        }
    },
    "lint-staged": {
        "*.{js,vue}": [
            "eslint --fix"
        ]
    }
}      

首先git鈎子執行的指令改成lint-staged,lint-staged字段的值是個對象,對象的key也是glob比對模式,value可以是字元串或字元串數組,每個字元串代表一個可執行的指令。

如果lint-staged發現目前存在staged狀态的檔案會進行比對,如果某個規則比對到了檔案那麼就會執行這個規則對應的指令,在執行指令的時候會把比對到的檔案作為參數清單傳給此指令。

比如:exlint --fix xxx.js xxx.vue ...,是以上面配置的意思就是如果在已暫存的檔案裡比對到了js或vue檔案就執行eslint --fix xxx.js ... ,為啥指令不直接寫npm run lint呢,因為lint指令裡我們配置了./路徑,那麼仍将會檢查所有檔案。

執行效果如下,在上文的截圖中可以看到一共有14個錯誤,但是本次我隻修改了一個檔案,是以隻檢查了這一個檔案:

手把手教你從零配置一個vue元件庫

stylelint

stylelint和eslint十分相似,隻不過是用來檢查css文法的,除了css檔案,同時也支援scss、less等css預處理語言,stylelint可能沒eslint那麼流行,不過本着學習的目的,咱們也嘗試一下,畢竟元件庫肯定少不了寫樣式,依舊先安裝:npm i stylelint stylelint-config-standard --save-dev,stylelint-config-standard是推薦的配置檔案,和eslint-config-xxx一樣,也可以拿來繼承,不喜歡這個規則也可以換其他的,接着建立一個配置檔案.stylelintrc,輸入以下内容:

{
  "extends": "stylelint-config-standard"
}      

建立一個忽略配置檔案.stylelintignore,輸入:

node_modules      

最後在package.json中添加一行指令:

{
  "scripts": {
        "style:lint": "stylelint packages/**/*.{css,less} --fix"
    }
}      

檢查packages目錄下所有以css或less結尾的檔案,并且可以的話自動進行修複,執行指令效果如下:

手把手教你從零配置一個vue元件庫

最後的最後和eslint一樣,在git commit之前也加上自動進行檢查,package.json檔案修改如下:

{
    "lint-staged": {
        "*.{css,less}": [
            "stylelint --fix"
        ]
    }
}      

commitlint

commit的内容對于了解一次送出做了什麼來說是很重要的,git commit内容的标準格式其實是包含三部分的:Header、Body、Footer,其中Header部分是必填的。

但是說實話對于我來說Header部分都懶得認真寫,更不用說其他幾部分了,是以靠自覺不行還是上工具吧,讓我們在git的commit-msg鈎子上加上對commit内容的檢查功能,不符合規則就打回重寫,安裝一下校驗工具commitlint:

npm i --save-dev @commitlint/config-conventional @commitlint/cli      

同樣也是一個工具,一個配置,通過繼承的方式來使用,嚴重懷疑這些工具的開發者都是同一批人,接下來建立一個配置檔案commitlint.config.js,輸入如下内容:

module.exports = {
    extends: ['@commitlint/config-conventional']
}      

當然你也可以再單獨配置你需要的規則,然後去package.json的husky部配置設定置鈎子:

{
    "husky": {
        "hooks": {
            "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
        }
    }
}      

commitlint指令需要有輸入參數,也就是我們輸入的commit message,-E參數的意思如下:

手把手教你從零配置一個vue元件庫

大意就是從環境變量裡給定的檔案裡擷取輸入内容,這個環境變量看名字就知道是husky提供的,具體它是啥呢,咱們來簡單看一下。

首先打開.git/hooks/commit-msg檔案,這個就是commit-msg鈎子執行的bash腳本:

手把手教你從零配置一個vue元件庫

可以看到最後執行了run.js,參數分别為hookName及gitParams,baseName "$0"代表目前執行的腳本名稱,也就是檔案名commit-msg,"$*"代表所有的參數,run.js裡又輾轉反側的最後調用了一個run方法:

function run([, scriptPath, hookName = '', HUSKY_GIT_PARAMS], getStdinFn = get_stdin_1.default) {
    console.log('攔截', scriptPath, hookName, HUSKY_GIT_PARAMS)


    // ...
}      

我們列印看一下參數都是啥:

手把手教你從零配置一個vue元件庫

可以看到HUSKY_GIT_PARAMS就是一個檔案路徑,這個檔案裡儲存着我們這次輸入的commit message的内容,接着husky會把它設定到環境變量裡:

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;
}      

現在再看commitlint -E HUSKY_GIT_PARAMS就很容易了解了,commitlint會去讀取.git/COMMIT_EDITMSG檔案内容來檢查我們輸入的commit message是否符合規範。

手把手教你從零配置一個vue元件庫

可以看到我們隻輸入了一個1的話就報錯了。

commitizen

上面提到一個标準的commit message是包含三部分的,詳細看就是這樣的:

<type>(<scope>): <subject>
空行
<body>
空行
<footer>      

當你輸入git commit時,就會出現一個指令行編輯器讓你來輸入,但是這個編輯器很不好用,沒用過的話怎麼儲存都是個問題,是以可以使用commitizen來進行互動式的輸入,依次執行下列指令:

npm install commitizen -g


commitizen init cz-conventional-changelog --save-dev --save-exact      

執行完後應該會自動在你的package.json檔案裡加上下列配置:

{
    "config": {
        "commitizen": {
            "path": "./node_modules/cz-conventional-changelog"
        }
    }
}      

然後你就可以使用git cz指令來代替git commit指令了,它會給你一些選項,以及詢問你一些問題,如實輸入即可:

手把手教你從零配置一個vue元件庫

但是這樣git commit指令仍然是可用的,文檔上說可以進行如下配置來将git commit轉換為git cz:

{
    "husky": {
        "hooks": {
            "prepare-commit-msg": "exec < /dev/tty && git cz --hook || true",
        }
    }
}      

但是我嘗試了不行,報系統找不到指定的路徑。的錯誤,沒找到原因和解決方法,如果你知道如何解決的話評論區見吧~強制不了,那隻能加一句卑微的提示了:

{
    "husky": {
        "hooks": {
            "prepare-commit-msg": "echo ----------------please use [git cz] command instead of [git commit]----------------"
        }
    }
}      
手把手教你從零配置一個vue元件庫

規範化的暫且就配置這麼多,其他的比如代碼美化可以使用prettier、生成送出日志的可以使用conventional-changelog或standard-version,有需要的可以自行嘗試。

打包配置

目前每個元件的結構都是類似下面這樣的:

手把手教你從零配置一個vue元件庫

index.js傳回一個帶install方法的對象,作為vue的插件,使用這個元件的方式如下:

import ModuleX from 'module-x'
Vue.use(ModuleX)      

元件庫其實直接這麼釋出就可以了,如果js檔案裡使用了最新的文法,那麼需要在使用該元件的項目裡的vue.config.js裡添加一下如下配置:

{
    transpileDependencies: [
        'module-x'
    ]
}      

因為預設情況下 babel-loader 會忽略所有 node_modules 中的檔案,添加這個配置可以讓Babel 顯式轉譯這個依賴。

不過如果你硬想要打包後再進行釋出也是可以的,我們增加一下打包的配置。

先安裝一下相關的工具:

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      

因為比較多,就不挨個介紹了,應該還是比較清晰的,分别是用來解析樣式檔案、vue單檔案、js檔案及其他檔案,可以根據你的實際情況增減。

先說一下打包目标,分别給每個包進行打包,打包結果輸出到各自檔案夾的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')


// 擷取指令行參數,用來打包指定的包,否則打包packages目錄下的所有包
const args = process.argv.slice(2)


// 生成webpack配置
const createConfigList = () => {
    const pkgPath = path.join(__dirname, '../', 'packages')
    // 根據是否傳入了參數來判斷要打的包
    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預設打包生成的代碼是包含const、let、箭頭函數等es6文法的,是以需要設定一下生成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預設使用es module的方式引入圖檔,最終生成的連結是個對象,是以如果是通過require方式引入圖檔就通路不了,可以通過該配置關掉
                        }
                    }
                ]
            },
            plugins: [
                new VueLoaderPlugin(),
                new CleanWebpackPlugin()
            ]
        }
    })
}


// 開始打包
webpack(createConfigList(), (err, stats) => {
    // 處理和結果處理...
})      

然後運作指令node ./bin/buildModule.js 即可打所有的包,或者node ./bin/buildModule.js xxx xxx2 ...來打你指定的包。

當然,這隻是最簡單的配置,實際上肯定還會遇到很多特定問題,比如:

  • 如果依賴了其他基礎元件庫的話會比較麻煩,推薦這種情況就不要打包了,直接源碼釋出;
  • 尋找檔案時缺少vue擴充名,那麼需要配置一下webpack的resolve.extensions;
  • 使用了某些比較新的JavaScript文法或者用到jsx等,那麼需要配置一下對應的babel插件或預設;
  • 引用了vue、jquery等外部庫,不可能直接打包進去,是以需要配置一下webpack的externals;
  • 某個包可能有多個入口,換句話說也就是個别的包可能有特定的配置,那麼可以在該包下面添加一個配置檔案,然後上述生成配置的代碼裡可以讀取該檔案進行配置合并;

這些問題解決都不難,看一下報的錯然後去搜尋一下基本很容易就能解決,有興趣的話也可以去本文的源碼檢視。

接下來做個小優化,因為webpack打包不是同時進行的,是以包的數量多了的話總時間就很慢,可以使用parallel-webpack這個插件來讓它并行打包:

npm i parallel-webpack -D      

因為它的api使用的是配置的檔案路徑,不能直接傳遞對象類型,是以需要修改一下上述的代碼,改成導出一個配置的方式:

// 檔案名改成config.js


// ...


// 删除
// webpack(createConfigList(), (err, stats) => {
    // 處理和結果處理...
// })


// 增加導出語句
module.exports = createConfigList()      

另外建立一個檔案:

// run.js


const run = require('parallel-webpack').run
const configPath = require.resolve('./config.js')


run(configPath, {
    watch: false,
    maxRetries: 1,
    stats: true
})      

執行node ./bin/run.js即可執行,我簡單計時了一下,節省了大約一半的時間。

元件文檔配置

元件文檔工具使用的是VuePress,如果跟我一樣遇到了webpack版本沖突問題,可以選擇在./docs目錄下單獨安裝:

cd ./docs
npm init
npm install -D vuepress      

vuepress的基本配置很簡單,使用預設主題按照教程配置即可,這裡就不細說了,隻說一下如何在文檔裡使用packages裡的元件,先看一下目前目錄結構:

手把手教你從零配置一個vue元件庫

config.js檔案是vuepress的預設配置檔案,打包選項、導航欄、側邊欄等等都在這裡配置,enhanceApp是用戶端應用的增強,在這裡可以擷取到vue執行個體,可以做一些應用啟動的工作,比如注冊元件等。

zh/rate是我添加的一個元件的文檔,文檔及示例内容都在檔案夾下的README.md檔案裡,vuepress對markdown做了擴充,是以在markdown檔案裡可以使用像vue單檔案一樣包含template、script、style三個塊,友善在文檔裡進行示例開發,元件需要先在enhanceApp.js檔案裡進行導入及注冊,那麼問題來了,我們是導入開發中的還是打包後的呢,小朋友才做選擇,成年人都要,比如開發階段我們就導入開發中的,開發完成了就導入打包後的,差別隻是在于package.json裡的main入口字段指向不同而已,比如我們先指向開發中的:

// package.json


{
  "main": "index.js"
}      

接下來去enhanceApp.js裡導入及注冊:

import Rate from '@zf/rate'


export default ({
    Vue
}) => {
    Vue.use(Rate)
}      

如果直接這樣的話預設是會報錯的,因為找不到這個包,此時我們的包也還沒釋出,是以也不能直接安裝,那怎麼辦呢,辦法應該有好幾個,比如可以使用npm link來将包連結到這裡。

但是這樣太麻煩,是以我選擇修改一下vuepress的webpack配置,讓它尋找包的時候順便去找packages目錄下找,另外也需要給@zf設定一下别名,顯然我們的目錄裡并沒有@zf,修改webpack的配置需要在config.js檔案裡操作:

const path = require('path')


module.exports = {
    chainWebpack: (config) => {
        // 我們包存放的位置
        const pkgPath = path.resolve(__dirname, '../../../', 'packages')
        // 修改webpack的resolve.modules配置,解析子產品時應該搜尋的目錄,先去packages,再去node_modules
        config.resolve
            .modules
            .add(pkgPath)
            .add('node_modules')
        // 修改别名resolve.alias配置
        config.resolve
            .alias
            .set('@zf', pkgPath)
    }
}      

這樣在vuepress裡就可以正常使用我們的元件了,當你開發完成後就可以把這個包package.json的入口字段改成打包後的目錄:

// package.json


{
  "main": "dist/index.js"
}      

其他基本資訊、導航欄、側邊欄等可以根據你的需求進行配置,效果如下:

手把手教你從零配置一個vue元件庫
手把手教你從零配置一個vue元件庫

使用腳本新增元件

現在讓我們來看一下新增一個元件都有哪些步驟:

1、給要新增的元件取個名字,然後使用npm search xxx來檢查一下是否已存在,存在就換個名字;

2、在packages目錄下建立檔案夾,建立幾個基本檔案,通常來說是複制粘貼其他元件然後修改;

3、在docs目錄下建立文檔檔案夾,建立README.md檔案,檔案内容一般也是通過複制粘貼;

4、修改config.js進行側邊欄配置(如果配置了側邊欄的話)、修改enhanceApp.js導入及注冊元件;

這一套步驟下來雖然不難,但是繁瑣,很容易漏掉某一步,上述這些事情其實特别适合讓腳本來幹,接下來就實作一下。

初始化工作

先在./bin目錄下建立一個add.js檔案,這個就是咱們要執行的腳本,首先它肯定要接收一些參數,簡單起見這裡隻需要輸入一個元件名,但是為了後續擴充友善,我們使用inquirer來處理指令行輸入,接收到輸入的元件名稱後自動進行一下是否已存在的校驗:

// add.js
const {
    exec
} = require('child_process')
const inquirer = require('inquirer')
const ora = require('ora')// ora是一個指令行loading工具
const scope = '@zf/'// 包的作用域,如果你的包沒有作用域,那麼則不需要


inquirer
    .prompt([{
            type: 'input',
            name: 'name',
            message: '請輸入元件名稱',
            validate(input) {
                // 異步驗證需要調用這個方法來告訴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 => {
      // 指令行輸入完成,進行其他操作
        console.log(answers)
    })
    .catch(error => {
        // 錯誤處理
    });      

執行後效果如下:

手把手教你從零配置一個vue元件庫

使用模闆建立

接下來在packages目錄下自動生成檔案夾及檔案,在【打包配置】一節中可以看到一個基本的包一共有四個檔案:index.js、package.json、index.vue以及style.less,首先在./bin目錄下建立一個template檔案夾。

然後再建立這四個檔案,基本内容可以先複制粘貼進去,其中index.js和style.less的内容不需要修改,是以直接複制到新元件的目錄下即可:

// add.js


const upperCamelCase = require('uppercamelcase')// 字元串-風格的轉駝峰
const fs = require('fs-extra')


const templateDir = path.join(__dirname, 'template')// 模闆路徑


// 這個方法在上述inquirer的then方法裡調用,參數為指令行輸入的資訊
const create = ({
    name
}) => {
    // 元件目錄
    const destDir = path.join(__dirname, '../', 'packages', name)
    const srcDir = path.join(destDir, 'src')
    // 建立目錄
    fs.ensureDirSync(destDir)
    fs.ensureDirSync(srcDir)
    // 複制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内容的部分資訊需要動态注入,比如index.vue的元件名、package.json的包名,我們可以使用一個很簡單的庫json-templater來以雙大括号插值的方法來注入資料,以package.json為例:

// ./bin/template/package.json
{
    "name": "{{name}}",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {},
    "author": "",
    "license": "ISC"
}      

name是我們要注入的資料,接下來讀取模闆的内容,然後注入并渲染,最後建立檔案:

// add.js


const upperCamelCase = require('uppercamelcase')// 字元串-風格的轉駝峰
const render = require('json-templater/string')


// 渲染模闆及建立檔案
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
}) => {
    // 元件目錄
    // ...
    // 建立package.json
    renderTemplateAndCreate('package.json', {
        name: scope + name
    }, destDir)
    // index.vue
    renderTemplateAndCreate('index.vue', {
        name: upperCamelCase(name)
    }, srcDir)
}      

到這裡元件的目錄及檔案就建立完成了,文檔的目錄及檔案也是一樣,這裡就不貼代碼了。

使用AST修改

最後要修改的兩個檔案是config.js和enhanceApp.js,這兩個檔案雖然也可以向上面一樣使用模闆注入的方式,但是考慮到這兩個檔案修改的頻率可能比較頻繁,是以每次都得去模闆裡修改不太友善,是以我們換一種方式,使用AST,這樣就不需要模闆的占位符了。

先看enhanceApp.js,每增加一個元件,我們都需要在這裡導入和注冊:

import Rate from '@zf/rate'


export default ({
    Vue
}) => {
    Vue.use(Rate)
    console.log(1)
}      

思路很簡單,把這個檔案的源代碼先轉換成AST,然後在最後一個import語句後面插入新元件的導入語句,以及在最後一條Vue.use語句和console.log語句之間插入新元件的注冊語句,最後再轉換回源碼寫回到這個檔案裡,AST相關的操作可以使用babel的工具包:@babel/parser、@babel/traverse、@babel/generator、@babel/types。

@babel/parser

把源代碼轉換成AST很簡單:

// add.js
const parse = require('@babel/parser').parse


// 更新enhanceApp.js
const updateEnhanceApp = ({
    name
}) => {
    // 讀取檔案内容
    const filePath = path.join(__dirname, '../', 'docs', 'docs', '.vuepress', 'enhanceApp.js')
    const code = fs.readFileSync(filePath, {
        encoding: 'utf-8'
    })
    // 轉換成AST
    const ast = parse(code, {
        sourceType: "module"// 因為用到了`import`文法,是以指明把代碼解析成module模式
    })
    console.log(ast)
}      

生成的資料很多,是以指令行一般都顯示不下去,可以去https://astexplorer.net/這個網站上檢視,選擇@babel/parser的解析器即可。

手把手教你從零配置一個vue元件庫

@babel/traverse

得到了AST樹之後就需要修改這顆樹,@babel/traverse用來周遊和修改樹節點,這是整個過程中相對麻煩的一個步驟,如果不熟悉AST的基礎知識和操作的話推薦先閱讀一下這篇文檔babel-handbook。

接下來我們對着上面解析的截圖來寫一下添加import語句的代碼:

// add.js
const traverse = require('@babel/traverse').default
const t = require("@babel/types")// 這個包是一個工具包,用來檢測某個節點的類型、建立新節點等


const updateEnhanceApp = ({
    name
}) => {
    // ...


    // traverse的第一個參數是ast對象,第二個是一個通路器,當周遊到某種類型的節點後會調用對應的函數
    traverse(ast, {
        // 周遊到了Program節點會執行該函數
        // 函數的第一個參數并不是節點本身,而是代表節點的路徑,路徑上會包含該節點和其他節點之間的關系資訊,後續的一些操作也都是在路徑上進行,如果要通路節點本身,可以通路path.node
        Program(nodePath) {
            let bodyNodesList = nodePath.node.body // 通過上圖可以看到是個數組
            // 周遊節點找到最後一個import節點
            let lastImportIndex = -1
            for (let i = 0; i < bodyNodesList.length; i++) {
                if (t.isImportDeclaration(bodyNodesList[i])) {
                    lastImportIndex = i
                }
            }
            // 建構即将要插入的import語句的AST節點:import name from @zf/name
            // 節點類型及需要的參數可以在這裡檢視:https://babeljs.io/docs/en/babel-types
            // 如果不确定使用哪個類型的話可以在上述的https://astexplorer.net/網站上看一下某個語句對應的是什麼
            const newImportNode = t.importDeclaration(
                [ t.ImportDefaultSpecifier(t.Identifier(upperCamelCase(name))) ], // name
                t.StringLiteral(scope + name)
            )
            // 目前沒有import節點,則在第一個節點之前插入import節點
            if (lastImportIndex === -1) {
                let firstPath = nodePath.get('body.0')// 擷取body的第一個節點的path
                firstPath.insertBefore(newImportNode)// 在該節點之前插入節點
            } else { // 目前存在import節點,則在最後一個import節點之後插入import節點
                let lastImportPath = nodePath.get(`body.${lastImportIndex}`)
                lastImportPath.insertAfter(newImportNode)
            }
        }
    });
}      

接下來看一下添加Vue.use的代碼,因為生成的AST樹太長了,是以不友善截圖,大家可以打開上面的網站輸入示例代碼後看生成的結構:

// add.js


// ...


traverse(ast, {
    Program(nodePath) {},


    // 周遊到ExportDefaultDeclaration節點
    ExportDefaultDeclaration(nodePath) {
        let bodyNodesList = nodePath.node.declaration.body.body // 找到箭頭函數節點的body,目前存在兩個表達式節點
        // 下面的邏輯和添加import語句的邏輯基本一緻,周遊節點找到最後一個vue.use類型的節點,然後添加一個新節點
        let lastIndex = -1
        for (let i = 0; i < bodyNodesList.length; i++) {
            let node = bodyNodesList[i]
            // 找到vue.use類型的節點
            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
            }
        }
        // 建構新節點:Vue.use(name)
        const newNode = t.expressionStatement(
            t.callExpression(
                t.memberExpression(
                    t.identifier('Vue'),
                    t.identifier('use')
                ),
                [ t.identifier(upperCamelCase(name))]
            )
        )
        // 插入新節點
        if (lastIndex === -1) {
            if (bodyNodesList.length > 0) {
                let firstPath = nodePath.get('declaration.body.body.0')
                firstPath.insertBefore(newNode)
            } else {// body為空的話需要調用`body`節點的pushContainer方法追加節點
                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樹修改完成接下來就可以轉回源代碼了:

//  add.js
const generate = require('@babel/generator').default


const updateEnhanceApp = ({
    name
}) => {
    // ...


    // 生成源代碼
    const newCode = generate(ast)
}      

效果如下:

手把手教你從零配置一個vue元件庫

可以看到使用AST進行簡單的操作并不難,關鍵是要細心及耐心,找對節點。另外對config.js的修改也是大同小異,有興趣的可以直接看源碼。

到這裡我們隻要使用npm run add指令就可以自動化的建立一個新元件,可以直接進行元件開發了~

結尾

本文是筆者在改造自己元件庫的一些過程記錄,因為是第一次實踐,難免會有錯誤或不合理的地方,歡迎指出,感謝閱讀,再會~

示例代碼倉庫:https://github.com/wanglin2/vue_components。

學習更多技能

請點選下方公衆号