避免设计系统中嵌套组件的陷阱

Avatar of Dan Christofi
Dan Christofi

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

在创建基于组件的前端基础设施时,我个人遇到的最大痛点之一是,当组件内部存在嵌套组件时,如何使组件既可重用又具有响应性。

例如,以下“号召性用语”(<CTA />)组件

在较小的设备上,我们希望它看起来像这样

使用基本的媒体查询可以很容易地做到这一点。 如果我们使用 flexbox,媒体查询可以更改 flex 方向并使按钮占据整个宽度。 但是,当我们开始在其中嵌套其他组件时,就会遇到问题。 例如,假设我们正在使用一个按钮组件,并且它已经有一个使它全宽的 prop。 当我们对父组件应用媒体查询时,实际上是在复制按钮的样式。 嵌套的按钮已经能够处理它了!

这是一个简单的例子,问题不会太大,但在其他情况下,它可能会导致大量重复代码来复制样式。 如果将来我们想更改全宽按钮的样式,该怎么办? 我们需要遍历所有这些不同的位置并进行更改。 我们应该能够在按钮组件中更改它,并使其在所有地方更新。

如果我们可以摆脱媒体查询并更好地控制样式,那不是很好吗? 我们应该使用组件现有的 props,并能够根据屏幕宽度传递不同的值。

好吧,我有一个方法可以做到这一点,并且将向您展示我是如何做到的。

我意识到 容器查询 可以解决很多这些问题,但它还处于早期阶段,并且无法解决根据屏幕宽度传递各种 props 的问题。

跟踪窗口宽度

首先,我们需要跟踪页面的当前宽度并设置断点。 这可以使用任何前端框架来完成,但我在这里使用 Vue 可组合函数 来演示这个想法

// composables/useBreakpoints.js

import { readonly, ref } from "vue";

const bps = ref({ xs: 0, sm: 1, md: 2, lg: 3, xl: 4 })
const currentBreakpoint = ref(bps.xl);

export default () => {
  const updateBreakpoint = () => {
  
    const windowWidth = window.innerWidth;
    
    if(windowWidth >= 1200) {
      currentBreakpoint.value = bps.xl
    } else if(windowWidth >= 992) {
      currentBreakpoint.value = bps.lg
    } else if(windowWidth >= 768) {
      currentBreakpoint.value = bps.md
    } else if(windowWidth >= 576) {
      currentBreakpoint.value = bps.sm
    } else {
      currentBreakpoint.value = bps.xs
    }
  }

  return {
    currentBreakpoint: readonly(currentBreakpoint),
    bps: readonly(bps),
    updateBreakpoint,
  };
};

我们使用数字作为 currentBreakpoint 对象的原因将在后面变得清晰。

现在,我们可以监听窗口调整大小事件,并在主 App.vue 文件中使用可组合函数更新当前断点

// App.vue

<script>
import useBreakpoints from "@/composables/useBreakpoints";
import { onMounted, onUnmounted } from 'vue'

export default {
  name: 'App',
  
  setup() {
    const { updateBreakpoint } = useBreakpoints()

    onMounted(() => {
      updateBreakpoint();
      window.addEventListener('resize', updateBreakpoint)
    })

    onUnmounted(() => {
      window.removeEventListener('resize', updateBreakpoint)
    })
  }
}
</script>

我们可能希望对它进行去抖动,但为了简洁起见,我在这里保持简单。

设置组件样式

我们可以更新 <CTA /> 组件以接受一个新的 prop 来指定其样式

// CTA.vue
props: {
  displayMode: {
    type: String,
    default: "default"
  }
}

这里的命名完全是任意的。 您可以为每个组件模式使用任何您喜欢的名称。

然后,我们可以使用此 prop 根据当前断点更改模式

<CTA :display-mode="currentBreakpoint > bps.md ? 'default' : 'compact'" />

您现在可以看到为什么我们使用数字来表示当前断点——这样就可以将正确的模式应用于某个数字以下或以上的所有断点。

然后,我们可以在 CTA 组件中使用它根据传递的模式设置样式

// components/CTA.vue

<template>
  <div class="cta" :class="displayMode">
    
    <div class="cta-content">
      <h5>title</h5>
      <p>description</p>
    </div>
    
    <Btn :block="displayMode === 'compact'">Continue</Btn>
    
  </div>
</template>

<script>
import Btn from "@/components/ui/Btn";
export default {
  name: "CTA",
  components: { Btn },
  props: {
    displayMode: {
      type: String,
      default: "default"
    },
  }
}
</script>

<style scoped lang="scss">
.cta {
  display: flex;
  align-items: center;
  
  .cta-content {
    margin-right: 2rem;
  }

  &.compact {
    flex-direction: column;
    .cta-content {
      margin-right: 0;
      margin-bottom: 2rem;
    }
  }
}
</style>

我们已经消除了对媒体查询的需求! 您可以在我创建的 演示页面 上看到它的实际效果。

诚然,对于如此简单的事情,这似乎是一个漫长的过程。 但当应用于多个组件时,这种方法可以极大地提高 UI 的一致性和稳定性,同时减少我们需要编写的代码量。 使用 JavaScript 和 CSS 类来控制响应式样式的这种方式还有另一个好处……

嵌套组件的可扩展功能

在某些情况下,我需要恢复到组件的先前断点。 例如,如果它占据屏幕的 50%,我希望它以小型模式显示。 但是在某个屏幕尺寸下,它会变成全宽。 换句话说,当发生调整大小事件时,模式应该向一个或另一个方向更改。

Showing three versions of a call-to-action components with nested components within it.

我还遇到过在不同页面上以不同模式使用相同组件的情况。 Bootstrap 和 Tailwind 等框架无法做到这一点,并且使用媒体查询来实现它将是一场噩梦。(您仍然可以使用这些框架使用此技术,只是不需要它们提供的响应式类。)

我们可以使用仅应用于中等尺寸屏幕的媒体查询,但这并不能解决根据屏幕宽度更改 props 的问题。 谢天谢地,我们正在介绍的方法可以解决这个问题。 我们可以修改之前的代码,允许通过数组传递每个断点的自定义模式,其中数组中的第一项是最小的屏幕尺寸。

<CTA :custom-mode="['compact', 'default', 'compact']" />

首先,让我们更新 <CTA /> 组件可以接受的 props

props: {
  displayMode: {
    type: String,
    default: "default"
  },
  customMode: {
    type: [Boolean, Array],
    default: false
  },
}

然后,我们可以添加以下内容来生成正确的模式

import { computed } from "vue";
import useBreakpoints from "@/composables/useBreakpoints";

// ...

setup(props) {

  const { currentBreakpoint } = useBreakpoints()

  const mode = computed(() => {
    if(props.customMode) {
      return props.customMode[currentBreakpoint.value] ?? props.displayMode
    }
    return props.displayMode
  })

  return { mode }
},

这是根据当前断点从数组中获取模式,如果找不到则默认为 displayMode。 然后我们可以使用 mode 来设置组件的样式。

提取以实现可重用性

许多这些方法可以提取到其他可组合函数和 mixin 中,这些函数和 mixin 可以与其他组件一起重用。

提取计算模式

返回正确模式的逻辑可以提取到一个可组合函数中

// composables/useResponsive.js

import { computed } from "vue";
import useBreakpoints from "@/composables/useBreakpoints";

export const useResponsive = (props) => {

  const { currentBreakpoint } = useBreakpoints()

  const mode = computed(() => {
    if(props.customMode) {
      return props.customMode[currentBreakpoint.value] ?? props.displayMode
    }
    return props.displayMode
  })

  return { mode }
}

提取 props

在 Vue 2 中,我们可以通过使用 mixin 来重复 props,但存在 明显的缺点。 Vue 3 允许我们使用相同的可组合函数将它们与其他 props 合并。 这方面有一个小问题,因为 IDE 似乎无法识别使用此方法的 props 以进行自动完成功能。 如果这太烦人,您可以改用 mixin。

或者,我们还可以传递自定义验证以确保我们仅使用每个组件可用的模式,其中传递给验证程序的第一个值是默认值。

// composables/useResponsive.js

// ...

export const withResponsiveProps = (validation, props) => {
  return {
    displayMode: {
      type: String,
      default: validation[0],
      validator: function (value) {
        return validation.indexOf(value) !== -1
      }
    },
    customMode: {
      type: [Boolean, Array],
      default: false,
      validator: function (value) {
        return value ? value.every(mode => validation.includes(mode)) : true
      }
    },
    ...props
  }
}

现在让我们将逻辑移出并导入它们

// components/CTA.vue

import Btn from "@/components/ui/Btn";
import { useResponsive, withResponsiveProps } from "@/composables/useResponsive";

export default {
  name: "CTA",
  components: { Btn },
  props: withResponsiveProps(['default 'compact'], {
    extraPropExample: {
      type: String,
    },
  }),
  
  setup(props) {
    const { mode } = useResponsive(props)
    return { mode }
  }
}

结论

创建可重用且响应式的组件设计系统具有挑战性,并且容易出现不一致的情况。 此外,我们还看到了产生大量重复代码是多么容易。 在创建不仅可以在许多上下文中工作,而且在组合时与其他组件配合良好的组件方面,存在微妙的平衡。

我相信您在自己的工作中也遇到过这种情况。 使用这些方法可以减少问题,并希望使 UI 更加稳定、可重用、易于维护和易于使用。