Home> Web Front-end> Vue.js> body text

[From the shallower to the deeper] Summary and sharing of practical development of vue component library

青灯夜游
Release: 2022-12-27 19:58:42
forward
2925 people have browsed it

I’m glad that the title can catch up with the end of 2022. This article proceeds from the shallower to the deeper levels, and makes a summary of the development process of the component library.

Due to limited space, the contents of the shaded parts will be introduced in the middle/next article.

Not much to say, let’s get straight to the point.

yarn workspace lerna: Manage component library and its ecological projects

Considering that the overall component library needs multi-lateral resource support, such as component source code, library documentation site, color- Class library tools such as gen, code specification configuration, vite plug-ins, scaffolding, storybook, etc. need to be separated into many packages. The packages are related to each other, so consider using monorepo management method, and use yarn as the package management tool and lerna as the package management tool. Package publishing tool. [Related recommendations:vuejs video tutorial,web front-end development]

Before monorepo, the root directory was a workspace, we directly added/remove/run through yarn, etc. You can manage the package. However, in the monorepo project, there are multiple sub-packages in the root directory, and the yarn command cannot directly operate the sub-packages. For example, the dev command in the sub-package package-a cannot be started through yarn run dev in the root directory. In this case, we need to enable yarn. workspaces function, each sub-package corresponds to a workspace, and then we can start the dev command in package-a throughyarn workspace package-a run dev.

You may think that we can just cd to package-a and run it, which is true, but yarn workspaces are not the only ones that can be used, such as auto link, dependency promotion, single.lock, etc. Where its value lies in the monorepo.

Enable yarn workspaces

We enable yarn workspaces in the root directory package.json:

{ "private": true, "workspaces": [ "packages/*" ] }
Copy after login

Each direct subdirectory under the packages directory as a workspace. Since our root project does not need to be published, set private to true.

Install lerna and initialize

I have to say that yarn workspaces already has some of the functions of lerna. The reason why I use it is to borrow its publishing workflow to make up for it. Workspaces are deficient in this aspect under monorepo. Next we start integrating lerna into the project.

First we install lerna:

# W指workspace-root,即在项目根目录下安装,下同 yarn add lerna -D -W # 由于经常使用lerna命令也推荐全局安装 yarn global add lerna or npm i lerna -g
Copy after login

Executelerna initto initialize the project. After success, alerna.jsonfile will be created for us. The lerna-schema.json pointed to by

lerna init
Copy after login
// lerna.json { "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useWorkspaces": true, "version": "0.0.0" }
Copy after login
  • $schemadescribes how to configure lerna.json. After configuring this field, hovering the mouse over the attribute will have the corresponding describe. Note that the above path value requires you to install lerna in the project root directory.

  • useWorkspacesDefines whether to combine yarn workspace duringlerna bootstrap.

  • Since the default working mode of lerna is the fixed mode, that is, the version number of each package is the same when released. Here we change it toindependentindependent mode, and set the npm client toyarn. If you likepnpm, just do it!

// lerna.json { "version": "independent", "npmClient": "yarn" }
Copy after login

This faryarn workspacesThe monorepo project withlernais configured Okay, very simple!

Additional lerna configuration

By the way! Since the project will usecommitlintto verify whether the submitted information is It complies with the Argular specification, andlerna versiondefaults to "Publish" for our commit information, so we need to make some additional configurations.

// lerna.json { "command": { "version": { "message": "chore(release): publish", "conventionalCommits": true } } }
Copy after login

As you can see, we use"chore(release): publish"which complies with the Argular team's submission specifications instead of the default "Publish".

conventionalCommitsmeans that when we runlerna version, it will actually runlerna version --conventional-commitsto help us generate CHANGELOG.md.

Summary

When lerna was first released, there was noworkspacessolution available for the package management tools at that time, so lerna implemented a set of solutions itself. Today, almost all modern package management tools have built-inworkspacesfunctions, which makes lerna and yarn have many overlapping functions, such as executing the dev command of package pkg-alerna run dev --stream --scope=pkg-a, we can useyarn workspace pkg-a run devinstead. lerna bootstrap --hoist will elevate the installation package to the root directory, and just run yarn directly in yarn workspaces.

Anyway, useyarnas the software package management tool andlernaas the software package publishing tool, which is a good choice under themonorepomanagement method practice!

Integrated Lint tool to standardize code

It’s very helpless. I know that most people don’t like Lint, but for me, this is a must.

集成eslint

packages目录下创建名为@argo-design/eslint-config(非文件夹名)的package

1. 安装eslint

cd argo-eslint-config yarn add eslint npx eslint --init
Copy after login

注意这里没有-D或者--save-dev。选择如下:

安装完成后手动将devDependencies下的依赖拷贝到dependencies中。或者你手动安装这一系列依赖。

2. 使用

// argo-eslint-config/package.json { scripts: { "lint:script": "npx eslint --ext .js,.jsx,.ts,.tsx --fix --quiet ./" } }
Copy after login

运行yarn lint:script,将会自动修复代码规范错误警告(如果可以的话)。

3. VSCode保存时自动修复

安装VSCode Eslint插件并进行如下配置,此时在你保存代码时,也会自动修复代码规范错误警告。

// settings.json { "editor.defaultFormatter": "dbaeumer.vscode-eslint", "editor.codeActionsOnSave": { "source.fixAll.eslint": true } }
Copy after login

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: {} }
Copy after login

确保package.json配置main指向我们刚刚创建的index.js。

// argo-eslint-config/package.json { "main": "index.js" }
Copy after login

根目录package.json新增如下配置

// argo-eslint-config/package.json { "devDependencies": { "@argo-design/eslint-config": "^1.0.0" }, "eslintConfig": { "root": true, "extends": [ "@argo-design" ] } }
Copy after login

最后运行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", }
Copy after login

通过配置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
Copy after login

2. 使用

// argo-prettier-config/index.js module.exports = { printWidth: 80, //一行的字符数,如果超过会进行换行,默认为80 semi: false, // 行尾是否使用分号,默认为true trailingComma: 'none', // 是否使用尾逗号 bracketSpacing: true // 对象大括号直接是否有空格 };
Copy after login

完整配置参考官网prettier配置

3. 配置eslint

回到argo-eslint-config/index.js,只需新增如下一条配置即可

module.exports = { "extends": ["plugin:prettier/recommended"] };
Copy after login

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" } }
Copy after login

4. 集成到项目全局

根目录package.json新增如下配置

{ "devDependencies": { "@argo-design/prettier-config": "^1.0.0" }, "prettier": "@argo-design/prettier-config" }
Copy after login

运行yarn重新安装依赖。

5. VSCode安装prettier扩展并将其设置成默认格式化工具

// settings.json { "editor.defaultFormatter": "esbenp.prettier-vscode" }
Copy after login

集成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
Copy after login

对于结合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" } ] };
Copy after login

3. 集成到项目全局

根目录package.json新增如下配置

{ "devDependencies": { "@argo-design/stylelint-config": "^1.0.0" }, "stylelint": { "extends": [ "@argo-design/stylelint-config" ] } }
Copy after login

运行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 }
Copy after login

修改settings.json之后如不能及时生效,可以重启一下vscode。如果你喜欢,可以将eslint,prettier,stylelint配置安装到全局并集成到编辑器。

集成husky

为防止一些非法的commitpush,我们借助git hooks工具在对代码提交前进行 ESLint 与 Stylelint的校验,如果校验通过,则成功commit,否则取消commit。

1. 安装

# 在根目录安装husky yarn add husky -D -W
Copy after login

2. 使用

npm pkg set scripts.prepare="husky install" npm run prepare # 添加pre-commit钩子,在提交前运行代码lint npx husky add .husky/pre-commit "yarn lint"
Copy after login

至此,当我们执行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
Copy after login

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" ] } }
Copy after login

在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
Copy after login

至此,当我们执行git commit -m "xxx"时,lint-staged会如期运行帮我们校验staged(暂存区)中的代码,避免了对工作区的全量检查。

集成commitlint: 规范化commit message

除了代码规范检查之后,Git 提交信息的规范也是不容忽视的一个环节,规范精准的 commit 信息能够方便自己和他人追踪项目和把控进度。这里,我们使用大名鼎鼎的Angular团队提交规范

commit message格式规范

commit message 由HeaderBodyFooter组成。其中Herder时必需的,Body和Footer可选。

Header

Header 部分包括三个字段typescopesubject

(): 
Copy after login
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
Copy after login

2. 使用

安装适配器

yarn add cz-conventional-changelog -D -W
Copy after login

这行命令做了两件事:

  • 安装cz-conventional-changelog到开发依赖

  • 在根目录下的package.json中增加了:

"config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } }
Copy after login

添加npm scriptscm

"scripts": { "cm": "cz" },
Copy after login

至此,执行yarn cm,就能看到交互界面了!跟着交互一步步操作就能自动生成规范的message了。

集成commitlint: 对最终提交的message进行校验

1. 安装

首先在根目录安装依赖:

yarn add commitlint @commitlint/cli @commitlint/config-conventional -D -W
Copy after login

2. 使用

接着新建.commitlintrc.js:

module.exports = { extends: ["@commitlint/config-conventional"] };
Copy after login

最后向husky中添加commit-msg钩子,终端执行:

npx husky add .husky/commit-msg "npx --no-install commitlint -e $HUSKY_GIT_PARAMS"
Copy after login

执行成功之后就会在.husky文件夹中看到commit-msg文件了:

#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx --no-install commitlint -e
Copy after login

至此,当你提交代码时,如果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;
Copy after login
// foo.ts import _Foo from "./foo.vue"; const Foo = Object.assign(_Foo, { install: (app) => { app.component(_Foo.name, _Foo); } }); export default Foo;
Copy after login
// 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;
Copy after login
// index.ts export { default } from "./argoIcon"; export { default as Foo } from "./foo"; export { default as Bar } from "./bar";
Copy after login

之所以这么设计是由图标库最终如何使用决定的,除此之外argoIcon.ts也将会是打包umd的入口文件。

// 全量引入import ArgoIcon from "图标库"; app.use(ArgoIcon); // 按需引入import { Foo } from "图标库"; app.use(Foo);
Copy after login

图标库的整个构建流程大概分为以下3步:

1. svg图片转.vue文件

整个流程很简单,我们通过glob匹配到.svg拿到所有svg的路径,对于每一个路径,我们读取svg的原始文本信息交由第三方库svgo处理,期间包括删除无用代码,压缩,自定义属性等,其中最重要的是为svg标签注入我们想要的自定义属性,就像这样:

  
Copy after login

之后这段svgHtml会传送给我们预先准备好的摸板字符串:

const template = `   `
Copy after login

为摸板字符串填充数据后,通过fs模块的writeFile生成我们想要的.vue文件。

2. 打包vue组件

在打包构建方案上直接选择vite为我们提供的lib模式即可,开箱即用,插件扩展(后面会讲到),基于rollup,能帮助我们打包生成ESM这是按需引入的基础。当然,commonjsumd也是少不了的。整个过程我们通过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 { 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); } }
Copy after login
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 }) ] }; };
Copy after login
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()] }; };
Copy after login
export const CWD = process.cwd(); export const C_DIR = resolve(CWD, "components");
Copy after login

可以看到,我们通过type区分组件库和图标库打包。实际上打包图标库和组件库都是差不多的,组件库需要额外打包国际化相关的语言包文件。图标样式内置在组件之中,因此也不需要额外打包。

3. 打包声明文件

我们直接通过第三方库vite-plugin-dts打包图标库的声明文件。

import dts from "vite-plugin-dts"; plugins: [ dts({ outputDir: "es", entryRoot: C_DIR }) ]
Copy after login

关于打包原理可参考插件作者的这片文章。

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, }
Copy after login

这将告诉构建工具,图标库没有任何副作用,一切没有被引入的代码或模块都将被移除。前提是你使用的是ESM。

指定入口

Last but important!当图标库在被作为npm包导入时,我们需要在package.json为其配置相应的入口文件。

{ "main": "lib/index.js", // 以esm形式被引入时的入口 "module": "es/index.js", // 以commonjs形式被引入时的入口 "types": "es/index.d.ts" // 指定声明文件 }
Copy after login

引入storybook:是时候预览我们的成果了!

顾名思义,storybook就是一本"书",讲了很多个"故事"。在这里,"书"就是argo-icons,我为它讲了3个故事:

  • 基本使用

  • 按需引入

  • 使用iconfont.cn项目

初始化storybook

新建@argo-design/ui-storybookpackage,并在该目录下运行:

npx storybook init -t vue3 -b webpack5
Copy after login

-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" } }
Copy after login

接下来把目光放到.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$/ } } };
Copy after login

它定义了如果属性值是以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" }, }
Copy after login
  • 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; }, }
Copy after login

配置JSX

storybook默认支持解析jsx/tsx,但你如果需要使用jsx书写vue3的stories,仍需要安装相关插件。

在argo-ui-storybook下安装 @vue/babel-plugin-jsx

yarn add @vue/babel-plugin-jsx -D
Copy after login

新建.babelrc

{ "plugins": ["@vue/babel-plugin-jsx"] }
Copy after login

关于如何书写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) }
Copy after login

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; }
Copy after login

而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] } }
Copy after login

你可能会疑惑,为什么这里是直接去查父组件,而不是先查自身实例的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模块

关于打包组件的esmcommonjs模块在之前打包图标库章节已经做了介绍,这里不再赘述。

打包样式

相对于图标库,组件库的打包需要额外打包样式文件,大概流程如下:

  • 生成总入口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!"); };
Copy after login

代码很简单,值得一提就是为什么不将lessContent初始化为空,glob中将ignore移除,这不是更简洁吗。这是因为style/index.less作为全局样式,我希望它在引用的最顶部。最终将会在components目录下生成index.less内容如下:

@import "./style/index.less"; @import "./button/style/index.less"; /* other less of components */
Copy after login

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"); } }
Copy after login

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`);
Copy after login

其中最重要的就是使用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";
Copy after login

button/style/css.js内容也就是导入css:

import "../../style/index.css"; import "./index.css";
Copy after login

最后你可能会好奇,诸如上面提及的compileComponentcompileStyle等函数是如何被调度使用的,这其实都归功于脚手架@argo-design/scripts。当它作为依赖被安装到项目中时,会为我们提供诸多命令如argo-scripts geniconargo-scripts compileComponent等,这些函数都在执行命令时被调用。

配置sideEffects

"sideEffects": [ "dist/*", "es/**/style/*", "lib/**/style/*", "*.less" ]
Copy after login

国际化

基本实现

// 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; // 默认使用中文 const LOCALE = ref("zh-CN"); const I18N_MESSAGES = reactive({ "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(() => 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 }; };
Copy after login

添加需要支持的语言包,这里默认支持中文和英文。

// lang/zh-CN.ts const lang: ArgoLang = { locale: "zh-CN", button: { defaultText: "按钮" }, }
Copy after login
// lang/en-US.ts const lang: ArgoLang = { locale: "en-US", button: { defaultText: "Button", }, }
Copy after login

button组件中接入

 
Copy after login

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; const BasicTemplate: StoryFn = (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: ` 

内部切换语言,当前语言: {{currentLang}}

仅在未提供ConfigProvider时生效

` }; }; export const Basic = BasicTemplate.bind({}); Basic.storyName = "基本使用"; Basic.args = {};
Copy after login

.preview.js中全局引入组件库样式

import "@argo-design/argo-ui/components/index.less";
Copy after login

终端启动项目就可以看到效果了。

实现config-provider组件

通常组件库都会提供config-provider组件来使用国际化,就像下面这样

Copy after login

下面我们来实现一下config-provider组件:

 
Copy after login
export interface ConfigProvider { locale?: ArgoLang; } export const configProviderInjectionKey: InjectionKey = Symbol("ArgoConfigProvider");
Copy after login

修改locale/index.ts中计算属性i18nMessage的获取逻辑

import { configProviderInjectionKey } from "../config-provider/context"; export const useI18n = () => { const configProvider = inject(configProviderInjectionKey, undefined); const i18nMessage = computed( () => 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 }; };
Copy after login

编写stories验证一下:

const ProviderTemplate: StoryFn = (args) => { return { components: { Button, ConfigProvider }, render() { return (  
Copy after login

以上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");
Copy after login

添加插件之前:

添加插件之后:

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`; } } ] }) ] })
Copy after login

插件实现

实践之前浏览一遍官网插件介绍是个不错的选择。插件API

vite插件是一个对象,通常由name和一系列钩子函数组成:

{ name: "vite-plugin-vue-auto-inject-style", configResolved(config) {} }
Copy after login

常用钩子

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+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); }); }
Copy after login
export interface Lib { libraryName: string; resolveStyle: (name: string) => string; } export type RegOptions = | string | RegExp | Array | null | undefined; export interface VitePluginOptions { include?: RegOptions; exclude?: RegOptions; libs: Lib[]; }
Copy after login

换肤与暗黑风格

换肤

在我们的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);
Copy after login

基于此,我们再演化出具体场景下的颜色梯度变量:

@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等等 */
Copy after login

有了具体场景下的颜色梯度变量,我们就可以设计变量供给组件消费了:

@color-primary-1: @primary-1; @color-primary-2: @primary-2; @color-primary-3: @primary-3; /* ... */
Copy after login
.argo-btn.arco-btn-primary { color: #fff; background-color: @color-primary-1; }
Copy after login

在使用组件库的项目中我们通过 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, }, }, }], }], }
Copy after login

vite配置

// vite.config.js export default { css: { preprocessorOptions: { less: { modifyVars: { 'primary-6': '#f85959', }, javascriptEnabled: true, } } }, }
Copy after login

设计暗黑风格

首先,颜色梯度变量需要增加暗黑风格。也是基于@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);
Copy after login

然后,在相应节点下挂载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; }
Copy after login

紧接着,组件消费的less变量更改为css变量:

.argo-btn.argo-btn-primary { color: #fff; background-color: var(--primary-6); }
Copy after login

此外,我们还设置了--color-bg,--color-text等用于设置body色调:

body { color: var(--color-bg); background-color: var(--color-text); }
Copy after login

最后,在消费组件库的项目中,通过编辑body的argo-theme属性即可切换亮暗模式:

// 设置为暗黑模式 document.body.setAttribute('argo-theme', 'dark') // 恢复亮色模式 document.body.removeAttribute('argo-theme');
Copy after login

在线动态换肤

前面介绍的是在项目打包时通过less配置修改less变量值达到换肤效果,有了css变量,我们可以实现在线动态换肤。默认的,打包过后样式如下:

body { --primary-6: '#3491fa' } .argo-btn { color: #fff; background-color: var(--primary-6); }
Copy after login

在用户选择相应颜色后,我们只需要更改css变量--primary-6的值即可:

// 可计算selectedColor的10个颜色梯度值列表,并逐一替换 document.body.style.setProperty('--primary-6', colorPalette(selectedColor, 6)); // ....
Copy after login

文档站点

还记得每个组件目录下的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)%%
Copy after login

它是如何一步步被渲染出我们想要的界面呢?

TEMPLATE.md的作用

TEMPLATE.md将被解析并生成中英文版READE.md(组件使用文档),之后在vue-router中被加载使用。

这时当我们访问路由/button,vite服务器将接管并调用一系列插件解析成浏览器识别的代码,最后由浏览器渲染出我们的文档界面。

1. 解析TEMPLATE 生成 README

简单起见,我们忽略国际化和使用例子部分。

%%API(button.vue)%% %%INTERFACE(interface.ts)%%
Copy after login

其中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);
Copy after login

最终生成README.zh-CN.md如下

```yaml meta: type: 组件 category: 通用 title: 按钮 Button description: 按钮是一种命令组件,可发起一个即时操作。 ``` @import ./__demo__/basic.md @import ./__demo__/disabled.md ## API ### `
Copy after login

2. 路由配置

const Button = () => import("@argo-design/argo-ui/components/button/README.zh-CN.md"); const router = createRouter({ { path: "/button", component: Button } }); export default router;
Copy after login

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()], });
Copy after login
// App.vue  
Copy after login
// readme.md @import ./__demo__/basic.md
Copy after login

开发之前我们先看看插件对README.md源码的解析转换流程。

1. 源码转换

首先我们来实现第一步: 源码转换。即将

@import "./__demo__/basic.md"
Copy after login

转换成

 
Copy after login

转换过程我们借助第三方markdown解析工具marked完成,一个高速,轻量,无阻塞,多平台的markdown解析器。

众所周知,md2html规范中,文本默认会被解析渲染成p标签。也就是说,README.md里的@import ./__demo__/basic.md会被解析渲染成

@import ./__demo__/basic.md

,这不是我想要的。所以需要对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 `\n`; }, }; marked.use({ extensions: [mdImport], }); export default marked;
Copy after login

我们新建了一个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 }); }, }; }
Copy after login
// 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 `   `; };
Copy after login

其中change-case是一个名称格式转换的工具,比如basic-demo转BasicDemo等。

transformMain返回的vueCode就是我们的目标vue模版了。但浏览器可不认识vue模版语法,所以我们仍要将其交给官方插件@vitejs/plugin-vuetransform钩子函数转换一下。

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)); }, }; }
Copy after login
// utils.ts export const getVueId = (id: string) => { return id.replace(".md", ".vue"); };
Copy after login

这里使用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)
Copy after login

在带有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)); } },
Copy after login
// utils.tsexport const isDemoMarkdown = (id: string) => { return //__demo__//.test(id); };
Copy after login
// 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 `   `; };
Copy after login

现在已经可以在浏览器中看到结果了,水平线和示例代码。

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 `   `; };
Copy after login
// utils.ts export const isVirtualModule = (id: string) => { return //@virtual/.test(id); };
Copy after login
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)); } // 省略其它代码... } } }
Copy after login
// 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); };
Copy after login

最后为示例代码加上样式。安装prismjs

yarn add prismjs
Copy after login
// 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"); }, });
Copy after login

项目入口引入css

// main.ts import "prismjs/themes/prism.css";
Copy after login

重启预览,以上就是vite-plugin-vue-docs的核心部分了。

遗留问题

最后回到上文构建组件style/index.ts遗留的问题,index.ts的内容很简单,即引入组件样式。

import "../../style/index.less"; // 全局样式 import "./index.less"; // 组件样式复制代码
Copy after login

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") }); } } }; }
Copy after login

结语

下篇暂定介绍版本发布,部署站点,集成到在线编辑器,架构复用等,技术涉及linux云服务器,站点服务器nginx,docker,stackblitz等。

(学习视频分享:vuejs入门教程编程基础视频

The above is the detailed content of [From the shallower to the deeper] Summary and sharing of practical development of vue component library. For more information, please follow other related articles on the PHP Chinese website!

Related labels:
source:juejin.cn
Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template
About us Disclaimer Sitemap
php.cn:Public welfare online PHP training,Help PHP learners grow quickly!