簡介
本文會從零開始配置一個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、獨立模式就是每個包使用獨立的版本号。
自動生成的目錄如下:
可以看到沒有.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檢查到如果依賴的包是本項目中的會直接連結過去:
可以看到有個連結标志,lerna add預設也會執行lerna bootstrap的操作,即給所有的包安裝依賴項。
當修改完成後需要釋出時可以使用lerna publish指令,該指令會完成子產品的釋出及git上傳工作,有個需要注意的點是帶作用域的包使用npm釋出時需要添加--access public參數,但是lerna publish不支援該參數,一個解決方法是在所有包的package.json檔案裡添加:
{
// ...
"publishConfig": {
"access": "publish"
}
}
規範化配置
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配置檔案,根據你的情況回答完一些問題後就會生成一個預設配置,我生成的内容如下:
簡單看一下各個字段的意思:
- 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,結果如下:
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:
發現目前的這些鈎子檔案後面還是帶着sample字尾,如果想要某個鈎子生效,這個字尾要去掉才行,但是這種操作顯然不應該讓我手動來幹,那麼隻能重裝husky試試,經過簡單的測試,我發現v5.x版本也是不行的,但是v3.0.0及v1.1.1兩個版本是生效的(筆者系統是windows10,可能和筆者電腦環境有關):
這樣如果檢查到有錯誤就會終止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個錯誤,但是本次我隻修改了一個檔案,是以隻檢查了這一個檔案:
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結尾的檔案,并且可以的話自動進行修複,執行指令效果如下:
最後的最後和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參數的意思如下:
大意就是從環境變量裡給定的檔案裡擷取輸入内容,這個環境變量看名字就知道是husky提供的,具體它是啥呢,咱們來簡單看一下。
首先打開.git/hooks/commit-msg檔案,這個就是commit-msg鈎子執行的bash腳本:
可以看到最後執行了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)
// ...
}
我們列印看一下參數都是啥:
可以看到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是否符合規範。
可以看到我們隻輸入了一個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指令了,它會給你一些選項,以及詢問你一些問題,如實輸入即可:
但是這樣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]----------------"
}
}
}
規範化的暫且就配置這麼多,其他的比如代碼美化可以使用prettier、生成送出日志的可以使用conventional-changelog或standard-version,有需要的可以自行嘗試。
打包配置
目前每個元件的結構都是類似下面這樣的:
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裡的元件,先看一下目前目錄結構:
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"
}
其他基本資訊、導航欄、側邊欄等可以根據你的需求進行配置,效果如下:
使用腳本新增元件
現在讓我們來看一下新增一個元件都有哪些步驟:
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 => {
// 錯誤處理
});
執行後效果如下:
使用模闆建立
接下來在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的解析器即可。
@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)
}
效果如下:
可以看到使用AST進行簡單的操作并不難,關鍵是要細心及耐心,找對節點。另外對config.js的修改也是大同小異,有興趣的可以直接看源碼。
到這裡我們隻要使用npm run add指令就可以自動化的建立一個新元件,可以直接進行元件開發了~
結尾
本文是筆者在改造自己元件庫的一些過程記錄,因為是第一次實踐,難免會有錯誤或不合理的地方,歡迎指出,感謝閱讀,再會~
示例代碼倉庫:https://github.com/wanglin2/vue_components。
學習更多技能
請點選下方公衆号