>웹 프론트엔드 >JS 튜토리얼 >작은 React Chendering vDOM 구축

작은 React Chendering vDOM 구축

Patricia Arquette
Patricia Arquette원래의
2024-10-20 18:28:30761검색

Build a Tiny React Chendering vDOM

이 튜토리얼은 이 튜토리얼을 기반으로 하지만 JSX, TypeScript 및 구현하기 더 쉬운 접근 방식을 사용합니다. 내 GitHub 저장소에서 메모와 코드를 확인할 수 있습니다.

이 부분에서는 vDOM을 실제 DOM으로 렌더링합니다. 추가적으로 React의 핵심구조인 Fiber Tree에 대해서도 소개하겠습니다.

vDOM 렌더링

vDOM 렌더링은 간단합니다. 너무 간단합니다. 다음의 웹 네이티브 API를 알아야 합니다.

  • document.createElement(tagName: string): HTMLElement 실제 DOM 요소를 생성합니다.
  • document.createTextNode(text: string): Text 텍스트 노드를 생성합니다.
  • .appendChild(child: Node): void 상위 노드에 하위 노드를 추가합니다. HTMLElement의 메소드
  • .removeChild(child: Node): void 상위 노드에서 하위 노드를 제거합니다. HTMLElement의 메소드
  • .replaceChild(newChild: Node, oldChild: Node): void 하위 노드를 새 하위 노드로 교체합니다. HTMLElement의 메소드
  • .replaceWith(...nodes: Node[]): void 노드를 새 노드로 교체합니다. 노드에 대한 메소드
  • .remove(): void 문서에서 노드를 제거합니다. 노드에 대한 메소드
  • .insertBefore(newChild: Node, refChild: Node): void 참조 하위 노드 앞에 새 하위 노드를 삽입합니다. HTMLElement의 메소드
  • .setAttribute(name: string, value: string): void 요소에 속성을 설정합니다. HTMLElement에 대한 메소드입니다.
  • .removeAttribute(name: string): void 요소에서 속성을 제거합니다. HTMLElement에 대한 메소드입니다.
  • .addEventListener(type: string, Listener: Function): void 요소에 이벤트 리스너를 추가합니다. HTMLElement에 대한 메소드입니다.
  • .removeEventListener(type: string, Listener: Function): void 요소에서 이벤트 리스너를 제거합니다. HTMLElement에 대한 메소드입니다.
  • .dispatchEvent(event: Event): void 요소에 이벤트를 전달합니다. HTMLElement에 대한 메소드입니다.

와, 좀 과하지 않나요? 하지만 vDOM 생성을 실제 DOM에 미러링하기만 하면 됩니다. 간단한 예를 들어보겠습니다.

function render(vDom: VDomNode, parent: HTMLElement) {
    if (typeof vDom === 'string') {
        parent.appendChild(document.createTextNode(vDom))
    } else if (vDom.kind === 'element') {
        const element = document.createElement(vDom.tag)
        for (const [key, value] of Object.entries(vDom.props ?? {})) {
            if (key === 'key') continue
            if (key.startsWith('on')) {
                element.addEventListener(key.slice(2).toLowerCase(), value as EventListener)
            } else {
                element.setAttribute(key, value as string)
            }
        }
        for (const child of vDom.children ?? []) {
            render(child, element)
        }
        parent.appendChild(element)
    } else {
        for (const child of vDom.children ?? []) {
            render(child, parent)
        }
    }
}

on으로 시작하는 속성을 이벤트 리스너로 등록했는데, 이는 React에서 일반적인 관행입니다. 또한 렌더링용이 아닌 조정용으로 사용되는 키 속성을 무시했습니다.

그럼 렌더링이 완료되고 이번 챕터가 끝나는 걸까요...? 아니요

유휴 시간 렌더링

실제 반응에서는 렌더링 프로세스가 좀 더 복잡합니다. 좀 더 구체적으로 말하자면, requestIdleCallback을 사용하여 더 긴급한 작업을 먼저 처리하고 우선 순위를 낮춥니다.

requestIdleCallback은 Safari, MacOS 및 iOS 모두에서 지원되지 않습니다(Apple 엔지니어 여러분, 이유는 무엇입니까? 적어도 2024년에는 작업 중입니다). Mac을 사용하는 경우 Chrome을 사용하거나 간단한 setTimeout으로 바꾸세요. 실제 React에서는 이를 처리하기 위해 Scheduler를 사용하지만 기본 개념은 동일합니다.

그러려면 다음과 같은 웹 네이티브 API를 알아야 합니다.

  • requestIdleCallback(callback: Function): void 브라우저가 유휴 상태일 때 콜백이 호출되도록 요청합니다. 콜백에는 IdleDeadline 객체가 전달됩니다. 콜백에는 다음 속성을 가진 객체인 마감일 인수가 있습니다.
    • timeRemaining(): number 브라우저가 더 이상 유휴 상태가 아닐 때까지 남은 시간을 밀리초 단위로 반환합니다. 그러니까 시간이 다 되기 전에 일을 끝내야 해요.

따라서 렌더링을 청크로 분할하고 requestIdleCallback을 사용하여 이를 처리해야 합니다. 간단한 방법은 한 번에 하나의 노드를 렌더링하는 것입니다. 쉽지만 너무 열성적으로 하지 마십시오. 그렇지 않으면 렌더링하는 동안 수행해야 할 다른 작업도 필요하기 때문에 많은 시간을 낭비하게 됩니다.

그러나 우리가 하려는 작업에 대한 기본 프레임워크로 다음 코드를 사용할 수 있습니다.

function render(vDom: VDomNode, parent: HTMLElement) {
    if (typeof vDom === 'string') {
        parent.appendChild(document.createTextNode(vDom))
    } else if (vDom.kind === 'element') {
        const element = document.createElement(vDom.tag)
        for (const [key, value] of Object.entries(vDom.props ?? {})) {
            if (key === 'key') continue
            if (key.startsWith('on')) {
                element.addEventListener(key.slice(2).toLowerCase(), value as EventListener)
            } else {
                element.setAttribute(key, value as string)
            }
        }
        for (const child of vDom.children ?? []) {
            render(child, element)
        }
        parent.appendChild(element)
    } else {
        for (const child of vDom.children ?? []) {
            render(child, parent)
        }
    }
}

이제 // TODO를 vDOM 렌더링으로 채우고 렌더링할 다음 vDOM 노드를 반환하면 간단한 유휴 시간 렌더링을 수행할 수 있습니다. 하지만 서두르지 마세요. 더 많은 작업이 필요합니다.

섬유나무

다음 장에서는 반응성을 구현할 예정인데 조정이 다소 복잡하므로 일부 콘텐츠를 파이버 트리인 이 부분으로 옮깁니다.

Fiber Tree는 단지 특별한 데이터 구조일 뿐입니다. React가 변경사항을 처리할 때 다음과 같은 프로세스를 수행합니다.

  1. 사용자 또는 초기 렌더링 등 무언가가 변경을 촉발할 수 있습니다.
  2. React는 새로운 vDOM 트리를 생성합니다.
  3. React가 새로운 Fiber Tree를 계산합니다.
  4. React는 기존 Fiber 트리와 새 Fiber 트리의 차이를 계산합니다.
  5. React는 실제 DOM에 차이점을 적용합니다.

아시다시피 React에는 Fiber Tree가 필수입니다.

파이버 트리는 기존 트리와 조금 다르게 노드 사이에 세 가지 유형의 관계가 있습니다.

  • 하위 노드: 노드는 다른 노드의 하위입니다. 파이버 트리에서는 모든 노드가 하나의 자식만 가질 수 있습니다. 전통적인 트리 구조는 형제가 많은 아이로 표현됩니다.
  • 동위 노드: 노드는 다른 노드의 형제입니다.
  • 부모: 노드는 다른 노드의 부모입니다. 자식 노드와는 달리 많은 노드가 동일한 부모를 공유할 수 있습니다. 파이버 트리의 부모 노드는 첫 번째 자식에만 관심이 있지만 실제로는 여전히 많은 자식의 부모인 나쁜 부모로 생각할 수 있습니다.

예를 들어 다음 DOM의 경우

import { createDom, VDomNode } from "./v-dom"

interface Fiber {
    parent: Fiber | null
    sibling: Fiber | null
    child: Fiber | null
    vDom: VDomNode,
    dom: HTMLElement | Text  | null
}

let nextUnitOfWork: Fiber | null = null

function workLoop(deadline: IdleDeadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork: Fiber | null): Fiber | null {
    // TODO
    throw new Error('Not implemented')
}

나무로 표현하면 됩니다.

<div>
    <p></p>
    <div>
        <h1></h1>
        <h2></h2>
    </div>
</div>

p는 루트 div의 자식이지만 보조 div는 루트 div의 자식이 아니라 p의 형제입니다. h1과 h2는 보조 div의 하위입니다.

렌더링의 경우 순서는 주로 깊이 우선이지만 약간 다르기 때문에 기본적으로 다음 규칙을 따릅니다. 각 노드별로 다음과 같은 단계를 거칩니다.

  1. 이 노드에 처리되지 않은 하위 항목이 있는 경우 해당 하위 항목을 처리합니다.
  2. 이 노드에 형제 노드가 있으면 형제를 처리합니다. 모든 형제가 처리될 때까지 반복합니다.
  3. 이 노드를 처리됨으로 표시하세요.
  4. 상위 항목을 처리합니다.

이제 구현해 보겠습니다. 하지만 먼저 렌더링 프로세스를 시작해야 합니다. 간단합니다. nextUnitOfWork를 파이버 트리의 루트로 설정하기만 하면 됩니다.

function render(vDom: VDomNode, parent: HTMLElement) {
    if (typeof vDom === 'string') {
        parent.appendChild(document.createTextNode(vDom))
    } else if (vDom.kind === 'element') {
        const element = document.createElement(vDom.tag)
        for (const [key, value] of Object.entries(vDom.props ?? {})) {
            if (key === 'key') continue
            if (key.startsWith('on')) {
                element.addEventListener(key.slice(2).toLowerCase(), value as EventListener)
            } else {
                element.setAttribute(key, value as string)
            }
        }
        for (const child of vDom.children ?? []) {
            render(child, element)
        }
        parent.appendChild(element)
    } else {
        for (const child of vDom.children ?? []) {
            render(child, parent)
        }
    }
}

렌더링을 트리거한 후 브라우저는 PerformUnitOfWork를 호출하며 여기서 작업을 수행합니다.

첫 번째는 실제 DOM 요소를 생성해야 한다는 것입니다. 새 DOM 요소를 생성하고 이를 상위 DOM 요소에 추가하면 됩니다.

import { createDom, VDomNode } from "./v-dom"

interface Fiber {
    parent: Fiber | null
    sibling: Fiber | null
    child: Fiber | null
    vDom: VDomNode,
    dom: HTMLElement | Text  | null
}

let nextUnitOfWork: Fiber | null = null

function workLoop(deadline: IdleDeadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork: Fiber | null): Fiber | null {
    // TODO
    throw new Error('Not implemented')
}
<div>
    <p></p>
    <div>
        <h1></h1>
        <h2></h2>
    </div>
</div>

작업의 첫 번째 부분입니다. 이제 현재의 광섬유에서 분기된 광섬유를 구성해야 합니다.

div
├── p
└── div
    ├── h1
    └── h2

이제 현재 노드에 대해 파이버 트리가 구축되었습니다. 이제 규칙에 따라 섬유 나무를 처리해 보겠습니다.

export function render(vDom: VDomNode, parent: HTMLElement) {
    nextUnitOfWork = {
        parent: null,
        sibling: null,
        child: null,
        vDom: vDom,
        dom: parent
    }
}

이제 vDOM을 렌더링할 수 있습니다. typescript는 가상 DOM의 유형을 알 수 없기 때문에 어리석은 짓이라는 점에 유의하십시오. 여기서는 추악한 우회가 필요합니다.

function isString(value: VDomNode): value is string {
    return typeof value === 'string'
}

function isElement(value: VDomNode): value is VDomElement {
    return typeof value === 'object'
}

export function createDom(vDom: VDomNode): HTMLElement | Text | DocumentFragment {
    if (isString(vDom)) {
        return document.createTextNode(vDom)
    } else if (isElement(vDom)) {
        const element = document.createElement(vDom.tag === '' ? 'div' : vDom.tag)
        Object.entries(vDom.props ?? {}).forEach(([name, value]) => {
            if (value === undefined) return
            if (name === 'key') return
            if (name.startsWith('on') && value instanceof Function) {
                element.addEventListener(name.slice(2).toLowerCase(), value as EventListener)
            } else {
                element.setAttribute(name, value.toString())
            }
        })
        return element
    } else {
        throw new Error('Unexpected vDom type')
    }
}

이제 vDOM이 실제 DOM으로 렌더링됩니다. 축하해요! 당신은 훌륭한 일을 해냈습니다. 하지만 아직 끝나지 않았습니다.

누적 커밋

현재 구현에는 문제가 있습니다. 노드가 너무 많아 전체 프로세스 속도가 느려지면 사용자는 렌더링이 어떻게 수행되는지 확인할 수 있습니다. 물론 영업비밀이라던가 유출이 되진 않겠지만 좋은 경험은 아닙니다. 돔 생성을 커튼 뒤에 숨기고 한꺼번에 제출하는 것이 좋습니다.

해결책은 간단합니다. 문서에 직접 커밋하는 대신 문서에 추가하지 않고 요소를 만들고 작업이 완료되면 문서에 추가합니다. 이것을 누적 커밋이라고 합니다.

function performUnitOfWork(nextUnitOfWork: Fiber | null): Fiber | null {
    if(!nextUnitOfWork) {
        return null
    }

    if(!nextUnitOfWork.dom) {
        nextUnitOfWork.dom = createDom(nextUnitOfWork.vDom)
    }

    if(nextUnitOfWork.parent && nextUnitOfWork.parent.dom) {
        nextUnitOfWork.parent.dom.appendChild(nextUnitOfWork.dom)
    }

    // TODO
    throw new Error('Not implemented')
}

이제 PerformUnitOfWork에서 AppendChild를 제거합니다. 즉, 다음 부분입니다.

const fiber = nextUnitOfWork
if (isElement(fiber.vDom)) {
    const elements = fiber.vDom.children ?? []
    let index = 0
    let prevSibling = null

    while (index < elements.length) {
        const element = elements[index]
        const newFiber: Fiber = {
            parent: fiber,
            dom: null,
            sibling: null,
            child: null,
            vDom: element,
        }

        if (index === 0) {
            fiber.child = newFiber
        } else {
            prevSibling!.sibling = newFiber
        }
        prevSibling = newFiber
        index++
    }
}

이제 모든 작업을 마치면 모든 Fiber가 DOM으로 올바르게 구축되었지만 문서에 추가되지는 않습니다. 이러한 이벤트가 전달되면 문서에 DOM을 추가하는 커밋 함수를 호출합니다.

if (fiber.child) {
    return fiber.child
}
let nextFiber: Fiber | null = fiber
while (nextFiber) {
    if (nextFiber.sibling) {
        return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
}
return null

이제 커밋 기능은 간단합니다. 모든 하위 DOM을 wip에 재귀적으로 추가한 다음 wip를 DOM에 커밋하면 됩니다.

import { render } from "./runtime";
import { createElement, fragment, VDomNode } from "./v-dom";

function App() {
    return <>
        <h1>a</h1>
        <h2>b</h2>
    </>
}
const app = document.getElementById('app')
const vDom: VDomNode = App() as unknown as VDomNode
render(vDom, app!)

commitChildren 함수에 시간 초과를 추가하여 이를 테스트할 수 있습니다. 이전에는 렌더링이 단계별로 이루어졌는데 이제는 한꺼번에 이루어집니다.

중첩된 구성요소

다음과 같이 중첩된 함수를 사용해 볼 수도 있습니다.

function render(vDom: VDomNode, parent: HTMLElement) {
    if (typeof vDom === 'string') {
        parent.appendChild(document.createTextNode(vDom))
    } else if (vDom.kind === 'element') {
        const element = document.createElement(vDom.tag)
        for (const [key, value] of Object.entries(vDom.props ?? {})) {
            if (key === 'key') continue
            if (key.startsWith('on')) {
                element.addEventListener(key.slice(2).toLowerCase(), value as EventListener)
            } else {
                element.setAttribute(key, value as string)
            }
        }
        for (const child of vDom.children ?? []) {
            render(child, element)
        }
        parent.appendChild(element)
    } else {
        for (const child of vDom.children ?? []) {
            render(child, parent)
        }
    }
}

하지만 JSX를 구문 분석할 때 태그는 레이블 이름일 뿐이므로 작동하지 않습니다. 물론, 기본 요소의 경우 문자열일 뿐이지만 구성 요소의 경우 함수입니다. 그래서 JSX를 vDOM으로 변환하는 과정에서 태그가 함수인지 확인하고 함수라면 호출해야 합니다.

import { createDom, VDomNode } from "./v-dom"

interface Fiber {
    parent: Fiber | null
    sibling: Fiber | null
    child: Fiber | null
    vDom: VDomNode,
    dom: HTMLElement | Text  | null
}

let nextUnitOfWork: Fiber | null = null

function workLoop(deadline: IdleDeadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork: Fiber | null): Fiber | null {
    // TODO
    throw new Error('Not implemented')
}

이제 각 구성요소에 소품과 하위 요소가 필요합니다. 실제 React에서는 확인하기 위해 추가 필드를 추가했습니다. 함수를 클래스로 대체하면 추가 필드가 생기고 일반적인 팩토리 패턴인 객체를 생성하는 새 함수를 제공할 수 있지만 여기서는 게으르게 처리합니다.

<div>
    <p></p>
    <div>
        <h1></h1>
        <h2></h2>
    </div>
</div>

실제 React에서는 함수 컴포넌트 호출이 Fiber 구축 단계로 지연된다는 점에 유의하세요. 그럼에도 불구하고 편의상 그렇게 한 것이며, 이 시리즈의 목적을 크게 훼손하지는 않습니다.

파편

그러나 아직은 부족합니다. 이전에는 조각을 div로 처리했는데 이는 올바르지 않습니다. 하지만 이를 문서 조각으로 바꾸면 작동하지 않습니다. 그 이유는 프래그먼트가 일회성 컨테이너이기 때문에 실제 항목을 꺼낼 수 없고 중첩할 수 없으며 이상한 동작이 발생하는 일회성 컨테이너이기 때문입니다. 더 간단하게 작업할 수 없습니다...). 그러니까 젠장, 이걸 파헤쳐야 해.

그래서 해결책은 프래그먼트용 DOM을 생성하지 않고 DOM을 추가할 올바른 부모를 찾는 것입니다.

우리에게는

div
├── p
└── div
    ├── h1
    └── h2

렌더링도 바꿔보세요

export function render(vDom: VDomNode, parent: HTMLElement) {
    nextUnitOfWork = {
        parent: null,
        sibling: null,
        child: null,
        vDom: vDom,
        dom: parent
    }
}

이제 조각이 올바르게 처리되었습니다.

위 내용은 작은 React Chendering vDOM 구축의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.