Píšeme vlastní React

Způsob, jakým nám React umožňuje psát deklarativní uživatelské rozhraní, které je následně efektivně vykreslováno, je přinejmenším úžasný. Většina vývojářů pravděpodobně hrubou představu o tom, jak algoritmus hledání změn virtuálního DOMu a samotný rendering funguje, má.

Zkoušeli jste se ale někdy detailně zamyslet nad tím, jak celý systém funguje?

Já jsem se o to pokusil. A výsledkem mého snažení je menší zjednodušená implementace vlastního (naivního) Reactu. Tato implementace samozřejmě nenabízí to, co samotný React, ale jako demonstrace postačí. Dovolte mi, abych vám prostřednictvím následujících řádků tuto svou implementaci stručně představil.

Než začneme, rád bych zmínil, že se budeme věnovat pouze bezstavovým komponentám, které nemají vlastní instance. Sama se tedy nabízí kombinace s nějakým stavovým kontejnerem, např. Reduxem.

Seznamte se s Virtuálním DOMem

Myšlenka samotného Reactu, pakliže se odprostíme od vnitřního stavu komponent, je velmi jednoduchá. Při každém vykreslení se nejprve vytvoří reprezentace virtuálního DOMu (vDOM), což není nic víc než velmi košatý strom reflektující strukturu reálného DOMu. Každý list této struktury obsahuje tři základní informace:

  1. Typ DOM uzlu – můžeme si zjednodušeně představit jako řetězec identifikující DOM Element, např. divnebo span.
  2. Objekt držící props – v našem případě props odpovídají HTML atributům a event handlerům.
  3. Pole potomků – obecně může být obsaženo již v objektu props.
{
  type: 'ul',
  props: { className: 'ul-wrapper' },
  children: [
    {
      type: 'li',
      props: { className: 'active' },
      children: ['First Item']
    },
    {
      type: 'li',
      props: { onClick: () => console.log('Hello, I have just clicked the second item') },
      children: ['Second Item']
    }
  ]
}

Ne náhodou se stromová reprezentace vDOMu podobá samotné DOM reprezentaci. Důvod je prostý, React nejdříve zjístí změny ve struktuře vDOMu, tyhle změny převede na seznam záplat a tyto záplaty následně aplikuje na realný DOM. Reprezentace vDOMu musí korespondovat s reprezentaci DOMu, abychom následně mohli veškeré změny pohodlně promítnout.

Bezstavové komponenty jako čisté funkce

React od verze 0.14.0 podporuje bezstavové komponenty ve formě čistých funkcí. Použití takovéto komponenty je velmi jednoduché.

const WelcomeComponent = ({ name }) =>
  <div>Welcome {name}!</div>;

const RootComponent = ({ user }) => {
  if (user) {
    return <WelcomeComponent name={user.name} />;
  } else {
    return <div>Please, Log in</div>;
  }
}

Jelikož komponenta je v našem případě funkce, získáváme veškeré výhody a vlastnosti s funkcemi spojené, např. velmi jednoduchou kompozici.

const welcome = name => `Welcome ${name}!`;

const root = user => {
  if (user) {
    // Kompozici funkcí zde rozumíme pouhé volání funkce uvnitř jiné funkce
    return welcome(user.name);
  } else {
    return 'Please, Log in';
  }
}

Teď si představme, že naše bezstavová komponenta bude vracet vDOM, tj. stromovou strukturu, která v sobě nese informaci o tom, jak má realný DOM vypadat.

const WelcomeComponent = ({ name }) => ({
  type: 'div',
  props: {},
  children: [`Welcome ${name}`]
});

Nechat uživatele takto psát každou komponentu je velmi upovídané a náchylné k chybám. Proto si vytvoříme funkci, která nám vrátí vDOM uzel. Tuto funkci pojmenujeme h, a to z důvodu zřejmé podobnosti s knihovnou hyperscript. Funkce h bude vracet pouze uzel virtuálního DOMu.

const h = (type, props = {}, children = []) => ({
  type,
  props,
  children
});

const WelcomeComponent = ({ name }) => h('div', {}, [`Welcome ${name}`]);

const RootComponent = ({ user }) => {
  if (user) {
    // Typ vDOM uzlu může očividně být i jiná komponenta.
    // React se při vytváření celého stromu postará o její zavolání.
    return h(WelcomeComponent, { name: user.name });
  } else {
    return h('div', {}, [`Please, Log in`]);
  }
}

Nicméně, mít v celé aplikaci pouze jednu komponentu není zrovna užitečné. Zde přichází ke slovu již zmíněná jednoduchá kompozice. React do položky type umožňuje dosadit String nebo jinou komponentu (Function). Uživateli tak je umožněno velmi jednoduše vytvářet deklarativní UI za pomocí funkce h. React však jde ještě dál a přináší syntaktický cukr v podobě JSX, který odstraňuje upovídanost řešení v čistém JavaScriptu.

V článku jsem již zmínil dva důležité body:

  • Při každém vykreslení se nejprve vytvoří reprezentace vDOMu.
  • Reprezentace vDOMu musí korespondovat s reprezentací reálného DOMu.

Kdybychom přímo zavolali naši funkci RootComponent, druhý bod bychom porušili, jelikož bychom vrátili objekt, který by ve svém typu neobsahoval DOM Element, ale komponentu.

{
  type: WelcomeComponent, // Toto je Funkce (komponenta), nikoliv String (DOM Element).
  props: { user: { name: 'Tomas Weiss' }},
  children: []
}

Náš vDOM by ovšem měl mít podobu, která koresponduje s reálným DOMem, např.:

{
  type: 'div',
  props: {},
  children: ['Welcome Tomas Weiss!']
}

A přesně tady přichází na řadu bod druhý, tedy vytvoření vDOMu. Tím máme na mysli rekurzivní průchod celého stromu komponent a samotné zavolání funkce s dostupnými props, dokud se celý strom netransformuje na čistý strom obsahující pouze DOM elementy.

// Pro jednoduchou korelaci vDOMu a DOMu si vygenerujeme unikátní ID
// pro každý uzel vDOMu, budou to prakticky pouze tečkou oddělené
// indexy potomka tak, abychom mohli později použít jednoduchou manipulaci
// s řetězci pro případné pokročilé funkce (např. Event System).
export const createVDOM = (element, id = '.') => {

  // Tato funkce se musí volat rekurzivně nad všemi potomky, abychom transformovali
  // celý strom, který v sobě potenciálně může zanořovat další komponenty.
  const newElement = {
    ...element,
    id,
    children: element.children.map((child, index) => createVDOM(child, `${id}${index}.`))
  };

  // Jedná se o komponentu?
  if (typeof element.type === 'function') {
    // Zavoláním komponenty a předáním props
    // získáme podstrom, který komponenta vytvořila.
    const subtree = newElement.type(element.props);

    // Nad výsledkem je opět nutné zavolat naši funkci, abychom
    // správně přiřadili ID uzlu, případně dále zpracovali veškeré 
    // komponenty, které se v tomto podstromu nachází.
    return createVDOM(subtree, id);
  } else {
    // Pakliže narazíme na element, který není funkce, je ho možno 
    // přímo vrátit, protože se jedná o DOM Element, nikoliv komponentu.
    return newElement;
  }
};

Koncepty FP pro optimalizace

Komponenty jako čisté bezstavové funkce v Reactu mají jednu přirozenou vlasnost: jsou referenčně transparentní. Proto se dají nahradit hodnotou, kterou vrací. Tohoto faktu můžeme využít k velmi zajímavé optimalizaci, a to memoizaci samotných komponent. Ukážeme si to na příkladu:

// High Order Component? High Order Function? :-)
const memoize = component => {
  
  // Je zapotřebí si uložit poslední
  // stav props a výsledku volání komponenty,
  // při prvním volání je očividně vše prázdné.
  let lastProps = null;
  let lastResult = null;

  // Vrátíme novou komponentu, která
  // v sobě již tuto optimalizaci zahrnuje
  return props => {

    // Pakliže se props změnily od posledního volání funkce,
    // komponentu s novými props zavoláme a uložíme výsledek.
    if (!shallowEqual(props, lastProps)) {
      lastResult = component(props);
      lastProps = props;

      // Do výsledku si uložíme informaci o tom, zda se jedná o cachovanou
      // hodnotu, nebo ne. Toho později využijeme při vytváření vDOMu.
      lastResult.memoized = true;
    } else {
      lastResult.memoized = false;
    }

    // Nakonec vrátíme cachovanou hodnotu - toto je referenční transparentnost 
    // v praxi, jelikož můžeme samotné volání komponenty nahradit její hodnotou.
    return lastResult;
  }
}

Nyní můžeme předpokládat, že veškeré naše kompenty jsou zároveň memoizovány, a tedy podstrom, který vrací, obsahuje informaci, zda se jedná o výsledek z cache, nebo ne. Tohoto faktu můžeme využit již při vytváření vDOMu. Upravíme si proto funkci createVDOM tak, aby zastavila rekurzi, pokud narazí na takovýto podstrom, protože je zřejmé, že tento podstrom je naprosto identický jako v předchozím volání, jelikož veškerá kompozice komponent je vlastně kompozice referenčně transparentních funkcí, a lze jí tedy nahradit samotnou hodnotou.

export const createVDOM = (element, id = '.') => {
  const newElement = {
    ...element,
    id,
    children: element.children.map((child, index) => createVDOM(child, `${id}${index}.`))
  };

  if (typeof element.type === 'function') {
    const subtree = newElement.type(element.props);

    // Jesliže narazíme na cachovaný podstrom,
    // není již třeba pokračovat v rekurzi.
    if (subtree.memoized) {
      return subtree;
    } else {
      return createVDOM(subtree, id);
    }
  } else {
    return newElement;
  }
};

Algoritmus zjišťování změn vDOMu

Již víme, jak za pomocí komponent vytvořit vDOM. Zkusme si tedy nyní vytvořit vDOM stromy dva.

// Vytvoříme komponentu, která může zobrazit
// text v divu nebo spanu
const RootComponent = ({ showDiv }) => {
  if (showDiv) {
    return h('div', {}, ['Hello World']);
  } else {
    return h('span', {}, ['Hello World']);
  }
}

// Vytvoříme si jeden vDOM strom, který zobrazuje div
const leftVDOM = createVDOM(h(RootComponent, { showDiv: true }));

// A druhý který zobrazuje span
const rightVDOM = createVDOM(h(RootComponent, { showDiv: false }));

const diff = (left, right, patches) => {
  // Funkce která porovná dva stromy
  // a naplní seznam záplat, které transformují levý
  // strom na strom pravý
}

const patches = [];
diff(leftVDOM, rightVDOM, patches);

Hledání veškerých záplat potřebných k transformaci levého stromu na strom pravý je netriviální problém se složitostí O(n^3). Reálný algoritmus Reactu ale celý postup velmi zjednodušuje. Předpoklad je takový, že dvě stejné komponenty vygenerují podobný DOM podstrom, naopak dvě rozdílné komponenty vygenerují DOM podstrom rozdílný. Jinými slovy, vyplatí se přerušit rekurzi pro zjištění změn v případě, kdy se typ komponenty změní, a celý tento podstrom vytvořit znovu, namísto výpočetně náročného hledání každé drobné změny v tomto podstromu.

V naší ukázkové implementaci si pouze ukážeme, jak se toto promítne do implementace odebrání uzlu, resp. jeho přidání a nahrazení. V reálné implementaci by samozřejmě bylo zapotřebí zjišťovat i změny argumentů uzlů atp.

const diff = (
  left,
  right,
  patches,
  parent = null
) => {
  // Pakliže na levé straně (tj. současný vDOM) uzel neexistuje, 
  // bude jej potřeba vytvořit, již není potřeba pokračovat v rekurzi,
  // protože vytvoření samotného uzlu je rekurzivní.
  if (!left) {
    patches.push({
      parent, // Je potřeba předat předka tohoto uzlu z levé strany
              // k určení, pro který uzel bude nově vytvořený uzel potomkem.
      type: PatchTypes.PATCH_CREATE_NODE,
      node: right // A samozřejmě je potřeba předat nově vytvářený uzel.
    });
  } else if (!right) {
    // Pakliže na pravé straně (tj. nový vDOM) uzel neexistuje, 
    // znamená to, že bude potřeba uzel odstranit z vDOMu.
    patches.push({
      type: PatchTypes.PATCH_REMOVE_NODE,
      node: left // Jediný potřebný argument odstranění uzlu je informace, který uzel se má odebrat.
    });
  } else if (left.type !== right.type) {
    // Zde dochází k změně typu, opět si všimněte zastavení rekurze,
    // což je důležitý předpoklad rychlosti algoritmu.
    patches.push({
      type: PatchTypes.PATCH_REPLACE_NODE,
      replacingNode: left,
      node: right
    });
  } else if (right.memoized) {
    // Zde opět přichází na scénu skvělá a jednoduchá výkonová
    // optimalizace. Pakliže víme, že se jedná o memoizovaný uzel, není
    // třeba si dělat starosti průchodem zbytku podstromu, jelikož
    // si můžeme být jistí, že k žádné změně vDOMu nedošlo.
    return;
  } else {

    // Nyní chceme iterovat přes všechny potomky levé nebo 
    // pravé strany, a následně volat tuto funkci rekurzivně.
    const children = left.children.length >= right.children.length ?
      left.children :
      right.children;

    children.forEach((child, index) => diff(
      left.children[index],
      right.children[index],
      patches,
      left
    ));
  }
};

Aplikujeme změny – DOM renderer

Již víme, jak vytvořit vDOM a jak najít změny mezi dvěma stromy vDOMu. Chybí nám už jen poslední část. Tou je samotné promítnutí změn do reálného DOMu. Tady jsme si velmi dobře připravili půdu tím, že každý vDOM uzel jsme schopni identifikovat dle unikátního ID. Budeme potřebovat naimplementovat funkci applyPatch, která bude mít za cíl záplatu reflektovat do DOMu. Tato funkce by jednoznačně byla součástí balíčku react-dom, nikoliv react, jelikož se jedná o DOM specifický kód. Implementaci bychom mohli kdykoliv změnit např. pro server rendering nebo jako plátno použít třeba OpenGL kontext.

const ID_KEY = 'data-react-id';

// Korelace mezi vDOMNode a DOMNode je velmi jednoduchá,
// postačí nám využít ID, které již v vDOMNode uložené máme,
// a toto ID uložit jako HTML atribut při vytváření DOM node.
const correlateVDOMNode = (vdomNode, domRoot) => {
  if (vdomNode === null) {
    return domRoot;
  } else {
    return document.querySelector(`[${ID_KEY}="${vdomNode.id}"]`);
  }
}

// Vytvoření DOM node na základě vDOMNode je rekurzivní operace,
// jak již bylo zmíněno v kapitole o hledání změn mezi jednotlivými stromy.
const createNodeRecursive = (vdomNode, domNode) => {
  // Vytvoříme DOM Element na základě typu.
  const domElement = document.createElement(vdomNode.type);
  // Nastavíme ID atribut, abychom mohli později korelovat vDOM a DOM.
  domElement.setAttribute(ID_KEY, vdomNode.id);
  // A nakonec DOM Element přidáme do DOMu.
  domNode.appendChild(domElement);

  // Provedeme rekurzi, kde jako domNode nastavujeme nově vytvořený DOM Element.
  vdomNode.children.forEach(child =>
    createNodeRecursive(child, domElement));
};

const applyPatch = (patch, domRoot) => {
  switch (patch.type) {
    case PatchTypes.PATCH_CREATE_NODE: {
      // Nejdříve nalezneme DOM node, který koresponduje 
      // s uzlem vDOM, do kterého DOM node chceme vkládat.
      const domNode = correlateVDOMNode(patch.parent, domRoot);

      // Uzel rekurzivně v DOMu vytvoříme
      createNodeRecursive(patch.node, domNode);
    }
      break;

    case PatchTypes.PATCH_REMOVE_NODE: {
      // Nalezneme DOM node, který koresponduje s uzlem vDOM, který chceme odebrat
      const domNode = correlateVDOMNode(patch.node, domRoot);

      // Poté velmi jednoduše odebereme z jeho potomka samotný uzel.
      domNode.parentNode.removeChild(domNode);
    }
      break;

    case PatchTypes.PATCH_REPLACE_NODE: {
      // Opět nalezneme DOM node, který se snažíme přepsat.
      const domNode = correlateVDOMNode(patch.replacingNode, domRoot);

      // Z jeho potomka samotný DOM node odstraníme.
      const parentDomNode = domNode.parentNode;
      parentDomNode.removeChild(domNode);

      // A opět uzel rekurzivně vytvoříme.
      createNodeRecursive(patch.node, parentDomNode);
    }
      break;

    default:
      throw new Error(`Missing implementation for patch ${patch.type}`);
  }
};

Funkce render

Nyní máme vše potřebné k tomu, abychom vytvořili finální funkci, která nám zobrazí samotný DOM a je schopná dalších inkrementálních změn při změnách vDOMu. Myšlenka je velmi jednoduchá: funkce si musí zapamatovat předchozí stav vDOMu, a poté se snažít zjistit veškeré změny, které vzniknou aplikovaním vDOMu nově vytvořeného. Tyto změny poté stačí jednoduše aplikovat jako seznam záplat na reálný DOM.

export const createRender = domElement => {

  let lastVDOM = null;
  let patches = null;

  return element => {
    // Nejdříve vytvoříme novou reprezentaci vDOMu.
    const vdom = createVDOM(element);

    // Následuje získání seznamu záplat porovnáním
    // předchozího stavu se stavem nově vytvořeným.
    patches = [];
    diff(lastVDOM, vdom, patches);

    // Každou záplatu jednu po druhé v daném pořadí
    // za pomocí rendereru postupně aplikujeme.
    patches.forEach(patch => applyPatch(patch, domElement));

    // Nakonec si uložíme poslední stav vDOMu, abychom 
    // při dalším volání mohli zjistit, co se změnilo.
    lastVDOM = vdom;
  };
};

// Jednoduchá demonstrace, která nám v daném intervalu renderuje rozdílný DOM.
const render = createRender(document.getElementById('app'));

let loggedIn = false;
setInterval(() => {
  loggedIn = !loggedIn;

  render(h(RootComponent, {
    user: loggedIn ? { name: 'Tomas Weiss' } : null
  }));
}, 1000);

Jak sami vidíte, myšlenky, které za Reactem stojí, jsou založeny na jednoduchých předpokladech a principech funkcionálního programování. Samotná (naivní) implementace není až tak složitá. React toho ale v reálu nabízí mnohem více než samotný rendering. Pokud vás myšlenka jednoduchého Reactu zaujala, rozhodně doporučuji sledovat projekt virtual-dom, který dané postupy implementuje v produkční kvalitě. V dalším článku si ukážeme, jak implementovat velmi chytrý Event Delegation dle Reactu, a podíváme se na realné dopady na výkon.

Tento článek by nemohl vzniknout bez excelentního článku od Christophera Chedeau @vjeux, který mi byl inspirací pro samotnou implementaci.

Salsita Software

Salsita Software je softwarová společnost, která se specializuje na vývoj komplexních moderních webových a mobilních aplikací. Sponzorujeme JavaScripting.com, komunitní portál, který pomáhá vývojářům hledat knihovny a frameworky pro JavaScript.

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 12

Přehled komentářů

vojta tranta ID prvku vypisovaného v poli a nespolehlivé memoize
Diskobolos Proč ne čistý JS?
fos4 Re: Proč ne čistý JS?
dominik-selmeci Re: Proč ne čistý JS?
Jarda Re: Proč ne čistý JS?
Hmmm Re: Proč ne čistý JS?
Jarda Re: Proč ne čistý JS?
vojta tranta Re: Proč ne čistý JS?
dominik-selmeci Re: Proč ne čistý JS?
Martin Hassman Re: Proč ne čistý JS?
dominik-selmeci Re: Proč ne čistý JS?
Tomáš Randus
Zdroj: https://www.zdrojak.cz/?p=19256