※ 전반적인 코드는 황준일님의 코드를 참고하여 구현하였습니다.
1. 컴포넌트 생명주기
(1) constructor(컴포넌트 생성)
- 상태변화를 감지할 observer에게 상태 등록
- props 설정
- event 설정
(2) created(엘리먼트 생성 전)
- 엘리먼트를 그리기전 필요한 로직 수행
(3) render(엘리먼트 생성)
- 엘리먼트 생성
(4) mounted(엘리먼트 생성 후)
- 자식 컴포넌트 마운팅
2. 상태관리
(1) 컴포넌트에 종속되는 상태
- getState(키, 초기값)으로 상태 호출
- setState(키, 값)으로 상태 변경
(2) 전역으로 관리되는 상태
- store생성시 observer에게 상태 등록
- observer에 등록한 상태를 호출할때 컬렉션에 존재하지 않을 경우 해당 값 컬렉션에 추가
- 컬렉션에 새로운 값이 추가될경우 observer에 등록된 함수 호출
3. 최적화
(1) requestAnimationFrame을 이용한 호출 횟수 최적화
- requestAnimationFrame을 이용한 1프레임(60초)에 1회 호출
(2) 가상돔을 이용한 엘리먼트 생성 최적화
- 가상 엘리먼트를 실제엘리먼트와 비교하여 바뀐 부분만 랜더링 수행
4. 구현
(1) index.html
<!DOCTYPE html>
<html>
<head>
<title>Core</title>
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="/dist/bundle.js"></script>
</body>
</html>
(2) utils/debounceFrame.ts
export function debounceFrame(callback) {
// 1프레임(60초)에 1회 호출
let nextFrameCallback = -1;
return () => {
cancelAnimationFrame(nextFrameCallback);
nextFrameCallback = requestAnimationFrame(callback);
};
}
(3) utils/diff.ts
function updateAttributes(oldNode, newNode) {
// oldNode와 newNode의 태그 이름(type)이 똑같을 경우(oldNode.type === newNode.type)
// newNode와 oldNode의 attribute를 비교하여 변경된 부분만 반영한다.
// oldNode의 attribute 중 newNode에 없는 것은 모두 제거한다.
// newNode의 attribute에서 변경된 내용만 oldNode의 attribute에 반영한다.
for (const {name, value} of [...newNode.attributes]) {
if (value === oldNode.getAttribute(name)) continue;
oldNode.setAttribute(name, value);
}
for (const {name} of [...oldNode.attributes]) {
if (newNode.getAttribute(name) !== undefined) continue;
oldNode.removeAttribute(name);
}
}
export function updateElement(parent, newNode, oldNode) {
// 1. oldNode만 있는 경우
// oldNode를 parent에서 제거한다.
if (!newNode && oldNode) return oldNode.remove();
// 2. newNode만 있는 경우
// newNode를 parent에 추가한다.
if (newNode && !oldNode) return parent.appendChild(newNode);
// 3. oldNode와 newNode 모두 text 타입일 경우
// oldNode의 내용과 newNode의 내용이 다르다면, oldNode의 내용을 newNode의 내용으로 교체한다.
if (newNode instanceof Text && oldNode instanceof Text) {
// Text일 경우 nodeValue로 값 비교가 가능하다.
if (oldNode.nodeValue === newNode.nodeValue) return;
oldNode.nodeValue = newNode.nodeValue;
return;
}
// 4. oldNode와 newNode의 태그 이름이 다를 경우
// 둘 중에 하나가 String일 경우에도 해당
// oldNode를 제거하고, 해당 위치에 newNode를 추가한다.
if (newNode.nodeName !== oldNode.nodeName) {
const index = [...parent.childNodes].indexOf(oldNode);
oldNode.remove();
parent.appendChild(newNode, index);
return;
}
// 5. oldNode와 newNode의 태그 이름(type)이 같을 경우
// 가상돔(VirtualDOM)의 props를 넘기는게 아니기 때문에 oldNode와 newNode를 직접 넘긴다.
updateAttributes(oldNode, newNode);
// 6. newNode와 oldNode의 모든 자식 태그를 순회하며 1 ~ 5의 내용을 반복한다.
// 일단 childNodes를 배열로 변환해야한다.
const newChildren = [...newNode.childNodes];
const oldChildren = [...oldNode.childNodes];
const maxLength = Math.max(newChildren.length, oldChildren.length);
for (let i = 0; i < maxLength; i++) {
updateElement(oldNode, newChildren[i], oldChildren[i]);
}
}
(4) core/Observer.ts
import {debounceFrame} from "../utils/debounceFrame";
let currentObserver = null;
export const observe = callback => {
// observe에 등록한 함수(render)를 모든 컴포넌트에서 실행
currentObserver = debounceFrame(callback);
callback();
currentObserver = null;
};
export const observable = obj => {
// observable에 등록한 변수(store.state)를 순회
Object.keys(obj).forEach(key => {
let _value = obj[key];
const observers = new Set();
Object.defineProperty(obj, key, {
// observable에 등록한 변수(store.state)를 호출할 경우
get() {
// 현재 변수가 observer 컬렉션에 존재하지 않을경우 추가
if (currentObserver) observers.add(currentObserver);
return _value;
},
// observable에 등록한 변수(store.state)가 변경할 경우
set(value) {
_value = value;
// observer 컬렉션에 새로운 값이 추가될경우 observe에 등록한 함수 실행
// 현재 코드에서는 store.state에 값이 추가될경우
observers.forEach((callback: any) => callback());
},
});
});
return obj;
};
(5) core/Store.ts
import {observable} from "./Observer";
export const createStore = reducer => {
// 초기 Store생성시 initState를 리턴받고 그값을 observable에 등록한다.
const initState = observable(reducer());
// getState가 실제 state를 반환하는 것이 아니라 proxyState 반환하도록 만들어야 한다.
const proxyState: any = {};
Object.keys(initState).forEach(key => {
Object.defineProperty(proxyState, key, {
get: () => initState[key], // get만 정의, set은 불가.
});
});
// dispatch로만 state의 값을 변경할 수 있다.
const dispatch = action => {
const newState = reducer(initState, action);
for (const [key, value] of Object.entries(newState)) {
// state의 key가 아닐 경우 변경을 생략한다.
if (!initState[key]) continue;
initState[key] = value;
}
};
const getState = () => proxyState;
// subscribe는 observe로 대체한다.
return {getState, dispatch};
};
(6) store/index.ts
import {createStore} from "../core/Store";
// 초기 state의 값을 정의해준다.
const initState = {
isFilter: 0,
items: [
{
seq: 1,
contents: "item1",
active: false,
},
{
seq: 2,
contents: "item2",
active: true,
},
],
};
// dispatch에서 사용될 type들을 정의해준다.
export const ADD_ITEM = "ADD_ITEM";
export const SET_FILTER = "SET_FILTER";
export const TOGGLE_ITEM = "TOGGLE_ITEM";
export const DELETE_ITEM = "DELETE_ITEM";
// reducer를 정의하여 store에 넘겨준다.
export const store = createStore((state = initState, action: any = {}) => {
const {items} = state;
switch (action.type) {
case ADD_ITEM: {
const seq = Math.max(0, ...items.map(v => v.seq)) + 1;
const active = false;
const contents = action.payload;
return {...state, items: [...items, {seq, contents, active}]};
}
case TOGGLE_ITEM: {
const seq = action.payload;
const index = items.findIndex(v => v.seq === seq);
items[index].active = !items[index].active;
return state;
}
case DELETE_ITEM: {
const seq = action.payload;
items.splice(
items.findIndex(v => v.seq === seq),
1,
);
return state;
}
case SET_FILTER: {
const isFilter = action.payload;
state.isFilter = isFilter;
return state;
}
default:
return initState;
}
});
// reducer에서 사용될 action을 정의해준다.
export const addItem = payload => ({type: ADD_ITEM, payload});
export const setFilter = payload => ({type: SET_FILTER, payload});
export const toggleItem = payload => ({type: TOGGLE_ITEM, payload});
export const deleteItem = payload => ({type: DELETE_ITEM, payload});
(7) core/Component.ts
import {store} from "../store";
import {observable, observe} from "./Observer";
import {updateElement} from "../utils/diff";
export default class Component {
#state = {};
props;
$el;
constructor($el, props = {}) {
this.$el = $el;
this.props = props;
this.setup();
this.setEvent();
}
mounted() {}
template() {}
setEvent() {}
created() {}
getState(key, defalut) {
if (!this.#state[key]) this.#state[key] = defalut;
return this.#state[key];
}
setState(key, value) {
this.#state[key] = value;
this.render();
}
setup() {
observable(store.getState()); // state를 관찰한다.
observe(() => {
// state가 변경될 경우, 함수가 실행된다.
this.render();
});
}
render() {
this.created();
const {$el} = this;
// $el.innerHTML = this.template();
// 기존 Node를 복제한 후에 새로운 템플릿을 채워넣는다.
const newNode = $el.cloneNode(true);
newNode.innerHTML = this.template();
// diff알고리즘을 적용한다.
const oldChildNodes = [...$el.childNodes];
const newChildNodes = [...newNode.childNodes];
const max = Math.max(oldChildNodes.length, newChildNodes.length);
for (let i = 0; i < max; i++) {
updateElement($el, newChildNodes[i], oldChildNodes[i]);
}
this.mounted();
}
//이벤트 버블링
addEvent(eventType, selector, callback) {
const children = [...this.$el.querySelectorAll(selector)];
// selector에 명시한 것 보다 더 하위 요소가 선택되는 경우가 있을 땐
// closest를 이용하여 처리한다.
const isTarget = target =>
children.includes(target) || target.closest(selector);
this.$el.addEventListener(eventType, currentEvent => {
if (!isTarget(currentEvent.target)) return false;
callback(currentEvent);
});
}
}
(8) components/Count.ts
import Component from "../core/Component";
export default class Count extends Component {
key;
count;
template() {
return `
<div>
<p>${this.count}</p>
<button class="increment">증가</button>
<button class="decrement">감소</button>
</div>
`;
}
created() {
this.key = "count";
this.count = this.getState(this.key, 0);
}
setEvent() {
this.addEvent("click", ".increment", () => {
this.setState(this.key, this.count + 1);
});
this.addEvent("click", ".decrement", () => {
this.setState(this.key, this.count + 1);
});
}
}
(9) components/ItemAppender.ts
import Component from "../core/Component";
import {addItem, store} from "../store";
export default class ItemAppender extends Component {
template() {
return `<input type="text" class="appender" placeholder="아이템 내용 입력" />`;
}
setEvent() {
this.addEvent("keyup", ".appender", ({key, target}) => {
if (key !== "Enter") return;
store.dispatch(addItem(target.value));
target.value = "";
});
}
}
(10) components/Item.ts
import Component from "../core/Component";
import {addItem, store} from "../store";
export default class ItemAppender extends Component {
template() {
return `<input type="text" class="appender" placeholder="아이템 내용 입력" />`;
}
setEvent() {
this.addEvent("keyup", ".appender", ({key, target}) => {
if (key !== "Enter") return;
store.dispatch(addItem(target.value));
target.value = "";
});
}
}
(11) components/ItemFilter.ts
import Component from "../core/Component";
import {setFilter, store} from "../store";
export default class ItemFilter extends Component {
template() {
return `
<button class="filterBtn" data-is-filter="0">전체 보기</button>
<button class="filterBtn" data-is-filter="1">활성 보기</button>
<button class="filterBtn" data-is-filter="2">비활성 보기</button>
`;
}
setEvent() {
this.addEvent("click", ".filterBtn", ({target}) => {
const isFilter = Number(target.dataset.isFilter);
store.dispatch(setFilter(isFilter));
});
}
}
(12) App.ts
import Component from "./core/Component";
import Item from "./components/Item";
import ItemAppender from "./components/ItemAppender";
import ItemFilter from "./components/ItemFilter";
import Count from "./components/Count";
export default class App extends Component {
template() {
return `
<header data-component="item-appender"></header>
<main data-component="items"></main>
<footer data-component="item-filter"></footer>
<div data-component="count"></div>
`;
}
mounted() {
//자식컴포넌트 마운트
const $itemAppender = this.$el.querySelector(
'[data-component="item-appender"]',
);
const $items = this.$el.querySelector('[data-component="items"]');
const $itemFilter = this.$el.querySelector(
'[data-component="item-filter"]',
);
const $count = this.$el.querySelector('[data-component="count"]');
new ItemAppender($itemAppender);
new Item($items);
new ItemFilter($itemFilter);
new Count($count);
}
}
(13) Main.ts
import App from "./App";
new App(document.querySelector("#app"));
5. 전체코드
https://github.com/yg1110/Component/tree/core
'Web' 카테고리의 다른 글
웹페이지 동작 원리 & 브라우저 랜더링 과정 (0) | 2022.03.18 |
---|---|
Vanilla Javascript 라우터 구현 (0) | 2021.09.27 |
WebPack을 이용한 Typescript 번들링 (0) | 2021.08.09 |
Class (0) | 2020.05.01 |
prototype chain (0) | 2020.05.01 |