发布于 2026-01-06 4 阅读
0

让我们构建一个颜色选择器 Web 组件

让我们构建一个颜色选择器 Web 组件

让我们用 HTML、CSS 和少量 JavaScript 代码构建一个颜色选择器 Web 组件。最终,我们将得到一个自定义元素,它:

内容

  1. 先决条件
  2. 设置
  3. 定义我们的元素
  4. 为我们的元素设计样式
  5. 使用响应式控制器跟踪鼠标
  6. 射击事件
  7. 无障碍
  8. 使用我们的颜色选择器
  9. 后续步骤
  10. 脚注

先决条件

为了更好地理解本文,您应该对 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
Enter fullscreen mode Exit fullscreen mode

这些命令会在您的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>
Enter fullscreen mode Exit fullscreen mode

让我们先添加一些初始样式。style.css

color-picker {
  width: 400px;
  height: 400px;
}
Enter fullscreen mode Exit fullscreen mode

由于我们还没有定义该元素,所以屏幕上目前什么也看不到<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);
Enter fullscreen mode Exit fullscreen mode

让我们逐块分析这个文件。

我们首先声明一个<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;
}
Enter fullscreen mode Exit fullscreen mode

我们稍后会详细介绍 CSS 规则(可以跳过这部分)。现在,保存文件,看看页面上的变化。这才像样!现在我们的元素看起来像个颜色选择器了!

Shadow CSS 问答

如果您对 Web 组件不太熟悉,此时您可能会问自己一些问题:

:host

这到底是什么鬼东西:host

CSS:host选择器会获取包含样式表的根元素所在的元素。如果您对此感到困惑,别担心,我们稍后会详细解释。现在,您只需要知道,在这种情况下,它:hostcolor-picker元素本身是同义的。

ID 选择器(例如#loupe

ID选择器?!这难道不是CSS的大忌吗?

层叠样式表中,ID 选择器的优先级极高,这意味着它们会覆盖优先级较低的规则,例如类选择器或元素选择器。在传统的(全局)CSS 中,这很容易导致意想不到的后果。

34

看到“不要在CSS中使用ID选择器”这句话,我感到很震惊。这是真的吗?我发现很多帖子都这么说。

  1. http://mattwilcox.net/archive/entry/id/1054/
  2. http://screwlewse.com/2010/07/dont-use-id-selectors-in-css/
  3. http://oli.jp/2011/ids/

我认为我们可以使用 ID 作为选择器。

我还是想把这件事弄清楚。

虽然我们的样式表并非全局生效,但由于我们是在 `<div>` 标签内而非文档中<link>调用它,因此样式被严格限定在该根元素上。这种作用域限制是由浏览器本身强制执行的,而非某个 JavaScript 库。这意味着我们在 `<div>` 标签内定义的样式不会“泄露”并影响页面其他位置的样式,因此我们使用的选择器可以非常简单。我们甚至可以用一个裸选择器替换该选择器,效果也完全一样。ShadowRootcolor-picker.css#loupediv

影子根封装也意味着我们在模板 HTML 中使用的元素 ID 是私有的。请在浏览器控制台中尝试以下操作:

document.getElementById('loupe');
Enter fullscreen mode Exit fullscreen mode

如果没有 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) {/*...*/}
Enter fullscreen mode Exit fullscreen mode
<json-fetcher src="lemurs.json"></json-fetcher>
<text-fetcher src="othello.txt"></text-fetcher>
Enter fullscreen mode Exit fullscreen mode

但 mixin 有一个局限性——它们与其元素类之间存在“is-a-*”关系。将 mixin 添加到类中意味着结果基类和 mixin 类的组合。由于 mixin 是函数,我们可以使用函数组合来组合它们,但如果组合后的 mixin 覆盖了类成员(例如字段、方法、访问器),则可能会出现问题。

为了解决这个问题,Lit团队最近发布了一种名为“响应式控制器”(Reactive Controllers )的新型“组合原语” ,它表示一种“拥有*”关系。控制器是一个 JavaScript 类,其中包含对宿主元素的引用,该宿主元素必须实现一组称为ReactiveControllerHost接口的特定方法。

简单来说,这意味着你可以编写一个控制器类,并将其添加到任何符合特定条件的元素类中。一个控制器宿主可以拥有多个独立或相互依赖的控制器,一个控制器实例可以拥有一个宿主,控制器之间可以独立地引用共享状态。

如果你熟悉 React Hooks,你可能会发现控制器也遵循类似的模式。不过,Hooks 的缺点在于它们只能与 React 一起使用。

同样,控制器相对于混合的缺点是,它们要求宿主元素类满足某些条件,即:该类必须实现该ReactiveControllerHost接口。

可组合 可重复使用的 可堆叠 独立的
混合料 ⚠️
控制器

不过,与 React 不同的是,控制器可以与来自不同框架的组件或自定义元素类(而非 React 本身)一起使用。通过一些巧妙的粘合代码,LitElement控制器可以与ReactAngularVueHaunted等框架协同工作。

在我的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);
Enter fullscreen mode Exit fullscreen mode

哇,那是什么?我们正在通过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);
  }
}
Enter fullscreen mode Exit fullscreen mode

请注意,此模块本身没有任何导入语句。控制器无需捆绑任何依赖项,它们可以像我们这里一样,只是单个模块中的一个类。另请注意我们引用host元素的位置:

  • 通过constructor调用addController()将其注册为元素的控制器之一
  • 进入hostConnectedhostDisconnected运行我们的设置和清理代码
  • 在我们的 MouseEvent 处理程序中,调用host.requestUpdate()以更新宿主元素

这个host.requestUpdate()调用至关重要,它是响应式控制器通知宿主重新渲染的方式。调用它会启动一个异步管道,其中包括对宿主update()方法的调用。欲了解更多详情,请阅读@thepassleLitElement 生命周期的深入分析。

让我们将 `<controller>` 添加MouseController到我们的元素中,并使用它console.log来观察更新。在 ` <controller>` 中color-picker.js,导入控制器:

import { MouseController } from './mouse-controller.js';
Enter fullscreen mode Exit fullscreen mode

然后将其添加到元素的类中:

mouse = new MouseController(this);

update() {
  console.log(this.mouse.pos);
  super.update();
}
Enter fullscreen mode Exit fullscreen mode

完整来源
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);
Enter fullscreen mode Exit fullscreen mode

保存后,当您在屏幕上移动鼠标时,您会在控制台中看到鼠标位置的日志。现在我们可以将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();
}
Enter fullscreen mode Exit fullscreen mode

简而言之,我们从控制器获取鼠标位置,将其与元素的边界矩形进行比较。如果两者相交,则设置 `<div>` --x、 `<span>` --y、` --hue<span>` 和`<span> --saturation` 自定义 CSS 属性。这些属性控制着元素的 `<div>`transformbackground`<span>` 属性#loupe。保存文件,尽情欣赏吧!

射击事件

好了,我们已经完成了大部分工作,剩下的就是与外部世界通信了。我们将使用浏览器的内置消息通道来实现这一点。首先,我们定义一个#pick()触发自定义pick事件的私有方法,并在元素中添加一个color属性来保存最近选择的颜色。

color = '';

#pick() {
  this.color = getComputedStyle(this.loupe).getPropertyValue('background-color');
  this.dispatchEvent(new CustomEvent('pick'));
}
Enter fullscreen mode Exit fullscreen mode

让我们监听元素中的点击事件,并触发拾取事件。

constructor() {
  super()
  this
    .attachShadow({ mode: 'open' })
    .append(template.content.cloneNode(true));
  this.addEventListener('click', () => this.#pick());
}
Enter fullscreen mode Exit fullscreen mode

通过更改放大镜的边框颜色来添加一些用户反馈:

#loupe {
  /* ... */
  transition: border-color 0.1s ease-in-out;
}
Enter fullscreen mode Exit fullscreen mode

为了允许用户通过鼠标按下来拖动选择器,我们将在更新函数中,也就是在调用 super 函数之前,添加一些条件:

this.style.setProperty('--loupe-border-color', this.mouse.down ? 'white' : 'black');
if (this.mouse.down)
  this.#pick();
Enter fullscreen mode Exit fullscreen mode

完整来源
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);
Enter fullscreen mode Exit fullscreen mode

无障碍

作为工程师,我们应该认真履行社会责任。我很惭愧地承认,最初撰写这篇文章时,我并没有把无障碍设计放在心上,但希望这一部分能有所改进。

让我们为元素添加屏幕阅读器辅助功能。首先,给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>
Enter fullscreen mode Exit fullscreen mode

将提示框的样式设置为“视觉隐藏”,因为我们将设置其文本内容来宣布我们的颜色。

#alert {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}
Enter fullscreen mode Exit fullscreen mode

最后,我们需要设置选择颜色时的提示文本。

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'));
}
Enter fullscreen mode Exit fullscreen mode

好了,屏幕阅读器现在会播报所选颜色。

使用我们的颜色选择器

自定义元素完成后,我们通过监听事件将其连接到文档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>
Enter fullscreen mode Exit fullscreen mode
output {
  display: block;
  width: 400px;
  height: 120px;
  margin-top: 12px;
}
Enter fullscreen mode Exit fullscreen mode

后续步骤

好了,大功告成!我们不仅实现了上述所有目标,还额外完成了一些其他功能。您可以在 Glitch 上体验一下实际示例:

您还可以通过查看 GitHub 上的提交历史记录来了解具体步骤:

GitHub 标志 bennypowers / controller-host-color-picker

带有响应式控制器的颜色选择器 Web 组件

让我们用 HTML、CSS 和少量 JavaScript 代码构建一个颜色选择器 Web 组件。最终,我们将得到一个自定义元素,它:

内容

  1. 先决条件
  2. 设置
  3. 定义我们的元素
  4. 为我们的元素设计样式
  1. 使用响应式控制器跟踪鼠标
  1. 射击事件
  2. 无障碍
  3. 使用我们的颜色选择器
  4. 后续步骤
  5. 脚注

先决条件

为了更好地理解本文,您应该对 HTML、CSS 和 JavaScript 有一定的了解,包括:

你觉得这个设计可以改进吗?这里有一些建议,希望能给你一些启发:

  • 以十六进制、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