Application en vue.js, angular ou polymer2 qui est le plus developer friendly

publié le

Il y a peu, SII Nantes organisait une soirée bench, vue.js, angular, React quel est le meilleur ? Malgré un teasing conséquent sur twitter, ils ont oublié de publier les résultats de leur petite sauterie. Mais cela m’a donné l’idée de faire une petite comparaison rapide entre mes 3 chouchous du moment. React n’en faisant pas partie, c’est Polymer2 qui est en beta qui va le remplacer.

Soyons clair, sur le papier vue.js, angular et polymer2 ne sont pas fait pour répondre aux mêmes besoin, ils ont chacun des forces pour des typologies d’applications diffférentes, d’ailleurs les web components polymer2 sont faits pour s’intégrer avec n’importe quelle application. La question que je me pose est la suivante : Si je dois partir de zéro pour réaliser une application intégralement avec mes petites mains, avec laquelle de ces 3 technologies j’irai le plus rapidement et pour quel résultat.

Cet article a été réalisé en 4 temps :

  • Vous êtes en train de lire le premier temps qui définit l’introduction et la conception de l’application
  • Le deuxième temps est le développement des applications
  • Le troisième est la rédaction a posteriori des réalisations
  • La quatrième est la partie analyses et conclusions tirés des étapes 2 et 3

Habituellement, je développe et écrit mes tutoriaux simultanément (étapes 2 et 3), mais cette façon de faire ne convenait pas à un concours de vitesse.

L’application

L’application réalisée pour cette comparaison est une PWA qui fonctionne intégralement (à l’exception du payload) sur le device dont le but est la gestion de cartes de visites. L’application est composée de 4 vues/routes :

  • La liste filtrable des cartes de visites
  • La vue détaillée d’une carte de visite permettant sa supression
  • Le formulaire d’ajout d’une carte de visite
  • La vue d’accueil contenant un bouton pour installer l’application sur le device si elle est lancée depuis un navigateur Les cartes de visites sont stockées dans indexedDB, et respecte le format suivant :
    {
      'name' : String,
      'tel' : String,
      'mail': String,
      'card': Base64Url
    }
    
    A l’exception de card, tous les champs sont optionnels, ils sont utilisés pour le filtre sur la liste. Pour sauvegarder la photo de la carte de visite, on aura besoin d’accéder à la caméra, un composant de saisie dédiée est réalisée. L’application utilisera donc les services suivants :
  • Communication avec l’indexedDB
  • Capture photo
  • Installation sur le device

Le mode offline est assuré par un service worker exploitant sw-toolbox qui est commun pour les 3 applications.

Détail des vues

Accueil

2 boutons, 1 pour installer l’application, l’autre pour continuer en mode web.

Liste de cartes

  • 1 champ de recherche permettant de filtrer sur toutes les metadonnées n’importe où dans la chaine
  • 1 liste de miniatures des cartes de visites, triées alphabétiquement sur le name (s’il est renseigné)
  • les cartes sont clicables

Vue détaillée

  • La carte de visite en grand
  • 2 boutons pour modifier / supprimer la carte

Vue formulaire

  • 1 champ capture photo avec preview si pré-rempli
  • 1 champ name pour saisir nom & prénom peu importe dans quel ordre
  • 1 champ email
  • 1 champ tel

Réalisations

La cli fourni par chacun des frameworks est utilisée car faisant partie intégrante de la définition de developer friendly, yarn, bower, npm, angular-cli, vue-cli et polymer-cli sont installés au préalable en global. On démarre le chrono au moment de l’invocation new project de la Cli dédiée.

Angular

Le premier framework (seul framework ?) à passer sur le banc d’essai : Angular. La nouvelle mouture du framework de Google orienté composant. Il est le plus industriel, le plus outillé, mais aussi le plus lourd. Le démarrage post-conception est intuitif, puisque la CLI permet de scaffold l’intégralité des fichiers que nous avons présenté en partie 1 :

ng ng2pwa
cd ng2pwa/
ng serve

#Another bash
cd services
ng g service capture
ng g service card
ng g service install

cd ..
mkdir views
cd views
ng g component list-view
ng g component detail-view
ng g component form-view
ng g component home-view

En bénéfice un beau fichier spec.ts qu’on ne manquera pas de remplir pour nos projets les plus robustes

On amorce par remplir le poind d’entrée de l’application : HomeView qui est un simple template (le fichier ts est non modifié)

<!-- home-view.component.html-->
<button (click)="do()">Installer</button>
<a routerLink="/list">Poursuivre en mode web</a>

Ce composant introduit la nécessité d’initialiser le routeur :

//app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { RouterModule } from '@angular/router';


import { AppComponent } from './app.component';
import { ListViewComponent } from './views/list-view/list-view.component';
import { DetailViewComponent } from './views/detail-view/detail-view.component';
import { FormViewComponent } from './views/form-view/form-view.component';
import { HomeViewComponent } from './views/home-view/home-view.component';

import { CardService } from './services/card.service';

var Routes = RouterModule.forRoot([
{
    path: 'list',
    component: ListViewComponent
},
{
    path: 'new',
    component: FormViewComponent
},
{
    path: 'new/:id',
    component: FormViewComponent
},
{
    path: 'list/:id',
    component: DetailViewComponent
},
{
    path: '**',
    component: HomeViewComponent
}
])

@NgModule({
    declarations: [
    AppComponent,
    ListViewComponent,
    DetailViewComponent,
    FormViewComponent,
    HomeViewComponent
    ],
    imports: [
    BrowserModule,
    FormsModule,
    Routes,
    HttpModule
    ],
    providers: [CardService],
    bootstrap: [AppComponent]
})
export class AppModule { }

Je suis perplexe sur le fait que par défaut Angular nous incite à initialiser le router dans app.module.ts, il me semblerait plus opportun que la CLI crée naturellement le router dans un module annexe, en tout cas les possibiltiés fournies par le router angular sont loins d’être exploitée ici, mais à usecase simple : router simple ce uqi est un bon point.

Je ne vais pas vous présenter tous les fichiers ils sont simplistes, angular nous guide sur quelle partie mettre dans quel fichier et rester le plus KISS possible.

<!-- form-view.component.html -->
<div><input type="file" accept="image/*" capture="camera" (change)="updateAvatar($event)">
<img [src]="card.card" *ngIf="card.card"/></div>
<div><label for="name"></label><input id="name" type="text" [(ngModel)]="card.name"></div>
<div><label for="mail"></label><input id="mail" type="text" [(ngModel)]="card.mail"></div>
<div><label for="tel"></label><input id="tel" type="text" [(ngModel)]="card.tel"></div>
<button (click)="save()">Save</button>
//form-view.component.ts
import { Component, OnInit } from '@angular/core';
import { CaptureService } from '../../services/capture.service';
import { Card, CardService } from '../../services/card.service';
import { Router, ActivatedRoute, Params }   from '@angular/router';
import { Location }                 from '@angular/common';
import 'rxjs/add/operator/switchMap';

@Component({
    selector: 'form-view',
    templateUrl: './form-view.component.html',
    styleUrls: ['./form-view.component.css'],
    providers: [CaptureService]
})
export class FormViewComponent implements OnInit {

    private card:Card;
    private index:string;

    constructor(
        private captureService:CaptureService,
        private cardService:CardService,
        private location: Location,
        private router: Router,
        private route: ActivatedRoute
        ) {
        this.card={
            card:'',
            index:-1
        }
    }

    ngOnInit() {
        this.route.params.switchMap((params: Params) => {
            if (params['id']){
                this.index = params['id'];
                return this.cardService.getCard(+params['id'])
            }else {
                return Promise.resolve({card: ''});
            }
        })
        .subscribe(card => this.card = card)
    }

    updateAvatar(evt) {
        console.log(evt)
        this.captureService.convertToBase64(evt.target.files[0])
        .then(base64 => this.card.card = base64)
    }

    save(){
        if (! this.card.card) {
            return;
        }
        if (this.index){
            this.cardService.updateCard(parseInt(this.index), this.card)
        }else {
            this.cardService.addCard(this.card)
        }
        this.router.navigate(['/list']);
    }

}
<!--list-view.component.html-->
<p>
    list-view works!
</p>

<input type="search" [(value)]="filter"/>
<ul class="cards">
    <li *ngFor="let card of cards; let i = index"><img routerLink="/list/" [src]="card.card"/></li>
</ul>

//list-view.component.ts
import { Component, OnInit } from '@angular/core';
import { Card, CardService } from '../../services/card.service';

@Component({
    selector: 'list-view',
    templateUrl: './list-view.component.html',
    styleUrls: ['./list-view.component.css']
})
export class ListViewComponent implements OnInit {

    private filter:string;
    private cards:Card[];

    constructor(private cardService:CardService) {
        cardService.getCards().then(cards => this.cards = cards ? cards : []);
    }

    ngOnInit() {
    }

}

Je présente néanmois les 2 comopsants formulaires et liste qui sont classique mais interessant car on se rend compte que le formulaire est bien plus complexe que la liste car il gère à la fois la modification et la création, une séparation en micro-composant Smart et Dumb serait justifiée ici.

On notera dans ces composants les inection de Service uniquement au niveau ou c’est nécessaire, une force d’Angular facilitant la cohésion et la conception.

Vue.js

L’extra-terrestre venu de nulle part en 2016 : Vue.js, la petite lib qui monte … chez les développeurs. Ic i il faut tout faire à la main, on est vraiment dans l’artisanat, cela vous rend le contrôle et vous permet de faire n’importe quoi.

vue init webpack vuepwa
cd vuepwa/
yarn install
npm run dev

On a un tooling minimal qui fait le travail, et prépare également les archi de test unitaire et e2e, mais ça s’arrète là. Mis a part ce template de base, tout est à créé à la main, les dossiers, les services, les vues, …

On démarre cependant par le point d’entrée

<!-- views/Home.vue -->
<template>
<div>
<button>Install</button>
<router-link to="/list">Continuer en mode web</router-link>
</div>
</template>

<script type="text/javascript">
    export default {
    }
</script>

<style type="text/css">

</style>

ça me rappelle quelque chose ;)

De nouveau le besoin d’un routeur se fait sentir, cependant il a déjà été créé pour nous par le scaffold de la CLI, on a juste à l’enrichir :

import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
import Cards from '@/components/Cards'
import Details from '@/components/Details'
import Form from '@/components/Form'

Vue.use(Router)

export default new Router({
    routes: [
        {
            path: '/',
            component: Home
        },
        {
            path: '/list',
            component: Cards
        },
        {
            path: '/list/:id',
            component: Details
        },
        {
            path: '/new',
            component: Form
        },
        {
            path: '/new/:id',
            component: Form
        }
    ]
})

Pour des use case simple, on conserve un router simpliste, comme pour Angular il est possible de faire des Use Cases plus avancé, cependant il va un peu moins loin.

Cas interessant, le router est disponible pour toute l’application, nul besoin de l’injecter, cependant ça signifie que cela complexifie l’utilisation de 2 routeurs au sein de l’application. (Cela reste possible mais la vigilance sera primordiale, là ou le système d’injection d’Angular rend les choses claires sans ambiguités)

Voici le même extract de composants que pour l’application angular :

<!-- views/Form.vue -->
<template>
    <div>
        <div><input type="file" name="card" capture="camera" @change="updateCard($event)"><img :src="card.card"/></div>
        <div>
            <label for="name">Name : </label><input type="text" id="name" v-model="card.name">
        </div>
        <div>
            <label for="mail">Mail : </label><input type="text" id="mail" v-model="card.mail">
        </div>
        <div>
            <label for="tel">Tel : </label><input type="text" id="tel" v-model="card.tel">
        </div>
        <div>
            <button @click="saveCard()">Save</button>
            <router-link to="/list"><button>Cancel</button></router-link>
        </div>
    </div>
</template>
<script type="text/javascript">
    import CardService from '@/services/CardService'
    import CaptureService from '@/services/CaptureService'
    export default {
        methods: {
            saveCard () {
                if (!this.card.card) return
                if (this.$route.params.id) {
                    CardService.updateCard(parseInt(this.$route.params.id), this.card)
                } else {
                    CardService.addCard(this.card)
                }
                this.$router.push('/list')
            },
            updateCard (evt) {
                CaptureService.convertToBase64(evt.target.files[0]).then(src => this.card.card = src)
            }
        },
        created () {
            if (this.$route.params.id) {
                CardService.getCard(parseInt(this.$route.params.id)).then(card => this.card = card)
            }
        },
        data () {
            return {
                card: {}
            }
        }
    }
</script>

<style scoped>

</style>

<!-- views/List.vue -->
<template>
<div>
<input type="search" v-model="filter"/>
<ul>
    <li v-for="(card,index) in cards"><router-link :to="'/list/'+index"><img :src="card.card"></router-link></li>
</ul>
<router-link to="/new"><button>New</button></router-link>
</div>
</template>

<script type="text/javascript">
    import CardService from '@/services/CardService'
    export default {
        created () {
            CardService.getCards().then(cards => this.cards = cards)
        },
        data () {
            return {
                filter: '',
                cards: []
            }
        }
    }
</script>

<style scoped>

</style>

Uniquement 2 fichiers, Vue n’incitant pas à séparer le template et le Script. Force ou faiblesse, à vous de choisir..

Polymer2

J’ai conservé Polymer2 dans cet article car il n’est pas fait pour réaliser des applications mais créer des WebComponents qu’on peut réutiliser à loisir dans n’importe quel autre framework. De plus Polymer2 est en béta et je voulais tester sa maturité.

En fait je ne suis jamais parvenu à initialiser le routeur, cela est du au fait que l’éco-système est resté en polymer1 (iron-pages par exemple des pas migré, et app-route fonctionne, mais ne s’insère pas dans le nouveau cycle de vie)

Du coup l’application a perdu de son charme mais fonctionne tout de même, moyennant de gérer un état conditionnel sur la vue chargée.

<!-- card-view.html -->
<dom-module id="card-view">
    <template>
        <style>
            :host {
                display: block;
                height:100%;
            }
        </style>
        <input type="search" v-model="filter"/>
        <ul>
            <template is="dom-repeat" items="[[cards]]">
                <li><a href="'/list/'+index"><img src="[[item.card]]"></a></li>
            </template>
        </ul>
        <a href="/new"><button>New</button></a>

    </template>

    <script>
        class CardView extends Polymer.Element {
            static get is() { return 'card-view'; }
            static get properties() {
                return {
                    cards: {
                        type: Array,
                        value :[]
                    }
                };
            }
        }

        window.customElements.define(CardView.is, CardView);
    </script>
</dom-module>
<!-- form-view.html -->
<link rel="import" href="../components/capture-component.html">
<dom-module id="form-view">
    <template>
        <style>
            :host {
                display: block;
                height:100%;
            }
        </style>
            <div>
            <capture-component value=""></capture-component>
            <label for="name">Name : </label><input type="text" id="name" value="">
        </div>
        <div>
            <label for="mail">Mail : </label><input type="text" id="mail" value="">
        </div>
        <div>
            <label for="tel">Tel : </label><input type="text" id="tel" value="">
        </div>
        <div>
            <button @click="saveCard()">Save</button>
            <router-link to="/list"><button>Cancel</button></router-link>
        </div>
    </div>

    </template>

    <script>
        class FormView extends Polymer.Element {
            static get is() { return 'form-view'; }
            static get properties() {
                return {
                    name: {
                        type:String,
                        value: ''
                    },
                    mail: {
                        type:String,
                        value: ''
                    },
                    tel: {
                        type:String,
                        value: ''
                    },
                    card: {
                        type:String,
                        value: ''
                    }
                };
            }
        }

        window.customElements.define(FormView.is, FormView);
    </script>
</dom-module>

Résultats

Quand j’ai démarré cet étude j’avais des préjugés sur le ranking de chacun pour les 3 critères : Plaisir de développement, Rapidité de développement, Poids du livrable, et certains ont volé en éclat, notamment pour le premier point.

Plaisir de développement

Je m’attendais à kiffer Polymer, malheureusement, c’était beaucoup trop tôt pour cela, et je pensais m’ennuyer ferme sur Angular, au final c’est tout l’inverse qui s’est produit.

En développant sur Angular, j’ai vraiment eu le sentiment de construire quelque chose de solide, et d’être incité à rendre les fondations le plus robuste possible. Le peu d’injection de dépendance que j’ai eu à géré pour ce POC était un pur bonheur, seul le router m’a laissé un gout amer.

Vue.js qui se classe deuxième sur ce classement fais le choix de convention qui ne sont pas toujours explicable (route.params accessible partout) à posteriori. C’est puissant, ça permet d’aller vite, mais si je dois revenir sur mon application dans 2 ans serais-je capapble de facilement retrouver mes petits ?

Polymer2 a souffert de son défaut de jeunesse sur ce point, mais aussi d’un mode déclaratif qui est finalement atrocement verbeux par rapport au mode programmatique.

Classement: Angular, Vue.js, Polymer2

Rapidité de développement

Angular étant passé preums il a été un peu pénalisé par plus de réflexion sur le code à écrire, mais très peu. Il a tout de même pris 2 fois plus de temps que pour Vue.js, c’est en partie du à l’initialisation avec la CLI qui ramène vraiment beaucout de dépendances, cependant le différentiel de vélocité est vraiment énorme. Nous avons vraiment l’opposition entre le quick and dirty (pas si dirty que ça) et le slow and durable. La problématique de contournement du router pour Polymer2 lui a une nouvelle fois valu d’être bon dernier.

Classement: Vue.js, Angular, Polymer2

###Poids du livrable

ng build --prod --aot
npm run build
polymer build --entrypoint index.html

Ici il n’y a eu aucune surprise :

  • Angular : 591,1 kB
  • Vue.js : 103,3 kB
  • Polymer2 : 113,8 kB, dont le polyfil webcomponents-lite N’oublions pas pour la défense d’angular que la même commande lancée immédiatement apres un new pèse déjà 441,1 kB, la surcouche propre à mon appli n’est donc “que” de 150kB. Angular reste encore le grand perdant de ce point de vue.

Classement: Vue.js, Polymer2, Angular

Conclusion

J’ai pris beaucoup de plaisir à réaliser ces micro-applications et à réaliser ces comparaisons, à la fois subjective et objectire. Selon moi, il en ressort 3 choses :

  • Polymer est une formidable librairie pour réaliser des composants, et un bon catalogue de composants existants (notamment paper) qu’il ne faut pas hésiter à prendre pour les injecter dans nos applications, mais il ne faut pas l’utiliser comme template pour les applications.
  • Vue.js est le champion de la rapidité, et doit être plébiscité pour les projets courts, one shot et autre POCs
  • Angular est le champion de la maintenabilité et doit être plébiscité pour tout le reste

React aurait mérité sa place dans cette comparaison, probablement à la place de Polymer2, il a encore bien des forces à faire valoir, mais je suis un peu allergique au JSX, donc j’ai passé mon tour.