从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