乐趣区

关于前端:基于-Vite-搭建开发体验超级丝滑的-Vue3-组件库开发框架

说到 Vue 的组件库,大家必定早已耳熟能详,随随便便就能列举出一大堆。那为什么还须要本人去搭建呢?联合本身的教训,在业务中往往须要高度定制化的组件,无论是 UI 和交互,可能都会跟市面上现有的组件库有着较大的出入。这个时候如果是基于现有的组件库进行批改的话,其了解老本和批改老本也不小,甚至比本人搭建一套还要高。因而搭建一套本人的组件库还是一个相当常见的需要。

对于一个组件库来说,除了”组件“自身以外,另个一个十分重要的货色就是文档展现。参考市面上优良的开源组件库,无一不是既有高质量的组件,更有一套十分标准且具体的文档。文档除了对组件的性能进行阐明以外,同时也具备了组件交互预览的能力,让用户的学习老本尽可能地升高。

对于许多程序员来说,最厌恶的无非是两件事。一件是他人不写文档,另一件是本人写文档。既然在组件库里文档是必不可少的,那么咱们应该尽可能地缩小写文档的苦楚,尤其是这种既要有代码展现、又要有文字说明的文档。

市面上对于组件文档展现的框架也有不少,比方 Story Book、Docz、Dumi 等等。它们都有一套本人的规定可能让你展现本人的组件,然而对于团队来说学习老本较高,同时它们也在肯定水平上割裂了“开发”和“写文档”之间的体验。

如果在开发组件库的过程中可能一边开发一边预览调试,预览调试的内容就是文档的一部分就好了。开发者只须要关注组件自身的开发,而后再略微补上一点必要的 API 和事件阐明即可。

咱们这次就要来搭建这么一套体验超级丝滑的组件库开发框架。先上一个最终成绩的例子,随后再一步一步地教大家去实现。

在线体验

Github 仓库

演示视频

一、开发框架初始化

这一套开发框架咱们把它命名为 MY-Kit。在技术选型上应用的是 Vite + Vue3 + Typescript。

在空白目录执行下列命令:

yarn create vite

顺次填写项目名称和抉择框架为 vue-ts 后,将会主动实现我的项目的初始化,代码构造如下:

.
├── README.md
├── index.html
├── package.json
├── public
├── src
├── tsconfig.json
├── vite.config.ts
└── yarn.lock

在根目录下新建一个 /packages 目录,后续组件的开发都会在该目录进行。以一个 <my-button /> 组件为例,看看 /packages 目录外部是什么样的:

packages
├── Button
│   ├── docs
│   │   ├── README.md  // 组件文档
│   │   └── demo.vue   // 交互式预览实例
│   ├── index.ts       // 模块导出文件
│   └── src
│       └── index.vue  // 组件本体
├── index.ts           // 组件库导出文件
└── list.json          // 组件列表 

上面别离看看这些文件都是些什么内容。


packages/Button/src/index.vue

该文件是组件的本体,代码如下:

<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

为了让组件库既容许全局调用:

import {createApp} from 'vue'
import App from './app.vue'

import MyKit from 'my-kit'

createApp(App).use(MyKit)

也容许部分调用:

import {Button} from 'my-kit'

Vue.component('my-button', Button)

因而须要为每一个组件定义一个 VuePlugin 的援用形式。package/Button/index.ts 的内容如下:

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

该文件是作为组件库自身的导出文件,它默认导出了一个 VuePlugin,同时也导出了不同的组件:

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

最初就是组件库的一个记述文件,用来记录了它外面组件的各种阐明,这个咱们前面会用到:

[
  {
    "compName": "Button",
    "compZhName": "按钮",
    "compDesc": "这是一个按钮",
    "compClassName": "button"
  }
]

实现了上述组件库目录的初始化当前,此时咱们的 MY-Kit 是曾经能够被业务侧间接应用了。

回到根目录下找到 src/main.ts 文件,咱们把整个 MY-Kit 引入:

import {createApp} from 'vue'
import App from './App.vue'

import MyKit from '../packages';

createApp(App).use(MyKit).mount('#app')

改写 src/App.vue,引入 <my-button></my-button> 试一下:

<template>
  <my-button> 我是自定义按钮 </my-button>
</template>

运行 yarn dev 开启 Vite 的服务器当前,就能够间接在浏览器上看到成果了:

二、实时可交互式文档

一个组件库必定不止有 Button 一种组件,每个组件都应该有它独立的文档。这个文档不仅有对组件各项性能的形容,更应该具备组件预览、组件代码查看等性能,咱们能够把这种文档称之为“可交互式文档”。同时为了良好的组件开发体验,咱们心愿这个文档是实时的,这边批改代码,那边就能够在文档里实时地看到最新的成果。接下来咱们就来实现这么一个性能。

组件的文档个别是用 Markdown 来写,在这里也不例外。咱们心愿一个 Markdown 一个页面,因而须要应用 vue-router@next 来实现路由管制。

在根目录的 /src 底下新建 router.ts,写入如下代码:

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;

能够看到这是一个典型的 vue-router@next 配置,仔细的读者会发现这里为 path 为 /components/Button 的路由引入了一个 Markdown 文件,这个在默认的 Vite 配置里是有效的,咱们须要引入 vite-plugin-md 插件来解析 Markdown 文件并把它变成 Vue 文件。回到根目录下找到 vite.config.ts,增加该插件:

import Markdown from 'vite-plugin-md'

export default defineConfig({
  // 默认的配置
  plugins: [vue({ include: [/\.vue$/, /\.md$/] }),
    Markdown(),],
})

这样配置当前,任意的 Markdown 文件都能像一个 Vue 文件一样被应用了。

回到 /src/App.vue,稍作改写,减少一个侧边栏和主区域:

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

最初咱们往 /packages/Button/docs/README.md 外面轻易写点货色:

# 按钮组件

<my-button> 我是自定义按钮 </my-button>

实现当前就能在浏览器上看到成果了:

因为咱们全局引入了 MY-Kit,所以外面所注册的自定义组件都能够间接在 Markdown 文件中像一般 HTML 标签一样被写入并被正确渲染。然而这里也有另一个问题,就是这些组件都是动态的无事件的,无奈执行 JS 逻辑。比方当我想要实现点击按钮触发 click 事件而后弹一个告警弹窗进去,是无奈间接这么写的:

# 按钮组件

<my-button @click="() => { alert(123) }"> 我是自定义按钮 </my-button>

那怎么办呢?还记得刚刚引入的解析 Markdown 的插件 vite-plugin-md 吗?认真看它的文档,它是反对在 Markdown 外面写 setup 函数的!因而咱们能够把须要执行 JS 逻辑的代码封装成一个组件,而后在 Markdown 里通过 setup 来引入。

首先在 packages/Button/docs 目录下新建一个 demo.vue

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

而后在 Markdown 里把它引进来:

<script setup>
import demo from './demo.vue'
</script>

# 按钮组件

<demo />

最初就能实现点击响应了。

与此同时,如果咱们对 <my-button /> 的本体 Vue 文件进行任何的批改,都可能实时在文档中体现进去。

三、代码预览性能

可交互式文档曾经根本弄好了,但还有一个问题,就是不能直观地预览代码。你可能会说,要预览代码很简略啊,间接在 Markdown 外面把代码贴进去不就好了?话虽如此并没有错,然而秉承着“偷懒才是第一生产力”,预计没有人喜爱把本人写过的代码再抄一遍,必定是心愿可能有个方法既可能在文档里把所写的 demo 展现进去,又能间接看到它的代码,比如说这样:

只有把组件放进一个 <Preview /> 标签内就能间接展现组件的代码,同时还具备代码高亮的性能,这才是可交互式文档真正具备的样子!接下来咱们就来钻研一下应该如何实现这个性能。

在 Vite 的开发文档里有记录到,它反对在资源的开端加上一个后缀来管制所引入资源的类型。比方能够通过 import xx from 'xx?raw' 以字符串模式引入 xx 文件。基于这个能力,咱们能够在 <Preview /> 组件中获取所须要展现的文件源码。

首先来新建一个 Preview.vue 文件,其核心内容是通过 Props 拿到源码的门路,而后通过动静 import 的形式把源码拿到。以下展现外围代码(模板局部临时略过)

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 的正文是因为 Vite 基于 Rollup,在 Rollup 当中动静 import 是被要求传入确定的门路,不能是这种动静拼接的门路。具体起因和其动态剖析无关,感兴趣的同学能够自行搜寻理解。此处加上该正文则会疏忽 Rollup 的要求而间接反对该写法。

然而这样的写法在 dev 模式下可用,待真正执行 build 构建了当前再运行会发现报错。其起因也是同样的,因为 Rollup 无奈进行动态剖析,因而它无奈在构建阶段解决须要动静 import 的文件,导致会呈现找不到对应资源的状况。这个问题截止到目前(2021.12.11)临时没有好的方法,只好判断环境变量,在 build 模式下通过 fetch 申请文件的源码来绕过。改写后如下:

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());
}

假如构建后的输入目录为 /docs,记得在构建后也要把 /packages 目录复制过来,否则在 build 模式下运行会呈现 404 的状况。

可能又有同学会问,为什么要这么麻烦,间接在 dev 模式下也走 fetch 申请的形式不行么?答案是不行,因为在 Vite 的 dev 模式下,它原本就是通过 http 申请去拉取文件资源并解决完了才给到了业务的那一层。因而在 dev 模式下通过 fetch 拿到的 Vue 文件源码是曾经被 Vite 给解决过的。

拿到了源码当前,只须要展现进去即可:

<template>
  <pre>{{sourceCode}}</pre>
</template>

然而这样的源码展现十分丑,只有水灵灵的字符,咱们有必要给它们加个高亮。高亮的计划我抉择了 PrismJS,它十分玲珑又灵便,只须要引入一个相干的 CSS 主题文件,而后执行 Prism.highlightAll() 即可。本例所应用的 CSS 主题文件曾经搁置在仓库,能够自行取用。

回到我的项目,执行 yarn add prismjs -D 装置 PrismJS,而后在 <Preview /> 组件中引入:

import Prism from 'prismjs';
import '../assets/prism.css'; // 主题 CSS

export default {
  // ... 省略...
  async mounted() {
    // ... 省略...
    await this.$nextTick(); // 确保在源码都渲染好了当前再执行高亮
    Prism.highlightAll();},
}

因为 PrismJS 没有反对 Vue 文件的申明,因而 Vue 的源码高亮是通过将其设置为 HTML 类型来实现的。在 <Preview /> 组件的模板中咱们间接指定源码的类型为 HTML:

<pre class="language-html"><code class="language-html">{{sourceCode}}</code></pre>

这样调整了当前,PrismJS 就会主动高亮源码了。

四、命令式新建组件

到目前为止,咱们的整个“实时可交互式文档”曾经搭建完了,是不是意味着能够交付给其他同学进行真正的组件开发了呢?假如你是另一个开发同学,我跟你说:“你只有在这里,这里和这里新建这些文件,而后在这里和这里批改一下配置就能够新建一个组件了!”你会不会很想打人?作为组件开发者的你,并不想关怀我的配置是怎么的,框架是怎么跑起来的,只心愿可能在最短时间内就可能初始化一个新的组件而后着手开发。为了满足这个想法,咱们有必要把之前解决的步骤变得更加自动化一些,学习老本更低一些。

国际惯例,先看实现成果再看实现形式:

从效果图能够看到,在终端答复了三个问题后,主动就生成了一个新的组件 Foo。与此同时,无论是新建文件还是批改配置都是一键实现,齐全不须要人工干预,接下来的工作只须要围绕 Foo 这一个新组件发展即可。咱们能够把这种一键生成组件的形式成为“命令式新建组件”。

要实现这个性能,咱们 inquirerhandlebars 这两个工具。前者用于创立交互式终端提出问题并收集答案;后者用于依据模板生成内容。咱们首先来做交互式终端。

回到根目录下,新建 /script/genNewComp 目录,而后创立一个 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 运行该文件时,会在终端内顺次提出三个组件信息相干的问题,并把答案 compName(组件英文名),compZhName(组件中文名)和 compDesc(组件形容)保留在 meta 对象中并导出。

收集到了组件相干信息后,就要通过 handlebars 替换模板中的内容,生成或批改文件了。

/script/genNewComp 中新建一个 .template 目录,而后依据须要去建设新组件所需的所有文件的模板。在咱们的框架中,一个组件的目录是这样的:

Foo
├── docs
│   ├── README.md
│   └── demo.vue
├── index.ts
└── src
    └── index.vue

一共是 4 个文件,因而须要新建 index.ts.tplindex.vue.tplREADME.md.tpldemo.vue.tpl。同时因为新组件须要一个新的路由,因而 router.ts 也是须要一个对应的模板。因为篇幅关系就不全展现了,只挑最外围的 index.ts.tpl 来看看:

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}},
};

位于双括号 {{}} 中的内容最终会被 handlebars 所替换,比方咱们曾经得悉一个新组件的信息如下:

{
  "compName": "Button",
  "compZhName": "按钮",
  "compDesc": "这是一个按钮",
  "compClassName": "button"
}

那么模板 index.ts.tpl 最终会被替换成这样:

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};

模板替换的外围代码如下:

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)
  })
}

上述代码中的 listFileContent 即为 /packages/list.json 中的内容,这个 JSON 文件也是须要依据新组件而动静更新。

在实现了模板替换的相干逻辑后,就能够把它们都收归到一个可执行文件中了:

const infoCollector = require('./infoCollector')
const tplReplacer = require('./tplReplacer')

async function run() {const meta = await infoCollector()
  tplReplacer(meta)
}

run()

新增一个 npm script 到 package.json

{
  "scripts": {"gen": "node ./script/genNewComp/index.js"},
}

接下来只有执行 yarn gen 就能够进入交互式终端,答复问题主动实现新建组件文件、批改配置的性能,并可能在可交互式文档中实时预览成果。

五、离开文档和库的构建逻辑

在默认的 Vite 配置中,执行 yarn build 所构建进去的产物是“可交互式文档网站”,并非“组件库”自身。为了构建一个 my-kit 组件库并公布到 npm,咱们须要将构建的逻辑离开。

在根目录下增加一个 /build 目录,顺次写入 base.jslib.jsdoc.js,别离为根底配置、库配置和文档配置。


base.js

根底配置,须要确定门路别名、配置 Vue 插件和 Markdown 插件用于对应文件的解析。

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

库构建,用于构建位于 /packages 目录的组件库,同时须要 vite-plugin-dts 来帮忙把一些 TS 申明文件给打包进去。

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

交互式文档构建配置,跟 base 是简直一样的,只须要批改输入目录为 docs 即可。

import baseConfig from './vite.base.config';
import {defineConfig} from 'vite';

export default defineConfig({
  ...baseConfig,
  build: {outDir: 'docs',},
});

还记得前文有提到的构建文档时须要把 /packages 目录也一并复制到输入目录吗?亲测了好几个 Vite 的复制插件都不好使,罗唆本人写一个:

const child_process = require('child_process');

const copyDir = (src, dist) => {child_process.spawn('cp', ['-r', , src, dist]);
};

copyDir('./packages', './docs');

实现了下面这些构建配置当前,批改一下 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",

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

build:doc 的产物:

docs
├── assets
│   ├── README.04f9b87a.js
│   ├── README.e8face78.js
│   ├── index.917a75eb.js
│   ├── index.f005ac77.css
│   └── vendor.234e3e3c.js
├── index.html
└── packages

功败垂成!

六、序幕

至此咱们的组件开发框架曾经根本实现了,它具备了绝对残缺的代码开发、实时交互式文档、命令式新建组件等能力,在它下面开发组件曾经领有了超级丝滑的体验。当然它间隔完满还有很长的间隔,比如说单元测试、E2E 测试等也还没集成进去,组件库的版本治理和 CHANGELOG 还须要接入,这些不完满的局部都很值得补充进去。本文纯当抛砖引玉,也期待更多的交换~

退出移动版