3 Ansätze zur Integration von React mit Custom Elements

Avatar of Caleb Williams
Caleb Williams am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

In meiner Rolle als Webentwickler, der an der Schnittstelle von Design und Code sitzt, fühle ich mich von Web Components wegen ihrer Portabilität angezogen. Das macht Sinn: Custom Elements sind voll funktionsfähige HTML-Elemente, die in allen modernen Browsern funktionieren, und das Shadow DOM kapselt die richtigen Stile mit einer guten Oberfläche für Anpassungen. Es passt wirklich gut, besonders für größere Organisationen, die konsistente Benutzererlebnisse über mehrere Frameworks hinweg schaffen wollen, wie Angular, Svelte und Vue.

In meiner Erfahrung gibt es jedoch einen Ausreißer, bei dem viele Entwickler glauben, dass Custom Elements nicht funktionieren, insbesondere diejenigen, die mit React arbeiten, das wohl beliebteste Frontend-Framework derzeit. Und es stimmt, React hat einige deutliche Verbesserungsmöglichkeiten bei der Kompatibilität mit den Web Components-Spezifikationen; die Vorstellung, dass React nicht tief in Web Components integriert werden kann, ist jedoch ein Mythos.

In diesem Artikel werde ich Sie durch die Integration einer React-Anwendung mit Web Components führen, um eine (fast) nahtlose Entwicklererfahrung zu schaffen. Wir werden uns die Best Practices von React und seine Einschränkungen ansehen und dann generische Wrapper und benutzerdefinierte JSX-Pragmas erstellen, um unsere Custom Elements und das heute beliebteste Framework enger zu koppeln.

Linien ausmalen

Wenn React ein Malbuch ist – verzeihen Sie die Metapher, ich habe zwei kleine Kinder, die gerne malen – gibt es definitiv Möglichkeiten, innerhalb der Linien zu bleiben, um mit Custom Elements zu arbeiten. Zunächst schreiben wir ein sehr einfaches Custom Element, das ein Texteingabefeld an das Shadow DOM anhängt und ein Ereignis auslöst, wenn sich der Wert ändert. Der Einfachheit halber verwenden wir LitElement als Basis, aber Sie können sicherlich Ihr eigenes Custom Element von Grund auf neu schreiben, wenn Sie möchten.

Unser Element super-cool-input ist im Grunde ein Wrapper mit einigen Stilen für ein einfaches <input>-Element, das ein benutzerdefiniertes Ereignis auslöst. Es hat eine reportValue-Methode, um Benutzer auf die obskurste Weise über den aktuellen Wert zu informieren. Während dieses Element möglicherweise nicht das nützlichste ist, werden die Techniken, die wir bei der Einbindung in React demonstrieren, hilfreich sein, um mit anderen Custom Elements zu arbeiten.

Ansatz 1: Verwenden von ref

Laut React-Dokumentation für Web Components: „Um auf die imperativen APIs eines Web Components zuzugreifen, müssen Sie einen Ref verwenden, um direkt mit dem DOM-Knoten zu interagieren.“

Dies ist notwendig, da React derzeit keine Möglichkeit hat, native DOM-Ereignisse zu überwachen (und stattdessen sein eigenes proprietäres SyntheticEvent-System bevorzugt) und auch keine Möglichkeit hat, das aktuelle DOM-Element deklarativ ohne die Verwendung eines Refs abzurufen.

Wir werden den useRef-Hook von React verwenden, um eine Referenz auf das definierte native DOM-Element zu erstellen. Wir werden auch die Hooks useEffect und useState von React verwenden, um auf den Wert des Eingabefelds zuzugreifen und ihn in unserer App zu rendern. Wir werden den Ref auch verwenden, um die reportValue-Methode unseres super-cool-input aufzurufen, wenn der Wert jemals eine Variante des Wortes „rad“ ist.

Eine Sache, die man im obigen Beispiel beachten sollte, ist der useEffect-Block unserer React-Komponente.

useEffect(() => {
  coolInput.current.addEventListener('custom-input', eventListener);
  
  return () => {
    coolInput.current.removeEventListener('custom-input', eventListener);
  }
});

Der useEffect-Block erstellt einen Nebeneffekt (Hinzufügen eines Ereignis-Listeners, der nicht von React verwaltet wird), daher müssen wir vorsichtig sein, den Ereignis-Listener zu entfernen, wenn die Komponente eine Änderung benötigt, damit wir keine unbeabsichtigten Speicherlecks haben.

Während das obige Beispiel einfach einen Ereignis-Listener bindet, ist dies auch eine Technik, die angewendet werden kann, um an DOM-Eigenschaften zu binden (definiert als Einträge im DOM-Objekt, anstatt als React-Props oder DOM-Attribute).

Das ist nicht schlecht. Wir haben unser Custom Element in React zum Laufen gebracht und können uns an unser benutzerdefiniertes Ereignis binden, darauf zugreifen und die Methoden unseres Custom Elements aufrufen. Das funktioniert zwar, ist aber umständlich und sieht nicht wirklich wie React aus.

Ansatz 2: Verwenden eines Wrappers

Unser nächster Versuch, unser Custom Element in unserer React-Anwendung zu verwenden, ist die Erstellung eines Wrappers für das Element. Unser Wrapper ist einfach eine React-Komponente, die Props an unser Element weitergibt und eine API für die Schnittstelle zu den Teilen unseres Elements erstellt, die in React nicht üblicherweise verfügbar sind.

Hier haben wir die Komplexität in eine Wrapper-Komponente für unser Custom Element verschoben. Die neue React-Komponente CoolInput kümmert sich um die Erstellung eines Refs, während sie Ereignis-Listener für uns hinzufügt und entfernt, sodass jede konsumierende Komponente Props wie jede andere React-Komponente übergeben kann.

function CoolInput(props) {
  const ref = useRef();
  const { children, onCustomInput, ...rest } = props;
  
  function invokeCallback(event) {
    if (onCustomInput) {
      onCustomInput(event, ref.current);
    }
  }
  
  useEffect(() => {
    const { current } = ref;
    current.addEventListener('custom-input', invokeCallback);
    return () => {
      current.removeEventListener('custom-input', invokeCallback);
    }
  });
  
  return <super-cool-input ref={ref} {...rest}>{children}</super-cool-input>;
}

Auf dieser Komponente haben wir eine Prop namens onCustomInput erstellt, die, wenn sie vorhanden ist, einen Ereignis-Callback von der Elternkomponente auslöst. Im Gegensatz zu einem normalen Ereignis-Callback haben wir uns entschieden, ein zweites Argument hinzuzufügen, das den aktuellen Wert des internen Refs von CoolInput weitergibt.

Mit diesen Techniken ist es möglich, einen generischen Wrapper für ein Custom Element zu erstellen, wie z.B. diese reactifyLitElement-Komponente von Mathieu Puech. Diese spezielle Komponente übernimmt die Definition der React-Komponente und die Verwaltung des gesamten Lebenszyklus.

Ansatz 3: Verwenden eines JSX-Pragmas

Eine weitere Option ist die Verwendung eines JSX-Pragmas, was so etwas wie das Hijacking des JSX-Parsers von React ist und dem Sprachumfang eigene Funktionen hinzufügt. Im folgenden Beispiel importieren wir das Paket jsx-native-events von Skypack. Dieses Pragma fügt React-Elementen einen zusätzlichen Prop-Typ hinzu, und jeder Prop, der mit onEvent präfigiert ist, fügt einen Ereignis-Listener zum Host hinzu.

Um ein Pragma aufzurufen, müssen wir es in die Datei importieren, die wir verwenden, und es mit dem Kommentar /** @jsx <PRAGMA_NAME> */ am Anfang der Datei aufrufen. Ihr JSX-Compiler weiß im Allgemeinen, was er mit diesem Kommentar tun soll (und Babel kann so konfiguriert werden, dass er global ist). Dies haben Sie vielleicht schon in Bibliotheken wie Emotion gesehen.

Ein <input>-Element mit dem Prop onEventInput={callback} führt die Funktion callback aus, wann immer ein Ereignis mit dem Namen 'input' ausgelöst wird. Sehen wir uns an, wie das für unser super-cool-input aussieht.

Der Code für das Pragma ist auf GitHub verfügbar. Wenn Sie sich an native Eigenschaften statt an React-Props binden möchten, können Sie react-bind-properties verwenden. Lassen Sie uns das kurz betrachten.

import React from 'react'

/**
 * Convert a string from camelCase to kebab-case
 * @param {string} string - The base string (ostensibly camelCase)
 * @return {string} - A kebab-case string
 */
const toKebabCase = string => string.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()

/** @type {Symbol} - Used to save reference to active listeners */
const listeners = Symbol('jsx-native-events/event-listeners')

const eventPattern = /^onEvent/

export default function jsx (type, props, ...children) {
  // Make a copy of the props object
  const newProps = { ...props }
  if (typeof type === 'string') {
    newProps.ref = (element) => {
      // Merge existing ref prop
      if (props && props.ref) {
        if (typeof props.ref === 'function') {
          props.ref(element)
        } else if (typeof props.ref === 'object') {
          props.ref.current = element
        }
      }

      if (element) {
        if (props) {
          const keys = Object.keys(props)
          /** Get all keys that have the `onEvent` prefix */
          keys
            .filter(key => key.match(eventPattern))
            .map(key => ({
              key,
              eventName: toKebabCase(
                key.replace('onEvent', '')
              ).replace('-', '')
            })
          )
          .map(({ eventName, key }) => {
            /** Add the listeners Map if not present */
            if (!element[listeners]) {
              element[listeners] = new Map()
            }

            /** If the listener hasn't be attached, attach it */
            if (!element[listeners].has(eventName)) {
              element.addEventListener(eventName, props[key])
              /** Save a reference to avoid listening to the same value twice */
              element[listeners].set(eventName, props[key])
            }
          })
        }
      }
    }
  }
  
  return React.createElement.apply(null, [type, newProps, ...children])
}

Im Wesentlichen konvertiert dieser Code alle vorhandenen Props mit dem Präfix onEvent und transformiert sie in einen Ereignisnamen, nimmt den Wert, der an diesen Prop übergeben wird (vermutlich eine Funktion mit der Signatur (e: Event) => void) und fügt ihn als Ereignis-Listener zur Elementinstanz hinzu.

Ausblick

Zum Zeitpunkt des Schreibens hat React kürzlich Version 17 veröffentlicht. Das React-Team hatte ursprünglich geplant, Verbesserungen für die Kompatibilität mit Custom Elements zu veröffentlichen; leider scheinen diese Pläne auf Version 18 verschoben worden zu sein.

Bis dahin wird es etwas mehr Aufwand erfordern, alle Funktionen, die Custom Elements bieten, mit React zu nutzen. Hoffentlich wird das React-Team die Unterstützung weiter verbessern, um die Lücke zwischen React und der Webplattform zu schließen.