어제 만들었던 컴포넌트에서는 상위 컴포넌트(App)이 리렌더링될때 자식 컴포넌트(Todo)의 state 값들이 초기화 되는 현상이 있었다. 아직 정확한 원인을 찾지못했지만 componentCompare함수 내부에서 컴포넌트를 새로 만들고 render()함수를 호출하여 반환된 vDOM객체와 이미 존재하는 컴포넌트의 render()함수를 호출하여 반환된 vDOM객체 사이에 차이점이 있다고 판단되어 componentCompare함수를 수정하게 되었다.
(찾게 되면 내용을 추가할 예정이지만 디버깅을 해봐도 확인이 어려웠다... 다음 포스팅에서 정확한 원인을 찾아냈다!!)
기존에는 다음과 같이 업데이트된 props로 vDOM 객체를 얻기위해서 컴포넌트를 생성 후에 render()메서드를 호출했다.
// 기존
function componentCompare(vDOM: IDom, realDOM: INode, idx: number = 0){
// 과거랑 현재랑 같은 컴포넌트라면
const nextComponentVDOM = createComponent(vDOM);
nodeCompare(nextComponentVDOM, realDOM.parentNode, realDOM, idx);
}
// createComponent()함수
export function createComponent(vDOM: IDom) {
if(typeof vDOM.type === 'string') return;
const C = vDOM.type;
const component = new C(vDOM.attributes || {});
const componentVDOM = component.render();
injectComponentToVDOM(componentVDOM, component);
return componentVDOM;
}
그런데 새로운 컴포넌트를 생성하지 않아도 기존 컴포넌트의 props를 업데이트시키고 render()메서드를 호출하여도 갱신된 vDOM객체를 얻을 수 있다.
갱신된 vDOM객체를 얻어야 기존 vDOM객체와 비교하여 노드들을 업데이트 할 수 있기 때문에 기존 컴포넌트의 props를 업데이트 시킬 수 있는 updateProps()라는 메서드를 추가시켜주었고 해당 메서드에 접근할 수 있도록 vDOM객체의 속성에 컴포넌트를 추가시켜주었다.
// Core/Dj/Component.ts
setState(newState: Record<string, any>){
// 생략...
const componentVDOM = this.render();
injectComponentToVDOM(componentVDOM, this);
}
updateProps(props: AttributeType) {
this.props = props;
}
// injectComponentToVDOM()함수
export const injectComponentToVDOM = (componentVDOM: IDom, component: Component) => {
componentVDOM.DJ_COMPONENT = component;
}
그러면 추가한 vDOM객체의 속성을 활용하여 메서드에 접근한 다음 props를 업데이트 후 render()메서드를 호출하면 갱신된 vDOM객체를 얻을 수 있었다. 이후에는 갱신된 vDOM객체와 기존의 vDOM객체를 비교해주는 nodeCompare()함수를 다시 호출해주면 필요한 부분만 업데이트 할 수 있게된다.
// 수정
function componentCompare(vDOM: IDom, oldVDOM: IDom, realDOM: INode, idx: number = 0){
// 과거랑 현재랑 같은 컴포넌트인 경우
const nextComponentVDOM = getVDOMFromOldComponent(vDOM, oldVDOM);
nodeCompare(nextComponentVDOM, realDOM.parentNode, realDOM, idx);
}
// getVDOMFromOldComponent함수
export function getVDOMFromOldComponent(vDOM: IDom, oldVDOM: IDom){
const oldComponent = oldVDOM.DJ_COMPONENT;
oldComponent.updateProps(vDOM.attributes);
const nextComponentVDOM = oldComponent.render();
injectComponentToVDOM(nextComponentVDOM, oldComponent);
// 최신 vDOM 업데이트
injectVDOMInToNode(oldComponent._DOM, nextComponentVDOM);
return nextComponentVDOM;
}
value, checked, disabled와 같은 key는 값이 없어도 저장이 되어야한다. 그런데 기존에는 값이 없다면 업데이트를 하지않았기 때문에 정상적으로 처리되지 않는 문제점이 있었다. (1)
// 기존
Object.entries(newProps).forEach(([key, value]) => {
const newProp = newProps[key];
const oldProp = oldProps[key];
if(newProp === oldProp) return;
if(!value) return; // 기존의 처리 문제점 (1)
if(key.startsWith('on')){
const eventType = key.slice(2).toLowerCase();
newNode.addEventListener(eventType, value);
if(oldProp) newNode.removeEventListener(eventType, oldProp);
return;
}
newNode.setAttribute(key, value); // 기존의 처리 문제점 (2)
});
// 수정 (1)
if(!isSpecialAttribute(key) && !value) return;
// isSpecialAttribute()함수
export const isSpecialAttribute = (attribute: string) => attribute === 'value' || attribute === 'checked' || attribute === 'disabled';
그런데 checked, disabled의 경우는 또 value값이 없다면 속성을 제거해줘야하기 때문에 추가적인 예외처리가 필요했다. (2)
// 수정 (2)
newNode.setAttribute(key, value);
if(isBooleanAttribute(key) && !value){
newNode.removeAttribute(key);
}
// isSpecialAttribute()함수, isBooleanAttribute()함수
export const isSpecialAttribute = (attribute: string) => attribute === 'value' || isBooleanAttribute(attribute);
export const isBooleanAttribute = (attribute: string) => attribute === 'checked' || attribute === 'disabled';
드디어 todo에서 정상적인 동작이 가능하다. 🥲
자식 컴포넌트만 리렌더링 가능하며 상위 컴포넌트 리렌더링 시 기존 컴포넌트의 상태값들이 유지되고 있다.
자식 컴포넌트에서 props로 전달받은 함수를 호출하여 해당 요소만을 업데이트 할 수 있다.
자식 컴포넌트에서 props로 전달받은 함수를 호출하여 해당 요소만을 삭제 할 수 있다.
class Todo extends Dj.Component {
constructor(props: AttributeType){
super(props);
this.state = {
value: ''
}
this.handleInput = this.handleInput.bind(this);
this.handleClick = this.handleClick.bind(this);
}
handleInput(event: Event){
const { value } = event.target as HTMLInputElement;
this.setState({
...this.state,
value
})
}
handleClick(event: Event){
const { value } = this.state;
if(value === '') return alert('책이름을 입력해주세요.');
this.props.addItem(value);
}
render() {
const {books} = this.props;
return (
<div class="my-component">
<input type='text' onInput={this.handleInput} value={this.state.value} />
<button onClick={this.handleClick}>책추가</button>
<ul>
{books.map((book: IBook) => (
<li>
<input type="checkbox" class="toggle" checked={book.completed} onChange={() => this.props.checkItem(book.id)} />
{book.content}
<button onClick={() => this.props.removeItem(book.id)}>삭제</button>
</li>
))}
</ul>
</div>
)
}
}
class App extends Dj.Component {
constructor(props: AttributeType){
super(props);
this.state = {
books: [
{ id: 1, completed: false, content: 'star' },
{ id: 2, completed: true, content: 'rain' },
]
}
this.addItem = this.addItem.bind(this);
this.checkItem = this.checkItem.bind(this);
this.removeItem = this.removeItem.bind(this);
}
addItem(item: string) {
const newBooks = [...this.state.books, {
id: DjIdx++,
completed: false,
content: `newBook:${DjIdx}-${item}`
}]
this.setState({
...this.state,
books: newBooks,
});
}
checkItem(id: number) {
const newBooks = this.state.books.map((book: IBook) => {
if(book.id === id) book.completed = !book.completed;
return book;
});
this.setState({
...this.state,
books: newBooks,
});
}
removeItem(id: number){
const newBooks = this.state.books.filter((book: IBook) => book.id !== id);
this.setState({
...this.state,
books: newBooks,
});
}
render() {
const {books} = this.state;
return (
<div>
<Todo books={books} addItem={this.addItem} checkItem={this.checkItem} removeItem={this.removeItem} />
</div>
)
}
}
Dj.render(<App />, document.querySelector('#root'));
구현된 코드는 전체코드에서 확인하실 수 있습니다.