Typescript: 기초부터 실전형 프로젝트까지 with React + NodeJS <Section 9 121 ~ 128>
정확하게 뭘 하는지 설명도 안 해주고 다짜고짜 클래스 만들면서 출발해가지고 너무나도 어려웠다,,,
오버뷰라도 살짝 한 다음에 코드 작성으로 들어갔으면 이해하기가 훨씬 쉽지 않았을까 싶다. 솔직히 강의의 절반도 이해 못한 것 같다. 모르는 건 다 제쳐두고 이해한 것 위주로 작성할 예정이다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>ProjectManager</title>
<link rel="stylesheet" href="app.css" />
<script src="dist/app.js" defer></script>
</head>
<body>
<template id="project-input">
<form>
<div class="form-control">
<label for="title">Title</label>
<input type="text" id="title" />
</div>
<div class="form-control">
<label for="description">Description</label>
<textarea id="description" rows="3"></textarea>
</div>
<div class="form-control">
<label for="people">People</label>
<input type="number" id="people" step="1" min="0" max="10" />
</div>
<button type="submit">ADD PROJECT</button>
</form>
</template>
<template id="single-project">
<li></li>
</template>
<template id="project-list">
<section class="projects">
<header>
<h2></h2>
</header>
<ul></ul>
</section>
</template>
<div id="app"></div>
</body>
</html>
처음 강의 예제 코드를 보고, 왜 HTML이랑 CSS가 다 작성되어 있는데 화면에 아무것도 안 나오지? 내가 npm install을 잘못했나? 이런 생각을 했다.
<template> 태그
<template> 태그는 추가되거나 복사될 수 있는 HTML 요소들을 정의할 때 사용한다.
<template> 요소 내의 콘텐츠는 페이지가 로드될 때 바로 렌더링되지는 않기 때문에 사용자에게 보이지 않는다.
하지만 나중에 자바스크립트를 사용하여, 해당 콘텐츠를 복제한 후 보이도록 렌더링할 수 있다.
<template> 요소는 특정 HTML 요소들을 원하지 않을 때까지 계속해서 다시 사용할 수 있게 해준다.
만약 <template> 요소를 사용하지 않고 이러한 작업을 수행하려면,
자바스크립트를 사용하여 브라우저가 해당 HTML 요소들을 렌더링하지 않도록 HTML 코드를 작성해야 한다.
ProjectInput 클래스
class ProjectInput {
templateElement: HTMLTemplateElement;
hostElement: HTMLDivElement;
element: HTMLFormElement;
constructor() {
this.templateElement = document.getElementById(
'project-input'
)! as HTMLTemplateElement;
this.hostElement = document.getElementById('app')! as HTMLDivElement;
const importedNode = document.importNode(
this.templateElement.content,
true
);
this.element = importedNode.firstElementChild as HTMLFormElement;
this.attach();
}
private attach() {
this.hostElement.insertAdjacentElement('afterbegin', this.element);
}
}
const prjInput = new ProjectInput();
결론적으로, 우리가 보게 되는 건 <div id="app"></div>
내부다. (hostElement
)
템플릿 요소의 content를 importedNode에 할당해서(깊은 복사) 그 첫 번째 자식(form)을 클래스의 element로 지정한다.
attach()
메서드가 호출되면 hostElement
의 첫 번째 자식으로 this.element
가 삽입된다.
그러면 아래 캡처와 같이 input 폼이 렌더된다.
.importNode()
노드 복사 메서드
let node = document.importNode('복제 원하는 노드', boolean : 자식 노드 포함 여부);
반환 : [True인경우(깊은 복사) : 자식 포함], [False인 경우 : 자식 미포함]
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>노드의 복사 예제</title>
<style>
table {
border-collapse: collapse;
margin : 20px;
text-align :center;
}
table td {
border : 1px solid grey;
padding : 6px 12px;
}
</style>
</head>
<body>
<template>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</template>
<table>
<thead>
<tr>
<td>번호</td>
<td>제목</td>
<td>내용</td>
</tr>
</thead>
<tbody></tbody>
</table>
<script src="importNode.js"></script>
</body>
</html>
let temp = document.querySelector('template');
let tbody = document.querySelector('tbody');
//데이터 SET
let db = [
{'id':'1','title':'title1','content':'content1'},
{'id':'2','title':'title2','content':'content2'},
{'id':'3','title':'title3','content':'content3'}
];
//테이블 내용 생성
for (let i = 0; i < db.length; i++) {
//TR (자식노드 까지 복사)
let copyAllChild = document.importNode(temp.content,true).firstElementChild;
console.log(copyAllChild);
copyAllChild.children[0].innerText = db[i].id;
copyAllChild.children[1].innerText = db[i].title;
copyAllChild.children[2].innerText = db[i].content;
tbody.append(copyAllChild);
}
HTML 렌더링된 내용(좌)과 console.log(copyAllChild);
출력 내용(우)
말 그대로 필요한 노드 복사해서 쓰기 위한 메서드. template이랑 함께 쓰면 유용할 듯.
.insertAdjacentElement()
특정 위치에 노드 삽입
element.insertAdjacentElement(position, text)
position | 실제 위치 |
beforebegin | element 전 |
afterbegin | element 안 첫 번째 child |
beforeend | element 안 마지막 child |
afterend | element 후 |
<!-- beforebegin -->
<p>
<!-- afterbegin -->
foo
<!-- beforeend -->
</p>
<!-- afterend -->
Input Form 에 입력한 데이터 처리하기
// autobind decorator
function autobind(_: any, _2: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
const adjDescriptor: PropertyDescriptor = {
configurable: true,
get() {
const boundFn = originalMethod.bind(this);
return boundFn;
}
};
return adjDescriptor;
}
// ProjectInput Class
class ProjectInput {
templateElement: HTMLTemplateElement;
hostElement: HTMLDivElement;
element: HTMLFormElement;
titleInputElement: HTMLInputElement;
descriptionInputElement: HTMLInputElement;
peopleInputElement: HTMLInputElement;
constructor() {
this.templateElement = document.getElementById(
'project-input'
)! as HTMLTemplateElement;
this.hostElement = document.getElementById('app')! as HTMLDivElement;
const importedNode = document.importNode(
this.templateElement.content,
true
);
this.element = importedNode.firstElementChild as HTMLFormElement;
this.element.id = 'user-input';
this.titleInputElement = this.element.querySelector(
'#title'
) as HTMLInputElement;
this.descriptionInputElement = this.element.querySelector(
'#description'
) as HTMLInputElement;
this.peopleInputElement = this.element.querySelector(
'#people'
) as HTMLInputElement;
this.configure();
this.attach();
}
private gatherUserInput(): [string, string, number] | void {
const enteredTitle = this.titleInputElement.value;
const enteredDescription = this.descriptionInputElement.value;
const enteredPeople = this.peopleInputElement.value;
// 하나라도 입력 안 됐으면 alert 에러 메시지
if (
enteredTitle.trim().length === 0 ||
enteredDescription.trim().length === 0 ||
enteredPeople.trim().length === 0
) {
alert('Invalid input, please try again!');
return;
} else {
return [enteredTitle, enteredDescription, +enteredPeople];
}
}
private clearInputs() {
this.titleInputElement.value = '';
this.descriptionInputElement.value = '';
this.peopleInputElement.value = '';
}
@autobind
private submitHandler(event: Event) {
event.preventDefault();
const userInput = this.gatherUserInput();
if (Array.isArray(userInput)) {
const [title, desc, people] = userInput;
console.log(title, desc, people);
this.clearInputs();
}
}
// (1)
private configure() {
this.element.addEventListener('submit', this.submitHandler);
}
private attach() {
this.hostElement.insertAdjacentElement('afterbegin', this.element);
}
}
const prjInput = new ProjectInput();
코드가 길지만
1. input form 에 정보 입력
2. submit
3. 그 데이터 받아와서 처리
4. input 칸 비우기
아직까지는 이게 다다.
(1) configure()
메서드
이벤트 리스너를 설정하는 configure()
메서드를 따로 만드는 이유는 코드의 가독성과 유지 보수성을 높이기 위함이다.
만약 이벤트 리스너를 constructor()에서 바로 설정한다면, 코드의 복잡도가 높아져 가독성이 떨어지고, 추후 이벤트 리스너의 수정 및 삭제가 어려울 수 있다. 따라서, configure() 메서드를 만들어서 이벤트 리스너를 설정하면, 코드의 역할을 명확하게 구분할 수 있고, 추후 코드 수정 및 유지보수가 쉬워진다.
또한, this.submitHandler
를 configure()에서 등록하는 이유는 이벤트 핸들러 함수 내부에서 this를 사용할 때, this가 해당 클래스의 인스턴스를 가리키도록 하기 위해서다. 만약 이벤트 핸들러 함수를 외부에서 등록하지 않고, 바로 생성자 내에서 등록하게 되면, 이벤트 핸들러 함수 내부에서 this를 사용할 때, this가 이벤트 객체를 가리키게 되어, 해당 클래스의 인스턴스의 프로퍼티나 메서드에 접근할 수 없게 된다. 이 문제를 해결하기 위해서 configure() 메서드 내부에서 이벤트 핸들러 함수를 등록하고, 해당 함수를 데코레이터 @autobind를 사용하여 바인딩하면, this가 해당 클래스의 인스턴스를 가리키게 된다.
Q: constructor 안에서 이벤트 핸들러 함수를 등록하면 @autobind로 바인딩이 안 되는가?
@autobind 데코레이터를 사용하여 메서드를 자동으로 바인딩할 때, 이 데코레이터는 해당 메서드를 클래스의 프로토타입에 등록된 메서드로 바인딩한다. 이때 constructor에서 이벤트 핸들러 함수를 등록하면, 해당 메서드는 클래스 인스턴스의 메서드가 아니라, constructor에서 선언된 지역 함수로 등록된다.
따라서, 이벤트 핸들러 함수를 등록할 때는 configure() 메서드와 같이 클래스 인스턴스에 속하는 메서드로 등록해야 한다. 그리고 configure() 메서드에서 등록한 메서드는 @autobind 데코레이터를 사용하여 자동으로 바인딩된다. 이렇게 하면, 해당 메서드에서 this를 사용할 때, 클래스 인스턴스를 가리키게 된다.
Validation
// Validation
interface Validatable {
value: string | number;
required?: boolean;
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
}
// 객체를 받는다. 검증 조건과 input이 포함되어 있다.
function validate(validatableInput: Validatable) {
let isValid = true;
if (validatableInput.required) {
isValid = isValid && validatableInput.value.toString().trim().length !== 0;
}
if (
validatableInput.minLength != null &&
typeof validatableInput.value === 'string'
) {
isValid =
isValid && validatableInput.value.length >= validatableInput.minLength;
}
if (
validatableInput.maxLength != null &&
typeof validatableInput.value === 'string'
) {
isValid =
isValid && validatableInput.value.length <= validatableInput.maxLength;
}
if (
validatableInput.min != null &&
typeof validatableInput.value === 'number'
) {
isValid = isValid && validatableInput.value >= validatableInput.min;
}
if (
validatableInput.max != null &&
typeof validatableInput.value === 'number'
) {
isValid = isValid && validatableInput.value <= validatableInput.max;
}
return isValid;
}
// autobind decorator
function autobind(_: any, _2: string, descriptor: PropertyDescriptor) {
...
}
// ProjectInput Class
class ProjectInput {
...
constructor() {
this.templateElement = document.getElementById(
'project-input'
)! as HTMLTemplateElement;
this.hostElement = document.getElementById('app')! as HTMLDivElement;
const importedNode = document.importNode(
this.templateElement.content,
true
);
this.element = importedNode.firstElementChild as HTMLFormElement;
this.element.id = 'user-input';
this.titleInputElement = this.element.querySelector(
'#title'
) as HTMLInputElement;
this.descriptionInputElement = this.element.querySelector(
'#description'
) as HTMLInputElement;
this.peopleInputElement = this.element.querySelector(
'#people'
) as HTMLInputElement;
this.configure();
this.attach();
}
private gatherUserInput(): [string, string, number] | void {
const enteredTitle = this.titleInputElement.value;
const enteredDescription = this.descriptionInputElement.value;
const enteredPeople = this.peopleInputElement.value;
const titleValidatable: Validatable = {
value: enteredTitle,
required: true
};
const descriptionValidatable: Validatable = {
value: enteredDescription,
required: true,
minLength: 5
};
const peopleValidatable: Validatable = {
value: +enteredPeople,
required: true,
min: 1,
max: 5
};
if (
!validate(titleValidatable) ||
!validate(descriptionValidatable) ||
!validate(peopleValidatable)
) {
alert('Invalid input, please try again!');
return;
} else {
return [enteredTitle, enteredDescription, +enteredPeople];
}
}
...
}
@autobind
private submitHandler(event: Event) {
event.preventDefault();
const userInput = this.gatherUserInput();
if (Array.isArray(userInput)) {
const [title, desc, people] = userInput;
console.log(title, desc, people);
this.clearInputs();
}
}
private configure() {
this.element.addEventListener('submit', this.submitHandler);
}
private attach() {
this.hostElement.insertAdjacentElement('afterbegin', this.element);
}
}
const prjInput = new ProjectInput();
Project List 클래스
// Validation
interface Validatable {
...
}
function validate(validatableInput: Validatable) {
...
}
// autobind decorator
function autobind(_: any, _2: string, descriptor: PropertyDescriptor) {
...
}
// ProjectList Class
class ProjectList {
templateElement: HTMLTemplateElement;
hostElement: HTMLDivElement;
element: HTMLElement;
// 리스트의 type을 받아서
constructor(private type: 'active' | 'finished') {
this.templateElement = document.getElementById(
'project-list'
)! as HTMLTemplateElement;
this.hostElement = document.getElementById('app')! as HTMLDivElement;
const importedNode = document.importNode(
this.templateElement.content,
true
);
this.element = importedNode.firstElementChild as HTMLElement;
// id로 지정
this.element.id = `${this.type}-projects`;
this.attach();
this.renderContent();
}
private renderContent() {
const listId = `${this.type}-projects-list`;
this.element.querySelector('ul')!.id = listId;
// 받은 타입을 대문자로 변환해서 렌더링
this.element.querySelector('h2')!.textContent =
this.type.toUpperCase() + ' PROJECTS';
}
private attach() {
// 인풋 폼보다 아래로 가도록 마지막에 추가(beforeend)
this.hostElement.insertAdjacentElement('beforeend', this.element);
}
}
// ProjectInput Class
class ProjectInput {
...
}
const prjInput = new ProjectInput();
// 액티브, 피니쉬드 순으로 추가
const activePrjList = new ProjectList('active');
const finishedPrjList = new ProjectList('finished');
Input 데이터 -> 프로젝트 목록 전달 (+ 상태 관리)
// Project State Management
class ProjectState {
// 함수 참조 배열
private listeners: any[] = [];
private projects: any[] = [];
private static instance: ProjectState;
private constructor() {}
static getInstance() {
if (this.instance) {
return this.instance;
}
this.instance = new ProjectState();
return this.instance;
}
addListener(listenerFn: Function) {
this.listeners.push(listenerFn);
}
2. 새 프로젝트 생성
addProject(title: string, description: string, numOfPeople: number) {
const newProject = {
id: Math.random().toString(),
title: title,
description: description,
people: numOfPeople
};
this.projects.push(newProject);
for (const listenerFn of this.listeners) {
listenerFn(this.projects.slice());
}
}
}
// submitHandler에서 addProject를 호출하기 위해
// 전역에서 사용 가능한 인스턴스 생성
const projectState = ProjectState.getInstance();
// Validation
...
// autobind decorator
...
// ProjectList Class
class ProjectList {
...
assignedProjects: any[];
constructor(private type: 'active' | 'finished') {
...
projectState.addListener((projects: any[]) => {
this.assignedProjects = projects;
this.renderProjects();
});
this.attach();
this.renderContent();
}
private renderProjects() {
const listEl = document.getElementById(`${this.type}-projects-list`)! as HTMLUListElement;
for (const prjItem of this.assignedProjects) {
const listItem = document.createElement('li');
listItem.textContent = prjItem.title;
listEl.appendChild(listItem)
}
}
private renderContent() {
const listId = `${this.type}-projects-list`;
this.element.querySelector('ul')!.id = listId;
this.element.querySelector('h2')!.textContent =
this.type.toUpperCase() + ' PROJECTS';
}
private attach() {
this.hostElement.insertAdjacentElement('beforeend', this.element);
}
}
// ProjectInput Class
class ProjectInput {
...
constructor() {
...
this.configure();
this.attach();
}
...
@autobind
private submitHandler(event: Event) {
event.preventDefault();
const userInput = this.gatherUserInput();
if (Array.isArray(userInput)) {
const [title, desc, people] = userInput;
// 1. 여기서 호출
projectState.addProject(title, desc, people);
this.clearInputs();
}
}
private configure() {
this.element.addEventListener('submit', this.submitHandler);
}
private attach() {
this.hostElement.insertAdjacentElement('afterbegin', this.element);
}
}
const prjInput = new ProjectInput();
const activePrjList = new ProjectList('active');
const finishedPrjList = new ProjectList('finished');
listeners ?
ProjectState
클래스에서 listeners
배열은 새로운 프로젝트가 추가되면 해당 배열 내의 모든 함수를 실행하는 데 사용된다. 이러한 함수는 addListener 메소드를 사용하여 listeners 배열에 추가된다. ProjectList 클래스는 생성자에서 해당 배열에 함수를 추가한다. 따라서 ProjectList 클래스는 새로운 프로젝트가 추가될 때마다 해당 함수를 실행한다.
이렇게 하면 상태 변경 시 자동으로 업데이트되는 UI를 만들 수 있다.
예를 들어, addProject
메소드는 새로운 프로젝트를 projects 배열에 추가 한 다음, listeners 배열 내에 등록 된 모든 함수를 호출하여 새로운 상태를 전달한다. ProjectList 클래스는 addListener 메소드를 사용하여 새로운 상태를 수신하고 해당 상태에 대한 UI 업데이트를 수행한다.
이러한 방식으로 상태 관리를 수행하면, 상태 변경시마다 UI를 업데이트하는 작업을 수동으로 수행하지 않아도 되므로 코드 유지 보수성이 향상된다. 또한 애플리케이션 전반에 걸쳐 일관된 상태를 유지할 수 있다.
중간 정리
1. ProjectState
클래스는 프로젝트 상태를 관리한다. addListener()
메서드를 사용하여 프로젝트 상태 변경에 대한 리스너 함수를 등록하고, addProject()
메서드를 사용하여 새 프로젝트를 추가한다.
2. validate()
함수는 Validatable
인터페이스를 구현하는 입력 객체를 받아 유효성 검사를 수행한다. 각 속성에 대한 필수 여부, 최소/최대 길이 또는 값 등을 확인하고, 유효한 경우 true를 반환한다.
3. autobind
데코레이터는 메서드를 this로 바인딩한다.
4. ProjectList
클래스는 프로젝트 목록을 렌더링한다. 생성자에서는 type 매개변수를 사용하여 목록이 'active'인지 'finished'인지 지정한다. renderContent()
메서드는 제목을 설정하고, renderProjects()
메서드는 할당된 프로젝트 목록을 렌더링한다.
5. ProjectInput
클래스는 새 프로젝트를 추가하는 입력 양식을 렌더링한다. gatherUserInput()
메서드에서는 입력 필드에서 사용자 입력을 수집하고, configure()
메서드에서는 입력 필드에서 이벤트 리스너를 등록한다. attach()
메서드는 입력 양식을 문서에 추가한다.
마지막으로 new ProjectInput()
과
new ProjectList('active')
, new ProjectList('finished')
를 생성하여 프로젝트 입력 양식과 두 개의 프로젝트 목록을 렌더링한다.
참고 자료
https://goodmemory.tistory.com/8
[Javascript] 노드복사 importNode
노드의 복사 var node = document.importNode('복제 원하는 노드', boolean : 자식 노드 포함 여부); 반환 : [True인경우 : 자식 포함], [False인 경우 : 자식 미포함] 샘플 예제 ↓ [HTML] 번호 제목 내용 [Javascript] let
goodmemory.tistory.com
https://powerku.tistory.com/115
자바스크립트 | insertAdjacentElement() 엘리먼트에서 특정 위치에 노드 삽입
insertAdjacentElement(position, text) 특정 위치에 노드를 추가합니다. position에는 4가지의 단어만 들어갈 수 있습니다 beforebegin element 앞에 afterbegin element 안에 가장 첫번째 child beforeend element 안에 가장 마
powerku.tistory.com
https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement
Element.insertAdjacentElement() - Web APIs | MDN
The insertAdjacentElement() method of the Element interface inserts a given element node at a given position relative to the element it is invoked upon.
developer.mozilla.org
TypeScript 강의 정리: 데코레이터
1. 데코레이터란? 1) 메타 프로그래밍이란? 메타프로그래밍이란 자기 자신 혹은 다른 컴퓨터 프로그램을 데이터로 취급하며 프로그램을 작성·수정하는 것을 말한다.(from 위키백과) 클래스와 데
velog.io
'프론트엔드 개발 > Typescript' 카테고리의 다른 글
객체 지향 프로그래밍 - Drag & Drop 프로젝트 02. (0) | 2023.03.22 |
---|---|
[타입스크립트] 제네릭 (0) | 2023.03.15 |
[타입스크립트 1분 상식] 인터페이스 존재 이유(feat. type alias) (0) | 2023.03.09 |
[TypeScript] 타입스크립트 추상 클래스 (0) | 2023.03.07 |
타입스크립트 인터페이스 (0) | 2023.03.03 |