Skip to content

如何创建一个组件库

组件库的基本要求

  • 开发环境,起个服务去调试代码
  • 组件库编译,生成umdesm模块的组件代码
  • 构建开发文档,说明一下组件怎么用
  • 单元测试
    • 暂无
  • 桌面端和移动端的组件预览
    • 暂无
  • 代码格式化和规范检测工具
    • huskycommintlint
  • 自动化的文档部署和测试流程
    • gitlab ci
    • github actions
    • jekens

组件库入口文件的设计

  • 可作为插件一次性注册所有组件、混入、其它功能插件

    1. 建立一个入口文件index.ts
    2. 将所有组件导入,作为一个数组,创建一个install函数,循环调用app.component
    3. 默认导出一个插件(这个install函数)
  • 可作为插件安装某一个组(组件、插件)、或者单独一个插件

    1. 将组设计为一个组件库
    2. 在主文件导出
  • 可单独安装某个插件或组件

    1. 每个组件新建一个文件夹,并且创建一个单独的index.ts文件
    2. 每个组件设计成一个插件(在组件对象上添加一个install方法)
    3. 在全局入口文件导出

组件库文档的设计

  • 创建一个md文件夹为每个组件、插件书写md文档说明
  • 创建一个examples文件夹存放每个组件、插件的example
  • 开发一个demo组件,展示example的预览、源代码、复制、查看仓库等功能

vuepress中预览vue2组件

vue2中,通常使用vuepress创建组件库文档,因为vuepress本身为vue2构建,可以很方便的注册组件,展示example

  1. vuepress中引入组件库:

    js
    // .vuepress/enhanceApp.js
    import ElementUI from 'element-ui';
    import 'element-ui/lib/theme-chalk/index.css';
    
    import DayeElements,{DictPlugin} from '../../dist/daye-elements.esm.js'
    import "../../dist/bundle.css";
    
    
    export default ({
        Vue, // VuePress 正在使用的 Vue 构造函数
        options, // 附加到根实例的一些选项
        router, // 当前应用的路由实例
        siteData // 站点元数据
    }) => {
        DictPlugin.Dict({});
        Vue.use(DayeElements)
        Vue.use(ElementUI)
    }

    此时就可以在md文件中使用组件库组件了,虽然可以直接在md文件上书写example,但是我们除了要展示example实例,还需要展示example的源代码供查看和复制,如果再写一份example源代码在文档中,这就需要写两次example,未免太麻烦了。

    这时就需要把example作为一个单文件组件,供vuepress实例化在文档和读取源代码展示在文档。

  2. 为组件创建example

    1. 创建一个examples文件夹
    2. 为组件库书写若干个example,为展示作准备

有了example,接下来需要做的是如何实例化这个example,如何读取example的源代码

  1. example创建一个容器组件<Demo />
  • 实例化example

  • 查看源代码、提供复制、查看仓库等功能

    将需要注册的相关组件放置在.vuepress/components文件夹下,将会被vuepress自动注册:

    • SourceCode:展示源代码

      vue
      <template>
        <div class="SourceCode language-vue" v-html="decoded"></div>
      </template>
      
      <script>
      export default {
        name: 'SourceCode',
        components: {},
        props: {
          source: {
            type: String,
            required: true,
          },
        },
        data() {
          return {};
        },
        computed: {
          decoded() {
            return decodeURIComponent(this.source);
          },
        },
        methods: {},
      };
      </script>
      
      <style>
      .language-vue {
        margin: 0;
        border-radius: 0;
      }
      .theme-default-content pre {
        margin: 0;
      }
      </style>
    • IconSourceCode:源代码图标

      vue
      <template>
        <svg viewBox="0 0 24 24">
          <path
            d="M8.7 15.9L4.8 12l3.9-3.9a.984.984 0 0 0 0-1.4a.984.984 0 0 0-1.4 0l-4.59 4.59a.996.996 0 0 0 0 1.41l4.59 4.6c.39.39 1.01.39 1.4 0a.984.984 0 0 0 0-1.4zm6.6 0l3.9-3.9l-3.9-3.9a.984.984 0 0 1 0-1.4a.984.984 0 0 1 1.4 0l4.59 4.59c.39.39.39 1.02 0 1.41l-4.59 4.6a.984.984 0 0 1-1.4 0a.984.984 0 0 1 0-1.4z"
            fill="currentColor"
          ></path>
        </svg>
      </template>
      
      <script>
      export default {
        name: 'IconSourceCode',
      };
      </script>
    • IconGitlabgitlab图标

      vue
      <template>
        <svg
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 48 48"
          width="48px"
          height="48px"
        >
          <path fill="#e53935" d="M24 43L16 20 32 20z" />
          <path fill="#ff7043" d="M24 43L42 20 32 20z" />
          <path fill="#e53935" d="M37 5L42 20 32 20z" />
          <path fill="#ffa726" d="M24 43L42 20 45 28z" />
          <path fill="#ff7043" d="M24 43L6 20 16 20z" />
          <path fill="#e53935" d="M11 5L6 20 16 20z" />
          <path fill="#ffa726" d="M24 43L6 20 3 28z" />
        </svg>
      </template>
      
      <script>
      export default {
        name: 'IconGitlab',
      };
      </script>
    • IconCopy:复制图标

      vue
      <template>
        <svg viewBox="0 0 24 24">
          <path
            d="M7 6V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-3v3c0 .552-.45 1-1.007 1H4.007A1.001 1.001 0 0 1 3 21l.003-14c0-.552.45-1 1.007-1H7zm2 0h8v10h2V4H9v2zm-2 5v2h6v-2H7zm0 4v2h6v-2H7z"
            fill="currentColor"
          ></path>
        </svg>
      </template>
      
      <script>
      export default {
        name: 'IconCopy',
      };
      </script>
    • Example:展示example

      vue
      <template>
        <div class="Example">
          <ClientOnly>
            <component :is="demo" v-if="demo" v-bind="$attrs" />
          </ClientOnly>
        </div>
      </template>
      
      <script>
      export default {
        name: 'Example',
        components: {},
        props: {
          file: {
            type: String,
            required: true,
          },
          demo: {
            type: Object,
          },
        },
        data() {
          return {};
        },
        methods: {},
      };
      </script>
      
      <style scoped>
      .Example {
        padding: 20px;
      }
      </style>
    • Demo:主组件

      vue
      <template>
        <ClientOnly>
          <div class="demo">
            <p class="example-description" v-html="decodedDescription" />
            <div class="example">
              <Example :file="path" :demo="formatPathDemos[path]" />
              <div class="op-btns">
                <IconSourceCode @click.native="setSourceVisible" />
                <IconCopy @click.native="copyCode" />
                <a :href="demoSourceUrl" rel="noreferrer noopener" target="_blank">
                  <IconGitlab />
                </a>
              </div>
              <SourceCode v-show="sourceVisible" :source="source" />
            </div>
          </div>
        </ClientOnly>
      </template>
      
      <script>
      import { createGitHubUrl } from '../utils/utils';
      import copy from 'copy-to-clipboard';
      const demos = require.context('../../examples', true, /\.vue$/);
      // 匹配对应的示例
      const formatPathDemos = {};
      demos.keys().forEach((key) => {
        formatPathDemos[key.replace('./', '').replace('.vue', '')] =
          demos(key).default;
      });
      export default {
        name: 'demo',
        components: {},
        props: {
          source: {
            type: String,
          },
          path: {
            type: String,
          },
          rawSource: {
            type: String,
          },
          description: {
            type: String,
          },
        },
        data() {
          return {
            formatPathDemos,
            sourceVisible: false,
          };
        },
        computed: {
          decodedDescription() {
            return decodeURIComponent(this.description);
          },
          demoSourceUrl() {
            const { repo, docsDir, docsBranch } = this.$themeConfig;
            return createGitHubUrl(repo, docsDir, docsBranch, this.path);
          },
        },
        methods: {
          async copyCode() {
            try {
              await copy(decodeURIComponent(this.rawSource));
              this.$message.success('复制成功!');
            } catch (e) {
              this.$message.error(e.message);
            }
          },
          setSourceVisible() {
            this.sourceVisible = !this.sourceVisible;
          },
        },
      };
      </script>
      <style scoped>
      .example-description {
        font-size: 14px;
      }
      .example {
        border: 1px solid #eee;
        border-radius: 5px;
        overflow: hidden;
      }
      .op-btns {
        padding: 0.5rem;
        display: flex;
        align-items: center;
        justify-content: flex-end;
        height: 3rem;
        line-height: 3rem;
        border-top: 1px solid #eee;
      }
      svg {
        width: 20px;
        height: 20px;
        line-height: 1em;
        margin-right: 20px;
        cursor: pointer;
        color: var(--text-color);
      }
      a {
        display: flex;
        align-items: center;
      }
      </style>

      utils/utils.js

      js
      export function createGitHubUrl(
        docsRepo,
        docsDir,
        docsBranch,
        path,
        folder = 'docs/examples/',
        ext = '.vue'
      ) {
        return `${docsRepo}/blob/${docsBranch}/${folder || ''}${path}${ext || ''}`;
      }

    在主组件中,使用require.context方法预加载了所有example,此时已经可以在demo中任意的使用example了,但是我们还需要在md文档中实例化demo,并告诉demoexample的路径是什么,源代码是什么,并将这些作为参数传入到demo供使用。

  1. 自定义容器,在md文档中加载Demo组件。

    我们知道,vuepress已经可以在文档中自由使用Demo组件,但是我们无法在文档中书写代码,自动获取到example源文件的具体信息,所以需要使用自定义容器来加载Demo组件:

    如何实现一个自定义容器请看这里:自定义容器

    vuepress创建一个markdown-it的插件放置在.vuepress/plugins文件夹:

    • markdown-it

      js
      const path = require('path');
      const fs = require('fs');
      const MarkdownIt = require('markdown-it');
      const mdContainer = require('markdown-it-container');
      const { highlight } = require('./highlight');
      
      const localMd = MarkdownIt();
      const docRoot = path.resolve(__dirname, '../../', 'examples');
      
      module.exports = {
        extendMarkdown: (md) => {
          md.use(mdContainer, 'demo', {
            validate(params) {
              return !!params.trim().match(/^demo\s*(.*)$/);
            },
      
            render(tokens, idx) {
              // const data = md.$data
              const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/);
              if (tokens[idx].nesting === 1 /* means the tag is opening */) {
                const description = m && m.length > 1 ? m[1] : '';
                const sourceFileToken = tokens[idx + 2];
                let source = '';
                const sourceFile = sourceFileToken.children?.[0].content ?? '';
      
                if (sourceFileToken.type === 'inline') {
                  source = fs.readFileSync(
                    path.resolve(docRoot, `${sourceFile}.vue`),
                    'utf-8'
                  );
                }
      
                if (!source) throw new Error(`Incorrect source file: ${sourceFile}`);
      
                return `<Demo source="${encodeURIComponent(
                  highlight(source, 'vue')
                )}" path="${sourceFile}" raw-source="${encodeURIComponent(
                  source
                )}" description="${encodeURIComponent(
                  localMd.render(description)
                )}">`;
              } else {
                return '</Demo>';
              }
            },
          });
        },
      };
    • highlight

      js
      'use strict';
      Object.defineProperty(exports, '__esModule', { value: true });
      exports.highlight = void 0;
      const chalk = require('chalk');
      const prism = require('prismjs');
      const loadLanguages = require('prismjs/components/index');
      const escapeHtml = require('escape-html');
      // required to make embedded highlighting work...
      loadLanguages(['markup', 'css', 'javascript']);
      function wrap(code, lang) {
        if (lang === 'text') {
          code = escapeHtml(code);
        }
        return `<pre v-pre><code>${code}</code></pre>`;
      }
      const highlight = (str, lang) => {
        if (!lang) {
          return wrap(str, 'text');
        }
        lang = lang.toLowerCase();
        const rawLang = lang;
        if (lang === 'vue' || lang === 'html') {
          lang = 'markup';
        }
        if (lang === 'md') {
          lang = 'markdown';
        }
        if (lang === 'ts') {
          lang = 'typescript';
        }
        if (lang === 'py') {
          lang = 'python';
        }
        if (!prism.languages[lang]) {
          try {
            loadLanguages([lang]);
          } catch (e) {
            console.warn(
              chalk.yellow(
                `[vitepress] Syntax highlight for language "${lang}" is not supported.`
              )
            );
          }
        }
        if (prism.languages[lang]) {
          const code = prism.highlight(str, prism.languages[lang], lang);
          return wrap(code, rawLang);
        }
        return wrap(str, 'text');
      };
      exports.highlight = highlight;
      //# sourceMappingURL=highlight.js.map

    .vuepress/config.js中使用插件:

    • config

      js
      const base = process.env.npm_config_base
        ? `/${process.env.npm_config_base}`
        : '/';
      const dest = process.env.npm_config_dest || '.vuepress/dist';
      module.exports = {
        base,
        dest,
        title: '大也科技 Vue2 组件库',
        head: [['link', { rel: 'icon', href: './images/logo.png' }]],
        description: '大也科技内部vue2组件库',
        plugins: [require('./plugins/markdown-it')],
        themeConfig: {
          logo: '/images/logo.png',
          repo: 'http://git.gxucreate.com:8091/gxdaye/web/infrastructure/elements',
          docsDir: 'docs',
          docsBranch: 'master',
          editLinks: true,
          nav: [
            { text: '指南', link: '/md/guide/' },
            { text: '通用组件', link: '/md/components-common/' },
            { text: 'ElementUI 扩展组件', link: '/md/components-fl/' },
            { text: '插件', link: '/md/plugins/' },
            { text: 'Mixins 混入', link: '/md/mixins/' },
          ],
          sidebar: {
            '/md/guide': 'auto',
            '/md/components-common/': [
              ['', '介绍'],
              {
                title: '组件',
                collapsable: false,
                children: [
                  ['count-to', 'count-to'],
                  ['marquee', 'marquee'],
                ],
              },
            ],
            '/md/components-fl/': [
              ['', '介绍'],
              {
                title: '组件',
                collapsable: false,
                children: [
                  ['button', 'button'],
                  ['checkbox', 'checkbox'],
                  ['radio', 'radio'],
                  ['select', 'select'],
                  ['date-picker', 'date-picker'],
                  ['daterange-picker', 'daterange-picker'],
                  ['table-page', 'table-page'],
                ],
              },
            ],
            '/md/plugins/': [
              ['', '介绍'],
              {
                title: '插件',
                collapsable: false,
                children: [['dict', '字典模块']],
              },
            ],
            '/md/mixins/': [
              ['', '介绍'],
              {
                title: 'Mixins 混入',
                collapsable: false,
                children: [['page', '分页']],
              },
            ],
          },
          lastUpdated: 'Last Updated',
          smoothScroll: true,
        },
      };
  2. 到这里,就可以在文档中使用自定义容器展示demo了:

    md
      :::demo 这是一个组件
    
      components-common/count-to/normal
    
      :::

为组件库书写属性、事件等文档:

如下书写属性说明表格:

md
## `Attributes`

<div class="attr-table" >

| 参数          | 说明                    | 类型       | 默认值 |
| ------------- | ----------------------- | ---------- | ------ |
| `getDataFunc` | 获取列表数据的 API 方法 | _Function_ | -      |

</div>

如下书写插槽说明表格:

md
## `slots`

<div class="slot-table" >

| 名称     | 说明 | 绑定参数 |
| -------- | ---- | -------- |
| `search` | -    | -        |

</div>

如下书写方法说明表格:

md
## `methods`

<div class="methods-table" >

| 方法名      | 说明                     | 参数 |
| ----------- | ------------------------ | ---- |
| `onRefresh` | 列表刷新方法,更新列表用 | -    |

</div>

如下书写事件说明表格:

md
## `Events`

<div class="event-table" >

| 事件名称 | 说明     | 回调参数        |
| -------- | -------- | --------------- |
| `click`  | 点击事件 | `(event:Event)` |

</div>

包裹一个div用于对表格样式进行定制:.vuepress/styles/index.styl

css
.el-date-table{
    display: table;
    border-top:none;
    tr:nth-child(2n){
        background-color: transparent;
    }
    tr{
        border-top:none;
    }
    th, td{
        border:none;
    }
}
.fl-table-page{
    table{
        margin:0;
        border-collapse:collapse;
        tr{
            border-top:0;
        }
        th, td{
            border:none;
        }
    }
}
.attr-table,
.event-table,
.slot-table,
.methods-table{
    table{
        th:first-of-type{
            min-width:120px;
        }
        th:nth-child(2){
            width:100%;
        }
        th:nth-child(3){
            min-width:100px;
        }
        th:nth-child(4){
            min-width:100px;
        }
        th:nth-child(5){
            min-width:100px;
        }
        td{
            word-break: break-all;
        }
    }
}

.event-table{
    table{
        th:first-of-type{
            min-width:140px;
        }
        th:nth-child(2){
            width:100%;
        }
        th:nth-child(3){
            min-width:120px;
        }
    }
}
.slot-table{
    table{
        th:nth-child(3){
            min-width:300px;
        }
    }
}