사실 이게 다른 분들께는 별 의미가 없다는 건 잘 안다. 하지만 정리를 이런식으로 해두지 않으면 손에서 빠져나가는 모래처럼 배운 것이 없어질 것 같아 이 글을 게시하기로 하였다.
사실 집에 이런 책이 있다.
굉장히 깊은 내용들을 다루고 있어서 한번 쯤 읽지 않으면 리액트로 프론트엔드 개발을 못할 것만 같지만 이 책 너무 어렵다.
리액트는 수많은 개발 프로젝트를 자양분 삼아 엄청난 발전이 있었다. 그렇지만 천리길도 한걸음 부터라고 중요한 것만 간단히 다루기로 했다.
React 의 기본 UI 단위이다. 단순히 html div 태그 특별히 하나 더 만든 것은 아니다.
<></>
로도 나타낼 수 있다.Component 를 생성 방법에 따라 종류가 나뉜다.
import React from "react";
// Element.jsx 파일
export default function Element(props) {
return <div>{props.inx}번째 Hello world!</div>
}
// Root.jsx 파일
export default function Root() {
return <>
<Element inx="0"/>
<Element inx="1"/>
<Element inx="2"/>
</>
}
// index.js 파일
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Root/>
</React.StrictMode>
)
import React, { Component } from "react";
class ProgressBar() extends Component {
constructor(props) {
super(props); // this.props 에 값 반영
this.state = { progress: this.props.progress || 0 };
}
componentDidUpdate(prevProps) { // 갱신 직후 호출
if (prevProps.progress != this.props.progress) {
this.setState({ progress: this.props.progress });
}
}
render() {
const { progress } = this.state;
return (
<div class="w-full h-4 bg-gray-200 rounded">
<div
className="h-full bg-blue-500 rounded"
style={{ width: `${progress}%` }}
>
`${progress}%`
</div>
</div>
)
}
}
리액트가 클래스 컴포넌트에 대한 언급을 최신 문서에서 제외한 이유는 다음과 같다고 생각된다.
자바스크립트의 export 를 간단히 짚고 넘어가자
Syntax | Export statement | Import statement |
---|---|---|
Default | export default function Button() {} | import Button from './Button.js'; |
Named | export function Button() {} | import { Button } from './Button.js'; |
예제를 끝으로 간단히 마무리 한다. 예제는 MDN 을 그대로 가져왔다.
// named export
// 호이스팅에 의해 미리 export 가능
// import 시 이름은 고정!
export { cube, foo, graph };
function cube(x) {
return x*x*x;
}
const foo = Math.PI + Math.SQRT2;
var graph = {
options: {
color: "white",
thickness: "2px",
},
draw: function() {
console.log("From graph draw function");
},
};
// default export
// File A
export default function cube(x) {
return x*x*x;
}
// File B
import { createContext } from "react"; // 컨텍스트 생성 hook
const AppContext = createContext({ }); // 객체 형태의 컨텍스트 생성
// AppContext 라는 이름으로 export. 다른 파일에선 여러 이름으로 사용 가능.
export default AppContext;
JSX 는 자바스크립트 파일 내에서 자바스크립트를 이용하여 HTML 를 작성할 수 있도록 도와주는 마크업을 작성할 수 있게 도와주는 자바스크립트 확장 기능이다. 대부분의 리액트 개발에서 UI 를 작성할 때 활용된다.
리액트 안에 JSX 가 포함된 것도 아니고, 반드시 써야되는 건 아니다.
예를 들어 조건에 따라 3 개의 HTML 을 화면에 출력해야 한다고 가정하자. 그럴 경우에는 약간 복잡한 필요하였다.
<script>
document.addEventListener("DOMContentLoaded", function() {
const isLoggedIn = await isLoggedIn();
let id = "";
if (isLoggedIn === true) {
id = "A";
} else if (isLoggedIn === false) {
id = "B";
} else {
id = "C";
}
document.getElementById(id).hidden = false;
});
</script>
<div id="A" hidden>
<p>Hello A world</p>
</div>
<div id="B" hidden>
<p>Hello B world</p>
</div>
<div id="C" hidden>
<p>Hello C world</p>
</div>
위의 경우 불필요하게 코드가 길어지게 되었다. 원하는 것은 단지 isLoggedIn() 의 결과에 따라 다른 HTML 을 화면에 보여주는 것 뿐인데 말이다. 코드를 읽는 입장에서도 복잡하다.
JSX 는 자바스크립트로 HTML 을 생성할 수 있게 도와준다. 참고로 useState 의 setter 역할의 함수가 set 을 하면 React 의 컴포넌트는 ReRendering 된다.
import React, { useState } from "react";
export default function HelloWorld() {
const [id, setID] = useState("");
() => {
const isLoggedIn = await isLoggedIn();
if (isLoggedIn === true) {
setID("A");
} else if (isLoggedIn === false) {
setID("B");
} else {
setID("C");
}
}();
return (
<div>
<p>Hello {id} world</P>
</div>
);
}
JSX 는 확장 기능이자 일종의 문법이기 때문에 아래의 제한사항을 갖는다.
// X. It won't work.
export default function ShoppingList() {
return (
<h1>Shopping List</h1>
<ul>
<li>Tomatoes</li>
<li>Beef</li>
<li>Milk</li>
</ul>
);
}
// Correct. Used Fragment
export default function ShoppingList() {
return (<>
<h1>Shopping List</h1>
<ul>
<li>Tomatoes</li>
<li>Beef</li>
<li>Milk</li>
</ul>
</>);
}
여러 개의 Element 를 바로 반환하는 것을 허용하지 않는 이유는 JSX 의 반환값이 자바스크립트 객체이기 때문이다. 하나의 함수에서 두 개의 객체를 동시에 반환하지는 못한다.
<img
src="https://i.imgur.com/yXOvdOSs.jpg"
alt="Hedy Lamarr"
className="photo" // CamelCase
/>
JSX 는 HTML 을 쉽게 작성하기 위한 마크업 확장이기 때문에 동적으로 리턴값인 HTML(자바스크립트 객체) 을 정의할 수 있다.
export default function toDollar(number) {
function formatNumber(number) {
return number.toLocaleString();
}
return <p>Current price is `${formatNumber(number)}`</p>
}
특이하게도 style 처럼 다수의 속성을 정의하는 경우에는 다음과 같이 사용한다. 하지만 이것도 자세히 보면 한 개의 객체를 전달하는 것 뿐이라는 것을 알 수 있다.
export default function TodoList() {
return (
<ul style={{
backgroundColor: 'black',
color: 'pink'
}}>
<li>Study</li>
<li>Workout</li>
<li>Sleep well</li>
</ul>
);
}
리액트의 컴포넌트들은 props 라는 변수를 이용해 서로 소통한다. 소통방향은 부모 컴포넌트에서 자식 컴포넌트가 일반적이며, props 는 값, 객체, 함수/메소드 심지어 컴포넌트도 가능하다.
컴포넌트는 미리 지정된 다음의 프로퍼티 혹은 파라미터를 사용할 수 있다. 클래스 컴포넌트인 경우는 this.props (이는 constructur 에서 super(props) 를 호출하였기 때문) 으로 사용 가능하며, 함수형 컴포넌트인 경우는 그냥 파라미터로 받아서 사용하면 된다.
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}
class Welcome extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>
}
}
주로 함수형 컴포넌트를 사용하는 현재는 자바스크립트의 객체 destructuring 문법을 혼합하여 사용하고 있다.
export default function MyProfile({name, job, age}) {
return (<>
<ul>
<li>이름은 {name}</li>
<li>직업은 {job}</li>
<li>나이는 {age}</li>
</ul>
</>);
}
간단히 예제로 언급하도록 한다.
export default function MyProfile({ name, job = 'searching', age }) {
// ...
}
props 내에는 children 이라는 키워드가 있다. 이는 사용중인 컴포넌트를 리턴값으로 선언할 때 태그와 태그 사이의 위치한 컴포넌트이다.
function Card({ children }) {
return (
<div className="card">
{children}
</div>
);
}
export default function Profile() {
return (
<Card>
<Avatar
size={100}
person={{
name: 'Katsuko Saruhashi',
imageId: 'YfeOqp2'
}}
/>
</Card>
);
}
부모 컴포넌트에 의해 전달된 props 는 부모 컴포넌트의 라이프사이클에 따라 바뀔 수 있다.
export default function Clock({ color, time }) {
return (
<h1 style={{color: color}}>
{time}
</h1>
);
}
부모에 의해 전달된 color 값, time 값은 state 일 것이다. useState 훅에 의해 정의된 위의 두 값은 바뀔 때마다 부모 컴포넌트를 rendering 할 것이며, 그로 인해 자식 컴포넌트도 redering 될 것이다.
순수 함수란 외부의 영향 없이 정해진 값에 정해진 결과만을 반환하는 함수를 말한다.
function pureSum(a, b) {
return a+b;
}
let externalNumber = 0;
function notPureSum(a) {
return externalNumber + a;
}
순수 함수의 장점은 개발자가 의도한 결과를 반환하는데 더 유리하다는 것이다. 해당 함수에 대해서는 정확한 값만 전달 되었다면 예상한 값만 반환할 것이기 때문에, 전달된 값만 검증하면 된다.
React 뿐만 아닌 최근 프론트엔드의 트렌드는 트리 구조의 UI 인가보다.
여기서 중요한 것은 부모 컴포넌트가 렌더링되면 자식 컴포넌트도 같이 렌더링 된다는 것이다. 위의 그림에서 A가 렌더링되면 B,C 도 렌더링되지만, B가 렌더링 된다고 A가 렌더링 되지는 않는다.
그렇기 때문에 자식 컴포넌트를 정의함에 있어 순수 함수를 사용하는 것이 중요하다
만약 자식 컴포넌트를 렌더링하는 함수가 다른 함수 혹은 컴포넌트의 영향을 받게 되면 코드 베이스는 혼잡해지고, 유지보수하기 어려워진다.
리액트 공식문서에서는 렌더링과 함께 순수함수에 대해 아래와 같이 명시하고 있다.
렌더링은 반드시 순수 연산으로 이뤄져야 한다. - 같은 입력은 같은 출력을 동반해야 한다. 컴포넌트는 같은 입력에 대해 언제나 같은 JSX 를 반환해야 한다. - 독립성을 고려해야 한다. 특정 컴포넌트 변경이 다른 컴포넌트에 영향을 끼쳐서는 안된다. 코드베이스가 복잡 해질수록 혼란스러운 버그와 예상치 못한 상황을 여러 번 맞이할 것이다. "Strict Mode" 로 개발을 진행하면 각 컴포넌트 함수가 두 번 호출 되는데 표면 상 잘 드러나지 않는 실수를 검증할 수 있다.
렌더링은 다음의 과정에 따라 진행된다. 내 머릿 속에 박아놓고 튀어 나와야 할 지식들을 적어 놓는다.
Triggering은 렌더링을 유도하는 과정이고 다음의 두 이유로 발생한다.
여기서 말하는 최초 렌더링은 createRoot 에 의해 발생하는 최초 렌더링을 말한다. 주로 리액트 프로젝트를 생성하면 index.js 가 생성되는데 이 안에 createRoot 가 들어있는 것을 확인할 수 있다.
(화면 이동은 react-router-dom 같은 외부 라이브러리를 이용하거나, a 태그를 이용하는 것으로 리액트와는 거리가 먼 이야기다)
상태값이 변경되는 것은 useState 훅을 이용하는 경우를 말한다. useState 훅의 리턴값은 배열인데, 0번째 인덱스는 상태값 자체, 1번째 인덱스는 상태값을 변경할 수 있는 set 함수이다. 즉, 화면 상의 변화가 발생하였다면 컴포넌트의 set 함수가 작동했을 가능성이 가장 높다.
트리거가 되면 리액트는 렌더링해야 할 컴포넌트를 호출한다.
이 과정은 연속적인데, 한 부모 컴포넌트를 런더링하면 자식 컴포넌트도 렌더링되는 방식이다.
렌더링의 작동방식을 이해한다면, UI 트리 구조상 높은 위치의 컴포넌트 렌더링에는 신중을 기해야 한다는 것도 이해할 수 있을 것이다. 성능 이슈가 발생한다면 Performance 를 참고하자.
렌더링과 동시에 리액트는 DOM 을 변경한다.
React 16.8 부터 지원되어오던 리액트의 기본 기능 중 하나이다. 버전만 보면 상당히 늦게 지원이 시작된 것으로 보인다.
훅의 사용은 선택적이면서 클래스로 관리되었던 컴포넌트를 함수형으로 관리할 때의 장점을 극대화 한다.
훅의 특이한 점은 반드시 컴포넌트의 최상위 부분에 선언해야 한다는 것이다. 이는 훅이 컴포넌트의 상태값을 관리하는데 깊이 관여하기 때문인데, 이에 관하여서는 바로 아래 useState 와 함께 정리한다.
컴포넌트는 자신의 상태를 따로 저장하고 있다. 이를 useState 를 통해 정의하고 가져올 수 있다.
import { useState } from 'react';
import { images } from './images.js';
export default function Gallery() {
const [index, setIndex] = useState(0);
function handleClick() {
setIndex((prev) => prev % images.length);
}
return (
<>
<img src={images[index].url} alt={images[index].alt}/>
<button onClick={handleClick}>Next</button>
</>
);
}
다음은 Hook 없이 useState 를 구현한 코드이다.
사실 굉장히 간단하지만 약간 마법같은 일이다. 값을 바꾸니 컴포넌트가 렌더링 된다는 것은 어떻게 발생하는가? 그건 다음 링크에 자세히 나와있지만 따로 아래에 정리하도록 한다. How does React know which state to return
리액트는 같은 함수로 생성된 상태값임에도 불구하고 특정 setter 를 통해 정확한 상태값 업데이트가 가능하다. 그것이 가능한 이유는 컴포넌트 당 최상위에 각자의 상태값을 갖기 때문이다.
리액트 내부에는 각 컴포넌트마다 상태값 쌍을 위한 배열과 상태값을 가져올 현재 인덱스를 갖도록 한다.
아래 useState 를 직접 구현한 것을 보면 더 자세히 이해할 수 있다.
let componentHooks = [];
let currentHookIndex = 0;
function useState(initialState) {
let pair = componentHook[currentHookIndex];
if (pair) { // 현재 인덱스로 저장된 상태값이 있다면 그 상태값 반환
currentHookIndex++;
return pair;
}
pair = [initialState, setState]; // 없다면 새로 만들어줌
function setState(nextState) {
pair[0] = nextState;
updateDOM(); // setter 에서 DOM 을 업데이트 하도록 함
}
componentHooks[currentHookIndex] = pair;
currentHookIndex++;
return pair;
}
useState 는 특히 다른 Hook 도 마찬가지지만, 컴포넌트 최상위에서만 선언이 가능함을 다시 강조한다. 특정 컴포넌트만 렌더링 하는 것으로 퍼포먼스를 상승시키고, 상태값을 분리하고 순수함수 형태로 컴포넌트를 관리할 수 있도록 하려면 훅은 컴포넌트 내 최상위에서 선언되어야 알맞다.
요즘 대세는 단방향 아키텍처인 것 같다. 예를 들어 나는 본업이 iOS 개발자이니 단방향 아키텍처이면서 redux 의 영향을 받은 2 개의 구조를 나타내는 그림만 가져와 보았다.
(왼쪽은 ReactorKit Github, 오른쪽은 이 블로그의 TCA Deep-Dive[1] 에서 가져왔다.
2 개의 구조에 Reactor, Reducer 라는 요소를 발견할 수 있다. 이 2 요소는 공통적으로 Action 을 받는데 (Store 내부에 Action 이 정의되어 있음) Action 은 여러 개 정의될 수 있으며, 다수의 액션에 대한 동작을 Reactor, Reducer 가 정의하고 있다.
Reducer 연산자를 컴퓨터 과학에서는 동시 프로그래밍에 사용한다고 한다. 여러 개의 element 들을 하나로 만들어주는 연산자를 뜻한다. 위키에서 행렬로 이론을 설명하는 걸 보고 여기까지만 알고 싶어졌다.
리액트에서도 마찬가지이다. 미리 정의한 액션들을 토대로 똑같은 작업을 수행하는 Pure 한 reducer 함수와 reducer 의 초기 상태값을 정의한 뒤 reducer 에 명령을 보낼 수 있는 dispatch 함수를 반환한다.
내가 쓰고도 무슨 말인지 잘 모르겠으니 코드로 표현하고자 한다.
import { useReducer } from 'react';
import { data } from './cards.js';
export default function Root() {
// dispatch 로 실행되는 reducer.
// 현재 상태값인 state, 전달된 action 을 파라미터로 받는다. 둘 다 객체.
function reducer(state, action) {
switch(action.type) {
case "setCard":
let newCards = state.cards;
newCards.push(action.card);
return { ...state, newCards };
case "removeCard:
let newCards = state.cards;
newCards.splice(action.index, 1);
return { ...state, newCards };
case "printCard":
console.log(state.status());
return state;
default:
return state;
}
}
// dispatch 에는 액션 객체를 전달해서 작업을 수행하게 할 수 있다.
// dispatch({ type: 'printCard' });
// dispatch({ type: 'removeCard', index: 0 });
const [state, dispatch] = useReducer(reducer, {
cards: data.cards,
status: () => {
return `Card count is ${state.cards.length}`
}
});
return <CardGame state={state} dispatch={dispatch}/>
}
개인적으로 이 훅은 상당히 즐겨쓴다. 위에서 정한 reducer 만 잘 테스트 된다면 이를 통해 정의된 컴포넌트도 testable 해지게 되는 것 같다고 생각한다.
이 훅은 굉장히 의존성과 관련이 많다. 만약 이 훅이 선언된 컴포넌트가 추가되면 이 훅의 셋업 함수가 실행되는데, 이외에도 훅의 동작방식이 굉장히 특이하다.
useEffect(setup, dependencies?);
리액트 공식문서의 예제 코드가 아주 좋다. 사실 나도 네트워크 연결보다는 컴포넌트 트리를 완전 리프레쉬할 때 말고는 써본 적이 없다.
import { useEffect, useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
conntion.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
// ...
}
만약 setServerUrl 이 호출되어 URL 이 바뀐다면 connection.disconnect() 를 호출 후에 다시 connection.connect() 하게 된다.
리액트에서 컨텍스트란 컴포넌트와 완전히 분리된 위치에서 선언되는 객체이며, 컴포넌트는 이 컨텍스트의 Provider (Consumer 도 있지만 잘 사용하지 않음) 내에 있다면 이를 읽고 수정할 수 있다.
컴포넌트들을 분리한 뒤 특정 컴포넌트들끼리 공유하는 값을 관리하고 싶을 때 유용하다.
import ThemeContext from './context.js';
function App() {
const [theme, setTheme] = useState('light');
// ...
return (
<ThemeContext.Provier value={ theme, setTheme }>
<Page/>
</ThemeContext.Provider>
);
}
function Page() {
let { theme, setTheme } = useContext(ThemeContext);
function toggleTheme() {
setTheme((prev) => prev === 'light' ? 'dark' : 'light');
}
return (
<div>
<Contents theme={theme}/>
<button onClick={toggleTheme}>toggle theme</button>
</div>
)
}
Page 는 ThemeContext 의 Provider 의 트리 내에 속해있기 때문에 useContext 를 통해서 상태를 읽을 수도 있고, 수정할수도 있다.