borisbaer: JavaScript greift auf bereits entfernte DOM-Elemente zu?

problematische Seite

Hallo zusammen,

ich habe ein js script geschrieben, welches das Hin- und Herblättern zwischen mehreren Seiten ermöglichen soll (change-page.js). Jetzt wird über ein anderes script (maps.js) der Inhalt von .maps .wrapper über fetch() ausgetauscht.

Es funktioniert auch oberfläch alles, doch wenn ich in die Konsole schaue, dann gibt es eine Fehlermeldung, wenn ich mit den Pfeiltasten oder Pfeil-Buttons umblättere: Uncaught TypeError: node.parentNode is null. Man kann den Fehler leicht rekonstruieren, wenn man über die Navigation „Reise zum Nexus“ aufruft (diese und „Palast von Boletaria“ sind momentan die einzigen beiden aufrufbaren Karten). Blättert man dann hin und her, dann erscheint irgendwann obiger Fehler in der Konsole.

Ich habe versucht, ein wenig Debugging zu betreiben, und mein Eindruck ist, dass hier das script change-page.js zweimal schießt, einmal für den alten Inhalt von .maps .wrapper und einmal für den neuen. Wie kann ich das verhindern? Denn der alte Inhalt ist ja freilich nicht mehr verfügbar und somit greift das script ins Leere.

Außerdem musste ich feststellen, dass ich einen Timer brauche nach dem fetch(), da sonst das script change-page zu schnell ausgeführt wird, sprich bevor die neuen Elemente überhaupt im DOM sind. Da gibt es sicher eine bessere Lösung als die mit dem Timer, oder?

setTimeout( () => {
    changePage( reset = true );
}, 100 );

Ich wäre sehr dankbar für jede Hilfe, denn ich komme hier nicht mehr weiter.

Gruß
Boris

akzeptierte Antworten

  1. problematische Seite

    Hallo borisbaer,

    vor heute Abend wird das bei mir nichts.

    Ich würde, ohne Code gesehen zu haben, vergessene Eventhandler und falsches Promise-Handling beim fetch mutmaßen... Das sind so die Lieblingsfehler.

    Rolf

    --
    sumpsi - posui - obstruxi
  2. problematische Seite

    Ich habe versucht, ein wenig Debugging zu betreiben, und mein Eindruck ist, dass hier das script change-page.js zweimal schießt, einmal für den alten Inhalt von .maps .wrapper und einmal für den neuen. Wie kann ich das verhindern?

    Hm.

    /scripts/change-page.js endet wie folgt:

    // R U N - S C R I P T
    
    document.addEventListener( 'DOMContentLoaded', () => {
    
    	if ( document.querySelector( '.change-page' ) )
    
            changePage();
    
    });
    

    Wird denn dieser „Ereignisabwarter“ auch entfernt, wenn Du das DOM-Element entfernst? Oder nur mehrfach hinzugefügt??

    Logikdemo:

    In so einem Computer arbeitet bekanntlich ein dummer Mann namens „Prog“ für Dich, der allerdings irre schnell rechnen, sortieren und raussuchen kann. Geboren und wohnhaft ist diese Fachkraft in Ganzgenauheim. Diese Sozialisierung (manche behaupten allerdings, es läge an den Genen - die wären nämlich „alle so“) ist nicht folgenlos: Er meckert wegen jedem Scheiß herum.

    Den weise man wie folgt an:

    • Füge Ereignisbehandlung für „Knall“ hinzu: Warte 10 Minuten und schlage auf Tonne 1!
    • Füge Ereignisbehandlung für „Knall“ hinzu: Warte 10 Minuten und schlage auf Tonne 2!

    Das Geschehen:

    • Knall!
    • „Prog“ wartet brav 10 Minuten und schlägt auf Tonne 1.
    • (Tonne 1 wird entfernt)
    • Knall!

    Nanu?

    Wieso wartet der Ganzgenauheimer 10 Minuten, nölt sodann herum, was das denn soll, dass Tonne 1 überhaupt, ganz und gar nicht da sei - und schlägt dann auf Tonne 2?

    Stimme aus dem Off:

    Ist doch klar: „Prog“ kann irre schnell rechnen, sortieren und raussuchen - aber eben nicht für Dich denken! Er versucht so lange beide Aufgaben zu erledigen bis Du ihm sagst, dass er die erste Aufgabe vergessen soll.

  3. problematische Seite

    Lieber borisbaer,

    ich habe ein js script geschrieben, welches das Hin- und Herblättern zwischen mehreren Seiten ermöglichen soll (change-page.js).

    na also, da isser ja, der Übeltäter. Weg damit und alles wird gut. Warte... hatte ich das nicht schon einmal empfohlen?

    Liebe Grüße

    Felix Riesterer

  4. problematische Seite

    Hallo borisbaer,

    dass ich einen Timer brauche nach dem fetch(), da sonst das script change-page zu schnell ausgeführt wird,

    Hattest Du den changePage-Aufruf, den Du derzeit im Timer hast, vorher in dem .then-Aufruf des fetch, wo Du das DOM veränderst?

            .then( ( html ) => { 
                 mapsWrapper.innerHTML = html;
                 changePage( true );
            })
    

    sollte eigentlich funktionieren.

    Und ja, changePage(true), nicht changePage(reset=true). Du tust so, als wäre das ein Funktionsparameter. Aber changePage nimmt gar keinen Parameter entgegen, statt dessen definiert change-page.js eine globale Variable reset und die setzt Du während der Parameterübergabe flugs.

    Ändere change-page.js so, dass let reset; wegfällt und schreibe dort

    const changePage = (reset) => {
       ...
    }
    

    Dann klappt das auch mit der Übergabe eines reset-Parameters.

    Deine click-Handler Steuerung ist gruselig, du fummelst da instinktiv mit Closures herum und weißt vermutlich nicht einmal, was das ist.

    Wenn Du die maps Subpage erstmalig aufrufst, wird changePage ausgeführt. Darin setzt Du eine Variable page, mit einer Liste der Karten, die gezeigt werden. Und dann erzeugst Du eine newPage-Funktion - lokal zu changePage - und registrierst anonyme Funktionen - lokal zu changePage - als Eventhandler. Damit beginnt das Drama, denn die newPage-Funktion, die so entstanden ist, enthält einen Verweis auf den Variablenkontext, in dem sie definiert wurde. Das sind die lokalen Variablen von dem changePage-Aufruf, der diese newPage Funktion erzeugt hat. newPage schließt diese Variablen mit ein - das ist die Closure.

    changePage endet, aber die click- und keydown-Handler bleiben. Und damit auch die lokalen Variablen von changePage. Deswegen und NUR deswegen funktioniert das Blättern, die Closure mit den Variablen von changePage liefert alles nötige.

    Soweit, so gut, aber wenn Du nun einen neuen Kartensatz lädst, rufst Du changePage(true) auf - für den Reset. Den führst Du aber nicht vollständig aus. Die alten click-Handler auf den Pfeilen und auch der keydown-Handler auf dem Body bleiben erhalten, und damit auch die Closure mit den page-Verweisen auf die vorige Karte. Diese Eventhandler laufen dann auf der neuen Karte munter mit, und crashen natürlich, weil die Kartenseiten aus dem DOM gelöscht wurden und keinen parentNode mehr haben.

    Der neue changePage Aufruf definiert wiederum eine newPage Funktion. Beachte: Dies ist eine andere Funktion wie die aus dem ersten changePage Aufruf. Sie ist lokal zu changePage, sie existiert nur in diesem einen Aufrufkontext. Die lokale Variable, in der Du sie speicherst, heißt zwar gleich, aber trotzdem ist es eine andere Funktion. Der Hauptunterschied ist: Die Closure über die lokalen changePage-Variablen ist eine andere.

    Wie kommt man da jetzt heraus?

    In dem Moment, wo Du die Maps Seite verlässt, werden die Pfeile aus dem DOM gelöscht und damit auch alle Eventhandler, die daran hängen. Der keydown-Handler auf dem Body bleibt aber erhalten - der kriegt Dich auch dann noch am Allerwertesten, wenn Du die Subpage wechselst, z.B. nach Mods und wieder zurück. Nur - das merkst Du nicht. Du hast die komplette Subpage weggeschmissen, inclusive des draggable Wrappers, und dieser Zombie-Verein klammert sich jetzt verzweifelt über eine Closure am keydown-Handler fest und blättert fleißig im Nichts vor sich hin.

    Also: erstmal dringend den keydown-Handler vom Body auf den draggable Wrapper verlegen, damit der einen Subpage-Wechsel nicht überlebt.

    Und nun wird's schwieriger: Das Entfernen der alten Eventhandler. Das ist de facto so schwierig, dass ich für den Wechsel der abgefragten Karte generell davon abraten würde. Versuche statt dessen, die Eventhandler für die Links- und Rechtsklicks sowie den keydown-Handler so zu bauen, dass sie keine Kontextinformationen brauchen und alles, was sie brauchen, aus dem aktuellen DOM beziehen. Wenn das erreicht ist, kannst Du auf Closure-Daten aus dem changePage Aufruf verzichten und brauchst die Eventhandler nicht neu zu registrieren. Das wäre mein Vorgehen. Trivial ist es nicht, aber ich denke, ziemlich robust. Die Alternative wäre ein Datenspeicher mit den erforderlichen Steuerinformationen für die Eventhandler, den Du beim Wechsel des Kartensets aktualisieren müsstest. Oder ein globaler Speicher für die alten Eventhandler-Funktionen, womit du diese deregistrieren könntest. Alles Brrr. Hantieren auf dem aktuellen DOM ist der einfachste Weg.

    Du kannst auch DOM Objekten eigene Eigenschaften hinzufügen.

    const wrap = document.querySelector(".maps .wrapper");
    wrap.boris.daten = {
       foo: 1,
       bar: 2
    };
    

    Und schon hat das Wrapper div eine Eigenschaft boris, die außer Dir keiner kennt und wo Du Dinge speichern kannst. Z.B. wieviele Kartenteile Du hast und welches Teil aktiv ist. Aber gerade das brauchst Du ja eigentlich nicht. Du kannst mit querySelectorAll die Anzahl der Kartenteile finden, und die gerade angezeigte Karte kannst Du mit dem Attribut aria-selected=true markieren und wiederfinden. Du solltest auf ein Anhängsel dieser Art nach aller Möglichkeit verzichten.

    Rolf

    --
    sumpsi - posui - obstruxi
    1. problematische Seite

      Hallo Rolf,

      Hattest Du den changePage-Aufruf, den Du derzeit im Timer hast, vorher in dem .then-Aufruf des fetch, wo Du das DOM veränderst?

              .then( ( html ) => { 
                   mapsWrapper.innerHTML = html;
                   changePage( true );
              })
      

      sollte eigentlich funktionieren.

      danke, das funktioniert in der Tat! Ich hatte vergessen, dass ich den Funktionsaufruf in die geschweifte Klammer aufnehmen muss. Stattdessen hatte ich ihn zwischen .then und .catch. 😯

      Und ja, changePage(true), nicht changePage(reset=true). Du tust so, als wäre das ein Funktionsparameter. Aber changePage nimmt gar keinen Parameter entgegen, statt dessen definiert change-page.js eine globale Variable reset und die setzt Du während der Parameterübergabe flugs.

      Ändere change-page.js so, dass let reset; wegfällt und schreibe dort

      const changePage = (reset) => {
         ...
      }
      

      Dann klappt das auch mit der Übergabe eines reset-Parameters.

      Habe ich gemacht, danke für den Hinweis! Da müsste ich mich auch tatsächlich mal genauer mit beschäftigen, wie das mit den Funktionsparametern in JS funktioniert.

      Deine click-Handler Steuerung ist gruselig, du fummelst da instinktiv mit Closures herum und weißt vermutlich nicht einmal, was das ist.

      Ertappt! Aber auch das werde ich mir bei Gelegenheit zu Gemüte führen.

      Wenn Du die maps Subpage erstmalig aufrufst, wird changePage ausgeführt. Darin setzt Du eine Variable page, mit einer Liste der Karten, die gezeigt werden. Und dann erzeugst Du eine newPage-Funktion - lokal zu changePage - und registrierst anonyme Funktionen - lokal zu changePage - als Eventhandler.

      Bis hierhin habe ich es verstanden …

      Damit beginnt das Drama, denn die newPage-Funktion, die so entstanden ist, enthält einen Verweis auf den Variablenkontext, in dem sie definiert wurde. Das sind die lokalen Variablen von dem changePage-Aufruf, der diese newPage Funktion erzeugt hat. newPage schließt diese Variablen mit ein - das ist die Closure.

      Also handelt es sich bei diesen lokalen Variablen um eine ganz spezifische Zuordnung zu ganz bestimmten Elementen im DOM? Habe ich das richtig verstanden?

      changePage endet, aber die click- und keydown-Handler bleiben. Und damit auch die lokalen Variablen von changePage. Deswegen und NUR deswegen funktioniert das Blättern, die Closure mit den Variablen von changePage liefert alles nötige.

      Soweit, so gut, aber wenn Du nun einen neuen Kartensatz lädst, rufst Du changePage(true) auf - für den Reset. Den führst Du aber nicht vollständig aus. Die alten click-Handler auf den Pfeilen und auch der keydown-Handler auf dem Body bleiben erhalten, und damit auch die Closure mit den page-Verweisen auf die vorige Karte. Diese Eventhandler laufen dann auf der neuen Karte munter mit, und crashen natürlich, weil die Kartenseiten aus dem DOM gelöscht wurden und keinen parentNode mehr haben.

      Der neue changePage Aufruf definiert wiederum eine newPage Funktion. Beachte: Dies ist eine andere Funktion wie die aus dem ersten changePage Aufruf. Sie ist lokal zu changePage, sie existiert nur in diesem einen Aufrufkontext. Die lokale Variable, in der Du sie speicherst, heißt zwar gleich, aber trotzdem ist es eine andere Funktion. Der Hauptunterschied ist: Die Closure über die lokalen changePage-Variablen ist eine andere.

      Heißt das, dass bei jedem Aufruf der Mutterfunktion (changePage) eine neue Kindfunktion(newPage) erstellt wird? newPage bekommt also bei jedem Wechsel des Kartensatzes eine neue Instanz, nicht? Und nur die jeweils aktuelle Instanz funktioniert, die alten Instanzen laufen gewissermaßen ins Leere.

      Wie kommt man da jetzt heraus?

      In dem Moment, wo Du die Maps Seite verlässt, werden die Pfeile aus dem DOM gelöscht und damit auch alle Eventhandler, die daran hängen. Der keydown-Handler auf dem Body bleibt aber erhalten - der kriegt Dich auch dann noch am Allerwertesten, wenn Du die Subpage wechselst, z.B. nach Mods und wieder zurück. Nur - das merkst Du nicht. Du hast die komplette Subpage weggeschmissen, inclusive des draggable Wrappers, und dieser Zombie-Verein klammert sich jetzt verzweifelt über eine Closure am keydown-Handler fest und blättert fleißig im Nichts vor sich hin.

      Also: erstmal dringend den keydown-Handler vom Body auf den draggable Wrapper verlegen, damit der einen Subpage-Wechsel nicht überlebt.

      Kapiert. Jetzt habe ich nur das Problem, dass eigentlich beim Umblättern mit den Pfeil-Tasten der Fokus auf den jeweiligen Pfeil-Button gelegt werden soll. Dann muss ich aber jedes Mal den draggable Wrapper neu fokussieren, um weiter mit den Pfeiltasten navigieren zu können.
      Ich muss mir mal eine Lösung dafür überlegen. Ist aber jetzt erst mal nebensächlich.

      Und nun wird's schwieriger: Das Entfernen der alten Eventhandler. Das ist de facto so schwierig, dass ich für den Wechsel der abgefragten Karte generell davon abraten würde. Versuche statt dessen, die Eventhandler für die Links- und Rechtsklicks sowie den keydown-Handler so zu bauen, dass sie keine Kontextinformationen brauchen und alles, was sie brauchen, aus dem aktuellen DOM beziehen.

      Leider habe ich keine Ahnung, was genau du damit meinst bzw. wie ich das umsetzen sollte. 🙁 Sie dürfen keine Kontextinformationen brauchen … hmm.

      Wenn das erreicht ist, kannst Du auf Closure-Daten aus dem changePage Aufruf verzichten und brauchst die Eventhandler nicht neu zu registrieren. Das wäre mein Vorgehen. Trivial ist es nicht, aber ich denke, ziemlich robust. Die Alternative wäre ein Datenspeicher mit den erforderlichen Steuerinformationen für die Eventhandler, den Du beim Wechsel des Kartensets aktualisieren müsstest. Oder ein globaler Speicher für die alten Eventhandler-Funktionen, womit du diese deregistrieren könntest. Alles Brrr. Hantieren auf dem aktuellen DOM ist der einfachste Weg.

      Klingt für mich auch zu kompliziert für das, was erreicht werden soll.

      Du kannst auch DOM Objekten eigene Eigenschaften hinzufügen.

      const wrap = document.querySelector(".maps .wrapper");
      wrap.boris.daten = {
         foo: 1,
         bar: 2
      };
      

      Und schon hat das Wrapper div eine Eigenschaft boris, die außer Dir keiner kennt und wo Du Dinge speichern kannst. Z.B. wieviele Kartenteile Du hast und welches Teil aktiv ist. Aber gerade das brauchst Du ja eigentlich nicht. Du kannst mit querySelectorAll die Anzahl der Kartenteile finden, und die gerade angezeigte Karte kannst Du mit dem Attribut aria-selected=true markieren und wiederfinden. Du solltest auf ein Anhängsel dieser Art nach aller Möglichkeit verzichten.

      Sehr interessant, vielleicht nicht für das aktuelle Vorhaben so relevant, aber damit werde ich definitiv mal herumexperimentieren. Danke für den Hinweis.

      Gruß
      Boris

      1. problematische Seite

        Hallo borisbaer,

        Stattdessen hatte ich ihn zwischen .then und .catch. 😯

        Da gehört er ja nun gar nicht hin. Hinter fetch folgt eine Folge von .then und .catch Aufrufen, die alle auf Promises aufgerufen werden, die aus dem fetch-Aufruf entstehen. Auch nicht so einfach zu kapieren, das gebe ich zu.

        Also handelt es sich bei diesen lokalen Variablen um eine ganz spezifische Zuordnung zu ganz bestimmten Elementen im DOM?

        Ja. Du ermittelst sie aus dem DOM, und dann bleiben sie in der Closure stehen, die an den Eventhandlern hängt. Und weil Du die Eventhandler nicht beseitigst, leben sie "ewig", bzw. bis zum Seitenrefresh.

        Heißt das, dass bei jedem Aufruf der Mutterfunktion (changePage) eine neue Kindfunktion(newPage) erstellt wird?

        Genau das wollte ich sagen, ja. Der dahinterliegende Quellcode ist der gleiche, aber es ist jedesmal ein neues Funktionsobjekt, mit einer eigenen Closure um die lokalen Daten des changePage-Aufrufs.

        Kapiert. Jetzt habe ich nur das Problem, dass eigentlich beim Umblättern mit den Pfeil-Tasten der Fokus auf den jeweiligen Pfeil-Button gelegt werden soll.

        Das sollte kein Problem sein; Du hast im keydown-Handler ein arrowLeft.focus(); stehen, DAS kannst Du machen, wenn die Pfeile unverändert im DOM bleiben. Du kannst natürlich auch den beiden Buttons eine ID geben und sie nach Bedarf mit getElementById heraussuchen.

        Leider habe ich keine Ahnung, was genau du damit meinst bzw. wie ich das umsetzen sollte. 🙁 Sie dürfen keine Kontextinformationen brauchen … hmm.

        Du solltest die newPage-Funktion nur einmal erzeugen, d.h. nicht innerhalb von changePage, sondern separat.

        Innerhalb von newPage nutzt Du im Moment die page-Variable, um die vorhandenen Karten zu ermitteln. Verwende eine lokale Variable page (oder maps) und initialisiere sie zu Beginn von newPage mit

        let page = document.querySelectorAll( '.maps .page' );
        

        und die erste Abhängigkeit zum changePage-Kontext ist weg. Die executed Variable ist eine andere Sache, deren Zweck verstehe ich noch nicht. Damit steuerst Du offenbar das Zuweisen der Klasse 'on', das beim ERSTEN Mal - also beim ersten newPage Aufruf für ein Kartenset - nicht passieren soll. Mir scheint, das ist genau den newPage Aufruf, der in changePage direkt passiert, die übrigen kommen aus den Eventlistenern. Eventuell kannst Du newPage einen zweiten Parameter mitgeben, der beim newPage-Aufruf in Zeile 127 (also newpage(pageNumber) false ist und beim newPage-Aufruf aus den Eventhandlern true, und damit das Zuweisen der Klasse steuern.

        Ich habe mir jetzt nicht alles angeschaut und kann daher nicht sagen, ob sich die Animation eleganter steuern lässt.

        Die letzte Amtshandlung von newPage ist das Setzen der globalen Variablen pageNumber. Diese globale Variable muss weg, die pageNumber muss sich aus dem DOM ermitteln. Deswegen habe ich Dir aria-selected empfohlen. Die Eventhandler können, statt pageNumber zu nutzen, die Pages durchgehen und feststellen, welche davon aria-selected=true hat. Das geht mit querySelector:

        let currentPage = document.querySelector(".maps .page[aria-selected=true]");
        

        Und dann verwendest Du nicht die pageNumber und rechnest plus/minus 1, sondern Du nutzt das DOM und bestimmst zur gefundenen Page previousElementSibling bzw. nextElementSibling. Das sind die Nachbar-Karten im DOM. Diese Eigenschaften liefern NULL, wenn Du auf dem ersten bzw. letzten Element bist, und mich deucht, dass Du in diesem Fall newPage gar nicht aufrufen musst.

        Die Siblings brauchst Du eh - Du ermittelst sie im Moment manuell.

        Eigentlich solltest Du newPage mit zwei Parametern aufrufen: Die aktuelle Seite, und der Sibling, der zur neuen Seite werden soll. In newPage kannst Du dann prüfen: Ist die neue Seite null - dann geh gleich wieder raus.

        Andernfalls deaktiviere die bisherige Seite und aktiviere die neue Seite.

        Das vereinfacht deinen Code deutlich.

        Rolf

        --
        sumpsi - posui - obstruxi
        1. problematische Seite

          Hallo Rolf, ich komme leider erst jetzt zum Antworten, da ich beruflich sehr eingespannt war.

          Die executed Variable ist eine andere Sache, deren Zweck verstehe ich noch nicht. Damit steuerst Du offenbar das Zuweisen der Klasse 'on', das beim ERSTEN Mal - also beim ersten newPage Aufruf für ein Kartenset - nicht passieren soll. Mir scheint, das ist genau den newPage Aufruf, der in changePage direkt passiert, die übrigen kommen aus den Eventlistenern.

          Die Klasse on gibt den Karten lediglich eine Fade-In-Animation, die ich beim ersten Laden jedoch nicht haben möchte. Mehr macht dieser Teil des Codes nicht.

          Die letzte Amtshandlung von newPage ist das Setzen der globalen Variablen pageNumber. Diese globale Variable muss weg, die pageNumber muss sich aus dem DOM ermitteln. Deswegen habe ich Dir aria-selected empfohlen. Die Eventhandler können, statt pageNumber zu nutzen, die Pages durchgehen und feststellen, welche davon aria-selected=true hat. Das geht mit querySelector:

          let currentPage = document.querySelector(".maps .page[aria-selected=true]");
          

          Ich habe mal versucht, über aria-selected die Navigation zu steuern.

          Und dann verwendest Du nicht die pageNumber und rechnest plus/minus 1, sondern Du nutzt das DOM und bestimmst zur gefundenen Page previousElementSibling bzw. nextElementSibling. Das sind die Nachbar-Karten im DOM. Diese Eigenschaften liefern NULL, wenn Du auf dem ersten bzw. letzten Element bist, und mich deucht, dass Du in diesem Fall newPage gar nicht aufrufen musst.

          Die Siblings brauchst Du eh - Du ermittelst sie im Moment manuell.

          Eigentlich solltest Du newPage mit zwei Parametern aufrufen: Die aktuelle Seite, und der Sibling, der zur neuen Seite werden soll. In newPage kannst Du dann prüfen: Ist die neue Seite null - dann geh gleich wieder raus.

          Andernfalls deaktiviere die bisherige Seite und aktiviere die neue Seite.

          Das Ergebnis:

          const turnPageLeft = () => {
          
              let page = document.querySelectorAll( '.page' );
              let doOnce;
          
              page.forEach( node => {
                  if ( node.getAttribute( 'aria-selected' ) === 'true' && !doOnce ) {
                      if ( !node.previousElementSibling )
                          return;
                      node.previousElementSibling.setAttribute( 'aria-selected', true );
                      node.removeAttribute( 'aria-selected' );
                      doOnce = true;
                  }
              });
          
          }
          
          const turnPageRight = () => {
          
              let page = document.querySelectorAll( '.page' );
              let doOnce;
          
              page.forEach( node => {
                  if ( node.getAttribute( 'aria-selected' ) === 'true' && !doOnce ) {
                      if ( !node.nextElementSibling )
                          return;
                      node.nextElementSibling.setAttribute( 'aria-selected', true );
                      node.removeAttribute( 'aria-selected' );
                      doOnce = true;
                  }
              });
          }
          
          const changePage = () => {
              let page = document.querySelectorAll( '.page' );
              page[0].setAttribute( 'aria-selected', true );
          	document.body.addEventListener( 'keydown', ( e ) => {
          
          		var key = e.keyCode;
          		// l e f t - a r r o w - k e y
          		if ( key === 37 ) { turnPageLeft(); }
          		// r i g h t - a r r o w - k e y
          		if ( key === 39 ) { turnPageRight(); }
          
          	});
          };
          
          // R U N - S C R I P T
          
          document.addEventListener( 'DOMContentLoaded', () => {
          	if ( document.querySelector( '.change-page' ) )
                  changePage();
          });
          

          Geht sicher noch eleganter.

          Grüße
          Boris

          1. problematische Seite

            Ich habe den Code ein wenig angepasst zugunsten von DRY. Sorry wegen der Doppelzeiligkeit.

            const turnPage = ( right ) => {
            
                let doOnce;
            
                let page = document.querySelectorAll( '.page' );
            
                page.forEach( node => {
            
                    if ( node.getAttribute( 'aria-selected' ) === 'true' && !doOnce ) {
            
                        if ( right === true ) {
            
                            if ( !node.nextElementSibling ) return;
            
                            node.nextElementSibling.setAttribute( 'aria-selected', true );
            
                        } else {
            
                            if ( !node.previousElementSibling ) return;
            
                            node.previousElementSibling.setAttribute( 'aria-selected', true );
            
                        }
            
                        node.removeAttribute( 'aria-selected' ); 
            
                        doOnce = true;
            
                    }
            
                });
            
            }
            
            const changePage = () => {
            
            	document.body.addEventListener( 'keydown', ( e ) => {
            
            		var key = e.keyCode;
            
            		// r i g h t - a r r o w - k e y
            
            		if ( key === 39 ) { turnPage( true ); }
            
            		// l e f t - a r r o w - k e y
            
            		if ( key === 37 ) { turnPage(); }
            
            	});
            
            };
            
            // R U N - S C R I P T
            
            document.addEventListener( 'DOMContentLoaded', () => {
            
            	if ( document.querySelector( '.change-page' ) )
            
                    changePage();
            
            });
            
        2. problematische Seite

          So, ich habe endlich eine funktionierende, einigermaßen zufriedenstellende Lösung gefunden. Die Schwierigkeit bei der changePage-Funktion war, die einzelnen Seiten sowohl über die Pfeile als auch über diese runden Positionsmarkierungen unten steuern zu können.

          Jetzt habe ich eine einzige Funktion, die sich um alles kümmert und dabei auf keine anderen Funktionen angewiesen ist (weder innenerhalb noch außerhalb):

          const changePage = ( currentPage, reset ) => {
          
              console.log( '%crun function: ' + '%cchangePage', 'font-family: "JetBrains Mono";', 'font-family: "JetBrains Mono"; color: red;' );
          
              const page = document.querySelectorAll( '.page' );
          
              const pos = document.querySelector( '.pos' );
          
          	const posMarker = pos.childNodes;
          
          	const arrowLeft = document.querySelector( '.arrow:first-child' );
          
          	const arrowRight = document.querySelector( '.arrow:last-child' );
          
              let i = 1;
          
              // P R E P A R A T I O N
          
              if ( reset === true ) {
          
                  currentPage = document.querySelector( '.page[aria-selected="true"]' ).id;
          
                  // a d d - p o s M a r k e r s
          
                  pos.replaceChildren();
          
                  page.forEach ( () => {
          
                      const el = `<button type="button" data-ref="${i++}"><span class="sr">Positionsmarkierung</span></button>`;
          
                      pos.insertAdjacentHTML( 'beforeend', el );
          
                  });
          
                  // a d d - E v e n t L i s t e n e r s
          
                  arrowLeft.addEventListener( 'click', () => { changePage( 'previous', false ); });
          
                  arrowRight.addEventListener( 'click', () => { changePage( 'next', false ); });
          
                  document.querySelector( '.change-page' ).addEventListener( 'keydown', ( e ) => {
          
                      let key = e.keyCode;
          
                      if ( key === 37 ) { changePage( 'previous', false ); arrowLeft.focus(); }
          
                      if ( key === 39 ) { changePage( 'next', false ); arrowRight.focus(); }
          
                  });
          
                  // p o s M a r k e r
          
                  posMarker.forEach( node => {
          
                      node.addEventListener( 'click', function() {
          
                          for ( let sibling of node.parentNode.children ) // s e l e c t - a l l - p o s M a r k e r s
          
                              if ( sibling === this ) sibling.setAttribute( 'aria-current', 'location' ); // a d d - h i g h l i g h t i n g
          
                              else sibling.removeAttribute( 'aria-current' ); // r e m o v e - h i g h l i g h t i n g
          
                          // s e l e c t - c u r r e n t P a g e
          
                          currentPage = this.dataset.ref;
          
                          changePage( currentPage, false );
          
                      });
          
                      // i n i t i a l - h i g h l i g h t i n g
          
                      if ( node.dataset.ref !== currentPage ) return;
          
                      else node.setAttribute( 'aria-current', 'location' );
          
                  });
          
              } else {
          
                  // C H A N G E - P A G E
          
                  let currentPosMarker = document.querySelector( '.pos button[aria-current="location"]' );
          
                  if ( currentPage === 'previous' ) {
          
                      currentPage = document.querySelector( '.page[aria-selected="true"]' );
          
                      if ( currentPage.previousElementSibling ) {
          
                          // s e l e c t - c u r r e n t P a g e
          
                          currentPage = currentPage.previousElementSibling.id;
          
                          // h i g h l i g h t - c u r r e n t P o s M a r k e r
          
                          currentPosMarker.previousElementSibling.setAttribute( 'aria-current', 'location' );
          
                          currentPosMarker.removeAttribute( 'aria-current' );
          
                          currentPosMarker = currentPosMarker.previousElementSibling;
          
          
                      } else return; // s t o p - o n - m i n
          
                  } else if ( currentPage === 'next' ) {
          
                      currentPage = document.querySelector( '.page[aria-selected="true"]' );
          
                      if ( currentPage.nextElementSibling ) {
          
                          // s e l e c t - c u r r e n t P a g e
          
                          currentPage = currentPage.nextElementSibling.id;
          
                          // h i g h l i g h t - c u r r e n t P o s M a r k e r
          
                          currentPosMarker.nextElementSibling.setAttribute( 'aria-current', 'location' );
          
                          currentPosMarker.removeAttribute( 'aria-current' );
          
                          currentPosMarker = currentPosMarker.nextElementSibling;
          
                      } else return; // s t o p - o n - m a x
          
                  }
          
                  // A D D - A N I M A T I O N
          
                  page.forEach( node => { node.classList.add( 'on' ); });
          
              }
          
              // S H O W - C U R R E N T - P A G E
          
              page.forEach( node => {
          
                  if ( node.id == currentPage )
          
                      node.setAttribute( 'aria-selected', true );
          
                  else node.removeAttribute( 'aria-selected' );
          
              });
          
              // D E A C T I V A T E - A R R O W - O N - M I N - O R - M A X
          
              if ( currentPage == 1 ) { arrowLeft.classList.add( 'off' ); arrowLeft.tabIndex = -1; }
          
              else if ( currentPage >= 1 ) { arrowLeft.classList.remove( 'off' ); arrowLeft.tabIndex = 0; }
          
              if ( currentPage == page.length ) { arrowRight.classList.add( 'off' ); arrowRight.tabIndex = -1; }
          
              else if ( currentPage < page.length ) { arrowRight.classList.remove( 'off' ); arrowRight.tabIndex = 0; }
          
              // --> P R E V E N T : P R E P A R A T I O N
          
              reset = false;
          
          };
          

          Ein weiteres Problem bestand jedoch, das mich einige Zeit gekostet hat, bis ich eine passable Lösung gefunden habe, und zwar die Tatsache, dass die HTML-Elemente mit den EventListeners „resettet“ werden mussten (der Grund dafür wurde ja schon festgestellt). Hier habe ich nun gelernt, dass man mehrere fetch requests nacheinander ausführen kann. Also wird der ganze HTML-Teil, der für die Navigation zwischen den Seiten zuständig ist, beim Wechsel der „Kartenpakete“ ebenfalls neu geladen:

          fetch( 'includes/' + href )
          
              .then( ( response ) => {
          
                  if ( !response.ok ) throw 'Seite nicht gefunden.';
          
                  else return response.text();
          
              })
          
              .then( ( html ) => {
          
                  mapsGroup.innerHTML = html;
          
                  // s e c o n d - f e t c h - r e q u e s t
          
                  return fetch( '/includes/change-page.php' );
          
              })
          
              .then( response => {
          
                  if ( !response.ok ) throw 'Seite nicht gefunden.';
          
                  else return response.text();
          
              })
          
              .then( html => {
          
                  document.querySelector( '.change-page' ).outerHTML = html;
          
                  changePage( undefined, true );
          
              })
          
              .catch( ( error ) => { console.log( error ); });
          

          Ich glaube, sonst habe ich hier aber nichts mehr übersehen, außer vermutlich noch ein paar Redundanzen im Code. Promise.all gibt es ja auch noch. Keine Ahnung, ob das geeignet ist, um fetch() bei mehreren Anfragen zu ersetzen.