When it comes to Vue’s component library, you’ve probably heard a lot about it. So why build it yourself? Based on my own experience, the business often requires highly customized components, whether UI or interaction, which may be quite different from the existing component library in the market. At this time, if it is based on the existing component library for modification, the cost of understanding and modification is not small, even higher than building a set of their own. So building your own component library is a fairly common requirement.
In addition to the “components” themselves, another important aspect of a component library is documentation. If you look at the best open source component libraries on the market, all of them have high-quality components and a very standard and detailed set of documentation. In addition to the function description of the component, the document also has the capability of component interaction preview, so that users can reduce the learning cost as much as possible.
For many programmers, the most annoying thing is two things. One is that other people don’t document, and the other is to document yourself. Since documentation is essential in a component library, it should be as painless as possible to write documentation, especially one that includes both code presentation and text.
There are several frameworks on the market for presenting component documentation, such as Story Book, Docz, Dumi, and so on. They all have their own set of rules that allow you to present your components, but are costly for the team to learn, and they divide the experience somewhat between “development” and “documentation.”
If you can preview debugging as you develop a component library, it would be nice to preview debugging as part of your documentation. Developers only need to focus on the development of the components themselves, and then fill in the necessary API and event specification.
We are going to build such a set of experience super silky component library development framework. Give an example of the end result and then walk you through it step by step.
Online experience
Making the warehouse
Demo video
First, the development framework initialization
This development framework is called my-Kit. Vite + Vue3 + Typescript is used for the technical selection.
Execute the following command in a blank directory:
yarn create vite
Copy the code
After filling in the project name and selecting vuE-TS as the framework, the initialization of the project will be automatically completed. The code structure is as follows:
. ├ ─ ─ the README. Md ├ ─ ─ index. The HTML ├ ─ ─ package. The json ├ ─ ─ public ├ ─ ─ the SRC ├ ─ ─ tsconfig. Json ├ ─ ─ vite. Config. Ts └ ─ ─ yarn. The lockCopy the code
Create a new/Packages directory under the root directory where the development of subsequent components will take place. Take a
Packages ├ ─ ─ Button │ ├ ─ ─ docs │ │ ├ ─ ─ the README. Md / / component document │ │ └ ─ ─ demo. Vue / / interactive preview instance │ ├ ─ ─ index. The ts / / module export file │ └ ─ ─ the SRC │ └ ─ ─ index. Vue / / component ontology ├ ─ ─ index. The ts / / component library export file └ ─ ─ the list. The json / / component listCopy the code
Let’s take a look at each of these files.
packages/Button/src/index.vue
This file is the component’s ontology with the following code:
<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 part omitted}</style>
Copy the code
packages/Button/index.ts
To have the component library allow both global calls:
import { createApp } from 'vue'
import App from './app.vue'
import MyKit from 'my-kit'
createApp(App).use(MyKit)
Copy the code
Local calls are also allowed:
import { Button } from 'my-kit'
Vue.component('my-button', Button)
Copy the code
Therefore, a VuePlugin reference needs to be defined for each component. The contents of package/Button/index.ts are 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 };
Copy the code
packages/index.ts
This file is used as an export file for the component library itself, which exports a VuePlugin by default, as well as 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';
Copy the code
/packages/list.json
Finally, there is a description file of the component library, which is used to record the various descriptions of the components in it, which we will use later:
[{"compName": "Button"."compZhName": "Button"."compDesc": "This is a button."."compClassName": "button"}]Copy the code
After completing the initialization of the component library directory, our my-kit is ready to be used directly by the business side.
Go back to the root directory to find the SRC /main.ts file, and we’ll introduce the whole my-kit:
import { createApp } from 'vue'
import App from './App.vue'
import MyKit from '.. /packages';
createApp(App).use(MyKit).mount('#app')
Copy the code
<template>
<my-button>I'm a custom button</my-button>
</template>
Copy the code
After running Yarn Dev to start the Vite server, you can directly view the effect on the browser:
Real-time interactive documents
A component library should definitely have more than one type of component, Button, and each component should have its own documentation. This document not only describes the functions of components, but also has the functions of component preview, component code view, etc. We can call this kind of document “interactive document”. And for a good component development experience, we wanted the document to be real-time, so that when you change the code, you can see the latest effects in the document in real time. Let’s implement one of these.
Component documentation is typically written in Markdown, and this is no exception. We want to Markdown a page per page, so we need to use vue-router@next for routing control.
Create a new router.ts directory under the root directory/SRC and write the following code:
import { createRouter, createWebHashHistory, RouterOptions } from 'vue-router'
const routes = [{
title: 'button'.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;
Copy the code
As you can see, this is a typical vue-router@next configuration. Careful readers will notice that it introduces a Markdown file for routes with a path of /components/Button, which is invalid in the default Vite configuration. We need to introduce the viet-plugin-MD plug-in to parse the Markdown file and turn it into a Vue file. Go back to the root directory to find vite.config.ts and add the plugin:
import Markdown from 'vite-plugin-md'
export default defineConfig({
// Default configuration
plugins: [
vue({ include: [/\.vue$/./\.md$/] }),
Markdown(),
],
})
Copy the code
After this configuration, any Markdown file can be used as a Vue file.
Go back to/SRC/app.vue and rewrite it slightly, adding 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>
Copy the code
Finally we/packages/Button/docs/README. Md to write something inside:
# Button component
<my-button>I'm a custom button</my-button>
Copy the code
When you’re done, you can see it in your browser:
Since we introduced my-kit globally, any custom components registered in it can be written directly into Markdown files and rendered correctly, just like normal HTML tags. However, there is another problem, which is that these components are static and eventless and cannot execute JS logic. For example, if I want to implement a button click event and then pop up an alarm popover, I can’t write this directly:
# Button component
<my-button @click="() = >{alert(123)}"> I am a custom button</my-button>
Copy the code
So what to do? Remember the vite-plugin-MD plug-in you just introduced to parse Markdown? Take a look at its documentation. It supports writing setup functions inside Markdown! So we can wrap the code that needs to execute THE JS logic into a component and import it in Markdown via setup.
Create a demo. Vue in packages/Button/docs directory:
<template>
<div>
<my-button @click="onClick(1)">The first one</my-button>
<my-button @click="onClick(2)">The second</my-button>
<my-button @click="onClick(3)">The third</my-button>
</div>
</template>
<script setup>
const onClick = (num) = > { console.log(I was the first `${num}A custom button)}</script>
Copy the code
Then import it in Markdown:
<script setup>
import demo from './demo.vue'
</script>
# Button component
<demo />
Copy the code
And then finally you have a click response.
Meanwhile, if we make any changes to the
Code preview function
The interactive documentation is almost complete, but there is still a problem with not being able to visually preview the code. You might say, well, it’s easy to preview the code, why not just paste it in Markdown? That said, there’s nothing wrong with being lazy, but no one wants to copy code over and over again. They want a way to show the demo in a document and see the code directly, for example:
Just putting the component in a
As noted in the Vite development documentation, it supports adding a suffix to the end of a resource to control the type of resource introduced. For example, you can import xx from ‘xx? Raw ‘introduces the xx file as a string. With this capability, the
Create a Preview. Vue file that uses Props to get the source code, and then import it dynamically. The core code is shown below (the templates section is skipped for the moment)
export default {
props: {
/** Component name */
compName: {
type: String.default: ' '.require: true,},/** The component to display the code for */
demoName: {
type: String.default: ' '.require: true,}},data() {
return {
sourceCode: ' '}; },mounted() {
this.sourceCode = (
await import(/* @vite-ignore */ `.. /.. /packages/The ${this.compName}/docs/The ${this.demoName}.vue? raw`) ).default; }}Copy the code
The @vite-ignore comment is needed because vite is based on Rollup, where dynamic import is required to pass in a certain path, not a dynamically concatenated path. Specific reasons and its static analysis related, interested students can search to understand. Adding this comment here will ignore the Rollup requirement and directly support the script.
However, this method can be used in dev mode, and an error will be detected after the actual build is executed. The reason is the same, because Rollup cannot do static analysis, it cannot process files that need to be dynamically imported during the build phase, resulting in a situation where the corresponding resource cannot be found. There is no good way to solve this problem until now (2021.12.11). We have to judge the environment variables and bypass this problem by using the source code of the fetch request file in build mode. Rewrite as follows:
const isDev = import.meta.env.MODE === 'development';
if (isDev) {
this.sourceCode = (
await import(/* @vite-ignore */ `.. /.. /packages/The ${this.compName}/docs/The ${this.demoName}.vue? raw`)
).default;
} else {
this.sourceCode = await fetch(`/packages/The ${this.compName}/docs/The ${this.demoName}.vue`).then((res) = > res.text());
}
Copy the code
Given that the output directory after the build is /docs, remember to copy the /packages directory after the build as well, otherwise you will get 404 when running in Build mode.
Why bother to fetch in dev mode? The answer is no, because in Vite’s Dev mode, it is already pulling file resources through HTTP requests and processing them to the business layer. Therefore, the source code of the Vue file fetched through the FETCH in Dev mode has already been processed by Vite.
Once you have the source code, you just need to display it:
<template>
<pre>{{ sourceCode }}</pre>
</template>
Copy the code
But the source code display is ugly, with only dry characters, and we need to highlight them. I chose PrismJS for the highlighted solution, which is small and flexible enough to simply import a relevant CSS theme file and execute Prism.Highlightall (). The CSS theme file used in this example has been placed in the repository and is available for your own use.
Go back to the project, execute yarn Add prismjs -d to install Prismjs, and then introduce in the
import Prism from 'prismjs';
import '.. /assets/prism.css'; / / theme CSS
export default {
/ /... Omit...
async mounted() {
/ /... Omit...
await this.$nextTick(); Make sure the source code is rendered before highlightingPrism.highlightAll(); }},Copy the code
Since PrismJS does not support declarations for Vue files, source highlighting of Vue is done by setting it to HTML type. In the
<pre class="language-html"><code class="language-html">{{ sourceCode }}</code></pre>
Copy the code
After this adjustment, PrismJS automatically highlights the source code.
4. Imperative new component
So far, our entire “real-time interactive documentation” has been built. Does that mean we can deliver it to other students for real component development? Let’s say you’re another developer and I say to you, “All you have to do is create these files here, here and here, and then modify the configuration here and here to create a new component!” Do you feel like hitting someone? You, the component developer, don’t care what my configuration looks like or how the framework works, just want to be able to initialize a new component in the shortest possible time and start developing it. In order to satisfy this idea, it is necessary to make the previous steps more automatic and cheaper to learn.
International practice, first look at the effect and then look at the way of implementation:
As you can see from the renderings, a new component Foo is automatically generated after the terminal answers three questions. In the meantime, creating new files and changing configurations is a one-click process with no human intervention, and the rest of the work just revolves around Foo as a new component. We can call this one-click approach to component generation “imperative new component creation.”
To do this, we use the 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 from templates. Let’s do the interactive terminal first.
Go back to the root directory, create the /script/genNewComp directory, and create an infoCollector. Js file:
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.The '-')
.toLowerCase();
module.exports = async() = > {const meta = await inquirer
.prompt([
{
type: 'input'.message: 'Please enter the name of the component you want to create (in English only, starting with uppercase) :'.name: 'compName'}, {type: 'input'.message: Please enter the name of the component you want to create:.name: 'compZhName'
},
{
type: 'input'.message: 'Please enter a functional description of the component:'.name: 'compDesc'.default: 'Default: This is a new component'}])const { compName } = meta
meta.compClassName = kebabCase(compName)
return meta
}
Copy the code
When running this file through Node, three component information questions will be asked in the terminal, and the answers to compName, compZhName, and compDesc will be saved in the meta object and exported.
Once you have collected component-related information, it’s time to replace the content in the template and generate or modify the file via Handlebars.
Create a.template directory in /script/genNewComp and create templates for all the files needed for the new component as needed. In our framework, a component’s catalog looks like this:
Foo ├ ─ ─ docs │ ├ ─ ─ the README. Md │ └ ─ ─ demo. Vue ├ ─ ─ but ts └ ─ ─ the SRC └ ─ ─ index. The vueCopy the code
TPL, index.vue.tpl, readme.md. TPL, and Demo.vue.tpl. Because the new component needs a new route, router.ts also needs a template. Because of the lack of space to show the whole, 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 }}, };Copy the code
The contents in the double parentheses {{}} will eventually be replaced by handlebars. For example, we already know the following information about a new component:
{
"compName": "Button"."compZhName": "Button"."compDesc": "This is a button."."compClassName": "button"
}
Copy the code
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 };
Copy the code
The core code for template replacement is as follows:
const fs = require('fs-extra')
const handlebars = require('handlebars')
const { resolve } = require('path')
const installTsTplReplacer = (listFileContent) = > {
// Set the input/output paths
const installFileFrom = './.template/install.ts.tpl'
const installFileTo = '.. /.. /packages/index.ts'
// Read the template contents
const installFileTpl = fs.readFileSync(resolve(__dirname, installFileFrom), 'utf-8')
// Construct data from incoming information
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'),}// Replace the template content with handlebars
const installFileContent = handlebars.compile(installFileTpl, { noEscape: true })(installMeta)
// Render the template and print it to the specified directory
fs.outputFile(resolve(__dirname, installFileTo), installFileContent, err= > {
if (err) console.log(err)
})
}
Copy the code
The listFileContent in the above code is the contents of/Packages /list.json, which also needs to be dynamically updated with new components.
Once you’ve done the logic for template replacement, you can put it all together in one executable:
const infoCollector = require('./infoCollector')
const tplReplacer = require('./tplReplacer')
async function run() {
const meta = await infoCollector()
tplReplacer(meta)
}
run()
Copy the code
Add a new NPM script to package.json:
{
"scripts": {
"gen": "node ./script/genNewComp/index.js"}},Copy the code
After that, you can run YARN Gen to access the interactive terminal. After answering questions, you can automatically create component files and modify configurations, and preview the effects in real time in the interactive document.
Separate documentation and library build logic
In the default Vite configuration, the resulting yarn Build is an “interactive document site”, not a “component library” itself. To build a my-Kit component library and publish it to NPM, we need to separate the build logic.
Add a /build directory to the root directory and write base.js, lib.js, and doc.js to base configuration, library configuration, and document configuration, respectively.
base.js
Basic configuration, need to determine the path alias, configure the Vue plug-in and Markdown plug-in for the corresponding file parsing.
import { resolve } from 'path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Markdown from 'vite-plugin-md';
/ / documentation: https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
The '@': resolve(__dirname, './src'),
packages: resolve(__dirname, './packages'),}},plugins: [
vue({ include: [/\.vue$/./\.md$/] }),
Markdown(),
],
});
Copy the code
lib.js
Library build, used to build component libraries located in the/Packages directory, and need viet-plugin-dTS to help package some OF the TS declarations.
import baseConfig from './base.config';
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export defaultdefineConfig({ ... baseConfig,build: {
outDir: 'dist'.lib: {
entry: resolve(__dirname, '.. /packages/index.ts'),
name: 'MYKit'.fileName: (format) = > `my-kit.${format}.js`,},rollupOptions: {
// Be sure to externalize dependencies that you don't want packaged into the repository
external: ['vue'].output: {
Provide a global variable for these externalized dependencies in UMD build mode
globals: {
vue: 'Vue'}}}},plugins: [
...baseConfig.plugins,
dts(),
]
});
Copy the code
doc.js
The interactive document build configuration is pretty much the same as base, just change the output directory to docs.
import baseConfig from './vite.base.config';
import { defineConfig } from 'vite';
export defaultdefineConfig({ ... baseConfig,build: {
outDir: 'docs',}});Copy the code
Remember earlier when you build documents you need to copy the/Packages directory to the output directory as well? Pro test several Vite copy plug-ins are not good, simply write their own:
const child_process = require('child_process');
const copyDir = (src, dist) = > {
child_process.spawn('cp'['-r', , src, dist]);
};
copyDir('./packages'.'./docs');
Copy the code
After completing the above build configuration, 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",
Copy the code
Build :lib product:
Dist ├ ─ ─ my - kit. Es. Js ├ ─ ─ my - kit. The umd. Js ├ ─ ─ packages │ ├ ─ ─ Button │ │ ├ ─ ─ the index, which s │ │ └ ─ ─ the SRC │ │ └ ─ ─ index. The vue. Which s │ ├ ─ ─ Foo │ │ └ ─ ─ the index, which s │ └ ─ ─ the index, which s ├ ─ ─ the SRC │ └ ─ ─ env. Which s └ ─ ─ style. The CSSCopy the code
Build: Doc products:
Docs ├─ Assets │ ├─ ├.04F9b87a.js │ ├─ index.917a7eb.js │ ├─ index.917a7eb.js │ ├─ index.917a7e.js │ ├─ index.917a7a.css │ ├─ ├─ Vendor.234e3k.js ├─ index. HTML ├─ ├─Copy the code
And you’re done!
Sixth, the end
At this point, our component development framework is almost complete. It has relatively complete code development, real-time interactive documentation, and the ability to create new components by command. Developing components on it has become a super silky experience. Of course, it is still far from perfect, such as unit testing, E2E testing, etc., are not integrated, component library versioning and CHANGELOG still need to be plugged in, these imperfect parts are worth adding. This article is only to throw a brick to draw jade, also look forward to more exchanges ~