Spis treści

Prosta aplikacja React + Deno

Dziś krótki post dokumentujący, w jaki sposób uczę się środowiska Deno, języka JavaScript i języka TypeScript oraz biblioteki React: Uczę się nie tak, jak należy (po kolei), tylko robię wszystko na raz. Tak wygląda mini-aplikacja, ghm, ghm, a raczej małe ćwiczenie, którego celem jest użycie tych wszystkich cudowności razem.

/deno-react-dino-green.png

Przetestuj tutaj!

Programik

Wiem, że Deno potrafi “zbundlować” jsx do pliku js, o czym dowiedziałam się podczas opisywania Deno we wpisie deno. Spróbuję - nie posiadając node.js na swoim systemie - zbudować jakąś prostą aplikację w Reakcie używając wyłącznie:

  • Deno
  • nvima
  • przeglądarki

Konfiguracja nvima

Zacznę bardzo nieprofesjonalnie: chcę, żeby typescript i react (jsx) ładnie się kolorował w nvimie. Dlatego po szybkim researchu zaktualizowałam swój .vimrc:

  • dodałam dwa pluginy do kolorowania składni do konfiguracji vundle:
1
2
Plugin 'ianks/vim-tsx'
Plugin 'leafgarland/typescript-vim'
  • dodałam autocommands do ustawiania typu plików na podstawie roszerzenia, ponieważ domyślnie ani pliki .ts nie są rozpoznawane jako typscript, ani .tsx nie są rozpoznawane jako typescriptowe pliki reactowe:
1
2
au BufNewFile,BufRead *.ts setlocal filetype=typescript
au BufNewFile,BufRead *.tsx setlocal filetype=typescript.tsx

Mój pomysł na programik to prosty edytorek do kolorowania orazka svg… na przykład dinozaura.

Dinozaur - iteracja 1

Moje próby narysowania dinozaura okazały się dość krzywe:

Znalazłam jednak piękną stronę svgsilh.com z grafikami svg opublikowanymi na licencji CreativeCommons0 (Public Domain), planuję więc wykorzystać obrazek dinozaura:

Najpierw jednak muszę sprawić, żeby wszystko ładnie “zagrało” - czyli z grubsza chcę, aby Deno zbundlował mi Reacta i moje typescriptowe moduły do jednego pliku .js, który będę mogła dodać do strony html.

Plik html

Zaczynam od pliku html:

  1. Wewnątrz head-a deklaruję użycie modułu app.bundle.js, który Deno zbuduje z wszystkich moich plików .tsx i potrzebnych modułów.
  2. W body tworzę pusty div o id root - to będzie główny element dla Reacta - w tym elemencie DOM-a React wyrenderuje główny komponent.
  3. W body znajduje się też prosty moduł, w którym importuję z app.bundle.js funkcję renderApp, a następnie wywołuję ją podając jako argument element o id root.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
 <!DOCTYPE html>
<html>
<head>
<title>Deno and React </title>
<script type="module" src="app.bundle.js"></script>

<meta charset="utf-8">
</head>
<body>
  <h1>Deno Art</h1>
  <h3 >Let's color a deno </h3>
  <div id="root"></div>
  <script type="module">
    import renderApp from "./app.bundle.js";
    renderApp(root);
  </script>
</body>
</html> 

Główna funkcja renderująca

Funkcja renderApp będzie zdefiniowana w pliku App.ts, a jej zadaniem będzie wyrenderowanie komponentu App wewnątrz przekazanego elementu DOM.

1
2
3
export default function renderApp(rootElem: HTMLElement) {
ReactDOM.render(<App {...appData} />, rootElem);
}

Moje pierwsze typy w TypeScript

Zaczynam od bardzo prostych definicji: typ DenoConfig zawiera dane potrzebne do narysowania obrazka svg. AppData posiada dodatwoko atrybut title. I tak oto powstają moje pierwsze w życiu typy w języku TypeScript :)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type DenoConfig = {
  stroke: string;
  fill: string;
  width: number;
  height: number;
};

type AppData = {
  title: string;
  deno: DenoConfig;
};

Komponent App

Komponent App będzie pozwalał na niezależną zmianę czterech właściwości obiektu Deno ujętych w typie DenoConfig: szerokości i wysokości obrazka SVG oraz koloru krawędzi i wypełnienia. Oto komponent App, który na razie tylko wypisuje przekazane wartości:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const App = (ad: AppData) => {
  return (
    <div className="app">
      <h1>{ad.title}</h1>
        <p>Width:  {ad.deno.width} </p>
        <p>Height:  {ad.deno.height} </p>
        <p>Fill:  {ad.deno.fill} </p>
        <p>Stroke:  {ad.deno.stroke} </p>
          
    </div>
  );
};

Problem: jak zaimportować Reacta

Pierwszy problem do rozwiązania brzmiał: jak zaimportować bibliotekę React do moich modułów? Po wielu różnych podejściach znalazłam stronę https://esm.sh (projekt na GitHubie), na której dowiedziałam się, że stanowi ona ContentDeliveryNetwork dla modułów javascriptowych.

Otóż możliwość użycia Reacta jako modułu została przedstawiona w pierwszym przykładzie na stronie:

1
import React from 'https://esm.sh/react'

Ponieważ chcę się odwoływać do konkretnej wersji biblioteki React oraz używać jej wariantu “deweloperskiego” a nie “produkcyjnego”, zaimportowałam React oraz ReactDOM w następujący sposób:

W pliku deps.ts zaimportowałam obydwie nazwy, a następnie wyeksportowałam je po to, aby w innych modułach import Reacta mógł odbywać się z lokalnego modułu deps.ts (to pozwoli mi na szybszą zmianę wersji oraz wariantu w przyszłości).

Oto mój deps.ts:

1
2
3
import React from "https://esm.sh/react@17.0.2?dev";
import ReactDOM from "https://esm.sh/react-dom@17.0.2?dev";
export { React, ReactDOM };

Makefile - Deno bundluje app.bundle.js

Teraz poproszę Deno, żeby zbundlował wszystko, co konieczne, do jednego pliku, który nazwała app.bundle.js.

Spodziewam się, że zadzieje się jakaś magia, po czym do app.bundle.js trafi React oraz funkcja renderApp, którą eksportuję w app.jsx i chcę zaimportować w skrypcie-module w index.html.

Podaję plik źródłowy (początkowy) od którego będą analizowane zależności oraz nazwę pliku docelowego, mojego “bundla”:

1
deno bundle app.tsx app.bundle.js

Napiszę prosty Makefile, dzięki któremu będę mogła:

  1. szybciej generować app.bundle.js zawsze, gdy zmienię coś w moich modułach (wpisując w wierszu poleceń make)
  2. uruchomić lokalny serwer, który będzie hostował moją “stronkę” z programem

Wygląda on tak:

1
2
3
4
5
.PHONY: all
all: 
	deno bundle app.tsx app.bundle.js
run: all
	deno run --allow-net --allow-read https://deno.land/std/http/file_server.ts

Rezultat

Pierwsze uruchomienie mojej aplikacji renderuje właściwości AppData w komponencie App: title oraz właściwości obiektu deno: width, height, fill oraz stroke:

/deno-react-first.png To pierwszy, ważny “milestone” w moim prototypie. Mam dowód na to, że prototyp działa i robi dokładnie to, czego się spodziewałam. Teraz mogę przejść do dodawania do mojego projektu interaktywności.

Dinozaur - iteracja 2

Spróbuję teraz nie tylko wyświetlać właściwości obiektu DenoConfig, ale użyć ich jako stanu komponentu App. Każda z właściwości będzie później opakowana komponentem Reactowym odpowiedzialnym za aktualizację jednego aspektu obiektu DenoConfig, jego jednej właściwości. W przypadku wysokości i szerokości będzie to właściwość typu number, a w przypadku kolorów - string.

Typ Prop - właściwość DenoConfig

Wymyśliłam więc, że utworzę typ generyczny Prop (property, właściwość obiektu). Będzie on reprezentował model widoku dla pojedynczej właściwości obiektu DenoConfig.

Typ Prop:

  • każda właściwość DenoConfig posiada nazwę - przechowam ją w polu name
  • właściwość posiada wartość typu ValueType (string lub number) - w polu val
  • widok będzie reprezentowany w html-u przez element <input ...\>, którego atrybut type przechowuję w polu inputType
  • onPropUpdated to callback do zmiany stanu (ds) obiektu DenoConfig w komponencie App tak, aby zmieniona została jedynie edytowana wartość
  • opcjonalnym polem będzie range - ta wartość ma sens jedynie dla wartiantu Prop lecz nie dla Prop (na razie zostawiam opcjonalność; myślę, że da się jakoś zdefiniować typ range jako zależny od ValueType: T == 'number' ? Range : never…)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export type InputType = string|number

export interface Prop<T extends InputType> {
  name: string;
  inputType: string;
  val: T;
  onPropUpdated: (val: T) => void;
  range? : Range
}

export interface Range {
  min: number;
  max: number;
  step: number;
}

Stan komponentu App - state hook

Komponent App będzie komponentem stanowym. Jego stanem będzie obiekt DenoConfig z przekazanego property AppData. Używam idiomu “state hook” - czyli funkcji setState, otrzymując listę [property, callback_do_zmiany_property].

Wyobrażam sobie, że komponent App będzie się składał z podkomponentów - edytorów, z których każdy będzie potrafił wyrenderować jedną właściwość obiektu DenoConfig.

1
2
3
4
5
const App = (ad: AppData) => {

const [ds, setDs] = React.useState(ad.deno);
//... edytory
}

Komponent InputElem - edytor dla typu Prop

Czas zdefiniować nowy komponent - edytor dla właściwości Prop. Wyświetli on mały formularz: label z nazwą i pole edycyjne, oraz wartość bieżącą właściwości. Jeśli pole inputType w obiekcie Prop będzie typu number, odczyta również pole range i ustawi atrybuty min, max i step w elemencie <input type='range' ...>.

Komponent ten:

  • jest generyczny - w tym sensie, że przyjmuje jako prop obiekt typu Prop
  • jego stan jest inicjowany na podstawie pola val w obiekcie Prop
  • posiada funkcję update, którą wywołuję w reakcji na zdarzenie onChange
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function InputElem<T extends ValueType>(p: Prop<T>) {
  const [val, setVal] = React.useState(p.val)
  const update = (e:any) => {
  const val = e.target.value
  setVal(val)
  p.onPropUpdated(val)
  }
  return (
    <form className="row g-3">
    <div className="col-2">
      <label className="form-label" htmlFor="input-{p.name}">{p.name}</label>
    </div>
    <div className="col">
      { p.range !== undefined  ?
        <input className="form-control"
          id="input-{p.name}"
          type={p.inputType}
          min={p.range.min}
          max={p.range.max}
          step={p.range.step}
          onChange={update}
          value={val}>
        </input>
      :
        <input
          className="form-control"
          id="input-{p.name}"
          type={p.inputType}
          onChange={update}
          value={val}>
        </input>
      }
    </div>
    <div className="col">
      {val}
    </div>
  </form>
  )
}

Niestety, nie poradziłam sobie z definicją typu zdarzenia onChange w elemencie <input .../>; gdy deklarowałam e jako React.ChangeEventHandler<HTMLInputElement>, wówczas otrzymywałam komunikat : 2339: Property 'target' does not exist on type 'ChangeEventHandler<HTMLInputElement>'.

/deno-react-event.png Ostatecznie poddałam się i zostałam z e: any.

Użycie komponentu InputElem

Teraz użyję komponentu InputElem w komponencie App czterokrotnie wyświetlając cztery właściwości z obiektu AppData.deno. Tak naprawdę do komponentów InputElem, które są komponentami-dziećmi - muszę przekazać stan (ds) otrzymany z hooka useState, a jako callback przekazuję fukcję, która wywoła funkcję zmiany stanu (setDs) otrzymaną również z hooka useState.

Jak teraz wygląda mój komponent App?

Oto on:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const App = (ad: AppData) => {
  const [ds, setDs] = React.useState(ad.deno);

  return (
    <div className="app">
      <h1>{ad.title}</h1>
      <div className="row">
        <div className="col">
          <InputElem 
              name="width"
              inputType= "range"
              val= {ds.width}
              range={{min: 1, max: 500, step: 10}}
              onPropUpdated={(nv) => setDs({...ds, width: nv})} />
            <InputElem  
              name="height"
              inputType= "range"
              val= {ds.height}
              range={{min: 1, max: 500, step: 10}}
              onPropUpdated={(nv) => setDs({...ds, height: nv})} />

            <InputElem
              name="Fill" 
              inputType="color" 
              val={ds.fill} 
              onPropUpdated={(nv) => setDs({...ds, fill: nv})} />
            <InputElem 
              name="Stroke" 
              inputType="color" 
              val={ds.stroke} 
              onPropUpdated={(nv) => setDs({...ds, stroke: nv})} />
        </div>
         <div className="col">
          <p>{ds.width}</p>
          <p>{ds.height}</p>
          <p>{ds.fill}</p>
          <p>{ds.stroke}</p>
        </div>
      </div>
    </div>
  );
};

Teraz wystarczy kolejny raz zawołać:

1
make

i w rezultacie otrzymuję reagujący na zmiany edytor. /deno-react-editors.png

Narysuj dinozaura!

A co z tym dinozaurem?

Dinozaur przypomina trochę prostokąt…

Zaczynam od narysowania prostokąta w SVG - rysowany prostokąt powinien być komponentem Reactowym i reagować na zmiany właściwości.Tworzę więc plik rect.tsx, importuję do niego Reacta i zmieniam wartości width, height, fill i stroke tak, aby nie były literałami, lecz wartościami przekazanymi do komponentu.

W międzyczasie mały refaktor: przerzucam m.in. typ DenoConfig do pliku types.ts, więc w rect.rs muszę ten typ zaimportować:

Oto rect.tsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { React } from "./deps.ts";
import  {DenoConfig } from "./types.ts";

export const Rect = (dc: DenoConfig) => {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      xmlnsXlink="http://www.w3.org/1999/xlink"
      viewBox="0 0 500 500"
    >
      <rect
        x="10"
        y="10"
        height={dc.height}
        width={dc.width}
        style={{ fill: dc.fill, stroke: dc.stroke }}
      />
    </svg>
  );
};

I jeszcze zmieniam app.tsx - zamiast wypisywać wszystkie właściwości, używam nowego komponentu Rect:

1
2
3
    <div className="col">
      <Rect {...ds}/>
    </div>

I już:

/deno-react-rect.png

Komponent Deno

Komponent do rysowania dinozaura jest dość podobny do komponentu prostokąta, szybko wklejam svg i zmieniam w miejscach, w których jest odwołanie do edytowanych właściwości:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { React  } from "./deps.ts";
import  {DenoConfig } from "./types.ts";

export const Deno = (dc: DenoConfig) => {
  return (
    <svg version="1.0" xmlns="http://www.w3.org/2000/svg"
     width={dc.width}height={dc.height} viewBox="0 0 1280.000000 921.000000"
     preserveAspectRatio="xMidYMid meet">
    <metadata>
    Created by potrace 1.15, written by Peter Selinger 2001-2017
    </metadata>
      <g 
        transform="translate(0.000000,921.000000) scale(0.100000,-0.100000)"
    fill={dc.fill} stroke={dc.stroke} stroke-width="100">
    <path d="M1081 9195 c-96 -22 -158 -43 -244 -86 -105 -52 -151 -85 -220 -158
    -68 -72 -103 -142 -112 -231 -14 -125 -52 -161 -232 -220 -199 -65 -232 -93
    -243 -213 -4 -40 -13 -100 -20 -135 -15 -79 -7 -137 29 -194 37 -57 109 -105
    265 -173 137 -59 178 -83 245 -139 102 -85 162 -98 315 -67 121 25 149 20 176
    -34 19 -38 19 -391 -1 -860 -16 -402 -17 -1103 -1 -1290 57 -652 146 -973 401
    -1439 105 -192 151 -373 151 -589 0 -71 -14 -248 -35 -431 -52 -467 -64 -618
    -75 -939 -6 -160 -13 -305 -16 -322 -11 -52 -33 -85 -122 -179 -161 -172 -273
    -338 -312 -465 -58 -185 13 -309 220 -386 71 -27 86 -28 295 -34 252 -7 258
    -8 295 -88 30 -65 65 -110 99 -128 16 -8 66 -17 112 -20 46 -4 179 -20 294
    -36 427 -61 613 -29 781 136 151 148 195 347 174 775 -15 287 -9 522 14 605 9
    32 19 61 21 63 3 3 28 -13 57 -36 290 -221 689 -352 1075 -352 102 0 311 23
    398 45 91 22 85 28 85 -92 0 -128 -15 -169 -81 -227 -219 -192 -341 -371 -356
    -521 -10 -98 32 -138 252 -234 66 -29 147 -72 180 -96 97 -69 121 -75 315 -87
    420 -24 704 34 859 178 67 62 99 111 133 204 23 64 23 69 20 432 -2 203 0 368
    3 368 4 0 99 -94 213 -208 219 -220 357 -338 537 -456 268 -176 439 -247 890
    -366 870 -230 1250 -308 1672 -340 76 -6 282 -24 458 -40 757 -69 1023 -84
    1505 -84 402 -1 536 6 745 40 265 42 438 123 486 229 94 207 -195 350 -850
    420 -292 31 -562 47 -1091 65 -408 14 -662 28 -735 41 -73 13 -532 118 -699
    160 -545 137 -904 278 -1206 474 -311 202 -536 471 -735 880 -82 169 -142 320
    -255 645 -275 791 -529 1287 -853 1668 -492 577 -1084 830 -1832 783 -387 -25
    -714 -94 -1280 -272 -277 -87 -458 -56 -634 107 -158 146 -276 386 -366 744
    -121 482 -180 916 -205 1520 -28 687 -39 804 -89 1002 -90 355 -263 560 -566
    665 -88 31 -214 39 -304 18z"/>
    </g>
</svg>
  );
};

Oryginalny svg miał ustawiony atrybut preserveAspectRatio (sensownie zresztą), dlatego manipulowanie wysokością i szerokością jest trochę mało intuicyjne - dinozaur próbuje się “zmieścić” wewnątrz prostokąta o wyznaczonych wymiarach (stylizuję więc komponent odrobinę - dodaję styl border).

/deno-react-dino.png

Zielony dinozaur

Można sobie teraz pokolorować dinozaura na zielono :) /deno-react-dino-green.png

Podsumowanie

Po pierwsze: udało mi się napisać mały program w TypeScript bez instalowania Webpacka, Babela, TypeScripta, nodejsa i-co-tam-jeszcze-byłoby-potrzebne (Alleluja!) i bez zaśmiecania katalgu projektu modułami.

Po drugie: użycie jednej binarki do zbundlowania kodu, formatowania źródeł czy uruchamiania testów jest po prostu niezwykle estetyczne. Lubię taki minimalizm. Nie muszę nic dodatkowo instalować, nie muszę uczyć się nowych narzędzi, zaśmiecać systemu pakietami, których przeznaczenia mogę się jedynie domyślać.

Mogę skupić się na moim głównym celu - którym jest uczenie się TypeScripta/JavaScripta. Mam skrzynkę z narzędziami i mogę zacząć ich używać. Nie muszę najpierw budować warsztatu (analogia trochę nie na miejscu, bo w dziedzinie programowania “warsztat” akurat zawsze się przydaje).

Jestem zdecydowanie pozytywnie zmotywowana, żeby bawić się TypeScriptem dalej. Oczywiście, w środowisku Deno :)

Może Cię również zainteresować…

Jeśli zastanawiasz się, czym jest Deno, zapraszam Cię do przeczytania wpisu: Deno - czym jest i co potrafi

Źródła

Źródła tego projekciku znajdują się w repozytorium deno-react-example na GitHubie.