npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@px_wlq/my-kit

v0.0.1

Published

![IMG_5885](https://user-images.githubusercontent.com/12172868/145699258-517293e0-c5e1-474c-8ba6-7695b07cfac3.jpg)

Downloads

2

Readme

IMG_5885

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

对于一个组件库来说,除了”组件“本身以外,另个一个非常重要的东西就是文档展示。参考市面上优秀的开源组件库,无一不是既有高质量的组件,更有一套非常规范且详细的文档。文档除了对组件的功能进行说明以外,同时也具备了组件交互预览的能力,让用户的学习成本尽可能地降低。

对于许多程序员来说,最讨厌的无非是两件事。一件是别人不写文档,另一件是自己写文档。既然在组件库里文档是必不可少的,那么我们应该尽可能地减少写文档的痛苦,尤其是这种既要有代码展示、又要有文字说明的文档。

市面上对于组件文档展示的框架也有不少,比如 Story Book、Docz、Dumi 等等。它们都有一套自己的规则能够让你展示自己的组件,但是对于团队来说学习成本较高,同时它们也在一定程度上割裂了“开发”和“写文档”之间的体验。

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

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

image

在线体验

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 的服务器以后,就可以直接在浏览器上看到效果了:

image

二、实时可交互式文档

Kapture 2021-12-12 at 11 20 50

一个组件库肯定不止有 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>

完成以后就能在浏览器上看到效果了: image

由于我们全局引入了 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 />

最后就能实现点击响应了。 Kapture 2021-12-11 at 14 43 53

与此同时,如果我们对 <my-button /> 的本体 Vue 文件进行任何的修改,都能够实时在文档中体现出来。

三、代码预览功能

可交互式文档已经基本弄好了,但还有一个问题,就是不能直观地预览代码。你可能会说,要预览代码很简单啊,直接在 Markdown 里面把代码贴进去不就好了?话虽如此并没有错,但是秉承着“偷懒才是第一生产力”,估计没有人喜欢把自己写过的代码再抄一遍,肯定是希望能够有个办法既能够在文档里把所写的 demo 展示出来,又能直接看到它的代码,比如说这样:

Kapture 2021-12-12 at 11 26 58

只要把组件放进一个 <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>

image

但是这样的源码展示非常丑,只有干巴巴的字符,我们有必要给它们加个高亮。高亮的方案我选择了 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>

image

这样调整了以后,PrismJS 就会自动高亮源码了。

四、命令式新建组件

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

国际惯例,先看完成效果再看实现方式: Kapture 2021-12-11 at 22 30 07

从效果图可以看到,在终端回答了三个问题后,自动就生成了一个新的组件 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 还需要接入,这些不完美的部分都很值得补充进去。本文纯当抛砖引玉,也期待更多的交流~