안녕하세요, Sapa 라는 UI 라이브러리를 만들고 있는 easylogic 입니다.
개인적으로 만들고 있는 디자인 툴에서 코어 부분을 분리하게 되어서 기념으로 글을 남겨 봅니다.
UI 라이브러리 이름은 Sapa(사파) 입니다.
왜 Sapa(사파)일까요?
이름만 보고 예상하실 수도 있는데요.
네, 맞습니다.
무협지에 나오는 정파/사파 할 때 그 사파입니다.
정파(React/Angular/Vue)와는 다른 길로 간다.
나는 나만의 길을 갈 것이다.
그렇게 하더라도 충분히 무엇인가를 만들어 낼 수 있다.
라는 약간의 선언아닌 선언이었는데요.
요즘 UI 라이브러리 새로 만드시는 개발자분들이 많아서 이름을 다르게 지어야 할지 고민되네요.
그 본질적인 것을 개발자로써 추구해보고자 해서 일단은 사파라는 이름이 마음에 드네요.
사파는 본질적으로 극단을 추구합니다.
하나의 극단을 추구해서 전체를 덮어버립니다.
사설이 길었네요.
프런트엔드를 개발하다 보면 어떤 환경에서도 종속되지 않는 나만의 라이브러리가 필요할 때가 있습니다.
저도 그랬습니다. (참고: 개발자, 트렌드를 버리다)
특히나 다른 라이브러리의 버전업이나 기타 내가 이해할 수 없는 영역에서 성능저하가 일어난다던가 하면 상당히 난감해지는 경우들이 생깁니다. 물론 여기 저기 찾아가면서 회피하는 코드를 찾아서 적용하면 되긴 하지만 일시적인 방법인 경우가 많습니다.
차라리 그럴 때는 내가 처음부터 만든 라이브러리를 내가 바로 수정하면 어떨까 하는 생각이 많이 들었습니다. 코드에서 자유로워진다고 해야할까요?
어짜피 처음부터 내가 만드는건데 내가 편한 방식대로 해도 되지않을까? 그런 고민이 많이 됐습니다. 그래서 열심히 처음부터 해보고 있는데요.
그렇게 해서 만들어진 결과물이 에디터입니다.
Sapa는 에디터를 만들기 이전부터 계속 했었는데요. 지금과 같이 새롭게 공개하는 이유는 에디터를 만들면서 Sapa 가 어떤식으로 쓰일 수 있는지 정리가 되었기 때문입니다.
에디터 코드는 모두 Sapa 를 기준으로 만들어졌고, 나름 규모있는 어플리케이션도 만들어질 수 있다는 증명서 같은 것이 됩니다.
그럼 지금부터 Sapa에 대한 이야기를 간단(?)하게 한번 해보겠습니다.
Sapa(사파)는 UI 쉽게 만들 수 있게 해주는 심플한 라이브러리입니다.
https://github.com/easylogic/sapa
기본 컨셉은 아래와 같습니다.
이렇게 컨셉을 정한건 개발할 때 스펙보다는 흐름을 만들기 위함이었습니다. 하나의 기능을 위해서 알아야 하는걸 너무 많이 만드는 것 자체가 개발의 흐름을 계속 끊는 것이라고 생각했습니다.
그래서 최소한의 스펙만을 구현하고 그걸 계속 응용하는 방식으로 구현하였습니다.
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>
모든 것은 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
를 지정해서 내가 원하는 곳에서 시작할 수 있습니다.
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>
`
}
}
SecondElement
에 MyElement
를 위와 같은 코드로 넣을 수 있게 됩니다.
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 를 사용할 수 있습니다.
사용 방법은 객체 내에서 this.props
라는 이름을 값을 가지고 올 수 있습니다.
class MyElement extends UIElement {
template () {
const titleObject = this.props.title;
return `
<div>
${titleObject.title}
</div>
`
}
}
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"
이런식으로 바로 지정해도 상관 없습니다.
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')
}
}
}
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
는 특정 영역의 내부 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"]
형태로 정의 할 수 있는데, 이걸 많이 활용한 패턴입니다.
글을 따라 가면서 코드를 보시다 보면 어떤식으로 활용하는지 감이 오실거에요.
LOAD
함수는 처음에 동기적으로만 동작을 하다가 현재는 async(Promise) 형태로 동작을 할 수 있도록 구조를 바꿨습니다.
async [LOAD('$list')] () {
return await api.get('xxxx').data;
}
네, 코드를 보시면 아시겠지만 원격지에 있는 어떤 결과물도 바로 html 에 넣을 수 있게 됩니다.
파이프(PIPE)는 특정 매직메소드를 확장 하는 방법입니다. 아래 쪽에서 자세히 설명하도록 하겠습니다.
LOAD 함수에 DOMDIFF 라는 PIPE를 제공합니다. DOMDIFF 는 내부에 정의된 element 중에서 변화가 있는 것만 최종적으로 변경시켜 줍니다. (virtual dom 이랑 비슷한데요. 실제 DOM을 비교하는게 다릅니다.)
[LOAD('$list') + DOMDIFF] () {
return "<div>new element</div>
}
간혹 많은 리스트의 element 를 생성할 경우 필요한 것만 업데이트 할 수 있습니다.
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
는 개별할 수로 실행할 수 있습니다.
this.load('$list')
this.bindData('$list');
즉, 컴포넌트 전체를 매번 업데이트 하지 않고 내가 원하는 영역(ref로 지정된)을 업데이트 할 수 있게 됩니다.
이쯤되면 뭐하는 라이브러리인지 헷갈릴 수 있는데요. 사파도 간단한 라이프 사이클을 가지고 있습니다.
UIElement ->
created()
initialize() ->
initState()
render ->
template()
parseComponent() ->
create child component ->
load()
initializeEvent()
afterRender()
메소드(Hook) | 재정의 가능 | 설명 |
---|---|---|
created | O | UIElement 가 생성 될 때 실행 |
initialize | O | 내부 데이타 초기화를 할 때 실행됩니다. 형식상 created 뒤에 불려집니다. |
initState | O | 로컬 상태를 초기화 해줍니다. |
template | O | 초기 렌더링할 html 을 지정합니다. |
afterRender | O | 렌더링(브라우저에 완전히 그려지는 것) 후에 실행됩니다. |
위와 같이 사파는 특별하지 않지만 간단한 흐름을 가지고 있습니다.
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 이벤트를 자동으로 지정하게 되는 것입니다.
다양한 이벤트들이 사전에 정의가 되어 있습니다. 아래 코드를 한번 봐주세요.
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 속성은 DOM 이벤트를 지정할 때도 활용됩니다.
template () {
return `<div><span ref='$text'></span></div>`
}
[CLICK('$text')] (e) { }
이벤트가 발생할 DOM element 에 ref 를 지정해두고 , [CLICK('$text')]
이벤트를 지정하게 되면 원하는 곳에 바로 이벤트를 지정할 수 있게 됩니다.
매직메소드는 window, document 같은 글로벌 객체의 이벤트도 지정할 수 있게 해줍니다.
[RESIZE('window')] (e) { }
[POINTERSTART('document')] (e) { }
'window', 'document' 같은 이름으로 RESIZE, POINTERSTART 같은 이벤트를 바로 지정할 수 있습니다.
글로벌 객체 이벤트 지정하기
글로벌 객체에 이벤트를 지정하더라도 context 는 그대로 UIElement 의 this 로 유지됩니다. 그렇기 때문에 UIElement 객체가 사라지면 지정된 이벤트도 같이 사라지게 됩니다.
즉, 특별히 외부 이벤트 관리를 해줄 필요가 없는 상태입니다.
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 는 decorator 와 비슷한(?) 형태의 기능을 지원하기 위한 Sapa 만의 고유한 기능합니다.
js 에서 메소드를 지정하는 방법을 확장 해서 +
기호를 가지고 몇 가지 기능을 연결해서 사용할 수 있습니다. 이렇게 함으로써 메소드의 기능을 확장하고 필요한 로직만 나열 할 수 있도록 도와줍니다.
[CLICK() + ALT]
위 구문은 click 이벤트에 ALT 키가 눌러져있을 때만 해당 이벤트 메소드를 실행합니다.
PIPE 는 이렇게 +
문자로 조합을 할 수 있습니다.
어떤게 있는지 하나씩 보시죠.
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 키를 동시에 누른 상태가 됩니다.
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) {}
LEFT_BUTTON, RIGHT_BUTTON 은 마우스 버튼의 위치에 따른 필터링을 합니다.
[CLICK() + LEFT_BUTTON] (e) {}
[CLICK() + RIGHT_BUTTON] (e) {}
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 이벤트를 다루는 방식과 비슷하게 내부 메세징 시스템도 메소드 형태로 다룹니다.
모든 실행 요소는 역시나 this
를 context
로 가지고 있습니다. 즉, 코딩하는 흐름이 자연스럽게 이어지게 됩니다.
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)
B
는 A
와 직접적인 연관은 없지만 emit
으로 메세지를 보낼 수 있습니다.
emit
은 SUBSCRIBE
로 등록된 함수에 메세지를 보내는 역할을 합니다.
기본적으로 나 자신을 제외한 등록된 모든 객체에 메세지를 보내게 됩니다.
[CLICK()] () {
this.emit('setLocale', 'ko')
}
나 자신에세 메세지를 보내지 않는 이유는 메세지가 여러 단계에서 전송이 되다가 나에게 까지 와서 다시 나 자신을 부르면 호출하면 메세지가 끝나지 않고 영원히 돌면서 호출할 수 있기 때문에 막혀있습니다.
그렇다면 나 자신에게 메세지를 보낼 수는 없을까요?
그 때 사용할 수 있는 것이 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 로 메세지 구독을 정의 했는데 다른 외부 메세지랑 이름이 같아서 필요치 않게 계속 실행 된다면 SUBSCRIBE_SELF 를 사용해서 trigger 로만 사용할 수 있도록 막을 수 있습니다.
이렇게 구독된 이벤트는 emit 로 실행되지 않습니다.
class A extends UIElement {
[SUBSCRIBE_SELF('setLocale')] (locale) {
console.log(locale);
}
}
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 라는 메세지를 각자 다르게 줘도 위에 정의된 메소드는 하나만 실행하게 됩니다.
메세지는 DOM 과 비슷하게 DEBOUNCE
파이프를 가집니다.
특정 메세지를 받고 실행 시간을 조절할 수 있습니다.
[SUBSCRIBE('a') + DEBOUNCE(100)] () { }
TROTTLE
도 같은 방식으로 사용가능합니다.
[SUBSCRIBE('a') + THROTTLE(100)] () { }
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 를 다시 렌더링 하게 됨으로 큰변화가 있을 때 잘 쓰면 좋습니다.
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 를 찾을 수 있고 이벤트를 원하는 시점에 정의하고 특정 구간에 메세지를 보내는 것만으로 대부분은 할 수 있을 것 같습니다.
기본 스펙은 이렇게 하나의 문서가 끝이고 특별한 일이 있지 않으면 크게 달라지지 않을거라고 생각합니다. 이렇게만 보면 간단하긴 한데 이걸로 어떻게 할 수 있지라는 생각을 하실 수 있을 것 같은데요.
저도 고민이 많이 되었습니다.
그렇게 고민하다가 시간보내는 것 보다 일단 한번 해보고 생각해보자 해서 현재는 이렇게 만들어진 라이브러리를 가지고 좀 규모있게 열심히 웹 디자인 에디터를 만들고 있습니다.
Sapa를 어떻게 썼는지를 볼려면 여기를 보시면 됩니다.
https://github.com/easylogic/editor
Sapa는 나 스스로도 아직은 많은 것을 할 수 있구나를 많이 느낄 수 있는 프로젝트였습니다. 기존에는 항상 기술에 쫓기는 느낌이었다면 이 프로젝트를 하면서 기술에서 좀 더 자유로워지고 다르게 생각할 수 있어서 좋았던 것 같습니다.
긴글 읽어주셔서 감사합니다.
ps.
혹시나 같이 하실 분은 언제든지 연락 주셔도 좋아요. ^^
https://github.com/easylogic/sapa
https://github.com/easylogic/editor
옛날생각이 나네요 :) 저 역시 angular / react / vue말고 내 프레임워크를 만들어서 서비스를 만들던 때도 있었습니다. 사업이 아니라 취업을 하게 되면서 모든 걸 놓고 메이져를 해야 할때 참 슬프더라구요~ 예전 부터 git에서 figma와 같은 editor 만드는 거 지켜보고 있었는데 응원합니다!!
멋집니다..!!