引言
在前端开发中,颜色选择器(color-picker)是可视化交互场景的核心组件,广泛用于主题配置、表单设计、图像编辑等功能中。本文将详细介绍一款自定义 Vue3 颜色选择器组件—— 该组件模仿 Element Plus 的 el-color-pick 交互逻辑,但省去 “展开面板” 的操作,直接展示取色界面,同时支持饱和度 - 明度(SV)、色相(Hue)、透明度(Alpha)的精细化调整,满足高效取色需求。
组件功能概述
该自定义 color-picker 组件具备以下核心能力,覆盖颜色选择的全场景需求:
- 多维度颜色调整:通过「饱和度 - 明度(SV)面板」调整颜色纯度与明暗、「色相(Hue)滑块」切换颜色基调、「透明度(Alpha)滑块」控制颜色透明程度;
- 灵活数据交互:支持通过
modelValue传入初始颜色(默认#ffffff),通过change事件实时传出当前颜色的十六进制值与透明度,实现数据双向同步; - 流畅交互体验:支持鼠标拖拽调整(SV 面板、Hue/Alpha 滑块),实时反馈颜色变化,选中态视觉清晰;
- 组件化拆分:按功能拆分为入口组件、子交互组件、工具函数与颜色核心逻辑,结构清晰,可维护性强。
代码结构分析
该 color-picker 组件由 6 个核心文件组成,各文件职责明确,按 “入口整合 - 子组件交互 - 工具支撑 - 颜色逻辑” 分层设计,以下逐一解析各文件的实现细节。
1. 入口组件:index.vue(核心整合)
入口组件负责整合所有子组件、管理颜色状态、处理父子组件通信,是组件对外暴露的唯一入口。
模板部分(<template>)
<template>
<div class="color-dropdown__main-wrapper">
<SvPanel ref="svPanel" :color="state.color"></SvPanel>
<HueSlider ref="hueSlider" :color="state.color"></HueSlider>
</div>
<AlphaSlider ref="alphaSlider" :color="state.color"></AlphaSlider>
</template>
模板解析:
- 整体结构:根元素
color-dropdown__main-wrapper包裹「SV 面板 + Hue 滑块」,下方单独放置「Alpha 滑块」,形成 “主调整区 + 透明度调整区” 的布局; - 子组件引用:通过
SvPanel、HueSlider、AlphaSlider标签引入子组件,均通过:colorprops 传递全局颜色实例(state.color),确保颜色状态统一; - 引用标识:通过
ref标记子组件(如svPanel、alphaSlider),用于后续调用子组件方法(如alphaSlider.value.update())。
脚本部分(<script setup>)
<script setup>
import {
reactive,
onBeforeUnmount,
onMounted,
watch,
ref,
computed,
} from "vue";
import SvPanel from "./SvPanel.vue";
import HueSlider from "./HueSlider.vue";
import AlphaSlider from "./AlphaSlider.vue";
import Color from "/public/native/core/color.js";
const state = reactive({
color: new Color(),
});
const emits = defineEmits({
change: null,
});
const alphaSlider = ref(null);
let colorValue = computed(() => {
const hue = state.color.get("hue");
const value = state.color.get("value");
const saturation = state.color.get("saturation");
const alpha = state.color.get("alpha");
return { hue, value, saturation, alpha };
});
let props = defineProps({
modelValue: {
type: String,
required: false,
default: "#ffffff",
},
});
watch(
() => colorValue,
(colorValue) => {
alphaSlider.value.update();
let alpha = colorValue.value.alpha
? colorValue.value.alpha / 101
: 0.99;
emits("change", {
color: state.color.tohex(),
alpha,
});
},
{ deep: true }
);
watch(
() => props.modelValue,
(modelValue) => {
console.log("modelValue-change:", modelValue);
state.color.fromHex(modelValue);
}
);
onMounted(() => {
state.color.fromHex(props.modelValue);
});
onBeforeUnmount(() => {
//console.log('onBeforeUnmount')
});
</script>
脚本解析:
- 依赖引入:导入 Vue3 组合式 API(
reactive/watch等)、子组件与Color类(颜色核心逻辑); - 状态管理:通过
reactive定义state,其中state.color是Color类实例,作为全局颜色状态的 “唯一来源”; - 父子通信:
- 入参:通过
defineProps定义modelValue(初始颜色,默认白色),用于父组件传入初始值; - 出参:通过
defineEmits定义change事件,颜色变化时传出「十六进制色值(state.color.tohex())」与「透明度(alpha)」;
- 入参:通过
- 响应式计算:
colorValue计算属性实时获取state.color的 H(色相)、S(饱和度)、V(明度)、Alpha(透明度)四属性; - 监听逻辑:
- 监听
colorValue:颜色属性变化时,调用AlphaSlider的update()方法同步透明度滑块视图,并触发change事件; - 监听
modelValue:父组件传入的初始颜色变化时,通过state.color.fromHex()同步到颜色实例;
- 监听
- 生命周期:
onMounted初始化颜色(将modelValue转为颜色实例),onBeforeUnmount预留组件销毁时的处理逻辑。
样式部分(<style scoped lang="less">)
<style scoped lang="less">
.color-dropdown__main-wrapper {
margin-bottom: 3px;
display: flex;
&:after {
content: "";
display: table;
clear: both;
}
}
</style>
样式解析:
- 布局控制:
color-dropdown__main-wrapper采用flex布局,使「SV 面板」与「Hue 滑块」横向排列; - 清除浮动:通过
&:after伪元素清除浮动,避免子元素高度塌陷; - 间距控制:
margin-bottom: 3px为下方的「Alpha 滑块」预留垂直间距,保证布局美观。
2. 饱和度 - 明度面板:SvPanel.vue(S/V 调整)
SvPanel 是核心交互组件,用于通过鼠标拖拽调整颜色的饱和度(Saturation) 与明度(Value),视觉上通过双层渐变实现颜色维度的可视化。
模板部分(<template>)
<template>
<div
class="color-svpanel"
:style="{
backgroundColor: state.background,
}"
>
<div class="color-svpanel__white"></div>
<div class="color-svpanel__black"></div>
<div
class="color-svpanel__cursor"
:style="{
top: state.cursorTop + 'px',
left: state.cursorLeft + 'px',
}"
>
<div></div>
</div>
</div>
</template>
模板解析:
- 面板容器:
color-svpanel是面板根元素,背景色state.background由当前色相(Hue)决定(固定为hsl(色相, 100%, 50%)); - 渐变层:
color-svpanel__white:横向线性渐变(#fff → 透明),控制 “饱和度” 维度(左→右饱和度从 0→100);color-svpanel__black:纵向线性渐变(#000 → 透明),控制 “明度” 维度(下→上明度从 0→100);
- 光标元素:
color-svpanel__cursor是拖拽光标,通过state.cursorTop/state.cursorLeft控制位置,标识当前选中的 S/V 坐标。
脚本部分(<script setup>)
<script setup>
import {
reactive,
onBeforeUnmount,
onMounted,
getCurrentInstance,
computed,
watch,
} from "vue";
import { draggable, getClientXY } from "./utils.js";
let { vnode } = getCurrentInstance();
let props = defineProps({
color: {
type: Object,
required: true,
},
});
const state = reactive({
cursorTop: 0,
cursorLeft: 0,
background: "hsl(0, 100%, 50%)",
});
let colorValue = computed(() => {
const hue = props.color.get("hue");
const value = props.color.get("value");
return { hue, value };
});
watch(
() => colorValue.value,
() => {
update();
}
);
onMounted(() => {
draggable(vnode.el, {
drag: (event) => {
handleDrag(event);
},
end: (event) => {
handleDrag(event);
},
});
update();
});
onBeforeUnmount(() => {
//console.log('onBeforeUnmount')
});
function update() {
const saturation = props.color.get("saturation");
const value = props.color.get("value");
const el = vnode.el;
const { clientWidth, clientHeight } = el;
state.cursorLeft = (saturation * clientWidth) / 100;
state.cursorTop = ((100 - value) * clientHeight) / 100;
state.background = `hsl(${props.color.get("hue")}, 100%, 50%)`;
}
function handleDrag(event) {
const el = vnode.el;
const rect = el.getBoundingClientRect();
const { clientX, clientY } = getClientXY(event);
let left = clientX - rect.left;
let top = clientY - rect.top;
left = Math.max(0, left);
left = Math.min(left, rect.width);
top = Math.max(0, top);
top = Math.min(top, rect.height);
state.cursorLeft = left;
state.cursorTop = top;
props.color.set({
saturation: (left / rect.width) * 100,
value: 100 - (top / rect.height) * 100,
});
}
</script>
脚本解析:
- 依赖引入:导入
draggable(拖拽工具函数)、getClientXY(获取鼠标坐标工具函数),以及 Vue3 API; - Props 接收:通过
defineProps接收父组件传递的color实例(必传,确保颜色状态同步); - 响应式状态:
state存储光标位置(cursorTop/cursorLeft)与面板背景色(background); - 监听逻辑:监听
colorValue(当前色相与明度),变化时调用update()同步光标位置与面板背景; - 拖拽交互:
onMounted中通过draggable绑定面板的拖拽事件(drag/end),拖拽时触发handleDrag;handleDrag:计算鼠标在面板内的相对位置,限制位置在面板边界内,再通过props.color.set()更新颜色的饱和度与明度;
- 更新逻辑:
update()根据当前颜色的 S/V 值,反推光标在面板中的位置,并更新面板背景色(与当前色相匹配)。
样式部分(<style scoped lang="less">)
<style scoped lang="less">
.color-svpanel {
position: relative;
width: 128px;
height: 82px;
margin-right: 3px;
background-color: #fff;
.color-svpanel__white,
.color-svpanel__black {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.color-svpanel__white {
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
}
.color-svpanel__black {
background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
}
.color-svpanel__cursor {
position: absolute;
& > div {
cursor: head;
width: 4px;
height: 4px;
box-shadow: 0 0 0 1.5px #fff, inset 0 0 1px 1px #0000004d,
0 0 1px 2px #0006;
border-radius: 50%;
transform: translate(-2px, -2px);
}
}
}
</style>
样式解析:
- 面板尺寸:固定宽
128px、高82px,确保交互区域大小统一; - 渐变层定位:
color-svpanel__white与color-svpanel__black均采用绝对定位(top/left/right/bottom: 0),覆盖整个面板,实现渐变叠加; - 光标样式:光标内层是
4px的圆形,通过多层阴影(白色外框、黑色内阴影、黑色模糊阴影)增强视觉辨识度,transform: translate(-2px, -2px)确保光标中心与鼠标点击位置对齐。
3. 色相滑块:HueSlider.vue(H 调整)
HueSlider 是垂直滑块组件,用于调整颜色的色相(Hue)(范围 0-360°,对应红→黄→绿→青→蓝→紫→红的光谱)。
模板部分(<template>)
<template>
<div class="color-hue-slider is-vertical hue-slider">
<div class="color-hue-slider__bar" ref="bar"></div>
<div
class="color-hue-slider__thumb"
ref="thumb"
:style="{
left: 0,
top: state.thumbTop + 'px',
}"
>
</div>
</div>
</template>
模板解析:
- 滑块容器:
color-hue-slider是根元素,通过is-vertical类标识为垂直方向,hue-slider是基础样式类; - 光谱条:
color-hue-slider__bar是色相光谱的载体,背景是垂直的色相渐变; - 滑块 thumb:
color-hue-slider__thumb是可拖拽的滑块,通过state.thumbTop控制垂直位置,标识当前选中的色相。
脚本部分(<script setup>)
<script setup>
import {
ref,
reactive,
onBeforeUnmount,
onMounted,
getCurrentInstance,
watch,
defineProps,
nextTick,
} from "vue";
import { draggable, getClientXY } from "./utils.js";
let { vnode } = getCurrentInstance();
let props = defineProps({
color: {
type: Object,
required: true,
},
});
const state = reactive({
thumbLeft: 0,
thumbTop: 0,
hue: 0,
});
const thumb = ref(null);
const bar = ref(null);
onMounted(() => {
const dragConfig = {
drag: (event) => {
handleDrag(event);
},
end: (event) => {
handleDrag(event);
},
};
draggable(bar.value, dragConfig);
draggable(thumb.value, dragConfig);
nextTick(() => {
update();
});
});
onBeforeUnmount(() => {
//console.log('onBeforeUnmount')
});
watch(
() => state.hue,
(hue) => {
update();
}
);
function handleDrag(event) {
if (!bar.value || !thumb.value) return;
const el = vnode.el;
const rect = el.getBoundingClientRect();
const { clientY } = getClientXY(event);
let top = clientY - rect.top;
top = Math.min(top, rect.height - thumb.value.offsetHeight / 2);
top = Math.max(thumb.value.offsetHeight / 2, top);
state.hue = Math.round(
((top - thumb.value.offsetHeight / 2) /
(rect.height - thumb.value.offsetHeight)) *
360
);
props.color.set("hue", state.hue);
}
function update() {
state.thumbTop = getThumbTop();
}
function getThumbTop() {
if (!thumb.value) return 0;
const el = vnode.el;
if (!el) return 0;
return Math.round(
(state.hue * (el.offsetHeight - thumb.value.offsetHeight / 2)) / 360
);
}
</script>
脚本解析:
- 拖拽绑定:
onMounted中为「光谱条(bar)」和「滑块(thumb)」绑定拖拽事件,支持两种拖拽触发方式; - 色相计算:
handleDrag中计算鼠标在滑块容器内的垂直位置,限制位置在滑块上下边界内(避免超出),再通过公式将位置转为 0-360° 的色相值,最后通过props.color.set()更新颜色实例; - 视图同步:
update()调用getThumbTop(),根据当前色相值反推滑块的垂直位置,确保色相与滑块位置一致; nextTick:初始化时通过nextTick调用update(),确保 DOM 渲染完成后再同步滑块位置,避免获取不到 DOM 尺寸的问题。
样式部分(<style scoped lang="less">)
<style scoped lang="less">
.color-hue-slider__bar {
position: relative;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
height: 100%;
}
.color-hue-slider__thumb {
position: absolute;
cursor: pointer;
box-sizing: border-box;
left: 0;
top: 0;
width: 4px;
height: 100%;
border-radius: 1px;
background: #fff;
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 0 2px #0009;
z-index: 1;
}
.color-hue-slider {
position: relative;
box-sizing: border-box;
width: 280px;
height: 12px;
background-color: red;
padding: 0 2px;
float: right;
&.is-vertical {
width: 6px;
height: 82px;
padding: 2px 0;
.color-hue-slider__bar {
background: linear-gradient(
to bottom,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
}
.color-hue-slider__thumb {
left: 0;
top: 0;
width: 100%;
height: 4px;
}
}
}
</style>
样式解析:
- 光谱渐变:
- 水平方向(默认):渐变方向
to right,覆盖红→黄→绿→青→蓝→紫→红的光谱; - 垂直方向(
is-vertical):渐变方向to bottom,光谱顺序与垂直位置对应;
- 水平方向(默认):渐变方向
- 滑块样式:垂直模式下,滑块是
4px高的水平条,背景为白色,搭配浅色边框与黑色阴影,确保在光谱条上清晰可见; - 容器尺寸:垂直模式下固定宽
6px、高82px,与「SV 面板」高度一致,布局协调。
4. 透明度滑块:AlphaSlider.vue(Alpha 调整)
AlphaSlider 是横向滑块组件,用于调整颜色的透明度(Alpha)(范围 0-100,对应完全透明→完全不透明),视觉上通过 “透明→当前颜色” 的渐变展示透明度变化。
模板部分(<template>)
<template>
<div class="color-alpha-slider">
<div
ref="bar"
class="color-alpha-slider__bar"
@click="handleClick"
:style="{ background: state.background }"
>
</div>
<div
ref="thumb"
class="color-alpha-slider__thumb"
:style="thumbStyle"
>
</div>
</div>
</template>
模板解析:
- 滑块容器:
color-alpha-slider是根元素,横向布局; - 透明度条:
color-alpha-slider__bar是透明度渐变的载体,背景state.background是 “透明→当前颜色” 的横向渐变,支持点击切换透明度; - 滑块 thumb:
color-alpha-slider__thumb是可拖拽滑块,通过thumbStyle(计算属性)控制位置。
脚本部分(<script setup>)
<script setup>
import {
reactive,
onBeforeUnmount,
onMounted,
getCurrentInstance,
ref,
computed,
watch,
} from "vue";
import { draggable, getClientXY } from "./utils.js";
let { vnode } = getCurrentInstance();
const state = reactive({
thumbLeft: 0,
thumbTop: 0,
background: "",
});
let props = defineProps({
color: {
type: Object,
required: true,
},
vertical: {
type: Boolean,
default: false,
},
});
const thumb = ref(null);
const bar = ref(null);
const thumbStyle = computed(() => ({
left: addUnit(state.thumbLeft),
top: addUnit(state.thumbTop),
}));
watch(
() => props.color.get("alpha"),
() => {
update();
}
);
watch(
() => props.color.get("value"),
() => {
update();
}
);
onMounted(() => {
if (!bar.value || !thumb.value) return;
const dragConfig = {
drag: (event) => {
handleDrag(event);
},
end: (event) => {
handleDrag(event);
},
};
draggable(bar.value, dragConfig);
draggable(thumb.value, dragConfig);
update();
});
onBeforeUnmount(() => {
//console.log('onBeforeUnmount')
});
function handleClick(event) {
const target = event.target;
if (target !== thumb.value) {
handleDrag(event);
}
}
function handleDrag(event) {
if (!bar.value || !thumb.value) return;
const el = vnode.el;
const rect = el.getBoundingClientRect();
const { clientX } = getClientXY(event);
let left = clientX - rect.left;
left = Math.max(thumb.value.offsetWidth / 2, left);
left = Math.min(left, rect.width - thumb.value.offsetWidth / 2);
props.color.set(
"alpha",
Math.round(
((left - thumb.value.offsetWidth / 2) /
(rect.width - thumb.value.offsetWidth)) *
100
)
);
}
function update() {
state.thumbLeft = getThumbLeft();
state.thumbTop = getThumbTop();
state.background = getBackground();
}
function getThumbLeft() {
if (!thumb.value) return 0;
if (props.vertical) return 0;
const el = vnode.el;
const alpha = props.color.get("alpha");
if (!el) return 0;
return Math.round(
(alpha * (el.offsetWidth - thumb.value.offsetWidth / 2)) / 100
);
}
function getThumbTop() {
if (!thumb.value) return 0;
const el = vnode.el;
if (!props.vertical) return 0;
const alpha = props.color.get("alpha");
if (!el) return 0;
return Math.round(
(alpha * (el.offsetHeight - thumb.value.offsetHeight / 2)) / 100
);
}
function getBackground() {
if (props.color && props.color.get("value")) {
const { r, g, b } = props.color.toRgba();
return `linear-gradient(to right, rgba(${r}, ${g}, ${b}, 0) 0%, rgba(${r}, ${g}, ${b}, 1) 100%)`;
}
return "";
}
function addUnit(value, defaultUnit = "px") {
if (!value) return "";
if (isNumber(value) || isStringNumber(value)) {
return `${value}${defaultUnit}`;
} else if (isString(value)) {
return value;
}
}
const isStringNumber = (val) => {
if (!isString(val)) {
return false;
}
return !Number.isNaN(Number(val));
};
const isNumber = (val) => typeof val === "number";
const isString = (val) => typeof val === "string";
defineExpose({
update,
bar,
thumb,
});
</script>
脚本解析:
- 计算属性:
thumbStyle通过addUnit工具函数为位置值添加单位(默认px),确保样式合法; - 监听逻辑:监听颜色的
alpha(透明度)与value(明度),变化时调用update()同步滑块位置与透明度条背景; - 交互逻辑:
- 拖拽:
handleDrag计算鼠标横向位置,转为 0-100 的透明度值,更新颜色实例; - 点击:
handleClick支持点击透明度条直接切换透明度(非滑块区域点击也触发拖拽逻辑);
- 拖拽:
- 背景计算:
getBackground()通过props.color.toRgba()获取当前颜色的 RGB 值,生成 “透明→不透明” 的横向渐变,直观展示透明度效果; - 暴露方法:通过
defineExpose暴露update()方法,供父组件(index.vue)调用,同步视图。
样式部分(<style scoped lang="less">)
<style scoped lang="less">
.color-alpha-slider {
position: relative;
box-sizing: border-box;
width: 280px;
height: 12px;
background-color: #fff;
border-radius: 6px;
margin-top: 3px;
.color-alpha-slider__bar {
width: 100%;
height: 100%;
border-radius: 6px;
}
.color-alpha-slider__thumb {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 1px #ddd, 0 0 2px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
}
</style>
样式解析:
- 容器样式:固定宽
280px、高12px,背景为白色,圆角6px,与「Hue 滑块」宽度一致,布局统一; - 透明度条:
color-alpha-slider__bar占满容器,圆角与容器一致,渐变背景覆盖整个区域; - 滑块样式:滑块是
16px的圆形,白色背景搭配灰色边框与黑色阴影,transform: translateY(-50%)确保滑块垂直居中,视觉上更协调。
5. 工具函数:utils.js(辅助支撑)
utils.js 封装通用工具函数,为子组件提供拖拽、鼠标坐标获取等基础能力,避免代码重复。
// utils.js
export function draggable(el, options = {}) {
let isDragging = false;
const handleMouseDown = (event) => {
isDragging = true;
options.start && options.start(event);
};
const handleMouseMove = (event) => {
if (isDragging) {
options.drag && options.drag(event);
}
};
const handleMouseUp = (event) => {
if (isDragging) {
isDragging = false;
options.end && options.end(event);
}
};
el.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
// 组件销毁时移除事件监听
return () => {
el.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
export function getClientXY(event) {
// 兼容鼠标事件与触摸事件
return {
clientX: event.clientX || event.touches?.[0]?.clientX || 0,
clientY: event.clientY || event.touches?.[0]?.clientY || 0,
};
}
工具函数解析:
draggable:为 DOM 元素绑定拖拽事件,支持start(拖拽开始)、drag(拖拽中)、end(拖拽结束)三个回调,返回事件移除函数(供组件销毁时调用,避免内存泄漏);getClientXY:兼容鼠标事件与触摸事件,统一返回鼠标 / 触摸点的clientX/clientY坐标,为多端适配预留扩展空间。
6. 颜色核心:color.js(颜色逻辑)
color.js 是颜色处理的核心,封装颜色的解析(如 Hex 转 HSV)、转换(如 HSV 转 Hex/RGBA)、属性读写(get/set)等方法,是整个组件的 “颜色大脑”(此前版本遗漏,本次补充完整)。
// color.js
export default class Color {
constructor() {
// 初始颜色:白色(H=0, S=0, V=100, Alpha=100)
this.hue = 0;
this.saturation = 0;
this.value = 100;
this.alpha = 100;
}
/**
* 从 Hex 色值解析颜色(如 #ffffff → HSV+Alpha)
* @param {string} hex - 十六进制色值(支持 #fff 或 #ffffff)
*/
fromHex(hex) {
if (!hex) return;
// 处理简写 Hex(如 #fff → #ffffff)
hex = hex.replace(/^#?([a-f\d])([a-f\d])([a-f\d])$/i, (m, r, g, b) => r + r + g + g + b + b);
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
const r = parseInt(result[1], 16) / 255;
const g = parseInt(result[2], 16) / 255;
const b = parseInt(result[3], 16) / 255;
// RGB 转 HSV
const hsv = this.rgbToHsv(r, g, b);
this.hue = hsv.h * 360;
this.saturation = hsv.s * 100;
this.value = hsv.v * 100;
}
}
/**
* 将当前颜色转为 Hex 色值(如 #ffffff)
* @returns {string} 十六进制色值
*/
tohex() {
const rgb = this.hsvToRgb(this.hue / 360, this.saturation / 100, this.value / 100);
const r = Math.round(rgb.r * 255);
const g = Math.round(rgb.g * 255);
const b = Math.round(rgb.b * 255);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
/**
* 将当前颜色转为 RGBA 对象(如 { r:255, g:255, b:255, a:1 })
* @returns {object} RGBA 对象
*/
toRgba() {
const rgb = this.hsvToRgb(this.hue / 360, this.saturation / 100, this.value / 100);
return {
r: Math.round(rgb.r * 255),
g: Math.round(rgb.g * 255),
b: Math.round(rgb.b * 255),
a: this.alpha / 100
};
}
/**
* 获取颜色属性(hue/saturation/value/alpha)
* @param {string} key - 属性名
* @returns {number} 属性值
*/
get(key) {
switch (key) {
case "hue":
return this.hue;
case "saturation":
return this.saturation;
case "value":
return this.value;
case "alpha":
return this.alpha;
default:
return 0;
}
}
/**
* 设置颜色属性(支持单个或批量设置)
* @param {string|object} key - 属性名或属性对象
* @param {number} [value] - 属性值(仅单个设置时需传)
*/
set(key, value) {
if (typeof key === "object") {
// 批量设置(如 { hue: 0, saturation: 100 })
Object.assign(this, key);
} else {
// 单个设置(如 set("hue", 0))
this[key] = value;
}
// 限制属性范围(避免超出合理值)
this.hue = Math.max(0, Math.min(360, this.hue));
this.saturation = Math.max(0, Math.min(100, this.saturation));
this.value = Math.max(0, Math.min(100, this.value));
this.alpha = Math.max(0, Math.min(100, this.alpha));
}
/**
* RGB 转 HSV(内部工具方法)
* @param {number} r - 红通道(0-1)
* @param {number} g - 绿通道(0-1)
* @param {number} b - 蓝通道(0-1)
* @returns {object} HSV 对象(h:0-1, s:0-1, v:0-1)
*/
rgbToHsv(r, g, b) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, v = max;
const d = max - min;
s = max === 0 ? 0 : d / max;
if (max !== min) {
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return { h, s, v };
}
/**
* HSV 转 RGB(内部工具方法)
* @param {number} h - 色相(0-1)
* @param {number} s - 饱和度(0-1)
* @param {number} v - 明度(0-1)
* @returns {object} RGB 对象(r:0-1, g:0-1, b:0-1)
*/
hsvToRgb(h, s, v) {
let r = 0, g = 0, b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v, g = t, b = p; break;
case 1: r = q, g = v, b = p; break;
case 2: r = p, g = v, b = t; break;
case 3: r = p, g = q, b = v; break;
case 4: r = t, g = p, b = v; break;
case 5: r = v, g = p, b = q; break;
}
return { r, g, b };
}
}
颜色逻辑解析:
- 类初始化:默认颜色为白色(H=0, S=0, V=100, Alpha=100);
- 颜色解析与转换:
fromHex:将十六进制色值转为 HSV 格式,同步到实例属性;tohex/toRgba:将当前 HSV 颜色转为 Hex 或 RGBA 格式,供外部使用;
- 属性读写:
get:根据属性名获取 H/S/V/Alpha 值;set:支持单个或批量设置属性,并限制属性范围(如 Hue 0-360,S/V/Alpha 0-100);
- 内部工具:
rgbToHsv/hsvToRgb实现 RGB 与 HSV 格式的互相转换,是颜色调整的核心算法(HSV 格式更适合直观的颜色维度调整)。
完整代码汇总
以下是 6 个核心文件的完整代码,可直接复制到项目中使用(需确保文件路径正确)。
1. index.vue(入口组件)
<template>
<div class="color-dropdown__main-wrapper">
<SvPanel ref="svPanel" :color="state.color"></SvPanel>
<HueSlider ref="hueSlider" :color="state.color"></HueSlider>
</div>
<AlphaSlider ref="alphaSlider" :color="state.color"></AlphaSlider>
</template>
<script setup>
import {
reactive,
onBeforeUnmount,
onMounted,
watch,
ref,
computed,
} from "vue";
import SvPanel from "./SvPanel.vue";
import HueSlider from "./HueSlider.vue";
import AlphaSlider from "./AlphaSlider.vue";
import Color from "/public/native/core/color.js";
const state = reactive({
color: new Color(),
});
const emits = defineEmits({
change: null,
});
const alphaSlider = ref(null);
let colorValue = computed(() => {
const hue = state.color.get("hue");
const value = state.color.get("value");
const saturation = state.color.get("saturation");
const alpha = state.color.get("alpha");
return { hue, value, saturation, alpha };
});
let props = defineProps({
modelValue: {
type: String,
required: false,
default: "#ffffff",
},
});
watch(
() => colorValue,
(colorValue) => {
alphaSlider.value.update();
let alpha = colorValue.value.alpha
? colorValue.value.alpha / 101
: 0.99;
emits("change", {
color: state.color.tohex(),
alpha,
});
},
{ deep: true }
);
watch(
() => props.modelValue,
(modelValue) => {
console.log("modelValue-change:", modelValue);
state.color.fromHex(modelValue);
}
);
onMounted(() => {
state.color.fromHex(props.modelValue);
});
onBeforeUnmount(() => {
//console.log('onBeforeUnmount')
});
</script>
<style scoped lang="less">
.color-dropdown__main-wrapper {
margin-bottom: 3px;
display: flex;
&:after {
content: "";
display: table;
clear: both;
}
}
</style>
2. SvPanel.vue(饱和度 - 明度面板)
<template>
<div
class="color-svpanel"
:style="{
backgroundColor: state.background,
}"
>
<div class="color-svpanel__white"></div>
<div class="color-svpanel__black"></div>
<div
class="color-svpanel__cursor"
:style="{
top: state.cursorTop + 'px',
left: state.cursorLeft + 'px',
}"
>
<div></div>
</div>
</div>
</template>
<script setup>
import {
reactive,
onBeforeUnmount,
onMounted,
getCurrentInstance,
computed,
watch,
} from "vue";
import { draggable, getClientXY } from "./utils.js";
let { vnode } = getCurrentInstance();
let props = defineProps({
color: {
type: Object,
required: true,
},
});
const state = reactive({
cursorTop: 0,
cursorLeft: 0,
background: "hsl(0, 100%, 50%)",
});
let colorValue = computed(() => {
const hue = props.color.get("hue");
const value = props.color.get("value");
return { hue, value };
});
watch(
() => colorValue.value,
() => {
update();
}
);
onMounted(() => {
draggable(vnode.el, {
drag: (event) => {
handleDrag(event);
},
end: (event) => {
handleDrag(event);
},
});
update();
});
onBeforeUnmount(() => {
//console.log('onBeforeUnmount')
});
function update() {
const saturation = props.color.get("saturation");
const value = props.color.get("value");
const el = vnode.el;
const { clientWidth, clientHeight } = el;
state.cursorLeft = (saturation * clientWidth) / 100;
state.cursorTop = ((100 - value) * clientHeight) / 100;
state.background = `hsl(${props.color.get("hue")}, 100%, 50%)`;
}
function handleDrag(event) {
const el = vnode.el;
const rect = el.getBoundingClientRect();
const { clientX, clientY } = getClientXY(event);
let left = clientX - rect.left;
let top = clientY - rect.top;
left = Math.max(0, left);
left = Math.min(left, rect.width);
top = Math.max(0, top);
top = Math.min(top, rect.height);
state.cursorLeft = left;
state.cursorTop = top;
props.color.set({
saturation: (left / rect.width) * 100,
value: 100 - (top / rect.height) * 100,
});
}
</script>
<style scoped lang="less">
.color-svpanel {
position: relative;
width: 128px;
height: 82px;
margin-right: 3px;
background-color: #fff;
.color-svpanel__white,
.color-svpanel__black {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.color-svpanel__white {
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
}
.color-svpanel__black {
background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
}
.color-svpanel__cursor {
position: absolute;
& > div {
cursor: head;
width: 4px;
height: 4px;
box-shadow: 0 0 0 1.5px #fff, inset 0 0 1px 1px #0000004d,
0 0 1px 2px #0006;
border-radius: 50%;
transform: translate(-2px, -2px);
}
}
}
</style>
3. HueSlider.vue(色相滑块)
<template>
<div class="color-hue-slider is-vertical hue-slider">
<div class="color-hue-slider__bar" ref="bar"></div>
<div
class="color-hue-slider__thumb"
ref="thumb"
:style="{
left: 0,
top: state.thumbTop + 'px',
}"
>
</div>
</div>
</template>
<script setup>
import {
ref,
reactive,
onBeforeUnmount,
onMounted,
getCurrentInstance,
watch,
defineProps,
nextTick,
} from "vue";
import { draggable, getClientXY } from "./utils.js";
let { vnode } = getCurrentInstance();
let props = defineProps({
color: {
type: Object,
required: true,
},
});
const state = reactive({
thumbLeft: 0,
thumbTop: 0,
hue: 0,
});
const thumb = ref(null);
const bar = ref(null);
onMounted(() => {
const dragConfig = {
drag: (event) => {
handleDrag(event);
},
end: (event) => {
handleDrag(event);
},
};
draggable(bar.value, dragConfig);
draggable(thumb.value, dragConfig);
nextTick(() => {
update();
});
});
onBeforeUnmount(() => {
//console.log('onBeforeUnmount')
});
watch(
() => state.hue,
(hue) => {
update();
}
);
function handleDrag(event) {
if (!bar.value || !thumb.value) return;
const el = vnode.el;
const rect = el.getBoundingClientRect();
const { clientY } = getClientXY(event);
let top = clientY - rect.top;
top = Math.min(top, rect.height - thumb.value.offsetHeight / 2);
top = Math.max(thumb.value.offsetHeight / 2, top);
state.hue = Math.round(
((top - thumb.value.offsetHeight / 2) /
(rect.height - thumb.value.offsetHeight)) *
360
);
props.color.set("hue", state.hue);
}
function update() {
state.thumbTop = getThumbTop();
}
function getThumbTop() {
if (!thumb.value) return 0;
const el = vnode.el;
if (!el) return 0;
return Math.round(
(state.hue * (el.offsetHeight - thumb.value.offsetHeight / 2)) / 360
);
}
</script>
<style scoped lang="less">
.color-hue-slider__bar {
position: relative;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f0f 100%
);
height: 100%;
}
.color-hue-slider__thumb {
position: absolute;
cursor: pointer;
box-sizing: border-box;
left: 0;
top: 0;
width: 4px;
height: 100%;
border-radius: 1px;
background: #fff;
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 0 2px #0009;
z-index: 1;
}
.color-hue-slider {
position: relative;
box-sizing: border-box;
width: 280px;
height: 12px;
background-color: red;
padding: 0 2px;
float: right;
&.is-vertical {
width: 6px;
height: 82px;
padding: 2px 0;
.color-hue-slider__bar {
background: linear-gradient(
to bottom,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f0f 100%
);
}
.color-hue-slider__thumb {
left: 0;
top: 0;
width: 100%;
height: 4px;
}
}
}
</style>
4. AlphaSlider.vue(透明度滑块)
<template>
<div class="color-alpha-slider">
<div
ref="bar"
class="color-alpha-slider__bar"
@click="handleClick"
:style="{ background: state.background }"
>
</div>
<div
ref="thumb"
class="color-alpha-slider__thumb"
:style="thumbStyle"
>
</div>
</div>
</template>
<script setup>
import {
reactive,
onBeforeUnmount,
onMounted,
getCurrentInstance,
ref,
computed,
watch,
} from "vue";
import { draggable, getClientXY } from "./utils.js";
let { vnode } = getCurrentInstance();
const state = reactive({
thumbLeft: 0,
thumbTop: 0,
background: "",
});
let props = defineProps({
color: {
type: Object,
required: true,
},
vertical: {
type: Boolean,
default: false,
},
});
const thumb = ref(null);
const bar = ref(null);
const thumbStyle = computed(() => ({
left: addUnit(state.thumbLeft),
top: addUnit(state.thumbTop),
}));
watch(
() => props.color.get("alpha"),
() => {
update();
}
);
watch(
() => props.color.get("value"),
() => {
update();
}
);
onMounted(() => {
if (!bar.value || !thumb.value) return;
const dragConfig = {
drag: (event) => {
handleDrag(event);
},
end: (event) => {
handleDrag(event);
},
};
draggable(bar.value, dragConfig);
draggable(thumb.value, dragConfig);
update();
});
onBeforeUnmount(() => {
//console.log('onBeforeUnmount')
});
function handleClick(event) {
const target = event.target;
if (target !== thumb.value) {
handleDrag(event);
}
}
function handleDrag(event) {
if (!bar.value || !thumb.value) return;
const el = vnode.el;
const rect = el.getBoundingClientRect();
const { clientX } = getClientXY(event);
let left = clientX - rect.left;
left = Math.max(thumb.value.offsetWidth / 2, left);
left = Math.min(left, rect.width - thumb.value.offsetWidth / 2);
props.color.set(
"alpha",
Math.round(
((left - thumb.value.offsetWidth / 2) /
(rect.width - thumb.value.offsetWidth)) *
100
)
);
}
function update() {
state.thumbLeft = getThumbLeft();
state.thumbTop = getThumbTop();
state.background = getBackground();
}
function getThumbLeft() {
if (!thumb.value) return 0;
if (props.vertical) return 0;
const el = vnode.el;
const alpha = props.color.get("alpha");
if (!el) return 0;
return Math.round(
(alpha * (el.offsetWidth - thumb.value.offsetWidth / 2)) / 100
);
}
function getThumbTop() {
if (!thumb.value) return 0;
const el = vnode.el;
if (!props.vertical) return 0;
const alpha = props.color.get("alpha");
if (!el) return 0;
return Math.round(
(alpha * (el.offsetHeight - thumb.value.offsetHeight / 2)) / 100
);
}
function getBackground() {
if (props.color && props.color.get("value")) {
const { r, g, b } = props.color.toRgba();
return `linear-gradient(to right, rgba(${r}, ${g}, ${b}, 0) 0%, rgba(${r}, ${g}, ${b}, 1) 100%)`;
}
return "";
}
function addUnit(value, defaultUnit = "px") {
if (!value) return "";
if (isNumber(value) || isStringNumber(value)) {
return `${value}${defaultUnit}`;
} else if (isString(value)) {
return value;
}
}
const isStringNumber = (val) => {
if (!isString(val)) {
return false;
}
return !Number.isNaN(Number(val));
};
const isNumber = (val) => typeof val === "number";
const isString = (val) => typeof val === "string";
defineExpose({
update,
bar,
thumb,
});
</script>
<style scoped lang="less">
.color-alpha-slider {
position: relative;
box-sizing: border-box;
width: 280px;
height: 12px;
background-color: #fff;
border-radius: 6px;
margin-top: 3px;
.color-alpha-slider__bar {
width: 100%;
height: 100%;
border-radius: 6px;
}
.color-alpha-slider__thumb {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 1px #ddd, 0 0 2px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
}
</style>
5. utils.js(工具函数)
export function draggable(el, options = {}) {
let isDragging = false;
const handleMouseDown = (event) => {
isDragging = true;
options.start && options.start(event);
};
const handleMouseMove = (event) => {
if (isDragging) {
options.drag && options.drag(event);
}
};
const handleMouseUp = (event) => {
if (isDragging) {
isDragging = false;
options.end && options.end(event);
}
};
el.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
el.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
export function getClientXY(event) {
return {
clientX: event.clientX || event.touches?.[0]?.clientX || 0,
clientY: event.clientY || event.touches?.[0]?.clientY || 0,
};
}
6. color.js(颜色核心)
export default class Color {
constructor() {
this.hue = 0;
this.saturation = 0;
this.value = 100;
this.alpha = 100;
}
fromHex(hex) {
if (!hex) return;
hex = hex.replace(/^#?([a-f\d])([a-f\d])([a-f\d])$/i, (m, r, g, b) => r + r + g + g + b + b);
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
const r = parseInt(result[1], 16) / 255;
const g = parseInt(result[2], 16) / 255;
const b = parseInt(result[3], 16) / 255;
const hsv = this.rgbToHsv(r, g, b);
this.hue = hsv.h * 360;
this.saturation = hsv.s * 100;
this.value = hsv.v * 100;
}
}
tohex() {
const rgb = this.hsvToRgb(this.hue / 360, this.saturation / 100, this.value / 100);
const r = Math.round(rgb.r * 255);
const g = Math.round(rgb.g * 255);
const b = Math.round(rgb.b * 255);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
toRgba() {
const rgb = this.hsvToRgb(this.hue / 360, this.saturation / 100, this.value / 100);
return {
r: Math.round(rgb.r * 255),
g: Math.round(rgb.g * 255),
b: Math.round(rgb.b * 255),
a: this.alpha / 100
};
}
get(key) {
switch (key) {
case "hue":
return this.hue;
case "saturation":
return this.saturation;
case "value":
return this.value;
case "alpha":
return this.alpha;
default:
return 0;
}
}
set(key, value) {
if (typeof key === "object") {
Object.assign(this, key);
} else {
this[key] = value;
}
this.hue = Math.max(0, Math.min(360, this.hue));
this.saturation = Math.max(0, Math.min(100, this.saturation));
this.value = Math.max(0, Math.min(100, this.value));
this.alpha = Math.max(0, Math.min(100, this.alpha));
}
rgbToHsv(r, g, b) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, v = max;
const d = max - min;
s = max === 0 ? 0 : d / max;
if (max !== min) {
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return { h, s, v };
}
hsvToRgb(h, s, v) {
let r = 0, g = 0, b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v, g = t, b = p; break;
case 1: r = q, g = v, b = p; break;
case 2: r = p, g = v, b = t; break;
case 3: r = p, g = q, b = v; break;
case 4: r = t, g = p, b = v; break;
case 5: r = v, g = p, b = q; break;
}
return { r, g, b };
}
}
使用示例
在父组件中引用 color-picker 组件,通过 v-model 传入初始颜色,通过 @change 监听颜色变化,示例代码如下:
<!-- 父组件中使用 color-picker -->
<template>
<div class="demo-container">
<h3>自定义颜色选择器示例</h3>
<!-- 引入颜色选择器组件 -->
<color-picker
v-model="currentColor"
@change="handleColorChange"
></color-picker>
<!-- 展示选中的颜色 -->
<div
class="color-preview"
:style="{ backgroundColor: currentColorWithAlpha }"
>
选中颜色:{{ currentColor }}(透明度:{{ alpha.toFixed(2) }})
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import ColorPicker from './components/color-picker/index.vue'; // 调整为实际组件路径
// 初始颜色(默认白色)
const currentColor = ref('#409eff'); // Element Plus 主题蓝
// 透明度
const alpha = ref(1);
// 处理颜色变化
const handleColorChange = (data) => {
currentColor.value = data.color;
alpha.value = data.alpha;
};
// 带透明度的颜色值(用于预览)
const currentColorWithAlpha = computed(() => {
return `${currentColor.value}${Math.round(alpha.value * 255).toString(16).padStart(2, '0')}`;
});
</script>
<style scoped>
.demo-container {
padding: 20px;
max-width: 300px;
}
.color-preview {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
color: #fff;
text-shadow: 0 1px 1px rgba(0,0,0,0.3);
}
</style>
使用说明:
- 通过
v-model绑定初始颜色(支持十六进制格式,如#ffffff); @change事件返回对象包含color(十六进制色值)和alpha(透明度,0-1);- 示例中通过
currentColorWithAlpha计算属性将颜色与透明度组合,用于实时预览。
成果预览

注意事项
- 文件路径配置:
index.vue中引入Color类的路径为/public/native/core/color.js,需根据项目实际结构调整,确保路径正确; - 样式依赖:组件使用 Less 编写样式,需确保项目已安装
less和less-loader(可通过npm install less less-loader --save-dev安装); - 颜色格式支持:当前版本仅支持十六进制色值(
#ffffff)作为输入,如需支持 RGBA 或 HSL,可扩展color.js的fromRgba/fromHsl方法; - 移动端适配:
utils.js的getClientXY已兼容触摸事件,但滑块尺寸较小,移动端使用时可适当调大thumb元素的宽高; - 性能优化:组件通过
watch和计算属性实现响应式,若在大型项目中使用,建议对频繁变化的颜色属性添加防抖处理。
总结
本自定义 Vue3 颜色选择器组件通过组件化拆分(入口组件 + 3 个子交互组件)、工具函数封装(拖拽、坐标计算)、颜色逻辑抽象(Color 类),实现了一套功能完整、交互流畅的取色工具。其核心优势在于:
- 交互直观:通过可视化面板与滑块,支持用户精细化调整颜色的色相、饱和度、明度与透明度;
- 架构清晰:按功能分层设计,各文件职责单一,便于维护与扩展(如新增颜色格式支持);
- 适配灵活:支持父子组件数据双向绑定,可无缝集成到表单、主题配置等场景;
- 逻辑闭环:补充了此前遗漏的
color.js,完整实现颜色解析、转换与属性管理,确保组件可用。
如需进一步优化,可考虑添加 “预设颜色面板”“最近使用颜色记录”“颜色值手动输入” 等功能,丰富组件的使用场景。
7492

被折叠的 条评论
为什么被折叠?



