自定义组件开发
本文档以**底部导航栏(TabBar)**为例,详细说明如何在 CatPull 低代码平台中开发一个完整的自定义组件。
概述
自定义组件开发需要完成以下几个部分:
| 部分 | 文件位置 | 说明 |
|---|---|---|
| 设计器画布组件 | packages/built-in-materials/src/components/ | 在设计器中渲染的组件 |
| 组件元数据配置 | packages/built-in-materials/src/meta/ | 定义组件的属性、事件、插槽等配置 |
| 渲染器组件映射 | packages/canvas/CanvasContainer/src/renderer/core/node.ts | 在 NATIVE_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 定位,需要注意:
- 设计器画布中:
fixed定位是相对于 iframe 视口 - 选中框位置:平台已自动处理
fixed元素的选中框位置计算(优先检测 fixed 子元素) - 滚动行为:
fixed元素不会随内容滚动 - 高度避让:
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
};总结
开发自定义组件需要关注以下要点:
- 三端一致性:设计器画布组件和运行时组件的 UI 结构应保持一致
- 样式隔离:设计器画布组件使用内联样式,运行时组件可使用 scoped 样式
- 单位转换:运行时组件需要使用
getRpx()处理尺寸单位 - 事件传递:运行时组件通过
processor.handleEvent()触发事件 - 状态管理:使用
v-show保持页面状态,避免重复渲染
