简介
评分组件 Rate
多用于展示评价或者对事物快速评级。本文将分析源码实现,耐心读完,相信会对您有所帮助。组件源码实现详见packages/rate/src/main.vue
。 🔗 组件文档 Rate 🔗 gitee源码 main.vue
更多组件剖析详见 👉 📚 Element 2 源码剖析组件总览 。
源码实现
组件的 prop
声明,各属性功能说明详见 官方文档rate#attributes 。
组件DOM结构
组件的根节点是类名el-rate
的div元素,子元素包含评分icon图标和辅助文字两部分。
精简之后的结构代码如下:
<template>
<div class="el-rate">
<span v-for="(item, key) in max" class="el-rate__item">
<i class="el-rate__icon"></i>
</span>
<span class="el-rate__text">{{ text }}</span>
</div>
</template>
评分icon图标
组件的主要逻辑都在评分icon图标部分,接下来将采用渐进式的讲解方式,对比分析各代码功能逻辑。
基础展示
评分icon图标基于属性max
渲染一个类名el-rate__item
的span
元素列表生成。默认个数为 5
。使用计算属性 classes
返回不同状态(选中/未选中)的 icon 类名。使用方法 getIconStyle
返回不同状态(选中/未选中)的 icon 颜色。
此时组件代码如下,没有鼠标交互事件,只能用作展示。
<span
v-for="(item, key) in max"
class="el-rate__item"
:key="key">
<i
class="el-rate__icon"
:class="[classes[item - 1]]"
:style="getIconStyle(item)">
</i>
</span>
运行如下示例代码
<template>
<div>
<div class="block">
<span class="demonstration">默认不区分颜色</span>
<el-rate v-model="value1"></el-rate>
</div>
<div class="block">
<span class="demonstration">区分颜色</span>
<el-rate v-model="value2" :colors="colors"> </el-rate>
</div>
</div>
</template>
<script>
export default {
data() {
return {
value1: 2,
value2: 2,
// 等同于 { 2: '#99A9BF', 4: { value: '#F7BA2A', excluded: true }, 5: '#FF9900' }
colors: ['#99A9BF', '#F7BA2A', '#FF9900']
};
},
};
</script>
组件支持评分分级,通过 iconClasses
、 colors
、 lowThreshold
、 highThreshold
等属性控制显示逻辑内容。
评分默认被分为三个等级,可以利用颜色数组对分数及情感倾向进行分级(默认情况下不区分颜色)。三个等级所对应的颜色用colors
属性设置,而它们对应的两个阈值则通过 low-threshold
和 high-threshold
设定。也可以通过传入颜色对象来自定义分段,键名为分段的界限值,键值为对应的颜色。
当组件用v-model
传入了一个值, v-model
的语法糖会把这个值当成props
的value
传到组件中。此时组件内部使用属性currentValue
等同于属性value
。
⚠️ 当后面添加鼠标事件后,鼠标移动会直接改变 currentValue
,属性currentValue
不等同于属性value
,
data() {
return {
currentValue: this.value,
};
},
watch: {
value(val) {
this.currentValue = val;
}
},
基础展示的样式计算
计算属性 classes
基于组件的绑定值判断选中状态,生成的icon图标类名数组,由于遍历MAX
是从1
开始,所以调用时需item-1
。
classes() {
let result = [];
let i = 0;
// 基于当前选中值 作为阈值
let threshold = this.currentValue;
// 半选逻辑 ...
// 选中状态
for (; i < threshold; i++) {
result.push(this.activeClass);
}
// 未选中状态
for (; i < this.max; i++) {
result.push(this.voidClass);
}
return result;
},
方法getIconStyle
返回当前图标状态颜色。根据组件的只读状态,返回不同的未选中颜色。
getIconStyle(item) {
const voidColor = this.rateDisabled ? this.disabledVoidColor : this.voidColor;
return {
color: item <= this.currentValue ? this.activeColor : voidColor
};
},
计算属性activeClass
使用方法getValueFromMap
,返回对应分段区间的选中状态icon类名。
//computed
activeClass() {
return this.getValueFromMap(this.currentValue, this.classMap);
},
// methods
getValueFromMap(value, map) {
const matchedKeys = Object.keys(map)
.filter(key => {
const val = map[key];
// 区间最大值是否包含 true 不包括 false 包括
const excluded = isObject(val) ? val.excluded : false;
return excluded ? value < key : value <= key;
})
.sort((a, b) => a - b); // 排序 从小到大
// 获取最小配置
const matchedValue = map[matchedKeys[0]];
// 根据类型获取值
return isObject(matchedValue) ? matchedValue.value : (matchedValue || '');
},
计算属性 classMap
返回 3 个分段区间对应颜色的对象。
若iconClasses
属性传入数组,共有 3 个元素,转换成对象,分段界限值基于lowThreshold
、highThreshold
、max
,各区间为 [0,lowThreshold]
、(lowThreshold,highThreshold)
、[highThreshold,max]
。
若传入对象,可自定义分段,键名为分段的界限值,键值为对应的icon类名。
// 默认值 { 2: 'el-icon-star-on', 4: { value: 'el-icon-star-on', excluded: true }, 5: 'el-icon-star-on' }
classMap() {
return Array.isArray(this.iconClasses)
? {
[this.lowThreshold]: this.iconClasses[0],
[this.highThreshold]: { value: this.iconClasses[1], excluded: true },
[this.max]: this.iconClasses[2]
} : this.iconClasses;
},
属性 iconClasses
默认值 ['el-icon-star-on', 'el-icon-star-on', 'el-icon-star-on']
,组件默认各评分层级 icon 类型相同。
计算属性 activeColor
基于同样的处理逻辑,返回对应分段区间的选中状态icon颜色。
activeColor() {
return this.getValueFromMap(this.currentValue, this.colorMap);
},
colorMap() {
return Array.isArray(this.colors)
? {
[this.lowThreshold]: this.colors[0],
[this.highThreshold]: { value: this.colors[1], excluded: true },
[this.max]: this.colors[2]
} : this.colors;
},
计算属性voidClass
根据是否只读状态,返回未选中状态icon类名。计算属性rateDisabled
返回组件是否只读状态。
// 未选中 icon 的类名 不同模式下:只读模式/可选
voidClass() {
return this.rateDisabled ? this.disabledVoidIconClass : this.voidIconClass;
},
// 是否为只读
rateDisabled() {
return this.disabled || (this.elForm || {}).disabled;
}
// elForm 注入
inject: {
elForm: {
default: ''
}
},
若表单禁用设置为 true,则表单内组件上的 disabled 属性不再生效。
支持半选
接下来在类名el-rate__icon
的i
元素中添加一个类名el-rate__decimal
的i
元素,用于实现半选效果。
<span
v-for="(item, key) in max"
class="el-rate__item"
:key="key">
<i
class="el-rate__icon"
:class="[classes[item - 1]]"
:style="getIconStyle(item)">
<i
v-if="showDecimalIcon(item)"
:class="decimalIconClass"
:style="decimalStyle"
class="el-rate__decimal">
</i>
</i>
</span>
方法showDecimalIcon
控制组件支持半选展示状态。
非只读模式下,小数位要不小于0.5才会显示半选效果。
showDecimalIcon(item) {
let showWhenDisabled = this.rateDisabled && this.valueDecimal > 0 &&
item - 1 < this.value && item > this.value;
let showWhenAllowHalf = this.allowHalf &&
this.pointerAtLeftHalf &&
item - 0.5 <= this.currentValue &&
item > this.currentValue;
return showWhenDisabled || showWhenAllowHalf;
},
属性pointerAtLeftHalf
用于普通模式下控制半星展示,当绑定值包含小数位时为true
。
data() {
return {
pointerAtLeftHalf: true,
};
},
watch: {
value(val) {
this.pointerAtLeftHalf = this.value !== Math.floor(this.value);
}
},
Icon类名使用计算属性decimalIconClass
设置,跟上一小节的activeClass
实现一样。
半选显示样式通过计算属性decimalStyle
控制,返回包含选中颜色、宽度的样式对象。
只读模式下,按照小数位百分比 valueDecimal
显示宽度。
非只读模式下,小数位不管多大,只显示半颗星 50%
。
decimalStyle() {
let width = '';
if (this.rateDisabled) { // 只读模式
width = `${ this.valueDecimal }%`;
} else if (this.allowHalf) {
width = '50%'; // 半颗星
}
return {
color: this.activeColor,
width
};
},
// 当前值百分比 3.2 => 20(%)
valueDecimal() {
return this.value * 100 - Math.floor(this.value) * 100;
},
不同模式下样式对比如下:
<!-- 只读模式 -->
<el-rate v-model="value" disabled show-score> </el-rate>
<!-- 半选模式 -->
<el-rate v-model="value" allow-half show-score> </el-rate>
鼠标交互事件
在元素添加一些事件和样式处理逻辑,用于支持鼠标滑动、点击事件。
<span
v-for="(item, key) in max"
class="el-rate__item"
@mousemove="setCurrentValue(item, $event)"
@mouseleave="resetCurrentValue"
@click="selectValue(item)"
:style="{ cursor: rateDisabled ? 'auto' : 'pointer' }"
:key="key">
<i
:class="[classes[item - 1], { 'hover': hoverIndex === item }]"
class="el-rate__icon"
:style="getIconStyle(item)">
<i
v-if="showDecimalIcon(item)"
:class="decimalIconClass"
:style="decimalStyle"
class="el-rate__decimal">
</i>
</i>
</span>
span
元素样式根据只读状态设置光标的类型。
:style="{ cursor: rateDisabled ? 'auto' : 'pointer' }"
mousemove
事件监听方法setCurrentValue
,用于设置鼠标悬停元素索引 hoverIndex
(从1
开始)和currentValue
,只读模式下无效。
在半选模式下,选择类名el-rate__icon
的DOM元素作为目标,获取基于目标节点的内填充边在 X 轴方向上的偏移量 offsetX
。
如果offsetX
小于目标元素的内部宽度的一半,则认为光标在目标元素的左半边,pointerAtLeftHalf
值为 true
,currentValue
值为 item-0.5
;反之pointerAtLeftHalf
值为 false
,currentValue
值为 item
。所以选中效果就是半星->整星
或 整星->半星
的过程。
setCurrentValue(value, event) {
if (this.rateDisabled) {
return;
}
if (this.allowHalf) {
let target = event.target;
if (hasClass(target, 'el-rate__item')) {
target = target.querySelector('.el-rate__icon');
}
if (hasClass(target, 'el-rate__decimal')) {
target = target.parentNode;
}
this.pointerAtLeftHalf = event.offsetX * 2 <= target.clientWidth;
this.currentValue = this.pointerAtLeftHalf ? value - 0.5 : value;
} else {
this.currentValue = value;
}
this.hoverIndex = value;
},
属性hoverIndex
用于icon图标鼠标悬停放大效果。
:class="[{ 'hover': hoverIndex === item }]"
// 生成样式
.el-rate__icon.hover {
transform: scale(1.15);
}
mouseleave
事件监听方法resetCurrentValue
,用于重置pointerAtLeftHalf
、hoverIndex
和currentValue
等属性值,只读模式下无效
resetCurrentValue() {
if (this.rateDisabled) {
return;
}
if (this.allowHalf) {
this.pointerAtLeftHalf = this.value !== Math.floor(this.value);
}
this.currentValue = this.value;
this.hoverIndex = -1;
}
clcik
事件监听方法selectValue
用于触发组件自定义change
事件,返回改变后的分值,只读模式下无效。 $emit('input', val)
用来更新组件v-model
值。
selectValue(value) {
if (this.rateDisabled) {
return;
}
if (this.allowHalf && this.pointerAtLeftHalf) {
this.$emit('input', this.currentValue);
this.$emit('change', this.currentValue);
} else {
this.$emit('input', value);
this.$emit('change', value);
}
},
辅助文字
类名el-rate__text
的span元素用于显示辅助文字,是否显示由属性 showText
、showScore
值控制,文字的颜色由属性 textColor
控制。
<span v-if="showText || showScore"
class="el-rate__text" :style="{ color: textColor }">
{{ text }}
</span>
显示内容使用了计算属性 text
。 当 showScore=true
, 显示当前分数,将当前组件分值使用 scoreTemplate 模板进行内容格式化。 当 showText=true
, 显示当前辅助文字,基于当前组件分值调用对应索引(属性 texts
)的文字描述。
text() {
let result = '';
if (this.showScore) {
result = this.scoreTemplate.replace(/\{\s*value\s*\}/, this.rateDisabled
? this.value
: this.currentValue);
} else if (this.showText) {
result = this.texts[Math.ceil(this.currentValue) - 1];
}
return result;
},
属性showText
和showScore
不能同时为真。
键盘事件
回到根节点,添加如下代码。
<div
class="el-rate"
@keydown="handleKey"
role="slider"
:aria-valuenow="currentValue"
:aria-valuetext="text"
aria-valuemin="0"
:aria-valuemax="max"
tabindex="0">
// el-rate__item...
// el-rate__text...
</div>
属性 role
、aria-valuenow
、aria-valuetext
、aria-valuemin
、aria-valuemax
用于实现 ARIA无障碍网页应用。
属性 tabindex="0"
表示元素是可聚焦的,并且可以通过键盘导航来聚焦到该元素。
添加方法handleKey
监听keydown
事件,只读模式下无效。
当根节点元素聚焦后,使用方向键增加/减少分值,按下键Up/Right
用于增加分值,按下键Left/Down
用于减少分值。默认步进为1
,当允许半选时,步进为0.5
。
最后触发组件定义change
事件,返回改变后的分值;同时更新v-model值。
handleKey(e) {
if (this.rateDisabled) {
return;
}
let currentValue = this.currentValue;
const keyCode = e.keyCode;
if (keyCode === 38 || keyCode === 39) { // Up / Right
if (this.allowHalf) {
currentValue += 0.5;
} else {
currentValue += 1;
}
// 阻止事件冒泡
e.stopPropagation();
e.preventDefault();
} else if (keyCode === 37 || keyCode === 40) { // Left / Down
if (this.allowHalf) {
currentValue -= 0.5;
} else {
currentValue -= 1;
}
e.stopPropagation();
e.preventDefault();
}
currentValue = currentValue < 0 ? 0 : currentValue;
currentValue = currentValue > this.max ? this.max : currentValue;
this.$emit('input', currentValue); // 更新v-model值
this.$emit('change', currentValue);
},
组件需要指定一个$emit('input')
来改变组件v-model值,否则无法更新用户传入的v-model值。
组件样式
组件样式源码 packages\theme-chalk\src\rate.scss
使用混合指令 b
、e
嵌套生成组件样式。
// 生成 .el-rate
@include b(rate) {
// ..
// 生成 .el-rate:active,.el-rate:focus
&:focus, &:active {
// ..
}
// 生成 .el-rate__item
@include e(item) {
// ..
}
// 生成 .el-rate__icon
@include e(icon) {
// ..
// 生成 .el-rate__icon.hover
&.hover {
transform: scale(1.15);
}
// 不知具体作用
.path2 {
// ..
}
}
// 生成 .el-rate__decimal
@include e(decimal) {
// ..
}
// 生成 .el-rate__text
@include e(text) {
// ..
}
}
在created
,增加逻辑如果value传入非数值,使用$emit('input', 0)
,更新v-model值为0。
created() {
if (!this.value) {
this.$emit('input', 0);
}
}
📚参考&关联阅读
"ARIA",MDN
"Element/mousemove_event",MDN
"tabindex",MDN