Home » 컴포넌트와 가상노드 – mithril

컴포넌트와 가상노드 – mithril

mithril 앱은 기본적으로 가상 DOM을 사용하여 웹페이지 상의 UI를 생성합니다. 기본적으로 m.render() 함수를 사용해서 페이지 내의 DOM Element 내에 어떤 UI를 그려내거나, m.mount()를 사용해서 해당 DOM Element가 가상 DOM으로 구성되는 UI를 관리할 수 있도록 합니다. 이 두 함수는 UI를 구성하는 방법으로 사용되지만, 그 기능이나 용도가 조금 다릅니다.

  • m.render(DomElement, Vnode) : 가상 DOM 노드를 주어진 DOM 요소 내에 그려 냅니다.
  • m.mount(DomElement, Component) : 가상 DOM 노드 하위에 컴포넌트가 생성하는 가상 노드를 그려냅니다.

m.render()는 이름 그대로 가상 노드를 렌더링하는 함수입니다. 인자로는 가상 노드를 받으며, m.mount() 내에서 호출됩니다. m.mount()는 컴포넌트가 생성하는 가상 노드를 렌더링되도록 하며, 가상 DOM 트리에 변경이 가해질 때마다 m.render()를 호출하여 화면을 갱신하는 일도 담당합니다. 그렇다면 가상 노드와 컴포넌트는 어떻게 다른지 살펴보겠습니다.

가상 노드는 간단히 말해 m() 함수가 리턴하는 객체입니다. 이 객체는 m.render()에 넘겨져서 지정된 DOM 요소 하위에 추가되어 브라우저 화면에 표시됩니다. m() 함수는 다음과 같은 인자를 받을 수 있습니다

m(셀렉터, 속성, 자식노드, 텍스트) -> vnode
  1. 셀렉터: HTML태그명이나 CSS셀렉터 문자열로 생성할 가상 DOM 요소의 타입을 결정합니다. #someid, .class 등으로 id나 클래스명을 주는 경우에는 기본적으로 div 요소를 생성한다고 가정합니다.
  2. 속성: DOM 요소의 속성을 자바스크립트 객체 형식으로 지정하여 전달합니다. 생략될 수 있습니다.
  3. 자식노드 : 가상DOM 이나 가상 DOM의 배열을 전달하여, 해당 가상 노드의 자식 노드를 만들 수 있습니다. 자식노드들 역시 가상 DOM 이므로 m() 함수를 중첩으로 사용하여 트리 구조를 생성할 수 있습니다.
  4. 텍스트 : 마지막으로 텍스트 값을 전달하여 해당 DOM 내의 텍스트 노드 값을 줄 수 있습니다.

파라미터 중에서 셀렉터를 제외한 값은 사실 모두 선택적 인자입니다. 이렇게 m() 함수를 사용해서 직접 DOM 요소를 빌드하는 것도 가능하지만, 반복적으로 사용되는 요소라면 별도의 컴포넌트로 구성하여 재사용하는 것이 가능합니다. 컴포넌트는 정의한 후에 m() 함수에 전달해서 가상 노드로 만들 수 있습니다.

m(컴포넌트) -> vnode
// 혹은
m(컴포넌트, 데이터객체) -> vnode

컴포넌트가 사용하는 데이터가 외부에 있다면 m(comp, obj)와 같이 호출하여 컴포넌트에 데이터를 전달할 수도 있습니다. 이 부분은 조금 뒤에서 다뤄보도록 하겠습니다.

m() – 가상 노드 만들기

m() 함수를 사용하면 가상 노드를 만들 수 있고, 이 가상 노드를 m.render() 함수에 전달해서 페이지 상에 렌더링하는 것이 UI를 만드는 가장 기본적인 방법이라고 했습니다. 몇 가지 예를 통해서 가상 노드를 만들고 화면에 출력하는 방법을 살펴보겠습니다.

m.render(document.body, "hello")

가장 먼저 m.render() 함수에 일반 문자열을 전달했습니다. 이 결과는 <body>hello</body> 가 됩니다. mithril에서는 일반 문자열이 가상노드처럼 취급되면, 지정된 DOM 요소 내의 텍스트 값이 됩니다.

m.render(document.body,
  m('h2', 'Hello World')
)

m(tagName, text)의 형식으로 DOM 요소를 생성했습니다. <h2>Hello World</h2>의 형태로 HTML 요소가 생성되어 화면에 추가됩니다. 이번에는 속성 값을 주는 방법을 보겠습니다. 속성 값은 자바스크립트 객체의 형식으로 만들어 두 번째 인자로 전달해줍니다.

m.render(document.body,
  m('img', {src: '/images/my_pic.png'})
)

이렇게 하면 화면에 그림을 표시하는 것도 가능합니다.

중첩된 가상 DOM 구조 만들기

가상 노드나 가상 노드의 배열을 전달해서 DOM트리를 구성할 수 있다고 했습니다.

m('.wrapper', [
  m('input', {name: 'value'}),
  m('button', {}, 'Send')
])

위 코드에서 .wrapper를 쓴 것은 <div class="wrapper">로 해석됩니다. div 요소 아래에 텍스트 입력 박스와 버튼을 추가합니다. (버튼에서 {} 부분은 사실 없어도 됩니다. 습관적으로 쓴 부분임;;;)

사실 가상 노드의 배열 그 자체도 가상 노드로 보기 때문에, 래퍼 요소가 굳이 필요하지 않다면 다음과 같이 렌더링할 수도 있습니다.

m.render(document.body,[
  m('input', {name: 'value'}),
  m('button', {}, 'Send')
])

중첩된 구조를 만들다보면, 괄호짝을 맞춘다든지 하는 일이 점점 어려워집니다. 개인적으로는 괄호가 아닌 들여쓰기로 단계를 구분할 수 있는 LiveScript와 mithril의 궁합이 정말 잘 맞는다고 생각합니다. 다만 LiveScript는 몇년 째 개발이 중단된 상태라, 재미로 배워볼만하다고 할 수는 있지만 ES6까지 많이 보급되는 이 시점에는 추천하기가 좀 망설여지는 것이 사실입니다.

또 배열을 사용해서 자식 노드를 파퓰레이팅하는 것도 가능합니다. 참고로 자바스크립트에는 파이썬의 range() 같은 함수가 없어서 좀 불편하군요… 어쨌든 아래 코드는 5개짜리 순서 없는 리스트를 생성합니다.

m.render(document.body, 
  m('ul', 
    [...Array(5).keys()].map(item => m('li', item + 1))
  )
)

컴포넌트

m.render()가 가상 노드를 한 번 렌더링하는 일회용 함수라면, m.mount()는 컴포넌트를 사용해서 이벤트에 반응하여 자동으로 갱신되는 UI를 만드는 방법입니다. 여기서 UI를 다시 그리도록 하는 이벤트에는 다음과 같은 것들이 있습니다.

  • 수동으로 m.redraw()를 호출했을 때
  • 컴포넌트 내에 정의된 DOM 이벤트 핸들러가 실행된 후
  • m.request()를 사용하여 서버에 보낸 요청에 대해 응답을 받은 후

mithril에서 컴포넌트는 view라는 이름의 가상 노드를 리턴하는 함수를 가진 자바 스크립트 객체입니다. 즉 따로 클래스 등으로 정의된 타입이 아니므로 어떤 맥락에서나 쉽게 생성할 수 있습니다. 간단한 예를 통해서 컴포넌트를 사용했을 때의 잇점을 알아보겠습니다. 다음 코드는 간단해보입니다. App이라는 객체를 하나 정의하고 거기에 view() 메소드를 정의했습니다. 이 메소드는 입력 필드 1개와 버튼 2개를 리턴하고 있습니다.

var x = 0
var App = {
  view: function() {
    return [
      m('input', {}),
      m('button', {}, '+'),
      m('button', {}, '-')
    ]
  }
}
m.mount(document.body, App)

그 결과는 m.render()를 사용해서 그려내는 화면과 큰 차이가 없습니다. 그러면 이제 각 요소에 다음과 같은 속성을 넣어보겠습니다.

return m([
  m('input', {value: x}),
  m('button', {onclick: () => x += 1}, '+'),
  m('button', {onclick: () => x -= 1}, '-')
])

추가된 코드 역시 어려운 것이 없습니다.. input 요소는 표시할 것으로 x가 연결되어 있고, +, – 버튼은 각각 이 x값을 1씩 증가/감소 시킵니다. 이제 화면에 렌더링된 결과를 봅니다. +, – 버튼을 클릭하면 input 필드의 값이 변경되는 것을 볼 수 있습니다! 즉 컴포넌트 내에 정의된 두 개의 onclick 이벤트가 처리되고 나면 컴포넌트는 변경된 내용을 UI에 반영해줍니다.

참고로 컴포넌트를 작성할 때, view() 메소드는 화살표 함수보다는 익명함수 정의방법을 사용하는 것이 권장됩니다. 익명함수로 작성된 메소드에서 this는 해당 객체를 가리키지만, 화살표 함수는 this 문맥을 가지지 않기 때문입니다. 위 예제를 살짝 변형하면 x 값을 App 객체 내부로 옮길 수 있습니다. 재사용을 위해서는 이렇게 해당 컴포넌트가 사용하는 프로퍼티 값을 함께 묶어놓는 것이 좋습니다.

var App = {
  x: 0,
  view: function() {
    return [
      m('input', {value: this.x}),
      m('button', {onclick: () => this.x += 1}, '+'),
      m('button', {onclick: () => this.x -= 1}, '-')
    ]
  }
}

컴포넌트의 라이프사이클 메소드

컴포넌트는 필수적으로 view() 메소드를 가져야 하는데, 그외에도 아래에 정의된 메소드들을 추가적으로 정의할 수 있습니다. 이들은 컴포넌트가 생성한 가상 노드의 라이프사이클에 따라 호출되는 것들로, SPA를 구현할 때 유용하게 사용될 수 있습니다.

  • oninit(vnode) : 가상 노드가 DOM트리에 추가되기직전에 호출됨
  • oncreate(vnode) : 가상 노드가 DOM트리에 추가된 후 호출됨
  • onupdate(vnode) : 마운트된 가상 노드가 다시 그려진 직후 직전에 호출
  • onbeforeremove(vnode) : 마운트된 가상노드가 DOM 트리에서 제거되기 전에 호출
  • onremove(vnode) : 가상노드가 제거된 후에 호출
  • onbeforeupdate(vnode) : onupdate() 호출 직전에 호출되는데, 이 메소드가 만약 false를 호출했다면, 다시 그리기 동작이 수행되지 않습니다.

컴포넌트의 의존성

컴포넌트가 사용하는 데이터가 해당 컴포넌트 객체 내부에 있다면 가장 이상적일테지만, 실제 세계에서는 어떤 컴포넌트가 표현하거나 조작해야하는 데이터가 외부에 있는 경우도 있습니다. 이 경우에 컴포넌트는 다른 컴포넌트에 의해서 가상 노드로 변환되도록 하고, 제 3자 컴포넌트가 필요한 데이터를 전달해주어야 합니다.

다음 예는 바로 위의 예제에서 버튼 부분을 별도의 컴포넌트로 분리했습니다. Buttons 컴포넌트는 두 개의 버튼을 제공하는데, 각각 전달받은 속성 객체의 up(), down() 메소드를 호출해줍니다. 이렇게 일부분의 UI를 관리하는 코드를 별도의 컴포넌트로 분리할 수 있고, 메인 앱 객체의 view() 함수가 괄호 지옥에 빠지는 것을 방지할 수 있게 됩니다.

var Buttons = {
  view: function(vnode) {
    return [
      m('button', {
        onclick: () => vnode.attrs.up()
      }, "+"),
      m('button', {
        onclick: () => vnode.attrs.down()
      }, "-")
    ]
  }
}
var App = {
  x: 0,
  view: function() {
    return [
      m('input', {value: this.x}),
      m(Buttons, this)
    ]
  },
  up: function() { this.x += 1 },
  down: function() { this.x -= 1}
}

m.mount(document.body, App)