When it comes to Vue's component library, everyone must be familiar with it, and you can list a lot of them. Then why do you need to build it yourself? Combining your own experience, you often need highly customized components in your business. Both UI and interaction may be quite different from the existing component libraries on the market. At this time, if the modification is based on the existing component library, the cost of understanding and modification is not small, even higher than building a set by yourself. Therefore, it is a fairly common requirement to build your own component library.
For a component library, besides the "component" itself, another very important thing is document display. Refer to the excellent open source component libraries on the market, all of which have both high-quality components and a set of very standardized and detailed documents. In addition to explaining the function of the component, the document also has the ability to preview the component interactively, so that the user's learning cost can be reduced as much as possible.
For many programmers, two things are the most annoying. One is that others do not write documents, and the other is that they write documents themselves. Since documentation is indispensable in the component library, we should minimize the pain of writing documentation, especially this kind of documentation that requires both code display and text description.
There are also many frameworks for component document display in the market, such as Story Book, Docz, Dumi, and so on. They all have their own set of rules that allow you to display your own components, but they are expensive for the team to learn. At the same time, they also split the experience between "development" and "documentation" to a certain extent.
If you can preview and debug while developing the component library, the content of the preview and debug should be part of the document. Developers only need to pay attention to the development of the component itself, and then add a little bit of necessary API and event descriptions.
This time we are going to build such a super silky component library development framework. Let's start with an example of the final result, and then teach everyone step by step to achieve it.
One, the development framework initialization
We named this development framework MY-Kit
. Vite + Vue3 + Typescript is used in technology selection.
Execute the following commands in a blank directory:
yarn create vite
After filling in the project name and selecting the frame as vue-ts in turn, the initialization of the project will be automatically completed. The code structure is as follows:
.
├── README.md
├── index.html
├── package.json
├── public
├── src
├── tsconfig.json
├── vite.config.ts
└── yarn.lock
/packages
directory 061b57adc1b992 under the root directory, and subsequent component development will be carried out in this directory. Take a <my-button />
component as an example to see what the inside of the /packages
packages
├── Button
│ ├── docs
│ │ ├── README.md // 组件文档
│ │ └── demo.vue // 交互式预览实例
│ ├── index.ts // 模块导出文件
│ └── src
│ └── index.vue // 组件本体
├── index.ts // 组件库导出文件
└── list.json // 组件列表
Let's take a look at what these files are.
packages/Button/src/index.vue
This file is the body of the component, and the code is as follows:
<template>
<button class="my-button" @click="$emit('click', $event)">
<slot></slot>
</button>
</template>
<script lang="ts" setup>
defineEmits(['click']);
</script>
<style scoped>
.my-button {
// 样式部分省略
}
</style>
packages/Button/index.ts
In order to allow the component library to allow global calls:
import { createApp } from 'vue'
import App from './app.vue'
import MyKit from 'my-kit'
createApp(App).use(MyKit)
Local calls are also allowed:
import { Button } from 'my-kit'
Vue.component('my-button', Button)
Therefore, a reference method of VuePlugin
needs to be defined for each component. package/Button/index.ts
is as follows:
import { App, Plugin } from 'vue';
import Button from './src/index.vue';
export const ButtonPlugin: Plugin = {
install(app: App) {
app.component('q-button', Button);
},
};
export { Button };
packages/index.ts
This file is used as the export file of the component library itself, it exports a VuePlugin
default, and also exports different components:
import { App, Plugin } from 'vue';
import { ButtonPlugin } from './Button';
const MyKitPlugin: Plugin = {
install(app: App) {
ButtonPlugin.install?.(app);
},
};
export default MyKitPlugin;
export * from './Button';
/packages/list.json
Finally, there is a description file of the component library, which is used to record various descriptions of the components in it. We will use this later:
[
{
"compName": "Button",
"compZhName": "按钮",
"compDesc": "这是一个按钮",
"compClassName": "button"
}
]
After completing the initialization of the above component library catalog, at this time our MY-Kit
can be used directly by the business side.
Go back to the root directory and find the src/main.ts
file, we MY-Kit
import the entire 061b57adc1bb35:
import { createApp } from 'vue'
import App from './App.vue'
import MyKit from '../packages';
createApp(App).use(MyKit).mount('#app')
Rewrite src/App.vue
and introduce <my-button></my-button>
try:
<template>
<my-button>我是自定义按钮</my-button>
</template>
After running yarn dev
start the Vite server, you can see the effect directly on the browser:
2. Real-time interactive documents
A component library certainly has more than one Button component, and each component should have its own independent document. This document not only has a description of the various functions of the component, but also has functions such as component preview and component code viewing. We can call this kind of document an "interactive document". At the same time, for a good component development experience, we hope that this document is real-time. If you modify the code here, you can see the latest effect in the document in real time. Next we will implement such a function.
Component documentation is generally written in Markdown, and this is no exception. vue-router@next
Markdown, so we need to use 061b57adc1bbd5 to achieve routing control.
Create a new /src
router.ts
in the root directory, and write the following code:
import { createRouter, createWebHashHistory, RouterOptions } from 'vue-router'
const routes = [{
title: '按钮',
name: 'Button',
path: '/components/Button',
component: () => import(`packages/Button/docs/README.md`),
}];
const routerConfig = {
history: createWebHashHistory(),
routes,
scrollBehavior(to: any, from: any) {
if (to.path !== from.path) {
return { top: 0 };
}
},
};
const router = createRouter(routerConfig as RouterOptions);
export default router;
You can see that this is a typical vue-router@next
configuration. Careful readers will find that /components/Button
. This is invalid in the default Vite configuration. We need to introduce the vite-plugin-md
plug-in to parse the Markdown file. Turn it into a Vue file. Go back to the root directory and find vite.config.ts
, add the plug-in:
import Markdown from 'vite-plugin-md'
export default defineConfig({
// 默认的配置
plugins: [
vue({ include: [/\.vue$/, /\.md$/] }),
Markdown(),
],
})
After this configuration, any Markdown file can be used like a Vue file.
Go back to /src/App.vue
, rewrite it a bit, and add a sidebar and main area:
<template>
<div class="my-kit-doc">
<aside>
<router-link v-for="(link, index) in data.links" :key="index" :to="link.path">{{ link.name }}</router-link>
</aside>
<main>
<router-view></router-view>
</main>
</div>
</template>
<script setup>
import ComponentList from 'packages/list.json';
import { reactive } from 'vue'
const data = reactive({
links: ComponentList.map(item => ({
path: `/components/${item.compName}`,
name: item.compZhName
}))
})
</script>
<style lang="less">
html,
body {
margin: 0;
padding: 0;
}
.my-kit-doc {
display: flex;
min-height: 100vh;
aside {
width: 200px;
padding: 15px;
border-right: 1px solid #ccc;
}
main {
width: 100%;
flex: 1;
padding: 15px;
}
}
</style>
Finally, we write something casually in /packages/Button/docs/README.md
# 按钮组件
<my-button>我是自定义按钮</my-button>
After the completion, you can see the effect on the browser:
Since we have introduced MY-Kit
, all the custom components registered in it can be directly written in the Markdown file like normal HTML tags and rendered correctly. But there is another problem here, that is, these components are static and eventless and cannot execute JS logic. For example, when I want to realize that clicking a button triggers a click event and then an alert pop-up window pops up, I cannot write it directly like this:
# 按钮组件
<my-button @click="() => { alert(123) }">我是自定义按钮</my-button>
then what should we do? vite-plugin-md
for parsing Markdown that was just introduced? Look carefully at its documentation, it supports writing setup functions in Markdown! Therefore, we can encapsulate the code that needs to execute JS logic into a component, and then introduce it through setup in Markdown.
First create a new demo.vue
in the packages/Button/docs
directory:
<template>
<div>
<my-button @click="onClick(1)">第一个</my-button>
<my-button @click="onClick(2)">第二个</my-button>
<my-button @click="onClick(3)">第三个</my-button>
</div>
</template>
<script setup>
const onClick = (num) => { console.log(`我是第 ${num} 个自定义按钮`) }
</script>
Then introduce it in Markdown:
<script setup>
import demo from './demo.vue'
</script>
# 按钮组件
<demo />
Finally, click response can be achieved.
At the same time, if we <my-button />
, it can be reflected in the document in real time.
Three, code preview function
But the interactive document is basically ready, but there is another problem, that is, the code cannot be previewed intuitively. You might say that it’s easy to preview the code, just paste the code directly in Markdown? That being said, there is nothing wrong, but adhering to "Lazy is the first productive force", it is estimated that no one likes to copy the code they have written again. I must hope that there is a way to write the demo in the document. Show it, and you can see its code directly, for example:
Just put the component into a <Preview />
tag to directly display the code of the component, and it also has the function of code highlighting. This is what the interactive document really has! Next, we will study how to implement this function.
It is documented in Vite's development document that it supports adding a suffix to the end of the resource to control the type of resource introduced. For example, the import xx from 'xx?raw'
as a string through 061b57adc1bdbc. Based on this ability, we can obtain the source code of the files that need to be displayed <Preview />
First, create a Preview.vue
file 061b57adc1bdd3, the core content of which is to get the source code path through Props, and then get the source code through dynamic import. The core code is shown below (template omitted)
export default {
props: {
/** 组件名称 */
compName: {
type: String,
default: '',
require: true,
},
/** 要显示代码的组件 */
demoName: {
type: String,
default: '',
require: true,
},
},
data() {
return {
sourceCode: '',
};
},
mounted() {
this.sourceCode = (
await import(/* @vite-ignore */ `../../packages/${this.compName}/docs/${this.demoName}.vue?raw`)
).default;
}
}
@vite-ignore
needs to be added here because Vite is based on Rollup. In Rollup, the dynamic import is required to pass in a certain path, which cannot be such a dynamic splicing path. The specific reason is related to its static analysis, and interested students can search for it by themselves. Adding this comment here will ignore Rollup's requirements and directly support the wording.
But this writing method is available in dev mode, and you will find an error when you run it after the build is actually executed. The reason is the same. Because Rollup cannot perform static analysis, it cannot process files that need to be dynamically imported during the construction phase, resulting in a situation where the corresponding resources cannot be found. As of now (2021.12.11), there is no good way to solve this problem. I have to judge the environment variables and bypass it by requesting the source code of the file fetch
After rewriting as follows:
const isDev = import.meta.env.MODE === 'development';
if (isDev) {
this.sourceCode = (
await import(/* @vite-ignore */ `../../packages/${this.compName}/docs/${this.demoName}.vue?raw`)
).default;
} else {
this.sourceCode = await fetch(`/packages/${this.compName}/docs/${this.demoName}.vue`).then((res) => res.text());
}
Assuming that the output directory after the build is/docs
, remember to/packages
directory 061b57adc1be69 after the build, otherwise 404 will appear when running in build mode.
Some students may ask again, why is it so troublesome? Isn't it possible fetch
The answer is no, because in Vite's dev mode, it originally used http requests to pull file resources and processed them before reaching the level of business. Therefore, the source code of the Vue file fetch
After getting the source code, just show it:
<template>
<pre>{{ sourceCode }}</pre>
</template>
But this kind of source code display is very ugly, there are only dry characters, we need to add a highlight to them. I chose PrismJS for the highlighting scheme, which is very small and flexible. It only needs to introduce a related CSS theme file, and then execute Prism.highlightAll()
. The CSS theme file used in this example has been placed in the warehouse and can be used by yourself.
Back to the project, execute yarn add prismjs -D
install PrismJS, and then <Preview />
component:
import Prism from 'prismjs';
import '../assets/prism.css'; // 主题 CSS
export default {
// ...省略...
async mounted() {
// ...省略...
await this.$nextTick(); // 确保在源码都渲染好了以后再执行高亮
Prism.highlightAll();
},
}
Since PrismJS does not support the declaration of Vue files, Vue's source code highlighting is achieved by setting it to HTML type. In <Preview />
component, we directly specify the source code type as HTML:
<pre class="language-html"><code class="language-html">{{ sourceCode }}</code></pre>
After this adjustment, PrismJS will automatically highlight the source code.
Four, imperative new components
So far, our entire "real-time interactive document" has been built. Does it mean that it can be delivered to other students for real component development? Suppose you are another development classmate, I tell you: "You just need to create these files here, here and here, and then modify the configuration here and here to create a new component!" Do you really want to hit people? ? As a component developer, you don't want to care about my configuration and how the framework runs. You only want to be able to initialize a new component in the shortest time and then start development. In order to satisfy this idea, we need to make the previous processing steps more automated, and the learning cost is lower.
International practice, first look at the completion effect and then look at the realization method:
As you can see from the renderings, after the terminal has answered three questions, a new component Foo
automatically generated. Foo
a new file or modifying the configuration, it is completed with one click, without manual intervention. The next work only needs to be carried out around the new component 061b57adc1c00b. We can call this one-click method of generating components as "command-style new components".
To achieve this function, we have two tools inquirer
and handlebars
The former is used to create interactive terminals to ask questions and collect answers; the latter is used to generate content based on templates. Let's first make an interactive terminal.
Go back to the root directory, create a new /script/genNewComp
, and then create a file infoCollector.js
const inquirer = require('inquirer')
const fs = require('fs-extra')
const { resolve } = require('path')
const listFilePath = '../../packages/list.json'
// FooBar --> foo-bar
const kebabCase = string => string
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/[\s_]+/g, '-')
.toLowerCase();
module.exports = async () => {
const meta = await inquirer
.prompt([
{
type: 'input',
message: '请输入你要新建的组件名(纯英文,大写开头):',
name: 'compName',
},
{
type: 'input',
message: '请输入你要新建的组件名(中文):',
name: 'compZhName'
},
{
type: 'input',
message: '请输入组件的功能描述:',
name: 'compDesc',
default: '默认:这是一个新组件'
}
])
const { compName } = meta
meta.compClassName = kebabCase(compName)
return meta
}
node
running the file through 061b57adc1c079, three component information related questions will be asked in the terminal in turn, and the answers compName
(component English name), compZhName
(component Chinese name) and compDesc
(component description) will be saved in the meta
object and exported .
After collecting the component-related information, it is necessary to replace the content in the template handlebars
Create a .template
/script/genNewComp
in 061b57adc1c0a4, and then create a template for all files required by the new component as needed. In our framework, the catalog of a component looks like this:
Foo
├── docs
│ ├── README.md
│ └── demo.vue
├── index.ts
└── src
└── index.vue
There are 4 files in total, so you need to create index.ts.tpl
, index.vue.tpl
, README.md.tpl
and demo.vue.tpl
. At the same time, because the new component needs a new route, router.ts
also needs a corresponding template. Due to the space relationship, it is not fully displayed, so just pick the core index.ts.tpl
to have a look:
import { App, Plugin } from 'vue';
import {{ compName }} from './src/index.vue';
export const {{ compName }}Plugin: Plugin = {
install(app: App) {
app.component('my-{{ compClassName }}', {{ compName }});
},
};
export {
{{ compName }},
};
The content in the double brackets {{}}
will eventually be replaced by handlebars
. For example, we have learned that the information of a new component is as follows:
{
"compName": "Button",
"compZhName": "按钮",
"compDesc": "这是一个按钮",
"compClassName": "button"
}
Then the template index.ts.tpl
will eventually be replaced with this:
import { App, Plugin } from 'vue';
import Button from './src/index.vue';
export const ButtonPlugin: Plugin = {
install(app: App) {
app.component('my-button', Button);
},
};
export { Button };
The core code of template replacement is as follows:
const fs = require('fs-extra')
const handlebars = require('handlebars')
const { resolve } = require('path')
const installTsTplReplacer = (listFileContent) => {
// 设置输入输出路径
const installFileFrom = './.template/install.ts.tpl'
const installFileTo = '../../packages/index.ts'
// 读取模板内容
const installFileTpl = fs.readFileSync(resolve(__dirname, installFileFrom), 'utf-8')
// 根据传入的信息构造数据
const installMeta = {
importPlugins: listFileContent.map(({ compName }) => `import { ${compName}Plugin } from './${compName}';`).join('\n'),
installPlugins: listFileContent.map(({ compName }) => `${compName}Plugin.install?.(app);`).join('\n '),
exportPlugins: listFileContent.map(({ compName }) => `export * from './${compName}'`).join('\n'),
}
// 使用 handlebars 替换模板内容
const installFileContent = handlebars.compile(installFileTpl, { noEscape: true })(installMeta)
// 渲染模板并输出至指定目录
fs.outputFile(resolve(__dirname, installFileTo), installFileContent, err => {
if (err) console.log(err)
})
}
/packages/list.json
in the above code is the listFileContent
, this JSON file also needs to be dynamically updated according to new components.
After completing the relevant logic of template replacement, you can put them all into one executable file:
const infoCollector = require('./infoCollector')
const tplReplacer = require('./tplReplacer')
async function run() {
const meta = await infoCollector()
tplReplacer(meta)
}
run()
Add a npm script to package.json
:
{
"scripts": {
"gen": "node ./script/genNewComp/index.js"
},
}
Then just execute yarn gen
to enter the interactive terminal, answer the questions and automatically complete the functions of creating new component files and modifying the configuration, and can preview the effect in real time in the interactive document.
Five, separate the construction logic of the document and the library
In the default Vite configuration, yarn build
is the "interactive document website", not the "component library" itself. In order to build a my-kit
component library and publish it to npm, we need to separate the logic of the build.
/build
directory under the root directory, write base.js
, lib.js
and doc.js
, which are the basic configuration, library configuration and document configuration respectively.
base.js
Basic configuration, you need to determine the path alias, configure the Vue plug-in and Markdown plug-in for the analysis of the corresponding file.
import { resolve } from 'path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Markdown from 'vite-plugin-md';
// 文档: https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, './src'),
packages: resolve(__dirname, './packages'),
},
},
plugins: [
vue({ include: [/\.vue$/, /\.md$/] }),
Markdown(),
],
});
lib.js
Library building, used to build the component library /packages
vite-plugin-dts
is needed to help package some TS declaration files.
import baseConfig from './base.config';
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
...baseConfig,
build: {
outDir: 'dist',
lib: {
entry: resolve(__dirname, '../packages/index.ts'),
name: 'MYKit',
fileName: (format) => `my-kit.${format}.js`,
},
rollupOptions: {
// 确保外部化处理那些你不想打包进库的依赖
external: ['vue'],
output: {
// 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
globals: {
vue: 'Vue'
}
}
}
},
plugins: [
...baseConfig.plugins,
dts(),
]
});
doc.js
The interactive document construction configuration is almost the same as base, only need to modify the output directory to docs
.
import baseConfig from './vite.base.config';
import { defineConfig } from 'vite';
export default defineConfig({
...baseConfig,
build: {
outDir: 'docs',
},
});
Remember the above mentioned that when building the document, the /packages
directory should also be copied to the output directory? I tested several Vite replication plug-ins, but they didn’t work well, so I just wrote one myself:
const child_process = require('child_process');
const copyDir = (src, dist) => {
child_process.spawn('cp', ['-r', , src, dist]);
};
copyDir('./packages', './docs');
After completing the above build configuration, you can modify the npm script:
"dev": "vite --config ./build/base.config.ts",
"build:lib": "vue-tsc --noEmit && vite build --config ./build/lib.config.ts",
"build:doc": "vue-tsc --noEmit && vite build --config ./build/doc.config.ts && node script/copyDir.js",
The product of build:lib
dist
├── my-kit.es.js
├── my-kit.umd.js
├── packages
│ ├── Button
│ │ ├── index.d.ts
│ │ └── src
│ │ └── index.vue.d.ts
│ ├── Foo
│ │ └── index.d.ts
│ └── index.d.ts
├── src
│ └── env.d.ts
└── style.css
The product of build:doc
docs
├── assets
│ ├── README.04f9b87a.js
│ ├── README.e8face78.js
│ ├── index.917a75eb.js
│ ├── index.f005ac77.css
│ └── vendor.234e3e3c.js
├── index.html
└── packages
That's it!
Six, the end
So far, our component development framework has been basically completed. It has relatively complete code development, real-time interactive documentation, and command-based new component capabilities. Developing components on it already has a super silky experience. Of course, it is still a long way from perfection. For example, unit testing, E2E testing, etc. have not yet been integrated. The version management and CHANGELOG of the component library still need to be accessed. These imperfect parts are worth adding. This article is purely an introduction, and I look forward to more exchanges~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。