Shell-Skripting mit Node.js
Sie können die Offline-Version dieses Buches (HTML, PDF, EPUB, MOBI) kaufen und damit die kostenlose Online-Version unterstützen.
(Werbung, bitte nicht blockieren.)

5 Pakete: JavaScript-Einheiten für die Softwareverteilung



Dieses Kapitel erklärt, was npm-Pakete sind und wie sie mit ESM-Modulen interagieren.

Vorausgesetztes Wissen: Ich gehe davon aus, dass Sie mit der Syntax von ECMAScript-Modulen lose vertraut sind. Wenn nicht, können Sie Kapitel „Module“ in „JavaScript for impatient programmers“ lesen.

5.1 Was ist ein Paket?

Im JavaScript-Ökosystem ist ein Paket eine Möglichkeit, Softwareprojekte zu organisieren: Es ist ein Verzeichnis mit einer standardisierten Struktur. Ein Paket kann alle Arten von Dateien enthalten – zum Beispiel

Ein Paket kann von anderen Paketen (die als seine Abhängigkeiten bezeichnet werden) abhängen, die Folgendes enthalten:

Die Abhängigkeiten eines Pakets werden innerhalb dieses Pakets installiert (wir werden bald sehen, wie).

Eine häufige Unterscheidung zwischen Paketen ist:

Der nächste Unterabschnitt erklärt, wie Pakete veröffentlicht werden können.

5.1.1 Veröffentlichen von Paketen: Paket-Repositories, Paketmanager, Paketnamen

Die Hauptmethode zur Veröffentlichung eines Pakets ist der Upload auf ein Paket-Repository – ein Online-Software-Repository. Der De-facto-Standard ist das npm-Repository, aber es ist nicht die einzige Option. Zum Beispiel können Unternehmen ihre eigenen internen Repositories hosten.

Ein Paketmanager ist ein Kommandozeilenwerkzeug, das Pakete aus einem Repository (oder anderen Quellen) herunterlädt und sie lokal oder global installiert. Wenn ein Paket Bin-Skripte enthält, macht es diese auch lokal oder global verfügbar.

Der beliebteste Paketmanager heißt npm und wird mit Node.js ausgeliefert. Sein Name stand ursprünglich für „Node Package Manager“. Später, als npm und das npm-Repository nicht nur für Node.js-Pakete verwendet wurden, wurde die Definition zu „npm ist kein Paketmanager“ geändert (Quelle).

Es gibt andere beliebte Paketmanager wie yarn und pnpm. Alle diese Paketmanager verwenden standardmäßig das npm-Repository.

Jedes Paket im npm-Repository hat einen Namen. Es gibt zwei Arten von Namen:

5.2 Die Dateisystemstruktur eines Pakets

Sobald ein Paket my-package vollständig installiert ist, sieht es fast immer so aus:

my-package/
  package.json
  node_modules/
  [More files]

Welchen Zweck haben diese Dateisystemeinträge?

Manche Pakete haben auch die Datei package-lock.json, die neben package.json liegt: Sie speichert die genauen Versionen der installierten Abhängigkeiten und wird bei jeder weiteren Installation von Abhängigkeiten über npm auf dem neuesten Stand gehalten.

5.2.1 package.json

Dies ist eine anfängliche package.json, die über npm erstellt werden kann:

{
  "name": "my-package",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Welchen Zweck haben diese Eigenschaften?

Weitere nützliche Eigenschaften:

Weitere Informationen zu package.json finden Sie in der npm-Dokumentation.

5.2.2 Die Eigenschaft "dependencies" von package.json

So sehen die Abhängigkeiten in einer package.json-Datei aus:

"dependencies": {
  "minimatch": "^5.1.0",
  "mocha": "^10.0.0"
}

Die Eigenschaften geben sowohl die Namen der Pakete als auch Einschränkungen für deren Versionen an.

Versionen selbst folgen dem semantischen Versionierungsstandard. Sie bestehen aus bis zu drei Zahlen (die zweite und dritte Zahl sind optional und standardmäßig null), die durch Punkte getrennt sind:

  1. Hauptversion: Diese Zahl ändert sich, wenn ein Paket in inkompatiblen Weise geändert wird.
  2. Nebenversion: Diese Zahl ändert sich, wenn Funktionalität auf abwärtskompatible Weise hinzugefügt wird.
  3. Patchversion: Diese Zahl ändert sich, wenn abwärtskompatible Fehlerkorrekturen vorgenommen werden.

Node's Versionsbereiche werden im semver-Repository erklärt. Beispiele sind:

5.2.3 Die Eigenschaft "bin" von package.json

So können wir npm mitteilen, Module als Shell-Skripte zu installieren:

"bin": {
  "my-shell-script": "./src/shell/my-shell-script.mjs",
  "another-script": "./src/shell/another-script.mjs"
}

Wenn wir ein Paket mit diesem "bin"-Wert global installieren, stellt Node.js sicher, dass die Befehle my-shell-script und another-script auf der Kommandozeile verfügbar sind.

Wenn wir das Paket lokal installieren, können wir die beiden Befehle in Paket-Skripten oder über den Befehl npx verwenden.

Ein String ist auch als Wert von "bin" zulässig:

{
  "name": "my-package",
  "bin": "./src/main.mjs"
}

Dies ist eine Abkürzung für:

{
  "name": "my-package",
  "bin": {
    "my-package": "./src/main.mjs"
  }
}

5.2.4 Die Eigenschaft "license" von package.json

Der Wert der Eigenschaft "license" ist immer ein String mit einer SPDX-Lizenz-ID. Zum Beispiel verweigert der folgende Wert anderen das Recht, ein Paket unter beliebigen Bedingungen zu nutzen (was nützlich ist, wenn ein Paket unveröffentlicht bleibt):

"license": "UNLICENSED"

Die SPDX-Website listet alle verfügbaren Lizenz-IDs auf. Wenn es Ihnen schwerfällt, eine auszuwählen, kann die Website „Choose an open source license“ helfen – zum Beispiel ist dies der Rat, wenn Sie „es einfach und permissiv haben möchten“:

Die MIT-Lizenz ist kurz und bündig. Sie erlaubt es den Leuten, fast alles mit Ihrem Projekt zu machen, wie z. B. Closed-Source-Versionen zu erstellen und zu verteilen.

Babel, .NET und Rails verwenden die MIT-Lizenz.

Sie können diese Lizenz wie folgt verwenden:

"license": "MIT"

5.3 Archivieren und Installieren von Paketen

Pakete im npm-Repository werden oft auf zwei verschiedene Arten archiviert:

In beiden Fällen wird das Paket ohne seine Abhängigkeiten archiviert – die wir installieren müssen, bevor wir es verwenden können.

Wenn ein Paket in einem Git-Repository gespeichert ist:

Wenn ein Paket im npm-Repository veröffentlicht wird:

Entwicklungsabhängigkeiten (Eigenschaft devDependencies in package.json) werden nur während der Entwicklung installiert, aber nicht, wenn wir das Paket aus dem npm-Repository installieren.

Beachten Sie, dass unveröffentlichte Pakete in Git-Repositories während der Entwicklung ähnlich wie veröffentlichte Pakete behandelt werden.

5.3.1 Installieren eines Pakets aus Git

Um ein Paket pkg aus Git zu installieren, klonen wir sein Repository und

cd pkg/
npm install

Dann werden die folgenden Schritte ausgeführt:

Wenn das Root-Paket keine package-lock.json-Datei hat, wird diese während der Installation erstellt (wie erwähnt, haben Abhängigkeiten diese Datei nicht).

In einem Abhängigkeitsbaum kann dieselbe Abhängigkeit mehrmals existieren, möglicherweise in verschiedenen Versionen. Es gibt Wege, Duplikate zu minimieren, aber das liegt außerhalb des Rahmens dieses Kapitels.

5.3.1.1 Neuinstallieren eines Pakets

Dies ist eine (etwas grobe) Methode zur Behebung von Problemen in einem Abhängigkeitsbaum:

cd pkg/
rm -rf node_modules/
rm package-lock.json
npm install

Beachten Sie, dass dies dazu führen kann, dass andere, neuere Pakete installiert werden. Wir können dies vermeiden, indem wir package-lock.json nicht löschen.

5.3.2 Erstellen eines neuen Pakets und Installieren von Abhängigkeiten

Es gibt viele Werkzeuge und Techniken zum Einrichten neuer Pakete. Dies ist eine einfache Methode:

mkdir my-package
cd my-package/
npm init --yes

Danach sieht das Verzeichnis so aus:

my-package/
  package.json

Diese package.json enthält den anfänglichen Inhalt, den wir bereits gesehen haben.

5.3.2.1 Installieren von Abhängigkeiten

Derzeit hat my-package keine Abhängigkeiten. Nehmen wir an, wir möchten die Bibliothek lodash-es verwenden. So installieren wir sie in unser Paket:

npm install lodash-es

Dieser Befehl führt die folgenden Schritte aus:

5.4 Module über Deskriptoren referenzieren

Code in anderen ECMAScript-Modulen wird über import-Anweisungen (Zeile A und Zeile B) abgerufen:

// Static import
import {namedExport} from 'https://example.com/some-module.js'; // (A)
console.log(namedExport);

// Dynamic import
import('https://example.com/some-module.js') // (B)
.then((moduleNamespace) => {
  console.log(moduleNamespace.namedExport);
});

Sowohl statische als auch dynamische Importe verwenden Moduldeskriptoren, um auf Module zu verweisen:

Es gibt drei Arten von Moduldeskriptoren:

5.4.1 Dateinamenserweiterungen in Moduldeskriptoren

Vorbehalt bei Bare-Deskriptoren von Stil 3: Wie die Dateinamenserweiterung interpretiert wird, hängt von der Abhängigkeit ab und kann vom importierenden Paket abweichen. Zum Beispiel kann das importierende Paket .mjs für ESM-Module und .js für CommonJS-Module verwenden, während die von der Abhängigkeit exportierten ESM-Module Bare-Pfade mit der Dateinamenserweiterung .js haben können.

5.5 Moduldeskriptoren in Node.js

Sehen wir uns an, wie Moduldeskriptoren in Node.js funktionieren.

5.5.1 Auflösen von Moduldeskriptoren in Node.js

Der Node.js-Auflösungsalgorithmus funktioniert wie folgt:

Dies ist der Algorithmus:

Das Ergebnis des Auflösungsalgorithmus muss auf eine Datei verweisen. Das erklärt, warum absolute und relative Deskriptoren immer Dateinamenserweiterungen haben. Bare-Deskriptoren haben sie meistens nicht, da sie Abkürzungen sind, die in Paket-Exports nachgeschlagen werden.

Moduldateien haben normalerweise diese Dateinamenserweiterungen:

Wenn Node.js Code aus stdin, --eval oder --print ausführt, verwenden wir die folgende Kommandozeilenoption, damit er als ES-Modul interpretiert wird:

--input-type=module

5.5.2 Paket-Exports: Steuern, was andere Pakete sehen

In diesem Unterabschnitt arbeiten wir mit einem Paket, das die folgende Dateistruktur hat:

my-lib/
  dist/
    src/
      main.js
      util/
        errors.js
      internal/
        internal-module.js
    test/

Paket-Exports werden über die Eigenschaft "exports" in package.json definiert und unterstützen zwei wichtige Funktionen:

Erinnern wir uns an die drei Stile von Bare-Deskriptoren:

Paket-Exports helfen uns bei allen drei Stilen:

5.5.2.1 Stil 1: Konfigurieren, welche Datei die (Bare-Deskriptor für) das Paket repräsentiert

package.json:

{
  "main": "./dist/src/main.js",
  "exports": {
    ".": "./dist/src/main.js"
  }
}

Wir geben "main" nur zur Abwärtskompatibilität an (mit älteren Bundlern und Node.js 12 und älter). Andernfalls reicht der Eintrag für "." aus.

Mit diesen Paket-Exports können wir nun wie folgt von my-lib importieren:

import {someFunction} from 'my-lib';

Dies importiert someFunction() aus dieser Datei:

my-lib/dist/src/main.js
5.5.2.2 Stil 2: Zuordnung von Subpfaden ohne Erweiterung zu Moduldateien

package.json:

{
  "exports": {
    "./util/errors": "./dist/src/util/errors.js"
  }
}

Wir ordnen den Deskriptor-Subpfad 'util/errors' einer Moduldatei zu. Dies ermöglicht den folgenden Import:

import {UserError} from 'my-lib/util/errors';
5.5.2.3 Stil 2: Bessere Subpfade ohne Erweiterungen für einen Unterbaum

Der vorherige Unterabschnitt erklärte, wie eine einzelne Zuordnung für einen Subpfad ohne Erweiterung erstellt wird. Es gibt auch eine Möglichkeit, mehrere solcher Zuordnungen über einen einzigen Eintrag zu erstellen:

package.json:

{
  "exports": {
    "./lib/*": "./dist/src/*.js"
  }
}

Jede Datei, die ein Nachkomme von ./dist/src/ ist, kann nun ohne Dateinamenserweiterung importiert werden.

import {someFunction} from 'my-lib/lib/main';
import {UserError}    from 'my-lib/lib/util/errors';

Beachten Sie die Sternchen in diesem "exports"-Eintrag:

"./lib/*": "./dist/src/*.js"

Dies sind eher Anweisungen, wie Subpfade zu tatsächlichen Pfaden zugeordnet werden, als Wildcards, die Dateipfadfragmente abgleichen.

5.5.2.4 Stil 3: Zuordnung von Subpfaden mit Erweiterungen zu Moduldateien

package.json:

{
  "exports": {
    "./util/errors.js": "./dist/src/util/errors.js"
  }
}

Wir ordnen den Deskriptor-Subpfad 'util/errors.js' einer Moduldatei zu. Dies ermöglicht den folgenden Import:

import {UserError} from 'my-lib/util/errors.js';
5.5.2.5 Stil 3: Bessere Subpfade mit Erweiterungen für einen Unterbaum

package.json:

{
  "exports": {
    "./*": "./dist/src/*"
  }
}

Hier kürzen wir die Moduldeskriptoren des gesamten Unterbaums unter my-package/dist/src:

import {InternalError} from 'my-package/util/errors.js';

Ohne die Exports wäre die Importanweisung:

import {InternalError} from 'my-package/dist/src/util/errors.js';

Beachten Sie die Sternchen in diesem "exports"-Eintrag:

"./*": "./dist/src/*"

Dies sind keine Dateisystem-Globs, sondern Anweisungen, wie externe Moduldeskriptoren internen zugeordnet werden.

5.5.2.6 Exponieren eines Unterbaums und gleichzeitiges Ausblenden von Teilen davon

Mit dem folgenden Trick exponieren wir alles im Verzeichnis my-package/dist/src/ mit Ausnahme von my-package/dist/src/internal/:

"exports": {
  "./*": "./dist/src/*",
  "./internal/*": null
}

Beachten Sie, dass dieser Trick auch funktioniert, wenn Unterbäume ohne Dateinamenserweiterungen exportiert werden.

5.5.2.7 Bedingte Paket-Exports

Wir können Exports auch bedingt machen: Dann bildet ein gegebener Pfad unterschiedliche Werte ab, je nachdem, in welchem Kontext ein Paket verwendet wird.

Node.js vs. Browser. Zum Beispiel könnten wir unterschiedliche Implementierungen für Node.js und für Browser bereitstellen:

"exports": {
  ".": {
    "node": "./main-node.js",
    "browser": "./main-browser.js",
    "default": "./main-browser.js"
  }
}

Die Bedingung "default" passt, wenn keine andere Bedingung zutrifft, und muss zuletzt kommen. Wenn zwischen Plattformen unterschieden wird, ist eine empfohlen, da sie neue und/oder unbekannte Plattformen berücksichtigt.

Entwicklung vs. Produktion. Ein weiterer Anwendungsfall für bedingte Paket-Exports ist der Wechsel zwischen „Entwicklungs“- und „Produktions“-Umgebungen:

"exports": {
  ".": {
    "development": "./main-development.js",
    "production": "./main-production.js",
  }
}

In Node.js können wir eine Umgebung wie folgt angeben:

node --conditions development app.mjs

5.5.3 Paket-Importe

Paket-Importe ermöglichen es einem Paket, Abkürzungen für Moduldeskriptoren zu definieren, die es selbst intern verwenden kann (während Paket-Exports Abkürzungen für andere Pakete definieren). Dies ist ein Beispiel:

package.json:

{
  "imports": {
    "#some-pkg": {
      "node": "some-pkg-node-native",
      "default": "./polyfills/some-pkg-polyfill.js"
    }
  },
  "dependencies": {
    "some-pkg-node-native": "^1.2.3"
  }
}

Der Paket-Import # ist bedingt (mit denselben Funktionen wie bedingte Paket-Exports):

(Nur Paket-Importe können sich auf externe Pakete beziehen, Paket-Exports können dies nicht.)

Welche Anwendungsfälle gibt es für Paket-Imports?

Seien Sie vorsichtig bei der Verwendung von Paket-Imports mit einem Bundler: Diese Funktion ist relativ neu und Ihr Bundler unterstützt sie möglicherweise nicht.

5.5.4 Importe über das node:-Protokoll

Node.js verfügt über viele integrierte Module wie 'path' und 'fs'. Alle sind sowohl als ES-Module als auch als CommonJS-Module verfügbar. Ein Problem bei ihnen ist, dass sie durch in node_modules installierte Module überschrieben werden können, was sowohl ein Sicherheitsrisiko darstellt (wenn es versehentlich geschieht) als auch ein Problem ist, wenn Node.js neue integrierte Module einführen möchte und ihre Namen bereits von npm-Paketen belegt sind.

Wir können das node:-Protokoll verwenden, um klarzustellen, dass wir ein integriertes Modul importieren möchten. Zum Beispiel sind die folgenden beiden Importanweisungen weitgehend äquivalent (wenn kein npm-Modul mit dem Namen 'fs' installiert ist):

import * as fs from 'node:fs/promises';
import * as fs from 'fs/promises';

Ein weiterer Vorteil der Verwendung des node:-Protokolls ist, dass wir sofort erkennen, dass ein importiertes Modul integriert ist. Angesichts der vielen integrierten Module hilft dies beim Lesen von Code.

Da node:-Deskriptoren ein Protokoll haben, gelten sie als absolut. Deshalb werden sie nicht in node_modules nachgeschlagen.