개발자, UI 라이브러리를 만들다.

jinho park·2021년 8월 23일
26
post-thumbnail

안녕하세요, Sapa 라는 UI 라이브러리를 만들고 있는 easylogic 입니다.

개인적으로 만들고 있는 디자인 툴에서 코어 부분을 분리하게 되어서 기념으로 글을 남겨 봅니다.

UI 라이브러리 이름은 Sapa(사파) 입니다.

왜 Sapa(사파)일까요?
이름만 보고 예상하실 수도 있는데요.

네, 맞습니다.
무협지에 나오는 정파/사파 할 때 그 사파입니다.

정파(React/Angular/Vue)와는 다른 길로 간다.
나는 나만의 길을 갈 것이다.
그렇게 하더라도 충분히 무엇인가를 만들어 낼 수 있다.

라는 약간의 선언아닌 선언이었는데요.
요즘 UI 라이브러리 새로 만드시는 개발자분들이 많아서 이름을 다르게 지어야 할지 고민되네요.

그 본질적인 것을 개발자로써 추구해보고자 해서 일단은 사파라는 이름이 마음에 드네요.
사파는 본질적으로 극단을 추구합니다.
하나의 극단을 추구해서 전체를 덮어버립니다.

사설이 길었네요.
프런트엔드를 개발하다 보면 어떤 환경에서도 종속되지 않는 나만의 라이브러리가 필요할 때가 있습니다.

저도 그랬습니다. (참고: 개발자, 트렌드를 버리다)

특히나 다른 라이브러리의 버전업이나 기타 내가 이해할 수 없는 영역에서 성능저하가 일어난다던가 하면 상당히 난감해지는 경우들이 생깁니다. 물론 여기 저기 찾아가면서 회피하는 코드를 찾아서 적용하면 되긴 하지만 일시적인 방법인 경우가 많습니다.

차라리 그럴 때는 내가 처음부터 만든 라이브러리를 내가 바로 수정하면 어떨까 하는 생각이 많이 들었습니다. 코드에서 자유로워진다고 해야할까요?
어짜피 처음부터 내가 만드는건데 내가 편한 방식대로 해도 되지않을까? 그런 고민이 많이 됐습니다. 그래서 열심히 처음부터 해보고 있는데요.

그렇게 해서 만들어진 결과물이 에디터입니다.

Sapa는 에디터를 만들기 이전부터 계속 했었는데요. 지금과 같이 새롭게 공개하는 이유는 에디터를 만들면서 Sapa 가 어떤식으로 쓰일 수 있는지 정리가 되었기 때문입니다.

에디터 코드는 모두 Sapa 를 기준으로 만들어졌고, 나름 규모있는 어플리케이션도 만들어질 수 있다는 증명서 같은 것이 됩니다.

그럼 지금부터 Sapa에 대한 이야기를 간단(?)하게 한번 해보겠습니다.

Sapa

Sapa(사파)는 UI 쉽게 만들 수 있게 해주는 심플한 라이브러리입니다.

https://github.com/easylogic/sapa

기본 컨셉은 아래와 같습니다.

  • 컴파일이 필요없습니다. (babel 같은 것에 따로 파서를 지정하지 않아도 됩니다.)
  • Virtual Dom 을 사용하지 않습니다.
  • Dom 이벤트를 쉽게 생성할 수 있는 핸들러를 가지고 있습니다.

이렇게 컨셉을 정한건 개발할 때 스펙보다는 흐름을 만들기 위함이었습니다. 하나의 기능을 위해서 알아야 하는걸 너무 많이 만드는 것 자체가 개발의 흐름을 계속 끊는 것이라고 생각했습니다.

그래서 최소한의 스펙만을 구현하고 그걸 계속 응용하는 방식으로 구현하였습니다.

설치

npm install @easylogic/sapa 

설치는 간단하게 하실 수 있습니다.

사용

import {App, UIElement, SUBSCRIBE, CLICK} from '@easylogic/sapa'

import 구문으로 사용하셔도 되고 아래와 같이 브라우저에서도 직접 사용이 가능합니다.

<script type='text/javascript' src='https://cdn.jsdelivr.net/npm/@easylogic/sapa@0.3.0/dist/sapa.umd.js'></script>
<script type='text/javacript'>
    const {App, CLICK, SUBSCRIBE, UIElement} = sapa;   // or window.sapa 
</script>

Core System Design

모든 것은 UIElement 를 확장하면서 시작됩니다. UIElement 내부에 EventMachine 과 BaseStore 를 가지고 있는데요.

EventMachine 은 클래스를 좀 더 잘 활용할 수 있는 방식을 제공하고 Dom 핸들링을 쉽게 할 수 있게 해줍니다.

BaseStore 는 서로 떨어져있는 컴포넌트간에 메세지를 전송할 수 있게 해줍니다.

시작하기


import {start, UIElement} from '@easylogic/sapa';

class SampleElement extends UIElement { }

start(SampleElement, {
    container: document.getElementById('sample') // default value is document.body
})

UIElement 를 확장하고 start 로 간단하게 시작을 할 수 있습니다.
이 때 container 를 지정해서 내가 원하는 곳에서 시작할 수 있습니다.

DOM 기반 클래스 시스템

class MyElement extends UIElement {
    template () {
        return `<div>my element</div>`
    }
}

기본적으로 모든 템플릿은 html 문자열 기준으로 이루어 집니다. 그래서 브라우저에서 그대로 파싱되서 element 로 변환이 됩니다.

UIElement 로 확장된 컴포넌트는 다른 컴포넌트를 포함할 수 있습니다.

class SecondElement extends UIElement {
    components () {
        return { MyElement }
    }
    template () {
        return `
        <div>
            <object refClass='MyElement' />
        </div>
        `
    }
}

SecondElementMyElement 를 위와 같은 코드로 넣을 수 있게 됩니다.

refClass 속성

하나의 컴포넌트에서 다른 컴포넌트를 포함하기 위해서는 refClass 라는 속성을 사용합니다.

refClass 를 사용하기까지 우여곡절이 많았는데요.
처음에는 <MyElement /> 처럼 커스텀 element 같은 방식을 썼는데요.
기본적으로 html 을 그대로 파싱하다 보니 HTMLUnknownElement 로 변환되는 문제가 있었습니다.

간단하게 다른 컴포넌트를 포함할려면 아래와 같이 object 태그로 감싸고 refClass 를 적어줍니다.

<object refClass="MyElement" />

일단 모든 html 문자열은 브라우저를 통해서 dom 으로 다시 변환되기 때문에 object element 가 되는 것인데요. 여기가 MyElement 라는 컴포넌트의 시작 포인트가 됩니다. MyElement 컴포넌트가 생성되면 object element 는 사라지게 됩니다.

하지만 꼭 object element 를 사용하지 않아도 됩니다.
아래와 같이 자유롭게 태그를 지정하셔도 됩니다. refClass 만 있으면 되는 구조입니다.

<span refClass="MyElement" />

속성 넘기기

sapa 는 간편하게 속성을 넘길 수 있습니다. 기존에 dom 을 사용하듯이 속성을 나열하면 됩니다.


class SecondElement extends UIElement {
    components () {
        return { MyElement }
    }
    template () {
        return `
            <div>
                <object refClass='MyElement' title="my element title" />
            </div>
        `
    }
}

하지만 여기서 문제가 하나 있습니다. 모든 template 은 문자열이기 때문에 속성이나 기타 다른 값들도 모두 문자열이 되어야 한다는 문제가 있습니다.

간단한 값이면 상관 없는데요.

엄청나게 큰 배열 같은 것을 넘길 수가 없습니다.

초기 설계할 때는 엄청나게 큰 배열이나 객체도 json 문자열로 만들고 넘기면 되지 않을까 해서 그렇게 썼었는데요.
매번 문자열을 만드는 비용이 만만치 않았습니다.

변수로서 속성 넘기기

Sapa는 변수를 그대로 속성으로 넘기는 방법을 제공합니다.

jsx 같은 것을 사용하면 파싱할 때 부터 원본 객체가 들어가게 할 수 있지만 여기서는 모든 것이 문자열이 되어야 했기 때문에 불가능 했습니다.

그래서 조금 다르게 생각해보기로 하였습니다.

어떤 값을 넘긴다는 것은 하나의 변수 값 또는 참조의 위치를 알고 다른 곳으로 이동하는 것인데요.

이 때 위치를 알고있고 값을 가지고 올 때 활용할 수 있으면 되지 않을까 했습니다.

그렇게 고민을 한 결과 아래와 같이 variable 이라는 함수를 통해서 전송되는 객체를 캐슁하고 넘겨주게 됩니다.


class SecondElement extends UIElement {
    components () {
        return { MyElement }
    }
    template () {
        return `
            <div>
                <object refClass='MyElement' title=${this.variable({
                    title: 'my element title'
                })} />
            </div>
        `
    }
}

html 은 기본적으로 아래와 같은 형태로 속성을 정의합니다.

a="bbb" 
x='aaaa'
c=hello

여기서는 마지막 방법을 적극적으로 활용합니다.

variable() 로 변수를 캐슁할 때 아이디를 리턴해주고 나중에 복원하게 됩니다.

그리고 object spread 형태로 속성을 넘길 수 있습니다.

this.variable() 함수를 그대로 출력하는 것인데요. variable() 함수는 id 를 리턴해주기 때문에 해당 id 에 설정된 object 가 있으면 개별 필드를 풀어서 props 로 설정해주게 됩니다.


class SecondElement extends UIElement {
    components () {
        return { MyElement }
    }
    template () {
        return `
            <div>
                <object refClass='MyElement' ${this.variable({
                    title: 'my element title',
                    type: "label",
                    "data-value": "myElement"
                })} />
            </div>
        `
    }
}

props 사용하기

다른 컴포넌트에서 넘겨준 props 를 사용할 수 있습니다.

사용 방법은 객체 내에서 this.props 라는 이름을 값을 가지고 올 수 있습니다.


class MyElement extends UIElement {

    template () {
        const titleObject = this.props.title;
        return `
            <div>
                ${titleObject.title}
            </div>
        `
    }
}

객체 저장소(local state)

UIElement 는 간단한 state 를 저장하는 변수를 가집니다.

언제든 객체 내의 변수를 저장할 수 있습니다.


class MyElement extends UIElement {

    // initialize local state 
    initState() {
        return {
            title: this.props.title
        }

    }

    template () {
        const {title} = this.state; 
        return `<div>${title}</div>`
    }
}

로컬 저장소는 변화에 따라 반응하는 것은 아니고 저장 패턴을 분기시키기 위한 용도입니다. 예를 들어 this.abc = "title" 이런식으로 바로 지정해도 상관 없습니다.

DOM 에 접근하기

UIElement 는 생성 이후에 root element 를 가지는데요. (약간 ShadowRoot 랑 비슷하죠)

바로 접근 할 수 있는 속성이 있습니다.

this.$el 로 접근을 바로 할 수 있습니다.

이름에 $ 이 붙은건 단순 HTMLElement 가 아니라서 그렇습니다.

내부적으로 Dom 이라는 jQuery 와 유사한 dom 을 다루는 객체가 있습니다. 그것의 인스턴스라는 의미로 $ 가 들어가있습니다.

class Test extends UIElement {
    template () { return '<div class="test-item"></div>' }

    [CLICK()] () {
        if (this.$el.hasClass('test-item')) {
            console.log('this element has .test-item')
        }
    }
}

ref 속성

this.$el 로 DOM 의 root 에는 접근을 할 수 있었는데요.
그 외의 DOM에는 어떻게 접근해야할까요?

여기서는 리액트나 뷰와 비슷하게 ref 라는 속성을 사용해서 해당 요소를 미리 캐슁해둡니다.

template () {
    return `<div><span ref='$text'></span></div>`
}
[CLICK('$text')]  (e) { 
    console.log(this.refs.$text.html())
}

위 코드는 $text element 에 click 이벤트를 지정하는 모습입니다. 원하는 태그에 ref 속성을 줌으로써 간단하게 dom 을 바로 참조할 수 있게 됩니다.

LOAD 함수

LOAD 함수는 사파내부에서 제공하는 몇가지 매직메소드라는 요소 중에 하나입니다. LOAD 는 특정 영역의 내부 Html 을 업데이트 하게 해줍니다.

ref 로 지정된 $list 라는 div 에 간단한 리스트를 넣는 코드 입니다.

template () {
    return `
        <div>
            <div ref='$list'></div>
        </div>
    `
}

[LOAD('$list')] () {
    const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    return arr.map(value => `<div class='item'>${value}</div>`)
}

refresh( ) {
    this.load();
}

사용법이 조금 독특하다고 느낄 수도 있으실텐데요.

자바스크립트에서는 기본적으로 메소드 이름을 ["method"] 형태로 정의 할 수 있는데, 이걸 많이 활용한 패턴입니다.

글을 따라 가면서 코드를 보시다 보면 어떤식으로 활용하는지 감이 오실거에요.

async 함수 지원하기

LOAD 함수는 처음에 동기적으로만 동작을 하다가 현재는 async(Promise) 형태로 동작을 할 수 있도록 구조를 바꿨습니다.

async [LOAD('$list')] () {
    return await api.get('xxxx').data;
}

네, 코드를 보시면 아시겠지만 원격지에 있는 어떤 결과물도 바로 html 에 넣을 수 있게 됩니다.

DOMDIFF 파이프 사용하기

파이프(PIPE)는 특정 매직메소드를 확장 하는 방법입니다. 아래 쪽에서 자세히 설명하도록 하겠습니다.

LOAD 함수에 DOMDIFF 라는 PIPE를 제공합니다. DOMDIFF 는 내부에 정의된 element 중에서 변화가 있는 것만 최종적으로 변경시켜 줍니다. (virtual dom 이랑 비슷한데요. 실제 DOM을 비교하는게 다릅니다.)

[LOAD('$list') + DOMDIFF] () { 
    return "<div>new element</div>
} 

간혹 많은 리스트의 element 를 생성할 경우 필요한 것만 업데이트 할 수 있습니다.

BIND 함수

BIND 함수는 LOAD와 유사한 매직메소드 중에 하나인데요.

특정한 element 하나에 정의되는 속성이나 스타일, 클래스를 핸들링 할 수 있게 해줍니다.

$list 로 지정된 element 에 개별 속성을 쉽게 변화시킬 수 있습니다.

template () {
    return `
        <div>
            <div ref='$list'></div>
        </div>
    `
}

[BIND('$list')] () {
    return {
      	// attribute 설정 
        'data-length': arr.length,
        // 스타일 설정 
        style: {
            overflow: 'hidden'
        },
        // 빠른 성능을 위한 cssText 속성 설정 
        cssText: `
            background-color: yellow;
            color: white;
            background-image: linear-gradient('xxxx')
        `,
        // innerHTML 설정 적용 
        html: "<div></div>",
        innerHTML: "<div></div>",
      
        // textContent 설정 적용 
        text: "blackblack",
        textContent: "redred", 
        // Object, Array, String 형태로 class 지정 
        class: {
            "is-selected": true,
            "is-focused": false,
        },
        class : [ 'className', 'className' ],
        class : 'string-class',
      
        // 내부에 있는 dom 을 diff 해서 갱신 하도록 설정 
        htmlDiff: '<div><span></span></div>',
        // 내부에 있는 svg 를 diff 해서 갱신 하도록 설정 
        svgDiff: '<g><rect /><circle /></g>',
        // value 속성 설정 
        value: "input text",
    }
}

refresh( ) {
    this.load();
}

위의 코드를 실행하면 아래와 같은 형태로 값이 들어가게 됩니다.

<div ref='$list' data-value='0' style='overflow:hidden'></div>

BIND와 LOAD를 만든 이유

virtual dom 이었다면 개별 객체를 비교를 통해서 업데이트 해주게 할 수 있지만 기본적으로 virtual dom 을 사용하지 않기 때문에 특정 영역을 바로 업데이트 할 수 있는 방법이 필요했습니다.

개별적으로 LOAD/BIND 실행하기

LOADBIND는 개별할 수로 실행할 수 있습니다.

this.load('$list')
this.bindData('$list');

즉, 컴포넌트 전체를 매번 업데이트 하지 않고 내가 원하는 영역(ref로 지정된)을 업데이트 할 수 있게 됩니다.

라이프 사이클(Life Cycle)

이쯤되면 뭐하는 라이브러리인지 헷갈릴 수 있는데요. 사파도 간단한 라이프 사이클을 가지고 있습니다.

UIElement ->			
    created()
    initialize() -> 
        initState()
    render -> 
        template() 
        parseComponent() -> 
            create child component -> 
    load()            
    initializeEvent()
    afterRender()
메소드(Hook)재정의 가능설명
createdOUIElement 가 생성 될 때 실행
initializeO내부 데이타 초기화를 할 때 실행됩니다. 형식상 created 뒤에 불려집니다.
initStateO로컬 상태를 초기화 해줍니다.
templateO초기 렌더링할 html 을 지정합니다.
afterRenderO렌더링(브라우저에 완전히 그려지는 것) 후에 실행됩니다.

위와 같이 사파는 특별하지 않지만 간단한 흐름을 가지고 있습니다.

DOM 이벤트 핸들러

UI 를 만들 때 생각보다 많은 코드가 이벤트와 연결되어 있습니다. 그렇게 연결된 이벤트들은 각자만의 context 를 가지고 조합이 되는데요.

Sapa 는 매직메소드 라는 개념을 사용해서 구현을 합니다. 매직메소드는 모두 자기 객체를 context로 가지기 때문에 코딩을 쉽게 할 수 있도록 돕습니다.

아래 코드를 한번 보실까요?

class Test extends UIElement {
    template() {
        return '<div>Text</div>'
    }

    [CLICK()] (e) {
        console.log(e);
    }
}

[CLICK()] 은 기본적으로 [CLICK('$el')] 과 같습니다. $el 은 this.$el 과 같습니다.

즉, 현재 div 엘레멘트에 click 이벤트를 자동으로 지정하게 되는 것입니다.

DOM 이벤트 리스트

다양한 이벤트들이 사전에 정의가 되어 있습니다. 아래 코드를 한번 봐주세요.

CLICK = "click"
DOUBLECLICK = "dblclick"
MOUSEDOWN = "mousedown"
MOUSEUP = "mouseup"
MOUSEMOVE = "mousemove"
MOUSEOVER = "mouseover"
MOUSEOUT = "mouseout"
MOUSEENTER = "mouseenter"
MOUSELEAVE = "mouseleave"
TOUCHSTART = "touchstart"
TOUCHMOVE = "touchmove"
TOUCHEND = "touchend"
KEYDOWN = "keydown"
KEYUP = "keyup"
KEYPRESS = "keypress"
DRAG = "drag"
DRAGSTART = "dragstart"
DROP = "drop"
DRAGOVER = "dragover"
DRAGENTER = "dragenter"
DRAGLEAVE = "dragleave"
DRAGEXIT = "dragexit"
DRAGOUT = "dragout"
DRAGEND = "dragend"
CONTEXTMENU = "contextmenu"
CHANGE = "change"
INPUT = "input"
FOCUS = "focus"
FOCUSIN = "focusin"
FOCUSOUT = "focusout"
BLUR = "blur"
PASTE = "paste"
RESIZE = "resize"
SCROLL = "scroll"
SUBMIT = "submit"
POINTERSTART = "mousedown", "touchstart"
POINTERMOVE = "mousemove", "touchmove"
POINTEREND = "mouseup", "touchend"
CHANGEINPUT = "change", "input"
WHEEL = "wheel", "mousewheel", "DOMMouseScroll"

하나의 이벤트에 내부에 여러 이벤트도 지정할 수 있습니다. 예를 들어 POINTERSTART 이벤트는 mousedown과 touchstart 이벤트를 동시에 지정합니다.
이렇게 되면 touch 이벤트를 지원하는 곳에서는 touch로 동작하게 될 것입니다.

ref 활용하기

ref 속성은 DOM 이벤트를 지정할 때도 활용됩니다.

template () {
    return `<div><span ref='$text'></span></div>`
}
[CLICK('$text')]  (e) { }

이벤트가 발생할 DOM element 에 ref 를 지정해두고 , [CLICK('$text')]이벤트를 지정하게 되면 원하는 곳에 바로 이벤트를 지정할 수 있게 됩니다.

window, document 이벤트 지정하기

매직메소드는 window, document 같은 글로벌 객체의 이벤트도 지정할 수 있게 해줍니다.

[RESIZE('window')] (e) { }
[POINTERSTART('document')] (e) { }

'window', 'document' 같은 이름으로 RESIZE, POINTERSTART 같은 이벤트를 바로 지정할 수 있습니다.

글로벌 객체 이벤트 지정하기

글로벌 객체에 이벤트를 지정하더라도 context 는 그대로 UIElement 의 this 로 유지됩니다. 그렇기 때문에 UIElement 객체가 사라지면 지정된 이벤트도 같이 사라지게 됩니다.
즉, 특별히 외부 이벤트 관리를 해줄 필요가 없는 상태입니다.

delegate 개념 활용하기

Sapa 는 delegate 라는 개념도 자주 활용하고 있습니다. delegate 는 많은 element 에 이벤트를 걸기 보다 부모 element 에 이벤트를 걸고 이벤트가 발생한 element 를 바로 찾아줌으로써 더 나은 성능을 기대하게 됩니다.

template () {
    return `
    <div>
        <div class='list' ref='$list'>
            <div class='item'>Item</div>
        </div>
    </div>
    `
}

[CLICK('$list .item')] (e) {
    // this method will run after .item element is clicked
}

$list 에 있는 .item 클래스를 가진 element 가 click 이 되었을 때 이벤트가 발생합니다. (context 는 여전히 this로 그대로 활용가능합니다.)

delegate 는 css selector 를 지원합니다. 그래서 아래와 같이 조금 복잡한 코드도 가능합니다.

[CLICK('$list .item:not(.selected)')] (e) {
    // do event 
    console.log(e.$dt.html())
}

.item 클래스를 가진 것 중에 .selected 가 아닌 것만 클릭할 수 있습니다.

이벤트는 e라는 네이티브 이벤트 객체를 그대로 받는데요. 이 때 delegate 가 사용된 이벤트의 경우 실제로 발생한 객체를 $dt 라는 속성으로 넘겨주게 됩니다.

지금까지 간단하게 이벤트를 지정하는 방법을 알아봤는데요. 이제부터는 이벤트를 좀 더 다양하게 활용할 수 있는 방법에 대해서 알아보도록 하겠습니다.

Sapa 에서는 이것을 PIPE 라는 이름으로 부르는데요. 몇몇 매직메소드에는 PIPE 를 사용할 수 있습니다.

PIPE 는 매직메소드에 기능을 추가해서 특정 원하는 기능만을 할 수 있도록 필터링 하는 개념입니다.

먼저 DOM 이벤트를 지정할 때 사용할 수 있는 PIPE 에 대해서 알아보시죠.

PIPE 사용하기

PIPE 는 decorator 와 비슷한(?) 형태의 기능을 지원하기 위한 Sapa 만의 고유한 기능합니다.

js 에서 메소드를 지정하는 방법을 확장 해서 + 기호를 가지고 몇 가지 기능을 연결해서 사용할 수 있습니다. 이렇게 함으로써 메소드의 기능을 확장하고 필요한 로직만 나열 할 수 있도록 도와줍니다.

[CLICK() + ALT]

위 구문은 click 이벤트에 ALT 키가 눌러져있을 때만 해당 이벤트 메소드를 실행합니다.

PIPE 는 이렇게 + 문자로 조합을 할 수 있습니다.

어떤게 있는지 하나씩 보시죠.

ALT, CTRL, SHIFT

alt 키가 눌러져있는지 체크합니다.

[CLICK() + ALT] (e) {
    // when alt key is pressed
}

ctrl 나 shift 키도 비슷하게 적용할 수 있습니다.

[CLICK() + ALT + CTRL] (e) {
    // when alt and control key are pressed 
}

PIPE 는 + 로 동시에 여러개가 연결될 수 있어요.

ALT + CTRL 은 alt 키와 ctrl 키를 동시에 누른 상태가 됩니다.

IF

ALT, SHIFT, CTRL 은 이미 지정이 되어 있는 패턴인데요. 이것 말고 다른 요소를 체크 하고 싶다면 IF 를 사용할 수 있습니다.

사용법은 IF('checkFunctionName') 형태로 지정하고 checkFunction 메소드를 실행한 결과값으로 필터링 하게 됩니다.

checkTarget(e) {
    // 클릭한 target 의 nodeType 이 3 이 아닌 경우 는 실행하지 않음 
    if (e.target.nodeType != 3) return false;
    return true; 
}
[CLICK() + IF('checkTarget')] (e) {}

check LeftMouseButton or RightMouseButton

LEFT_BUTTON, RIGHT_BUTTON 은 마우스 버튼의 위치에 따른 필터링을 합니다.

[CLICK() + LEFT_BUTTON] (e) {}

[CLICK() + RIGHT_BUTTON] (e) {}

DEBOUNCE , TROTTLE

DEBOUNCE 는 이벤트가 여러번 실행될 때 실행시간을 늦추는 효과를 줍니다.

[RESIZE('window') + DEBOUNCE(100)] (e) {}

TROTTLE은 이벤트가 여러번 실행 되더라도 특정 시간 간격으로는 주기적으로 실행하게 만들어줍니다.

[SCROLL('document') + TROTTLE(100)] (e) {}

여기까지 간단하게 DOM 이벤트를 다루는 방법을 매직메소드 + PIPE 개념으로 알아보았습니다.

간단한 예제를 한번 보시죠.

class MyElement extends UIElement {
	template() {
     	return `<div>click</div>`; 
    }
  
  	[CLICK() + DEBOUNCE(300)] (e) {
      	console.log('클릭 후 300ms 이후 실행합니다.');
    }
}

메소드는 클릭한 후 300ms 후에 실행 됩니다. 간단하죠? ^^

지금까지는 DOM 에 관련된 기능을 위주로 살펴 봤는데요. 이제부터 메세지 처리에 대한 이야기를 해보도록 하겠습니다.

메소드 기반 이벤트 시스템

Sapa 는 DOM 이벤트를 다루는 방식과 비슷하게 내부 메세징 시스템도 메소드 형태로 다룹니다.

모든 실행 요소는 역시나 thiscontext 로 가지고 있습니다. 즉, 코딩하는 흐름이 자연스럽게 이어지게 됩니다.

SUBSCRIBE

SUBSCRIBE는 메세지 구독을 하도록 설정합니다.

다른 쪽에서 emit 으로 메세지를 전달하게 되면 해당 메세지 이름에 맞는 SUBSCRIBE 로 지정된 함수가 실행되게 됩니다.


class A extends UIElement {
    [SUBSCRIBE('setLocale')] (locale) {
        console.log(locale);
    }
}

class B extends UIElement {
    template () {
        return `<button type="button">Click</button>`
    }

    [CLICK()] () {
        this.emit('setLocale', 'ko')
    }
}

class C extends UIElement {
    components() { return { A, B}  }
   
 	template() { 
    	return `
			<div>
				<object refClass="A" />
				<object refClass="B" />
			</div>
		`
    }
  
    
}

start(C)

BA와 직접적인 연관은 없지만 emit 으로 메세지를 보낼 수 있습니다.

emit

emitSUBSCRIBE 로 등록된 함수에 메세지를 보내는 역할을 합니다.

기본적으로 나 자신을 제외한 등록된 모든 객체에 메세지를 보내게 됩니다.

[CLICK()] () {
    this.emit('setLocale', 'ko')
}

나 자신에세 메세지를 보내지 않는 이유는 메세지가 여러 단계에서 전송이 되다가 나에게 까지 와서 다시 나 자신을 부르면 호출하면 메세지가 끝나지 않고 영원히 돌면서 호출할 수 있기 때문에 막혀있습니다.

trigger

그렇다면 나 자신에게 메세지를 보낼 수는 없을까요?

그 때 사용할 수 있는 것이 trigger 입니다.

trigger 는 기본적으로 emit 과 같지만 나 자신에게만 메세지를 보낼 수 있습니다.

this.trigger('setLocale', 'en')  // setLocale message is run only on self instance 

보통 부모 객체에 메세지를 보낼 때 자주 사용합니다.

this.parent.trigger('setLocale', 'en'); 

this.parent

UIElement 는 서로 포함관계로 되어 있고 포함하고 있는 객체를 parent, 포함되어져 있는 객체를 children 으로 관리합니다.

SUBSCRIBE_SELF

간혹 SUBSCRIBE 로 메세지 구독을 정의 했는데 다른 외부 메세지랑 이름이 같아서 필요치 않게 계속 실행 된다면 SUBSCRIBE_SELF 를 사용해서 trigger 로만 사용할 수 있도록 막을 수 있습니다.

이렇게 구독된 이벤트는 emit 로 실행되지 않습니다.

class A extends UIElement {
    [SUBSCRIBE_SELF('setLocale')] (locale) {
        console.log(locale);
    }
}

여러개의 메세지 동시에 구독(SUBSCRIBE)하기

SUBSCRIBE 는 여러 메세지를 동시에 받을 수 있습니다.

간혹 이런 경우가 있습니다. 메세지를 받고 난 이후에 refresh() 를 실행을 하는데 이런 메세지가 여러개 인것이죠.

[SUBSCRIBE('a')] () { this.refresh();}
[SUBSCRIBE('b')] () { this.refresh();}
[SUBSCRIBE('c')] () { this.refresh();}

이렇게 지정을 해야하는데요. 이걸 좀 더 쉽게 SUBSCRIBE 를 한번에 지정 할 수 있는 기능입니다.


[SUBSCRIBE('a', 'b', 'c')] () {
    // 
}

// this.emit('a')
// this.emit('b')
// this.emit('c')

a, b, c 라는 메세지를 각자 다르게 줘도 위에 정의된 메소드는 하나만 실행하게 됩니다.

DEBOUNCE, TROTTLE

메세지는 DOM 과 비슷하게 DEBOUNCE 파이프를 가집니다.

특정 메세지를 받고 실행 시간을 조절할 수 있습니다.


[SUBSCRIBE('a') + DEBOUNCE(100)] () { }

TROTTLE 도 같은 방식으로 사용가능합니다.


[SUBSCRIBE('a') + THROTTLE(100)] () { }

IF

IF 파이프를 통해서 원하는 시점에만 메세지를 실행할 수도 있습니다.

class A extends UIElement {

    checkShow(locale) {
        return true;        // 실행 가능 
    }

    [SUBSCRIBE('setLocale') + IF("checkShow")] (locale) {
        console.log(locale);
    }
}

렌더링시점 제어하기

대략적으로 template() 로 정해진 부분을 main view 라고 하고 LOAD, BIND 로 정의된 부분을 sub view 라고 부릅니다.

template() 은 기본적으로 업데이트 하지 않습니다. 즉, UIElement 의 root element 는 특별한 일이 있지 않은 이상은 수정하지 않는 것을 원칙으로 합니다.

변화가 필요하면 ref="$xxx" 로 정의해서 LOAD, BIND 를 붙여주는 걸로 합니다.

그래서 저걸 실행할 시점이 필요한데요.

refresh() { 
    this.load();
} 

기본적으로 refresh() 메소드를 제공합니다. refresh 메소드는 load() 함수를 호출해서 LOAD, BIND 를 모두 실행하게 합니다.

그렇기 때문에 refresh 를 호출하지 않거나 다른 쪽으로 분산시키면 react 에 있는 componentDidUpdate 같은 것을 흉내낼 수 있게 됩니다.

여기서는 직접적으로 렌더링 되는 것을 하거나 하지 않거나를 지정할 수 있기 때문에 좀 더 직관적입니다.

refresh () {
  this.setValue(this.state.value);  
}

렌더링을 수행하지 않고 특정 영역의 값만 변경시켜도 되는 것이죠.

이렇게 설계한 이유는 저희가 만드는 대부분의 UI 들이 변경되는 영역과 아닌 영역이 나뉘어져 있기 때문입니다. 그래서 변경되지 않는 영역은 최소한의 변경만 지원하고 변경되는 영역을 제어하는 것이죠.

이렇게 하지 않고 ref 로 참조한 element 를 직접 다뤄도 됩니다.

전체를 다시 그리기

refresh() 로 최대한 변경 영역을 그렸지만 간혹 처음부터 다시 그려야 할 때가 있습니다.

이 때는 rerender() 라는 함수를 씁니다.

예를 들어 i18n 으로 locale 이 변경이 되면 전체 UI 를 처음부터 다시 그려야 한다거나 router 에서 특정 페이지를 부를 때 그 시점의 페이지만 부른다거나 할 때 사용 할 수 있습니다.

[SUBSCRIBE('setLocale')] (locale) {
    this.setState({locale}, false);
    this.rerender();
}

rerender 는 root에 있는 UIElement 에서 실행하면 모든 Element 를 다시 렌더링 하게 됨으로 큰변화가 있을 때 잘 쓰면 좋습니다.

setState()와 refresh 함수

refresh() 함수를 부르는 시점이 있습니다.
2가지가 있는데요. UIElement 가 처음 생성되었을 때와 setState() 를 호출할 때 입니다.

여기서는 setState()에 대해서 간단하게 이야기 해보겠습니다.

[CLICK()] () {
    this.setState({a: 10}); 
}

위의 코드는 아래 코드와 같습니다.

this.state.a = 10

다만 다른게 있다고 한다면 state 객체를 변경후 refresh() 를 호출하는게 다릅니다.
즉, 기존에 LOAD(), BIND() 를 데이타 기반으로 잘 적용해놓았다면 setState() 로 데이타만 변경해서 쉽게 refresh() 를 호출 할 수 있게 됩니다.

데이타만 변경하고 UI 는 변경 안되게 하고 싶을 때는 setState(data, false) 형태로 뒤에 false 를 넘겨주면 refresh() 를 수행하지 않습니다.

시점에 맞게 활용하면 될 것 같습니다.

이렇게 하나의 UI 를 다루는 라이브러리가 완성이 되었습니다.

마무리

UI 를 다룰 때 필요한 여러 개념들은 들어간 것 같습니다.

template 이 존재하고 element 를 찾을 수 있고 이벤트를 원하는 시점에 정의하고 특정 구간에 메세지를 보내는 것만으로 대부분은 할 수 있을 것 같습니다.

기본 스펙은 이렇게 하나의 문서가 끝이고 특별한 일이 있지 않으면 크게 달라지지 않을거라고 생각합니다. 이렇게만 보면 간단하긴 한데 이걸로 어떻게 할 수 있지라는 생각을 하실 수 있을 것 같은데요.

저도 고민이 많이 되었습니다.

  • 커뮤니티도 없고
  • 필요한 UI 들도 없고
  • 하는 것마다 다 다시 만들어야하고
  • 이걸 하는게 과연 맞을까?

그렇게 고민하다가 시간보내는 것 보다 일단 한번 해보고 생각해보자 해서 현재는 이렇게 만들어진 라이브러리를 가지고 좀 규모있게 열심히 웹 디자인 에디터를 만들고 있습니다.

Sapa를 어떻게 썼는지를 볼려면 여기를 보시면 됩니다.
https://github.com/easylogic/editor

Sapa는 나 스스로도 아직은 많은 것을 할 수 있구나를 많이 느낄 수 있는 프로젝트였습니다. 기존에는 항상 기술에 쫓기는 느낌이었다면 이 프로젝트를 하면서 기술에서 좀 더 자유로워지고 다르게 생각할 수 있어서 좋았던 것 같습니다.

긴글 읽어주셔서 감사합니다.

ps.

혹시나 같이 하실 분은 언제든지 연락 주셔도 좋아요. ^^

https://github.com/easylogic/sapa
https://github.com/easylogic/editor

profile
행복개발자

6개의 댓글

comment-user-thumbnail
2021년 8월 27일

멋집니다..!!

1개의 답글
comment-user-thumbnail
2021년 12월 24일

옛날생각이 나네요 :) 저 역시 angular / react / vue말고 내 프레임워크를 만들어서 서비스를 만들던 때도 있었습니다. 사업이 아니라 취업을 하게 되면서 모든 걸 놓고 메이져를 해야 할때 참 슬프더라구요~ 예전 부터 git에서 figma와 같은 editor 만드는 거 지켜보고 있었는데 응원합니다!!

1개의 답글
comment-user-thumbnail
2021년 12월 24일

easylogic님이 만들고 계신 에디터에 no code베이스로 간단한 로직이 들어가서 프로토타입이 sapa로 출력되는 javascript코드가 나온다면 sapa 프레임워크가 커뮤니티 경쟁력이 없어도 가치는 커질 수 있지 않을까 생각해봅니다 :) 화이팅입니다!

1개의 답글