이 글을 읽기에 앞서
이 게시글은 리액트처럼 동작 하는 코드를 작성해보는 것이지 리액트와 100% 동일한 코드를 작성하려 하는 것이 아닙니다.
이전게시글에서 render와 rerender를 구현했다....만 너무 성능이 안좋을 것 같으니 개선을 해보고자 한다.
참고로 아래의 diffing알고리즘은 이 블로그 글의 코드를 기반으로 작성, 개선되었다.
기존의 DOM요소 변경은 특정 DOM요소를 DOM API를 통해 가져와서 그 부분만 업데이트하는 형태이다.
이런 형태를 지금의 render방식에 적용할수는 없을까?
할수있다!
바로 기존의 element객체를 활용하는 것이다.
우리는 element객체를 DOM요소로 변경했다.
따라서 element객체의 중첩구조와 DOM요소의 중첩구조는 완벽하게 동일하다.
따라서 element객체를 virtual DOM형태로 활용할 수 있다.
렌더된 HTML Element들
<div className="asdf">
<header>헤더임</header>
<span>내용</span>
</div>
렌더되기 전의 createElement함수로 만들어진 중첩 객체
{
tagName: 'div',
props: {
className: 'asdf',
},
children: [
{
tagName: 'header',
props: null,
children: ['헤더임'],
},
{
tagName: 'span',
props: null,
children: ['내용'],
},
],
};
diffing
이전에 render된 element(현재의 DOM을 만드는데 사용된 녀석)를 기록해두면
이번에 새롭게 render될 element와 비교할 수 있다.
따라서 다른 부분을 찾아낼 수 있다.
예를들어 아래와 같은 App컴포넌트와 Change컴포넌트를 비교해보자.
// App컴포넌트
const App = () => {
return (
<div className="asdf">
<header>헤더임</header>
<span>내용</span>
</div>
);
};
//Change컴포넌트
const Change = () => {
return <h1>바꼈지롱~</h1>;
};
이 컴포넌트들은 createElement에 의해 각각 아래와 같은 객체로 변환된다.
옆에 두고 차이점을 비교해보자.
사실 이 둘은 최상단 부터 다르기때문에 어쩔수없이 다 다시 그려줘야한다.
만약 같다면?
다른 부분을 만날때까지 비교하면서 하위요소로 타고 내려간다.
그리고 바뀐 부분만 다시 그려줄 것이다.
경우의 수 따져보기
그럼 어떤 경우들이 있을 수 있을까?
- 이전에는 없던게 생기는 경우 -> 새롭게 생성시켜서 붙이기
- 이전에는 있던게 없어지는 경우 -> 이전의 요소를 제거하기
- 이전의 props와 현재의 props과 다른 경우 -> 차이가 생긴 props만을 변경하기
- 이전의 tagName과 현재의 tagName이 다른 경우 -> 새로운 dom요소를 만들어서 이전 요소의 자리에 위치시키기
- 이전의 string요소, number요소 처럼 단일 요소 값이 달라지는 경우 -> 새로운 textNode를 만들어서 이전 요소 자리에 위치시키기
이 경우를 최종 요소까지 내려가면서 체크하고 적용시키면 변경된 부분만 업데이트시킬 수 있게 된다!
이부분을 updateDOM 함수로 짜보도록 하자.
인자로는
- DOM요소에 접근하기 위한 최상위 DOM요소
- 이전 element
- 변경될 element
- 현재 요소의 부모요소로부터의 인덱스(정확한 DOM요소를 찾기위함)
가 필요할 것이다.
문제점
다만, 여기서 문제가 있다.
이전에는 있던게 없어지는 경우 -> 이전의 요소를 제거하기
여기가 문제인데, index기반으로 요소를 찾기때문에
예를들어서,
3개의 요소가 있는 상태에서 중간 요소가 사라지는경우 인덱스가 하나 밀리며 2번 인덱스의 요소가 무조건 undefined가 되는 문제가 생기는 것이다.
이러면 2번요소도 지워진 경우를 업데이트 하지 못한다.
element에 ref도입
그래서 makeDOM으로 실제 DOM을 만들때, 이전 DOM요소에 ref를 더해서 실제 DOM요소를 참조할 수 있도록 했다.
export const makeDOM = (element) => {
if (typeof element === 'string' || typeof element === 'number') {
...
}
const DOMElement = document.createElement(element.tagName);
if (element.props)
...
if (element.children) {
...
}
element.ref=DOMElement //여기 추가!!
return DOMElement;
};
그러면 이전의 element는 실제 자신의 DOM요소에 접근할 수 있게되어 index와 상관없이 부모요소로부터 제거가 가능해진다!
updateAttributes
props만 바뀌는 경우, 바뀐 부분만 렌더링해주는 코드를 작성해주었다.
const updateAttributes = (target, newProps, oldProps) => {
const diffProps = getDiffKeysObject(oldProps, newProps); //달라진 props객체를 가져온다.
const addedProps = getNewKeysObject(oldProps, newProps); //추가된 props객체를 가져온다.
const deletedProps = getDeleteKeysArray(oldProps, newProps); //삭제된 props key배열을 가져온다.
if (
Object.keys(diffProps).length === 0 &&
Object.keys(addedProps).length === 0 &&
deletedProps.length === 0
)
return; //아무것도 안바뀐 경우
deletedProps.forEach((key) => { //삭제된 props에 대해 반복
if (key.match(/^on/)) { //onclick등 이벤트 콜백 삭제
target[key] = null;
}
if (key === 'className') { //className처리
target.removeAttribute('class');
}
delete target[key];
target.removeAttribute(key);
//attribute가 제대로 삭제되지 않는 경우가 있어서 이렇게 처리
});
Object.keys({ ...diffProps, ...addedProps }).forEach((key) => {
target[key] = newProps[key]; //변경된 props적용
});
};
그럼이제 작성한 updateAttributes를 활용해서 updateDOM을 작성해주자.
updateDOM
export const updateDOM = (parent, prev, cur, index = 0) => {
if (prev && cur === null) { //이전에는 있었는데 현재는 없는경우
if (typeof prev === 'string' || typeof prev === 'number') {
return (parent.innerHTML = ''); //string이거나 number인 경우 ref가 없으므로 싹 비워준다.
}
return prev.ref.remove(); //ref를 활용해서 지워준다.
}
if (prev === null && cur) { //이전에는 없었는데 새롭게 생긴 경우
return parent.appendChild(makeDOM(cur)); // 새롭게 DOM을 만들어 기존 요소에 붙인다.
}
if (typeof cur === 'string' || typeof cur === 'number') { //단일 text인경우
if (cur === prev) return; //내용이 같으면 끝
return parent.replaceChild(makeDOM(cur), parent.childNodes[index]); //내용이 다르면 새롭게 만들어 변경한다.
}
if (cur.tagName !== prev.tagName) { //태그가 달라진경우
return parent.replaceChild(makeDOM(cur), parent.childNodes[index]); //새롭게 요소를 만든다.
}
if (parent.children[index]) { //자식요소가 있다면
updateAttributes(parent.children[index], cur.props || {}, prev.props || {}); //자식요소에 대해 props를 업데이트한다.
const length = getLongerArrayLength(prev.children, cur.children); //prev와 cur중 긴 녀석을 갖고와서 반복한다.
createArray(length).forEach((i) =>
updateDOM(parent.children[index], prev.children[i], cur.children[i], i) //updateDOM을 재귀적으로 발생시킨다.
);
}
cur.ref = prev.ref; //최신 DOM의 참조를 최신 virtual DOM에 추가한다.
};
자 이렇게 updateDOM을 해줬다면, 이제 이걸 적용시켜주자.
React객체에 updateDOM을 추가해주고,
import { createElement } from './createElement';
import { render } from './render';
import { updateDOM } from './updateDOM';
const React = {
createElement,
render,
updateDOM,
};
export default React;
index.js에서 업데이트 할 수 있도록 해보자.
import App from './components/App';
import React from './core/React';
import { Change } from './components/Change';
const app = document.getElementById('root');
const prev = App(); //prev의 ref프로퍼티를 통해 실제 DOM요소를 참조할 수 있기때문에 prev변수로 관리했다.
React.render(prev, app);
setTimeout(() => React.updateDOM(app, prev, Change()), 3000); //변경된 업데이트 함수
잘 변경되는 것을 볼 수 있다!
그런데 이건 우리가 아는 리액트의 그 방식이 아니다...
현재는 똑같은 컴포넌트를 리렌더링하는게 아니라, 달라진 컴포넌트를 명시적으로 update시키는 방식으로 리렌더하고 있기 때문이다.
다음 게시글에서는 이렇게 명시적으로 업데이트를 시키기보다는상태 기반으로 리렌더링을 일으켜보자.
https://github.com/d0422/make-react