웹 컴포넌트(5) - lit-html로 React 처럼 코딩하기


image

이번 글은 웹 컴포넌트 소개 연재 5번째로 웹 컴포넌트를 react처럼 코딩하기를 해보겠다. 사용하는 예제 전체 코드는 Todo Web Components에서 참조할 수 있다. 이 예제를 통해 지난 연재에서 알아보았던 커스텀 엘리먼트, 쉐도우 돔 그리고 lit-HTML을 사용하여 웹 컴포넌트 애플리케이션을 어떻게 만들 수 있는지 확인해보자.

웹 컴포넌트 TODO APP

글을 읽기 전에 아래의 링크에서 예제 페이지를 열어두고 시작하자.

Todo Web Components 앱 저장소

Todo Web Components 앱 데모

이번 글에서 사용하는 Todo Web Components 예제는 TodoMVC를 따라 만들었다. TodoMVC는 수 많은 프론트엔드 프레임워크 중에 선택을 해야 하는 개발자를 돕기 위해 만들어졌다. 이곳에서 같은 TODO 앱을 각 프레임워크를 이용하여 어떻게 구현할 수 있는지, 예제들을 확인하여 비교해 볼 수 있다. TodoMVC 예제는 어떠한 프레임워크의 강점을 보여주기 위해 지나치게 간략화되고 편중된 예제가 아니라, 어느 정도 실제 애플리케이션을 구성하는 것과 같이 구성되어 있으므로 객관적으로 비교해 볼 수 있는 장점이 있다. 위의 이유로 예제 코드가 이번 설명에서 필요한 것보다 다소 길다. 그러므로 이번 글에서는 전체 코드가 아닌 일부 코드들만 떼어 설명하도록 하겠다. 또한, 이 예제는 준비가 되면 TodoMVC에 제출할 예정이므로, 여러분이 코드 리뷰를 겸해주어도 좋겠다.

프로젝트 구조

이 프로젝트는 지난 연재에서 알아보았던 커스텀 엘리먼트, 쉐도우 돔 그리고 lit-HTML을 사용하고 있다. 커스텀 엘리먼트를 사용하기 위해서는 ES6 Class 문법이 필수이다. 더 많은 브라우저를 지원하기 위해서 babel을 통해 ES5 문법으로 src에 있는 소스 파일들을 트랜스파일링 하며, 그 툴링은 webpack을 통해 dist에 저장하고 있다. 프로젝트 디렉터리 구조는 아래와 같다.

  • src: 소스 파일들

    • components: 커스텀 엘리먼트들
    • todoApp.js: 애플리케이션 메인 커스텀 엘리먼트
    • todoInput.js: 상단 TODO 아이템 입력창
    • todoItem.js: 입력된 하나의 TODO
    • todoList.js: todoItem들을 리스트 형태로 보여줌
    • todoToolbar: 하단 툴바. 남은 TODO 개수, 상태에 따른 TODO 아이템 노출 토글 버튼들
    • libs: 이외의 소스들
    • actions.js: redux-zero 액션들. TODO 앱에서 사용되어 상태를 업데이트 할 수 있는 액션들이 정의
    • litRender.js: lit-HTML 컴포넌트 헬퍼. 커스텀 엘리먼트에서 invalidate를 호출하면 화면 업데이트를 스케줄
    • store.js: redux-zero 스토어, 기본값 설정
    • index.js: index
  • index.html: index HTML
  • webpack.config.js: 웹팩 설정
  • package.json: 패키지 설정과 스크립트

image

프로젝트 사용

이번 글을 따라가는데 반드시 로컬에서 이 프로젝트를 실행해야 하는 것은 아니므로, 원치 않는 독자는 이 섹션을 넘어가도 무방하다. 위에서 알려준 Todo Web Components 앱 저장소데모 페이지만 참조해도 충분하다.

로컬에서 확인하고자 한다면 우선 git 커맨드로 Todo Web Components 앱 저장소에서 전체 프로젝트를 가져오자.

git clone git@github.com:kyuwoo-choi/todo-web-components.git

그다음 yarn 커맨드로 필요한 디펜던시 패키지들을 설치한다. yarn이 설치되어 있지 않다면 물론 npm을 사용하여도 무방하다.

yarn install
혹은
npm install

이제 필요한 준비가 끝났으므로 package.json에 정의된 serve 스크립트를 실행하여 브라우저로 확인해보자. http://localhost:8080/

yarn run serve
혹은
npm run serve

index.html: 커스텀 엘리먼트 사용

<html>
  <head>
    ...
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.20/custom-elements-es5-adapter.js"
      defer
    ></script>
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.20/webcomponents-sd-ce.js"
      defer
    ></script>
    <script src="./dist/TodoApp.js" defer></script>
    ...
  </head>
</html>

<head>에는 트랜스파일 된 TodoApp.js파일 그리고 두 개의 웹 컴포넌트 폴리필 custom-elements-es5-adapter.js, webcomponents-sd-ce.js 스크립트가 포함되었다. 현재 크롬과 사파리 브라우저에서는 폴리필 없이 확인할 수 있으며, 두 폴리필을 사용하면 파이어폭스, 엣지, IE11도 지원할 수 있다.

웹 컴포넌트 폴리필 중 이 프로젝트에서는 Shadow DOM, Custom Elements를 사용하므로, webcomponents-sd-ce.js를 선택했다. (폴리필 파일 이름에 포함된 sd, ceShadow DOM, Custom Elements의 약자이다) 또한 커스텀 엘리먼트가 필요로 하는 ES6문법을 ES5문법으로 트랜스파일하고 있으므로 custom-elements-es5-adapter.js가 필요하다.

<body>
  <todo-app></todo-app>
  ...
</body>

</html>

<body>는 간략히 <todo-app>태그를 포함하고 있다. 이 태그는 TodoApp.js에 포함된 커스텀 엘리먼트가 정의하고 있다. 이처럼 커스텀 엘리먼트를 사용하는 입장에서는 자바스크립트 파일과 태그를 하나를 사용하는 것뿐으로 매우 편리하다.

todoApp.js: 애플리케이션 컴포넌트

todoApp.jssrc/components에서 찾을 수 있으며, 위 index.html에서 사용한 <todo-app>태그를 커스텀 엘리먼트로 정의한다. 더불어 하나의 애플리케이션으로서 필요한 API도 제공하고 있다.

imports

import { html } from "lit-html";

import LitRender from "../libs/litRender";
import store from "../libs/store";
import {
  add,
  toggle,
  remove,
  toggleAll,
  clearCompleted,
  replace
} from "../libs/actions";

import "./todoInput";
import "./todoToolbar";
import "./todoList";

코드 상단에서 필요한 디펜던시들을 가져온다. html렌더링에 필요한 lit-HTML과 이것을 커스텀 엘리먼트에서 편하게 사용하기 위해 정의한 LitRender믹스인 헬퍼. 애플리케이션 상태와 액션을 관리하기 위한 Redux-Zero(Redux 미니미라고 생각하면 된다) storeadd, toggle같은 액션들. 마지막으로 애플리케이션의 컴포넌트를 구성하는 todoInput, todoToolbar, todoList를 가져온다.

import './todoInput'의 문법이 의문스러운 독자도 있을 것이라 본다. 이것은 가져온 모듈을 저장하지 않고 모듈을 로드만 위한 방법이다. import TodoInput from './todoInput'도 올바른 사용법이지만, 코드에서 TodoInput을 사용하지 않는 경우 웹팩이 Tree Shaking으로 디펜던시를 제거해 버린다. 이를 피하기 위한 문법이며, 딱히 컴포넌트 클래스들을 직접 사용하지도 않으므로 현재의 형태가 되었다고 이해하면 되겠다.

커스텀 엘리먼트 클래스

...
class TodoApp extends LitRender(HTMLElement) {
  constructor(name) {
    super();

    this.attachShadow({ mode: 'open' });

    this.invalidate();
  }
...

ES6 class문법으로 TodoApp 커스텀 엘리먼트를 정의한다. 이 클래스는 HTMLElementLitRender믹스인을 확장한다. constructor에서는 쉐도우 돔을 open모드로 이 커스텀 엘리먼트에 생성한다. 마지막으로 invalidate()를 하고 있는데, 이는 LitRender에 정의된 함수로 이 컴포넌트를 렌더링 하도록 해준다. LitRenderinvalidate에 대해서는 이후 더 자세히 알아보고, 여기서는 직관적으로 invalidate의 효용만 떠올리면 충분하다.

커스텀 엘리먼트 API

...
  add(title) {
    add(title);
  }
...
  get length() {
    const todoList = store.getState().todoList;

    return todoList.length;
  }
...

API를 정의한다. 데모 페이지 혹은 로컬 서버 http://localhost:8080에 접속해서 API를 사용해보자. document.querySelector('todo-app').add('hello'), document.querySelector('todo-app').length 의 커맨드로 사용할 수 있다. 편하지 않은가?! 우리는 커스텀 엘리먼트 클래스에 함수를 정의해 주는 것으로 이처럼 직관적인 API를 제공해 줄 수 있다.

image

HTML 렌더링

...
  render() {
    return html`
      <style>
        host: {
          display: block;
        }
        section {
          background: #fff;
          margin: 130px 0 40px 0;
          position: relative;
          box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
        }
      </style>
      <section>
        <todo-input></todo-input>
        <todo-list></todo-list>
        <todo-toolbar></todo-toolbar>
      </section>
    `;
  }
}
...

render 함수는 위의 invalidate와 쌍을 이루는 함수로 LitRender를 통해 호출된다. 이 함수가 호출되면 lit-HTMLhtml Template Literal 함수를 사용해 이 커스텀 엘리먼트의 하위 엘리먼트를 렌더링한다. 오오! 제법 React 같은 모양새가 되었지 않은가?

커스텀 엘리먼트 등록

customElements.define("todo-app", TodoApp);

마지막으로 커스텀 엘리먼트를 정의한 클래스를 todo-app태그로 정의한다. 커스텀 엘리먼트를 작성할 때 todo-app처럼 태그 이름은 반드시 -를 하나 이상 포함해야 함을 기억하자. 브라우저는 HTML을 파싱하다 -를 포함한 태그를 만나면 이것이 커스텀 엘리먼트로 쓰일 수 있다는 것을 알아채 처리할 수 있다.

litRender.js

litRender.jssrc/libs 밑에서 찾을 수 있으며, 이 애플리케이션의 각 컴포넌트들의 렌더링을 돕는다. 각 컴포넌트들은 class SomeComponent extends LitRender(HTMLElement)의 형식으로 litRender를 믹스인 확장하여 사용한다. 한번에 여러번 내용이 업데이트되는 경우 매번 렌더링 하지 않고, 모았다가 한번에 렌더링 하는 것으로 성능 향상에 도움 주기 위한 코드이다. 이것을 확장하는 컴포넌트에서 this.invalidate를 호출하면 컴포넌트에 정의된 render 함수의 호출이 예약된다.

import { render } from "../../node_modules/lit-html/lib/lit-extended";

export default base =>
  class extends base {
    render() {}

    async invalidate(instant) {
      if (!this.needsRender) {
        if (!instant) {
          this.needsRender = true;
          await 0;
          this.needsRender = false;
        }
        render(this.render(), this.shadowRoot);
      }
    }
  };

컴포넌트들: todoList.js, todoItem.js, todoInput.js, todoToolbar.js

Todo 애플리케이션을 구성하는 개별 컴포넌트들을 정의한다. todoApp.js 코드를 보면 <todo-list>, <todo-toolbar>등의 형태로 사용하는 것을 확인할 수 있다.

connectedCallback / disconnectedCallback

import { toggle, remove, replace } from '../libs/actions';
...
class TodoItem extends LitRender(HTMLElement) {
...
  connectedCallback() {
    const root = this.shadowRoot;
...
    root.addEventListener('click', handlers.onClick);
...
  }

  disconnectedCallback() {
    const root = this.shadowRoot;
...
    root.removeEventListener('click', this._handlers.onClick);
...
  }
...
  _onClick(event) {
    const id = this.todo.id;
    const classList = event.path[0].classList;

    if (classList.contains('toggle')) {
      toggle(id);
    } else if (classList.contains('destroy')) {
      remove(id);
    }
  }
...

TodoApp에서는 사용되지 않았던 connectedCallback, disconnectedCallback이 보인다. 이 함수들은 커스텀 엘리먼트 콜백으로 이 엘리먼트가 DOMattach, detach될 때 호출된다. 따라서 이 콜백 함수들이 DOM 이벤트 핸들러를 할당/해제하는데 최적의 장소이다. 만약 적절히 핸들러를 해제해주지 않으면 메모리 누수를 경험할 수 있으니 잊지 말자.

onClick 핸들러는 조건에 따라 toggle, remove Redux 액션을 수행하고 있다.

render / html

  render() {
    const todo = this.todo;
    const classCompleted = todo.completed ? ' completed' : '';
    const inputToggle = todo.completed
      ? html`<input class="toggle" type="checkbox" checked>`
      : html`<input class="toggle" type="checkbox">`;

    const classEditing = this._editing ? ' editing' : '';

    return html`
      ${style}
      <div data-id$="${todo.id}" class$="${'item' +
      classCompleted +
      classEditing}">
        <div class="view">
          ${inputToggle}
          <label>${todo.title}</label>
          <button class="destroy"></button>
        </div>
        <input class="edit" type="text" />
      </div>
    `;
  }
}
...
const style = html`
  <style>
    host: {
      display: block;
    }
    .item {
      position: relative;
      font-size: 24px;
      border-bottom: 1px solid #ededed;
    }
...
  </style>
`;
...

render함수가 조금 복잡졌다. lit-HTML html 템플릿 리터럴 함수는 템플릿 리터럴을 인자로 받아 HTMLTemplateElement를 포함하는 오브젝트 TemplateResult를 반환한다. ${something}에는 변수나 상수표현 이외에도 TemplateResult, Promise, Array, Iterables 등을 지원한다. 여러 방식을 조합하여 자유롭게 템플릿을 구성하면 된다. 위의 코드에서도 템플릿이 복잡해 보이지 않게 <style>, <input>등을 분리한 후, html 템플릿 리터럴을 중복하여 사용하고 있다.

객체의 전달

  // todoList.js
  render() {
...
    const todoItems = todoList
      .filter(todo => {
        return (
          route === '' ||
          (route === 'completed' && todo.completed) ||
          (route === 'active' && !todo.completed)
        );
      })
      .map(todo => html`<todo-item todo=${todo}></todo-item>`);

    return html`
      ${style}
      <div class="todo">
        ${btnToggleAll}
        <div class="todo-list">
          ${todoItems}
        </div>
      </div>
    `;
  }
...
  // todoItem.js
  set todo(todo) {
    this._todo = todo;
    this.invalidate();
  }

lit-HTML의 확장기능을 사용하면 html`<todo-item todo=${todo}></todo-item>`처럼 다른 커스텀 엘리먼트에 object를 전달할 수 있다. Attributehtml`<todo-item name$=${someText}></todo-item>`로 이름 뒤에 $를 붙이면 된다. 받아오는 object를 사용하는 todoItems.js에서는 평범하게 this.todo로 접근하면 된다. 이 코드에서는 getter를 만들어서 값이 할당되면 자동으로 invalidate를 호출하여 커스텀 엘리먼트가 업데이트 되도록 했다. 여기서 한가지 짚고 넘어갈 점은 lit-HTML의 작동방식이 충분히 성능을 고려해 만들어 졌다는 것이다. 이것은 템플릿 리터럴로 전달된 값을 기억하고 있다가, 전달된 값이 다를 경우에만 컴포넌트를 업데이트한다. 데모에서 이 코드가 동작하는 것을 확인해보면 추가/삭제/변경된 아이템만 업데이트되는 것을 확인할 수 있다.

store.js, actions.js

Redux-Zerostore, action들을 정의한다. 여러분께 이미 익숙할 Redux가 간략해진 모양이며, 웹 컴포넌트 설명글의 범위를 벗어나므로 이 부분의 설명은 생략하겠다.

import createStore from "redux-zero";

const initialState = { route: "", todoList: [] };
const store = createStore(initialState);

export default store;
import store from './store';

function actionCreator(action) {
  return function() {
    let state = store.getState();
    state = action(state, ...arguments);
    store.setState(state);
  };
}
...
export const remove = actionCreator((state, id) => {
  state.todoList = state.todoList.filter(todo => todo.id !== id);

  return state;
});
...

#UseThePlatform 프레임워크 없이도 React처럼 코딩

여기까지 TODO 애플리케이션을 커스텀 엘리먼트, 쉐도우 돔과 lit-HTML을 사용하여 우리에게 익숙한 React처럼 작성한 코드를 가볍게 설명했다. 이 방법은 단순히 React를 흉내 내는 것이 목적이 아니다. 우리가 우리에게 익숙한 방법으로 접근할 수 있으면서도, 프레임워크를 요구하지 않는다. 단지 2kb가 채 안되는 lit-HTML 라이브러리만 하나 사용하고 있다. 이것이 우리에게 주는 장점은 뚜렷하다.

  1. 프레임워크 다운로드 시간이 없으므로 페이지가 가볍고 빠르다.
  2. 직관적인 DOM Integration. document.querySelector('todo-app').add('hello') 같은 직관적인 방법을 제공해주는 프레임워크는 없다.
  3. 완벽히 표준 ECMAScript코드이므로, 프레임워크 유행을 타지 않고 오래 유지보수 가능한 코드를 만든다. 불과 2, 3년 전 가장 인기 있던 Angular의 위상을 생각해보자.
  4. 프레임워크와 무관하므로 오히려 어떤 프레임워크와도 같이 쓰일 수 있다.
  5. 프레임워크는 선택이지만 프론트엔드 개발자로서 DOM과 ECMAScript는 기본이다. 진입 장벽이 낮고, 어떤 프론트엔드 개발자도 이해할 수 있는 코드이다.

이 같은 장점에 이끌려 이 연재글을 벌써 5번째 쓰고 있다. 물론 프레임워크가 제공해주는 편리함이나, 브라우저 지원 등을 생각해보면 아쉬운 점들이 없지 않다. lit-HTML 역시 조금 더 다듬어져야 할 필요가 있다. 이 예제 또한 lit-HTML이 정리되어 이 예제를 업데이트할 수 있기를 기다리고 있다. 그러나 이 장점들은 충분히 살린다면, 적당한 시기(아마도 IE10 이하를 집어던져도 되는 때)에, 공통 컴포넌트나 오픈소스를 개발하는 곳에서는 충분히 그 장점을 발휘할 수 있을 때가 오리라 믿는다.

마치며

Chrome Dev Summit 2017 - lit-HTML 영상을 본 후, 여기서 소개된 대로 lit-HTML 그리고, 웹 컴포넌트 자체가 얼마나 빠른 성능을 보여줄 지 궁금했다. 기왕이면 이전 글에서 약속한 대로 예제도 만들어야 하니 todo preact benchmark에 추가하여 성능 비교를 해보려 했다. 테스트 결과는 너무 빠르다. 다만 너무 빨라 스스로 의문이 생겨 조금 더 알아본 결과, Vue.js TodoMVC Benchmark를 찾았다. 필자 역시 이 의견처럼 프레임워크 벤치마크가 의미 없다는 것에 동의하기 때문에 벤치마크 결과를 따로 만들지는 않았고, 이 프로젝트만 TodoMVC에 제출할 예정이다. 그러나 표준 스크립트만으로 동작하는 이 방식이 충분히 빠를수 밖에 없다는 것을 독자 여러분들도 잘 아시리라 생각한다. 개인적으로 #UseThePlatform의 가치, 웹 컴포넌트의 비전에 거는 기대가 크기에 이 글을 읽는 독자가 한명이라도 더 동의해서 주변에 웹 컴포넌트 개발을 하는 환경을 볼 수 있으면 좋겠다는 바람이다. 이 글을 끝으로 웹 컴포넌트 연재를 마친다.

References

최규우2017.12.15
Back to list