sungwook
Vanilla React 1편 - JSX 적용하기 본문
내가 이 프로젝트를 시작하면서 가장 먼저 하고 싶었던게 바로 이 JSX를 사용하는 것이다.
물론 react를 사용하지 않으면서 말이다.
그렇다면 JSX는 뭘까?
JSX는 Javascript XML의 줄임말이다.
XML의 예시는 다음과 같다.
<bookstore>
<book category="fiction">
<title>Harry Potter and the Philosopher's Stone</title>
<author>J.K. Rowling</author>
<year>1997</year>
<price>24.99</price>
</book>
<book category="non-fiction">
<title>A Brief History of Time</title>
<author>Stephen Hawking</author>
<year>1988</year>
<price>19.99</price>
</book>
</bookstore>
많이 낯이 익지 않은가?
HTML 태그랑 비슷한 것 같기도 하고 React를 써봤다면 return문에 들어가는 형식과 똑같다는 것을 알 수 있다.
HTML과 XML은 모두 마크업 언어인 SGML(Standard Generalized Markup Language)에서 파생되었고
JSX는 HTML과 Javascript를 결합시킬 목적으로 만들어진 문법이다.
그렇다면 왜 JSX가 탄생했을까?
리액트는 `컴포넌트` 라는 유닛을 통해 파일의 역할별로 관심사가 분리되는 것에 집중했다.
기존에는 HTML, CSS, Javascript로 관심사가 분리되어있었다면 이제는 기능 단위로 관심사 분리를 시도한 것이다.
JSX를 사용하면 XSS(Cross-site scripting) 공격을 막을 수 있다는 점에서 보안에도 이점이 있다.
예를 들어 h1 태그에 title이라는 값을 넣어준다고 생각해보자.
<h1>{title}</h1>
여기서 사용자가 만약에 title 값을 <script>alert('해킹!');</script>와 같이 넣어도
JSX에서 사용자 입력을 이스케이프(escape) 해주기 때문에 문제가 되지 않는다.
이스케이프한다는 말은 특수문자를 문자로 바꿔준다는 뜻이다.
따라서 <script></script>를 넣어도 스크립트로 동작하는 것이 아닌
문자열로 출력하게 되며 javascript로 들어오는 공격을 막아줄 수 있다.
이제 프로젝트를 세팅하고 JSX를 함수형 컴포넌트에서 쓰기 위해 설정을 해보자.
먼저 webpack과 babel을 설치해야한다.
webpack은 정적 모듈 번들러다.
여러 개의 javascript 파일들을 하나로 합쳐주고 플러그인을 추가해주는 등
복잡한 프론트엔드 프로젝트를 효율적으로 관리하고 최적화하는데 도움을 준다.
babel은 javscript 컴파일러다.
최신 버전의 JavaScript 코드를 이전 버전과 호환되는 코드로 변환해주며
JSX를 javascript로 변환해주는 기능을 가지고 있다.
1. 패키지 매니저에 webpack, babel 추가 및 설정
나는 패키지 매니저로 yarn을 사용했다. 그 이유로는
1. react를 만든 facebook에서 만든 패키지 매니저다. (안전성)
2. npm, pnpm은 사용해봤지만 yarn은 사용해보지 않았다. (학습용도)
더 자세히 얘기하면 길어지기에 패키지 매니저는 다른 글에서 다시 정리해보려한다.
아래 명령어로 다음의 패키지들을 설치해주면 된다.
dev로 설치하는 이유는 런타임 단계가 아닌 빌드 단계에서 쓰이는 패키지들이기 때문이다
yarn add --dev \
@babel/core@^7.25.2 \
@babel/preset-env@^7.25.3 \
@babel/preset-react@^7.24.7 \
babel-loader@^9.1.3 \
cross-env@^7.0.3 \
html-webpack-plugin@^5.6.0 \
webpack@^5.93.0 \
webpack-cli@^5.1.4 \
webpack-dev-server@^5.0.4
각 패키지의 용도)
@babel/core, @babel/preset-env, @babel/preset-react: babel의 기본 패키지들로 JSX를 Javascript로 트랜스파일할 때 필요한 패키지
babel-loader: webpack에서 JavaScript 파일을 처리할 때 babel을 사용하도록 도와주는 패키지
cross-env: 다른 os 환경 (window)에서도 빌드될 수 있게 도와주는 패키지
html-webpack-plugin: 빌드된 Javascript를 HTML에 자동으로 삽입해주는 플러그인
webpack, webpack-cli: webpack의 기본 패키지들, cli는 명령어로 사용할 수 있게 해주는 패키지
webpack-dev-server: 개발 서버를 제공해주며 실시간 리로딩 기능과 함께 로컬 개발 환경을 설정할 수 있는 패키지
설치했으면 이제 설정도 해줘야한다.
// package.json
{
// 기존 코드에 scripts 추가
"scripts": {
"build": "cross-env NODE_ENV=production webpack",
"start": "webpack serve --mode development"
},
}
// babel.config.json
{
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"pragma": "createElement",
"pragmaFrag": "Fragment",
"throwIfNamespace": false,
"runtime": "classic"
}
]
]
}
// webpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: './src/App.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
})
],
devServer: {
static: {
directory: path.join(__dirname, 'dist')
},
compress: true,
port: 9000
}
}
각 설정 파일을 만들 때 파일 확장자에 유의해야한다.
babel 설정은 정적인 데이터 형식이기 때문에 json 확장자를 사용해도 되지만
webpack 설정은 require문을 포함하고 있기 때문에 js 확장자를 사용해야한다.
이제 빌드를 할 수 있는 환경은 다 구축이 되었지만 babel 설정에
pragma와 pragmaFrag 옵션을 주면 직접 createElement와 fragment 설정을 줄 수 있다.
쉽게 말하면 react에서 구현되어있는 컴포넌트 생성과 fragment의 정의를 내가 직접 하겠다는 뜻이다.
나는 react에 있는거 그대로 쓸거야! 한다면 babel.config.json을 아래와 같이 수정하면 된다.
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", {
"runtime": "automatic"
}]
]
}
이렇게 쓰면 React.createElement를 그대로 사용할 수 있다.
나는 직접 만들어 보고 싶었기에 framework.js라는 파일에 직접 createElement와 Fragment를 정의해보았다.
function createElement(tag, props, ...children) {
if (typeof tag === 'function') {
return tag({ ...props, children })
}
const element = document.createElement(tag)
Object.entries(props || {}).forEach(([name, value]) => {
if (name.startsWith('on') && name.toLowerCase() in window)
element.addEventListener(name.toLowerCase().slice(2), value)
else element.setAttribute(name, value.toString())
})
children.forEach((child) => {
appendChild(element, child)
})
return element
}
function appendChild(parent, child) {
if (child === null || child === undefined) {
return
}
if (Array.isArray(child)) {
child.forEach((nestedChild) => appendChild(parent, nestedChild))
} else {
parent.appendChild(
child.nodeType ? child : document.createTextNode(child.toString())
)
}
}
여기서 createElement가 하는 역할은 두가지다
1. 태그에 이벤트를 추가해준다
2. 자식 요소를 부모에 추가해준다
slice(2)가 사용된 이유는 React에서는 이벤트 핸들러 props를
onClick, onSubmit 등의 형태로 지정하는 반면
실제 이벤트 이름에는 'on' 접두사가 없기 때문이다.
예를 들어 React에서 onClick으로 버튼이 눌렸을 때의 이벤트를 정의한다면
DOM API에서는 click이라는 이름을 사용하고 있다.
appendChild는 child가 배열인 경우, 각 항목에 대해 재귀적으로 appendChild를 호출하도록 했다.
나도 배열로 요소를 생성해본적은 없지만 객체와 다르게 배열은 렌더링해주는 것을 알 수 있었다.
// 트랜스파일 전
<div>
{condition ? [<span>True</span>, <span>Condition</span>] : <span>False</span>}
</div>
// 트랜스파일 후
createElement('div', null,
condition
? [createElement('span', null, 'True'), createElement('span', null, 'Condition')]
: createElement('span', null, 'False')
)
이제 Fragment를 만들어보자.
Fragment가 필요한 이유는 JSX에서 하나의 부모 요소만 허용하기 때문이다.
이는 가상 DOM을 사용하면서 구성되는 트리 구조를 더 효율적으로 비교하기 위함이다.
function Fragment(props) {
return props.children
}
이제 App 컴포넌트를 만들어 렌더링해주면 사용해볼 수 있다.
// App.js
import { createElement, Fragment } from './framework'
function Header({ name }) {
return <h1>Hello, {name}!</h1>
}
function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>
}
export function App() {
return (
<div>
<>
<p>a</p>
<p>b</p>
<div>c</div>
</>
<Header name="World" />
<Button onClick={() => alert('Button clicked!')}>Click Me</Button>
</div>
)
}
function render(component, container) {
const app = createElement(component)
container.appendChild(app)
}
document.addEventListener('DOMContentLoaded', () => {
const root = document.getElementById('app')
if (!root) {
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
}
render(App, root)
})
다 해놓고나니 한가지 마음에 안드는 점이 있었다.
바로 컴포넌트를 만들 때마다 createElement를 framework 파일에서 호출해와야한다는 점이다.
알아보니 몇가지 설정을 통해 자동으로 호출되게 만들 수 있었다.
다음은 바뀐 설정이다
// babel.config.json
{
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"pragma": "Framework.createElement",
"pragmaFrag": "Framework.Fragment",
"throwIfNamespace": false,
"runtime": "classic"
}
]
]
}
// webpack.config.js
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: './src/App.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
]
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new webpack.ProvidePlugin({
Framework: path.resolve(path.join(__dirname, 'src/framework'))
}),
],
devServer: {
static: {
directory: path.join(__dirname, 'dist')
},
compress: true,
port: 9000
},
}
이제는 export를 쓰지 않아도 똑같이 동작하는 것을 확인해볼 수 있을 것이다.
전체 코드를 보려면 https://github.com/42sungwook/Vanilla-React 여기서 확인할 수 있다.
다음 글에서는 scss (전처리 css) 모듈 적용하는 방법에 대해 다뤄볼 예정이다.
'Vanilla React' 카테고리의 다른 글
Vanilla React 2편 - SCSS 모듈 적용하기 (0) | 2024.08.22 |
---|---|
Vanilla React 시작하기 (0) | 2024.08.19 |