src/components 디렉토리 하위에 10개 컴포넌트를 구성한다.
설치경로
├─ node_modules
├─ public
├─ scrtips
├─ src
│ ├─ components // 디렉토리 생성(하위 포함)
│ │ ├─ Article.svelte
│ │ ├─ ArticleAddForm.svelte
│ │ ├─ ArticleEditForm.svelte
│ │ ├─ ArticlHeader.svelte
│ │ ├─ ArticlList.svelte
│ │ ├─ ArticlLoading.svelte
│ │ ├─ AuthHeader.svelte
│ │ ├─ AuthRegister.svelte
│ │ ├─ Comment.svelte
│ │ ├─ CommentList.svelte
│ ├─ styles
│ │ └─ main.css
│ ├─ App.svelte
│ └─ main.js
├─ index.html
├─ package.json
└─ rollup.config.js
indiecoder-slog-tailwindcss 프로젝트 dist 디렉토리 하위의 main.css 파일을
indiecoder-slog-svelte3-frontend 프로젝트의 src/styles 디렉토리 하위로 복사한다.
indiecoder-slog-svelte3-frontend 프로젝트의 src/main.js 파일(엔트리포인트)의 최상단에 해당 css 파일을 import한다
import './styles/main.css'
indiecoder-slog-tailwindcss 프로젝트 dist 디렉토리 하위의 마크업 파일 기준으로 적용한다.
각 컴포넌트는 아래 표의 Html파일의 주석의 시작과 끝에 해당하는 영역을 분리하여 구성한다.
| 컴포넌트 | HTML 파일 | 주석 |
|---|---|---|
| Article.svelte | articles.html | slog-content-box (start ~ end) |
| ArticleAddForm.svelte | articles.html | slog-addForm (start ~ end) |
| ArticleEditForm.svelte | articles.html | slog-content-edit-form (start ~ end) |
| ArticleHeader.svelte | articles.html | header (start ~ end) |
| ArticleList.svelte | articles.html | slog-list-wrap (start ~ end) |
| ArticleLoading.svelte | articles.html | loading-box (start ~ end) |
| 컴포넌트 | HTML 파일 | 주석 |
|---|---|---|
| AuthHeader.svelte | login.html | main-header (start ~ end) |
| AuthLogin.svelte | login.html | login-box (start ~ end) |
| AuthRegister.svelte | login.html | register-box (start ~ end) |
| 컴포넌트 | HTML 파일 | 주석 |
|---|---|---|
| Comment.svelte | comments.html | comment (start ~ end) |
| CommentList.svelte | comments.html | slog-comment-wrap (start ~ end) |
svelte는 router를 기본적으로 제공하지 않는다.
대신 다양한 기능의 router를 플러그인으로 설치할 수 있고, 개발자는 필요에 따라 라우터를 선별해서 사용하면 되며, 강의에서는 Tinro 라는 라우터 플러그인 패키지를 설치하여 사용한다.
(svelte의 확장 프레임워크인 SvelteKit에는 라우터를 제공한다.)
npm install -D tinro
src/pages 디렉토리 하위에 4개 컴포넌트를 구성한다.
설치경로
├─ node_modules
├─ public
├─ scrtips
├─ src
│ ├─ components
│ ├─ pages // 디렉토리 생성(하위 포함)
│ │ ├─ Articles.svelte
│ │ ├─ Comments.svelte
│ │ ├─ Login.svelte
│ │ ├─ notFound.html.svelte
│ │ └─ Register.svelte
│ ├─ styles
│ │ └─ main.css
│ ├─ App.svelte
│ └─ main.js
│ └─ router.svelte // 생성
├─ package.json
└─ rollup.config.js
<script>
import ArticleHeader from "../components/ArticleHeader.svelte";
import ArticleList from "../components/ArticleList.svelte";
import ArticleAddForm from "../components/ArticleAddForm.svelte";
</script>
<ArticleHeader />
<main class="slog-main">
<ArticleAddForm />
<ArticleList />
</main>
<script>
import AuthHeader from "../components/AuthHeader.svelte";
import AuthLogin from "../components/AuthLogin.svelte";
</script>
<AuthHeader />
<main class="auth-box">
<AuthLogin />
</main>
<script>
import AuthHeader from "../components/AuthHeader.svelte";
import AuthRegister from "../components/AuthRegister.svelte";
</script>
<AuthHeader />
<main class="auth-box">
<AuthRegister />
</main>
indiecoder-slog-tailwindcss 프로젝트 dist 디렉토리 하위의 not-found.html 마크업 파일 기준으로 적용한다.
<!-- not-found.html -->
<!-- 404 page start -->
<div class="not-found-full-box">
<h1>404 Not Found</h1>
</div><!-- 404 page end -->
pages 디렉토리를 생성하고, 그 하위에 라우팅에 의해 화면에 표시되는 컴포넌트들을 주제별로 구성한다.
pages 디렉토리와 동일한 레벨에 router.svelte를 생성한다.
해당 컴포넌트에 라우팅과 관련된 코드가 작성된다.
<script>
import { Route } from 'tinro'
import Articles from './pages/Articles.svelte';
import Login from './pages/Login.svelte';
import Register from './pages/Register.svelte';
import NotFound from './pages/notFound.svelte';
</script>
<Route path="/" redirect="/articles/all" />
<Route path="/articles/*" ><Articles/></Route>
<Route path="/login" ><Login/></Route>
<Route path="/register" ><Register/></Route>
<Route fallback ><NotFound/></Route>
trino로 부터 Route를 import하여 해당 컴포넌트를 선언하여 path 속성에 라우팅할 주소를, 해당 주소 요청이 들어오면 렌더링 할 컴포넌트를 Route 컴포넌트의 자식 컴포넌트로 지정해준다.
기본주소로 접근하는 사용자에게는 redirect를 이용해 articles로 이동하게 설정한다.
comment의 경우 articles의 하위 라우터로 배치될 예정이므로 주소 끝에 *를 표시해 둔다.
<script>
import Router from "./router.svelte";
</script>
<div class="main-comtainer">
<Router />
</div>
App.svelte 컴포넌트에서 해당 컴포넌트를 import 하여 배치하면, 배치한 영역에 출력되게 된다.
백엔드 서버와 REST API를 이용한 통신이 필요하다.
자바스크립트에서 Ajax 통신을 하는 대표적 방법으로 fetch와 Axios가 있다.
npm install axios
axios.get("http://localhost:3000/api/articles", {
headers: {
X-Auth-Token: '###'
}
})
axios.get("http://localhost:3000/api/likes", {
headers: {
X-Auth-Token: '###'
}
})
axios.post("http://localhost:3000/api/article",
{
content: "###"
},
{
headers: {
X-Auth-Token: '###'
}
}
)
해당 서비스의 정해진 보안 정책을 기준으로 api 호출 메소드를 공통화 시키면 아래와 같이 코드가 간결해진다.
getApi({path: '/articles'});
getApi({path: '/likes'});
const options = {
path: '/articles',
data: {
email: '###'
}
}
getApi(options);
src 디렉토리 하위에 service 디렉토리를 구성하고 그 하위에 api.js 를 만든다.
설치경로
├─ node_modules
├─ public
├─ scrtips
├─ src
│ ├─ components
│ ├─ pages
│ ├─ service // 디렉토리 생성(하위 포함)
│ │ └─ api.js // 생성
│ ├─ styles
│ │ └─ main.css
│ ├─ App.svelte
│ └─ main.js
│ └─ router.svelte
├─ index.html
├─ package.json
└─ rollup.config.js
import axios from 'axios'
const send = async ({method='', path='', data={}, access_token=''} = {}) => {
const commonUrl = 'http://localhost:3000'
const url = commonUrl + path
const headers = {
"Access-Control-Allow-Origin": commonUrl, // cross domain 이슈 대응 옵션
"Access-Control-Allow-Credentials": true, //
"content-type": "application/json;charset=UTF-8", // 송수신 데이터 타입
"accept": "application/json,",
"SameSite": "None", // 인증시 사용할 쿠키를 위한 설정
"Authorization": access_token // 토큰정보 전송
}
const options = {
method,
url,
headers,
data,
withCredentials: true, // 프론트,백엔드 서버의 포트가 다른 형태에 서버 쿠키를 공유하기 위한 설정
}
try {
const response = await axios(options);
return response.data
} catch (error) {
throw error
}
}
const getApi = ({path='', access_token=''} = {}) => {
return send({method: 'GET', path, access_token})
}
const putApi = ({path='', data={} access_token=''} = {}) => {
return send({method: 'PUT', path, data, access_token})
}
const postApi = ({path='', data={} access_token=''} = {}) => {
return send({method: 'POST', path, data, access_token})
}
const delApi = ({path='', data={} access_token=''} = {}) => {
return send({method: 'DELETE', path, data, access_token})
}
export {
getApi,
putApi,
postApi,
delApi
}
강의 코드에서는 headers 옵션에 Access-Control-Allow-Origin와 Access-Control-Allow-Credencials 이라는 CORS 관련 옵션을 요청 헤더에 담아 보내는데, 백엔드 서버에서 응답 헤더에 담아 반환하는 설정값이먀, SameSite의 경우 Cookie에 설정하는 값이기 때문에 백엔드에서 요청 헤더로 부터 꺼내서 다시 세팅하지 않는 이상 사실 이 헤더값은 무의미하다고 봐도 무방하다.
Store는 전역으로 사용할 수 있는 상태값이다.
src 디렉토리 하위에 stores 디렉토리를 구성하고 그 하위에 index.js 를 만든다.
설치경로
├─ node_modules
├─ public
├─ scrtips
├─ src
│ ├─ components
│ ├─ pages
│ ├─ service
│ ├─ stores // 디렉토리 생성(하위 포함)
│ │ └─ index.js.js // 생성
│ ├─ styles
│ │ └─ main.css
│ ├─ App.svelte
│ └─ main.js
│ └─ router.svelte
├─ index.html
├─ package.json
└─ rollup.config.js
import { writable, get } from 'svelte/store'
import { getApi, putApi, delApi, postApi } from '../service/api.js'
import { router } from 'tinro'
function setCurrentArticlesPage() {}
function setArticles() {}
function setLoadingArticle() {}
function setArticleContent() {}
function setComments() {}
function setAuth() {}
function setArticlesMode() {}
function setIsLogin() {}
export const currentArticlesPage = setCurrentArticlesPage()
export const articles = setArticles()
export const loadingArticle = setLoadingArticle()
export const articleContent = setArticleContent()
export const comments = setComments()
export const auth = setAuth()
export const articlesMode = setArticlesMode()
export const isLogin = setIsLogin()
| store | 설명 |
|---|---|
| currentArticlesPage | 게시물 스크롤 시 페이지 증가를 관리하는 스토어 |
| articles | 서비스의 가장 메인이 되는 스토어 articles라는 게시물 목록이 누적되며 게시물 수정·삭제와 관련된 사용자 정의 메소드를 가진다. 좋아요나 코멘트를 추가했을 때 상태를 변경해주는 사용자 정의 메소드 등을 포함한다. |
| loadingArticle | 게시물 데이터를 조회할 때 서버와 통신 중이라면 로딩 상태를 표시하는 기능을 하는 스토어 |
| articleContent | 게시물 단건에 대한 정보만을 담는 스토어 |
| comments | 특정 게시물의 Comment를 담는 스토어 코멘트 추가, 수정, 삭제 등을 처리하는 사용자 정의 메소드를 가진다. |
| auth | 로그인된 유저의 정보를 담는 스토어 로그인, 로그아웃, 회원가입 등의 사용자 정의 메소드를 가진다. |
| articlesMode | 보기 상태를 나타내는 스토어 보기 모드: [모두보기, 좋아요보기, 내글보기] |
| isLogin | 로그인 상태 여부를 확인하는 스토어 |
토큰은 일반적으로 액세스토큰과 리프레시토큰으로 나뉘며, 둘의 가장 큰 차이점은 토큰의 만료시간이 다르다는 점이다.
액세스토큰은 사용자 정보가 들어있는 암호화된 토큰으로, 15분에서 1시간 정도로 매우 짧은 만료시간을 갖는다.
액세스 토큰이 만료될 쯤 리프레시 토큰을 이용하여 액세스 토큰을 재발급 받아 로그인을 유지한다.
따라서 리프래시 토큰은 1주 혹은 그 이상의 만료시간으로 설정하여 사용하는 경우가 많다.
인증의 메인으로 사용되는 액세스 토큰이 해커에 의해 탈취되더라도 해당 토큰의 만료 시간이 짧아 사용하기 힘들게 하기 위함이다.
두 토큰의 저장 위치는 다르게 적용된다.
액세스 토큰은 클라이언트의 메모리(JS 변수)에 저장되어 필요한 요청이 발생하면 바로 사용할 수 있게 준비한다.
svelte에서는 store에 저장하여 필요에 따라 불러 사용한다.
리프레시 토큰은 http-only 옵션이 적용된 쿠키 즉, 로컬에 저장한다.
http-only로 저장된 쿠키는 브라우저에서 자바스크립트를 이용해 로드할 수 없고, 서버의 요청을 통해서만 읽거나 쓸 수 있어 보안에 강화
이렇게 재발급 받은 AccessToken을 이용하여 필요한 요청이 가능
-
function setAuth() {
let initValues = {
id: '',
email: '',
Authrization: ''
}
const { subscribe, set, update } = writable({...initValues})
const refresh = async () => {}
const resetUserInfo = () => {}
const login = async () => {}
const logout = async () => {}
const register = async () => {}
return {
subscribe,
refresh,
login,
logout,
resetUserInfo,
register
}
}
writable을 초기화 할 객체인 initValue를 정의한다.
initValue에는 사용자 정보와 Authorization을 초기화 상태로 만들어주는데, Authorization이 바로 AccessToken이 된다.
writable에 initValue를 그냥 넘기지 않고 전개하는 이유는 writable에서 참조를 끊어(복제) 추후 같은 변수로 초기화 시킬 수 있기 때문이다.
| 함수 | 설명 |
|---|---|
| refresh | refreshToken을 이용 AccessToken 요청 메소드 |
| resetUserInfo | auth store 초기화 메소드 |
| login | 로그인 기능 메소드 |
| logout | 로그아웃 기능 메소드 |
| register | 회원가입 기능 메소드 |
위 5가지 기능의 메소드와 함께 subscribe를 포함하여 내보낸다.
set, update의 경우 store 외부에서 굳이 store를 조작할 필요가 없기 때문에 내보내지 않는다.
refresh
const refresh = async () => {
try {
const authenticationUser = await postApi({path: 'auth/refresh'})
set(authenticationUser) // AccessToken 초기화
} catch(error) {
auth.resetUserInfo() // 정상이 아닐경우 폴백 (auth store 초기화)
}
}
아래 코드와 같이 refresh 상태를 기록하는 isRefresh store 추가한다.
export const isRefresh = writable(false)
refresh의 경우 사용자가 특정 행동을 해서 호출되는 것이 아니다.
하지만 로그아웃 상태거나 RefreshToken이 정상이 아닐 경우에는 refresh를 더이상 요청할 필요가 없게 된다.
따라서 frontend에서 refresh를 계속 호출할지 여부를 파악해야 하며, 이 상태를 관리하는 store가 isRefresh store이다.
const refresh = async () => {
try {
const authenticationUser = await postApi({path: 'auth/refresh'})
set(authenticationUser) // Authorization(AccessToken) 초기화
isRefresh.set(true)
} catch(error) {
auth.resetUserInfo() // 정상이 아닐경우 폴백 (auth store 초기화)
isRefresh.set(false)
}
}
위와같이 refresh가 정상적으로 완료된 경우 isRefresh를 set을 이용하여 true로 유지하고 오류 등에 의해 비정상일 경우 isRefresh를 false로 초기화하여 더이상 refresh를 호출하지 않도록 한다.
resetUserInfo
const resetUserInfo = () => set({...initValues})
login
const login = async (email, password) => {
try {
const options = {
path: '/auth/login',
data: {
email: email,
pwd: password
}
}
const result = await postApi(options)
set(result) // Authorization(AccessToken) 초기화
isRefresh.set(true) // refresh 호출여부 on
router.goto('/articles') // 라우터의 goto를 이용하여 게시글 목록 화면으로 이동
} catch(error) {
alert('오류가 발생했습니다. 로그인을 다시 시도해 주세요.')
}
}
refresh와 login 모두 RefreshToken을 받아 저장하거나 저장된 토큰을 서버로 전달하는 부분이 없다.
이유는 쿠키 옵션 중 http-only라는 옵션이 서버에서 설정했으며, 해당 옵션은 브라우저에서 자바스크립트를 이용해 쿠키를 조회할 수 없다.
오직 서버만이 쿠키를 쓰거나 읽을 수 있도록 설정하는 옵션이기 때문이다.
logout
const logout = async () => {
try {
const options = {
path: '/auth/logout'
}
await delApi(options)
set({...initValues})
isRefresh.set(false) // refresh 호출여부 off
router.goto('/') // 라우터의 goto를 이용하여 메인 화면으로 이동
} catch(error) {
alert('오류가 발생했습니다. 다시 시도해 주세요.')
}
}
register
const register = async (email, password) => {
try {
const options = {
path: '/auth/register',
data: {
email: email,
pwd: password
}
}
await postApi(options)
alert('가입이 완료되었습니다.')
router.goto('/login') // 라우터의 goto를 이용하여 로그인 화면으로 이동
} catch(error) {
alert('오류가 발생했습니다. 로그인을 다시 시도해 주세요.')
}
}
<script>
import { auth } from '../stores'
/** 입력값과 연결할 상태값 */
let values = {
formEmail: '',
formPassword: ''
}
/** values 초기화 메소드 */
const resetValues = () => {
values.formEmail = '';
values.formPassword = ''
}
/** 로그인 요청 메소드 */
const onLogin = async () => {
try {
await auth.login(values.formEmail, values.formPassword)
resetValues();
} catch (error) {
alert('인증이 되지 않았습니다. 다시 시도해주세요.')
}
}
</script>
<div class="auth-content-box " >
<div class="auth-box-main">
<div class="auth-input-box">
<input type="email" name="floating_email" id="floating_email" class="auth-input-text peer" placeholder=" " bind:value={values.formEmail} />
<label for="floating_email" class="auth-input-label">이메일</label>
</div>
<div class="auth-input-box">
<input type="password" name="floating_email" id="floating_email" class="auth-input-text peer" placeholder=" " bind:value={values.formPassword} />
<label for="floating_email" class="auth-input-label">비밀번호</label>
</div>
</div>
<div class="content-box-bottom">
<div class="button-box">
<button class="button-base" on:click={onLogin}>로그인</button>
</div>
</div>
</div>
로그인 상태를 확인하는 store이다.
기존의 writable과는 다른 성격의 derived라는 store를 사용한다.
이미 만들어진 스토어를 참조해서 새로운 값을 리턴하는 형태의 store이다.
이때 참조한 store의 값에는 아무런 영향을 주지 않는다.
const storeName = derived(a, $a => {
return $a + 1
})
derived 내에서 참조할 store와 콜백함수를 매개변수로 받아 콜백 함수 내에서 조작한 후 반환하는 형태가 된다.
이때 참조할 store를 사용할 때는 store 변수 앞에 $기호를 작성해야 한다.
첫번째 매개변수인 store(a)를 콜백 함수의 매개변수에 전달하는데 이때 $를 작성하여 사용한다.
svelte store에서 $ 접두사를 사용할 경우 자동 구독 기능 즉, subscribe 함수가 자동으로 호출되어 현재 store 값 즉, writable에 등록한 값을 반환한다.
authStore의 authorization(AccessToken)에 값이 있는지를 파악하여 값이 있으면 true 없으면 false를 return한다.
import { /* 생략 */ derived } from 'svelte/store'
function setIsLogin() {
const checkLogin = derived(auth, $auth => $auth.Authorization ? true : false)
return checkLogin;
}
function setAuth() { /* 생략 */ }
export const auth = setAuth() // store
export const isLogin = setIsLogin()
이렇게 구현한 isLogin 값을 기준으로 게시글 헤더에서 로그인 로그아웃 아이콘을 조건에 따라 분기하여 출력되도록 ArticleHeader.svelte 컴포넌트에 적용한다.
<script>
import { router } from 'tinro'
import { auth, isLogin } from '../stores'
const goLogin = () => router.goto('/login')
const onLogout = () => auth.logout()
</script>
<header class="main-header">
<p class="p-main-title" >SLogs</p>
<nav class="main-nav">
<button class=" main-menu main-menu-selected mr-6" >모두 보기</button>
<button class="main-menu mr-6" >좋아요 보기</button>
<button class="main-menu main-menu-blocked" >내글 보기</button>
</nav>
{#if $isLogin}
<!--로그아웃 -->
<button href="#" class="text-white" on:click={onLogout}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-7 w-7 fill-white"><path d="M16 13v-2H7V8l-5 4 5 4v-3z"></path><path d="M20 3h-9c-1.103 0-2 .897-2 2v4h2V5h9v14h-9v-4H9v4c0 1.103.897 2 2 2h9c1.103 0 2-.897 2-2V5c0-1.103-.897-2-2-2z"></path></svg>
</button>
{:else}
<!--로그인 -->
<button href="#" class="text-white" on:click={goLogin}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-7 w-7 fill-white"><path d="m13 16 5-4-5-4v3H4v2h9z"></path><path d="M20 3h-9c-1.103 0-2 .897-2 2v4h2V5h9v14h-9v-4H9v4c0 1.103.897 2 2 2h9c1.103 0 2-.897 2-2V5c0-1.103-.897-2-2-2z"></path></svg>
</button>
{/if}
</header>
<!-- end header -->
AuthRegister.svelte 컴포넌트에 store로부터 auth store를 불러와 연동해준다.
불러온 auth store로 부터 register 기능을 호출하여 실제 회원가입을 할 수 있도록 회원가입 함수를 구현한다.
<script>
import { auth } from '../stores'
let values = {
formEmail: '',
formPassword: '',
formPasswordConfirm: '',
}
const onRegister = async () => {
try {
await auth.register(values.formEmail, values.formPassword)
} catch (error) {
alert('회원가입에 실패했습니다. 다시 시도해 주세요.')
}
}
</script>
<div class="auth-content-box" >
<div class="auth-box-main">
<div class="auth-input-box">
<input type="email" name="floating_email" id="floating_email" class="auth-input-text peer" placeholder=" " bind:value={values.formEmail}/>
<label for="floating_email" class="auth-input-label">이메일</label>
</div>
<div class="auth-input-box">
<input type="password" name="floating_email" id="floating_email" class="auth-input-text peer" placeholder=" " bind:value={values.formPassword}/>
<label for="floating_email" class="auth-input-label">비밀번호</label>
</div>
<div class="auth-input-box">
<input type="password" name="floating_email" id="floating_email" class="auth-input-text peer" placeholder=" " bind:value={values.formPasswordConfirm}/>
<label for="floating_email" class="auth-input-label">비밀번호 확인</label>
</div>
</div>
<div class="content-box-bottom">
<div class="button-box">
<button class="button-base" on:click={onRegister}>회원가입</button>
</div>
</div>
</div>
현재 로그인시 액세스 토큰이 유지되는 시간은 단 15분이다.
그리고 화면을 새로고침 했을 경우에도 로그인이 풀어진다.
이때 필요한 기능이 바로 Refresh 으로, 로그인을 유지할 수 있다.
우선 앱을 처음 실행할 때 Refresh가 실행되고 RefreshToken이 정상이면 AccessToken을 발급받을 수 있는 기능부터 구현한다.
App.svelte에서 auth store를 import 한 후 auth.refresh()를 호출한다.
auth.refresh()는 서버와 통신을 해서 토큰을 가져오므로 비동기적으로 작동한다.
따라서 첫번째 Refresh가 작동한 후 앱의 기능들이 실행해야 하므로 Svelte의 await-block을 활용하여 처리한다.
<script>
import Router from "./router.svelte";
import { auth } from './stores'
</script>
<div class="main-comtainer">
{#await auth.refresh() then}
<Router />
{/await}
</div>
await-block은 함수의 반환값 여부와 상관없이 오직 Promise가 성공인 경우에만 then을 탄다.
만약 실패일 경우 :catch 블록을 사용하면된다.{#await auth.refresh() then}
<Router />
{:catch err}
<Error />
{/await}
await-block을 사용하지 않는다면 아래와 같이 #if와 onMount를 활용하여 기본 문법으로 구현 가능하다.
<script>
import Router from "./router.svelte";
import { onMount } from 'svelte';
let ready = false;
onMount(async () => {
await auth.refresh();
ready = true;
});
</script>
{#if ready}
<Router />
{/if}
첫번째 refresh 이후 Router 배치 기능들이 작동하게 된다.
await block의 경우 markup 영역에서 비동기를 처리하기 위한 방법이다. 또 다른 방법으로 main.js에서 refresh를 설정할 수도 있다.
import './styles/main.css'
import App from './App.svelte'
/* Refresh 적용 */
import { auth } from './stores'
await auth.refresh()
const app = new App({
target: document.getElementById('app'),
})
export default app
참고로 원래 await은 async 안에서만 사용 가능했지만 ECMAScript 2022부터는 최상위 레벨에서 await 호출이 가능해졌다.
AccessToken의 유효기간은 15분 이므로 14분마다 새로고침 하도록 구현한다.
최상위 컴포넌트인 App.svelte 컴포넌트에서 구현한다.
<script>
import Router from './router.svelte'
import { onMount } from 'svelte'
import { auth, isRefresh } from './stores'
const refresh_time = 1000 * 60 * 14 // 14분
onMount(() => {
const onRefresh = setInterval(() => {
if($isRefresh) {
auth.refresh()
} else {
clearInterval(onRefresh)
}
}, refresh_time)
})
</script>
<div class="main-container">
<Router />
</div>
이제 백엔드에 의해 http-only 설정이 된 쿠키값이 14분마다 초기화된다.
import { writable, get, derived } from 'svelte/store'
import { getApi, putApi, delApi, postApi } from '../service/api.js'
import { router } from 'tinro'
function setCurrentArticlesPage() {
const { subscribe, update, set } = writable(1)
const resetPage = () => {}
const increPage = () => {}
return {
subscribe,
resetPage,
increPage
}
}
function setArticles() {
let initValues = {
articleList: [], // 서버로부터 받은 글 목록
totalPageCount: 0, // 서버로부터 받은 전체 페이지 수
menuPopup: '', // 게시글 하나마다 표시되는 팝업메뉴 상태값
editMode: '' // 게시글중 수정할 경우 수정모드로 전환된 글 고유 번호
}
const { subscribe, update, set } = writable({...initValues})
const fetchArticles = async () => {}
const resetArticles = () => {}
return {
subscribe,
fetchArticles,
resetArticles
}
}
export const articles = setArticles()
export const currentArticlesPage = setCurrentArticlesPage()
import { writable, get, derived } from 'svelte/store'
import { getApi, putApi, delApi, postApi } from '../service/api.js'
import { router } from 'tinro'
function setCurrentArticlesPage() {
const { subscribe, update, set } = writable(1)
const resetPage = () => set(1)
const increPage = () => {
update(data => data = data + 1) // 페이지 번호 증가
articles.fetchArticles(); // 게시글 목록 호출
}
return {
subscribe,
resetPage,
increPage
}
}
/* setArticles 생략 */
export const articles = setArticles()
export const currentArticlesPage = setCurrentArticlesPage()
articles store는 서버로부터 호출한 글목록을 담아두는 스토어이다.
단순히 글 목록만 담아두는것 뿐만 아니라 담겨진 글 목록의 값들을 조작하는 다양한 사용자 정의 메소드들도 가지게 된다.
import { writable, get, derived } from 'svelte/store'
import { getApi, putApi, delApi, postApi } from '../service/api.js'
import { router } from 'tinro'
/* setCurrentArticlesPage 생략 */
function setArticles() {
let initValues = {
articleList: [],
totalPageCount: 0,
menuPopup: '',
editMode: ''
}
const { subscribe, update, set } = writable({...initValues})
/** 선택된 페이지 데이터 조회(페이지 증가시 호출) */
const fetchArticles = async () => {
const currentPage = get(currentArticlesPage) // 다른 store에서 값을 참조하는 경우 혹은 svelte파일이 아닌 일반 js 모듈에서 store값을 참조하는 경우 get을 사용
let path = `/articles/?pageNumber=${currentPage}`
try {
const access_token = get(auth).Authorization
const options = {
path: path,
access_token: access_token
}
const getDatas = await getApi(options)
const newData = {
articleList: getDatas.articleList,
totalPageCount: getDatas.totalPageCount,
}
update(datas => {
if (currentPage == 1) {
datas.articleList = newData.articleList
datas.totalPageCount = newData.totalPageCount
} else {
const newArticles = [...datas.articleList, ...newData.articleList]
datas.articleList = newArticles
data.totalPageCount = newData.totalPageCount
}
return datas
})
} catch(error) {
throw error
}
}
/** 게시글 목록 초기화(페이지번호 초기화) */
const resetArticles = () => {
set({...initValues})
currentArticlesPage.resetPage()
}
return {
subscribe,
fetchArticles,
resetArticles
}
}
export const articles = setArticles()
export const currentArticlesPage = setCurrentArticlesPage()
스크롤이 일정 위치 이하로 내려오면 새로운 페이지가 호출되고, 기존의 데이터에 더해지는 방식으로 구현한다.
무한스크롤의 핵심 기능으로 onScroll 이라는 메소드를 구현하여 사용한다.
특정 마크업 영역의 dom 정보를 전달인자로 전달받게 된다.
전달받은 값은 dom에 대한 다양한 값이 담겨져 있으며, 그중 스크롤과 화면 사이즈 관련 정보를 사용한다.
위 정보들을 바탕으로 realHeight와 triggerHeight 값을 구한다.
화면에 scroll이 70%정도 내려오게 되면 다음 페이지를 호출하고 데이터를 받아 목록에 추가하는 형태로 무한스크롤을 구현한다.
<script>
import Article from "./Article.svelte";
import ArticleLoading from "./ArticleLoading.svelte";
import { onMount } from 'svelte'
import { articles, currentArticlesPage } from '../stores'
/* 스크롤 정보를 담을 상태값 */
let component
let element
onMount(() => {
articles.resetArticles()
articles.fetchArticles()
})
$: {
if (component) {
element = component.parentNode
element.addEventListener('scroll', onScroll)
element.addEventListener('resize', onScroll)
}
}
const onScroll = (e) => {
const scrollHeight = e.target.scrollHeight // 스크롤 높이
const clientHeight = e.target.clientHeight // 화면 높이
const scrollTop = e.target.scrollTop // 현재 스크롤 위치
const realHeight = scrollHeight - clientHeight // 실제 스크롤 높이
const triggerHeight = realHeight * 0.7 // 화면 70%에 해당하는 높이(다음 페이지가 호출될 스크롤 위치)
const triggerComputed = () => {
return scrollTop > triggerHeight
}
const scrollTrigger = () => {
return triggerComputed()
}
if (scrollTrigger()) {
currentArticlesPage.increPage()
}
}
</script>
<div class="slog-list-wrap" bind:this={component}>
<ul class="slog-ul">
{#each $articles.articleList as article, index}
<li class="mb-5">
<Article/>
</li>
{/each}
</ul>
</div>
bind:this의 경우 자신의 dom 정보를 상태값 component에 바인딩시키는 것이다.
react 혹은 vue의 ref와 유사하다고 보면 된다.
component의 parentNode의 경우 ArticleList를 자식 컴포넌트로 참조하는 Articles.svelte 컴포넌트의 div.slog-main을 가리킨다.
바로 해당 dom에 scroll이 생기게 되므로 scroll 정보를 얻기 위해서는 현재 컴포넌트의 부모 dom을 변수로 받아야 한다.
<script>
export let article
</script>
<div class="slog-content-box" >
<div class="content-box-header">
<div class="content-box-header-inner-left " >
<p class="p-user" >{article.userEmail}</p>
<p class="p-date" >{article.createdAt}</p>
</div>
<div class="content-box-header-inner-right">
<button class="button-base-circle">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M12 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 12c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></svg>
</button>
<div class="drop-menu-box">
<ul>
<li><button href="#" class="drop-menu-button" >수정</button></li>
<li><button href="#" class="drop-menu-button" >삭제</button></li>
</ul>
</div>
</div>
</div>
<div class="content-box-main">
<p class="whitespace-pre-line">{article.content}</p>
</div>
<div class="content-box-bottom">
<div class="button-box-inner-left">
<button class="flex">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6 mr-1 cursor-pointer" >
<path d="M12 4.595a5.904 5.904 0 0 0-3.996-1.558 5.942 5.942 0 0 0-4.213 1.758c-2.353 2.363-2.352 6.059.002 8.412l7.332 7.332c.17.299.498.492.875.492a.99.99 0 0 0 .792-.409l7.415-7.415c2.354-2.354 2.354-6.049-.002-8.416a5.938 5.938 0 0 0-4.209-1.754A5.906 5.906 0 0 0 12 4.595zm6.791 1.61c1.563 1.571 1.564 4.025.002 5.588L12 18.586l-6.793-6.793c-1.562-1.563-1.561-4.017-.002-5.584.76-.756 1.754-1.172 2.799-1.172s2.035.416 2.789 1.17l.5.5a.999.999 0 0 0 1.414 0l.5-.5c1.512-1.509 4.074-1.505 5.584-.002z"></path>
</svg>
<p class="text-base" >{article.likeCount}</p>
</button>
</div>
<div class="button-box-inner-right ">
<button class="flex">
<p class="text-base">{article.commentCount}</p>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 ml-1" >
<path d="M20 2H4c-1.103 0-2 .897-2 2v18l5.333-4H20c1.103 0 2-.897 2-2V4c0-1.103-.897-2-2-2zm0 14H6.667L4 18V4h16v12z"></path>
</svg>
</button>
</div>
</div>
</div>
function setLoadingArticle() {
const { subscribe, set } = writable(false)
const turnOnLoading = () => {
set(true)
articlePageLock.set(true)
}
const turnOffLoading = () => {
set(false)
articlePageLock.set(false)
}
return {
subscribe,
turnOnLoading,
turnOffLoading
}
}
/* 생략 */
export const articlePageLock = writable(false)
/* 생략 */
loadingArticle의 turnOnLoading과 turnOffLoading 메소드를 article Store의 fetchArticles 메소드에서 호출하도록 적용한다.
function setArticles {
/* 생략 */
const fetchArticles = async () => {
/* 생략 */
loadingArticle.turnOnLoading()
try {
/* 조회 및 update 생략 */
loadingArticle.turnOffLoading()
} catch (error) {
loadingArticle.turnOffLoading()
throw error
}
}
/* 생략 */
}
articles Store의 resetArticles 메소드에도 articlePageLock을 set으로 false값을 할당하여 페이지를 초기화 시킬 때 페이지 잠금이 되어있다면 잠금을 해제하도록 한다.
function setArticles {
/* 생략 */
const resetArticles = () => {
/* 생략 */
articlePageLock.set(false) // 추가
}
}
<script>
import ArticleLoading from "./ArticleLoading.svelte";
import { /* 생략 */ loadingArticle, articlePageLock } from '../stores'
const onScroll = (e) => {
/* 생략 */
const countCheck = () => { // 추가
const check = $articles.totalPageCount <= $currentArticlesPage
return check
}
if(countCheck()) {// 전체 페이지보다 현재 호출된 페이지보다 작거나 같은 경우 조회 잠금
articlePageLock.set(true)
}
const scrollTrigger = () => {
return triggerComputed() && !countCheck() && !$articlePageLock // 조건추가
}
/* 생략 */
}
</script>
<div class="slog-list-wrap" bind:this={component}>
<!-- 생략 -->
{#if $loadingArticle}
<ArticleLoading />
{/if}
</div>
게시글 작성 이전에 먼저, 게시글 등록은 로그인이 되었을 경우에만 가능하므로, 게시글을 입력받을 수 있는 영역이 로그인 여부에 따라 출력되도록 적용해야한다.
게시글 입력 폼 컴포넌트인 ArticleAddForm.svelte 를 출력하는 pages/Articles.svelte 컴포넌트에서 적용한다.
<script>
import ArticleHeader from "../components/ArticleHeader.svelte";
import ArticleList from "../components/ArticleList.svelte";
import ArticleAddForm from "../components/ArticleAddForm.svelte";
import { isLogin } from '../stores'
</script>
<ArticleHeader />
<main class="slog-main">
{#if $isLogin}
<ArticleAddForm />
{/if}
<ArticleList />
</main>
isLogin store를 import한 후 조건문으로 컴포넌트를 렌더링하도록 적용한다.
로그아웃 상태일 경우 ArticleAddForm 영역이 렌더링되지 않고, 로그인 상태일 경우에만 렌더링된다.
function setArticles() {
/* 생략 */
const resetArticles = () => {
/* 생략 */
}
const addArticle = async (content) => {
const access_token = get(auth).Authorization
try {
const options = {
path: "/articles",
data: {
content: content
},
access_token: access_token
}
const newArticles = await postApi(options)
update(datas => {
datas.articleList = [newArticles, ...datas.articleList]
return datas;
})
} catch (error) {
throw error;
}
}
return {
subscribe,
fetchArticles,
resetArticles,
addArticle // 반환값에 프로퍼티 추가
}
}
<script>
import { articles } from '../stores'
let values = {
formContent: ''
}
const onAddArticle = async () => {
try {
await articles.addArticle(values.formContent)
} catch (error) {
}
}
const onCancelAddArticle = () => {
values.formContent = ''
}
</script>
<div class="slog-add-content-box" >
<div class="content-box-header ">
<div class="flex" >
<p>지금 여러분의 생각을 적어주세요.</p>
</div>
</div>
<div class="content-box-main">
<textarea bind:value={values.formContent} id="message" rows="5" class="slog-content-textarea " placeholder="내용을 입력해 주세요."></textarea>
</div>
<div class="content-box-bottom">
<div class="button-box">
<button class="button-base" on:click={onAddArticle}>입력</button>
<button class="button-base" on:click={onCancelAddArticle}>취소</button>
</div>
</div>
</div>
게시글 수정 및 삭제를 만드는 기능은 articles store에서 구현한다.
function setArticles() {
let initValues = {
/* 생략 */
menuPopup: '',
editMode: ''
}
/* 생략 */
}
articles store에는 menuPopup과 editMode가 이미 초기값의 속성으로 구성되어 있다.
menuPopup과 editMode에는 특정 게시글, 즉 하나의 article에 대한 고유 값인 id값이 필요에 따라 저장되거나 공백으로 남게 된다.
function setArticles() {
let initValues = {
/* 생략 */
menuPopup: '',
editMode: ''
}
const openMenuPopup = (id) => {
update(datas => {
datas.menuPopup = id
return datas
})
}
const closeMenuPopup = () => {
update(datas => {
datas.menuPopup = ''
return datas
})
}
return {
/* 생략 */
openMenuPopup,
closeMenuPopup,
}
}
<script>
export let article
import { articles, auth } from '../stores'
let isViewMenu = false // true일 경우 context 버튼 출력
$: {
if ($articles.menuPopup === article.id) {
isViewMenu = true
} else {
isViewMenu = false
}
}
const onToggleMenuPopup = (id) => {
if (isViewMenu === true) {
articles.closeMenuPopup()
return;
}
articles.openMenuPopup(id)
}
</script>
<div class="slog-content-box" >
<div class="content-box-header">
<!-- 생략 -->
{#if article.userId === $auth.id}
<div class="content-box-header-inner-right">
<!-- 생략 -->
<div class="drop-menu-box" class:block={isViewMenu}>
<ul>
<li><button href="#" class="drop-menu-button" >수정</button></li>
<li><button href="#" class="drop-menu-button" >삭제</button></li>
</ul>
</div>
</div>
{/if}
</div>
<!-- 생략 -->
</div>
function setArticles() {
/* 생략 */
const closeMenuPopup = () => {/* 생략 */}
const openEditModeArticle = () => {
articles.closeMenuPopup()
update(datas => {
datas.editMode = id
return datas
})
}
const closeEditModeArticle = () => {
update(datas => {
datas.editMode = ''
return datas
})
}
return {
/* 생략 */
openEditModeArticle,
closeEditModeArticle,
}
}
<script>
export let article
import { articles, auth } from '../stores'
import ArticleEditForm from './ArticleEditForm.svelte' /* 게시글 수정 컴포넌트 import */
/* 생략 */
const onToggleMenuPopup = (id) => {/* 생략 */}
/* onEditModeArticle 메소드 구현 */
const onEditModeArticle = (id) => {
articles.openEditModeArticle(id)
}
</script>
{#if $articles.editMode === article.id} <!-- 조건 블록 및 ArticleEditForm 신규 적용 -->
<ArticleEditForm {article} />
{:else}
<div class="slog-content-box" >
<div class="content-box-header">
<!-- 생략 -->
<div class="content-box-header-inner-right">
<!-- 생략 -->
<div class="drop-menu-box" class:block={isViewMenu}>
<ul>
<li><button href="#" class="drop-menu-button" on:click={onEditModeArticle}>수정</button></li> <!-- onEditModeArticle dom 연동 -->
<li><button href="#" class="drop-menu-button" >삭제</button></li>
</ul>
</div>
</div>
</div>
<!-- 생략 -->
</div>
{/if}
<script>
export let article
import { articles } from '../stores'
let articleValue = {
id: article.id,
userEmail: article.userEmail,
createdAt: article.createdAt,
content: article.content,
}
const closeEditModeArticle = () => {
articles.closeEditModeArticle()
}
</script>
<div class="slog-content-box" >
<div class="content-box-header">
<div class="content-box-header-inner-left" >
<p class="p-user" >{articleValue.userEmail}</p>
<p class="p-date" >{articleValue.createdAt}</p>
</div>
</div>
<div class="content-box-main">
<textarea bind:value={articleValue.content} id="message" rows="5" class="slog-content-textarea " placeholder="내용을 입력해 주세요."></textarea>
</div>
<div class="content-box-bottom">
<div class="button-box">
<button class="button-base">완료</button>
<button class="button-base" on:click={closeEditModeArticle}>취소</button>
</div>
</div>
</div>
function setArticles() {
/* 생략 */
const openEditModeArticle = () => {/* 생략 */}
const updateArticle = async (article) => {
const access_token = get(auth).Authorization
try {
const updateData = {
articleId: article.id,
content: article.content
}
const options = {
path: '/articles',
data: updateData,
access_token: access_token
}
const updateArticle = await putApi(options)
update(datas => {
const newArticleList = datas.articleList.map(article => {
if (article.id === updateArticle.id) {
article = updateArticle
}
return article // 수정된 게시글 id가 수정 대상 id와 일치할 경우 게시글정보를 수정 완료된 정보로 수정
})
datas.articleList = newArticleList
return datas;
})
articles.closeEditModeArticle()
alert('수정이 완료되었습니다.')
} catch (error) {
alert('수정중에 오류가 발생했습니다. 다시 시도해 주세요.')
}
}
return {
/* 생략 */
updateArticle
}
}
<script>
export let article
import { articles } from '../stores'
let articleValue = {/* 생략 */}
const onCoseEditModeArticle = () => {/* 생략 */}
const onUpdateArticle = () => {// 코드 추가
articles.updateArticle(articleValue)
}
</script>
<div class="slog-content-box" >
<!-- 생략 -->
<div class="content-box-bottom">
<div class="button-box">
<button class="button-base" on:click={onUpdateArticle}>완료</button> <!-- 코드 적용 -->
<!-- 생략 -->
</div>
</div>
</div>
function setArticles() {
/* 생략 */
const updateArticle = async (article) => {/* 생략 */}
const deleteArticle = async (id) => {
const access_token = get(auth).Authorization
console.log(access_token)
try {
const options = {
path: `/articles/${id}`,
access_token: access_token
}
await delApi(options)
update(datas => {
const newArticleList = datas.articleList.filter(article => article.id != id)
datas.articleList = newArticleList // 현재 id를 제외한 게시글목록으로 수정
return datas
})
} catch (error) {
}
}
return {
/* 생략 */
deleteArticle
}
}
코멘트의 경우 다른 라우터와 조금 다르게 articles 경로 하위로 배치하게 된다.
url/articles/comments/:id
위와같이 articles 하위에 comments 접속 주소가 호출이 되면 comment 관련 페이지가 출력된다.
그리고 컴포넌트 배치 방식도 상이하다.
<script>
/* 생략 */
import Comments from "./Comments.svelte";
import { Route } from "tinro";
</script>
<ArticleHeader />
<main class="slog-main">
<!-- 생략 -->
<Route path="/comments/:id">
<Comments />
</Route>
</main>
Articles 컴포넌트 하위에 자식 요소로 라우팅 되는 원리이므로 뒤로가기 등을 했을 때 스크롤이 이전 스크롤을 기억하는것처럼 보이는 장점이 있다. 즉, Articles의 기존 출력되던 DOM은 그대로 유지된채 그 위에 렌더링 된다.
게시글 상세보기 안에 comment가 들어온다.
게시글의 정보를 출력하기 위해 article의 정보를 개별 조회 한다.
setArticleContent 함수에 구현한다.
stores/index.js ```js /* 생략 / function setLoadingArticle() {/ 생략 */} function setArticleContent() { // 구현 let initValues = { id:'', userId:'', userEmail:'', content:'', createdAt:'', commentCount:0, likeCount: 0, likeUsers: [] }
const { subscribe, set } = writable({...initValues})
const getArticle = async (id) => {
try {
const options = {
path: /articles/${id}
}
const getData = await getApi(options)
set(getData)
} catch (error) {
alert('오류가 발생했습니다. 다시 시도해 주세요.')
}
}
return { subscribe, getArticle }
} function setComments() {/* 생략 /} / 생략 / export const articleContent = setArticleContent() / 생략 */
### store 컴포넌트 적용
- [CommentList.svelte](indiecoder-slog-svelte3-frontend/src/components/CommentList.svelte)
```svelte
<script>
/* 생략 */
import { onMount } from "svelte";
import { meta, router } from "tinro";
import { articleContent } from "../stores";
const route = meta()
const articleId = Number(route.params.id)
onMount(() => {
articleContent.getArticle(articleId) // store 조회 호출
})
const goArticles = () => router.goto('/articles')
</script>
<div class="slog-comment-wrap">
<div class="slog-comment-box" >
<div class="comment-box-header ">
<div class="content-box-header-inner-left" >
<p class="p-user" >{$articleContent.userEmail}</p>
<p class="p-date" >{$articleContent.createdAt}</p>
</div>
</div>
<div class="comment-box-main ">
<p class="whitespace-pre-line">{$articleContent.content}</p>
<div class="inner-button-box ">
<button class="button-base" on:click={goArticles}>글 목록 보기</button>
</div>
</div>
<!-- 생략 -->
</div>
</div>
<script>
import CommentList from "../components/CommentList.svelte";
</script>
<CommentList />
comment의 CRUD 기능으로는 아래 3개의 기능으로 comments store에 구성한다.
조회 : fetchComments()
추가 : addComment()
삭제 : deleteComment()
/* 생략 */
function setArticleContent() {/* 생략 */}
function setComments() { // 구현
const { subscribe, update, set } = writable([])
const fetchComments = async (id) => {}
const addComment = async (articleId, commentContent) => {}
const deleteComment = async (commentId, articleId) => {}
return {
subscribe,
fetchComments,
addComment,
deleteComment,
}
}
function setAuth() {/* 생략 */}
/* 생략 */
export const articleContent = setArticleContent()
/* 생략 */
/* 생략 */
function setArticleContent() {/* 생략 */}
function setComments() { // 구현
const { subscribe, update, set } = writable([])
const fetchComments = async (id) => {
try {
const options = {
path: `/comments/${id}`
}
const getDatas = await getApi(options)
set(getDatas.comments)
} catch (error) {
alert('오류가 발생했습니다. 다시 시도해 주세요.')
}
}
const addComment = async (articleId, commentContent) => {
const access_token = get(auth).Authorization;
try {
const options = {
path: `/comments`,
data: {
articleId: articleId,
content: commentContent
},
access_token: access_token
}
const newData = await postApi(options)
update(datas => [...datas, newData])
} catch (error) {
alert('오류가 발생했습니다. 다시 시도해 주세요.')
}
}
const deleteComment = async (commentId, articleId) => {
const access_token = get(auth).Authorization;
try {
const options = {
path: `/comments`,
data: {
commentId: commentId,
articleId: articleId,
},
access_token: access_token
}
await delApi(options)
update(datas => datas.filter(comment => comment.id !== commentId))
alert('코멘트가 삭제 되었습니다.')
} catch (error) {
alert('삭제 중 오류가 발생했습니다. 다시 시도해 주세요.')
}
}
return {
subscribe,
fetchComments,
addComment,
deleteComment,
}
}
function setAuth() {/* 생략 */}
/* 생략 */
export const articleContent = setArticleContent()
/* 생략 */
<script>
/* 생략 */
import { /* 생략 */ comments, isLogin } from "../stores";
/* 생략 */
let values = {
formContent: ''
}
onMount(() => {
/* 생략 */
comments.fetchComments(articleId)
})
/* 생략 */
const onAddComment = async () => { // 추가
await comments.addComment(articleId, values.formContent)
}
</script>
<div class="slog-comment-wrap">
<div class="slog-comment-box" >
<!-- 생략 -->
<div class="commnet-list-box ">
<h1 class="comment-title">Comments</h1>
<ul class="my-5">
{#each $comments as comment, index}
<Comment {comment} {articleId}/>
{/each}
</ul>
</div>
{#if $isLogin}
<div class="comment-box-bottom ">
<textarea bind:value={values.formContent} id="message" rows="5" class="slog-content-textarea " placeholder="내용을 입력해 주세요."></textarea>
<div class="button-box-full">
<button class="button-base" on:click={onAddComment}>입력</button>
</div>
</div>
{/if}
</div>
</div>
<script>
import { auth, comments } from "../stores";
export let comment
export let articleId
const onDeleteComment = () => {
if(confirm('삭제하시겠습니까?')) {
comments.deleteComment(comment.id, articleId)
}
}
</script>
<li>
<div class="comment-top ">
<div class="comment-top-left ">
<p class="p-user" >{comment.userEmail}</p>
<p class="p-date-comment" >{comment.createdAt}</p>
</div>
<div class="comment-top-right ">
{#if comment.userId === $auth.id}
<button on:click={onDeleteComment}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-5 w-5" ><path d="M5 20a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8h2V6h-4V4a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v2H3v2h2zM9 4h6v2H9zM8 8h9v12H7V8z"></path><path d="M9 10h2v8H9zm4 0h2v8h-2z"></path></svg>
</button>
{/if}
</div>
</div>
<div class="comment-bottom ">
<p class="whitespace-pre-line">{comment.content}</p>
</div>
</li>
function setCurrentArticlesPage() {/* 생략 */}
function setArticles() { // 구현
/* 생략 */
const deleteArticle = (articleId) => {/* 생략 */}
const increArticleCommentCount = (ㅑㅇ) => { // 추가
update(datas => {
const newArticleList = datas.articleList.map(article => {
if (article.id === articleId) {
article.commentCount = article.commentCount + 1
}
return article
})
datas.articleList = newArticleList
return datas
})
}
const decreArticleCommentCount = (articleId) => { // 추가
update(datas => {
const newArticleList = datas.articleList.map(article => {
if (article.id === articleId) {
article.commentCount = article.commentCount - 1
}
return article
})
datas.articleList = newArticleList
return datas
})
}
return {
/* 생략 */
deleteArticle,
increArticleCommentCount, // 추가
decreArticleCommentCount // 추가
}
}
function setArticleContent() {/* 생략 */}
위 articles store에 구현한 증가,감소 기능을 comments store에서 comment 추가/삭제 기능에 적용한다.
function setComments() {
/* 생략 */
const addComment = async (articleId, commentContent) => {
const access_token = get(auth).Authorization;
try {
/* 생략 */
update(datas => [...datas, newData])
articles.increArticleCommentCount(articleId) // 추가
} catch (error) {
alert('오류가 발생했습니다. 다시 시도해 주세요.')
}
}
const deleteComment = async (commentId, articleId) => {
const access_token = get(auth).Authorization;
try {
/* 생략 */
update(datas => datas.filter(comment => comment.id !== commentId))
articles.increArticleCommentCount(articleId) // 추가
alert('코멘트가 삭제 되었습니다.')
} catch (error) {
alert('삭제 중 오류가 발생했습니다. 다시 시도해 주세요.')
}
}
/* 생략 */
}
만약 하나의 게시글에 눌려진 전체 좋아요 갯수라면 로그인 정보가 필요하지 않겠으나, 하나의 게시글에 한명의 사용자가 좋아요 기능을 작동시키기 때문에 좋아요의 모든 기능에는 로그인 된 사용자 정보인 토큰 정보가 필요하다.
setArticles 함수내에 좋아요, 좋아요 취소 기능 함수를 구현한다.
api를 통해 게시글에 해당하는 전체 좋아요 갯수와 로그인한 사용자를 기준으로 좋아요 여부를 db에 저장한다.
api 호출 종료 후 재조회 하지 않고 현재 반영한 좋아요 여부와 좋아요 갯수에 대한 데이터를 실제 사용중인 상태값에 update하여 메모리 수정을 통해 재조회 하지 않고 성능 이점을 얻도록 구현한다.
function setArticles() {
/* 생략 */
const decreArticleCommentCount = (articleId) => {/* 생략 */}
const likeArticle = async (articleId) => {
const access_token = get(auth).Authorization
try {
const options = {
path: `/likes/add/${articleId}`,
access_token: access_token
}
await postApi(options)
update(datas => {
const newArticles = datas.articleList.map(article => {
if (article.id === articleId) { // 좋아요 갯수 증가 및 사용자 좋아요 여부 수정
article.likeCount = article.likeCount + 1
article.likeMe = true
}
return article
})
datas.articleList = newArticles
return datas;
})
} catch (error) {
alert('오류가 발생했습니다. 다시 시도해 주세요.')
}
}
return {
/* 생략 */
decreArticleCommentCount,
likeArticle
}
}
function setArticles() {
/* 생략 */
const likeArticle = (articleId) => {/* 생략 */}
const cancelLikeArticle = async (articleId) => {
const access_token = get(auth).Authorization
try {
const options = {
path: `/likes/cancel/${articleId}`,
access_token: access_token
}
await postApi(options)
update(datas => {
const newArticles = datas.articleList.map(article => {
if (article.id === articleId) { // 좋아요 갯수 증가 및 사용자 좋아요 여부 수정
article.likeCount = article.likeCount - 1
article.likeMe = false
}
return article
})
datas.articleList = newArticles
return datas;
})
} catch (error) {
alert('오류가 발생했습니다. 다시 시도해 주세요.')
}
}
return {
/* 생략 */
likeArticle,
cancelLikeArticle
}
}
<script>
/* 생략 */
import { /* 생략 */ isLogin } from '../stores'
/* 생략 */
const goComment = (id) => {/* 생략 */}
const onLike = (id) => { // 추가
if ($isLogin) {
articles.likeArticle(id)
}
}
const onCancelLike = (id) => {// 추가
if ($isLogin) {
articles.cancelLikeArticle(id)
}
}
</script>
<!-- 생략 -->
<div class="slog-content-box" >
<!-- 생략 -->
<div class="content-box-bottom">
<div class="button-box-inner-left">
{#if article.likeMe} <!-- 추가 -->
<button class="flex" on:click={() => onCancelLike(article.id)/* 연동 */}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6 mr-1 cursor-pointer" >
<path d="M20.205 4.791a5.938 5.938 0 0 0-4.209-1.754A5.906 5.906 0 0 0 12 4.595a5.904 5.904 0 0 0-3.996-1.558 5.942 5.942 0 0 0-4.213 1.758c-2.353 2.363-2.352 6.059.002 8.412L12 21.414l8.207-8.207c2.354-2.353 2.355-6.049-.002-8.416z"></path>
</svg>
<p class="text-base" >{article.likeCount}</p>
</button>
{:else} <!-- 추가 -->
<button class="flex" on:click={() => onLike(article.id) /* 연동 */}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6 mr-1 cursor-pointer" >
<path d="M12 4.595a5.904 5.904 0 0 0-3.996-1.558 5.942 5.942 0 0 0-4.213 1.758c-2.353 2.363-2.352 6.059.002 8.412l7.332 7.332c.17.299.498.492.875.492a.99.99 0 0 0 .792-.409l7.415-7.415c2.354-2.354 2.354-6.049-.002-8.416a5.938 5.938 0 0 0-4.209-1.754A5.906 5.906 0 0 0 12 4.595zm6.791 1.61c1.563 1.571 1.564 4.025.002 5.588L12 18.586l-6.793-6.793c-1.562-1.563-1.561-4.017-.002-5.584.76-.756 1.754-1.172 2.799-1.172s2.035.416 2.789 1.17l.5.5a.999.999 0 0 0 1.414 0l.5-.5c1.512-1.509 4.074-1.505 5.584-.002z"></path>
</svg>
<p class="text-base" >{article.likeCount}</p>
</button>
{/if}
</div>
<!-- 생략 -->
</div>
</div>
현재 서비스는 모든 게시글을 보는 기능인 모두보기만 헤더에 활성화 되어 있다.
모두 보기 우측으로 좋아요 보기, 내글 보기 메뉴는 존재하지만 구현되어있지 않다.
let path = `/articles/?pageNumber=${currentPage}`
const options = { path:path }
현재 url은 위와 같이 path가 고정되어 있다.
보기 모드에 따라 전체 글, 내 글, 좋아요 글 같이 목록이 달라지게 하기 위해서는 조건문으로 분기하여 path를 변경핸다.
이를 위해서 store를 수정해야 한다.
store를 수정하기 전 해당 옵션에 대한 상수값을 만든다.
일정한 규칙으로 동일하게 코딩되어야 하는데 해당 값을 매번 수동으로 입력하다 보면 오류가 발생할 수 있어 이를 방지하고 어떤 옵션이 있는지 쉽게 찾을 수 있다.
ALL: 전체 글 목록MY: 내 글 목록LIKE: 좋아요 글 목록설치경로
├─ node_modules
├─ public
├─ scrtips
├─ src
│ ├─ components
│ ├─ pages
│ ├─ service
│ ├─ stores
│ ├─ styles
│ ├─ utils // 디렉토리 생성(하위 포함)
│ │ └─ constant.js
│ ├─ App.svelte
│ └─ main.js
│ └─ router.svelte
├─ index.html
├─ package.json
└─ rollup.config.js
export const ALL = 'all'
export const LIKE = 'like'
export const MY = 'my'
/* 생략 */
import { router } from 'tinro'
import {ALL, LIKE, MY } from '../utils/constant'
function setCurrentArticlesPage() {/* 생략 */}
/* 생략 */
mode를 변경하는 setArticlesMode 함수를 구현한다.
변경된 mode를 기준으로 조건 분기처리한 url을 통해 게시물을 조회를 하게 된다.
/* 생략 */
import { router } from 'tinro'
import {ALL, LIKE, MY } from '../utils/constant'
/* 생략 */
function setAuth() {/* 생략 */}
function setArticlesMode() {
const { subscribe, update, set } = writable(ALL)
const changeMode = async (mode) => {
set(mode)
articles.resetArticles()
await articles.fetchArticles()
}
return {
subscribe,
changeMode
}
}
function setIsLogin() {/* 생략 */}
export const auth = setAuth()
export const articlesMode = setArticlesMode()
export const isLogin = setIsLogin()
setArticlesMode 함수를 구현한다.
/* 생략 */
import { router } from 'tinro'
import {ALL, LIKE, MY } from '../utils/constant'
/* 생략 */
function setCurrentArticlesPage() {/* 생략 */}
function setArticles() { // 구현
/* 생략 */
const fetchArticles = async () => {
const currentPage = get(currentArticlesPage)
// let path = `/articles/?pageNumber=${currentPage}`
let path = '';
const mode = get(articlesMode)
switch(mode) {
case ALL:
path = `/articles/?pageNumber=${currentPage}`
break
case LIKE:
path = `/likes/?pageNumber=${currentPage}`
break
case MY:
path = `/articles/?pageNumber=${currentPage}&mode=${mode}`
break
}
/* 생략 */
}
/* 생략 */
}
function setArticleContent() {/* 생략 */}
<script>
/* 생략 */
import { /* 생략 */
isLogin, articlesMode } from '../stores'
import { ALL, LIKE, MY } from '../utils/constant';
/* 생략 */
const onChangeMode = (mode) => {
if ($articlesMode !== mode) articlesMode.changeMode(mode)
}
</script>
<header class="main-header">
<p class="p-main-title" >SLogs</p>
<nav class="main-nav">
<button class="main-menu mr-6" class:main-menu-selected={$articlesMode === ALL} on:click={() => onChangeMode(ALL)} >모두 보기</button>
{#if $isLogin}
<button class="main-menu mr-6" class:main-menu-selected={$articlesMode === LIKE} on:click={() => onChangeMode(LIKE)}>좋아요 보기</button>
<button class="main-menu " class:main-menu-selected={$articlesMode === MY} on:click={() => onChangeMode(MY)}>내글 보기</button>
{:else}
<button class="main-menu mr-6">좋아요 보기</button>
<button class="main-menu main-menu-blocked" >내글 보기</button>
{/if}
</nav>
<!-- 생략 -->
</header>
function setComments() {/* 생략 */}
function setAuth() {
/* 생략 */
const login = async (email, password) => {/* 생략 */}
const logout = async () => {
try {
/* 생략 */
isRefresh.set(false) // refresh 호출여부 off
articlesMode.changeMode(ALL) // 보기모드 변경
} catch (error) {
alert('오류가 발생했습니다. 다시 시도해 주세요.')
}
}
/* 생략 */
return {
subscribe,
refresh,
login,
logout,
resetUserInfo,
register
}
}
function setArticlesMode() {/* 생략 */}
현재 로그인 혹은 게시글을 작성할 때 공백이거나 형식에 맞지 않는 내용을 입력해도 바로 서버에 그 값을 전송하게 된다.
아래 AuthLogin.svelte에서 로그인 코드를 보면 그냥 전송하는것을 확인할 수 있다.
<script>
import { auth } from '../stores'
/** 입력값과 연결할 상태값 */
let values = {
formEmail: '',
formPassword: ''
}
/** values 초기화 메소드 */
const resetValues = () => {
values.formEmail = '';
values.formPassword = ''
}
/** 로그인 요청 메소드 */
const onLogin = async () => {
try {
await auth.login(values.formEmail, values.formPassword)
resetValues();
} catch (error) {
alert('인증이 되지 않았습니다. 다시 시도해주세요.')
}
}
</script>
물론 비정상적인 값을 서버에 보내면 서버가 그에 맞는 오류를 리턴하지만 매번 비정상적인 값을 서버에 보내는 것은 자원 낭비이며 보안적인 이슈가 될 수 있다.
이런경우 필요한 기능이 바로 form validation 즉, 폼 검증이다.
폼 검증을 하기 위해 필요한 매우 범용적인 라이브러리이다.
svelte가 아니더라도 react나 vue 혹은 nodejs기반의 백엔드 서버에서도 사용할 수 있는 라이브러리이다.
npm install yup
let formName =
yup.object().shape({
formName: yup.string() // 검증 요소
.required('이메일을 입력해주세요.')
.email('이메일 형식이 잘 못 되었습니다.')
.label('이메일')
})
먼저 yup의 object() 메소드를 호출한 뒤 이어 체이닝을 통해 shape() 메소드를 호출하며 해당 메소드 내에 객체 형태로 검증 요소를 전달한다.
검증 요소를 자세히 분석해보면, formName이라는 값을 검증하며 체이닝 형태로 string()을 통해 string타입 여부를 검증하고 required()를 이용하여 null을 불허한다.
또한 email()을 통해 이메일 형식에 맞는지 검증을 거치며, 모두에 해당하지 않을 경우 오류를 발생시키고 각 메소드에 전달한 문자열을 오류 메시지로 출력한다.
여기서 label()은 에러 메시지에서 해당 값을 부를 때 사용하는 이름표이다.
만약 yup.string().required()와 같이 메시지를 지정하지 않은 기본 형태의 required 검증에서 에러가 난다면 this is required 라는 메시지가 출력되지만, yup.string().required().label('이메일') 일때 출력되는 메시지는 이메일 is a required field 즉, this 자리에 label이 들어가게 된다.
설치경로
├─ node_modules
├─ public
├─ scrtips
├─ src
│ ├─ components
│ ├─ pages
│ ├─ service
│ ├─ stores
│ ├─ styles
│ ├─ utils
│ │ ├─ validates.js // 추가
│ │ └─ constant.js
│ ├─ App.svelte
│ └─ main.js
│ └─ router.svelte
├─ index.html
├─ package.json
└─ rollup.config.js
게시글(코멘트), 로그인, 회원가입에 대한 각각의 검증 스키마들을 구현하고, form마다 발생되는 오류를 reduce를 통해 한번에 수집하는 extractErrors라는 유틸 함수를 같이 구현한다.
import * as yup from 'yup'
export const contentValidate = yup.object().shape({
formContent: yup.string().required('내용을 입력해 주세요.').label('내용')
})
export const loginValidate = yup.object().shape({
formEmail: yup.string().required('이메일을 입력해 주세요.').email('이메일 형식이 잘 못 되었습니다.').label('이메일'),
formPassword: yup.string().required('패스워드를 입력해 주세요.').label('패스워드')
})
export const registerValidate = yup.object().shape({
formEmail: yup.string().required('이메일을 입력해 주세요.').email('이메일 형식이 잘 못 되었습니다.'),
formPassword: yup.string().required('패스워드를 입력해 주세요.'),
formPasswordConfirm: yup.string().required('패스워드확인을 입력해 주세요.')
.oneOf([yup.ref('formPassword'), null], '패스워드와 패스워드 확인이 일치하지 않습니다.')
.label('패스워드 확인')
})
export const extractErrors = error => { //
return error.inner.reduce((acc, error) => {
return {...acc, [error.path]: error.message}
}, {})
}
회원가입을 대표 예시로 둔다.
<script>
import { auth } from '../stores'
import { registerValidate, extractErrors } from '../utils/validates';
let errors = {}
let values = {
formEmail: '',
formPassword: '',
formPasswordConfirm: '',
}
const onRegister = async () => {
try {
await registerValidate.validate(values, {abortEarly: false /* 오류 벌크/개별 처리 여부 - false=모든form검증 및 오류 발생 */})
await auth.register(values.formEmail, values.formPassword)
} catch (error) {
alert('회원가입에 실패했습니다. 다시 시도해 주세요.')
errors = extractErrors(error)
if (errors.formEmail) alert (errors.formEmail)
if (errors.formPassword) alert (errors.formPassword)
if (errors.formPasswordConfirm) alert (errors.formPasswordConfirm)
}
}
</script>
<div class="auth-content-box" >
<div class="auth-box-main">
<div class="auth-input-box">
<input type="email" name="floating_email" id="floating_email" class="auth-input-text peer" placeholder=" " bind:value={values.formEmail} class:wrong={errors.formEmail}/>
<label for="floating_email" class="auth-input-label">이메일</label>
</div>
<div class="auth-input-box">
<input type="password" name="floating_email" id="floating_email" class="auth-input-text peer" placeholder=" " bind:value={values.formPassword} class:wrong={errors.formPassword}/>
<label for="floating_email" class="auth-input-label">비밀번호</label>
</div>
<div class="auth-input-box">
<input type="password" name="floating_email" id="floating_email" class="auth-input-text peer" placeholder=" " bind:value={values.formPasswordConfirm} class:wrong={errors.formPasswordConfirm}/>
<label for="floating_email" class="auth-input-label">비밀번호 확인</label>
</div>
</div>
<div class="content-box-bottom">
<div class="button-box">
<button class="button-base" on:click={onRegister}>회원가입</button>
</div>
</div>
</div>
<style>
.wrong {
border-bottom: 3px solid red;
}
</style>
위와 같이 실제 api를 호출하기 전에 먼저 validate 해준 뒤 catch블록에서 extractErrors를 통해 폼 검증 중 발생한 모든 오류를 수집하여 경고창을 출력해준다.
(참고로 yup의 validate는 스키마로 등록된 모든 검증에 대해 배열 형태로 수집하여 하나의 error 객체를 catch로 던진다.)
이외의 다른 컴포넌트 (ArticleAddForm.svelte ,AuthLogin.svelte , CommentList.svelte 등) 에서도 동일한 패턴으로 적용한다.
현재 서비스에서 작성일자는 작성된 전체 날짜 및 시간을 출력해준다.
전체 날짜를 보여주는것도 좋지만, 2일전 혹은 1시간 전 등으로 표시한다면 좀 더 직관적으로 사용자가 인식할 수 있을것이다.
dayjs가 바로 이런 기능을 지원해준다.
npm install dayjs
설치경로
├─ node_modules
├─ public
├─ scrtips
├─ src
│ ├─ components
│ ├─ pages
│ ├─ service
│ ├─ stores
│ ├─ styles
│ ├─ utils
│ │ ├─ validates.js
│ │ ├─ date.js // 추가
│ │ └─ constant.js
│ ├─ App.svelte
│ └─ main.js
│ └─ router.svelte
├─ index.html
├─ package.json
└─ rollup.config.js
직관적으로 얼마만큼의 시간이 흘렀는지 출력하기 위해서는 단순히 dayjs만 사용하는것이 아닌 dayjs 내 relativeTime이라는 플러그인을 함께 사용해아 한다.
또한 utc 플러그인도 함께 적용해야 하며, DB 등에 저장되어 활용되는 데이터가 UTC 형태일 경우 적용하면 된다.
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"
import utc from "dayjs/plugin/utc"
import "dayjs/locale/ko"
function dateView(date) {
dayjs.extend(utc)
dayjs.locale('ko')
dayjs.extend(relativeTime)
return dayjs().to(dayjs(date).utc().format('YYYY-MM-DD HH:mm:ss'))
}
export default dateView
아래는 dateView를 컴포넌트에 적용한 간단한 예제이다.
<script>
import dateView from './utils/date.js';
let time = '2026-01-26T06:30:00Z'
</script>
<p>{dateView(time)}</p>
Article.svelte ,Comment.svelte , [CommentList.svelte] 에서 날짜 영역에 동일한 패턴으로 적용한다.
현재 보기모드는 아래와같이 MODE를 변경하여 조건부로 api 주소를 변경하여 리스트의 데이터를 출력하는 형태이다.
이 경우 api가 아닌 브라우저 url을 다시 붙여넣거나 새로고침을 할 경우 상태값이 모두 초기화되어 모두보기로 돌아가게된다.
MODE값 자체를 store에서 기억해두면 상관없지만, url 자체에서 제어되도록 할수 있다.
<script>
/* 생략 */
import { ALL, LIKE, MY } from '../utils/constant';
/* 생략 */
const onChangeMode = (mode) => {
if ($articlesMode !== mode) articlesMode.changeMode(mode)
}
</script>
<!-- 생략 -->
<button class="main-menu mr-6" class:main-menu-selected={$articlesMode === ALL} on:click={() => onChangeMode(ALL)} >모두 보기</button>
<button class="main-menu mr-6" class:main-menu-selected={$articlesMode === LIKE} on:click={() => onChangeMode(LIKE)}>좋아요 보기</button>
<button class="main-menu " class:main-menu-selected={$articlesMode === MY} on:click={() => onChangeMode(MY)}>내글 보기</button>
<!-- 생략 -->
이를 위해서는 라우터 구성을 수정 해야 한다.
AS-IS
<script>
import { Route } from 'tinro'
import Articles from './pages/Articles.svelte';
import Login from './pages/Login.svelte';
import Register from './pages/Register.svelte';
import NotFound from './pages/notFound.svelte';
</script>
<Route path="/" redirect="/articles/all" />
<Route path="/articles/*" ><Articles/></Route>
<Route path="/login" ><Login/></Route>
<Route path="/register" ><Register/></Route>
<Route fallback ><NotFound/></Route>
TO-BE
<script>
import { Route } from 'tinro'
import { isLogin } from ''
import Articles from './pages/Articles.svelte';
/* 생략 */
</script>
<!-- 생략 -->
<Route path="/articles/*" >
<Route path="/all/*"><Articles/></Route>
{#if $isLogin}
<Route path="/my/*"><Articles/></Route>
<Route path="/like/*"><Articles/></Route>
{:else}
<Route path="/my/*" redirect="/articles/all"/>
<Route path="/like/*" redirect="/articles/all"><Articles/></Route>
{/if}
</Route>
<!-- 생략 -->
articles/*에 해당하는 route의 하위로 기존 Articles 컴포넌트 대신 Route를 한번 더 중첩으로 구성해준다.
중첩 구성을 함으로써 all, my, like url이 들어오도록 구성한다.
해당 url은 모두 Articles 컴포넌트를 출력하도록 태그 사이에 구성한다.
로그인 했을경우 isLogin store를 통해 조건블록으로 내용을 출력하도록 하고
로그인이 아닌 경우에는 모두보기에 해당하는 페이지로 redirect 되도록 구성한다.
다음으로는 ArticleList.svelte 컴포넌트에서 url의 mode 값을 받아 artilceModeStore의 changeMode 메소드에 해당 값을 전달하도록 구현한다.
<script>
/* 생략 */
import { /* 생략 */, articlesMode } from '../stores'
import { router } from 'tinro'
/* 생략 */
let currentMode = $router.path.split("/")[2]
onMount(() => {
// articles.resetArticles()
// articles.fetchArticles()
articlesMode.changeMode(currentMode) // 코드 추가
})
/* 생략 */
</script>
<!-- 생략 -->
ArticleHeader.svelte 컴포넌트에서는 헤더의 메뉴를 변경했을 때 기존 모드 변경 대신 router.go()를 활용하여 선택된 route URL로 이동하도록 수정한다.
<script>
/* 생략 */
import { /* 생략 */, articlesMode } from '../stores'
import { ALL, LIKE, MY } from '../utils/constant';
/* 생략 */
const onChangeMode = (mode) => {
// if ($articlesMode !== mode) articlesMode.changeMode(mode)
if ($articlesMode !== mode) router.goto(`/articles/${mode}`) // 코드 추가
}
</script>
Article컴포넌트에서 Comment로 이동하는 기능을 URL에 담긴 현재 선택된 모드 정보 기반으로 수정한다.
<script>
/* 생략 */
let currentMode = $router.path.split("/")[2]
/* 생략 */
const goComment = (id) => {
// router.goto(`/articles/comments/${id}`)
router.goto(`/articles/${currentMode}/comments/${id}`)
}
</script>
CommentList 컴포넌트에서 Article 목록으로 이동하는 기능을 URL에 담긴 현재 선택된 모드 정보 기반으로 수정한다.
<script>
/* 생략 */
let currentMode = $router.path.split("/")[2]
/* 생략 */
onMount(() => {/* 생략 */})
// const goArticles = () => router.goto('/articles')
const goArticles = () => router.goto(`/articles/${currentMode}`)
</script>
스크롤을 내렸을 경우 페이징이 정상적으로 작동하지만, 게시글을 새롭게 작성한 후 곧바로 스크롤을 내릴 경우 마지막 게시글이 중복되어 출력된다.
이유는 기존 게시글의 가장 마지막 게시글을 기준으로 이전 게시글 10개를 조회해 오는데, 게시글을 작성한 후 다음 10개를 조회하게되면 페이지가 1개 추가된 상태에서 두번째 10개에 해당하는 데이터를 불러오기 때문이다.
예를들어 1페이지는 50번부터 41번까지 10개의 게시글이, 2페이지는 40번부터 31번까지 10개의 게시글로 조회된다.
50 49 48 47 46 45 44 43 42 41
40 39 38 37 36 35 34 33 32 31
만약 스크롤을 내리지 않고 게시글을 추가하게되면 가장 마지막 데이터는 51이 된다.
이때 스크롤을 내리면 2페이지는 41부터 시작하게 된다.
마지막 게시글이 51이므로 1페이지의 10개에 해당하는 목록 기준을 서버는 51~42로 인식하기 때문이다.
51 50 49 48 47 46 45 44 43 42 41
41 39 38 37 36 35 34 33 32 31
그래서 41번이라는 중복 게시글이 발생하게 된다.
setArticles store의 fetchArticles 힘수 내 update 로직을 수정한다.
기존 newArticles에 filter를 적용하여 조건을 만족하는 index를 모두 필터링한다.
비교할 index는 filter 함수의 세번째 인자인 원본 배열. 즉, newArticles에서 findIndex를 활용하여 id가 일치하는 첫번째 값에 해당하는 index를 반환받는다.
이렇게 필터링 된 배열을 datas의 articleList에 초기화해준다.
update(datas => {
if (currentPage == 1) {
/* 생략 */
} else {
const newArticles = [...datas.articleList, ...newData.articleList]
datas.articleList = newArticles
/* 생략 */
}
return datas
})
update(datas => {
if (currentPage == 1) {
/* 생략 */
} else {
const newArticles = [...datas.articleList, ...newData.articleList]
const uniqueArr = newArticles.filter((arr, index, callback) => index === callback.findIndex(t => t.id === arr.id))
/* 생략 */
}
return datas
})