很庆幸标题能够赶上2022结束的脚步。本文由浅入深层层递进,对组件库的开发过程做个了小结。
由于篇幅有限,阴影部分的内容将在中/下篇介绍。
话不多说,直入主题。
yarn workspace + lerna: 管理组件库及其生态项目
考虑到组件库整体需要有多边资源支持,比如组件源码,库文档站点,color-gen等类库工具,代码规范配置,vite插件,脚手架,storybook等等,需要分出很多packages,package之间存在彼此联系,因此考虑使用monorepo的管理方式,同时使用yarn作为包管理工具,lerna作为包发布工具。【相关推荐:vuejs视频教程、web前端开发】
在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*.html"],
customSyntax: "postcss-html"
},
{
files: ["***.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 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
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: {
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
。
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";
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("**stylestyle*.vue", "***.tsx"],
exclude = "node_modules
有了具体场景下的颜色梯度变量,我们就可以设计变量供给组件消费了:
@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>
import ReadMe from "./readme.md";
</script>
// readme.md
@import ./__demo__/basic.md
开发之前我们先看看插件对README.md
源码的解析转换流程。
1. 源码转换
首先我们来实现第一步: 源码转换。即将
@import "./__demo__/basic.md"
转换成
<template>
<basic-demo />
</template>
<script>
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 = /^@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
的扩展,用来自定义解析我们的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>
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.tsexport
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>
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>
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等。
(学习视频分享:vuejs入门教程、编程基础视频)
以上就是【由浅入深】vue组件库实战开发总结分享的详细内容,更多请关注编程网其它相关文章!