이번 글은 웹 컴포넌트 소개 연재 5번째로 웹 컴포넌트를 react처럼 코딩하기를 해보겠다. 사용하는 예제 전체 코드는 Todo Web Components에서 참조할 수 있다. 이 예제를 통해 지난 연재에서 알아보았던 커스텀 엘리먼트, 쉐도우 돔 그리고 lit-HTML을 사용하여 웹 컴포넌트 애플리케이션을 어떻게 만들 수 있는지 확인해보자.
글을 읽기 전에 아래의 링크에서 예제 페이지를 열어두고 시작하자.
이번 글에서 사용하는 Todo Web Components 예제는 TodoMVC를 따라 만들었다. TodoMVC는 수 많은 프론트엔드 프레임워크 중에 선택을 해야 하는 개발자를 돕기 위해 만들어졌다. 이곳에서 같은 TODO 앱을 각 프레임워크를 이용하여 어떻게 구현할 수 있는지, 예제들을 확인하여 비교해 볼 수 있다. TodoMVC 예제는 어떠한 프레임워크의 강점을 보여주기 위해 지나치게 간략화되고 편중된 예제가 아니라, 어느 정도 실제 애플리케이션을 구성하는 것과 같이 구성되어 있으므로 객관적으로 비교해 볼 수 있는 장점이 있다. 위의 이유로 예제 코드가 이번 설명에서 필요한 것보다 다소 길다. 그러므로 이번 글에서는 전체 코드가 아닌 일부 코드들만 떼어 설명하도록 하겠다. 또한, 이 예제는 준비가 되면 TodoMVC에 제출할 예정이므로, 여러분이 코드 리뷰를 겸해주어도 좋겠다.
이 프로젝트는 지난 연재에서 알아보았던 커스텀 엘리먼트, 쉐도우 돔 그리고 lit-HTML을 사용하고 있다. 커스텀 엘리먼트를 사용하기 위해서는 ES6 Class 문법이 필수이다. 더 많은 브라우저를 지원하기 위해서 babel을 통해 ES5 문법으로 src에 있는 소스 파일들을 트랜스파일링 하며, 그 툴링은 webpack을 통해 dist에 저장하고 있다. 프로젝트 디렉터리 구조는 아래와 같다.
src: 소스 파일들
invalidate
를 호출하면 화면 업데이트를 스케줄이번 글을 따라가는데 반드시 로컬에서 이 프로젝트를 실행해야 하는 것은 아니므로, 원치 않는 독자는 이 섹션을 넘어가도 무방하다. 위에서 알려준 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
<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
, ce
는 Shadow DOM
, Custom Elements
의 약자이다)
또한 커스텀 엘리먼트가 필요로 하는 ES6문법을 ES5문법으로 트랜스파일하고 있으므로 custom-elements-es5-adapter.js가 필요하다.
<body>
<todo-app></todo-app>
...
</body>
</html>
<body>
는 간략히 <todo-app>
태그를 포함하고 있다. 이 태그는 TodoApp.js에 포함된 커스텀 엘리먼트가 정의하고 있다.
이처럼 커스텀 엘리먼트를 사용하는 입장에서는 자바스크립트 파일과 태그를 하나를 사용하는 것뿐으로 매우 편리하다.
todoApp.js는 src/components에서 찾을 수 있으며, 위 index.html에서 사용한 <todo-app>
태그를 커스텀 엘리먼트로 정의한다.
더불어 하나의 애플리케이션으로서 필요한 API도 제공하고 있다.
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 미니미라고 생각하면 된다) store
와 add
, 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
커스텀 엘리먼트를 정의한다. 이 클래스는 HTMLElement
과 LitRender
믹스인을 확장한다.
constructor
에서는 쉐도우 돔을 open
모드로 이 커스텀 엘리먼트에 생성한다.
마지막으로 invalidate()
를 하고 있는데, 이는 LitRender
에 정의된 함수로 이 컴포넌트를 렌더링 하도록 해준다.
LitRender
와 invalidate
에 대해서는 이후 더 자세히 알아보고, 여기서는 직관적으로 invalidate
의 효용만 떠올리면 충분하다.
...
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를 제공해 줄 수 있다.
...
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-HTML의 html
Template Literal 함수를 사용해 이 커스텀 엘리먼트의 하위 엘리먼트를 렌더링한다.
오오! 제법 React 같은 모양새가 되었지 않은가?
customElements.define("todo-app", TodoApp);
마지막으로 커스텀 엘리먼트를 정의한 클래스를 todo-app
태그로 정의한다.
커스텀 엘리먼트를 작성할 때 todo-app
처럼 태그 이름은 반드시 -
를 하나 이상 포함해야 함을 기억하자.
브라우저는 HTML을 파싱하다 -
를 포함한 태그를 만나면 이것이 커스텀 엘리먼트로 쓰일 수 있다는 것을 알아채 처리할 수 있다.
litRender.js는 src/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);
}
}
};
Todo 애플리케이션을 구성하는 개별 컴포넌트들을 정의한다. todoApp.js 코드를 보면 <todo-list>
, <todo-toolbar>
등의 형태로 사용하는 것을 확인할 수 있다.
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
이 보인다.
이 함수들은 커스텀 엘리먼트 콜백으로 이 엘리먼트가 DOM에 attach, detach될 때 호출된다.
따라서 이 콜백 함수들이 DOM 이벤트 핸들러를 할당/해제하는데 최적의 장소이다.
만약 적절히 핸들러를 해제해주지 않으면 메모리 누수를 경험할 수 있으니 잊지 말자.
onClick
핸들러는 조건에 따라 toggle
, remove
Redux 액션을 수행하고 있다.
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
를 전달할 수 있다.
Attribute는 html`<todo-item name$=${someText}></todo-item>`
로 이름 뒤에 $
를 붙이면 된다.
받아오는 object
를 사용하는 todoItems.js에서는 평범하게 this.todo
로 접근하면 된다.
이 코드에서는 getter를 만들어서 값이 할당되면 자동으로 invalidate
를 호출하여 커스텀 엘리먼트가 업데이트 되도록 했다.
여기서 한가지 짚고 넘어갈 점은 lit-HTML의 작동방식이 충분히 성능을 고려해 만들어 졌다는 것이다.
이것은 템플릿 리터럴로 전달된 값을 기억하고 있다가, 전달된 값이 다를 경우에만 컴포넌트를 업데이트한다.
데모에서 이 코드가 동작하는 것을 확인해보면 추가/삭제/변경된 아이템만 업데이트되는 것을 확인할 수 있다.
Redux-Zero의 store, 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;
});
...
여기까지 TODO 애플리케이션을 커스텀 엘리먼트, 쉐도우 돔과 lit-HTML을 사용하여 우리에게 익숙한 React처럼 작성한 코드를 가볍게 설명했다. 이 방법은 단순히 React를 흉내 내는 것이 목적이 아니다. 우리가 우리에게 익숙한 방법으로 접근할 수 있으면서도, 프레임워크를 요구하지 않는다. 단지 2kb가 채 안되는 lit-HTML 라이브러리만 하나 사용하고 있다. 이것이 우리에게 주는 장점은 뚜렷하다.
document.querySelector('todo-app').add('hello')
같은 직관적인 방법을 제공해주는 프레임워크는 없다.이 같은 장점에 이끌려 이 연재글을 벌써 5번째 쓰고 있다. 물론 프레임워크가 제공해주는 편리함이나, 브라우저 지원 등을 생각해보면 아쉬운 점들이 없지 않다. lit-HTML 역시 조금 더 다듬어져야 할 필요가 있다. 이 예제 또한 lit-HTML이 정리되어 이 예제를 업데이트할 수 있기를 기다리고 있다. 그러나 이 장점들은 충분히 살린다면, 적당한 시기(아마도 IE10 이하를 집어던져도 되는 때)에, 공통 컴포넌트나 오픈소스를 개발하는 곳에서는 충분히 그 장점을 발휘할 수 있을 때가 오리라 믿는다.
Chrome Dev Summit 2017 - lit-HTML 영상을 본 후, 여기서 소개된 대로 lit-HTML 그리고, 웹 컴포넌트 자체가 얼마나 빠른 성능을 보여줄 지 궁금했다. 기왕이면 이전 글에서 약속한 대로 예제도 만들어야 하니 todo preact benchmark에 추가하여 성능 비교를 해보려 했다. 테스트 결과는 너무 빠르다. 다만 너무 빨라 스스로 의문이 생겨 조금 더 알아본 결과, Vue.js TodoMVC Benchmark를 찾았다. 필자 역시 이 의견처럼 프레임워크 벤치마크가 의미 없다는 것에 동의하기 때문에 벤치마크 결과를 따로 만들지는 않았고, 이 프로젝트만 TodoMVC에 제출할 예정이다. 그러나 표준 스크립트만으로 동작하는 이 방식이 충분히 빠를수 밖에 없다는 것을 독자 여러분들도 잘 아시리라 생각한다. 개인적으로 #UseThePlatform의 가치, 웹 컴포넌트의 비전에 거는 기대가 크기에 이 글을 읽는 독자가 한명이라도 더 동의해서 주변에 웹 컴포넌트 개발을 하는 환경을 볼 수 있으면 좋겠다는 바람이다. 이 글을 끝으로 웹 컴포넌트 연재를 마친다.