이전 글 보기: https://moneytech.kr/55
1. 사용자 컴포넌트란 무엇인가
앞서 React 프레임워크의 변천사를 흉내 내서 기본적인 형태를 구현해보았다.
이번에는 React에서 지원하는 '사용자 컴포넌트'라는 것을 구현해볼 것이다.
사용자 컴포넌트에는 크게 2가지가 있다. 함수형 컴포넌트, 클래스형 컴포넌트.
종류를 배우기 전에 먼저 사용자 컴포넌트가 뭔지 알아야 하지 않겠는가?
리액트 공식문서: https://ko.reactjs.org/docs/components-and-props.html
컴포넌트(Component)란 UI를 재사용할 수 있도록 내가 만든 HTML 조각이라고 생각하면 된다.
전체 중에 특정 부분만 잘라서 작게 만들어놓고, 계속 재사용하면서 쓰는 퍼즐 조각이다.
그래서 소프트웨어를 개발할 때 컴포넌트 조각들을 끼워넣는 조립형으로 만들 수 있다는 이야기가 있는 것이다.
예를 들어, 내가 로그인 폼을 만들었다고 하면 리액트에서는 이렇게 표현할 수 있다.
// 이 파일은 JSX 형태
<내가 만든 로그인 폼></내가 만든 로그인폼>
보면 알겠지만, 원래 있던 HTML의 태그명이 아니라 내가 직접 만들었기 때문에 이름도 내가 원하는 대로 지어서 사용한다.
이름과 기능을 커스터마이즈해서 재사용할 수 있게 개발한 이후에 HTML 태그처럼 JSX 문법으로 재사용한다. 이것이 사용자 컴포넌트이다.
2. 함수형 컴포넌트를 살펴보자
현재 우리가 가지고 있는 app.js와 react.js를 다시 톺아보자.
app.js 형태는 아래와 같다.
/* @jsx createElement */
// app.js의 형태
import { createDOM, createElement, render } from './react';
const vdom = <p>
<h1>React 만들기</h1>
<ul>
<li style="color:red">첫 번째 아이템</li>
<li style="color:blue">두 번째 아이템</li>
<li style="color:green">세 번째 아이템</li>
</ul>
</p>
console.log(vdom);
render(vdom, document.querySelector('#root'));
react.js의 형태는 아래와 같다.
처음에 구현했던 createElement()에서 두 번째 입력값인 props의 null처리를 해주었고
세 번째 입력값인 children을 전개구문(spread operation)을 통해 배열이 들어갈 수 있게 수정했다.
// react.js
export function createDOM(node) {
if (typeof node === 'string') {
return document.createTextNode(node);
}
const element = document.createElement(node.tag);
Object.entries(node.props)
.forEach(([name, value]) => element.setAttribute(name, value));
node.children
.map(createDOM)
.forEach(element.appendChild.bind(element));
return element;
}
export function createElement(tag, props, ...children) {
props = props || {};
return { tag, props, children };
}
export function render(vdom, container) {
container.appendChild(createDOM(vdom));
}
여기서 컴포넌트를 하나 만들었다고 가정하자.
/* @jsx createElement */
// app.js의 형태
import { createDOM, createElement, render } from './react';
function Title() { // 함수형 컴포넌트를 하나 만들었다.
return <h1>React 만들기</h1>
}
const vdom = <p>
<Title></Title> // 기존에 있던 태그를 삭제하고, 내가 만든 컴포넌트를 삽입했다.
<ul>
<li style="color:red">첫 번째 아이템</li>
<li style="color:blue">두 번째 아이템</li>
<li style="color:green">세 번째 아이템</li>
</ul>
</p>
console.log(vdom);
render(vdom, document.querySelector('#root'));
이렇게 하게 되면 위의 함수형태의 컴포넌트를 어떻게 작동할까?
vdom 변수에 있는 Title태그 → JSX 문법 → JSX 태그는 결국 트랜스파일(Transpile)되어 createElement()로 바뀐다.
결국 위에 있는 Title()은 최종적으로 createElement 함수의 호출로 바뀌는 것이며 createElement가 리턴하는 형태를 Title도 똑같이 리턴할 것이다.
지금 저렇게 쓰면 오류가 난다. 왜 오류가 나는지 확인해보자.
실제 Babel에서 돌려보면 app 부분의 Hello는 문자열이 아니라 하나의 코드로 넘긴다. 어딘가에 Hello라는 코드가 있을 것이라고 가정하고 실행시키는 것이다. (Hello는 변수일 수도 있고 함수일 수도 있고..)
그런데 우리는 현재 tag가 오면 하나의 문자열로 처리하게 되어있다.
현재는 Hello가 변수니까 실행시키면 오류가 날 것이다. 우리는 '함수형 컴포넌트'로 코딩하기 때문에 Hello를 함수로 바꿔주자.
그리고 우리가 만든 react.js에 함수형태가 왔을 때에 createElement를 호출할 수 있도록 연결만 하면 정상적으로 DOM를 만들 수 있을 것이다.
이렇게 되면, 앞으로 사용자(React를 사용하는 개발자)가 함수로 만든 것에 모든 리턴값을 JSX 문법만 준수해준다면 모든 형태의 함수형 컴포넌트를 react.js는 정상적으로 수행할 수 있게 된다.
여기에 React 프레임워크는 하나 더 제약을 두는데, 바로 컴포넌트는 가장 앞이 '대문자'여야 한다는 것이다.
정리하자면,
React 프레임워크에서 함수형 컴포넌트를 어떻게 소화하는가?
1. app.js에서 사용하는 태그(JSX 문법을 준수한)의 첫 번째 영단어가 대문자이다.
2. 해당 JSX는 어딘가에서는 함수로 리턴되는 값이어야 한다. (ex. const Hello = () => <h1>Hello</h1>)
3. React에서는 createElement로 바뀔 때, 해당 입력값이 함수일 때를 체크해서 DOM를 그릴 수 있도록 처리한다.
그러면 소문자일 때는 어떻게 동작할까?
React 프레임워크는 소문자일 때 해당 부분의 조각을 문자열로 처리하여 컴포넌트처럼 UI를 그리지 않는다.
동작하지 않는다.
왜 이렇게 했을까? 그건 너무 당연하다.
프레임워크 사용자가 해당 태그를 문자열로 쓰고 싶은지, UI를 그리는 컴포넌트로 쓰고 싶은지 내가 어떻게 아나.
(모르는데 어떻게 그려줘요!)
그래서 하나의 기준을 세운 것이다.
그러면 이제 우리 코드를 수정해보자.
3. 함수형 컴포넌트를 직접 구현하자.
이제 react.js에서 함수가 넘어올 때는 무조건 createElement가 실행될 수 있도록 해야한다.태그명이 오면 바로 객체를 만들지 말고, createElement 형태로 만들어주자.
// react.js
export function createDOM(node) {
if (typeof node === 'string') {
return document.createTextNode(node);
}
const element = document.createElement(node.tag);
Object.entries(node.props)
.forEach(([name, value]) => element.setAttribute(name, value));
node.children
.map(createDOM)
.forEach(element.appendChild.bind(element));
return element;
}
export function createElement(tag, props, ...children) {
// 이 부분을 수정했습니다.
props = props || {};
if (typeof tag === 'function') {
return tag();
// 자세한 설명: 해당 태그명을 실행하면 그 리턴값은 무조건 JSX형태이다.
// Babel은 JSX형태를 최종적으로 createElement를 호출한다.
// 그러면 그 이름으로 된 createElement 리턴값이 실행되니까 DOM를 그린다.
} else {
return { tag, props, children };
// 함수가 아니면 그냥 tag에 그 값을 그대로 넣으면 된다. (자바스크립트 축약형)
}
}
export function render(vdom, container) {
container.appendChild(createDOM(vdom));
}
여기서의 핵심은 JSX는 Babel이 createElement로 알아서 잘 바꿔줄 것이라는 믿음이다. 그것이 중요한 것이여~
이제 여기서 props를 추가해보자.
/* @jsx createElement */
// app.js의 형태
import { createDOM, createElement, render } from './react';
function Title(props) { // 드디어 입력값으로 props를 받는다. 이름이야 본인이 짓기 나름이다.
// return <h1> props.children </h1> ← 이렇게 쓰면 문자열로 쓰는 것이다. js 코드를 써야한다.
return <h1> { props.children } </h1> // 자바스크립트 코드는 중괄호(brace)를 사용한다.
}
const vdom = <p>
<Title>React 만들기</Title>
<ul>
<li style="color:red">첫 번째 아이템</li>
<li style="color:blue">두 번째 아이템</li>
<li style="color:green">세 번째 아이템</li>
</ul>
</p>
render(vdom, document.querySelector('#root'));
이렇게 작성한다고 해서 동작하지 않는다.
왜냐하면 우리가 작성한 react.js에서의 props는 HTML태그의 '속성 attributes'을 뜻한다.
헷갈리면 안된다. 우리는 지금 props를 HTML 속성을 가지고 오기로 작성해두고는 children을 props로 처리하기를 기대하고 있다.
(예, <Title label="React 만들기"> children </Title> 이렇게 되어야지 현재는 작동함.)
그런데 우리는 가운데에 있는 값을 children으로 보내고 싶다.
// react.js
export function createDOM(node) {
if (typeof node === 'string') {
return document.createTextNode(node);
}
const element = document.createElement(node.tag);
Object.entries(node.props)
.forEach(([name, value]) => element.setAttribute(name, value));
node.children
.map(createDOM)
.forEach(element.appendChild.bind(element));
return element;
}
export function createElement(tag, props, ...children) {
props = props || {};
if (typeof tag === 'function') {
// 이 부분을 수정했습니다.
if (children.length > 0) {
return tag({
...props,
children: children.length === 1 ? children[0] : children,
// 현재 React에서는 단일 값인 경우 알아서 배열의 첫번째로 인식한다.
// 이렇게 하지 않으면 children이 1개일 때마다 app.js에서 children[0]이라고 적어줘야하기 때문에
// 사용자가 불편을 겪게 될 것이다.
});
} else {
return tag(props);
}
} else {
return { tag, props, children };
}
}
export function render(vdom, container) {
container.appendChild(createDOM(vdom));
}
계속 재귀의 재귀형태이기도 하고, 여기저기 함수가 왔다갔다해서 단번에 이해하기는 어렵다.
그래도 계속 쫓아가면서 이해하면 원리를 이해할 수는 있으니 끝까지 포기하지 말자.
이제 색상을 모두 props를 이용해서 입력해보자.
/* @jsx createElement */
import { createElement, render } from './react';
function Title(props) {
return <h1>{ props.children }</h1>;
}
function Item(props) {
// 새롭게 추가
return <li style={`color:${props.color}`}>{props.children}</li>
}
const vdom = <p>
<Title label="React">React 정말 잘 만들기</Title>
<ul>
<Item color="red">첫 번째 아이템</Item> /* 사용자 컴포넌트를 사용 */
<Item color="green">두 번째 아이템</Item>
<Item color="blue">세 번째 아이템</Item>
</ul>
</p>
render(vdom, document.querySelector('#root'));
리액트의 마법과 달리 몇 개의 코드 변환만 했는데 리액트처럼 작동되고 있다.
현재 프로덕션 버전에서는 엄청난 코드량이 있지만 이게 리액트의 기초 원리이다.
생각보다 너무 재미있어서 유익하고 좋다!