Vanilla SPA 1: JSX & Virtual DOM Diffing

Photo by Max Chen on Unsplash

Vanilla SPA 1: JSX & Virtual DOM Diffing

대망의 나만의 리액트 스타일 SPA 만들기 시작해보겠습니다.

이번 아티클에서는

  1. JSX 파싱

  2. JSX to Virtual DOM

  3. Virtual DOM Diffing

  4. Commit

을 다뤄보도록 하겠습니다.

JSX란?

JSX란 XML 스타일의 트리 구조로 표현하고 싶은 것들을 자바스크립트와 함께 작성하는 문법입니다.

JSX의 구성 요소

  • JSXElement: HTML element와 비슷한 역할을 하는 컴포넌트

  • JSXAttributes: JSXElement에 부여할 수 있는 속성(props), 필수가 아님

  • JSXChildren: JSXElement의 자식, 0개 이상 존재 가능

  • JSXStrings: 문자열

예시

const JSXExample = (
  <JSXElement JSXAttributes={true}>
    <p>foobar</p> {/* JSXChildren */}
  </JSXElement>
)

가상돔이란?

가상돔(Virtual DOM)은 Document Object Model의 가벼운 복사본 객체 정도로 설명할 수 있습니다.

표현에 필요한 최소한의 정보만을 담고 있습니다.

아래는 구현해 본 가상돔의 예시입니다. tag, props, children 정보를 담고 있음을 알 수 있습니다.

{
  "tag": "div",
  "props": null,
  "children": [
    {
      "tag": "h1",
      "props": {
        "className": "foobar"
      },
      "children": [
        "Hello Virtual DOM!"
      ]
    },
    {
      "tag": "p",
      "props": null,
      "children": [
        "Lorem Ipsum....."
      ]
    }
  ]
}

기본 세팅

먼저, 번들러로 vite를 사용할 것입니다. Typescript 세팅이 완료되어 있는 vanilla-ts 템플릿을 사용해 시작하겠습니다.

yarn create vite@latest my-spa --template vanilla-ts
  • eslint, prettier

  • import alias 세팅 (typescript, vite)

정도만 마치고 바로 개발에 들어가겠습니다.

JSX 파싱 & JSX to Virtual DOM

먼저 JSX를 파싱하고 JSX를 가상돔으로 변환하는 기능을 구현해보겠습니다.

JSX 파싱

JSX 파싱은 vite에 내장된 esbuild의 기능을 이용하겠습니다.

참고자료

// vite.config.ts
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [tsconfigPaths()],
  esbuild: {
    jsx: 'transform',
    jsxImportSource: '@/libs/jsx',
    jsxInject: `import { jsx } from '@/libs/jsx/jsx-runtime'`,
    jsxFactory: 'jsx.toVDOM',
  },
})

vite 설정 파일을 위와 같이 설정합니다.

각각의 jsx 설정은 아래와 같은 의미라고 생각됩니다.

  • jsx: 'transform' : .jsx 파일에 나타나는 jsx 구문을 파싱하여 jsxFactory 에 명시된 함수의 호출로 바꿔줍니다.

  • jsxImportSource : jsx-runtime.ts 파일의 위치를 지정해줍니다.

  • jsxInject : .jsx 파일에 해당 임포트 구문을 주입해줍니다.

  • jsxFactory : jsx-runtime.ts 내에서 해당 함수를 찾아서 jsx 파싱에 사용합니다.

jsxFactory 커스텀 함수

위와 같이 JSX 파싱은 간단하게 번들러에게 맡기고 커스텀 함수만 구현해보겠습니다.

jsxFactory 함수의 입력은 리액트의 createElement 함수의 입력과 같습니다.

// @/libs/jsx/jsx-runtime.ts
declare global {
  module JSX {
    type IntrinsicElements = {
      [elemName in keyof HTMLElementTagNameMap]: Record<string, unknown>
    }
    interface Element {
      tag: keyof HTMLElementTagNameMap
      props: Record<string, unknown>
      children: VirtualDOMNode[]
    }
  }
}

export type Component<T extends DefaultProps = DefaultProps> = (
  props: T,
) => JSX.Element

export const jsx = {
  toVDOM(
    component: string | Component,
    props: Record<string, unknown> | null,
    ...children: VirtualDOMNode[]
  ) {
    if (typeof component === 'function') {
      return component({ ...props, children })
    }
    return {
      tag: component,
      props,
      children: children.flat(Infinity),
    }
  },
}

구현한 JSX to VDOM 함수인데, 특이사항으로 jsx에 다른 컴포넌트가 들어오면 재귀적으로 컴포넌트를 실행해주어 VDOM 트리를 만들어 줍니다. (컴포넌트 리턴값은 esbuild에 의해 jsxFactory 함수로 대체됩니다. 따라서 재귀적 실행..)

예시

번들러를 통해 .tsx파일을 실행하면 아래와 같이 가상돔이 잘 생성되는 것을 알 수 있습니다.

// @/App.tsx

const App: Component = () => (
  <div>
    <h1 className="foobar">Hello Virtual DOM!</h1>
  </div>
)

console.log(JSON.stringify(App(), null, 2))
// output
{
  "tag": "div",
  "props": null,
  "children": [
    {
      "tag": "h1",
      "props": {
        "className": "foobar"
      },
      "children": [
        "Hello Virtual DOM!"
      ]
    },
    {
      "tag": "p",
      "props": null,
      "children": [
        "Lorem Ipsum....."
      ]
    }
  ]
}

Diff & commit

만들어진 트리 형태의 가상돔의 변경점을 감지하여 변경점 아래만 실제 돔으로 커밋하는 로직을 작성해보겠습니다.

크게 가상돔 Diff와 변경사항의 실제 돔 생성/업데이트 로직으로 나눌 수 있습니다.

가상돔 Diff 알고리즘

  1. 기존 가상돔과 새로운 가상돔을 루트 노드부터 비교해나갑니다.

  2. 만약 기존 노드가 없는데, 새 노드가 나타나 있다면 DOM 추가가 필요한 상태 입니다.

  3. 만약 기존 노드는 있는데, 새 노드가 없다면 DOM 제거가 필요한 상태 입니다.

  4. 만약 둘 다 있는데 같은 가상돔이 아니라면 DOM 교체가 필요한 상태 입니다.

위와 같은 가상돔 비교를 수행하며,
각각의 상태에 놓이게 되면 실제 돔에 변경사항을 반영해줍니다.
그리고 자식이 있다면 재귀적으로 비교를 수행합니다.

이를 코드로 옮기면 다음과 같습니다.

function updateDOM(
  $parent: ChildNode,
  oldNode?: VirtualDOM,
  newNode?: VirtualDOM,
  idx = 0,
) {
  if (newNode === undefined) {
    if (oldNode !== undefined) {
      $parent.removeChild($parent.childNodes[idx])
      return true
    }
    return false
  }

  if (oldNode === undefined) {
    $parent.appendChild(createDOM(newNode))
    return false
  }

  if (!checkIsSameVDOM(oldNode, newNode)) {
    $parent.replaceChild(createDOM(newNode), $parent.childNodes[idx])
    return false
  }

  if (!checkIsTextNode(newNode) && !checkIsTextNode(oldNode)) {
    const length = Math.max(
      newNode.children?.length ?? 0,
      oldNode.children?.length ?? 0,
    )
    let nodeDeleteCnt = 0
    for (let i = 0; i < length; i++) {
      const isNodeDeleted = updateDOM(
        $parent?.childNodes[idx],
        oldNode.children?.[i],
        newNode.children?.[i],
        i - nodeDeleteCnt,
      )
      if (isNodeDeleted) {
        nodeDeleteCnt++
      }
    }
  }

  return false
}

특이한 점으로 노드가 삭제될 때 splice 형식으로 삭제되기 때문에 그 후로 주어지는 자식 노드 인덱스와 가상돔의 인덱스가 맞지 않는 이슈를 발견하여 함수의 리턴값으로 불리언을 주고, 노드가 삭제됐을 때만 카운팅을 해서 처리했습니다.

createDOM

위 코드를 보면 곳곳에 실제 DOM이 필요한 곳에 createDOM 함수를 사용하는 것을 알 수 있습니다.

이 함수는 가상돔을 실제돔으로 바꿔주는 역할을 합니다.

  1. 가상돔이 textNode라면 textNode를 만들어줍니다.

  2. 아닐 경우 element를 만들고 props를 지정해줍니다.

  3. 자식이 있다면 재귀적으로 수행해줍니다.

  4. 결과물을 리턴해줍니다.

function createDOM(node: VirtualDOM): HTMLElement | Text {
  if (checkIsTextNode(node)) {
    if (typeof node === 'object' && node != null) {
      return document.createTextNode(JSON.stringify(node))
    }
    return document.createTextNode(node == null ? '' : node.toString())
  }

  const element = document.createElement(node.tag)

  if (node.props) {
    for (const key in node.props) {
      if (key.startsWith('data-')) {
        const dataKey = _.camelCase(key.slice(5))
        element.dataset[dataKey] = node.props[key] as string
      } else {
        ;(element as any)[key] = node.props[key]
      }
    }
  }

  node.children?.forEach((child) => {
    element.append(createDOM(child))
  })

  return element
}

checkIsTextNode 는 노드가 textNode인지 확인하는 간단한 함수입니다.

function checkIsTextNode(element: VirtualDOM): element is TextNode {
  if (Array.isArray(element)) {
    return true
  }

  if (typeof element === 'object' && element != null && element.tag) {
    return false
  }

  return true
}

updateDOMcreateDOM 로직을 통해 가상돔을 실제 돔에 반영할 수 있게 되었습니다.

다음 아티클에서는

  1. 리액트 like하게 useState 훅을 만들어보고,

  2. 상태의 변경에 반응하여 가상돔을 업데이트하고,

  3. updateDOM 함수를 이용해 가상돔을 diffing하고 실제 돔에 업데이트 하는 로직을 작성해 보겠습니다.

만들어진 최종본은 아래 리파지터리에 있습니다.

kickbelldev/vanilla-daangn-blog-spa: JSX based function component system example (github.com)