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 + + + +
+
+

todos

+ +
+
+ + + +
+ +
+ + + + 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'); + }); +});