您好,欢迎访问一九零五行业门户网

【由浅入深】vue组件库实战开发总结分享

很庆幸标题能够赶上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/*" ]}
packages目录下的每个直接子目录作为一个workspace。由于我们的根项目是不需要发布出去的,因此设置private为true。
安装lerna并初始化不得不说,yarn workspaces已经具备了lerna部分功能,之所以使用它,是想借用它的发布工作流以弥补workspaces在monorepo下在这方面的不足。下面我们开始将lerna集成到项目中。
首先我们先安装一下lerna:
# w指workspace-root,即在项目根目录下安装,下同yarn add lerna -d -w# 由于经常使用lerna命令也推荐全局安装yarn global add lernaornpm 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,但对我而言,这是必须的。
集成eslintpackages目录下创建名为@argo-design/eslint-config(非文件夹名)的package
1. 安装eslintcd argo-eslint-configyarn add eslintnpx 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-configyarn add prettieryarn add eslint-config-prettier eslint-plugin-prettier
2. 使用// argo-prettier-config/index.jsmodule.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-prettierpackage下的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"}
集成stylelintstylelint配置我们也新建一个package@argo-design/stylelint-config。
1. 安装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
对于结合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.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理应作为盒子模型的一部分 默认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. 安装# 在根目录安装huskyyarn add husky -d -w
2. 使用npm pkg set scripts.prepare="husky install"npm run prepare# 添加pre-commit钩子,在提交前运行代码lintnpx 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可选。
headerheader 部分包括三个字段 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
scopescope 用于指定本次 commit 影响的范围。
subjectsubject 是本次 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.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";
之所以这么设计是由图标库最终如何使用决定的,除此之外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/*.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-storybookpackage,并在该目录下运行:
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.jspreview.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
配置并启动storybookless配置由于项目使用到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; },}
配置jsxstorybook默认支持解析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.lessimport 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.cssimport 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.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>;// 默认使用中文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.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"; // 源文件形式引入方便开发时调试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>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钩子执行。
loadresolveid之后调用,可自定义模块加载内容
transformload之后调用,可自定义修改模块内容。这是一个串行钩子,即多个插件实现了这个钩子,下个插件的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+as\s+\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.jsmodule.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.jsexport 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```yamlmeta: type: 组件 category: 通用title: 按钮 buttondescription: 按钮是一种命令组件,可发起一个即时操作。```---## 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的作用
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如下
```yamlmeta: type: 组件 category: 通用title: 按钮 buttondescription: 按钮是一种命令组件,可发起一个即时操作。```@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解析mdyarn 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
开发之前我们先看看插件对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.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的扩展,用来自定义解析我们的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.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是一个名称格式转换的工具,比如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.tsexport 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.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>`;};
现在已经可以在浏览器中看到结果了,水平线和示例代码。
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.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模块,直接返回缓存的内容 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";
重启预览,以上就是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组件库实战开发总结分享的详细内容。
其它类似信息

推荐信息