netAdvising

solid. digital. experience.

Tutorial: Svelte-Integration via OXID-Modul


Alexander
Alexander
13 Nov 2021
OXID Development

Im heutigen Beitrag wollen wir demonstrieren, wie wir aktuelle Frontend-Technologien via Modul in einen OXID-Shop integrieren. Hierfür nutzen wir Svelte. Neben anderen bekannten Frameworks wie Vue.js und React ist Svelte eines der performantesten. Der Vollständigkeit halber sei gesagt, dass es sich bei Svelte um einen Compiler handelt und nicht um ein Framework.

Svelte is a radical new approach to building user interfaces. Whereas traditional frameworks like React and Vue do the bulk of their work in the browser, Svelte shifts that work into a compile step that happens when you build your app. Instead of using techniques like virtual DOM diffing, Svelte writes code that surgically updates the DOM when the state of your app changes.

Quelle: https://svelte.dev

Erstellung OXID-Modul

Wir beginnen unsere Modul-Entwicklung mit dem üblichen Modul-Gerüst, welches aus folgenden Komponenten besteht:

  • metadata.php
  • composer.json
  • Label-Dateien
  • Erweiterung Klasse ViewConfig für separate App-Labels
  • Controller für Frontend-Seite
  • Template-Datei

metadata.php

#file: source/modules/na/svelte-demo/metadata.php
<?php

/**
 * Metadata version
 */
$sMetadataVersion = '2.0';

/**
 * Module information
 */
$aModule = [
    'id'          => 'sveltedemo',
    'title'       => 'netAdvising Svelte-Demo',
    'description' => [
        'de' => 'Demonstriert die Einbindung einer Svelte-Applikation.',
        'en' => 'Demonstrates the implementation of a svelte application.',
    ],
    'thumbnail'   => 'logo.png',
    'version'     => '1.0.0',
    'author'      => 'netAdvising',
    'url'         => 'http://www.netadvising.de',
    'email'       => 'info@netadvising.de',
    'extend'      => [
        OxidEsales\Eshop\Core\ViewConfig::class => NetAdvising\SvelteDemo\Core\ViewConfig::class,
    ],
    'controllers' => [
        'sveltecontroller' => \NetAdvising\SvelteDemo\Application\Controller\SvelteController::class,
    ],
    'templates'   => [
        'na_svelte.tpl' => 'na/svelte-demo/Application/views/page/na_svelte.tpl',
    ],
    'events'      => [],
    'blocks'      => [],
    'settings'    => []
];

composer.json

{
	"name": "netadvising/sveltedemo",
	"description": "Ajax Basket for Tutorial",
	"type": "netadvising-module",
	"version": "v1.0.0",
	"keywords": ["OXID", "modules", "tutorial", "Ajax", "basket"],
	"homepage": "https://wwww.netadvising.de",
	"license": ["GPL-3.0-only", "proprietary"],
	"autoload": {
		"psr-4": {
			"NetAdvising\\SvelteDemo\\": "../../../source/modules/na/svelte-demo"
		}
	}
}

Essentiell ist auch hier, dass die Erweiterung als Dependecy zu unserem Haupt-Projekt hinzugefügt wird:

"require": {
    "netadvising/sveltedemo":"^v1.0.0"
}

Label-Dateien

Wir erstellen zwei Label-Dateien, jeweils eine für die Sprachen Deutsch und Englisch. Wichtig ist hierbei, dass wir die Labels nicht der Variable $aLang zuordnen, sondern hierfür eine neue Variable $aAppLang nutzen. Grund hierfür ist, dass wir gegebenenfalls Labels im Frontend nutzen wollen, die jedoch nicht in der Svelte-App genutzt werden sollen. Damit überflüssige Labels nicht an die Svelte-App übergeben werden, erfolgt eine logische Trennung der Labels.

#file: source/modules/na/svelte-demo/Application/translations/de/SvelteDemo_lang.php
<?php

$sLangName = "Deutsch";

$aLang = [
    'charset' => 'UTF-8',
];

$aAppLang = [
    //======================================
    // Component: index.svelte
    //======================================
    'NA_BASKETITEM_TITLE'   => 'Produkt',
    'NA_BASKETITEM_AMOUNT'  => 'Menge',
    'NA_BASKETITEM_PRICE'   => 'Preis',
];

#file: source/modules/na/svelte-demo/Application/translations/en/SvelteDemo_lang.php
<?php

$sLangName = "English";

$aLang = [
    'charset' => 'UTF-8',
];

$aAppLang = [
    //======================================
    // Component: index.svelte
    //======================================
    'NA_BASKETITEM_TITLE'   => 'Title',
    'NA_BASKETITEM_AMOUNT'  => 'Amount',
    'NA_BASKETITEM_PRICE'   => 'Price',
];

Erweiterung Klasse ViewConfig für separate App-Labels

Wie in der metadata.php ersichtlich, wird die Klasse ViewConfig erweitert. Dies dient der Extraktion der App-spezifischen Labels und wird wie folgt umgesetzt:

#file: source/modules/na/svelte-demo/Core/ViewConfig.php
<?php

declare(strict_types=1);

namespace NetAdvising\SvelteDemo\Core;

use OxidEsales\Eshop\Core\Registry;

/**
 * Class ViewConfig
 * @package NetAdvising\SvelteDemo\Core
 */
class ViewConfig extends ViewConfig_parent
{
    /**
     * @return array
     */
    public function getSvelteDemoModuleTranslations(): array
    {
        $language = Registry::getLang();

        $langPath = Registry::getConfig()->getConfigParam('sShopDir') .
                    'modules' . DIRECTORY_SEPARATOR .
                    'na/svelte-demo'. DIRECTORY_SEPARATOR .
                    'Application' . DIRECTORY_SEPARATOR .
                    'translations' . DIRECTORY_SEPARATOR .
                    $language->getLanguageAbbr($language->getTplLanguage());

        $langFile = $this->getLanguageFiles($langPath);

        $aAppLang = [];
        if (file_exists($langFile) && is_readable($langFile)) {
            include $langFile;
        }

        return $aAppLang;
    }

    /**
     * @param string $sFullPath
     *
     * @return string
     */
    protected function getLanguageFiles(string $sFullPath): string
    {
        $aFiles = glob($sFullPath . "/*_lang.php");
        if (is_array($aFiles) && count($aFiles)) {
            foreach ($aFiles as $sFile) {
                if (!strpos($sFile, 'cust_lang.php')) {
                    return $sFile;
                }
            }
        }

        return "";
    }
}

Bei der Implementierung der Logik haben wir uns an der originialen OXID-Implementierung orientiert. Diese scannt innerhalb eines bestimmten Verzeichnisses nach Dateien, die mit dem String _lang.php enden und inkludiert diese.

Controller für Frontend-Seite

Vor der Anlage der Template-Datei, welche unsere App lädt, erstellen wir hierfür noch ein Controller, da wir später noch zusätzliche Logik implementieren wollen.

#file: source/modules/na/svelte-demo/Application/Controller/SvelteController.php
<?php

namespace NetAdvising\SvelteDemo\Application\Controller;

use OxidEsales\Eshop\Application\Controller\FrontendController;
use OxidEsales\Eshop\Core\Registry;

class SvelteController extends FrontendController
{
    /**
     * Current class name
     *
     * @var string
     */
    protected $_sClassName = 'sveltecontroller';

    /**
     * Current class template name.
     *
     * @var string
     */
    protected $_sThisTemplate = 'na_svelte.tpl';
}

Template-Datei

Die Template-Datei bleibt vorerst weitestgehend leer:

#file: source/modules/na/svelte-demo/Application/views/page/na_svelte.tpl
[{capture append="oxidBlock_content"}][{/capture}]
[{include file="layout/page.tpl"}]

Unser Modul sollte nun etwa wie folgt aussehen:

Screenshot

Nachdem das Modul-Gerüst soweit vorbereitet ist, installieren wir das Modul und übernehmen die Modul-Informationen in die DatenbanK:

/var/www/html/vendor/bin/oe-console oe:module:install-configuration /var/www/html/source/modules/na/svelte-demo
/var/www/html/vendor/bin/oe-console oe:module:apply-configuration

Unser neues Modul kann nun via Backend oder Konsole aktiviert werden.

Einrichtung Svelte-App

Für die Svelte-App nutzen wir folgende package.json:

{
	"name": "svelte-app",
	"version": "1.0.0",
	"private": true,
	"scripts": {
		"build": "rollup -c",
		"dev": "rollup -c -w",
		"start": "sirv public --no-clear",
		"check": "svelte-check --tsconfig ./tsconfig.json",
		"format": "prettier --write --plugin-search-dir=. ."
	},
	"devDependencies": {
		"@rollup/plugin-commonjs": "^21.0.1",
		"@rollup/plugin-node-resolve": "^13.0.6",
		"@rollup/plugin-typescript": "^8.3.0",
		"@tsconfig/svelte": "^2.0.1",
		"rollup": "^2.60.0",
		"prettier": "^2.4.1",
		"prettier-eslint": "^13.0.0",
		"prettier-plugin-svelte": "^2.4.0",
		"rollup-plugin-copy-watch": "^0.0.1",
		"rollup-plugin-css-only": "^3.1.0",
		"rollup-plugin-livereload": "^2.0.5",
		"rollup-plugin-svelte": "^7.1.0",
		"rollup-plugin-terser": "^7.0.2",
		"svelte": "^3.44.1",
		"svelte-check": "^2.2.8",
		"svelte-preprocess": "^4.9.8",
		"tslib": "^2.3.1",
		"typescript": "^4.4.4"
	},
	"dependencies": {
		"sirv-cli": "^1.0.14"
	}
}

Diese package.json enthält bereits alle Abhängigkeiten, die wir für ein Minimal-Beispiel benötigen.

Da wir TypeScript verwenden, legen wir noch die folgende tsconfig.json an:

{
	"extends": "@tsconfig/svelte/tsconfig.json",
	"include": ["out/src/js/**/*"],
	"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}

Wichtig ist hierbei die Anpassung des Pfades unter include. In OXID-Modulen ist es üblich, dass JavaScript unter out/src/js gespeichert wird. Daher passen wir den Pfad entsprechend an.

Die Formatierung unseres Quelltextes übernimmt Prettier. Hierfür legen wir eine Datei .prettierrc an, welche die Formatierung der Vorgaben grob beschreibt:

{
	"useTabs": true,
	"singleQuote": true,
	"trailingComma": "none",
	"printWidth": 100
}

Zudem erstellen wir eine .prettierignore, die dafür sorgt, dass nur die Dateien formatiert werden, die zur eigentlichen Codebase gehören:

Application/**
Core/**
dist/**
node_modules/**

Svelte nutzt rollup.js für das bundling. Um rollup.js ordnugnsgemäß verwenden zu können, muss dies via rollup.config.js korrekt konfiguriert werden.

import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import path from 'path';
import livereload from 'rollup-plugin-livereload';
import copy from 'rollup-plugin-copy-watch';
import { terser } from 'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';
import css from 'rollup-plugin-css-only';

const production = !process.env.ROLLUP_WATCH;
const buildDir = 'dist';

function serve() {
	let server;

	function toExit() {
		if (server) server.kill(0);
	}

	return {
		writeBundle() {
			if (server) return;
			server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
				stdio: ['ignore', 'inherit', 'inherit'],
				shell: true
			});

			process.on('SIGTERM', toExit);
			process.on('exit', toExit);
		}
	};
}

export default {
	input: path.join(__dirname, 'out', 'src', 'js', 'main.ts'),
	output: {
		sourcemap: true,
		format: 'iife',
		name: 'app',
		dir: path.join(__dirname, buildDir, 'build')
	},
	plugins: [
		svelte({
			preprocess: sveltePreprocess({ sourceMap: !production }),
			compilerOptions: {
				// enable run-time checks when not in production
				dev: !production
			}
		}),
		// we'll extract any component CSS out into
		// a separate file - better for performance
		css({ output: 'bundle.css' }),
		copy({
			watch: 'src',
			targets: [
				{
					src: path.join(__dirname, 'out', 'src', 'assets', '*'),
					dest: path.join(__dirname, buildDir, 'assets')
				}
			]
		}),
		// If you have external dependencies installed from
		// npm, you'll most likely need these plugins. In
		// some cases you'll need additional configuration -
		// consult the documentation for details:
		// https://github.com/rollup/plugins/tree/master/packages/commonjs
		resolve({
			browser: true,
			dedupe: ['svelte']
		}),
		commonjs(),
		typescript({
			sourceMap: !production,
			inlineSources: !production
		}),

		// In dev mode, call `npm run start` once
		// the bundle has been generated
		!production && serve(),

		// Watch the buildDir directory and refresh the
		// browser on changes when not in production
		!production &&
			livereload({
				watch: path.join(__dirname, buildDir)
			}),

		// If we're building for production (npm run build
		// instead of npm run dev), minify
		production && terser()
	],
	watch: {
		clearScreen: false
	}
};

Diese Konfiguration sorgt dafür, dass rollup die Quell-Dateien an der korrekten Stelle sucht und die fertigen Pakete in den dafür vorgesehenen Ordner verschiebt. Weiterhin haben wir livereload hinzugefügt, was dafür sorgt, dass die Seite bei Anpassungen am Quelltext automatisch neu geladen wird. Änderungen werden somit sofort sichtbar.

Anschließend legen wir den Einstiegspunkt unserer App an und erstellen hierfür eine main.ts im Ordner source/modules/na/svelte-demo/out/src/js/:

import App from './App.svelte';

let target: HTMLElement = document.querySelector('#svelteDemo');

const app = new App({
	target
});

export default app;

Die dazugehörige Web-Komponente (App.svelte) bleibt vorerst leer:

<script lang="ts">

</script>

Hello World

Die App wird nun im Container mit der ID svelteDemo geladen, den wir bereits in unserer na_svelte.tpl hinterlegt haben.

Wir installieren nun alle JavaScript-Abhängigkeiten mit dem Befehl pnpm install und starten die App anschließend mit pnpm run dev.

Wenn wir nun den Shop und den dazugehörigen Controller aufrufen, sollten wir folgendes Bild sehen:

Screenshot

Unsere App ist also innerhalb des OXID-Shops lauffähig.

Im letzten Schritt möchten wir verdeutlichen, wie wir mit einer solchen App speziell in OXID arbeiten könnten. Hierfür möchten wir den Warenkorb innerhalb der Svelte-App darstellen, wobei der Inhalt des Warenkorbs asynchron nachgeladen wird.

Erweiterung Frontend-Controller

Um den Warenkorb via Ajax zu übergeben, müssen wir diesen in eine, für JavaScript gut zu verstehende Form bringen:

# file: source/modules/na/svelte-demo/Application/Controller/SvelteController.php
/**
 * get user basket
 *
 * @throws oxNoArticleException|oxArticleInputException
 */
public function getUserBasket(): void
{
    $sessionBasket = Registry::getSession()->getBasket();
    $sessionBasketItems = $sessionBasket->getContents();

    $basket = [];

    $currency = Registry::getConfig()->getActShopCurrencyObject();

    foreach ($sessionBasketItems as $sessionBasketItem) {
        $basketItem = [
            'title' => $sessionBasketItem->getTitle(),
            'productId' => $sessionBasketItem->getProductId(),
            'amount' => $sessionBasketItem->getAmount(),
            'price' => $sessionBasketItem->getFRegularUnitPrice(),
            'currency' => $currency->sign
        ];
        array_push($basket, $basketItem);
    }

    Registry::getUtils()->showMessageAndExit(json_encode($basket));
}

Wir entnehmen dem Warenkorb-Objekt nur die Daten, die wir im Frontend benötigen und geben diese im JSON-Format zurück.

Da wir noch die Labels in unserer App benötigen, passen wir die na_svelte.tpl wie folgt an:

[{assign var="i18n" value=$oViewConf->getSvelteDemoModuleTranslations()}]
[{capture append="oxidBlock_content"}]
    [{oxstyle include=$oViewConf->getModuleUrl('na/svelte-demo','/dist/build/bundle.css')}]
    <script type="module" src='[{$oViewConf->getModuleUrl('na/svelte-demo','/dist/build/main.js')}]'></script>
    <div
        id="svelteDemo"
        data-baseUrl="[{$oViewConf->getBaseDir()}]"
        data-i18n='[{$i18n|@json_encode}]'
    ></div>
[{/capture}]
[{include file="layout/page.tpl"}]

Die Labels werden als Daten-Attribut (i18n) übergeben. Zudem übergeben wir die baseURL des Shops. Diese benötigen wir für spätere Ajax-Abfragen.

Anzumerken ist, dass oxscript in diesem Fall nicht genutzt wird, da das Script als Modul eingebunden werden muss.

Wir erweitern außerdem die main.ts um die neuen Informationen und geben diese weiter an die Web-Komponente:

import App from './App.svelte';

let target: HTMLElement = document.querySelector('#svelteDemo');

const app = new App({
	target,
	props: {
		baseUrl: target.dataset.baseurl,
		i18n: JSON.parse(target.dataset.i18n)
	}
});

export default app;

In der App.svelte nehmen wir diese neuen Eigenschaften entgegen, rufen unsere PHP-Methode via Ajax auf und stellen den Warenkorb dar:

<script lang="ts">
	// imports
	import { onMount } from 'svelte';

	// types
	interface I18n {
		[key: string]: string;
	}

	interface UserBasket {
		title: string;
		productId: string;
		amount: number;
		price: string;
		currency: string;
	}

	// exports
	export let name: string;
	export let baseUrl: string;
	export let i18n: I18n;

	// variables
	let userBasket: UserBasket[];

	// logic
	const getUserBasket = async (): Promise<any> => {
		// we send form data because the oxid frameworks understand this kind of data
		const formData = new FormData();
		formData.append('cl', 'sveltecontroller');
		formData.append('fnc', 'getUserBasket');

		const response = await fetch(baseUrl, {
			method: 'POST',
			redirect: 'follow',
			body: formData
		});
		return response.json();
	};

	onMount(async () => {
		userBasket = await getUserBasket();
	});
</script>

{#if userBasket}
	<table class="table table-hover">
		<thead>
			<tr>
				<th scope="col">#</th>
				<th scope="col">{i18n.NA_BASKETITEM_TITLE}</th>
				<th scope="col">{i18n.NA_BASKETITEM_AMOUNT}</th>
				<th scope="col">{i18n.NA_BASKETITEM_PRICE}</th>
			</tr>
		</thead>
		<tbody>
			{#each userBasket as basketItem, i}
				<tr>
					<th scope="row">{i}</th>
					<td>{basketItem.title}</td>
					<td>{basketItem.amount}</td>
					<td>{basketItem.price} {basketItem.currency}</td>
				</tr>
			{/each}
		</tbody>
	</table>
{/if}

Sobald die Komponente geladen wird, ruft diese unsere PHP-Logik auf und beschafft den OXID-Warenkorb. Dieser wird anschließend tabellarisch angezeigt: Screenshot

Ändern wir nun die Sprache auf Englisch, werden die neuen Labels geladen und der Svelte-App übergeben: Screenshot

Deployment der App

Nachdem unsere App fertig entwickelt ist, muss der produktionsbereite Code noch erzeugt werden. Hierfür rufen wir pnpm run build auf. Der fertige Code wird in den dist-Ordner kopiert. Dieser muss nun der Versionsverwaltung hinzugefügt werden und mit dem Modul ausgeliefert werden. Alternativ können die separaten Applikationen auch innerhalb einer Build-Pipeline erstellt werden.