최근에 작업하는 토이 프로젝트의 프론트엔드를 바닐라로 개발하다가 심각하게 복잡해지기 시작했다. 비교적 단순한 것임에도 불구하고 말이다. UI의 업데이트가 필요한 부분은 개발자가 가장 잘 안다는 생각 하나로 리액트보다 효율적인 UI 관리를 해보겠다는 생각이었다.
괜찮은 구조를 만들 수 있으리라 기대했는데...
UI를 관리하기 위한 로직이 점차 복잡해졌고, 상태로만 관리하자니 그에 따른 렌더링 구조를 만들지 않았기에 효율과는 거리가 멀어 보였다. 결국은 리액트를 써야하나 싶다가 새로운 기술을 사용해 볼 목적으로 Svelte를 사용해 보았다. 처음에는 마냥 쉽고 재밌었는데 복잡한 상태를 관리하는 부분에서 난항을 겪기도 하였다. 그에 대해서 알아낸 것들을 적어두려 한다.
1. 컴포넌트 상태
위에서 말했다. 'UI의 업데이트가 필요한 부분은 개발자가 가장 잘 안다' 아니었다. Svelte가 더 잘 알아준다. Svelte는 let으로 선언한 변수에 대해서 변화가 생기면 변경이 필요한 부분에 대해서 UI를 다시 렌더링 해준다. 물론 함수형 프로그래밍이 유행하는 현 시점에서 상태를 직접 변경한다는 것은 큰 이점은 아닐 수 있겠지만 매우 직관적이고 단순한 형태라고 생각되었다.
<script>
let count = 0;
function increment() {
count += 1;
}
</script>
<main>
<h1>Count: {count}</h1>
<button on:click={increment}>Increment</button>
</main>
단순히 count
라는 변수를 선언하고 변경했을 뿐이지만 Svelte는 멋지게 렌더한다. 또한 해당 변수를 export 하면 부모 컴포넌트에서 props 형태로 전달해 줄 수 있는 상태가 된다.
2. 리액티브 선언
<script>
let count = 0;
$: doubleCount = count * 2
$: {
console.log("count updated!")
}
function increment() {
count += 1;
}
</script>
<main>
<h1>Count: {count}</h1>
<h1>Doucle Count: {doubleCount}</h1>
<button on:click={increment}>Increment</button>
</main>
직관적이었던 1과는 달리 이 문법은 처음에는 다소 난해해다는 생각이 들었다. $:
안에 나열된 상태나 로직은 상태가 업데이트되는 즉시 실행된다. Vue의 computed
와 유사하게 동작하는 개념이다. 특히 두번째에 나열된 리액티브 선언의 경우에는 아무런 상태와도 연관되어 있지 않기 때문에 초기에만 실행되며 다른 상태가 변경되어도 동작하지 않는다.
3. 스토어 (writable)
전역 스토어를 만들기 위해 다른 파일(./count.ts
)에 아래와 같이 선언했다.
import { writable } from 'svelte/store';
export const count = writable(0);
컴포넌트에서 해당 값을 불러온다.
<script>
import { count } from "./count";
$: doubleCount = $count * 2;
function increment() {
$count += 1;
}
</script>
<main>
<h1>Count: {$count}</h1>
<h1>Doucle Count: {doubleCount}</h1>
<button on:click={increment}>Increment</button>
</main>
스벨트에서 제공하는 스토어에 writable
라는 함수를 이용해서 상태를 선언하고 외부에서 이 값을 사용할때는 $count
와 같이 접근하여 값을 출력할 수 있다. 이는 스벨트에서 내부적으로 관리되는 값이기 때문에 해당 문법을 사용하는 것으로 보인다.
해당 값은 위처럼 직접 변경할 수도 있고 혹은 아래와 같이 update 함수를 사용해도 된다
function increment() {
count.update((n) => n + 1);
}
다른 컴포넌트에서도 해당 상태에 접근하면 당연히 동일한 값을 가진다.
4. 스토어 구독 스토어 (derived)
derived는 상당히 다용도로 활용할 수 있는 스토어로 보이는데, 나는 다음과 같은 상황에서 필요했다. 하나의 배열에 동일한 형태이지만 독립적인 상태들이 존재하고 각 상태의 메소드에 의해 수정된 상태를 UI에 반영하고 싶었다. 예를들어 다음과 같은 상황이다.
<script lang="ts">
import type { Writable } from "svelte/store";
import { writable } from "svelte/store";
import { onMount } from "svelte";
interface Item {
title: string;
delete: () => void;
}
function itemState(item: Item) {
const store = writable(item);
const methods = {
delete() {
store.update((item) => ({
...item,
title: "deleted",
}));
}
};
return {
...store,
...methods,
};
}
let items: Writable<Item>[] = [];
onMount(() => {
getItems().then(({ data }) => {
items = data.allItems.map(itemState);
});
});
</script>
여기까진 좋았는데 템플릿에서의 접근할 방도가 없었다.
시작되는 삽질
<main>
{#each items as item}
<p>{item.title}</p>
<button on:click={() => item.delete()}>삭제</button>
{/each}
<main>
Property 'title' does not exist on type 'Writable<Item>'
<main>
{#each items as item}
<p>{$item.title}</p>
<button on:click={() => $item.delete()}>삭제</button>
{/each}
<main>
Cannot find name '$item'. Did you mean 'item'?
그러다 derived의 존재를 알게 되었는데, derived는 상태거나 상태를 가진 배열인 경우에 구독을 진행할 수 있다. 구독한 상태의 변화에 대응해서 값을 생성할 수 있는데, 첫번째 인자에는 구독하는 상태, 두번째 인자에는 생성되는 값을 나열한다.
<script lang="ts">
...
import { writable, derived, get } from "svelte/store";
...
$: resolveItems = derived(items, () =>
array.map((item) => ({
...get(item),
...item,
}))
);
</script>
<main>
{#each $resolveItems as item}
<p>{item.title}</p>
<button on:click={() => item.delete()}>삭제</button>
{/each}
</main>
처음 사용해 본 스벨트. 첫 인상으로는 쉽고, 재밌고, 번들 사이즈도 작고 아직까진 매우 훌륭한 프레임워크 라고 생각된다. 앞으로도 바닐라로 작업할 일(?)이 있다면 가능한 스벨트를 선택하게 될 것 같다.
Ghost