在 Vue 中动态切换 HTML 元素

Avatar of Travis Almand
Travis Almand

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

一位朋友曾联系我,询问我是否有办法在 Vue 的模板块中动态地将一个 HTML 元素更改为另一个。例如,根据某些条件将一个 <div> 元素切换为 <span> 元素。诀窍是在不依赖一系列 v-ifv-else 代码的情况下实现这一点。

我当时并没有太在意,因为我看不出这么做的充分理由;这种情况并不常见。然而,就在同一天晚些时候,他又联系了我,告诉我他已经学会了如何更改元素类型。他兴奋地指出,Vue 有一个内置组件,可以像他需要的那样用作动态元素。

这个小功能可以使模板中的代码保持简洁整齐。它可以减少 v-ifv-else 的冗余代码,从而获得更易于理解和维护的代码量。这使我们可以使用方法或计算属性在脚本块中创建编写良好的、更复杂的条件。这些东西应该放在脚本中,而不是模板块中。

我写这篇文章的想法主要是因为我们在工作中设计的系统中有多个地方使用了这个功能。诚然,它不是一个很大的功能,而且在文档中几乎没有提到,至少据我所知是这样的。但它有可能帮助渲染组件中的特定 HTML 元素。

Vue 的内置 <component> 元素

Vue 中有几个功能可以轻松地对视图进行动态更改。其中一项功能是内置的 <component> 元素,它允许组件动态切换。在 Vue 2Vue 3 文档 中,都有一小段关于将此元素与 HTML 元素一起使用的说明;这就是我们现在要探讨的部分。

其思路是利用 <component> 元素的这一特性来交换本质上类似但功能、语义或视觉效果不同的常用 HTML 元素。以下基本示例将展示此元素在保持 Vue 组件简洁整齐方面的潜力。

按钮和链接经常可以互换使用,但它们的功能、语义甚至视觉效果都有很大差异。一般来说,按钮 (<button>) 用于当前视图中的内部操作,并与 JavaScript 代码相关联。另一方面,链接 (<a>) 用于指向另一个资源,无论是主机服务器上的资源还是外部资源;最常见的是网页。单页应用程序往往比链接更多地依赖按钮,但两者都是必需的。

链接通常在视觉上被设计成按钮的样子,就像 Bootstrap 的 .btn 创建按钮外观一样。考虑到这一点,我们可以轻松地创建一个组件,根据单个 prop 在这两个元素之间切换。该组件默认情况下将是一个按钮,但如果应用了 href prop,它将呈现为一个链接。

这是模板中的 <component>

<component
  :is="element"
  :href="href"
  class="my-button"
>
  <slot />
</component>

此绑定的 is 属性指向一个名为 element 的计算属性,而绑定的 href 属性则使用恰如其分命名的 href prop。这利用了 Vue 的正常行为,即如果 prop 没有值,则绑定的属性不会出现在渲染的 HTML 元素中。无论最终元素是按钮还是链接,插槽都提供内部内容。

计算属性的本质很简单

element () {
  return this.href ? 'a' : 'button';
}

如果有 href prop,则应用 <a> 元素;否则,我们得到一个 <button>

<my-button>this is a button</my-button>
<my-button href="https://www.css-tricks.com">this is a link</my-button>

HTML 渲染如下

<button class="my-button">this is a button</button>
<a href="https://www.css-tricks.com" class="my-button">this is a link</a>

在这种情况下,人们可能期望这两个元素在视觉上相似,但出于语义和可访问性方面的考虑,它们显然是不同的。也就是说,这两个输出元素不必具有相同的样式。您可以在样式块中使用选择器 div.my-button 来使用该元素,或者创建一个根据元素变化的动态类。

总体目标是简化操作,允许一个组件根据需要呈现为两个不同的 HTML 元素——无需 v-ifv-else

有序列表还是无序列表?

与上面的按钮示例类似,我们可以创建一个输出不同列表元素的组件。由于无序列表和有序列表都使用相同的列表项 (<li>) 元素作为子元素,因此这很容易实现;我们只需交换 <ul><ol> 即可。即使我们想要提供一个使用描述列表 <dl> 的选项,这也很容易实现,因为内容只是一个可以接受 <li> 元素或 <dt>/<dd> 组合的插槽。

模板代码与按钮示例非常相似

<component
  :is="element"
  class="my-list"
>
  <slot>No list items!</slot>
</component>

请注意插槽元素内的默认内容,我稍后会讲到。

有一个用于要使用的列表类型的 prop,默认为 <ul>

props: {
  listType: {
    type: String,
    default: 'ul'
  }
}

同样,还有一个名为 element 的计算属性

element () {
  if (this.$slots.default) {
    return this.listType;
  } else {
    return 'div';
  }
}

在这种情况下,我们正在测试默认插槽是否存在,这意味着它有要渲染的内容。如果存在,则使用通过 listType prop 传递的列表类型。否则,元素将变为 <div>,它将在插槽元素内显示“No list items!”消息。这样,如果没有列表项,则 HTML 不会呈现为包含一条消息的列表,表明没有项目。当然,这部分取决于您,但最好考虑没有明显有效项目的列表的语义。另一个需要考虑的是,辅助工具可能会误将此列表识别为包含一条消息的列表,而消息表明没有项目,这可能会导致混淆。

就像上面的按钮示例一样,您也可以对每个列表进行不同的样式设置。这可以基于目标元素类名为 ul.my-list 的选择器。另一种选择是根据所选元素动态更改类名。

此示例遵循类似于 BEM 的类命名结构

<component
  :is="element"
  class="my-list"
  :class="`my-list__${element}`"
>
  <slot>No list items!</slot>
</component>

用法与之前的按钮示例一样简单

<my-list>
  <li>list item 1</li>
</my-list>

<my-list list-type="ol">
  <li>list item 1</li>
</my-list>

<my-list list-type="dl">
  <dt>Item 1</dt>
  <dd>This is item one.</dd>
</my-list>

<my-list></my-list>

每个实例都呈现指定的列表元素。最后一个实例导致一个 <div> 元素,因为它没有要显示的列表,因此它显示“没有列表项!”。

有人可能会问,当可以使用简单的 HTML 时,为什么要创建一个在不同列表类型之间切换的组件?虽然出于样式和可维护性方面的考虑,将列表包含在组件中可能会有好处,但还可以考虑其他原因。例如,如果某些形式的功能与不同的列表类型相关联?也许可以考虑对 <ul> 列表进行排序,切换到 <ol> 以显示排序顺序,然后在完成排序后切换回来?

现在我们正在控制元素

即使这两个示例本质上是在更改根元素组件,也要考虑组件的更深层次内容。例如,标题可能需要根据某些条件从 <h2> 更改为 <h3>

如果您发现自己不得不使用三元运算符来控制超出几个属性的内容,我建议您坚持使用 v-if。编写更多代码来处理属性、类和属性只会使代码比 v-if 更复杂。在这些情况下,从长远来看,v-if 会使代码更简单,而更简单的代码更容易阅读和维护。

在创建组件时,如果有一个简单的 v-if 用于在元素之间切换,请考虑 Vue 主要功能的这一小方面。

扩展思路,灵活的卡片系统

考虑我们到目前为止所涵盖的所有内容,并将其应用于灵活的卡片组件中。此卡片组件示例允许将三种不同类型的卡片放置在文章布局的特定部分

  • 英雄卡片:预计此卡片将用于页面顶部,并比其他卡片吸引更多的注意力。
  • 号召性用语卡片:此卡片用作文章之前或文章内部的用户操作行。
  • 信息卡片:此卡片用于引用。

请将这些卡片视为遵循设计系统,并且组件控制 HTML 的语义和样式。

在上面的示例中,您可以看到顶部的英雄卡片,接下来是一行号召性用语卡片,然后——向下滚动一点——您将在右侧看到信息卡片。

以下是卡片组件的模板代码

<component :is="elements('root')" :class="'custom-card custom-card__' + type" @click="rootClickHandler">
  <header class="custom-card__header" :style="bg">
    <component :is="elements('header')" class="custom-card__header-content">
      <slot name="header"></slot>
    </component>
  </header>
  <div class="custom-card__content">
    <slot name="content"></slot>
  </div>
  <footer class="custom-card__footer">
    <component :is="elements('footer')" class="custom-card__footer-content" @click="footerClickHandler">
      <slot name="footer"></slot>
    </component>
  </footer>
</component>

卡片中有三个“组件”元素。每个元素代表卡片内部的一个特定元素,但会根据卡片的类型进行更改。每个组件都使用一个参数调用elements()方法,该参数标识正在调用卡片的哪个部分。

elements()方法是

elements(which) {
  const tags = {
    hero: { root: 'section', header: 'h1', footer: 'date' },
    cta: { root: 'section', header: 'h2', footer: 'div' },
    info: { root: 'aside', header: 'h3', footer: 'small' }
  }
  return tags[this.type][which];
}

可能有多种处理方法,但您必须选择适合组件需求的方向。在本例中,有一个对象跟踪每种卡片类型中每个部分的 HTML 元素标签。然后,该方法根据当前卡片类型和传入的参数返回所需的 HTML 元素。

对于样式,我在卡片的根元素上插入了一个基于卡片类型的类。这使得根据需求为每种类型的卡片创建 CSS 变得足够容易。您也可以根据 HTML 元素本身创建 CSS,但我倾向于使用类。将来对卡片组件的更改可能会更改 HTML 结构,并且不太可能对创建类的逻辑进行更改。

卡片还支持英雄卡片标题的背景图像。这是通过在标题元素上放置一个简单的计算属性完成的:bg。这是计算属性

bg() {
  return this.background ? `background-image: url(${this.background})` : null;
}

如果在background道具中提供了图像 URL,则计算属性将返回一个用于内联样式的字符串,该字符串将图像应用为背景图像。这是一个相当简单的解决方案,可以轻松地使其更加健壮。例如,它可以支持自定义颜色、渐变或在未提供图像的情况下提供默认颜色。这个示例没有涉及大量的可能性,因为每种卡片类型都可能拥有自己的可选道具供开发人员使用。

这是此演示中的英雄卡片

<custom-card type="hero" background="https://picsum.photos/id/237/800/200">
  <template v-slot:header>Article Title</template>
  <template v-slot:content>Lorem ipsum...</template>
  <template v-slot:footer>January 1, 2011</template>
</custom-card>

您会看到卡片的每个部分都有自己的内容插槽。并且,为了简单起见,插槽中仅期望文本。卡片组件仅根据卡片类型处理所需的 HTML 元素。让组件只期望文本使组件的使用变得相当简单。它取代了需要做出的关于 HTML 结构的决策,从而简化了卡片的实现。

为了比较,以下是演示中使用的其他两种类型

<custom-card type="cta">
  <template v-slot:header>CTA Title One</template>
  <template v-slot:content>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</template>
  <template v-slot:footer>footer</template>
</custom-card>

<custom-card type="info">
  <template v-slot:header>Here's a Quote</template>
  <template v-slot:content>“Maecenas ... quis.”</template>
  <template v-slot:footer>who said it</template>
</custom-card>

同样,请注意每个插槽仅期望文本,因为每种卡片类型都根据elements()方法定义生成自己的 HTML 元素。如果将来需要使用不同的 HTML 元素,只需更新组件即可。构建辅助功能特性是另一个潜在的未来更新。甚至可以根据卡片类型扩展交互功能。

组件中的组件才是关键

Vue 组件中奇怪命名的<component>元素最初的目的是为了实现某一功能,但就像经常发生的那样,它产生了一个小小的副作用,使其在其他方面变得非常有用。<component>元素旨在根据需要动态地在另一个组件内部切换 Vue 组件。这个基本概念可以是一个选项卡系统,用于在充当页面的组件之间切换;这实际上在 Vue 文档中进行了演示。但它也支持对 HTML 元素执行相同的操作。

这是一个由朋友分享的新技术示例,它已成为我使用过的 Vue 功能集中一个令人惊讶的有用工具。我希望本文能将关于此小功能的想法和信息传递给您,以便您探索如何在自己的 Vue 项目中利用它。