패스트캠퍼스 강의를 정리한 내용입니다.
"김민태의 프론트엔드 아카데미 : 제 1강 JavaScript & TypeScript Essential"
마이그레이션(migration)
데이터나 소프트웨어를 한 시스템에서 다른 시스템으로 이동하는 것
*앞의 강의 예제를 그대로 활용
1) 스크립트 파일로 파일 변환 : app.js → app.ts
2) 타입스크립트 관련 설정 파일 추가 : tsconfig.json
→ 타입스크립트는 트랜스파일러여서 브라우저에서 실행하려면 자바스크립트로 변환을 해야하는데 이때 여러가지 옵션 추가 가능
//tsconfig.json
{
"compilerOptions": {
"strict": true, // 타입스크립트로 변환 시 엄격성
"target": "ES5", // 컴파일 된 자바스크립트에 사용될 문법 체계
"module": "CommonJS",
"alwaysStrict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"sourceMap": true, // ~.js.map 파일 생성 여부
"downlevelIteration": true
}
}
타입스크립트 컴파일 옵션 문서 :
https://www.typescriptlang.org/tsconfig
3) 터미널에 명령어 입력 : parcel index.html
parcel로 실행 시 node_modules, dist, .cache 디렉토리가 생성되며, 이러한 디렉토리와 파일들은 parcel.js가 타입스크립트를 이용해서 앱을 만드는데 필요한 어플리케이션을 자동으로 명령하고 다운로드하여 설정을 끝내놓은 상태라고 볼 수 있음
타입스크립트의 명시적인 타입 지원 기능을 이용한 타입 지정
VSCode에서는 특정 부분에 마우스오버 시 타입 관련 정보를 제공함
→인자로 elementId를 받으며 elementId의 타입은 string
→함수 인자 뒤의 콜론(:)은 함수의 반환값 타입을 의미함. 즉 HTMLElement 또는 null 타입
특정 타입이나 인터페이스를 참조할 수 있는 타입 변수를 의미
//예시
// string 타입을 사용할 때
const name: string = 'Yudery';
// 타입 별칭을 사용할 때
type MyName = string;
const name: MyName = 'Yudery';
// interface 레벨의 복잡한 타입에도 별칭 부여 가능
type Developer = {
name: string;
skill: string;
}
타입 별칭과 인터페이스의 가장 큰 차이점은 타입의 확장 가능 / 불가능 여부
인터페이스는 확장이 가능한데 반해 타입 별칭은 확장이 불가능
따라서, 가능한 type 보다는 interface로 선언해서 사용하는 것을 추천
Object is possibly 'null'
→null이 아닌 경우에만 코드가 동작하도록 수정하라는 경고
→어떤 유형의 값이 두 가지가 들어오는 케이스(그 둘 중 하나는 null)에서 null을 체크하라는 유형의 경고 코드를 '타입 가드'라고 표현함
→아래 예제에서는 updateView() 함수로 해당 경고 코드 대응
//변수로 타입 작성한 예제
<script>
type Store = {
currentPage: number;
feeds: NewsFeed[]; //명확히 어떤 타입의 데이터가 들어갈지 명시
}
type NewsFeed = {
id: number;
comments_count: number;
url: string;
user: string;
time_ago: string;
points: number;
title: string;
read?: boolean; //콜론(:)과 속성명 사이의 물음표(?)는 데이터가 있을 수도 있고 없을 수도 있는 '선택 속성'을 의미
}
const container: HTMLElement | null = document.getElementById('root');
const ajax: XMLHttpRequest = new XMLHttpRequest();
const content = document.createElement('div');
const NEWS_URL = 'https://api.hnpwa.com/v0/news/1.json';
const CONTENT_URL = 'https://api.hnpwa.com/v0/item/@id.json';
const store: Store = {
currentPage: 1,
feeds: [],
};
function getData(url) {
ajax.open('GET', url, false);
ajax.send();
return JSON.parse(ajax.response);
}
function makeFeeds(feeds) {
for (let i = 0; i < feeds.length; i++) {
feeds[i].read = false;
}
return feeds;
}
function updateView(html) {
if (container != null) {
container.innerHTML = html;
} else {
console.error('최상위 컨테이너가 없어 UI를 진행하지 못합니다.')
}
}
function newsFeed() {
let newsFeed: NewsFeed[] = store.feeds;
const newsList = [];
let template = `
<div class="bg-gray-600 min-h-screen">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacker News</h1>
</div>
<div class="itmes-center justify-end">
<a href="#/page/{{__prev_page__}}">Prev</a>
<a href="#/page/{{__next_page__}}">Next</a>
</div>
</div>
</div>
</div>
<div class="p-4 text-2xl text-gray-700">
{{__news_feed__}}
</div>
</div>
`;
if (newsFeed.length === 0) {
newsFeed = store.feeds = makeFeeds(getData(NEWS_URL));
}
for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
newsList.push(`
<div class="p-6 ${
newsFeed[i].read ? 'bg-gray-500' : 'bg-white'
} mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
<div class="flex">
<div class="flex-auto">
<a href="#/show/${newsFeed[i].id}">${newsFeed[i].title}</a>
</div>
<div class="text-center text-sm">
<div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${newsFeed[i].comments_count}</div>
</div>
</div>
<div class="flex mt-3">
<div class="grid grid-cols-3 text-sm text-gray-500">
<div><i class="fas fa-user mr-1">${newsFeed[i].user}</i></div>
<div><i class="fas fa-heart mr-1">${newsFeed[i].points}</i></div>
<div><i class="fas fa-clock mr-1">${newsFeed[i].time_ago}</i></div>
</div>
</div>
</div>
`);
}
template = template.replace('{{__news_feed__}}', newsList.join(''));
template = template.replace('{{__prev_page__}}', store.currentPage > 1 ? store.currentPage - 1 : 1);
template = template.replace(
'{{__next_page__}}',
store.currentPage * 10 < newsFeed.length ? store.currentPage + 1 : store.currentPage
);
updateView(template);
}
const ul = document.createElement('ul');
function newsDetail() {
const id = location.hash.substr(7);
const newsContent = getData(CONTENT_URL.replace('@id', id));
let template = `
<div class="bg-gray-600 min-h-screen pb-8">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacher News</h1>
</div>
<div class="items-center justify-end">
<a href="#/page/${store.currentPage}" class="text-gray-500">
<i class="fa fa-times"></i>
</a>
</div>
</div>
</div>
</div>
<div class="h-full border rounded-xl bg-white m-6 p-4">
<h2>${newsContent.title}</h2>
<div class="text-gray-400 h-20">
${newsContent.content}
</div>
{{__comments__}}
</div>
</div>
`;
for (let i = 0; i < store.feeds.length; i++) {
if (store.feeds[i].id === Number(id)) {
store.feeds[i].read = true;
break;
}
}
function makeComment(comments, called = 0) {
const commentString = [];
for (let i = 0; i < comments.length; i++) {
commentString.push(`
<div style="padding-left: ${called * 40}px;" class="mt-4">
<div class="text-gray-400">
<i class="fa fa-sort-up mr-2"></i>
<strong>${comments[i].user}</strong>${comments[i].time_ago}
</div>
<p class="text-gray-700">${comments[i].content}</p>
</div>
`);
if (comments[i].comments.length > 0) {
//재귀호출
commentString.push(makeComment(comments[i].comments, called + 1));
}
}
return commentString.join('');
}
updateView(template.replace('{{__comments__}}', makeComment(newsContent.comments)));
}
function router() {
const routePath = location.hash;
//location hash에 #만 들어있는 경우에는 빈값을 반환
if (routePath === '') {
newsFeed();
} else if (routePath.indexOf('#/page/') >= 0) {
store.currentPage = Number(routePath.substr(7));
newsFeed();
} else {
newsDetail();
}
}
window.addEventListener('hashchange', router);
router();
</script>
참고 : https://blog.kangho.me/js-primitive/
VSCode 확장프로그램: REST client
API 호출을 브라우저 개발툴로 보지 않고 에디터에서 볼 수 있도록 기능 제공
에디터에서 JSON 결과물 확인 가능
REST client 사용 방법
1) 확장프로그램 설치
2) ~.http 파일 생성
3) ~.http 파일에 ### 입력 후 줄바꿈
4) 호출할 API 주소 작성
5) HTTP 헤더 필드 작성 (예: HTTP/1.1)
6) 작성한 API 주소 좌측 상단의 'Sent Request' 클릭
7) 우측에 Response 화면 생성되어 호출 결과값 확인 가능
AND(&) 연산자를 사용하며 'A이면서 B이다' 라는 의미의 타입
입력이 n개의 유형일 때, 출력도 n개의 유형인 것을 정의하는 것
입력이 ABCD 유형 중에 A가 들어오면 출력도 A로, B로 들어오면 출력도 B..로 나가는 것
호출하는 쪽에서 유형을 명시해주면 그 유형을 그대로 받아서 반환 유형으로 사용하는 것
<script>
// 제네릭 사용 전
// getData가 리턴하는 타입은 NewsFeed, NewsDetail 두 가지인데, 호출 함수에서 정확히 어떤 타입을 리턴하는지 명확하지 않음
// 타입 가드 코드로 처리 시 getData가 처리하는 API가 많을 경우 코드가 너무 길어지므로 제네릭 사용하여 해결
function getData(url: string): NewsFeed[] | NewsDetail {
ajax.open('GET', url, false);
ajax.send();
return JSON.parse(ajax.response);
}
function newsFeed() {
...
if (newsFeed.length === 0) {
newsFeed = store.feeds = makeFeeds(getData(NEWS_URL));
}
...
}
// 제네릭 사용 후
function getData<T>(url: string): T {
ajax.open('GET', url, false);
ajax.send();
return JSON.parse(ajax.response);
}
function newsFeed() {
...
if (newsFeed.length === 0) {
newsFeed = store.feeds = makeFeeds(getData<NewsFeed[]>(NEWS_URL));
}
...
}
</script>
자바스크립트를 타입스크립트 코드로 포팅 완료한 코드
//app.js
<script>
type Store = {
currentPage: number;
feeds: NewsFeed[]; //명확히 어떤 타입의 데이터가 들어갈지 명시
}
type News = {
id: number;
time_ago: string;
title: string;
url: string;
user: string;
content: string;
}
// Intersection type
type NewsFeed = News & {
comments_count: number;
points: number;
read?: boolean; //콜론(:)과 속성명 사이의 물음표(?)는 데이터가 있을 수도 있고 없을 수도 있는 '선택 속성'을 의미
}
type NewsDetail = News & {
comments: NewsComment[];
}
type NewsComment = News & {
comments: NewsComment[];
level: number;
}
const container: HTMLElement | null = document.getElementById('root');
const ajax: XMLHttpRequest = new XMLHttpRequest();
const content = document.createElement('div');
const NEWS_URL = 'https://api.hnpwa.com/v0/news/1.json';
const CONTENT_URL = 'https://api.hnpwa.com/v0/item/@id.json';
const store: Store = {
currentPage: 1,
feeds: [],
};
function getData<AjaxResponse>(url: string): AjaxResponse {
ajax.open('GET', url, false);
ajax.send();
return JSON.parse(ajax.response);
}
function makeFeeds(feeds: NewsFeed[]): NewsFeed[] {
for (let i = 0; i < feeds.length; i++) {
feeds[i].read = false;
}
return feeds;
}
function updateView(html: string): void {
if (container != null) {
container.innerHTML = html;
} else {
console.error('최상위 컨테이너가 없어 UI를 진행하지 못합니다.')
}
}
function newsFeed(): void {
let newsFeed: NewsFeed[] = store.feeds;
const newsList = [];
let template = `
<div class="bg-gray-600 min-h-screen">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacker News</h1>
</div>
<div class="itmes-center justify-end">
<a href="#/page/{{__prev_page__}}">Prev</a>
<a href="#/page/{{__next_page__}}">Next</a>
</div>
</div>
</div>
</div>
<div class="p-4 text-2xl text-gray-700">
{{__news_feed__}}
</div>
</div>
`;
if (newsFeed.length === 0) {
newsFeed = store.feeds = makeFeeds(getData<NewsFeed[]>(NEWS_URL));
}
for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
newsList.push(`
<div class="p-6 ${
newsFeed[i].read ? 'bg-gray-500' : 'bg-white'
} mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
<div class="flex">
<div class="flex-auto">
<a href="#/show/${newsFeed[i].id}">${newsFeed[i].title}</a>
</div>
<div class="text-center text-sm">
<div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${newsFeed[i].comments_count}</div>
</div>
</div>
<div class="flex mt-3">
<div class="grid grid-cols-3 text-sm text-gray-500">
<div><i class="fas fa-user mr-1">${newsFeed[i].user}</i></div>
<div><i class="fas fa-heart mr-1">${newsFeed[i].points}</i></div>
<div><i class="fas fa-clock mr-1">${newsFeed[i].time_ago}</i></div>
</div>
</div>
</div>
`);
}
template = template.replace('{{__news_feed__}}', newsList.join(''));
template = template.replace('{{__prev_page__}}', String(store.currentPage > 1 ? store.currentPage - 1 : 1));
template = template.replace(
'{{__next_page__}}',
String(store.currentPage * 10 < newsFeed.length ? store.currentPage + 1 : store.currentPage
));
updateView(template);
}
const ul = document.createElement('ul');
function newsDetail(): void {
const id = location.hash.substr(7);
const newsContent = getData<NewsDetail>(CONTENT_URL.replace('@id', id));
let template = `
<div class="bg-gray-600 min-h-screen pb-8">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacher News</h1>
</div>
<div class="items-center justify-end">
<a href="#/page/${store.currentPage}" class="text-gray-500">
<i class="fa fa-times"></i>
</a>
</div>
</div>
</div>
</div>
<div class="h-full border rounded-xl bg-white m-6 p-4">
<h2>${newsContent.title}</h2>
<div class="text-gray-400 h-20">
${newsContent.content}
</div>
{{__comments__}}
</div>
</div>
`;
for (let i = 0; i < store.feeds.length; i++) {
if (store.feeds[i].id === Number(id)) {
store.feeds[i].read = true;
break;
}
}
updateView(template.replace('{{__comments__}}', makeComment(newsContent.comments)));
}
function makeComment(comments: NewsComment[]): string {
const commentString = [];
for (let i = 0; i < comments.length; i++) {
const comment: NewsComment = comments[i];
commentString.push(`
<div style="padding-left: ${comment.level * 40}px;" class="mt-4">
<div class="text-gray-400">
<i class="fa fa-sort-up mr-2"></i>
<strong>${comment.user}</strong>${comment.time_ago}
</div>
<p class="text-gray-700">${comment.content}</p>
</div>
`);
if (comment.comments.length > 0) {
//재귀호출
commentString.push(makeComment(comment.comments));
}
}
return commentString.join('');
}
function router(): void {
const routePath = location.hash;
//location hash에 #만 들어있는 경우에는 빈값을 반환
if (routePath === '') {
newsFeed();
} else if (routePath.indexOf('#/page/') >= 0) {
store.currentPage = Number(routePath.substr(7));
newsFeed();
} else {
newsDetail();
}
}
window.addEventListener('hashchange', router);
router();
</script>
Type Aliases가 아닌 interface 기능을 활용해서 타이핑하기
// Type Aliases
Type Store = {
currentPage: number;
feeds: NewsFeed[];
}
// interface
interface Store {
currentPage: number;
feeds: NewsFeed[];
}
Type Aliases와 interface는 타입을 결합하는 방식에서 차이가 큼
interface는 유니언타입이나 인터섹션을 지원하지 않음
// Type Aliases
type News = {
id: number;
time_ago: string;
title: string;
url: string;
user: string;
content: string;
}
type NewsFeed = News & {
comments_count: number;
points: number;
read?: boolean;
}
// interface
interface News {
id: number;
time_ago: string;
title: string;
url: string;
user: string;
content: string;
}
interface NewsFeed extends News {
comments_count: number;
points: number;
read?: boolean;
}
<script>
interface News {
readonly id: number;
readonly time_ago: string;
readonly title: string;
readonly url: string;
readonly user: string;
readonly content: string;
}
</script>
상속을 다루는 메커니즘 두 가지: 클래스 사용, 믹스인 사용
<script>
...
class Api {
url: string;
ajax: XMLHttpRequest;
// 초기화 과정을 처리하는 생성자
constructor(url: string) {
this.url = url;
this.ajax = new XMLHttpRequest();;
}
protected getRequest<AjaxResponse>(): AjaxResponse {
this.ajax.open('GET', this.url, false);
this.ajax.send();
return JSON.parse(this.ajax.response)
}
}
class NewsFeedApi extends Api {
getData(): NewsFeed[] {
return this.getRequest<NewsFeed[]>();
}
}
class NewsDetailApi extends Api {
getData(): NewsDetail[] {
return this.getRequest<NewsDetail[]>();
}
}
function getData<AjaxResponse>(url: string): AjaxResponse {
ajax.open('GET', url, false);
ajax.send();
return JSON.parse(ajax.response);
}
function makeFeeds(feeds: NewsFeed[]): NewsFeed[] {
for (let i = 0; i < feeds.length; i++) {
feeds[i].read = false;
}
return feeds;
}
function updateView(html: string): void {
if (container != null) {
container.innerHTML = html;
} else {
console.error('최상위 컨테이너가 없어 UI를 진행하지 못합니다.')
}
}
function newsFeed(): void {
const api = new NewsFeedApi(NEWS_URL);
let newsFeed: NewsFeed[] = store.feeds;
const newsList = [];
let template = `
<div class="bg-gray-600 min-h-screen">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacker News</h1>
</div>
<div class="itmes-center justify-end">
<a href="#/page/{{__prev_page__}}">Prev</a>
<a href="#/page/{{__next_page__}}">Next</a>
</div>
</div>
</div>
</div>
<div class="p-4 text-2xl text-gray-700">
{{__news_feed__}}
</div>
</div>
`;
if (newsFeed.length === 0) {
newsFeed = store.feeds = makeFeeds(api.getData());
}
for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
newsList.push(`
<div class="p-6 ${
newsFeed[i].read ? 'bg-gray-500' : 'bg-white'
} mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
<div class="flex">
<div class="flex-auto">
<a href="#/show/${newsFeed[i].id}">${newsFeed[i].title}</a>
</div>
<div class="text-center text-sm">
<div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${newsFeed[i].comments_count}</div>
</div>
</div>
<div class="flex mt-3">
<div class="grid grid-cols-3 text-sm text-gray-500">
<div><i class="fas fa-user mr-1">${newsFeed[i].user}</i></div>
<div><i class="fas fa-heart mr-1">${newsFeed[i].points}</i></div>
<div><i class="fas fa-clock mr-1">${newsFeed[i].time_ago}</i></div>
</div>
</div>
</div>
`);
}
template = template.replace('{{__news_feed__}}', newsList.join(''));
template = template.replace('{{__prev_page__}}', String(store.currentPage > 1 ? store.currentPage - 1 : 1));
template = template.replace(
'{{__next_page__}}',
String(store.currentPage * 10 < newsFeed.length ? store.currentPage + 1 : store.currentPage
));
updateView(template);
}
const ul = document.createElement('ul');
function newsDetail(): void {
const id = location.hash.substr(7);
const api = new NewsDetailApi(CONTENT_URL.replace('@id', id));
const newsContent = api.getData();
let template = `
<div class="bg-gray-600 min-h-screen pb-8">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacher News</h1>
</div>
<div class="items-center justify-end">
<a href="#/page/${store.currentPage}" class="text-gray-500">
<i class="fa fa-times"></i>
</a>
</div>
</div>
</div>
</div>
<div class="h-full border rounded-xl bg-white m-6 p-4">
<h2>${newsContent.title}</h2>
<div class="text-gray-400 h-20">
${newsContent.content}
</div>
{{__comments__}}
</div>
</div>
`;
for (let i = 0; i < store.feeds.length; i++) {
if (store.feeds[i].id === Number(id)) {
store.feeds[i].read = true;
break;
}
}
updateView(template.replace('{{__comments__}}', makeComment(newsContent.comments)));
}
...
</script>
class를 마치 함수처럼 혹은 단독의 객체처럼 바라보면서 필요할 때마다 class를 합성해서 새로운 기능으로 확장해나가는 기법
<script>
function applyApiMixins(targetClass: any, baseClasses: any[]): void{
baseClasses.forEach(baseClass => {
Object.getOwnPropertyNames(baseClass.prototype).forEach(name => {
const descriptor = Object.getOwnPropertyDescriptor(baseClass.prototype, name);
if (descriptor) {
Object.defineProperty(targetClass.prototype, name, descriptor);
}
});
});
}
class Api {
getRequest<AjaxResponse>(url: string): AjaxResponse {
ajax.open('GET', url, false);
ajax.send();
return JSON.parse(ajax.response)
}
}
class NewsFeedApi {
getData(): NewsFeed[] {
return this.getRequest<NewsFeed[]>(NEWS_URL);
}
}
class NewsDetailApi {
getData(id: string): NewsDetail {
return this.getRequest<NewsDetail>(CONTENT_URL.replace('@id', id));
}
}
function getData<AjaxResponse>(url: string): AjaxResponse {
ajax.open('GET', url, false);
ajax.send();
return JSON.parse(ajax.response);
}
interface NewsFeedApi extends Api { };
interface NewsDetailApi extends Api { };
applyApiMixins(NewsFeedApi, [Api]);
applyApiMixins(NewsDetailApi, [Api]);
function makeFeeds(feeds: NewsFeed[]): NewsFeed[] {
for (let i = 0; i < feeds.length; i++) {
feeds[i].read = false;
}
return feeds;
}
function updateView(html: string): void {
if (container != null) {
container.innerHTML = html;
} else {
console.error('최상위 컨테이너가 없어 UI를 진행하지 못합니다.')
}
}
function newsFeed(): void {
const api = new NewsFeedApi();
let newsFeed: NewsFeed[] = store.feeds;
const newsList = [];
let template = `
<div class="bg-gray-600 min-h-screen">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacker News</h1>
</div>
<div class="itmes-center justify-end">
<a href="#/page/{{__prev_page__}}">Prev</a>
<a href="#/page/{{__next_page__}}">Next</a>
</div>
</div>
</div>
</div>
<div class="p-4 text-2xl text-gray-700">
{{__news_feed__}}
</div>
</div>
`;
if (newsFeed.length === 0) {
newsFeed = store.feeds = makeFeeds(api.getData());
}
for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
newsList.push(`
<div class="p-6 ${
newsFeed[i].read ? 'bg-gray-500' : 'bg-white'
} mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
<div class="flex">
<div class="flex-auto">
<a href="#/show/${newsFeed[i].id}">${newsFeed[i].title}</a>
</div>
<div class="text-center text-sm">
<div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${newsFeed[i].comments_count}</div>
</div>
</div>
<div class="flex mt-3">
<div class="grid grid-cols-3 text-sm text-gray-500">
<div><i class="fas fa-user mr-1">${newsFeed[i].user}</i></div>
<div><i class="fas fa-heart mr-1">${newsFeed[i].points}</i></div>
<div><i class="fas fa-clock mr-1">${newsFeed[i].time_ago}</i></div>
</div>
</div>
</div>
`);
}
template = template.replace('{{__news_feed__}}', newsList.join(''));
template = template.replace('{{__prev_page__}}', String(store.currentPage > 1 ? store.currentPage - 1 : 1));
template = template.replace(
'{{__next_page__}}',
String(store.currentPage * 10 < newsFeed.length ? store.currentPage + 1 : store.currentPage
));
updateView(template);
}
const ul = document.createElement('ul');
function newsDetail(): void {
const id = location.hash.substr(7);
const api = new NewsDetailApi();
const newsDetail: NewsDetail = api.getData(id);
let template = `
<div class="bg-gray-600 min-h-screen pb-8">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacher News</h1>
</div>
<div class="items-center justify-end">
<a href="#/page/${store.currentPage}" class="text-gray-500">
<i class="fa fa-times"></i>
</a>
</div>
</div>
</div>
</div>
<div class="h-full border rounded-xl bg-white m-6 p-4">
<h2>${newsDetail.title}</h2>
<div class="text-gray-400 h-20">
${newsDetail.content}
</div>
{{__comments__}}
</div>
</div>
`;
for (let i = 0; i < store.feeds.length; i++) {
if (store.feeds[i].id === Number(id)) {
store.feeds[i].read = true;
break;
}
}
updateView(template.replace('{{__comments__}}', makeComment(newsDetail.comments)));
}
</script>
class는 인스턴스를 만들고 시작하므로, 일반 함수 호출과 구분하기 위해 첫문자를 대문자로 작성
상위 클래스로부터 extends 받으면, 반드시 상위 클래스의 생성자를 명시적으로 호출해줘야 함
super 키워드: 부모 오브젝트의 함수를 호출할 때 사용
<script>
interface Store {
currentPage: number;
feeds: NewsFeed[]; //명확히 어떤 타입의 데이터가 들어갈지 명시
}
interface News {
readonly id: number;
readonly time_ago: string;
readonly title: string;
readonly url: string;
readonly user: string;
readonly content: string;
}
// Intersection type
interface NewsFeed extends News {
readonly comments_count: number;
readonly points: number;
read?: boolean; //콜론(:)과 속성명 사이의 물음표(?)는 데이터가 있을 수도 있고 없을 수도 있는 '선택 속성'을 의미
}
interface NewsDetail extends News {
readonly comments: NewsComment[];
}
interface NewsComment extends News {
readonly comments: NewsComment[];
readonly level: number;
}
interface RouteInfo {
path: string;
page: View;
}
const ajax: XMLHttpRequest = new XMLHttpRequest();
const content = document.createElement('div');
const NEWS_URL = 'https://api.hnpwa.com/v0/news/1.json';
const CONTENT_URL = 'https://api.hnpwa.com/v0/item/@id.json';
const store: Store = {
currentPage: 1,
feeds: [],
};
function applyApiMixins(targetClass: any, baseClasses: any[]): void{
baseClasses.forEach(baseClass => {
Object.getOwnPropertyNames(baseClass.prototype).forEach(name => {
const descriptor = Object.getOwnPropertyDescriptor(baseClass.prototype, name);
if (descriptor) {
Object.defineProperty(targetClass.prototype, name, descriptor);
}
});
});
}
class Api {
getRequest<AjaxResponse>(url: string): AjaxResponse {
ajax.open('GET', url, false);
ajax.send();
return JSON.parse(ajax.response)
}
}
class NewsFeedApi {
getData(): NewsFeed[] {
return this.getRequest<NewsFeed[]>(NEWS_URL);
}
}
class NewsDetailApi {
getData(id: string): NewsDetail {
return this.getRequest<NewsDetail>(CONTENT_URL.replace('@id', id));
}
}
function getData<AjaxResponse>(url: string): AjaxResponse {
ajax.open('GET', url, false);
ajax.send();
return JSON.parse(ajax.response);
}
interface NewsFeedApi extends Api { };
interface NewsDetailApi extends Api { };
applyApiMixins(NewsFeedApi, [Api]);
applyApiMixins(NewsDetailApi, [Api]);
// 공통 요소의 class
abstract class View {
// private 속성 접근자 사용 : View 클래스 안에서만 접근할 수 있음
private template: string;
private renderTemplate: string;
private container: HTMLElement;
private htmlList: string[];
constructor(containerId: string, template: string) {
const containerElement = document.getElementById(containerId);
// null 값에 대한 예외처리 - 종료(throw)
if (!containerElement) {
throw '최상위 컨테이너가 없어 UI를 진행하지 못합니다.'
}
// class에 속성이 들어가면 생성자에서 반드시 초기화 해줘야 함
this.container = containerElement;
this.template = template;
this.renderTemplate = template;
this.htmlList = [];
}
// protected 속성 접근자 사용 : 외부에서는 접근 불가, 자식 요소에서만 접근 가능
protected updateView(): void {
this.container.innerHTML = this.renderTemplate;
// renderTemplate 초기화
this.renderTemplate = this.template;
}
protected addHtml(htmlString: string): void {
this.htmlList.push(htmlString)
}
protected getHtml(): string {
const snapshot = this.htmlList.join('');
this.clearHtmlList();
return snapshot;
}
protected setTemplateData(key: string, value: string): void {
this.renderTemplate = this.renderTemplate.replace(`{{__${key}__}}`, value)
}
private clearHtmlList(): void {
this.htmlList = [];
}
// 추상메소드
// 자식들에게 render 메소드를 구현하라는 의미의 마킹
abstract render(): void;
}
class Router {
routeTable: RouteInfo[];
defaultRoute: RouteInfo | null;
constructor() {
// this.route는 브라우저의 이벤트 시스템이 호출하므로 this context는 Router 인스턴스가 아니다.
// 따라서 defaultRoute나 routeTable 같은 정보에 접근할 수 없다.
// 그러므로 값을 넘겨줄 때, 현재 등록 시점의 this context로 this를 고정시켜줘야 함 (bind 함수 사용)
window.addEventListener('hashchange', this.route.bind(this));
this.routeTable = [];
this.defaultRoute = null;
}
setDefaultPage(page: View): void {
this.defaultRoute = { path: '', page };
}
addRoutePath(path: string, page: View): void {
this.routeTable.push({
// path: path,
// page: page,
// 이름과 값이 같은 경우는 아래와 같이 생략 가능
path, page
})
}
route() {
const routePath = location.hash;
if (routePath === '' && this.defaultRoute) {
this.defaultRoute.page.render();
}
for (const routeInfo of this.routeTable) {
if (routePath.indexOf(routeInfo.path) >= 0) {
routeInfo.page.render();
break;
}
}
}
}
class NewsFeedView extends View {
private api: NewsFeedApi;
private feeds: NewsFeed[];
constructor(containerId: string) {
let template: string = `
<div class="bg-gray-600 min-h-screen">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacker News</h1>
</div>
<div class="itmes-center justify-end">
<a href="#/page/{{__prev_page__}}">Prev</a>
<a href="#/page/{{__next_page__}}">Next</a>
</div>
</div>
</div>
</div>
<div class="p-4 text-2xl text-gray-700">
{{__news_feed__}}
</div>
</div>
`;
super(containerId, template);
this.api = new NewsFeedApi();
this.feeds = store.feeds;
if (this.feeds.length === 0) {
this.feeds = store.feeds = this.api.getData();
this.makeFeeds();
}
}
render(): void {
store.currentPage = Number(location.hash.substr(7) || 1)
for (let i = (store.currentPage - 1) * 10; i < store.currentPage * 10; i++) {
// 구조분해할당
const { id, title, comments_count, user, points, time_ago, read } = this.feeds[i];
this.addHtml(`
<div class="p-6 ${
read ? 'bg-gray-500' : 'bg-white'
} mt-6 rounded-lg shadow-md transition-colors duration-500 hover:bg-green-100">
<div class="flex">
<div class="flex-auto">
<a href="#/show/${id}">${title}</a>
</div>
<div class="text-center text-sm">
<div class="w-10 text-white bg-green-300 rounded-lg px-0 py-2">${comments_count}</div>
</div>
</div>
<div class="flex mt-3">
<div class="grid grid-cols-3 text-sm text-gray-500">
<div><i class="fas fa-user mr-1">${user}</i></div>
<div><i class="fas fa-heart mr-1">${points}</i></div>
<div><i class="fas fa-clock mr-1">${time_ago}</i></div>
</div>
</div>
</div>
`);
}
this.setTemplateData('news_feed', this.getHtml());
this.setTemplateData('prev_page', String(store.currentPage > 1 ? store.currentPage - 1 : 1));
this.setTemplateData('next_page', String(store.currentPage * 10 < this.feeds.length ? store.currentPage + 1 : store.currentPage));
this.updateView();
}
private makeFeeds(): void {
for (let i = 0; i < this.feeds.length; i++) {
this.feeds[i].read = false;
}
}
}
class NewsDetailView extends View {
constructor(containerId: string) {
let template = `
<div class="bg-gray-600 min-h-screen pb-8">
<div class="bg-white text-xl">
<div class="mx-auto px-4">
<div class="flex justify-between items-center py-6">
<div class="flex justify-start">
<h1 class="font-extrabold">Hacher News</h1>
</div>
<div class="items-center justify-end">
<a href="#/page/{{__currentPage__}}" class="text-gray-500">
<i class="fa fa-times"></i>
</a>
</div>
</div>
</div>
</div>
<div class="h-full border rounded-xl bg-white m-6 p-4">
<h2>{{__title__}}</h2>
<div class="text-gray-400 h-20">
{{__content__}}
</div>
{{__comments__}}
</div>
</div>
`;
super(containerId, template);
}
render() {
const id = location.hash.substr(7);
const api = new NewsDetailApi();
const newsDetail: NewsDetail = api.getData(id);
for (let i = 0; i < store.feeds.length; i++) {
if (store.feeds[i].id === Number(id)) {
store.feeds[i].read = true;
break;
}
}
this.setTemplateData('comments', this.makeComment(newsDetail.comments))
this.setTemplateData('currentPage', String(store.currentPage))
this.setTemplateData('title', newsDetail.title)
this.setTemplateData('content', newsDetail.content)
this.updateView();
}
makeComment(comments: NewsComment[]): string {
for (let i = 0; i < comments.length; i++) {
const comment: NewsComment = comments[i];
this.addHtml(`
<div style="padding-left: ${comment.level * 40}px;" class="mt-4">
<div class="text-gray-400">
<i class="fa fa-sort-up mr-2"></i>
<strong>${comment.user}</strong>${comment.time_ago}
</div>
<p class="text-gray-700">${comment.content}</p>
</div>
`);
if (comment.comments.length > 0) {
//재귀호출
this.addHtml(this.makeComment(comment.comments));
}
}
return this.getHtml();
}
}
// 라우터 인스턴스 생성
const router: Router = new Router();
// 만들어놓은 View class들을 작동시키기 위한 인스턴스 생성
const newsFeedView = new NewsFeedView('root');
const newsDetailView = new NewsDetailView('root');
router.setDefaultPage(newsFeedView);
router.addRoutePath('/page/', newsFeedView);
router.addRoutePath('/show/', newsDetailView);
router.route();
</script>
class에서 protected와 같은 별도의 속성 접근자를 사용하지 않으면 public이라는 속성 접근자가 디폴트로 붙음 (public은 생략 가능한 속성 접근자)
public은 외부에서 모두 접근 가능