构建可互操作的 Web 组件,甚至可以与 React PlatoBlockchain 数据智能一起使用。 垂直搜索。 哎。

构建甚至可以与 React 一起使用的可互操作的 Web 组件

我们这些从事 Web 开发超过几年的人可能已经使用多个 JavaScript 框架编写过代码。 有了所有的选择——React、Svelte、Vue、Angular、Solid——这几乎是不可避免的。 在跨框架工作时,我们必须处理的更令人沮丧的事情之一是重新创建所有那些低级 UI 组件:按钮、选项卡、下拉菜单等。特别令人沮丧的是,我们通常会将它们定义在一个框架中,比如说 React,但是如果我们想在 Svelte 中构建一些东西,就需要重写它们。 或 Vue。 或固体。 等等。

如果我们可以一次以与框架无关的方式定义这些低级 UI 组件,然后在框架之间重用它们,不是更好吗? 当然会! 我们可以; Web 组件是一种方式。 这篇文章将告诉你如何。

到目前为止,Web 组件的 SSR 故事还有些欠缺。 声明性影子 DOM (DSD) 是 Web 组件在服务器端呈现的方式,但在撰写本文时,它尚未与您喜欢的应用程序框架(如 Next、Remix 或 SvelteKit)集成。 如果这是您的要求,请务必检查 DSD 的最新状态。 但除此之外,如果 SSR 不是您正在使用的东西,请继续阅读。

首先,一些背景

Web 组件本质上是您自己定义的 HTML 元素,例如 <yummy-pizza> 或者其他什么,从头开始。 它们在 CSS-Tricks 中都有介绍(包括 Caleb Williams 的一个广泛的系列约翰·瑞亚的一个) 但我们将简要介绍该过程。 本质上,你定义了一个 JavaScript 类,继承自 HTMLElement,然后定义 Web 组件具有的任何属性、属性和样式,当然还有它最终将呈现给用户的标记。

能够定义不绑定到任何特定组件的自定义 HTML 元素是令人兴奋的。 但这种自由也是一种限制。 独立于任何 JavaScript 框架存在意味着您无法真正与那些 JavaScript 框架进行交互。 想想一个 React 组件,它获取一些数据然后渲染一些数据 other React 组件,传递数据。 这实际上不能作为 Web 组件工作,因为 Web 组件不知道如何渲染 React 组件。

Web 组件尤其擅长于 叶组件. 叶组件 是要在组件树中呈现的最后一件事。 这些是接收一些道具并渲染一些的组件 UI。 这些是 不能 位于组件树中间的组件,传递数据,设置上下文等 - 只是纯粹的部分 UI 无论是哪个 JavaScript 框架为应用程序的其余部分提供动力,这看起来都是一样的。

我们正在构建的 Web 组件

与其构建一些无聊(和常见)的东西,比如一个按钮,让我们构建一些不同的东西。 在我的 加载后 我们研究了使用模糊图像预览来防止内容重排,并在我们的图像加载时为用户提供一个不错的 UI。 我们查看了 base64 编码我们图像的模糊、降级版本,并在加载真实图像时在我们的 UI 中显示它。 我们还研究了使用名为 模糊哈希.

那篇文章向您展示了如何生成这些预览并在 React 项目中使用它们。 这篇文章将向您展示如何使用 Web 组件中的这些预览,以便它们可以被 任何 JavaScript 框架。

但是我们需要先走路才能跑步,所以我们先通过一些琐碎而愚蠢的事情来了解 Web 组件是如何工作的。

这篇文章中的所有内容都将在没有任何工具的情况下构建香草 Web 组件。 这意味着代码会有一些样板,但应该相对容易理解。 像这样的工具 or 蜡纸 专为构建 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 时触发。 我们可以使用该方法来渲染我们想要的任何内容。 请记住,这是一个继承自的 JS 类 HTMLElement,这意味着我们的 this value 是 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);
}

......这将工作得很好。

绿色的“嘿”字。
构建甚至可以与 React 一起使用的可互操作的 Web 组件

添加真实内容

让我们添加一些有用的交互式内容。 我们需要一个 <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 组件实例插入内容。 可能还有其他一些我没有想到的选项,或者您始终可以使用 Web 组件框架,例如 or 蜡纸. 但是对于这篇文章,我们将继续保持简单。

继续前进,我们需要一个可设置的 JavaScript 类属性,名为 value

#currentValue = 0; set #value(val) { this.#currentValue = val; this.update();
}

它只是一个带有 setter 的标准类属性,以及用于保存值的第二个属性。 一个有趣的转折是我对这些值使用私有 JavaScript 类属性语法。 这意味着我们的 Web 组件之外的任何人都无法触及这些值。 这是标准的 JavaScript 所有现代浏览器都支持,所以不要害怕使用它。

或者随意调用它 _value 如果你更喜欢。 最后,我们的 update 方法:

update() { this.valSpan.innerText = this.#currentValue;
}

有用!

计数器 Web 组件。
构建甚至可以与 React 一起使用的可互操作的 Web 组件

显然,这不是您想要大规模维护的代码。 这里有一个完整的 工作示例 如果你想仔细看看。 正如我所说,像 Lit 和 Stencil 这样的工具旨在让这一切变得更简单。

添加更多功能

这篇文章不是对 Web 组件的深入探讨。 我们不会涵盖所有的 API 和生命周期; 我们甚至不会覆盖 影子根或槽. 关于这些主题的内容无穷无尽。 我的目标是提供一个足够体面的介绍来激发一些兴趣,以及一些有用的指导 运用 具有您已经知道和喜爱的流行 JavaScript 框架的 Web 组件。

为此,让我们稍微增强一下计数器 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 模板中渲染颜色属性,当值更改时,Svelte 处理调用的繁琐工作 setAttribute 在我们的底层 Web 组件实例上。 这里没有什么特别的:这和它已经为 任何 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 组件的实际实例。 每当我们添加一个 ref 与反应。 使用 Svelte,这很简单 bind:this 指示:

<counter-wc bind:this={wcInstance} color={color}></counter-wc>

现在,在我们的 Svelte 模板中,我们监听组件增量变量的变化并设置底层 Web 组件属性。

$: { if (wcInstance) { wcInstance.increment = increment; }
}

你可以测试一下 在这个现场演示中.

我们显然不想对我们需要管理的每个 Web 组件或道具都这样做。 如果我们可以设置,那不是很好吗 increment 就在我们的 Web 组件上,在标记中,就像我们通常对组件道具所做的那样,并且拥有它,你知道, 只是工作? 换句话说,如果我们可以删除所有的用法就好了 wcInstance 并改用这个更简单的代码:

<counter-wc increment={increment} color={color}></counter-wc>

事实证明我们可以。 此代码有效; Svelte 为我们处理所有的跑腿工作。 在这个演示中查看它。 这是几乎所有 JavaScript 框架的标准行为。

那我为什么要给你看手动设置 web 组件的 prop 的方法呢? 两个原因:了解这些东西是如何工作的很有用,而且,刚才我说过这适用于“几乎”所有的 JavaScript 框架。 但是,令人抓狂的是,有一个框架不支持我们刚刚看到的 Web 组件属性设置。

React 是一个不同的野兽

构建可互操作的 Web 组件,甚至可以与 React PlatoBlockchain 数据智能一起使用。 垂直搜索。 哎。
构建甚至可以与 React 一起使用的可互操作的 Web 组件

做出反应。 这个星球上最流行的 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 组件,我更喜欢的是 一种 我们传递我们的 web 组件的 React 组件 标签名称 到 (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 check 检查 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} />

您甚至可以编写一个 codemod 在任何地方执行此操作,然后删除 <WcWrapper> 共。 实际上,从头开始:使用 RegEx 进行全局搜索和替换可能会起作用。

实施

我知道,似乎需要一段旅程才能到达这里。 如果你还记得,我们最初的目标是获取我们在我的 加载后,并将其移动到 Web 组件中,以便可以在任何 JavaScript 框架中使用。 React 缺乏适当的互操作性为混合添加了很多细节。 但是现在我们已经很好地掌握了如何创建和使用 Web 组件,实现几乎是反高潮。

我将把整个 Web 组件放在这里,并列出一些有趣的部分。 如果你想看到它的实际效果,这里有一个 工作演示. 它将在我最喜欢的三本关于我最喜欢的编程语言的书籍之间切换。 每本书的 URL 每次都是唯一的,因此您可以看到预览,尽管您可能希望在 DevTools Network 选项卡中限制某些内容以真正看到正在发生的事情。

查看整个代码
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', () =&gt; { 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 组件相反)不会符合人体工程学,但好处是它们可以广泛重复使用。


构建甚至可以与 React 一起使用的可互操作的 Web 组件 最初发表于 CSS技巧。 你应该 获取时事通讯.

时间戳记:

更多来自 CSS技巧