Skip to content

使用Vite和TypeScript带你从零打造一个属于自己的Vue3组件库

笔者之前就经常有这样的痛点,在某个项目里二次封装了 el-select ,实现 filterable 的时搜索输入框移到下拉列表中,避免多选时多个tag挤压了搜索框的空间。当时是写在一个项目里,然后其他项目也遇到了这样的需求...我在十几个项目里面寻找、回忆,找回当年封装的组件,人都麻了......

大多B端系统都是以 element 、 antd 等ui框架为主,基于各种业务场景,基本都会有自己团队的二次封装。其实类似的功能扩展肯定会有的,如果有组件库把组件都集中起来,就能减少很多重复造轮子的劳动力了!

文章介绍了使用 Vite 和 TypeScript 从零打造 Vue3 组件库“xy-ui”的全过程,包括搭建 Monorepo 环境、使用 pnpm 配置和安装依赖、搭建 Vue3 脚手架项目、本地调试、开发组件、vite 打包(含样式处理)、发布等步骤,还提供了直接使用和后续相关计划的说明。

亮点

  • Vite+Vitest+Vitepress 工具链 (项目构建+测试+项目文档)
  • monorepo 分包管理

Monorepo环境

Monorepo 就是指在一个大的项目仓库中,管理多个模块/包(package),这种类型的项目大都在项目根目录下有一个packages文件夹,分多个项目管理;简单来说就是单仓库 多项目。

目前很多我们熟知的项目都是采用这种模式,如Vant,ElementUI,Vue3等。打造一个Monorepo环境的工具有很多,如:lerna、pnpm、yarn等,这里我们将使用pnpm来开发我们的UI组件库。

为什么要使用pnpm?

因为它简单高效,它没有太多杂乱的配置,它相比于lerna操作起来方便太多,pnpm 是 performant npm(高性能的 npm),它是一款快速的,节省磁盘空间的包管理工具,同时,它也较好地支持了 workspace 和 monorepo,简化开发者在多包组件开发下的复杂度和开发流程。

pnpm monorepo 搭建

1. 安装 pnpm:

bash
$npm install -g pnpm

新建文件夹作为工作区 ,例如我这里新建文件夹 xy-ui

2. 初始化package.json: cd 到目录下,初始化环境:

bash
# 初始化 - 文件夹下生成了 package.json
$pnpm init

3. 新建配置文件 .npmrc:

ini
shamefully-hoist = true

这里简单说下为什么要配置shamefully-hoist。 如果某些工具仅在根目录的node_modules时才有效,可以将其设置为true来提升那些不在根目录的node_modules,就是将你安装的依赖包的依赖包的依赖包的...都放到同一级别(扁平化)。说白了就是不设置为true有些包就有可能会出问题。

pnpm 内置了对单一存储库(也称为多包存储库、多项目存储库或单体存储库)的支持,你只需要创建一个 workspace 就可将多个项目合并到一个仓库中,这样的作用是能在我们开发调试多包时,彼此间的依赖引用更加简单。

pnpm版本在 9.0 之后 pnpm,修改了 link-workspace-packages 的默认值为 false。我们需要开启该属性,在安装依赖时优先在本地链接,而不是从 registry(远程) 中下载。

在根目录新建.npmrc并配置以下内容:

ini
link-workspace-packages = true
```

**4. monorepo的实现:**

接下就是pnpm如何实现monorepo的了。

为了我们各个项目之间能够互相引用我们要新建一个`pnpm-workspace.yaml`文件将我们的包关联起来

我们在根目录下新建 `packages` 文件夹,再新建 `pnpm-workspace.yaml` 文件,用来声明对应的工作区,写入如下内容:
```yaml
packages:
  # 存放组件库和其他工具库
  - 'packages/*'
  # 存放组件测试的代码
  - 'play'
  # 存放文档目录
  - 'docs'
```
这样就能将我们项目下的`packages`目录和`play`目录关联起来了,当然如果你想关联更多目录你只需要往里面添加即可。根据上面的目录结构很显然你在根目录下新packages和play文件夹,packages文件夹存放我们开发的包,play用来调试我们的组件。

play文件夹就是接下来我们要使用vite搭建一个基本的Vue3脚手架项目的地方

**5. 安装对应依赖:**
我们开发环境中的依赖一般全部安装在整个项目根目录下,方便下面我们每个包都可以引用,所以在安装的时候需要加个 `-w`(先安装一下相关的依赖最为全局依赖,这样我们工作区域内的包就无需重复安装)
```bash
# 1.@vitejs/plugin-vue用来支持.vue文件的转译
# 2.unplugin-vue-define-options 可以在编写组件的时候 通过 defineOptions 方法为组件设置 name
# 3.vue-tsc 是一个专为 Vue.js 设计的 TypeScript 编译器包装器,它基于 TypeScript 的官方命令行接口(tsc)构建,但添加了对 Vue 单文件组件(SFCs)的支持。该工具允许开发者在使用 Vue 时享受 TypeScript 的静态类型检查优势,同时简化编译过程
# 4.@vitejs/plugin-vue-jsx是Vite官方提供的JSX支持插件,其内部使用了Vue官方提供的@vue/babel-plugin-jsx插件。
$pnpm i vue vite typescript vue-tsc unplugin-vue-define-options @vitejs/plugin-vue @vitejs/plugin-vue-jsx sass @types/node -D -w
# $pnpm i vue typescript sass @vitejs/plugin-vue vite vue-tsc -D -w
```
- `unplugin-vue-define-options` 可以在编写组件的时候 通过 defineOptions 方法为组件设置 name,使用示例如下:
  ```typescript
  defineOptions({
    name: "PlayButton"
  })
  ```
  vite.config.js 配置:
  ```js
  import { defineConfig } from 'vite'
  import vue from '@vitejs/plugin-vue'
  import DefineOptions from 'unplugin-vue-define-options/vite'

  // https://vitejs.dev/config/
  export default defineConfig({
    plugins: [vue(), DefineOptions()]
  })
  ```
- `vite-plugin-vue-setup-extend`可以`<script name="SButton" setup>` 这样的形式
  vite.config.js 配置:
  ```javascript
  import { defineConfig } from 'vite'
  import vue from '@vitejs/plugin-vue'
  import VueSetupExtend from 'vite-plugin-vue-setup-extend'

  export default defineConfig({
    plugins: [vue(),VueSetupExtend()]
  })
  ```
  利用安装的插件,直接于script 标签上定义 name 属性。
- `@vitejs/plugin-vue-jsx`
  - vite.config.js中引用插件:
  ```js
  import { defineConfig } from 'vite'
  import vue from '@vitejs/plugin-vue'
  import vueJsx from '@vitejs/plugin-vue-jsx'

  // https://vitejs.dev/config/
  export default defineConfig({
    plugins: [vue(), vueJsx()],
  })
  ```
  - 使用:
  ```tsx
  // App.vue
  <script setup lang="tsx">
  const JsxComp = (<>
    <div className="flex-center mt-20px">
      <span>Render By Jsx</span>
    </div>
  </>
  )
  </script>

  <template>
    <JsxComp />
  </template>
  ```
- `unplugin-vue-macros`
  unplugin-vue-macros/macros-global 是一个在 Vue 项目中使用的宏插件,它允许你在全局范围内定义宏。这些宏可以是常量、函数或其他可以在你的项目代码的任何位置直接使用的实用工具。
  要使用 unplugin-vue-macros/macros-global,你需要先安装 unplugin-vue-macros 插件,然后在你的 Vue 项目中配置它。`npm install unplugin-vue-macros --save-dev`
- 可以新增调试的命令,一般启动项目可以使用 dev:项目名 来进行分别启动项目,后面跟上需要启动的路径即可,在根项目的 package.json里设置如下:
  ```json
  {
    "scripts": {
      "dev:vue-demo1": "vite packages/vue-demo1",
    }
  }
  ```
- 仓库项目内的包相互调用:
  如何对hooks、utils和components这三个包进行互相调用呢?我们只需要把这三个包都安装到仓库根目录下的 node_modules 目录中即可。所有的依赖都在根目录下安装,安装到根目录需要加上-w ,表示安装到公共模块的 packages.json 中。
  ```sh
  # 全局安装依赖项 添加全局的依赖项的时候,需要在命令后面加上 -W
  $pnpm install xykfz-ui -w
  $pnpm install @xykfz-ui/components -w
  $pnpm install @xykfz-ui/hooks -w
  $pnpm install @xykfz-ui/utils -w
  # 全局安装依赖项 比如所有的组件都需要使用到 lodash,就可以执行:
  $pnpm i lodash -W
  # 局部安装依赖项
  进入到指定目录去安装:npm i lodash
  或 使用 --filter 修饰符可以实现在根目录指定某个目录进行安装,具体命令为:
  $pnpm i lodash --filter vue-demo1
  ```
  以components包的package.json举例,其中"@uv-ui/hooks": "workspace:^1.0.0""@uv-ui/utils": "workspace:^1.0.0"是依赖于其他目录的包,如果这个components包需要用到这两个包,需要在这个目录下也安装上,之后发布npm包的时候将workspace:^去掉即可,这两个依赖的包也需要独立发布npm包,这些在发布组件中有详细描述。    

因为我们开发的是vue3组件, 所以需要安装vue3,当然ts肯定是必不可少的(当然如果你想要js开发也是可以的,甚至可以省略到很多配置和写法。但是ts可以为我们组件加上类型,并且使我们的组件有代码提示功能,未来ts也将成为主流);sass为了我们写样式方便。
**6. 配置tsconfig.json:**
```bash
# 生成tsconfig.json
$npx tsc --init
```
tsconfig.json:
```json
{
  "compilerOptions": {
    "baseUrl": ".",
    "jsx": "preserve",
    "strict": true,
    "target": "ES2015",
    "module": "ESNext",
    "skipLibCheck": true,
    "esModuleInterop": true,
    "moduleResolution": "Node",
    "lib": ["esnext", "dom"]
  }
}

```
**7. 使用vite搭建一个基本的Vue3脚手架项目play:**
```bash
# 在根目录下执行
$pnpm create vite play 或 npm init vite play
```

## 开发 
一般`packages`要有`utils`包来存放我们公共方法,工具函数等.

`packages`下的目录结构:
```sh
- core #npm包入口
  "name": ""
- components #组件目录
  "name": "@xykfz-ui/components"
- hooks #组合式API hooks目录
  "name": "@xykfz-ui/hooks"
- theme #主题目录
  "name": "@xykfz-ui/theme"
- utils #工具函数目录
  "name": "@xykfz-ui/utils"
```
1. utils包

既然它是一个包,所以我们新建utils目录后就需要初始化它,让它变成一个包;终端进入`utils`文件夹执行:`pnpm init` 然后会生成一个package.json文件;这里需要改一下包名,我这里将name改成@xy-ui/utils表示这个utils包是属于xy-ui这个组织下的。所以记住发布之前要登录npm新建一个组织;例如xy-ui
```json
{
  "name": "@xykfz-ui/utils",
  "version": "1.0.0",
  "description": "",
  "main": "index.ts",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
```
因为我们使用`ts`写的,所以需要将入口文件`index.js`改为`index.ts`,并新建`index.ts`文件:(先导出一个简单的加法函数)

2. 组件库包(这里命名为@xykfz-ui/components)
`components`是我们用来存放各种UI组件的包

新建`components`文件夹并执行 `pnpm init` 生成`package.json`并修改 package.json 文件的包名 name 和入口文件main。
```json
{
  "name": "@xykfz-ui/components",
  "version": "0.0.1",
  "description": "",
  "main": "index.ts",
  "peerDependencies": {
    "vue": "^3.5.12"
  },
  "keywords": [],
  "author": "",
  "license": "MIT"
}
```
以 button 组件为例,创建如下目录:
```js
packages/components
├─ button
  ├─ src
      └─ button.vue // 组件代码
  └─ index.ts // 用于导出button组件
└─ index.ts // 集中导出src下的所有组件
```
我们来写一下 button 组件,并默认导出组件:
```js
//button.vue
<template>
    <button>按钮组件</button>
</template>
  
<script lang='ts' setup>

defineOptions({
  name: 'XyButton'
})
</script>
// button/index.ts
import XyButton from './src/button.vue'
export {
  XyButton
}
// index.ts
export * from './button'
```
3. 包之间本地调试
进入components文件夹执行 `$pnpm install @xy-ui/utils -w`,你会发现pnpm会自动创建个软链接直接指向我们的utils包

4. 测试组件 - `pnpm create vite play`快速创建一个 vite + vue + ts的项目,并且删除掉tsconfig.json 和 tsconfig.node.json
  ```json
  {
    "name": "@xykfz-ui/play",
    "private": true,
    "version": "0.0.0",
    "type": "module",
    "scripts": {
      "dev": "vite",
      "build": "vue-tsc -b && vite build",
      "preview": "vite preview"
    },
    "dependencies": {
    },
    "devDependencies": {
    }
  }

  ```
5. 接着我们通过 -w 将我们的私有包安装到根目录下:`pnpm i @xykfz-ui/components -w`
  发现根目录的 `package.json`的依赖中多了 "@xykfz-ui/components": "workspace:^" ,其中的 `workspace` 代表安装的是工作区的依赖包,因为在执行pnpm安装的时候,pnpm会优先在工作区的目录下找对应的依赖,如果找到了则会建立对应的软连接。

  我们在 `play` 目录新建的项目中的 `App.vue` 来引入对应组件:
  ```js
  <script setup lang="ts">
  import { XyButton } from '@xykfz-ui/components'
  </script>

  <template>
    <xy-button></xy-button>
  </template>
  ```
  运行效果如下:
  使用之前在根项目下的package.json里配置的dev:play,执行`pnpm run dev:play`运行 play项目,出现按钮效果,这样一个简单的组件就完成了!
6. 全局引入组件库
  假设我们需要全局使用组件库的话怎么办?
  我们在组件包里就需要提供一个install的方法来全部引入。
  ```js
  // packages/components/index.ts
  import { App } from 'vue'
  import { XyButton } from './button'

  const components =  [
    XyButton
  ]
  //作用:全局引入
  export default {
    install: (app: App) => {
      for (const c of components) {
        app.component(c.name, c)
      }
    }
  }
  //作用:局部引入
  export * from './button'
  ```
  在 `play` 目录的项目中的 `main.ts` 中全局引入,`app`页面中不单独引入按钮组件:
  ```js
  // main.ts
  import { createApp } from 'vue'
  import './style.css'
  import App from './App.vue'
  import xUi from '@xykfz-ui/components' // 新增
  const app = createApp(App)
  app.use(xUi)  // 新增
  app.mount('#app')

  // App.vue
  <script setup lang="ts">
  </script>
  <template>
    <xy-button></xy-button>
  </template>
  ```
7. 组件打包
  我们使用`vite`来打包,首先在 `packages/components` 目录下创建个vite的配置文件 `vite.config.ts` ,如下
  ```js
  // vite.config.ts
  import { defineConfig } from 'vite'
  import vue from '@vitejs/plugin-vue'
  export default defineConfig({
    build: {
      rollupOptions: {
        external: ["vue"], // 忽略打包vue文件
        output: [
          {
            format: 'es',
            entryFileNames: '[name].js',
            dir: './dist',
            preserveModulesRoot: 'src'
          }
        ]
      },
      lib: {
        entry: "./index.ts"
      },
    },
    plugins: [vue()],
  })
  ```
  打包我们的入口文件 `index.ts`
  在 `package.json` 文件中增加脚本执行命令:
  ```js
  // package.json
  "scripts": {
    "build": "vite build"
  }
  ```
  执行命令 `pnpm build`,可以发现多了一个`dist`的目录,里面有一个 `index.js` 的文件,现在我们来测试一下打包的文件是否有效
  我们来更改当前包的入口文件为打包后的文件如下:
  ```js
  // package.json
  "main": "dist/index.js"
  ```
  然后看看 play 目录下运行的项目是否正常
8. npm 发布
  npm 发布就很简单,切换到`packages/components`目录,配置需要发布到npm上的包的内容,增加如下配置,只发布 `dist` 里的内容:
  ```js
  // package.json
  "files": [
    "dist"
  ],
  ```
  其中`package.json`中的 `name` 就是代表的包名,即你 pnpm install [name] 的 name。
  ```js
  npm login
  npm publish
  ```
  执行以上命令,npm login 先获取npm账号的认证,然后就可以发布!
9. ddd
10. ddd
11. 配置文件 - 在根目录创建一些必要额配置文件,比如刚才删除play中的ts配置,我们在根目录配置

创作不易请尊重他人劳动成果,未经授权禁止转载!
Released under the MIT License.