「回顾2022,展望2023,我正在参与2022年终总结征文大赛活动」
很庆幸标题能够赶上2022完毕的脚步。本文由浅入深层层递进,对组件库的开发进程做个了小结。

因为篇幅有限,阴影部分的内容将在中/下篇介绍.终究依据组件库开发一个实在的手机壳子在线规划项目。

话不多说,直入主题。
yarn workspace + lerna: 办理组件库及其生态项目
考虑到组件库全体需求有多边资源支撑,比方组件源码,库文档站点,color-gen等类库东西,代码标准装备,vite插件,脚手架,storybook等等,需求分出许多packages,package之间存在彼此联络,因而考虑运用monorepo的办理方法,一起运用yarn作为包办理东西,lerna作为包发布东西。
在monorepo之前,根目录便是一个workspace,咱们直接通过yarn add/remove/run等就能够对包进行办理。但在monorepo项目中,根目录下存在多个子包,yarn 指令无法直接操作子包,比方根目录下无法通过yarn run dev发动子包package-a中的dev指令,这时咱们就需求敞开yarn的workspaces功用,每个子包对应一个workspace,之后咱们就能够通过yarn workspace package-a run dev
发动package-a中的dev指令了。
你或许会想,咱们直接cd到package-a下运转就能够了,不错,但yarn workspaces的用武之地并不只此,像auto link,依靠提升,单.lock等才是它在monorepo中的价值地点。
启用yarn workspaces
咱们在根目录packge.json中启用yarn workspaces:
{
"private": true,
"workspaces": [
"packages/*"
]
}
packages目录下的每个直接子目录作为一个workspace。因为咱们的根项目是不需求发布出去的,因而设置private为true。
装置lerna并初始化
不得不说,yarn workspaces现已具备了lerna部分功用,之所以运用它,是想借用它的发布作业流以弥补workspaces在monorepo下在这方面的不足。下面咱们开端将lerna集成到项目中。
首要咱们先装置一下lerna:
# W指workspace-root,即在项目根目录下装置,下同
yarn add lerna -D -W
# 因为常常运用lerna指令也推荐大局装置
yarn global add lerna
or
npm i lerna -g
履行lerna init
初始化项目,成功之后会帮咱们创立了一个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,装备此字段后,鼠标悬浮在特点上会有对应的描绘。留意,以上的途径值需求你在项目根目录下装置lerna。 -
useWorkspaces
界说了在lerna bootstrap
期间是否结合yarn workspace。 - 因为lerna默许的作业方式是固定方式,即发布时每个包的版别号一致。这儿咱们修正为
independent
独立方式,一起将npm客户端设置为yarn
。假如你喜爱pnpm
,just do it!
// lerna.json
{
"version": "independent",
"npmClient": "yarn"
}
至此yarn workspaces
搭配lerna
的monorepo项目就装备好了,十分简略!
额定的lerna装备
By the way!因为项目会运用commitlint
对提交信息进行校验是否契合Argular标准,而lerna version
默许为咱们commit的信息是”Publish”,因而咱们需求进行一些额定的装备。
// lerna.json
{
"command": {
"version": {
"message": "chore(release): publish",
"conventionalCommits": true
}
}
}
能够看到,咱们运用契合Argular团队提交标准的"chore(release): publish"
代替默许的”Publish”。
conventionalCommits
表明当咱们运转lerna version
,实践上会运转lerna version --conventional-commits
协助咱们生成CHANGELOG.md。
小结
在lerna刚发布的时分,那时的包办理东西还没有可用的workspaces
解决计划,因而lerna本身完结了一套解决计划。时至今日,现代的包办理东西几乎都内置了workspaces
功用,这使得lerna和yarn有许多功用堆叠,比方履行包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
作为软件包发布东西,是在monorepo
办理方法下一个不错的实践!
集成Lint东西标准化代码
很无法,我知道大部分人都不喜爱Lint,但对我而言,这是有必要的。
集成eslint
packages目录下创立名为@argo-design/eslint-config(非文件夹名)的package
1. 装置eslint
cd argo-eslint-config
yarn add eslint
npx eslint --init
留意这儿没有-D或许–save-dev。挑选如下:

装置完结后手动将devDependencies
下的依靠仿制到dependencies
中。或许你手动装置这一系列依靠。
2. 运用
// argo-eslint-config/package.json
{
scripts: {
"lint:script": "npx eslint --ext .js,.jsx,.ts,.tsx --fix --quiet ./"
}
}
运转yarn lint:script
,将会主动修正代码标准过错正告(假如能够的话)。
3. VSCode保存时主动修正
装置VSCode Eslint插件并进行如下装备,此刻在你保存代码时,也会主动修正代码标准过错正告。
// settings.json
{
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
4. 集成到项目大局
在argo-eslint-config
中新建包进口文件index.js,并将.eslintrc.js的内容仿制到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
指向咱们刚刚创立的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书写规矩;root表明根装备,对eslint装备文件冒泡查找到此为止。
集成prettier
接下来咱们引进formatter东西prettier
。首要咱们需求关闭eslint规矩中那些与prettier抵触或许不必要的规矩,终究由prettier
代为完结这些规矩。前者咱们通过eslint-config-prettier
完结,后者凭借插件eslint-plugin-prettier
完结。比方抵触规矩尾逗号,eslint-config-prettier
帮咱们屏蔽了与之抵触的eslint规矩:
{
"comma-dangle": "off",
"no-comma-dangle": "off",
"@typescript-eslint/comma-dangle": "off",
"vue/comma-dangle": "off",
}
通过装备eslint规矩"prettier/prettier": "error"
让过错露出出来,这些过错交给eslint-plugin-prettier
收拾。
prettier装备咱们也新建一个package@argo-design/prettier-config
。
1. 装置
cd argo-prettier-config
yarn add prettier
yarn add eslint-config-prettier eslint-plugin-prettier
2. 运用
// argo-prettier-config/index.js
module.exports = {
printWidth: 80, //一行的字符数,假如超越会进行换行,默许为80
semi: false, // 行尾是否运用分号,默许为true
trailingComma: 'none', // 是否运用尾逗号
bracketSpacing: true // 方针大括号直接是否有空格
};
完好装备参阅官网 prettier装备
3. 装备eslint
回到argo-eslint-config/index.js,只需新增如下一条装备即可
module.exports = {
"extends": ["plugin:prettier/recommended"]
};
plugin:prettier/recommended
指的eslint-plugin-prettier
package下的recommended.js。该扩展现已帮咱们装备好了
{
"extends": ["eslint-config-prettier"],
"plugins": ["eslint-plugin-prettier"],
"rules": {
"prettier/prettier": "error",
"arrow-body-style": "off",
"prefer-arrow-callback": "off"
}
}
4. 集成到项目大局
根目录package.json新增如下装备
{
"devDependencies": {
"@argo-design/prettier-config": "^1.0.0"
},
"prettier": "@argo-design/prettier-config"
}
运转yarn重新装置依靠。
5. VSCode装置prettier扩展并将其设置成默许格局化东西
// settings.json
{
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
集成stylelint
stylelint装备咱们也新建一个package@argo-design/stylelint-config
。
1. 装置
cd argo-stylelint-config
yarn add stylelint stylelint-prettier stylelint-config-prettier stylelint-order stylelint-config-rational-order postcss-html postcss-less
# 独自postcss8
yarn add postcss@8.0.0
关于结合prettier
这儿不在赘述。
stylelint-order
允许咱们自界说款式特点称号次序。而stylelint-config-rational-order
为咱们供给了一套合理的开箱即用的次序。
值得留意的是,stylelint14版别不在默许支撑less,sass等预处理言语。并且stylelint14依靠postcss8版别,或许需求独自装置,不然vscode 的stylellint扩展或许提示报错TypeError: this.getPosition is not a function at LessParser.inlineComment….
2. 运用
// argo-stylelint-config/index.js
module.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理应作为盒子模型的一部分 默许false
"empty-line-between-groups": false // 组之间添加空行 默许false
}
]
},
overrides: [
{
files: ["*.html", "**/*.html"],
customSyntax: "postcss-html"
},
{
files: ["**/*.{less,css}"],
customSyntax: "postcss-less"
}
]
};
3. 集成到项目大局
根目录package.json新增如下装备
{
"devDependencies": {
"@argo-design/stylelint-config": "^1.0.0"
},
"stylelint": {
"extends": [
"@argo-design/stylelint-config"
]
}
}
运转yarn重新装置依靠。
4. VSCode保存时主动修正
VSCode装置Stylelint扩展并添加装备
// 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装备装置到大局并集成到编辑器。
集成husky
为防止一些不合法的commit
或push
,咱们凭借git hooks
东西在对代码提交前进行 ESLint 与 Stylelint的校验,假如校验通过,则成功commit,不然撤销commit。
1. 装置
# 在根目录装置husky
yarn add husky -D -W
2. 运用
npm pkg set scripts.prepare="husky install"
npm run prepare
# 添加pre-commit钩子,在提交前运转代码lint
npx husky add .husky/pre-commit "yarn lint"
至此,当咱们履行git commit -m "xxx"
时就会先履行lint校验咱们的代码,假如lint通过,成功commit,不然停止commit。详细的lint指令请自行添加。
集成lint-staged: 仅校验staged中文件
现在,当咱们git commit时,会对整个作业区的代码进行lint。当作业区文件过多,lint的速度就会变慢,从而影响开发体会。实践上咱们只需求对暂存区中的文件进行lint即可。下面咱们引进lint-staged
解决咱们的问题。
1. 装置
在根目录装置lint-staged
yarn add lint-staged -D -W
2. 运用
在根目录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
运转时,将一直向上查找并运用最接近暂存文件的装备,因而咱们能够在根目录下的package.json中装备lint-staged。值得留意的是,每个glob匹配的数组中的指令是从左至右依次运转,和webpack的loder运用机制不同!
终究,咱们在.husky文件夹中找到pre-commit
,并将yarn lint
修正为npx --no-install lint-staged
。
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no-install lint-staged
至此,当咱们履行git commit -m "xxx"
时,lint-staged
会按期运转帮咱们校验staged(暂存区)中的代码,避免了对作业区的全量检查。
集成commitlint: 标准化commit message
除了代码标准检查之后,Git 提交信息的标准也是不容忽视的一个环节,标准精准的 commit 信息能够便利自己和别人追踪项目和把控进度。这儿,咱们运用大名鼎鼎的Angular团队提交标准
。
commit message格局标准
commit message 由 Header
、Body
、Footer
组成。其间Herder时必需的,Body和Footer可选。
Header
Header 部分包含三个字段 type
、scope
和 subject
。
<type>(<scope>): <subject>
type
其间type 用于说明 commit 的提交类型(有必要是以下几种之一)。
值 | 描绘 |
---|---|
feat | Feature) 新增一个功用 |
fix | Bug修正 |
docs | Documentation) 文档相关 |
style | 代码格局(不影响功用,例如空格、分号等格局修正),并非css款式更改 |
refactor | 代码重构 |
perf | Performent) 性能优化 |
test | 测验相关 |
build | 构建相关(例如 scopes: webpack、gulp、npm 等) |
ci | 更改持续集成软件的装备文件和 package 中的 scripts 指令,例如 scopes: Travis, Circle 等 |
chore | 改变构建流程或辅助东西,日常业务 |
revert | git revert |
scope
scope 用于指定本次 commit 影响的规模。
subject
subject 是本次 commit 的简练描绘,一般遵从以下几个标准:
- 用动词开头,第一人称现在时表述,例如:change 代替 changed 或 changes
- 第一个字母小写
- 完毕不加句号
.
Body(可选)
body 是对本次 commit 的详细描绘,能够分成多行。跟 subject 相似。
Footer(可选)
假如本次提交的代码是突破性的改变或关闭Issue,则 Footer 必需,不然能够省略。
集成commitizen(可选)
咱们能够凭借东西帮咱们生成标准的message。
1. 装置
yarn add commitizen -D -W
2. 运用
装置适配器
yarn add cz-conventional-changelog -D -W
这行指令做了两件事:
- 装置
cz-conventional-changelog
到开发依靠 - 在根目录下的package.json中添加了:
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
添加npm scriptscm
"scripts": {
"cm": "cz"
},
至此,履行yarn cm
,就能看到交互界面了!跟着交互一步步操作就能主动生成标准的message了。

集成commitlint: 对终究提交的message进行校验
1. 装置
首要在根目录装置依靠:
yarn add commitlint @commitlint/cli @commitlint/config-conventional -D -W
2. 运用
接着新建.commitlintrc.js
:
module.exports = {
extends: ["@commitlint/config-conventional"]
};
终究向husky中添加commit-msg
钩子,终端履行:
npx husky add .husky/commit-msg "npx --no-install commitlint -e $HUSKY_GIT_PARAMS"
履行成功之后就会在.husky文件夹中看到commit-msg文件了:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no-install commitlint -e
至此,当你提交代码时,假如pre-commit
钩子运转成功,紧接着在commit-msg
钩子中,commitlint会按期运转对咱们提交的message进行校验。
关于lint东西的集成到此就告一段落了,在实践开发中,咱们还会对lint装备进行一些小改动,比方ignore,相关rules等等。这些和详细项目有关,咱们不会改变package里的装备。
千万别投机取巧仿制别人的装备文件!仿制一时爽,代码火葬场。
图标库
巧妇难为无米之炊。组件库一般依靠许多图标,因而咱们先开发一个支撑按需引进的图标库。
假设咱们现在现已拿到了一些漂亮的svg图标,咱们要做的便是将每一个图标转化生成.vue组件与一个组件进口index.ts文件。然后再生成汇总一切组件的进口文件。比方咱们现在有foo.svg与bar.svg两个图标,终究生成的文件及结构如下:

相应的内容如下:
// bar.ts
import _Bar from "./bar.vue";
const Bar = Object.assign(_Bar, {
install: (app) => {
app.component(_Bar.name, _Bar);
}
});
export default Bar;
// foo.ts
import _Foo from "./foo.vue";
const Foo = Object.assign(_Foo, {
install: (app) => {
app.component(_Foo.name, _Foo);
}
});
export default Foo;
// argoIcon.ts
import 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.ts
export { default } from "./argoIcon";
export { default as Foo } from "./foo";
export { default as Bar } from "./bar";
之所以这么规划是由图标库终究怎么运用决议的,除此之外argoIcon.ts
也将会是打包umd
的进口文件。
// 全量引进
import ArgoIcon from "图标库";
app.use(ArgoIcon);
// 按需引进
import { Foo } from "图标库";
app.use(Foo);
图标库的整个构建流程大约分为以下3步:

1. svg图片转.vue文件

整个流程很简略,咱们通过glob匹配到.svg拿到一切svg的途径,关于每一个途径,咱们读取svg的原始文本信息交由第三方库svgo处理,期间包含删去无用代码,紧缩,自界说特点等,其间最重要的是为svg标签注入咱们想要的自界说特点,就像这样:
<svg
:class="cls"
:style="innerStyle"
:stroke-linecap="strokeLinecap"
:stroke-linejoin="strokeLinejoin"
:stroke-width="strokeWidth"
>
<path d="..."></path>
</svg>
之后这段svgHtml
会传送给咱们预先准备好的摸板字符串:
const template = `
<template>
${svgHtml}
</template>
<script lang="ts" setup>
defineProps({
"stroke-linecap": String;
// ...
})
// 省略逻辑代码...
</script>
`
为摸板字符串填充数据后,通过fs模块的writeFile生成咱们想要的.vue文件。
2. 打包vue组件
在打包构建计划上直接挑选vite为咱们供给的lib方式即可,开箱即用,插件扩展(后边会讲到),依据rollup,能协助咱们打包生成ESM,这是按需引进的根底。当然,commonjs
与umd
也是少不了的。整个进程咱们通过Vite 的JavaScript API
完结:
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", // 产品存放途径
entryFileNames: "[name].js", // 进口模块的产品文件名
preserveModules: true, // 保存模块结构,不然一切模块都将打包在一个bundle文件中
/*
* 保存模块的根途径,该值会在打包后的output.dir中被移除
* 咱们的进口是components/index.ts,打包后文件结构为:es/components/index.js
* preserveModulesRoot设为"components",打包后便是:es/index.js
*/
preserveModulesRoot: "components"
},
{
format: "commonjs",
dir: "lib",
entryFileNames: "[name].js",
preserveModules: true,
preserveModulesRoot: "components",
exports: "named" // 导出方式
}
]
},
// 敞开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", // 打包产品存放途径
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时,符号外部依靠vue,打包后以Vue替代
* 未界说时打包作用如下
* var ArgoIcon = function(vue2) {}(vue);
* rollup主动猜想是vue,但实践是Vue.这会导致报错
* 界说后
* 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区分组件库和图标库打包。实践上打包图标库和组件库都是差不多的,组件库需求额定打包国际化相关的言语包文件。图标款式内置在组件之中,因而也不需求额定打包。
3. 打包声明文件
咱们直接通过第三方库 vite-plugin-dts 打包图标库的声明文件。
import dts from "vite-plugin-dts";
plugins: [
dts({
outputDir: "es",
entryRoot: C_DIR
})
]
关于打包原理可参阅插件作者的这片文章。
lequ7.com/guan-yu-qia…
4. 完结按需引进
咱们都知道完结tree-shaking的一种方法是依据ESM的静态性,即在编译的时分就能摸清依靠之间的关系,关于”孤儿”会残忍的移除。可是关于import "icon.css"
这种没导入导出的模块,打包东西并不知道它是否具有副作用,索性移除,这样就导致页面缺少款式了。sideEffects便是npm与构建东西联合推出的一个字段,旨在协助构建东西更好的为npm包进行tree-shaking。
运用上,sideEffects设置为false表明一切模块都没有副作用,也能够设置数组,每一项能够是详细的模块名或Glob匹配。因而,完结图标库的按需引进,只需求在argo-icons项目下的package.json里添加以下装备即可:
{
"sideEffects": false,
}
这将告诉构建东西,图标库没有任何副作用,一切没有被引进的代码或模块都将被移除。条件是你运用的是ESM。
指定进口
Last but important!当图标库在被作为npm包导入时,咱们需求在package.json为其装备相应的进口文件。
{
"main": "lib/index.js", // 以esm方式被引进时的进口
"module": "es/index.js", // 以commonjs方式被引进时的进口
"types": "es/index.d.ts" // 指定声明文件
}
引进storybook:是时分预览咱们的作用了!
顾名思义,storybook便是一本”书”,讲了许多个”故事”。在这儿,”书”便是argo-icons,我为它讲了3个故事:
- 根本运用
- 按需引进
- 运用iconfont.cn项目
初始化storybook
新建@argo-design/ui-storybook
package,并在该目录下运转:
npx storybook init -t vue3 -b webpack5
-t (即–type): 指定项目类型,storybook会依据项目依靠及装备文件等计算项目类型,但显然咱们仅仅是通过npm init新创立的项目,storybook无法主动判别项目类型,故需求指定type为vue3,然后storybook会帮咱们初始化storybook vue3 app。
-b (–builder): 指定构建东西,默许是webpack4,别的支撑webpack5, vite。这儿指定webpack5,不然后续会有相似报错:cannot read property of undefine(reading ‘get’)…因为storybook默许以webpack4构建,可是@storybook/vue3
依靠webpack5,会抵触导致报错。这儿是天坑!!
storybook默许运用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
preview.js能够签字导出parameters,decorators,argTypes,用于大局装备UI(stories,界面,控件等)的烘托行为。比方默许装备中的controls.matchers:
export const parameters = {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/
}
}
};
它界说了假如特点值是以background或color完毕,那么将为其启用color控件,咱们能够挑选或输入色彩值,date同理。

除此之外你能够在这儿引进大局款式,注册组件等等。更多详情见官网 Configure story rendering
main.js
终究来看看最重要的项目装备文件。
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, 装备需求的扩展。庆幸的是,当前一些重要的扩展都现已集成到@storybook/addon-essentials。
- framework和core便是咱们初识化传递的
-t vue3 -b webpack5
。
更多详情见官网 Configure your Storybook project
装备并发动storybook
less装备
因为项目运用到less因而咱们需求装备一下less,装置less以及相关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;
},
}
装备JSX
storybook默许支撑解析jsx/tsx,但你假如需求运用jsx书写vue3的stories,仍需求装置相关插件。
在argo-ui-storybook下装置 @vue/babel-plugin-jsx
yarn add @vue/babel-plugin-jsx -D
新建.babelrc
{
"plugins": ["@vue/babel-plugin-jsx"]
}
关于怎么书写story,篇幅受限,请自行查阅典范文件或官网。
装备完后终端履行yarn storybook
即可发动咱们的项目,辛苦的作用也将栩栩如生。



关于UI,在咱们的组件库逐步丰富之后,将会自建一个独具组件库风格的文档站点,拭目以待。
组件库
组件通讯
在Vue2时代,组件跨层级通讯方法可谓“百花齐放”,provide/inject便是其间一种。时至今日,在composition,es6,ts加持下,provide/inject能够更加大展身手。
provide/inject原理
在创立组件实例时,会在本身挂载一个provides方针,默许指向父实例的provides。
const instance = {
provides: parent ? parent.provides : Object.create(appContext.provides)
}
appContext.provides即createApp创立的app的provides特点,默许是null
在本身需求为子组件供数据时,即调用provide()时,会创立一个新方针,该方针的原型指向父实例的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的完结原理则时通过key去查找祖先provides对应的值:
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默许指向父实例的provides么。可是请留意,是“默许”。假如当前实例履行了provide()是不是把instance.provides“污染”了呢?这时再履行inject(key),假如provide(key)的key与你inject的key一致,就从当前实例provides取key对应的值了,而不是取父实例的provides!
终究,我画了2张图协助咱们理解


新增button组件并完结打包
篇幅有限,本文不会对组件的详细完结讲解哦,简略介绍下文件

- __demo__组件运用案例
- constants.ts界说的常量
- context.ts上下文相关
- interface.ts组件接口
- TEMPLATE.md用于生成README.md的模版
- button/style下存放组件款式
- style下存放大局款式
打包esm与commonjs模块
关于打包组件的esm
与commonjs
模块在之前打包图标库章节现已做了介绍,这儿不再赘述。
打包款式
相关于图标库,组件库的打包需求额定打包款式文件,大约流程如下:
- 生成总进口components/index.less并编译成css。
- 编译组件less。
- 生成dist下的argo.css与argo.min.css。
- 构建组件style/index.ts。
1. 生成总进口components/index.less
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
内容如下:
@import "./style/index.less";
@import "./button/style/index.less";
/* other less of components */
2. 打包组件款式
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相对应目录下
copySync(lessPath, path.resolve(ES_DIR, `${filename}`));
copySync(lessPath, path.resolve(LIB_DIR, `${filename}`));
// 组件款式/总进口文件/大局款式的进口文件编译成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");
}
}
3. 生成dist下的argo.css与argo.min.css
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。
4. 构建组件style/index.ts
假如你运用过babel-plugin-import
,那一定熟悉这项装备:
- [“import”, { “libraryName”: “antd”, “style”: true }]: import js and css modularly (LESS/Sass source files)
- [“import”, { “libraryName”: “antd”, “style”: “css” }]: import js and css modularly (css built files)
通过指定style: true,babel-plugin-import
能够协助咱们主动引进组件的less文件,假如你担心less文件界说的变量会被掩盖或抵触,能够指定’css’,即可引进组件的css文件款式。
这一步便是要接入这点。但目前不是很必要,且涉及到vite插件
开发,暂可略过,后边会讲。
来看看终究完结的姿态。

其间button/style/index.js
内容也便是导入less:
import "../../style/index.less";
import "./index.less";
button/style/css.js
内容也便是导入css:
import "../../style/index.css";
import "./index.css";
终究你或许会猎奇,诸如上面提及的compileComponent
,compileStyle
等函数是怎么被调度运用的,这其实都归功于脚手架@argo-design/scripts
。当它作为依靠被装置到项目中时,会为咱们供给许多指令如argo-scripts genicon
,argo-scripts compileComponent
等,这些函数都在履行指令时被调用。
装备sideEffects
"sideEffects": [
"dist/*",
"es/**/style/*",
"lib/**/style/*",
"*.less"
]
国际化
根本完结
// locale.ts
import { 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>;
// 默许运用中文
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;
};
// 获取当前言语
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
};
};
添加需求支撑的言语包,这儿默许支撑中文和英文。
// lang/zh-CN.ts
const lang: ArgoLang = {
locale: "zh-CN",
button: {
defaultText: "按钮"
},
}
// lang/en-US.ts
const lang: ArgoLang = {
locale: "en-US",
button: {
defaultText: "Button",
},
}
button组件中接入
<template>
<button>
<slot> {{ t("button.defaultText") }} </slot>
</button>
</template>
<script lang="ts">
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"; // 源文件方式引进便利开发时调试
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>内部切换言语,当前言语: {{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组件
一般组件库都会供给config-provider组件来运用国际化,就像下面这样
<template>
<a-config-provider :locale="enUS">
<a-button />
</a-config-provider>
</template>
下面咱们来完结一下config-provider
组件:
<template>
<slot />
</template>
<script lang="ts">
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默许是英文的,表单控件也接收到enUS言语包了,契合预期。
主动引进组件款式
值得留意的是,上面提到的按需引进仅仅引进了组件js逻辑代码,但关于款式仍然没有引进。
下面咱们通过开发vite插件vite-plugin-auto-import-style,让组件库能够主动引进组件款式。
作用演示
现在咱们书写的代码如下,现在咱们现已知道了,这样仅仅是加载了组件而已。
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`;
}
}
]
})
]
})

插件完结
实践之前阅读一遍官网插件介绍是个不错的挑选。插件API
vite插件是一个方针,一般由name
和一系列钩子函数
组成:
{
name: "vite-plugin-vue-auto-inject-style",
configResolved(config) {}
}
常用钩子
config
在vite.config.ts
被解析完结后触发。常用于扩展装备。能够直接在config上界说或回来一个方针,该方针会尝试与装备文件vite.config.ts
中导出的装备方针深度兼并。
configResolved
在解析完一切装备时触发。形参config
表明终究确认的装备方针。一般将该装备保存起来在有需求时供给给其它钩子运用。
resolveId
开发阶段每个传入模块恳求时被调用,常用于解析模块途径。回来string或方针将停止后续插件的resolveId钩子履行。
load
resolveId之后调用,可自界说模块加载内容
transform
load之后调用,可自界说修正模块内容。这是一个串行钩子,即多个插件完结了这个钩子,下个插件的transform需求等候上个插件的transform钩子履行完毕。上个transform回来的内容将传给下个transform钩子。
为了让插件完结主动引进组件款式,咱们需求完结如下作业:
- 过滤出咱们想要的文件。
- 对文件内容进行AST解析,将契合条件的import句子提取出来。
- 然后解析出详细import的组件。
- 终究依据组件查找到款式文件途径,生成导入款式的句子字符串追加到import句子后边即可。
其间过滤咱们运用rollup供给的东西函数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+ass+w+,?/g;
// 插件实质是一个方针,但为了接受在装备时传递的参数,咱们一般在一个函数中将其回来。
// 插件默许开发和构建阶段都会运用
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句子介完毕索引
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句子导入的组件调集
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函数完结:
@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等等 */
有了详细场景下的色彩梯度变量,咱们就能够规划变量供给组件消费了:
@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装备
// webpack.config.js
module.exports = {
rules: [{
test: /.less$/,
use: [{
loader: 'style-loader',
}, {
loader: 'css-loader',
}, {
loader: 'less-loader',
options: {
lessOptions: {
modifyVars: {
'primary-6': '#f85959',
},
javascriptEnabled: true,
},
},
}],
}],
}
vite装备
// vite.config.js
export default {
css: {
preprocessorOptions: {
less: {
modifyVars: {
'primary-6': '#f85959',
},
javascriptEnabled: true,
}
}
},
}
规划暗黑风格
首要,色彩梯度变量需求添加暗黑风格。也是依据@blue-6
计算,只不过这儿换成了dark-color-palette
函数:
@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);
然后,在相应节点下挂载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);
}
此外,咱们还设置了–color-bg,–color-text等用于设置body色调:
body {
color: var(--color-bg);
background-color: var(--color-text);
}
终究,在消费组件库的项目中,通过编辑body的argo-theme特点即可切换亮暗方式:
// 设置为暗黑方式
document.body.setAttribute('argo-theme', 'dark')
// 恢复亮色方式
document.body.removeAttribute('argo-theme');
在线动态换肤
前面介绍的是在项目打包时通过less装备修正less变量值达到换肤作用,有了css变量,咱们能够完结在线动态换肤。默许的,打包往后款式如下:
body {
--primary-6: '#3491fa'
}
.argo-btn {
color: #fff;
background-color: var(--primary-6);
}
在用户挑选相应色彩后,咱们只需求更改css变量–primary-6的值即可:
// 可计算selectedColor的10个色彩梯度值列表,并逐一替换
document.body.style.setProperty('--primary-6', colorPalette(selectedColor, 6));
// ....
文档站点
还记得每个组件目录下的TEMPLATE.md文件吗?
## zh-CN
```yaml
meta:
type: 组件
category: 通用
title: 按钮 Button
description: 按钮是一种指令组件,可建议一个即时操作。
```
---
## en-US
```yaml
meta:
type: Component
category: Common
title: Button
description: 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的作用

TEMPLATE.md将被解析并生成中英文版READE.md(组件运用文档),之后在vue-router中被加载运用。
这时当咱们拜访路由/button,vite服务器将接管并调用一系列插件解析成阅读器识别的代码,终究由阅读器烘托出咱们的文档界面。
1. 解析TEMPLATE 生成 README
简略起见,咱们疏忽国际化和运用比方部分。
%%API(button.vue)%%
%%INTERFACE(interface.ts)%%
其间button.vue便是咱们的组件,interface.ts便是界说组件的一些接口,比方ButtonProps,ButtonType等。
解析button.vue
大致流程如下:
- 读取TEMPLATE.md,正则匹配出button.vue;
- 运用vue-doc-api解析vue文件; let componentDocJson = VueDocApi.parse(path.resolve(__dirname, “button.vue”));
- componentDocJson转化成md字符串,md字符串替换掉占位符%%API(button.vue)%%,写入README.md;
关于vue文件与解析出来的conponentDocJson结构见 vue-docgen-api
解析interface.ts
因为VueDocApi.parse无法直接解析.ts文件,因而凭借ts-morph
解析ts文件并转化成componentDocJson结构的JSON方针,再将componentDocJson转化成md字符串,替换掉占位符后终究写入README.md;
- 读取TEMPLATE.md,正则匹配出interface.ts;
- 运用ts-morph解析inerface.ts出interfaces;
- interfaces转componentDocJson;
- componentDocJson转化成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如下
```yaml
meta:
type: 组件
category: 通用
title: 按钮 Button
description: 按钮是一种指令组件,可建议一个即时操作。
```
@import ./__demo__/basic.md
@import ./__demo__/disabled.md
## API
### `<button>` Props
|参数名|描绘|类型|默许值|
|---|---|---|:---:|
|type|按钮的类型,分为五种:次要按钮、主要按钮、虚框按钮、线性按钮、文字按钮。|`'secondary' | 'primary' | 'dashed' | 'outline' | 'text'`|`"secondary"`|
|shape|按钮的形状|`'square' | 'round' | 'circle'`|`"square"`|
|status|按钮的状况|`'normal' | 'warning' | 'success' | 'danger'`|`"normal"`|
|size|按钮的尺度|`'mini' | 'small' | 'medium' | 'large'`|`"medium"`|
|long|按钮的宽度是否随容器自适应。|`boolean`|`false`|
|loading|按钮是否为加载中状况|`boolean`|`false`|
|disabled|按钮是否禁用|`boolean`|`false`|
|html-type|设置 `button` 的原生 `type` 特点,可选值参阅 [HTML标准](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type "_blank")|`'button' | 'submit' | 'reset'`|`"button"`|
|href|设置跳转链接。设置此特点时,按钮烘托为a标签。|`string`|`-`|
### `<button>` Events
|事件名|描绘|参数|
|---|---|---|
|click|点击按钮时触发|event: `Event`|
### `<button>` Slots
|插槽名|描绘|参数|
|---|:---:|---|
|icon|图标|-|
### `<button-group>` Props
|参数名|描绘|类型|默许值|
|---|---|---|:---:|
|disabled|是否禁用|`boolean`|`false`|
## INTERFACE
### ButtonProps
|参数名|描绘|类型|默许值|
|---|---|---|:---:|
|type|按钮类型|`ButtonTypes`|`-`|
2. 路由装备
const Button = () => import("@argo-design/argo-ui/components/button/README.zh-CN.md");
const router = createRouter({
{
path: "/button",
component: Button
}
});
export default router;
3. README是怎么被烘托成UI的
首要咱们来看下README.md(为便利直接省略.zh-CN)以及其间的demos.md的姿态与它们终究的UI。

能够看到,README便是一系列demo的调集,而每个demo都会被烘托成一个由代码示例与代码示例运转作用组成的代码块。
开发vite-plugin-vue-docs解析md
yarn create vite
快速建立一个package
// vite.config.ts
import { 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 lang="ts">
import ReadMe from "./readme.md";
</script>
// readme.md
@import ./__demo__/basic.md
开发之前咱们先看看插件对README.md
源码的解析转化流程。

1. 源码转化
首要咱们来完结第一步: 源码转化。即将
@import "./__demo__/basic.md"
转化成
<template>
<basic-demo />
</template>
<script lang="ts">
import { defineComponent } from "vue";
import BasicDemo from "./__demo__/basic.md";
export default defineComponent({
name: "ArgoMain",
components: { BasicDemo },
});
</script>
转化进程咱们凭借第三方markdown解析东西marked
完结,一个高速,轻量,无堵塞,多平台的markdown解析器。
众所周知,md2html标准中,文本默许会被解析烘托成p标签。也便是说,README.md里的@import ./__demo__/basic.md
会被解析烘托成<p>@import ./__demo__/basic.md</p>
,这不是我想要的。所以需求对marked
进行一下小小的扩展。
// marked.ts
import { marked } from "marked";
import path from "path";
const mdImport = {
name: "mdImport",
level: "block",
tokenizer(src: string) {
const rule = /^@imports+(.+)(?: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
的扩展,用来自界说解析咱们的md。在tokenizer 中咱们界说了解析规矩并回来一系列自界说的tokens,其间raw便是@import "./__demo__/basic.md"
,filename便是./__demo__/basic.md
,basename便是basic
,咱们能够通过marked.lexer(code)
拿到这些tokens。在renderer中咱们自界说了烘托的html,通过marked.parser(tokens)
能够拿到html字符串了。因而,咱们开端在插件中完结第一步。
// index.ts
import { 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.ts
import 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 lang="ts">
import { defineComponent } from "vue";
${imports.join("n")};
export default defineComponent({
name: "ArgoMain",
components: { ${components.join(",")} },
});
</script>
`;
};
其间change-case
是一个称号格局转化的东西,比方basic-demo转BasicDemo等。
transformMain
回来的vueCode便是咱们的方针vue模版了。但阅读器可不知道vue模版语法,所以咱们仍要将其交给官方插件@vitejs/plugin-vue
的transform
钩子函数转化一下。
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.ts
export const getVueId = (id: string) => {
return id.replace(".md", ".vue");
};
这儿运用getVueId
修正扩展名为.vue是因为vuePlugin.transform
会对非vue文件进行阻拦就像咱们上面阻拦非md文件相同。
在configResolved
钩子函数中,形参resolvedConfig
是vite终究运用的装备方针。在该钩子中拿到其它插件并将其供给给其它钩子运用,是vite插件开发中的一种“惯用伎俩”了。
2. 处理basic.md
在通过vuePlugin.transform
及后续处理往后,终究vite服务器对readme.md呼应给阅读器的内容如下

关于basic.md?import呼应如下

能够看到,这一坨字符串可没有有用的默许导出句子。因而关于解析句子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标签中,每个import句子都会向vite服务器建议恳求从而持续走到插件的transform钩子之中。下面咱们持续,对/src/__demo__/basic.md?import
进行阻拦处理。
// index.ts
async 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.ts
export const isDemoMarkdown = (id: string) => {
return //__demo__//.test(id);
};
// vue-template.ts
export 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 lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "ArgoDemo",
});
</script>
`;
};
现在现已能够在阅读器中看到作用了,水平线和示例代码。

3. 虚拟模块
那怎么完结示例代码的运转作用呢?其实在对tokens遍历(filter)的时分,咱们是能够拿到vue模版字符串的,咱们能够将其缓存起来,一起手动结构一个import恳求import Result from "${virtualPath}";
这个恳求用于回来运转作用。
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 lang="ts">
import { defineComponent } from "vue";
import Result from "${virtualPath}";
export default defineComponent({
name: "ArgoDemo",
components: {
Result
}
});
</script>
`;
};
// utils.ts
export 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模块,直接回来缓存的内容
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.ts
const 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.ts
import 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.ts
import "prismjs/themes/prism.css";
重启预览,以上便是vite-plugin-vue-docs
的核心部分了。

留传问题
终究回到上文构建组件style/index.ts留传的问题,index.ts的内容很简略,即引进组件款式。
import "../../style/index.less"; // 大局款式
import "./index.less"; // 组件款式
index.ts在通过vite的lib方式
构建后,咱们添加css插件,在generateBundle
钩子中,咱们能够对终究的bundle
进行新增,删去或修正。通过调用插件上下文中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")
});
}
}
};
}
结语
下篇暂定介绍版别发布,部署站点,集成到在线编辑器,架构复用等,技能涉及linux云服务器,站点服务器nginx,docker,stackblitz等。
下篇见,或许,明年见。
参阅:
arco-design-vue