WebApps in der Adresszeile des Browsers

von | 19.12.2019 | Softwareentwicklung | 0 Kommentare

Desktop-Festplatten speichern heutzutage mehrere TeraByte und lassen sich in Servern zu exorbitanten Speichergrößen zusammenschalten. Der Hauptspeicher bei aktuellen Systemen bewegt sich im GigaByte-Bereich. Grafikkarten für Computerspiele sind mittlerweile bei 12 GB angekommen und selbst mobile Geräte wie Smartphones besitzen vergleichbar gigantische Speichermöglichkeiten; mein iPhone 7 Plus hat bspw. 128 GB Flashspeicher und 3GB RAM – wow! Die Zeiten, in denen mangelnder Speicherplatz für Programmierer ein großes Problem darstellte, scheinen vorbei zu sein.

Es gibt allerdings noch Bereiche, bei denen der Speicherplatzverbrauch eine wichtige Rolle spielt: Webseiten und WebApps auf mobilen Geräten. Für solche Systeme spielt die Größe der App bzgl. Javascript, HTML und CSS eine nicht zu unterschätzende Rolle. Für das Ladeverhalten und die Reaktionszeit einer App macht es eben einen großen Unterschied, ob eine App 300 KB oder 2 MB groß ist.

Auch wenn aktuell zu lesen ist, dass bspw. bei der Entwicklung von Angular 9 mit dem neuen Renderer Ivy dafür gesorgt wird, dass die erstellten Javascript-Bundles deutlich kleiner werden als in vorherigen Versionen, habe ich mir eine Aufgabe gestellt: Ich möchte eine App programmieren, die so klein ist, dass sie in die Adresszeile des Browsers passt. Einerseits ist das zwar nur eine technische Spielerei, die für mich persönlich wieder einen kleinen „Geek-Faktor“ hat, andererseits hat diese Spielerei aber auch einen konkreten Anwendungsfall, auf den ich später noch zurückkomme.

Effekte einer App, die in die Adresszeile des Browsers passt

Eine App, die so klein ist, dass sie in die Adresszeile eines Browsers passt, offenbart einige positive und negative Effekte. Und das nicht nur auf mobilen Geräten. Hier die aus meiner Sicht positiven Effekte:

  • Wenn keine externen Ressourcen wie andere Dateien oder XHR-Requests genutzt werden, ist die initiale Ladezeit extrem gering und es muss nichts aus dem Netz nachgeladen werden.
  • Die gesamte App ließe sich in den Favoriten speichern.
  • Daten könnten lokal bspw. über den local storage abgelegt werden.
  • Die App könnte 100% offline funktionieren.

Technisch gesehen gibt es neben den positiven Effekten auch einige Nachteile:

  • Die URL zum Öffnen der App ist nicht gut lesbar oder per Hand in den Browser einzugeben.
  • Die ausgeführte URL kann jeglichen Javascript-Code ausführen und lässt sich somit schlecht vor Manipulation schützen. (Es empfiehlt sich daher dringend, die Quelle der URL zu kennen UND natürlich muss diese Quelle absolut vertrauenswürdig sein.)
  • Die Funktionalität der App ist relativ limitiert, da die Adressleiste des Browsers stark eingeschränkt ist.

Die Implementierung von Pong als Beispiel

Für die Implementierung einer App, die in die Adresszeile des Browsers passt, nehme ich mir Pong als Beispiel. Die Logik des Spiels ist simpel, das Spiel benötigt keinerlei Grafiken oder ähnliches, und es lässt sich vollständig mit Browser-Bordmitteln realisieren.

Als Entwicklungsumgebung verwende ich Visual Studio Code. Für ein kleines bisschen einfaches Javascript und HTML ist das mehr als ausreichend. Zusätzlich verwende ich noch ein lokales Git-Repository. Los geht’s.

Zuallererst gilt es das Spiel im Browser mittels einer HTML-Datei mit direkt eingebettetem Javascript zum Laufen zu bringen:

Pong als App in der Adresszeile des Browsers

Die Rechtecke oben in der Mitte des Bildes stehen für die zu erzielenden Tore. Sobald ein Spieler 5 Tore erzielt, gewinnt er die Partie und das Spiel lässt sich per Enter-Taste neu starten. Der linke Spieler wird mit den Tasten W und S und der Rechte mit Pfeiltaste hoch und Pfeiltaste runter gesteuert.

Hier der dazugehörige Quellcode:

<!DOCTYPE html>
<html>
    <head>
        <title>Tiny Pong</title>
    </head>
    <body>
        <canvas id="mainCanvas" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);">
    </body>
    <script>
        const game = function() {
            const WIDTH = 640;
            const HEIGHT = 480;
            const PADDLE_WIDTH = 24;
            const PADDLE_HEIGHT = 120;
            const BALL_SIZE = 24;
            const BALL_SPEED = 200;
            const BALL_MAX_SPEED = 400;
            const BALL_SPEED_INCREASE = 20;
            const PADDLE_SPEED = 300;
            const MAX_POINTS = 5;
            const POINT_SIZE = 16;
            const POINT_PADDING = 4;

            const mainCanvas = document.getElementById('mainCanvas');
            mainCanvas.width = WIDTH;
            mainCanvas.height = HEIGHT;
            const context = mainCanvas.getContext('2d');

            let input = {
                left_up : false,
                left_down: false,
                right_up: false,
                right_down: false,
                restart: false
            };

            let ball = {
                x: WIDTH/2-BALL_SIZE/2,
                y: HEIGHT/2-BALL_SIZE/2,
                dirX: 0,
                dirY: 1,
                angle: Math.PI / 6,
                speed: BALL_SPEED
            };

            let paddle_left = {
                x : 10,
                y : HEIGHT/2-PADDLE_HEIGHT/2,
                goals: 0
            };

            let paddle_right = {
                x : WIDTH - 10 - PADDLE_WIDTH,
                y : HEIGHT/2-PADDLE_HEIGHT/2,
                goals: 0
            };

            updateBallDirection(ball);

            window.addEventListener('keydown', event => {
                if (event.key === 'w') {
                    input.left_up = true;
                } else if (event.key === 's') {
                    input.left_down = true;
                } if (event.key === 'ArrowUp') {
                    input.right_up = true;
                } else if (event.key === 'ArrowDown') {
                    input.right_down = true;
                } else if (event.key === 'Enter') {
                    input.restart = true;
                }
            });
            window.addEventListener('keyup', event => {
                if (event.key === 'w') {
                    input.left_up = false;
                } else if (event.key === 's') {
                    input.left_down = false;
                } if (event.key === 'ArrowUp') {
                    input.right_up = false;
                } else if (event.key === 'ArrowDown') {
                    input.right_down = false;
                } else if (event.key === 'Enter') {
                    input.restart = false;
                }
            });

            let start = null;
            function animationFrameRequested(timestamp) {
                if (!start) start = timestamp;
                var elapsedTime = (timestamp - start) * 0.001;
                start = timestamp;
                updateAndRender(context, elapsedTime);
                window.requestAnimationFrame(animationFrameRequested);
            }

            function updateAndRender(context, elapsedTime) {
                context.fillStyle = 'black';
                context.fillRect(0, 0, WIDTH, HEIGHT);
                context.fillStyle = 'white';
                context.strokeStyle = 'white';
                context.lineWidth = 2;

                if (paddle_left.goals === MAX_POINTS) {
                    showWinner(context, 'Left Player Wins');
                } else if (paddle_right.goals === MAX_POINTS) {
                    showWinner(context, 'Right Player Wins');
                } else {
                    moveBall(elapsedTime);
                    updatePaddle(paddle_left, input.left_up, input.left_down, elapsedTime);
                    updatePaddle(paddle_right, input.right_up, input.right_down, elapsedTime);

                    let newAngle = null;
                    if (ball.x <= -BALL_SIZE) {
                        paddle_right.goals += 1;
                        newAngle = -Math.PI/4 + Math.random() * Math.PI/2
                    } else if (ball.x >= WIDTH + BALL_SIZE) {
                        paddle_left.goals += 1;
                        newAngle = Math.PI/4*3 + Math.random() * Math.PI/2
                    }
                    if (newAngle) {
                        respawnBall(ball, newAngle);
                    }

                    drawBall(context, ball);
                    drawPaddle(context, paddle_left);
                    drawPaddle(context, paddle_right);
                    drawPoints(context);
                }
            }

            function respawnBall(ball, newAngle) {
                ball.angle = newAngle;
                ball.speed = BALL_SPEED;
                ball.x = WIDTH/2-BALL_SIZE/2;
                ball.y = HEIGHT/2-BALL_SIZE/2;
                updateBallDirection(ball);
            }

            function showWinner(context, text) {
                context.font = '30px Arial';
                context.fillText(text, WIDTH/2-context.measureText(text).width/2, HEIGHT/2-15);
                const restartText = 'Press <Enter> to Restart Game';
                context.fillText(restartText, WIDTH/2-context.measureText(restartText).width/2, HEIGHT/2+40);

                if (input.restart) {
                    paddle_left.y = HEIGHT/2-PADDLE_HEIGHT/2;
                    paddle_right.y = HEIGHT/2-PADDLE_HEIGHT/2;
                    respawnBall(ball,  Math.PI / 6);
                    paddle_left.goals = 0;
                    paddle_right.goals = 0;
                }
            }

            function moveBall(elapsedTime) {
                ball.x += ball.dirX * elapsedTime * ball.speed;
                ball.y += ball.dirY * elapsedTime * ball.speed;

                if (ball.y > HEIGHT-BALL_SIZE && ball.dirY > 0 || ball.y < 0 && ball.dirY < 0) {
                    ball.dirY = -ball.dirY;
                }

                if (ball.dirX < 0 
                    && ball.x <= paddle_left.x + PADDLE_WIDTH
                    && ball.y + BALL_SIZE > paddle_left.y
                    && ball.y < paddle_left.y + PADDLE_HEIGHT) {
                    ball.dirX = -ball.dirX;
                    ball.speed = Math.min(BALL_MAX_SPEED, ball.speed + BALL_SPEED_INCREASE);
                }
                
                if (ball.dirX > 0 
                    && ball.x + BALL_SIZE > paddle_right.x
                    && ball.y + BALL_SIZE > paddle_right.y
                    && ball.y < paddle_right.y + PADDLE_HEIGHT) {
                    ball.dirX = -ball.dirX;
                    ball.speed = Math.min(BALL_MAX_SPEED, ball.speed + BALL_SPEED_INCREASE);
                }
            }

            function updatePaddle(paddle, up, down, elapsedTime) {
                if (up) {
                    paddle.y -= elapsedTime * PADDLE_SPEED;
                } else if (down) {
                    paddle.y += elapsedTime * PADDLE_SPEED;
                }
                paddle.y = Math.min(Math.max(0, paddle.y), HEIGHT-PADDLE_HEIGHT);
            }

            function updateBallDirection(ball) {
                ball.dirX = Math.cos(ball.angle);
                ball.dirY = Math.sin(ball.angle);
            }

            function drawBall(context, ball) {
                context.fillRect(ball.x, ball.y, BALL_SIZE, BALL_SIZE);
            }

            function drawPaddle(context, paddle) {
                context.fillRect(paddle.x, paddle.y, PADDLE_WIDTH, PADDLE_HEIGHT);
            }

            function drawPoints(context) {
                for (let i = 0; i < MAX_POINTS; i++) {
                    const xleft = WIDTH/2-(i+1)*(POINT_SIZE+POINT_PADDING)-POINT_SIZE;
                    if (i < paddle_left.goals) {
                        context.fillRect(xleft, POINT_PADDING, POINT_SIZE, POINT_SIZE);
                    } else {
                        context.beginPath();
                        context.rect(xleft, POINT_PADDING, POINT_SIZE, POINT_SIZE);
                        context.stroke();
                    }

                    const xright = WIDTH/2+i*(POINT_SIZE+POINT_PADDING)+POINT_SIZE;
                    if (i < paddle_right.goals) {
                        context.fillRect(xright, POINT_PADDING, POINT_SIZE, POINT_SIZE);
                    } else {
                        context.beginPath();
                        context.rect(xright, POINT_PADDING, POINT_SIZE, POINT_SIZE);
                        context.stroke();
                    }
                }
            }

            window.requestAnimationFrame(animationFrameRequested);
        }();
    </script>
</html>

Der Minifier im Einsatz

Als Nächstes muss der gesamte Code möglichst „klein gemacht werden“. Dafür verwendet man sogenannte Minifier. Das sind Programme, die alle unnötigen Zeichen entfernen und besonders bei Javascript alle Bezeichner verkürzen, so dass das Programm deutlich schlanker wird. Diese Vorgehensweise wird auch bei Single Page Applications verwendet, um die Größe der an den Browser ausgelieferten Dateien zu verkleinern. Hierbei gibt es oftmals noch viel umfangreichere Optimierungen wie Tree Shaking, um nicht verwendeten Code aus verwendeten Bibliotheken zu entfernen. Für uns reicht das reine Kürzen des Inhalts. Entsprechende Tools finden Sie unter https://javascript-minifier.com/ oder http://minifycode.com/html-minifier/.

Und so sieht das Ergebnis nach dem Einsatz eines Minifiers aus:

<!DOCTYPE html><html><head><title>Tiny Pong</title></head><body><canvas id="mainCanvas" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);"></body><script>const game=function(){const e=640,t=480,n=24,i=120,r=24,l=200,o=400,a=20,s=300,d=5,y=16,f=4,h=document.getElementById("mainCanvas");h.width=e,h.height=t;const w=h.getContext("2d");let g={left_up:!1,left_down:!1,right_up:!1,right_down:!1,restart:!1},u={x:e/2-r/2,y:t/2-r/2,dirX:0,dirY:1,angle:Math.PI/6,speed:l},c={x:10,y:t/2-i/2,goals:0},p={x:e-10-n,y:t/2-i/2,goals:0};M(u),window.addEventListener("keydown",e=>{"w"===e.key?g.left_up=!0:"s"===e.key&&(g.left_down=!0),"ArrowUp"===e.key?g.right_up=!0:"ArrowDown"===e.key?g.right_down=!0:"Enter"===e.key&&(g.restart=!0)}),window.addEventListener("keyup",e=>{"w"===e.key?g.left_up=!1:"s"===e.key&&(g.left_down=!1),"ArrowUp"===e.key?g.right_up=!1:"ArrowDown"===e.key?g.right_down=!1:"Enter"===e.key&&(g.restart=!1)});let x=null;function m(n,i){n.angle=i,n.speed=l,n.x=e/2-r/2,n.y=t/2-r/2,M(n)}function k(n,r){n.font="30px Arial",n.fillText(r,e/2-n.measureText(r).width/2,t/2-15);n.fillText("Press <Enter> to Restart Game",e/2-n.measureText("Press <Enter> to Restart Game").width/2,t/2+40),g.restart&&(c.y=t/2-i/2,p.y=t/2-i/2,m(u,Math.PI/6),c.goals=0,p.goals=0)}function _(e,n,r,l){n?e.y-=l*s:r&&(e.y+=l*s),e.y=Math.min(Math.max(0,e.y),t-i)}function M(e){e.dirX=Math.cos(e.angle),e.dirY=Math.sin(e.angle)}function P(e,t){e.fillRect(t.x,t.y,n,i)}window.requestAnimationFrame(function l(s){x||(x=s);var h=.001*(s-x);x=s,function(l,s){if(l.fillStyle="black",l.fillRect(0,0,e,t),l.fillStyle="white",l.strokeStyle="white",l.lineWidth=2,c.goals===d)k(l,"Left Player Wins");else if(p.goals===d)k(l,"Right Player Wins");else{!function(e){u.x+=u.dirX*e*u.speed,u.y+=u.dirY*e*u.speed,(u.y>t-r&&u.dirY>0||u.y<0&&u.dirY<0)&&(u.dirY=-u.dirY),u.dirX<0&&u.x<=c.x+n&&u.y+r>c.y&&u.y<c.y+i&&(u.dirX=-u.dirX,u.speed=Math.min(o,u.speed+a)),u.dirX>0&&u.x+r>p.x&&u.y+r>p.y&&u.y<p.y+i&&(u.dirX=-u.dirX,u.speed=Math.min(o,u.speed+a))}(s),_(c,g.left_up,g.left_down,s),_(p,g.right_up,g.right_down,s);let h=null;u.x<=-r?(p.goals+=1,h=-Math.PI/4+Math.random()*Math.PI/2):u.x>=e+r&&(c.goals+=1,h=Math.PI/4*3+Math.random()*Math.PI/2),h&&m(u,h),function(e,t){e.fillRect(t.x,t.y,r,r)}(l,u),P(l,c),P(l,p),function(t){for(let n=0;n<d;n++){const i=e/2-(n+1)*(y+f)-y;n<c.goals?t.fillRect(i,f,y,y):(t.beginPath(),t.rect(i,f,y,y),t.stroke());const r=e/2+n*(y+f)+y;n<p.goals?t.fillRect(r,f,y,y):(t.beginPath(),t.rect(r,f,y,y),t.stroke())}}(l)}}(w,h),window.requestAnimationFrame(l)})}();</script></html>

Mit ein bisschen Handarbeit lässt sich der Umfang und damit die Größe des Codes noch weiter reduzieren. Um das Ganze jetzt in die Adresszeile des Browsers zu bekommen, gilt es nur noch den korrekten MIME-Type mittels einer Data URI anzugeben. Dazu nehmen Sie einfach den obigen minifizierten Code und stellen folgenden Text vorne an:

data:text/html,
Damit haben Sie auch schon alles zusammen! Wenn Sie eine Runde Pong mit einem Kollegen im Browser spielen wollen, können Sie einfach den folgenden Text kopieren und in Ihre Browser-Adressleiste einfügen. Viel Spaß beim Spielen.
data:text/html,<!DOCTYPE html><html><head><title>Tiny Pong</title></head><body><canvas id="mainCanvas" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);"></body><script>const game=function(){const e=640,t=480,n=24,i=120,r=24,l=200,o=400,a=20,s=300,d=5,y=16,f=4,h=document.getElementById("mainCanvas");h.width=e,h.height=t;const w=h.getContext("2d");let g={left_up:!1,left_down:!1,right_up:!1,right_down:!1,restart:!1},u={x:e/2-r/2,y:t/2-r/2,dirX:0,dirY:1,angle:Math.PI/6,speed:l},c={x:10,y:t/2-i/2,goals:0},p={x:e-10-n,y:t/2-i/2,goals:0};M(u),window.addEventListener("keydown",e=>{"w"===e.key?g.left_up=!0:"s"===e.key&&(g.left_down=!0),"ArrowUp"===e.key?g.right_up=!0:"ArrowDown"===e.key?g.right_down=!0:"Enter"===e.key&&(g.restart=!0)}),window.addEventListener("keyup",e=>{"w"===e.key?g.left_up=!1:"s"===e.key&&(g.left_down=!1),"ArrowUp"===e.key?g.right_up=!1:"ArrowDown"===e.key?g.right_down=!1:"Enter"===e.key&&(g.restart=!1)});let x=null;function m(n,i){n.angle=i,n.speed=l,n.x=e/2-r/2,n.y=t/2-r/2,M(n)}function k(n,r){n.font="30px Arial",n.fillText(r,e/2-n.measureText(r).width/2,t/2-15);n.fillText("Press <Enter> to Restart Game",e/2-n.measureText("Press <Enter> to Restart Game").width/2,t/2+40),g.restart&&(c.y=t/2-i/2,p.y=t/2-i/2,m(u,Math.PI/6),c.goals=0,p.goals=0)}function _(e,n,r,l){n?e.y-=l*s:r&&(e.y+=l*s),e.y=Math.min(Math.max(0,e.y),t-i)}function M(e){e.dirX=Math.cos(e.angle),e.dirY=Math.sin(e.angle)}function P(e,t){e.fillRect(t.x,t.y,n,i)}window.requestAnimationFrame(function l(s){x||(x=s);var h=.001*(s-x);x=s,function(l,s){if(l.fillStyle="black",l.fillRect(0,0,e,t),l.fillStyle="white",l.strokeStyle="white",l.lineWidth=2,c.goals===d)k(l,"Left Player Wins");else if(p.goals===d)k(l,"Right Player Wins");else{!function(e){u.x+=u.dirX*e*u.speed,u.y+=u.dirY*e*u.speed,(u.y>t-r&&u.dirY>0||u.y<0&&u.dirY<0)&&(u.dirY=-u.dirY),u.dirX<0&&u.x<=c.x+n&&u.y+r>c.y&&u.y<c.y+i&&(u.dirX=-u.dirX,u.speed=Math.min(o,u.speed+a)),u.dirX>0&&u.x+r>p.x&&u.y+r>p.y&&u.y<p.y+i&&(u.dirX=-u.dirX,u.speed=Math.min(o,u.speed+a))}(s),_(c,g.left_up,g.left_down,s),_(p,g.right_up,g.right_down,s);let h=null;u.x<=-r?(p.goals+=1,h=-Math.PI/4+Math.random()*Math.PI/2):u.x>=e+r&&(c.goals+=1,h=Math.PI/4*3+Math.random()*Math.PI/2),h&&m(u,h),function(e,t){e.fillRect(t.x,t.y,r,r)}(l,u),P(l,c),P(l,p),function(t){for(let n=0;n<d;n++){const i=e/2-(n+1)*(y+f)-y;n<c.goals?t.fillRect(i,f,y,y):(t.beginPath(),t.rect(i,f,y,y),t.stroke());const r=e/2+n*(y+f)+y;n<p.goals?t.fillRect(r,f,y,y):(t.beginPath(),t.rect(r,f,y,y),t.stroke())}}(l)}}(w,h),window.requestAnimationFrame(l)})}();</script></html>

Der praktische Nutzen

Natürlich ist mein Beispiel im wahrsten Sinne des Wortes eine Spielerei. Könnte das Ganze auch einen praktischen Nutzen haben? Meiner Meinung nach: ja. Ich könnte mir bspw. vorstellen, dass man damit eine kleine, persistente Todo-Listen App erzeugen kann. Diese speichert ihre Daten im local storage und lässt sich schnell und einfach per Favoriten-Eintrag in meinem Browser speichern und aufrufen. Auch die Idee, dass man die App direkt in einer E-Mail als Link verschicken kann, finde ich ziemlich cool. Wenn man solche URLs noch komprimieren und signieren könnte, dann wären vielleicht auch größere Apps denkbar und die Sicherheit der Anwendung ließe sich steigern. Mal schauen, vielleicht fällt mir in den nächsten Jahren dazu noch etwas ein. Oder vielleicht haben Sie ja eine Idee … 😉

 

Hinweise:

Peter Friedland hat im t2informatik Blog einige weitere Beiträge veröffentlicht, u. a.

Fehlerbehandlung in Angular-Anwendungen
Performance-Optimierung für WPF Anwendungen
CI/CD Pipeline auf einem Raspberry Pi
Warum ich bei Godot gelandet bin

Übrigens: t2informatik sucht nach Unterstützung.

Peter Friedland
Peter Friedland

t2informatik GmbH

Peter Friedland ist bei der t2informatik GmbH als Software Consultant tätig. In verschiedenen Kundenprojekten entwickelt er innovative Lösungen in enger Zusammenarbeit mit Partnern und den Ansprechpartnern vor Ort. Und ab und an bloggt er auch.