第十三集: 从零开始实现一套 pc 端 vue 的 ui 组件库(评分组件 小星星)
1. 本集定位
说起评分的话, 最早看到这种形式是电影网站, 每部电影得到几颗星这种方式, 后来就出现了用户来手动选星星打分的玩法, 这些方式更直观, 更吸引用户参与进去, 这个组件其实还有很多玩法, 比加载动画, 我可以把星星不断的点亮作为一个加载进度的映射, 这个组件很多 ui 库都把他做的很固定, 比如说自能是 5 颗星星或者笑脸, 而本次编写这个组件我的原则就是, 星星的数量可以任意的多, 当然也可以任意的少, 最少 1 颗, 最多无限颗, 是不是很有趣????.
实现思路
因为我这边 icon 组件用的是 svg 实现的, 最后选择了使用两排一样的 icon 组件, 重叠在一起, 然后把最上层的宽度变化一下, 就达到了选择区域有颜色的效果了.
2. 需求评审
- 只读模式: 可只做展示.
- 选择模式: 可通过点击设置新的评分.
- 颜色与大小, 要可供用户自己设定.
- 特色: 可设置 ’ 星星 ’ 的总数量.
- 可设置满分为多少分
- 可更换图形, 绝对不止是 ’ 星星 ’.
- 要兼容多层父级组件的情况以及多层父级并且父级组件滚动偏移的情况
- 可以每次以半颗星为单位进行选取.
3. 基础的搭建
先上一张正常状态下的效果图
vue-cc-ui/src/components/Rate/index.js
import Rate from './main/rate.vue'
Rate.install = function(Vue) {Vue.component(Rate.name, Rate);
};
export default Rate
vue-cc-ui/src/components/Rate/main/rate.vue
<template>
<div class='cc-rate'
:style="{cursor: disabled ? 'auto' : 'pointer', // 不让修改的状态也就没必要出现小手了}"
>
<i class='cc-rate__box'>
<span class='cc-rate__dark'>
<cc-icon v-for='item in num'
:key='item'
:size="`${size}px`"
:name='iconType.name' />
</span>
<span class='cc-rate__bright'
:style="{width}">
<cc-icon v-for='item in num'
:key='item'
:size="`${size}px`"
:name='iconType.name' />
</span>
</i>
</div>
</template>
上述的 num 指的就是 用户定义的星星的数量
total 就是当星星满分的时候, 相当于多少分
props: {
disabled: Boolean, // 为 true 的时候, 不允许修改
num: {
// 展示几颗星
type: Number,
default: 5
},
total: {
// 总共多少分
type: Number,
default: 5
},
size: {
// 星星的大小
type: Number,
default: 20
},
value: {
// 当前的分数
type: [Number, String],
required: true
}
}
为 cc-icon 添加 动态的 style, 这样他的宽度就是随着我鼠标的位置而变化了
<span class='cc-rate__bright'
:style="{width}">
// ...
</span>
计算当该如何显示
methods: {
// this.value 就是用户绑定的 v -modle
boundary(value) {
// 小于边界则为最小, 大于边界则为最大
if (value <= 0) value = 0;
if (value >= this.numTotal) value = this.numTotal;
return value;
}
},
computed: {width() {
// 当前需要显示的值, 相对总分数的占比!
let proportion = this.boundary(this.value) / this.numTotal;
// 以这个比例来进行换算, 求出对应总宽度下, 应该多宽
return `${proportion * (this.size * this.num)}px`;
}
numTotal() {
// 传入的总分是否有效
// 如果小于 0, 那总分就按星星的数量为准
return this.total <= 0 ? this.num : this.total;
},
}
上述代码就能实现一个简易的评分组件展示了
那么接下来我们就在 icon 的样式上做一些文章
<span class='cc-rate__dark'>
<cc-icon v-for='item in num'
:key='item'
:size="`${size}px`"
:name='iconType.name'
:color='iconType.darkColor' />
</span>
<span class='cc-rate__bright'
:style="{width}">
<cc-icon v-for='item in num'
:key='item'
:name='iconType.name'
:size="`${size}px`"
:color='iconType.brightColor' />
</span>
大家注意到, 这个 iconType 关键字很重要, 决定了 icon 长什么
props: {
type: String, // 自定义图标
darkColor: String, // 自定义暗色
brightColor: String, // 自定义亮色
}
computed: {
// icon 样式
iconType() {
// 暂时有用的属性就定义为他们
let {type, darkColor, brightColor} = this,
// 默认的样式不能少
result = {
name: type || "cc-stars2",
brightColor:brightColor || "rgb(247, 186, 42)",
darkColor: darkColor || "#bbbbbb"
};
return result;
}
}
如果你选择了动态的 loading 方面的图标, 他还可以转动的
4. 与鼠标的互动
从鼠标移动说起
鼠标滑到哪里, 星星的选择评分就会跟随到哪里
1、onmouseleave、onmouseenter,鼠标进入到指定元素区域内触发事件,不支持冒泡,不包含子元素的区域。
2、onmouseout、onmouseover、鼠标进入指定元素触发事件,含子元素区域。
<i ref='box'
class='cc-rate__box'
@mouseleave='handelMouseleave()'
@mousemove='handelMousemove($event)'>
handelMousemove 比较核心的代码(还会添加新的计算, 接下来会说)
handelMousemove(e) {
// 实时判断的原因是, 可能用户现在禁止改动, 一会又不禁止了!!
if (!this.disabled) {
// 获取到 i 标签的 dom
let node = this.$refs.box;
// getHTMLScroll 是之前封装的一个获取距离的方法 (下一集会介绍新的方法)
// 鼠标距离左侧的距离, 减去元素距离左侧的距离, 就是鼠标到 元素左侧的距离
// 当前 icon 的大小 * icon 的总数, 就是总共 icon 的宽度
// 把上面的比例 换算为在总分值中的分数;
let value =
((e.pageX - getHTMLScroll(node).left) / (this.size * this.num)) *
this.numTotal;
// 把值拿取校验一下;
value = this.boundary(value);
this.$emit("input", value);
}
},
离开的函数
data() {
return {oldVal: 0};
},
methods: {handelMouseleave() {if (!this.disabled) {this.$emit("input", this.oldVal);
}
},
}
oldVal
上面提到了这个变量, 那我来举一个例子说明
去年我刚开始接触后台管理系统, 很多页面布局都是上方有大量的查询条件与搜索框, 下面是查询结果列表, 那就遇到一个问题, 比如用户通过条件 a 查询出了结果列表, 用户在翻页的时候, 是按照条件 a 去查询下一页的列表, 但是如果用户修改了条件 a 为条件 b 但是没有点重新搜索, 而是点击了翻页, 这个时候肯定我们还是要用 a 条件去查询下一页, 页面上展示的条件是 b, 所以由此可知, 每一个条件背后都对应着两个变量, 一个是显示的, 一个是真实的查询条件.
本次 oldVal 解决的问题与上面所说类似, 用户鼠标滑过组件的时候, 组件相应的改变被选中的状态, 也就是上层 icon 的宽度, 但是用户没有进行选择, 而是离开了 icon 元素, 那应该把 icon 的状态还原为最初的状态, 而这个状态值, 就是 oldVal
点击改变选择状态
由于上面逻辑与结构的搭建, 才让这步操作很简单
selectValue() {
// 更新一下, 确保已执行
this.oldVal = this.value;
// 这个 value 在滑动的时候其实已经计算好了
// 这里为了避免多 v -model 绑定可能出现的 bug
this.$emit("change",this.oldVal);
},
上面我们做了对用户配置的处理, 所以以下效果就可以实现了;
满分已经可以自己设为 10 分;
并且数量任君设置;
5. 展示评分与样式
dom 结构上
只有用户传入 score 才会给用户展示我们的分数展示组件
<i>
// ...
</i>
<span v-if='score'
class="cc-rate__score">
<slot name='score'> {{value | fix}} </slot>
</span>
上面的过滤器
展示的数据补上 .0;
有同学会以为为什么不用 toFixed
- 总写 toFixed 写腻了 …..
- toFixed 其实有弊端, 他会自动四舍五入, 所以要分情况使用;
export const myToFixed = value => {
value = value + '';
if (value.includes('.')) {let sp = value.split('.');
return sp[0] + '.' + sp[1].slice(0, 1);
} else {return value + '.0';}
};
每次必须选择完整的一颗星
有时候用户不想要.1.2.3 而是想要整数, 那好每次都给用户返回整数就好了
当然要付出一定的计算了, 这里要注意, 由于总分不是固定的, 所以别忘了总分的计算
props: {one: Boolean, // 只让完整的每一颗}
methods: {
// 计算手指的位置
// 点了有效还是移动就有效?
handelMousemove(e) {if (!this.disabled) {
let node = this.$refs.box;
let value =
((e.pageX - getHTMLScroll(node).left) / (this.size * this.num)) *
this.numTotal;
value = this.boundary(value);
// 新增代码 ---------------------------------------
// 每颗必须完整 一颗的距离
let i = 0,
// 求出一颗星星对应的分数
oneNum = this.numTotal / this.num;
// 直到大于这个
while (oneNum * i <= value) {i++;}
if (this.one) {
// 防止溢出
value = Math.min(oneNum * i, this.numTotal);
this.$emit("input", value);
// 新增代码 ---------------------------------------
} else {this.$emit("input", value);
}
}
},
}
已选择缩小效果: 如图
对于这个效果我的思路就是, 给一个 index 数值, 小于 index 的都缩小就好
当然需要用户开启 big 模式
<cc-icon v-for='item in num'
// ...
:class="{'cc-rate--big':item < bigIndex}" />
data() {
return {
oldVal: 0,
bigIndex: 0
};
},
methods: {
// 计算手指的位置
// 点了有效还是移动就有效?
handelMousemove(e) {
// 实时判断的原因是, 可能用户现在禁止改动, 一会又需要改动了!!
if (!this.disabled) {
// ...
// 前面的变大效果
// 每颗必须完整 一颗的距离
let i = 0,
oneNum = this.numTotal / this.num;
while (oneNum * i <= value) {i++;}
// 新增代码 -------------
if (this.big) {
// 借花献佛, 把上面计算好的星星个数, 直接拿来用
this.bigIndex = i;
}
// 新增代码 -------------
if (this.one) {
// 防止溢出
value = Math.min(oneNum * i, this.numTotal)
this.$emit("input", value);
} else {this.$emit("input", value);
}
}
},
}
上述的 i 变量, 可以优化一下啦, 因为并不是每次都需要它, 毕竟有可能用户生成 1000 个星星;
let i, oneNum;
if (this.big || this.one) {
i = 0;
oneNum = this.numTotal / this.num;
while (oneNum * i <= value) {i++;}
if (this.big) {this.bigIndex = i;}
}
if (this.one) { // 防止溢出
value = Math.min(oneNum * i, this.numTotal);
this.$emit("input", value);
} else {this.$emit("input", value);
}
继续处理 index
// 离开区域
handelMouseleave() {
// 离开的时候当然要把所有放大效果都取消
this.bigIndex = 0;
if (!this.disabled) {this.$emit("input", this.oldVal);
}
},
尾巴
mounted() {
// 初始的时候也初始一下 oldValue;
this.oldVal = this.value;
}
end
我也是服了, 文章一长卡的不要不要的 ….
下一章聊一聊 提示框组件 popover
大家都可以一起交流, 共同学习, 共同进步, 早日实现自我价值!!
工程 github 地址:github
个人技术博客(组件的官网): 链接描述