a-select / a-input 自定义下拉、Vue2 Fragment ,以及 transfer-dom
Vue2 / Vue3 · ant-design-vue · Fragment · DOM portal
背景
今天在重构升级项目,从 Vue2 到 Vue2, 总结分享一下 自定义面板 与 Fragment
做搜索栏时经常要:
a-select里塞自定义面板(全选 + Checkbox 列表)a-input点开后出现一块自定义编辑层(多段起止时间)- Vue2 中实现,Vue3 中的 Fragment
Vue2 → Vue3、antd1 → antd4 时,这三类写法都要改。下面按实现对比说明。
一、a-select 自定义下拉面板
思路:用官方 dropdownRender(或旧版 slot="dropdownRender")替换默认 Option 列表;用受控 open 管显隐;面板内 @mousedown.prevent 避免点 Checkbox 时 Select 失焦关掉。
Vue2 + ant-design-vue 1.x
vue
<template>
<a-select
ref="select"
:value="value"
:open="dropdownOpen"
:dropdownMatchSelectWidth="false"
mode="multiple"
@focus="selectFocus"
@change="onChange"
>
<span slot="removeIcon"></span>
<div slot="dropdownRender">
<div
ref="dropdownRenderRef"
style="min-width: 420px; padding: 10px; max-height: 420px; overflow: auto"
@click.stop
@mousedown.stop="(e) => e.preventDefault()"
>
<a-checkbox :checked="checkAll" :indeterminate="indeterminate" @change="onCheckAll">
全选
</a-checkbox>
<div style="border: 1px solid #5ba3ff; margin: 5px 0"></div>
<a-checkbox-group :value="value" @change="onGroupChange">
<div v-for="item in list" :key="item.id">
<a-checkbox :value="item.id">{{ item.label }}</a-checkbox>
</div>
</a-checkbox-group>
</div>
</div>
<a-select-option v-for="item in selectedOptions" :key="item.id" :value="item.id">
{{ item.label }}
</a-select-option>
</a-select>
</template>
<script>
export default {
model: { prop: 'value', event: 'change' },
props: { value: { type: Array, default: () => [] }, list: Array },
data() {
return { dropdownOpen: false, checkAll: false, indeterminate: false, selectedOptions: [] };
},
methods: {
selectFocus() {
// Vue2 / antd1 常见写法:focus 时切换开闭
if (this.dropdownOpen) {
this.dropdownOpen = false;
this.$refs.select.blur();
return;
}
this.dropdownOpen = true;
this.$nextTick(() => {
const panel = this.$refs.dropdownRenderRef;
const close = (e) => {
if (panel && panel.contains(e.target) && e instanceof WheelEvent) return;
window.onclick = null;
this.dropdownOpen = false;
this.$refs.select.blur();
};
window.onclick = close;
});
},
onChange(val) {
this.$emit('change', val);
},
// onCheckAll / onGroupChange ...
},
};
</script>
注意:Vue2 父组件 v-model 对应 value + change(或组件 model 配置)。
Vue3 + ant-design-vue 4.x
差异点:
- 插槽改
#dropdownRender/#removeIcon - 父组件用
v-model:value;子组件emit('update:value') - antd4 一次点击可能多次
focus,不要再「已打开则关闭」;只负责打开,关闭交给外部点击 - Checkbox.Group 默认横向,要自己做纵向;下拉 teleport 到 body 后 scoped 样式无效,用全局类或内联样式
vue
<template>
<a-select
ref="selectRef"
:value="value"
:open="dropdownOpen"
:popupMatchSelectWidth="false"
mode="multiple"
@focus="selectFocus"
@change="onChange"
>
<template #removeIcon><span /></template>
<template #dropdownRender>
<div
ref="panelRef"
style="width: 420px; padding: 10px; max-height: 420px; overflow: auto"
@click.stop
@mousedown.stop="(e) => e.preventDefault()"
>
<a-checkbox :checked="checkAll" :indeterminate="indeterminate" @change="onCheckAll">
全选
</a-checkbox>
<a-checkbox-group
class="custom-select-checkbox-group"
:value="value"
@change="onGroupChange"
>
<div v-for="item in list" :key="item.id" class="custom-select-checkbox-item">
<a-checkbox :value="item.id">{{ item.label }}</a-checkbox>
</div>
</a-checkbox-group>
</div>
</template>
<a-select-option v-for="item in selectedOptions" :key="item.id" :value="item.id">
{{ item.label }}
</a-select-option>
</a-select>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue';
const props = defineProps<{ value?: string[]; list?: { id: string; label: string }[] }>();
const emit = defineEmits<{
(e: 'update:value', v: string[]): void;
(e: 'change', v: string[]): void;
}>();
const selectRef = ref<{ blur: () => void } | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const dropdownOpen = ref(false);
function selectFocus() {
dropdownOpen.value = true;
nextTick(() => {
const panel = panelRef.value;
if (!panel) return;
// 错开当前点击冒泡,避免一打开就被关掉
window.setTimeout(() => {
const close = (e: MouseEvent | WheelEvent) => {
if (!(e.target instanceof Node)) return;
if (e instanceof WheelEvent && panel.contains(e.target)) return;
window.onclick = null;
window.onwheel = null;
dropdownOpen.value = false;
selectRef.value?.blur();
};
window.onclick = close;
window.onwheel = close;
}, 0);
});
}
function onChange(v: unknown) {
const next = Array.isArray(v) ? v.map(String) : [];
emit('update:value', next);
emit('change', next);
}
</script>
<style>
/* teleport 后 scoped 无效,用明确类名 */
.custom-select-checkbox-group {
display: flex !important;
flex-direction: column;
width: 100%;
}
.custom-select-checkbox-item {
display: block;
width: 100%;
}
.custom-select-checkbox-item .ant-checkbox-wrapper {
margin-inline-start: 0 !important;
white-space: normal;
}
</style>
父组件:
vue
<!-- Vue2 -->
<TaskSelect v-model="ids" :list="list" />
<!-- Vue3 -->
<TaskSelect v-model:value="ids" :list="list" />
二、a-input 自定义下拉面板
a-select 自带下拉层;a-input 没有,一般自己做一层面板:focus 显示,挂到 body(避免被父级裁切),再算位置。
Vue2 写法(absolute + offset 链)
vue
<template>
<div style="display: inline-block" @click.stop>
<a-input :value="text" @focus="onFocus" />
<div
v-show="visible"
v-transfer-dom
:data-transfer="true"
class="input-drop"
:style="panelStyle"
@click.stop
>
<!-- 自定义内容:多行起止时间等 -->
<div v-for="(row, i) in rows" :key="i" class="row">
<a-input v-model="row.start" />
~
<a-input v-model="row.end" />
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
visible: false,
top: 0,
left: 0,
minWidth: 0,
};
},
computed: {
panelStyle() {
return {
position: 'absolute',
top: this.top + 'px',
left: this.left + 'px',
minWidth: this.minWidth + 'px',
zIndex: 9999,
};
},
},
methods: {
onFocus() {
const el = this.$el;
this.left = getElementLeft(el, true);
this.top = getElementTop(el, true) + 35;
this.minWidth = el.offsetWidth;
document.body.click(); // 关掉其它浮层
this.visible = true;
},
},
mounted() {
document.body.addEventListener('click', () => {
this.visible = false;
});
},
};
</script>
父级布局一复杂,offsetTop 累加容易偏。
Vue3 写法(fixed + getBoundingClientRect)
面板仍可用 v-transfer-dom(钩子改成 Vue3),或直接 <Teleport to="body">。
vue
<template>
<div ref="rootRef" style="display: inline-block" @click.stop>
<a-input :value="text" @focus="onFocus" />
<div
v-show="visible"
v-transfer-dom
:data-transfer="true"
class="input-drop"
:style="panelStyle"
@click.stop
>
<div v-for="(row, i) in rows" :key="i" class="row">
<a-input :value="row.start" @change="..." />
~
<a-input :value="row.end" @change="..." />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
const rootRef = ref<HTMLElement | null>(null);
const visible = ref(false);
const top = ref(0);
const left = ref(0);
const minWidth = ref(0);
const panelStyle = computed(() => ({
position: 'fixed' as const,
top: `${top.value}px`,
left: `${left.value}px`,
minWidth: `${minWidth.value}px`,
zIndex: 9999,
}));
function onFocus() {
const el = rootRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
left.value = rect.left;
top.value = rect.bottom + 4;
minWidth.value = rect.width;
document.body.click();
visible.value = true;
}
onMounted(() => {
const close = () => {
visible.value = false;
};
document.body.addEventListener('click', close);
onBeforeUnmount(() => document.body.removeEventListener('click', close));
});
</script>
对比:
| Vue2 常见 | 更稳妥(Vue3 推荐) | |
|---|---|---|
| 挂载目标 | transfer-dom → body | 同左,或 <Teleport to="body"> |
| 定位 | absolute + offset 累加 |
fixed + getBoundingClientRect |
| 坐标系 | 受 offsetParent / 滚动影响大 | 相对视口,少漂移 |
三、Vue2 里「无根节点」怎么凑
需求:子组件要输出多个搜索项,和父级其它项平级,方便同一行折行。
Vue2 组件只能有一个根节点,不能直接:
vue
<!-- Vue2 不合法 -->
<template>
<ItemA />
<ItemB />
<ItemC />
</template>
当时的降级做法:
- 外面套一个合法根(常用空
span) - 更新后再把子节点挪到根前面,让它们在 DOM 上变成父级的直接子节点
vue
<template>
<span ref="container">
<SearchItem ref="item1">日期</SearchItem>
<SearchItem ref="item2">车辆</SearchItem>
<SearchItem ref="item3">任务</SearchItem>
<SearchItem ref="item4">时间</SearchItem>
</span>
</template>
<script>
export default {
// 解决:单根限制 vs 父级要多个平级搜索项折行
updated() {
this.$nextTick(() => {
const container = this.$refs.container;
const parent = container && container.parentNode;
if (!parent) return;
const children = [
this.$refs.item1,
this.$refs.item2,
this.$refs.item3,
this.$refs.item4,
].filter(Boolean);
children.forEach((vm) => vm.$el.remove());
children.forEach((vm) => {
parent.insertBefore(vm.$el, container);
});
});
},
beforeDestroy() {
[this.$refs.item1, this.$refs.item2, this.$refs.item3, this.$refs.item4]
.filter(Boolean)
.forEach((vm) => vm.$el.remove());
},
};
</script>
本质是用 DOM 手术模拟 Fragment。
Vue3 支持多根,直接写即可,不必再挪 DOM:
vue
<template>
<SearchItem>日期</SearchItem>
<SearchItem>车辆</SearchItem>
<SearchItem>任务</SearchItem>
<SearchItem>时间</SearchItem>
</template>
补充:若仍把挪 DOM 写在 updated / onUpdated 里,注意 Vue3 首次挂载不会走 updated ,只写 updated 会漏第一帧。Vue3 正确做法是删掉这套降级,而不是再补 onMounted。
四、transfer-dom:实现与使用
把浮层从组件内部挪到 body(或指定节点),避免被父级 overflow: hidden、层叠上下文挡住。这套实现常见于移动端/组件库,不是自己凭空发明的。
当时是在组件库源码里看到这套 portal 写法,再落到项目指令里用的。
核心实现(Vue2 钩子)
js
function getTarget(node) {
if (node === undefined) node = document.body;
if (node === true) return document.body;
return node instanceof Node ? node : document.querySelector(node);
}
const directive = {
inserted(el, { value }) {
if (el.dataset && el.dataset.transfer !== 'true') return;
const parentNode = el.parentNode;
if (!parentNode) return;
const home = document.createComment(''); // 占位
let hasMovedOut = false;
if (value !== false) {
parentNode.replaceChild(home, el); // 原位置留下注释
getTarget(value).appendChild(el); // 真节点挂到 body
hasMovedOut = true;
}
el.__transferDomData = { parentNode, home, target: getTarget(value), hasMovedOut };
},
componentUpdated(el, { value }) {
const data = el.__transferDomData;
if (!data) return;
// 按 value 在「回家 / 继续挂在外部 / 换目标」之间切换
// ...
},
unbind(el) {
const data = el.__transferDomData;
if (!data) return;
if (data.hasMovedOut) data.parentNode && data.parentNode.appendChild(el);
el.__transferDomData = null;
},
};
export default directive;
要点:
- 原位置用
Comment占坑,Vue 更新时还能对上树 - 真 DOM 挂到
body(或选择器 / 节点) - 卸载时视情况移回,避免泄漏
Vue3 钩子名
| Vue2 | Vue3 |
|---|---|
| inserted | mounted |
| componentUpdated | updated |
| unbind | unmounted |
逻辑可以几乎原样搬,只改钩子名。
使用方式
vue
<!-- 面板移到 body;data-transfer="true" 才启用 -->
<div v-show="visible" v-transfer-dom :data-transfer="true" :style="panelStyle">
...
</div>
js
// 局部注册
import TransferDom from '@/directives/transfer-dom';
// script setup:
const vTransferDom = TransferDom;
// 或全局
// app.directive('transfer-dom', TransferDom)
Vue3 也可以用内置能力,少维护一份指令:
vue
<Teleport to="body">
<div v-show="visible" :style="panelStyle">...</div>
</Teleport>
已有业务大量依赖 v-transfer-dom 时,改钩子 + 修正定位通常比一次性全改 Teleport 成本低。
小结
| 场景 | Vue2 | Vue3 |
|---|---|---|
| a-select 自定义面板 | slot="dropdownRender" + 受控 open |
#dropdownRender;focus 只开不关;v-model:value |
| a-input 自定义面板 | transfer + absolute/offset | transfer 或 Teleport + fixed/getBoundingClientRect |
| 多搜索项平级 | 单根 span + insertBefore | 多根 Fragment |
| 挂到 body | vux / vue-dom-portal 系 transfer-dom | 同左(改钩子)或 <Teleport> |
相关链接
本文基于真实项目经验整理,手工起草文章大纲,AI 辅助润色,于 2026-08-03