前言

最近在开发后台管理系统时,我遇到了一个常见的需求:在表格的操作列中,根据数据状态动态显示不同的操作按钮。

为了让按钮之间排列整齐并带有分隔符,我使用了 Element Plus 的 el-space 组件。代码如下:

<el-space size="16" spacer="|">
  <el-button type="primary" text> 修改 </el-button>
  <el-button v-if="isRecycle" type="success" text> 还原 </el-button>
  <el-button type="danger" text > 删除 </el-button>
</el-space>

预期效果是:当 isRecyclefalse 时,“还原”按钮不渲染,并且“修改”和“删除”按钮之间只有一个分隔符。

但实际情况是,即便 isRecyclefalse,“还原”按钮确实消失了,但它原本所在的位置却留下了一个多余的分隔符。

多余的间隔符

研究了一上午,终于是找到了解决办法。

问题分析:为何会产生多余的分隔符?

要解决问题,首先需要理解 el-space 的工作原理和 v-if 在 Vue 3 中的行为。

  1. el-space 的工作原理
    通过阅读源码可以发现,el-space 组件的核心逻辑是遍历其默认插槽(default slot)中的所有 VNode (虚拟节点)。然后,在除了最后一个有效节点之外的每个节点后面,插入一个 spacer 元素(即我们设置的 | 分隔符)。
  2. v-if="false" 的行为
    在 Vue 3 中,当一个元素或组件的 v-if 指令为 false 时,它不会被渲染到真实 DOM 中。然而,在虚拟 DOM 层面,它会被渲染成一个注释节点(Comment Node),作为占位符。

结论:当 v-if="false" 时,el-space 获取到的插槽内容 VNode 列表实际上是 [VNode(修改按钮), VNode(注释节点), VNode(删除按钮)]el-space 将这个注释节点视为一个有效的子节点,因此在“修改按钮”和“注释节点”后面都添加了分隔符,导致最终渲染出两个分隔符,而第二个分隔符因为“注释节点”本身不可见,看起来就像是多余的。

解决方案

既然问题根源在于 el-space 将注释节点也计入了分隔符的渲染逻辑,那么我们的目标就是避免让注释节点出现在 el-space 的直接子节点中

我封装了一个组件:TableOperations.vue

实现方式如下,通过组合式 API useSlots 和计算属性来动态生成一个“干净”的子节点列表:

<script lang="ts">
import { ElSpace } from "element-plus";
import type { SpaceProps } from "element-plus";
import { Comment } from "vue";

export default defineComponent({
 name: "TableOperations",
 setup(props) {
  const slots = useSlots();

  return () =>
   h(
    ElSpace,
    {
     size: 0,
     spacer: "|"
    },
    () => (slots.default?.() || []).filter((node) => node?.type !== Comment)
   );
 }
});
</script>

现在v-if的问题解决了,但是在真实的业务场景中,我们可能还需要考虑其他因素,比如:v-show,有时候为了减少渲染开销,我们可能需要使用 v-show 来代替 v-if

但是上面的代码只兼容了v-if,对于v-show的情况,我们需要进一步处理。

<script lang="ts">
import { ElSpace } from "element-plus";
import type { SpaceProps } from "element-plus";
import { Comment, type VNode } from "vue";

export interface TableOperationsProps {
 size?: SpaceProps["size"];
 spacer?: SpaceProps["spacer"];
}

export default defineComponent({
 name: "TableOperations",
 props: {
  size: {
   type: [Number, String] as PropType<SpaceProps["size"]>,
   default: "small"
  },
  spacer: {
   type: [String, Object] as PropType<SpaceProps["spacer"]>
  }
 },
 setup(props) {
  const slots = useSlots();

  /** 判断是不是Comment节点
   * 当`v-if="false"`时默认会插入一个注释节点Comment
   */
  function isComment(node: VNode): boolean {
   return node?.type === Comment;
  }

  /** 判断是不是v-show指令 */
  function isVShowDirective(dir: DirectiveBinding): boolean {
   // @ts-expect-error fuck ts
   return typeof dir.dir.name === "string" && dir.dir.name === "show";
  }

  return () =>
   h(
    ElSpace,
    {
     size: props.size,
     spacer: props.spacer
    },
    () => {
     return (slots.default?.() || []).filter((node) => {
      // 剔除 v-if="false" (Comment 节点)
      if (isComment(node)) return false;

      // 检查 v-show="false"
      if (node.dirs && node.dirs.length) {
       const findShow = node.dirs.find(isVShowDirective);
       if (findShow) return Boolean(findShow.value);
      }

      // 既不是 v-if=false 也不是 v-show=false
      return true;
     });
    }
   );
 }
});
</script>

做法也很粗暴,在剔除了注释节点后,再来获取 v-show 指令,并根据其值来决定是否渲染该节点。

完善功能

目前组件已经达到了我们需要的功能,但是还有一些不足,就是我们没办法完全将 TableOperations 作为一个增强版的 ElSpace 组件来使用,比如它不支持其它的props属性,但是事实上我们只是在 ElSpace 组件的基础上对插槽(slot)做了增强,所以,为了不仅仅是在表格中使用它,在别的地方也可以使用它,我们还需要完善属性传递,使其可以像正常的 ElSpace 组件一样使用。

<script lang="ts">
import { ElSpace } from "element-plus";
import { type SpaceProps, spaceProps } from "element-plus";
import { Comment, type VNode } from "vue";

export type ElSpaceProProps = SpaceProps;

export default defineComponent({
 name: "ElSpacePro",
 props: spaceProps,
 setup(props) {
  const slots = useSlots();
  const attrs = useAttrs();

  /** 判断是不是Comment节点
   * 当`v-if="false"`时默认会插入一个注释节点Comment
   */
  function isComment(node: VNode): boolean {
   return node?.type === Comment;
  }

  /** 判断是不是v-show指令 */
  function isVShowDirective(dir: DirectiveBinding): boolean {
   // @ts-expect-error fuck ts
   return typeof dir.dir.name === "string" && dir.dir.name === "show";
  }

  return () =>
   h(
    ElSpace,
    {
     ...props,
     ...attrs
    },
    () => {
     return (slots.default?.() || []).filter((node) => {
      // 剔除 v-if="false" (Comment 节点)
      if (isComment(node)) return false;

      // 检查 v-show="false"
      if (node.dirs && node.dirs.length) {
       const findShow = node.dirs.find(isVShowDirective);
       if (findShow) return Boolean(findShow.value);
      }

      // 既不是 v-if=false 也不是 v-show=false
      return true;
     });
    }
   );
 }
});
</script>

做法也很简单,把props定义成ElSpace的props就行了,然后传递过去,并且组件名我们也改成 ElSpacePro 使其泛用性更高。

效果展示

效果展示

实现原理解析

  1. 获取插槽内容:通过 Vue 3 提供的 useSlots() hook 访问组件插槽。slots.default() 的执行结果是一个包含所有默认插槽子节点的 VNode 数组。
  2. 使用render组件函数:使用render组件函数可以实现更灵活的渲染逻辑,我测试了使用template的方式,会导致节点的diff发生问题,导致绑定的数据无法更新。
  3. 过滤插槽内容 (核心): 遍历 slots.default() 数组,过滤掉 Comment 类型的节点,在vue3中,v-if="false" 在 VDOM 中会生成一个注释节点(Comment Node)。这类特殊节点的类型由 Vue 内部的一个 Symbol` 标识。
    最健壮且官方推荐的识别方法,是使用 Vue 自身导出的类型标识符。代表注释节点的 Symbol 被导出为 Comment。因此,我们可以从'vue' 中导入它,并使用 node.type !== Comment 作为精确的过滤条件。

    这种方式避免了依赖内部实现细节的脆弱判断(如 node.type.toString() === 'Symbol(v-cmt)'),保证了代码在 Vue 版本迭代中的稳定性和向后兼容性。

  4. 渲染最终结果:通过 h(ElSpace) 调用 ElSpace 组件,并传入 sizespacer 属性,并使用 () => (slots.default?.() || []).filter((node) => node?.type !== Comment) 作为插槽内容,注意插槽内容是一个函数,不然会触发vue的警告。

总结

el-spacev-if 的渲染冲突,本质上是 el-space 的通用分隔符逻辑与 Vue 3 v-if="false" 产生的注释节点之间的不兼容。通过手动介入 VNode 的处理流程,即使用 useSlots 获取插槽内容,并过滤掉 Comment 类型的节点,我们可以在不改变原有业务逻辑和模板结构的前提下,优雅地解决这一问题,确保最终渲染效果符合预期。

分类: vue 项目实战 标签: slot插槽element-plusel-spacev-ifComment注释节点

评论

暂无评论数据

暂无评论数据

目录