我用trae重构了ew-color-picker,一个插件化架构的现代颜色选择器

颜色选择器不该是黑盒:为什么我把 ew-color-picker 做成了插件系统

每个项目早晚都需要一个颜色选择器。多年来我的默认答案是 <input type="color">,但每年都会撞上同一堵墙:它在每个浏览器里长得不一样,你几乎控制不了面板,也基本无法扩展。

常见的替代方案是大型 UI 库,颜色选择器只是其中几百个组件之一。你装了整个库,继承了它的样式风格,然后希望你需要的那个 prop 恰好存在。

还有第三条路:把选择器看作一组可组合的部件。这就是 ew-color-picker 的设计思路。

真正的问题是集成,不是颜色计算

大多数颜色选择器库的颜色计算都是正确的。HSV、HSL、RGB、alpha、hex 解析、拖拽交互------这些早就解决了。

没解决的是选择器怎么融入你的产品:

  • 它是否强制绑定某个框架运行时?
  • 你能不能去掉不需要的部分?
  • 你能不能替换饱和度面板、按钮或输入框,而不需要 fork 整个库?
  • 它能不能继承你的设计系统,而不是跟它打架?
  • 多个选择器能不能在同一页面共存,没有全局状态冲突?
  • 它能不能在弹窗或滚动容器里正常工作,不被裁剪?

ew-color-picker 就是围绕这些集成问题构建的。核心只负责维护当前颜色和运行事件总线,可见的部分都是插件:触发框、饱和度面板、色相滑块、透明度滑块、文本输入、数字输入、操作按钮、预设色板、颜色模式切换器。

这意味着你可以用全量入口包,也可以只导入你真正需要的部件。

具体解决了什么

1. 包级别的组合

全量入口只是一个薄聚合层。插件以独立包的形式发布,所以选择器是从部件组装出来的,而不只是用布尔开关来开关功能。

不需要预设色板?不注册 predefine 插件就行。想用自己的操作栏?跳过 button 插件,自己渲染。

2. 框架无关

选择器基于纯 DOM 和事件工作。内部没有 React、Vue 或 Angular 运行时。React 组件只是 new ewColorPicker(...) 的一层薄封装,不是独立实现。

这也意味着同一个选择器可以用在设计系统包、CMS 或白标 widget 里,不需要为每个框架重写。

3. 设计系统适配

当选择器是嵌入使用而不是独立 widget 时,有两个选项很重要:

  • prefix 改变所有生成的 CSS 类名,避免和其他库的类名冲突。
  • appendTo 把面板挂载到 document.body 或任意容器,弹窗、抽屉和 overflow: hidden 容器不会裁剪它。

SCSS 构建暴露了对应的 $prefix 变量,面板尺寸由 --panel-width / --panel-height CSS 变量驱动,而不是硬编码的视口规则。

4. 可预测的状态和事件

setColor() 是编程式修改颜色的公开入口,所有变化都经过同一个事件总线:

ts 复制代码
picker.on('change', (color) => { /* 颜色变化 */ })
picker.on('sure', (color, instance) => { /* 点击确认 */ })
picker.on('toggle', (isOpen) => { /* 面板打开或关闭 */ })

多实例场景下,每个实例拥有自己的 DOM、插件实例和事件总线,没有共享的全局颜色状态。

5. 不依赖框架的 TypeScript

源码是 TypeScript,每个 JavaScript 包都附带声明文件,构造函数选项有类型标注。不需要引入框架特定封装就能获得自动补全。事件名是普通字符串,在运行时校验而不是通过类型化事件映射。

适用场景

  • 设计系统和主题编辑器。 样式可以用自己的 CSS 覆盖,发布的 SCSS 源码可以用自定义 $prefix 重新编译;面板尺寸由 CSS 变量驱动。
  • 管理后台和 CMS 内容编辑。 预设色板、hex/RGB/HSL 模式、alpha、清除和确认都是开箱即用的。
  • 白标产品和嵌入式 widget。 prefix 和 appendTo 解决了嵌入最常见的两个问题:类名冲突和面板裁剪。
  • 画布、绘图和数据可视化工具。 选择器只是大型编辑器的一个小部件;事件总线和 setColor() 让它容易被当作控件而非应用来对待。
  • 多选择器页面。 每个实例完全独立,多个颜色字段互不干扰。

和其他方案对比

没有绝对最好的颜色选择器,正确的选择取决于你想定制什么。

方案 框架 组合方式 主要优势 最适合
<input type="color"> 无 零 JavaScript 浏览器内置 快速 hex 输入,无设计要求
UI 库选择器(Mantine、PrimeReact 等) 绑定对应库 整个库 表单集成和一致的外观 已经标准化使用该 UI 库的应用
react-colorful 仅 React 每种布局一个小组件 极小的体积、零依赖、快速 需要标准布局的 React 弹层
react-color 仅 React 每种风格一个组件 大量现成的选择器布局 想要 Sketch/Photoshop 风格组件的 React 应用
Pickr 无 单包含主题和内置扩展 功能丰富的独立选择器 想要完整选择器、少组装
iro.js 无 单包 色轮/滑块风格交互 喜欢 iro 交互模型的创意工具
coloris 无 单包加 CSS 极小体积和 CSS 变量主题 轻量级、简单的颜色字段
ew-color-picker 无 核心加按插件分包 移除或替换选择器的任意部件 设计系统、嵌入式 widget 和定制产品

关键区别在于定制发生在哪一层:

  • 原生 input:你几乎什么都定制不了。
  • UI 库:你通过库的主题和 props 定制,但要接受整个库。
  • react-colorful 和 react-color:你在现成组件中选择。
  • Pickr、iro.js、coloris:你配置和主题化一个完整的选择器。
  • ew-color-picker:你选择哪些部件存在,并且可以把任何一个换成自己的插件。

这不仅仅是包体积的问题,更是控制权的问题。如果你的产品需要非标准的操作栏、自定义预设布局,或者挂载到弹窗外面的面板,写一个自己的插件通常比覆盖一个单体库更简单。

诚实的取舍

ew-color-picker 比 react-colorful、react-color、Pickr 或大型 UI 库更新,生态更小。没有官方的框架绑定包;文档展示的是薄集成模式。渐变编辑器和屏幕吸管作为独立插件提供,不是核心功能的一部分。

选择器也不假装自己是完整的设计系统。它给你一个小巧的、有类型的、事件驱动的控件,设计初衷就是被嵌入。

一分钟上手

bash 复制代码
npm install @ew-color-picker/ew-color-picker @ew-color-picker/style
ts 复制代码
import ewColorPicker from '@ew-color-picker/ew-color-picker'
import '@ew-color-picker/style/dist/index.css'
import '@ew-color-picker/style/dist/input-number.css'

const picker = new ewColorPicker({
  el: '#color-picker',
  defaultColor: '#6366f1',
  predefineColor: ['#ef4444', '#22c55e', '#3b82f6', '#0f172a'],
})

picker.on('change', (color) => {
  document.documentElement.style.setProperty('--brand-color', color)
})

两个样式文件都是必须的:index.css 给选择器本身,input-number.css 给 RGB 和 HSL 模式下的数字输入框。

如果你喜欢更短的包名,npm install ew-color-picker 安装的是一个薄的兼容入口,re-export 同样的 API。

不想用构建工具的话,UMD 包可以直接从 CDN 加载:

html 复制代码
<link rel="stylesheet" href="https://unpkg.com/@ew-color-picker/style/dist/index.css" />
<link rel="stylesheet" href="https://unpkg.com/@ew-color-picker/style/dist/input-number.css" />

<div id="color-picker"></div>

<script src="https://unpkg.com/@ew-color-picker/ew-color-picker/dist/index.min.js"></script>
<script>
  new ewColorPicker({ el: '#color-picker', defaultColor: '#ff0000' })
</script>

只组装你需要的部件

如果你只想要触发框、饱和度面板、色相滑块、文本输入和操作按钮,精确导入这些:

ts 复制代码
import ewColorPicker from '@ew-color-picker/core'
import Box from '@ew-color-picker/box'
import Panel from '@ew-color-picker/panel'
import Hue from '@ew-color-picker/hue'
import Input from '@ew-color-picker/input'
import Button from '@ew-color-picker/button'

ewColorPicker.use(Box)
  .use(Panel)
  .use(Hue)
  .use(Input)
  .use(Button)

new ewColorPicker({ el: '#color-picker' })

use() 是静态注册步骤。每个插件声明一个 pluginName,接收选择器实例,可以渲染到挂载点或订阅颜色变化。注册按 pluginName 去重,重复导入或 HMR 不会注册同一个插件两次。

编写自定义插件

插件遵循和内置插件相同的结构:

ts 复制代码
import ewColorPicker from '@ew-color-picker/core'

class BrandSwatchesPlugin {
  static pluginName = 'brandSwatches'

  constructor(picker: ewColorPicker) {
    const host = picker.getMountPoint('panelContainer')

    // 在 host 中渲染你自己的 UI
    // 颜色更新时订阅 change 事件
    picker.on('change', (color) => {
      // 更新你的 UI
    })
  }
}

ewColorPicker.use(BrandSwatchesPlugin)

因为插件接收选择器实例,它可以调用和应用代码相同的公开 API:getColor()、setColor()、getOptions()、showPanel() 等等。

嵌入设计系统

当选择器活在另一个库或白标产品里时,两边要一致配置:

ts 复制代码
new ewColorPicker({
  el: '#color-picker',
  prefix: 'acme-',
  appendTo: document.body,
  panelPlacement: 'bottom-start',
})
scss 复制代码
@use '@ew-color-picker/style/src/index.scss' with ($prefix: 'acme-');
@use '@ew-color-picker/style/src/input-number.scss' with ($prefix: 'acme-');

JS 的 prefix 和 SCSS 的 $prefix 必须一致。预构建的 dist CSS 使用 ew-;自定义前缀需要编译 SCSS 源码。

实用配置,不是配置迷宫

大多数选项直接对应一个产品需求:

ts 复制代码
new ewColorPicker({
  el: '#color-picker',
  defaultColor: 'rgba(99, 102, 241, 0.8)',
  size: 'large',
  hueDirection: 'horizontal',
  alphaDirection: 'horizontal',
  predefineColor: [
    '#ef4444',
    '#22c55e',
    '#3b82f6',
    { color: '#0f172a', disabled: true },
  ],
  clearText: '重置',
  sureText: '确定',
  panelPlacement: 'bottom-start',
  togglePickerAnimation: 'height',
  pickerAnimationTime: 250,
})

你可以用 hasPanel、hasInput、showButton、showColorMode 等标志位禁用单个部件,或者用 hue: false 和 alpha: false 移除色相和透明度滑块。支持 Hex、RGB 和 HSL 模式,以及可选的 alpha 滑块。

在 React 中使用

选择器不需要 React 封装。规则是:挂载后创建,卸载时销毁,外部值通过 setColor() 写入并用守卫防止回显为用户变更。

tsx 复制代码
import { useEffect, useRef } from 'react'
import ewColorPicker from '@ew-color-picker/ew-color-picker'
import '@ew-color-picker/style/dist/index.css'
import '@ew-color-picker/style/dist/input-number.css'

interface Props {
  value: string
  onChange: (color: string) => void
}

export function ColorField({ value, onChange }: Props) {
  const hostRef = useRef<HTMLDivElement>(null)
  const pickerRef = useRef<ewColorPicker | null>(null)
  const lastEmitted = useRef(value)
  const syncing = useRef(false)

  useEffect(() => {
    const picker = new ewColorPicker({
      el: hostRef.current!,
      defaultColor: value,
      changeColor: (color: string) => {
        if (syncing.current) return
        lastEmitted.current = color
        onChange(color)
      },
    })
    pickerRef.current = picker

    return () => {
      picker.destroy()
      pickerRef.current = null
    }
  }, [])

  // 受控:外部值 -> 选择器,不回显为 onChange
  useEffect(() => {
    const picker = pickerRef.current
    if (!picker || value === lastEmitted.current) return
    syncing.current = true
    picker.setColor(value)
    syncing.current = false
    lastEmitted.current = value
  }, [value])

  return <div ref={hostRef} />
}

两个容易忽略的细节:

  1. 优先传 DOM 元素而不是选择器字符串。多实例场景下选择器可能冲突。
  2. 卸载时始终调用 destroy()。面板打开时实例会在 document 上注册 mousedown 监听;泄漏它会让其他实例行为异常。

文档中有 Vue 3 和 Angular 的同样精简的集成模式。

在 Solid 中使用

Solid 需要同样的生命周期处理,封装足够小,值得展示真实使用的版本。它导入核心加按需插件------触发框、饱和度面板、色相滑块和预设色板------而不是全量入口。选择器在 onMount 中创建,在 onCleanup 中销毁,外部值通过 setColor() 写入并用重入守卫保护。

tsx 复制代码
import { createEffect, onCleanup, onMount } from 'solid-js'
import EwColorPicker from '@ew-color-picker/core'
import Box from '@ew-color-picker/box'
import Panel from '@ew-color-picker/panel'
import Hue from '@ew-color-picker/hue'
import Predefine from '@ew-color-picker/predefine'
import '@ew-color-picker/style/dist/index.css'

EwColorPicker.use(Box).use(Panel).use(Hue).use(Predefine)

interface Props {
  value: string
  presets?: string[]
  onChange: (color: string) => void
}

export function ColorField(props: Props) {
  let host!: HTMLDivElement
  let picker: EwColorPicker | undefined
  let lastEmitted = props.value
  let syncing = false

  onMount(() => {
    picker = new EwColorPicker({
      el: host,
      defaultColor: props.value,
      predefineColor: props.presets ?? [],
      changeColor: (color) => {
        if (syncing) return
        lastEmitted = color
        props.onChange(color)
      },
    })
  })

  // 受控:外部值 -> 选择器,不回显为 onChange
  createEffect(() => {
    const value = props.value
    if (!picker || value === lastEmitted) return
    syncing = true
    picker.setColor(value)
    syncing = false
    lastEmitted = value
  })

  onCleanup(() => picker?.destroy())

  return <div ref={host} />
}

因为插件接收选择器实例和 DOM 挂载点,你也可以把 Solid JSX 直接渲染进选择器的 chrome 里。下面的例子把应用自己的输入框和按钮挂载到选择器的底部行,不需要 fork 库:

tsx 复制代码
import { render } from 'solid-js/web'
import { createSignal, onCleanup, onMount } from 'solid-js'
import { ApplyOrder } from '@ew-color-picker/utils'

function AppControls(props: { picker: EwColorPicker }) {
  const [color, setColor] = createSignal(props.picker.getColor() || '')

  onMount(() => {
    const sync = (value: string) => setColor(value)
    props.picker.on('change', sync)
    onCleanup(() => props.picker.off('change', sync))
  })

  return (
    <div class="app-controls">
      <input
        value={color()}
        onChange={(e) => props.picker.setColor(e.currentTarget.value)}
      />
      <button onClick={() => props.picker.hidePanel()}>完成</button>
    </div>
  )
}

class AppControlsPlugin {
  static pluginName = 'appControls'
  // 在面板插件创建底部行之后执行
  static applyOrder = ApplyOrder.Post
  private dispose?: () => void

  constructor(picker: EwColorPicker) {
    const panel = picker.getMountPoint('panelContainer')
    const bottomRow = panel?.querySelector('.ew-color-picker-bottom-row')
    if (bottomRow) {
      this.dispose = render(() => <AppControls picker={picker} />, bottomRow)
    }
  }

  destroy() {
    this.dispose?.()
  }
}

EwColorPicker.use(AppControlsPlugin)

use() 按 pluginName 去重,HMR 或多入口不会重复注册插件。选择器销毁时核心会调用 destroy(),Solid 渲染根随实例一起被释放。

上面的选择器假设使用默认的 ew- 前缀。如果自定义了 prefix,用 @ew-color-picker/utils 导出的 getPrefix(picker) 来构建底部行的选择器,确保匹配生成的类名。

同一个封装里还有一个技巧:如果宿主应用已经有自己的 .ew-color-picker 样式,你可以把导入的库样式限定在自己的子树内(Vite 的 ?raw 导入),这样库的规则不会泄漏到其他组件:

ts 复制代码
import pickerCss from '@ew-color-picker/style/dist/index.css?raw'

const style = document.createElement('style')
style.textContent = `@scope (.color-field-scope) {\n${pickerCss}\n}`
document.head.appendChild(style)

这不会覆盖宿主针对你子树内选择器的规则,但能阻止库重新样式化子树外的任何东西。不需要 !important,也不需要强制改前缀。

什么时候不该用它

  • 你只需要一个简单的 hex 输入,不在意浏览器差异:<input type="color"> 就够了。
  • 你已经标准化使用某个 UI 库,需要它的表单、主题和无障碍行为,超过你需要选择器级别的控制。
  • 你想要官方的框架绑定包,零集成代码。
  • 你需要功能丰富的独立选择器,几乎不需要组装:Pickr 或 UI 库组件可能更合适。

选择器有意设计为控件,而不是完整应用。

链接

如果插件模型对你有用,GitHub 上点个 star 能帮更多人发现它。如果你写了一个填补空白的插件,我很乐意看到。

相关推荐
Penge66616 分钟前
React JSX 核心语法:从入门到业务开发实战
前端
zhanlang021723 分钟前
HTML 零基础入门到企业实战全套教程【第五章 5.5 综合案例:图文影音展示页面开发】
前端·html
zhanlang02171 小时前
HTML 零基础入门到企业实战全套教程【连载 5.3】audio、video 音频视频标签|兼容格式、自动播放踩坑、企业规范
前端·html
qq_406981032 小时前
c++ lamda(三)Lambda表达式
java·前端·c++
zhanlang02172 小时前
HTML 零基础入门到企业实战【第五章 5.4】内嵌网页 iframe 标签:嵌入网页、视频、地图实战
前端·html·音视频
FYKJ_20102 小时前
springboot同人周边订单管理系统48955-计算机毕业设计、课程设计
java·javascript·vue.js·spring boot·django·php·课程设计
总要学点什么2 小时前
第106篇:三维特效-流动线效果——让一颗光点沿整条线一路滚到头
开发语言·javascript·ecmascript·gis·webgl·材质·cesium
parade岁月9 小时前
开源也扛不住 AI 了?ESLint 宣布立即终止外部贡献者奖励
开源
2分钟速写快排9 小时前
孔乙己的茴字有多少种写法?|AI 对话实时通信的三种实现
前端·javascript·github