颜色选择器不该是黑盒:为什么我把 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} />
}
两个容易忽略的细节:
- 优先传 DOM 元素而不是选择器字符串。多实例场景下选择器可能冲突。
- 卸载时始终调用
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 库组件可能更合适。
选择器有意设计为控件,而不是完整应用。
链接
- 文档:eveningwater.github.io/ew-color-pi...
- 在线演示:eveningwater.github.io/ew-color-pi...
- GitHub:github.com/eveningwate...
- npm:www.npmjs.com/package/@ew...
如果插件模型对你有用,GitHub 上点个 star 能帮更多人发现它。如果你写了一个填补空白的插件,我很乐意看到。