Skip to content

自定义组件开发

本文档以**底部导航栏(TabBar)**为例,详细说明如何在 CatPull 低代码平台中开发一个完整的自定义组件。

概述

自定义组件开发需要完成以下几个部分:

部分文件位置说明
设计器画布组件packages/built-in-materials/src/components/在设计器中渲染的组件
组件元数据配置packages/built-in-materials/src/meta/定义组件的属性、事件、插槽等配置
渲染器组件映射packages/canvas/CanvasContainer/src/renderer/core/node.tsNATIVE_TAG_MAPPER 中注册组件映射
运行时组件catpull-uni/src/render/components/custom/在 UniApp 端实际运行的组件(可选)

开发流程

1. 创建设计器画布组件

设计器画布组件用于在设计器中预览和编辑组件。

vue
<!-- packages/built-in-materials/src/components/CanvasTabBar.vue -->
<template>
	<div>
		<div
			class="canvas-tab-bar"
			:style="{
				position: fixed ? 'fixed' : 'relative',
				bottom: fixed ? 0 : 'auto',
				left: 0,
				display: 'flex',
				flexDirection: 'row',
				alignItems: 'center',
				justifyContent: 'space-around',
				width: '100%',
				borderTop: '1px solid #eee',
				boxSizing: 'border-box',
				height: `${height}px`,
				background: background
			}"
		>
			<div
				v-for="(item, index) in items"
				:key="index"
				:style="{
					flex: '1',
					display: 'flex',
					flexDirection: 'column',
					alignItems: 'center',
					justifyContent: 'center',
					cursor: 'pointer'
				}"
				@click="handleClick(index, item)"
			>
				<div
					:style="{
						display: 'flex',
						alignItems: 'center',
						justifyContent: 'center'
					}"
				>
					<i
						v-if="item.icon"
						:class="`uniui-${item.icon}`"
						:style="{
							fontSize: `${iconSize}px`,
							color: getIconColor(index),
							fontFamily: 'uniui',
							fontStyle: 'normal'
						}"
					/>
					<div
						v-else
						:style="{
							width: `${iconSize}px`,
							height: `${iconSize}px`
						}"
					/>
				</div>
				<span
					:style="{
						fontSize: `${textSize}px`,
						lineHeight: 1.2,
						textAlign: 'center',
						color: getTextColor(index)
					}"
				>
					{{ item.text || '导航' }}
				</span>
			</div>
		</div>
	</div>
</template>

<script>
export default {
	props: {
		height: { type: Number, default: 47 },
		fixed: { type: Boolean, default: true },
		background: { type: String, default: '#ffffff' },
		activeColor: { type: String, default: '#007aff' },
		inactiveColor: { type: String, default: '#7f7f7f' },
		iconActiveColor: { type: String, default: '#007aff' },
		iconInactiveColor: { type: String, default: '#7f7f7f' },
		iconSize: { type: Number, default: 26 },
		textSize: { type: Number, default: 14 },
		current: { type: Number, default: 0 },
		enablePageRoute: { type: Boolean, default: false },
		items: { type: Array, default: () => [] }
	},
	emits: ['update:current', 'tabChange'],
	data() {
		return {
			localCurrent: this.current
		};
	},
	watch: {
		current(val) {
			this.localCurrent = val;
		}
	},
	computed: {
		currentIndex() {
			return this.localCurrent;
		}
	},
	methods: {
		getIconColor(index) {
			return index === this.currentIndex ? this.iconActiveColor : this.iconInactiveColor;
		},
		getTextColor(index) {
			return index === this.currentIndex ? this.activeColor : this.inactiveColor;
		},
		handleClick(index, item) {
			this.localCurrent = index;
			this.$emit('update:current', index);
			this.$emit('tabChange', { index, text: item.text || '' });
		}
	}
};
</script>

2. 创建组件元数据配置

元数据配置定义了组件在设计器中的行为,包括属性面板、事件配置等。

typescript
// packages/built-in-materials/src/meta/custom/tabBar.ts
import { generateId } from '@catpull/utils';

export default {
	componentName: 'TabBar',
	title: '底部导航栏',
	docUrl: '',
	screenshot: '',
	group: '自定义组件',
	icon: 'tab-bar',
	isContainer: false,
	disableBehaviors: ['copy', 'drag', 'remove'],
	configure: {
		props: [
			{
				name: 'height',
				title: '高度',
				setter: 'NumberSetter',
				defaultValue: 47
			},
			{
				name: 'fixed',
				title: '固定底部',
				setter: 'BoolSetter',
				defaultValue: true
			},
			{
				name: 'background',
				title: '背景颜色',
				setter: 'ColorSetter',
				defaultValue: '#ffffff'
			},
			{
				name: 'activeColor',
				title: '选中文字颜色',
				setter: 'ColorSetter',
				defaultValue: '#007aff'
			},
			{
				name: 'inactiveColor',
				title: '未选中文字颜色',
				setter: 'ColorSetter',
				defaultValue: '#7f7f7f'
			},
			{
				name: 'iconSize',
				title: '图标大小',
				setter: 'NumberSetter',
				defaultValue: 26
			},
			{
				name: 'textSize',
				title: '文字大小',
				setter: 'NumberSetter',
				defaultValue: 14
			},
			{
				name: 'current',
				title: '当前选中索引',
				setter: {
					componentName: 'NumberSetter',
					props: {
						min: 0
					}
				},
				defaultValue: 0
			},
			{
				name: 'enablePageRoute',
				title: '启用路由页面',
				setter: 'BoolSetter',
				defaultValue: false,
				condition: () => false
			},
			{
				name: 'items',
				title: '导航项配置',
				setter: {
					componentName: 'ArraySetter',
					props: {
						item: {
							setters: [
								{
									componentName: 'ObjectSetter',
									props: {
										config: {
											items: [
												{
													name: 'text',
													title: '文字',
													setter: 'StringSetter',
													defaultValue: '导航'
												},
												{
													name: 'icon',
													title: '图标',
													setter: {
														componentName: 'IconSetter',
														props: {
															type: 'uniui'
														}
													},
													defaultValue: 'home'
												},
												{
													name: 'pageId',
													title: '路由页面',
													setter: {
														componentName: 'PageSelectSetter',
														props: {}
													},
													condition: (target) => {
														return target.parent?.parent?.getPropValue('enablePageRoute');
													}
												}
											]
										}
									}
								}
							]
						}
					}
				},
				defaultValue: [
					{ text: '首页', icon: 'home', pageId: '' },
					{ text: '分类', icon: 'list', pageId: '' },
					{ text: '我的', icon: 'person', pageId: '' }
				]
			}
		],
		component: {
			isModal: false,
			isContainer: false,
			rootSelector: '.canvas-tab-bar',
			disableBehaviors: ['copy', 'drag', 'remove'],
			nestingRule: {
				childWhitelist: [],
				descendantBlacklist: ['TabBar']
			}
		},
		supports: {
			events: [
				{
					name: 'tabChange',
					title: '切换标签',
					description: '切换标签时触发',
					params: [
						{ name: 'index', title: '索引', type: 'number' },
						{ name: 'text', title: '文字', type: 'string' }
					]
				}
			],
			loop: false,
			condition: true
		}
	}
};

3. 创建运行时组件

运行时组件是在 UniApp 端实际运行的组件。

vue
<!-- catpull-uni/src/render/components/custom/TabBar.vue -->
<template>
	<view class="tab-bar-container">
		<view
			class="tab-bar"
			:class="{ 'tab-bar-fixed': fixed }"
			:style="{
				height: getRpx(height),
				backgroundColor: background
			}"
		>
			<view
				v-for="(item, index) in items"
				:key="index"
				class="tab-bar-item"
				@click="handleClick(index, item)"
			>
				<uni-icons
					v-if="item.icon"
					:type="item.icon"
					:size="iconSize"
					:color="getIconColor(index)"
				/>
				<text
					class="tab-bar-text"
					:style="{
						fontSize: getRpx(textSize),
						color: getTextColor(index)
					}"
				>
					{{ item.text || '导航' }}
				</text>
			</view>
		</view>

		<view
			v-if="enablePageRoute"
			class="tab-bar-pages"
		>
			<view
				v-for="(item, index) in items"
				:key="index"
				v-show="index === localCurrent"
				class="tab-bar-page"
			>
				<page-route-block
					v-if="item.pageId"
					:node="{ props: { pageId: item.pageId, width: '100%', height: '100%' } }"
					:context="context"
				/>
			</view>
		</view>
	</view>
</template>

<script>
import { getRpx } from '../../utils/rpx';

export default {
	props: {
		height: { type: Number, default: 47 },
		fixed: { type: Boolean, default: true },
		background: { type: String, default: '#ffffff' },
		activeColor: { type: String, default: '#007aff' },
		inactiveColor: { type: String, default: '#7f7f7f' },
		iconActiveColor: { type: String, default: '#007aff' },
		iconInactiveColor: { type: String, default: '#7f7f7f' },
		iconSize: { type: Number, default: 26 },
		textSize: { type: Number, default: 14 },
		current: { type: Number, default: 0 },
		enablePageRoute: { type: Boolean, default: false },
		items: { type: Array, default: () => [] },
		context: { type: Object, default: () => ({}) }
	},
	emits: ['update:current', 'tabChange'],
	data() {
		return {
			localCurrent: this.current
		};
	},
	watch: {
		current(val) {
			this.localCurrent = val;
		}
	},
	methods: {
		getRpx,
		getIconColor(index) {
			return index === this.localCurrent ? this.iconActiveColor : this.iconInactiveColor;
		},
		getTextColor(index) {
			return index === this.localCurrent ? this.activeColor : this.inactiveColor;
		},
		handleClick(index, item) {
			this.localCurrent = index;
			this.$emit('update:current', index);
			
			const processor = this.context?.processor;
			if (processor) {
				processor.handleEvent('tabChange', { index, text: item.text || '' });
			}
		}
	}
};
</script>

<style lang="scss" scoped>
.tab-bar-container {
	width: 100%;
}

.tab-bar {
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: space-around;
	width: 100%;
	border-top: 1px solid #eee;
	box-sizing: border-box;

	&-fixed {
		position: fixed;
		bottom: 0;
		left: 0;
		z-index: 999;
	}

	&-item {
		flex: 1;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		padding: 4px 0;
	}

	&-text {
		margin-top: 2px;
		line-height: 1.2;
		text-align: center;
	}

	&-pages {
		width: 100%;
		flex: 1;
	}

	&-page {
		width: 100%;
		height: 100%;
	}
}
</style>

注意事项

1. 样式处理

设计器画布组件:

  • 使用内联样式(:style),避免样式隔离问题
  • 尺寸单位使用 px

运行时组件:

  • 可以使用 <style> 标签
  • 尺寸单位需要使用 getRpx() 函数转换为 rpx,以适配不同屏幕
javascript
import { getRpx } from '../../utils/rpx';

// 使用方式
:style="{ fontSize: getRpx(textSize) }"

2. 固定定位元素

如果组件使用 position: fixed 定位,需要注意:

  1. 设计器画布中fixed 定位是相对于 iframe 视口
  2. 选中框位置:平台已自动处理 fixed 元素的选中框位置计算(优先检测 fixed 子元素)
  3. 滚动行为fixed 元素不会随内容滚动
  4. 高度避让fixed 元素脱离文档流,需要处理内容遮挡问题

设计器画布组件:添加占位元素

设计器画布中组件无法控制父容器的 padding,需要在组件内部添加占位元素:

vue
<template>
	<div>
		<!-- 固定定位的 TabBar -->
		<div
			class="canvas-tab-bar"
			:style="{
				position: fixed ? 'fixed' : 'relative',
				bottom: fixed ? 0 : 'auto',
				// ...其他样式
			}"
		>
			<!-- TabBar 内容 -->
		</div>
		<!-- 占位元素:当 fixed 时撑开父容器高度 -->
		<!-- 注意:占位元素放在 fixed 元素之后,确保 getRect 优先检测到 fixed 元素 -->
		<div 
			v-if="fixed" 
			class="canvas-tab-bar__placeholder" 
			:style="{ height: `${height}px` }"
		></div>
	</div>
</template>

关键点说明:

要点说明
占位元素位置放在 fixed 元素之后,确保渲染器优先检测到 fixed 元素
高度fixed 元素高度一致
条件渲染使用 v-if="fixed" 仅在固定定位时显示

运行时组件:添加 padding-bottom

运行时组件可以控制页面容器的 padding,给内容区域添加底部边距:

vue
<template>
	<view class="tab-bar-container">
		<!-- 页面内容区域:添加 padding-bottom 避让 fixed 的 TabBar -->
		<view 
			class="tab-bar-pages" 
			:style="{ paddingBottom: fixed ? heightRpx : 0 }"
		>
			<!-- 页面内容 -->
		</view>
		<!-- 固定定位的 TabBar -->
		<view
			class="tab-bar"
			:class="{ 'tab-bar--fixed': fixed }"
			:style="{ height: heightRpx }"
		>
			<!-- TabBar 内容 -->
		</view>
	</view>
</template>

3. 事件处理

设计器画布组件:

javascript
this.$emit('tabChange', { index, text: item.text || '' });

运行时组件:

javascript
const processor = this.context?.processor;
if (processor) {
	processor.handleEvent('tabChange', { index, text: item.text || '' });
}

注册组件

完成组件开发后,需要在以下位置注册:

1. 设计器画布组件注册

typescript
// packages/built-in-materials/src/index.ts
import CanvasTabBar from './components/CanvasTabBar.vue';

export const components = {
	TabBar: CanvasTabBar
};

// 导出组件供渲染器使用
export { CanvasTabBar };

2. 元数据注册

typescript
// packages/built-in-materials/src/meta/index.ts
import tabBar from './custom/tabBar';

export const meta = {
	TabBar: tabBar
};

3. 渲染器组件映射

在设计器画布渲染器中注册组件映射,使渲染器能够正确加载组件:

typescript
// packages/canvas/CanvasContainer/src/renderer/core/node.ts

// 导入组件
import { CanvasTable, CanvasAudio, CanvasPageRouteBlock, CanvasTabBar } from '@catpull/built-in-materials';

/**
 * 内置物料映射,将特殊标签映射到自定义的 Vue 组件
 * 这样可以让原生标签支持插槽等功能
 */
const NATIVE_TAG_MAPPER: Record<string, any> = {
	div: CanvasBoxWrapper,
	slot: CanvasBoxWrapper,
	template: CanvasBoxWrapper,
	table: CanvasTable,
	audio: CanvasAudio,
	PageRouteBlock: CanvasPageRouteBlock,
	TabBar: CanvasTabBar  // 添加自定义组件映射
};

4. 运行时组件注册

typescript
// catpull-uni/src/render/components/custom/index.ts
import TabBar from './TabBar.vue';

export const customComponents = {
	TabBar
};

总结

开发自定义组件需要关注以下要点:

  1. 三端一致性:设计器画布组件和运行时组件的 UI 结构应保持一致
  2. 样式隔离:设计器画布组件使用内联样式,运行时组件可使用 scoped 样式
  3. 单位转换:运行时组件需要使用 getRpx() 处理尺寸单位
  4. 事件传递:运行时组件通过 processor.handleEvent() 触发事件
  5. 状态管理:使用 v-show 保持页面状态,避免重复渲染