目录
- 引言
- 做之前先仔细看看UI设计稿
- 解读一下都有哪些元素:
- 参考下成熟的组件库,看看还需要做什么?
- 代码编写
- 1. 设计group包裹选项的组件
- group.vue
- item.vue
- 2. 让group的v-model和item的value联动起来
- 3. 完善一下item的指示器样式
- 4. 补充禁用模式和change事件
- 5. 看下最终效果
- 6. 组件完整代码
- group.vue
- item.vue
- 7. 组件调用方式
- 总结
引言
本教程基于前端UI样式库 NES.css 的UI设计,自行研究复现。欢迎大家交流优化实现方法~
此次组件库开发基于vue3框架,框架基础搭建过程以及基础素材准备参考:【VUE3.0】动手做一套像素风的前端UI组件库—先导篇
本篇复现的组件为radio,日常项目中较为常见的组件,主要涉及到的内容有:
- 参考NES.css进行基础样式构建,使用css模拟箭头指示器。
- 点击动效设计,利用
animation
的animation-timing-function
属性模拟指示器闪动效果。 - 参考组件库
Element Plus
的设计使用方式,利用vue的slot插槽复现组件调用方式。 - 设置禁用模式和change事件。
做之前先仔细看看UI设计稿
解读一下都有哪些元素:
- 抛开暗色版本的不谈,这个组件的内容其实蛮少的。
- 我需要一组单选框,可以两个或者更多,互相之间互斥。
- 在选中的选项旁边有一个闪动的指示器,有股红白机游戏开头设置的感觉。
- 其他没什么特殊的。
参考下成熟的组件库,看看还需要做什么?
这里我们参考element plus
- 使用group将选项包裹起来,通过v-model去双向绑定选择的项目value。
- 作为一个组件,应该有一个禁用标识。
- 作为一个组件,应该有个change事件,监控选择时候的响应值。
- 其他的颜色控制、大小控制等属性我们舍弃,因为我们的组件库是个定制样式的组件库,不跟市面上的通用组件库攀比。
代码编写
按照设计稿解读内容以及其他组件库的参考:
1. 设计group包裹选项的组件
参考element的调用组件方式,思考下组件该怎么写能够达到这种调用方式?没错这里我们利用vue的slot去设计。
在components文件夹下创建radio文件夹。分别创建group.vue和item.vue并引入install.js注册全局组件。这里不清楚的回看:先导篇。
依照上图的设计逻辑,编写group和item的vue模板文件。
group.vue
首先写好html部分,设置基础样式类和插槽:
<template>
<div class="radio_group">
<slot></slot>
</div>
</template>
<style scoped>
.radio_group {
padding: 10px;
display: flex;
gap: 15px;
user-select: none;
}
</style>
设置group组件的v-model双向绑定,在vue3的自定义组件中可以设置一个或多个双向绑定属性,组件标签使用v-model:attribute
绑定变量,组件内部使用defineProps
接受attribute
,并且使用defineEmits(["update:attribute"])
向父组件更新变量。(以上提到的attribute均为自定义变量名,不要混淆了),假如自定义组件仅有一个需要双向绑定的属性,那么可以直接在标签使用v-model绑定变量,组件内部会默认传入一个属性名为modelValue
,更新变量和之前一样。(注意这里的modelValue是一个vue中确定的名字,不可以改)
这里需要v-model的属性为任意基本类型, type设置为 [String, Number, Boolean],这些应该够了,反正element是这三种。
const props = defineProps({
modelValue: {
type: [String, Number, Boolean],
default: "",
},
});
const emits = defineEmits(["update:modelValue"]);
item.vue
利用弹性盒子将指示器和label文字设置好布局关系。我不希望label可以无限长,所以设置了最大文字长度截断,并采用title属性让鼠标悬浮时显示全部文字,由于这个tooltip过于简陋,后期会专门制作tooltip组件去替换这里的title属性。
html部分,设置基础样式类和插槽:
<template>
<div class="radio">
<span class="radio_arrow"></span>
<span class="radio_label" :title="slotText"><slot>option</slot></span>
</div>
</template>
<style scoped>
.radio {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.radio_arrow {
width: 16px;
height: 16px;
position: relative;
}
.radio_label {
max-width: 150px;
white-space: nowrap; /* 确保文本在一行内显示 */
overflow: hidden; /* 隐藏溢出的内容 */
text-overflow: ellipsis; /* 使用省略号表示文本溢出 */
}
</style>
由于单选项的label是插槽提供的文本,默认是option,那么我该如何获取到slot填写的内容呢?
这里是利用到vue的useSlots的hooks函数,拿到slot内容。
以下是js部分:
<script setup>
import { ref, useSlots } from "vue";
const props = defineProps({
value: {
type: [String, Number, Boolean],
default: "",
},
});
// 设置过长文本提示词,利用useSlots抓取slot内容。
const slots = useSlots();
const slot = slots.default ? slots.default() : undefined;
const slotText = ref("");
if (slot) {
slotText.value = slot[0].children;
} else {
slotText.value = "option";
}
</script>
2. 让group的v-model和item的value联动起来
现在面临一个问题,group和item的数据如何联动起来,我需要用一个什么样的联动方式?
- 首先声明一个点,虽然group和item是设计逻辑上的父子关系,但是并是真正的父子组件,他们只是通过插槽安放在一起,还是属于两个独立的组件。显然这里不能使用props和emit去父子组件传值,更新group的v-model的状态。
- 使用全局状态,例如vuex、pinia等。这当然可以解决问题,但是未免有点太奢侈了,而且组件调用的地方有很多,需要创建多个全局状态去管理。不合适
- 使用hooks函数,这也是个不错的选择。但是hooks的状态是一次性的,在group和item中注册两遍,两边的数据是互相封闭的,无法真正联动起来。如果是将状态放在闭包函数之外,倒也是可以做到数据共享,那其实和使用vuex这些全局状态没什么区别,反倒是需要增加动态id去区分,工作量巨大。不合适
- 本次使用vue的依赖注入去解决问题,provide和inject
在group.vue中提供radio状态值和更新状态值的方法
import { ref, provide } from "vue";
const radioValue = ref(props.modelValue);
const updateRadioValue = (value) => {
radioValue.value = value;
emits("update:modelValue", value);
};
provide("radioValue", {
radioValue,
updateRadioValue,
});
在item.vue中截获依赖注入,并更新选项指示器的样式
在item中通过点击选项,触发更新radioValue的updateRadioValue方法,更新全局的radioValue状态,又因为radioValue是一个响应式变量,通过判断当前item的value值是否等于radioValue,来确认当前的选项是否被选中,从而更新当前选项的样式。
<template>
<div @click="checkRadio" class="radio">
<span
class="radio_arrow"
:class="{ radio_arrow_check: radioValue === value}"
></span>
<span class="radio_label" :title="slotText"><slot>option</slot></span>
</div>
</template>
<script setup>
import { ref, inject} from "vue";
const { radioValue, updateRadioValue} = inject("radioValue");
const checkRadio = () => {
if (radio_disabled.value) return;
updateRadioValue(props.value);
};
</script>
至此组件的传值问题已经解决了。
3. 完善一下item的指示器样式
原本我是制作了一张指示器的icon图片,后来忍不住去 抄 借鉴了一下NES.css是怎么做的指示器icon,发现居然可以使用box-shadow去完成效果。
这里还需要注意几个点:
- keyframes只设置到50%就可以了。
- animation需要设置steps(1)
解释下原因,如果你按照常规的方式设置keyframes到100%,不加steps(1),你会得到一个非常丝滑的动画效果,丝滑的有点过头,但是红白机的闪动方式是跳跃式的,不连贯的。按照如下设置后,才能得到想要的闪动效果。后续我会出一篇关于css的animation的使用详解,会涉及到这个概念。
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.radio_arrow {
width: 16px;
height: 16px;
position: relative;
}
.radio_arrow_check::before {
position: absolute;
top: 0;
left: 0;
transform: translateY(-50%);
content: "";
width: 2px;
height: 2px;
color: #212529;
box-shadow: 2px 2px, 4px 2px, 2px 4px, 4px 4px, 6px 4px, 8px 4px, 2px 6px,
4px 6px, 6px 6px, 8px 6px, 10px 6px, 2px 8px, 4px 8px, 6px 8px, 8px 8px,
10px 8px, 12px 8px, 2px 10px, 4px 10px, 6px 10px, 8px 10px, 10px 10px,
2px 12px, 4px 12px, 6px 12px, 8px 12px, 2px 14px, 4px 14px;
animation: blink 1s infinite steps(1);
}
先看下这一步的效果
4. 补充禁用模式和change事件
- 在group组件中需要接收一个disabled的变量,控制组件变灰一些并且切换手势为禁用。
- 向item传递这个disabled变量,禁用点击事件,让闪动停止,并且设置item的禁用手势。
- 在group组件中updateRadioValue方法里向外emit一个change事件,将此刻的radioValue值传递出去。
在group.vue文件中做如下修改:
<template>
<div class="radio_group" :class="{ disabled: disabled }">
<slot></slot>
</div>
</template>
<script setup>
import { ref, provide } from "vue";
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(["change"]);
const radio_disabled = ref(props.disabled);
const updateRadioValue = (value) => {
emits("change", value);
};
provide("radioValue", {
radio_disabled,
});
</script>
<style scoped>
.disabled {
opacity: 0.5;
cursor: var(--cursor_disabled);
}
</style>
在item.vue文件中做如下修改:
<template>
<div @click="checkRadio" class="radio">
<span
class="radio_arrow"
:class="{ radio_arrow_check_disabled: radio_disabled }"
></span>
<span class="radio_label" :title="slotText"><slot>option</slot></span>
</div>
</template>
<script setup>
import { ref, inject} from "vue";
const { radio_disabled } = inject("radioValue");
const cursorStyle = radio_disabled.value
? "var(--cursor_disabled)"
: "var(--cursor_pointer)";
const checkRadio = () => {
// 当禁用标识传递进来后,禁用此方法
if (radio_disabled.value) return;
updateRadioValue(props.value);
};
</script>
<style scoped>
.radio {
cursor: v-bind(cursorStyle);
}
.radio_arrow_check_disabled::before {
animation: none;
}
</style>
5. 看下最终效果
6. 组件完整代码
group.vue
<template>
<div class="radio_group" :class="{ disabled: disabled }">
<slot></slot>
</div>
</template>
<script setup>
import { ref, provide } from "vue";
const props = defineProps({
modelValue: {
type: [String, Number, Boolean],
default: "",
},
disabled: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(["update:modelValue", "change"]);
const radio_disabled = ref(props.disabled);
const radioValue = ref(props.modelValue);
const updateRadioValue = (value) => {
radioValue.value = value;
emits("update:modelValue", value);
emits("change", value);
};
provide("radioValue", {
radioValue,
updateRadioValue,
radio_disabled,
});
</script>
<style scoped>
.radio_group {
padding: 10px;
display: flex;
gap: 15px;
user-select: none;
}
.disabled {
opacity: 0.5;
cursor: var(--cursor_disabled);
}
</style>
item.vue
<template>
<div @click="checkRadio" class="radio">
<span
class="radio_arrow"
:class="{
radio_arrow_check: radioValue === value,
radio_arrow_check_disabled: radio_disabled,
}"
></span>
<span class="radio_label" :title="slotText"><slot>option</slot></span>
</div>
</template>
<script setup>
import { ref, inject, useSlots } from "vue";
const props = defineProps({
value: {
type: [String, Number, Boolean],
default: "",
},
});
const { radioValue, updateRadioValue, radio_disabled } = inject("radioValue");
const cursorStyle = radio_disabled.value
? "var(--cursor_disabled)"
: "var(--cursor_pointer)";
const checkRadio = () => {
if (radio_disabled.value) return;
updateRadioValue(props.value);
};
// 设置过长文本提示词
const slots = useSlots();
const slot = slots.default ? slots.default() : undefined;
const slotText = ref("");
if (slot) {
slotText.value = slot[0].children;
} else {
slotText.value = "option";
}
</script>
<style scoped>
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.radio {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
cursor: v-bind(cursorStyle);
}
.radio_arrow {
width: 16px;
height: 16px;
position: relative;
}
.radio_label {
max-width: 150px;
white-space: nowrap; /* 确保文本在一行内显示 */
overflow: hidden; /* 隐藏溢出的内容 */
text-overflow: ellipsis; /* 使用省略号表示文本溢出 */
}
.radio_arrow_check::before {
position: absolute;
top: 0;
left: 0;
transform: translateY(-50%);
content: "";
width: 2px;
height: 2px;
color: #212529;
box-shadow: 2px 2px, 4px 2px, 2px 4px, 4px 4px, 6px 4px, 8px 4px, 2px 6px,
4px 6px, 6px 6px, 8px 6px, 10px 6px, 2px 8px, 4px 8px, 6px 8px, 8px 8px,
10px 8px, 12px 8px, 2px 10px, 4px 10px, 6px 10px, 8px 10px, 10px 10px,
2px 12px, 4px 12px, 6px 12px, 8px 12px, 2px 14px, 4px 14px;
animation: blink 1s infinite steps(1);
}
.radio_arrow_check_disabled::before {
animation: none;
}
</style>
7. 组件调用方式
<p-radio-group v-model="radio" @change="changeRadio">
<p-radio :value="true">是</p-radio>
<p-radio :value="false">否</p-radio>
</p-radio-group>
<p-radio-group v-model="radio" disabled>
<p-radio :value="true">YES</p-radio>
<p-radio :value="false">NO</p-radio>
<p-radio value="or"></p-radio>
</p-radio-group>
总结
至此一个完整的像素风单选组件radio就开发完成了。
本篇主要强化理解了几个点:
- vue的插槽以及插槽的嵌套使用。
- vue默认单个变量双向绑定的传值方法。
- vue的依赖注入provide和inject。
- vue的useSlots方法,用于查询slot内容。
- 组件封装逻辑。
- animation的animation-timing-function
- box-shadow的妙用。
再接再厉~