组件库架构为什么必须先定约束再写代码
Vue3组合式API给了组件库设计更大的灵活性,但灵活性本身不是目标。一个没有类型约束的组件库,用起来灵活,维护起来是灾难——属性类型不确定、事件参数无法推导、插槽内容不可控。组件库架构的第一步是建立TypeScript类型约束体系,这个体系决定了组件的API设计质量、开发者使用体验和长期可维护性。
组件库工程化的核心问题不是”组件怎么写”,而是”组件怎么被安全地使用和演进”。TypeScript约束、响应式状态封装、样式隔离这三个维度构成了组件库的架构基础。
TypeScript泛型约束组件Props类型
组件Props的类型定义是组件库类型体系的入口。Vue3的defineComponent配合泛型,可以做到Props类型的完整推导:
// 基础按钮组件的Props类型定义
import type { ExtractPropTypes, PropType } from 'vue'
type ButtonSize = 'small' | 'medium' | 'large'
type ButtonType = 'primary' | 'secondary' | 'danger' | 'ghost'
const buttonProps = {
size: {
type: String as PropType<ButtonSize>,
default: 'medium' as const,
validator: (val: string) => ['small', 'medium', 'large'].includes(val)
},
type: {
type: String as PropType<ButtonType>,
default: 'primary' as const
},
loading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
} as const
// 提取Props类型供外部使用
type ButtonProps = ExtractPropTypes<typeof buttonProps>
// 组件定义
const Button = defineComponent({
name: 'ZButton',
props: buttonProps,
emits: {
click: (e: MouseEvent) => true
},
setup(props, { emit, slots }) {
const classes = computed(() => ({
['z-btn--' + props.size]: true,
['z-btn--' + props.type]: true,
'z-btn--loading': props.loading,
'z-btn--disabled': props.disabled
}))
const handleClick = (e: MouseEvent) => {
if (props.loading || props.disabled) return
emit('click', e)
}
return () => (
<button class={['z-btn', classes.value]} onClick={handleClick}>
{props.loading && <ZIcon name="loading" spin />}
{slots.default?.()}
</button>
)
}
})
as const推断确保default值的字面量类型不会丢失。如果不加as const,default: ‘medium’会被推断为string而非’medium’,导致泛型推导链断裂。
泛型组件:表格组件的行类型推导
表格组件是组件库中最需要泛型约束的组件——行数据的类型必须从传入的数据源自动推导:
// 泛型表格组件
import type { SetupContext, VNode } from 'vue'
interface TableColumn<T> {
key: keyof T & string
title: string
width?: number
render?: (value: T[keyof T], row: T, index: number) => VNode
}
function defineTable<T>() {
return defineComponent({
name: 'ZTable',
props: {
data: { type: Array as PropType<T[]>, required: true },
columns: { type: Array as PropType<TableColumn<T>[]>, required: true },
rowKey: { type: String as PropType<keyof T & string>, required: true },
loading: { type: Boolean, default: false }
},
setup(props, { slots }: SetupContext) {
const renderCell = (row: T, col: TableColumn<T>, index: number) => {
if (col.render) return col.render(row[col.key], row, index)
return row[col.key] as VNode
}
return () => (
<div class="z-table">
<table>
<thead>
<tr>
{props.columns.map(col => (
<th style={{ width: col.width + 'px' }}>{col.title}</th>
))}
</tr>
</thead>
<tbody>
{props.data.map((row, idx) => (
<tr key={String(row[props.rowKey])}>
{props.columns.map(col => (
<td>{renderCell(row, col, idx)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
})
}
// 使用时自动推导行类型
interface UserRow {
id: number
name: string
email: string
status: 'active' | 'inactive'
}
const ZTable = defineTable<UserRow>()
Composable封装模式:状态逻辑复用
组合式API最大的架构价值是逻辑复用。好的Composable设计遵循三个原则:单一职责、响应式输入输出、可测试。
// 分页逻辑Composable
function usePagination<T>(options: {
data: Ref<T[]>
pageSize: number
}) {
const currentPage = ref(1)
const totalPages = computed(() =>
Math.ceil(options.data.value.length / options.pageSize)
)
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * options.pageSize
return options.data.value.slice(start, start + options.pageSize)
})
const goTo = (page: number) => {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
}
const nextPage = () => goTo(currentPage.value + 1)
const prevPage = () => goTo(currentPage.value - 1)
watch(() => options.data.value.length, () => {
currentPage.value = 1
})
return {
currentPage: readonly(currentPage),
totalPages: readonly(totalPages),
paginatedData,
goTo,
nextPage,
prevPage
}
}
关键设计决策:返回的currentPage和totalPages用readonly包装,外部只能通过goTo/nextPage/prevPage修改状态。这避免了外部直接修改currentPage导致的状态不一致。
组件样式隔离方案
组件库的样式隔离是发布NPM包的前提。推荐使用CSS Variables + BEM命名双重隔离:
/* 组件样式使用CSS Variables暴露主题定制点 */
.z-btn {
--z-btn-height: var(--z-comp-height-md, 32px);
--z-btn-padding: 0 var(--z-spacing-md, 12px);
--z-btn-radius: var(--z-radius-sm, 4px);
--z-btn-bg: var(--z-color-primary, #1677ff);
--z-btn-text: var(--z-color-white, #fff);
display: inline-flex;
align-items: center;
height: var(--z-btn-height);
padding: var(--z-btn-padding);
border-radius: var(--z-btn-radius);
background: var(--z-btn-bg);
color: var(--z-btn-text);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.z-btn--small {
--z-btn-height: var(--z-comp-height-sm, 24px);
--z-btn-padding: 0 var(--z-spacing-sm, 8px);
}
.z-btn--large {
--z-btn-height: var(--z-comp-height-lg, 40px);
--z-btn-padding: 0 var(--z-spacing-lg, 16px);
}
使用者只需覆盖顶层CSS Variables即可全局切换主题,不需要了解组件内部样式结构。这种方案兼容SSR场景,也不需要运行时JS开销。
组件库打包与Tree-shaking配置
组件库的打包输出直接影响使用者的包体积。推荐Rollup配置保留ESM格式以支持Tree-shaking:
// rollup.config.mjs 关键配置
export default {
input: 'src/index.ts',
output: [
{
format: 'esm',
dir: 'dist/es',
preserveModules: true,
preserveModulesRoot: 'src'
}
],
external: ['vue'],
plugins: [
typescript2({ useTsconfigDeclarationDir: true }),
postcss({ extract: false, modules: true })
]
}
preserveModules确保每个组件独立输出一个ESM文件,使用方按需import时只打包引用到的组件。package.json的exports字段配置每个组件的独立入口,配合Vite/Webpack的Tree-shaking实现精确的按需加载。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-zu-jian-ku-she-ji-typescript-yue-shu-yu/