반응형

 

 
OMG
아티스트
NewJeans
앨범
NewJeans 'OMG'
발매일
2023.01.02

완성본링크

https://cokeholic-kim.github.io/VanillaJS-TODO/

 

Document

 

cokeholic-kim.github.io

이번에 노마드코더 자바스크립트 챌린지 강의를 자바스크립트 복습도 할 겸 도전하게 되었다. 정말 기초부터 시작해서 크게 어렵지 않게 따라 할 수 있고 다 배우면 요런 투두리스트사이트를 하나 만들 수 있다. 

 

코드를 한번 보자.

 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="./style.css">
</head>
<body>
    <div id="login">
        <div>
            <h1 class="" id="greeting">Welcome</h1>
            <form class="" id="login-form">
                <input type="text" placeholder="이름을 알려주세요">
            </form>
        </div>
    </div>
    <header>
        <h2 id="clock">00:00:00</h2>
    </header>
    <div id="content">
        <form id="todo-form">
            <input type="text" placeholder="Write a To Do and press Enter">
        </form>
        <ul id="todo-list"></ul>
    </div>
    <footer>
        <div id="quote">
            <span>Daily advice :</span>
            <span></span>
        </div>
        <div id="weather">
            <span></span>
            <span></span>
        </div>
    </footer>
    <div id="background"></div>
</body>
<script src="src/login.js"></script>
<script src="src/timer.js"></script>
<script src="src/weather.js"></script>
<script src="src/todo.js"></script>
<script src="src/backgroundimg.js"></script>
<script src="src/advice.js"></script>
</html>

HTML에는 로그인을 할수있는 부분과 콘텐츠 부분 그리고 뒤에 보일 배경 div들을 만들어놓았다. 자바스크립트는 기능별로 파일을 만들어서 사용한다.

 

제일처음 login.js부분을 보자

 

login.js

const loginInput = document.querySelector("#login-form input")
const loginForm = document.querySelector("#login-form")
let UserId = ""
loginForm.addEventListener("submit",(e)=>{
    e.preventDefault();
    UserId = loginInput.value;
    document.querySelector("#login").classList.add('hidden')
    console.log(UserId)

    if(UserId){
        savedToDos = localStorage.getItem(UserId);
        if(savedToDos){
            const parsedTodos = JSON.parse(savedToDos)
            toDos = parsedTodos
            parsedTodos.forEach(newTodo => {
                paintTodo(newTodo)
            });
        }
    }
    
})

로그인폼에서 submit이벤트가 발생하면 창이 새로고침되는 이벤트를 막아주고

Input태그에 입력된값을 UserId 변수에 저장한다.

그 후 로그인창은 hidden클래스를 추가해서 안 보이게 숨겨준다.

UserId가 입력되었을때 로컬저장소에 UserId키로 저장한 값이 있다면

값을 받아와서 forEach구문으로 todolist에 뿌려준다

아래에 로컬스토리지 부분은 todolist 쪽에서 사용하는 기능인데

submit 될 때 다시 값을 읽어와야 해서 완전히 분리는 못했다.

localstorageAPI는 todolist구문에서 설명하겠다.

 

timer.js

const timeContainer = document.querySelector("#clock")

function timer(){
    const now = new Date()
    timeContainer.innerText = 
    `${now.getHours()}:${String(now.getMinutes()).padStart(2,"0")}:${String(now.getSeconds()).padStart(2,"0")}`
}
timer()
setInterval(timer,1000)

현재시간을 알려주는 함수 부분이다. new Date() 로 현재의 시간을 받고 아래에서 innerText로 

now.getHours() : 시간

now.getMinutes() : 분

now.getSeconds() : 초 

값들을 넣어준다. 여기서 padStart라는 메서드를 사용했는데 이 메서드로 원래 6 이렇게 한 개만 보이던 숫자를 06처럼 앞에 0을 붙여서 2글자로 만들어준다 이미 두 글자이면 같은 글자가나 온다 padStart는 문자열에만 사용가능하기 때문에 String으로 숫자를 문자열로 변경한 다음 사용해 줬다.

 

시간을 초가 바뀔 때마다 새로 그려줘 야하기 때문에 setInterval로 1초마다 새로 시간을 받아오고 그려주게 했다.

setInterval을 쓰면 1초가 지난 뒤에 함수가 실행되기 때문에 처음 창을 열 때 아무것도 실행되고 있지 않기 때문에 timer() 함수를 호출해서 한번 먼저 실행되게 해 줬다.

 

weather.js

const API_KEY = 'apikey'

async function onGeoOk(position){
    const lat = position.coords.latitude
    const lng = position.coords.longitude
    const API_URL = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lng}&appid=${API_KEY}&units=metric`
    
    fetch(API_URL).then(res => res.json()).then(data=>{
        const city = document.querySelector('#weather span:first-child')
        const weather = document.querySelector('#weather span:last-child')
        city.innerText = `${data.name} :`
        weather.innerText = `${data.weather[0].main} / temp ${data.main.temp}`
    })
}
function onGeoError(){
    alert("can't find you")
}

navigator.geolocation.getCurrentPosition(onGeoOk,onGeoError)

이 구문은 openWeathermapAPI를 사용하는 구문으로 현재위치를 받아서 API에서 현재위치의 기온 위치이름 날씨를 받아온다.

 

현재위치는

navigator.geolocation.getCurrentPosition(성공했을 때실행될 함수, 실패할 때실행될 함수)로 받아올 수 있다.

현재위치의 위도경도를 저장하고 API_URL문자열에 삽입해서 fetch로 자료를 요청해 받아 온다음

자료를 html에 넣어준다.

 

todo.js

const toDoForm = document.getElementById("todo-form")
const toDoInput = toDoForm.querySelector("input")
const toDoList = document.getElementById("todo-list")

let toDos = [];
let savedToDos

function saveTodos(){
    localStorage.setItem(UserId,JSON.stringify(toDos))
}

function deleteToDo(e){
    const li = e.target.parentElement
    li.remove()
    toDos = toDos.filter(toDo => toDo.id !== Number(li.id));
    saveTodos()
}

function paintTodo(newTodoObj){
    const li = document.createElement('li')
    li.id = newTodoObj.id
    const span = document.createElement('span')
    const button = document.createElement('button')
    button.innerText = "❌"
    button.addEventListener('click',deleteToDo)
    li.appendChild(span);
    li.appendChild(button);
    span.innerText = newTodoObj.text;
    toDoList.appendChild(li)
}

function handleTodoSubmit(e){
    e.preventDefault()
    const newTodo = toDoInput.value
    toDoInput.value = ""
    const newTodoObj = {text:newTodo,id:Date.now()}
    toDos.push(newTodoObj)
    paintTodo(newTodoObj)
    saveTodos()
}

toDoForm.addEventListener("submit",handleTodoSubmit)

 todolist사이트의 꽃인 todolist를 만들어주는 구문이다

localstorage를 사용해서 새로고침을 해도 작성했던 todolist가 사라지지 않는다.

작동을 설명해 주자면 todolist를 작성하는 input에 할 일을 작성하고 엔터를 누르면

submit이벤트가 발생하게 되고 submit 이벤트가 발생하면 handleTodoSubmit함수가 실행된다. 

 

handleTodoSubmit

function handleTodoSubmit(e){
    e.preventDefault() //새로고침되는 이벤트를 막는다
    const newTodo = toDoInput.value //인풋태그에 입력된값을 newToDo라는 변수에저장
    toDoInput.value = "" //인풋태그를 비워줌
    //입력받은값을 객체로만든다 text키에 입력받은값,id키에 현재시간(밀리초단위)
    const newTodoObj = {text:newTodo,id:Date.now()} //id는 나중에 삭제할때 값을 구분하기위해사용
    toDos.push(newTodoObj) //객체를 toDos배열에 넣어준다.
    paintTodo(newTodoObj) //객체를 넘겨받아서 태그를 만들고 태그에값을넣은다음 추가해주는함수
    saveTodos() //toDos배열을 localStorage에 저장해주는 함수.
}

paintTodo

function paintTodo(newTodoObj){
    const li = document.createElement('li') //li태그생성
    li.id = newTodoObj.id //li태그에 id를 추가 ,,객체의id 현재시간(밀리초)
    const span = document.createElement('span') //span태그 생성
    const button = document.createElement('button')//button태그 생성
    button.innerText = "❌" // == <button>❌</button>
    button.addEventListener('click',deleteToDo) //button에 클릭이벤트 생성
    li.appendChild(span); // == <li><span></span></li>
    li.appendChild(button); // == <li><span></span><button></button></li>
    span.innerText = newTodoObj.text; //<span>{newTodoObj.text}<span> 작성한 할일이 들어감
    toDoList.appendChild(li) //li를 todolist가 있는 ul에 붙여줌
}

saveTodos

function saveTodos(){
    localStorage.setItem(UserId,JSON.stringify(toDos))
    //localStorage.setItem(저장할키,저장할내용)
}

userId를 키값으로 저장해서 같은 유저아이디로 들어와야 같은 값을 저장할 수 있고 읽을 수 있다.

다른 키로 들어오면 다른 키에 저장된다.

JSON.stringify()는 값이나 객체를 JSON문자열로 변환시켜 준다.

(localStorage에는 문자열만 저장되기 때문에 문자열로 바꿔야 한다.)

 

이제 값을 저장했으니 새로고침 했을 때 값을 불러와서 다시 paintTodo로 그려주면 된다.

 

이 구문이 아까 login에 있던 구문이다.

if(UserId){ //로그인한값이있는지 검사
        savedToDos = localStorage.getItem(UserId); //로그인한 아이디로 localstorage에서 값을 받아옴
        if(savedToDos){ //입력한아이디로 localstorage에서 받아온값이 있으면 실행
            const parsedTodos = JSON.parse(savedToDos) //문자열로만든 값을 다시 배열로바꿔준다.
            toDos = parsedTodos //toDos변수에 받아온값을 넣어준다 넣어주지않으면 
            //새로 todolist를 입력했을때 toDos값이 비어있기때문에 localStorage값이 새로 입력된다.
            parsedTodos.forEach(newTodo => { //받아온값을 forEach로 paintTodo함수를 다시돌려준다.
                paintTodo(newTodo)
            });
        }
    }

삭제기능

deleteToDo

function deleteToDo(e){
    const li = e.target.parentElement //클릭한버튼의 부모요소인li를 받아옴
    li.remove() //받아온부모요소인li를 지워줌
    toDos = toDos.filter(toDo => toDo.id !== Number(li.id)); 
    //toDos에서 filter를 돌려서 클릭한li요소와 같지않은값들을 리턴. 해서 toDos에 넣어줌
    saveTodos() //localStorage업데이트
}

 

값이 변경될 때마다 toDos배열을 변경시키고 변경된 toDos값을 localStorage에 저장해 주는 방식이다.

 

 

backgroundimg.js

const background = document.querySelector("#background")
const imgs = ['bg1','bg2','bg3','bg4','bg5','bg6','bg7','bg8']
const random = Math.floor(Math.random()*imgs.length)
background.style.background = `url(./imgs/${imgs[random]}.jpg)`
background.style.backgroundSize =  'cover'

 이미지의 이름을 배열로 만들고 이미지배열길이범위만큼 랜덤한숫자를 만들어서 랜덤 하게 이미지를 받아올수있게 해서 넣어줬다. 새로고침될때마다 랜덤숫자가 바뀌게되고 랜덤하게 배경이미지를 받아올 수 있다.

 

advice.js

fetch("https://api.adviceslip.com/advice").then(res=>res.json()).then(
    data => document.querySelector('#quote span:last-child').innerText=(data.slip.advice)
)

명언을 랜덤 하게 보여주는 구문이다 . 원래 수업에서는 배열에 명언을 넣어놓고 랜덤하게 하나씩받아와서 html에 넣어주는 거였는데 명언을 구할시간도 없고 이쪽이더 공부하기 좋아보여서 그냥 랜덤하게 명언을 보여주는 api에 요청해서 값을 받아서 넣어주었다.

 

aaa로 들어가서 todo를 작성해 줬다.

 

bbb로 로그인해서 들어가면

아무런 값도 뜨지 않는다.

 

로컬스토리지를 들어가서 보면 각각 다른 키에 저장되어 있는 걸 볼 수 있다.

 

로컬스토리지는 f12키나 개발자도구에 들어가서 애플리케이션에 들어가면 볼 수 있다.

 

내용자체가 되게 어렵지는 않았지만 내가 따로 더 추가해서 로그인한 사람에 따라 다르게 보이는 기능을 추가해 줬다. 제대로 로그인기능을 만들어서 해도 좋을 것 같다. 혹시 설명에 이해가 안 가는 부분이 있다면 댓글로 알려주시면 확인해 보겠습니다. 

 

반응형
반응형

리액트 라우터 나 라이브러리를 사용하지 않고 순수 자바스크립트만으로 라우터를 구현해서 Single Page Application을 구현하는 문제를 학원에서 풀어보았다. 이런 걸 해볼 때마다 라이브러리 만든 사람방향으로 절을 올리고 문제를 만든 사람 방향으로는 두 번 절을 올리고 싶은 심정이다. 한번 풀어보쟈!

 

코드샌드박스로 완성본을 올려놓고싶었는데 같은 코드가 코드샌드박스에선 실행이 안된다.. 어째서...ㅜㅜ

그래서 캡쳐로 대신하겠다.

 

index.js

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="./style.css">
</head>
<body>
    <nav class="navbar">
        <a href="/">HOME</a>
        <a href="/post/123">POST</a>
        <a href="/shop">SHOP</a>
    </nav>
    <div id="app"></div>
    <script src="./main.js" type="module"></script>
</body>
</html>

각 페이지로 넘어갈 버튼들의 마크업이다 자바스크립트를 연결해 주는데 데브매칭 문제들은 대부분 컴포넌트로 구현하기 때문에 type을 module로 하고 나머지 요소들을 자바스크립트에서 컴포넌트로 만들어서 넣어준다.

 

style.css

#app{
    text-align: center;
    color: #2c3e50;
    margin-top:60;
}

.navbar{
    margin-top:60px;
    text-align: center;
}

.navbar > a{
    display: inline-block;
    font-size: 32px;
    text-decoration: none;
    border-radius: 18px;
    background-color: #8040ff;
    color: white;
    padding: 5px 10px;
} 
.navbar > a:hover{
    transform: scale(0.95);
}

// 라우팅되었을때 렌더링될 컴포넌트의 스타일
.mainPage{
    background-color: #4a4b43;
    padding:50px 0;
    color:white
}
.postPage{
    background-color: #40d2ff;
    padding:50px 0;
    color:white
}
.shopPage{
    background-color: #ff40cf;
    padding:50px 0;
    color:white
}

css는 딱히 설명할 게 없다.. 아직 화면에 렌더링 되지 않은 컴포넌트의 스타일도 미리정의해 놨다는 거 정도?

 

html,css 까지 끝난상태

여기까지 하면 이렇게 예쁜 버튼 3개(버튼태그는 아니지만)가 만들어진 걸 볼 수 있다.

 

이제 자바스크립트로 들어가 보자.

우선 컴포넌트들과 필요한 모듈들을 만들 src폴더를 생성해 준다 모든 모듈은 src폴더 내에서 관리해 준다.

 

src / app.js

// import Router from "./router.js"
// import { navigate } from "./utils/navigate.js"

export default function App({target}){
    this.container = target
    const init=()=>{
        document.querySelector(".navbar").addEventListener('click',(e)=>{
            e.preventDefault()
            const targetURL = e.target.href.replace("http://127.0.0.1:5500","")
            console.log(targetURL);
            // navigate(targetURL)
        })
        // new Router(target)
    }
    init()
}

주석처리 한 부분은 아직 구현하지 않은 부분이다 이따가 차례대로 구현해서 넣어주자.

App컴포넌트는 target을 인수로 받는다 target 은 App컴포넌트에서 렌더링 해줄 요소들이 들어갈 위치이다.

target = document.querySelector("#app")

init메서드를 만들어준다

init메서드는. navbar

    <nav class="navbar">
        <a href="/">HOME</a>
        <a href="/post/123">POST</a>
        <a href="/shop">SHOP</a>
    </nav>

이 부분에 이벤트를 클릭이벤트를 생성하고 클릭했을 때 a태그의 페이지가 이동되는 이벤트를 막아주고 preventDefault()

클릭한 a태그의 href 주소를 받아서 앞의 로컬주소는 공백으로 처리해서  a태그에 달아놓은 주소만 받는다.

"http://127.0.0.1:5500/shop"  ==> "/shop"으로 변환시켜 준다.

init() 만들어놓은 메서드를 실행. App컴포넌트가 불려서 실행되면 실행된다.

 

main.js

import App from "./src/app.js";

window.addEventListener("DOMContentLoaded",()=>{
    new App({target:document.querySelector("#app")})
})

만들어놓은 App모듈을 연결시켜 준다 html과 연결된 유일한 자바스크립트는 main.js하나이고 

main.js에서 App모듈을 불러서 렌더링 해주고 App모듈에 여러 모듈들을 불러서 렌더링 해준다

 

index.html -- main.js -- app.js -- 모듈 1

                                                --모듈 2

                                                --모듈 3

 

이런 식으로 연결해서 사용해 준다 , 리액트와 비슷한 것 같기도 아닌 것 같기도..

 

이제 app에서 필요한 요소들을 하나씩 만들어주자.

a태그를 클릭했을 때. 이벤트를 막고 href를 받았다. 이 href를 받아서 넘겨주고 주소창에 추가해줘야 한다.

 

href를 받을 커스텀이벤트 함수를 만들어주자

src / utils / navigate.js

export const navigate = (to)=>{
// const 이벤트개체 = new CustomEvent("이벤트이름",{detail:{키:밸류}})
    const historyChangeEvent = new CustomEvent("historyChange",{
        // to 이동하게 될 url
        detail:{to}
    })
    dispatchEvent(historyChangeEvent);
}

 나도 커스텀이벤트함수는 처음 본 거라 좀 생소한데 navigate함수를 실행하면 to라는 파라미터를 받고 

CustomEvent를 만들 때 detail에 to키에 넣어준다. 

만들어진 이벤트개체를 dispatchEvent 해주면 이벤트를 호출한다. 

 

navigate함수를 실행하면 이벤트함수를 만들고 이벤트함수를 호출한다. 

여기서는 호출되었을 때의 동작은 없기때문에 이따가 다른 모듈에서 호출되었을때의 동작을 만들어 준다.

 

렌더링 할 페이지들을 먼저 만들어주겠다.

src / pages / Main.js

export default function Main(target){
    console.log(target)
    this.container = target
    this.setState = ()=>{
        this.render();
    }
    this.render = () =>{
        this.container.innerHTML = `
        <div class = "mainPage">
        메인 페이지에요.
        </div>
        `;
    }
    this.render()
}

src / pages / Pages.js

export default function Shop(target){
    this.container = target
    this.setState = ()=>{
        this.render();
    }
    this.render = () =>{
        this.container.innerHTML = `
        <div class = "shopPage">
        SHOP페이지에요.
        </div>
        `;
    }
    this.render()
}

src / pages / Post.js

export default function Post(target){
    this.container = target
    this.setState = ()=>{
        this.render();
    }
    this.render = () =>{
        this.container.innerHTML = `
        <div class = "postPage">
        POST페이지에요.
        </div>
        `;
    }
    this.render()
}

target을 전달받아서 target에 html요소를 렌더링 해주는 모듈들이다. this.setState메서드는 지금은 없어도 상관없긴 한데 나중에 값을 전달받거나 할 때 사용할 수도 있어서 만들어두었다.

 

이제 app에서 href를 받아서 유효성을 검사하고 해당하는 href에 page를 보낼 수 있는 모듈을 만들어보자

src / constant / routerinfo.js

import Main from "../pages/Main.js";
import Shop from "../pages/Pages.js";
import Post from "../pages/Post.js";

export const routes = [
    {path:/^\/$/, element:Main}, // "/"
    {path:/^\/post\/[\w]+$/, element:Post},  // "/post/123 , 456"
    {path:/^\/shop$/, element:Shop}, // " /shop "
]

배열의 path에는 정규표현식이 작성되어 있어서 path로 유효성을 검사하고 알맞다면 해당 객체를 받을 수 있는 배열메서드를 사용해서 element에 있는 모듈을 받아올 수 있다.

 

이제 모듈을 받아서 렌더링 해주고 href주소를 받아서 주소창에 입력해 줄 모듈 router를 만들어보자.

src / router

import { routes } from "./constants/routerinfo.js";

export default function Router(target){
    this.container = target;
    const findMatchedRoute = () =>{
        //pathname 이 정규표현식에 true로 나오는 객체를리턴
        return routes.find(route => route.path.test(location.pathname)); //test 메서드는 정규표현식에 값을넣어서 true false 로 반환함
    }
    
    const route = () =>{
        const TargetPage = findMatchedRoute().element;
        new TargetPage(target)
    }
    const init = () =>{
        window.addEventListener("historyChange",({detail})=>{ //커스텀이벤트에서 전달해주는 detail을 객체구조분해할당으로 받음
            const {to} = detail;
            history.pushState(null,"",to); //페이지이동없이 주소를바꿔주는 메소드 (넘겨줄데이터,타이틀,변경할주소)
            route() //pushState로 주소를 변경해주고 라우트함수를 실행시킴
        })
        window.addEventListener("popstate",()=>{
        //뒤로가기버튼을 클릭했을때 실행되는 이벤트
        //주소가 이전주소로바뀌면서 route를 실행하면 이전주소에 해당하는 컴포넌트렌더링
            route()
        })
    }
    init();
    route();
}

router는 target을 전달받아서 렌더링 할 요소에 전달해 준다.

init( ) 메서드는 아까 dispatch 한 커스텀이벤트의 동작을 만들어준다 historyChange이벤트가 발생했을 때 이벤트에서 전달해 주는 detail을 파라미터로 함수 안에서 사용하고 detail안에 있는 to를 구조분해 할당해서 사용한다

to  에는 "/" , "/shop" , "/post/123" 같은 클릭한 a태그의 href 값이 들어있다.

history.pushState는 주소창을 변경시켜 줄 수 있는 메서드이다 pushState(넘겨주는 데이터, 타이틀, 변경할 주소)가 파라미터로 들어간다 여기에 to를 넣으면 현재주소에서 /shop 이 추가된 주소로 바뀐다 (페이지변경 없이!!!)

주소창의 주소를 변경시켜 주고

route메서드를 실행시키는데 

route메서드는 findMatchedRoute메서드를 실행시키고 값을 리턴 받은 후 리턴 받은 값의 element요소를 TargetPage에 할당한다

findMatchedRoute는 아까 만든 정규표현식 배열을 이용하는 메서드인데 정규표현식배열에 find메서드를 이용해서 true값을 리턴하는 배열요소를 받아온다. 현재주소(location.pathname)를 배열에

정규표현식이 들어있는 path키에 test메서드(정규표현식을 만족하는지 판별하는 메서드 맞으면  true 아니면 false 리턴)

안에 넣어서 정규표현식을 만족하는지 검사한 후 리턴해준다.

 

 

이제 99.99% 완성이다 아까 App에서 주석으로 처리해 놨던 구문들을 풀어주면 끝난다

src /app.js

import Router from "./router.js"
import { navigate } from "./utils/navigate.js"

export default function App({target}){
    this.container = target
    const init=()=>{
        document.querySelector(".navbar").addEventListener('click',(e)=>{
            e.preventDefault()
            const targetURL = e.target.href.replace("http://127.0.0.1:5500","")
            console.log(targetURL);
            navigate(targetURL)
        })
        new Router(target)
    }
    init()
}

 

 init 메서드를 실행하면 

a태그의 이벤트를 막고 navigate에 targetURL을 넣어서 historyChange이벤트를 만들고 디스패치해 주고

Router에 target을 넣어서 실행시켜 준다 

그러면 Router에서 클릭한 주소를 historyChange이벤트에서 받아서 historypush.state로 주소에 넣어주고

해당하는 주소를 배열에서 찾아서 렌더링 할 모듈을 받아와서 타깃위치에 렌더링 해준다.

 

완성이미지

012
각 모듈이 렌더링될때의 이미지

 

 

나도 처음 배운 내용이고 이해하기 쉽게 좀 길게 풀어서 설명하다 보니 두서없는 설명이 된 것 같은데 일단 나는 알아볼 수 있으니 복습용으로는 충분하지만 혹시라도 누군가가 보고 이해가 안 가는 게 있다면 댓글로 알려주면 답 해줄 수 있을 것 같다 

아니면 내 깃허브를 보거나..

 

GitHub - cokeholic-kim/spaVanillaJS

Contribute to cokeholic-kim/spaVanillaJS development by creating an account on GitHub.

github.com

 

반응형

+ Recent posts