안전한 전역 상태 관리

 

기존의 app.ts로 만든 하나의 파일에 페이지의 모든 기능을 작성했다. 이번에는 하나로 작성된 ts 파일을 기능 단위로 여러개의 ts파일로 구분할 것이다. 페이지의 역할을 하는 파일, 타입을 지정해주는 파일, 공통적으로 사용되는 파일, 라우팅 기능을 하는 파일 등으로 구분하는 폴더 구조를 갖는다.

 

구조를 정하여 관리하게 되면 어느 부분에서 에러, 오류 등이 발생 했을 때, 해당하는 파일에서 사용된 부분만을 수정하면 되고, 기능을 사용하는 부분은 사용 되어지는 파일의 세부적인 부분까지 몰라도 기능을 사용할 수 있지만 너무 세세하게 나눠진 구조는 오히려 구조를 더 복잡하게 할 수도 있다.

 

// (src/app.ts)

import Router from "./core/router";
import { NewsFeedView, NewsDetailView } from "./page";
import Store from "./store";

const store = new Store();
const router: Router = new Router();
const newsFeedView = new NewsFeedView('root', store);
const newsDetailView = new NewsDetailView('root', store);

router.setDefaultPage(newsFeedView);

router.addRoutePath('/page/', newsFeedView);
router.addRoutePath('/show/', newsDetailView);

router.route();

 

// (src/config.ts)

export const NEWS_URL :string = 'https://api.hnpwa.com/v0/news/1.json'
export const CONTENT_URL :string = 'https://api.hnpwa.com/v0/item/@id.json';

 

// (src/store.ts)

import { NewsFeed, NewsStore } from "./types";

export default class Store implements NewsStore{
    private feeds: NewsFeed[];
    private _currentPage: number;

    constructor(){
        this.feeds = []
        this._currentPage = 1;
    }

    get currentPage(){
        return this._currentPage;
    }

    set currentPage(page: number){
        this._currentPage = page;
    }

    get nextPage(): number{
        return this._currentPage + 1;
    }

    get prevPage(): number{
        return this._currentPage > 1 ? this._currentPage - 1 : 1;
    }

    get numberOfFeed(): number{
        return this.feeds.length;
    }

    get hasFeeds(): boolean{
        return this.feeds.length > 0;
    }

    getAllFeeds(): NewsFeed[]{
        return this.feeds;
    }

    getFeed(position: number): NewsFeed{        
        return this.feeds[position];
    }

    setFeeds(feeds: NewsFeed[]): void{
        this.feeds = feeds.map((feed) => ({
            ...feed,
            read: false
        }));
    }

    makeRead(id: number): void{
        const feed = this.feeds.find((feed: NewsFeed) => feed.id === id);
        
        if(feed){
            feed.read = true;
        }
    }
}

 

// (src/core/api.ts)

import { NewsFeed, NewsDetail } from '../types';

export 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); // method, Url, async
        this.ajax.send();
    
        return JSON.parse(this.ajax.response);    
    }

}

export class NewsFeedApi extends Api{
    constructor(url: string) {
        super(url);
    }

    getData(): NewsFeed[]{
        return this.getRequest<NewsFeed[]>();
    }
}

export class NewsDetailApi extends Api{
    constructor(url: string) {
        super(url);
    }
    
    getData(): NewsDetail{
        return this.getRequest<NewsDetail>();
    }
}

 

// (src/core/router.ts)

import {RouteInfo} from '../types';
import View from './view';

export default class Router{
    routeTable: RouteInfo[];
    defaultRoute: RouteInfo | null;

    constructor(){        
        window.addEventListener('hashchange', this.route.bind(this));
        
        this.routeTable = [];
        this.defaultRoute = null;
    }
    
    addRoutePath(path: string, page: View): void{
        this.routeTable.push({path, page});
    }
    
    setDefaultPage(page: View): void{
        this.defaultRoute = {path: '', page}
    }
    
    route() {
        const routePath = location.hash;

        if(routePath === '' && this.defaultRoute){
            this.defaultRoute.page.render('1');
        }

        for(const routeInfo of this.routeTable){
            if(routePath.indexOf(routeInfo.path) >= 0){
                routeInfo.page.render(routeInfo.path);
                break;
            }
        }
    }
}

 

// (src/core/view.ts)

export default abstract class View {
    private template :string;
    private rederTemplate: string;
    private container: HTMLElement;
    private htmlList: string[];

    constructor(containerId: string, template :string){
        const containerElement = document.getElementById(containerId);

        if(!containerElement){
            throw '최상위 컨테이너가 없어 UI를 진행하지 못합니다.'
        }

        this.container = containerElement;
        this.template = template;
        this.rederTemplate = template;
        this.htmlList = [];
    }

    protected updateView() :void {        
        this.container.innerHTML = this.rederTemplate;
        this.rederTemplate = this.template;
    }

    protected addHtml(htmlString: string): void {
        this.htmlList.push(htmlString);
    }

    protected getHtml(): string{
        const snapshot = this.htmlList.join('');
        this.clearHtmlList();
        return snapshot;
    }

    protected setTempateData(key: string, value: string): void{
        this.rederTemplate = this.rederTemplate.replace(`{{__${key}__}}`, value);
    }

    private clearHtmlList(): void{
        this.htmlList = [];
    }

    abstract render(id: string): void;
}

 

// (src/page/index.ts)

export {default as NewsDetailView} from './news-detail-view';
export {default as NewsFeedView} from './news-feed-view';

 

// (src/core/news-detail-view.ts)

import View from '../core/view';
import { NewsDetailApi } from '../core/api';
import { NewsDetail, NewsComments, NewsStore } from '../types';
import { CONTENT_URL } from '../config';

const 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">Hacker 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="border rounded-xl bg-white m-6 p-4 ">
            <h2>{{__title__}}</h2>
            <div class="text-gray-400 h-20">
                {{__content__}}
            </div>
            {{__comments__}}
        </div>
    </div>
`;

export default class NewsDetailView extends View{
    private store: NewsStore;
    
    constructor(containerId: string, store: NewsStore){        
        super(containerId, template);
        this.store = store;
    }
    
    render = (id: string): void => {
        const api = new NewsDetailApi(CONTENT_URL.replace('@id', id));
        const {title, content, comments} = api.getData();

        this.store.makeRead(parseInt(id));
        this.setTempateData('comments', this.makeComment(comments))
        this.setTempateData('currentPage', this.store.currentPage.toString());
        this.setTempateData('title', title);
        this.setTempateData('content', content);
                
        this.updateView();
    }

    private makeComment(comments :NewsComments[]) :string{        
        for(let i=0; i<comments.length; i++){
            const comment :NewsComments = 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();
    }
}

 

// (src/core/news-feed-view.ts)

import View from "../core/view";
import { NewsFeedApi } from "../core/api";
import { NewsFeed, NewsStore } from "../types";
import { NEWS_URL } from "../config";

const 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="items-center justify-end">
            <a href="#/page/{{__prev_page__}}" class="text-gray-500">
                Previous
            </a>
            <a href="#/page/{{__next_page__}}" class="text-gray-500 ml-4">
                Next
            </a>
            </div>
        </div> 
        </div>
    </div>
    <div class="p-4 text-2xl text-gray-700">
        {{__news_feed__}}        
    </div>
    </div>
`;

export default class NewsFeedView extends View{
    private api: NewsFeedApi;
    private store: NewsStore;
    private newsTotalPage :number;
    
    constructor(containerId: string, store: NewsStore){

        super(containerId, template);

        this.store = store;
        this.api = new NewsFeedApi(NEWS_URL);
        this.newsTotalPage = this.store.numberOfFeed / 10;
    
        if(!this.store.hasFeeds){
            this.store.setFeeds(this.api.getData())            
        }        
    }
    
    render = (page: string = '1'): void => {
        this.store.currentPage = Number(location.hash.substring(7) || 1);
        for(let i=(this.store.currentPage - 1) * 10; i<this.store.currentPage * 10; i++){            
            const {id, title, comments_count, user, points, time_ago, read} = this.store.getFeed(i);
            this.addHtml(`
                <div class="p-6 ${read ? 'bg-red-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"></i>${user}</div>
                            <div><i class="fas fa-heart mr-1"></i>${points}</div>
                            <div><i class="far fa-clock mr-1"></i>${time_ago}</div>
                        </div>  
                    </div>
                </div>
            `);
        }
    
        this.setTempateData('news_feed', this.getHtml());
        this.setTempateData('prev_page', `${this.store.currentPage > 1 ? this.store.currentPage - 1 : 1}" ${this.store.currentPage === 1 ? 'style="pointer-events: none;"' : " "}`);
        this.setTempateData('next_page', `${this.store.currentPage + 1}" ${this.store.currentPage === this.newsTotalPage ? 'style="pointer-events: none;"' : " "}`);    
    
        this.updateView();
    }
}

 

// (src/type/news-feed-view.ts)

import View from "../core/view";

export interface NewsStore{
    getAllFeeds: () => NewsFeed[];
    getFeed: (position: number) => NewsFeed;
    setFeeds: (feeds: NewsFeed[]) => void;
    makeRead: (id: number) => void;
    hasFeeds: boolean;
    currentPage: number;
    numberOfFeed: number;
    nextPage: number;
    prevPage: number;
}

export interface Store {
    currentPage :number, 
    feeds :NewsFeed[]
}

export interface News {
    readonly id :number,
    readonly url :string,
    readonly user :string,
    readonly time_ago :string,
    readonly title :string,
    readonly content :string
}

export interface NewsFeed extends News{    
    readonly comments_count :number,    
    readonly points :number,    
    read? :boolean
}

export interface NewsDetail extends News{    
    readonly comments :NewsComments[]
}

export interface NewsComments extends News{
    readonly comments: NewsComments[],
    readonly level :number
}

export interface RouteInfo {
    path: string;
    page: View;
}

 

이번 파트에서는 지난번에 모든 작업들을 클래스로 만들어 구조화 했는데 그 작업한 결과를 각 영역별로 폴더를 분할하고 import & export를 통해 클래스들을 관리한다.

 

 

 

 

https://bit.ly/37BpXiC

 

패스트캠퍼스 [직장인 실무교육]

프로그래밍, 영상편집, UX/UI, 마케팅, 데이터 분석, 엑셀강의, The RED, 국비지원, 기업교육, 서비스 제공.

fastcampus.co.kr

 

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.

 

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #김민태의프론트엔드아카데미:제1강JavaScript&TypeScriptEssential

+ Recent posts