우리는 한다, 개발을.

vue 렌더링 방식 (작동 원리)

⌛ 5 mins

원래 react를 사용하다 vue로 넘어오게 되었습니다.

리액트의 작동 방식, 렌더링 방식을 본 적이 있는데

뷰는 어떨지 궁금해 찾아보게 되었습니다.

Vue 렌더링 방식

먼저 공식문서에 있는 그림입니다.

vue_rendering_mechanism.png

하나씩 보겠습니다.

Template

<template>
  <article class='my-data-container'>
    <div>
      <h1 class='my-data-hub-h1'>
        마이 데이터 허브
      </h1>
      <section class='my-data-hub-box my-data-content'>
        <MyDataHubNav />
        <router-view :key='$route.fullPath'/>
      </section>
    </div>
  </article>
</template>

뷰로 UI를 그릴때는 위처럼 html 문법으로 템플릿을 만듭니다.

그러면 뷰는 템플릿을 컴파일해서 render function을 만듭니다.

Render Function

render(h) {
  return h('article', { class: 'my-data-container' }, [
    h('div', {}, [
      h('h1', { class: 'my-data-hub-h1' }, '마이 데이터 허브'),
      h('section', { class: 'my-data-hub-box my-data-content' }, [
        h(MyDataHubNav),
        h('router-view', { key: this.$route.fullPath })
      ])
    ])
  ]);
}

(Vue Template Explorer 참고용) render_function_example.png

node의 많은 정보를 담고있어서 상당히 복잡한 함수가 나옵니다.

vue2 에서는 위 h (createElement)를 파라미터로 받아와 사용하지만

vue3 에서는 import { h } from ‘vue’ 로 바로 h 를 사용합니다.

참고로 h 는 하이퍼스크립트(hyperscript)의 약자라고 합니다.

아무튼 위 리턴값으로 가상 DOM을 반환합니다.

Render Function 과정을 거치는 이유

  • 일관성 유지

뷰는 템플릿 말고도 jsx나 render method를 통해서 컴포넌트를 만들 수 있는데

어떤 방식을 쓰든 렌더 함수를 거쳐서 가상 DOM 을 반환하도록 만들어 내부 메커니즘의

일관성을 유지합니다.

  • 최적화

컴포넌트의 정적인 부분을 캐시해 최적화 기능을 만듭니다.

Render Function 을 써야하는 경우

선언적으로 동적 컴포넌트를 사용하기 힘들때

export default class Test extends Vue {
  tmp = 3

  render(h) {
    let element = h('div', 'test');

    for (let i = 0; i < this.tmp; i++) {
      element = h('div', [element]);
    }
    return element;
  }
}
<div>
  <div>
    <div>
      test
    </div>
  </div>
</div>

위 예시처럼 중첩(nesting) 태그를 사용할 때 렌더 함수를 써야 합니다.

Virtual DOM

{
  tag: 'article',
  data: {
    class: 'my-data-container'
  },
  children: [
    {
      tag: 'div',
      data: {},
      children: [
        {
          tag: 'h1',
          data: {
            class: 'my-data-hub-h1'
          },
          text: '마이 데이터 허브'
        },
        {
          tag: 'section',
          data: {
            class: 'my-data-hub-box my-data-content'
          },
          children: [
            {
              tag: 'MyDataHubNav',
              data: {}
            },
            {
              tag: 'router-view',
              data: {
                key: this.$route.fullPath
              }
            }
          ]
        }
      ]
    }
  ]
}

가상 DOM은 리액트나 뷰에서 효율적인 DOM 업데이트를 위해 사용하는 방법(패턴)이며

워낙 자료도 많고 또 자세히 파헤치려면 내용이 많아서 한마디로 요약하자면

‘js 객체’ 라고 보시면 됩니다.

이 가상 DOM을 가지고 뷰는 실제 DOM에 적용하여 렌더링을 합니다.

Vue의 가상 DOM 렌더링 최적화

가상 DOM이 업데이트되어 이전 가상 DOM과 비교가 일어날 때

어디 부분에서 변화가 일어났는지 무슨 변화가 일어났는지 DOM 트리의 노드들을

하나하나 비교해봐야합니다. (완전 탐색)

하지만 이렇게 원시적으로 한다면 좋은 성능을 기대하기 힘듭니다.

그래서 뷰는 Compiler-Informed Virtual DOM 방식을 사용합니다.

Compiler-Informed Virtual DOM

단어 뜻 그대로 컴파일(템플릿 -> 렌더 함수) 할 때 힌트를 알려준다고 보면 될 것 같습니다.

렌더 함수를 작성할 때 가상 DOM이 어디에서 무엇이 업데이트 되었는지 찾기 쉽도록 흔적을 남겨준다고 생각하면 됩니다.

여러 방법이 있지만 공식문서에는 크게 효율적인 세가지 방법이 소개됩니다.

  1. Static Hoisting

첫번째는 정적 호이스팅입니다.

자바스크립트에서 호이스팅은 실행 컨텍스트, 렉시컬 환경등 다른 핵심 개념들과 관련이 있어 더 정확한 정의가 있지만 특정 코드나 선언을 코드의 상위 레벨로 이동시키는 것을 의미한다고 봐도 무방할 것 같습니다.

템플릿 코드

<div>
  <div>foo</div>
  <div>bar</div>
  <div></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 */)
  ]))
}

위 코드에서 보시다시피 <div>foo</div><div>bar</div> 는 정적인 부분이라 호이스팅을 통해 렌더 함수 밖 상단으로 보내 변수로 선언한다음 재사용을 해서 createElementVNode 의 무분별한 사용을 줄여 렌더링 최적화를 하고있습니다.

이 밖에도

템플릿

<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></div>
</div>

반복되는 정적 코드

렌더 함수

import { 
  createElementVNode as _createElementVNode, 
  toDisplayString as _toDisplayString, 
  openBlock as _openBlock, 
  createElementBlock as _createElementBlock,
  createStaticVNode as _createStaticVNode
} 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 */)
  ]))
}

호이스팅해서 하나의 변수로 만들어 가상 DOM이 동일한 부분은 빨리 처리할 수 있도록 도와 렌더링 최적화를 하고 있습니다.

  1. Patch Flags

패치 플래그는 가상 DOM이 어떻게 업데이트를 해야하는지 신호를 제공합니다.

<div :class="{ active }"></div>

<input :id="id" :value="value">

<div></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 */))
}

createElementVNode 함수의 네번째 인자를 보면 숫자가 있습니다.

각 숫자는 가상 DOM이 무슨 업데이트를 해야하는지를 알려줍니다.

https://github.com/vuejs/core/blob/main/packages/shared/src/patchFlags.ts

위 vue git code를 보면 1은 문자만 바꼈을 때, 2는 class, 3은 스타일 등등 뭐가 업데이트 되었는지 알려줍니다. (이때 뷰는 비트 연산을 사용해 더욱 빠르게 연산할 수 있습니다.)

  1. Tree Flattening

마지막 세번째는 트리 플래트닝입니다.

예시를 보면 바로 이해할 수 있습니다.

<div> <!-- 루트 블록 -->
  <div>...</div>         <!-- 추적하지 않음 -->
  <div :id="id"></div>   <!-- 추적함 -->
  <div>                  <!-- 추적하지 않음 -->
    <div></div> <!-- 추적함 -->
  </div>
</div>

정적 노드들을 제외하고 :id, `````` 를 가지고 있는 정적 노드들만을

묶어서 병합(flattened)시킵니다.

div (block root)
- div with :id binding
- div with  binding

그러면 나중에 업데이트가 필요할 때 전체 노드가 아닌 위 병합된 노드만 탐색해서

최적화를 시킬 수 있습니다.

Vue 2 와 Vue 3 최적화의 차이

위 Compiler-Informed Virtual DOM 기법은 Vue 3의 최적화 방법입니다.

이전 Vue 2에서는 keep-alive, diffing 알고리즘, static tree optimization, 비동기 컴포넌트 등등 여러 방법을 쓴다고 합니다.

위 Vue 3의 최적화 기법보다 좋지 않아 그냥 이런게 있다고만 알면 충분할 것 같습니다.


[참고]