Document를 통해 보는 Vue의 Rendering Mechanism

Chaeil·2022년 6월 6일
1

이전에 내가 개발 공부를 할 때는 책이나 강의를 우선으로 하여 공부를 했었다. 이 방법도 좋지만 아무래도 document를 읽으면서 공부하는 습관을 들이고 싶었다. 인턴을 하면서 이참에 Vue는 document를 위주로 공부를 해봐야겠다 마음먹었다.

Document를 보면서 Extra Topic에 흥미로운 주제가 많았고 이 글은 그 중에 Vue의 rendering mechanism에 관련된 부분을 살펴보는 글이다.


Rendering Mechanism

Vue는 어떻게 template을 실제 DOM node로 변환할까? 또한 어떻게 DOM node들을 효율적으로 업데이트할까? Vue의 내부 렌더링 메커니즘을 살펴보자


Virtual DOM

Vue도 virtual DOM을 기반으로 렌더링을 한다.

Virtual DOM은 프로그래밍 컨셉인데, 이상적인 혹은 가상의 UI를 저장하고 실제 DOM과 동기화한다.

Virtual DOM은 특정 기술보단 패턴에 가까워서 정형화된 구조는 없다.

간단한 예시를 살펴보자

 const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    /* more vnodes */
  ]
}

위의 vnode<div> 요소를 나타내는 JS 객체다. 우리가 생성해야 할 실제 요소의 정보를 포함하고 있다. 뿐만 아니라 자식 vnode들도 포함하고 있다.

런타임 renderer는 virtual DOM tree로 부터 실제 DOM tree를 구성한다. 이 과정을 mount 라고 부른다.

상태가 변경되면 2 개의 virtual DOM tree가 되고, renderer는 두 개의 tree를 비교하여 차이를 파악하고 실제 DOM에 적용한다. 이 과정을 patch라 하며, “diffing” 혹은 “reconciliation”으로 알려져있다.

virtual DOM의 장점으로는 직접적인 DOM 조작은 renderer에게 맡기고 개발자는 선언적인 방식으로 UI 구조 혹은 view에만 집중할 수 있다.


Render Pipeline

Vue 컴포넌트가 mounted 되면 어떤 일이 발생할까

  1. Compile: Vue templates은 render functions로 컴파일된다. render functions는 virtual DOM tree를 반환한다. 이 과정은 빌드 단계를 통해 미리 수행되거나, 런타임 컴파일러를 사용하여 그때 수행될 수 있다.
  2. Mount: runtime renderer는 render functions를 부르며, virtual DOM tree를 확인한 후 실제 DOM node를 생성한다. 이 과정은 reactive effect로 수행되므로, 사용된 모든 reactive 종속성을 추적한다.
  3. Patch: state가 변경되면 새로운 업데이트된 virtual DOM 트리가 생성된다. 그리고 이전의 virtual DOM tree와 비교하여 변경된 부분을 실제 DOM에 적용시킨다.


Templates vs. Render Functions

Vue template은 컴파일되면 virtual DOM render function으로 변환이 된다. Vue에서 제공하는 API를 통해 template을 skip하고 바로 render function을 작성할 수 있다. Render function은 좀 더 복잡하고 다이나믹한 로직을 다룰 때 template 보다 유연한데, JS의 모든 기능을 사용하여 vnode로 작업할 수 있기 때문이다.

Vue가 template을 기본적으로 권장하는 이유는 무엇일까?

  1. Template은 HTML과 유사하다. 따라서 기존의 HTML 스니펫을 재사용하거나, a11y 접근성 적용, CSS 스타일링 등이 수월하다.
  2. Template은 보다 분석하기가 더 수월하다. Vue의 template 컴파일러는 virtual DOM의 성능을 향상시키기 위해 컴파일 시간 최적화를 적용할 수 있다.

실제로 templates로도 거의 모든 경우를 커버하기 충분하다. Render function은 상당히 다이나믹한 rendering 로직을 다루는 컴포넌트를 재사용할 때 보통 사용된다.


Compiler-Informed Virtual DOM

React 혹은 기타 다른 virtual-DOM 구현은 순전히 런타임이다. reconciliation 알고리즘은 변화되는 virtual DOM 트리에 대한 어떠한 것도 가정할 수 없다. 따라서 정확성을 보장하기 위해선 모든 vnode를 비교하며 체크해봐야 한다. 또한 트리가 변경되지 않았어도 매번 re-render시 생성되는 vnode는 불필요한 메모리 낭비를 야기한다. 이것이 virtual DOM의 가장 부정적인 측면 중 하나이다. brute-force 방식의 reconciliation은 선언성과 정확성을 대가로 효율성을 희생한다.

하지만 Vue는 컴파일러와 런타임을 모두 컨트롤한다. 컴파일러는 template을 분석하여 생성된 코드에 힌트를 남겨두어(밑에서 얘기하는 patch flag 등) 최대한 시간을 단축시킨다. 뿐만 아니라 유저가 다른 예외 처리를 할 수 있도록 render function을 통해 제어를 할 수 있다. 이러한 하이브리드 접근 방식을 Compiler-Informed Virtual DOM이라 한다.

그러면 Vue template 컴파일러가 virtual DOM의 런타임 퍼포먼스를 향상시키기 위한 방법들을 살펴보자.

Static Hoisting

어떠한 동적 바인딩도 하지 않는 template은 꽤나 자주 있다.

<div>
  <div>foo</div> <!-- hoisted -->
  <div>bar</div> <!-- hoisted -->
  <div>{{ dynamic }}</div>
</div>

위의 코드는 다음과 같이 변환된다.

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", null, "foo", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "bar", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _hoisted_2,
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ]))
}

foobar div는 정적이다. 따라서 이 vnodes들은 각 re-render때마다 재생성하며 비교하는 작업은 불필요하다. Vue 컴파일러는 이 vnodes들은 render함수 밖에서 hoist하고 매번 render에서 같은 vnodes들을 재사용한다. Renderer는 또한 기존의 vnode와 새로운 vnode가 완전히 동일하다면 비교작업을 건너뛸 수 있다.

또한, 정적 요소가 연속적으로 여러 개 있다면 이 모든 HTML 문자열을 포함하는 하나의 정적 vnode로 응축할 수 있는데, 말로만 보는 것보다 직접 코드로 보는 것이 낫다.

<div>
  <div class="foo">foo</div>
  <div class="foo">foo</div>
  <div class="foo">foo</div>
  <div class="foo">foo</div>
  <div class="foo">foo</div>
  <div>{{ dynamic }}</div>
</div>

위의 코드를 보면 정적 요소인 <div class="foo">foo</div> 가 연속적으로 있다.

이것은 다음과 같이 변환된다.

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = /*#__PURE__*/_createStaticVNode("<div class=\"foo\">foo</div><div class=\"foo\">foo</div><div class=\"foo\">foo</div><div class=\"foo\">foo</div><div class=\"foo\">foo</div>", 5)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ]))
}

하나의 _hoisted_1 가 연속된 <div class="foo">foo</div> 를 포함하고 render 함수에선 이것을 포함하고 있다. 이런 정적 vnodes들은 innerHTML 에 의해 바로 마운트된다. 또한 초기 마운트시 해당 DOM node들을 캐시한다. 만약 같은 내용이 앱의 다른 부분에서 재사용된다면 새로운 DOM 노드들은 native한 cloneNode() 를 사용하기 때문에 매우 효율적이다.

Patch Flags

동적 바인딩을 하는 단일 요소에서도 컴파일 시 많은 정보를 추론할 수 있다.

<!-- class binding only -->
<div :class="{ active }"></div>

<!-- id and value bindings only -->
<input :id="id" :value="value">

<!-- text children only -->
<div>{{ dynamic }}</div>

위의 코드는 다음과 같이 변환된다.

import { normalizeClass as _normalizeClass, createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _createElementVNode("div", {
      class: _normalizeClass({ active: _ctx.active })
    }, null, 2 /* CLASS */),
    _createElementVNode("input", {
      id: _ctx.id,
      value: _ctx.value
    }, null, 8 /* PROPS */, ["id", "value"]),
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}

template의 elements(요소)들을 기반으로 render function 코드를 생성할 때, Vue는 vnode를 생성하는 호출할 때 직접적으로 업데이트를 필요로 하는 타입을 인코딩한다.

createElementVNode("div", {
  class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)

위의 코드에서 4번째 인수에 2 가 있는데 이것은 patch flag이다. 들어가서 확인해보면 CLASS = 1 << 1 로 돼있다. 요소는 하나의 번호로 통합하여 여러 개의 patch flag들을 가질 수 있다. runtime renderer는 비트 연산을 통해 플래그를 확인한 후 어떤 작업을 수행해야 할지 결정하게 된다.


if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
  // update the element's class
}

비트 연산은 굉장히 빠르다. Vue는 patch flag를 통해 dynamic binding을 가진 요소들을 업데이트할 때 최소로 필요한 만큼의 작업만 할 수 있게 된다.


export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* children */
  ], 64 /* STABLE_FRAGMENT */))
}

Vue는 vnode를 가진 자식의 유형도 인코딩한다. 예를 들어, 여러 개의 루트 노드를 가진 template은 fragment로 표시된다. 이런 루트 노드들의 순서는 보통 변하지 않는다. 따라서 이 정보는 런타임에 patch flag로써 제공될 수 있다. patch flag 링크를 보면 STABLE_FRAGMENT = 1 << 6 은 자식의 순서가 변하지 않는 fragment를 나타낸다고 설명돼있다.

따라서 런타임은 하위 순서에 관한 reconciliation을 건너뛸 수 있으므로 효율적이다.

Tree Flattening

위의 변환된 코드들을 살펴보면 반환된 virtual DOM tree의 root는 createElementBlock() 이라는 호출을 통해 생성되는 것을 볼 수 있다.

export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* children */
  ], 64 /* STABLE_FRAGMENT */))
}

개념적으로 “block”은 안정된 내부 구조를 가진 template의 일부이다. 이 경우 v-ifv-for 같은 directives를 포함하지 않았기 때문에 전체 template은 하나의 block을 가지게 된다.

다음과 같이, 각 block은 patch flags들을 가지고 있는 하위 node들을 추적한다.

<div> <!-- root block -->
  <div>...</div>         <!-- not tracked -->
  <div :id="id"></div>   <!-- tracked -->
  <div>                  <!-- not tracked -->
    <div>{{ bar }}</div> <!-- tracked -->
  </div>
</div>

위의 결과는 dynamic 하위 node들만 포함하고 있는 falttened array가 된다.

div (block root)
- div with :id binding
- div with {{ bar }} binding

이 컴포넌트가 re-render해야 될 때 대상을 전체 tree로 하는 것이 아니라 flattened tree로만 대상을 한다. 이것을 Tree Flattening 이라 부르며 virtual DOM을 reconciliation할 때 비교해야 할 node들을 상당히 줄일 수 있다.

Impact on SSR Hydration

patch flag와 tree flattening은 Vue의 SSR Hydration 퍼포먼스에도 크게 향상시킨다.

  • vnodedml patch flag를 기반으로 요소의 hydration을 빠르게 적용할 수 있다.
  • hydration 진행 중에 block node들과 그것의 dynamic 자손들만 대상으로 하기에 template 레벨에서 효과적으로 부분만 hydration을 적용할 수 있다.

마치며

Vue는 virtual DOM의 reconciliation을 효율적으로 하기 위해 patch flagtree flattening을 사용한다는 것은 처음 알았고, 이 용어들 조차 처음 들었다. Document의 extra topic을 보지 않고 essential만 보았다면 전혀 알 수 없는 내용이었기에 굉장히 흥미로웠다.

단순 사용법보단 Vue에 대해 좀 더 deep한 부분을 알아볼 수 있어서 좋았고, 시간이 날 때 github를 통해 직접 코드도 살펴보며 Vue에 어떤 패턴이 적용됐는지, 어떻게 구현을 했는지도 공부하면 크게 도움이 될 거 같았다.

추가로 React를 공부한 지 오래 됐지만, Vue의 template 코드가 render function으로 어떻게 변환되는지 직접 코드로 보았을 때 React의 createElement() 가 생각이 났다. 지금은 Vue를 사용하지만 이후에 React를 사용하게 될 때 React document를 위주로 deep하게 공부를 해보고 싶다는 생각이 들었다.

프레임워크나 라이브러리의 사용법을 공부한 후, 어떻게 구현됐는지도 공부해보자!

Reference

https://vuejs.org/guide/extras/rendering-mechanism.html

profile
서글픈 너의 눈길은 나의 가슴을 아리게 한다

0개의 댓글