Integration von Webpack in Bestandsanwendungen

Senior Java Developer @ Chrono24

CSS und JavaScript für den Live-Betrieb zu komprimieren, ist seit langem Standard. Auch das Zusammenführen von mehren Dateien in ein / mehreren Bundle(s) wurde lange als Best Practice von uns und anderen betrieben. Inzwischen geht der Trend entgegengesetzt zum Bundle Splitting (teilweise auch Code Splitting genannt), d.h. jedes Bundle wird in einzelne Chunks gesplittet. Dank HTTP/2 wird nur noch eine Verbindung pro Server benötigt, sodass eine Reduktion auf möglichst wenige Requests nicht mehr notwendig ist. Der Vorteil des Bundle Splitting ist, dass diese separat vom Browser gecacht werden und sich somit wenig ändernde Chunks länger im Cache des Clients halten lassen. Falls ein Chunk von mehreren Bundles genutzt wird, muss dieser zudem nur einmal heruntergeladen werden, statt mit jeder Bundle-Datei noch einmal.

Zusammengefasst ergeben sich für uns folgende Ziele für das Asset-Handling:

  • Die Auslieferung erfolgt komprimiert (keine unnötigen Whitespaces etc.).
  • Das HTML-Template muss nur wissen, welche Entrypoints geladen werden sollen.
  • Alle Abhängigkeiten werden automatisch in der richtigen Reihenfolge geladen.
  • Gemeinsame Abhängigkeiten sollen ausgelagert werden, sodass diese nur einmal geladen werden müssen.
  • Die Build-Zeit soll möglichst gering sein, damit bei der Entwicklung keine Zeit durch Warten / manuelle Aktionen verschwendet wird.
  • Optional: Hot-Reload, sodass Änderungen beim Speichern ohne weitere Interaktion direkt im Browser sichtbar werden.

Es gibt verschiedene Tools, mit denen sich diese Ziele erreichen lassen. Bei Chrono24 setzen wir inzwischen auf Webpack. Zwar gibt es massenweise Tutorials, wie man Webpack für neue Projekte am besten konfiguriert und benutzt, allerdings sind die notwendigen Schritte zur Integration in eine bestehende Anwendung vergleichsweise schlecht dokumentiert.

Um anderen den Umstieg zu erleichtern, dokumentieren wir hier unseren Ansatz. Grundsätzlich haben wir uns an der Anleitung von Samuel Teboul orientiert. Die Grundschritte werden deshalb hier nicht erneut erklärt, sondern auf die Besonderheiten bei der Integration in eine Bestandsanwendung eingegangen.

Umgang mit jQuery / Inline-Skripten

Viele bestehende Anwendungen nutzen jQuery und Inline-Skripte, so auch eine unserer Anwendungen, auf deren Setup sich dieser Artikel bezieht. Neben den Inline-Skripten gibt es in dieser Anwendung einige ausgelagerte Skripte, die ebenfalls auf jQuery aufsetzen und globale Auswirkungen haben.

Um die Migration auf Webpack zunächst möglichst ohne Änderungen am bestehenden Code durchführen zu können, werden diese Skripte weiterhin über den script-loader im globalen Kontext ausgeführt und nicht als Module behandelt. Alle alten Skripte wurden in ein legacy/-­​Unterverzeichnis verschoben, um diese klar als veraltet zu markieren und um eine Regel definieren zu können, welche Dateien über den script-loader geladen werden müssen. Im folgenden Webpack-Konfigurationsausschnitt wird dementsprechend das legacy/-​Unterverzeichnis bei den regulären *.js-Dateien ausgeschlossen.

module.exports = {
  /* ... */
  resolve: {
    extensions: ['.js', '.vue'],
    alias: {
      'jquery': path.join(config.inputPath, '/legacy/vendor/jquery.js'),
      'vue$': isDev ? 'vue/dist/vue.runtime.js' : 'vue/dist/vue.runtime.min.js'
    },
  },
  module: {
    rules: [{
      test: /\.js$/,
      include: [config.inputPathJs],
      exclude: /legacy[\\\/]/,
      loader: 'babel-loader'
    }, {
      test: /legacy[\\\/].*\.js$/,
      include: [config.inputPathJs],
      exclude: /node_modules/,
      use: [{
        loader: 'script-loader',
        options: { useStrict: false }
      }]
    }, {
      /* other rules here */
    }]
  },
  externals: {
    'jquery': 'jQuery'
  },
  /* ... */
}

resolve legt die aufzulösenden Dateiendungen fest. alias definiert, dass jquery über legacy/vendor/jquery.js statt über die NPM-Abhängigkeit geladen wird. externals legt fest, dass jQuery nicht ins Bundle mit aufgenommen wird, sondern extern zur Verfügung steht. Die Konfigurationsausnahmen für alte Dateien sind damit erledigt.

Einbinden der generierten Dateien

Unsere Anwendung ist keine Single-Page-Application, womit wir nicht auf das HtmlWebpackPlugin zur automatischen Erstellung von HTML zum Einbinden der generierten JS-Dateien zurückgreifen können. Die Entscheidung, welche Dateien eingebunden werden, erfolgt innerhalb unserer Anwendung.

Um diese Entscheidungslogik umzusetzen, wird eine Liste der von Webpack verarbeiteten Dateien und deren Ausgabepfade benötigt. Mithilfe des webpack-stats-plugin kann man verschiedene Informationen aus dem Webpack-Build-Prozess in eine JSON-Datei schreiben lassen. Für unsere Zwecke genügt eine Liste der Entrypoints inklusive deren Abhängigkeiten. In der Webpack-Konfiguration muss deshalb das Plugin mit entsprechenden Optionen eingebunden werden:

const webpackStats = require('webpack-stats-plugin');

// add inside config.plugins
new webpackStats.StatsWriterPlugin({
  filename: 'stats.json',
  stats: {
    all: false,
    entrypoints: true
  }
})

Anschließend generiert Webpack die folgende JSON-Datei:

{
  "entrypoints": {
    "announcements.js": {
      "chunks": [
        "runtime",
        "npm.webpack-dev-server",
        "npm.webpack",
        "npm.html-entities",
        "npm.querystring-es3",
        "npm.url",
        "npm.ansi-html",
        "npm.loglevel",
        "npm.sockjs-client",
        "npm.process",
        "npm.setimmediate",
        "npm.timers-browserify",
        "npm.vue-hot-reload-api",
        "npm.vue-loader",
        "npm.vue",
        "npm.vuex",
        "npm.ckeditor",
        "announcements.js"
      ],
      "assets": [
        "runtime.53611fccb470fcfba3b9.js",
        "npm.webpack-dev-server.53611fccb470fcfba3b9.js",
        "npm.webpack.53611fccb470fcfba3b9.js",
        "npm.html-entities.53611fccb470fcfba3b9.js",
        "npm.querystring-es3.53611fccb470fcfba3b9.js",
        "npm.url.53611fccb470fcfba3b9.js",
        "npm.ansi-html.53611fccb470fcfba3b9.js",
        "npm.loglevel.53611fccb470fcfba3b9.js",
        "npm.sockjs-client.53611fccb470fcfba3b9.js",
        "npm.process.53611fccb470fcfba3b9.js",
        "npm.setimmediate.53611fccb470fcfba3b9.js",
        "npm.timers-browserify.53611fccb470fcfba3b9.js",
        "npm.vue-hot-reload-api.53611fccb470fcfba3b9.js",
        "npm.vue-loader.53611fccb470fcfba3b9.js",
        "npm.vue.53611fccb470fcfba3b9.js",
        "npm.vuex.53611fccb470fcfba3b9.js",
        "npm.ckeditor.53611fccb470fcfba3b9.js",
        "announcements.js.53611fccb470fcfba3b9.js",
        "announcements.js.335cee00594887838036.hot-update.js"
      ],
      "children": {},
      "childAssets": {}
    }
  }
}

Diese kann in der Anwendung eingelesen werden. Für jeden Entrypoint im Template wird der entsprechende Eintrag in entrypoints gesucht und alle Dateien, die nicht bereits vorher referenziert wurden, eingebunden, z.B. wird der Runtime-Chunk in jedem Entrypoint als Datei aufgeführt. Diese Deduplizierung ist nur notwendig, wenn mehrere Entrypoints pro Seite geladen werden.

Zusatz: Hot-Reload

Webpack bietet einen --watch-Modus, der dafür sorgt, dass geänderte Dateien automatisch zu einem Rebuild führen und kein manueller Prozess bei Änderungen angestoßen werden muss. Noch komfortabler wird der Entwicklungsprozess, wenn die Änderungen direkt ohne Reload im Browser sichtbar werden. Webpack unterstützt sogenanntes Hot Module Replacement, das Module (falls möglich) ohne Reload automatisch zur Laufzeit ersetzt. Sollte das Übernehmen der Änderungen zu Problemen führen, wird stattdessen ein voller Seitenaufruf initiiert.

Die notwendigen Konfigurationsänderungen beschränken sich dabei auf ein Minimum:

  • watchOptions legt fest, welche Pfade ignoriert werden und wie viele Millisekunden ab der ersten Dateiänderung gewartet wird, bis ein Rebuild getriggert wird. Das verhindert, dass mehrere fast zeitgleiche Änderungen zu mehreren Rebuilds führen, z.B. ausgelöst durch eine IDE, die alle geänderten Dateien auf einmal speichert.
  • devServer enthält die Konfiguration für den Webpack-Dev-Server. Dieser übernimmt das Überwachen der Dateien, das Initiieren des Rebuilds sowie die Benachrichtigung des Browsers, welche Module sich geändert haben. writeToDisk sorgt dafür, dass die generierten Dateien ins Dateisystem geschrieben werden, sodass u.a. die Anwendung Zugriff auf die generierte stats.json-Datei zur Auflösung der Abhängigkeiten hat. Der Server wird so konfiguriert, dass alle Dateien weiterhin von der Anwendung ausgespielt werden. Der Webpack-Server antwortet zusätzlich lediglich auf eine WebSocket-URL, sodass der Browser von Webpack über Änderungen informiert werden kann.
  • Im Browser können sowohl die URL http://localhost:8080/ (ohne Hot-Reload) als auch http://localhost:8000/ (mit Hot-Reload) verwendet werden.
{
  plugins: [
    new webpack.EnvironmentPlugin(environment),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  ],
  watchOptions: {
    ignored: /node_modules/,
    aggregateTimeout: 300
  },
  devServer: {
    hot: true,
    contentBase: false,
    index: '',
    port: 8000,
    publicPath: '/assets/generated/',
    proxy: {
      context: () => true,
      target: 'http://localhost:8080'
    },
    writeToDisk: true
  }
}

Fazit

Mithilfe der script-loader-Konfiguration war die Umstellung auf Webpack selbst mit Altlasten problemlos und ohne Fehler möglich. Das Entwickeln gestaltet sich Dank Hot-Reload deutlich komfortabler, v.a. für Komponenten, die sonst nur nach einigen Zwischenschritten im Browser sichtbar werden. Falls einzelne Seitenbereiche per Ajax ersetzt und dabei neue Skripte geladen werden oder das HTML nicht in der Ausgabe-Reihenfolge generiert wird, sind weitere Schritte im Browser notwendig, um das doppelte Laden von einzelnen Chunks zu unterbinden. Diese Aufgabe wird jedoch an dieser Stelle dem Leser überlassen oder folgt in einem weiteren Post. 😉