在创建基于组件的前端基础设施时,我个人遇到的最大痛点之一是,当组件内部存在嵌套组件时,如何使组件既可重用又具有响应性。
例如,以下“号召性用语”(<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%,我希望它以小型模式显示。 但是在某个屏幕尺寸下,它会变成全宽。 换句话说,当发生调整大小事件时,模式应该向一个或另一个方向更改。

我还遇到过在不同页面上以不同模式使用相同组件的情况。 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 更加稳定、可重用、易于维护和易于使用。
我难以理解这种方法的价值,并且希望更多地了解您为什么认为 JavaScript 比 CSS 媒体查询更好的选择。
当我查看这种方法时,感觉像是倒退了一步……您提倡了一种在采用
@media
断点之前实现响应式设计的既定方法。 这意味着您基本上是在推广一种已经过时的 JavaScript 解决方案。您是否认为存在性能问题,导致 JavaScript 在这里成为更好的解决方案? 您文章中的示例并没有真正提供关于为什么
@media
不能满足您需求的见解。 这是向那些可能不太擅长编写 CSS 的用户提供 JavaScript 优先方法的问题吗?现代 CSS 可以帮助解决大多数这些问题。 容器查询 即将推出,在此之前,我们有一个可靠的 polyfill 可用于它们。
clamp()
也非常有用。同样有用的还有minmax()
、:where()
、:is()
和:has()
(目前仅在 Safari 中可用,但很快就会被 Chrome 和 Firefox 采用)。我个人更愿意使用这些方法,并在需要时结合一些智能/简短的媒体查询,而不是添加更多 JavaScript(尤其是用于样式)。
我喜欢这个,我认为所讨论的示例不是最好的。但我明白了要点,并且可以看到在设计人员为每个断点提供截然不同的设计并且每个组件的属性都需要完全更改的情况下,这将非常有用。或者在 UI 在较小屏幕上拆分为页面/选项卡的情况下。
简洁、DRY 的 CSS 万岁 :)
我不明白。JavaScript 当然应该测量包含元素的宽度,而不是整个视口的宽度?
如果这是容器查询的 polyfill,那就说得通了。但现在,它是媒体查询的 polyfill……我们已经有了。
我错过了什么?
好文章。我认为这种方法对于处理过具有数百个相同组件变体的大型应用程序的人来说最有意义。是的,在较小的项目中,使用媒体查询可能更容易,但是当组件以不同的方式在其他组件内部重复使用时,在我看来,这似乎是一个不错的方法,并且将大量凌乱的媒体查询转换为简洁、干净且可重用的代码。
实现类似功能的另一种方法是在组件中使用 css 变量来设置“displayMode”,并在顶级组件上使用媒体查询来设置其后代子元素中的变量……
我需要在 Angular2 中使用它,请有人帮助我?
我建议为此使用 ResizeObserver API——至少在容器查询得到完全支持之前。主要好处是,您可以监听直接父容器而不是窗口。 :)
当我听到“在设计系统中”时,我希望能在这里看到更多内容。设计系统对我来说比仅仅是 vue/react/angular 实现更广泛。那只是系统的一部分。模式不是设计系统。
同时,即使作为一个模式,我也不清楚除了可能稍微减少 CSS 输出之外,从 CSS 切换回使用 JS 来处理这些事情有什么具体优势,而这种减少很可能在输出被 gzip 压缩后就会抵消。
在这里回应其他人,使用内在 CSS 尺寸和容器查询
使用 cq-prolyfill 获取所有 CSS 属性的容器查询:高度、颜色、文本对齐等,而不仅仅是容器宽度。如果您不想使用预处理器,也可以与数据属性一起使用。[https://ausi.github.io/cq-prolyfill/demo/]
对于较新的浏览器,使用最新的 polyfill CSS-Tricks:只需工作的容器查询 polyfill
CSS-Tricks 文章中提到了 CQfill,但我发现 CQ-prolyfill 是一个更好的解决方案,并且适用于所有旧浏览器(IE9+、Safari7+)。
使用网格和为不同媒体查询映射的网格区域是否更容易?
我只是问问。
使用 JS 解决 CSS 任务似乎不是一个好主意。我认为这可能会导致性能问题以及将来出现一些奇怪的错误/行为。
您可能使用了过于简单的示例,使您的方法看起来设计过度。
仅使用现代 CSS 就可以实现这一点,无需媒体查询。为什么需要 JS?
我发现这种方法没有多大用处。对我来说,它很复杂且凌乱。我宁愿使用容器查询,它已经有了一个可靠的 polyfill。
这在我看来相当复杂,并且是针对可以用 CSS 完成的事情的 JS 解决方案。我会做的事情(目前在一个大型 Angular 项目中正在做)是在子组件
<Button />
中使用 CSS 自定义属性(名称说明了一切:这些是 CSS 的属性),并在父组件<CTA />
中定义它们。这也是从外部为 Web 组件设置样式的少数几种方法之一,可以绕过其 shadow dom。我赞同上面关于更喜欢使用 CSS、媒体查询和容器查询来处理此用例的观点。
在针对任何静态呈现内容使用像这样的 JS 优先方法时,还需要考虑的一件事是,如果需要知道窗口宽度,则不会为服务器端渲染计算样式,因此如果窗口宽度不属于您为组件设置的默认样式,您可能会看到布局偏移或样式更改闪烁。