Hallo Kollegen!
Wir erinnern Sie daran, dass wir vor nicht allzu langer Zeit die 3. Ausgabe des legendären Buches
„Expressive JavaScript “ (Eloquent JavaScript) veröffentlicht haben - es wurde zum ersten Mal in russischer Sprache gedruckt, obwohl im Internet hochwertige Übersetzungen früherer Ausgaben
gefunden wurden .

Weder JavaScript noch die Forschungsarbeit von Herrn Haverbeke stehen jedoch natürlich nicht still. Wir setzen das Thema des ausdrucksstarken JavaScript fort und bieten eine Übersetzung des Artikels über das Design von Erweiterungen (am Beispiel der Entwicklung eines Texteditors), der Ende August 2019 im Blog des Autors veröffentlicht wurde
Heutzutage ist es Mode geworden, große Systeme in Form vieler separater Pakete zu strukturieren. Die diesem Ansatz zugrunde liegende treibende Idee ist, dass es besser ist, Personen nicht durch Implementierung einer Funktion auf eine bestimmte (von Ihnen vorgeschlagene) Funktion zu beschränken, sondern diese Funktion als separates Paket bereitzustellen, das eine Person zusammen mit dem Basissystempaket herunterladen kann.
Um dies zu tun, benötigen Sie im Allgemeinen ...
- Die Möglichkeit, nicht einmal benötigte Funktionen nicht einmal zu laden, ist besonders nützlich, wenn Sie mit clientseitigen Systemen arbeiten.
- Die Möglichkeit, die Funktionalität, die Ihren Anforderungen nicht entspricht, durch eine andere Implementierung zu ersetzen. Auf diese Weise wird auch der Druck auf die Kernmodule reduziert, die sonst alle möglichen praktischen Fälle abdecken müssten.
- Überprüfen der Kernel-Schnittstellen unter realen Bedingungen - durch Implementieren grundlegender Funktionen über der Client-Schnittstelle; Sie müssen die Benutzeroberfläche leistungsfähig genug machen, um zumindest die Unterstützung dieser Funktionen zu bewältigen - und sicherstellen, dass funktional ähnliche Elemente aus Code von Drittanbietern zusammengestellt werden können.
- Isolation zwischen Systemkomponenten. Die Projektteilnehmer müssen nach dem spezifischen Paket suchen, das sie interessiert. Pakete können versioniert, veraltet oder durch andere ersetzt werden, und all dies hat keine Auswirkungen auf den Kernel.
Dieser Ansatz ist mit bestimmten Kosten verbunden, die auf zusätzliche Komplexität hinauslaufen. Damit Benutzer loslegen können, können Sie ihnen ein All-Inclusive-Wrapper-Paket zur Verfügung stellen. Irgendwann müssen sie diesen Wrapper jedoch wahrscheinlich entfernen und die Installation und Konfiguration von Zusatzpaketen selbst in Angriff nehmen. Dies ist schwieriger als das Einschließen Neue Funktion in der monolithischen Bibliothek.
In diesem Artikel werde ich versuchen, verschiedene Möglichkeiten zu untersuchen, um Erweiterungsmechanismen zu entwerfen, die „Erweiterbarkeit im großen Maßstab“ beinhalten, und sofort neue Punkte für zukünftige Erweiterungen zu setzen.
ErweiterbarkeitWas brauchen wir von einem erweiterbaren System? Zunächst müssen Sie natürlich in der Lage sein, neue Verhaltensweisen über externen Code zu erstellen.
Dies reicht jedoch kaum aus. Lassen Sie mich abschweifen und Ihnen von einem dummen Problem erzählen, auf das ich einmal gestoßen bin. Ich entwickle einen Texteditor. In einer frühen Version des
Code-Editors konnte der Client
das Erscheinungsbild einer bestimmten Zeile
angeben . Es war ausgezeichnet - der Benutzer konnte diese oder jene Zeile auf diese Weise selektiv auswählen.
Wenn Sie außerdem versuchen, das Layout einer Zeile aus zwei voneinander unabhängigen Codefragmenten zu initiieren, treten sie sich gegenseitig auf die Fersen. Die zweite Erweiterung, die auf eine bestimmte Zeile angewendet wird, überschreibt die durch die erste vorgenommenen Änderungen. Oder wenn wir zu einem späteren Zeitpunkt versuchen, die Änderungen im Design, die wir mit seiner Hilfe vorgenommen haben, durch den ersten Code zu löschen, überschreiben wir als Ergebnis das aus dem zweiten Codefragment vorgenommene Design.
Die Lösung bestand darin, das
Hinzufügen (und
Entfernen ) von Code anstelle der Installation zuzulassen, sodass zwei Erweiterungen mit derselben Zeile interagieren konnten, ohne die Arbeit des anderen zu unterbrechen.
In einer allgemeineren Formulierung müssen Sie sicherstellen, dass Erweiterungen kombiniert werden können, auch wenn sie sich „überhaupt nicht bewusst“ sind - und dass während ihrer Interaktionen keine Konflikte auftreten.
Damit dies funktioniert, muss jede Erweiterung einer beliebigen Anzahl von Agenten gleichzeitig ausgesetzt sein. Wie die einzelnen Effekte verarbeitet werden, hängt vom jeweiligen Fall ab. Hier sind einige Strategien, die Sie vielleicht nützlich finden:
- Alle Änderungen werden wirksam. Wenn wir beispielsweise einem Element eine CSS-Klasse hinzufügen oder ein Widget an einer bestimmten Position im Text anzeigen, können diese beiden Schritte sofort ausgeführt werden. Natürlich ist eine bestimmte Reihenfolge erforderlich: Widgets sollten in einer vorhersehbaren und genau definierten Reihenfolge angezeigt werden.
- Änderungen stehen in der Pipeline an. Ein Beispiel für diesen Ansatz ist ein Handler, der Änderungen an einem Dokument filtern kann, bevor sie wirksam werden. Jedem Handler wird eine vom vorherigen Handler vorgenommene Änderung zugeführt, und der nachfolgende Handler kann solche Änderungen fortsetzen. Die Reihenfolge hier ist nicht entscheidend, kann aber von Bedeutung sein.
- Der First-Come-First-Served-Ansatz. Dieser Ansatz ist beispielsweise bei Ereignishandlern anwendbar. Jeder Handler hat die Möglichkeit, an dem Ereignis zu basteln, bis einer der Handler ankündigt, dass bereits alles erledigt ist, und der nächste Handler wiederum nicht gestört wird.
- Manchmal muss nur ein Wert ausgewählt werden, um beispielsweise den Wert eines bestimmten Konfigurationsparameters zu bestimmen. Hier wäre es angebracht, eine Art Operator (zum Beispiel logisch oder logisch und minimal, maximal) zu verwenden, um die potenzielle Eingabe auf einen einzelnen Wert zu reduzieren. Ein Editor kann beispielsweise in den schreibgeschützten Modus wechseln, wenn mindestens eine Erweiterung dies erfordert, oder die maximale Länge eines Dokuments kann das Minimum aller für diese Option angegebenen Werte sein.
In vielen solchen Situationen ist Ordnung wichtig. Hier meine ich die Einhaltung der Reihenfolge, in der die Effekte angewendet werden. Diese Reihenfolge muss kontrolliert und vorhersehbar sein.
Dies ist eine der Situationen, in denen zwingende Verlängerungssysteme normalerweise nicht sehr gut sind, deren Funktion von Nebenwirkungen abhängt. Für die Operation
addEventListener
eines
addEventListener
DOM-Modells müssen beispielsweise Ereignishandler in der Reihenfolge aufgerufen werden, in der sie registriert sind. Dies ist normal, wenn alle Anrufe von einem einzigen System gesteuert werden oder wenn die Reihenfolge der Anrufe nicht so wichtig ist. Wenn Sie jedoch viele Softwarekomponenten haben, die Handler unabhängig voneinander hinzufügen, kann es sehr schwierig sein, vorherzusagen, welche überhaupt aufgerufen werden.
Einfacher AnsatzLassen Sie mich ein konkretes Beispiel geben: Ich habe zuerst eine solche modulare Strategie angewendet, als ich ProseMirror entwickelt habe, ein System zum Bearbeiten von Rich Text. Der Kern dieses Systems an sich ist im Wesentlichen nutzlos: Es stützt sich auf zusätzliche Pakete, um die Struktur von Dokumenten zu beschreiben, Schlüssel zu binden und einen Verlauf von Stornierungen aufrechtzuerhalten. Obwohl es wirklich etwas schwierig ist, dieses System zu verwenden, hat es Anwendung in Programmen gefunden, in denen Sie Dinge konfigurieren müssen, die in klassischen Editoren nicht unterstützt werden.
Der ProseMirror-Erweiterungsmechanismus ist relativ einfach. Beim Erstellen eines Editors wird im Clientcode ein einzelnes Array verbundener Objekte angegeben. Jedes dieser Plug-In-Objekte kann verschiedene Aspekte des Editors beeinflussen und beispielsweise Statusdatenbits hinzufügen oder Schnittstellenereignisse verarbeiten.
Alle diese Aspekte wurden entwickelt, um mit einem geordneten Array von Konfigurationswerten unter Verwendung einer der oben beschriebenen Strategien zu arbeiten. Wenn Sie beispielsweise viele Wörterbücher mit Werten angeben müssen, hängt die Priorität der folgenden Erweiterungsinstanzen für die Schlüsselbindung von der Reihenfolge ab, in der Sie diese Instanzen angeben. Die erste Erweiterung für die Schlüsselbindung, die weiß, was mit diesem Tastenanschlag zu tun ist, verarbeitet sie.
Normalerweise erweist sich ein solcher Mechanismus als ziemlich leistungsfähig und sie können ihn zu ihrem Vorteil nutzen. Früher oder später erreicht das Erweiterungssystem jedoch eine solche Komplexität, dass es unpraktisch wird, es zu verwenden.
- Wenn das Plugin viele Effekte hat, können Sie nur hoffen, dass entweder alle die gleiche Priorität im Vergleich zu den anderen Plugins benötigen, oder Sie müssen sie in kleinere Plugins aufteilen, um ihre Reihenfolge richtig zu ordnen.
- Im Allgemeinen wird das Organisieren von Plugins sehr gewissenhaft, da dem Endbenutzer nicht immer klar ist, welche Plugins welche anderen Plugins stören können, wenn sie eine höhere Priorität erhalten. In solchen Fällen gemachte Fehler treten normalerweise nur zur Laufzeit auf, wenn eine bestimmte Gelegenheit genutzt wird, sodass sie leicht übersehen werden können.
- Wenn Plugins auf der Basis anderer Plugins erstellt werden, sollte diese Tatsache dokumentiert werden und hoffen, dass Benutzer nicht vergessen, die entsprechenden Abhängigkeiten anzugeben (bei Bedarf bei diesem Bestellschritt).
CodeMirror
Version 6 ist eine umgeschriebene Version des gleichnamigen
Code-Editors . In diesem Projekt versuche ich einen modularen Ansatz zu entwickeln. Dazu brauche ich ein ausdrucksstärkeres Erweiterungssystem. Lassen Sie uns einige der Herausforderungen diskutieren, mit denen wir uns beim Entwurf eines solchen Systems befassen mussten.
BestellungEs ist einfach, ein System zu entwerfen, mit dem Sie die Reihenfolge der Erweiterungen vollständig steuern können. Es ist jedoch viel schwieriger, ein System zu entwerfen, mit dem es angenehm zu arbeiten ist und das es Ihnen gleichzeitig ermöglicht, den Code verschiedener Erweiterungen ohne zahlreiche Eingriffe aus der Kategorie „Jetzt aufpassen“ zu kombinieren.
Wenn es um die Bestellung geht, möchte ich wirklich auf Prioritätswerte zurückgreifen. Beispielsweise gibt die CSS-
z-index
Eigenschaft die Nummer der Position an, die dieses Element in der Tiefe des Stapels einnimmt.
Wie Sie am Beispiel lächerlich großer
z-index
Werte sehen können, die manchmal in Stylesheets zu finden sind, ist diese Art der Prioritätsanzeige problematisch. Das Modul selbst weiß nicht, welche Prioritätswerte andere Module haben. Optionen sind nur Punkte in der Mitte eines undifferenzierten numerischen Bereichs. Sie können einen großen (oder zutiefst negativen) Wert festlegen, um zu versuchen, die äußersten Ränder dieses Spektrums zu erreichen, aber der Rest der Arbeit läuft auf Wahrsagerei hinaus.
Die Situation kann ein wenig verbessert werden, wenn Sie einen begrenzten Satz klar definierter Prioritätskategorien definieren, sodass Erweiterungen die allgemeine „Ebene“ ihrer Priorität charakterisieren können. Außerdem benötigen Sie eine bestimmte Methode, um die Links innerhalb der Kategorien zu unterbrechen.
Gruppierung und DeduplizierungWie oben erwähnt, kann es vorkommen, dass einige Erweiterungen beim Arbeiten andere verwenden, sobald Sie sich ernsthaft auf Erweiterungen verlassen. Wenn Sie Abhängigkeiten manuell verwalten, lässt sich dieser Ansatz nicht gut skalieren. Daher wäre es schön, wenn Sie eine Gruppe von Erweiterungen gleichzeitig aufrufen könnten.
Dieser Ansatz verschärft jedoch nicht nur das Prioritätsproblem weiter, sondern führt auch ein weiteres Problem ein: Viele andere Erweiterungen können von einer bestimmten Erweiterung abhängen, und wenn die Erweiterungen als Werte dargestellt werden, kann es durchaus sein, dass dieselbe Erweiterung mehrmals geladen wird . Bei einigen Arten von Erweiterungen, z. B. Ereignishandlern, ist dies normal. In anderen Fällen, z. B. bei einem Stornierungsverlauf und einer Tooltip-Bibliothek, ist dieser Ansatz verschwenderisch und kann sogar alles beschädigen.
Wenn wir also das Layout von Erweiterungen zulassen, führt dies zu einer zusätzlichen Komplexität unseres Systems in Bezug auf das Abhängigkeitsmanagement. Sie müssen in der Lage sein, solche Erweiterungen zu erkennen, die nicht dupliziert werden dürfen, und sie genau einzeln herunterladen.
Da jedoch in den meisten Fällen Erweiterungen konfiguriert werden können und daher nicht alle Instanzen derselben Erweiterung exakt gleich sind, können wir nicht nur eine Instanz verwenden und damit arbeiten. Wir müssen eine sinnvolle Zusammenführung solcher Fälle in Betracht ziehen (oder einen Fehler melden, wenn die Zusammenführung von Interesse für uns unmöglich ist).
ProjektHier werde ich allgemein beschreiben, was wir in CodeMirror 6 tun. Dies ist nur eine Skizze, keine fehlgeschlagene Lösung. Es ist möglich, dass sich dieses System weiterentwickelt, wenn sich die Bibliothek stabilisiert.
Das in diesem Ansatz verwendete Hauptprimitiv heißt Verhalten. Verhalten sind nur die Funktionen, auf denen Sie mit Erweiterungen aufbauen und Werte für sie angeben können. Ein Beispiel ist das Verhalten eines Statusfelds, in dem Sie mithilfe von Erweiterungen neue Felder hinzufügen und eine Beschreibung des Felds bereitstellen können. Ein weiteres Beispiel ist das Verhalten von Ereignishandlern in einem Browser. In diesem Fall können wir mithilfe von Erweiterungen eigene Handler hinzufügen.
Aus Sicht des Verhaltenskonsumenten geben die Verhaltensweisen selbst, die in einer bestimmten Instanz des Editors auf eine bestimmte Weise konfiguriert wurden, eine geordnete Folge von Werten an, und die vorhergehenden Werte haben eine höhere Priorität. Jedes Verhalten hat einen Typ, und die dafür angegebenen Werte müssen mit diesem Typ übereinstimmen.
Das Verhalten wird als Wert dargestellt, der sowohl zum Deklarieren einer Instanz des Verhaltens als auch zum Zugreifen auf die Werte des Verhaltens verwendet wird. Es gibt eine Reihe von integrierten Verhaltensweisen in der Bibliothek, aber externer Code kann sein eigenes Verhalten definieren. In der Erweiterung, die das Intervall zwischen Zeilennummern definiert, kann beispielsweise ein Verhalten definiert werden, mit dem ein anderer Code zusätzliche Markierungen in diesem Intervall hinzufügen kann.
Eine Erweiterung ist ein Wert, der beim Konfigurieren des Editors verwendet werden kann. Ein Array solcher Werte wird während der Initialisierung übergeben. Jede Erweiterung ist in null oder mehr Verhaltensweisen zulässig.
Eine solche einfache Erweiterung kann als Verhaltensinstanz betrachtet werden. Wenn wir einen Wert für das Verhalten angeben, gibt der Code den Wert der Erweiterung zurück, die dieses Verhalten generiert.
Eine Folge von Erweiterungen kann auch zu einer einzigen Erweiterung zusammengefasst werden. In der Konfiguration des Editors für die Arbeit mit einer bestimmten Programmiersprache können Sie beispielsweise mehrere andere Erweiterungen aufrufen, z. B. Grammatik zum Parsen und Hervorheben von Text, Informationen zum erforderlichen Einzug, eine Quelle für die automatische Vervollständigung, die Eingabeaufforderungen zum Vervollständigen von Zeilen in dieser Sprache korrekt anzeigt. Somit ist es möglich, eine einzelne Spracherweiterung zu erstellen, in der wir einfach alle diese entsprechenden Erweiterungen sammeln und zusammenfassen, was zu einem einzigen Wert führt.
Wenn Sie eine einfache Version eines solchen Systems erstellen, können Sie damit aufhören, indem Sie einfach alle verschachtelten Erweiterungen in einem Array von Verhaltenserweiterungen ausrichten. Dann könnten sie nach Verhaltenstypen gruppiert werden und dann geordnete Folgen von Verhaltenswerten erstellen.
Es bleibt jedoch die Deduplizierung und eine bessere Kontrolle über die Bestellung.
Die Werte von Erweiterungen, die sich auf den dritten Typ beziehen, eindeutige Erweiterungen, tragen lediglich zur Deduplizierung bei. Erweiterungen, die nicht zweimal im selben Editor instanziiert werden sollten, sind von dieser Art. Um eine solche Erweiterung zu definieren, müssen Sie den
Spezifikationstyp angeben,
dh den Typ des vom Erweiterungskonstruktor erwarteten Konfigurationswerts, und
eine Instanziierungsfunktion angeben, die ein Array dieser angegebenen Werte verwendet und die Erweiterung zurückgibt.
Eindeutige Erweiterungen erschweren das Auflösen einer Reihe von Erweiterungen in eine Reihe von Verhaltensweisen. Wenn der ausgerichtete Satz von Erweiterungen eindeutige Erweiterungen enthält, sollte der Auflösungsmechanismus den Typ der eindeutigen Erweiterung auswählen, alle Instanzen erfassen und die entsprechende Instanziierungsfunktion zusammen mit den Spezifikationen aufrufen und dann alle durch das Ergebnis ersetzen (in einer einzigen Kopie).
(Es gibt noch einen weiteren Haken: Sie müssen in der richtigen Reihenfolge aufgelöst werden. Wenn Sie zuerst die eindeutige Erweiterung X aktivieren, dann aber aufgrund der Auflösung ein weiteres X erhalten, ist dies falsch, da alle Instanzen von X zusammengefügt werden müssen. Seit der Instanziierungsfunktion Die Erweiterung ist sauber, das System bewältigt diese Situation durch Ausprobieren, startet den Prozess neu und zeichnet Informationen darüber auf, was in dieser Situation untersucht werden konnte.)
Schließlich müssen Sie das Problem mit den folgenden Regeln beheben. Der grundlegende Ansatz bleibt derselbe: Behalten Sie die Reihenfolge bei, in der Erweiterungen vorgeschlagen wurden. Zusammengesetzte Erweiterungen werden an dem Punkt, an dem sie auftreten, in derselben Reihenfolge ausgerichtet. Das Ergebnis der Auflösung einer eindeutigen Erweiterung wird beim ersten Einschalten eingefügt.
Erweiterungen können jedoch einige ihrer Untererweiterungen mit Kategorien verknüpfen, die eine andere Priorität haben. Das System bietet vier solcher Kategorien:
Fallback (wird wirksam, nachdem andere Dinge geschehen sind),
Standard (Standard),
Erweitern (höhere Priorität als die Masse) und
Überschreiben (sollte wahrscheinlich an erster Stelle stehen). In der Praxis erfolgt die Sortierung zuerst nach Kategorie und dann nach Startposition.
Eine Schlüsselbindungserweiterung mit niedriger Priorität und ein Ereignishandler mit normaler Priorität basieren darauf, dass es in Mode ist, eine zusammengesetzte Erweiterung aus dem Ergebnis einer Schlüsselbindungserweiterung (ohne zu wissen, aus welchem Verhalten sie besteht) mit einer Fallback-Prioritätsstufe und einer Instanz zu erstellen mit dem Verhalten des Event-Handlers.
Dieser Ansatz, mit dem Sie Erweiterungen kombinieren können, ohne darüber nachzudenken, was sie „im Inneren“ tun, scheint eine großartige Leistung zu sein. Zu den Erweiterungen, die wir weiter oben in diesem Artikel modelliert haben, gehören zwei Analysesysteme, die auf Syntaxebene dasselbe Verhalten aufweisen: Syntaxhervorhebungsdienst, intelligenter Einrückungsdienst, Stornierungsverlauf, Zeilenabstandsdienst, automatisch schließende Klammern, Schlüsselbindung und Mehrfachauswahl - alles funktioniert gut.
Es
gibt mehrere neue Konzepte, die ein Benutzer lernen muss, um dieses System verwenden zu können. Darüber hinaus ist die Arbeit mit einem solchen System in der Tat etwas komplizierter als mit herkömmlichen imperativen Systemen, die in der JavaScript-Community verwendet werden (wir nennen eine Methode zum Hinzufügen / Entfernen eines Effekts). Wenn die Erweiterungen jedoch ordnungsgemäß angeordnet sind, überwiegen die damit verbundenen Vorteile die damit verbundenen Kosten.