Kliensoldali webprogramozás

Bevezetés a komponensalapú fejlesztés világába. React. React alapok

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

Weboldalak progresszív fejlesztése

  • Elv
    • JS nélkül is működjön
    • JS-sel kényelmesebb, szebb
  • Felépítés
    1. szerkezet (szemantikus HTML)
    2. megjelenés, stílus (CSS)
    3. viselkedés (JavaScript)

Progresszív fejlesztés

<form action="" method="get">
  <input type="password" name="password1" value="secret1" data-show-password>
  <input type="password" name="password2" value="secret2" data-show-password>
  <button>Submit</button>
</form>
<style></style>
class ShowPassword {
  // ...
}

document.querySelectorAll('[data-show-password]').forEach(
  el => new ShowPassword(el))

Web komponensek

  • Web komponensek
    • Custom elements
    • Templates
    • Shadow DOM
  • Progresszív fejlesztésre használható
<show-password>
  <input type="password" name="password1" value="secret">
</show-password>

<input type="password" name="password1" value="secret" is="show-password">

Felületi elemek kezelése

Példa

Elvek

  • Adat és felület szétválasztása
  • Egységbe zárás
  • Adat és felület szinkronban tartása
    • imperatív
    • deklaratív
  • Célok
    • kényelmes fejlesztés
    • jól strukturált, karbantartható kód
    • hatékony megoldás

Deklaratív felület kezelés

  • Minden állapotváltozáskor a teljes felület újragenerálása az állapot szerint
  • A felület a mindenkori állapottér HTML elemekre való leképezése
  • Állapottér megfelelő definiálása
  • Adatvezérelt megközelítés (data-first, adat → felület)

Kódszervezés

Kérdés

Adat → UI

(UI → adat)

Mátrix


???

Táblázat

Üzleti logika

class Game {
  constructor() {
    this.n = 0
    this.m = 0
    this.board = []
    this.gameState = 0
  }
  initBoard(n, m) { /* ... */ }
  select(i, j)    { /* ... */ }
  get solved()    { /* ... */ }
}

Üzleti logika

class Game {
  constructor() {
    this.n = 0
    this.m = 0
    this.board = []
    this.gameState = 0 // 0: initial, 1: playing, 2: end
  }
  initBoard(n, m) {
    this.n = n
    this.m = m
    const numbers = Array(n*m/2).fill(null).map((e, i) => i+1)
    const values = [...numbers, ...numbers]
    this.board = Array(n).fill(null).map(() => Array(m).fill(null).map(() => ({
      value: values.shift(),
      flipped: false,
      solved: false,
    })))
    this.gameState = 1
  }
  select(i, j) {
    if (this.isFlipped(i, j) || this.isSolved(i, j)) {
      return
    }
    const flipped = this.board.flatMap(row => row.filter(card => card.flipped))
    // turn back
    if (flipped.length === 2) {
      flipped.forEach(card => card.flipped = false)
      flipped.shift()
      flipped.shift()
    }
    // flip over
    flipped.push(this.board[i][j])
    flipped.forEach(card => card.flipped = true)
    // check cards
    if (flipped.length === 2 && flipped[0].value === flipped[1].value) {
      flipped.forEach(card => card.solved = true)
    }
    // check win
    if (this.isWin()) {
      this.gameState = 2
    }
  }
  isFlipped(i, j) {
    return this.board[i][j].flipped
  }
  isSolved(i, j) {
    return this.board[i][j].solved
  }
  isWin() {
    return this.board.every(row => row.every(card => card.solved))
  }
  get solved() {
    return this.board.flatMap(row => row.filter(card => card.solved)).length
  }
}

UI

<div id="main">
  <form action="">
    n = <input type="number" id="n" value="3">
    m = <input type="number" id="m" value="4">
    <button type="button">Start new game</button>
  </form>
  <div id="board"></div>
  <div id="status"></div>
</div>

Viselkedés

Globális változók és függvények

const game = new Game()

const form = document.querySelector('form')
const button = form.querySelector('button')
const boardDiv = document.querySelector('#board')
const status = document.querySelector('#status')

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

button.addEventListener('click', onGenerate)
function onGenerate(e) {
  e.preventDefault()
  const n = form.querySelector('#n').valueAsNumber
  const m = form.querySelector('#m').valueAsNumber
  game.initBoard(n, m)
  renderBoard(game.board)
  renderStatus(game.solved, game.gameState)
}

boardDiv.addEventListener('click', onSelectCard)
function onSelectCard(e) {
  const card = e.target.closest('.card')
  if (boardDiv.contains(card)) {
    const {x, y} = xyCoord(card)
    game.select(y, x)
    renderBoard(game.board)
    renderStatus(game.solved, game.gameState)
  }
}

function renderBoard(board) {
  boardDiv.innerHTML = `
    <table>
      ${board.map(row => `
        <tr>
          ${row.map(card => `
            <td>
              <div class="card ${card.flipped || card.solved ? 'flipped' : ''}">
                <div class="front">${card.value}</div>
                <div class="back"></div>
              </div>
            </td>
          `).join('')}
        </tr>
      `).join('')}
    </table>
  `
}

function renderStatus(solved, gameState) {
  status.innerHTML = `
    <p>Game state: ${gameState}</p>
    <p>You have already solved ${solved} cards.</p>
  `
}

Egységbe zárás

const game = new Game()
const view = new View(game)
function xyCoord(card) { /* ... */ }

class View {
  constructor(game) {
    this.game = game
    this.form = document.querySelector('form')
    this.button = this.form.querySelector('button')
    this.boardDiv = document.querySelector('#board')
    this.statusDiv = document.querySelector('#status')

    this.onGenerate = this.onGenerate.bind(this)
    this.onSelectCard = this.onSelectCard.bind(this)

    this.button.addEventListener('click', this.onGenerate)
    this.boardDiv.addEventListener('click', this.onSelectCard)
  }

  onGenerate(e) {
    e.preventDefault()
    const n = this.form.querySelector('#n').valueAsNumber
    const m = this.form.querySelector('#m').valueAsNumber
    this.game.initBoard(n, m)
    this.renderBoard(this.game.board)
    this.renderStatus(this.game.solved, this.game.gameState)
  }

  onSelectCard(e) {
    const card = e.target.closest('.card')
    if (this.boardDiv.contains(card)) {
      const { x, y } = xyCoord(card)
      this.game.select(y, x)
      this.renderBoard(this.game.board)
      this.renderStatus(this.game.solved, this.game.gameState)
    }
  }

  renderBoard(board) {
    this.boardDiv.innerHTML = `...`
  }

  renderStatus(solved, gameState) {
    this.statusDiv.innerHTML = `...`
  }
}

Feladatkörök szerinti szétbontás

<div id="main">
  <div id="form"></div>
  <div id="board"></div>
  <div id="status"></div>
</div>
export class AppView {
  constructor(game) {
    this.game = game
    
    this.render = this.render.bind(this)

    this.formView = new FormView(this.game, this.render)
    this.boardView = new BoardView(this.game, this.render)
    this.statusView = new StatusView(this.game, this.render)

    this.render()
  }
  render() {
    const game = this.game
    this.formView.render(game.gameState, game.n, game.m)
    this.boardView.render(game.board)
    this.statusView.render(game.solved, game.gameState)
  }
}

Feladatkörök szerinti szétbontás

FormView, BoardView, StatusView

export class FormView {
  constructor(game, render) {
    this.game = game
    this.renderAll = render

    this.form = document.querySelector('#form')
    this.onGenerate = this.onGenerate.bind(this)
    this.form.addEventListener('click', this.onGenerate)
  }
  onGenerate(e) {
    if (e.target.matches('button')) {
      e.preventDefault()
      const n = this.form.querySelector('#n').valueAsNumber
      const m = this.form.querySelector('#m').valueAsNumber
      this.game.initBoard(n, m)
      this.renderAll()
    }
  }
  render(gameState, n, m) {
    this.form.innerHTML = `
      <form action="">
        n = <input type="number" id="n" value="${gameState === 0 ? 3 : n}"> <br>
        m = <input type="number" id="m" value="${gameState === 0 ? 4 : m}">
        <button type="button">Start new game</button>
      </form>
    `
  }
}

DEMO

Analizálni a rerendert; probléma: animációk

Imperatív kikacsintás

export class BoardView {
  render(n, m, board) {
    if (n !== this.prevN || m !== this.prevM) {
      // declarative
      this.prevN = n
      this.prevM = m
      this.boardDiv.innerHTML = `<table>...</table>`
    } else {
      // imperative
      board.forEach((row, i) => row.forEach((card, j) => 
        this.boardDiv.querySelector(`table tr:nth-child(${i+1}) td:nth-child(${j+1}) .card`)
          .classList.toggle('flipped', card.flipped || card.solved)
      ))
    }
  }
}

DEMO

DOM diffing

  • A DOM aktuális és tervezett struktúrájának összehasonlítása
  • Változások hatékony bevezetése
  • Fejlesztő: deklaratív
  • Eszköz: imperatív
  • Függvénykönyvtárak
    • lit-html
    • yo-yo
    • incremental-dom
    • dom-diff

lit-html

import {html, render} from 'lit-html';

// A lit-html template uses the `html` template tag:
let sayHello = (name) => html`<h1>Hello ${name}</h1>`;

// It's rendered with the `render()` function:
render(sayHello('World'), document.body);

// And re-renders only update the data that changed, without VDOM diffing!
render(sayHello('Everyone'), document.body);

AppView

<div id="main"></div>
import {html, render} from 'https://unpkg.com/lit-html?module';

export class AppView {
  constructor(game) {
    this.game = game
    
    this.render = this.render.bind(this)

    this.formView = new FormView(this.game, this.render)
    this.boardView = new BoardView(this.game, this.render)
    this.statusView = new StatusView(this.game, this.render)

    this.render()
  }
  template(game) {
    return html`
      <div id="form">
        ${this.formView.render(game.gameState, game.n, game.m)}
      </div>
      <div id="board">
        ${this.boardView.render(game.board)}
      </div>
      <div id="status">
        ${this.statusView.render(game.solved, game.gameState)}
      </div>
    `
  }
  render() {
    const game = this.game
    render(this.template(game), document.querySelector('#main'))
  }
}

FormView

import {html, render} from 'https://unpkg.com/lit-html?module';

export class FormView {
  constructor(game, render) {
    this.game = game
    this.renderAll = render
    this.onGenerate = this.onGenerate.bind(this)
  }
  onGenerate(e) {
    e.preventDefault()
    const n = document.querySelector('#n').valueAsNumber
    const m = document.querySelector('#m').valueAsNumber
    this.game.initBoard(n, m)
    this.renderAll()
  }
  render(gameState, n, m) {
    return html`
      <form action="">
        n = <input type="number" id="n" value="${gameState === 0 ? 3 : n}"> <br>
        m = <input type="number" id="m" value="${gameState === 0 ? 4 : m}">
        <button type="button" @click=${this.onGenerate}>Start new game</button>
      </form>
    `
  }
}

DEMO

Funkcionális megközelítés

export class FormView {
  constructor(game, render) {
    this.game = game
    this.renderAll = render
    this.onGenerate = this.onGenerate.bind(this)
  }
  onGenerate(e) {
    e.preventDefault()
    const n = document.querySelector('#n').valueAsNumber
    const m = document.querySelector('#m').valueAsNumber
    this.game.initBoard(n, m)
    this.renderAll()
  }
  render(gameState, n, m) {
    return html`
      <form action="">
        n = <input type="number" id="n" value="${gameState === 0 ? 3 : n}"> <br>
        m = <input type="number" id="m" value="${gameState === 0 ? 4 : m}">
        <button type="button" @click=${this.onGenerate}>Start new game</button>
      </form>
    `
  }
}
export function formView(game, renderAll) {
  




  function onGenerate(e) {
    e.preventDefault()
    const n = document.querySelector('#n').valueAsNumber
    const m = document.querySelector('#m').valueAsNumber
    game.initBoard(n, m)
    renderAll()
  }
  
  return (gameState, n, m) => html`
    <form action="">
      n = <input type="number" id="n" value="${gameState === 0 ? 3 : n}"> <br>
      m = <input type="number" id="m" value="${gameState === 0 ? 4 : m}">
      <button type="button" @click=${onGenerate}>Start new game</button>
    </form>
  `
}

Végeredmény

  • Felület adott része egységbe zárva kezelve
    • megjelenítő logika
    • eseménykezelő logika
  • Függőségeit a szülőtől kapja
  • Deklaratív megközelítés
  • Okos DOM módosítással

A komponensalapú fejlesztés felé

Történeti kontextus

2006-2010

  • AJAX
  • jQuery
  • Progresszív fejlesztés
  • HTML, CSS, JS szétválasztása
  • Logika: szerver → kliens
  • Növekvő kódmennyiség
  • Kódszervezési problémák

Kódszervezési problémák

Architektúrakoncepció

Architektúrakoncepció

  • Modulkezelés
    • névterezés (névtér minta)
    • minden modul számára egy közös alapfunkcionalitás biztosítása (homokozó minta)
    • modulok élettartamának kezelése (regisztrálás, indítás, megállítás)
    • eseménykezelő rendszer biztosítása a modulok kommunikációjához
    • alkalmazásszintű adatok kezelése
    • bővítmények definiálása
  • Lazán kapcsolt architektúrák
    • Observable, Observer minta
    • Pubsub minta

2010-2014: MV* éra

Model-View-Controller (MVC)

  • Kiindulás: adat és felület szétválasztása
  • Új: felület és vezérlés szétválasztása
  • 1979: SmallTalk-80, asztali interaktív alkalmazás
  • 1996: WebObject
  • 2000-: szerveroldal nagy sikere

Asztali MVC

Webes MVC

Webes MVC

Miért működött jól szerveroldalon?

Kliens MVC

Miért nem működik jól kliensoldalon?

Kliens MVC

  • Eredeti MVC-ből megmarad
    • A nézet egy objektum, amely a megjelenéssel kapcsolatos logikát tartalmazza.
    • A nézet a megfigyelő mintán keresztül figyeli a modell változásait, az pedig állapotváltozásait eseményeken keresztül közli az őt figyelőkkel.
Backbone keretrendszer architektúrája

MVP, MVVM

2010-2014: MV*

  • 2010: Single Page Applications
  • 2010: Backbone, MVR
  • 2010: Knockout.js, MVVM, kétirányú adatkötés
  • 2010: AngularJS, MVW, kétirányú adatkötés
  • 2011: Ember.js

Kétirányú adatkötés

Kétirányú adatkötés

2014: MV* éra vége

React

Nézet és DOM absztrakció

React

  • 2013: Facebook
  • nem teljes keretrendszernek indult
  • függvénykönyvtár UI létrehozására
    • render + események
  • a View az MVC-ben
  • felhasználható egyéb keretrendszerekben

Alapkoncepciók

  1. Komponensek
  2. Renderelés minden módosításkor
  3. Virtuális DOM

Felelősségi körök elkülönítése

  • Régebben:
    • Sablonok
    • ViewModel (megjelenítési logika)
    • Vezérlő
  • Ezek összetartoznak
  • Eddigi szétválasztásuk:
    • technológiai alapon történt
  • Most:
    • felelősségi kör szerint

1. React komponensek

  • Felület funkcionális egységekre bontása
  • Egy megjelenítési egység leírása
  • Sablon + megjelenítési logika
  • Komponenshierarchia
  • Komponens
    • kompozitálhatóság
    • újrafelhasználhatóság
    • tesztelhetőség
// component definition
function HelloMessage(props) {
  return <div>Hello {props.name}!</div>;
}

// show component
ReactDOM.render(<HelloMessage name="Győző" />, mountNode);

2. Megjelenítés

  • Nem módosítunk, hanem újrarajzolunk!
  • Minden állapotbeli változásnál újrarenderelődik a komponenshierarchia
  • Mint PHP-nál
  • Hogyan lesz ez hatékony?

3. Virtuális DOM

  • DOM lassú
  • –> Memóriabeli reprezentáns
  • DOM diff
  • Változások kötegelt, optimális bevezetése
  • Szerveroldalon is működik

Virtuális DOM

Telepítés

Telepítés

npx create-react-app <alkalmazás neve>

vagy

npm create vite@latest <alkalmazás neve> -- --template react

vagy

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Add React in One Minute</title>
  </head>
  <body>
    <!-- We will put our React component inside this div. -->
    <div id="like_button_container"></div>

    <!-- Load React. -->
    <!-- Note: when deploying, replace "development.js" with "production.min.js". -->
    <script src="https://unpkg.com/react@16/umd/react.development.js" crossorigin></script>
    <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js" crossorigin></script>

    <!-- Load our React component. -->
    <script src="like_button.js"></script>
  </body>
</html>

Elemek létrehozása, JSX

Elemek létrehozása és megjelenítése

JSX: alternatív formátum (Babel támogatja)

ReactDOM.render(
    <h1 className="greeting">Hello, world!</h1>,
    document.querySelector('#example')
);

JavaScript:

ReactDOM.render(
  React.createElement('h1', {className: 'greeting'}, 'Hello, world!'),
  document.querySelector('#example')
);

JSX

// Expressions
const name = 'Josh Perez';
const element = <h1>Hello, {name}</h1>;

// Attributes
const element = <img className="big" src={user.avatarUrl}></img>;

// no children: autoclosing
const element = <img src={user.avatarUrl} />;

// children
const element = (
  <div>
    <h1>Hello!</h1>
    <h2>Good to see you here.</h2>
  </div>
);

Renderelés

Felületi elemek megjelenítése

  • rendering
  • deklaratív
  • egész újrarenderelése
  • virtuális DOM segítségével

React.render()

  • A felület egy részének teljes újrarajzolásáért felelős függvény
  • Virtuális DOM segítségével
  • JSX formátumban
<div id="react-container">
  <!-- Managed by React -->
</div>
const random = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a;

✒>ReactDOM.render<✒(
  <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 50%)` }}>
    React rendered box, random value: {random(1, 100)}
  </div>,
  document.querySelector("#react-container")
);

React kezelte elemek

Az oldal bármely kis/nagy része lehet

<body>
  <h1>My React Sandbox</h1>
  <p>With some static content before</p>
  <div id="react-container">
    <!-- Managed by React -->
  </div>
  <p>With some static content after</p>
  <div id="other-container">
    <!-- Managed by other library -->
  </div>
</body>

Re-render

  • A React.render() egyszer rendereli ki az elemeket
  • Újrarendereléshez újra meg kell hívni.
  • (Vagy másként kikényszeríteni – ld. később)
<div class="wrapper-container">
  <div id="react-container"><!-- Managed by React --></div>
</div>
function render() {
  ✒>ReactDOM.render<✒(
    <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 80%)` }}>
      React rendered box, random value: {random(1, 100)}.
    </div>,
    document.querySelector("#react-container")
  );
}
document.querySelector('.wrapper-container')
  .addEventListener('mouseover', render)
render()

Több oldalrész felügyelete

<div class="wrapper-container">
  <div id="react-container-1"></div>
</div>
<div id="react-container-2"></div>
function render() {
  ✒>ReactDOM.render<✒(
    <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 80%)` }}>
    React rendered box, random value: {random(1, 100)}.
  </div>,
  document.querySelector("#react-container-1")
  );
}
function render2() {
  ✒>ReactDOM.render<✒(
    <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 80%)` }}>
      React rendered box, random value: {random(1, 100)}.
    </div>,
    document.querySelector("#react-container-2")
  )
}
function init() {
  document.querySelector('.wrapper-container')
    .addEventListener('mouseover', ✒>render<✒)
  setInterval(✒>render2<✒, 1000)
  render(); render2()
}
init()

render-ek száma

  • Több
    • az oldal különböző részei
    • függetlenül
    • külön komponensek
    • Web Components
  • Egy
    • általában ez szokott lenni
    • az egész alkalmazás felületét a React felügyeli
    • Single Page Application (SPA)

Komponensek

JSX kifejezés

const colorBox = 
  <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 50%)` }}>
    React rendered box, random value: {random(1, 100)}
  </div>

ReactDOM.render(
  colorBox,
  document.querySelector("#react-container")
);

Függvény?

function ColorBox() {
  return (
    <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 50%)` }}>
      React rendered box, random value: {random(1, 100)}
    </div>
  )
}

ReactDOM.render(
  <>{ColorBox()}</>,
  document.querySelector("#react-container")
);

Komponens!

function ColorBox() {
  return (
    <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 50%)` }}>
      React rendered box, random value: {random(1, 100)}
    </div>
  )
}

ReactDOM.render(
  <ColorBox />,
  document.querySelector("#react-container")
);

Komponens kétféleképpen

// functional component
function ColorBox() {
  return (
    <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 50%)` }}>
      React rendered box, random value: {random(1, 100)}
    </div>
  )
}
// class component
class ColorBox extends React.Component {
  render() {
    return (
      <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 50%)` }}>
        React rendered box, random value: {random(1, 100)}
      </div>
    ) 
  }
}

HTML → komponens



<div class="container">
  <div class="card text-center">
    <div class="card-header">
      Code list
    </div>
    <div class="card-body">
      <form class="form-inline">
        <label for="inlineFormInputName2">Filter</label>
        <input type="text" class="form-control m-2" id="text-filter">
      </form>
      <ul id="code-list" class="list-group">
        <li class="list-group-item">title1</li>
        <li class="list-group-item">title2</li>
      </ul>
    </div>
    <div class="card-footer text-muted">
      3 item(s) listed
    </div>
  </div>
</div>


export function App() {
  return (
    <div ✒>className<✒="container">
      <div className="card text-center">
        <div className="card-header">
          Code list
        </div>
        <div className="card-body">
          <form className="form-inline">
            <label for="inlineFormInputName2">Filter</label>
            ✒><input type="text" className="form-control m-2" id="text-filter" /><✒
          </form>
          <ul id="code-list" className="list-group">
            <li className="list-group-item">title1</li>
            <li className="list-group-item">title2</li>
          </ul>
        </div>
        <div className="card-footer text-muted">
          3 item(s) listed
        </div>
      </div>
    </div>
  )
}

Komponens használata

npm i bootstrap --save
import React from 'react';
import ReactDOM from 'react-dom';

import 'bootstrap/dist/css/bootstrap.css';
import { App } from "./App";

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

Oldal felosztása

  • Komponensek azonosítása
    • funkcionálisan összetartozó
    • újrahasznosítható
    • átlátható
  • Komponensek kompozíciója
    • komponensek egymásba ágyazása
    • ~ HTML

Kompozíció

  • App
    • Card
      • CardHeader
      • FilterForm
      • FilterList
        • FilterListItem
      • CardFooter
export function App() {
  return (
    <div className="container">
      <Card />
    </div>
  )
}
export function Card() {
  return (
    <div className="card text-center">
      <CardHeader />
      <div className="card-body">
        <FilterForm />
        <FilterList />
      </div>
      <CardFooter />
    </div>
  )
}

Végszó

  • Vastagkliensek
  • Egyoldalas alkalmazások
  • Komponensalapú fejlesztés
  • React
  • Kimenet előállítása
    • JSX
    • render
    • komponensek