#node #ssr #vuejs #vuecember2020

Globaler Zustand in SSR mit Vue und Node.js

Bei der Entwicklung von Universal Apps anstelle von Single-Page Applications (SPAs) gibt es eine Menge zu beachten.

Der Code wird jetzt nicht nur im Browser, sondern auch auf einem Node.js Server ausgeführt. Das bedeutet beispielsweise, dass wir universellen Code schreiben müssen, um Fehler wie "Uncaught ReferenceError: window is not defined" zu vermeiden.

Wir müssen uns um Client Side Hydration, Build-Konfiguration, Caching und vieles mehr kümmern. Diese Themen werden zu weiten Teilen im offiziellen Vue SSR Guide behandelt.

Eines der fehleranfälligsten Themen ist meiner Erfahrung nach der Prozess der Client Side Hydration.
Sven Wagner und ich haben bereits einen Blogpost darüber verfasst, wie man Hydratationsfehler in Vue.js versteht und löst. Ebenso beschreibt Alex Lichter in einem Leitfaden, was zu tun ist, wenn die Hydratation von Vue versagt.

Die andere Kategorie von häufig frustrierenden und schwer zu lösenden Fehlern entsteht aus dem Server selbst während des Server-Side Renderings.

Während im Browser in der Regel nur eine einzelne Applikation ausgeliefert wird, werden auf dem Server mehrere Anfragen gleichzeitig und nacheinander bearbeitet. In Node.js werden die verschiedenen Anfragen nicht in einzelnen Threads isoliert bearbeitet, sondern innerhalb eines Threads mit einem globalen Zustand. Fehler entstehen dann, wenn Zustände nicht sauber zwischen Anfragen getrennt werden. Ebenso wenn der globale Zustand mit jeder eingehenden Anfrage wiederholt erweitert wird.

In diesem Beitrag möchte ich ein tieferes Verständnis für den serverseitigen Lifecycle und der Beziehung zwischen verschiedenen Ausführungskontexten vermitteln, um die Identifizierung und Behebung häufig auftretender Probleme zu vereinfachen.

Der Browser

1_r7xmz-rLilKBDa0UPc1i0Q.png

Im Browser gibt es normalerweise genau eine app, eine VueRouter Instanz und eine Vuex Instanz. Nav, Router usw. sind Komponenten, die Children der Root-Komponente (aka app) sind.

Das vereinfacht vieles. Zum Beispiel das Abrufen einer Vuex Instanz aus einem globalen beforeEach-Hook von VueRouter:

// store.js
export const store = new Vuex.Store(%7B
  state: %7B
    authenticated: false
  %7D,
%7D)

// router.js
export const router = new VueRouter(%7B /* ... */ %7D)
router.beforeEach((to, from, next) => %7B
  if (to.name !== 'Login' && !store.state.authenticated) %7B
    next(%7B name: 'Login' %7D)
    return
 %7D
  next()
%7D)

Der Grund dafür, dass wir zustandsbehaftete Singletons verwenden können, liegt darin, dass es zu jedem Zeitpunkt genau eine Instanz gibt und diese nicht mit einer anderen Anfrage geteilt wird.

In Server-Side Rendering Applications verhalten sich die Dinge ein wenig anders.

Der Server

1_U2HVqV6FBW-lX3O303xdJg.png

Der globale Kontext verhält sich in etwa wie der, den wir vom Browser gewohnt sind. In jedem Node.js Prozess gibt es genau ein global, genauso wie es im Browser genau ein window gibt. Jedes geladene JavaScript-Modul ist ein zustandsbehaftetes Singleton in diesem globalen Kontext.

// counter.js
const list = []
module.exports = %7B 
  list,
  add: entry => list.push(entry)
%7D

// foo.js
const %7B list, add %7D = require('./counter.js')
add('foo')
console.log(list) // [ 'foo' ]

// index.js
const %7B list, add %7D = require('./counter.js')
require('./foo.js')
add('index')
console.log(list) // [ 'foo', 'index' ] - there is only *one* list

Instances of stateful objects per request

Ohne Anpassung der Struktur unseres Codes würde das bedeuten, dass alle Anfragen sich die selben zustandsbehafteten Singletons teilen. Nach dem Beispiel oben würden sich jeweils alle gleichzeitigen Anfragen den selben Authentifzierungs-Zustand teilen.

In den meisten Anwendungen wird die Authentifizierung nicht im Server-Side Rendering behandelt

Anfragen, die gleichzeitig gerendert werden, können sich gegenseitig stören. Anfragen, die danach gerendert werden, starten nicht mit einem "frischen" Zustand, sondern mit dem, was die vorherigen Anfragen hinterlassen haben.

1_b2-P91Yb8JFiXPRC2j8I0w.png

Letzteres haben wir einmal mit VueApollo erlebt, einem Vue-Plugin für die Integration von GraphQL.

In diesem Fall haben wir nicht für jede Anfrage einen apolloProvider erstellt und der apollo InMemoryCache ist mit jeder Anfrage weiter gewachsen, was zwei wesentliche Nebeneffekte hatte.

Der erste war, dass wir eine wachsende Payload mit dem vollen Cache an den Browser zur Client Side Hydration schickten.

Darüber hinaus verbarg der von vorigen Anfragen gefüllte Cache die Tatsache, dass wir nicht bei jeder Anfrage alle externen Daten korrekt geladen haben - das war, bevor der serverPrefetch-Hook eingeführt wurde. Jedes Mal wenn der Server neu gestartet und der Cache effektiv geleert wurde, waren auch die meisten Seiten leer, da der Großteil der Daten Daten nicht rechtzeitig abgerufen werden konnten.

Der Vue SSR Guide erklärt, wie man die Struktur des Codes ändern kann, um zustandsbehaftete Singletons zu vermeiden. In NuxtJS Anwendungen hilft der "context" beim Zugriff auf Instanzen von zustandsbehafteten Objekten, auf die sonst nicht zugegriffen werden könnte, weil sie nicht einfach "importiert" werden können.

Dies ist jedoch nicht die einzige Falle, in die man tappen kann. Es gibt noch mehr zustandsbehaftete Singletons als es auf den ersten Blick scheint. Beispielsweise Vue selbst.

Globale zustandsbehaftete Objekte

Vue ist selbst ein zustandsbehaftetes Objekt. Es speichert installierte Plugins, Optionen von Mixins und Assets wie Komponenten und Dekoratoren. Dies kann zu Problemen führen, wenn dieses zustandsbehaftete Objekt nicht nur beim Start der Anwendung, sondern bei jeder eingehenden Anfrage geändert wird.

Das lässt sich am besten anhand der folgenden drei Fälle verstehen:

function createPlugin() %7B
  return (vue) => vue.mixin(%7B
    mounted() %7B
      console.log('hi there')
   %7D
  %7D)
%7D

// case 1:
Vue.use(createPlugin())
export const createApp = () => %7B

%7D

// case 2:
const plugin = createPlugin()
export const createApp = () => %7B
  Vue.use(plugin)
%7D

// case 3:
export const createApp = () => %7B
  const plugin = createPlugin()
  Vue.use(plugin)
%7D

Im ersten Fall wird ein Plugin installiert - fertig. Es gibt genau eine Änderung des globalen Wandels, und so bleibt es für alle kommenden Anfragen.

Im zweiten Fall wird ein Plugin einmal erstellt, aber bei jeder Anfrage installiert. Dies könnte ein Problem sein, aber Vue.use prüft, ob ein Plugin bereits installiert ist, und deshalb wird das Plugin effektiv nur einmal installiert.

Wenn das Plugin jedoch pro Anfrage erstellt wird, kann die in Vue.use implementierte Prüfung das bereits installierte Plugin nicht identifizieren und installiert das neue Plugin zusätzlich.

Das Plugin in unserem Beispiel installiert dann ein globales Mixin, das mit den aktuellen Optionen gemerged wird. Dies kann zu allen möglichen Regressions führen - von reduzierter Performance, über voll laufenden Speicher bis hin zu einem "Maximum call stack size exceeded error".

Mit einem Bewusstsein für diesen lifecycle und der Installation von Plugins, Mixins und Assets ist man schon verhältnismäßig sicher. NuxtJS hilft dabei, solange man nicht den für NuxtJS Plugins mitgelieferten Callback verwendet, um das Vue-Singleton zu ändern.

Das letzte, was einem nun noch in die Quere kommen könnte, insbesondere in einer Entwicklungsumgebung, in der Code wiederholt kompiliert und ausgeführt wird, ist der bundleRenderer.

Der bundleRenderer

Der bundleRenderer erledigt das eigentliche Rendering zu einem HTML-String. Das kann während der Generierung statischer Seiten sein oder zur Bearbeitung einer HTTP-Anfrage.

Es gibt eine Einstellung, die beeinflusst, in welchem Kontext zustandsbehaftete Objekte erzeugt werden: runInNewContext. Es kann auf false, 'once' oder true gesetzt werden - mit jeweils einem anderen Verhalten der Applikation

Contexts depending on bundleRenderer.runInNewContext

Wenn runInNewContext auf false gesetzt ist, gibt es keinen Grund zur Sorge. Es gibt keine zusätzliche "Box".

Wird 'once' konfiguriert gibt es einige Besonderheiten, die in der offiziellen Dokumentation beschrieben sind. Sie stehen jedoch in keinem Zusammenhang mit dem, was oben besprochen wurde.

Wenn runInNewContext auf true gesetzt ist, was die Standardeinstellung für einen NuxtJS Development Server ist, ist das Verhalten ganz anders:

[…] für jedes Rendering erzeugt der Bundle-Renderer einen neuen V8-Kontext und führt das gesamte Bundle erneut aus.

Das bedeutet, dass ein scheinbar globales Objekt innerhalb des Bundles für jede Anfrage neu erstellt wird. Das schlägt sich nicht nur in der Performance nieder. Es verändert auch das Verhalten der Anwendung.

Um zu verstehen, wie sich das Verhalten ändert, ist die erste Frage: Was ist Teil des Server-Bundles?

Standardmäßig inkludiert Webpack alles, was in irgendeiner Weise durch den entry importiert wird. In den meisten Applikationen wird nodeExternals verwendet um die Größe des Bundles zu verringern. Damit wird alles aus dem bundle entfernt, was sich innerhalb des Ordners node_modules befindet, da es auf dem Server ohnehin zur Verfügung stehen wird.

NuxtJS tut dies standardmäßig. Jedes Modul innerhalb von node_modules wird also im globalen Kontext ausgeführt - und alle anderen Module (aka die Anwendung) wird bei jeder Anfrage in einem neuen bundleRenderer-Kontext erneut ausgeführt.

Betrachten wir nun die drei oben besprochenen Beispiele - die createApp Funktion und die Art und Weise, wie sich das Verhalten der Anwendung ändert, je nachdem, wo Plugins erstellt und wo sie installiert werden. Alles, was Teil des Bundles ist, verhält sich nun so, als befände es sich innerhalb der createApp Funktion.

Die folgenden Beispiele orientieren sich an einer NuxtJS Applikation.

Beispiel 1

// ~/plugins/i18n.js
import Vue from 'vue'
import VueI18n from 'vue-i18n'

Vue.use(VueI18n)

Alles funktioniert problemlos. Vue und VueI18n kommen beide aus dem globalen Kontext, da beide aus node_modules importiert werden. VueI18n wird nur einmal effektiv installiert.

Beispiel 2

// ~/plugins/log-$root-created.js
import Vue from 'vue'
Vue.mixin(%7B
  created() %7B
    if(this === this.$root) %7B
      console.log('$root has been created')
    %7D
  %7D
%7D)

Wenn in einem NuxtJS Development server ein solches Plugin registriert wird, wird man feststellen, dass die Anweisung bei jeder Anfrage ein weiteres Mal geloggt wird. Bei der 1. Anfrage wird die Anweisung einmal geloggt. Bei der 50. Anfrage wird das Statement 50 Mal geloggt.

Wenn runInNewContext auf false gesetzt wird, verschwindet dieses Verhalten!

// nuxt.config.js
export default %7B
  render: %7B
    bundleRenderer: %7B
      runInNewContext: false,
    %7D,
  %7D,
%7D

… aber die Anweisung wird trotzdem noch ein zusätzliches mal protokolliert, wenn der Code nach einer Änderung neu kompiliert wird, da dadurch ein vollständig neues Bundle erzeugt wird.

Beispiel 3

// ~/plugins/log-$root-created-once.js
if(!Vue.logRootCreatedInstalled) %7B
  Vue.mixin(%7B
    created() %7B
      if(this === this.$root) %7B
        console.log('$root has been created')
      %7D
    %7D
  %7D)
  Vue.logRootCreatedInstalled = true
%7D

Indem wir im globalen Kontext speichern, ob wir die Installation bereits durchgeführt haben, können wir die Neuinstallation auch in diesem Fall vermeiden. Dies behebt unser Problem unabhängig von den Einstellungen des bundleRenderers.

Vue 3

Mit Vue 3 ändert sich die globale API, da "einige der derzeitigen globalen API und Konfigurationen von Vue den globalen Zustand permanent verändern".
Plugins werden nicht mehr global installiert, sondern in den neu eingeführten App-Instanzen und damit isoliert je Anfrage.
Ich erwarte, dass dadurch die meisten der oben beschriebenen Fallstricke beseitigt werden.

Fazit

Es hat für mich einige Zeit gebraucht, um dieses Thema hinreichend tief zu verstehen. Ich hoffe, dass die Bilder, Beschreibungen und Beispiele dabei helfen können, Fehler in Server-Side Rendering Applications zu vermeiden und ihr Verhalten zu verstehen.

❤ Ich freue mich über Feedback und Fragen im begleitenden Blogpost auf Medium. ❤