组件库工程化环境设计(四):轻松搭建组件库文档,组件示例原理剖析?

651 阅读9分钟

前言

自动化工具?npm 发包?样式按需引入?组件及样式自动引入?组件文档?单元测试?听着有点熟悉又带点陌生😱。。。别急,阅读本系列文章:将从工程化角度带你从0到1实现一个组件库,一站到底!

源码地址:github.com/Devil-Train…

上篇:组件库工程化环境设计(三):组件库编译硬核优化,三万字带你手写 compiler

阅读完本篇,你的组件库将具有以下特点:

  • 使用 rollup 作为打包工具 ✅
  • 支持 babel 和 esbuild 两种构建方式✅
  • 支持 cjs、esm 和浏览器直接引入✅
  • 支持组件样式按需引入✅
  • 自动引入☑️
  • 接入eslint、commitlint 等静态检测工具✅
  • 能够进行 npm 发包和产出 changelog☑️
  • 提供组件文档和组件示例✅🆕
  • 接入单元测试☑️

初始化

常见的组件库文档生成器有:

  • vitePress:基于 vue3 和 vite,vue 官方团队维护,更推荐
  • vuePress:vue 官方团队维护
  • storybook
  • dumi

我们这里选择 vitePress 作为组件库文档生成工具。

组件库根目录下 ./docs目录初始化 vitePress,具体初始化命令见 vitepress 官网,这里不再赘述。

初始化后目录结构如下:

.
├─ docs
│  ├─ .vitepress
│  │  └─ config.js
│  ├─ api-examples.md
│  ├─ markdown-examples.md
│  └─ index.md
└─ package.json

./docs/.vitepress/config.js即配置文件,需要为文档自定义一些配置。

路由

我希望组件文档和组件源码写在一起方便维护,其他文档如首页,放在 ./docs 目录下:

为此我需要配置路由重写:

import { defineConfig } from 'vitepress';
// https://vitepress.dev/reference/site-config
export default defineConfig({
  cleanUrls: true, // 让路由可以省略 .html
  srcDir: '../', // 源目录
  rewrites: {
    'src/packages/:pkg/docs/:lang(.+).md': ':lang/:pkg.md',
    'docs/:lang*': ':lang*',
  },
  ...
});

项目默认当前工作目录为根目录,vitePress 将在根目录寻找 .vitepress 特殊目录,所以我们在执行 vitePress 相关命令时应该带上根目录参数,即 vitepress dev docs,那么 ./docs被视作根目录。

源目录是 md 源文件所在的位置,源目录可以通过 srcDir配置,源目录是相对于项目根目录解析的。所以在这里配置:

srcDir: '../'

将源目录设置到当前工作目录,方便能够找到同级 ./src 下的组件文档。

配置路由重写能够实现访问组件文档:

zh-CN/fixed-size-list --------> src/packages/fixed-size-list/docs/zh-CN.md

rewrites: {
    'src/packages/:pkg/docs/:lang(.+).md': ':lang/:pkg.md'
  },

访问其他位于 ./docs 目录下的文档:

zh-CN/overview ---------> docs/zh-CN/overview.md

rewrites: {
    'docs/:lang*': ':lang*',
  },

多语言

组件文档

如上路由配置,约定了每一个组件 md 都采用语言命名区分:

其他文档都在 ./docs/语言路径下:

这样配合路由重写,能够实现多语言统一路径格式:

zh-CN/fixed-size-list --------> src/packages/fixed-size-list/docs/zh-CN.md

en-US/fixed-size-list --------> src/packages/fixed-size-list/docs/en-US.md

zh-CN/overview ---------> docs/zh-CN/overview.md

en-US/overview ---------> docs/en-US/overview.md

主题

要想支持主题多语言需要配置 locales,新建 ./docs/.vitepree/locales目录来存放主题多语言配置文件,统一以语言名称命名:

动态生成 locales 配置,这样新增语言可以直接在 locales 目录下新增配置文件,无需其他配置:

import path from 'path';
import { glob } from 'glob';
import type { DefaultTheme, LocaleConfig } from 'vitepress';
import type { LocaleModuleType } from './types';

export async function generateLocals() {
  const entries = await glob('docs/.vitepress/locales/*'); // 读取 docs/.vitepress/locales 下配置
  const locales : LocaleConfig<DefaultTheme.Config> = {};
  for (const entry of entries) {
    const fileName = path.basename(entry);
    const { locale, name } = (await import(`./locales/${fileName}`)) as LocaleModuleType;
    locales[name] = locale;
  }
  return locales;
}

这里的 name 取自文件名,如 zh-CN,一个多语言配置形如:

export const name = path.basename(__filename, '.ts');
export const locale: Locale = {
    label: '简体中文',
    lang: name, // 可选,将作为 `lang` 属性添加到 `html` 标签中   
    link: `/${name}/`, // 默认 /fr/ -- 显示在导航栏翻译菜单上,可以是外部的
    themeConfig, // 主题配置 
};

配置成功后即可语言切换:

主题

对默认主题进行改造,默认主题配置形如:

const themeConfig : DefaultTheme.Config = {
  nav: [
    { text: '首页', link: `/${name}/` },
    { text: '组件', link: `/${name}/overview` },
  ],
  sidebar,
  outline: {
    label: '本页目录',
  },
  darkModeSwitchLabel: '切换主题',
  lightModeSwitchTitle: '切换到浅色模式',
  darkModeSwitchTitle: '切换到深色模式',
  sidebarMenuLabel: '菜单',
  returnToTopLabel: '回到顶部',
  docFooter: {
    prev: '上一页',
    next: '下一页',
  },
};

其中大部分是页面上语言配置,nav 配置了顶部导航菜单:

sidebar

需要生成文档的组件不确定,所以侧边导航应该是动态生成的:

// docs/.vitepress/locales/zh-CN.ts
export const name = path.basename(__filename, '.ts'); // zh-CN

const developItems = await generateDevelopItems(name);
const basicItems = await generateBasicItems(name);
const sidebar : DefaultTheme.Sidebar = [
  {
    text: 'Development 开发指南',
    items: developItems,
  },
  {
    text: 'Basic 基础组件',
    items: basicItems,
  },
];

// docs/.vitepress/generator.ts
export async function generateBasicItems(localeName : string) { // 按照组件源目录生成 basic sidebar
  const componentEntries = await glob(`src/packages/*/`);
  const basicItems = componentEntries
    .reduce((prev : DefaultTheme.SidebarItem[], entry) => {
      const componentName = path.basename(entry);
      prev.push({ text: `${componentName}`, link: `/${localeName}/${componentName}` });
      return prev;
    }, [])
    .reverse();
  return basicItems;
}
export async function generateDevelopItems(localeName : string) { // 按照其他文档目录生成 develop sidebar
  const developEntries = await glob(`docs/${localeName}/*`);
  const developItems = developEntries
    .reduce((prev : DefaultTheme.SidebarItem[], entry) => {
      const mdName = path.basename(entry, '.md');
      if (mdName == 'index') {
        return prev;
      }
      prev.push({
        text: `${mdName}`,
        link: `/${localeName}/${mdName}`,
      });
      return prev;
    }, [])
    .reverse();
  return developItems;
}

这样生成的 sidebar 如下所示:

自定义主题

./docs/theme 目录下来自定义默认主题,vitepress 主题支持 extends 来继承其他主题配置:

import type { Theme } from 'vitepress';
import DefaultTheme from 'vitepress/theme';

import Components from '../../../src';

export default {
  extends: DefaultTheme, // 继承默认主题
    enhanceApp({ router, app }) {}, // 主题初始化后回调函数
} satisfies Theme;

组件文档以及 demo

vitepress 支持将 md 文档当作 vue 文件来解析,我们能够直接在 md 文档中使用 vue sfc 语法(除了 template 块),vue 组件能够直接在 md 文档中使用。

一个组件示例如下,示例中可能包含组件、code、title、desc:

而在一个 md 文档中可能包含多个这样的示例,但我们并不想在 md 文档中维护这么多的示例,虽然可以外部导入示例组件,但组件和组件 code 需要分别导入比较麻烦,所以我们需要一个通用的 demo 格式。

思路

如何实现呢?md 文档是被markdown-it解析的,可以设计一个 markdown-it 插件来解析我们约定的 demo 格式,一个 demo 格式可以像这样:

:::demo
按钮类型 type // title
按钮有三种类型:`主按钮``次按钮``线框按钮` 。主按钮在同一个操作区域建议最多出现一次。// desc
../example/index.vue // src
:::

或者形如一个元素:

<demo src="../demo.vue" title="Demo block" desc="use demo"></demo>

接着设计 markdown-it 插件将这种 demo 格式转化成 vitepress 能够接受的 vue 组件,这个组件包含:

  • title,code 使用 props 传入。
  • desc,demo 使用 插槽 传递。

一个转化完成的 demo 组件:

<demo
    title="组件标题"
    code="组件 code"
>
    <template slot="desc">组件描述</template>
    <template slot="demo">组件</template>
</demo>

这样 vitepress 就能够识别并渲染它了。

demo 组件

由上设计思路可知,这个 demo 组件应该大致是以下结构:

<script setup lang="ts">
  const props = defineProps({
    title: {
      type: String,
      default: '标题',
    },
    code: {
      type: String,
      default: 'demo',
    },
  });
</script>

<template>
  <div class="demo-wrapper">
    <div class="demo-title">{{ props.title }}</div>
    <div class="demo-content">
      <slot name="desc"></slot>
      <slot name="demo"></slot>
      <div class="demo-code">{{ props.code }}</div>
    </div>
  </div>
</template>

<style scoped></style>

demo 组件只负责呈现内容即可。

markdown-it 解析插件

markdown 插件要实现 转化 demo 格式 为 demo 组件,我们可以提取 :::demo::: 块的内容:

  • 第一行为 title。
  • 第二行为 desc。
  • 第三行是组件路径,通过 <script>引入组件,然后通过 demo slot 传递。
  • 根据路径读取组件文件获取组件内容即为 code。
:::demo
按钮类型 type // title
按钮有三种类型:`主按钮``次按钮``线框按钮` 。主按钮在同一个操作区域建议最多出现一次。// desc
../example/index.vue // src
:::

将解析出来的内容拼成以下格式返回:

<demo
    title="组件标题"
    code="组件 code"
>
    <template slot="desc">组件描述</template>
    <template slot="demo">组件</template>
</demo>

注册

@ruabick/vitepress-demo-block实现了 demo 组件,我们可以全局注册 demo 组件避免重复导入:

import DefaultTheme from 'vitepress/theme';
import DemoBlock from '@ruabick/vitepress-demo-block';
import '@ruabick/vitepress-demo-block/dist/style.css';
export default {
  extends: DefaultTheme,
  enhanceApp({ router, app }) {
    app.component('Demo', DemoBlock);
  },
} satisfies Theme;

@ruabick/md-demo-plugins,实现了 markdown-it 插件,需要在 vitepress 引入:

import { applyPlugins } from '@ruabick/md-demo-plugins';
export default defineConfig({
  markdown: {
    config: md => {
      applyPlugins(md);
    },
  },
});

在 md 文档中使用:

## 基础用法

<demo src="../src/packages/fixed-size-list/example/index.vue"></demo>

效果如下:

默认主题 table 优化

默认主题为了适配移动端 table,确保 table 在超出容器宽度时能够出现横向滚动条,将 table display: block,导致 PC 端 table 宽度不能 100%占满容器:

移动端存在display: block时 table 正常出现滚动条:

当我们去掉 display: block会发现 table 溢出了:

这似乎没什么问题,但是 table 在屏幕较宽的 PC 上无法布满整个容器,即使设置 width: 100%

当去掉 display: block,表现正常:

markdown-it table 插件

针对以上情况,只需要给 table 外包裹一个元素宽度 100%,table 不需要 display: block ,内滚动即可满足要求。

介于 组件文档以及 demo 章节中的思路,可以开发一个 markdown-it插件,在解析 md 文档时在 table 元素外包裹一个div 元素:

import type { MarkdownRenderer } from 'vitepress';

// 主要是解决 vitepress 默认主题 table 会 display block 来适配移动端宽度,但是 pc 上 table 无法自动 100% 宽度的问题;
export function wrapTablePlugin(md : MarkdownRenderer) {
  // 保存原始的table渲染器
  const defaultRender =
    md.renderer.rules.table_open ||
    function (tokens, idx, options, env, self) {
      return self.renderToken(tokens, idx, options);
    };

  md.renderer.rules.table_open = function (tokens, idx, options, env, self) {
    // 返回新增的div开始标签 + 原始的table开始标签
    return '<div class="table-wrapper">' + defaultRender(tokens, idx, options, env, self);
  };

  const defaultRenderClose =
    md.renderer.rules.table_close ||
    function (tokens, idx, options, env, self) {
      return self.renderToken(tokens, idx, options);
    };

  md.renderer.rules.table_close = function (tokens, idx, options, env, self) {
    // 返回原始的table结束标签 + 新增的div结束标签
    return defaultRenderClose(tokens, idx, options, env, self) + '</div>';
  };
}

效果:

结论

本篇介绍了 vitepress 常见配置, 对默认主题进行改造,包括动态生成 sidebar、多语言、对 table 进行优化,对组件示例 demo 格式做出详细分析,知其所以然,才能知其然。

性能

我对 vitepress 官网上提到的:

对任何页面的初次访问都将会是静态的、预呈现的 HTML ,以实现极快的加载速度和最佳的 SEO 。然后页面加载一个 JavaScript bundle,将页面变成 Vue SPA (这被称为“激活”)。

很好奇,如何实现单页应用却具有极佳的 SEO 的?

vitepress 在 build 时为每一个路由页面都单独生成了单独的静态 html,这样无论第一次访问哪个路径都能直接获取最终页面元素而不是未经转换的 Vue 模板,所以有良好的 SEO,后续通过激活变成 SPA,也就具有 SPA 页面特点了。

后续

至此,你的组件库将具有以下特点:

  • 使用 rollup 作为打包工具 ✅
  • 支持 babel 和 esbuild 两种构建方式✅
  • 支持 cjs、esm 和浏览器直接引入✅
  • 支持组件样式按需引入✅
  • 自动引入☑️
  • 接入eslint、commitlint 等静态检测工具✅
  • 能够进行 npm 发包和产出 changelog☑️
  • 提供组件文档和组件示例✅🆕
  • 接入单元测试☑️

后续我们将为组件库接入单元测试,帮助你和你的团队更快速、自信地构建复杂的组件库应用。

组件库工程化环境设计(五):测试方案选型及实战