a-select / a-input 自定义下拉、Vue2 Fragment ,以及 transfer-dom

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

差异点:

  1. 插槽改 #dropdownRender / #removeIcon
  2. 父组件用 v-model:value;子组件 emit('update:value')
  3. antd4 一次点击可能多次 focus,不要再「已打开则关闭」;只负责打开,关闭交给外部点击
  4. 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>

当时的降级做法:

  1. 外面套一个合法根(常用空 span
  2. 更新后再把子节点挪到根前面,让它们在 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;

要点:

  1. 原位置用 Comment 占坑,Vue 更新时还能对上树
  2. 真 DOM 挂到 body(或选择器 / 节点)
  3. 卸载时视情况移回,避免泄漏

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

相关推荐
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 10 篇:路由
前端·vue.js·react.js
杉氧1 小时前
跨平台持久化:Flutter 本地数据库的多线程安全与架构设计实践
android·前端·flutter
毅yi1 小时前
里程碑1:实现elpis-core以及基于elpis-core的基础设置搭建
前端
FogLetter1 小时前
进程VS线程:你的电脑到底是怎么同时干那么多活的?
前端·面试
rememberme0011 小时前
华为云 CodeArts Pipeline前端 Vue 项目自动打包发布配置指南
前端·vue.js·华为云
默_笙1 小时前
🍔 我用 React 写了一个 TodoList,终于搞懂了"状态该放谁家"
前端·javascript
FogLetter1 小时前
我真的写了个“诈尸式”缓存组件:手撕React KeepAlive
前端·react.js·面试
A24207349302 小时前
Vue.js 初学者注意事项与项目开发实践指南
前端·javascript·vue.js
用户059540174462 小时前
「记仇」记忆存储测试踩坑实录:用 Pytest+Docker 把 BUG 扼杀在凌晨 3 点前
前端·css