Skip to content

实现 颜色格式转换器 工具

分类:developer | 标签:颜色、HEX、RGB、HSL、转换 HEX / RGB / HSL / HSV 互相转换,支持点击取色与复制

2.1 功能说明

HEX / RGB / HSL / HSV 互相转换,支持点击取色与复制

核心能力

  • HEX
  • RGB
  • HSL
  • HSV
  • CMYK
  • LAB
  • OKLCH
  • 两色混合
  • 反色 / 补色
  • WCAG 对比度检测
  • 场景
  • 要求
  • 结果

2.1.1 使用指南

颜色格式转换器使用指南

支持的格式

  • HEX#RRGGBB,前端最常用。
  • RGBrgb(r, g, b),取值范围 0-255。
  • HSLhsl(h, s%, l%),色相/饱和度/亮度,便于调色。
  • HSVhsv(h, s%, v%),设计软件常用。
  • CMYKcmyk(c%, m%, y%, k%),印刷四分色。此处使用不含 ICC 色彩管理的简易换算,仅供参考。
  • LABlab(L, a, b),CIE 1976 L*a*b*,D65 白点。感知均匀,适合做色差计算。
  • OKLCHoklch(L C H),现代感知均匀色彩空间,CSS Color 4 已支持,调明度时色相更稳定。

使用说明

点击左侧色块或用取色器选择颜色,或直接输入 HEX 值(支持简写 #abc),右侧自动换算全部格式并可一键复制。

两色混合

在 sRGB 空间对两个颜色做线性插值,滑块控制颜色 B 的占比(50% 即取中间色), 同时给出 0% / 25% / 50% / 75% / 100% 五档色卡,点击任意色卡可将其设为上方主色。

反色 / 补色

反相255 - 通道值 取反;色相补色在 HSL 中把色相旋转 180°, 保持明度与饱和度;另外给出三分色(±120°)和分裂补色(±150°),可直接复制为一组 CSS 变量。

WCAG 对比度检测

按 WCAG 2.1 计算相对亮度并得出对比度比值 (L1 + 0.05) / (L2 + 0.05),比值范围 1:1 ~ 21:1。判定标准:

  • 普通文本(小于 18pt 或小于 14pt 加粗):AA 需 ≥ 4.5,AAA 需 ≥ 7。
  • 大字文本(≥ 18pt 或 ≥ 14pt 加粗):AA 需 ≥ 3,AAA 需 ≥ 4.5。
  • 图形与界面组件(图标、输入框边框等):至少需 ≥ 3。

典型场景

前端配色、设计稿还原、CSS 变量生成、无障碍(a11y)可读性走查、印刷稿色值参考。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • isValidHex()
  • normalizeHex()
  • hexToRgb()
  • rgbToHsl()
  • rgbToHsv()
  • rgbToCmyk()
  • srgbToLinear()
  • rgbToLab()
  • rgbToOklab()
  • rgbToOklch()
  • rgbToHex()
  • hslToRgb()
  • safeRgb()
  • mixHex()
  • swapMix()
  • applyToMain()
  • relLuminance()
  • swapContrast()
  • onHexInput()
  • onHexText()
  • copy()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import ToolPageShell from '@/components/ToolPageShell.vue'

const hex = ref('#409eff')
const hexText = ref('#409eff')
const error = ref('')

const rgb = computed(() => hexToRgb(normalizeHex(hex.value)))
const hsl = computed(() => rgbToHsl(rgb.value))
const hsv = computed(() => rgbToHsv(rgb.value))

const displayColor = computed(() => (isValidHex(hex.value) ? hex.value : '#cccccc'))
const rgbText = computed(() => (rgb.value ? `rgb(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b})` : ''))
const hslText = computed(() => (hsl.value ? `hsl(${Math.round(hsl.value.h)}, ${Math.round(hsl.value.s)}%, ${Math.round(hsl.value.l)}%)` : ''))
const hsvText = computed(() => (hsv.value ? `hsv(${Math.round(hsv.value.h)}, ${Math.round(hsv.value.s)}%, ${Math.round(hsv.value.v)}%)` : ''))

const cmykText = computed(() => {
  const c = rgbToCmyk(rgb.value)
  return `cmyk(${round1(c.c)}%, ${round1(c.m)}%, ${round1(c.y)}%, ${round1(c.k)}%)`
})
const labText = computed(() => {
  const l = rgbToLab(rgb.value)
  return `lab(${round2(l.l)}, ${round2(l.a)}, ${round2(l.b)})`
})
const oklchText = computed(() => {
  const o = rgbToOklch(rgb.value)
  return `oklch(${round1(o.l * 100)}% ${round4(o.c)} ${round2(o.h)})`
})

function isValidHex(v) {
  return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)
}
function normalizeHex(v) {
  if (!isValidHex(v)) return '#000000'
  let h = v.replace('#', '')
  if (h.length === 3) h = h.split('').map(c => c + c).join('')
  return '#' + h
}
function hexToRgb(v) {
  const h = normalizeHex(v).replace('#', '')
  return {
    r: parseInt(h.slice(0, 2), 16),
    g: parseInt(h.slice(2, 4), 16),
    b: parseInt(h.slice(4, 6), 16)
  }
}
function rgbToHsl({ r, g, b }) {
  r /= 255; g /= 255; b /= 255
  const max = Math.max(r, g, b), min = Math.min(r, g, b)
  let h = 0, s = 0
  const l = (max + min) / 2
  const d = max - min
  if (d !== 0) {
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
    switch (max) {
      case r: h = (g - b) / d + (g < b ? 6 : 0); break
      case g: h = (b - r) / d + 2; break
      default: h = (r - g) / d + 4
    }
    h *= 60
  }
  return { h, s: s * 100, l: l * 100 }
}
function rgbToHsv({ r, g, b }) {
  r /= 255; g /= 255; b /= 255
  const max = Math.max(r, g, b), min = Math.min(r, g, b)
  const d = max - min
  let h = 0
  const s = max === 0 ? 0 : d / max
  const v = max
  if (d !== 0) {
    switch (max) {
      case r: h = (g - b) / d + (g < b ? 6 : 0); break
      case g: h = (b - r) / d + 2; break
      default: h = (r - g) / d + 4
    }
    h *= 60
  }
  return { h, s: s * 100, v: v * 100 }
}

/* ---------------- 新增色彩空间:CMYK / LAB / OKLCH ---------------- */
const round1 = n => Math.round(n * 10) / 10
const round2 = n => Math.round(n * 100) / 100
const round4 = n => Math.round(n * 10000) / 10000

function rgbToCmyk({ r, g, b }) {
  const rr = r / 255, gg = g / 255, bb = b / 255
  const k = 1 - Math.max(rr, gg, bb)
  if (k >= 1) return { c: 0, m: 0, y: 0, k: 100 }
  return {
    c: ((1 - rr - k) / (1 - k)) * 100,
    m: ((1 - gg - k) / (1 - k)) * 100,
    y: ((1 - bb - k) / (1 - k)) * 100,
    k: k * 100
  }
}

// sRGB 分量 -> 线性光
function srgbToLinear(c) {
  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}

// sRGB -> XYZ(D65) -> CIE LAB
function rgbToLab({ r, g, b }) {
  const R = srgbToLinear(r / 255), G = srgbToLinear(g / 255), B = srgbToLinear(b / 255)
  const X = R * 0.4124564 + G * 0.3575761 + B * 0.1804375
  const Y = R * 0.2126729 + G * 0.7151522 + B * 0.0721750
  const Z = R * 0.0193339 + G * 0.1191920 + B * 0.9503041
  // D65 白点
  const Xn = 0.95047, Yn = 1.0, Zn = 1.08883
  const f = t => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29)
  const fx = f(X / Xn), fy = f(Y / Yn), fz = f(Z / Zn)
  return { l: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) }
}

// sRGB -> 线性 sRGB -> OKLab
function rgbToOklab({ r, g, b }) {
  const R = srgbToLinear(r / 255), G = srgbToLinear(g / 255), B = srgbToLinear(b / 255)
  const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B)
  const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B)
  const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B)
  return {
    L: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
    a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
    b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
  }
}

// OKLab -> OKLCH(极坐标)
function rgbToOklch(rgbVal) {
  const { L, a, b } = rgbToOklab(rgbVal)
  const c = Math.sqrt(a * a + b * b)
  let h = (Math.atan2(b, a) * 180) / Math.PI
  if (h < 0) h += 360
  return { l: L, c, h: c < 1e-7 ? 0 : h }
}

function rgbToHex({ r, g, b }) {
  const to = v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
  return '#' + to(r) + to(g) + to(b)
}

function hslToRgb({ h, s, l }) {
  const hh = ((h % 360) + 360) % 360 / 360
  const ss = s / 100
  const ll = l / 100
  if (ss === 0) {
    const v = Math.round(ll * 255)
    return { r: v, g: v, b: v }
  }
  const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss
  const p = 2 * ll - q
  const hue2rgb = t => {
    if (t < 0) t += 1
    if (t > 1) t -= 1
    if (t < 1 / 6) return p + (q - p) * 6 * t
    if (t < 1 / 2) return q
    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
    return p
  }
  return {
    r: Math.round(hue2rgb(hh + 1 / 3) * 255),
    g: Math.round(hue2rgb(hh) * 255),
    b: Math.round(hue2rgb(hh - 1 / 3) * 255)
  }
}

/* ---------------- 两色混合 ---------------- */
const advTab = ref('mix')
const mixA = ref('#409eff')
const mixB = ref('#f56c6c')
const mixRatio = ref(50)

function safeRgb(v) {
  return hexToRgb(normalizeHex(isValidHex(v) ? v : '#000000'))
}
// 在 sRGB 空间按比例 t(0~1)线性插值
function mixHex(a, b, t) {
  const ca = safeRgb(a), cb = safeRgb(b)
  return rgbToHex({
    r: ca.r + (cb.r - ca.r) * t,
    g: ca.g + (cb.g - ca.g) * t,
    b: ca.b + (cb.b - ca.b) * t
  })
}

const mixResultHex = computed(() => mixHex(mixA.value, mixB.value, mixRatio.value / 100))
const mixResultRgb = computed(() => {
  const c = hexToRgb(mixResultHex.value)
  return `rgb(${c.r}, ${c.g}, ${c.b})`
})
const mixSteps = computed(() =>
  [0, 25, 50, 75, 100].map(p => ({ label: p + '%', hex: mixHex(mixA.value, mixB.value, p / 100) }))
)

function swapMix() {
  const t = mixA.value
  mixA.value = mixB.value
  mixB.value = t
  mixRatio.value = 100 - mixRatio.value
}

/* ---------------- 反色 / 补色 ---------------- */
const complementList = computed(() => {
  const c = rgb.value
  const base = hsl.value
  const rotate = deg => rgbToHex(hslToRgb({ h: base.h + deg, s: base.s, l: base.l }))
  return [
    { label: '原色', hex: normalizeHex(hex.value) },
    { label: '反相 (255-x)', hex: rgbToHex({ r: 255 - c.r, g: 255 - c.g, b: 255 - c.b }) },
    { label: '色相补色 (+180°)', hex: rotate(180) },
    { label: '三分色 (+120°)', hex: rotate(120) },
    { label: '三分色 (-120°)', hex: rotate(-120) },
    { label: '分裂补色 (+150°)', hex: rotate(150) },
    { label: '分裂补色 (-150°)', hex: rotate(-150) }
  ]
})

const CSS_VAR_NAMES = [
  '--color-base',
  '--color-invert',
  '--color-complement',
  '--color-triad-1',
  '--color-triad-2',
  '--color-split-1',
  '--color-split-2'
]
const complementCss = computed(() =>
  ':root {\n' +
  complementList.value.map((s, i) => `  ${CSS_VAR_NAMES[i]}: ${s.hex}; /* ${s.label} */`).join('\n') +
  '\n}'
)

function applyToMain(v) {
  hex.value = normalizeHex(v)
  hexText.value = hex.value
  error.value = ''
  ElMessage.success('已设为主色 ' + hex.value)
}

/* ---------------- WCAG 对比度 ---------------- */
const fgColor = ref('#409eff')
const bgColor = ref('#ffffff')

// WCAG 2.1 相对亮度
function relLuminance({ r, g, b }) {
  const R = srgbToLinear(r / 255), G = srgbToLinear(g / 255), B = srgbToLinear(b / 255)
  return 0.2126 * R + 0.7152 * G + 0.0722 * B
}

const contrastRatio = computed(() => {
  const l1 = relLuminance(safeRgb(fgColor.value))
  const l2 = relLuminance(safeRgb(bgColor.value))
  const hi = Math.max(l1, l2), lo = Math.min(l1, l2)
  return (hi + 0.05) / (lo + 0.05)
})
const contrastRatioText = computed(() => contrastRatio.value.toFixed(2))

const wcagRows = computed(() => {
  const r = contrastRatio.value
  return [
    { scene: '普通文本(< 18pt)', require: 'AA ≥ 4.5', pass: r >= 4.5, desc: '正文、说明文字等常规字号' },
    { scene: '普通文本(< 18pt)', require: 'AAA ≥ 7', pass: r >= 7, desc: '面向低视力用户的增强级要求' },
    { scene: '大字文本(≥ 18pt 或 14pt 粗体)', require: 'AA ≥ 3', pass: r >= 3, desc: '标题、大号数字等' },
    { scene: '大字文本(≥ 18pt 或 14pt 粗体)', require: 'AAA ≥ 4.5', pass: r >= 4.5, desc: '大字号的增强级要求' },
    { scene: '图形与界面组件', require: '≥ 3', pass: r >= 3, desc: '图标、边框、焦点框等非文本元素' }
  ]
})

const contrastLevel = computed(() => {
  const r = contrastRatio.value
  if (r >= 7) return 'AAA(普通文本达标)'
  if (r >= 4.5) return 'AA(普通文本达标)'
  if (r >= 3) return '仅大字文本达标'
  return '不达标'
})
const contrastLevelTagType = computed(() => {
  const r = contrastRatio.value
  if (r >= 7) return 'success'
  if (r >= 4.5) return 'success'
  if (r >= 3) return 'warning'
  return 'danger'
})

function swapContrast() {
  const t = fgColor.value
  fgColor.value = bgColor.value
  bgColor.value = t
}

function onHexInput() {
  hexText.value = hex.value
  error.value = ''
}
function onHexText() {
  const v = hexText.value.startsWith('#') ? hexText.value : '#' + hexText.value
  if (isValidHex(v)) {
    hex.value = normalizeHex(v)
    error.value = ''
  } else {
    error.value = 'HEX 格式不正确,应为 #RGB 或 #RRGGBB'
  }
}

async function copy(text) {
  if (!text) return
  try {
    await navigator.clipboard.writeText(text)
    ElMessage.success('已复制')
  } catch {
    ElMessage.warning('复制失败,请手动复制')
  }
}

2.3 效果截图

颜色格式转换器 效果截图

工具访问地址:https://www.i91tools.com/tools/color-converter