Wie man eine Komponente erstellt, die mehrere Frameworks in einem Monorepo unterstützt

Avatar of Rob Levin
Rob Levin am

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

Ihre Mission – sollten Sie sich entscheiden, sie anzunehmen – ist es, eine Button-Komponente in vier Frameworks zu erstellen, aber nur eine button.css Datei zu verwenden!

Diese Idee ist mir sehr wichtig. Ich arbeite an einer Komponentenbibliothek namens AgnosticUI, deren Zweck darin besteht, UI-Komponenten zu erstellen, die nicht an ein bestimmtes JavaScript-Framework gebunden sind. AgnosticUI funktioniert in React, Vue 3, Angular und Svelte. Genau das werden wir heute in diesem Artikel tun: eine Button-Komponente erstellen, die mit all diesen Frameworks funktioniert.

Der Quellcode für diesen Artikel ist auf GitHub unter dem Zweig the-little-button-that-could-series verfügbar.

Inhaltsverzeichnis

Warum ein Monorepo?

Wir werden ein kleines Monorepo basierend auf Yarn-Workspaces einrichten. Warum? Chris hat in einem anderen Beitrag eine schöne Übersicht über die Vorteile. Hier ist meine eigene voreingenommene Liste von Vorteilen, die meiner Meinung nach für unser kleines Button-Vorhaben relevant sind.

Kopplung

Wir versuchen, eine einzige Button-Komponente zu erstellen, die nur eine button.css-Datei über mehrere Frameworks hinweg verwendet. Daher gibt es von Natur aus eine gewisse absichtliche Kopplung zwischen den verschiedenen Framework-Implementierungen und der Single-Source-of-Truth-CSS-Datei. Eine Monorepo-Einrichtung bietet eine praktische Struktur, die das Kopieren unserer einzelnen button.css-Komponente in verschiedene Framework-basierte Projekte erleichtert.

Workflow

Nehmen wir an, der Button benötigt eine Anpassung – wie die Implementierung des „Fokusrings“ oder wir haben die Verwendung von aria in den Komponentenvorlagen vermasselt. Idealerweise möchten wir Dinge an einer Stelle korrigieren, anstatt einzelne Korrekturen in separaten Repositories vorzunehmen.

Testen

Wir möchten die Bequemlichkeit haben, alle vier Button-Implementierungen gleichzeitig zum Testen zu starten. Wenn dieses Projekt wächst, ist es sicher anzunehmen, dass es mehr ordnungsgemäße Tests geben wird. In AgnosticUI verwende ich beispielsweise derzeit Storybook und starte oft alle Framework-Storybooks oder führe Snapshot-Tests im gesamten Monorepo durch.

Mir gefällt, was Leonardo Losoviz über den Monorepo-Ansatz sagt. (Und es passt zufällig zu allem, worüber wir bisher gesprochen haben.)

Ich glaube, dass das Monorepo besonders nützlich ist, wenn alle Pakete in derselben Programmiersprache kodiert sind, eng gekoppelt sind und auf denselben Werkzeugen basieren.

Einrichtung

Zeit, in den Code einzutauchen – beginnen Sie damit, ein Top-Level-Verzeichnis auf der Befehlszeile zu erstellen, um das Projekt unterzubringen, und wechseln Sie dann dorthin. (Kein Name eingefallen? mkdir buttons && cd buttons wird gut funktionieren.)

Zuerst initialisieren wir das Projekt

$ yarn init
yarn init v1.22.15
question name (articles): littlebutton
question version (1.0.0): 
question description: my little button project
question entry point (index.js): 
question repository url: 
question author (Rob Levin): 
question license (MIT): 
question private: 
success Saved package.json

Das gibt uns eine package.json-Datei mit etwas wie dem Folgenden

{
  "name": "littlebutton",
  "version": "1.0.0",
  "description": "my little button project",
  "main": "index.js",
  "author": "Rob Levin",
  "license": "MIT"
}

Erstellung des Basis-Workspaces

Wir können den ersten mit diesem Befehl einrichten

mkdir -p ./littlebutton-css

Als Nächstes müssen wir die beiden folgenden Zeilen zur package.json-Datei des Monorepos hinzufügen, damit wir das Monorepo selbst privat halten. Außerdem deklarieren wir unsere Workspaces

// ...
"private": true,
"workspaces": ["littlebutton-react", "littlebutton-vue", "littlebutton-svelte", "littlebutton-angular", "littlebutton-css"]

Wechseln Sie nun in das Verzeichnis littlebutton-css. Wir möchten wieder eine package.json mit yarn init generieren. Da wir unser Verzeichnis littlebutton-css genannt haben (gleich wie wir es in unserer package.json unter workspaces angegeben haben), können wir einfach die Return-Taste drücken und alle Aufforderungen akzeptieren.

$ cd ./littlebutton-css && yarn init
yarn init v1.22.15
question name (littlebutton-css): 
question version (1.0.0): 
question description: 
question entry point (index.js): 
question repository url: 
question author (Rob Levin): 
question license (MIT): 
question private: 
success Saved package.json

Zu diesem Zeitpunkt sollte die Verzeichnisstruktur wie folgt aussehen

├── littlebutton-css
│   └── package.json
└── package.json

Wir haben bisher nur den CSS-Paket-Workspace erstellt, da wir unsere Framework-Implementierungen mit Tools wie vite generieren werden, die wiederum eine package.json und ein Projektverzeichnis für Sie erstellen. Wir müssen uns daran erinnern, dass der Name, den wir für diese generierten Projekte wählen, mit dem Namen übereinstimmen muss, den wir in der package.json für unsere früheren workspaces angegeben haben, damit diese funktionieren.

Basis-HTML & CSS

Wir bleiben im ./littlebutton-css-Workspace und erstellen unsere einfache Button-Komponente mit Vanilla-HTML- und CSS-Dateien.

touch index.html ./css/button.css

Nun sollte unser Projektverzeichnis wie folgt aussehen

littlebutton-css
├── css
│   └── button.css
├── index.html
└── package.json

Lassen Sie uns nun einige Verbindungen mit etwas Boilerplate-HTML in ./index.html herstellen

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>The Little Button That Could</title>
  <meta name="description" content="">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="css/button.css">
</head>
<body>
  <main>
    <button class="btn">Go</button>
  </main>
</body>
</html>

Und damit wir etwas Visuelles zum Testen haben, können wir in ./css/button.css etwas Farbe hinzufügen

.btn {
  color: hotpink;
}
A mostly unstyled button with hot-pink text from the monorepo framework.

Öffnen Sie nun diese index.html-Seite im Browser. Wenn Sie einen hässlichen, generischen Button mit hotpink-Text sehen... Erfolg!

Framework-spezifische Workspaces

Das, was wir gerade erreicht haben, ist die Basis für unsere Button-Komponente. Was wir jetzt tun wollen, ist, sie etwas zu abstrahieren, damit sie für andere Frameworks und dergleichen erweiterbar ist. Was ist zum Beispiel, wenn wir den Button in einem React-Projekt verwenden wollen? Wir werden Workspaces in unserem Monorepo für jeden davon benötigen. Wir beginnen mit React, dann folgen Vue 3, Angular und Svelte.

React

Wir werden unser React-Projekt mit vite generieren, einem sehr leichten und blitzschnellen Builder. Seien Sie gewarnt, dass Sie bei einem Versuch, dies mit create-react-app zu tun, wahrscheinlich später auf Konflikte mit react-scripts und widersprüchliche Webpack- oder Babel-Konfigurationen von anderen Frameworks wie Angular stoßen werden.

Um unseren React-Workspace zum Laufen zu bringen, gehen wir zurück ins Terminal und wechseln mit cd zurück in das Top-Level-Verzeichnis. Von dort aus verwenden wir vite, um ein neues Projekt zu initialisieren – nennen wir es littlebutton-react – und wir wählen natürlich react als Framework und Variante in den Aufforderungen.

$ yarn create vite
yarn create v1.22.15
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...

success Installed "[email protected]" with binaries:
      - create-vite
      - cva
✔ Project name: … littlebutton-react
✔ Select a framework: › react
✔ Select a variant: › react

Scaffolding project in /Users/roblevin/workspace/opensource/guest-posts/articles/littlebutton-react...

Done. Now run:

  cd littlebutton-react
  yarn
  yarn dev

✨  Done in 17.90s.

Wir initialisieren die React-App mit diesen Befehlen als Nächstes

cd littlebutton-react
yarn
yarn dev

Nachdem React installiert und verifiziert ist, ersetzen wir den Inhalt von src/App.jsx, um unseren Button mit dem folgenden Code unterzubringen

import "./App.css";

const Button = () => {
  return <button>Go</button>;
};

function App() {
  return (
    <div className="App">
      <Button />
    </div>
  );
}

export default App;

Nun schreiben wir ein kleines Node-Skript, das unsere littlebutton-css/css/button.css-Datei direkt in unsere React-Anwendung kopiert. Dieser Schritt ist wahrscheinlich der interessanteste für mich, weil er gleichzeitig magisch und hässlich ist. Er ist magisch, weil er bedeutet, dass unsere React-Button-Komponente ihre Stile tatsächlich aus demselben CSS bezieht, das im Basisprojekt geschrieben wurde. Er ist hässlich, weil wir aus einem Workspace herausgreifen und eine Datei aus einem anderen holen. ¯\_(ツ)_/¯

Fügen Sie das folgende kleine Node-Skript zu littlebutton-react/copystyles.js hinzu

const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
fs.writeFileSync("./src/button.css", css, "utf8");

Legen wir einen node-Befehl fest, um dies in einem package.json-Skript auszuführen, das vor dem dev-Skript in littlebutton-react/package.json ausgeführt wird. Wir fügen syncStyles hinzu und aktualisieren dev so, dass es syncStyles vor vite aufruft

"syncStyles": "node copystyles.js",
"dev": "yarn syncStyles && vite",

Jedes Mal, wenn wir unsere React-Anwendung mit yarn dev starten, kopieren wir zuerst die CSS-Datei. Im Wesentlichen „zwingen“ wir uns damit, nicht vom button.css des CSS-Pakets in unserem React-Button abzuweichen.

Aber wir wollen auch CSS Modules nutzen, um Namenskollisionen und globale CSS-Leaks zu vermeiden. Dazu müssen wir noch einen Schritt tun, um das zu verdrahten (aus demselben littlebutton-react-Verzeichnis).

touch src/button.module.css

Fügen Sie als Nächstes Folgendes zur neuen Datei src/button.module.css hinzu

.btn {
  composes: btn from './button.css';
}

Ich halte composes (auch bekannt als Composition) für eines der coolsten Features von CSS Modules. Im Grunde kopieren wir unsere HTML/CSS-Version von button.css komplett und komponieren dann aus unserer einen .btn-Stilregel.

Damit können wir zu unserer src/App.jsx zurückkehren und die CSS-Module styles mit Folgendem in unsere React-Komponente importieren

import "./App.css";
import styles from "./button.module.css";

const Button = () => {
  return <button className={styles.btn}>Go</button>;
};

function App() {
  return (
    <div className="App">
      <Button />
    </div>
  );
}

export default App;

Puh! Machen wir eine Pause und versuchen, unsere React-App erneut zu starten

yarn dev

Wenn alles gut gegangen ist, sollten Sie denselben generischen Button mit hotpink-Text sehen. Bevor wir zum nächsten Framework übergehen, wechseln wir zurück zu unserem Top-Level-Monorepo-Verzeichnis und aktualisieren seine package.json

{
  "name": "littlebutton",
  "version": "1.0.0",
  "description": "toy project",
  "main": "index.js",
  "author": "Rob Levin",
  "license": "MIT",
  "private": true,
  "workspaces": ["littlebutton-react", "littlebutton-vue", "littlebutton-svelte", "littlebutton-angular"],
  "scripts": {
    "start:react": "yarn workspace littlebutton-react dev"
  }
}

Führen Sie den Befehl yarn vom Top-Level-Verzeichnis aus, um die monorepo-gehoisteten Abhängigkeiten zu installieren.

Die einzige Änderung, die wir an dieser package.json vorgenommen haben, ist ein neuer scripts-Bereich mit einem einzigen Skript zum Starten der React-App. Durch das Hinzufügen von start:react können wir jetzt yarn start:react von unserem Top-Level-Verzeichnis aus ausführen, und es wird das gerade erstellte Projekt in ./littlebutton-react starten, ohne dass wir wechseln müssen – super praktisch!

Als Nächstes kümmern wir uns um Vue und Svelte. Es stellt sich heraus, dass wir für beide einen ziemlich ähnlichen Ansatz verfolgen können, da beide Single-File-Komponenten (SFC) verwenden. Grundsätzlich dürfen wir HTML, CSS und JavaScript in einer einzigen Datei mischen. Ob Ihnen der SFC-Ansatz gefällt oder nicht, er ist sicherlich ausreichend für die Erstellung von präsentations- oder primitiven UI-Komponenten.

Vue

Nach den Schritten in den Scaffolding-Dokumenten von Vite führen wir den folgenden Befehl vom Top-Level-Verzeichnis des Monorepos aus, um eine Vue-App zu initialisieren

yarn create vite littlebutton-vue --template vue

Dies generiert ein Grundgerüst mit einigen bereitgestellten Anweisungen zum Ausführen der Start-Vue-App

cd littlebutton-vue
yarn
yarn dev

Dies sollte eine Startseite im Browser mit einer Überschrift wie „Hallo Vue 3 + Vite“ starten. Von hier aus können wir src/App.vue aktualisieren zu

<template>
  <div id="app">
    <Button class="btn">Go</Button>
  </div>
</template>

<script>
import Button from './components/Button.vue'

export default {
  name: 'App',
  components: {
    Button
  }
}
</script>

Und wir ersetzen alle src/components/* durch src/components/Button.vue

<template>
  <button :class="classes"><slot /></button>
</template>

<script>
export default {
  name: 'Button',
  computed: {
    classes() {
      return {
        [this.$style.btn]: true,
      }
    }
  }
}
</script>

<style module>
.btn {
  color: slateblue;
}
</style>

Lassen Sie uns das ein wenig aufschlüsseln

  • :class="classes" verwendet Vues Binding, um die berechnete classes-Methode aufzurufen.
  • Die classes-Methode nutzt wiederum CSS-Module in Vue mit der Syntax this.$style.btn, die Stile aus einem <style module>-Tag verwendet.

Fürs Erste kodieren wir color: slateblue fest, nur um zu testen, ob die Dinge innerhalb der Komponente ordnungsgemäß funktionieren. Versuchen Sie, die App erneut mit yarn dev zu starten. Wenn Sie den Button mit unserer deklarierten Testfarbe sehen, dann funktioniert es!

Nun schreiben wir ein Node-Skript, das unsere littlebutton-css/css/button.css-Datei in unsere Button.vue-Datei kopiert, ähnlich wie bei der React-Implementierung. Wie erwähnt, ist diese Komponente eine SFC, also müssen wir dies mit einem einfachen regulären Ausdruck tun.

Fügen Sie das folgende kleine Node.js-Skript zu littlebutton-vue/copystyles.js hinzu

const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
const vue = fs.readFileSync("./src/components/Button.vue", "utf8");
// Take everything between the starting and closing style tag and replace
const styleRegex = /<style module>([\s\S]*?)<\/style>/;
let withSynchronizedStyles = vue.replace(styleRegex, `<style module>\n${css}\n</style>`);
fs.writeFileSync("./src/components/Button.vue", withSynchronizedStyles, "utf8");

Es gibt etwas mehr Komplexität in diesem Skript, aber die Verwendung von replace zum Kopieren von Text zwischen öffnenden und schließenden style-Tags über Regex ist nicht allzu schlimm.

Fügen wir nun die folgenden beiden Skripte zur scripts-Klausel in der littlebutton-vue/package.json-Datei hinzu

"syncStyles": "node copystyles.js",
"dev": "yarn syncStyles && vite",

Führen Sie nun yarn syncStyles aus und schauen Sie sich ./src/components/Button.vue noch einmal an. Sie sollten sehen, dass unser Stilmodul durch Folgendes ersetzt wird

<style module>
.btn {
  color: hotpink;
}
</style>

Starten Sie die Vue-App erneut mit yarn dev und überprüfen Sie, ob Sie die erwarteten Ergebnisse erhalten – ja, ein Button mit hotpinkfarbenem Text. Wenn ja, sind wir bereit, mit dem nächsten Framework-Workspace fortzufahren!

Svelte

Gemäß den Svelte-Dokumenten sollten wir unseren littlebutton-svelte-Workspace mit Folgendem starten, beginnend im Top-Level-Verzeichnis des Monorepos

npx degit sveltejs/template littlebutton-svelte
cd littlebutton-svelte
yarn && yarn dev

Bestätigen Sie, dass Sie die „Hallo Welt“-Startseite unter https://:5000 erreichen können. Aktualisieren Sie dann littlebutton-svelte/src/App.svelte

<script>
  import Button from './Button.svelte';
</script>
<main>
  <Button>Go</Button>
</main>

Außerdem möchten wir in littlebutton-svelte/src/main.js die name-Prop entfernen, damit es so aussieht

import App from './App.svelte';

const app = new App({
  target: document.body
});

export default app;

Und schließlich fügen Sie littlebutton-svelte/src/Button.svelte mit Folgendem hinzu

<button class="btn">
  <slot></slot>
</button>

<script>
</script>

<style>
  .btn {
    color: saddlebrown;
  }
</style>

Ein letzter Punkt: Svelte benennt unsere App anscheinend: "name": "svelte-app" in der package.json. Ändern Sie dies in "name": "littlebutton-svelte", damit es mit dem workspaces-Namen in unserer Top-Level-package.json-Datei übereinstimmt.

Auch hier können wir unsere Basis littlebutton-css/css/button.css in unsere Button.svelte kopieren. Wie erwähnt, ist diese Komponente eine SFC, daher müssen wir dies mit einem regulären Ausdruck tun. Fügen Sie das folgende Node-Skript zu littlebutton-svelte/copystyles.js hinzu

const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
const svelte = fs.readFileSync("./src/Button.svelte", "utf8");
const styleRegex = /<style>([\s\S]*?)<\/style>/;
let withSynchronizedStyles = svelte.replace(styleRegex, `<style>\n${css}\n</style>`);
fs.writeFileSync("./src/Button.svelte", withSynchronizedStyles, "utf8");

Dies ist super ähnlich zum Kopierskript, das wir mit Vue verwendet haben, nicht wahr? Wir fügen ähnliche Skripte zu unserem package.json-Skript hinzu

"dev": "yarn syncStyles && rollup -c -w",
"syncStyles": "node copystyles.js",

Führen Sie nun yarn syncStyles && yarn dev aus. Wenn alles gut ist, sollten wir wieder einen Button mit hotpink-Text sehen.

Wenn das langsam repetitiv wird, kann ich nur sagen: Willkommen in meiner Welt. Was ich Ihnen hier zeige, ist im Wesentlichen derselbe Prozess, den ich verwendet habe, um mein AgnosticUI-Projekt aufzubauen!

Angular

Sie kennen das Prozedere wahrscheinlich mittlerweile. Vom Top-Level-Verzeichnis des Monorepos aus installieren Sie Angular und erstellen eine Angular-App. Wenn wir eine vollwertige UI-Bibliothek erstellen würden, würden wir wahrscheinlich ng generate library oder sogar nx verwenden. Aber um die Dinge so einfach wie möglich zu halten, richten wir eine Boilerplate-Angular-App wie folgt ein

npm install -g @angular/cli ### unless you already have installed
ng new littlebutton-angular ### choose no for routing and CSS
? Would you like to add Angular routing? (y/N) N
❯ CSS 
  SCSS   [ https://sass-lang.de/documentation/syntax#scss ] 
  Sass   [ https://sass-lang.de/documentation/syntax#the-indented-syntax ] 
  Less   [ http://lesscss.org ]

cd littlebutton-angular && ng serve --open

Nachdem das Angular-Setup bestätigt ist, aktualisieren wir einige Dateien. cd littlebutton-angular, löschen Sie die Datei src/app/app.component.spec.ts und fügen Sie eine Button-Komponente in src/components/button.component.ts hinzu, wie folgt

import { Component } from '@angular/core';

@Component({
  selector: 'little-button',
  templateUrl: './button.component.html',
  styleUrls: ['./button.component.css'],
})
export class ButtonComponent {}

Fügen Sie das Folgende zu src/components/button.component.html hinzu

<button class="btn">Go</button>

Und fügen Sie Folgendes in die Datei src/components/button.component.css zum Testen ein

.btn {
  color: fuchsia;
}

In src/app/app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { ButtonComponent } from '../components/button.component';

@NgModule({
  declarations: [AppComponent, ButtonComponent],
  imports: [BrowserModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Ersetzen Sie als Nächstes src/app/app.component.ts durch

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {}

Ersetzen Sie dann src/app/app.component.html durch

<main>
  <little-button>Go</little-button>
</main>

Damit starten wir yarn start und überprüfen, ob unser Button mit fuchsia-Text wie erwartet gerendert wird.

Auch hier möchten wir die CSS-Datei aus unserem Basis-Workspace kopieren. Das können wir erreichen, indem wir Folgendes zu littlebutton-angular/copystyles.js hinzufügen

const fs = require("fs");
let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8");
fs.writeFileSync("./src/components/button.component.css", css, "utf8");

Angular ist gut darin, dass es ViewEncapsulation verwendet, das standardmäßig auf emulate eingestellt ist und wie folgt laut der Dokumentation,

[...] das Verhalten von Shadow DOM durch Vorverarbeitung (und Umbenennung) des CSS-Codes, um das CSS effektiv auf die Ansicht der Komponente zu beschränken.

Das bedeutet im Grunde, dass wir button.css buchstäblich kopieren und unverändert verwenden können.

Aktualisieren Sie schließlich die package.json-Datei, indem Sie diese beiden Zeilen im scripts-Bereich hinzufügen

"start": "yarn syncStyles && ng serve",
"syncStyles": "node copystyles.js",

Damit können wir nun erneut yarn start ausführen und die Textfarbe unseres Buttons (die fuchsia war) überprüfen, die jetzt hotpink ist.

Was haben wir gerade getan?

Lassen Sie uns eine Pause vom Programmieren machen und über das Gesamtbild nachdenken und was wir gerade getan haben. Grundsätzlich haben wir ein System eingerichtet, bei dem jede Änderung an der button.css-Datei unseres CSS-Pakets in alle Framework-Implementierungen kopiert wird, dank unserer copystyles.js Node-Skripte. Darüber hinaus haben wir idiomatische Konventionen für jedes der Frameworks integriert.

  • SFC für Vue und Svelte
  • CSS Modules für React (und Vue innerhalb der SFC <style module>-Einrichtung)
  • ViewEncapsulation für Angular

Natürlich sage ich das Offensichtliche, dass dies nicht die einzigen Möglichkeiten sind, CSS in jedem der oben genannten Frameworks zu verwenden (z. B. ist CSS-in-JS eine beliebte Wahl), aber es sind sicherlich anerkannte Praktiken und funktionieren recht gut für unser übergeordnetes Ziel – eine einzige CSS-Quelle zu haben, die alle Framework-Implementierungen steuert.

Wenn beispielsweise unser Button in Gebrauch wäre und unser Designteam beschließen würde, von einem 4px auf einen 3px border-radius zu wechseln, könnten wir die eine Datei aktualisieren, und jede separate Implementierung würde synchron bleiben.

Das ist überzeugend, wenn Sie ein polyglottes Team von Entwicklern haben, die gerne mit mehreren Frameworks arbeiten, oder z. B. ein Offshore-Team (das in Angular 3x produktiver ist), dem die Aufgabe übertragen wird, eine Backoffice-Anwendung zu erstellen, aber Ihr Flaggschiffprodukt ist in React erstellt. Oder Sie erstellen eine Übergangs-Admin-Konsole und möchten mit der Verwendung von Vue oder Svelte experimentieren. Sie verstehen schon.

Feinschliff

OK, die Monorepo-Architektur ist in einem wirklich guten Zustand. Aber es gibt ein paar Dinge, die wir tun können, um sie im Hinblick auf die Entwicklererfahrung noch nützlicher zu machen.

Bessere Startskripte

Lassen Sie uns zurück zu unserem Top-Level-Monorepo-Verzeichnis gehen und seinen package.json scripts-Bereich mit Folgendem aktualisieren, damit wir jede Framework-Implementierung starten können, ohne wechseln zu müssen.

// ...
"scripts": {
  "start:react": "yarn workspace littlebutton-react dev",
  "start:vue": "yarn workspace littlebutton-vue dev ",
  "start:svelte": "yarn workspace littlebutton-svelte dev",
  "start:angular": "yarn workspace littlebutton-angular start"
},

Bessere Basisstile

Wir können auch einen besseren Satz von Basisstilen für den Button bereitstellen, damit er von einem schönen, neutralen Ort aus startet. Hier ist, was ich in der Datei littlebutton-css/css/button.css gemacht habe.

Vollständigen Snippet anzeigen
.btn {
  --button-dark: #333;
  --button-line-height: 1.25rem;
  --button-font-size: 1rem;
  --button-light: #e9e9e9;
  --button-transition-duration: 200ms;
  --button-font-stack:
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    Roboto,
    Ubuntu,
    "Helvetica Neue",
    sans-serif;

  display: inline-flex;
  align-items: center;
  justify-content: center;
  white-space: nowrap;
  user-select: none;
  appearance: none;
  cursor: pointer;
  box-sizing: border-box;
  transition-property: all;
  transition-duration: var(--button-transition-duration);
  color: var(--button-dark);
  background-color: var(--button-light);
  border-color: var(--button-light);
  border-style: solid;
  border-width: 1px;
  font-family: var(--button-font-stack);
  font-weight: 400;
  font-size: var(--button-font-size);
  line-height: var(--button-line-height);
  padding-block-start: 0.5rem;
  padding-block-end: 0.5rem;
  padding-inline-start: 0.75rem;
  padding-inline-end: 0.75rem;
  text-decoration: none;
  text-align: center;
}

/* Respect users reduced motion preferences */
@media (prefers-reduced-motion) {
  .btn {
    transition-duration: 0.001ms !important;
  }
}

Testen wir das aus! Starten Sie jede der vier Framework-Implementierungen mit den neuen und verbesserten Startskripten und überprüfen Sie, ob die Styling-Änderungen wirksam sind.

Neutral (gray) styled button from the monorepo framework

Eine CSS-Datei-Aktualisierung, die sich auf vier Frameworks auswirkt – ziemlich cool, oder!?

Festlegen eines primären Modus

Wir fügen jeder unserer Buttons eine mode-Prop hinzu und implementieren als Nächstes den primary-Modus. Ein primärer Button kann jede Farbe haben, aber wir werden einen Grünton für den Hintergrund und weiße Schrift verwenden. Wiederum in der Basis-Stylesheet

.btn {
  --button-primary: #14775d;
  --button-primary-color: #fff;
  /* ... */
}

Fügen Sie dann, kurz vor der @media (prefers-reduced-motion)-Abfrage, Folgendes btn-primary zum selben Basis-Stylesheet hinzu

.btn-primary {
  background-color: var(--button-primary);
  border-color: var(--button-primary);
  color: var(--button-primary-color);
}

Da haben wir es! Einige Entwickler-Bequemlichkeiten und bessere Basisstile!

Aktualisierung jeder Komponente zur Annahme einer mode-Eigenschaft

Nachdem wir unseren neuen primary-Modus, der durch die Klasse .btn-primary repräsentiert wird, hinzugefügt haben, möchten wir die Stile für alle vier Framework-Implementierungen synchronisieren. Fügen wir also weitere package.json-Skripte zu unseren Top-Level-scripts hinzu

"sync:react": "yarn workspace littlebutton-react syncStyles",
"sync:vue": "yarn workspace littlebutton-vue syncStyles",
"sync:svelte": "yarn workspace littlebutton-svelte syncStyles",
"sync:angular": "yarn workspace littlebutton-angular syncStyles"

Achten Sie auf die JSON-Kommaregeln! Je nachdem, wo Sie diese Zeilen innerhalb Ihrer scripts: {...} platzieren, sollten Sie sicherstellen, dass keine Kommas fehlen oder überzählig sind.

Führen Sie Folgendes aus, um die Stile vollständig zu synchronisieren

yarn sync:angular && yarn sync:react && yarn sync:vue && yarn sync:svelte

Das Ausführen dies ändert nichts, da wir die primäre Klasse noch nicht angewendet haben, aber Sie sollten zumindest sehen, dass das CSS kopiert wurde, wenn Sie das Button-Komponenten-CSS des Frameworks aufrufen.

React

Wenn Sie es noch nicht getan haben, überprüfen Sie noch einmal, ob das aktualisierte CSS in littlebutton-react/src/button.css kopiert wurde. Wenn nicht, können Sie yarn syncStyles ausführen. Beachten Sie, dass unser dev-Skript dies für uns erledigt, wenn wir die Anwendung das nächste Mal starten, falls Sie vergessen sollten.

"dev": "yarn syncStyles && vite",

Für unsere React-Implementierung müssen wir zusätzlich eine zusammengesetzte CSS-Module-Klasse in littlebutton-react/src/button.module.css hinzufügen, die von der neuen .btn-primary zusammengesetzt wird.

.btnPrimary {
  composes: btn-primary from './button.css';
}

Wir aktualisieren auch littlebutton-react/src/App.jsx

import "./App.css";
import styles from "./button.module.css";

const Button = ({ mode }) => {
  const primaryClass = mode ? styles[`btn${mode.charAt(0).toUpperCase()}${mode.slice(1)}`] : '';
  const classes = primaryClass ? `${styles.btn} ${primaryClass}` : styles.btn;
  return <button className={classes}>Go</button>;
};

function App() {
  return (
    <div className="App">
      <Button mode="primary" />
    </div>
  );
}

export default App;

Starten Sie die React-App mit yarn start:react vom Top-Level-Verzeichnis aus. Wenn alles gut geht, sollten Sie nun Ihren grünen primären Button sehen.

A dark green button with white text positioning in the center of the screen.

Als Anmerkung: Ich behalte die Button-Komponente in App.jsx der Kürze halber bei. Sie können die Button-Komponente gerne in eine eigene Datei auslagern, wenn Sie das stört.

Vue

Überprüfen Sie erneut, ob die Button-Stile kopiert wurden. Wenn nicht, führen Sie yarn syncStyles aus.

Nehmen Sie als Nächstes die folgenden Änderungen im <script>-Abschnitt von littlebutton-vue/src/components/Button.vue vor

<script>
export default {
  name: 'Button',
  props: {
    mode: {
      type: String,
      required: false,
      default: '',
      validator: (value) => {
        const isValid = ['primary'].includes(value);
        if (!isValid) {
          console.warn(`Allowed types for Button are primary`);
        }
        return isValid;
      },
    }
  },
  computed: {
    classes() {
      return {
        [this.$style.btn]: true,
        [this.$style['btn-primary']]: this.mode === 'primary',
      }
    }
  }
}
</script>

Nun können wir das Markup in littlebutton-vue/src/App.vue aktualisieren, um die neue mode-Prop zu verwenden

<Button mode="primary">Go</Button>

Jetzt können Sie yarn start:vue vom Top-Level-Verzeichnis aus starten und den gleichen grünen Button überprüfen.

Svelte

Wechseln wir zu littlebutton-svelte und überprüfen wir, ob die Stile in littlebutton-svelte/src/Button.svelte die neue .btn-primary-Klasse kopiert haben, und führen Sie yarn syncStyles aus, falls erforderlich. Wiederum, das dev-Skript erledigt dies beim nächsten Start sowieso für uns, falls Sie es vergessen.

Aktualisieren Sie als Nächstes die Svelte-Vorlage, um den mode von primary zu übergeben. In src/App.svelte

<script>
  import Button from './Button.svelte';
</script>
<main>
  <Button mode="primary">Go</Button>
</main>

Wir müssen auch den oberen Teil unserer src/Button.svelte-Komponente selbst aktualisieren, um die mode-Prop anzunehmen und die CSS-Module-Klasse anzuwenden.

<button class="{classes}">
  <slot></slot>
</button>
<script>
  export let mode = "";
  const classes = [
    "btn",
    mode ? `btn-${mode}` : "",
  ].filter(cls => cls.length).join(" ");
</script>

Beachten Sie, dass der Abschnitt <styles> unserer Svelte-Komponente in diesem Schritt nicht berührt werden sollte.

Und jetzt können Sie yarn dev von littlebutton-svelte (oder yarn start:svelte von einem höheren Verzeichnis aus) ausführen, um zu überprüfen, ob der grüne Button angekommen ist!

Angular

Dasselbe, nur ein anderes Framework: Prüfen Sie, ob die Stile kopiert wurden und führen Sie bei Bedarf yarn syncStyles aus.

Fügen wir die mode-Prop zur Datei littlebutton-angular/src/app/app.component.html hinzu

<main>
  <little-button mode="primary">Go</little-button>
</main>

Jetzt müssen wir eine Bindung an einen classes-Getter einrichten, um die korrekten Klassen zu berechnen, je nachdem, ob der mode an die Komponente übergeben wurde oder nicht. Fügen Sie dies zu littlebutton-angular/src/components/button.component.html hinzu (und beachten Sie, dass die Bindung mit den eckigen Klammern erfolgt)

<button [class]="classes">Go</button>

Als Nächstes müssen wir die classes-Bindung tatsächlich in unserer Komponente unter littlebutton-angular/src/components/button.component.ts erstellen

import { Component, Input } from '@angular/core';

@Component({
  selector: 'little-button',
  templateUrl: './button.component.html',
  styleUrls: ['./button.component.css'],
})
export class ButtonComponent {
  @Input() mode: 'primary' | undefined = undefined;

  public get classes(): string {
    const modeClass = this.mode ? `btn-${this.mode}` : '';
    return [
      'btn',
      modeClass,
    ].filter(cl => cl.length).join(' ');
  }
}

Wir verwenden die Input-Direktive, um die mode-Prop zu übergeben. Dann erstellen wir einen classes-Accessor, der die Modusklasse hinzufügt, wenn sie übergeben wurde.

Starten Sie es und suchen Sie nach dem grünen Button!

Code abgeschlossen

Wenn Sie bis hierher gekommen sind, herzlichen Glückwunsch – Sie haben den Code-Abschluss erreicht! Wenn etwas schiefgelaufen ist, ermutige ich Sie, den Quellcode auf GitHub unter dem Branch the-little-button-that-could-series zu vergleichen. Da Bundler und Pakete dazu neigen, sich abrupt zu ändern, sollten Sie Ihre Paketversionen auf die in diesem Branch festlegen, falls Sie Abhängigkeitsprobleme haben.

Nehmen Sie sich einen Moment Zeit, um zurückzugehen und die vier Framework-basierten Button-Komponentenimplementierungen, die wir gerade erstellt haben, zu vergleichen. Sie sind immer noch klein genug, um schnell einige interessante Unterschiede zu erkennen, wie Props übergeben werden, wie wir Props binden und wie CSS-Namenskollisionen among other subtle differences verhindert werden. Während ich weiterhin Komponenten zu AgnosticUI hinzufüge (das genau diese vier Frameworks unterstützt), überlege ich ständig, welches das beste Entwicklererlebnis bietet. Was meinen Sie?

Hausaufgaben

Wenn Sie zu denjenigen gehören, die Dinge gerne selbst herausfinden oder tiefer eintauchen, hier sind einige Ideen.

Button-Zustände

Die aktuellen Button-Stile berücksichtigen keine verschiedenen Zustände, wie z. B. :hover. Ich glaube, das ist eine gute erste Übung.

/* You should really implement the following states
   but I will leave it as an exercise for you to 
   decide how to and what values to use.
*/
.btn:focus {
  /* If you elect to remove the outline, replace it
     with another proper affordance and research how
     to use transparent outlines to support windows
     high contrast
  */
}
.btn:hover { }
.btn:visited { }
.btn:active { }
.btn:disabled { }

Varianten

Die meisten Button-Bibliotheken unterstützen viele Button-Variationen für Dinge wie Größen, Formen und Farben. Versuchen Sie, mehr als den primary-Modus zu erstellen, den wir bereits haben. Vielleicht eine secondary-Variante? Eine warning oder success? Vielleicht filled und outline? Auch hier können Sie sich die Buttons-Seite von AgnosticUI für Ideen ansehen.

CSS-Benutzereigenschaften

Wenn Sie noch nicht mit CSS-Benutzereigenschaften begonnen haben, empfehle ich Ihnen dies dringend. Sie können damit beginnen, sich die gemeinsamen Stile von AgnosticUI anzusehen. Ich greife dort stark auf Benutzereigenschaften zurück. Hier sind einige großartige Artikel, die erklären, was Benutzereigenschaften sind und wie Sie sie nutzen können

Typen

Nein... nicht Typisierungen, sondern das type-Attribut des <button>-Elements. Das haben wir in unserer Komponente nicht behandelt, aber es gibt eine Möglichkeit, die Komponente mit gültigen Typen, wie button, submit und reset, auf andere Anwendungsfälle zu erweitern. Das ist ziemlich einfach zu machen und wird die API des Buttons erheblich verbessern.

Weitere Ideen

Ach, man könnte so viel tun – Linting hinzufügen, es in Typescript konvertieren, die Barrierefreiheit prüfen usw.

Die aktuelle Svelte-Implementierung leidet unter einigen sehr lockeren Annahmen, da wir keine Absicherung haben, falls der gültige primary-Modus nicht übergeben wird – das würde eine fehlerhafte CSS-Klasse erzeugen

mode ? `btn-${mode}` : "",

Man könnte sagen: "Nun, .btn-garbage als Klasse ist nicht gerade schädlich." Aber es ist wahrscheinlich eine gute Idee, defensives CSS zu verwenden, wenn und wo immer möglich.

Potenzielle Fallstricke

Es gibt einige Dinge, die Sie beachten sollten, bevor Sie diesen Ansatz weiterverfolgen

  • Positionsbasierte CSS-Regeln, die auf der Struktur des Markup basieren, funktionieren nicht gut für die hier verwendeten CSS-Module-basierten Techniken.
  • Angular erschwert positionsbasierte Techniken noch weiter, da es ein :host-Element generiert, das jede Komponentenansicht darstellt. Das bedeutet, dass Sie diese zusätzlichen Elemente zwischen Ihrer Vorlage oder Markup-Struktur haben. Sie müssen dies umgehen.
  • Das Kopieren von Stilen über Workspace-Pakete hinweg ist für einige Leute ein Anti-Pattern. Ich rechtfertige es, weil ich glaube, dass die Vorteile die Kosten überwiegen; außerdem, wenn ich darüber nachdenke, wie Monorepos Symlinks und (nicht ganz narrensicheres) Hoisting verwenden, fühle ich mich bei diesem Ansatz nicht so schlecht.
  • Sie müssen die hier verwendeten entkoppelten Techniken abonnieren, also kein CSS-in-JS.

Ich glaube, dass alle Ansätze zur Softwareentwicklung ihre Vor- und Nachteile haben und Sie letztendlich entscheiden müssen, ob das Teilen einer einzigen CSS-Datei über Frameworks hinweg für Sie oder Ihr spezifisches Projekt funktioniert. Es gibt sicherlich andere Möglichkeiten, dies zu tun (z. B. die Verwendung von littlebuttons-css als npm-Paketabhängigkeit), falls erforderlich.

Fazit

Ich hoffe, ich habe Ihren Appetit angeregt und Sie sind jetzt wirklich daran interessiert, UI-Komponentenbibliotheken und/oder Design-Systeme zu erstellen, die nicht an ein bestimmtes Framework gebunden sind. Vielleicht haben Sie eine bessere Idee, wie Sie dies erreichen können – ich würde gerne Ihre Gedanken in den Kommentaren hören!

Ich bin sicher, Sie haben das ehrwürdige TodoMVC-Projekt gesehen und wie viele Framework-Implementierungen dafür erstellt wurden. Wäre es ähnlich nicht schön, eine UI-Komponentenbibliothek von Primitiven für viele Frameworks verfügbar zu haben? Open UI macht große Fortschritte, um native UI-Komponenten-Standardwerte ordnungsgemäß zu standardisieren, aber ich glaube, wir werden uns immer bis zu einem gewissen Grad einbringen müssen. Sicherlich fällt der Aufbau eines benutzerdefinierten Designsystems, das ein gutes Jahr dauert, schnell aus der Mode, und Unternehmen hinterfragen ernsthaft ihren ROI. Eine Art Gerüst ist erforderlich, um das Unterfangen praktikabel zu machen.

Die Vision von AgnosticUI ist es, einen relativ agnostischen Weg zu bieten, um schnell Design-Systeme zu erstellen, die nicht an ein bestimmtes Frontend-Framework gebunden sind. Wenn Sie dazu neigen, sich zu engagieren, ist das Projekt noch sehr früh und zugänglich, und ich würde mich über etwas Hilfe freuen! Außerdem sind Sie jetzt schon recht vertraut damit, wie das Projekt funktioniert, da Sie diesen Tutorial durchlaufen haben!