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
component as an example to see what the /packages directory looks like inside:

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
ontology Vue file, it will be documented in real time.

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

TAB shows the component’s code directly, with code highlighting, and that’s what an interactive document really looks like! Let’s take a look at how this should be implemented.

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 component gets the source code for the files you want to show.

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

component:

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

component template we specify the source code type as HTML directly:

<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 ~