Comparaison de technologies Web pour implémenter une application de dessin basique

59
18
déc.
2018
Internet

Les applications Web actuelles tendent à réaliser une grande part des traitements en frontal (front‐end), c’est‐à‐dire du côté client, et à réduire au maximum la partie côté serveur (back‐end). 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 cadriciel frontal comme AngularJS, Vue.js, React/Redux… Ces cadriciels 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 (cadriciel frontal), C++ Wt (cadriciel fullstack basé widgets) et WebAssembly.

Sommaire

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.

Vous pouvez accéder à l’application en ligne.

Exemple d’application finale

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 canevas 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 HTML 5 pour les dessins dynamiques, fonctions de rappel 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" />
        <span id="span_r" /> </p>
    <p> G <input type="range" min="0" max="255" value="0" id="input_g" />
        <span id="span_g" /> </p>
    <p> B <input type="range" min="0" max="255" value="0" id="input_b" />
        <span id="span_b" /> </p>
    <p> radius <input type="range" min="2" max="50" value="10" id="input_radius" />
        <span id="span_radius" /> </p>

    <!-- the canvas for previewing the brush -->
    <p> <canvas id="canvas_brush" width="100" height="100" /> </p>

    <!-- the canvas for drawing -->
    <p> <canvas id="canvas_draw" width="640" height="480" style="border:1px solid black" /> </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 canevas de dessin et pour convertir les trois valeurs RVB des curseurs en couleur HTML hexadécimale.

    <script type="javascript">
        // 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 rappel pour le dessin proprement dit. Lorsqu’on appuie sur le bouton de la souris (onmousedown) dans le canevas 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 relâche 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 type="javascript">
        // start drawing
        canvas_draw.onmousedown = function(evt0) {
            // 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
            canvas_draw.onmousemove = function(evt1) {
                // draw a line from previous position to current position
                const p1 = getXY(canvas_draw, evt1);
                ctx.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();
                // update position 
                p0 = p1;
            };
        };

        // remove callback function when mouse up
        canvas_draw.onmouseup = function(evt) {
            canvas_draw.onmousemove = {};
        };
        // clear canvas_draw when clicking on button_clear
        button_clear.onclick = function() {
            const width = canvas_draw.clientWidth;
            const height = canvas_draw.clientHeight;
            const ctx = canvas_draw.getContext("2d");
            ctx.beginPath();
            ctx.clearRect(0, 0, width, height);
            ctx.stroke();
        }
    </script>

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.

    <script type="javascript">
        // 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
            span_r.innerHTML = r;
            span_g.innerHTML = g;
            span_b.innerHTML = b;
            span_radius.innerHTML = radius;

            // draw brush in canvas_brush
            const ctx = canvas_brush.getContext("2d");
            ctx.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();
        }
        updateDom();

        // set callback functions on HTML elements
        input_r.oninput = updateDom;
        input_g.oninput = updateDom;
        input_b.oninput = updateDom;
        input_radius.oninput = updateDom;
    </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’erreurs assez délicate ; la mise en place de tests automatisés devient vite indispensable.

En Haskell Miso (cadriciel frontal)

On arrive ici à un point essentiel de l’article, à savoir si l’utilisation d’un cadriciel frontal est pertinente pour une application qui sort un peu du schéma ordinaire « présentation et modification de données ». Comme je ne connais pas bien les cadriciels 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 cadriciels : il implémente une architecture classique (inspirée de Elm/Redux), et les cadriciels 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 canevas de dessin, modification de la brosse ou du canevas 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 ()
main = startApp App 
    { initialAction = UpdateBrushOp
    , update = updateModel 
    , view = viewModel
    , model  = Model 0 0 0 10 (0, 0) False
    , subs   = [ mouseSub UpdateDrawOp ]
    , events = defaultEvents
    , mountPoint = Nothing }

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
mkRange title vmin vmax v op = p_ [] 
    [ text title
    , input_ 
        [ type_ "range"
        , onInput op
        , min_ (toMisoString vmin)
        , max_ (toMisoString vmax)
        , value_ (toMisoString v) ]
    , text (toMisoString v) ]

-- create view
viewModel :: Model -> View Action
viewModel (Model r g b radius _ _) = span_ []
    [ h1_ [] [ text "draw" ]
    , mkRange "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 }))
    , p_ [] [ canvas_ [ id_ "canvas_brush" , width_ "100" , height_ "100" ] [] ]
    , p_ [] [ canvas_ [ id_ "canvas_draw" , width_ "600" , height_ "300"
                      , style_  (singleton "border" "1px solid black")
                      , onMouseDown (UpdateDrawingOp True), onMouseUp (UpdateDrawingOp False) ] [] ]
    , p_ [] [ button_ [ onClick ClearDrawOp ] [ text "Clear" ] ] ]

-- bind javascript foreign functions for accessing canvas

foreign import javascript unsafe "$r = document.getElementById($1).getContext('2d');"
    jsCtx :: MisoString -> IO Context

foreign import javascript unsafe "$r = canvas_draw.getBoundingClientRect().left;"
    jsRectLeft :: IO Int

foreign import javascript unsafe "$r = canvas_draw.getBoundingClientRect().top;"
    jsRectTop :: IO Int

foreign import javascript unsafe "$r = canvas_draw.clientWidth;"
    jsWidth :: IO Int

foreign import javascript unsafe "$r = canvas_draw.clientHeight;"
    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 correspondants 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

updateModel UpdateBrushOp m@(Model r g b radius _ _) = m <# do
    ctx <- jsCtx "canvas_brush"
    clearRect 0 0 100 100 ctx
    lineWidth 1 ctx
    strokeStyle r g b 255 ctx
    fillStyle r g b 255 ctx
    beginPath ctx
    arc 50 50 (fromIntegral radius) 0 (2*pi) True ctx
    fill ctx
    stroke ctx
    pure NoOp

updateModel ClearDrawOp m = m <# do
    ctx <- jsCtx "canvas_draw"
    w <- jsWidth
    h <- jsHeight
    clearRect 0 0 (fromIntegral w) (fromIntegral h) ctx
    pure NoOp

updateModel (UpdateDrawOp (x1, y1)) m@(Model r g b radius (x0, y0) drawing) = m <# do
    ctx <- jsCtx "canvas_draw"
    left <- jsRectLeft
    top <- jsRectTop
    let x1' = fromIntegral $ x1 - left
    let y1' = fromIntegral $ y1 - top
    when drawing $ do
        lineWidth 0 ctx
        strokeStyle r g b 255 ctx
        fillStyle r g b 255 ctx
        lineWidth (fromIntegral $ 2*radius) ctx
        lineCap LineCapRound ctx
        beginPath ctx
        moveTo x0 y0 ctx
        lineTo x1' y1' ctx
        stroke ctx
    pure $ UpdateXyOp (x1', y1')  -- request to update drawing position

updateModel (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

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 cadriciel, il semble plus lisible et plus adapté aux applications complexes.

Concernant le langage Haskell et le cadriciel 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. En revanche, la transpilation de Haskell vers JavaScript est assez lente et produit du code assez lourd.

En C++ Wt

Wt est un cadriciel C++ « fullstack » basé widgets. Il permet d’écrire des applications Web dans un style proche de Qt ou Gtkmm pour des applications de bureau. 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:
        WPen _pen;           // current pen (color + width)
        WPainterPath _path;  // current path

    public:
        Canvas(int penWidth, int width, int height) : WPaintedWidget() {
            resize(width, height);
            _pen.setWidth(penWidth);
            _pen.setCapStyle(PenCapStyle::Round);
            _pen.setJoinStyle(PenJoinStyle::Round);
        }

        // set the width of the pen
        void setWidth(int w) {
            _pen.setWidth(w);
        }

        // 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) {
            WColor color = _pen.color();
            updateFunc(color);
            _pen.setColor(color);
        }

        // clear canvas
        void clear() {
            _path = WPainterPath();
            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:
        CanvasBrush(int penWidth) : Canvas(penWidth, 50, 50) {}

    private:
        // paint event: draw a point in the middle of the canvas
        void paintEvent(WPaintDevice * paintDevice) override {
            WPainter painter(paintDevice);
            painter.setPen(_pen);
            _path.moveTo(25, 25);
            _path.lineTo(25, 25);
            painter.strokePath(_path, _pen);
        }
};

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 cadriciels pour le bureau :

// canvas for drawing
class CanvasDraw : public Canvas {
    public:
        CanvasDraw(int penWidth, int width, int height) : Canvas(penWidth, width, height) {
            WCssDecorationStyle deco;
            deco.setBorder(WBorder(BorderStyle::Solid));
            setDecorationStyle(deco);
            // connect event handlers
            mouseDragged().connect(this, &CanvasDraw::mouseDrag);
            mouseWentDown().connect(this, &CanvasDraw::mouseDown);
        }

    private:
        void paintEvent(WPaintDevice * paintDevice) override {
            // draw current path
            WPainter painter(paintDevice);
            painter.setPen(_pen);
            painter.drawPath(_path);
        }

        void mouseDown(const WMouseEvent & e) {
            // start path from current position 
            Coordinates c = e.widget();
            _path = WPainterPath(WPointF(c.x, c.y));
        }

        void mouseDrag(const WMouseEvent & e) {
            // add current position into the path
            Coordinates c = e.widget();
            _path.lineTo(c.x, c.y);
            update(PaintFlag::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:
        WSlider * _slider;
        WText * _text;

    public:
        Slider(const string & title, int vmin, int vmax, int vinit) {
            addWidget(make_unique<WText>(title));
            _slider = addWidget(make_unique<WSlider>());
            _text = addWidget(make_unique<WText>(to_string(vinit)));
            _slider->setRange(vmin, vmax);
            _slider->setValue(vinit);
        }

        void setUpdateFunc(function<void(int)> updateFunc) {
            _slider->sliderMoved().connect([=](int v) {
                // call updating function
                updateFunc(v);
                // update slider text
                _text->setText(to_string(v));
            });
        }
};

Enfin, on crée l’application complète à partir d’un patron (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 {

    App(const WEnvironment & env) : WApplication(env) {
        auto 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"));
        clearButton->clicked().connect(canvasDraw, &CanvasDraw::clear);

        // 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
        sliderR->setUpdateFunc([=](int v) {
            canvasDraw->setColor([v](WColor & c) { c.setRgb(v, c.green(), c.blue()); });
            canvasBrush->setColor([v](WColor & c) { c.setRgb(v, c.green(), c.blue()); });
            canvasBrush->update();
        });

        // add sliders for G and B
        // ...

        auto sliderWidth = tmpl->bindWidget("slider_width", make_unique<Slider>("Width", 2, 50, 10));
        sliderWidth->setUpdateFunc([=](int v) {
            canvasDraw->setWidth(v);
            canvasBrush->setWidth(v);
            canvasBrush->update();
        });
    }
};

// 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 langages comme le C, le 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 et 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 _color;
        int _squareRadius;

    public:
        MyCanvas(int width, int height) : 
            _width(width),
            _height(height),
            _data(width*height)
        {
            clear();
        }

        void clear() {
            memset(_data.data(), 255, _width*_height*sizeof(Color));
        }

        emscripten::val getData() { 
            // 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
            _squareRadius = radius*radius;
            _color = {uint8_t(r), uint8_t(g), uint8_t(b), 255};
        }

        // 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
EMSCRIPTEN_BINDINGS(MyCanvas) {
    emscripten::class_<MyCanvas>("MyCanvas")
        .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 type="javascript" src="draw.js"></script>

    <script type="javascript">
        Module.onRuntimeInitialized = function() {
            const 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');
                mycontext.putImageData(myimage, 0, 0);
            }
            update();

            canvas_draw.onmousedown = function(evt0) {
                // 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);
                mycanvas.initDraw(r, g, b, radius);

                // set callback function for drawing
                canvas_draw.onmousemove = function(evt1) {
                    const p1 = getXY(canvas_draw, evt1);
                    mycanvas.draw(p0.x, p0.y, p1.x, p1.y);
                    update();
                    p0 = p1;
                };
            };

            canvas_draw.onmouseup = function(evt) {
                // stop drawing -> remove callback function
                canvas_draw.onmousemove = {};
            };

            button_clear.onclick = function() {
                // clear C++ MyCanvas
                mycanvas.clear();
                // update JS canvas
                update();
            }
        }

        // update HTML elements (RGB span, canvas_brush)
        function updateDom() {
            []
        }
    </script>

Au lieu d’utiliser les fonctions de dessin du canevas HTML 5, on utilise ici la classe C++ MyCanvas et on recharge les données de MyCanvas vers le canevas HTML 5. 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émoire 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 canevas HTML 5. 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 canevas HTML 5, le dessin sera plus basique.

Exemple d’application en WebAssembly

SDL est une bibliothèque assez complète pour développer des jeux vidéo. 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 canevas pour le code WebAssembly. Voici le 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()" /> </p>

        <p><button id="button_clear"> Clear </button></p>

        <script type="text/javascript">
            var Module = { 
                canvas: canvas_draw,
                onRuntimeInitialized: function() {
                    button_clear.onclick = function() { Module.clear(); };
                }
            };
        </script>

        <script type="text/javascript" src="draw.js" async="async"></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 canevas 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;

SDL_Window * g_window;
SDL_Renderer * g_renderer;
SDL_Texture * g_texture;  // for rendering to 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() {
    SDL_SetRenderTarget(g_renderer, nullptr);
    SDL_RenderClear(g_renderer);
    SDL_RenderCopy(g_renderer, g_texture, nullptr, nullptr);
    SDL_RenderPresent(g_renderer);
}

// clear and display the texture
void clear() {
    SDL_SetRenderDrawColor(g_renderer, 0, 0, 0, 0);
    SDL_SetRenderTarget(g_renderer, g_texture);
    SDL_RenderClear(g_renderer);
    SDL_RenderPresent(g_renderer);
    g_texture = SDL_GetRenderTarget(g_renderer);
    display_texture();
}

// export function to JS
EMSCRIPTEN_BINDINGS(draw_sdl) {
    emscripten::function("clear", &clear);
}

Enfin, le programme principal initialise le canevas 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 event;
    while (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
                SDL_SetRenderDrawColor(g_renderer, 255, 255, 255, 255);
                SDL_SetRenderTarget(g_renderer, g_texture);
                SDL_RenderDrawLine(g_renderer, g_x0, g_y0, event.motion.x, event.motion.y);
                SDL_RenderPresent(g_renderer);
                g_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(SDL_INIT_VIDEO);
    SDL_CreateWindowAndRenderer(WIDTH, HEIGHT, 0, &g_window, &g_renderer);
    g_texture = SDL_CreateTexture(g_renderer, SDL_PIXELFORMAT_RGBA8888, 
                                  SDL_TEXTUREACCESS_TARGET, WIDTH, HEIGHT);
    emscripten_set_main_loop(iter_one, 0, 1);
    return 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’écosystème de base comprenant HTML, CSS et 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 fonctions 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 cadriciels frontaux apportent justement cette architecture de code pour gérer facilement des applications complexes (MVC, flux…). À l’usage ces cadriciels 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 cadriciels basés widgets comme C++ Wt (et également les cadriciels d’applications mobiles) utilisent la même approche classique que les cadriciels pour les applications de bureau, 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 un cadriciel « 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++ ou 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é, des applications interactives gérées complètement côté WebAssembly.

Aller plus loin

  • # Merci

    Posté par . Évalué à 7 (+7/-0).

    Reste plus que flutter et qmlweb ;-)

    Sympa comme article et merci beaucoup

  • # dessin basique > tableau blanc collaboratif

    Posté par . Évalué à 2 (+1/-0).

    Je voulais juste rebondir car il y a peu de temps un projet de tableau blanc interactif a été présenté : https://linuxfr.org/news/wbo-un-tableau-blanc-interactif
    Je n'y connais pas grand chose en JS, mais ça me semblerait intéressant de considérer ce projet dans la comparaison des approches. En l'occurence du JavaScript pur semble viable en s'appuyant sur des bibliothèques ?

  • # Complémentaire à AngularJS/Vue.js/React ...

    Posté par . Évalué à 1 (+0/-0).

    Je n'ai pas bien saisi ton introduction ; c'est peut-être moi qui ne pige pas bien, mais les technologies que tu présentes sont complémentaires aux framework front-end. On ne peut pas les comparer. Leurs grandes forces sont effectivement la gestion des templates, le routage, le binding des données, la modularité du code en composants et la gestion centralisée des données (flux) … mais pas de savoir faire du dessin sur un Canvas ?

    Donc ton article bien qu'intéressant en soit est un peu troublant ; par exemple on pourrait faire également tout ça en SVG !

    Hormis cela, j'aime bien ton essai en WebAssembly ; ça donne quoi le draw.js généré ? Je suis développeur C++ et ça me permettrait de réutiliser du code …

    • [^] # Re: Complémentaire à AngularJS/Vue.js/React ...

      Posté par (page perso) . Évalué à 1 (+0/-0).

      Effectivement la partie canvas se retrouve dans les différentes techno mais ce n'est qu'un élément de l'appli. Il a aussi la couleur de la brosse, sa taille, etc, c'est-à-dire un modèle sur lequel on ajoute des fonctionnalités d'affichage et de mise à jour. Du coup, les technos sont bien ici des alternatives différentes : JS c'est "sans framework, à la main", Miso c'est un framework "à la angular/vue/react", Wt un framework encore différent.
      Par contre, la première implémentation en wasm est effectivement un équivalent du canvas HTML5 et pourrait donc être utilisée avec les autres techno, de façon complémentaire, sauf que ce n'est pas toujours une bonne idée et qu'il vaut parfois mieux l'utiliser pour gérer toute l'appli (comme dans la seconde implémentation) donc de façon non complémentaire aux autres techno.

      Pour le draw.js généré par webassembly ça donne ça (un fichier de 58k pas très lisible qui fait le lien avec le draw.wasm) : https://nokomprendo.frama.io/tuto_fonctionnel/posts/tuto_fonctionnel_32/images/draw_wasm/draw.js

  • # Encore merci !

    Posté par . Évalué à 3 (+3/-1).

    C'est génial !

    J'ai envie de faire pleins de projets en C++, en C, en JS, d'utiliser des templates avec {{mustache}}, et tout !
    Merci pour cette suite d'articles, ça apporte beaucoup à ma culture et c'est vraiment agréable à lire, le code est vraiment cool <3!

    Je vais essayer d'utiliser des choses que tu présente pour faire une petite page, à la base je voulais simplement utiliser Wt mais vu que c'est une simple page sans trop de trucs bizarres ça va être sympa d'essayer Crow et {{mustache}} ! Je pense juste utiliser Crow pour faire le routage et renvoyer le rendu "parsé" de templates en mustache (c'est comme ça qu'on dit ?), ça sera amusant et pas trop dur ^ Et pour la blague j'essayerai d'utiliser WebAssembly pour remplacer un bout de JS ^

    Pour de la présentation de données et de l'api ça me semble pas dégueux ! Si c'est plus complexe, je ne me sentirai plus à l'aise avec Wt, faut voir ce que donne du pur WebAssembly parce que ça je dois avouer que ça m'épate.

    En tout cas c'est sympa de prendre du temps pour nous écrire pleins d'articles au top et de pisser du bon code en plein de langages et avec plein de technos ! Merci !

  • # OCaml / Ocsigen

    Posté par . Évalué à 2 (+2/-0). Dernière modification le 25/12/18 à 11:46.

    Une application similaire programmée en multi-tiers typé avec OCaml et Ocsigen.
    http://ocsigen.org/tuto/6.2/manual/application

    C'est le framework utilisé par http://www.besport.com

    OCaml est aussi utilisé de plus en plus par Facebook pour la programmation Web
    (par exemple Facebook Messenger).

  • # C'est bien joli mais...

    Posté par (page perso) . Évalué à 4 (+2/-0).

    Ton comparatif manque un peu de TapTempo.

Envoyer un commentaire

Suivre le flux des commentaires

Note : les commentaires appartiennent à ceux qui les ont postés. Nous n'en sommes pas responsables.