DOM:in manipulointi Refillä

React automaattisesti päivittää DOM:in vastaamaan renderöinnin lopputulosta, joten sitä ei usein tarvitse manipuloida. Kuitenkin joskus saatat tarvita pääsyn Reactin hallinnoimiin DOM elementteihin—esimerkiksi, kohdentaaksesi elementtiin, scrollata siihen, tai mitata sen kokoa ja sijaintia. Reactissa ei ole sisäänrakennettua tapaa tehdä näitä asioita, joten tarvitset viittauksen eli refin DOM noodiin.

Tulet oppimaan

  • Miten päästä käsiksi Reactin hallinnoimaan DOM noodiin ref attribuutilla
  • Miten ref attribuutti liittyy useRef Hookkiin
  • Miten päästä käsiksi toisen komponentin DOM noodiin
  • Missä tapauksissa on turvallista muokata Reactin hallinnoimaa DOM:ia

Refin saaminen noodille

Päästäksesi käsiksi Reactin hallinnoimaan DOM noodiin, ensiksi, tuo useRef Hookki:

import { useRef } from 'react';

Sitten, käytä sitä määrittääksesi ref komponentissasi:

const myRef = useRef(null);

Lopuksi, välitä se ref -attribuuttina JSX-tagille, jonka DOM-elementin haluat saada:

<div ref={myRef}>

useRef Hookki palauttaa olion yhdellä current propertyllä. Aluksi, myRef.current on null. Kun React luo DOM noodin tästä <div>:stä, React asettaa viitteen tähän noodiin myRef.current:iin. Voit sitten päästä käsiksi tähän DOM noodiin tapahtumakäsittelijästäsi ja käyttää sisäänrakennettuja selaimen rajapintoja jotka siihen on määritelty.

// Voit käyttää mitä tahansa selaimen rajapintoja, esimerkiksi:
myRef.current.scrollIntoView();

Esimerkki: Tekstikentän kohdentaminen

Tässä esimerkissä, tekstikenttä kohdentuu klikkaamalla nappia:

import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

Toteuttaaksesi tämän:

  1. Määritä inputRef useRef Hookilla.
  2. Välitä se seuraavasti <input ref={inputRef}>. Tämä pyytää Reactia asettamaan tämän <input>:n DOM noodin inputRef.current:iin.
  3. handleClick tapahtumakäsittelijässä, lue tekstikentän DOM noodi inputRef.current:sta ja kutsu sen focus() funktiota, inputRef.current.focus():lla.
  4. Välitä handleClick tapahtumakäsittelijä <button>:lle onClick attribuutilla.

Vaikka DOM manipulaatio on yleisin käyttötapaus refseille, useRef Hookia voidaan myös käyttää tallentamaan Reactin ulkopuolella olevia asioita, kuten ajastimien ID:tä. Juuri kuten tila, refit pysyvät renderöintien välillä. Refit ovat tilamuuttujia, jotka eivät aiheuta uudelleenrenderöintiä, kun niitä asetetaan. Lue refien esittely: Viittausten käyttö Refseillä.

Esimerkki: Scrollaaminen elementtiin

Sinulla voi olla enemmän kuin yksi ref komponentissa. Tässä esimerkissä on karuselli kolmesta kuvasta. Jokainen nappi keskittää kuvan kutsumalla vastaavan DOM noodin scrollIntoView() metodia vastaavalla DOM noodilla:

import { useRef } from 'react';

export default function CatFriends() {
  const firstCatRef = useRef(null);
  const secondCatRef = useRef(null);
  const thirdCatRef = useRef(null);

  function handleScrollToFirstCat() {
    firstCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToSecondCat() {
    secondCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToThirdCat() {
    thirdCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  return (
    <>
      <nav>
        <button onClick={handleScrollToFirstCat}>
          Tom
        </button>
        <button onClick={handleScrollToSecondCat}>
          Maru
        </button>
        <button onClick={handleScrollToThirdCat}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          <li>
            <img
              src="https://placekitten.com/g/200/200"
              alt="Tom"
              ref={firstCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/300/200"
              alt="Maru"
              ref={secondCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/250/200"
              alt="Jellylorum"
              ref={thirdCatRef}
            />
          </li>
        </ul>
      </div>
    </>
  );
}

Syväsukellus

Miten hallita listaa refseistä ref-callbackin avulla

Yllä olevissa esimerkeissä on määritelty valmiiksi refsejä. Joskus kuitenkin tarvitset refin jokaiseen listan kohteeseen, ja et tiedä kuinka monta niitä on. Seuraavanlainen koodi ei toimi:

<ul>
{items.map((item) => {
// Ei toimi!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>

Tämä tapahtuu koska Hookit on kutsuttava vain komponentin ylimmällä tasolla. Et voi kutsua useRef:ia silmukassa, ehtolauseessa tai map() kutsussa.

Yksi mahdollinen tapa ratkaista tämä on hakea yksi ref ylemmälle elementille, ja käyttää sitten DOM manipulaatiomenetelmiä kuten querySelectorAll löytääksesi yksittäiset lapsinoodit. Tämä on kuitenkin herkkä ja voi rikkoutua, jos DOM-rakenne muuttuu.

Toinen mahdollinen ratkaisu on välittää funktio ref attribuuttiin. Tätä kutsutaan ref callbackiksi. React kutsuu ref-callbackkia DOM noodilla kun on aika asettaa ref, ja null:lla kun se on aika tyhjentää se. Tämä mahdollistaa omien taulukoiden tai Map:n ylläpidon, ja mahdollistaa refin hakemisen indeksin tai jonkinlaisen ID:n perusteella.

Tämä esimerkki näyttää miten voit käyttää tätä menetelmää scrollataksesi mihin tahansa kohtaan pitkässä listassa:

import { useRef } from 'react';

export default function CatFriends() {
  const itemsRef = useRef(null);

  function scrollToId(itemId) {
    const map = getMap();
    const node = map.get(itemId);
    node.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function getMap() {
    if (!itemsRef.current) {
      // Alusta Map ensimmäisellä kerralla.
      itemsRef.current = new Map();
    }
    return itemsRef.current;
  }

  return (
    <>
      <nav>
        <button onClick={() => scrollToId(0)}>
          Tom
        </button>
        <button onClick={() => scrollToId(5)}>
          Maru
        </button>
        <button onClick={() => scrollToId(9)}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          {catList.map(cat => (
            <li
              key={cat.id}
              ref={(node) => {
                const map = getMap();
                if (node) {
                  map.set(cat.id, node);
                } else {
                  map.delete(cat.id);
                }
              }}
            >
              <img
                src={cat.imageUrl}
                alt={'Cat #' + cat.id}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

const catList = [];
for (let i = 0; i < 10; i++) {
  catList.push({
    id: i,
    imageUrl: 'https://placekitten.com/250/200?image=' + i
  });
}

Tässä esimerkissä itemsRef ei sisällä yhtäkään DOM noodia. Sen sijaan se sisältää Map:n, jossa on jokaisen kohteen ID ja DOM noodi. (Refseissä voi olla mitä tahansa arvoja!) Jokaisen listan kohteen ref callback huolehtii siitä, että Map päivitetään:

<li
key={cat.id}
ref={node => {
const map = getMap();
if (node) {
// Lisää Map:iin
map.set(cat.id, node);
} else {
// Poista Map:sta
map.delete(cat.id);
}
}}
>

Tämän avulla voit lukea yksittäiset DOM noodit Map:sta myöhemmin.

Pääsy toisen komponentin DOM-noodiin

Kun asetat refin sisäänrakennettuun komponenttiin, joka tuottaa selaimen elementin kuten <input />:n, React asettaa refin current propertyn vastaamaan DOM noodia (kuten todellista <input />:ia selaimessa).

Kuitenkin, jos yrität asettaa refin omalle komponentillesi, kuten <MyInput />, oletuksena saat null:n. Näet sen tässä esimerkissä. Huomaa miten painikkeen painaminen ei keskitä inputia:

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

Helpottaaksesi ongelman havaitsemista, React tulostaa myös virheen konsoliin:

Konsoli
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

Tämä tapahtuu, koska oletuksena React ei anna komponenttien päästä muiden komponenttien DOM noodeihin käsiksi. Ei edes omille lapsille! Tämä on tarkoituksellista. Refit ovat pelastusluukku, jota pitäisi käyttää niukasti. Toisen komponentin DOM noodin käsin manipulaatio tekee koodistasi vieläkin hauraamman.

Sen sijaan, komponentit jotka haluavat antaa muille pääsyn DOM noodehin, täytyy niiden eksplisiittisesti ottaa käyttöön tämä toiminto. Komponentti voi määrittää, että se “välittää” sen refit yhdelle lapsistaan. Tässä on tapa, jolla MyInput voi käyttää forwardRef API:a:

const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});

Tässä miten se toimii:

  1. <MyInput ref={inputRef} /> kertoo Reactille että asettaa vastaavan DOM noodin inputRef.current:iin. Kuitenkin, on se MyInput komponentin vastuulla ottaa tämä käyttöön—oletuksena se ei tee sitä.
  2. MyInput komponentti on määritelty käyttäen forwardRef:ia. Tämä antaa sen vastaanottaa inputRef:in yllä olevasta ref argumentista, joka on määritelty props:n jälkeen.
  3. MyInput komponentti välittää saamansa ref:n sen sisällä olevalle <input> komponentille.

Nyt painikkeen painaminen keskittää inputin:

import { forwardRef, useRef } from 'react';

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

Design-järjestelmissä yleinen malli on, että alhaisen tason komponentit kuten painikkeet, inputit ja muut, välittävät refit DOM noodeihinsa. Toisaalta, korkean tason komponentit kuten lomakkeet, listat tai sivun osat eivät yleensä välitä DOM noodejaan, jotta välteittäisiin tahallinen riippuvuus DOM rakenteesta.

Syväsukellus

API:n osajoukon julkaisu imperatiivisella käsittelyllä

Yllä olevassa esimerkissä MyInput julkaisee alkuperäisen DOM input elementin. Tämä mahdollistaa ylemmän tason komponentin kutsun focus():iin. Kuitenkin, tämä mahdollistaa myös sen, että ylemmän tason komponentti voi tehdä jotain muuta—esimerkiksi muuttaa sen CSS tyylejä. Harvoin tapahtuvissa tapauksissa, saatat haluta rajoittaa julkistettua toiminnallisuutta. Voit tehdä sen useImperativeHandle:n avulla:

import {
  forwardRef, 
  useRef, 
  useImperativeHandle
} from 'react';

const MyInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    // Julkaise vain focus eikä mitään muuta
    focus() {
      realInputRef.current.focus();
    },
  }));
  return <input {...props} ref={realInputRef} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

Tässä, MyInput:n sisällä realInputRef sisältää oikean input DOM noodin. Kuitenkin, useImperativeHandle ohjeistaa Reactia antamaan oman erityisen olion refin arvona ylemmälle komponentille. Joten Form:n sisällä inputRef.current pitää sisällään vain focus metodin. Tässä tapauksessa, ref “handle” ei ole DOM noodi, vaan oma olio, joka luotiin useImperativeHandle kutsussa.

Kun React liittää refit

Reactissa jokainen päivitys on jaettu kahteen vaiheeseen:

  • Renderöinnin aikana React kutsuu komponenttisi selvittääksesi mitä pitäisi näkyä ruudulla.
  • Kommitoinnin aikana React ottaa muutokset käyttöön DOM:ssa.

Yleensä ei kannata käyttää refseja renderöinnin aikana. Tämä koskee myös refseja, jotka sisältävät DOM noodeja. Ensimmäisellä renderöinnillä, DOM noodeja ei ole vielä luotu, joten ref.current on null. Ja päivitysten renderöinnin aikana, DOM noodeja ei ole vielä päivitetty. Joten on liian aikaista lukea niitä.

React asettaa ref.current:n kommitoinnin aikana. Ennen DOM:n päivittämistä, React asettaa ref.current arvot null:ksi. Päivittämisen jälkeen, React asettaa ne välittömästi vastaaviin DOM noodeihin.

Useiten saatat käyttää refseja tapahtumakäsittelijöiden sisällä. Jos haluat tehdä jotain refin kanssa, mutta ei ole tiettyä tapahtumaa jota käyttää, saatat tarvita effectia. Seuraavilla sivuilla käymme läpi effectin.

Syväsukellus

Tilapäivityksen tyhjentäminen synkronisesti flushSync:llä

Harkitse seuraavaa koodia, joka lisää uuden tehtävän listaan ja selaa ruudun listan viimeiseen lapsinoodiin. Huomaa, miten jostain syystä se aina selaa tehtävään, joka oli juuri ennen viimeksi lisättyä:

import { useState, useRef } from 'react';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    setText('');
    setTodos([ ...todos, newTodo]);
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

Ongelma on näiden kahden rivin kanssa:

setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();

Reactissa, tilapäivitykset ovat jonossa. Useiten tämä on haluttu toiminto. Kuitenkin tässä tapauksessa se aiheuttaa ongelman, koska setTodos ei päivitä DOM:ia välittömästi. Joten aikana jolloin listaa selataan viimeiseen elementtiin, tehtävää ei ole vielä lisätty. Tästä syystä, scrollaus “jää” aina yhden elementin jälkeen.

Korjataksesi tämän ongelman, voit pakottaa Reactin päivittämään (“flush”) DOM:n synkronisesti. Tämän saa aikaan tuomalla flushSync:n react-dom kirjastosta ja ympäröimällä tilapäivityksen flushSync kutsulla:

flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();

Tämä ohjeistaa Reactia päivittämään DOM:n synkronisesti heti flushSync:n ympäröimän koodin suorituksen jälkeen. Tämän seurauksena, viimeinen tehtävä on jo DOM:ssa, kun yrität scrollata siihen:

import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    flushSync(() => {
      setText('');
      setTodos([ ...todos, newTodo]);      
    });
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

Parhaat käytännöt DOM-manipulaatioon refeillä

Refit ovat pelastusluukku. Niitä tulisi käyttää vain kun sinun täytyy “astua Reactin ulkopuolelle”. Yleisiä esimerkkejä tästä ovat kohdentamisesta, scrollaamisesta tai selaimen API:sta, jota React ei tarjoa.

Jos pysyttelet ei-destruktivisissa toiminnoissa kuten kohdentamisessa ja scrollaamisessa, sinun ei tulisi törmätä ongelmiin. Kuitenkin, jos yrität muokata DOM:ia manuaalisesti, saatat riskeerata ristiriidan Reactin tekemien muutosten kanssa.

Ongelman kuvainnollistamiseksi, tämä esimerkki sisältää tervetuloviestin sekä kaksi painiketta. Ensimmäinen painike vaihtaa sen näkyvyyttä käyttäen ehdollista renderöintiä ja tilaa, kuten yleensä Reactissa on tapana. Toinen painike käyttää remove() DOM API:a poistaakseen sen DOM:ista väkisin Reactin ulkopuolella.

Kokeile painamalla “Toggle with setState” painiketta muutaman kerran. Viestin pitäisi hävitä ja ilmestyä uudelleen. Paina sitten “Remove from the DOM”. Tämä poistaa sen väkisin. Lopuksi paina “Toggle with setState”:

import { useState, useRef } from 'react';

export default function Counter() {
  const [show, setShow] = useState(true);
  const ref = useRef(null);

  return (
    <div>
      <button
        onClick={() => {
          setShow(!show);
        }}>
        Toggle with setState
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        Remove from the DOM
      </button>
      {show && <p ref={ref}>Hello world</p>}
    </div>
  );
}

Manuaalisen DOM elementin poiston jälkeen, kokeile setState:n käyttöä näyttääksesi sen uudelleen. Tämä johtaa kaatumiseen. Tämä johtuu siitä, että olet muuttanut DOM:ia, ja React ei tiedä miten jatkaa sen hallintaa oikein.

Vältä Reactin hallinnoimien DOM elementtien muuttamista. Reactin hallinnoimien elementtien muuttaminen, lasten lisääminen tai lasten poistaminen voi johtaa epäjohdonmukaisiin näkymiin tai kaatumisiin kuten yllä.

However, this doesn’t mean that you can’t do it at all. It requires caution. You can safely modify parts of the DOM that React has no reason to update. For example, if some <div> is always empty in the JSX, React won’t have a reason to touch its children list. Therefore, it is safe to manually add or remove elements there.

Kuitenkin, tämä ei tarkoita, etteikö sitä voisi tehdä ollenkaan. Tämä vaatii varovaisuutta. Voit turvallisesti muokata osia DOM:ista, joita React:lla ei syytä päivittää. Esimerkiksi, jos jokin <div> on aina tyhjä JSX:ssä, Reactilla ei ole syytä koskea sen lasten listaan. Näin siinä on turvallista manuaalisesti lisätä tai poistaa elementtejä.

Kertaus

  • Refit ovat yleinen konsepti, mutta yleensä käytät niitä pitämään DOM elementtejä.
  • Ohjeistat Reactia laittamaan DOM noodin myRef.current-propertyyn <div ref={myRef}>:lla.
  • Useiten, käytät refejä ei-destruktivisille toiminnoille kuten kohdentamiselle, scrollaamiselle tai DOM elementtien mitoittamiselle.
  • Komponentti ei julkaise sen DOM noodia oletuksena. Voit julkaista DOM noodin käyttämällä forwardRef:ia ja välittämällä toisen ref-argumentin alas tiettyyn noodiin.
  • Vältä Reactin hallinnoimien DOM elementtien muuttamista.
  • Mikäli muokkaat Reactin hallinnoimaa DOM noodia, muokkaa osia, joita Reactilla ei ole syytä päivittää.

Haaste 1 / 4:
Toista ja pysäytä video

In this example, the button toggles a state variable to switch between a playing and a paused state. However, in order to actually play or pause the video, toggling state is not enough. You also need to call play() and pause() on the DOM element for the <video>. Add a ref to it, and make the button work.

Tässä esimerkissä, painike vaihtaa tilamuuttujaa vaihtaakseen toistamisen ja pysäytetyn tilan välillä. Kuitenkin, jotta video oikeasti toistuisi tai pysähtyisi, tilan vaihtaminen ei riitä. Sinun täytyy myös kutsua <video> DOM elementin play() ja pause() funktioita. Lisää ref elementille, ja tee painike toimivaksi.

import { useState, useRef } from 'react';

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);

  function handleClick() {
    const nextIsPlaying = !isPlaying;
    setIsPlaying(nextIsPlaying);
  }

  return (
    <>
      <button onClick={handleClick}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <video width="250">
        <source
          src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
          type="video/mp4"
        />
      </video>
    </>
  )
}

Lisähaasteena, pidä “Play” painike synkronoituna videon toiston tilan kanssa, vaikka käyttäjä klikkaisi videota hiiren oikealla painikkeella ja toistaa sen käyttämällä selaimen sisäisiä media-ohjauksia. Saatat tarvita tapahtumakäsittelijää onPlay ja onPause video-elementillä tämän toteuttaaksesi.