Ich möchte dir erklären, wie du deinen TypeScript-Code derart in ein Modul in einer Package-Umgebung bettest, dass andere Entwickler dein Modul verwenden können, egal ob sie TypeScript oder JavaScript verwenden.
Wenn ich im Umfeld von TypeScript oder JavaScript arbeite, nutze ich in erheblichem Maße Code, der von anderen entwickelt wurde. Dazu benutze ich Node Package Management npm
(meist über yarn
, einer alternativ-CLI zu npm
aber das führt zu weit).
Ich habe mal in meiner Umgebung herumgefragt, warum die Leute nicht auch mal selbst ein solches Paket veröffentlichen, wenn sie eine tolle Idee umgesetzt haben. Neben individuellen Gründen gibt es auch einen ziemlich durchgängigen Grund:
Die Leute wissen nicht, wie sie ein Paket so sauber verschnüren, dass mögichst viele Entwickler das Geschenk darin auch auspacken können!
Diesen Grund möchte ich beseitigen. Leider kann ich nicht auf alle verschiedenen möglichen Setups eingehen daher nehme ich so eine Art Minimal-Wunsch-setup von mir.
Ich werde hier vorstellen, wie ich
Jest
als Test-Framework einsetze um Unit-Tests zu implementieren undgit
auf deinem Systemnode
auf deinem Systemyarn
oder npm
(global) installierttsc
(global) installiert (yarn global add typescript
)TLDR: Wenn du gerne einfach einen Boilerplate hättest, habe ich das Ergebnis aller folgenden Schritte zusammengefasst in ein GitHub-Repo gelegt.
Ich werde zwei verschiedene Projekte starten:
ts-package-skeleton
, ein Typescript-Package, das eine Klasse Greeter
bereitstellt, deren Objektinstanzen "Hello World!"
ausgeben können. Auf ziemlich komplizierte Art und Weise.ts-package-consumer
, ein Package, das Greeter
aus ts-package-skeleton
verwendet um "Hello World!"
auszugeben.Mein neues Package existiert bisher nur in meinem Kopf. Es braucht ein Verzeichnis.
mkdir ts-package-skeleton && cd $_
Der erste Teil erstellt das Verzeichnis und der zweite Teil wechselt in dieses neu erstellte Verzeichnis. In diesem Verzeichnis bleibe ich ab jetzt. Ich verlasse es nicht mehr, bevor ich fertig bin. Alle Befehle gehen von diesem Verzeichnis aus.
Ich möchte ein git-Repo haben. Also initialisiere ich eines
git init
Ich möchte ein Node-Package machen und dafür initialisiere ich es mit yarn init
:
Da, wo ENTER
steht habe ich einfach Enter gedrückt.
➜ ts-package-skeleton git:(master) yarn init
yarn init v1.22.0
question name (ts-package-skeleton): ENTER
question version (1.0.0): 0.0.0
question description: A basic package providing boilerplate
and a TypeScript module implementing Hello World!
question entry point (index.js): dist/index.js
question repository url: ENTER
question author: Julian Strecker
question license (MIT): ENTER
question private: ENTER
success Saved package.json
Done in 48.44s.
Das erzeugt eine eine package.json-Datei, die so aussieht:
{
"name": "ts-package-skeleton",
"version": "0.0.0",
"description": "A basic package providing boilerplate and a TypeScript module implementing Hello World!",
"main": "dist/index.js",
"author": "Julian Strecker",
"license": "MIT"
}
Die package.json
ist die zentrale Beschreibung des Pakets, das ich jetzt erstelle. Hier ist mir bis hierhin eigentlich alles austauschbar oder selbsterklärend außer
"main": dist/index.js
. Das bedeutet, dass die Einstiegsdatei für Benutzer meines Moduls in dist/index.js liegt (und nicht, wie default, in index.js im root des Pakets).
Da ich in TypeScript programmieren werde, mache ich noch die Initialisierung der tsconfig.json
mittels
tsc --init
Ergebnis ist, dass eine neue Datei namens tsconfig.json
entsteht. Sie hat sehr viele auskommentierte Zeilen und Textkommentare. Da ich nicht genau weiß, was bei dir generiert wird, schreibe ich mal auf, was ich daraus gemacht habe. Ich habe alle Kommentare entfernt und noch etwas hinzugefügt:
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"declaration": true,
"outDir": "./dist/",
"strict": true,
"esModuleInterop": true
}
}
Ich erkläre die Bedeutung der Zeilen:
compilerOptions
: Ich liefere an den Benutzer meines Moduls nur kompiliertes JavaScript aus. die compilerOptions
beschreiben, wie und wohin gebaut werden soll.target
: Die JavaScript-Version für die mein Paket Compiliert werden kann. ES5 wird so grob von allen Browsern vollständig unterstützt.module
: Es gibt verschiedene Konventionen, wie Module geschrieben werden können, sodass sie sich gegenseitig verwenden können (CommonJS, AMD, UMD, ES6, ESNext, ...). Ich entscheide mich für CommonJS, weil es eine gute Unterstützung bietet.declaration
: Ganz wichtig und nicht default! Ich möchte den Benutzern meines TypeScript Moduls zwar das JavaScipt-Kompilat geben, den TypeScript-Nutzern aber den Bonus von TypeScript nicht vorenthalten. Also brauchen sie TypeDefinitionen. Diese werden von tsc
(TypeScript Compile) mit erzeugt, wenn ich hier auf true
stelle.outDir
: Auch wichtig und nicht default! Hier gebe ich an, dass das Kompilat im Ordner dist
landen soll. Du erinnerst dich hoffentlich noch an die package.json
und main
...strict
: Auf true
gesetzt schaltet strict striktes TypeChecking an. Für mich bedeutet das, dass ich TypeScript schreiben muss und nicht JavaScript oder Typenangaben schreiben kann. Das ist wichtig, damit andere TypeScript-Programmierer mein Paket mit ihren TypeDefinitions nutzen können.esModuleInterop
: Ermöglicht das Importieren von CommonJS Modulen in eine ES6-TypeScript-Umgebung. Hier gibt es eine gute Erklärung dazu.Im Moment sieht mein Projektverzeichnis so aus und belegt 256kB Speicher.
.
├── .git
│ └── ...
├── package.json
└── tsconfig.json
Ich habe alles für TypeScript vorbereitet und habe tsc
auch für mich (global) zur Verfügung. Ich möchte aber das gesamte Ökosystem geschlossen im Paket definiert haben. Daher lege ich fest, dass typescript
eine Entwicklungs-Dependency von meinem Paket ist.
yarn add -D typescript
Das -D
bedeutet, dass es sich bei typescript
um eine Entwicklungs-Abhängigkeit handelt. Für den Verwender von ts-package-skeleton
ist TypeScript nicht notwnedig.
Ich habe durch die Installation von typescript
jetzt viel, viel mehr Daten in meinem Projektverzeichnis - und zwar in node_modules
. Mein Projekt ist jetzt schon 51MB groß geworden. Diese ganzen Daten herunterzuladen, möchte ich nicht gitlab, github oder anderen Entwicklern zumuten und ignoriere sie deshalb aus meinem Repository heraus:
echo "node_modules" > .gitignore
In die .gitignore
können noch andere Verzeichnisse und Dateien aufgenommen und damit aus dem Repo exkludiert werden. Meine .gitignore
sieht so aus:
/dist
/.idea
node_modules
Erkläreung:
dist
: Gebauter Code kommt nicht ins Repo, weil er aus dem Repo erzeugt wird. Er ist redundant.idea
: Ich benutze PHPStorm. Dessen Einstellungen etc. sind zwar für mich aber nicht für alle interessante. Raus damit.node_modules
: Richtig viel Datengewicht. Und auch redundant, weil die Information über benötigte Pakete und ihre Versionen in der package.json
stehen.Mein Quellcode soll im Verzeichnis src
unter dem Projektverzeichnis liegen. Ich lege das Verzeichnis und die Haupt-Quelldatei an:
mkdir src; touch src/index.ts
Diese index.ts
enthält jetzt den eigentlichen Code, den ich bereitstellen möchte.
Merke: bereitstellen =
export
.
Der Code der Datei bringt die Ausgabe von "Hello World"
auf eine ganz neue Stufe der Komplexität. Der Grund ist, dass ich natürlich auch Konzepte von TypeScript verwenden möchte, um zu zeigen, dass das Kontrukt in einer JavaScript-Umgebung dennoch funktioniert. Hier ist er:
const greetingPrefix = 'Hello';
const greetingSuffix = '!';
export class Greeter {
get name(): string {
return this._name;
}
private readonly _name: string;
private _greetingString: string = '';
constructor(greetedName: string) {
this._name = greetedName;
this.generateGreetingString();
}
private generateGreetingString(): void {
this._greetingString = `${greetingPrefix} ${this._name}${greetingSuffix}`;
}
public greet(callback: (greeting: string) => void): void {
callback(this._greetingString);
}
}
Das ganze kann ich jetzt schon kompilieren, indem ich yarn tsc
ausführe.
.
├── dist <-- Ergebnis des Build
│ ├── index.d.ts <-- TypeDefinitionen
│ └── index.js <-- JavaScript Code ES5-kompatibel
├── .git <-- Git Daten
│ └── ...
├── .gitignore <-- exkludierte Dateien
├── node_modules <-- Abhängigkeiten
├── package.json <-- Paketinformationen
├── src <-- Quellcode
│ └── index.ts <-- Einzige Quelldatei
├── tsconfig.json <-- TypeScript Compiler-Informationen
└── yarn.lock <-- Informationen über installierte Abhängigkeiten
Um den Befehl yarn tsc
durch etwas eingängigeres zu ersetzen, möchte ich in der package.json
ein Script definieren. Außerdem möchte ich die TypeDefinitions bekanntmachen. Damit Entwickler die mein Paket nutzen, diese direkt eingebunden bekommen in ihrer IDE.
Dafür ändere ich die package.json
derart ab, dass sie so beginnt (types
und scripts
habe ich hinzugefügt):
{
"name": "ts-package-skeleton",
"version": "0.0.0",
"description": "A basic package providing boilerplate and a TypeScript module implementing Hello World!",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"test": "jest --config jestconfig.json",
"build": "tsc",
"lint": "tslint -p tsconfig.json"
},
"author": "Julian Strecker",
"license": "MIT",
...
Ab jetzt kann ich mit yarn build
bauen. Die andern Befehle gehen noch nicht.
Für Linting mit TypeScript brauche ich (nur als Entwickler) tslint
:
yarn add -D tslint
Es bedarf jetzt noch einer tslint.json
touch tslint.json
mit Inhalt. Ich habe mal meine aus einem anderen Projekt verwendet. Hier gilt: Jeder Geschmack ist einzigartig und daher kann jede tslint.json
auch anders aussehen! Mein Ansatz soll nur als Inspiriation dienen.
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended"
],
"jsRules": {},
"rules": {
"eofline": true,
"no-trailing-whitespace": true,
"ordered-imports": false,
"comment-format": false,
"object-literal-sort-keys": false,
"interface-name": false,
"trailing-comma": [
true,
{
"multiline": {
"objects": "never",
"arrays": "never",
"functions": "never",
"typeLiterals": "ignore"
},
"esSpecCompliant": true
}
],
"quotemark": [
true,
"single"
],
"no-console": false,
"one-line": false,
"no-consecutive-blank-lines": true,
"curly": true,
"cyclomatic-complexity": [
true,
5
],
"no-irregular-whitespace": true,
"whitespace": [
true,
"check-postbrace",
"check-module",
"check-preblock",
"check-decl",
"check-branch",
"check-separator"
],
"no-magic-numbers": [
true,
-1,
0,
1,
2,
3,
10
],
"only-arrow-functions": false,
"no-shadowed-variable": true,
"no-var-keyword": true,
"no-duplicate-imports": true,
"space-within-parens": true,
"variable-name": {
"options": "allow-leading-underscore"
}
},
"rulesDirectory": []
}
Da ich einige Dateien im Repo habe, die explizit für die Entwicklung, nicht aber für die Verwendung des Pakets nötig sind. Ich möchte also in der package.json
angeben, welche Dateien genau im Paket veröffentlicht werden sollen. Das geht durch Hinzufügen des Schlüssels files
:
...
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
...
Also alle Dateien und Ordner innerhalb des dist-Orders oder seinen Kindern usw. sollen publiziert werden.
Testen ist ein sehr wichtiger Aspekt der Softwareentwicklung. Du solltest immer daran denken, deinen Projekten sinnvolle Tests hinzuzufügen. Du kannst diesen Punkt auch überspringen und später vielleicht hinzufügen.
Für das Testen meines Codes benutze ich Jest. Damit ich Jest verwenden kann, muss ich einige Pakete installieren:
yarn add -D @types/jest @types/node jest ts-jest
touch jestconfig.json
Die jestconfig.json
, die ich verwende, ist ganz default für TypeScript-Proejkte und sieht so aus:
{
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts)$",
"moduleFileExtensions": ["ts", "js", "json", "node"]
}
Sie beschreibt im Grunde, welche Dateien als Test-Dateien benutzt werden sollen. Dies sind Dateien, die in Ordnern liegen die __tests__
heißen und die mit test.ts
oder test.js
enden. So eine Datei lege ich jetzt an:
mkdir src/__tests__ && touch src/__tests__/Greeter.test.ts
Ihr Inhalt sieht so aus:
import { Greeter } from '../index';
// @ts-ignore
global.console = {
warn: jest.fn(),
log: jest.fn()
};
test('Test the Greeter Construction', () => {
const greeter = new Greeter('World');
expect(greeter).toBeTruthy();
});
test('Test Name Setting and Getting', () => {
expect(new Greeter('World').name).toBe('World');
expect(new Greeter('').name).toBe('');
});
test('Greeting Logging', () => {
const greeter = new Greeter('World');
const consoleLogger = (greeting: string) => console.log(greeting);
greeter.greet(consoleLogger);
expect(console.log).toHaveBeenCalledWith('Hello World!');
});
Mit yarn test
kann ich jetzt meinen Code testen.
Das Paket ist fertig!
Schließlich versetze ich mich in die Position eines Entwicklers, der den großartigen Greeter verwenden möchte. Er muss in seinem Projekt eigentlich nicht viel tun:
Er muss das Paket als Dependency verwenden. Da das Paket aber gar nicht bei npm liegt, sondern in einem Verzeichnis auf meiner Festplatte sieht das so aus:
"dependencies": {
"ts-package-skeleton": "../ts-package-skeleton"
},
Er muss in seinem TypeScript-Code den Greeter
importieren und benutzen:
import { Greeter } from "ts-package-skeleton";
const greeter = new Greeter('World');
greeter.greet((greeting) => console.log(greeting));
Das Paket wird erfolgreich genutzt. Ich habs getestet. Viel Spaß beim erstellen eines wiederverwendbaren Pakets.