ITHub

Bevezetés a Vue.js keretrendszerbe

Bevezetés a Vue.js keretrendszerbe
Rajcsányi Zoltán
Rajcsányi Zoltán
| ~16 perc olvasás

Szinte már megkerülhetetlen a web alapú fejlesztéseknél a JavaScript használata. Az ügyfelek elvárják az interaktivitást, ami komplex kliens oldali logikával jár. Ha meg szeretnénk felelni az elvárásoknak, azzal szembesülünk, hogy jóval komplexebb kódot kell írnunk a problémák megoldásához, mint korábban. Mivel a JavaScript "beépített" funkcióinak használata kevésbé hatékony, ezért előbb-utóbb valamilyen library-hez vagy keretrendszerhez nyúlunk. A jQuery az egyik legszélesebb körben használt ilyen eszköz, bizonyos fokú komplexitást követően a kódunkat érdemes vele pluginokba szervezni, azonban legtöbbször még ez sem ad megfelelő támogatást a programunk szervezésében. Hogy tovább egyszerűsítsük az életünket, a keretrendszerek irányába érdemes tekintenünk. A legelterjedtebb keretrendszerek az Angular, a React, és amivel meg fogunk ismerkedni: a Vue.js. A cikk megértéséhez szükséges, hogy legyen némi gyakorlatunk a JavaScript, HTML és CSS hármasban. Elsősorban frontend és fullstack fejlesztőknek, vagy ilyen pályára készülőknek érdemes megismerkedniük a Vue.js-szel.

A cikkben a teljesség igénye nélkül bemutatok, és felépítek egy interaktív galériát. Lássunk hozzá!

Feladat specifikáció

A főoldalon a galéria címe lesz felül, alatta középen a fotók, majd legalul a lapozó fog elhelyezkedni. Az aktív oldal száma eltérő stílusú lesz a további oldaltól.

Amennyiben ki szeretnénk egy fotót nagyítani, ráklikkelünk a képre. Ekkor a képernyő elsötétül (az ’overlayCSS osztályú réteg segítségével), majd megjelenik a nagyobb méretű fotó. A kinagyított fotón is tudunk lapozni a navigációs nyilakkal (előre és vissza). A fotó alatt helyezkedhet el az aktuális fotó indexe, és az összes megtekinthető fotó száma. Amennyiben vissza szeretnénk térni a főoldalra, két lehetőségünk is lesz: vagy a fotó mellé a háttérre kattintunk, vagy az escape billentyűt leütjük.

Vue.js gyorstalpaló

Mint korábban említettem, a Vue.js egy JavaScript keretrendszer, ami MVVM mintát követ, azaz lényegében szétválasztja az üzleti logikát és a megjelenítést.
Érdemes nézegetnünk az angol nyelvű felhasználói kézikönyvet a vuejs.org oldalon, vagy a a magyar nyelvű leírásokat a vuejs.hu oldalon.
Első feladatunk a Vue egyed /vue instance/ létrehozása.

<!-- Vue.js betöltése CDNJS segítségével-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.16/vue.min.js"></script>

// html kódunk
<div id="app">
    <h1>{{ title }}</h1>
</div>

<script>
  // vue egyed létrehozása
  vue = new Vue({
    el: '#app', // ehhez fér a Vue egyed
    data: {
      title: 'A galéria címe'  // dinamikus változó
    }
  });
</script>

A galéria címét dinamikusan adtam meg. A Vue egyedünk képes sablonokat használni, de bármihez a DOM-ban (HTML dokumentum) nem fér hozzá. A hozzáférhetőséget az {el: '#app'} paraméter határozza meg, ami egyébként a <div id="app"> elemre mutat. Tehát a sablonunk az app azonosítóval meghatározott div elem. Ha a sablonba szeretnénk beszúrni egy dinamikus értéket, akkor a dupla kacsacsőrös zárójel közé kell tennünk a változónk nevét így: {{ title }}, illetve létre is kell hoznunk a property-t a Vue egyedünkben a data objektumon belül, így: data: { title: 'A galéria címe'}. Miután a Vue értelmezi a sablont, majd lefordul a kód, a DOM-ban ezt fogjuk látni:

...
<div id="app">
    <h1>A galéria címe</h1>
</div>
...

Készítsük el a a fotókat

A pexel.com közösségi fotószolgáltatását fogom használni, ami a célnak tökéletesen megfelel, mert a fotók szabadon felhasználhatók, sőt elég, ha a képekhez tartozó azonosítót definiáljuk. Az url-ek programlogikával legenerálhatóak, az alábbi séma szerint: [https://images.pexels.com/photos/](https://images.pexels.com/photos/)<ID>/pexels-photo/<ID>.jpeg?w=<SZÉLESSÉG>&h=<MAGASSÁG>&auto=compress&cs=tinysrgb. Szükség lesz bélyegképre (urlThumbnail változó) és teljes képernyős méretre (urlPhoto változó), azaz két felbontás fog rendelkezésre állni. Az alábbi JavaScript kódrészlet, a getPhotos() metódus fogja elkészíteni a fotókat tartalmazó tömböt:

function getPhotos() {
  var pexelIds = [
    289225, 866019, 255349, 864939, 864990, 160998, 206529, 206388, 413998, 164262, 293029, 826349,
    247199, 255332, 32267,  18809,  32267, 906052, 317157, 863977, 163360, 573299, 415829, 247322,
    207936, 394545, 594365, 157967, 157967, 90754,  720598, 774367, 269746, 234059, 235516, 428543
  ];

  var photos = [];
  pexelIds.forEach(function(index, id) {
    photos.push({
      urlPhoto: 'https://images.pexels.com/photos/'+id+'/pexels-photo-'+id+'.jpeg?w=300&h=200&auto=compress&cs=tinysrgb',
      urlThumbnail: 'https://images.pexels.com/photos/'+id+'/pexels-photo-'+id+'.jpeg?w=1260&h=750&auto=compress&cs=tinysrgb',
      id: index 
    });
  });

  return photos;
};

A tömb szerkezete, azaz a getPhotos() metódus által visszaadott érték ilyesmi lesz:

[
    {
        urlPhoto: "https://images.pexels.com/photos/289225/pexels-pho…289225.jpeg?w=300&h=200&auto=compress&cs=tinysrgb",
        urlThumbnail: "https://images.pexels.com/photos/289225/pexels-pho…89225.jpeg?w=1260&h=750&auto=compress&cs=tinysrgb"
    },
  ...
]

Lássuk a fotókat

A Vue képes a HTML sablonban programszerkezeteket is futtatni. A v-for HTML attribútum a Vue sablon ciklusának felel meg (foreach jellegű, tömbbejáró szerkezetről van szó). A kódunk így néz ki:

<div id="app">
  <h1>{{ title }}</h1>
  <div v-for="photo in photos">
    <img :src="photo.urlThumbnail" alt="">
  </div>
</div>
<script>
  new Vue({
    el: '#app',
      data: {
      title: 'Collection of Awesome Girls',
      photos: getPhotos()
    }
  });
</script>

Most képesek vagyunk az összes fotót megjeleníteni.

A v-for="photo in photos" ciklus a photos tömbön végiglépkedve az egyes fotókhoz tartozó értékeket bemásolja a photo változóba. A v-for direktívához tartozó HTML elem belsejében tudunk hivatkozni erre a változóra: <img v-bind:src="photo.urlThumbnail" alt="">. Megjegyezném, hogy a v-bind: direktíva rövidítése a :, a felső kódban nem is írtam ki a teljes alakját. Ezt azért kell használnunk, mert a HTML attribútumokon belül nem használhatjuk a {{ }} szintaxisú string interpolációt. Tehát ez a kód: <img src="{{ photo.urlThumbnail }}" alt=""> nem működne.

Formázzuk meg a galéria főoldalát

A html kód ez lesz:

<div id="app" class="container">
  <div class="gallery">
    <h1>{{ title }} <small>Vue.js</small></h1>
    <div class="row">
      <div v-for="photo in photos" class="photo-frame">
        <div class="photo" :style="{ backgroundImage: 'url(' + photo.urlThumbnail + ')' }"></div>
      </div>
    </div>
    <div class="row">
      <div class="pager-frame">
        <ul class="pager-frame">
          <li class="page active"><a href="#">1</a></li>
          <li class="page"><a href="#">2</a></li>
          <li class="page"><a href="#">3</a></li>
        </ul>
      </div>
    </div>
  </div>
</div>

A stíluslap (sass/scss) pedig ilyen:

@import url('https://fonts.googleapis.com/css?family=PT+Sans+Narrow');

body { font-family: 'PT Sans Narrow', sans-serif; }

.container {
  max-width: 1500px;
  margin: 10px auto;
  clear: both;
  text-align: center;
}

.gallery {

  h1 { font-size: 3em; color: #226;
    small { font-size: 1em; color: #888; }
  }

  .photo-frame {
    width: 310px;
    height: 210px;
    padding: 5px;
    border: 1px solid #eee;
    background-color: #fafafa;
    margin: 5px;
    float: left;
    cursor: pointer;
    .photo {
      width: 300px;
      height: 200px;
      border: 1px solid #aaa;
      padding-left: 5px;
      padding-top: 5px;
      overflow: hidden;
      background-position: top center;
      background-size: cover;
    }
  }
}

div.row {  clear: both; }

ul.pager-frame {
  display: inline-block;
  clear: both;
  text-align: center;
  list-style-type: none;

  li.page {
    width: 40px;
    border-radius: 50px 50px;
    border: 1px solid #888;
    float: left;
    margin-left: 20px;
    font-size: 2em;
    &:hover { background-color: #ccc;  cursor: pointer;  }
    a { text-decoration: none;  color: #222; }
    &.active { background-color: #ddd; }
  }
}

Most jöhet a lapozás

A lapozót eddig csak statikusan készítettem el, ezért annak logikájával még tartozom. Egész biztos, hogy ott is szükségünk lesz v-for direktívára, azonban ki kell számítanunk az oldalak számát, meg kell határoznunk, hogy egy oldalon hány fotó helyezkedhet el, illetve azt is tudnunk kell, hogy hányadik oldalon járunk éppen. A fotókat tartalmazó tömböt kettéválasztom, lesz egy allPhotos és egy photos tömbünk. Az első a galéria összes fotóját, míg a második csak az aktuális oldal fotóit fogja tartalmazni. Amikor betöltődik az oldal és létrejön a Vue egyed, akkor kell az említett értékeket és az első oldal megjelenítését is megoldani. A kód így alakul:

<div id="app" class="container">
  <div class="gallery">
    <h1>{{ title }} <small>Vue.js</small></h1>
    <div class="row">
      <div v-for="photo in photos" class="photo-frame">
        <div class="photo" :style="{ backgroundImage: 'url(' + photo.urlThumbnail + ')' }"></div>
      </div>
    </div>
    <div class="row">
      <div class="pager-frame">
        <ul class="pager-frame">
          <li v-for="page in idxLastPage" class="page" :class="{ active: (idxCurrentPage+1) == page }" @click="paginate(page-1)">
            <a href="#">{{ page }}</a>
          </li>
        </ul>
      </div>
    </div>
  </div>
</div>

Annyi volt a változás, hogy dinamikus értéket rendeltem a style attribútumhoz, így: :stlye="...", és egy osztályhoz így: :class="...". Felvettem egy eseményt (@click)aminek a feladata, hogy elkapja a kattintás eseményt, ha a lapozó gombra klikkelne a felhasználó.

Csak megjegyezném, hogy jQuery-ben ez így nézne ki: $('.page').click(function({...})), míg Vue.js-ben csupán ennyi: @click="...". Az eseményhez egy új metódust rendeltem: paginate(page-1). Ez a metódus a lapozásért fog felelni. Most nézzük meg a JavaScript kódot:

var allPhotos = getPhotos();

new Vue({
  el: '#app',
  data: {
    title: 'Collection of Awesome Girls',
    allPhotos: allPhotos, // a galéria összes fotója
    photos: [],           // az aktuális oldal fotói
    photosPerPage: 12,    // ennyi fotó lehet egy oldalon
    idxLastPhoto: 0,      // utolsó fotó indexe
    idxCurrentPage: 0,    // aktuális oldal idexe
    idxLastPage: 0,       // utolsó oldal indexe
  },
  mounted: function() {
    // indexek inicializálása
    this.idxLastPhoto = this.allPhotos.length;
    this.idxLastPage = Math.floor(this.allPhotos.length / this.photosPerPage);

    // első oldal mutatása
    this.paginate(0);
  },
  methods: {
    // Lapozáskor frissíti a tömböket
    paginate: function(idxPage) {
      this.idxCurrentPage = idxPage;

      // periódus meghatározása
      var idxStart = Math.floor(idxPage*this.photosPerPage);
      var idxEnd = idxStart+this.photosPerPage>this.idxLastPhoto?this.idxLastPhoto:idxStart+this.photosPerPage;

      // oldalon lévő fotók törlése
      this.photos.splice(0, this.photos.length)

      // látható fotók újraépítése
      for(var idx = idxStart; idx < idxEnd; idx++) {
        this.photos.push(this.allPhotos[idx]);
      }
    }
  }
});

Metódusokat a Vue egyed methods objektumán belül vehetünk fel. Amit  le szeretnénk futtatni az app betöltésekor, azt a mounted property-ben határozhatjuk meg. A Vue egyedünk paraméterei közül már ezeket megismertük:

new Vue({
   el: '#app',  // hatókör
   data: {},    // adatok
   mounted: function(){}, // betöltéskor futtatandó
   methods: {}, // metódusok
});

A paginate metódusban a felhasználó által kiválasztott oldalt fogjuk betölteni. Első lépésben a Vue egyed aktuális oldalához rendeljük hozzá a kiválasztott oldal számát, így: this.idxCurrentPage = idxPage. A metóduson belül a Vue egyed változóit a this kulcsszóval tudjuk elérni. Következő lépés, hogy meghatározzuk a megjelenítendő periódust, azaz mely fotók lesznek a képernyőn. Viszonylag egyszerű a dolgom, mert a tömb értékeit a Vue szinkronba hozza a DOM-al, csak arra kell figyelnem, hogy a photos tömbben megfelelő adatok legyenek. Először kitörlöm a látható fotókat a this.photos.splice(0, this.photos.length) paranccsal. Sajnos a jóval egyszerűbb this.photos = [] parancs itt nem működik, mert a Vue csak bizonyos parancsokat tud kezelni, amiről bővebb infót itt találtok. Utolsó lépésként az allPhotos meghatározott fotóit áttöltjük a photos változóba.
Mivel vannak olyan értékek, amiket a Vue egyed létrehozásakor is meg kell határoznunk, még egy kis feladatunk van. Létrehozzuk a mounted tulajdonságot és meghatározzuk az utolsó fotó és oldal indexét idxLastPhoto, idxLastPage néven, majd meghívjuk a lapozó metódust az első oldalra: this.paginate(0);. Íme az eredmény:

Azért tudtam a tömböt és a DOM-ot összerendelni, mert a háttérben a Vue.js készít egy köztes virtuális DOM-ot. Egyik oldalon figyeli a változókat, ami a DOM-ban fel van használva. Ha a változók értéke megváltozik, a DOM-ot automatikusan frissíti. Angolul ezt two side binding-nek hívják, magyarul talán kétoldali kötésnek fordíthatnám. Megemlíteném, hogy ugyanezt a technikát alkalmazza az Angular is, viszont a React már kicsit eltér ettől a módszertől, a JQuery pedig egyszerűen a valós DOM-ban dolgozik.

Mivel a JQuery mindig a DOM-ban olvas, ami lassú, a Vue.js pedig a saját virtuális DOM-jában, így a Vue.js sokkal gyorsabb a JQuery-nél.

Jelenítsük meg a fotót nagyban

Cél, hogy ha egy tetszőleges fotóra a felhasználó ráklikkel, akkor a főoldal sötétüljön el, a kiválasztott fotó pedig középre helyezve jelenjen meg. A fotó bezárása pedig a képernyő bármely részére történő kattintással, vagy az escape billentyű megnyomásával megtörténjen. A CSS kódom az alábbi résszel egészíttettem ki:

// elsötétedő háttér
#overlay {
  position: fixed;
  z-index: 10;
  left: 0px;
  top: 0px;
  width: 100%;
  height: 100%;
  text-align: center;
  background: rgba(0, 0, 0, 0.7);
  text-align: center;
}

// a fotó ezen a rétegen van
#layout {
  z-index: 11;
  position: absolute;
  top: 0;
  left: 0;
  width: 95%;
  margin-top: 50px;
  text-align: center;

  img {
    margin: 40px auto 0;
    max-width: 1260px;
    width: 100%;
    border: 1px solid #aaa;
    box-shadow: 0 0 10px black;
  }
}

A HTML kódba csak egy újabb eseményt kell felvenni, amikor a fotóra ráklikkelnek:

<div v-for="photo in photos" class="photo-frame" @click="showPhoto(photo.id)">
  <div class="photo" :style="{ backgroundImage: 'url(' + photo.urlThumbnail + ')' }"></div>
</div>

A showPhoto() metódus feladata lesz a kép megjelenítés nagyban. De még létre kell hoznom a megfelelő DIV-et is, ahol a fotó megjelenik:

<div id='app'>
    ...
  <div id="overlay" v-show="isShowPhoto" @click="isShowPhoto=false"></div>
  <div id="layout" v-show="isShowPhoto" @click="isShowPhoto=false">
    <img :src="showPhotoUrl" alt="">
  </div>
</div>

A v-show direktíva csak akkor jeleníti meg a DIV-et, ha az isShowPhoto változó értéke true, egyébként nem. Ha ráklikkelünk a fotóra, be kell záródnia, amit egyetlen értékadással is meg tudtam oldani, ezért nem kell külön függvényt létrehoznom: @click="isShowPhoto=false" A nagy kép forrását a showPhotoUrl változó határozza majd meg, ami a showPhoto metódusban fog értéket kapni.

Csak akkor kell függvényt létrehoznunk, ha egy parancsnál többet szeretnénk a direktíván belül meghatározni. Tehát ez a kód hibás lenne: @click="a = 3; b = 2".

A fotót megjelenítő JavaScript kódrészlet:

showPhoto: function(id) {
   var that = this;
   // id alapján a fotó objektumának megkeresése
   this.photos.forEach(function(photo) {
       if (photo.id === id) {
           that.showPhotoUrl = photo.urlPhoto; // a nagykép url-jének meghatározása
           that.isShowPhoto = true;            // a nagykép megjelenítése
           return;
       }
   });
}

Itt csak annyi érdekeség van, hogy a forEach-en belül a this értéke nem egyezik meg a vue egyed this értékével, ezért az egyik közkedvelt sémával a that változó bevezetésével oldottam fel a problémát. A kinagyított kép így fog kinézni:

Azért, hogy az escape billentyű lenyomásával is bezárhassuk a galériát, a mounted metódust kell kiegészítenünk, és a document HTML objektum billentyűleütés eseményét kell figyelnünk:

mounted: function() {
    ...
    document.addEventListener("keydown", e => {
      if (this.isShowPhoto && e.keyCode == 27) {
          this.isShowPhoto = false;
      }
    });
},
...

A nagy fotókat is lapozzuk

Következő feladatom, hogy a kinagyított fotó is lapozható legyen, azaz az előző és a következő fotót is meg tudjuk jeleníteni a nélkül, hogy a jelenlegi kinagyított fotót bezárnánk. Most szükség lesz egy filter (szűrő) bevezetésére is.

<div id="app">
    ...
    <div id="layout" v-show="isShowPhoto" @click="isShowPhoto=false">
        <img :src="showPhotoUrl" alt="">
        <div class="prev" @click.stop="goPrevPhoto()">&lt;</div>
        <div class="next" @click.stop="goNextPhoto()">&gt;</div>
    </div>
</div>

A szűrőket a direktíva után, ponttal adjuk meg, tehát a .stop szűrőt így: @click.stop="...". A metódus az általunk megadott feladatokon kívül lefuttat egy event.stopPropagation() parancsot, aminek a célja, hogy az esemény ne fusson fel az eseménybuborékon. A stop szűrő nélkül az ablak akkor is bezáródna, ha a következő/előző nyomógombokra klikkelnénk, hiszen továbbterjedne az alatta lévő rétegekre, ahol megtörténne az "isShowPhoto=false" értékadás. A goPrevPhoto() metódus az előző, míg a goNextPhoto metódus a következő fotóra lapoz.

A .stop filter jQuery megfelelője:

$('#element').click(function(e) {
  e.stopPropagation();
});

A stíluslapot a következő kóddal bővítettem:

#layout {
  ...
  .next, .prev {
    position: absolute;
    top: 70px;
    width: 40px;
    height: 30px;
    border: 2px solid #ffffff88;
    border-radius: 50px 50px;
    box-shadow: 2px 2px 10px black;
    padding-top: 10px;
    color: white;
    text-shadow: 2px 2px 10px black;
    cursor: pointer;
    &:hover {background-color: #ffffff22; border-color: #ffffff; }
  }
  .prev { left:20px; }
  .next { right:20px; }
}

Most pedig nézzük meg a két új metódushoz tartozó kódrészletet:

methods: {
    ...
    goPrevPhoto: function() {
        var current = this.idxCurrentPhoto;
        this.showPhoto(this.allPhotos[current === 0 ? this.idxLastPhoto : current-1].id);
    },
    goNextPhoto: function() {
        var current = this.idxCurrentPhoto;
        this.showPhoto(this.allPhotos[current === this.idxLastPhoto ? 0 : current+1].id);
    }
}

Az utolsó fotó után az első fog következni, az első előtt pedig az utolsó. Vegyük észre, hogy a fotók lapozása független az aktuális oldalon megjelenített fotóktól, hiszen az allPhotos tömböt használtam.

A böngészőben ilyesmit látunk:

Az app működés közben:

See the Pen Vue.js gallery by Rajcsányi Zoltán (@rajcsanyiz) on CodePen.

Zárszó

A technika, amivel felépítettem a galériát, alig karcolta a Vue.js képességeit, melyek közül kiemelendő az újrafelhasználható komponensek építése és azok kommunikációja.
A cikkben lehetnek hibák, pontatlanságok, ezért az építő jellegű kritikát örömmel fogadom és javítom az elírásokat.

Amennyiben tetszett a téma, és a további példakódjaimat is szeretnéd megnézni, várlak a Vue.js codepen gyűjteményemben: https://codepen.io/collection/DwJBEZ/

Rajcsányi Zoltán
https://rajcsanyizoltan.hu