我们中有一些人已经从事 Web 开发工作超过几年,可能已经使用过不止一个 JavaScript 框架编写代码。有了所有现有的选择——React、Svelte、Vue、Angular、Solid——这是不可避免的。我们在跨框架工作时不得不处理的一件更令人沮丧的事情是重新创建所有这些低级 UI 组件:按钮、标签、下拉菜单等。特别令人沮丧的是,我们通常会在一个框架中定义它们,比如 React,但如果我们想在 Svelte 中构建一些东西,就需要重新编写它们。或者 Vue。或者 Solid。等等。
如果我们能以与框架无关的方式定义一次这些低级 UI 组件,然后在框架之间重复使用它们,那不是更好吗?当然可以!而且我们可以做到;Web 组件就是途径。这篇文章将向您展示如何做到这一点。
截至目前,Web 组件的 SSR 故事还略显不足。声明式影子 DOM (DSD) 是 Web 组件的服务器端渲染方式,但截至撰写本文时,它尚未与您最喜欢的应用程序框架(如 Next、Remix 或 SvelteKit)集成。如果这是您的一个要求,请务必查看 DSD 的最新状态。但除此之外,如果您没有使用 SSR,请继续阅读。
首先,一些上下文
Web 组件本质上是您自己定义的 HTML 元素,比如 <yummy-pizza>
或其他任何元素,从头开始构建。它们在这里的 CSS-Tricks 中都有介绍(包括 Caleb Williams 的一篇详尽的系列文章 和 John Rhea 的一篇),但我们将简要介绍一下这个过程。本质上,您定义一个 JavaScript 类,从 HTMLElement
继承它,然后定义 Web 组件具有的任何属性、属性和样式,当然还有它最终将呈现给用户的内容。
能够定义不受任何特定组件约束的自定义 HTML 元素令人兴奋。但这种自由也是一种限制。独立于任何 JavaScript 框架意味着您实际上无法与这些 JavaScript 框架交互。想象一下一个 React 组件,它获取一些数据,然后渲染一些其他 React 组件,并将数据传递过去。这对于 Web 组件来说实际上并不可行,因为 Web 组件不知道如何渲染 React 组件。
Web 组件在作为叶子组件方面尤其擅长。叶子组件 是组件树中最后一个要渲染的组件。这些组件接收一些道具,并渲染一些UI。这些不是位于您的组件树中间的组件,它们传递数据、设置上下文等——它们只是纯粹的UI 片段,无论哪个 JavaScript 框架为应用程序的其余部分提供支持,它们看起来都一样。
我们正在构建的 Web 组件
与其构建一些无聊的(且常见的)东西,比如按钮,不如构建一些稍微不同的东西。在我的 上一篇文章 中,我们探讨了如何使用模糊图像预览来防止内容重排,并在图像加载时为用户提供良好的 UI。我们探讨了对图像的模糊降级版本进行 Base64 编码,并在 UI 中显示它,同时加载真实图像。我们还探讨了如何使用名为 Blurhash 的工具生成极其紧凑的模糊预览。
那篇文章向您展示了如何生成这些预览并在 React 项目中使用它们。这篇文章将向您展示如何从 Web 组件中使用这些预览,以便它们可以被任何 JavaScript 框架使用。
但是,在我们跑步之前,我们先要走几步,所以我们先来介绍一些微不足道且愚蠢的东西,以了解 Web 组件的工作原理。
本文中的所有内容都将构建没有任何工具的普通 Web 组件。这意味着代码将包含一些样板代码,但应该相对容易理解。像 Lit 或 Stencil 这样的工具是为构建 Web 组件而设计的,可以用来消除大部分样板代码。我敦促您查看它们!但对于这篇文章,我更愿意为了不用引入和讲解另一个依赖项而使用一些样板代码。
一个简单的计数器组件
让我们构建 JavaScript 组件的经典“Hello World”:一个计数器。我们将渲染一个值,以及一个递增该值的按钮。简单而乏味,但它将让我们看看最简单的 Web 组件。
为了构建一个 Web 组件,第一步是创建一个 JavaScript 类,它从 HTMLElement
继承。
class Counter extends HTMLElement {}
最后一步是注册 Web 组件,但前提是我们还没有注册它。
if (!customElements.get("counter-wc")) {
customElements.define("counter-wc", Counter);
}
当然,还要渲染它。
<counter-wc></counter-wc>
介于两者之间的所有内容都是我们让 Web 组件做任何我们想做的事情。一个常见的生命周期方法是 connectedCallback
,当我们的 Web 组件被添加到 DOM 时,它会触发。我们可以使用该方法来渲染我们想要的内容。请记住,这是一个从 HTMLElement
继承的 JS 类,这意味着我们的 this
值就是 Web 组件元素本身,它具有我们已经知道和喜爱的所有正常 DOM 操作方法。
最简单的情况下,我们可以这样做
class Counter extends HTMLElement {
connectedCallback() {
this.innerHTML = "<div style='color: green'>Hey</div>";
}
}
if (!customElements.get("counter-wc")) {
customElements.define("counter-wc", Counter);
}
…这样可以正常工作。

添加真实内容
让我们添加一些有用的交互式内容。我们需要一个 <span>
来保存当前的数字值,以及一个 <button>
来递增计数器。现在,我们将在这个内容中创建它,并在 Web 组件实际位于 DOM 中时附加它。
constructor() {
super();
const container = document.createElement('div');
this.valSpan = document.createElement('span');
const increment = document.createElement('button');
increment.innerText = 'Increment';
increment.addEventListener('click', () => {
this.#value = this.#currentValue + 1;
});
container.appendChild(this.valSpan);
container.appendChild(document.createElement('br'));
container.appendChild(increment);
this.container = container;
}
connectedCallback() {
this.appendChild(this.container);
this.update();
}
如果您真的讨厌手动 DOM 创建,请记住您可以设置 innerHTML
,甚至可以创建一个模板元素作为 Web 组件类的静态属性,克隆它,并为新的 Web 组件实例插入内容。我可能没有想到其他一些选项,或者您可以始终使用像 Lit 或 Stencil 这样的 Web 组件框架。但对于这篇文章,我们将继续保持简单。
继续前进,我们需要一个名为 value
的可设置 JavaScript 类属性。
#currentValue = 0;
set #value(val) {
this.#currentValue = val;
this.update();
}
它只是一个带有设置器的标准类属性,以及一个用于保存值的第二个属性。一个有趣的变化是,我正在对这些值使用私有 JavaScript 类属性语法。这意味着 Web 组件外部的任何人都无法触碰这些值。这是标准的 JavaScript ,在所有现代浏览器中都受支持,所以不要害怕使用它。
或者,如果您愿意,可以将其称为 _value
。最后,我们的 update
方法
update() {
this.valSpan.innerText = this.#currentValue;
}
它可以工作!

显然,这不是您想要大规模维护的代码。如果您想更仔细地查看,这里有一个完整的 工作示例。正如我所说,像 Lit 和 Stencil 这样的工具是为简化此过程而设计的。
添加更多功能
这篇文章不是对 Web 组件的深入探讨。我们不会涵盖所有 API 和生命周期;我们甚至不会涵盖 影子根或插槽。关于这些主题的文章数不胜数。我的目标是提供一个足够好的介绍,激发一些兴趣,以及一些关于如何实际使用 Web 组件与您已经知道和喜爱的流行 JavaScript 框架的实用指南。
为此,让我们增强一下我们的计数器 Web 组件。让我们让它接受一个 color
属性,来控制显示的值的颜色。让我们还让它接受一个 increment
属性,以便这个 Web 组件的使用者可以一次递增 2、3、4。为了驱动这些状态更改,让我们在 Svelte 沙盒中使用我们的新计数器——我们稍后会讲解 React。
我们将从与之前相同的 Web 组件开始,并添加一个颜色属性。为了配置我们的 Web 组件以接受和响应属性,我们添加了一个静态 observedAttributes
属性,它返回我们的 Web 组件监听的属性。
static observedAttributes = ["color"];
有了它,我们就可以添加一个 attributeChangedCallback
生命周期方法,当 observedAttributes
中列出的任何属性被设置或更新时,它就会运行。
attributeChangedCallback(name, oldValue, newValue) {
if (name === "color") {
this.update();
}
}
现在,我们更新 update
方法以实际使用它。
update() {
this.valSpan.innerText = this._currentValue;
this.valSpan.style.color = this.getAttribute("color") || "black";
}
最后,让我们添加 increment
属性。
increment = 1;
简单而谦逊。
在 Svelte 中使用计数器组件
让我们使用我们刚刚创建的东西。 我们将进入我们的 Svelte 应用组件并添加类似这样的内容
<script>
let color = "red";
</script>
<style>
main {
text-align: center;
}
</style>
<main>
<select bind:value={color}>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
<counter-wc color={color}></counter-wc>
</main>
它可以正常工作! 我们的计数器会渲染、递增,并且下拉菜单会更新颜色。 正如您所看到的,我们在 Svelte 模板中渲染 color 属性,并且当值发生更改时,Svelte 会处理调用我们底层 Web 组件实例上的 `setAttribute` 的工作。 这没什么特别的:这与它对任何 HTML 元素的属性所做的事情相同。
对 `increment` 道具来说事情变得有点有趣。 这不是我们 Web 组件上的属性; 它是 Web 组件类的道具。 这意味着它需要设置在 Web 组件的实例上。 请耐心等待,因为稍后事情会变得简单得多。
首先,我们将在 Svelte 组件中添加一些变量
let increment = 1;
let wcInstance;
我们强大的计数器组件将允许您递增 1 或 2
<button on:click={() => increment = 1}>Increment 1</button>
<button on:click={() => increment = 2}>Increment 2</button>
但是,*理论上*,我们需要获取 Web 组件的实际实例。 这与我们每次使用 React 添加 `ref` 时所做的相同。 使用 Svelte,它只是一个简单的 `bind:this` 指令
<counter-wc bind:this={wcInstance} color={color}></counter-wc>
现在,在我们的 Svelte 模板中,我们监听组件的 increment 变量的变化并设置底层 Web 组件属性。
$: {
if (wcInstance) {
wcInstance.increment = increment;
}
}
您可以在此直播演示中测试它。
显然我们不想对每个需要管理的 Web 组件或道具都这样做。 如果我们能直接在我们的 Web 组件上设置 `increment`,就像我们通常对组件道具所做的那样,并且让它*正常工作*,那不是很好吗? 换句话说,如果我们能删除所有 `wcInstance` 的用法并改用更简单的代码,那会很棒
<counter-wc increment={increment} color={color}></counter-wc>
事实证明我们可以做到。 此代码可以正常工作; Svelte 会为我们处理所有那些工作。 在此演示中查看。 这是几乎所有 JavaScript 框架的标准行为。
那么为什么要向您展示手动设置 Web 组件道具的方式呢? 有两个原因:了解这些工作原理非常有用,而且我之前说过这对“几乎所有”JavaScript 框架都适用。 但是有一个框架,令人恼火的是,它不支持我们刚刚看到的 Web 组件道具设置。
React 是一个不同的野兽

React。 地球上最流行的 JavaScript 框架不支持与 Web 组件的基本互操作性。 这是一个众所周知的问题,它只存在于 React 中。 有趣的是,这实际上在 React 的实验分支中得到了修复,但由于某种原因没有合并到版本 18 中。 也就是说,我们仍然可以跟踪它的进度。 您可以在此直播演示中自己尝试一下。
当然,解决方案是使用 `ref`,获取 Web 组件实例,并在该值发生更改时手动设置 `increment`。 它看起来像这样
import React, { useState, useRef, useEffect } from 'react';
import './counter-wc';
export default function App() {
const [increment, setIncrement] = useState(1);
const [color, setColor] = useState('red');
const wcRef = useRef(null);
useEffect(() => {
wcRef.current.increment = increment;
}, [increment]);
return (
<div>
<div className="increment-container">
<button onClick={() => setIncrement(1)}>Increment by 1</button>
<button onClick={() => setIncrement(2)}>Increment by 2</button>
</div>
<select value={color} onChange={(e) => setColor(e.target.value)}>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
<counter-wc ref={wcRef} increment={increment} color={color}></counter-wc>
</div>
);
}
正如我们所讨论的,对每个 Web 组件属性都手动编写此代码根本不可扩展。 但并非一切都已丢失,因为我们有几个选择。
选项 1:在所有地方使用属性
我们有属性。 如果您单击了上面的 React 演示,`increment` 道具不起作用,但颜色已正确更改。 我们不能用属性编写所有代码吗? 可惜不行。 属性值只能是字符串。 这在这里足够了,我们可以用这种方法走得相当远。 像 `increment` 这样的数字可以转换为字符串,也可以从字符串转换为数字。 我们甚至可以将对象 JSON 序列化/反序列化。 但最终我们将需要将函数传递给 Web 组件,到那时我们将没有选择。
选项 2:封装它
有一句老话说,你可以通过增加一层间接性来解决计算机科学中的任何问题(除了间接性层数过多的问题)。 设置这些道具的代码非常可预测且简单。 如果我们将其隐藏在一个库中会怎么样? Lit 背后的聪明人提供了一个解决方案。 此库在您提供 Web 组件并列出它需要的属性后,会为您创建一个新的 React 组件。 虽然很聪明,但我不喜欢这种方法。
与其让 Web 组件与手动创建的 React 组件进行一对一映射,我更喜欢只有一个 React 组件,我们将 Web 组件的*标签名称*(在本例中为 `counter-wc`)传递给它——以及所有属性和属性——并让此组件渲染我们的 Web 组件,添加 `ref`,然后弄清楚什么是道具,什么是属性。 我认为这是理想的解决方案。 我不知道有哪个库能做到这一点,但它应该很容易创建。 让我们试一试!
这是我们正在寻找的*用法*
<WcWrapper wcTag="counter-wc" increment={increment} color={color} />
wcTag
是 Web 组件标签名称; 其余的是我们要传递的属性和属性。
以下是我实现的方式
import React, { createElement, useRef, useLayoutEffect, memo } from 'react';
const _WcWrapper = (props) => {
const { wcTag, children, ...restProps } = props;
const wcRef = useRef(null);
useLayoutEffect(() => {
const wc = wcRef.current;
for (const [key, value] of Object.entries(restProps)) {
if (key in wc) {
if (wc[key] !== value) {
wc[key] = value;
}
} else {
if (wc.getAttribute(key) !== value) {
wc.setAttribute(key, value);
}
}
}
});
return createElement(wcTag, { ref: wcRef });
};
export const WcWrapper = memo(_WcWrapper);
最有趣的代码行在最后
return createElement(wcTag, { ref: wcRef });
这就是我们在 React 中使用动态名称创建元素的方式。 事实上,这就是 React 通常将 JSX 转换成的方式。 我们所有的 div 都被转换成 `createElement("div")` 调用。 我们通常不需要直接调用此 API,但当我们需要它时,它就在那里。
除此之外,我们想运行一个布局效果并遍历我们传递给组件的所有道具。 我们遍历所有道具并检查它是否是一个属性,通过 `in` 检查来检查 Web 组件实例对象及其原型链,这将捕获最终出现在类原型上的任何 getter/setter。 如果不存在这样的属性,则假定它是一个属性。 无论哪种情况,只有当值真正发生变化时,我们才会设置它。
如果您想知道为什么我们使用 `useLayoutEffect` 而不是 `useEffect`,那是因为我们想在内容渲染之前立即运行这些更新。 此外,请注意,我们的 `useLayoutEffect` 没有依赖项数组; 这意味着我们希望在*每次渲染*时运行此更新。 这可能很冒险,因为 React 往往会*非常频繁地*重新渲染。 我通过将整个内容包装在 `React.memo` 中来改善这种情况。 这本质上是 `React.PureComponent` 的现代版本,这意味着组件只有在任何实际道具发生变化时才会重新渲染——它通过简单的相等性检查来检查是否发生了这种情况。
这里唯一的风险是,如果您正在传递一个您正在直接修改而不重新分配的对象道具,那么您将看不到更新。 但这强烈建议不要这样做,尤其是在 React 社区中,所以我不会担心。
在继续之前,我想提一下最后一件事情。 您可能不喜欢用法的形式。 同样,此组件的使用方式如下
<WcWrapper wcTag="counter-wc" increment={increment} color={color} />
具体来说,您可能不喜欢将 Web 组件标签名称传递给 `<WcWrapper>` 组件,而更喜欢上面提到的 `@lit-labs/react` 包,它为每个 Web 组件创建一个新的独立 React 组件。 这完全是公平的,我鼓励您使用您最习惯的方法。 但对我来说,这种方法的一个优势是它很容易*删除*。 如果奇迹般地,React 明天将正确的 Web 组件处理从他们的实验分支合并到 `main` 中,您可以将上面的代码从以下代码更改为以下代码
<WcWrapper wcTag="counter-wc" increment={increment} color={color} />
……变成这样
<counter-wc ref={wcRef} increment={increment} color={color} />
您甚至可以编写一个单一的代码修改来完成所有这些操作,然后完全删除 `<WcWrapper>`。 实际上,别说了:使用正则表达式进行全局搜索和替换可能就能奏效。
实现
我知道,似乎经历了一段旅程才走到这里。 如果你还记得,我们最初的目标是将我们在我上一篇文章中查看的图像预览代码移动到 Web 组件中,以便它可以在任何 JavaScript 框架中使用。 React 缺乏适当的互操作性给混合中增加了许多细节。 但现在我们已经对如何创建一个 Web 组件并使用它有了很好的了解,那么实现将几乎是乏味的。
我将在下面放出整个 Web 组件,并说明一些有趣的点。 如果你想查看它的实际效果,这里有一个有效的演示。 它将在我的三本关于我最喜欢的编程语言的书之间切换。 每次每本书的 URL 都会是唯一的,因此您可以看到预览,尽管您可能需要在 DevTools 网络选项卡中限制速率才能真正看到事情发生。
查看完整代码
class BookCover extends HTMLElement {
static observedAttributes = ['url'];
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'url') {
this.createMainImage(newValue);
}
}
set preview(val) {
this.previewEl = this.createPreview(val);
this.render();
}
createPreview(val) {
if (typeof val === 'string') {
return base64Preview(val);
} else {
return blurHashPreview(val);
}
}
createMainImage(url) {
this.loaded = false;
const img = document.createElement('img');
img.alt = 'Book cover';
img.addEventListener('load', () => {
if (img === this.imageEl) {
this.loaded = true;
this.render();
}
});
img.src = url;
this.imageEl = img;
}
connectedCallback() {
this.render();
}
render() {
const elementMaybe = this.loaded ? this.imageEl : this.previewEl;
syncSingleChild(this, elementMaybe);
}
}
首先,我们注册我们感兴趣的属性,并在它发生变化时做出反应
static observedAttributes = ['url'];
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'url') {
this.createMainImage(newValue);
}
}
这会导致创建我们的图像组件,该组件仅在加载时显示
createMainImage(url) {
this.loaded = false;
const img = document.createElement('img');
img.alt = 'Book cover';
img.addEventListener('load', () => {
if (img === this.imageEl) {
this.loaded = true;
this.render();
}
});
img.src = url;
this.imageEl = img;
}
接下来是我们的预览属性,它可以是我们的 base64 预览字符串,也可以是我们的 `blurhash` 包
set preview(val) {
this.previewEl = this.createPreview(val);
this.render();
}
createPreview(val) {
if (typeof val === 'string') {
return base64Preview(val);
} else {
return blurHashPreview(val);
}
}
这将取决于我们需要哪个辅助函数
function base64Preview(val) {
const img = document.createElement('img');
img.src = val;
return img;
}
function blurHashPreview(preview) {
const canvasEl = document.createElement('canvas');
const { w: width, h: height } = preview;
canvasEl.width = width;
canvasEl.height = height;
const pixels = decode(preview.blurhash, width, height);
const ctx = canvasEl.getContext('2d');
const imageData = ctx.createImageData(width, height);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
return canvasEl;
}
最后,我们的 `render` 方法
connectedCallback() {
this.render();
}
render() {
const elementMaybe = this.loaded ? this.imageEl : this.previewEl;
syncSingleChild(this, elementMaybe);
}
以及一些辅助方法将所有内容联系在一起
export function syncSingleChild(container, child) {
const currentChild = container.firstElementChild;
if (currentChild !== child) {
clearContainer(container);
if (child) {
container.appendChild(child);
}
}
}
export function clearContainer(el) {
let child;
while ((child = el.firstElementChild)) {
el.removeChild(child);
}
}
这比我们在框架中构建时需要的样板代码多一些,但好处是我们可以将它重新用于我们想要的任何框架——尽管 React 目前需要一个包装器,正如我们所讨论的。
零零碎碎
我已经提到了 Lit 的 React 包装器。但如果你发现自己使用 Stencil,它实际上支持一个 专门针对 React 的输出管道。微软的优秀团队也 创建了一些类似于 Lit 包装器的东西,它与 Fast web 组件库相关联。
正如我所说,所有非 React 框架都会为你处理设置 web 组件属性。只需注意一些框架具有特殊的语法。例如,使用 Solid.js,<your-wc value={12}>
总是假设 value
是一个属性,你可以用 attr
前缀来覆盖它,比如 <your-wc attr:value={12}>
。
总结
Web 组件是 Web 开发领域中一个有趣且经常被忽视的部分。它们可以通过管理 UI 或“叶子”组件来帮助减少对任何单个 JavaScript 框架的依赖。虽然将它们创建为 web 组件(而不是 Svelte 或 React 组件)可能不像 Svelte 或 React 组件那样符合人体工程学,但好处是它们将具有广泛的可重用性。
很高兴看到更多关于 Web 组件的内容!我认为你可能有兴趣看看我们在微软为 Web 组件所做的事情。我们有一个名为 FAST 的小项目,我们用它来构建 Fluent UI,并且现在已经向超过十亿用户发布了 Web 组件。我们有员工在微软将 Web 组件与 React、Angular.js、Angular、Ember 等集成在一起。由于集成对我们来说非常重要,我们实际上为每个框架都编写了指南。所以,如果你或你的社区有兴趣了解如何将 Web 组件与各种框架集成,请查看 https://www.fast.design/docs/category/integrations
嘿!我确实知道 Fast,我甚至在文章结尾为你的 React 集成项目提供了简短的介绍。看起来是个很棒的项目——干得好!
我创建了一个名为 Ewok 的小型库,它允许你使用 HTML 创建 web 组件,无需任何样板 JS 代码。它旨在与 Alpine JS 自动集成以实现响应性。
https://github.com/Lomacar/Ewok