由于篇幅有限,陰影部分的內(nèi)容將在中/下篇介紹。
話不多說,直入主題。
【資料圖】
考慮到組件庫整體需要有多邊資源支持,比如組件源碼,庫文檔站點,color-gen等類庫工具,代碼規(guī)范配置,vite插件,腳手架,storybook等等,需要分出很多packages,package之間存在彼此聯(lián)系,因此考慮使用monorepo的管理方式,同時使用yarn作為包管理工具,lerna作為包發(fā)布工具。【相關(guān)推薦:vuejs視頻教程、web前端開發(fā)】
在monorepo之前,根目錄就是一個workspace,我們直接通過yarn add/remove/run等就可以對包進(jìn)行管理。但在monorepo項目中,根目錄下存在多個子包,yarn 命令無法直接操作子包,比如根目錄下無法通過yarn run dev啟動子包package-a中的dev命令,這時我們就需要開啟yarn的workspaces功能,每個子包對應(yīng)一個workspace,之后我們就可以通過yarn workspace package-a run dev
啟動package-a中的dev命令了。
你可能會想,我們直接cd到package-a下運行就可以了,不錯,但yarn workspaces的用武之地并不只此,像auto link,依賴提升,單.lock等才是它在monorepo中的價值所在。
我們在根目錄packge.json中啟用yarn workspaces:
{ "private": true, "workspaces": [ "packages/*" ]}
packages目錄下的每個直接子目錄作為一個workspace。由于我們的根項目是不需要發(fā)布出去的,因此設(shè)置private為true。
不得不說,yarn workspaces已經(jīng)具備了lerna部分功能,之所以使用它,是想借用它的發(fā)布工作流以彌補workspaces在monorepo下在這方面的不足。下面我們開始將lerna集成到項目中。
首先我們先安裝一下lerna:
# W指workspace-root,即在項目根目錄下安裝,下同yarn add lerna -D -W# 由于經(jīng)常使用lerna命令也推薦全局安裝yarn global add lernaornpm i lerna -g
執(zhí)行lerna init
初始化項目,成功之后會幫我們創(chuàng)建了一個lerna.json
文件
lerna init
// lerna.json{ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useWorkspaces": true, "version": "0.0.0"}
$schema
指向的lerna-schema.json描述了如何配置lerna.json,配置此字段后,鼠標(biāo)懸浮在屬性上會有對應(yīng)的描述。注意,以上的路徑值需要你在項目根目錄下安裝lerna。
useWorkspaces
定義了在lerna bootstrap
期間是否結(jié)合yarn workspace。
由于lerna默認(rèn)的工作模式是固定模式,即發(fā)布時每個包的版本號一致。這里我們修改為independent
獨立模式,同時將npm客戶端設(shè)置為yarn
。如果你喜歡pnpm
,just do it!
// lerna.json{ "version": "independent", "npmClient": "yarn"}
至此yarn workspaces
搭配lerna
的monorepo項目就配置好了,非常簡單!
By the way!由于項目會使用commitlint
對提交信息進(jìn)行校驗是否符合Argular規(guī)范,而lerna version
默認(rèn)為我們commit的信息是"Publish",因此我們需要進(jìn)行一些額外的配置。
// lerna.json{ "command": { "version": { "message": "chore(release): publish", "conventionalCommits": true } }}
可以看到,我們使用符合Argular團(tuán)隊提交規(guī)范的"chore(release): publish"
代替默認(rèn)的"Publish"。
conventionalCommits
表示當(dāng)我們運行lerna version
,實際上會運行lerna version --conventional-commits
幫助我們生成CHANGELOG.md。
在lerna剛發(fā)布的時候,那時的包管理工具還沒有可用的workspaces
解決方案,因此lerna自身實現(xiàn)了一套解決方案。時至今日,現(xiàn)代的包管理工具幾乎都內(nèi)置了workspaces
功能,這使得lerna和yarn有許多功能重疊,比如執(zhí)行包pkg-a的dev命令lerna run dev --stream --scope=pkg-a
,我們完全可以使用yarn workspace pkg-a run dev
代替。lerna bootstrap --hoist將安裝包提升到根目錄,而在yarn workspaces中直接運行yarn就可以了。
Anyway, 使用yarn
作為軟件包管理工具,lerna
作為軟件包發(fā)布工具,是在monorepo
管理方式下一個不錯的實踐!
很無奈,我知道大部分人都不喜歡Lint,但對我而言,這是必須的。
packages目錄下創(chuàng)建名為@argo-design/eslint-config(非文件夾名)的package
cd argo-eslint-configyarn add eslintnpx eslint --init
注意這里沒有-D或者--save-dev。選擇如下:
安裝完成后手動將devDependencies
下的依賴拷貝到dependencies
中。或者你手動安裝這一系列依賴。
// argo-eslint-config/package.json{ scripts: { "lint:script": "npx eslint --ext .js,.jsx,.ts,.tsx --fix --quiet ./" }}
運行yarn lint:script
,將會自動修復(fù)代碼規(guī)范錯誤警告(如果可以的話)。
安裝VSCode Eslint插件并進(jìn)行如下配置,此時在你保存代碼時,也會自動修復(fù)代碼規(guī)范錯誤警告。
// settings.json{ "editor.defaultFormatter": "dbaeumer.vscode-eslint", "editor.codeActionsOnSave": { "source.fixAll.eslint": true }}
在argo-eslint-config
中新建包入口文件index.js,并將.eslintrc.js的內(nèi)容拷貝到index.js中
module.exports = { env: { browser: true, es2021: true, node: true }, extends: ["plugin:vue/vue3-essential", "standard-with-typescript"], overrides: [], parserOptions: { ecmaVersion: "latest", sourceType: "module" }, plugins: ["vue"], rules: {}}
確保package.json配置main
指向我們剛剛創(chuàng)建的index.js。
// argo-eslint-config/package.json{ "main": "index.js"}
根目錄package.json新增如下配置
// argo-eslint-config/package.json{ "devDependencies": { "@argo-design/eslint-config": "^1.0.0" }, "eslintConfig": { "root": true, "extends": [ "@argo-design" ] }}
最后運行yarn重新安裝依賴。
注意包命名與extends書寫規(guī)則;root表示根配置,對eslint配置文件冒泡查找到此為止。
接下來我們引入formatter工具prettier
。首先我們需要關(guān)閉eslint規(guī)則中那些與prettier沖突或者不必要的規(guī)則,最后由prettier
代為實現(xiàn)這些規(guī)則。前者我們通過eslint-config-prettier
實現(xiàn),后者借助插件eslint-plugin-prettier
實現(xiàn)。比如沖突規(guī)則尾逗號,eslint-config-prettier
幫我們屏蔽了與之沖突的eslint規(guī)則:
{ "comma-dangle": "off", "no-comma-dangle": "off", "@typescript-eslint/comma-dangle": "off", "vue/comma-dangle": "off",}
通過配置eslint規(guī)則"prettier/prettier": "error"
讓錯誤暴露出來,這些錯誤交給eslint-plugin-prettier
收拾。
prettier配置我們也新建一個package@argo-design/prettier-config
。
cd argo-prettier-configyarn add prettieryarn add eslint-config-prettier eslint-plugin-prettier
// argo-prettier-config/index.jsmodule.exports = { printWidth: 80, //一行的字符數(shù),如果超過會進(jìn)行換行,默認(rèn)為80 semi: false, // 行尾是否使用分號,默認(rèn)為true trailingComma: "none", // 是否使用尾逗號 bracketSpacing: true // 對象大括號直接是否有空格};
完整配置參考官網(wǎng) prettier配置
回到argo-eslint-config/index.js,只需新增如下一條配置即可
module.exports = { "extends": ["plugin:prettier/recommended"]};
plugin:prettier/recommended
指的eslint-plugin-prettier
package下的recommended.js。該擴(kuò)展已經(jīng)幫我們配置好了
{ "extends": ["eslint-config-prettier"], "plugins": ["eslint-plugin-prettier"], "rules": { "prettier/prettier": "error", "arrow-body-style": "off", "prefer-arrow-callback": "off" }}
根目錄package.json新增如下配置
{ "devDependencies": { "@argo-design/prettier-config": "^1.0.0" }, "prettier": "@argo-design/prettier-config"}
運行yarn重新安裝依賴。
// settings.json{ "editor.defaultFormatter": "esbenp.prettier-vscode"}
stylelint配置我們也新建一個package@argo-design/stylelint-config
。
cd argo-stylelint-configyarn add stylelint stylelint-prettier stylelint-config-prettier stylelint-order stylelint-config-rational-order postcss-html postcss-less# 單獨postcss8yarn add postcss@^8.0.0
對于結(jié)合prettier
這里不在贅述。
stylelint-order
允許我們自定義樣式屬性名稱順序。而stylelint-config-rational-order
為我們提供了一套合理的開箱即用的順序。
值得注意的是,stylelint14版本不在默認(rèn)支持less,sass等預(yù)處理語言。并且stylelint14依賴postcss8版本,可能需要單獨安裝,否則vscode 的stylellint擴(kuò)展可能提示報錯TypeError: this.getPosition is not a function at LessParser.inlineComment....
// argo-stylelint-config/index.jsmodule.exports = { plugins: [ "stylelint-prettier", ], extends: [ // "stylelint-config-standard", "stylelint-config-standard-vue", "stylelint-config-rational-order", "stylelint-prettier/recommended" ], rules: { "length-zero-no-unit": true, // 值為0不需要單位 "plugin/rational-order": [ true, { "border-in-box-model": true, // Border理應(yīng)作為盒子模型的一部分 默認(rèn)false "empty-line-between-groups": false // 組之間添加空行 默認(rèn)false } ] }, overrides: [ { files: ["*.html", "**/*.html"], customSyntax: "postcss-html" }, { files: ["**/*.{less,css}"], customSyntax: "postcss-less" } ]};
根目錄package.json新增如下配置
{ "devDependencies": { "@argo-design/stylelint-config": "^1.0.0" }, "stylelint": { "extends": [ "@argo-design/stylelint-config" ] }}
運行yarn重新安裝依賴。
VSCode安裝Stylelint擴(kuò)展并添加配置
// settings.json{ "editor.codeActionsOnSave": { "source.fixAll.eslint": true, "source.fixAll.stylelint": true }, "stylelint.validate": ["css", "less", "vue", "html"], "css.validate": false, "less.validate": false}
修改settings.json之后如不能及時生效,可以重啟一下vscode。如果你喜歡,可以將eslint,prettier,stylelint配置安裝到全局并集成到編輯器。
為防止一些非法的commit
或push
,我們借助git hooks
工具在對代碼提交前進(jìn)行 ESLint 與 Stylelint的校驗,如果校驗通過,則成功commit,否則取消commit。
# 在根目錄安裝huskyyarn add husky -D -W
npm pkg set scripts.prepare="husky install"npm run prepare# 添加pre-commit鉤子,在提交前運行代碼lintnpx husky add .husky/pre-commit "yarn lint"
至此,當(dāng)我們執(zhí)行git commit -m "xxx"
時就會先執(zhí)行l(wèi)int校驗我們的代碼,如果lint通過,成功commit,否則終止commit。具體的lint命令請自行添加。
現(xiàn)在,當(dāng)我們git commit時,會對整個工作區(qū)的代碼進(jìn)行l(wèi)int。當(dāng)工作區(qū)文件過多,lint的速度就會變慢,進(jìn)而影響開發(fā)體驗。實際上我們只需要對暫存區(qū)中的文件進(jìn)行l(wèi)int即可。下面我們引入·lint-staged
解決我們的問題。
在根目錄安裝lint-staged
yarn add lint-staged -D -W
在根目錄package.json
中添加如下的配置:
{ "lint-staged": { "*.{js,ts,jsx,tsx}": [ "eslint --fix", "prettier --write" ], "*.{less,css}": [ "stylelint --fix", "prettier --write" ], "**/*.vue": [ "eslint --fix", "stylelint --fix", "prettier --write" ] }}
在monorepo中,lint-staged
運行時,將始終向上查找并應(yīng)用最接近暫存文件的配置,因此我們可以在根目錄下的package.json中配置lint-staged。值得注意的是,每個glob匹配的數(shù)組中的命令是從左至右依次運行,和webpack的loder應(yīng)用機(jī)制不同!
最后,我們在.husky文件夾中找到pre-commit
,并將yarn lint
修改為npx --no-install lint-staged
。
#!/usr/bin/env sh. "$(dirname -- "$0")/_/husky.sh"npx --no-install lint-staged
至此,當(dāng)我們執(zhí)行git commit -m "xxx"
時,lint-staged
會如期運行幫我們校驗staged(暫存區(qū))中的代碼,避免了對工作區(qū)的全量檢查。
除了代碼規(guī)范檢查之后,Git 提交信息的規(guī)范也是不容忽視的一個環(huán)節(jié),規(guī)范精準(zhǔn)的 commit 信息能夠方便自己和他人追蹤項目和把控進(jìn)度。這里,我們使用大名鼎鼎的Angular團(tuán)隊提交規(guī)范
。
commit message 由 Header
、Body
、Footer
組成。其中Herder時必需的,Body和Footer可選。
Header 部分包括三個字段 type
、scope
和 subject
。
<type>(<scope>): <subject>
其中type 用于說明 commit 的提交類型(必須是以下幾種之一)。
值 | 描述 |
---|---|
feat | Feature) 新增一個功能 |
fix | Bug修復(fù) |
docs | Documentation) 文檔相關(guān) |
style | 代碼格式(不影響功能,例如空格、分號等格式修正),并非css樣式更改 |
refactor | 代碼重構(gòu) |
perf | Performent) 性能優(yōu)化 |
test | 測試相關(guān) |
build | 構(gòu)建相關(guān)(例如 scopes: webpack、gulp、npm 等) |
ci | 更改持續(xù)集成軟件的配置文件和 package 中的 scripts 命令,例如 scopes: Travis, Circle 等 |
chore | 變更構(gòu)建流程或輔助工具,日常事務(wù) |
revert | git revert |
scope 用于指定本次 commit 影響的范圍。
subject 是本次 commit 的簡潔描述,通常遵循以下幾個規(guī)范:
用動詞開頭,第一人稱現(xiàn)在時表述,例如:change 代替 changed 或 changes
第一個字母小寫
結(jié)尾不加句號.
body 是對本次 commit 的詳細(xì)描述,可以分成多行。跟 subject 類似。
如果本次提交的代碼是突破性的變更或關(guān)閉Issue,則 Footer 必需,否則可以省略。
我們可以借助工具幫我們生成規(guī)范的message。
yarn add commitizen -D -W
安裝適配器
yarn add cz-conventional-changelog -D -W
這行命令做了兩件事:
安裝cz-conventional-changelog
到開發(fā)依賴
在根目錄下的package.json中增加了:
"config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" }}
添加npm scriptscm
"scripts": { "cm": "cz"},
至此,執(zhí)行yarn cm
,就能看到交互界面了!跟著交互一步步操作就能自動生成規(guī)范的message了。
首先在根目錄安裝依賴:
yarn add commitlint @commitlint/cli @commitlint/config-conventional -D -W
接著新建.commitlintrc.js
:
module.exports = { extends: ["@commitlint/config-conventional"]};
最后向husky中添加commit-msg
鉤子,終端執(zhí)行:
npx husky add .husky/commit-msg "npx --no-install commitlint -e $HUSKY_GIT_PARAMS"
執(zhí)行成功之后就會在.husky文件夾中看到commit-msg文件了:
#!/usr/bin/env sh. "$(dirname -- "$0")/_/husky.sh"npx --no-install commitlint -e
至此,當(dāng)你提交代碼時,如果pre-commit
鉤子運行成功,緊接著在commit-msg
鉤子中,commitlint會如期運行對我們提交的message進(jìn)行校驗。
關(guān)于lint工具的集成到此就告一段落了,在實際開發(fā)中,我們還會對lint配置進(jìn)行一些小改動,比如ignore,相關(guān)rules等等。這些和具體項目有關(guān),我們不會變更package里的配置。
千萬別投機(jī)取巧拷貝別人的配置文件!復(fù)制一時爽,代碼火葬場。
巧婦難為無米之炊。組件庫通常依賴很多圖標(biāo),因此我們先開發(fā)一個支持按需引入的圖標(biāo)庫。
假設(shè)我們現(xiàn)在已經(jīng)拿到了一些漂亮的svg圖標(biāo),我們要做的就是將每一個圖標(biāo)轉(zhuǎn)化生成.vue組件與一個組件入口index.ts文件。然后再生成匯總所有組件的入口文件。比如我們現(xiàn)在有foo.svg與bar.svg兩個圖標(biāo),最終生成的文件及結(jié)構(gòu)如下:
相應(yīng)的內(nèi)容如下:
// bar.tsimport _Bar from "./bar.vue";const Bar = Object.assign(_Bar, { install: (app) => { app.component(_Bar.name, _Bar); }});export default Bar;
// foo.tsimport _Foo from "./foo.vue";const Foo = Object.assign(_Foo, { install: (app) => { app.component(_Foo.name, _Foo); }});export default Foo;
// argoIcon.tsimport Foo from "./foo";import Bar from "./bar";const icons = [Foo, Bar];const install = (app) => { for (const key of Object.keys(icons)) { app.use(icons[key]); }};const ArgoIcon = { ...icons, install};export default ArgoIcon;
// index.tsexport { default } from "./argoIcon";export { default as Foo } from "./foo";export { default as Bar } from "./bar";
之所以這么設(shè)計是由圖標(biāo)庫最終如何使用決定的,除此之外argoIcon.ts
也將會是打包umd
的入口文件。
// 全量引入import ArgoIcon from "圖標(biāo)庫";app.use(ArgoIcon); // 按需引入import { Foo } from "圖標(biāo)庫";app.use(Foo);
圖標(biāo)庫的整個構(gòu)建流程大概分為以下3步:
整個流程很簡單,我們通過glob匹配到.svg拿到所有svg的路徑,對于每一個路徑,我們讀取svg的原始文本信息交由第三方庫svgo處理,期間包括刪除無用代碼,壓縮,自定義屬性等,其中最重要的是為svg標(biāo)簽注入我們想要的自定義屬性,就像這樣:
<svg :class="cls" :style="innerStyle" :stroke-linecap="strokeLinecap" :stroke-linejoin="strokeLinejoin" :stroke-width="strokeWidth"> <path d="..."></path></svg>
之后這段svgHtml
會傳送給我們預(yù)先準(zhǔn)備好的摸板字符串:
const template = `<template> ${svgHtml}</template><script setup>defineProps({ "stroke-linecap": String; // ... }) // 省略邏輯代碼...</script>`
為摸板字符串填充數(shù)據(jù)后,通過fs模塊的writeFile生成我們想要的.vue文件。
在打包構(gòu)建方案上直接選擇vite為我們提供的lib模式即可,開箱即用,插件擴(kuò)展(后面會講到),基于rollup,能幫助我們打包生成ESM,這是按需引入的基礎(chǔ)。當(dāng)然,commonjs
與umd
也是少不了的。整個過程我們通過Vite 的JavaScript API
實現(xiàn):
import { build } from "vite";import fs from "fs-extra";const CWD = process.cwd();const ES_DIR = resolve(CWD, "es");const LIB_DIR = resolve(CWD, "lib");interface compileOptions { umd: boolean; target: "component" | "icon";}async function compileComponent({ umd = false, target = "component"}: compileOptions): Promise<void> { await fs.emptyDir(ES_DIR); await fs.emptyDir(LIB_DIR); const config = getModuleConfig(target); await build(config); if (umd) { await fs.emptyDir(DIST_DIR); const umdConfig = getUmdConfig(target); await build(umdConfig); }}
import { InlineConfig } from "vite";import glob from "glob";const langFiles = glob.sync("components/locale/lang/*.ts");export default function getModuleConfig(type: "component" | "icon"): InlineConfig { const entry = "components/index.ts"; const input = type === "component" ? [entry, ...langFiles] : entry; return { mode: "production", build: { emptyOutDir: true, minify: false, brotliSize: false, rollupOptions: { input, output: [ { format: "es", // 打包模式 dir: "es", // 產(chǎn)物存放路徑 entryFileNames: "[name].js", // 入口模塊的產(chǎn)物文件名 preserveModules: true, // 保留模塊結(jié)構(gòu),否則所有模塊都將打包在一個bundle文件中 /* * 保留模塊的根路徑,該值會在打包后的output.dir中被移除 * 我們的入口是components/index.ts,打包后文件結(jié)構(gòu)為:es/components/index.js * preserveModulesRoot設(shè)為"components",打包后就是:es/index.js */ preserveModulesRoot: "components" }, { format: "commonjs", dir: "lib", entryFileNames: "[name].js", preserveModules: true, preserveModulesRoot: "components", exports: "named" // 導(dǎo)出模式 } ] }, // 開啟lib模式 lib: { entry, formats: ["es", "cjs"] } }, plugins: [ // 自定義external忽略node_modules external(), // 打包聲明文件 dts({ outputDir: "es", entryRoot: C_DIR }) ] };};
export default function getUmdConfig(type: "component" | "icon"): InlineConfig { const entry = type === "component" ? "components/argo-components.ts" : "components/argo-icons.ts"; const entryFileName = type === "component" ? "argo" : "argo-icon"; const name = type === "component" ? "Argo" : "ArgoIcon"; return { mode: "production", build: { target: "modules", // 支持原生 ES 模塊的瀏覽器 outDir: "dist", // 打包產(chǎn)物存放路徑 emptyOutDir: true, // 如果outDir在根目錄下,則清空outDir sourcemap: true, // 生成sourcemap minify: false, // 是否壓縮 brotliSize: false, // 禁用 brotli 壓縮大小報告。 rollupOptions: { // rollup打包選項 external: "vue", // 匹配到的模塊不會被打包到bundle output: [ { format: "umd", // umd格式 entryFileNames: `${entryFileName}.js`, // 即bundle名 globals: { /* * format為umd/iife時,標(biāo)記外部依賴vue,打包后以Vue取代 * 未定義時打包結(jié)果如下 * var ArgoIcon = function(vue2) {}(vue); * rollup自動猜測是vue,但實際是Vue.這會導(dǎo)致報錯 * 定義后 * var ArgoIcon = function(vue) {}(Vue); */ vue: "Vue" } }, { format: "umd", entryFileNames: `${entryFileName}.min.js`, globals: { vue: "Vue" }, plugins: [terser()] // terser壓縮 }, ] }, // 開啟lib模式 lib: { entry, // 打包入口 name // 全局變量名 } }, plugins: [vue(), vueJsx()] };};
export const CWD = process.cwd();export const C_DIR = resolve(CWD, "components");
可以看到,我們通過type區(qū)分組件庫和圖標(biāo)庫打包。實際上打包圖標(biāo)庫和組件庫都是差不多的,組件庫需要額外打包國際化相關(guān)的語言包文件。圖標(biāo)樣式內(nèi)置在組件之中,因此也不需要額外打包。
我們直接通過第三方庫 vite-plugin-dts 打包圖標(biāo)庫的聲明文件。
import dts from "vite-plugin-dts";plugins: [ dts({ outputDir: "es", entryRoot: C_DIR })]
關(guān)于打包原理可參考插件作者的這片文章。
lequ7.com/guan-yu-qia…
我們都知道實現(xiàn)tree-shaking的一種方式是基于ESM的靜態(tài)性,即在編譯的時候就能摸清依賴之間的關(guān)系,對于"孤兒"會殘忍的移除。但是對于import "icon.css"
這種沒導(dǎo)入導(dǎo)出的模塊,打包工具并不知道它是否具有副作用,索性移除,這樣就導(dǎo)致頁面缺少樣式了。sideEffects就是npm與構(gòu)建工具聯(lián)合推出的一個字段,旨在幫助構(gòu)建工具更好的為npm包進(jìn)行tree-shaking。
使用上,sideEffects設(shè)置為false表示所有模塊都沒有副作用,也可以設(shè)置數(shù)組,每一項可以是具體的模塊名或Glob匹配。因此,實現(xiàn)圖標(biāo)庫的按需引入,只需要在argo-icons項目下的package.json里添加以下配置即可:
{ "sideEffects": false,}
這將告訴構(gòu)建工具,圖標(biāo)庫沒有任何副作用,一切沒有被引入的代碼或模塊都將被移除。前提是你使用的是ESM。
Last but important!當(dāng)圖標(biāo)庫在被作為npm包導(dǎo)入時,我們需要在package.json為其配置相應(yīng)的入口文件。
{ "main": "lib/index.js", // 以esm形式被引入時的入口 "module": "es/index.js", // 以commonjs形式被引入時的入口 "types": "es/index.d.ts" // 指定聲明文件}
顧名思義,storybook就是一本"書",講了很多個"故事"。在這里,"書"就是argo-icons,我為它講了3個故事:
基本使用
按需引入
使用iconfont.cn項目
新建@argo-design/ui-storybook
package,并在該目錄下運行:
npx storybook init -t vue3 -b webpack5
-t (即--type): 指定項目類型,storybook會根據(jù)項目依賴及配置文件等推算項目類型,但顯然我們僅僅是通過npm init新創(chuàng)建的項目,storybook無法自動判斷項目類型,故需要指定type為vue3,然后storybook會幫我們初始化storybook vue3 app。
-b (--builder): 指定構(gòu)建工具,默認(rèn)是webpack4,另外支持webpack5, vite。這里指定webpack5,否則后續(xù)會有類似報錯:cannot read property of undefine(reading "get")...因為storybook默認(rèn)以webpack4構(gòu)建,但是@storybook/vue3
依賴webpack5,會沖突導(dǎo)致報錯。這里是天坑!!
storybook默認(rèn)使用yarn安裝,如需指定npm請使用--use-npm。
這行命令主要幫我們做以下事情:
注入必要的依賴到packages.json(如若沒有指定-s,將幫我們自動安裝依賴)。
注入啟動,打包項目的腳本。
添加Storybook配置,詳見.storybook目錄。
添加Story范例文件以幫助我們上手,詳見stories目錄。
其中1,2步具體代碼如下:
{ "scripts": { "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook" }, "devDependencies": { "@storybook/vue3": "^6.5.13", "@storybook/addon-links": "^6.5.13", "@storybook/addon-essentials": "^6.5.13", "@storybook/addon-actions": "^6.5.13", "@storybook/addon-interactions": "^6.5.13", "@storybook/testing-library": "^0.0.13", "vue-loader": "^16.8.3", "@storybook/builder-webpack5": "^6.5.13", "@storybook/manager-webpack5": "^6.5.13", "@babel/core": "^7.19.6", "babel-loader": "^8.2.5" }}
接下來把目光放到.storybook下的main.js與preview.js
preview.js可以具名導(dǎo)出parameters,decorators,argTypes,用于全局配置UI(stories,界面,控件等)的渲染行為。比如默認(rèn)配置中的controls.matchers:
export const parameters = { controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } }};
它定義了如果屬性值是以background或color結(jié)尾,那么將為其啟用color控件,我們可以選擇或輸入顏色值,date同理。
除此之外你可以在這里引入全局樣式,注冊組件等等。更多詳情見官網(wǎng) Configure story rendering
最后來看看最重要的項目配置文件。
module.exports = { stories: [ "../stories/**/*.stories.mdx", "../stories/**/*.stories.@(js|jsx|ts|tsx)" ], addons: [ "@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-interactions" ], framework: "@storybook/vue3", core: { builder: "@storybook/builder-webpack5" },}
stories, 即查找stroy文件的Glob。
addons, 配置需要的擴(kuò)展。慶幸的是,當(dāng)前一些重要的擴(kuò)展都已經(jīng)集成到@storybook/addon-essentials。
framework和core即是我們初識化傳遞的-t vue3 -b webpack5
。
更多詳情見官網(wǎng) Configure your Storybook project
由于項目使用到less因此我們需要配置一下less,安裝less以及相關(guān)loader。來到.storybook/main.js
module.exports = { webpackFinal: (config) => { config.module.rules.push({ test: /.less$/, use: [ { loader: "style-loader" }, { loader: "css-loader" }, { loader: "less-loader", options: { lessOptions: { javascriptEnabled: true } } } ] }); return config; },}
storybook默認(rèn)支持解析jsx/tsx,但你如果需要使用jsx書寫vue3的stories,仍需要安裝相關(guān)插件。
在argo-ui-storybook下安裝 @vue/babel-plugin-jsx
yarn add @vue/babel-plugin-jsx -D
新建.babelrc
{ "plugins": ["@vue/babel-plugin-jsx"]}
關(guān)于如何書寫story,篇幅受限,請自行查閱范例文件或官網(wǎng)。
配置完后終端執(zhí)行yarn storybook
即可啟動我們的項目,辛苦的成果也將躍然紙上。
對于UI,在我們的組件庫逐漸豐富之后,將會自建一個獨具組件庫風(fēng)格的文檔站點,拭目以待。
在Vue2時代,組件跨層級通信方式可謂“百花齊放”,provide/inject就是其中一種。時至今日,在composition,es6,ts加持下,provide/inject可以更加大展身手。
在創(chuàng)建組件實例時,會在自身掛載一個provides對象,默認(rèn)指向父實例的provides。
const instance = { provides: parent ? parent.provides : Object.create(appContext.provides)}
appContext.provides即createApp創(chuàng)建的app的provides屬性,默認(rèn)是null
在自身需要為子組件供數(shù)據(jù)時,即調(diào)用provide()時,會創(chuàng)建一個新對象,該對象的原型指向父實例的provides,同時將provide提供的選項添加到新對象上,這個新對象就是實例新的provides值。代碼簡化就是
function provide(key, value) { const parentProvides = currentInstance.parent && currentInstance.parent.provides; const newObj = Object.create(parentProvides); currentInstance.provides = newObj; newObj[key] = value;}
而inject的實現(xiàn)原理則時通過key去查找祖先provides對應(yīng)的值:
function inject(key, defaultValue) { const instance = currentInstance; const provides = instance.parent == null ? instance.vnode.appContent && instance.vnode.appContent.provides :instance.parent.provides; if(provides && key in provides) { return provides[key] }}
你可能會疑惑,為什么這里是直接去查父組件,而不是先查自身實例的provides呢?前面不是說實例的provides默認(rèn)指向父實例的provides么。但是請注意,是“默認(rèn)”。如果當(dāng)前實例執(zhí)行了provide()是不是把instance.provides“污染”了呢?這時再執(zhí)行inject(key),如果provide(key)的key與你inject的key一致,就從當(dāng)前實例provides取key對應(yīng)的值了,而不是取父實例的provides!
最后,我畫了2張圖幫助大家理解
篇幅有限,本文不會對組件的具體實現(xiàn)講解哦,簡單介紹下文件
__demo__組件使用事例constants.ts定義的常量context.ts上下文相關(guān)interface.ts組件接口TEMPLATE.md用于生成README.md的模版button/style下存放組件樣式style下存放全局樣式關(guān)于打包組件的esm
與commonjs
模塊在之前打包圖標(biāo)庫章節(jié)已經(jīng)做了介紹,這里不再贅述。
相對于圖標(biāo)庫,組件庫的打包需要額外打包樣式文件,大概流程如下:
生成總?cè)肟赾omponents/index.less并編譯成css。
編譯組件less。
生成dist下的argo.css與argo.min.css。
構(gòu)建組件style/index.ts。
import path from "path";import { outputFileSync } from "fs-extra";import glob from "glob";export const CWD = process.cwd();export const C_DIR = path.resolve(CWD, "components");export const lessgen = async () => { let lessContent = `@import "./style/index.less";\n`; // 全局樣式文件 const lessFiles = glob.sync("**/style/index.less", { cwd: C_DIR, ignore: ["style/index.less"] }); lessFiles.forEach((value) => { lessContent += `@import "./${value}";\n`; }); outputFileSync(path.resolve(C_DIR, "index.less"), lessContent); log.success("genless", "generate index.less success!");};
代碼很簡單,值得一提就是為什么不將lessContent初始化為空,glob中將ignore移除,這不是更簡潔嗎。這是因為style/index.less作為全局樣式,我希望它在引用的最頂部。最終將會在components目錄下生成index.less
內(nèi)容如下:
@import "./style/index.less";@import "./button/style/index.less";/* other less of components */
import path from "path";import { readFile, copySync } from "fs-extra"import { render } from "less";export const ES_DIR = path.resolve(CWD, "es");export const LIB_DIR = path.resolve(CWD, "lib");const less2css = (lessPath: string): string => { const source = await readFile(lessPath, "utf-8"); const { css } = await render(source, { filename: lessPath }); return css;}const files = glob.sync("**/*.{less,js}", { cwd: C_DIR});for (const filename of files) { const lessPath = path.resolve(C_DIR, `${filename}`); // less文件拷貝到es和lib相對應(yīng)目錄下 copySync(lessPath, path.resolve(ES_DIR, `${filename}`)); copySync(lessPath, path.resolve(LIB_DIR, `${filename}`)); // 組件樣式/總?cè)肟谖募?全局樣式的入口文件編譯成css if (/index.less$/.test(filename)) { const cssFilename = filename.replace(".less", ".css"); const ES_DEST = path.resolve(ES_DIR, `${cssFilename}`); const LIB_DEST = path.resolve(LIB_DIR, `${cssFilename}`); const css = await less2css(lessPath); writeFileSync(ES_DEST, css, "utf-8"); writeFileSync(LIB_DEST, css, "utf-8"); }}
import path from "path";import CleanCSS, { Output } from "clean-css";import { ensureDirSync } from "fs-extra";export const DIST_DIR = path.resolve(CWD, "dist");console.log("start build components/index.less to dist/argo(.min).css");const indexCssPath = path.resolve(ES_DIR, "index.css");const css = readFileSync(indexCssPath, "utf8");const minContent: Output = new CleanCSS().minify(css);ensureDirSync(DIST_DIR);writeFileSync(path.resolve("dist/argo.css"), css);writeFileSync(path.resolve("dist/argo.min.css"), minContent.styles);log.success(`build components/index.less to dist/argo(.min).css`);
其中最重要的就是使用clean-css
壓縮css。
如果你使用過babel-plugin-import
,那一定熟悉這項配置:
通過指定style: true,babel-plugin-import
可以幫助我們自動引入組件的less文件,如果你擔(dān)心less文件定義的變量會被覆蓋或沖突,可以指定"css",即可引入組件的css文件樣式。
這一步就是要接入這點。但目前不是很必要,且涉及到vite插件
開發(fā),暫可略過,后面會講。
來看看最終實現(xiàn)的樣子。
其中button/style/index.js
內(nèi)容也就是導(dǎo)入less:
import "../../style/index.less";import "./index.less";
button/style/css.js
內(nèi)容也就是導(dǎo)入css:
import "../../style/index.css";import "./index.css";
最后你可能會好奇,諸如上面提及的compileComponent
,compileStyle
等函數(shù)是如何被調(diào)度使用的,這其實都?xì)w功于腳手架@argo-design/scripts
。當(dāng)它作為依賴被安裝到項目中時,會為我們提供諸多命令如argo-scripts genicon
,argo-scripts compileComponent
等,這些函數(shù)都在執(zhí)行命令時被調(diào)用。
"sideEffects": [ "dist/*", "es/**/style/*", "lib/**/style/*", "*.less"]
// locale.tsimport { ref, reactive, computed, inject } from "vue";import { isString } from "../_utils/is";import zhCN from "./lang/zh-cn";export interface ArgoLang { locale: string; button: { defaultText: string; }}type ArgoI18nMessages = Record<string, ArgoLang>;// 默認(rèn)使用中文const LOCALE = ref("zh-CN");const I18N_MESSAGES = reactive<ArgoI18nMessages>({ "zh-CN": zhCN});// 添加語言包export const addI18nMessages = ( messages: ArgoI18nMessages, options?: { overwrite?: boolean; }) => { for (const key of Object.keys(messages)) { if (!I18N_MESSAGES[key] || options?.overwrite) { I18N_MESSAGES[key] = messages[key]; } }};// 切換語言包export const useLocale = (locale: string) => { if (!I18N_MESSAGES[locale]) { console.warn(`use ${locale} failed! Please add ${locale} first`); return; } LOCALE.value = locale;};// 獲取當(dāng)前語言export const getLocale = () => { return LOCALE.value;};export const useI18n = () => { const i18nMessage = computed<ArgoLang>(() => I18N_MESSAGES[LOCALE.value]); const locale = computed(() => i18nMessage.value.locale); const transform = (key: string): string => { const keyArray = key.split("."); let temp: any = i18nMessage.value; for (const keyItem of keyArray) { if (!temp[keyItem]) { return key; } temp = temp[keyItem]; } return temp; }; return { locale, t: transform };};
添加需要支持的語言包,這里默認(rèn)支持中文和英文。
// lang/zh-CN.tsconst lang: ArgoLang = { locale: "zh-CN", button: { defaultText: "按鈕" },}
// lang/en-US.tsconst lang: ArgoLang = { locale: "en-US", button: { defaultText: "Button", },}
button組件中接入
<template> <button> <slot> {{ t("button.defaultText") }} </slot> </button></template><script>import { defineComponent } from "vue";import { useI18n } from "../locale";export default defineComponent({ name: "Button", setup(props, { emit }) { const { t } = useI18n(); return { t }; }});</script>
Button的國際化僅做演示,實際上國際化在日期日歷等組件中才有用武之地。
argo-ui-storybook/stories中添加locale.stories.ts
import { computed } from "vue";import { Meta, StoryFn } from "@storybook/vue3";import { Button, addI18nMessages, useLocale, getLocale} from "@argo-design/argo-ui/components/index"; // 源文件形式引入方便開發(fā)時調(diào)試import enUS from "@argo-design/argo-ui/components/locale/lang/en-us";interface Args {}export default { title: "Component/locale", argTypes: {}} as Meta<Args>;const BasicTemplate: StoryFn<Args> = (args) => { return { components: { Button }, setup() { addI18nMessages({ "en-US": enUS }); const currentLang = computed(() => getLocale()); const changeLang = () => { const lang = getLocale(); if (lang === "en-US") { useLocale("zh-CN"); } else { useLocale("en-US"); } }; return { args, changeLang, currentLang }; }, template: ` <h1>內(nèi)部切換語言,當(dāng)前語言: {{currentLang}}</h1> <p>僅在未提供ConfigProvider時生效</p> <Button type="primary" @click="changeLang">點擊切換語言</Button> <Button long style="marginTop: 20px;"></Button> ` };};export const Basic = BasicTemplate.bind({});Basic.storyName = "基本使用";Basic.args = {};
.preview.js
中全局引入組件庫樣式
import "@argo-design/argo-ui/components/index.less";
終端啟動項目就可以看到效果了。
通常組件庫都會提供config-provider組件來使用國際化,就像下面這樣
<template> <a-config-provider :locale="enUS"> <a-button /> </a-config-provider></template>
下面我們來實現(xiàn)一下config-provider
組件:
<template> <slot /></template><script>import type { PropType } from "vue";import { defineComponent, provide, reactive, toRefs,} from "vue";import { configProviderInjectionKey } from "./context";export default defineComponent({ name: "ConfigProvider", props: { locale: { type: Object as PropType<ArgoLang> }, }, setup(props, { slots }) { const { locale } = toRefs(props); const config = reactive({ locale, }); provide(configProviderInjectionKey, config); }});</script>
export interface ConfigProvider { locale?: ArgoLang;}export const configProviderInjectionKey: InjectionKey<ConfigProvider> = Symbol("ArgoConfigProvider");
修改locale/index.ts中計算屬性i18nMessage
的獲取邏輯
import { configProviderInjectionKey } from "../config-provider/context";export const useI18n = () => { const configProvider = inject(configProviderInjectionKey, undefined); const i18nMessage = computed<ArgoLang>( () => configProvider?.locale ?? I18N_MESSAGES[LOCALE.value] ); const locale = computed(() => i18nMessage.value.locale); const transform = (key: string): string => { const keyArray = key.split("."); let temp: any = i18nMessage.value; for (const keyItem of keyArray) { if (!temp[keyItem]) { return key; } temp = temp[keyItem]; } return temp; }; return { locale, t: transform };};
編寫stories驗證一下:
const ProviderTemplate: StoryFn<Args> = (args) => { return { components: { Button, ConfigProvider }, render() { return ( <ConfigProvider {...args}> <Button long={true} /> </ConfigProvider> ); } };};export const Provider = ProviderTemplate.bind({});Provider.storyName = "在config-provider中使用";Provider.args = { // 在這里把enUS傳給ConfigProvider的locale locale: enUS};
以上stories使用到了jsx,請確保安裝并配置了@vue/babel-plugin-jsx
可以看到,Button默認(rèn)是英文的,表單控件也接收到enUS語言包了,符合預(yù)期。
值得注意的是,上面提到的按需引入只是引入了組件js邏輯代碼,但對于樣式依然沒有引入。
下面我們通過開發(fā)vite插件vite-plugin-auto-import-style,讓組件庫可以自動引入組件樣式。
現(xiàn)在我們書寫的代碼如下,現(xiàn)在我們已經(jīng)知道了,這樣僅僅是加載了組件而已。
import { createApp } from "vue";import App from "./App.vue";import { Button, Empty, ConfigProvider } from "@argo-design/argo-ui";import { Anchor } from "@argo-design/argo-ui";createApp(App) .use(Button) .use(Empty) .use(ConfigProvider) .use(Anchor) .mount("#root");
添加插件之前:
添加插件之后:
import { defineConfig } from "vite";import argoAutoInjectStyle from "vite-plugin-argo-auto-inject-style";export default defineConfig({ plugins: [ argoAutoInjectStyle({ libs: [ { libraryName: "@argo-design/argo-ui", resolveStyle: (name) => { return `@argo-design/argo-ui/es/${name}/style/index.js`; } } ] }) ]})
實踐之前瀏覽一遍官網(wǎng)插件介紹是個不錯的選擇。插件API
vite插件是一個對象,通常由name
和一系列鉤子函數(shù)
組成:
{ name: "vite-plugin-vue-auto-inject-style", configResolved(config) {}}
在vite.config.ts
被解析完成后觸發(fā)。常用于擴(kuò)展配置。可以直接在config上定義或返回一個對象,該對象會嘗試與配置文件vite.config.ts
中導(dǎo)出的配置對象深度合并。
在解析完所有配置時觸發(fā)。形參config
表示最終確定的配置對象。通常將該配置保存起來在有需要時提供給其它鉤子使用。
開發(fā)階段每個傳入模塊請求時被調(diào)用,常用于解析模塊路徑。返回string或?qū)ο髮⒔K止后續(xù)插件的resolveId鉤子執(zhí)行。
resolveId之后調(diào)用,可自定義模塊加載內(nèi)容
load之后調(diào)用,可自定義修改模塊內(nèi)容。這是一個串行鉤子,即多個插件實現(xiàn)了這個鉤子,下個插件的transform需要等待上個插件的transform鉤子執(zhí)行完畢。上個transform返回的內(nèi)容將傳給下個transform鉤子。
為了讓插件完成自動引入組件樣式,我們需要完成如下工作:
過濾出我們想要的文件。
對文件內(nèi)容進(jìn)行AST解析,將符合條件的import語句提取出來。
然后解析出具體import的組件。
最后根據(jù)組件查找到樣式文件路徑,生成導(dǎo)入樣式的語句字符串追加到import語句后面即可。
其中過濾我們使用rollup提供的工具函數(shù)createFilter;
AST解析借助es-module-lexer
,非常出名,千萬級周下載量。
import type { Plugin } from "vite";import { createFilter } from "@rollup/pluginutils";import { ExportSpecifier, ImportSpecifier, init, parse } from "es-module-lexer";import MagicString from "magic-string";import * as changeCase from "change-case";import { Lib, VitePluginOptions } from "./types";const asRE = /\s+as\s+\w+,?/g;// 插件本質(zhì)是一個對象,但為了接受在配置時傳遞的參數(shù),我們通常在一個函數(shù)中將其返回。// 插件默認(rèn)開發(fā)和構(gòu)建階段都會應(yīng)用export default function(options: VitePluginOptions): Plugin { const { libs, include = ["**/*.vue", "**/*.ts", "**/*.tsx"], exclude = "node_modules/**" } = options; const filter = createFilter(include, exclude); return { name: "vite:argo-auto-inject-style", async transform(code: string, id: string) { if (!filter(id) || !code || !needTransform(code, libs)) { return null; } await init; let imports: readonly ImportSpecifier[] = []; imports = parse(code)[0]; if (!imports.length) { return null; } let s: MagicString | undefined; const str = () => s || (s = new MagicString(code)); for (let index = 0; index < imports.length; index++) { // ss import語句開始索引 // se import語句介結(jié)束索引 const { n: moduleName, se, ss } = imports[index]; if (!moduleName) continue; const lib = getLib(moduleName, libs); if (!lib) continue; // 整條import語句 const importStr = code.slice(ss, se); // 拿到每條import語句導(dǎo)入的組件集合 const importItems = getImportItems(importStr); let endIndex = se + 1; for (const item of importItems) { const componentName = item.n; const paramName = changeCase.paramCase(componentName); const cssImportStr = `\nimport "${lib.resolveStyle(paramName)}";`; str().appendRight(endIndex, cssImportStr); } } return { code: str().toString() }; } };}export type { Lib, VitePluginOptions };function getLib(libraryName: string, libs: Lib[]) { return libs.find((item) => item.libraryName === libraryName);}function getImportItems(importStr: string) { if (!importStr) { return []; } const matchItem = importStr.match(/{(.+?)}/gs); const formItem = importStr.match(/from.+/gs); if (!matchItem) return []; const exportStr = `export ${matchItem[0].replace(asRE, ",")} ${formItem}`; let importItems: readonly ExportSpecifier[] = []; try { importItems = parse(exportStr)[1]; } catch (error) { console.log(error); } return importItems;}function needTransform(code: string, libs: Lib[]) { return libs.some(({ libraryName }) => { return new RegExp(`("${libraryName}")|("${libraryName}")`).test(code); });}
export interface Lib { libraryName: string; resolveStyle: (name: string) => string;}export type RegOptions = | string | RegExp | Array<string | RegExp> | null | undefined;export interface VitePluginOptions { include?: RegOptions; exclude?: RegOptions; libs: Lib[];}
在我們的less樣式中,會定義一系列如下的顏色梯度變量,其值由color-palette函數(shù)完成:
@blue-6: #3491fa;@blue-1: color-palette(@blue-6, 1);@blue-2: color-palette(@blue-6, 2);@blue-3: color-palette(@blue-6, 3);@blue-4: color-palette(@blue-6, 4);@blue-5: color-palette(@blue-6, 5);@blue-7: color-palette(@blue-6, 7);@blue-8: color-palette(@blue-6, 8);@blue-9: color-palette(@blue-6, 9);@blue-10: color-palette(@blue-6, 10);
基于此,我們再演化出具體場景下的顏色梯度變量:
@primary-1: @blue-1;@primary-2: @blue-2;@primary-3: @blue-3;// 以此類推...@success-1: @green-1;@success-2: @green-2;@success-3: @green-3;// 以此類推.../* @warn @danger @info等等 */
有了具體場景下的顏色梯度變量,我們就可以設(shè)計變量供給組件消費了:
@color-primary-1: @primary-1;@color-primary-2: @primary-2;@color-primary-3: @primary-3;/* ... */
.argo-btn.arco-btn-primary { color: #fff; background-color: @color-primary-1;}
在使用組件庫的項目中我們通過 Less 的 ·modifyVars
功能修改變量值:
// webpack.config.jsmodule.exports = { rules: [{ test: /.less$/, use: [{ loader: "style-loader", }, { loader: "css-loader", }, { loader: "less-loader", options: { lessOptions: { modifyVars: { "primary-6": "#f85959", }, javascriptEnabled: true, }, }, }], }],}
// vite.config.jsexport default { css: { preprocessorOptions: { less: { modifyVars: { "primary-6": "#f85959", }, javascriptEnabled: true, } } },}
首先,顏色梯度變量需要增加暗黑風(fēng)格。也是基于@blue-6
計算,只不過這里換成了dark-color-palette
函數(shù):
@dark-blue-1: dark-color-palette(@blue-6, 1);@dark-blue-2: dark-color-palette(@blue-6, 2);@dark-blue-3: dark-color-palette(@blue-6, 3);@dark-blue-4: dark-color-palette(@blue-6, 4);@dark-blue-5: dark-color-palette(@blue-6, 5);@dark-blue-6: dark-color-palette(@blue-6, 6);@dark-blue-7: dark-color-palette(@blue-6, 7);@dark-blue-8: dark-color-palette(@blue-6, 8);@dark-blue-9: dark-color-palette(@blue-6, 9);@dark-blue-10: dark-color-palette(@blue-6, 10);
然后,在相應(yīng)節(jié)點下掛載css變量
body { --color-bg: #fff; --color-text: #000; --primary-6: @primary-6; }body[argo-theme="dark"] { --color-bg: #000; --color-text: #fff; --primary-6: @dark-primary-6; }
緊接著,組件消費的less變量更改為css變量:
.argo-btn.argo-btn-primary { color: #fff; background-color: var(--primary-6);}
此外,我們還設(shè)置了--color-bg,--color-text等用于設(shè)置body色調(diào):
body { color: var(--color-bg); background-color: var(--color-text);}
最后,在消費組件庫的項目中,通過編輯body的argo-theme屬性即可切換亮暗模式:
// 設(shè)置為暗黑模式document.body.setAttribute("argo-theme", "dark")// 恢復(fù)亮色模式document.body.removeAttribute("argo-theme");
前面介紹的是在項目打包時通過less配置修改less變量值達(dá)到換膚效果,有了css變量,我們可以實現(xiàn)在線動態(tài)換膚。默認(rèn)的,打包過后樣式如下:
body { --primary-6: "#3491fa"}.argo-btn { color: #fff; background-color: var(--primary-6);}
在用戶選擇相應(yīng)顏色后,我們只需要更改css變量--primary-6的值即可:
// 可計算selectedColor的10個顏色梯度值列表,并逐一替換document.body.style.setProperty("--primary-6", colorPalette(selectedColor, 6));// ....
還記得每個組件目錄下的TEMPLATE.md文件嗎?
## zh-CN```yamlmeta: type: 組件 category: 通用title: 按鈕 Buttondescription: 按鈕是一種命令組件,可發(fā)起一個即時操作。```---## en-US```yamlmeta: type: Component category: Commontitle: Buttondescription: Button is a command component that can initiate an instant operation.```---@import ./__demo__/basic.md@import ./__demo__/disabled.md## API%%API(button.vue)%%## TS%%TS(interface.ts)%%
它是如何一步步被渲染出我們想要的界面呢?
TEMPLATE.md將被解析并生成中英文版READE.md(組件使用文檔),之后在vue-router中被加載使用。
這時當(dāng)我們訪問路由/button,vite服務(wù)器將接管并調(diào)用一系列插件解析成瀏覽器識別的代碼,最后由瀏覽器渲染出我們的文檔界面。
簡單起見,我們忽略國際化和使用例子部分。
%%API(button.vue)%%%%INTERFACE(interface.ts)%%
其中button.vue就是我們的組件,interface.ts就是定義組件的一些接口,比如ButtonProps,ButtonType等。
大致流程如下:
讀取TEMPLATE.md,正則匹配出button.vue;
使用vue-doc-api解析vue文件; let componentDocJson = VueDocApi.parse(path.resolve(__dirname, "button.vue"));
componentDocJson轉(zhuǎn)換成md字符串,md字符串替換掉占位符%%API(button.vue)%%,寫入README.md;
關(guān)于vue文件與解析出來的conponentDocJson結(jié)構(gòu)見 vue-docgen-api
由于VueDocApi.parse無法直接解析.ts文件,因此借助ts-morph
解析ts文件并轉(zhuǎn)換成componentDocJson結(jié)構(gòu)的JSON對象,再將componentDocJson轉(zhuǎn)換成md字符串,替換掉占位符后最終寫入README.md;
讀取TEMPLATE.md,正則匹配出interface.ts;
使用ts-morph解析inerface.ts出interfaces;
interfaces轉(zhuǎn)componentDocJson;
componentDocJson轉(zhuǎn)換成md字符串,md字符串替換掉占位符%%API(button.vue)%%,寫入README.md;
import { Project } from "ts-morph";const project = new Project();project.addSourceFileAtPath(filepath);const sourceFile = project.getSourceFile(filepath);const interfaces = sourceFile.getInterfaces();const componentDocList = [];interfaces.forEach((interfaceDeclaration) => { const properties = interfaceDeclaration.getProperties(); const componentDocJson = { displayName: interfaceDeclaration.getName(), exportName: interfaceDeclaration.getName(), props: formatterProps(properties), tags: {} }; if (componentDocJson.props.length) { componentDocList.push(componentDocJson); }});// genMd(componentDocList);
最終生成README.zh-CN.md如下
```yamlmeta: type: 組件 category: 通用title: 按鈕 Buttondescription: 按鈕是一種命令組件,可發(fā)起一個即時操作。```@import ./__demo__/basic.md@import ./__demo__/disabled.md## API### `<button>` Props|參數(shù)名|描述|類型|默認(rèn)值||---|---|---|:---:||type|按鈕的類型,分為五種:次要按鈕、主要按鈕、虛框按鈕、線性按鈕、文字按鈕。|`"secondary" | "primary" | "dashed" | "outline" | "text"`|`"secondary"`||shape|按鈕的形狀|`"square" | "round" | "circle"`|`"square"`||status|按鈕的狀態(tài)|`"normal" | "warning" | "success" | "danger"`|`"normal"`||size|按鈕的尺寸|`"mini" | "small" | "medium" | "large"`|`"medium"`||long|按鈕的寬度是否隨容器自適應(yīng)。|`boolean`|`false`||loading|按鈕是否為加載中狀態(tài)|`boolean`|`false`||disabled|按鈕是否禁用|`boolean`|`false`||html-type|設(shè)置 `button` 的原生 `type` 屬性,可選值參考 [HTML標(biāo)準(zhǔn)](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type "_blank")|`"button" | "submit" | "reset"`|`"button"`||href|設(shè)置跳轉(zhuǎn)鏈接。設(shè)置此屬性時,按鈕渲染為a標(biāo)簽。|`string`|`-`|### `<button>` Events|事件名|描述|參數(shù)||---|---|---||click|點擊按鈕時觸發(fā)|event: `Event`|### `<button>` Slots|插槽名|描述|參數(shù)||---|:---:|---||icon|圖標(biāo)|-|### `<button-group>` Props|參數(shù)名|描述|類型|默認(rèn)值||---|---|---|:---:||disabled|是否禁用|`boolean`|`false`|## INTERFACE### ButtonProps|參數(shù)名|描述|類型|默認(rèn)值||---|---|---|:---:||type|按鈕類型|`ButtonTypes`|`-`|
const Button = () => import("@argo-design/argo-ui/components/button/README.zh-CN.md");const router = createRouter({ { path: "/button", component: Button }});export default router;
首先我們來看下README.md(為方便直接省略.zh-CN)以及其中的demos.md的樣子與它們最終的UI。
可以看到,README就是一系列demo的集合,而每個demo都會被渲染成一個由代碼示例與代碼示例運行結(jié)果組成的代碼塊。
yarn create vite
快速搭建一個package
// vite.config.tsimport { defineConfig } from "vite";import vue from "@vitejs/plugin-vue";import md from "./plugins/vite-plugin-md/index";export default defineConfig({ server: { port: 8002, }, plugins: [md(), vue()],});
// App.vue<template> <ReadMe /></template><script setup>import ReadMe from "./readme.md";</script>
// readme.md@import ./__demo__/basic.md
開發(fā)之前我們先看看插件對README.md
源碼的解析轉(zhuǎn)換流程。
首先我們來實現(xiàn)第一步: 源碼轉(zhuǎn)換。即將
@import "./__demo__/basic.md"
轉(zhuǎn)換成
<template> <basic-demo /></template><script>import { defineComponent } from "vue";import BasicDemo from "./__demo__/basic.md";export default defineComponent({ name: "ArgoMain", components: { BasicDemo },});</script>
轉(zhuǎn)換過程我們借助第三方markdown解析工具marked
完成,一個高速,輕量,無阻塞,多平臺的markdown解析器。
眾所周知,md2html規(guī)范中,文本默認(rèn)會被解析渲染成p標(biāo)簽。也就是說,README.md里的@import ./__demo__/basic.md
會被解析渲染成<p>@import ./__demo__/basic.md</p>
,這不是我想要的。所以需要對marked
進(jìn)行一下小小的擴(kuò)展。
// marked.tsimport { marked } from "marked";import path from "path";const mdImport = { name: "mdImport", level: "block", tokenizer(src: string) { const rule = /^@import\s+(.+)(?:\n|$)/; const match = rule.exec(src); if (match) { const filename = match[1].trim(); const basename = path.basename(filename, ".md"); return { type: "mdImport", raw: match[0], filename, basename, }; } return undefined; }, renderer(token: any) { return `<demo-${token.basename} />\n`; },};marked.use({ extensions: [mdImport],});export default marked;
我們新建了一個mdImport
的擴(kuò)展,用來自定義解析我們的md。在tokenizer 中我們定義了解析規(guī)則并返回一系列自定義的tokens,其中raw就是@import "./__demo__/basic.md"
,filename就是./__demo__/basic.md
,basename就是basic
,我們可以通過marked.lexer(code)
拿到這些tokens。在renderer中我們自定義了渲染的html,通過marked.parser(tokens)
可以拿到html字符串了。因此,我們開始在插件中完成第一步。
// index.tsimport { Plugin } from "vite";import marked from "./marked";export default function vueMdPlugin(): Plugin { return { name: "vite:argo-vue-docs", async transform(code: string, id: string) { if (!id.endsWith(".md")) { return null; } const tokens = marked.lexer(code); const html = marked.parser(tokens); const vueCode = transformMain({ html, tokens }); }, };}
// vue-template.tsimport changeCase from "change-case";import marked from "./marked";export const transformMain = ({ html, tokens,}: { html: string; tokens: any[];}): string => { const imports = []; const components = []; for (const token of tokens) { const componentName = changeCase.pascalCase(`demo-${token.basename}`); imports.push(`import ${componentName} from "${token.filename}";`); components.push(componentName); } return ` <template> ${html} </template> <script>import { defineComponent } from "vue";${imports.join("\n")};export default defineComponent({ name: "ArgoMain", components: { ${components.join(",")} },});</script>`;};
其中change-case
是一個名稱格式轉(zhuǎn)換的工具,比如basic-demo轉(zhuǎn)BasicDemo等。
transformMain
返回的vueCode就是我們的目標(biāo)vue模版了。但瀏覽器可不認(rèn)識vue模版語法,所以我們?nèi)砸獙⑵浣唤o官方插件@vitejs/plugin-vue
的transform
鉤子函數(shù)轉(zhuǎn)換一下。
import { getVueId } from "./utils";export default function vueMdPlugin(): Plugin { let vuePlugin: Plugin | undefined; return { name: "vite:argo-vue-docs", configResolved(resolvedConfig) { vuePlugin = resolvedConfig.plugins.find((p) => p.name === "vite:vue"); }, async transform(code: string, id: string) { if (!id.endsWith(".md")) { return null; } if (!vuePlugin) { return this.error("Not found plugin [vite:vue]"); } const tokens = marked.lexer(code); const html = marked.parser(tokens); const vueCode = transformMain({ html, tokens }); return await vuePlugin.transform?.call(this, vueCode, getVueId(id)); }, };}
// utils.tsexport const getVueId = (id: string) => { return id.replace(".md", ".vue");};
這里使用getVueId
修改擴(kuò)展名為.vue是因為vuePlugin.transform
會對非vue文件進(jìn)行攔截就像我們上面攔截非md文件一樣。
在configResolved
鉤子函數(shù)中,形參resolvedConfig
是vite最終使用的配置對象。在該鉤子中拿到其它插件并將其提供給其它鉤子使用,是vite插件開發(fā)中的一種“慣用伎倆”了。
在經(jīng)過vuePlugin.transform
及后續(xù)處理過后,最終vite服務(wù)器對readme.md響應(yīng)給瀏覽器的內(nèi)容如下
對于basic.md?import響應(yīng)如下
可以看到,這一坨字符串可沒有有效的默認(rèn)導(dǎo)出語句。因此對于解析語句import DemoBasic from "/src/__demo__/basic.md?import";
瀏覽器會報錯
Uncaught SyntaxError: The requested module "/src/__demo__/basic.md?import" does not provide an export named "default" (at readme.vue:9:8)
在帶有module屬性的script標(biāo)簽中,每個import語句都會向vite服務(wù)器發(fā)起請求進(jìn)而繼續(xù)走到插件的transform鉤子之中。下面我們繼續(xù),對/src/__demo__/basic.md?import
進(jìn)行攔截處理。
// index.tsasync transform(code: string, id: string) { if (!id.endsWith(".md")) { return null; } // 新增對demo文檔的解析分支 if (isDemoMarkdown(id)) { const tokens = marked.lexer(code); const vueCode = transformDemo({ tokens, filename: id }); return await vuePlugin.transform?.call(this, vueCode, getVueId(id)); } else { const tokens = marked.lexer(code); const html = marked.parser(tokens); const vueCode = transformMain({ html, tokens }); return await vuePlugin.transform?.call(this, vueCode, getVueId(id)); }},
// utils.tsexport const isDemoMarkdown = (id: string) => { return //__demo__//.test(id);};
// vue-template.tsexport const transformDemo = ({ tokens, filename,}: { tokens: any[]; filename: string;}) => { const data = { html: "", }; const vueCodeTokens = tokens.filter(token => { return token.type === "code" && token.lang === "vue" }); data.html = marked.parser(vueCodeTokens); return ` <template> <hr /> ${data.html} </template> <script>import { defineComponent } from "vue";export default defineComponent({ name: "ArgoDemo",});</script>`;};
現(xiàn)在已經(jīng)可以在瀏覽器中看到結(jié)果了,水平線和示例代碼。
那如何實現(xiàn)示例代碼的運行結(jié)果呢?其實在對tokens遍歷(filter)的時候,我們是可以拿到vue模版字符串的,我們可以將其緩存起來,同時手動構(gòu)造一個import請求import Result from "${virtualPath}";
這個請求用于返回運行結(jié)果。
export const transformDemo = ({ tokens, filename,}: { tokens: any[]; filename: string;}) => { const data = { html: "", }; const virtualPath = `/@virtual${filename}`; const vueCodeTokens = tokens.filter(token => { const isValid = token.type === "code" && token.lang === "vue" // 緩存vue模版代碼 isValid && createDescriptor(virtualPath, token.text); return isValid; }); data.html = marked.parser(vueCodeTokens); return ` <template> <Result /> <hr /> ${data.html} </template> <script>import { defineComponent } from "vue";import Result from "${virtualPath}";export default defineComponent({ name: "ArgoDemo", components: { Result }});</script>`;};
// utils.tsexport const isVirtualModule = (id: string) => { return //@virtual/.test(id);};
export default function docPlugin(): Plugin { let vuePlugin: Plugin | undefined; return { name: "vite:plugin-doc", resolveId(id) { if (isVirtualModule(id)) { return id; } return null; }, load(id) { // 遇到虛擬md模塊,直接返回緩存的內(nèi)容 if (isVirtualModule(id)) { return getDescriptor(id); } return null; }, async transform(code, id) { if (!id.endsWith(".md")) { return null; } if (isVirtualModule(id)) { return await vuePlugin.transform?.call(this, code, getVueId(id)); } // 省略其它代碼... } }}
// cache.tsconst cache = new Map();export const createDescriptor = (id: string, content: string) => { cache.set(id, content);};export const getDescriptor = (id: string) => { return cache.get(id);};
最后為示例代碼加上樣式。安裝prismjs
yarn add prismjs
// marked.tsimport Prism from "prismjs";import loadLanguages from "prismjs/components/index.js";const languages = ["shell", "js", "ts", "jsx", "tsx", "less", "diff"];loadLanguages(languages);marked.setOptions({ highlight( code: string, lang: string, callback?: (error: any, code?: string) => void ): string | void { if (languages.includes(lang)) { return Prism.highlight(code, Prism.languages[lang], lang); } return Prism.highlight(code, Prism.languages.html, "html"); },});
項目入口引入css
// main.tsimport "prismjs/themes/prism.css";
重啟預(yù)覽,以上就是vite-plugin-vue-docs
的核心部分了。
最后回到上文構(gòu)建組件style/index.ts遺留的問題,index.ts的內(nèi)容很簡單,即引入組件樣式。
import "../../style/index.less"; // 全局樣式import "./index.less"; // 組件樣式復(fù)制代碼
index.ts在經(jīng)過vite的lib模式
構(gòu)建后,我們增加css插件,在generateBundle
鉤子中,我們可以對最終的bundle
進(jìn)行新增,刪除或修改。通過調(diào)用插件上下文中emitFile
方法,為我們額外生成用于引入css樣式的css.js。
import type { Plugin } from "vite";import { OutputChunk } from "rollup";export default function cssjsPlugin(): Plugin { return { name: "vite:cssjs", async generateBundle(outputOptions, bundle) { for (const filename of Object.keys(bundle)) { const chunk = bundle[filename] as OutputChunk; this.emitFile({ type: "asset", fileName: filename.replace("index.js", "css.js"), source: chunk.code.replace(/.less/g, ".css") }); } } };}
下篇暫定介紹版本發(fā)布,部署站點,集成到在線編輯器,架構(gòu)復(fù)用等,技術(shù)涉及l(fā)inux云服務(wù)器,站點服務(wù)器nginx,docker,stackblitz等。
(學(xué)習(xí)視頻分享:vuejs入門教程、編程基礎(chǔ)視頻)
以上就是【由淺入深】vue組件庫實戰(zhàn)開發(fā)總結(jié)分享的詳細(xì)內(nèi)容,更多請關(guān)注php中文網(wǎng)其它相關(guān)文章!