Webprogramozás

Kódszervezés, this kontextusa, speciális DOM elemek, progresszív fejlesztés

Horváth Győző
Egyetemi docens
1117 Budapest, Pázmány Péter sétány 1/c., 2.408-as szoba
Tel: (1) 372-2500/8469
horvath.gyozo@inf.elte.hu

Ismétlés

Ismétlés

  • JavaScript nyelvi elemei
  • DOM programozás
  • Eseménykezelés részletei
  • JavaScript beépített objektumai

Ismétlés – Kódszervezés

  • fizikai, logikai csoportosítás
    • fájl, modul, függvény, osztály
  • egységbe zárás
    • objektum, osztály, függvény, modul
  • adattárolás helye
    • DOM vs JS adatszerkezetek

Ismétlés – Architektúra

Űrlapok, képek és táblázatok

Űrlapok és űrlapelemek

  • Interakció elsődleges eszközei
  • Rengeteg attribútum
  • Analóg megközelítés működik
    • tabindextabIndex
    • maxlengthmaxLength
    • placeholderplaceholder
  • Koncentráljunk az ezeken túli tulajdonságokra!

Események

  • form
    • submit, reset – megakadályozható
  • űrlapelemek
    • focus, blur
    • change (elem elhagyásakor)
    • input (value változásakor)
    • invalid, search
  • szöveges elemek
    • keydown, beforeinput – megakadályozható
    • input, keyup

Metódusok

  • form
    • submit(), reset()
  • űrlapelemek
    • click()
    • focus(), blur()
  • szöveges elemek
    • select()
  • step-es elemek
    • stepDown(), stepUp()

Tulajdonságok

  • form
    • elements (HTMLFormControlsCollection)
      • elements['id_or_name'] (Element vagy RadioNodeList)
  • űrlapelemek
    • form, formAction, formEncType, stb.
    • defaultValue, defaultChecked
    • defaultSelected (<option> elem)
    • valueAsDate, valueAsNumber
    • indeterminate (checkbox, radio)

Radio

<form action="">
    <input type="radio" name="fruit" id="fruit1" value="apple"><label for="fruit1">Apple</label>
    <input type="radio" name="fruit" id="fruit2" value="pear"><label for="fruit2">Pear</label>
    <input type="radio" name="fruit" id="fruit3" value="plum"><label for="fruit3">Plum</label>
    <input type="radio" name="fruit" id="fruit4" value="lemon"><label for="fruit4">Lemon</label>
</form>
document.addEventListener('click', function (e) {
    // RadioNodeList.value
    const value = document.querySelector('form').elements['fruit'].value
    console.log(value);
})

Select

  • Select
    • value
    • selectedIndex
    • selectedOptions
    • options
    • add(option[, before]), remove(index)
  • Option
    • index
    • text

Select

const from = document.querySelector('select[name=from]')
const to = document.querySelector('select[name=to]')
document.querySelector('button').addEventListener('click', function (e) {
    const selectedOptions = Array.from(from.selectedOptions)
    selectedOptions.forEach(o => to.add(o))
})

Text selection API

  • űrlapelemek
    • text/password/search/tel/url/week/month
  • Tulajdonságok
    • selectionStart, selectionEnd, selectionDirection
  • Metódusok
    • setSelectionRange(), setRangeText()
<input type="text" value="example text">
<button id="btn1">log selection</button>
<button id="btn2">set selection (2, 5)</button>
const input = document.querySelector('input')
document.querySelector('#btn1').addEventListener('click', function (e) {
    console.log(input.selectionStart, input.selectionEnd)
})
document.querySelector('#btn2').addEventListener('click', function (e) {
    input.setSelectionRange(2, 5)
})

Űrlapellenőrzés – HTML és CSS

  • HTML5 attribútumok
    • novalidate
    • required
    • pattern
    • minlength, maxlength
    • min, max, step
  • CSS pszeudo-szelektorok
    • :valid
    • :invalid
    • :in-range
    • :out-of-range
    • :required
    • :optional
    • :read-only
    • :read-write
    • :checked

Űrlapellenőrzés – JavaScript

HTML5 constraint validation API

  • invalid esemény
  • validationMessage: hibaüzenet
  • willValidate: lesz-e ellenőrizve
  • validity: ValidityState
    • patternMismatch, valueMissing, tooLong, valid, …
  • checkValidity(): logikai (invalid esemény)
  • reportValidity(): logikai (form elem)
  • setCustomValidity(üzenet): egyedi hibaüzenet

Példa és leírás

Űrlapellenőrzés 1.

Egyszerű űrlap

<form action="">
  <button type="submit" name="btn">Submit</button>
</form>

Űrlapellenőrzés 2.

submit esemény, küldés megakadályozása

document.querySelector('form').addEventListener('submit', function (e) {
  console.log(e);
  e.preventDefault();
})

Űrlapellenőrzés 3.

HTML validátorok használata

<form action="">
  <p>Kötelező szöveg: <input type="text" required><span></span></p>
  <p>RegExp szöveg: <input type="text" pattern="[A-Z]{3}-\d{3}" required><span></span></p>
  <p>Minlength, maxlength: <input type="text" minlength="3" maxlength="6"><span></span></p>
  <p>Szám mező: <input type="number" value="1" min="1" max="10" required><span></span></p>
  <p>Email mező: <input type="email" required><span></span></p>
  <button type="submit" name="btn">Submit</button>
</form>

Űrlapellenőrzés 4.

HTML validátorok + CSS

input:valid {
    box-shadow: 0 0 5px 1px green;
}
input:invalid {
    box-shadow: 0 0 5px 1px red;
}
input:focus {
    box-shadow: none;
}

input:valid + span:after {
    content: " ✔";
    color: green;
}
input:invalid + span:after {
    content: " ❌";
    color: red;
}

Űrlapellenőrzés 5.

Saját hibaüzenet

const input = document.querySelector('input')
input.addEventListener('input', function (e) {
  if (this.validity.patternMismatch) {
    this.setCustomValidity("Bad text according to the pattern (ABC-123)")
  } else {
    this.setCustomValidity("")
  }
  this.nextElementSibling.innerHTML = this.validationMessage
})

Űrlapellenőrzés 6.

Saját hibaüzenet invalid eseménnyel

const input = document.querySelector('input')
input.addEventListener('input', function (e) {
  this.setCustomValidity("")
  this.checkValidity();
  this.nextElementSibling.innerHTML = this.validationMessage
})
input.addEventListener('invalid', function (e) {
  if (this.validity.patternMismatch) {
    this.setCustomValidity("Bad text according to the pattern (ABC-123)")
  } else {
    this.setCustomValidity("")
  }
  this.nextElementSibling.innerHTML = this.validationMessage
})

Űrlapellenőrzés 7.

novalidate

<form action="" novalidate>
  <p>RegExp szöveg: <input type="text" pattern="[A-Z]{3}-\d{3}" required><span></span></p>
  <button type="submit" name="btn">Submit</button>
</form>

Űrlapellenőrzés 8.

Saját validáció

<form action="" novalidate>
  <p>From: <input type="time" name="from" required value="20:00"><span></span></p>
  <p>To: <input type="time" name="to" required value="10:00"><span></span></p>
  <button type="submit" name="btn">Submit</button>
</form>
const form = document.querySelector('form')
const from_field = document.querySelector("[name=from]")
const to_field = document.querySelector("[name=to]")

const padTime = timeString => timeString.split(":").map(e => e.padStart(2, "0")).join(":")
function checkTime() {
  const from = padTime(from_field.value)
  const to = padTime(to_field.value)
  return from <= to
}
form.addEventListener('submit', function (e) {
  if (checkTime()) {
    from_field.setCustomValidity("")
  } else {
    from_field.setCustomValidity("from < to")
  }

  if (!this.reportValidity()) {
    e.preventDefault()
  }
})
form.addEventListener('input', function (e) {
  if (e.target.matches('input[type=time]')) {
    if (checkTime()) {
      from_field.setCustomValidity("")
    } else {
      from_field.setCustomValidity("from < to")
    }
    e.target.nextElementSibling.innerHTML = e.target.validationMessage
  }
})

Képek

  • <img src="kep.png" alt="szöveg" >
  • Legfontosabb tulajdonsága: src
    • Dinamikusan lecserélni a képet
  • Tipikus képműveletek
    • Kép lecserélése
    • Kép előtöltése
const mem_kep = document.createElement('img');
mem_kep.src = 'korte.png';
//...
//ha szukseges a csere:
const kep = document.querySelector('img');
kep.src = mem_kep.src;

Kép előtöltése és cseréje

<img src="">
const images = [
  'https://source.unsplash.com/NRQV-hBF10M/640x480',
  'https://source.unsplash.com/VJTmFSendQ0/640x480',
  'https://source.unsplash.com/1q5KmcSe760/640x480',
  'https://source.unsplash.com/fBRVaRdFMIw/640x480',
  'https://source.unsplash.com/CyhYe8B9PuY/640x480',
  'https://source.unsplash.com/45bI5ezo3gE/640x480',
]
let index = 0
const img = document.querySelector('img')
const prevImage = document.createElement('img')
const nextImage = document.createElement('img')
function nextIndex(i) {
  return (i + 1) % images.length
}
function prevIndex(i) {
  return i === 0 ? images.length - 1 : i - 1
}
function init() {
  img.src = images[0]
  prevImage.src = images[images.length - 1]
  nextImage.src = images[1]
}
function next() {
  index = nextIndex(index)
  prevImage.src = img.src
  img.src = nextImage.src
  nextImage.src = images[nextIndex(index)]
}
function prev() {
  index = prevIndex(index)
  nextImage.src = img.src
  img.src = prevImage.src
  prevImage.src = images[prevIndex(index)]
}
document.addEventListener('keydown', function (e) {
  if (e.key === 'ArrowLeft') {
    prev()
  }
  else if (e.key === 'ArrowRight') {
    next()
  }
})
init()

Kép előtöltése és cseréje

Táblázat

  • Táblázat
    • rows
    • insertRow(index)
    • deleteRow(index)
  • Sor
    • rowIndex
    • sectionRowIndex
    • cells
    • insertCell(index)
    • deleteCell(index)
  • Cella
    • cellIndex
function xyKoord(td) {
  const x =  td.cellIndex
  const tr = td.parentNode
  const y =  tr.sectionRowIndex
  return {x, y}
}

Táblázat – példa

const table = document.querySelector('table')
table.addEventListener('click', function (e) {
  if (e.target.matches('td')) {
    const {x, y} = xyKoord(e.target)
    table.rows[y].cells[x].classList.toggle('piros')
    table.querySelector('caption').innerHTML = `${x}, ${y}`
  }
})

Weboldalak progresszív fejlesztése

Progressive enhancement

Weboldal vs webalkalmazás

Weboldal Webalkalmazás
Tartalom Funkció
Működik JS nélkül JS kell hozzá
Fogyasztás Létrehozás
Egyszerű Komplex
Szerveroldali Kliensoldali

Probléma

  • Mi történik, ha olyan funkciót használunk, amely nem mindenhol érhető el?
  • Például
    • Eredetileg: nincs JavaScript
    • vállalati beállítások
    • idősebb eszközök
    • limitált erőforrású eszközök (pl. hűtő)
  • Két irány
    • könnyed lefokozás
    • progresszív fejlesztés

Könnyed lefokozás

  • Graceful degradation (GD)
  • Célja: felhasználó funkcionálisan használhassa a felületet
  • Megközelítés: hiba tolerálása
  • Ha egy komplex rendszer egy vagy több komponensébe hiba csúszik, akkor egy alternatív útvonalon biztosítja a működést
  • Ld. noscript tag, alt attribútum, táblázat mint layout, canvas fallback content

Progresszív fejlesztés

  • Progressive enhancement (PE)
  • Cél ugyanaz, de a megközelítés más
  • Különböző felhasználók és eszközök támogatása
  • Egy közös alap (HTML) létrehozása a cél, amit minden eszköz megért, erre jön rá a CSS és a JavaScript
  • Az alapelv: tartalom, stílus és viselkedés szétválasztása

GD vs PE

Könnyed lefokozás Progresszív fejlesztés
Kiindulás: teljes funkcionalitású verzió Kiindulás: alap funkció
Ha valami nem elérhető, akkor azt kihagyva érhető el a funkció Ha valami elérhető, akkor azt elérhetővé teszi
Fentről lefele építkezik Lentről felfele építkezik

Progresszív fejlesztés

  • Lépések
    1. szerkezet (szemantikus HTML)
    2. megjelenés, stílus (CSS)
    3. viselkedés (JavaScript)

PE előnyei

  • JavaScript nélkül működik
  • Sokféle eszközön fut
  • Akadálymentesség jobb biztosítása
  • lentről felfelé építkezik → tisztább, modulárisabb kód
  • jövőben is kompatibilis marad az oldal

Példa az ősidőkből

Nyomtatás

<p id="printthis">
  <a href="javascript:window.print()">Print this page</a>
</p>

Példa

  • Könnyed lefokozás
    • Mi az a JavaScript?
    • Hogyan kell bekapcsolni?
    • Van jogom bekapcsolni?
<p id="printthis">
  <a href="javascript:window.print()">Print this page</a>
</p>
<noscript>
  <p class="scriptwarning">
    Printing the page requires JavaScript to be enabled. 
    Please turn it on in your browser.
  </p>
</noscript>

Példa

  • Progresszív fejlesztés
    • Kell egyáltalán nyomtatás funkció?
<p id="printthis">Thank you for your order. Please print this page for your records.</p>
const pt = document.getElementById('printthis');
if(pt && typeof window.print === 'function'){
  const but = document.createElement('input');
  but.setAttribute('type','button');
  but.setAttribute('value','Print this now');
  but.onclick = function(){
    window.print();
  };
  pt.appendChild(but);
}

Kódszervezés

Felület kezelése

Kiírás a DOM-ba

  1. Imperatív megközelítés
    • ha szükséges megőrizni az adott DOM elemet (van belső állapotuk, pl. input, animáció, DOM-ban tárolás)
    • direkt manipuláció
  2. Deklaratív megközelítés
    • ha az elemek újragenerálhatóak (nincs belső állapotuk)
    • adat leképezése felületi elemekre
    • felület = fn(adat)
    • HTML generálók

Kártyák

Alap funkcionalitás, kattintás

Kártyák

Tábla generálása

<table>
  <tr>
    <td>
      <div class="card">
        <div class="front">1</div>
        <div class="back"></div>
      </div>
    </td>
  </tr>
</table>
let m;

const table = document.querySelector('table')
table.addEventListener('click', onClick)
function onClick(e) {
  const card = e.target.closest('.card')
  if (this.contains(card)) {
    card.classList.toggle('flipped')
  }
}

document.querySelector('button').addEventListener('click', onButtonClick)
function onButtonClick(e) {
  const n = parseInt(document.querySelector('input[type=number]').value)
  m = genMatrix(n)
  table.innerHTML = genTable(m);
}

function random(a, b) {
  return Math.floor(Math.random() * (b - a + 1)) + a
}
function genMatrix(n) {
  return (new Array(n)).fill(0).map(() => (new Array(n)).fill(0).map(() => random(1, 99)))
}
function genTable(m) {
  return `${m.map(row => `
    <tr>
      ${row.map(cell => `
        <td>
          <div class="card">
            <div class="front">${cell}</div>
            <div class="back"></div>
          </div>
        </td>
      `).join('')}
    </tr>
  `).join('')}`
}

Kártyák

1 kattintásra több is változik → újrarajzolás

function onClick(e) {
  const card = e.target.closest('.card')
  if (this.contains(card)) {
    const {x, y} = xyCoord(card)

    const selected = m.flatMap(row => row.filter(card => card.flipped))
    m[y][x].flipped = !m[y][x].flipped
    if (selected.length === 2) {
      selected.forEach(card => card.flipped = false)
    }

    table.innerHTML = genTable(m);  
  }
}

function xyCoord(card) {
  const td = card.closest('td')
  const x = td.cellIndex
  const tr = td.parentNode
  const y = tr. sectionRowIndex
  return {x, y}
}

Kártyák

Animáció, újrarajzolás, hibás

.card.flipped {
  animation: flip 1s forwards;
}
@keyframes flip {
  100% {
    transform: rotateY(0deg);
  }
}
function onClick(e) {
  const card = e.target.closest('.card')
  if (this.contains(card)) {
    const {x, y} = xyCoord(card)

    const selected = m.flatMap(row => row.filter(card => card.flipped))
    m[y][x].flipped = !m[y][x].flipped
    if (selected.length === 2) {
      selected.forEach(card => card.flipped = false)
    }

    table.innerHTML = genTable(m);  
  }
}

Kártyák

Animáció, imperatív megközelítés

let m;
let changed = [];

function onClick(e) {
  const card = e.target.closest('.card')
  if (this.contains(card)) {
    const { x, y } = xyCoord(card)

    if (m[y][x].flipped) { return }
    changed = [];
    const selected = m.flatMap((row, i) => 
      row.map((card, j) => ({i, j, card}))
         .filter(o => o.card.flipped)
    )
    m[y][x].flipped = !m[y][x].flipped
    changed.push({i: y, j: x, card: m[y][x]})
    if (selected.length === 2) {
      selected.forEach(s => s.card.flipped = false)
      changed.push(...selected)
    }

    changed.forEach(c => {
      document.querySelector(`table tr:nth-child(${c.i+1}) td:nth-child(${c.j+1}) .card`).classList.toggle('flipped', c.card.flipped)
    })
  }
}

Kártyák

Kódszervezés: modul + osztály

<!-- index.html -->
<script type="module" src="index.js"></script>
// index.js
import { AppState } from "./appstate.js";
import { AppView } from "./appview.js";

const appState = new AppState()
new AppView(appState);
// appstate.js
import { random } from "./helper.js"

export class AppState {
  constructor() {
    this.m = 0
    this.changed = []
  }
  
  createMatrix(n) {
    this.m = (new Array(n)).fill(0).map(() => (new Array(n)).fill(0).map(() => ({
      value: random(1, 99),
      flipped: false
    })))
  }

  selectCard(x, y) {
    if (this.m[y][x].flipped) return;

    this.changed = [];
    const selected = this.m.flatMap((row, i) => 
      row.map((card, j) => ({i, j, card}))
        .filter(o => o.card.flipped)
    )
    this.m[y][x].flipped = !this.m[y][x].flipped
    this.changed.push({i: y, j: x, card: this.m[y][x]})
    if (selected.length === 2) {
      selected.forEach(s => s.card.flipped = false)
      this.changed.push(...selected)
    }
  }
}
// appview.js
import { xyCoord } from "./helper.js"

export class AppView {
  constructor(appState) {
    this.appState = appState
    this.table = document.querySelector('table')
    this.table.addEventListener('click', this.onClick.bind(this))
    document.querySelector('button').addEventListener('click', this.onButtonClick.bind(this))
  }
  
  onClick(e) {
    const card = e.target.closest('.card')
    if (this.table.contains(card)) {
      const { x, y } = xyCoord(card)
  
      this.appState.selectCard(x, y)
  
      this.appState.changed
        .forEach(c => {
          document.querySelector(`table tr:nth-child(${c.i+1}) td:nth-child(${c.j+1}) .card`).classList.toggle('flipped', c.card.flipped)
        })
    }
  }
  
  onButtonClick(e) {
    const n = parseInt(document.querySelector('input[type=number]').value)
  
    this.appState.createMatrix(n)
  
    this.table.innerHTML = this.genTable(this.appState.m);
  }
  
  genTable(m) {
    return `${m.map(row => `
    <tr>
      ${row.map(cell => `
        <td>
          <div class="card ${cell.flipped ? 'flipped' : ''}">
            <div class="front">${cell.value}</div>
            <div class="back"></div>
          </div>
        </td>
      `).join('')}
    </tr>
  `).join('')}`
  }
}
// helper.js
export function xyCoord(card) {
  const td = card.closest('td')
  const x = td.cellIndex
  const tr = td.parentNode
  const y = tr.sectionRowIndex
  return { x, y }
}
export function random(a, b) {
  return Math.floor(Math.random() * (b - a + 1)) + a
}

Kártyák

Refaktorálás, “okos” DOM manipulálással

// appstate.js
export class AppState {
  constructor() {
    this.m = 0
  }
  selectCard(x, y) {
    if (this.m[y][x].flipped) return;

    const selected = this.m.flatMap(row => row.filter(card => card.flipped))
    this.m[y][x].flipped = !this.m[y][x].flipped
    if (selected.length === 2) {
      selected.forEach(card => card.flipped = false)
    }
  }
}
// appview.js
onClick(e) {
  const card = e.target.closest('.card')
  if (this.table.contains(card)) {
    const { x, y } = xyCoord(card)

    this.appState.selectCard(x, y)

    this.appState.m.forEach((row, i) => row.forEach((card, j) => 
      document.querySelector(`table tr:nth-child(${i+1}) td:nth-child(${j+1}) .card`).classList.toggle('flipped', card.flipped)
      // this.table.rows[i].cells[j].querySelector('.card').classList.toggle('flipped', card.flipped)
    ))
  }
}

this-lexia

Azaz a this kontextusa

A this kontextusa

//Global
var name = 'Peter';  // window.name
function hello() {
    return this.name;  // this === global (window)
}
hello();  // window.hello()

//Method call
var peter = {
    name: 'Peter',
    describe: function () {
        return this.name;   // this === peter
    }
}
peter.describe();

//Constructor call
var Person = function(name) {
    this.name = name;   // this === instance object
}
var peter = new Person('Peter');

//Setting the context of this with call and apply
var peter = {
    name: 'Peter',
    hello: function () {
        return this.name;
    }
};
var julia = {
    name: 'Julia',
    hello: function () {
        return this.name;
    }
};
peter.hello.call(julia); // "Julia"

A kontextus elveszítése

var peter = {
    name: 'Peter',
    age: 42,
    describe: function () {
        function getAge() {
            return this.age;  // this === global (window)
        }
        return this.name + ':' + getAge(); // global call, ~ window.getAge()
    }
}
peter.describe();  // "Peter:undefined"

A this kontextusának helyreállítása belső függvényeknél

//With call and apply
var peter = {
    name: 'Peter',
    age: 42,
    describe: function () {
        function getAge() {
            return this.age;  // this depends on the call
        }
        return this.name + ':' + getAge.call(this);
    }
}
peter.describe();  // "Peter:42"

//ES5 bind()
var peter = {
    name: 'Peter',
    age: 42,
    describe: function () {
        var getAge = (function () {
            return this.age;  // inner this is always outer this
        }).bind(this);
        return this.name + ':' + getAge();
    }
}
peter.describe();  // "Peter:42"

//ES6 fat arrow syntax
var peter = {
    name: 'Peter',
    age: 42,
    describe: function () {
        var getAge = () => this.age;  
        return this.name + ':' + getAge();
    }
}
peter.describe();  // "Peter:42"

Példa: document.querySelector

const $ = document.querySelector;
$('p') // Illegal invocation

// Magyarázat
const document = {
  somethingInside: ...,
  querySelector: function (sel) {
    this.somethingInside
  }
}
document.querySelector() // --> this === document
window.$() // --> this === window, window.somethingInside nincs

// Megoldás: a kontextus rögzítése
const $ = document.querySelector.bind(this);

Példa: osztályon belüli eseménykezelők

// Alapeset
class AppView {
  constructor(appState) {
    this.elem = document.querySelector('something')
    this.elem.addEventListener('click', this.onClick)
  }
  onClick(e) {
    // this === document.querySelector('something')
  }
}

// Bind
class AppView {
  constructor(appState) {
    this.elem = document.querySelector('something')
    this.elem.addEventListener('click', this.onClick.bind(this))
  }
  onClick(e) {
    // this === appView
  }
}

// Fat arrow
class AppView {
  constructor(appState) {
    this.elem = document.querySelector('something')
    this.elem.addEventListener('click', e => this.onClick(e))
  }
  onClick(e) {
    // this === appView
  }
}

// Class property initializer
class AppView {
  constructor(appState) {
    this.elem = document.querySelector('something')
    this.elem.addEventListener('click', this.onClick)
  }
  onClick = (e) => {
    // this === appView
  }
}

Összefoglalás

  • Speciális DOM elemek
    • űrlapok, képek, táblázatok
  • Progresszív fejlesztés
  • Kódszervezés
    • imperatív, deklaratív UI kezelés
  • this kontextusa