自定义 Vue3 颜色选择器(color-picker)组件:实现与原理详解

引言

在前端开发中,颜色选择器(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 滑块」,形成 “主调整区 + 透明度调整区” 的布局;
  • 子组件引用:通过 SvPanelHueSliderAlphaSlider 标签引入子组件,均通过 :color props 传递全局颜色实例(state.color),确保颜色状态统一;
  • 引用标识:通过 ref 标记子组件(如 svPanelalphaSlider),用于后续调用子组件方法(如 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 计算属性将颜色与透明度组合,用于实时预览。

成果预览

注意事项

  1. 文件路径配置index.vue 中引入 Color 类的路径为 /public/native/core/color.js,需根据项目实际结构调整,确保路径正确;
  2. 样式依赖:组件使用 Less 编写样式,需确保项目已安装 less 和 less-loader(可通过 npm install less less-loader --save-dev 安装);
  3. 颜色格式支持:当前版本仅支持十六进制色值(#ffffff)作为输入,如需支持 RGBA 或 HSL,可扩展 color.js 的 fromRgba/fromHsl 方法;
  4. 移动端适配utils.js 的 getClientXY 已兼容触摸事件,但滑块尺寸较小,移动端使用时可适当调大 thumb 元素的宽高;
  5. 性能优化:组件通过 watch 和计算属性实现响应式,若在大型项目中使用,建议对频繁变化的颜色属性添加防抖处理。

总结

本自定义 Vue3 颜色选择器组件通过组件化拆分(入口组件 + 3 个子交互组件)、工具函数封装(拖拽、坐标计算)、颜色逻辑抽象Color 类),实现了一套功能完整、交互流畅的取色工具。其核心优势在于:

  1. 交互直观:通过可视化面板与滑块,支持用户精细化调整颜色的色相、饱和度、明度与透明度;
  2. 架构清晰:按功能分层设计,各文件职责单一,便于维护与扩展(如新增颜色格式支持);
  3. 适配灵活:支持父子组件数据双向绑定,可无缝集成到表单、主题配置等场景;
  4. 逻辑闭环:补充了此前遗漏的 color.js,完整实现颜色解析、转换与属性管理,确保组件可用。

如需进一步优化,可考虑添加 “预设颜色面板”“最近使用颜色记录”“颜色值手动输入” 等功能,丰富组件的使用场景。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

YAY_tyy

坚持不设置VIP文章

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值