diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 263fb3b..11dc228 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -21,5 +21,8 @@ module.exports = {
ecmaVersion: 'latest',
sourceType: 'module',
},
- rules: {},
+ rules: {
+ 'import/extensions': 'off',
+ 'no-plusplus': 'off',
+ },
};
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/README.md" "b/02. \353\240\214\353\215\224\353\247\201/05/README.md"
new file mode 100644
index 0000000..98724f0
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/README.md"
@@ -0,0 +1,56 @@
+# 02. 렌더링
+
+2장 렌더링의 섹션 03 ~ 05 부분을 구현하는 가이드입니다.
+
+구현에 앞서, 이전 회차에 작성했던 `view/counter.js`, `view/filters.js`, `view/todos.js` (혹은 추가로 작성한 코드들)을 가져와서 진행해주세요.
+
+## 테스트
+
+```
+npm test 02.\ 렌더링/05
+```
+
+또는
+
+```
+npm test
+```
+
+## 구현 가이드
+
+- [ ] `index.js` 에 일정 시간마다 상태를 무작위로 변경하고, 다시 렌더링을 하는 로직을 작성해보세요.
+
+ - 무작위로 변경된 상태의 값은 자유롭되, 타입은
+ ```ts
+ {
+ todos: Array<{ text: string; completed: boolean }>;
+ currentFilter: string;
+ }
+ ```
+ 을 지켜주세요.
+
+- [ ] `registry.js` 코드를 작성하고, 테스트를 통과해보세요.
+
+ - 캡슐화를 위하여 `registry` 변수는 export 하지 말아주세요.
+
+ - `index.html` 안에 렌더링 될 구성요소를 특정할 수 있도록 `data-component` 속성을 할당해보세요.
+
+- [ ] `isNodeChange.js` 코드를 작성하고, 테스트를 통과해보세요.
+
+- [ ] `applyDiff.js` 코드를 작성하고, 테스트를 통과해보세요.
+
+ - `isNodeChange.js` 에 작성한 모듈을 활용해보세요.
+
+- [ ] 프로그램 내부에서 가상 DOM을 렌더링해보세요.
+
+ - `registry.js` 의 `add` 함수를 호출하여 렌더링할 컴포넌트들을 미리 할당해주세요.
+
+ - `registry.js` 의 `renderRoot` 함수를 호출하여 컴포넌트들을 가상 DOM에 렌더링하세요.
+
+ - `applyDiff.js` 에 작성한 코드를 호출하여 가상 DOM을 렌더링하세요.
+
+## 덧붙임
+
+- 로컬 환경에서 의도한대로 작동하는지 확인해보세요.
+- 코드 맥락만 맞다면 자유롭게 import / export 하셔도 됩니다.
+- 테스트 코드에 케이스는 추가 가능하나, 기존 테스트 코드를 변경하지는 말아주세요.
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/applyDiff.js" "b/02. \353\240\214\353\215\224\353\247\201/05/applyDiff.js"
new file mode 100644
index 0000000..404a74a
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/applyDiff.js"
@@ -0,0 +1,3 @@
+const applyDiff = () => {};
+
+export default applyDiff;
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/applyDiff.test.js" "b/02. \353\240\214\353\215\224\353\247\201/05/applyDiff.test.js"
new file mode 100644
index 0000000..77b2d21
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/applyDiff.test.js"
@@ -0,0 +1,73 @@
+import applyDiff from './applyDiff';
+
+describe('applyDiff', () => {
+ let realContainer;
+ let virtualContainer;
+
+ beforeEach(() => {
+ realContainer = document.createElement('div');
+ virtualContainer = document.createElement('div');
+ realContainer.innerHTML = `
+
+
+ maintain
+
+
i am text
+
+
+ `;
+ virtualContainer.innerHTML = `
+
+
+
+ remove
+
+
hello
+
+
+ `;
+ });
+
+ test('applyDiff가 삭제된 노드를 실제 DOM에 반영하여야 한다.', () => {
+ applyDiff(realContainer, realContainer.children[0], virtualContainer.children[0]);
+
+ expect(realContainer.querySelector('ul')).toBe(null);
+ });
+
+ test('applyDiff가 추가된 노드를 실제 DOM에 반영하여야 한다.', () => {
+ applyDiff(realContainer, realContainer.children[0], virtualContainer.children[0]);
+
+ expect(realContainer.querySelector('a')).not.toBe(null);
+ expect(realContainer.querySelector('input')).not.toBe(null);
+ });
+
+ test('applyDiff가 기존 노드의 변경사항들을 실제 DOM에 반영하여야 한다.', () => {
+ applyDiff(realContainer, realContainer.children[0], virtualContainer.children[0]);
+
+ const span = realContainer.querySelector('span');
+ const textDiv = realContainer.querySelector('div.text');
+
+ expect(span.classList.contains('removed')).toBe(true);
+ expect(span.classList.contains('maintained')).toBe(false);
+ expect(textDiv.textContent).toBe('hello');
+ });
+
+ test('applyDiff의 결과로 나온 자식들의 개수가 정확해야한다.', () => {
+ applyDiff(realContainer, realContainer.children[0], virtualContainer.children[0]);
+
+ const targetNode = realContainer.querySelector('.target');
+
+ expect(targetNode.children.length).toBe(4);
+ });
+
+ test('applyDiff가 DOM의 깊은 수준까지 변경사항을 반영해야한다.', () => {
+ applyDiff(realContainer, realContainer.children[0], virtualContainer.children[0]);
+
+ const innerNode = realContainer.querySelector('.inner');
+
+ expect(innerNode).not.toBe(null);
+ expect(innerNode.textContent).toBe('remove');
+ });
+});
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/getTodos.js" "b/02. \353\240\214\353\215\224\353\247\201/05/getTodos.js"
new file mode 100644
index 0000000..ffccc74
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/getTodos.js"
@@ -0,0 +1,19 @@
+const { faker } = window;
+
+const createElement = () => ({
+ text: faker.random.words(2),
+ completed: faker.random.boolean(),
+});
+
+const repeat = (elementFactory, number) => {
+ const array = [];
+ for (let index = 0; index < number; index++) {
+ array.push(elementFactory());
+ }
+ return array;
+};
+
+export default () => {
+ const howMany = faker.random.number(10);
+ return repeat(createElement, howMany);
+};
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/index.html" "b/02. \353\240\214\353\215\224\353\247\201/05/index.html"
new file mode 100644
index 0000000..5fc15a3
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/index.html"
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ Frameworkless Frontend Development: Rendering
+
+
+
+
+
+
+
+
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/index.js" "b/02. \353\240\214\353\215\224\353\247\201/05/index.js"
new file mode 100644
index 0000000..d6d455e
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/index.js"
@@ -0,0 +1,14 @@
+import getTodos from './getTodos.js';
+import appView from './view/app.js';
+
+const state = {
+ todos: getTodos(),
+ currentFilter: 'All',
+};
+
+const main = document.querySelector('.todoapp');
+
+window.requestAnimationFrame(() => {
+ const newMain = appView(main, state);
+ main.replaceWith(newMain);
+});
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/isNodeChanged.js" "b/02. \353\240\214\353\215\224\353\247\201/05/isNodeChanged.js"
new file mode 100644
index 0000000..1a509ea
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/isNodeChanged.js"
@@ -0,0 +1,3 @@
+const isNodeChanged = () => {};
+
+export default isNodeChanged;
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/isNodeChanged.test.js" "b/02. \353\240\214\353\215\224\353\247\201/05/isNodeChanged.test.js"
new file mode 100644
index 0000000..b17bdea
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/isNodeChanged.test.js"
@@ -0,0 +1,59 @@
+import isNodeChanged from './isNodeChanged';
+
+describe('isNodeChanged', () => {
+ test('두 노드의 어트리뷰트, 텍스트 컨텐츠가 동일하다면 false 반환', () => {
+ const node1 = document.createElement('div');
+ node1.setAttribute('class', 'container');
+ node1.textContent = 'Hello, world!';
+
+ const node2 = document.createElement('div');
+ node2.setAttribute('class', 'container');
+ node2.textContent = 'Hello, world!';
+
+ expect(isNodeChanged(node1, node2)).toBe(false);
+ });
+
+ test('두 노드가 다른 어트리뷰트를 가지면 true 반환', () => {
+ const node1 = document.createElement('div');
+ node1.setAttribute('class', 'container');
+ node1.textContent = 'Hello, world!';
+
+ const node2 = document.createElement('div');
+ node2.setAttribute('class', 'wrapper');
+ node2.setAttribute('id', 'myDiv');
+ node2.textContent = 'Hello, world!';
+
+ expect(isNodeChanged(node1, node2)).toBe(true);
+ });
+
+ test('두 노드가 같은 어트리뷰트를 갖지만 어트리뷰트의 값이 다르면 true 반환', () => {
+ const node1 = document.createElement('div');
+ node1.setAttribute('class', 'container');
+ node1.textContent = 'Hello, world!';
+
+ const node2 = document.createElement('div');
+ node2.setAttribute('class', 'wrapper');
+ node2.textContent = 'Hello, world!';
+
+ expect(isNodeChanged(node1, node2)).toBe(true);
+ });
+
+ test('두 노드가 다른 텍스트 컨텐츠를 가지면 true 반환', () => {
+ const node1 = document.createElement('div');
+ node1.setAttribute('class', 'container');
+ node1.textContent = 'Hello, world!';
+
+ const node2 = document.createElement('div');
+ node2.setAttribute('class', 'container');
+ node2.textContent = 'Hello, GitHub Copilot!';
+
+ expect(isNodeChanged(node1, node2)).toBe(true);
+ });
+
+ test('두 노드가 어트리뷰트, 텍스트 컨텐츠를 가지지 않으면 false 반환', () => {
+ const node1 = document.createElement('div');
+ const node2 = document.createElement('div');
+
+ expect(isNodeChanged(node1, node2)).toBe(false);
+ });
+});
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/registry.js" "b/02. \353\240\214\353\215\224\353\247\201/05/registry.js"
new file mode 100644
index 0000000..d1c8940
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/registry.js"
@@ -0,0 +1,6 @@
+const registry = {}; // export 하지 말아주세요!
+
+const add = () => {};
+const renderRoot = () => {};
+
+export { add, renderRoot };
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/registry.test.js" "b/02. \353\240\214\353\215\224\353\247\201/05/registry.test.js"
new file mode 100644
index 0000000..50059cc
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/registry.test.js"
@@ -0,0 +1,53 @@
+import { add, renderRoot } from './registry';
+
+describe('registry 테스트', () => {
+ it('renderRoot를 이용하여 컴포넌트를 렌더링할 수 있어야한다.', () => {
+ const dummyElement = (target, className, innerHTML = '') => {
+ const element = target.cloneNode(true);
+ element.className = className;
+ element.innerHTML = innerHTML;
+ return element;
+ };
+
+ /*
+ 렌더링할 가상 컴포넌트를 정의한다.
+ 아래와 같은 DOM을 렌더링한다.
+
+ */
+ const virtualComponents = [
+ { name: 'A', component: (target) => dummyElement(target, 'aa') },
+ {
+ name: 'B',
+ component: (target) => dummyElement(target, 'bbb', ''),
+ },
+ { name: 'C', component: (target) => dummyElement(target, 'cccccc') },
+ ];
+
+ // 1. registry에 렌더링할 컴포넌트를 추가한다.
+ virtualComponents.forEach(({ name, component }) => {
+ add(name, component);
+ });
+
+ // 2. data-component 속성이 추가된 DOM을 생성한다.
+ const root = document.createElement('div');
+ root.innerHTML = `
+
+
+
+ `;
+
+ // 3. renderRoot를 이용하여 root를 렌더링한다.
+ const resultRoot = renderRoot(root, {});
+
+ expect(resultRoot.querySelector('[data-component="A"]').className).toBe('aa');
+ expect(resultRoot.querySelector('[data-component="B"]').className).toBe('bbb');
+ expect(resultRoot.querySelector('[data-component="B"] > span.test')).not.toBe(null);
+ expect(resultRoot.querySelector('[data-component="C"]').className).toBe('cccccc');
+ });
+});
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/view/counter.test.js" "b/02. \353\240\214\353\215\224\353\247\201/05/view/counter.test.js"
new file mode 100644
index 0000000..907828a
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/view/counter.test.js"
@@ -0,0 +1,47 @@
+import counterView from './counter';
+
+let targetElement;
+
+describe('counterView', () => {
+ beforeEach(() => {
+ targetElement = document.createElement('div');
+ });
+
+ test('새로운 DOM 요소는 완료되지 않은 todo의 수를 가지고 있어야 한다.', () => {
+ const newCounter = counterView(targetElement, {
+ todos: [
+ {
+ text: 'First',
+ completed: true,
+ },
+ {
+ text: 'Second',
+ completed: false,
+ },
+ {
+ text: 'Third',
+ completed: false,
+ },
+ ],
+ });
+
+ expect(newCounter.textContent).toBe('2 Items left');
+ });
+
+ test('완료하지 않은 todo가 1개일 경우를 고려해야 한다.', () => {
+ const newCounter = counterView(targetElement, {
+ todos: [
+ {
+ text: 'First',
+ completed: true,
+ },
+ {
+ text: 'Third',
+ completed: false,
+ },
+ ],
+ });
+
+ expect(newCounter.textContent).toBe('1 Item left');
+ });
+});
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/view/filters.test.js" "b/02. \353\240\214\353\215\224\353\247\201/05/view/filters.test.js"
new file mode 100644
index 0000000..b9f507c
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/view/filters.test.js"
@@ -0,0 +1,32 @@
+import filtersView from './filters';
+
+let targetElement;
+const TEMPLATE = ``;
+
+describe('filtersView', () => {
+ beforeEach(() => {
+ const tempElement = document.createElement('div');
+ tempElement.innerHTML = TEMPLATE;
+ [targetElement] = tempElement.childNodes;
+ });
+
+ test('"currentFilter"와 동일한 텍스트를 가지는 anchor 태그에 "selected" 클래스를 추가해야 한다.', () => {
+ const newCounter = filtersView(targetElement, {
+ currentFilter: 'Active',
+ });
+
+ const selectedItem = newCounter.querySelector('li a.selected');
+
+ expect(selectedItem.textContent).toBe('Active');
+ });
+});
diff --git "a/02. \353\240\214\353\215\224\353\247\201/05/view/todos.test.js" "b/02. \353\240\214\353\215\224\353\247\201/05/view/todos.test.js"
new file mode 100644
index 0000000..5959157
--- /dev/null
+++ "b/02. \353\240\214\353\215\224\353\247\201/05/view/todos.test.js"
@@ -0,0 +1,58 @@
+import todosView from './todos';
+
+let targetElement;
+
+describe('filtersView', () => {
+ beforeEach(() => {
+ targetElement = document.createElement('ul');
+ });
+
+ test('모든 todo 요소에 대해서 li 태그를 생성해야 한다.', () => {
+ const newCounter = todosView(targetElement, {
+ todos: [
+ {
+ text: 'First',
+ completed: true,
+ },
+ {
+ text: 'Second',
+ completed: false,
+ },
+ {
+ text: 'Third',
+ completed: false,
+ },
+ ],
+ });
+
+ const items = newCounter.querySelectorAll('li');
+ expect(items.length).toBe(3);
+ });
+
+ test('"todos"에 따라 모든 li 요소에 올바른 속성을 설정해야 한다.', () => {
+ const newCounter = todosView(targetElement, {
+ todos: [
+ {
+ text: 'First',
+ completed: true,
+ },
+ {
+ text: 'Second',
+ completed: false,
+ },
+ ],
+ });
+
+ const [firstItem, secondItem] = newCounter.querySelectorAll('li');
+
+ expect(firstItem.classList.contains('completed')).toBe(true);
+ expect(firstItem.querySelector('.toggle').checked).toBe(true);
+ expect(firstItem.querySelector('label').textContent).toBe('First');
+ expect(firstItem.querySelector('.edit').value).toBe('First');
+
+ expect(secondItem.classList.contains('completed')).toBe(false);
+ expect(secondItem.querySelector('.toggle').checked).toBe(false);
+ expect(secondItem.querySelector('label').textContent).toBe('Second');
+ expect(secondItem.querySelector('.edit').value).toBe('Second');
+ });
+});