Une appli web de dessin. JavaScript, Haskell Miso, C++ Wt ou WebAssembly ?
Voir aussi : article linuxfr - code source
Les applications web actuelles tendent à réaliser une grande part des traitements côté-client (frontend) et à réduire au maximum la partie côté-serveur (backend). Un exemple classique est l’application mono-page (Single-Page Application), où la gestion de l’interface et des données est réalisée principalement côté-client avec, quand c’est nécessaire, des requêtes serveurs (AJAX, websockets…).
Pour implémenter ce genre d’applications, on utilise généralement un framework frontend comme AngularJS, Vue.js, React/Redux… Ces frameworks proposent une architecture de base (MVC, flux…) qui permet d’implémenter facilement une application classique de présentation et de manipulation de données.
L’objectif de cet article est de comparer quelques technologies pour réaliser une application un peu plus interactive : une application de dessin basique. Les technologies considérées ici sont JavaScript (sans bibliothèque), Haskell Miso (framework frontend), C++ Wt (framework fullstack basé widgets) et WebAssembly.
L’application à réaliser
On veut implémenter une application basique permettant de dessiner à la souris. Des curseurs permettent de régler la couleur et la taille de la brosse de dessin. La brosse est illustrée dynamiquement en fonction de ses paramètres. Enfin un bouton permet de nettoyer la zone de dessin.
A priori, cette application ne pose pas de difficulté particulière. Les données à manipuler sont essentiellement les paramètres de la brosse et l’état du canvas de dessin. Pour le dessin en lui-même, il suffit de détecter quand le bouton de la souris est appuyé et de tracer des lignes entre les positions successives du pointeur.
En JavaScript
JavaScript possède toute les fonctionnalités nécessaires pour implémenter notre
application dans un navigateur web : input
de type range
pour les réglages de
la brosse, canvas
HTML5 pour les dessins dynamiques, fonctions de rappels pour
gérer les événements. Commençons par créer l’interface en HTML :
<!DOCTYPE html>
<html>
<head> <meta charset="UTF-8"/> </head>
<body>
<h1>Draw</h1>
<!-- the range elements (R, G, B and radius) -->
<p> R <input type="range" min="0" max="255" value="0" id="input_r"> </input>
<span id="span_r"> </span> </p>
<p> G <input type="range" min="0" max="255" value="0" id="input_g"> </input>
<span id="span_g"> </span> </p>
<p> B <input type="range" min="0" max="255" value="0" id="input_b"> </input>
<span id="span_b"> </span> </p>
<p> radius <input type="range" min="2" max="50" value="10" id="input_radius"> </input>
<span id="span_radius"> </span> </p>
<!-- the canvas for previewing the brush -->
<p> <canvas id="canvas_brush" width="100" height="100"> </canvas> </p>
<!-- the canvas for drawing -->
<p> <canvas id="canvas_draw" width="640" height="480" style="border:1px solid black"> </canvas> </p>
<!-- the button for clearing canvas_draw -->
<p><button id="button_clear"> Clear </button></p>
Ajoutons quelques fonctions utilitaires, en JavaScript, pour récupérer la position de la souris à l’intérieur du canvas de dessin et pour convertir les 3 valeurs RGB des curseurs en couleur HTML hexadécimale.
<script>
// find position in canvas, from mouse event
function getXY(canvas, evt) {
const rect = canvas.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
return {x, y};
}
// convert color from RGB triplet to hex code
// for example: 255, 0, 255 -> '#FF00FF'
function componentToHex(c) {
const hex = Number(c).toString(16);
return hex.length == 1 ? "0" + hex : hex;
}function rgbToHex(r, g, b) {
return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b);
}</script>
On peut alors implémenter les fonctions de rappels pour le dessin proprement
dit. Lorsqu’on appuie sur le bouton de la souris (onmousedown
) dans le
canvas de dessin, on récupère les paramètres actuels de la brosse et on
initialise la fonction de rappel onmousemove
de façon à effectuer le dessin.
Lorsqu’on relache le bouton de la souris (onmouseup
), on enlève la fonction
de rappel onmousemove
pour arrêter le dessin. Pour le bouton de nettoyage,
une fonction de rappel sur onclick
suffit.
<script>
// start drawing
.onmousedown = function(evt0) {
canvas_draw// get brush parameters from range elements
const r = Number(input_r.value);
const g = Number(input_g.value);
const b = Number(input_b.value);
const color = rgbToHex(r, g, b);
const radius = Number(input_radius.value);
// get canvas context and initial position
const ctx = canvas_draw.getContext("2d");
let p0 = getXY(canvas_draw, evt0);
// set callback function for mousemove event
.onmousemove = function(evt1) {
canvas_draw// draw a line from previous position to current position
const p1 = getXY(canvas_draw, evt1);
.beginPath();
ctx.strokeStyle = color
ctx.fillStyle = color;
ctx.lineWidth = 2 * radius;
ctx.lineCap = "round";
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.fill();
ctx.stroke();
ctx// update position
= p1;
p0 ;
};
}
// remove callback function when mouse up
.onmouseup = function(evt) {
canvas_draw.onmousemove = {};
canvas_draw;
}// clear canvas_draw when clicking on button_clear
.onclick = function() {
button_clearconst width = canvas_draw.clientWidth;
const height = canvas_draw.clientHeight;
const ctx = canvas_draw.getContext("2d");
.beginPath();
ctx.clearRect(0, 0, width, height);
ctx.stroke();
ctx }
Enfin, pour réagir dynamiquement aux curseurs de réglages de la brosse, on leur
connecte la fonction de rappel updateDom
, qui met à jour les valeurs
affichées ainsi que l’illustration de la brosse.
// update HTML elements (RGB span, canvas_brush)
function updateDom() {
// get values from range inputs
const r = input_r.value;
const g = input_g.value;
const b = input_b.value;
const radius = input_radius.value;
const color = rgbToHex(r, g, b);
// update span texts
.innerHTML = r;
span_r.innerHTML = g;
span_g.innerHTML = b;
span_b.innerHTML = radius;
span_radius
// draw brush in canvas_brush
const ctx = canvas_brush.getContext("2d");
.beginPath();
ctx.strokeStyle = color
ctx.fillStyle = color;
ctx.clearRect(0, 0, 100, 100);
ctx.arc(50, 50, radius, 0, 2*Math.PI);
ctx.fill();
ctx.stroke();
ctx
}updateDom();
// set callback functions on HTML elements
.oninput = updateDom;
input_r.oninput = updateDom;
input_g.oninput = updateDom;
input_b.oninput = updateDom;
input_radius</script>
</body>
</html>
Le fichier obtenu (draw_js/index.html) est assez concis, environ 130 lignes. Pour cette application, il y a peu d’éléments et d’événements à gérer donc le code reste lisible mais on imagine aisément qu’il va rapidement se compliquer si l’application grossit. On notera également que le typage dynamique de JavaScript permet une grande flexibilité mais rend la détection d’erreur assez délicate; la mise en place de tests automatisés devient vite indispensable.
En Haskell Miso (framework frontend)
On arrive ici à un point essentiel de l’article, à savoir si l’utilisation d’un framework frontend est pertinente pour une application qui sort un peu du schéma ordinaire « présentation/modification de données ». Comme je ne connais pas bien les frameworks JavaScript, j’ai choisi Miso, qui permet de coder en Haskell et de transpiler vers du JavaScript via Ghcjs. Miso est tout de même assez comparable aux autres frameworks car il implémente une architecture classique (inspirée de Elm/Redux) et car les frameworks classiques utilisent souvent des extensions à JavaScript voire un autre langage (TypeScript, Elm…).
Pour implémenter l’application de dessin avec Miso, on définit les composants
de l’application : modèle, actions, fonction de rendu de la vue, fonction de
mise à jour. Ici, le type Model
contient les paramètres de la brosse ainsi
que « l’état du dessin » (dessin en cours ou non, position précédente du
pointeur). Le type Action
définit les événements que peut générer la vue et
qu’il faut traiter lors de la mise à jour (nettoyage du canvas de dessin,
modification de la brosse ou du canvas de dessin…).
{-# LANGUAGE OverloadedStrings #-}
import Control.Monad (when)
import Data.Map (singleton)
import JavaScript.Web.Canvas
import Miso
import Miso.String hiding (singleton)
main :: IO ()
= startApp App
main = UpdateBrushOp
{ initialAction = updateModel
, update = viewModel
, view = Model 0 0 0 10 (0, 0) False
, model = [ mouseSub UpdateDrawOp ]
, subs = defaultEvents
, events = Nothing }
, mountPoint
data Model = Model
modelR :: Int -- brush color
{ modelG :: Int
, modelB :: Int
, modelRadius :: Int -- brush radius
, modelXy :: (Double, Double) -- last position inside drawing canvas
, modelDrawing :: Bool -- drawing state
,deriving (Eq)
}
data Action
= NoOp
| ClearDrawOp -- request clearing canvas_draw
| UpdateBrushOp -- update canvas_brush
| UpdateDrawOp (Int, Int) -- update canvas_draw
| UpdateDrawingOp Bool -- update drawing state
| UpdateFormOp (Model -> MisoString -> Model) MisoString -- update a range element
| UpdateXyOp (Double, Double) -- update last position
Pour générer la vue, la fonction viewModel
suivante crée les éléments de
l’interface et connecte les événements aux actions déclarées précédemment. La
fonction mkRange
permet de créer un curseur pour les réglages de la brosse
(le paramètre op
est une fonction permettant de mettre à jour la valeur
associée au curseur, sans avoir à dupliquer les actions). Les fonctions
jsCtx
, jsRectLeft
, etc interfacent du code JavaScript qui n’est pas géré
directement par Miso.
-- create a range element
mkRange :: MisoString -> Int -> Int -> Int -> (MisoString -> Action) -> View Action
= p_ []
mkRange title vmin vmax v op
[ text title
, input_ "range"
[ type_
, onInput op
, min_ (toMisoString vmin)
, max_ (toMisoString vmax)
, value_ (toMisoString v) ]
, text (toMisoString v) ]
-- create view
viewModel :: Model -> View Action
Model r g b radius _ _) = span_ []
viewModel ("draw" ]
[ h1_ [] [ text "R" 0 255 r (UpdateFormOp (\ m v -> m { modelR = fromMisoString v }))
, mkRange "G" 0 255 g (UpdateFormOp (\ m v -> m { modelG = fromMisoString v }))
, mkRange "B" 0 255 b (UpdateFormOp (\ m v -> m { modelB = fromMisoString v }))
, mkRange "radius" 2 50 radius (UpdateFormOp (\ m v -> m { modelRadius = fromMisoString v }))
, mkRange "canvas_brush" , width_ "100" , height_ "100" ] [] ]
, p_ [] [ canvas_ [ id_ "canvas_draw" , width_ "600" , height_ "300"
, p_ [] [ canvas_ [ id_ "border" "1px solid black")
, style_ (singleton UpdateDrawingOp True), onMouseUp (UpdateDrawingOp False) ] [] ]
, onMouseDown (ClearDrawOp ] [ text "Clear" ] ] ]
, p_ [] [ button_ [ onClick
-- bind javascript foreign functions for accessing canvas
import javascript unsafe "$r = document.getElementById($1).getContext('2d');"
foreign jsCtx :: MisoString -> IO Context
import javascript unsafe "$r = canvas_draw.getBoundingClientRect().left;"
foreign jsRectLeft :: IO Int
import javascript unsafe "$r = canvas_draw.getBoundingClientRect().top;"
foreign jsRectTop :: IO Int
import javascript unsafe "$r = canvas_draw.clientWidth;"
foreign jsWidth :: IO Int
import javascript unsafe "$r = canvas_draw.clientHeight;"
foreign jsHeight :: IO Int
Enfin, la mise à jour du modèle est implémentée par la fonction updateModel
suivante. Pour les actions UpdateBrushOp
, ClearDrawOp
et UpdateDrawOp
,
on met à jour les canvas correspondant. Pour les actions UpdateXyOp
,
UpdateFormOp
et UpdateDrawingOp
, on met à jour les champs correspondant du
modèle. On notera que UpdateDrawOp
et UpdateFormOp
génèrent des actions à la
fin de leur traitement (respectivement, pour mettre à jour la nouvelle position
du curseur et pour mettre à jour le dessin de la brosse).
updateModel :: Action -> Model -> Effect Action Model
UpdateBrushOp m@(Model r g b radius _ _) = m <# do
updateModel <- jsCtx "canvas_brush"
ctx 0 0 100 100 ctx
clearRect 1 ctx
lineWidth 255 ctx
strokeStyle r g b 255 ctx
fillStyle r g b
beginPath ctx50 50 (fromIntegral radius) 0 (2*pi) True ctx
arc
fill ctx
stroke ctxpure NoOp
ClearDrawOp m = m <# do
updateModel <- jsCtx "canvas_draw"
ctx <- jsWidth
w <- jsHeight
h 0 0 (fromIntegral w) (fromIntegral h) ctx
clearRect pure NoOp
UpdateDrawOp (x1, y1)) m@(Model r g b radius (x0, y0) drawing) = m <# do
updateModel (<- jsCtx "canvas_draw"
ctx <- jsRectLeft
left <- jsRectTop
top let x1' = fromIntegral $ x1 - left
let y1' = fromIntegral $ y1 - top
$ do
when drawing 0 ctx
lineWidth 255 ctx
strokeStyle r g b 255 ctx
fillStyle r g b fromIntegral $ 2*radius) ctx
lineWidth (LineCapRound ctx
lineCap
beginPath ctx
moveTo x0 y0 ctx
lineTo x1' y1' ctx
stroke ctxpure $ UpdateXyOp (x1', y1') -- request to update drawing position
UpdateXyOp xy) m = noEff m { modelXy = xy } -- update the field modelXy
updateModel (UpdateFormOp mFunc value) m = mFunc m value <# pure UpdateBrushOp
updateModel (UpdateDrawingOp drawing) m = noEff m { modelDrawing = drawing }
updateModel (NoOp m = noEff m updateModel
Le code obtenu (draw_miso/Main.hs) est à peu près aussi concis que le code JavaScript précédent (environ 125 lignes + le fichier d’appel draw_miso/index.html). En revanche, grâce à l’architecture demandée par le framework, il semble plus lisible et plus adapté aux applications complexes.
Concernant le langage Haskell et le framework Miso, on notera que le typage statique fort est très appréciable à l’usage car il permet de détecter une bonne partie des erreurs facilement et précocement. Par contre, la transpilation de Haskell vers JavaScript est assez lente et produit du code assez lourd.
En C++ Wt
Wt est un framework C++ fullstack basé widgets. Il permet d’écrire des applications web dans un style proche de Qt ou Gtkmm pour des applications desktop. Ceci peut être intéressant pour un développeur déjà habitué à ce style de programmation ou si l’application a également besoin de traitements côté-serveur.
Wt possède des widgets assez évolués, notamment WPaintedWidget
pour le
dessin. Pour notre application, on dérive une classe Canvas
pour ajouter une
brosse paramétrable et un chemin en cours de tracé.
#include <Wt/WApplication.h>
#include <Wt/WContainerWidget.h>
#include <Wt/WEnvironment.h>
#include <Wt/WPaintedWidget.h>
#include <Wt/WPainter.h>
#include <Wt/WPushButton.h>
#include <Wt/WSlider.h>
#include <Wt/WTemplate.h>
#include <Wt/WText.h>
using namespace std;
using namespace Wt;
// a canvas with a pen for drawing pathes
class Canvas : public WPaintedWidget {
protected:
; // current pen (color + width)
WPen _pen; // current path
WPainterPath _path
public:
(int penWidth, int width, int height) : WPaintedWidget() {
Canvas(width, height);
resize.setWidth(penWidth);
_pen.setCapStyle(PenCapStyle::Round);
_pen.setJoinStyle(PenJoinStyle::Round);
_pen}
// set the width of the pen
void setWidth(int w) {
.setWidth(w);
_pen}
// set the color of the pen
// updateFunc defines how to update color (for example, modify the red channel)
void setColor(function<void(WColor & c)> updateFunc) {
= _pen.color();
WColor color (color);
updateFunc.setColor(color);
_pen}
// clear canvas
void clear() {
= WPainterPath();
_path ();
update}
};
Pour l’affichage de la brosse, on dérive CanvasBrush
de Canvas
et on
redéfinit la fonction d’affichage de façon à dessiner un point central avec la
brosse courante.
// canvas for previewing the brush
class CanvasBrush : public Canvas {
public:
(int penWidth) : Canvas(penWidth, 50, 50) {}
CanvasBrush
private:
// paint event: draw a point in the middle of the canvas
void paintEvent(WPaintDevice * paintDevice) override {
(paintDevice);
WPainter painter.setPen(_pen);
painter.moveTo(25, 25);
_path.lineTo(25, 25);
_path.strokePath(_path, _pen);
painter}
};
Pour la gestion du dessin, on dérive CanvasDraw
de Canvas
et on définit les
fonctions de rappel et d’affichage. On notera les connexions signal-slot, très
classiques dans les frameworks desktop.
// canvas for drawing
class CanvasDraw : public Canvas {
public:
(int penWidth, int width, int height) : Canvas(penWidth, width, height) {
CanvasDraw;
WCssDecorationStyle deco.setBorder(WBorder(BorderStyle::Solid));
deco(deco);
setDecorationStyle// connect event handlers
().connect(this, &CanvasDraw::mouseDrag);
mouseDragged().connect(this, &CanvasDraw::mouseDown);
mouseWentDown}
private:
void paintEvent(WPaintDevice * paintDevice) override {
// draw current path
(paintDevice);
WPainter painter.setPen(_pen);
painter.drawPath(_path);
painter}
void mouseDown(const WMouseEvent & e) {
// start path from current position
= e.widget();
Coordinates c = WPainterPath(WPointF(c.x, c.y));
_path }
void mouseDrag(const WMouseEvent & e) {
// add current position into the path
= e.widget();
Coordinates c .lineTo(c.x, c.y);
_path(PaintFlag::Update);
update}
};
On implémente également un curseur avec affichage de sa valeur, pour les
paramètres de la brosse de dessin. La méthode setUpdateFunc
permet de
spécifier la fonction à appeler, lorsque le curseur est modifié, pour mettre à
jour la variable correspondante.
// slider that prints its value and calls and updating function when moved
class Slider : public WContainerWidget {
private:
* _slider;
WSlider * _text;
WText
public:
(const string & title, int vmin, int vmax, int vinit) {
Slider(make_unique<WText>(title));
addWidget= addWidget(make_unique<WSlider>());
_slider = addWidget(make_unique<WText>(to_string(vinit)));
_text ->setRange(vmin, vmax);
_slider->setValue(vinit);
_slider}
void setUpdateFunc(function<void(int)> updateFunc) {
->sliderMoved().connect([=](int v) {
_slider// call updating function
(v);
updateFunc// update slider text
->setText(to_string(v));
_text});
}
};
Enfin, on crée l’application complète à partir d’un template et des éléments définis précédemment.
// main template of the application
const string APP_TEMPLATE = R"(
<h1>Draw</h1>
<p> ${slider_r} </p>
<p> ${slider_g} </p>
<p> ${slider_b} </p>
<p> ${slider_width} </p>
<p> ${canvas_brush} </p>
<p> ${canvas_draw} </p>
<p> ${button_clear} </p>
)";
struct App : WApplication {
(const WEnvironment & env) : WApplication(env) {
Appauto tmpl = root()->addWidget(make_unique<WTemplate>(APP_TEMPLATE));
auto canvasBrush = tmpl->bindWidget("canvas_brush", make_unique<CanvasBrush>(10));
auto canvasDraw = tmpl->bindWidget("canvas_draw", make_unique<CanvasDraw>(10, 640, 480));
auto clearButton = tmpl->bindWidget("button_clear", make_unique<WPushButton>("Clear"));
->clicked().connect(canvasDraw, &CanvasDraw::clear);
clearButton
// add a slider for the red color channel of the pen
auto sliderR = tmpl->bindWidget("slider_r", make_unique<Slider>("R", 0, 255, 0));
// set an updating function that assigns the value v to the red channel
->setUpdateFunc([=](int v) {
sliderR->setColor([v](WColor & c) { c.setRgb(v, c.green(), c.blue()); });
canvasDraw->setColor([v](WColor & c) { c.setRgb(v, c.green(), c.blue()); });
canvasBrush->update();
canvasBrush});
// add sliders for G and B
// ...
auto sliderWidth = tmpl->bindWidget("slider_width", make_unique<Slider>("Width", 2, 50, 10));
->setUpdateFunc([=](int v) {
sliderWidth->setWidth(v);
canvasDraw->setWidth(v);
canvasBrush->update();
canvasBrush});
}
};
// start the application
int main(int argc, char ** argv) {
auto mkApp = [](const WEnvironment& env) { return make_unique<App>(env); };
return WRun(argc, argv, mkApp);
}
Le fichier obtenu (draw_wt/draw.cpp) est un peu plus long que le code en JavaScript (environ 180 lignes, soit un tiers de plus). Dans l’implémentation proposée ici, les données sont mises à jour directement depuis les éléments de la vue. Pour une application plus complexe, on implémenterait plutôt une vraie architecture MVC classique.
En WebAssembly
Le WebAssembly permet d’intégrer, dans une page web, du code issu de langage comme C, C++ ou Rust, avec des performances plus élevées qu’en JavaScript.
Première implémentation
Dans cette première implémentation avec WebAssembly, l’application est principalement codée en HTML/JavaScript. Seuls les traitements « lourds » sont codés en C++ puis compilés en wasm.
Pour cela, on définit une classe MyCanvas
qui implémente les fonctions de
dessin et gère sa propre mémoire (attention, l’implémentation fournie,
draw_wasm/draw.cpp,
n’est pas du tout optimale). La classe et les méthodes nécessaires sont
ensuite exportées pour être appelées depuis le code JavaScript.
#include <emscripten/bind.h>
#include <cmath>
#include <iostream>
struct Color {
uint8_t _r;
uint8_t _g;
uint8_t _b;
uint8_t _a;
};
class MyCanvas {
private:
// canvas properties
int _width;
int _height;
std::vector<Color> _data;
// pen properties
;
Color _colorint _squareRadius;
public:
(int width, int height) :
MyCanvas(width),
_width(height),
_height(width*height)
_data{
();
clear}
void clear() {
(_data.data(), 255, _width*_height*sizeof(Color));
memset}
::val getData() {
emscripten// get C++ data for filling a JS array
size_t s = _data.size()*4;
uint8_t * d = (uint8_t *) _data.data();
return emscripten::val(emscripten::typed_memory_view(s, d));
}
void initDraw(int r, int g, int b, int radius) {
// set current pen properties
= radius*radius;
_squareRadius = {uint8_t(r), uint8_t(g), uint8_t(b), 255};
_color }
// draw a line from (x0, y0) to (x1, y1)
void draw(int x0, int y0, int x1, int y1) {
// ...
}
private:
// draw a disc at (evt_x, evt_y) using current pen (_color + _squareRadius)
void drawDisc(int evt_x, int evt_y) {
// ...
}
};
// export MyCanvas to JS
(MyCanvas) {
EMSCRIPTEN_BINDINGS::class_<MyCanvas>("MyCanvas")
emscripten.constructor<int, int>()
.function("clear", &MyCanvas::clear)
.function("initDraw", &MyCanvas::initDraw)
.function("draw", &MyCanvas::draw)
.function("getData", &MyCanvas::getData);
}
Le code C++ précédent peut alors être compilé par emscripten. On obtient le
code wasm correspondant ainsi que l’interface draw.js
permettant d’appeler
les fonctions wasm dans du code JavaScript. Pour notre application, on peut
reprendre l’implémentation JavaScript et remplacer les fonctions de dessin par
le code suivant
(draw_wasm/index.html).
...
<script src="draw.js"></script>
<script>
.onRuntimeInitialized = function() {
Moduleconst width = canvas_draw.clientWidth;
const height = canvas_draw.clientHeight;
// create a C++ MyCanvas
const mycanvas = new Module.MyCanvas(width, height);
function update() {
// update JS canvas using C++ MyCanvas
const myarray = new Uint8ClampedArray(mycanvas.getData());
const myimage = new ImageData(myarray, width, height);
const mycontext = canvas_draw.getContext('2d');
.putImageData(myimage, 0, 0);
mycontext
}update();
.onmousedown = function(evt0) {
canvas_draw// start drawing
const r = Number(input_r.value);
const g = Number(input_g.value);
const b = Number(input_b.value);
const color = rgbToHex(r, g, b);
const radius = Number(input_radius.value);
const ctx = canvas_draw.getContext("2d");
let p0 = getXY(canvas_draw, evt0);
.initDraw(r, g, b, radius);
mycanvas
// set callback function for drawing
.onmousemove = function(evt1) {
canvas_drawconst p1 = getXY(canvas_draw, evt1);
.draw(p0.x, p0.y, p1.x, p1.y);
mycanvasupdate();
= p1;
p0 ;
};
}
.onmouseup = function(evt) {
canvas_draw// stop drawing -> remove callback function
.onmousemove = {};
canvas_draw;
}
.onclick = function() {
button_clear// clear C++ MyCanvas
.clear();
mycanvas// update JS canvas
update();
}
}
// update HTML elements (RGB span, canvas_brush)
function updateDom() {
...
Au lieu d’utiliser les fonctions de dessin du canvas HTML5, on utilise ici
la classe C++ MyCanvas
et on recharge les données de MyCanvas
vers le
canvas HTML5. Ceci introduit des allocations mémoire et n’est donc peut-être
pas la façon optimale de faire.
Ainsi, si emscripten permet assez facilement de compiler du code C++ en WebAssembly et de l’appeler dans du code JavaScript, il faut néanmoins prendre en compte quelques subtilités. Tout d’abord, le code C++ à interfacer doit être un minimum performant. Ensuite il faut éviter les copies mémoires inutiles (on peut faire les allocations côté C++ et les partager côté JavaScript ou inversement). Enfin, il faut réserver les appels WebAssembly à des traitements peu nombreux mais coûteux.
Seconde implémentation
L’implémentation WebAssembly précédente consiste essentiellement en une (mauvaise) réimplémentation des fonctions de dessin des canvas HTML5. La gestion d’événements est toujours faite côté JavaScript et résulte en de nombreux appels à des fonctions wasm peu coûteuses, ce qui n’est pas très intéressant pour WebAssembly.
Dans cette seconde implémentation, on implémente la majeur partie de l’application du côté WebAssembly en utilisant la bibliothèque SDL. Comme cette bibliothèque possède moins de fonctionnalités de dessin que les canvas HTML5, le dessin sera plus basique.
SDL est une bibliothèque assez complète pour développer des jeux vidéos. Elle gère notamment l’affichage et les événements. Pour notre application de dessin, le côté HTML/JavaScript se limite donc à réserver un canvas pour le code WebAssembly. Fichier draw_sdl/index.html :
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<h1> draw sdl </h1>
<p> <canvas id="canvas_draw" oncontextmenu="event.preventDefault()"> </canvas> </p>
<p><button id="button_clear"> Clear </button></p>
<script type='text/javascript'>
var Module = {
canvas: canvas_draw,
onRuntimeInitialized: function() {
.onclick = function() { Module.clear(); };
button_clear
};
}</script>
<script async type="text/javascript" src="draw.js"> </script>
</body>
</html>
Le code WebAssembly/C++ est très similaire à un jeu SDL classique. Dans le code
suivant
(draw_sdl/draw.cpp)
la fonction display_texture
charge, dans le canvas d’affichage, une texture
générée par la technique de render-to-texture. La fonction clear
permet de
nettoyer la texture à afficher. Cette fonction est également exportée pour être
connectée au bouton de l’interface.
#include <emscripten.h>
#include <emscripten/bind.h>
#include <SDL2/SDL.h>
const int WIDTH = 640;
const int HEIGHT = 480;
* g_window;
SDL_Window * g_renderer;
SDL_Renderer * g_texture; // for rendering to texture
SDL_Texture
int g_x0 = 0; // current drawing position
int g_y0 = 0;
bool g_drawing = false; // drawing state
// copy texture to displayed canvas
void display_texture() {
(g_renderer, nullptr);
SDL_SetRenderTarget(g_renderer);
SDL_RenderClear(g_renderer, g_texture, nullptr, nullptr);
SDL_RenderCopy(g_renderer);
SDL_RenderPresent}
// clear and display the texture
void clear() {
(g_renderer, 0, 0, 0, 0);
SDL_SetRenderDrawColor(g_renderer, g_texture);
SDL_SetRenderTarget(g_renderer);
SDL_RenderClear(g_renderer);
SDL_RenderPresentg_texture = SDL_GetRenderTarget(g_renderer);
();
display_texture}
// export function to JS
(draw_sdl) {
EMSCRIPTEN_BINDINGS::function("clear", &clear);
emscripten}
Enfin le programme principal initialise le canvas et la texture à afficher puis
lance la boucle principale, qui gère chaque événement via la fonction
iter_one
.
// do one iteration of the event loop
void iter_one() {
;
SDL_Event eventwhile (SDL_PollEvent(&event)) {
if (event.button.button == SDL_BUTTON_LEFT) {
if (event.type == SDL_MOUSEBUTTONDOWN) {
// start drawing
g_drawing = true;
g_x0 = event.button.x;
g_y0 = event.button.y;
}
else if (event.type == SDL_MOUSEBUTTONUP) {
// stop drawing
g_drawing = false;
}
}
else if (event.type == SDL_MOUSEMOTION) {
if (g_drawing) {
// draw a line in the texture, from previous position to current position
(g_renderer, 255, 255, 255, 255);
SDL_SetRenderDrawColor(g_renderer, g_texture);
SDL_SetRenderTarget(g_renderer, g_x0, g_y0, event.motion.x, event.motion.y);
SDL_RenderDrawLine(g_renderer);
SDL_RenderPresentg_texture = SDL_GetRenderTarget(g_renderer);
// display texture
();
display_texture// update current position
g_x0 = event.motion.x;
g_y0 = event.motion.y;
}
}
}
}
// init SDL and launch the event loop
int main() {
(SDL_INIT_VIDEO);
SDL_Init(WIDTH, HEIGHT, 0, &g_window, &g_renderer);
SDL_CreateWindowAndRendererg_texture = SDL_CreateTexture(g_renderer, SDL_PIXELFORMAT_RGBA8888,
, WIDTH, HEIGHT);
SDL_TEXTUREACCESS_TARGET(iter_one, 0, 1);
emscripten_set_main_loopreturn 0;
}
Ce type d’implémentation correspond mieux à l’esprit de WebAssembly mais nécessite d’utiliser des outils performants côté WebAssembly/C++. Certaines bibliothèques, comme la SDL, sont incluses dans emscripten. Pour les autres bibliothèques, il faut généralement les porter soi-même, ce qui peut rapidement se compliquer…
Conclusion
En conclusion, cette petite application de dessin permet de confirmer les tendances actuelles en développement web, notamment son vaste champ d’application et ses nombreux outils.
L’écosytème de base HTML/CSS/JavaScript apporte déjà de nombreuses fonctionnalités. JavaScript étant assez haut-niveau, développer en pur-JavaScript n’est pas complètement déraisonnable et permet d’obtenir un code léger et optimisé. Cependant, comme le typage dynamique et le système de fonction de rappel peuvent être source d’erreurs, il est préférable d’être rigoureux dans sa façon de programmer et d’utiliser des outils de vérification de code et de tests automatisés. Enfin, comme pour tous les langages, il devient vite indispensable de bien architecturer le code lorsque l’application grossit (par exemple selon un schéma MVC).
Les frameworks frontend apportent justement cette architecture de code pour gérer facilement des applications complexes (MVC, flux…). À l’usage ces frameworks sont très pratiques à utiliser même pour des applications simples. Combinés à un langage typé (TypeScript, Elm…) ou à un style de programmation fonctionnelle, ils réduisent considérablement les sources d’erreurs possibles. En revanche, ils nécessitent une phase d’apprentissage et un écosystème plus lourd et plus complexe.
Les frameworks basés widgets comme C++ Wt (et également les frameworks d’applications mobiles) utilisent la même approche classique que les frameworks desktop comme Qt ou Gtkmm. Ils permettent notamment de réutiliser et de personnaliser facilement des composants mais nécessitent également d’architecturer soigneusement le code pour des applications plus complexes. À noter que Wt est naturellement fullstack, ce qui facilite les communications client-serveur mais peut introduire un peu de latence côté-client.
Enfin, WebAssembly permet d’appeler, dans une page web, du code C/C++/Rust précompilé. Cette technologie est actuellement assez utilisable même si elle peut parfois demander quelques efforts de mise en place (portage de bibliothèques via emscripten). Cependant, elle est à réserver à des applications particulières, par exemple des traitements ponctuels lourds ou, à l’opposée, des applications interactives gérées complètement côté WebAssembly.