🤔 목표
이 펫 프로젝트의 목표는 다음과 같다.
- 최종적으로 라이트 한 프로젝트에 사용할 수준으로 제작한다.
- 컴포넌트 정의 기능을 간단하게 만들 수 있어야 한다.
- 아키텍처 레벨의 코드는 난이도가 높을 가능성이 크기 때문이다.
- 컴포넌트 사용을 쉽게 사용 가능한 형태로 제작해야 한다.
- 지원 기능
- 상태 변경 시, 다시 렌더링 되는 기능
- 부모-자식 관계를 맺을 수 있는 기능
- 공유상태를 사용할 수 있는 기능
📄 컴포넌트 사용법
기본 컴포넌트
js
import {component} from './core/component.js';
export const BasicComponent = component(({html}) => {
const render = () => {
return html(`<div>
<h2>Basic Component</h2>
Hello World!
</div>`);
};
return render
});
component
로 컴포넌트를 선언한다.component
인자에 함수를 전달하는 데, 해당 함수를createComponent
로 명명한다.createComponent
함수는 항상 함수를 반환해야 하는 데. 해당 함수는render
로 명명한다.createComponent
는 첫번째 인자에html
가 전달된다.html
은template
을 인자로 받아, DOM을 반환한다.
컴포넌트 내부 스토어 사용
js
import {events, query} from './core/helper/dom.js';
import {component} from './core/component.js';
export const CounterComponent = component(({store, html}) => {
const state = store.useState({
count: 0,
});
const actions = {
upCount: () => {
state.count.set(state.count.get() + 1);
},
downCount: () => {
state.count.set(state.count.get() - 1);
}
};
const render = () => {
const dom = html(`<div>
<h2>Counter Component</h2>
<button type="text" class="up">Up</button>
<button type="text" class="down">Down</button>
<div>${state.count.get()}</div>
</div>`);
events(query(dom, '.up'), {
click: actions.upCount
});
events(query(dom, '.down'), {
click: actions.downCount
});
return dom;
};
return render
});
createComponent
는 첫번째 인자에html
과 함께,store
가 전달된다.store
는 컴포넌트 내부 스토어다.store.useState
로 컴포넌트 상태를 등록한다.- 등록된 상태는
get
으로 조회,set
으로 수정 할 수 있다. set
이 실행되면render
로 다시 렌더링한다.
- 등록된 상태는
- DOM 이벤트는 DOM API로 등록한다.
리스트 렌더링
js
import {events, query} from './core/helper/dom.js';
import {component} from './core/component.js';
export const ListComponent = component(({store, html}) => {
const state = store.useState({
inputText: '',
todoList: []
});
const actions = {
addItem: () => {
if (!state.inputText.get()) {
return;
}
state.todoList.set([
...state.todoList.get(),
state.inputText.get()
])
state.inputText.set('', false);
},
changeInput: (inputText) => {
// 렌더링을 하고 싶지 않을 때, set 두번째 인자에 false처리
state.inputText.set(inputText, false);
}
};
const render = () => {
const dom = html(`<div>
<h2>List Rendering</h2>
<input type="text">
<button type="button">Add</button>
<ol>
${state.todoList.get().map((item) => {
return `<li>${item}</li>`
}).join('')}
</ol>
</div>`)
const input = query(dom, 'input');
const button = query(dom, 'button');
events(button, {
click: () => {
actions.addItem();
input.value = '';
}
});
events(input, {
input: (event) => {
actions.changeInput(event.target.value)
}
});
return dom;
};
return render;
});
- 리스트 렌더링은 Array API를 사용한다.
set
함수의 두번째 인자에false
를 전달하면, 해당 상태를 전파 하지 않는다.
컨디션 렌더링
js
import {events, query} from './core/helper/dom.js';
import {component} from './core/component.js';
export const ConditionComponent = component(({store, html}) => {
const state = store.useState({
toggle: false,
});
const actions = {
toggle: () => {
state.toggle.set(!state.toggle.get())
}
};
const render = () => {
const dom = html(`<div>
<h2>Condition Rendering</h2>
<button type="button">Toggle</button>
${state.toggle.get() ? '<div>Hello World</div>' : ''}
</div>`);
events(query(dom, 'button'), {
click: actions.toggle
});
return dom;
};
return render
});
- 컨디션 렌디링은 연산자를 사용한다.
부모-자식 관계
js
import {events, query, replaceWith} from './core/helper/dom.js';
import {component} from './core/component.js';
export const ParentButton = component(({html, store}) => {
const state = store.useState({
count: 0
});
const actions = {
upCount: () => {
state.count.set(state.count.get() + 1)
}
};
const render = () => {
const dom = html(`<div>
<h2>Parent-Child</h2>
<div>${state.count.get()}</div>
<child-button></child-button>
</div>`);
const props = {
buttonName: 'Up Count'
};
const emit = {
upCount: actions.upCount
};
replaceWith(
query(dom, 'child-button'),
ChildButton({props, emit})
);
return dom;
};
return render;
});
export const ChildButton = component(({html}, {props, emit}) => {
const render = () => {
const dom = html(`<div>
<button type="button">${props.buttonName}</button>
</div>`);
events(query(dom, 'button'), {
click: emit.upCount
});
return dom;
};
return render;
});
- 자식 컴포넌트는
replaceWith
으로 DOM을 변경한다. - 자식 컴포넌트에
{props, emit}
을 전달할 수 있다.props
는 자식에게 전달할 상태를 담는다.emit
은 자식에게 전달 받을 함수를 담는다.
- 자식 컴포넌트에 전달된
{props, emit}
은createComponent
의 두번째 인자로 전달된다. props
,emit
자료구조는 강제성이 없으나, 인자 네이밍은 강제한다.
공유 상태 사용
js
import {events, query, replaceWith} from './core/helper/dom.js';
import {createStore} from './core/store.js';
import {component} from './core/component.js';
const sharedStore = createStore();
const sharedState = sharedStore.useState({
count: 0
});
export const CounterButton = component(({html, store}) => {
store.share(sharedStore);
const actions = {
upCount: () => {
sharedState.count.set(sharedState.count.get() + 1)
}
};
const render = () => {
const dom = html(`<div>
<button type="button">Up Count</button>
</div>`);
events(query(dom, 'button'), {
click: actions.upCount
});
return dom;
};
return render;
});
export const MainComponent = component(({html, store}) => {
store.share(sharedStore);
return () => {
const dom = html(`<div>
<h2>Shared State</h2>
${sharedState.count.get()}
<counter-button1></counter-button1>
<counter-button2></counter-button2>
</div>`);
replaceWith(
query(dom, 'counter-button1'),
CounterButton()
);
replaceWith(
query(dom, 'counter-button2'),
CounterButton()
);
return dom;
}
});
- 공유 상태를 사용하려면,
store.share
로 스토어를 등록한다. - 등록된 스토어를 바로 사용하지 않고, 공유 상태를 사용한다.
마운트
js
import {append} from './core/helper/dom.js';
import {BasicComponent} from './BasicComponent.js';
import {CounterComponent} from './CounterComponent.js';
import {ListComponent} from './ListComponent.js';
import {ConditionComponent} from './ConditionComponent.js';
import {ParentButton} from './ParentChild.js';
import {MainComponent} from './SharedState.js';
const app = document.querySelector('#app');
append(app, BasicComponent());
append(app, CounterComponent());
append(app, ListComponent());
append(app, ConditionComponent());
append(app, ParentButton());
append(app, MainComponent());
- 컴포넌트 함수의 반환값은 DOM임으로
appendChild
로 마운트한다.
💻 데모
ESM를 지원하는 브라우저에서만 동작함
📄 코어 코드
헬퍼
mapValues
js
export const mapValues = (obj, f) => Object
.entries(obj)
.map(([k, v]) => ({[k]: f(v)}))
.reduce((acc, obj) => Object.assign(acc, obj));
- Object의 값을 변경해주는 map 함수다.
html
js
export const html = (template) => {
const body = document.createElement('body');
body.innerHTML = template;
if (body.childNodes.length > 1) {
console.warn('Wrapper 태그 필요!')
}
return body.childNodes[0];
};
- 템플릿을 DOM으로 변환하는 역할을 한다.
DOM
js
export const append = (parentNode, childNode) => {
parentNode.appendChild(childNode)
};
export const query = (parentNode, selector) => {
return parentNode.querySelector(selector)
};
export const events = (node, options) => {
Object
.entries(options)
.forEach(([eventName, listener]) => {
node.addEventListener(eventName, listener)
})
};
export const replaceWith = (oldNode, newNode) => {
oldNode.replaceWith(newNode)
};
- DOM API 헬퍼 역할을 한다.
observer
js
export const createSubject = () => {
const set = new Set();
return {
notify: (value) => {
set.forEach(observer => observer(value))
},
subscribe: (observer) => {
set.add(observer)
},
unsubscribe: (observer) => {
set.remove(observer)
}
};
};
- 옵져버 패턴을 구현한 함수다.
스토어
js
import {createSubject} from './helper/observer.js';
import {mapValues} from './helper/map-values.js';
export const createStore = () => {
const subject = createSubject();
const useState = (state = {}) => {
return mapValues(state, (value) => {
const get = () => value;
const set = (newValue, shouldNotify = true) => {
value = newValue;
shouldNotify && subject.notify()
};
return {get, set}
});
};
const share = (store) => {
store._subscribe(() => {
subject.notify();
})
};
return {
useState,
share,
_subscribe: subject.subscribe,
_unsubscribe: subject.unsubscribe,
}
};
createStore
실행 시,subject
를 생성한다.useState
로 상태를 등록한다.share
로 스토어의 상태변경전파를 공유한다._subscribe
,_unsubscribe
는 코어레벨에서 사용된다.
컴포넌트
js
import {html} from './helper/html.js';
import {createStore} from './store.js';
export const component = (createComponent) => {
return ({props, emit} = {props: null, emit: null}) => {
const store = createStore();
const render = createComponent({html, store}, {props, emit});
const state = {
dom: null
};
const observer = () => {
const newDom = render();
state.dom && state.dom.replaceWith(newDom);
state.dom = newDom;
};
store._subscribe(observer);
observer();
return state.dom;
};
};
createComponent
를 인자로 받는다.createComponent
를 실행하여render
를 반환 받는다.render
를 최초에 실행 후 반환한다.store
를 감시하며, 상태 전파을 받으면render
를 재실행하고 DOM을 교체한다.