이전 게시글에서 호환 가능한 JSX 컴포넌트를 만들었다.
이제는 JSX를 사용해서 SSR에서 반환할 HTML과 클라이언트에서 hydrate, 업데이트할 코드를 작성해볼 것이다.
이번 게시글에서 할 일
- jsx함수로 부터 반환된 miniReactNode객체를 서버에서 HTML String으로 변환할 것이다.(클라이언트로 js파일을 여기서 보내 줄 것이다.)
- jsx함수로 부터 반환된 miniReactNode객체를 클라이언트에서는 DOM으로 변환할 것이다.
- 클라이언트에서는 이전에 만들어둔 miniReact를 활용하여 useState을 통해 update할 것이다.
- update는 VDOM을 비교하여 바뀐부분만 업데이트한다.
- 받은 클라이언트는 js파일을 통해 hydrate를 하여 miniReact로 동작하는 어플리케이션을 조작할 수 있게될 것이다.
HTML생성하기
이제 만든 JSX를 통해 HTML String을 생성해주자.
MiniReactNode
type DefaultProps = Record<string, string>;
type Props = DefaultProps & {
children?: MiniReactNode[];
};
export interface MiniReactNode {
tagName: string;
props: Props;
}
이 자료구조를 활용해서 props.children을 재귀적으로 돌면서 HTML 문자열을 생성할 것이다.
_createHTML
const _createHTML = (element: string | MiniReactNode) => {
if (typeof element === 'string' || typeof element === 'number') {
//element가 text거나 number인 경우 그냥 내보낸다.
return element;
}
let HTMLString = `<${element.tagName} `; // 태그 열기
let styleProps = ``;
if (element.props) { //props를 HTML attribute로 붙인다.
Object.keys(element.props).forEach((key) => {
if (key === 'style') { //style의 경우 여러개가 붙을 수 있어서 따로 처리
const styleObject = element.props[key];
Object.entries(styleObject).forEach(([key, value]) => {
styleProps += `${key}: ${value}; `;
});
HTMLString += `style= "${styleProps}"`;
} else if (key !== 'children') { //children은 붙이지 않고 이후에 재귀적으로 만들어 붙인다.
HTMLString += `${key}= "${element.props[key]}" `;
}
});
}
HTMLString = HTMLString.trimEnd(); //'<tagName ...attribute '<- 마지막 공백을 제거한다
HTMLString += '>'; //태그를 닫아준다.
if (element.props.children) {
//children에 대해 재귀적으로 HTML을 생성해 붙인다.
element.props.children.forEach((child) => {
HTMLString += _createHTML(child);
});
}
HTMLString += `</${element.tagName}>`; //태그를 완전히 닫아준다.
return HTMLString; //최종 생성된 HTML을 반환한다.
};
createHTML
그리고 이렇게 나온 결과물에 html, head, body와 hydrate를 위한 루트요소(_miniNext)를 추가해주었다.
여기서 rollup으로 번들링 완료한 클라이언트 js파일을 보내준다.
export const createHTML = (element: MiniReactNode) => {
const root = `
<html>
<head>
<title>MiniNext</title>
</head>
<body>
<div id="_miniNext">${_createHTML(element)}</div>
</body>
<script src='index.js'></script> //클라이언트로 보내줄 js파일 (rollup으로 번들링해준 결과물)
</html>`;
return root;
};
app.tsx
이제 이렇게 변경해주고 npm run dev로 서버를 실행시켜주자.
app.get('/', (req, res) => {
const html = createHTML(<App />);
res.send(html);
});
이제 접속하면 작성한 컴포넌트대로 화면이 잘 보인다!
VDOM과 업데이트
이건 이전에 만들었던 miniReact코드를 활용했다.
따라서 이전 글을 첨부하고, 자세한 설명은 하지 않겠다.
https://0422.tistory.com/318
createDOM
마찬가치로 jsx함수를 통해 반환된 MiniReactNode에 대해서 수행하는데, 이번에는 실제 DOM요소를 만든다.
import { MiniReactNode } from './jsx-runtime';
export const makeDOM = (element: string | MiniReactNode) => {
if (typeof element === 'string' || typeof element === 'number') {
//element가 text거나 number인 경우 textNode로 만든다.
return document.createTextNode(String(element));
}
const DOMElement = document.createElement(element.tagName);
if (element.props)
//props를 실제 DOM요소에 적용시킨다.
Object.keys(element.props).forEach((key) => {
if (key === 'style') {
const styleObject = element.props[key];
Object.entries(styleObject).forEach(([key, value]) => {
(DOMElement as any).style[key] = value;
});
return;
}
if (key !== 'children') (DOMElement as any)[key] = element.props[key];
});
if (element.props.children) {
//children에 대해 재귀적으로 DOM요소를 만들어 현재 요소에 붙인다.
element.props.children.forEach((child) => {
DOMElement.appendChild(makeDOM(child));
});
}
element.ref = DOMElement;
return DOMElement; //최종 생성된 DOM요소를 반환한다.;
};
useState, useEffect
여기서는 miniReact 코드를 사용했으므로 훅을 직접 구현해주었다.
구현에 대한 설명은 https://0422.tistory.com/320 를 참고하면 좋을 것 같다.
useState에 대한 설명밖에 없으나, useEffect도 동일한 원리다. 둘다 index기반으로 작동하며, 따라서 rerender시 index를 초기화시켜주어야한다.
useState
import { rerender } from './render';
const states: any[] = []; //배열 형태의 states
let stateIndex = 0;
export const useState = <T>(initialValue: T) => {
const indexBind = stateIndex;
stateIndex++;
if (states[indexBind] === undefined) {
states.push(initialValue);
}
const setState = (updateValue: T) => {
states[indexBind] = updateValue;
rerender();
};
return [states[indexBind], setState];
};
export const initializeIndex = () => {
stateIndex = 0;
};
useEffect
type EffectCallbacks = {
func: () => void;
deps: any[];
haveToCall: boolean;
};
const effectCallbackArray: EffectCallbacks[] = [];
let effectIndex = 0;
export const useEffect = (callback: () => void, deps: any[]) => {
const indexBind = effectIndex;
effectIndex++;
if (effectCallbackArray[indexBind] === undefined) {
effectCallbackArray.push({
func: callback,
deps,
haveToCall: true,
});
return;
}
const isChange = deps.some(
(deps, index) => effectCallbackArray[indexBind].deps[index] !== deps
);
if (isChange) {
effectCallbackArray[indexBind].deps = deps;
effectCallbackArray[indexBind].haveToCall = true;
}
};
export const getEffectArray = () => effectCallbackArray;
export const initializeEffectIndex = () => (effectIndex = 0);
hydrate
hydrate는 앞서 craeteHTML에서 만들어줬던 _miniNext div요소 내부요소를 만들어준 DOM으로 교체해주는 역할을 한다.
기존의 miniReact에서의 최초의 render대신 hydrate가 이뤄지는 것이다.
export const hydrate = (content: MiniReactNode, container: HTMLElement) => {
console.log('hydrate 완료');
const element = makeDOM(content);
root = container;
prev = content;
container.innerHTML = '';
container.appendChild(element);
callEffects(); // DOM이 생성된 이후에 useEffect를 실행시킨다.
};
update, rerender
update의 경우 VDOM을 비교해서 DOM을 업데이트하는 함수다.
여기서 다루기에는 내용이 너무 많으므로 이전 게시글을 참고해주면 될것 같다.
약간 코드가 수정되었는데, github 링크를 참고하면 좋을 것 같다.
rerender는 이런식으로 구성해주었다.
let prev: MiniReactNode; //이전의 virtual DOM
let root: Element; //최상단 root요소
export const rerender = () => {
initializeIndex(); //리렌더시 useState의 index를 초기화
initializeEffectIndex(); //useEffect index 초기화
const updateElement = <App />;
updateDOM(root, prev, updateElement);
callEffects(); //useEffect콜백 호출
prev = updateElement; //업데이트가 끝나면, 요소 업데이트
};
컴포넌트 수정하기
이렇게 컴포넌트를 수정해주었다.
App.tsx
export default function App() {
return (
<div style={{ width: '100%' }}>
<Header />
<Content />
</div>
);
}
Header.tsx
export default function Header() {
return (
<header>
<h1
style={{
fontSize: '35px',
textAlign: 'center',
}}
>
MiniNext
</h1>
</header>
);
}
Content.tsx
여기서 useState를 통해 컴포넌트를 업데이트 할 것이다.
import { useState } from '@core/useState';
export default function Content() {
const [number, setNumber] = useState<number>(0);
return (
<div
style={{
...
}}
>
<div
style={{
...
}}
>
<div>Your Number : </div>
<div style={{ fontWeight: 700 }}>{number}</div>
</div>
<button
onclick={() => setNumber(number + 1)}
style={{
...
}}
>
Click This!
</button>
</div>
);
}
hydrate적용하기
이제 public/index.tsx에 hydrate를 적용하자.
public/index.tsx
import App from '../src/components/App';
import { hydrate } from '@core/render';
hydrate(<App />, document.getElementById('_miniNext'));
결과
hydrate가 정상적으로 완료되었고, state 도 잘 바뀌는 것을 확인할 수 있다!