3.22 Rate 评分

简介

评分组件 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__itemspan元素列表生成。默认个数为 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>

组件支持评分分级,通过 iconClassescolorslowThresholdhighThreshold等属性控制显示逻辑内容。

评分默认被分为三个等级,可以利用颜色数组对分数及情感倾向进行分级(默认情况下不区分颜色)。三个等级所对应的颜色用colors属性设置,而它们对应的两个阈值则通过 low-thresholdhigh-threshold 设定。也可以通过传入颜色对象来自定义分段,键名为分段的界限值,键值为对应的颜色。

当组件用v-model传入了一个值, v-model的语法糖会把这个值当成propsvalue传到组件中。此时组件内部使用属性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 个元素,转换成对象,分段界限值基于lowThresholdhighThresholdmax,各区间为 [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__iconi元素中添加一个类名el-rate__decimali元素,用于实现半选效果。

<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 值为 truecurrentValue值为 item-0.5;反之pointerAtLeftHalf 值为 falsecurrentValue值为 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,用于重置pointerAtLeftHalfhoverIndexcurrentValue等属性值,只读模式下无效

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元素用于显示辅助文字,是否显示由属性 showTextshowScore值控制,文字的颜色由属性 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;
},

属性showTextshowScore不能同时为真。

键盘事件

回到根节点,添加如下代码。

<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>

属性 rolearia-valuenowaria-valuetextaria-valueminaria-valuemax用于实现 ARIA无障碍网页应用。

属性 tabindex="0" 表示元素是可聚焦的,并且可以通过键盘导航来聚焦到该元素。

添加方法handleKey监听keydown事件,只读模式下无效。

  • 当根节点元素聚焦后,使用方向键增加/减少分值,按下键Up/Right用于增加分值,按下键Left/Down用于减少分值。默认步进为1,当允许半选时,步进为0.5

  • 边界值处理,最小值(0)和最大值(max)。

  • 最后触发组件定义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 使用混合指令 be 嵌套生成组件样式。

// 生成 .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

最后更新于