Architecture d'application web : SPA ou classique (en pages)

icon Tags de l'article : , , ,

Aout 16, 2018
Hello les gens,

Aujourd'hui je vais répondre à une question que beaucoup de développeurs se posent quand ils vont commencer un nouveau projet web : dois-je tenter l'aventure SPA (Single Page Application) ou dois-je rester sur une architecture classique (en pages web).

Pour répondre à ça, j'ai décidé de faire un petit listing des points positifs et négatifs d'une application SPA par rapport à une application classique.
A noter que c'est ma vision perso, après 6 ans à travailler sur des sites en pages web et 4 ans sur une application SPA.

Positif
  • Un code plus propre, mieux organisé, qui force à la rigueur
  • Un code JS beaucoup plus clair et parfaitement intégré aux vues
  • Moins de requêtes (stockage JS, moins de données à envoyer en permanence au serveur)
  • Possibilité d'avoir énormément de choses côté client (gros traitements, calculs, process en X étapes, etc.)
  • Maintenance et évolution plus facile
  • Pour l'utilisateur : une navigation plus agréable dans l'application

Négatif
  • Quel framework choisir ? Angular ? Vue.js ? React ? Un autre ?
  • Demande plus de rigueur en permanence (découpage en fichiers, architecture, dépendances JS, etc.)
  • Un temps de dev plus long (temps d'adaptation au langage, plus de javascript, temps pour trouver quel morceau de code correspond à quoi, plus de classes à écrire, ...)
  • Un debug plus compliqué au quotidien
  • Référencement et Routing
  • Monitoring des performances et tests d'intrusion moins évidents
  • Problématiques de mise à jour (si la personne ne refresh jamais, comment forcer la mise à jour sans lui faire perdre des données en cours de saisie ?)
  • Pour l'utilisateur : un premier chargement plus long

Pour moi, si j'avais à choisir entre une architecture SPA vs design classique, je poserais deux questions :
  • L'utilisateur de l'application fera-t-il de grosses sessions dessus ? (20 minutes ou plus)
  • Ais-je le temps et les moyens d'investir dans une SPA ? (car rien que la structure SPA rajoutera des problématiques à gérer au jour le jour)

Si vous avez un OUI aux deux questions, vous pouvez partir sur une architecture de type SPA sans hésiter. Le confort d'utilisation apporté à l'utilisateur par une SPA vaut vraiment le détour pour une application sur laquelle l'utilisateur passera des heures.

Après, personnellement, j'ai tendance à préférer une approche entre les deux :
  • Un site classique, découpé en pages
  • Chaque page utilisant un modèle de vue JS et un moteur de binding (dans mon cas Knockout)

Pourquoi ?
Après 4 ans passés à travailler sur une SPA, je me suis rendu compte que revenir à un design en pages web ne me posait pas de soucis. Au contraire, le développement devient plus simple.
La vraie problématique que je retrouve... c'est le JavaScript "à l'ancienne". Avoir un peu partout des méthodes qui font des modifications dans l'interface rend le tout extrêmement compliqué à relire et à améliorer.
Garder un design en pages web auquel on ajouterait un moteur de binding JS me parait être le meilleur compromis d'entre les deux mondes.

Après, évidemment... tout dépend de la taille de votre projet, de vos moyens... et de vos goûts ;)

Bonne journée et bon dev à tous/toutes !

KnockoutJS, JavaScript et JQuery : notes et exemples perso

icon Tags de l'article : , ,

Juin 24, 2016
Hello,

Une petite liste de notes perso, de morceaux de code et de comment faire 2/3 choses, pour moi, mais qui peut vous servir.

Enjoy !

<!-- quelques databind par défaut -->

<input type="text" data-bind="value: searchText" />
<td data-bind="text: 1 === Role() ? 'User' : 'Admin'"></td>
<p data-bind="visible: currentlyWorking">

<!-- A noter : si on est dans un cas de visible "si pas ce booléen", il faut mettre les parenthèses : -->
<div data-bind="visible: !currentlyWorking()">
<p data-bind="visible: !showSearchResults() && firstSearchDone()">

<!-- Plusieurs paramètres -->
<input class="btn" type="button" value="Add" data-bind="attr: { 'data-value': Id }, visible: !model.IsMethod(Id)" /> 

<!-- Appeler le modèle parent de l'objet KO courant : $parent -->
<span data-bind="visible: null !== ItemId && $parent.apiConsumerId() !== ApiConsumerId">

  • Dans l'idéal il vaut mieux éviter d'avoir de la logique côté vue, il vaut mieux faire des computed :
self.showSearchResults = ko.computed(function () {
    return self.firstSearchDone() && self.searchedUsers().length > 0;
});

// Attraper la touche entrée sur un champ texte
$('#txtSearch').on('keyup', function (e) {
    if (e.which !== 13) {
        return;
    }

    $('#btSearchUser').click();
});

// Passer des informations depuis la vue ASP.Net MVC vers le fichier JavaScript 
<script type="text/javascript">
    var urls = {
        'promoteUser': '@Url.Action("Promote", "User")',
    };
    var initialData = @MvcHtmlString.Create(Newtonsoft.Json.JsonConvert.SerializeObject(Model.Users));
    var currentItemId = @Model.Item.Id;
</script>

// Demander confirmation pour une action dans certaines conditions ?
// Créer une méthode JS globale "mustConfirm()" qui sera appelée avant chaque action critique comme ça :
if (confirm('Are you sure that you want to DELETE this user account? This action CANNOT BE UNDONE.') && mustConfirm()) {

}

// Un appel en direct dans le HTML :
<input type="submit" value="Purge" class="btn btn-default" onclick="return mustConfirm(); " />

// Et cette méthode dans la Layout CSHTML :
var mustConfirm = function () {
    @{
        if (UserHelper.GetCurrentEnvironment().AskForConfirmation)
        {
            <text>return confirm('Attention: This is a production environment with sensitive personal and customer data. Are you sure?');</text>
        }
        else
        {
            <text>return true;</text>
        }
    }
};

// Recharger la page courante
location.reload();

// Récupérer une collection d'observables à partir d'un JSON
// Pour ça on utilise la lib JS ko.mapping :
self.Users = ko.mapping.fromJS(initialData);

// Envoyer un fichier en POST à un contrôleur ASP.Net MVC
var data = new FormData();
var files = $('#fileUpload').get(0).files;
if (files.length === 1) {
    data.append("UploadedFile", files[0]);
    data.append("CreatorId", $('#creatorId').val());
    $.ajax({
        type: "POST",
        url: urls.import,
        contentType: false,
        processData: false,
        data: data,
        success: function (result) {
            if (0 === result.ErrorMessages.length) {
                setTimeout(function() { document.location = urls.importsList; }, 1000);
            } else {
                model.currentlyWorking(false);
                model.LoadResult(result, false);
            }
        }
    });
} else {
    alert('Error: you must choose a file');
}

// et côté ASP.Net MVC
public ActionResult Import(int? creatorId, int? userId)
{
    var context = this.CreateContext();

    var model = new ImportVM();

    if (Request.Files.Count != 1)
    {
        model.ErrorMessages.Add("You must upload a file to import profiles");
        return Json(model);
    }

    var uploadedFile = Request.Files[0];

    if (!uploadedFile.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
    {
        model.ErrorMessages.Add("Invalid extension: only CSV file can be used for import");
        return Json(model);
    }

    _importBusiness.Import(uploadedFile.InputStream, context);

    return Json(model);
}

// Exemple complet côté JS 
var model;

(function () {
    var Users = function () {
        var self = this;

        self.firstSearchDone = ko.observable(false);
        self.searchText = ko.observable("");
        self.searchedUsers = ko.observableArray();

        self.currentlyWorking = ko.observable(false);

        self.showSearchResults = ko.computed(function () {
            return self.firstSearchDone() && self.searchedUsers().length > 0;
        });

        self.cleanSearch = function () {
            self.searchedUsers.removeAll();
            self.searchText('');
            self.firstSearchDone(false);
        }
    }

    $('#txtSearch').on('keyup', function (e) {
        if (e.which !== 13) {
            return;
        }

        $('#btSearchUser').click();
    });

    $('#btSearchUser').on('click', function () {
        $('#txtSearch').val('');
        model.currentlyWorking(true);

        $.post(urls.searchUserByName, { text: model.searchText() }, function (response) {
            if (!response.ok) {
                alert(response.errorMessage);
                model.cleanSearch();
            } else {
                model.firstSearchDone(true);
                model.searchedUsers(response.searchedUsers);
            }
            model.currentlyWorking(false);
        });
    });

    // we initialize the model
    model = new Users();

    ko.applyBindings(model);
})();