从0到1搭建自己的组件库,详解附源码
前言
项目GitHub源码地址
为什么要搭建自己的组件库?
其实,在咱们的工作中,不免出现同时维护很多项目的情况。这时候,假如UI设计师,设计了几个通用组件,需要在各个系统间使用。这种情况,我们总不至于在每个系统都单独维护一份这个组件吧!假如真的这样做了,假如有一天,UI设计师换了个人,说之前的组件样式不好看,想换个样式!我的天,这时候我们要同时修改很多项目组的项目,这种情况,岂不是狗看了都摇头!至此,拥有一个自己的(或公司级)的组件库显得至关重要!
技术栈选型
在这里,我们以ElementPlus为基础,对组件进行二次封装,使其满足我们的业务需求。这里引入一个知名学者提出的一句话 我们尽可能使用已经存在的轮子,如果轮子不存在,我们再发明轮子 --知名学者 对此,我们的技术栈主要如下:
- Vue3 + TypeScript
- ElementPlus
目标
项目目标
- 将组件库发布到
NPM官网上,可以让各个组件库通过npm install的方式引入 - 可以像ElementPlus一样,在给组件传递参数不满足期望类型的时候,增加
TS报错提示 - 搭建一个属于自己组件库独有的官网
- 可以像ElementPlus一样,做到组件库可以
按需引入(重中之重!!!)
个人目标
- 能从0到1搭建自己组件库
- 学会通过使用
依赖注入,实现一父多子的效果(不要慌,后面会进行讲解~) - 学会如何做到
按需引入(当然,这也在后面的讲解中~)
说了这么多,下面就翻开我们的《武功秘籍》的第一页
第一节 - 创建、改造项目
首先我们先创建一个Vue3+ElementPlus+TypeScript的项目,这里建议使用VueCli进行创建,也没别的意思,就是方便,需要的核心配置项如下
重点!因为我们做的是通用型组件库,即他并不包含业务属性,所以用不到Router和Vuex, 当然,如果你要做的是业务组件库的话,那你可以根据自己的需求进行选择
创建完成之后,我们对项目进行下述修改:
- 将原来的
src目录变成examples,这个examples目录就是对咱们新封装的组件进行查看效果和测试的地方,可以理解为,他就是我们将要使用这个组件库的业务系统 - 新增
packages目录,此目录用于存放咱们封装的公共组件
如果你按着如上操作一步步做的话,是不是发现项目报错了呢,报错信息为:找不到main.js,接下来,我们更改vue.config.js
const { defineConfig } = require('@vue/cli-service')
const path = require("path")
module.exports = defineConfig({
transpileDependencies: true,
// 修改pages入口
pages: {
index: {
entry: "examples/main.ts",
template: "public/index.html",
filename: "index.html"
}
},
outputDir: "lib", // 将文件打包到lib目录,默认为dist
// 扩展 webpack配置
chainWebpack: (config) => {
config.resolve.alias
.set("~", path.resolve("packages"))
.set("@", path.resolve("examples"))
}
})
更改完之后,我们的项目结构如下

之后,我们引入ElementPlus, 为什么我们要引入ElementPlus呢?因为我们的组件库是针对ElementPlus进行二次封装的,通过对他的二次封装,更好的满足我们的业务,提高开发效率。当然,如果你的组件,使用不到ElementPlus的话,可以选择不安装。安装教程在此就不详述了,如果不知道如何安装的话,可以参考ElementPlus官网
第二节 用Vue插件的形式,书写一个组件
其实,不管咱们引入什么样的组件库,或者Vue生态的哪个包,本质上,都是在main.js中的app.use()方法中,执行了组件库或者生态包的install方法,这里引入Vue3官网app.use()中对app.use()作用的解释
插件可以是一个带 install() 方法的对象,亦或直接是一个将被用作 install() 方法的函数。插件选项 (app.use() 的第二个参数) 将会传递给插件的 install() 方法
接下来,就根据Vue3对app.use()的解释,我们本地创建一个“插件”
在项目的packages中,我们创建一个components目录和typings目录,components目录用于存放我们的组件,在typing目录中,存放我们项目需要的公共TS类型等。接着我们在components中创建一个组件,这里笔者简单写了一个MyButton组件,这里,我们创建一个MyButton目录,目录下创建一个src文件夹,用来存放我们的组件源码,组件源码内容如下
接着在和存放组件src目录同级的地方创建一个index.ts,目的是用于导出组件,代码如下
import MyButton from "./src/MyButton.vue"
export default MyButton
接着,在packages的根目录下,创建index.ts用于导出和install函数和注册组件,具体代码如下
import { App } from 'vue'
import MyButton from './components/MyButton/index'
// 所有组件列表
const components = [
MyButton
]
// 定义 install 方法, App 作为参数
const install = (app: App): void => {
// 遍历注册所有组件, app.component为Vue注册全局组件的API
components.map(component => app.component(component.name, component))
}
// 导出所有template形式组件
export default {
install
}
最后,在typing目录中,创建shims-vue.d.ts,这个文件是固定这样的写法,用于导出本组件库的类型说明,要不然在安装组件库的时候,会报组件库找不到的错误
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string | number | symbol, unknown>, Record<string | number | symbol, unknown>, unknown>
export default component
}
declare module 'custom-ui-plus'; // 就是这行,创建了组件库的声明文件
在package.json中增加.d.ts文件的声明

这里的lib是打包后生成的目录
因为上述的讲解中,目录用到的比较多,在此笔者放入一个目录结构截图

最后!我们在examples的main.js中,引入我们的组件库试试吧!
import { createApp } from 'vue'
import App from './App.vue'
import { ElButton } from "element-plus"
import 'element-plus/dist/index.css'
// 这里就是我们自己创建的,但是还未发布到NPM的组件库
import customUIPlus from "../packages/index"
createApp(App).use(ElButton).use(customUIPlus).mount('#app')
剩下的可以在examples中,像写平时业务代码一样,写一个测试的demo,这里笔者直接写在了app.vue中
<template>
<!-- 因为我们是在main.js全量引入了组件库,并挂载到了app实例上,所以我们这里可以直接使用 -->
<!-- 后面会讲解按需引入 -->
<my-button></my-button>
</template>
<script lang="ts" setup>
</script>
效果如下:

配置打包
在build目录下新建立rollup.config.ts
这里,我们使用打包工具rollup,原因是rollup更适合去打包生成EsModule,也就是我们常用的ES6的import的形式
import { nodeResolve } from '@rollup/plugin-node-resolve'
import babel from 'rollup-plugin-babel'
import path from 'path'
import { terser } from 'rollup-plugin-terser'
import typescript from 'rollup-plugin-typescript2'
import pkg from '../package.json'
import tsConfig from "../tsconfig.json"
import scss from "rollup-plugin-scss"; // 解析scss
// eslint-disable-next-line @typescript-eslint/no-var-requires
const vue = require('rollup-plugin-vue')
import { writeFileSync, existsSync, mkdirSync } from "fs";
export default [
{
input: path.resolve(__dirname, '../packages/index.ts'),
output: [
{
name: "custom-ui", // 打包完之后的名称
format: 'es', // 打包的格式为EsModule
file: pkg.module, // 这里也可以写死为index.esm.min.js,为了保持和package一致,所以引用了package.json文件
// sourcemap: true // sourcemap bug调试的时候打开
}
],
plugins: [
vue({
target: 'browser',
css: false,
exposeFilename: false
}),
// VueJsx(),
scss({
output: function (styles) {
if (!existsSync("lib/")) {
mkdirSync("lib/");
}
writeFileSync("lib/index.css", styles);
},
}),
babel({
exclude: 'node_modules/**', // 只转译我们的源代码
runtimeHelpers: true
}),
terser(), // 压缩
nodeResolve(),
typescript({
tsconfigOverride: {
compilerOptions: {
declaration: true // 是否创建 typescript 声明文件
},
include: tsConfig.include,
exclude: tsConfig.exclude
}
})
],
}
]
最后我们在package.json中创建命令
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"build:lib": "rollup --config ./build/rollup.config.ts" // 新增这个
},
测试打包


OK~没什么问题
发布NPM
发布NPM是个很简单的事儿,网上也有很多相关的文章,但是,这里有一个值得一提的东西 -- .npmignore。这个文件是用来忽略不上传到NPM的文件,类似于.gitignore。如果不配置这个文件,上传NPM的时候,会把整个项目上传上去,这里我们期望的是,只上传打包后的lib目录,对此,.npmignore文件如下
# 忽略目录
.idea
.vscode
build/
docs/
examples/
packages/
public/
node_modules/
typings/
tests/
# 忽略指定文件
babel.config.js
.prettierrc
jest.config.js
tsconfig.json
tslint.json
vue.config.js
.gitignore
.browserslistrc
*.map
因网上发布包到NPM的教程比较多,过程也比较简单,在这儿就不详细讲解了,附上一篇文章给大家npm发布教程
按着上述步骤搞完之后,我们可以执行npm i custom-ui,然后可以发现已经可以从npm成功安装自己发布的包了

安装包增加typescript提示
若想有typescript提示,则需要先写一个ts类型错误时,报错的组件,这里,笔者对MyButton组件进行了简单的改造,代码如下
<template>
<el-button type="primary">这是一个按钮,类型为{{ props.textType }}</el-button>
</template>
<script lang='ts'>
export default {
name: 'MyButton'
}
</script>
<script lang='ts' setup>
import { defineProps, PropType } from "vue"
// 这里定一个一个类型,限制了props中的textType字段值,只能是1或者2中的一个,如果用户写了3,则报错
type textTyping = 1 | 2
const props = defineProps({
textType: {
type: Number as PropType<textTyping>,
required: true
}
})
</script>
因为我们在rollup.config.ts打包配置中,对ts的配置引入了tsconfig.ts中的include和exclude,关键代码如下
// rollup.config.ts
...
typescript({
tsconfigOverride: {
compilerOptions: {
declaration: true // 是否创建 typescript 声明文件
},
include: tsConfig.include, // 这里引用的是tsconfig中的include
exclude: tsConfig.exclude // 这里引用的是tsconfig中的exclude
}
})
...
所以,我们修改tsconfig中的这两个值
// tsConfig
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"sourceMap": true,
"noImplicitAny": false,
"baseUrl": ".",
"types": [
"webpack-env",
"jest"
],
"paths": {
"@/*": [
"packages/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
},
"include": [
"packages/**/*.ts",
"packages/**/*.tsx",
"packages/**/*.vue",
"typings/**/*.ts",
"typings/shims-vue.d.ts" // 这里引用了typing下的shims-vue.d.ts声明
],
"exclude": [
"node_modules",
"examples",
]
}
最后,我们发布一版NPM试试吧!
当安装完我们的最新包之后,在example的main.ts中增加对.d.ts文件的引入,代码如下
import { createApp } from 'vue'
import App from './App.vue'
import { ElButton } from "element-plus"
import 'element-plus/dist/index.css'
import customUI from "custom-ui-plus"
// 增加了.d.ts文件的引入
import "custom-ui-plus/lib/index.d.ts"
createApp(App).use(ElButton).use(customUI).mount('#app')
测试结果如下,当我们给<MyButton>传递的textType的值,不是1或2,或者没有时候报错
Error
Error
Success

OK!完美
依赖注入
1.1 依赖注入是什么
一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖
1.2 依赖注入的使用场景
思考如下使用场景:
<Tabs v-model="activeName">
<Tab name="one">tab1</Tab>
<Tab name="two">tab2</Tab>
<Tab name="three">tab3</Tab>
</Tabs>
在组件开发中难免会碰到上述的使用场景,在上述场景中,存在一个父组件Tab和他的所有子组件Tabs。此时我们希望点击name="two"所对应的子组件的时候,这个子组件字体颜色变为红色,activeName变为two。
如果没有依赖注入的话,我们在设计组件的时候,也就只能将Tab组件和Tabs组件合二为一,暂且叫这个组件为TabsPlus。在这个TabsPlus中,我们肯定会设计一个参数TabsName,这个参数为数组形式,用于渲染每个tab,最后通过v-for的形式,将其渲染出来,比如下述伪代码
<!-- 定义TabsPlus组件 -->
<template>
<div
v-for="(item, index) in list"
:key="item"
@click="emit('update:modelValue', item.value)">
{{ item.label }}
</div>
</template>
<script lang="ts" setup>
const emit = defineEmit()
const props = defineProps({
list: {
type: Array as PropType<Array<{value: string, label: string}>>
}
})
</script>
<!-- 使用 -->
<TabsPlus v-model="activeName" list="[{value: "a", label: "tab1"}]" />
但是如果此时,如果用户希望使用插槽,将tab1对应的组件的<slot name="default"></slot>槽位插入我们的另一个组件,比如MyButton呢,此时显然没有依赖注入是实现不了的,比如下述我们所期望编写的伪代码
<Tabs v-model="activeName">
<Tab name="one">
<MyButton>按钮1</MyButton>
</Tab>
<Tab name="two">
<MyButton>按钮2</MyButton>
</Tab>
<Tab name="three">
<MyButton>按钮3</MyButton>
</Tab>
</Tabs>
1.3 依赖注入核心代码
这里,笔者不才,看了看Vant的源码,偷摸学到了点儿奇淫巧技,在Vant源码中的packages/vant-use/src/useRelation文件夹下,分别存在了两个核心文件useChildren、useParent,点击此处可跳转。这两个文件就是依赖注入的核心文件,下面我会尽可能把这个东西描述明白
1.4 依赖注入的核心原理
依赖注入的核心原理其实就是对Vue.js中提供的provide,reject进行二次封装。父组件通过provide暴露出一些属性,比如link注入子组件实例方法,unlink删除子组件实例方法。当子组件挂载的时候,通过调用link方法向父组件依赖中增加子组件实例;当子组件卸载的时候,调用unlink,删除父组件中当前子组件的实例。说了这么多,我们看看他的源码是怎么写的
// useChildren
/*
1. 此方法用于父组件获取其归属的所有子组件集合
examples:
<Father>
<Child>子组件1</Grandchild>
<Child>子组件2</Grandchild>
<Child>子组件3</Child>
</Father>
2. 使用的vue类型
(1)ComponentPublicInstance: 子组件实例
(2)ComponentInternalInstance:子组件的虚拟DOM实例
(3)InjectionKey: Symbol类型的Key
3. flattenVNodes:
以递归的形式获取父组件所有子组件
4.sortChildren
按着子组件在父组件中存在的顺序进行排列,目的是用于unlink时,internalChildren,和children中各项数据一一对应
5.useChildren【核心方法】
参数:key -> Symbol类型的唯一值,用于子组件通过Symbol值找到其对应的父组件
- 子方法link:
参数value,可用于父组件向子组件传递值,比如传递props等
作用:分别向internalChildren和children数组中放入当前子组件
- 子方法unlink
参数child,子组件实例
作用:分别在internalChildren和children中删除当前子组件
*/
import {
VNode,
isVNode,
provide,
reactive,
InjectionKey,
getCurrentInstance,
VNodeNormalizedChildren,
ComponentPublicInstance,
ComponentInternalInstance,
} from 'vue';
export function flattenVNodes(children: VNodeNormalizedChildren) {
const result: VNode[] = [];
const traverse = (children: VNodeNormalizedChildren) => {
if (Array.isArray(children)) {
children.forEach((child) => {
if (isVNode(child)) {
result.push(child);
if (child.component?.subTree) {
result.push(child.component.subTree);
traverse(child.component.subTree.children);
}
if (child.children) {
traverse(child.children);
}
}
});
}
};
traverse(children);
return result;
}
// 按vnodes顺序对子实例排序
export function sortChildren(
parent: ComponentInternalInstance,
publicChildren: ComponentPublicInstance[],
internalChildren: ComponentInternalInstance[]
) {
const vnodes = flattenVNodes(parent.subTree.children);
internalChildren.sort(
(a, b) => vnodes.indexOf(a.vnode) - vnodes.indexOf(b.vnode)
);
const orderedPublicChildren = internalChildren.map((item) => item.proxy!);
publicChildren.sort((a, b) => {
const indexA = orderedPublicChildren.indexOf(a);
const indexB = orderedPublicChildren.indexOf(b);
return indexA - indexB;
});
}
export function useChildren<
// eslint-disable-next-line
Child extends ComponentPublicInstance = ComponentPublicInstance<{}, any>,
ProvideValue = never
>(key: InjectionKey<ProvideValue>) {
const publicChildren: Child[] = reactive([]);
const internalChildren: ComponentInternalInstance[] = reactive([]);
const parent = getCurrentInstance()!;
const linkChildren = (value?: ProvideValue) => {
// link:向父组件的子组件们中添加新的子组件
const link = (child: ComponentInternalInstance) => {
if (child.proxy) {
internalChildren.push(child); // 添加子组件
publicChildren.push(child.proxy as Child); // // 添加子组件
sortChildren(parent, publicChildren, internalChildren); // 给子组件排序
}
};
// unlink: 向父组件的子组件们中删除指定子组件
const unlink = (child: ComponentInternalInstance) => {
const index = internalChildren.indexOf(child);
publicChildren.splice(index, 1); // 移除子组件
internalChildren.splice(index, 1); // 移除子组件
};
// 向后代注入当前子组件实例
provide(
key, // 这个key是Symbol类型,目的是为了保证唯一
Object.assign(
{
link,
unlink,
children: publicChildren, // 父组件的所有子组件的proxy对象
internalChildren, // 父组件的所有子组件
},
value
)
);
};
return { // 返回子组件实例们
children: publicChildren,
linkChildren,
};
}
// UseParent.ts
/**
* 1. 此方法用于子组件将当前实例,加入到父组件child集合中,并可接受父组件传递过来的值
* 2. 接收父组件的link和unlink方法,分别用于向父组件的child集合中,添加或删除当前子组件实例
* 3. 方法返回值
* parent:子组件对应的父组件,如useChildren中的example,parent为<father>组件
* index: 子组件在父组件child集合中对应的索引值
*/
import {
ref,
inject,
computed,
onUnmounted,
InjectionKey,
getCurrentInstance,
ComponentPublicInstance,
ComponentInternalInstance,
} from 'vue';
type ParentProvide<T> = T & {
link(child: ComponentInternalInstance): void;
unlink(child: ComponentInternalInstance): void;
children: ComponentPublicInstance[];
internalChildren: ComponentInternalInstance[];
};
export function useParent<T>(key: InjectionKey<ParentProvide<T>>) {
// 下面的这个key也是Symbol类型的,他和UseChildren的Key要保持一致
const parent = inject(key, null);
if (parent) {
const instance = getCurrentInstance()!;
const { link, unlink, internalChildren } = parent;
// 向父组件的子组件们中添加当前子组件
link(instance);
// 页面卸载的时候,删除当前子组件
onUnmounted(() => unlink(instance));
const index = computed(() => internalChildren.indexOf(instance));
return {
parent,
index,
};
}
return {
parent: null,
index: ref(-1),
};
}
1.5 依赖注入用法
什么?上述源码我没讲明白,好吧!欢迎评论区留言,我持续更新。BUT!看不懂也没关系,会用就行!下面在我们的组件库的packages/下,新建tools目录,用来存放我们的公共函数,然后把vant/packages/vant-use/useRelation下的useParent和useChildren粘过来!
然后,我们在packages/components下创建Tab.vue,内容如下
<!-- Tab.vue -->
<template>
<div :class="parentModelValue === props.name ? 'active' : ''" @click="toggle">
<slot></slot>
</div>
</template>
<script lang="ts">
export default {
name: 'Tab'
}
</script>
<script lang="ts" setup>
import { defineProps, defineExpose, Ref, computed } from "vue";
import { useParent } from "../../tools/useRelation/useParent"
import { FATHER_KEY } from "./index"
// 获取父组件
const { parent } = useParent<{ props: any, activeName: Ref<string> }>(FATHER_KEY)
// 获取父组件modelValue绑定值
const parentModelValue = computed(() => parent?.props.modelValue)
const props = defineProps({
name: {
type: String,
default: ""
}
})
// 选中子组件方法
const toggle = () => {
parent!.activeName.value = props.name
}
// 导出子组件方法,供父组件使用
defineExpose({ toggle, name: props.name })
</script>
<style lang="scss" scoped>
.active {
color: red
}
</style>
接着,我们创建Tabs.vue
<!-- Tabs.vue -->
<template>
<div>
<slot></slot>
</div>
</template>
<script lang="ts">
export default {
name: "Tabs"
}
</script>
<script lang="ts" setup>
import { FATHER_KEY } from "./index"
import { useChildren } from "../../tools/useRelation/useChildren"
import { defineProps, defineEmits, ref, watch } from "vue"
// 获取子组件实例
const { linkChildren } = useChildren(FATHER_KEY)
const props = defineProps({
modelValue: {
type: String,
default: ""
}
})
const activeName = ref(props.modelValue)
const emit = defineEmits<{ (event: "update:modelValue", value: string): void }>()
// 向子组件传递当前props和activeName
linkChildren({ props, activeName })
const handleEmit = (value: string) => {
emit("update:modelValue", value)
}
watch(() => activeName.value, () => {
handleEmit(activeName.value)
})
</script>
创建index.ts导出Symbol类型的key
export const FATHER_KEY = Symbol("FATHER_KEY")
最后,我们在packages/index导出这个组件
...
import Tabs from "./components/TestRelation/Tabs.vue"
import Tab from "./components/TestRelation/Tab.vue"
const components = [
...
Tabs,
Tab
]
declare module "@vue/runtime-core" {
export interface GlobalComponents {
...
Tab: typeof Tab
Tabs: typeof Tabs
}
}
``
最后!我们编写个测试`Demo试试吧`
```html
<template>
<h4>
active: {{ active }}
</h4>
<father v-model="active">
<child name="one">one</child>
<child name="two">two</child>
<child name="three" ref="childRef">three</child>
</father>
<button @click="handleClick">选中第三个</button>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
const active = ref("one")
const childRef = ref()
const handleClick = () => {
childRef.value.toggle()
console.log(active.value)
}
</script>
运行效果如下

完美!
按需引入
笔者不才,咱们继续研究下vant的实现方式,按需引入就变得很简单啦!源码如下
// withInstall.ts
import type { App, Component } from 'vue';
type EventShim = {
new (...args: any[]): {
$props: {
onClick?: (...args: any[]) => void;
};
};
};
export type WithInstall<T> = T & {
install(app: App): void;
} & EventShim;
export function withInstall<T extends Component>(options: T) {
(options as Record<string, unknown>).install = (app: App) => {
const { name } = options;
if (name) {
// * 挂载到全局
app.component(name, options);
}
};
return options as WithInstall<T>;
}
其实上述代码的大致原理就是给单个的组件,挂载到了全局上,注意上述带*的注释
什么?还是没懂!没关系,会用就行~
在packages/tools/utils目录下新建withInstall.ts,并粘贴进去上述代码
然后更改packages/index.ts,追加按需引入的代码
...
import MyButtonCom from './components/MyButton/index'
import { withInstall } from "./tools/utils/withInstall"
export const MyButton = withInstall(MyButtonCom);
...
// 所有组件列表
const components = [
MyButtonCom,
Tabs,
Tab
]
...
之后我们就可以正常使用按需引入了!
不粘贴测试截图了,直接下结论!完美!
增加VuePress
TODO
增加VueTestUtils
TODO
往期精彩
最后,如果你也喜欢本文章的话,可以给个点赞和关注吗~ GitHub也求个Star!
如果大家感兴趣,之后我们可以仔细讲解下Vant源码中的核心功能!
最后!!!!感谢大家阅读,笔者也放上他的祖传感谢!

转载自:https://juejin.cn/post/7241514309717639228