构建一个可以与React一起工作的可互操作的网络组件的方法

46 阅读18分钟

我们这些做了几年以上网络开发的人可能都使用过不止一个JavaScript框架编写代码。有那么多的选择--React、Svelte、Vue、Angular、Solid--这都是不可避免的。在跨框架工作时,我们必须处理的一个更令人沮丧的事情是重新创建所有那些低级别的UI组件:按钮、标签、下拉菜单等等。特别令人沮丧的是,我们通常会在一个框架中定义它们,比如React,但如果我们想在Svelte中构建一些东西,就需要重写它们。或者Vue。或者Solid。等等。

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

到目前为止,Web组件的SSR故事还有点欠缺。声明性影子DOM(DSD)是Web组件在服务器端渲染的方式,但截至目前,它还没有与你喜欢的应用框架(如Next、Remix或SvelteKit)集成。如果这是你的要求,请务必查看DSD的最新状态。但除此之外,如果SSR不是你正在使用的东西,请继续阅读。

首先,一些背景

网络组件本质上是你自己定义的HTML元素,就像<yummy-pizza> 或其他什么,从头开始。在CSS-Tricks网站上有很多关于它们的介绍(包括Caleb Williams的一个广泛系列John Rhea的一个系列),但我们将简要地介绍一下这个过程。从本质上讲,你定义一个JavaScript类,从HTMLElement ,然后定义网络组件的任何属性,属性和样式,当然还有它最终将呈现给用户的标记。

能够定义不受任何特定组件约束的自定义HTML元素是令人兴奋的。但这种自由也是一种限制。独立于任何JavaScript框架而存在,意味着你不能真正与这些JavaScript框架互动。想想看,一个React组件获取了一些数据,然后渲染了其他的React组件,并传递了这些数据。这并不是一个真正的网络组件,因为网络组件不知道如何渲染React组件。

网络组件作为叶子组件尤其出色。叶子组件是组件树中最后被渲染的东西。这些组件接收一些道具,并渲染一些用户界面。这些组件并不是坐在组件树中间,传递数据、设置上下文等的组件--只是纯粹的UI片段,无论应用程序的其他部分是由哪个JavaScript框架驱动的,它们看起来都一样。

我们正在构建的网络组件

与其构建一些无聊的东西(也是常见的),比如一个按钮,不如构建一些有点不同的东西。在我的上一篇文章中,我们研究了使用模糊的图片预览来防止内容回流,并在图片加载时为用户提供一个体面的用户界面。我们研究了对模糊的、退化的图片进行base64编码,并在真实图片加载时在我们的用户界面上显示出来。我们还研究了使用一个叫做Blurhash的工具生成令人难以置信的紧凑、模糊的预览。

那篇文章向你展示了如何生成这些预览并在React项目中使用它们。这篇文章将告诉你如何从一个Web组件中使用这些预览,因此它们可以被任何JavaScript框架使用。

但我们需要先走后跑,所以我们先走过一些琐碎和愚蠢的东西,看看网络组件到底是如何工作的。

这篇文章中的所有内容都将在没有任何工具的情况下构建虚构的Web组件。这意味着代码中会有一些模板,但应该是比较容易理解的。像LitStencil这样的工具是为构建Web组件而设计的,可以用来去除大部分的模板。我敦促你去看看它们。但在这篇文章中,我宁愿多用一点模板,以换取不需要介绍和教授另一个依赖关系。

一个简单的计数器组件

让我们建立一个经典的 "Hello World "的JavaScript组件:一个计数器。我们将渲染一个值,和一个使该值递增的按钮。简单而乏味,但它会让我们看到最简单的网络组件。

为了建立一个网络组件,第一步是制作一个JavaScript类,它继承自HTMLElement

class Counter extends HTMLElement {}

最后一步是注册网络组件,但前提是我们还没有注册它。

if (!customElements.get("counter-wc")) {
  customElements.define("counter-wc", Counter);
}

当然,还要渲染它。

<counter-wc></counter-wc>

而这中间的一切,都是我们让网络组件做我们想做的事。一个常见的生命周期方法是connectedCallback ,当我们的Web组件被添加到DOM时,它就会启动。我们可以使用这个方法来渲染我们想要的任何内容。请记住,这是一个继承自HTMLElement 的JS类,这意味着我们的this 值是网络组件元素本身,具有所有你已经知道并喜爱的正常DOM操作方法。

在它最简单的时候,我们可以这样做。

class Counter extends HTMLElement {
  connectedCallback() {
    this.innerHTML = "<div style='color: green'>Hey</div>";
  }
}

if (!customElements.get("counter-wc")) {
  customElements.define("counter-wc", Counter);
}

...这将会很好地工作。

The word "hey" in green.

添加真正的内容

让我们添加一些有用的、互动的内容。我们需要一个<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组件实例插入内容。可能还有其他一些我没有想到的选择,或者你可以一直使用像LitStencil这样的Web组件框架。但在这篇文章中,我们将继续保持简单。

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

#currentValue = 0;

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

这只是一个带设置器的标准类属性,还有第二个属性来保存值。一个有趣的变化是,我为这些值使用了私有的JavaScript类属性语法。这意味着我们的网络组件之外的任何人都不能触及这些值。这是所有现代浏览器都支持的标准JavaScript,所以不要害怕使用它。

如果你愿意的话,也可以把它叫做_value 。最后,我们的update 方法。

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

它成功了!

The counter web component.

很明显,这不是你想大规模维护的代码。如果你想仔细看看,这里有一个完整的工作实例。正如我所说的,像Lit和Stencil这样的工具被设计来使之更简单。

增加一些功能

这篇文章并不是对网络组件的深入研究。我们不会涉及所有的API和生命周期;我们甚至不会涉及影子根或槽。关于这些话题有无穷无尽的内容。我在这里的目标是提供一个足够体面的介绍,以激发一些兴趣,同时提供一些有用的指导,以实际使用web组件与你已经知道并喜爱的流行JavaScript框架。

为了达到这个目的,让我们加强一下我们的计数器网络组件。让我们让它接受一个color 属性,以控制所显示的值的颜色。让我们也让它接受一个increment 属性,所以这个网页组件的消费者可以让它每次递增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处理了在我们的底层Web组件实例上调用setAttribute 的工作。这里没有什么特别之处:这和它对任何HTML元素的属性所做的事情是一样的。

对于increment 这个道具,事情变得有点有趣。这不是我们的网络组件的一个属性;它是网络组件类的一个道具。这意味着它需要在网络组件的实例上设置。忍耐一下吧,因为事情一会儿就会变得简单得多。

首先,我们要给我们的Svelte组件添加一些变量。

let increment = 1;
let wcInstance;

我们的功能强大的计数器组件可以让你以1或2的方式递增。

<button on:click={() => increment = 1}>Increment 1</button>
<button on:click={() => increment = 2}>Increment 2</button>

但是,从理论上讲,我们需要获得我们的网络组件的实际实例。这和我们任何时候用React添加ref ,都是同样的事情。在Svelte中,它是一个简单的bind:this 指令。

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

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

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

你可以在这个实时演示中进行测试。

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

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

事实证明,我们可以。这段代码可以工作;Svelte为我们处理了所有的工作。在这个演示中可以看到。这几乎是所有JavaScript框架的标准行为。

那么,我为什么要向你展示手动设置网络组件道具的方法呢?有两个原因:了解这些东西的工作原理很有用,而且,刚才我说过,这对 "几乎 "所有的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字符串化/解析对象。但最终我们会需要将一个函数传递给一个网络组件,到那时我们就没有选择了。

选项2:包装它

有句老话说,在计算机科学中,你可以通过增加一层间接性来解决任何问题(除了太多的间接性的问题)。设置这些道具的代码是相当可预测和简单的。如果我们把它藏在一个库中呢?Lit背后的聪明人有一个解决方案。这个库在你给它一个网络组件,并列出它需要的属性后,为你创建一个新的React组件。虽然很聪明,但我并不喜欢这种方法。

与其让网络组件与手动创建的React组件进行一对一的映射,我更喜欢的是只有一个React组件,我们把我们的网络组件标签名称(在我们的例子中是counter-wc )--以及所有的属性和能量--让这个组件渲染我们的网络组件,添加ref ,然后弄清什么是道具,什么是属性。在我看来,这就是理想的解决方案。我不知道有什么库可以做到这一点,但它应该是可以直接创建的。让我们试一试吧!

这就是我们正在寻找的用法

<WcWrapper wcTag="counter-wc" increment={increment} color={color} />

wcTag 是网络组件标签的名称;其余的是我们希望传递的属性和特性。

下面是我的实现方式。

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 检查的属性,该检查会检查网络组件的实例对象以及它的原型链,这将捕获任何缠绕在类原型上的getters/setters。如果没有这样的属性存在,我们就假定它是一个属性。在这两种情况下,我们只在其值实际发生变化时才对其进行设置。

如果你想知道为什么我们使用useLayoutEffect ,而不是useEffect ,那是因为我们想在我们的内容被渲染之前立即运行这些更新。另外,请注意,我们的useLayoutEffect ,没有依赖数组;这意味着我们想在每次渲染时运行这个更新。这可能是有风险的,因为React倾向于经常重新渲染。我通过将整个事情包裹在React.memo 来改善这个问题。这基本上是现代版的React.PureComponent ,这意味着组件只有在其实际道具发生变化时才会重新渲染--它通过一个简单的平等检查来检查是否发生了变化。

这里唯一的风险是,如果你传递一个你直接变异的对象道具而不重新赋值,那么你将看不到这些更新。但这是非常不可取的,特别是在React社区,所以我不会担心这个问题。

在继续前进之前,我想指出最后一件事。你可能对使用方法的外观不满意。同样,这个组件是这样使用的。

<WcWrapper wcTag="counter-wc" increment={increment} color={color} />

具体来说,你可能不喜欢把网络组件的标签名传递给<WcWrapper> ,而喜欢上面的@lit-labs/react 包,它为每个网络组件创建一个新的单独的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网络标签中节流,以真正看到事情的发生。

查看整个代码

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的框架都会为你处理网络组件属性的设置。只是要注意,有些框架有一些特殊的语法风味。例如,在Solid.js中,<your-wc value={12}> 总是假设value 是一个属性,你可以用attr 的前缀来覆盖它,比如<your-wc attr:value={12}>

收尾工作

网络组件是网络开发领域中一个有趣的、经常被低估的部分。它们可以通过管理你的用户界面或 "叶子 "组件来帮助减少你对任何单一JavaScript框架的依赖。虽然以Web组件的形式创建这些组件--相对于Svelte或React组件--并不符合人体工程学,但好处是它们可以被广泛地重复使用。