让我们构建一个颜色选择器 Web 组件
让我们用 HTML、CSS 和少量 JavaScript 代码构建一个颜色选择器 Web 组件。最终,我们将得到一个自定义元素,它:
内容
- 先决条件
- 设置
- 定义我们的元素
- 为我们的元素设计样式
- 使用响应式控制器跟踪鼠标
- 射击事件
- 无障碍
- 使用我们的颜色选择器
- 后续步骤
- 脚注
先决条件
为了更好地理解本文,您应该对 HTML、CSS 和 JavaScript 有一定的了解,包括:
你不需要成为专家,但应该掌握基础知识。你也应该熟悉基于组件的 UI 设计概念,并了解 Web 组件是什么。如果你曾经使用过流行的 JS 框架编写过组件,那就没问题了。要了解 Web 组件是什么,请查看我的博客系列:
设置
在定义组件之前,我们先创建一个项目文件夹,并快速启动一个开发服务器,以便在保存文件时重新加载页面。将以下脚本粘贴到已安装 nodejs 和 npm的计算机上的 BASH 终端中:
mkdir ~/color-picker
cd ~/color-picker
touch index.html
touch style.css
touch mouse-controller.js
touch color-picker.js
touch color-picker.css
npx @web/dev-server --open --watch
这些命令会在您的HOME文件夹中创建一个包含一些空文件的工作目录,然后启动一个自动重载的开发服务器。
接下来,用您选择的文本编辑器打开新创建的文件夹,并编辑 index.html 文件,添加以下代码片段:
<!doctype html>
<head>
<link rel="stylesheet" href="style.css"/>
<script type="module" src="color-picker.js"></script>
</head>
<body>
<color-picker></color-picker>
</body>
让我们先添加一些初始样式。style.css
color-picker {
width: 400px;
height: 400px;
}
由于我们还没有定义该元素,所以屏幕上目前什么也看不到<color-picker>。现在就来定义它。
定义我们的元素
Web 组件(或自定义元素)是用户定义的 HTML 元素。让我们<color-picker>通过继承类来定义元素HTMLElement。打开color-picker.js并添加以下代码:
const template = document.createElement('template');
template.innerHTML = `
<link rel="stylesheet" href="color-picker.css">
<div id="loupe"></div>
`;
class ColorPicker extends HTMLElement {
constructor() {
super()
this
.attachShadow({ mode: 'open' })
.append(template.content.cloneNode(true));
}
}
customElements.define('color-picker', ColorPicker);
让我们逐块分析这个文件。
我们首先声明一个<template>元素来存放组件的 HTML。我们会添加一个指向组件私有 CSS 的链接,以及两个嵌套<div>元素,稍后会用到它们来增强组件的功能。通过使用 `<div>` 标签<template>,我们可以确保浏览器在页面加载时只解析一次 HTML。之后,我们可以创建<color-picker>任意数量的元素,每个元素都会复制现有的 HTML,这比重新解析要快得多。
接下来,我们声明自定义元素类。在构造函数中,我们将ShadowRoot附加到元素上,然后将我们创建的模板内容写入其中。
最后,我们调用customElements.define(),它将 HTML 标签名称分配<color-picker>给自定义元素类,并指示浏览器升级<color-picker>文档中已存在的元素。
如果保存文件,开发服务器会重新加载页面,但我们仍然看不到任何变化,因为我们元素的内容是不可见的。让我们通过应用一些传统的 CSS 来改变这种情况。
为我们的元素设计样式
打开color-picker.css并粘贴以下内容。
:host {
display: block;
min-height: 100px;
min-width: 100px;
cursor: crosshair;
background:
linear-gradient(to bottom, transparent, hsl(0 0% 50%)),
linear-gradient(
to right,
hsl(0 100% 50%) 0%,
hsl(0.2turn 100% 50%) 20%,
hsl(0.3turn 100% 50%) 30%,
hsl(0.4turn 100% 50%) 40%,
hsl(0.5turn 100% 50%) 50%,
hsl(0.6turn 100% 50%) 60%,
hsl(0.7turn 100% 50%) 70%,
hsl(0.8turn 100% 50%) 80%,
hsl(0.9turn 100% 50%) 90%,
hsl(1turn 100% 50%) 100%
);
}
#loupe {
display: block;
height: 40px;
width: 40px;
border: 3px solid black;
border-radius: 100%;
background: hsl(var(--hue, 0) var(--saturation, 100%) 50%);
transform: translate(var(--x, 0), var(--y, 0));
will-change: background, transform;
}
我们稍后会详细介绍 CSS 规则(可以跳过这部分)。现在,保存文件,看看页面上的变化。这才像样!现在我们的元素看起来像个颜色选择器了!
Shadow CSS 问答
如果您对 Web 组件不太熟悉,此时您可能会问自己一些问题:
:host
这到底是什么鬼东西:host
CSS:host选择器会获取包含样式表的根元素所在的元素。如果您对此感到困惑,别担心,我们稍后会详细解释。现在,您只需要知道,在这种情况下,它:host与color-picker元素本身是同义的。
ID 选择器(例如#loupe)
ID选择器?!这难道不是CSS的大忌吗?
在层叠样式表中,ID 选择器的优先级极高,这意味着它们会覆盖优先级较低的规则,例如类选择器或元素选择器。在传统的(全局)CSS 中,这很容易导致意想不到的后果。
虽然我们的样式表并非全局生效,但由于我们是在 `<div>` 标签内而非文档中<link>调用它,因此样式被严格限定在该根元素上。这种作用域限制是由浏览器本身强制执行的,而非某个 JavaScript 库。这意味着我们在 `<div>` 标签内定义的样式不会“泄露”并影响页面其他位置的样式,因此我们使用的选择器可以非常简单。我们甚至可以用一个裸选择器替换该选择器,效果也完全一样。ShadowRootcolor-picker.css#loupediv
影子根封装也意味着我们在模板 HTML 中使用的元素 ID 是私有的。请在浏览器控制台中尝试以下操作:
document.getElementById('loupe');
如果没有 Shadow DOM,我们应该能<div id="loupe"></div>在控制台中看到元素,但实际上并没有。Shadow DOM 让我们能够完全控制组件的 HTML 和 CSS,允许我们在组件中放置任何所需的 HTML 和 CSS 代码,而无需担心它们会如何影响页面的其他部分。
CSS-in-JS、BEM 等。
如果这是一个可复用的组件,这些样式和 ID 会不会影响页面?我们是否应该使用 BEM 规范,或者添加 JavaScript 或命令行工具将这些 ID 转换为唯一的随机类名?
现在我们对 Shadow DOM 的工作原理有了更多了解,就可以自己回答这个问题了:Shadow DOM(所有浏览器都支持)无需复杂的 CSS-in-JS 工具或 BEM 等类命名规范。我们终于可以用 CSS 编写简单、按需选择的选择器,将工作范围限定在当前任务上。
颜色选择器样式
掌握了 Shadow DOM 的知识后,让我们深入了解元素的样式。
我们元素样式的核心:host是两个函数linear-gradient()调用,一个函数使元素从透明逐渐变为灰色,另一个函数则使元素在色轮上旋转360度,每次旋转幅度为10%,从元素的最左侧移动到最右侧。为了方便起见,我们还添加了十字光标和一些默认尺寸。
我们的#loupe规则使取色放大镜呈现出美观的圆形,但更重要的是,它使用CSS 自定义属性(也称为CSS 变量)定义了放大镜的背景颜色和位置。这在下一步使用 JavaScript 为放大镜元素添加动画效果时将非常有用。此外,我们还会通知浏览器,这些background属性transform可能会发生变化。
使用响应式控制器跟踪鼠标
每个组件都需要 HTML、CSS 和 JavaScript 来处理属性、事件和响应式设计。我们已经分别介绍了 HTML 和 CSS 。现在让我们来学习响应式设计<template>,ShadowRoot也:host就是根据用户操作或属性变化等输入来更新元素的状态。
可重用、可组合的控制器
在编写组件时,我们经常会遇到一些逻辑或行为在多个地方重复出现的情况。例如,处理用户输入或通过网络异步获取数据等操作,最终可能会出现在给定项目中的大多数甚至所有组件中。与其将代码片段复制粘贴到元素定义中,不如采用更好的方法来跨元素共享代码。
JavaScript 类混入 (class mixin)是一种久经考验的组件间代码共享方式。例如,你可能有一个组件,它根据某个src属性获取文件。使用类混入FetchSrcMixin,你可以在一个地方编写这段代码,然后在任何地方复用它。
class JSONFetcher extends FetchSrcMixin(HTMLElement) {/*...*/}
class TextFetcher extends FetchSrcMixins(HTMLElement) {/*...*/}
<json-fetcher src="lemurs.json"></json-fetcher>
<text-fetcher src="othello.txt"></text-fetcher>
但 mixin 有一个局限性——它们与其元素类之间存在“is-a-*”关系。将 mixin 添加到类中意味着结果是基类和 mixin 类的组合。由于 mixin 是函数,我们可以使用函数组合来组合它们,但如果组合后的 mixin 覆盖了类成员(例如字段、方法、访问器),则可能会出现问题。
为了解决这个问题,Lit团队最近发布了一种名为“响应式控制器”(Reactive Controllers )的新型“组合原语” ,它表示一种“拥有*”关系。控制器是一个 JavaScript 类,其中包含对宿主元素的引用,该宿主元素必须实现一组称为ReactiveControllerHost接口的特定方法。
简单来说,这意味着你可以编写一个控制器类,并将其添加到任何符合特定条件的元素类中。一个控制器宿主可以拥有多个独立或相互依赖的控制器,一个控制器实例可以拥有一个宿主,控制器之间可以独立地引用共享状态。
如果你熟悉 React Hooks,你可能会发现控制器也遵循类似的模式。不过,Hooks 的缺点在于它们只能与 React 一起使用。
同样,控制器相对于混合的缺点是,它们要求宿主元素类满足某些条件,即:该类必须实现该ReactiveControllerHost接口。
|
可组合 |
可重复使用的 |
可堆叠 |
独立的 |
| 混合料 |
✅ |
⚠️ |
❌ |
✅ |
| 控制器 |
✅ |
✅ |
✅ |
❌ |
不过,与 React 不同的是,控制器可以与来自不同框架的组件或自定义元素类(而非 React 本身)一起使用。通过一些巧妙的粘合代码,LitElement控制器可以与React、Angular、Vue、Haunted等框架协同工作。
在我的Apollo Elements项目中,我编写了一些响应式控制器,用于执行 GraphQL 操作,例如查询和变更。我希望在任何自定义元素中使用这些控制器,因此我决定使用一个名为 `ReactiveController` 的类 mixin 来解决这个问题。通过将其应用于元素的基类,它会添加承载响应式控制器所需的最低限度代码。如果将其应用于已经实现了`ReactiveController` 接口的基类,它会调用父类,因此您可以安全地(尽管可能没有必要)将其应用于ControllerHostMixin`ReactiveController` 。ReactiveControllerHostLitElement
为我们的 Element 添加控制器支持
让我们更新(双关语!)我们的元素以接受控制器。打开color-picker.js并替换其内容如下:
import { ControllerHostMixin } from 'https://unpkg.com/@apollo-elements/mixins@next/controller-host-mixin.js?module';
const template = document.createElement('template');
template.innerHTML = `
<link rel="stylesheet" href="color-picker.css">
<div id="loupe"></div>
`;
class ColorPicker extends ControllerHostMixin(HTMLElement) {
constructor() {
super()
this
.attachShadow({ mode: 'open' })
.append(template.content.cloneNode(true));
}
update() {
super.update();
}
}
customElements.define('color-picker', ColorPicker);
哇,那是什么?我们正在通过ControllerHostMixin互联网从 CDN 加载内容,无需任何npm额外操作!
这次保存文件后,页面重新加载时,颜色选择器需要稍等片刻才能显示,因为页面需要从 unpkg 加载必要的文件。之后的页面重新加载速度应该会更快,这要归功于浏览器缓存。您可以colour-picker.js再次保存文件来验证我的意思。
现在我们已经设置好了可以托管响应式控制器的环境,接下来让我们添加一个用于跟踪鼠标位置和状态的控制器。打开mouse-controller.js并添加以下内容:
export class MouseController {
down = false;
pos = { x: 0, y: 0 };
onMousemove = e => {
this.pos = { x: e.clientX, y: e.clientY };
this.host.requestUpdate();
};
onMousedown = e => {
this.down = true;
this.host.requestUpdate();
};
onMouseup = e => {
this.down = false;
this.host.requestUpdate();
};
constructor(host) {
this.host = host;
host.addController(this);
}
hostConnected() {
window.addEventListener('mousemove', this.onMousemove);
window.addEventListener('mousedown', this.onMousedown);
window.addEventListener('mouseup', this.onMouseup);
}
hostDisconnected() {
window.removeEventListener('mousemove', this.onMousemove);
window.removeEventListener('mousedown', this.onMousedown);
window.removeEventListener('mouseup', this.onMouseup);
}
}
请注意,此模块本身没有任何导入语句。控制器无需捆绑任何依赖项,它们可以像我们这里一样,只是单个模块中的一个类。另请注意我们引用host元素的位置:
- 通过
constructor调用addController()将其注册为元素的控制器之一
- 进入
hostConnected并hostDisconnected运行我们的设置和清理代码
- 在我们的 MouseEvent 处理程序中,调用
host.requestUpdate()以更新宿主元素
这个host.requestUpdate()调用至关重要,它是响应式控制器通知宿主重新渲染的方式。调用它会启动一个异步管道,其中包括对宿主update()方法的调用。欲了解更多详情,请阅读@thepassle对LitElement 生命周期的深入分析。
让我们将 `<controller>` 添加MouseController到我们的元素中,并使用它console.log来观察更新。在 ` <controller>` 中color-picker.js,导入控制器:
import { MouseController } from './mouse-controller.js';
然后将其添加到元素的类中:
mouse = new MouseController(this);
update() {
console.log(this.mouse.pos);
super.update();
}
完整来源
import { ControllerHostMixin } from 'https://unpkg.com/@apollo-elements/mixins@next/controller-host-mixin.js?module';
import { MouseController } from './mouse-controller.js';
const template = document.createElement('template');
template.innerHTML = `
<link rel="stylesheet" href="color-picker.css">
<div id="loupe"></div>
`;
class ColorPicker extends ControllerHostMixin(HTMLElement) {
mouse = new MouseController(this);
constructor() {
super()
this
.attachShadow({ mode: 'open' })
.append(template.content.cloneNode(true));
}
update() {
console.log(this.mouse.pos);
super.update();
}
}
customElements.define('color-picker', ColorPicker);
保存后,当您在屏幕上移动鼠标时,您会在控制台中看到鼠标位置的日志。现在我们可以将MouseController响应式属性集成到我们的宿主元素中了。
连接光标
我们希望#loupe元素能够随鼠标光标移动,并且其背景颜色能够反映光标所在位置的颜色。请update()按如下方式编辑元素的方法,并确保不要忘记super.update()调用:
update() {
const x = this.mouse.pos.x - this.clientLeft;
const y = this.mouse.pos.y - this.clientTop;
if (x > this.clientWidth || y > this.clientHeight) return;
const hue = Math.floor((x / this.clientWidth) * 360);
const saturation = 100 - Math.floor((y / this.clientHeight) * 100);
this.style.setProperty('--x', `${x}px`);
this.style.setProperty('--y', `${y}px`);
this.style.setProperty('--hue', hue);
this.style.setProperty('--saturation', `${saturation}%`);
super.update();
}
简而言之,我们从控制器获取鼠标位置,将其与元素的边界矩形进行比较。如果两者相交,则设置 `<div>` --x、 `<span>` --y、` --hue<span>` 和`<span> --saturation` 自定义 CSS 属性。这些属性控制着元素的 `<div>`transform和background`<span>` 属性#loupe。保存文件,尽情欣赏吧!
射击事件
好了,我们已经完成了大部分工作,剩下的就是与外部世界通信了。我们将使用浏览器的内置消息通道来实现这一点。首先,我们定义一个#pick()触发自定义pick事件的私有方法,并在元素中添加一个color属性来保存最近选择的颜色。
color = '';
#pick() {
this.color = getComputedStyle(this.loupe).getPropertyValue('background-color');
this.dispatchEvent(new CustomEvent('pick'));
}
让我们监听元素中的点击事件,并触发拾取事件。
constructor() {
super()
this
.attachShadow({ mode: 'open' })
.append(template.content.cloneNode(true));
this.addEventListener('click', () => this.#pick());
}
通过更改放大镜的边框颜色来添加一些用户反馈:
#loupe {
/* ... */
transition: border-color 0.1s ease-in-out;
}
为了允许用户通过鼠标按下来拖动选择器,我们将在更新函数中,也就是在调用 super 函数之前,添加一些条件:
this.style.setProperty('--loupe-border-color', this.mouse.down ? 'white' : 'black');
if (this.mouse.down)
this.#pick();
完整来源
import { ControllerHostMixin } from 'https://unpkg.com/@apollo-elements/mixins@next/controller-host-mixin.js?module';
import { MouseController } from './mouse-controller.js';
const template = document.createElement('template');
template.innerHTML = `
<link rel="stylesheet" href="color-picker.css">
<div id="loupe"></div>
`;
class ColorPicker extends ControllerHostMixin(HTMLElement) {
mouse = new MouseController(this);
constructor() {
super()
this
.attachShadow({ mode: 'open' })
.append(template.content.cloneNode(true));
this.addEventListener('click', () => this.#pick());
}
update() {
const x = this.mouse.pos.x - this.clientLeft;
const y = this.mouse.pos.y - this.clientTop;
if (x > this.clientWidth || y > this.clientHeight) return;
const hue = Math.floor((x / this.clientWidth) * 360);
const saturation = 100 - Math.floor((y / this.clientHeight) * 100);
this.style.setProperty('--x', `${x}px`);
this.style.setProperty('--y', `${y}px`);
this.style.setProperty('--hue', hue);
this.style.setProperty('--saturation', `${saturation}%`);
this.style.setProperty('--loupe-border-color', this.mouse.down ? 'white' : 'black');
if (this.mouse.down)
this.#pick();
super.update();
}
#pick() {
this.color = getComputedStyle(this.loupe).getPropertyValue('background-color');
this.dispatchEvent(new CustomEvent('pick'));
}
}
customElements.define('color-picker', ColorPicker);
无障碍
作为工程师,我们应该认真履行社会责任。我很惭愧地承认,最初撰写这篇文章时,我并没有把无障碍设计放在心上,但希望这一部分能有所改进。
让我们为元素添加屏幕阅读器辅助功能。首先,给loupediv添加buttonrole 和 aria-label 属性。我们<button>也可以使用带有视觉隐藏文本内容的 .div 标签,但由于我们已经按照想要的方式设置了样式,我认为使用 .div 标签是可以接受的role="button"。
我们还要添加一个<div role="alert">符号,用来宣布我们选择的颜色。
<link rel="stylesheet" href="color-picker.css">
<div id="loupe" role="button" aria-label="color picker"></div>
<div id="alert" role="alert" aria-hidden="true"></div>
将提示框的样式设置为“视觉隐藏”,因为我们将设置其文本内容来宣布我们的颜色。
#alert {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
最后,我们需要设置选择颜色时的提示文本。
constructor() {
// ...
this.alert = this.shadowRoot.getElementById('alert');
}
#pick() {
this.color = getComputedStyle(this.loupe).getPropertyValue('background-color');
this.alert.textContent = this.color;
this.alert.setAttribute("aria-hidden", "false");
this.dispatchEvent(new CustomEvent('pick'));
}
好了,屏幕阅读器现在会播报所选颜色。
使用我们的颜色选择器
自定义元素完成后,我们通过监听事件将其连接到文档pick。编辑index.html并添加一个<output>元素来显示我们选择的颜色,以及一个用于监听pick事件的内联脚本。我们还要添加一些全局样式style.css:
<color-picker></color-picker>
<output></output>
<script>
document
.querySelector('color-picker')
.addEventListener('pick', event => {
document
.querySelector('output')
.style
.setProperty('background-color', event.target.color);
});
</script>
output {
display: block;
width: 400px;
height: 120px;
margin-top: 12px;
}
后续步骤
好了,大功告成!我们不仅实现了上述所有目标,还额外完成了一些其他功能。您可以在 Glitch 上体验一下实际示例:
您还可以通过查看 GitHub 上的提交历史记录来了解具体步骤:
你觉得这个设计可以改进吗?这里有一些建议,希望能给你一些启发:
- 以十六进制、HSL 或 RGB 格式显示所选颜色。
- 在弹出菜单中使用选择器
- 添加亮度滑块
- 实施 WCAG 对比检查
- 使用其他颜色空间
- 始终将放大镜置于颜色选择器区域内。
- 为光标添加动画效果
- 构建一个环绕图形元素的放大镜元素
- 优化运行时性能或软件包大小
- 如果知道应用程序中任意多个组件都会使用 MouseController,你会如何重写它?
请在评论区展示你的作品。如果你正在寻找一个可用于生产环境的颜色选择器元素,请查看@webpadawan的作品<vanilla-colorful>。
脚注
继承的风格
虽然 Shadow DOM 提供了强大的封装性,但继承的 CSS 属性能够“穿透”阴影边界,因此诸如 `<div>`、`<span>` 和任何 CSS 自定义属性之类的东西color可以font-family深入到我们的阴影根部并设置我们私有的 Shadow DOM 的样式。
文章来源:https://dev.to/open-wc/let-s-build-a-colour-picker-web-component-2j3n