diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..afa7d06 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "semi": false, + "printWidth": 120 +} diff --git a/index.html b/index.html index e73eb69..6e78c5e 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,6 @@
- + diff --git a/package.json b/package.json index ca6b4f1..5815fdd 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "devDependencies": { "typescript": "~5.6.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..5a98026 --- /dev/null +++ b/readme.md @@ -0,0 +1,102 @@ +# React Hooks 만들어보면서 이해하기 + +황준일님의 useState Hooks 만들기를 참고하여, 훅 작성 규칙 및 클로저를 어떤 식으로 활용할 수 있을지 이해와 고민하자. 또한, useState뿐 아니라, useCallback, useMemo, useEffect를 어떤 식으로 구현할 수 있을지도 생각해보자. + +> [!NOTE] +> useState, useEffect, useCallback, useMemo를 구현했다면, 추가적으로 useRef도 구현해보자! + +- 구현하면서 느낀 점들을 작성하며 회고하자. +- Hooks의 규칙에 대해 이해하자. +- 클로저에 대해 명확하게 이해하자. +- 메모이제이션을 어떻게 구현할 수 있을지 고민해보자. +- 모르는 것들이나 새롭게 알게 된 것들이 있다면 적극적으로 공유하며 서로의 학습을 확장해 보자. + +[황준일님 Vanilla Javascript로 React UseState Hook 만들기 링크](https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Make-useSate-hook/) + +## 🚀 학습 목표 + +- React Hook을 이해하고 규칙과 연관지어 설명할 수 있다. +- 클로저를 이해하고, 설명할 수 있다 +- React에서 메모이제이션 및 사이드 이펙트를 어떤 방식으로 처리하는지 추론할 수 있다. + +## 📝 Pull Request 주의할 점. + +- **학습과 고민의 흔적 기록** + - 본인이 배우고 고민했던 점들을 최대한 꼼꼼하게 작성해주세요. + - 작성된 내용은 리뷰어가 의도를 이해하고 더 나은 피드백을 제공하는 데 큰 도움이 됩니다. +- **코드리뷰는 존중과 솔직함을 기반으로** + - 서로 상처받지 않도록 배려하며 리뷰를 작성해주세요. + - 하지만, 서로의 발전을 위해 솔직한 피드백을 주고받는 문화를 지향합시다. +- **PR 방식** + - [본인 이름] React Hooks (본인 이름 브랜치에 PR 올려주세요) + - 위와 같은 방식으로 PR 올려주세요. + +## 🤖 GPT의 아티클 요약 + +# React의 `useState`를 바닐라 JS로 구현하기 + +React의 상태 관리 메커니즘인 `useState` 훅을 바닐라 자바스크립트로 구현하는 과정을 다룬 글입니다. React의 상태 관리 및 렌더링 동작 방식을 깊이 있게 이해할 수 있습니다. + +--- + +## 주요 내용 + +### 1. React의 `useState` 이해 + +- **React의 상태 관리 특징**: + + - React에서는 상태(`state`)가 컴포넌트 외부에서 관리됩니다. + - 상태는 컴포넌트 함수가 반복적으로 호출되더라도 초기화되지 않고 유지됩니다. + +- **`useState`의 역할**: + - 상태와 상태 업데이트 함수(`setState`)를 제공합니다. + - 상태 변경 시 컴포넌트를 재렌더링하여 UI를 업데이트합니다. + +--- + +### 2. 바닐라 JS에서 `useState` 구현 + +- **목표**: + + - React의 `useState`와 유사한 기능을 바닐라 자바스크립트로 구현합니다. + +- **핵심 기능**: + - 상태를 외부에서 배열로 관리하여 초기화 및 유지. + - 상태 변경 시 업데이트 함수(`setState`)를 통해 상태를 변경. + - 상태 변경 시 렌더 함수(`render()`) 호출로 컴포넌트를 재렌더링. + +--- + +### 3. 렌더링 관리 + +- 상태 변경 시 컴포넌트를 다시 렌더링하도록 설정합니다. +- 렌더링이 발생할 때 모든 상태 인덱스를 초기화하여 올바른 상태를 참조하도록 합니다. + +--- + +### 4. 최적화 + +- **불필요한 렌더링 방지**: + + - 상태가 변경되지 않으면 렌더링을 생략합니다. + +- **다중 상태 관리**: + + - 상태를 배열로 관리하여 효율성을 높입니다. + +- **컴포넌트 재사용성**: + - 상태 관리 로직과 렌더링 로직을 분리하여 유지보수와 재사용성을 강화합니다. + +--- + +## 학습 포인트 + +1. React의 상태 관리 메커니즘을 바닐라 자바스크립트로 직접 구현하며 학습할 수 있습니다. +2. 상태 저장, 업데이트, 렌더링 동작을 이해하며 React의 내부 동작 원리를 더 잘 알게 됩니다. +3. React 외의 환경에서도 상태 관리를 구현할 수 있는 방법론을 제공합니다. + +--- + +## 결론 + +React의 `useState` 훅을 직접 구현하며, 상태 관리의 기본 개념을 학습할 수 있습니다. 또한 상태와 렌더링 로직을 분리하여 코드의 유지보수성과 재사용성을 높이는 방법을 익힐 수 있습니다. diff --git a/src/App.ts b/src/App.ts new file mode 100644 index 0000000..02157b5 --- /dev/null +++ b/src/App.ts @@ -0,0 +1,6 @@ +import { render } from "./core/client" +import Main from "./pages/main" + +const rootElement = document.getElementById("app")! as HTMLDivElement + +render(rootElement, Main) diff --git a/src/components/Counter.css b/src/components/Counter.css new file mode 100644 index 0000000..b9a8f4e --- /dev/null +++ b/src/components/Counter.css @@ -0,0 +1,3 @@ +.count-wrapper { + color: red; +} diff --git a/src/components/Counter.tsx b/src/components/Counter.tsx new file mode 100644 index 0000000..3a763c7 --- /dev/null +++ b/src/components/Counter.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from "@/core/hooks" +import "./Counter.css" +import { useCallback } from "@/core/hooks/useCallback" + +export default function Counter() { + const [count, setCount] = useState(1) + + const handleIncrease = () => setCount(count + 1) + const handleDecrease = () => setCount(count - 1) + + useEffect(() => { + const setupCount = () => setCount(100) + setupCount() + }, []) + + return ( +
+

{count}

+ + +
+ ) +} diff --git a/src/core/client.ts b/src/core/client.ts new file mode 100644 index 0000000..8ec3892 --- /dev/null +++ b/src/core/client.ts @@ -0,0 +1,40 @@ +import { updateElement } from "@/core/jsx/jsx-runtime" +import { internals } from "./sharedInternals" +import { Component } from "./jsx/jsx-runtime.type" + +/** + * Note: 현재 microtaskQueue 활용으로 `frameRunner` 함수를 사용하지 않음 + */ +function frameRunner(callback: () => void) { + let rafId: ReturnType + + return () => { + if (rafId) { + cancelAnimationFrame(rafId) + } + + rafId = requestAnimationFrame(callback) + } +} + +export const { render, reRender } = (function () { + const render = (element: HTMLElement, component: Component) => { + internals.rootElement = element + internals.rootComponent = component + reRender() + } + + const reRender = () => { + if (!internals.rootElement || !internals.rootComponent) return + const newVDOM = internals.rootComponent() + + updateElement(internals.rootElement, newVDOM, internals.currentVDOM) + internals.currentHookIndex = 0 + internals.currentVDOM = newVDOM + + internals.effectList.filter((effectHook) => effectHook).forEach((fn) => fn()) + internals.effectList = [] + } + + return { render, reRender } +})() diff --git a/src/core/hooks/hooks.type.ts b/src/core/hooks/hooks.type.ts new file mode 100644 index 0000000..8a68a68 --- /dev/null +++ b/src/core/hooks/hooks.type.ts @@ -0,0 +1,2 @@ +export type Dependencies = any[] +export type Callback = (...args: any[]) => void diff --git a/src/core/hooks/index.ts b/src/core/hooks/index.ts new file mode 100644 index 0000000..2d03a26 --- /dev/null +++ b/src/core/hooks/index.ts @@ -0,0 +1,2 @@ +export { useState } from "./useState" +export { useEffect } from "./useEffect" diff --git a/src/core/hooks/useCallback.ts b/src/core/hooks/useCallback.ts new file mode 100644 index 0000000..accf2c8 --- /dev/null +++ b/src/core/hooks/useCallback.ts @@ -0,0 +1,25 @@ +import { internals } from "../sharedInternals" +import { Callback, Dependencies } from "./hooks.type" + +export function useCallback(callback: Callback, dependencies?: Dependencies) { + const currentIndex = internals.currentHookIndex + const [oldValue, oldDependencies] = internals.hooks[currentIndex] || [] + + let cachedFunction: Callback = oldValue + let hasChanged = true + + if (oldDependencies) { + hasChanged = dependencies + ? dependencies.some((dependency, index) => !Object.is(dependency, oldDependencies[index])) + : true + } + + if (hasChanged) { + cachedFunction = callback + internals.hooks[currentIndex] = [callback, dependencies] + } + + internals.currentHookIndex++ + + return cachedFunction +} diff --git a/src/core/hooks/useEffect.ts b/src/core/hooks/useEffect.ts new file mode 100644 index 0000000..c8f9284 --- /dev/null +++ b/src/core/hooks/useEffect.ts @@ -0,0 +1,21 @@ +import { internals } from "../sharedInternals" +import { Callback, Dependencies } from "./hooks.type" + +export function useEffect(callback: Callback, dependencies?: Dependencies) { + const currentIndex = internals.currentHookIndex + const oldDependencies = internals.hooks[currentIndex] + let hasChanged = true + + if (oldDependencies) { + hasChanged = dependencies + ? dependencies.some((dependency, index) => !Object.is(dependency, oldDependencies[index])) + : true + } + + if (hasChanged) { + internals.hooks[currentIndex] = dependencies || null + internals.effectList[currentIndex] = callback + } + + internals.currentHookIndex++ +} diff --git a/src/core/hooks/useMemo.ts b/src/core/hooks/useMemo.ts new file mode 100644 index 0000000..698e967 --- /dev/null +++ b/src/core/hooks/useMemo.ts @@ -0,0 +1,25 @@ +import { internals } from "../sharedInternals" +import { Callback, Dependencies } from "./hooks.type" + +export function useMemo(callback: Callback, dependencies?: Dependencies) { + const currentIndex = internals.currentHookIndex + const [oldValue, oldDependencies] = internals.hooks[currentIndex] || [] + + let hasChanged = true + let memoValue = oldValue || null + + if (oldDependencies) { + hasChanged = dependencies + ? dependencies.some((dependency, index) => !Object.is(dependency, oldDependencies[index])) + : true + } + + if (hasChanged) { + memoValue = callback() + internals.hooks[currentIndex] = [memoValue, dependencies] + } + + internals.currentHookIndex++ + + return memoValue +} diff --git a/src/core/hooks/useState.ts b/src/core/hooks/useState.ts new file mode 100644 index 0000000..490fd9c --- /dev/null +++ b/src/core/hooks/useState.ts @@ -0,0 +1,38 @@ +import { reRender } from "../client" +import { internals } from "../sharedInternals" + +export function useState(initialState: T) { + const currentIndex = internals.currentHookIndex + const state = internals.hooks[currentIndex] ?? initialState + + const setState = (newState: T) => { + if (Object.is(state, newState)) { + return + } + + const addBatchQueue = () => { + internals.hooks[currentIndex] = newState + } + + internals.batchQueue.push(addBatchQueue) + scheduleBatch() + } + + internals.currentHookIndex++ + + return [state, setState] +} + +function flushBatchQueue() { + internals.batchQueue.forEach((fn) => fn()) + internals.batchQueue = [] + reRender() + internals.isBatching = false +} + +function scheduleBatch() { + if (!internals.isBatching) { + internals.isBatching = true + queueMicrotask(flushBatchQueue) + } +} diff --git a/src/core/jsx/index.ts b/src/core/jsx/index.ts new file mode 100644 index 0000000..7f7bc12 --- /dev/null +++ b/src/core/jsx/index.ts @@ -0,0 +1,3 @@ +import { createElement, h } from "@/core/jsx/jsx-runtime" + +export { createElement, h } diff --git a/src/core/jsx/jsx-runtime.ts b/src/core/jsx/jsx-runtime.ts new file mode 100644 index 0000000..304a845 --- /dev/null +++ b/src/core/jsx/jsx-runtime.ts @@ -0,0 +1,101 @@ +import type { Type, VDOM, Props, VNode } from "./jsx-runtime.type" +import { isNullOrUndefined, isPrimitive, isVDOM, isDiffText } from "./jsx-runtime.utils" + +function h(type: Type, props: Props, ...children: VNode[]): VDOM { + if (typeof type === "function") return type(props) + return { type, props: props || {}, children: children.flat() } +} + +function createElement(node: VNode) { + if (isNullOrUndefined(node)) { + return document.createDocumentFragment() + } + + if (isPrimitive(node)) { + return document.createTextNode(String(node)) + } + + const element = document.createElement(node.type as keyof HTMLElementTagNameMap) + + setAttribute(element, node.props) + node.children.map(createElement).forEach((child) => element.appendChild(child)) + + return element +} + +function setAttribute(element: HTMLElement, props: Props) { + Object.entries(props) + .filter(([_, value]) => value) + .forEach(([attr, value]) => { + if (attr.startsWith("on") && typeof props[attr] === "function") { + if (attr === "onChange") { + element.addEventListener("input", props[attr]) + } + + const eventType = attr.slice(2).toLowerCase() + element.addEventListener(eventType, props[attr]) + } + + if (attr === "className") { + element.setAttribute("class", value) + return + } + + element.setAttribute(attr, value) + }) +} + +function updateAttributes(element: HTMLElement, newProps: Props = {}, oldProps: Props = {}) { + for (const [attr, value] of Object.entries(newProps)) { + if (newProps[attr] === oldProps[attr]) continue + + if (attr.startsWith("on") && typeof value === "function") { + const eventType = attr.slice(2).toLowerCase() + element.removeEventListener(eventType, oldProps[attr]) + element.addEventListener(eventType, newProps[attr]) + } + + element.setAttribute(attr, value) + } + + for (const [attr] of Object.entries(oldProps)) { + if (newProps[attr]) continue + element.removeAttribute(attr) + } +} + +export function updateElement(parent: HTMLElement, newNode?: VNode, oldNode?: VNode, index: number = 0) { + if (isNullOrUndefined(newNode) && oldNode) { + parent.removeChild(parent.childNodes[index]) + return + } + + if (newNode && isNullOrUndefined(oldNode)) { + parent.appendChild(createElement(newNode)) + return + } + + if (isDiffText(newNode, oldNode)) { + parent.replaceChild(createElement(newNode), parent.childNodes[index]) + return + } + + if (!isVDOM(newNode) || !isVDOM(oldNode)) return + + if (newNode.type !== oldNode.type) { + parent.replaceChild(createElement(newNode), parent.childNodes[index]) + return + } + + if (parent.childNodes[index]) { + updateAttributes(parent.childNodes[index] as HTMLElement, newNode.props, oldNode.props) + } + + const maxLength = Math.max(newNode.children.length, oldNode.children.length) + + for (let i = 0; i < maxLength; i++) { + updateElement(parent.childNodes[index] as HTMLElement, newNode.children[i], oldNode.children[i], i) + } +} + +export { h, createElement } diff --git a/src/core/jsx/jsx-runtime.type.ts b/src/core/jsx/jsx-runtime.type.ts new file mode 100644 index 0000000..84ce74d --- /dev/null +++ b/src/core/jsx/jsx-runtime.type.ts @@ -0,0 +1,13 @@ +export type Type = keyof HTMLElementTagNameMap | Component + +export type Props = Record + +export type VNode = string | number | null | undefined | VDOM + +export type Component = (props?: Props) => VDOM + +export interface VDOM { + type: Type + props: Record + children: VNode[] +} diff --git a/src/core/jsx/jsx-runtime.utils.ts b/src/core/jsx/jsx-runtime.utils.ts new file mode 100644 index 0000000..19374d2 --- /dev/null +++ b/src/core/jsx/jsx-runtime.utils.ts @@ -0,0 +1,19 @@ +import { VNode } from "./jsx-runtime.type" + +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 +} + +export function isDiffText(newNode: VNode, oldNode: VNode) { + if (JSON.stringify(newNode) === JSON.stringify(oldNode)) return false + if (typeof newNode === "object" || typeof oldNode === "object") return false + return true +} diff --git a/src/core/sharedInternals.ts b/src/core/sharedInternals.ts new file mode 100644 index 0000000..c1d2a9a --- /dev/null +++ b/src/core/sharedInternals.ts @@ -0,0 +1,26 @@ +import { Component, VNode } from "./jsx/jsx-runtime.type" + +interface Internals { + hooks: any[] + currentHookIndex: number + rootComponent: null | Component + rootElement: null | HTMLElement + currentVDOM: null | VNode + effectList: (() => void)[] + isBatching: boolean + batchQueue: (() => void)[] +} + +/** + * NOTE - 이 객체는 코어 객체입니다. 값을 절대로 변경하지 마세요. 만약 값을 변경하게되면 제대로 동작하지 않을 수 있습니다. + */ +export const internals: Internals = { + hooks: [], + currentHookIndex: 0, + rootComponent: null, + rootElement: null, + currentVDOM: null, + effectList: [], + isBatching: false, + batchQueue: [], +} diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..5b9643c --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,5 @@ +declare namespace JSX { + interface IntrinsicElements { + [elemName: string]: any + } +} diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 63589ae..0000000 --- a/src/main.ts +++ /dev/null @@ -1 +0,0 @@ -// Your code.. diff --git a/src/pages/main.tsx b/src/pages/main.tsx new file mode 100644 index 0000000..71439af --- /dev/null +++ b/src/pages/main.tsx @@ -0,0 +1,9 @@ +import Counter from "@/components/Counter" + +export default function Main() { + return ( +
+ +
+ ) +} diff --git a/tsconfig.json b/tsconfig.json index a4883f2..7b15cd7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,15 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + "jsx": "react-jsx", + "jsxImportSource": "@/core/jsx", + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["src"] } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5e29256 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite" +import tsconfigPaths from "vite-tsconfig-paths" + +export default defineConfig({ + plugins: [tsconfigPaths()], + esbuild: { + jsx: "transform", + jsxFactory: "h", + jsxInject: `import { h } from '@/core/jsx'`, + }, +}) diff --git a/yarn.lock b/yarn.lock index 8e382b7..b0f5f41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -486,7 +486,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:^4.1.1, debug@npm:^4.3.4": version: 4.4.0 resolution: "debug@npm:4.4.0" dependencies: @@ -689,6 +689,13 @@ __metadata: languageName: node linkType: hard +"globrex@npm:^0.1.2": + version: 0.1.2 + resolution: "globrex@npm:0.1.2" + checksum: 10c0/a54c029520cf58bda1d8884f72bd49b4cd74e977883268d931fd83bcbd1a9eb96d57c7dbd4ad80148fb9247467ebfb9b215630b2ed7563b2a8de02e1ff7f89d1 + languageName: node + linkType: hard + "graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -1037,6 +1044,7 @@ __metadata: dependencies: typescript: "npm:~5.6.2" vite: "npm:^6.0.5" + vite-tsconfig-paths: "npm:^5.1.4" languageName: unknown linkType: soft @@ -1274,6 +1282,20 @@ __metadata: languageName: node linkType: hard +"tsconfck@npm:^3.0.3": + version: 3.1.4 + resolution: "tsconfck@npm:3.1.4" + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + bin: + tsconfck: bin/tsconfck.js + checksum: 10c0/5120e91b3388574b449d57d08f45d05d9966cf4b9d6aa1018652c1fff6d7d37b1ed099b07e6ebf6099aa40b8a16968dd337198c55b7274892849112b942861ed + languageName: node + linkType: hard + "typescript@npm:~5.6.2": version: 5.6.3 resolution: "typescript@npm:5.6.3" @@ -1312,6 +1334,22 @@ __metadata: languageName: node linkType: hard +"vite-tsconfig-paths@npm:^5.1.4": + version: 5.1.4 + resolution: "vite-tsconfig-paths@npm:5.1.4" + dependencies: + debug: "npm:^4.1.1" + globrex: "npm:^0.1.2" + tsconfck: "npm:^3.0.3" + peerDependencies: + vite: "*" + peerDependenciesMeta: + vite: + optional: true + checksum: 10c0/6228f23155ea25d92b1e1702284cf8dc52ad3c683c5ca691edd5a4c82d2913e7326d00708cef1cbfde9bb226261df0e0a12e03ef1d43b6a92d8f02b483ef37e3 + languageName: node + linkType: hard + "vite@npm:^6.0.5": version: 6.0.7 resolution: "vite@npm:6.0.7"