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
Wir beginnen unsere Modul-Entwicklung mit dem üblichen Modul-Gerüst, welches aus folgenden Komponenten besteht:
ViewConfig
für separate App-Labels#file: source/modules/its/svelte-demo/metadata.php
<?php
/**
* Metadata version
*/
$sMetadataVersion = '2.0';
/**
* Module information
*/
$aModule = [
'id' => 'sveltedemo',
'title' => 'ITSOUP 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' => 'ITSOUP',
'url' => 'http://www.itsoup.de',
'email' => 'info@itsoup.de',
'extend' => [
OxidEsales\Eshop\Core\ViewConfig::class => ITSOUP\SvelteDemo\Core\ViewConfig::class,
],
'controllers' => [
'sveltecontroller' => \ITSOUP\SvelteDemo\Application\Controller\SvelteController::class,
],
'templates' => [
'its_svelte.tpl' => 'irs/svelte-demo/Application/views/page/its_svelte.tpl',
],
'events' => [],
'blocks' => [],
'settings' => []
];
{
"name": "itsoup/sveltedemo",
"description": "Ajax Basket for Tutorial",
"type": "itsoup-module",
"version": "v1.0.0",
"keywords": ["OXID", "modules", "tutorial", "Ajax", "basket"],
"homepage": "https://wwww.itsoup.de",
"license": ["GPL-3.0-only", "proprietary"],
"autoload": {
"psr-4": {
"ITSOUP\\SvelteDemo\\": "../../../source/modules/its/svelte-demo"
}
}
}
Essentiell ist auch hier, dass die Erweiterung als Dependecy zu unserem Haupt-Projekt hinzugefügt wird:
"require": {
"itsoup/sveltedemo":"^v1.0.0"
}
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/its/svelte-demo/Application/translations/de/SvelteDemo_lang.php
<?php
$sLangName = "Deutsch";
$aLang = [
'charset' => 'UTF-8',
];
$aAppLang = [
//======================================
// Component: index.svelte
//======================================
'ITS_BASKETITEM_TITLE' => 'Produkt',
'ITS_BASKETITEM_AMOUNT' => 'Menge',
'ITS_BASKETITEM_PRICE' => 'Preis',
];
#file: source/modules/its/svelte-demo/Application/translations/en/SvelteDemo_lang.php
<?php
$sLangName = "English";
$aLang = [
'charset' => 'UTF-8',
];
$aAppLang = [
//======================================
// Component: index.svelte
//======================================
'ITS_BASKETITEM_TITLE' => 'Title',
'ITS_BASKETITEM_AMOUNT' => 'Amount',
'ITS_BASKETITEM_PRICE' => 'Price',
];
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/its/svelte-demo/Core/ViewConfig.php
<?php
declare(strict_types=1);
namespace ITSOUP\SvelteDemo\Core;
use OxidEsales\Eshop\Core\Registry;
/**
* Class ViewConfig
* @package ITSOUP\SvelteDemo\Core
*/
class ViewConfig extends ViewConfig_parent
{
/**
* @return array
*/
public function getSvelteDemoModuleTranslations(): array
{
$language = Registry::getLang();
$langPath = Registry::getConfig()->getConfigParam('sShopDir') .
'modules' . DIRECTORY_SEPARATOR .
'its/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.
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/its/svelte-demo/Application/Controller/SvelteController.php
<?php
namespace ITSOUP\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 = 'its_svelte.tpl';
}
Die Template-Datei bleibt vorerst weitestgehend leer:
#file: source/modules/its/svelte-demo/Application/views/page/its_svelte.tpl
[{capture append="oxidBlock_content"}][{/capture}]
[{include file="layout/page.tpl"}]
Unser Modul sollte nun etwa wie folgt aussehen:
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/its/svelte-demo
/var/www/html/vendor/bin/oe-console oe:module:apply-configuration
Unser neues Modul kann nun via Backend oder Konsole aktiviert werden.
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/its/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 its_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:
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.
Um den Warenkorb via Ajax zu übergeben, müssen wir diesen in eine, für JavaScript gut zu verstehende Form bringen:
# file: source/modules/its/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 its_svelte.tpl
wie folgt an:
[{assign var="i18n" value=$oViewConf->getSvelteDemoModuleTranslations()}]
[{capture append="oxidBlock_content"}]
[{oxstyle include=$oViewConf->getModuleUrl('its/svelte-demo','/dist/build/bundle.css')}]
<script type="module" src='[{$oViewConf->getModuleUrl('its/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.ITS_BASKETITEM_TITLE}</th>
<th scope="col">{i18n.ITS_BASKETITEM_AMOUNT}</th>
<th scope="col">{i18n.ITS_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:
Ändern wir nun die Sprache auf Englisch, werden die neuen Labels geladen und der Svelte-App übergeben:
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.