diff --git a/.gitignore b/.gitignore index a547bf3..a88a1bf 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +yarn.lock +install-state.gz \ No newline at end of file diff --git a/index.html b/index.html index 4c93138..134130a 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,6 @@
- + diff --git a/package.json b/package.json index ff687e2..088e348 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "devDependencies": { "typescript": "~5.6.2", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c77483d --- /dev/null +++ b/readme.md @@ -0,0 +1,147 @@ +# Vanilla Javascript로 Virtual DOM 만들기 + +황준일님의 Vanilla Javascript로 Virtual DOM 만들기 아티클을 참고하여, 브라우저 렌더링 과정에 대해 이해하고, 성능적인 고민과 Virtual DOM이 해결하려는 문제를 파악하자 + +- 구현하면서 느낀 점들을 작성하며 회고하자. +- Virtual DOM이 해결하려는 문제를 인지하면서 구현해보자. +- 모르는 것들이나 새롭게 알게 된 것들이 있다면 적극적으로 공유하며 서로의 학습을 확장해 보자. + +[황준일님 Vanilla Javascript로 Virtual DOM 만들기 링크](https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Virtual-DOM/#_1-virtualdom%E1%84%8B%E1%85%B5-%E1%84%81%E1%85%A9%E1%86%A8-%E1%84%91%E1%85%B5%E1%86%AF%E1%84%8B%E1%85%AD%E1%84%92%E1%85%A1%E1%86%AB%E1%84%80%E1%85%A1) + +## 🚀 학습 목표 + +1. 브라우저 렌더링 과정에 대해 명확히 이해하자. +2. Virtual DOM이 해결하려는 문제를 파악하고, 어떻게 개선할 수 있는지를 파악해보자. +3. Diff 알고리즘이 어떤 방식으로 동작하는 이해하자. +4. Virtual DOM을 사용했을 때와 사용하지 않았을 때를 비교해보면서 느껴보자. + +## 📝 Pull Request 주의할 점. + +- **학습과 고민의 흔적 기록** + - 본인이 배우고 고민했던 점들을 최대한 꼼꼼하게 작성해주세요. + - 작성된 내용은 리뷰어가 의도를 이해하고 더 나은 피드백을 제공하는 데 큰 도움이 됩니다. +- **코드리뷰는 존중과 솔직함을 기반으로** + - 서로 상처받지 않도록 배려하며 리뷰를 작성해주세요. + - 하지만, 서로의 발전을 위해 솔직한 피드백을 주고받는 문화를 지향합시다. +- **PR 방식** + - [본인 이름] Virtual DOM (본인 이름 브랜치에 PR 올려주세요) + - 위와 같은 방식으로 PR 올려주세요. + +## 🤖 GPT의 아티클 요약 + +# 브라우저의 렌더링 과정 + +## 1. HTML과 CSS 파싱 + +- 브라우저는 HTML과 CSS를 읽어 DOM 트리와 CSSOM 트리를 생성합니다. +- 이후, DOM과 CSSOM을 결합하여 렌더 트리를 구성합니다. + +## 2. 레이아웃과 페인트 + +- **레이아웃**: 각 요소의 위치와 크기를 계산합니다. +- **페인트**: 렌더 트리를 픽셀로 변환하여 화면에 표시합니다. + +## 3. 성능 문제 + +- DOM이나 CSS에 변화가 생길 경우, 브라우저는 **reflow(레이아웃 다시 계산)**나 **repaint(화면 다시 그림)**를 실행합니다. +- 이러한 작업은 성능 저하의 주요 원인이 됩니다. + +--- + +# 가상 DOM(Virtual DOM)의 필요성 + +- DOM 조작이 복잡하고 잦아질수록 브라우저의 렌더링 성능이 저하됩니다. +- **가상 DOM**은 이러한 문제를 해결하기 위해 도입된 개념입니다. + - 메모리 상에서 DOM 구조를 본뜬 **자바스크립트 객체**를 생성합니다. + - 변경 사항을 가상 DOM에서 비교(diff)하고, 실제 DOM에 **최소한의 업데이트**만 적용합니다. + +--- + +# 가상 DOM의 기본 원리 + +## 1. 가상 DOM 생성 + +- 실제 DOM을 대신할 **자바스크립트 객체**를 만듭니다. +- 각 요소는 객체로 표현되며, 속성, 자식 요소, 이벤트 등을 포함합니다. + +## 2. 가상 DOM에서 실제 DOM 생성 + +- 가상 DOM 객체를 기반으로 실제 DOM 요소를 동적으로 생성합니다. +- 이 과정은 초기 렌더링 시에만 수행됩니다. + +## 3. Diff 알고리즘 + +- 이전 가상 DOM과 새로운 가상 DOM을 비교합니다. +- 변경된 부분만 찾아 실제 DOM에 업데이트합니다. +- 이를 통해 불필요한 렌더링 작업을 줄이고 성능을 최적화합니다. + +## 4. 최소 DOM 조작 + +- 가상 DOM에서 찾은 차이점만 실제 DOM에 반영합니다. +- 이를 통해 브라우저의 렌더링 부담을 줄이고 효율성을 높입니다. + +--- + +# Diff 알고리즘의 작동 방식 + +## 1. 요소 비교 + +- 두 가상 DOM 객체를 비교하여 같은 요소인지 판단합니다. +- 만약 타입이 다르면 해당 요소를 통째로 교체합니다. + +## 2. 속성 비교 + +- 두 요소의 속성을 비교하여 변경된 속성만 업데이트합니다. +- 속성이 제거된 경우, 실제 DOM에서도 제거합니다. + +## 3. 자식 노드 비교 + +- 자식 노드를 순회하며 재귀적으로 diff를 수행합니다. +- 추가되거나 삭제된 자식 노드만 업데이트합니다. + +--- + +# 가상 DOM의 장점 + +## 1. 성능 최적화 + +- 변경된 부분만 DOM에 반영하므로, 브라우저의 작업량을 줄일 수 있습니다. +- 렌더링 과정에서 발생하는 reflow와 repaint를 최소화합니다. + +## 2. 코드 유지보수성 향상 + +- DOM 조작 코드의 복잡성을 줄이고, 선언형 프로그래밍 방식을 채택할 수 있습니다. +- React나 Vue와 같은 라이브러리가 이러한 방식을 활용합니다. + +## 3. 추상화 + +- 개발자는 DOM 조작의 세부 사항을 신경 쓰지 않아도 됩니다. +- 변경 사항을 선언적으로 표현하면, 가상 DOM이 알아서 처리합니다. + +--- + +# 바닐라 자바스크립트로 구현하는 방법 + +- 가상 DOM을 이해하기 위해 바닐라 자바스크립트로 간단한 버전을 구현할 수 있습니다. +- 주요 단계는 다음과 같습니다: + 1. **가상 DOM 구조 정의**: 가상 DOM을 표현하는 데이터 구조를 설계합니다. + 2. **가상 DOM을 실제 DOM으로 변환**: 가상 DOM 객체를 기반으로 실제 DOM 요소를 생성합니다. + 3. **Diff 알고리즘 구현**: 이전 상태와 새로운 상태를 비교하여 변경 사항을 찾습니다. + 4. **DOM 업데이트**: Diff 결과를 바탕으로 최소한의 DOM 조작을 수행합니다. + +--- + +# 컴포넌트와 상태 관리의 통합 + +- 가상 DOM은 **컴포넌트 기반 개발**과 **상태 관리**에도 적합합니다. + - 각 컴포넌트는 자신의 상태(state)를 관리합니다. + - 상태 변화가 발생하면 해당 컴포넌트만 다시 렌더링합니다. +- 이를 통해 애플리케이션의 **구조화**와 **성능 최적화**가 동시에 이루어집니다. + +--- + +# 결론 + +- 가상 DOM은 복잡한 DOM 조작 문제를 해결하기 위한 강력한 도구입니다. +- React와 Vue 같은 라이브러리가 이를 활용하여 선언형 프로그래밍과 성능 최적화를 제공합니다. +- 바닐라 자바스크립트로 간단한 가상 DOM을 구현해 보면, 가상 DOM의 내부 원리를 이해하고 DOM 조작에 대한 깊은 통찰을 얻을 수 있습니다. diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..2613a68 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,31 @@ +import { useState } from './lib/core/hooks'; + +function Wrapper() { + return ( + <> +

HelloWorld

+ + ); +} + +export default function App() { + const [count, setCount] = useState(1); + const handleIncrease = () => { + setCount(count + 1); + }; + + // return ( + //
+ //

{count}

+ // + //
+ // ); + + return ( + <> +

{count}

+ + + + ); +} diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..c39c2cf --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,5 @@ +declare namespace JSX { + interface IntrinsicElements { + [elemName: string]: any; + } +} diff --git a/src/lib/core/hooks.ts b/src/lib/core/hooks.ts new file mode 100644 index 0000000..8ecf6e4 --- /dev/null +++ b/src/lib/core/hooks.ts @@ -0,0 +1,42 @@ +import { updateElement } from '@/lib/jsx/jsx-runtime'; +import { Component } from '../jsx/jsx-runtime.type'; +import { Internals } from './hooks.type'; + +export const { useState, render } = (function () { + const INTERNALS: Internals = { + rootElement: null, + rootComponent: null, + currentVDOM: null, + states: [], + hookIndex: 0, + }; + + const render = (rootElement: HTMLElement, component: Component) => { + INTERNALS.rootElement = rootElement; + INTERNALS.rootComponent = component; + _render(); + }; + + const _render = () => { + const newVDOM = INTERNALS.rootComponent!(); + updateElement(INTERNALS.rootElement!, newVDOM, INTERNALS.currentVDOM); + INTERNALS.hookIndex = 0; + INTERNALS.currentVDOM = newVDOM; + }; + + const useState = (initialState: T) => { + const index = INTERNALS.hookIndex; + const state = INTERNALS.states[index] ?? initialState; + + const setState = (newState: T) => { + INTERNALS.states[index] = newState; + _render(); + }; + + INTERNALS.hookIndex++; + + return [state, setState]; + }; + + return { useState, render }; +})(); diff --git a/src/lib/core/hooks.type.ts b/src/lib/core/hooks.type.ts new file mode 100644 index 0000000..ce45109 --- /dev/null +++ b/src/lib/core/hooks.type.ts @@ -0,0 +1,9 @@ +import { VDOM, Component } from '../jsx/jsx-runtime.type'; + +export interface Internals { + rootElement: HTMLElement | null; + rootComponent: Component | null; + currentVDOM: null | VDOM; + states: any[]; + hookIndex: number; +} diff --git a/src/lib/jsx/index.ts b/src/lib/jsx/index.ts new file mode 100644 index 0000000..48ad347 --- /dev/null +++ b/src/lib/jsx/index.ts @@ -0,0 +1,4 @@ +import { h } from '@/lib/jsx/jsx-runtime'; +import { FRAGMENT } from './jsx-runtime.constant'; + +export { h, FRAGMENT }; diff --git a/src/lib/jsx/jsx-runtime.constant.ts b/src/lib/jsx/jsx-runtime.constant.ts new file mode 100644 index 0000000..dae6768 --- /dev/null +++ b/src/lib/jsx/jsx-runtime.constant.ts @@ -0,0 +1 @@ +export const FRAGMENT = 'FRAGMENT' as const; diff --git a/src/lib/jsx/jsx-runtime.ts b/src/lib/jsx/jsx-runtime.ts new file mode 100644 index 0000000..a0887c5 --- /dev/null +++ b/src/lib/jsx/jsx-runtime.ts @@ -0,0 +1,112 @@ +import { isHTMLElement, isNullOrUndefined, isPrimitive, isVDOM } from './jsx-runtime.util'; +import { FRAGMENT } from './jsx-runtime.constant'; +import type { Type, Props, VNode, VDOM } from './jsx-runtime.type'; + +export function h(type: Type, props: Props, ...children: VNode[]): VDOM { + props = props ?? {}; + if (typeof type === 'function') return type(props); + return { type, props, children: children.flat() }; +} + +export function createElement(node: VNode) { + if (isNullOrUndefined(node)) { + return document.createDocumentFragment(); + } + + if (isPrimitive(node)) { + return document.createTextNode(String(node)); + } + + const element = + node.type === FRAGMENT + ? document.createDocumentFragment() + : document.createElement(node.type as keyof HTMLElementTagNameMap); + + if (isHTMLElement(element)) { + elementSetAttribute(element, node.props); + } + + node.children.map(createElement).forEach((child) => element.appendChild(child)); + + return element; +} + +function elementSetAttribute(element: HTMLElement, props: Props = {}) { + Object.entries(props) + .filter(([_, value]) => value) + .forEach(([attr, value]) => { + if (attr.startsWith('on') && typeof props[attr] === 'function') { + const eventType = attr.slice(2).toLowerCase(); + element.addEventListener(eventType, props[attr]); + } + + element.setAttribute(attr, value); + }); +} + +function diffTextVDOM(newVDOM: VNode, currentVDOM: VNode) { + if (JSON.stringify(newVDOM) === JSON.stringify(currentVDOM)) return false; + if (typeof newVDOM === 'object' || typeof currentVDOM === 'object') return false; + return true; +} + +export function updateElement(parent: HTMLElement, newNode?: VNode, oldNode?: VNode, index: number = 0) { + if (!newNode && oldNode) { + parent.removeChild(parent.childNodes[index]); + return; + } + + if (newNode && !oldNode) { + parent.appendChild(createElement(newNode)); + return; + } + + if (!parent.childNodes[index]) return; + + if (diffTextVDOM(newNode, oldNode)) { + parent.replaceChild(createElement(newNode), parent.childNodes[index]); + return; + } + + if (!isVDOM(oldNode) || !isVDOM(newNode)) return; + + const maxLength = Math.max(newNode!.children.length, oldNode!.children.length); + + if (newNode.type === FRAGMENT || oldNode.type === FRAGMENT) { + const fragmentElement = parent.childNodes[index]?.parentNode || parent; + Array.from({ length: maxLength }).forEach((_, i) => + updateElement(fragmentElement as HTMLElement, newNode.children[i], oldNode.children[i], i), + ); + } + + if (newNode.type !== oldNode.type) { + parent.replaceChild(createElement(newNode), parent.childNodes[index]); + return; + } + + if (parent.childNodes.length) { + updateAttributes(parent.childNodes[index] as HTMLElement, newNode.props, oldNode.props); + } + + Array.from({ length: maxLength }).forEach((_, i) => + updateElement(parent.childNodes[index] as HTMLElement, newNode.children[i], oldNode.children[i], i), + ); +} + +function updateAttributes(target: HTMLElement, newProps: Props = {}, oldProps: Props = {}) { + for (const [attr, value] of Object.entries(newProps)) { + if (oldProps[attr] === newProps[attr]) continue; + if (attr.startsWith('on') && typeof newProps[attr] === 'function') { + const eventType = attr.slice(2).toLowerCase(); + target.removeEventListener(eventType, oldProps[attr]); + target.addEventListener(eventType, newProps[attr]); + } + + target.setAttribute(attr, value); + } + + for (const attr of Object.keys(oldProps)) { + if (newProps[attr] !== undefined) continue; + target.removeAttribute(attr); + } +} diff --git a/src/lib/jsx/jsx-runtime.type.ts b/src/lib/jsx/jsx-runtime.type.ts new file mode 100644 index 0000000..b5552ca --- /dev/null +++ b/src/lib/jsx/jsx-runtime.type.ts @@ -0,0 +1,13 @@ +import { FRAGMENT } from './jsx-runtime.constant'; + +export type Fragment = typeof FRAGMENT; +export type Type = keyof HTMLElementTagNameMap | Component | Fragment; +export type Props = Record; +export type VNode = string | number | null | undefined | VDOM; +export type Component = (props?: Props) => VDOM; + +export interface VDOM { + type: Type; + props: Props; + children: VNode[]; +} diff --git a/src/lib/jsx/jsx-runtime.util.ts b/src/lib/jsx/jsx-runtime.util.ts new file mode 100644 index 0000000..60061fd --- /dev/null +++ b/src/lib/jsx/jsx-runtime.util.ts @@ -0,0 +1,20 @@ +import type { VNode } from './jsx-runtime.type'; + +/** + * NOTE: createElement Helper Function + */ +export function isHTMLElement(element: DocumentFragment | HTMLElement) { + return element instanceof HTMLElement; +} + +export function isNullOrUndefined(node: VNode) { + return node === null || node === undefined; +} + +export function isPrimitive(node: VNode) { + return typeof node === 'string' || typeof node === 'number'; +} + +export function isVDOM(node: VNode) { + return typeof node === 'object' && node !== null && 'type' in node; +} diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 62ed0f3..0000000 --- a/src/main.ts +++ /dev/null @@ -1 +0,0 @@ -// Your Code diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..6068b65 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,6 @@ +import App from './App'; +import { render } from './lib/core/hooks'; + +const app = document.getElementById('app')!; + +render(app, App); diff --git a/tsconfig.json b/tsconfig.json index a4883f2..7583167 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,13 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "jsx": "react-jsx", + "jsxImportSource": "@/lib/jsx", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["src"] } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..3acd7fe --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + esbuild: { + jsx: 'transform', + jsxInject: `import { h, FRAGMENT } from '@/lib/jsx'`, + jsxFactory: 'h', + jsxFragment: 'FRAGMENT', + }, +});