第十三集: 从零开始实现一套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
大家都可以一起交流, 共同学习,共同进步, 早日实现自我价值!!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。