diffing 구현

React에서는 화면에서 바뀐 부분만 부분적으로 렌더링 해주기 위해 diffing 알고리즘을 구현하였다.

virtualDOM과 realDOM을 비교해주기 위해 리액트 홈페이지에서 설명한 방법으로 vanilla JS로 diffing 알고리즘을 구현해보자.

0. 두 요소의 길이부터 비교한다.

우리가 작업하고 있는 요소를 $old, 새롭게 생성된 요소를 $new 요소라고 하자. 우리는 $new를 기준으로 $old의 요소들을 일치 시켜주는 행위를 하면 된다.

$new의 노드길이가 $old의 노드 길이보다 길다면, $old에서 추가해주면 되지만, $new가 $old보다 길이가 짧다면, $old의 나머지 부분을 제거해줘야한다.

0.1. 길이가 짧은 경우

1
2
3
4
5
6
7
8
9
const diff = ($new, $old) => {
const newNodes = [...$new.childNodes];
const oldNodes = [...$old.childNodes];

if (oldNodes.length > newNodes.length) {
for (let i = 0; i < oldNodes.length - newNodes.length; i++)
$old.removeChild($old.lastChild);
}
};

0.2. 길이가 긴 경우

1
2
3
4
5
6
7
8
newNodes.forEach(($n, i) => {
const $o = oldNodes[i];

if ($o === undefined) {
$old.appendChild($n.cloneNode(true));
return;
}
});
  • 이전 요소에 새로운 요소가 가진 자식 노드가 없다면 이전 요소에 추가해준다.

cloneNode(true)는 해당 요소의 자식노드들 까지 모두 복제해주기 위한 옵션이다.

1. 두 요소의 루트 노드부터 비교한다.

먼저 두 루트 요소의 타입이 다르면 $old 요소에서 제거하고 새로운 트리를 구축한다.

ex) 에서

로,
에서 로 바뀌는 경우

1
2
3
4
5
6
7
8
newNodes.forEach(($n, i) => {
const $o = oldNodes[i];

if ($o.tagName !== $n.tagName) {
$old.replaceChild($n.cloneNode(true), $o);
return;
}
});
  • 이전 요소의 자식노드과 새로운 요소의 자식노드를 1:1로 tagName을 비교하여 서로 다르면 교체를 해주었다.

2. textNode, commentNode 들은 textContent를 비교한 뒤 교체해준다.

앞서 tagName으로 비교하여 같지 않을 때, 자식노드까지 비교해주었는데, textNode와 commentNode는 서로 tagName이 같으므로 이들의 textContent로 비교해주어야 한다.

1
2
3
4
5
6
7
8
9
newNodes.forEach(($n, i) => {
const $o = oldNodes[i];
// textNode:3, commentNode:8
if ($n.nodeType === 3 || $n.nodeType === 8) {
if ($n.textContent !== $o.textContent)
$old.replaceChild($n.cloneNode(true), $o);
return;
}
});

3. 이제 각 노드의 attribute 노드를 비교한다.

2번까지의 과정으로 $old 요소의 자식노드와 $new 요소의 자식노드들의 비교가 끝났고 일치까지 시켜줬다. 그러므로 이제는 자식 노드가 가지고 있는 attribute(class, inline-style, checked, disabled…)를 비교해줘야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
newNodes.forEach(($n, i) => {
const $o = oldNodes[i];

const newAttrs = [...$n.attributes];
const oldAttrs = [...$o.attributes];

for (const $nAttr of newAttrs) {
const $sameAttr = oldAttrs.find(($oAttr) => $oAttr.name === $nAttr.name);

if ($sameAttr === undefined) {
$old.replaceChild($n.cloneNode(true), $o);
return;
}

if ($sameAttr.value !== $nAttr.value) $sameAttr.value = $nAttr.value;
}

oldAttrs.forEach(($oAttr) => {
if (!newAttrs.find(($nAttr) => $nAttr.name === $oAttr.name))
$o.removeAttribute($oAttr.name);
});

diff($n, $o);
});
  • 새로운 노드의 attritube 중 이전 노드의 attribute와 같은 attribute가 없다면, 이전 요소 노드의 attribute를 새로운 노드의 attribute로 추가해준다.

  • 이전 노드의 attribute 값과 새로운 노드의 attribute 값이 다르면 이전 노드 attribute에 값을 할당해준다.

  • 만약 이전 노드의 attribute 중 새로운 노드의 attribute가 없다면 이전 노드의 해당 attribute를 제거해준다.

그리고 마지막으로 자식 노드들을 재귀적으로 diff 함수를 호출해주면 자식 노드까지 바뀐 부분만 확인해줄 수 있다.

소감

  • diff 알고리즘을 경현님과 같이 이야기 하면서 작성하다보니 완성할 수 있어서 기뻤다.

  • 이전 노드의 attribute와 새로운 노드의 attribute가 다를 때, attribute를 추가해주기 위해 setAttribute()를 사용하였는데, attribute는 HTML 구조의 초기값을 설정해주는 것이므로, 사용자의 화면에 변화되는 것이 반영되지 않고 todo를 삭제할 때, 삭제한 다음 요소에 삭제 요소의 attribute가 남아있는 것처럼 보이는 문제가 발생하여 attribute를 추가하는 경우에는 replaceChild() 메서드를 사용하여 해당 요소의 노드를 바꿔주는 방식을 사용하였다.

때문에 attribute를 추가하는 경우에는 li 요소가 전부 렌더링 되는 모습을 보이고 attribute를 제거할 때는 input에 있는 checked attribute만 제거되므로 li 요소가 아닌 li 자식 요소의 input과 input의 다음 형제 요소들만 렌더링 되는 모습을 볼 수 있었다.

댓글 공유

CBD Library Tip

카테고리 Pair Programming, level 2

9. CBD

로직

  1. 처음 DOMContentLoaded 이벤트가 발생하였을 때, 초기 state 값 설정 및 render 함수를 실행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
window.addEventListener("DOMContentLoaded", () => {
createState({
todos: [
{ id: 3, content: "Javascript", completed: false },
{ id: 2, content: "CSS", completed: true },
{ id: 1, content: "HTML", completed: false },
],
todoFilter: ["All", "Completed", "Active"],
currentTodoFilterId: 0,
});

render();
});
  1. render가 실행되면 App 클래스의 인스턴스를 생성하고 생성된 인스턴스를 domStr 프로토타입 메서드를 사용하여 문자열로 반환하고 이 문자열을 node(virtual DOM)로 반환해주었다. 생성된 Virtual DOM과 기존 index.html에 있는 root가 생성한 Real DOM을 비교하는 diff 함수를 실행한다.
1
2
3
4
5
const render = () => {
const app = new App();
console.log(app);
diff(app.newDOM(), $root);
};
  1. App 클래스에서는 todoInput, todoList, todoFilter 라는 인스턴스 프로퍼티에 각각 클래스형 컴포넌트의 인스턴스를 할당해주었다. 이 프로퍼티(컴포넌트)를 components 배열에서 관리를 해준다. 또한, 각 컴포넌트에서 데이터가 변경되어 재렌더링이 발생하면 2번 과정이 발생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
let components = []; // App 인스턴스 여러개 만들면 구분해주기 위해 전역에서 선언
class App {
constructor() {
components.forEach((c) => c.unmount());

this.todoInput = new TodoInput();
this.todoList = new TodoList();
this.todoFilter = new TodoFilter();

components = [this.todoInput, this.todoList, this.todoFilter];

components.forEach((c) => c.mount());
}

domStr() {
return `
${this.todoInput.domStr()}
${this.todoList.domStr()}
${this.todoFilter.domStr()}
`;
}

newDOM() {
return domStrToNode(this.domStr());
}
}
  • 이렇게 생성된 컴포넌트에 이벤트 핸들러를 등록시켜주기 위해서는 App 클래스의 인스턴스가 생성될 때, 즉 데이터가 변경되어 렌더링이 될 때 마다 기존 컴포넌트 목록들에게 unmount(이벤트 제거)를 실행하고 다시 컴포넌트를 등록하고 mount(이벤트 등록)을 실행한다.
  • 이벤트를 등록하고 제거해주어 컴포넌트의 생명주기를 관리해주는 이유는 이벤트 위임으로 window에게 이벤트를 등록만 하고 제거해주지 않는다면 재렌더링 발생하여 컴포넌트가 새로 생성되면 그 때 다시 또 이벤트를 등록해주므로 불필요한 이벤트 등록과 성능상 문제가 발생할 수 있기 때문에 재렌더링이 발생할 때 꼭 이전 컴포넌트의 이벤트를 제거해줘야한다.
  1. 각 컴포넌트에서는 이벤트 핸들러를 작성해주고 mount 함수 안에 이벤트 리스너를 등록하고 unmount 함수안에 이벤트를 제거해준다. domStr 함수는 해당 컴포넌트를 HTML 구조를 문자열로 반환해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class TodoList extends Component {
constructor() {
super();

let todos = [...state.todos];
if (state.currentTodoFilterId === 1)
todos = state.todos.filter((todo) => todo.completed);
if (state.currentTodoFilterId === 2)
todos = state.todos.filter((todo) => !todo.completed);

this.items = todos.map(({ id }) => new TodoItem(id));
}

mount() {
this.items.forEach((item) => item.mount());
}

unmount() {
this.items.forEach((item) => item.unmount());
}

domStr() {
return `
<ul class="todo-list">
${this.items.map((item) => item.domStr()).join("")}
</ul>
`;
}
}
  • todoList 경우는 App 에서 todoList, todoInput, todoFilter 컴포넌트를 관리해주는 것처럼 todoItem 컴포넌트를 관리해준다. App 에서 mount 함수를 실행하여 각 컴포넌트의 mount, unmount를 통해 생명주기를 관리해준 것 처럼 todoList의 mount 에서는 todoItem 컴포넌트의 mount, unmount를 관리해준다.
  • this.items 에는 각 id에 해당하는 TodoItem의 컴포넌트들로 구성된 배열이 할당된다. 그리고 domStr 함수에서 TodoItem 컴포넌트들을 map 고차함수로 돌면서 각 컴포넌트들의 domStr 함수로 호출하여 문자열로 반환한다.
  • TodoFilter에서 domStr 부분에서 id가 꼭 필요한가?
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    domStr() {
    return `
    <ul class='todo-filter'>
    ${state.todoFilter
    .map(
    (filter, i) =>
    `<li class=" filter ${i === state.currentTodoFilterId ? 'active' : ''}" id="${i}">${filter}</li>`
    )
    .join('')}
    </ul>
    `;
    }
    • all, completed, active 에 id가 굳이 필요한가??
  1. 각 컴포넌트에서 state로 관리하는 부분이 변경될 때, 상태가 바뀌는 부분만 재렌더링이 일어나도록 구현하였으므로 todoInput에서는 재렌더링이 일어날 부분이 없으므로 autofocus 기능이 사라지지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class TodoInput extends Component {
constructor() {
super();

this.addTodo = (e) => {
if (e.key !== "Enter" || e.target.value.trim() === "") return;

state.todos = [
...state.todos,
{ id: getNextId(), content: e.target.value, completed: false },
];
e.target.value = "";
};
}

mount() {
window.addEventListener("keyup", this.addTodo);
}

unmount() {
window.removeEventListener("keyup", this.addTodo);
}

domStr() {
return `<input class='todo-input' autofocus/>`;
}
}

문제

  • checkbox 클릭하면 checked가 생성되고 사라지는 렌더링은 잘 되지만, 화면에서는 바뀌지 않는 문제가 발생하였다.
    • attribute와 property의 차이때문에 발생하는 문제점이라고 생각되었다.
    • setAttribute대신에 요소를 replaceChild()로 자식노드를 교체해주었다.

소감

  • 혼자서 공식문서들여다 보는 것 보다 확실히 옆에서 같이 이야기 하고 생각을 공유하면서 코드를 작성해나가다 보니 diff 알고리즘도 완성할 수 있어서 기뻤다.
  • node.children 은 텍스트 노드를 제외한 자식 노드들을 반환
  • node.childNodes는 모든 자식 노드를 반환한다.
  • attribute와 property의 차이점에 대해 한번 더 생각해볼 수 있는 시간이 있어서 좋았다. attribute는 HTML에 있는 속성으로 rendering이 되면 attribute는 DOM property로 변환된다. 하지만 1:1 매칭이 되지는 않는다는 점을 주의하자.
    • attribute : 변하지 않는 초기 default 값 전달
    • property : 사용자의 행동으로 변할 수 있다.
    • diff 구현할 때, removeAttribute는 제대로 동작했는데, setAttribute할 때는 checkbox값이 삭제했을 때, 다음 todo에 이전되는 문제가 발생하였는데 이는 따로 정리해둬야겠다.

댓글 공유

8. news viewer

  1. 전체적인 구조를 설명하면, Nav, NewsList는 컴포넌트로 관리하여 독립적인 요소로 관리하고 root 요소에 appendChild 해주었다.
  2. Nav 요소가 클릭되면 state 상태 데이터를 클릭한 요소의 id와 변경해준다. ⇒ Nav 요소는 상태를 변경 시키지만 리렌더링은 일어나지 않는다.
  3. state 상태 데이터가 변경이 일어나면 NewsList가 리렌더링이 발생한다.

Proxy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let state = new Proxy(
{ category: "all" }, // state 객체에 key,value로 들어가게된다.
{
set: (obj, key, value) => {
obj[key] = value;
window.dispatchEvent(
new CustomEvent("statechange", {
detail: obj,
})
);
return true; // setter 는 성공했음을 나타내기 위해 true를 반환해줘야 한다.
},
}
);

state[category] = "business"; // handler 객체의 setter 접근자 프로퍼티가 실행된다.
  • 프록시 객체를 생성하여 state(상태)가 변경, 할당이 발생하면 proxy의 handler에서 먼저 로직을 실행
  • setter 접근자 프로퍼티가 실행된다는 의미는 state(상태)에 변경이 일어난 경우이므로 이 때, window에게 custom event의 이벤트 객체를 보낸다.

Observer(Intersection Observer API)

옵저버 패턴은 이벤트를 발생 시킨 객체(subject)에 옵저버나 리스너를 등록하여서 이들이 subject를 관찰하면서 구독 혹은 구독 취소 같은 행위를 할 수 있도록 하는 패턴을 말한다.

  • 상태가 변경되면 옵저버에게 notify() 메서드로 알림을 보낸다
  • 옵저버(handler)는 subject가 변경될 때, 호출할 함수들이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
currentNews = [...currentNews, ...getCategoryData(currentCategory)];
render(currentNews);
}
});
},
{
threshold: 1,
}
);

observer.observe($newsList.querySelector('.scroll-observer'));
};
  • spinner(’.scroll-obeserver’) 가 viewport에 지정한 임계점(threshold) 이상 나타났을 때, if문 안의 코드를 실행시킨다.
  • entries forEach로 조건식 entry.isIntersecting이 true 이면 처음 스크롤을 내려서 보였을 때 실행하고 false면 다시 스크롤을 올렸을 때는 코드를 실행하지 말라는 의미이다. (양방향으로 일어나기 때문에 관리해줘야한다.)
1
2
3
4
5
6
window.addEventListener("DOMContentLoaded", async () => {
observer.subscribe(render);
intersectionObserver = createObserver();
await render();
intersectionObserver.observe($container.querySelector(".scroll-observer"));
});
  • observe() 메서드를 사용하여 옵저버 타겟을 설정할 수 있다.
    • 이 코드는 DOMContentLoaded 이벤트가 발생했을 때, 한번만 발생하면 된다. 리팩터링 이전 코드는 render 할 때마다 spinner 요소를 재생성하여 위 메서드를 사용하여 target으로 지정하기 위해서 꼭 render 함수 내부에 있어야 했는데, 이는 불필요한 함수 호출과 리렌더링이므로 이를 해결하기 위해 렌더링이 불필요한 템플릿들은 App.js에서 $container innerHTML로 미리 할당해준다.
    • 위와 같이 await로 렌더가 될 때 까지 기다렸다가 ‘.scroll-observer’ 요소가 생성된게 보장이 되면 옵저버 타겟을 등록한다.
1
2
3
4
5
6
7
8
9
10
const addNews = (category, nextNews) => {
news = {
...news,
[category]: {
...news[category],
articles: [...news[category].articles, ...nextNews],
page: news[category].page + 1,
},
};
};
  • 기존에 있던 news 객체에 새로운 news 객체를 스프레드 문법으로 추가해줘야하는데, 추가해줄 때, 기존의 값을 스프레드 문법으로 풀어준 뒤 새로운 객체를 추가해줘야지 아니면 새로운 값만 할당된다.

9. CBD

1
2
3
4
5
6
class Component {
constructor($container) {
this.$container = $container;
observer.subscribe(this.render.bind(this));
}
}
  • this.render는 handler 내부에 있는 함수이름으로 호출하면 이는 일반함수로 호출되므로 this가 전역객체에 바인딩된다. 그래서 this가 불일치하는 현상이 발생한다. 이 때 bind 메서드를 사용하여 Component로 this를 바인딩한 새로운 함수를 전달해주므로 this를 일치시켜준다.

소감

  • 객체의 깊이가 길어지고 대괄호 표기법과 마침표 표기법 사용시점을 확실히 알지 못한다.
  • 구조분해 해줄 수 있는 것들은 구조분해 해줘서 간단하게 작성하자.
  • 클래스 new 연산자로 생성했을 때, 인스턴스의 구조가 어떤지 모른다. + super()
  • 8번 옵저버 패턴 구조 설명을 제대로 못한다.
  • $container를 잘못이해하고있다. 클래스 구조에서 받은 $container 매개변수를 컴포넌트로 단위로 구성할 것이다. InnerHTML과 혼동하고 있다. 컴포넌트로 사용할 컨테이너를 매개변수로 받는다.

댓글 공유

1. ToggleSideNav

1
2
3
4
5
6
7
8
const isOpen = {
get local() {
return JSON.parse(localStorage.getItem("isOpen"));
},
set local(value) {
localStorage.setItem("isOpen", JSON.stringify(value));
},
};
  • 접근자 프로퍼티 사용하면 함수를 사용하지 않아도 응집도를 높힐 수 있다.
1
2
3
4
5
6
7
8
9
10
window.addEventListener("DOMContentLoaded", () => {
if (isOpen.local === null) isOpen.local = false;

$nav.classList.toggle("active", isOpen.local);
});

window.addEventListener("load", () => {
document.body.style.visibility = "visible";
document.body.classList.remove("preload");
});
  • DOMContentLoaded 에서 CSS 속성을 설정해준 뒤 load 이벤트가 발생했을 때, body의 preload 클래스를 제거해줘야 요구사항대로 깜빡임 없이 작동한다. 왜냐하면 preload 클래스가 있으면 모든 자식요소의 transition 이벤트가 무효화된다.

2. tictactoe

  • 즉시실행함수로 클로저를 구현한다. ⇒ 정보은닉가능, 응집도 높이고, 관심사 분리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const people = {
a: [],
b: [],
};

// 승자를 가려내는 로직
Object.entries(people).forEach(([key, value]) => {
winning.some((w) =>
w.every((v, index) => v === value.sort((a, b) => a - b)[index])
);
});

// 리팩터링 후
const items = Array(9).fill(null);

// 승자를 가려내는 함수
const getWinner = () => {
const winning = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];

for (let i = 0; i < winning.length; i++) {
const [a, b, c] = winning[i];
if (items[a] && items[a] === items[b] && items[a] === items[c]) {
return items[a];
}
}
return null;
};
  • people 객체 안에서 플레이어가 선택한 요소를 배열로 관리하니깐 depth가 깊어져서 가독성도 떨어지고 유지보수가 좋지 않다.
    • 요소를 클릭할 때 마다 getWinner() 함수 조건문을 판단한다. 그래서 items 배열에 0번째 인덱스부터 차례대로 player “O” or “X”가 삽입된다. 그런 다음에 승리의 경우의 수를 담은 winning 2차원 배열을 순회하면서 items 배열이 winning 배열과 같은지 비교해주고 있다.
      예를 들면, 처음에 X가 시작하면 X가 누른 요소의 data-id가 items의 index에 ‘X’가 할당되고 그 다음 ‘O’가 누른 요소의 data-id가 items의 index에 ‘O’가 할당된다. 그 때마다 반복문 돌면서 items 배열에 winning 배열의 인덱스에 모두 같은 player가 있는지 확인하여 승리 여부를 확인한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 리팩터링 전
let current = true;
const people = {
O: [],
X: [],
};
let isWin = false;
let winner = '';

// 리팩터링 후
const state = {
player: 'O',
people: {
O: [],
X: [],
},
get isWin() {
const winning = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];

return getCombinations(this.people[this.player], 3).some(c => winning.some(w => w.every((v, index) => v === c.sort((a, b) => a - b)[index])));
},
get winner() {
return this.isWin ? this.player : ' ';
},
get length() {
return Object.values(this.people).reduce((a, c) => a + c.length, 0);
},
};

// 2번째 리팩터링 후
let player = 'X';
let items = Array(9).fill(null);

const getWinner = () => {
...
};

const setPlayer = nextPlayer => {
...
};
  • 관리해야할 상태가 많아지면 부수효과 발생할 가능성이 높아지므로, 상태 최소화를 해주었다.
    • 리팩터링 후 상태값은 줄고 접근자 프로퍼티를 사용하여 로직의 기준을 설정해주었다. 함수를 사용하는 것 대신 접근자 프로퍼티를 쓰게되면 매개변수에 대한 고민이 줄어들어 사용하였다.
    • 하지만 접근자 프로퍼티를 사용하기 위해서는 데이터를 객체안에서 보관해야 하고 그에 따라 접근 방식이 마침표 표기법과 대괄호 표기법으로 depth가 길어져 가독성이 떨어지는 것을 느꼈고 굳이 state로 관리해줄 필요가 없다고 판단하여 state를 제거하여 변수로 나타내고 접근자 프로퍼티는 함수로 나타내었다.

3. Acordion

  • $container = document.querySelector(’.acordian1’) 이다. querySelector가 꼭 document, window에만 붙을 수 있는 것이 아니라 node에 사용 가능한 메서드이다.
1
2
3
4
5
6
7
8
9
10
11
12
// 리팩터링 전
const getHeight = (e) => {
const $item = $container.querySelector(".active > ul > li");
const height = getComputedStyle($item).getPropertyValue("height");
const count = e.target.closest(".active").querySelector("ul > li").length;

return +height.replace("px", "") * count + "px";
};

// 리팩터링 후
const getHeight = ($article = $container.querySelector("article")) =>
$article.querySelector("ul").scrollHeight + "px";
  • ul 요소의 scrollHeight 값을 구하면 overflow : hidden 처리된 요소까지 포함한 높이를 구할 수 있으므로 간결하게 사용할 수 있다.

오늘 배운 점

  1. 접근자 프로퍼티를 사용하여 객체의 응집도를 높이고 가독성을 키울 수 있다는 것을 배웠다.
  2. Array.fill() vs Array.from() 의 차이를 알게 되었다.
1
2
3
4
5
6
arr.fill(value[, start[, end]])

// EX)
[1, 2, 3].fill(4); // [4, 4, 4]
[1, 2, 3].fill(4, 1); // [1, 4, 4]
[1, 2, 3].fill(4, 1, 2); // [1, 4, 3]
  • Array.fill()은 ()안의 요소를 배열에 시작부터 끝 인덱스까지 채운다.
1
2
3
4
5
6
Array.from(arrayLike[, mapFn[, thisArg]])

mapFn : 배열의 모든 요소에 대해 호출할 맵핑 함수

// EX)
Array.from([1, 2, 3], x => x + x); // [2, 4, 6]
  • Array.from()은 배열로 변환하고자 하는 유사배열 객체 또는 Iterable 객체를 얕게 복사하여 새로운 Array 객체를 만든다.
  • 주로 이중배열을 작성할 때 사용한다.

댓글 공유

stopWatch 회고

1. 복잡한 삼항 연산자를 메서드로 간단하게

1
2
3
4
5
6
const formatElapsedTime = (() => {
// 1 => '01', 10 => '10'
const format = (n) => (n + "").padStart(2, 0);
// const format = n => (n < 10 ? '0' + n : n + '');
return ({ mm, ss, ms }) => `${format(mm)}:${format(ss)}:${format(ms)}`;
})();
  • 삼항 연산자로 조건에 따라 문자열을 직접 넣어서 구현하였는데 padStart 메서드를 사용하면 가독성을 키웠다.
  • 리팩터링 전에는 minutes, seconds 같은 변수를 사용했는데, format 함수를 사용하여 중복을 제거해주었다.

2. 일관성있는 코드를 작성하자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const renderLaps = (() => {
const $laps = document.querySelector('.stopwatch > .laps');

// 랩 타임을 생성하고 DOM에 반영한다.
const createLapElement = (newLap, index) => {
const $fragment = document.createDocumentFragment();

const $index = document.createElement('div');
$index.textContent = index;
$fragment.appendChild($index);

const $newLab = document.createElement('div');
$newLab.textContent = formatElapsedTime(newLap);
$fragment.appendChild($newLab);

$laps.appendChild($fragment);

$laps.style.display = 'grid';
};
  • 리팩터링 전에는 addLap 함수에서 insertAdjacentHTML 메서드 사용하고 reset 함수에서는 innerHTML을 사용하여 일관성이 떨어졌다.

  • 위 코드는 fragment 라는 서브 DOM을 구성하여 기존 DOM을 추가하는 용도로 사용하여 일관성을 갖췄다.

3. 템플릿 건드리지 말라면 건드리지 말기

1
2
3
4
5
6
7
8
9
10
11
12
13
// 리팩터링 전
$laps.innerHTML = `
<div class="lap-title">Laps</div>
<div class="lap-title">Time</div>
`;

// 리팩터링 후
const removeAllLapElement = () => {
document
.querySelectorAll(".laps > div:not(.lap-title)")
.forEach(($lap) => $lap.remove());
$laps.style.display = "none";
};
  • 요구사항에 맞게 템플릿을 건드리지 않아야한다면 템플릿을 건드리지 않아야 하므로 innerHTML로 새로운 값을 할당하는 대신, 자식 노드에 반복문을 사용하였다.

Tabs 회고

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 리팩터링 전
window.addEventListener("DOMContentLoaded", init);

$tabs.addEventListener("click", (e) => {
const $navItems = [...document.querySelectorAll(".tab-content")];
if (!e.target.matches(".tab")) return;

activateTab(e, $navItems);
});

// 리팩터링 후
document.querySelector("nav").addEventListener("click", (e) => {
if (!e.target.classList.contains("tab")) return;

currentTabIndex = +e.target.dataset.index;

document.querySelector(".glider").style.transform = `translate3D(${
currentTabIndex * 100
}%, 0, 0)`;
document.querySelectorAll(".tab-content").forEach(($tabContent, i) => {
$tabContent.classList.toggle("active", i === currentTabIndex);
});
});
  • async init 함수 내부에서 $tabs 이벤트 핸들러 등록까지 함께 해줘서 응집도 높은 코드를 설계할 수 있다.

autocomplete 회고

1. 정규표현식에 콜백함수 사용

1
2
3
4
5
6
7
8
// 리팩터링 전
country.replace(regExp, `<strong>${country.match(regExp)}</strong>`);

// 리팩터링 후
name.replace(
new RegExp(`(${searchString})`, "i"),
(matched) => `<strong>${matched}</strong>`
);

2. 요구사항을 잘 읽고 적합하게 설계하자.

1
2
3
4
5
6
7
8
9
10
const selectSuggestItem = ($selectedItem) => {
// prettier-ignore
$toggleButton.innerHTML = ($selectedItem
? $selectedItem.innerHTML
: `<span>
<i class="bx bx-search"></i>
<span class="autocomplete-title">Select a country</span>
</span>`
) + `<i class="bx bx-caret-down"></i>`;
};
  • 리팩터링 이전 코드는 li 요소 안의 span 요소를 클릭하면 제대로 동작하지 않았다. 그래서 위와 같이 로직을 수정을 해줬어야 했다. 그러므로 꼭 요구사항을 적절히 읽고 그에 적절한 코드를 작성하는 습관을 기르자.

Carousel 회고

1. transitionend 이벤트가 발생했다 안하는 현상

1
2
3
4
5
6
$container.addEventListener("transitionend", carouselSlider.completeTransition);

$container.addEventListener(
"transitioncancel",
carouselSlider.completeTransition
);
  • transition 이벤트가 완료 되었을 때, 이벤트 핸들러를 호출하도록 코드를 작성하였다.
    그런데 transitionend 이벤트가 발생하기 전 즉, transition이 완료되기 전에 transition-property가 제거되거나 display:none 으로 설정이 변경되면 이벤트가 생성되지 않는다.

그래서 보통은 안전하게 CSS 속성 변화가 되었을 때, transitionend 이벤트를 사용하기 위해 라이브러리를 사용하는데, 요구사항에서 라이브러리를 사용하지 말라고 하였으므로, transitioncancel 이벤트를 사용하였다.

transitionend이벤트와 transitioncancel이벤트는 양방향으로 발생한다. 즉, transitionend 이벤트가 발생하면 transitioncancel 이벤트는 발생하지 않는다.

댓글 공유

2주간의 페어 프로그래밍 회고

1. 무지성 코딩을 하지 말자.

생각 없이 코드를 짜면 내가 이 코드를 다른사람에게 설명해야하는 순간이 올 때, 말문이 턱 ! 막혀버리게 된다.

“여기서 왜 이 메서드를 사용했나요?”

“왜 이 변수는 이곳에 위치해야하나요?”

“이 함수의 이름으로 봐선 이러이러한 함수같은데 그게 맞나요?”

  • 위와 같은 질문들을 받았을 때, 나의 기준과 코드를 작성하는 신념을 세워 코드를 짜야지만 앞으로도 계속해서 발전하는 코드를 작성할 수 있다.

무지성 코딩을 하지 않기 위한 팁

  1. 상대방에게 말로 설명하면서 코딩을 하자

상대방에게 말로 설명하면서 코딩을 하다보면 논리에 맞지 않는 코드를 짤 수 없게 된다.

상대방이 이해하지 못하면 이해시킨 뒤 코딩을 이어간다.

2. 틀에 잡힌 생각을 버려라

처음부터 MVC 패턴을 머릿속에 담아두고 그 틀대로 코드를 짜려고 하지 말자. 틀을 가지고 코드를 짜게되면 하나의 패턴에 국한되어 생각하는 힘을 기르는 것을 막는다.

수업 때 배운 state와 setState를 사용한 MVC 패턴은 분명 좋은 패턴이지만, 모든 기능 구현에 필요하진 않다. 그러므로 처음부터 틀에 밖힌 사고를 하지 말고 차근차근 짜보면서 필요한 것들이 생긴다면 추가하도록 한다.

3. 성능과 가독성의 딜레마

성능을 생각하면 코드의 가독성이 떨어지고,

코드의 가독성을 생각하면 성능이 떨어지는 경우가 다반사이다.

성능이 미비한 차이이니 무조건 가독성만 챙기는 것은 옳지 않다. 각각의 상황에 맞게 성능과 가독성 중 하나의 우선순위를 택했다면, 선택받지 못한 것을 사용하였을 때, 장점도 알아두고 넘어가자.

4. 되는 이유와 되지 않는 이유

코드를 작성하다보면 막히는 부분이 분명있을 것이다. 그럴 때 마다 우선 어떤 부분에서 문제가 발생하는지를 파악하는 것이 우선이다.

그리고 그 문제를 해결하기 위해 구글링을 하거나 시도를 해보는데, 그 때마다 코드가 작동해서 좋아할 것이 아니고, 왜 코드가 작동하고, 왜 코드가 작동하지 않는지를 명확히 파악하고 진행해야 한다.

소감

처음 페어 프로그래밍을 시작했을 때는 코드에 대한 두려움은 없었다. 일단 요구사항에서 언급한 기능을 구현하기 위해 코드를 작성해 나갔는데, 그렇게 의식의 흐름대로 따라가다 보니 어느 순간 막히는 부분이 분명 생겼다. 그 때 마다 발생한 문제를 직면하고 파악하려 하지 않고 어떻게 하면 작동하게 될지에 집중하였다. 하지만 이렇게 작동에만 집중하다보니 내가 무엇을 모르고 무엇을 아는 것인지 메타인지가 떨어지게 되어 학습에 별로 도움이 되지 않았다.

내가 어떤 기능을 구현하기 위한 방법을 모르는 것과 문제가 발생했을 때, 문제를 직면하지 않고 구현에만 집착하는 것은 완전히 별개의 문제이다.

전자는 처음에는 당연히 모를 수 밖에 없는 부분으로 개발을 해나가면서 구글링을 통해 지식을 습득해나가면 되는 부분이지만, 후자는 다음에 똑같은 문제가 발생했을 때 같은 실수를 반복하고 발전이 있는 코드를 작성할 수 없게 된다.

그러므로, 어떤 문제가 발생하면 왜 그런 문제가 발생했는지 근본적인 원인을 파악하고 현재 상황에서 해당 문제의 원인을 해결하기 위해 어떤 방법이 있는지를 찾아보고 시도해보면서 성공하게 되면 이것보다 더 좋은 방법은 없는지를 생각해보는 방향으로 코딩을 해야한다는 것을 느꼈다.

댓글 공유

2번째 리팩터링 하면서 느낀점

scrollGotoTop

  • 스크롤 깊이와 스로틀 딜레이와 같은 정해진 숫자는 상수이므로, 가독성을 높이기 위해 대문자로 네이밍해주었다.
    • 은닉해야할 정보가 없기 때문에 클로저로 만들어줄 필요가 없다.

counter

  • 리팩터링 전 코드에서는 생성자 함수로 만들어서 인스턴스를 생성하여 클로저를 구현하였다
    • 하지만 이 방식은 불필요한 인스턴스 생성을 한다는 생각이 들었다.
    • 리팩토링한 코드는 즉시 실행 함수로 감싸서 변수 할당 같이 한번만 실행해도 되는 것들은 한 번만 실행되도록 만들어주었고, 내부의 데이터에 유일하게 접근할 수 있는 해당 함수를 return 해주어 데이터를 은닉해주었다.
    • counter에 관련된(해당 기능에만 사용되는) 데이터들과 함수들을 한데 묶어줘 응집도를 높여주었다
1
2
3
4
5
6
7
8
//document.body.addEventListener('click', e => {
// if (!e.target.matches('button')) return;

// e.target.matches('.increase') ? counter.increase() : counter.decrease();
//});

document.querySelector(".increase").addEventListener("click", counter.increase);
document.querySelector(".decrease").addEventListener("click", counter.decrease);
  • 증가, 감소 버튼을 이벤트 위임으로 작성해보았다. 이럴 경우 이벤트 핸들러 등록 갯수가 줄어들어 증감 버튼에 대한 로직을 한곳에서 관리할 수 있고 각각의 이벤트 마다 이벤트 핸들러를 등록하지 않아도 된다는 장점이 있다고 생각했다.
    • 하지만, 이벤트 위임의 경우 각 버튼이 다른 기능을 해야하는 경우라면 이벤트가 발생한 요소에 대한 조건문이 많아지고 그에 따라 코드가 복잡해질 수 있으므로 이벤트 위임 대신 각 요소에 이벤트 핸들러를 등록하는 방법으로 선택하였다.

isPalindrome

  • 문자열을 배열로 만들어 주는 경우 str.split('') 를 사용하는 것보단 문자열이 이터러블 요소인 것을 감안하여 스프레드 문법([…string])을 사용하였다.
    • 메서드를 사용하기위해 알아야 할 것들이 많은데 스프레드 문법은 알아야할 정보가 적어 코드의 이해성을 높일 수 있다.
1
2
3
4
5
//$result.textContent = isPalindrome(value) ? `"${value}" is a palindrome` : `"${value}" is not a palindrome`

$result.textContent = `"${value}" is ${
isPalindrome(value) ? "" : "not"
} a palindrome`;
  • 중복이 아닌 부분만 삼항 연산자를 이용하여 중복을 줄였다.
  • innerHTML 대신 textContent를 사용한 이유는 해당 요소에 할당한 값이 text 요소로만 이루어져 있기 때문이다.
    • innerText는 숨겨진 텍스트는 무시하고 사용자에게 보여지는 text만 가져올 수 있기 때문에 원하지 않는 오류가 발생할 수 있다. 내부에 특별히 스타일 적용이 없는 문자열을 할당할 때는 성능상 적합하지 않다.

DarkMode

1
2
3
4
5
// +getComputedStyle(document.querySelector(':root')).getPropertyValue('--transition-duration')

+getComputedStyle(document.documentElement).getPropertyValue(
"--transition-duration"
);
  • :root 에 존재하는 사용자 정의 변수를 가지고 올 경우에는 document.documentElement로 접근해야 한다.
    • document.querySelector(':root') 도 가능하지만 querySelector 메서드를 사용하는 것보다 프로퍼티를 접근하는 것이 더 효율적이므로 판단하여 사용하자.
1
const getCurrentMode = () => JSON.parse(localStorage.getItem("isDark"));
  • 로컬스토리지에 정보를 저장할 때 문자열로 저장되기 때문에 이를 getter 함수로 가져올 때 문자열 ‘true’ 값을 boolean 값으로 받기 위해 JSON.parse() 메서드를 사용하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// const setCurrentMode = selectedMode => {
// localStorage.setItem('isDark', selectedMode);
// toggleMode();
// };

init() {
// localStorage.getItem('isDark') === null ? setCurrentMode(userTheme) : toggleMode();
localStorage.setItem('isDark', localStorage.getItem('isDark') ?? userTheme);
toggleMode();
},
toggle() {
localStorage.setItem('isDark', !getCurrentMode());
toggleMode();
}
  • 기존에는 삼항연산자를 사용하였지만, 초기값이 없는 null 상태일 때 null 병합 연산자를 사용하여 초기값을 할당함으로써 함수를 줄일 수 있었다. 또한, setCurrentMode 함수가 필요없으므로 함수를 제거하여 코드의 이해성과 가독성을 높였다.
1
2
3
window.addEventListener("DOMContentLoaded", setDarkMode.init);

window.addEventListener("load", setDarkMode.preventBlink);
  • 렌더링 되는 시점이 다르면 다른 역할을 하는 함수로 판단한다.

popupModal

  • 변수를 사용한 이유는 단지 코드의 중복때문만이 아니라 이벤트가 발생할 때마다 호출될 필요가 없는 함수의 반환값을 변수에 저장하여 사용하기 위함이다.
  • 특정 목적을 위해서 밀접하게 연관된 기능들을 한데 모아 관리하면 해당 함수에 대한 이해도가 증가하게 되고 이는 유지보수성을 높이는데 도움이 된다. 또한 코드의 재사용성도 증가하는 장점도 있어 응집도를 높이는 구조로 코드를 설계하였습니다. 응집도를 높인 코드를 짜면서 정보를 안전하게 다룰 수 있도록 클로저를 구현하였습니다.
  • 요구사항에 맞게 Enter키 입력과 OK버튼 클릭시 중복을 줄이는 코드를 작성하기 위해서 이들을 form 태그로 감싸주었고 ‘submit’ 이벤트 타입으로 이벤트 핸들러를 등록하였습니다. cancel 버튼도 연관성을 위해 form 태그 안에 추가하였는데, cancel 버튼은 기본타입을 버튼으로 변경하여 submit 이벤트가 발생하지 않도록 하였습니다.
  • is-open 클래스를 이용하여 모달창을 보여주고 사라지도록 구현하였습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const popupModal = (() => {
const $modalContainer = document.querySelector(".modal-container");
const $modal = document.querySelector(".modal");
const $popupMessage = document.querySelector(".popup-message");

return {
toggle() {
$modal.classList.toggle("is-open");
$modalContainer.classList.toggle("background-gray");
},
// remove() {
// $modal.classList.remove('is-open');
// $modalContainer.classList.remove('background-gray');
// },
render(e) {
e.preventDefault();
$popupMessage.textContent = `from popup : ${e.target.firstElementChild.value}`;
popupModal.toggle();
},
};
})();

document
.querySelector(".popup-form")
.addEventListener("submit", popupModal.render);
document.querySelector(".modal").addEventListener("click", (e) => {
if (!e.target.matches(".is-close")) return;

// popupModal.remove()
popupModal.toggle();
});

document.querySelector(".modal-container").addEventListener("click", (e) => {
if (!e.target.matches(".modal-container")) return;

// popupModal.remove()
popupModal.toggle();
});
  • e ⇒ {popupModal.render(e)} === popupModal 인수 전달안해줘도 event 객체 첫번째 인수로 넘어간다. 축약형
  • toggle 메서드를 사용하여 remove 메서드가 필요없어지므로 제거하였다.

stopWatch

  • 동일한 버튼을 구분할 때 textContent가 Start인지 Stop인지로 판단하는 것은 매우 좋지 않은 방식이므로, isStarted라는 현재 상태(시작/정지)를 판단하는 변수를 사용하였습니다.
  • leftButton / rightButton , laps/ title은 동일한 클래스를 가져 querySelectAll로 불러와서 인덱스 값으로 접근하는 것이 아닌 배열 디스트럭처링 할당으로 식별자를 할당해주었습니다.
    • 리팩터링 이후 이벤트 핸들러를 위한 leftButton, rightButton만 querySelectorAll로 받아오고 lap,title은 내부적으로 해결하였다.
  • 스톱워치내에서 관리해야할 데이터(isStarted, elapsedTime, timerId)를 하나의 객체에 담았다.
    • isStarted : 스톱워치가 시작되었는지 여부 (true / false)
    • elapsedTime : Start 버튼을 누르고 경과된 시간
      (setInterval의 delay마다 값이 증가하며, 이를 convertTime 함수를 통해 display에 표시한다.)
    • timerId : setInterval 함수의 리턴값을 가지며 clearInterval 함수와 함께 stop 기능을 구현하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const convertTime = (time) => {
const minutes =
Math.floor(time / 6000) < 10
? `0${Math.floor(time / 6000)}`
: `${Math.floor(time / 6000)}`;
const seconds =
Math.floor((time % 6000) / 100) < 10
? `0${Math.floor((time % 6000) / 100)}`
: `${Math.floor((time % 6000) / 100)}`;
const ms =
Math.floor(time % 6000) % 100 < 10
? `0${Math.floor(time % 6000) % 100}`
: `${Math.floor(time % 6000) % 100}`;

return `${minutes}:${seconds}:${ms}`;
};
  • convert 함수는 display에 보여질 시간으로 변환하고 그 값을 문자열로 리턴한다.
1
2
// $laps.children.length
$laps.childElementCount;
  • 리팩터링 전에는 자식노드의 갯수를 알기 위해서 자식노드(children)로 접근한 다음 길이(length)로 접근하여 값을 얻었지만, childElementCount라는 프로퍼티를 이용하여 한 번에 값을 얻을 수 있었다.
1
$laps.insertAdjacentHTML("beforeend", newLap);
  • 리팩터링 전에는 laps를 배열로 관리하여 이를 innerHTML으로 하나가 추가되더라도 모든 부분을 렌더링하였지만, 리팩터링 후에는 insertAdjacentHTML 메서드를 사용하여 HTML을 동적으로 추가해주어 추가된 부분만 렌더링 되도록 해주었다. (참고 : 여기서 beforeend 는 해당 요소의 자식노드 마지막에 삽입하는 것을 의미한다)
  • reset 함수에서도 insertAdjacentHTML처럼 추가된 부분만 삭제해주고 싶었지만, 고려해야할 조건들이 많아져 가독성을 위해 기존의 innerHTML을 사용하였다.
1
2
3
4
5
6
7
8
return {
toggle,
lapAndReset() {
!data.isStarted ? addLap() : reset();
},
};

$rightButton.addEventListener("click", stopWatch.lapAndReset);
  • 이벤트 핸들러에서 data.isStarted를 이용하여 조건에 따라 함수를 실행해주려 하였으나 클로저로 구현하였기 때문에 클로저 바깥에서 data.isStarted 값을 참조할 수 없기 때문에 클로저 내부에서 data.isStarted를 참조하여 조건에 맞게 해당 함수를 실행하는 함수를 return하였다.

댓글 공유

Form Validation 구현 중 느낀점

1. 모든 코드를 setState로 틀을 고정하여 생각하지 말자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
let state = {
id: "pending",
pw: "pending",
};

const [$idSuccessIcon, $pwSuccessIcon] = [
...document.querySelectorAll(".icon-success"),
];
const [$idErrorIcon, $pwErrorIcon] = [
...document.querySelectorAll(".icon-error"),
];
const [$idErrorMessage, $pwErrorMessage] = [
...document.querySelectorAll(".error"),
];
const $signinButton = document.querySelector(".button");

const render = () => {
$idSuccessIcon.classList.toggle("hidden", state.id !== "success");
$idErrorIcon.classList.toggle("hidden", state.id !== "error");

$pwSuccessIcon.classList.toggle("hidden", state.pw !== "success");
$pwErrorIcon.classList.toggle("hidden", state.pw !== "error");

$idErrorMessage.textContent =
state.id === "error" ? "이메일 형식에 맞게 입력해 주세요." : "";
$pwErrorMessage.textContent =
state.pw === "error" ? "영문 또는 숫자를 6~12자 입력하세요." : "";

if (state.id === "success" && state.pw === "success")
$signinButton.removeAttribute("disabled");
else $signinButton.setAttribute("disabled", "");
};

const setState = (newState) => {
state = { ...state, ...newState };
render();
};
const checkIdValidation = (input) => {
if (input === "") {
setState({ id: "pending" });
return;
}
const regExp =
/^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/;

setState({ id: regExp.test(input) ? "success" : "error" });
};

const $signIdInput = document.querySelector("#signin-userid");

$signIdInput.addEventListener(
"keyup",
_.debounce(() => {
checkIdValidation($signIdInput.value);
}, 500)
);
  • 위 코드는 만약 인풋에 입력해야할 값이 늘어 유효성 검사 항목이 늘어나게 된다면 유지보수가 어려워진다. 또한, DOM API에 의존적인 코드이므로 HTML 코드가 변경될 시 유지보수가 어려워질 수 있다.

  • 위 코드는 처음부터 구조를 setState로 잡고 데이터를 변경하는 함수는 setState가 도맡아 하고 render는 화면에 보여주는 역할을 모은 함수 이런 식으로 코드를 작성하였다.

하지만 위와 같이 틀을 미리 잡고 코드를 작성하는 것은 별로 좋지 않는 습관이다.

왜냐하면, state를 사용해야하는 상황이 아님에도 setState를 사용하여 그 구조에 억지로 끼워맞추게 되고 그렇게 되면 코드의 가독성과 이해도가 떨어질 수 있고 발전이 없는 코드를 작성하게 된다.

그러므로 코드를 작성할 때는, 우선 구현이 되도록 작성을 하되, 생각을 하면서 그 때 그 때 상황에 맞게 코드를 개선해나가야 한다.

위 코드를 리팩터링한 결과는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const signinSchema = {
userid: {
value: "",
get valid() {
return /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/.test(
this.value
);
},
message: "이메일 형식에 맞게 입력해 주세요.",
},
password: {
value: "",
get valid() {
return /\w{6,12}$/.test(this.value);
},
message: "영문 또는 숫자를 6~12자 입력하세요.",
},
get valid() {
return this.userid.valid && this.password.valid;
},
};

const checkValidation = (target) => {
signinSchema[target.name].value = target.value;

if (signinSchema[target.name].value === "") {
document
.querySelector(`#${target.id} ~ .icon-success`)
.classList.toggle("hidden", true);
document
.querySelector(`#${target.id} ~ .icon-error`)
.classList.toggle("hidden", true);
document.querySelector(`#${target.id} ~ .error`).textContent = "";
return;
}

document
.querySelector(`#${target.id} ~ .icon-success`)
.classList.toggle("hidden", !signinSchema[target.name].valid);
document
.querySelector(`#${target.id} ~ .icon-error`)
.classList.toggle("hidden", signinSchema[target.name].valid);
document.querySelector(`#${target.id} ~ .error`).textContent = !signinSchema[
target.name
].valid
? signinSchema[target.name].message
: "";
};

document.querySelector(".signin").addEventListener(
"input",
_.debounce((e) => {
checkValidation(e.target);
}, 500)
);
  • 리팩터링한 코드는 우선 setState로 틀을 구성하지 않았다. 굳이 처음부터 setState 함수로 데이터를 변경하고 렌더링까지 해주는 구조를 가질 필요가 없다.

마치 setState로 정해놓고 코드를 작성하는 것은 아이가 어른 흉내를 내는 듯한 느낌이 든다.

  • email과 password의 input에 관련이 있는 데이터를 객체로 관리하고 있다.

  • input에 이벤트가 발생할 때 마다 input의 value 값을 객체의 데이터에 변경해줘야한다.

  • 접근자 프로퍼티를 사용하여 접근자 프로퍼티를 참조하면 input.value 값과 정규 표현식이 같은지 확인하여 유효성 검사를 결과를 반환한다. 접근자 프로퍼티를 사용한 이유는 해당 접근자 프로퍼티를 참조할 때마다 갱신된 해당 프로퍼티의 유효성 검사 결과를 얻기 위해서 get을 붙여 접근자 프로퍼티로 만들었다.

각 객체에 필요한 데이터를 한곳에서 관리하고 이들을 이용하여 요구사항을 어떻게 충족시킬지 생각해보자.

2. DOM API 사용을 줄이자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const checkValidation = (target) => {
signinSchema[target.name].value = target.value;

if (signinSchema[target.name].value === "") {
document
.querySelector(`#${target.id} ~ .icon-success`)
.classList.toggle("hidden", true);
document
.querySelector(`#${target.id} ~ .icon-error`)
.classList.toggle("hidden", true);
document.querySelector(`#${target.id} ~ .error`).textContent = "";
return;
}

document
.querySelector(`#${target.id} ~ .icon-success`)
.classList.toggle("hidden", !signinSchema[target.name].valid);
document
.querySelector(`#${target.id} ~ .icon-error`)
.classList.toggle("hidden", signinSchema[target.name].valid);
document.querySelector(`#${target.id} ~ .error`).textContent = !signinSchema[
target.name
].valid
? signinSchema[target.name].message
: "";
};
  • 최대한 DOM API를 적게 사용하기 위해 해당 이벤트 핸들러의 이벤트 객체를 통해서 다른 요소에 접근할 수 있도록 코드를 작성하였다.

  • document.querySelector(`#${target.id} ~ .icon-success`) 의 의미는 이벤트가 발생한 요소의 id값을 선택자로 가져와서 해당 선택자의 일반 형제 요소 중 클래스가 icon-success인 요소를 선택하라는 뜻이다.

DOM API로 요소를 선택할 때 마다 시간이 오래 걸리고 어려운 작업이므로, 이러한 과정을 줄이도록 생각하며 코드를 작성한다.

3. input 이벤트 타입

1
2
3
4
5
6
document.querySelector(".signin").addEventListener(
"input",
_.debounce((e) => {
checkValidation(e.target);
}, 500)
);
  • 리팩터링 이전 코드에서는 keyup 이벤트를 사용하여 email과 password의 input에서 발생하는 이벤트를 이벤트 핸들러로 각각 등록해주었다.

  • 리팩터링한 코드에서는 이벤트 위임을 통해서 이벤트를 다뤄주었다.

  • 또한, 인수로 이벤트가 발생한 객체를 전달해주고 이벤트 핸들러 함수 내부에서는 target 매개변수를 해당 인수를 받아 배치해주었다.

  • e.target.name은 HTML 문서에서 name 어트리뷰트를 가리킨다. 이러한 점을 알게되어 HTML이 매우 중요하다는 것을 깨달았다.

댓글 공유

Toaster 리팩터링 중 느낀점

1. 함수의 인수는 3개를 넘지 말자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const generateToast = (messageType) => {
const [type, title, message] = messageType;
const $newToast = document.createElement("div");
$newToast.style.bottom = "0";
$newToast.classList.add("toast", type);

$newToast.innerHTML = `
<h4 class="toast-title">${title} ${
[...document.querySelectorAll("body .toast")].length
}</h4>
<div class="toast-message">
<svg width="24" height="24">
<use xlink:href="#${type}" />
</svg>
<p>${message}</p>
</div>
<a class="toast-close">&times;</a>`;

return $newToast;
};
  • 배열 스트럭처링으로 매개변수를 함수 내부에서 나눠서 배치하였다.

2. 함수를 사용하는 이유를 고민하자.

1
2
3
4
5
6
7
8
9
10
11
12
const addToast = (messageType) => {
const $newToast = generateToast(messageType);

$body.appendChild($newToast);
lineUpToast();

setTimeout(() => {
$newToast.remove();

lineUpToast();
}, 3000);
};
  • setTimeout 함수를 따로 함수로 빼서 사용해보면 어떨까 생각해보았지만, 만약 함수로 들어가게 된다면 오히려 그 함수를 이해하기 위해 더 큰 노력을 쏟아야 할 수 있으므로 가치 판단을 하여 함수로 넣을지 말지를 고민하자.

Autocomplete 리팩터링 중 느낀점

1. setState 함수는 함수 내부의 데이터를 변경만 하는 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let state = {
countryCode: [],
};

const setState = (newState) => {
state = { ...state, ...newState };
render();
};

const init = () => {
setState({ countryCode });
};

document
.querySelector(".autocomplete-toggle-button")
.addEventListener("click", () => {
$suggester.classList.toggle("hide");
$searchInput.focus();

autocomplete.init();
});
  • 이벤트 핸들러는 모델에서 return 해준 함수만 사용할 수 있도록 하여 관심사를 분리해준다.

  • 또한, 이벤트 핸들러가 setState 함수를 사용하여 데이터를 직접 변경하는 것은 옳지 않다. 위와 같이 init 함수를 통해서 사용하는 것으로 한다. 왜냐하면 setState 함수는 데이터 변경에만 관여하는 함수이지 다른 기능을 하는 함수가 아니기 때문이다.

2. 함수 역할의 분리

1
2
3
4
5
6
7
8
9
10
11
12
const findCountry = (inputValue) => {
const regExp = new RegExp(inputValue, "i");

const newCountryCode = countryCode
.filter(([, country]) => country.match(regExp))
.map(([code, country]) => [
code,
country.replace(regExp, `<strong>${country.match(regExp)}</strong>`),
]);

setState({ countryCode: newCountryCode });
};
  • 이전 코드에서는 findCountry 함수가 하는 역할을 setState 함수내에 있어 이를 역할에 맞게 구분해주었다.

  • 또한, 배열 디스트럭처링을 사용하여 가독성을 높였다.

([, country]) 처럼 매개변수로 배열 디스트럭처링 사용할 때, 앞의 매개변수를 사용하지 않는다면, 빈칸으로 두어도 된다.

3. 함수의 가독성을 높이자.

1
2
3
4
const selectCountry = (target) => {
$toggleButtonSpan.innerHTML = target.firstElementChild.innerHTML;
$toggleButtonSpan.classList.add("country");
};
  • selectCountry 함수의 매개변수가 이벤트가 발생한 요소를 전달해주도록 코드를 작성하여 더욱 직관적으로 작성했다.

Carousel 리팩터링 중 느낀점

1. state로 관리해야하는 데이터와 그렇지 않은 데이터

1
2
3
4
5
6
7
let state = {
currentSlide: 1,
};

let isTransitioned = true;
const DURATION = 500;
const newImages = [images[images.length - 1], ...images, images[0]];
  • 이번 과제를 하면서 어떤 데이터를 state로 관리해야할지 결정하는 것이 힘들었다. 짝 코딩을 하면서 나름 이유와 컨벤션을 정하였다.

state로 관리해야하는 데이터

  1. 사용자의 액션에 의해서 변경되는 데이터

  2. 실제 화면의 렌더링에 영향을 끼치는 데이터

  3. 서버에 저장해야할 필요가 있는 데이터

위 3가지 항목에 해당된다면 해당 데이터를 state로 관리하는 것이 옳다고 판단하였다.

그에 대한 근거는 다음 예시를 통해 알아보자.

1
2
3
4
5
6
7
const completeTransition = () => {
setState({ isTransitioned: true });
};

const completeTransition = () => {
isTransitioned = true;
};
  • 모델안의 completeTransition 함수는 state 데이터를 수정하고 렌더링을 해주는 코드였다. 하지만 위 데이터는 화면에 보여지는 데이터도 아니고, 사용자의 액션에 의해 직접적으로 변경되었다기 보다는 사용자가 액션을 발생 시켰을 때 개발자가 눈속임으로 화면을 보여주기 위함이기 때문이다.

즉, completeTransition 데이터를 사용자에게 직접적으로 보여주지도 않고 서버에 전송해야할 데이터도 아니고 실제 화면에 렌더링에 영향을 끼치는 데이터가 아니기 때문에 state로 관리 하지 않았다.

2. Model과 Controller 패턴의 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const carousel = () => {
const carouselSlider = (() => {

...

return {
init,
setStyle,
prev,
next,
completeTransition,
}
})();

window.addEventListener('DOMContentLoaded', carouselSlider.init);
}

carousel(document.querySelector('.carousel'), [images...])
  • addEventListener가 사용할 수 있는 데이터는 Model에서 반환해준 함수만 사용할 수 있다. 이러한 구조를 만듦으로서 사용자는 자신이 어떤 함수를 사용해야하는지 명시적으로 알 수 있고 다른 함수에 대한 고민을 하지 않아 코드의 이해도를 높일 수 있다.

3. DOMContentLoaded와 load 이벤트 핸들러

1
2
3
window.addEventListener("DOMContentLoaded", carouselSlider.init);

window.addEventListener("load", carouselSlider.setStyle);
  • 이미지가 로드된 다음에 이미지의 width 값과 속성을을 설정해줘야지만 제대로 동작하므로 위와 같이 로드되는 시점에 따라 코드를 구분하였다.

  • 또한 요구사항에서 이미지가 로드되어 carousel의 width가 정해진 후 opacity를 1로 바꿔줘야 하므로 로드 시점에 따라 이벤트 핸들러를 구분하는 것이 옳다.

댓글 공유

darkMode 리팩터링 중 느낀점

1. setDarkMode 함수의 응집도를 높이고 관심사를 구분하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const setDarkMode = (() => {
const $body = document.body;

const userTheme = window.matchMedia("(prefers-color-scheme: dark)").matches;
const transitionDuration =
+getComputedStyle(document.documentElement).getPropertyValue(
"--transition-duration"
) * 1000;

const getCurrentMode = () => JSON.parse(localStorage.getItem("isDark"));

const toggleMode = () => {
$body.classList.toggle("dark", getCurrentMode());
};

const setCurrentMode = (selectedMode) => {
localStorage.setItem("isDark", selectedMode);
toggleMode();
};
return {
toggle() {
setCurrentMode(!getCurrentMode());
},
init() {
localStorage.getItem("isDark") === null
? setCurrentMode(userTheme)
: toggleMode();
},
preventBlink() {
setTimeout(() => {
$body.classList.remove("hide");
}, transitionDuration);
},
};
})();

document
.querySelector(".toggle-button")
.addEventListener("click", setDarkMode.toggle);

window.addEventListener("DOMContentLoaded", setDarkMode.init);

window.addEventListener("load", setDarkMode.preventBlink);
  • setDarkMode 함수와 관련된 변수와 함수를 한곳에 모아 응집도를 높이고 즉시실행함수로 감싸고 클로저를 구현하였다.

  • 즉시실행함수의 반환값인 toggle, init, preventBlink 함수는 자유변수를 참조하고 외부함수보다 생명주기가 긴 클로저이다.

  • preventBlink 함수와 init 함수는 결국 렌더링과 관련된 함수여서 하나로 합칠까 생각했지만, 그 경우, 이미지 파일이 많은 프로그램인 경우 이미지가 로드되기 전에 darkMode를 동작이 제대로 되지 않을 수 있다고 판단하여 다른 함수로 구분하였다.

  • addEventListener는 가능한 한줄로 쓰자는 팀원과의 컨벤션을 지키기 위해 위와같이 작성하였고 이벤트 핸들러가 많아지게 되면서 복잡해지더라도 한줄로 있으면 가독성을 높일 수 있을 것이라 생각했다.

popupModal 리팩터링 중 느낀점

1. 변수를 사용한 이유

1
2
3
const $modalContainer = document.querySelector(".modal-container");
const $modal = document.querySelector(".modal");
const $popupMessage = document.querySelector(".popup-message");
  • 위의 document.querySelector() DOM API는 처음에 한번만 요소를 가져와 변수에 할당해주면 된다. 굳이 이벤트 핸들러 함수 내부에 적어서 함수가 호출될 때마다 DOM API까지 같이 호출될 필요가 없으므로 변수에 값을 저장해서 사용하였다.

2. 누군가가 코드를 읽을 때, 한번 더 생각하게 하지 않도록 작성하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1번
toggle(method) {
$modal.classList[method]('is-open');
$modalContainer.classList[method]('background-gray');
},

// 2번
toggle() {
$modal.classList.toggle('is-open');
$modalContainer.classList.toggle('background-gray');
},
remove() {
$modal.classList.remove('is-open');
$modalContainer.classList.remove('background-gray');
},
  • 처음에 중복을 줄이기 위해 1번으로 구현했었는데, 오히려 직접 사용해보니 2번의 경우가 가독성이 더 낫다고 판단했다. 그 이유는 이벤트 핸들러에서 toggle(‘remove’) 이런식으로 사용하는 것보다 toggle, remove를 사용하는 것이 더 깔끔하기 때문이다.

3. 이벤트 핸들러 축약표현 알아두기

1
2
3
4
5
6
7
render(e) {
e.preventDefault();
$popupMessage.textContent = `from popup : ${e.target.firstElementChild.value}`;
popupModal.toggle('remove');
}

document.querySelector('.popup-form').addEventListener('submit', render);
  • render 함수를 이벤트 핸들러로 등록할 때, event 객체를 받아서 e.preventDefault() 메서드를 실행하는데, 이벤트 핸들러에서는 인수를 전달해주지 않아도 축약표현으로 인식하여 event 객체를 해당 위치에 전달해준다.

stopWatch 리팩터링 중 느낀점

1. 배열 디스트럭처링 사용

1
const [$leftButton, $rightButton] = [...document.querySelectorAll(".control")];
  • leftButton, rightButton / laps, title은 동일한 클래스를 가져 querySelectAll로 불러와서 인덱스 값으로 접근하는 것이 아닌 배열 디스트럭처링 할당으로 식별자를 할당해주었습니다.

2. 버튼 요소의 textContent 값으로 조건문을 구분하는 것은 좋지 않다.

1
2
3
4
5
6
7
8
9
10
11
12
// 1번
if ($leftButton.textContent === "Stop") {
...
}

// 2번
let state = {
isStarted:true
}
if (state.isStarted) {
...
}
  • 1번 처럼 textContent로 조건문을 사용하게되면 나중에 textContent를 수정하게되면 유지보수가 어려워지므로 상태로서 관리하는 것을 추천한다.

3. 함수는 각자의 역할에 맞는 코드를 포함하도록 작성해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const render = () => {
if (state.isStarted) {
$leftButton.textContent = "Stop";
$rightButton.textContent = "Lap";
$rightButton.removeAttribute("disabled");

$laps.innerHTML = `Laps
${state.laps.map((_, index) => `<div>${index + 1}</div>`).join("")}
`;
$time.innerHTML = `Time
${state.laps.map((lap) => `<div>${lap}</div>`).join("")}
`;
} else {
$leftButton.textContent = "Start";
$rightButton.textContent = "Reset";
}
};

const setState = (newState) => {
state = { ...state, ...newState };

render();
};

const toggleStarted = () => {
setState({ isStarted: !state.isStarted });

updateTime();
};

const addLap = () => {
const newLap = $display.textContent;

setState({ laps: [...state.laps, newLap] });
};
  • render 함수는 leftButton, rightButton을 클릭했을 때, 화면에 글자가 바꾸는 일을 해준다.

  • setState 함수는 데이터를 변경하고 그 데이터를 가지고 화면에 render 하는 함수를 호출하는 역할을 한다.

  • toggleStarted 함수는 isStarted 데이터를 변경시키고 시간을 갱신하는 역할을 한다.

  • addLap 함수는 $display의 textContent값으로 laps 데이터를 변경시키는 역할을 한다.

스톱워치에 아직 리팩터링 중인데, 버튼은 2개인데 가정해야할 상황이 4가지여서 어떻게 짜면 좋을지 고민중이다…

댓글 공유

loco9939

author.bio


author.job