如何创建一个组件库
组件库的基本要求
- 开发环境,起个服务去调试代码
- 组件库编译,生成
umd
和esm
模块的组件代码 - 构建开发文档,说明一下组件怎么用
- 单元测试
- 暂无
- 桌面端和移动端的组件预览
- 暂无
- 代码格式化和规范检测工具
husky
、commintlint
- 自动化的文档部署和测试流程
gitlab ci
github actions
jekens
组件库入口文件的设计
可作为插件一次性注册所有组件、混入、其它功能插件
- 建立一个入口文件
index.ts
, - 将所有组件导入,作为一个数组,创建一个
install
函数,循环调用app.component
- 默认导出一个插件(这个
install
函数)
- 建立一个入口文件
可作为插件安装某一个组(组件、插件)、或者单独一个插件
- 将组设计为一个组件库
- 在主文件导出
可单独安装某个插件或组件
- 每个组件新建一个文件夹,并且创建一个单独的
index.ts
文件 - 每个组件设计成一个插件(在组件对象上添加一个
install
方法) - 在全局入口文件导出
- 每个组件新建一个文件夹,并且创建一个单独的
组件库文档的设计
- 创建一个
md
文件夹为每个组件、插件书写md
文档说明 - 创建一个
examples
文件夹存放每个组件、插件的example
- 开发一个
demo
组件,展示example
的预览、源代码、复制、查看仓库等功能
vuepress
中预览vue2
组件
在vue2
中,通常使用vuepress
创建组件库文档,因为vuepress
本身为vue2
构建,可以很方便的注册组件,展示example
在
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
实例化在文档和读取源代码展示在文档。为组件创建
example
- 创建一个
examples
文件夹 - 为组件库书写若干个
example
,为展示作准备
- 创建一个
有了example
,接下来需要做的是如何实例化这个example
,如何读取example
的源代码
- 为
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>
IconGitlab
:gitlab
图标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
:jsexport 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
,并告诉demo
,example
的路径是什么,源代码是什么,并将这些作为参数传入到demo
供使用。
自定义容器,在
md
文档中加载Demo
组件。我们知道,
vuepress
已经可以在文档中自由使用Demo
组件,但是我们无法在文档中书写代码,自动获取到example
源文件的具体信息,所以需要使用自定义容器来加载Demo
组件:如何实现一个自定义容器请看这里:自定义容器
为
vuepress
创建一个markdown-it
的插件放置在.vuepress/plugins
文件夹:markdown-it
:jsconst 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
:jsconst 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, }, };
到这里,就可以在文档中使用自定义容器展示
demo
了:md:::demo 这是一个组件 components-common/count-to/normal :::
为组件库书写属性、事件等文档:
如下书写属性说明表格:
## `Attributes`
<div class="attr-table" >
| 参数 | 说明 | 类型 | 默认值 |
| ------------- | ----------------------- | ---------- | ------ |
| `getDataFunc` | 获取列表数据的 API 方法 | _Function_ | - |
</div>
如下书写插槽说明表格:
## `slots`
<div class="slot-table" >
| 名称 | 说明 | 绑定参数 |
| -------- | ---- | -------- |
| `search` | - | - |
</div>
如下书写方法说明表格:
## `methods`
<div class="methods-table" >
| 方法名 | 说明 | 参数 |
| ----------- | ------------------------ | ---- |
| `onRefresh` | 列表刷新方法,更新列表用 | - |
</div>
如下书写事件说明表格:
## `Events`
<div class="event-table" >
| 事件名称 | 说明 | 回调参数 |
| -------- | -------- | --------------- |
| `click` | 点击事件 | `(event:Event)` |
</div>
包裹一个div
用于对表格样式进行定制:.vuepress/styles/index.styl
.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;
}
}
}