React 를 Svelte 로 변환하기 - 1일차
포스트
취소

React 를 Svelte 로 변환하기 - 1일차

흔히 찾을 수 있는 react 기반 프로젝트들을 svelte 로 바꿔서 작성하는 연습을 합니다. 클론 코딩을 통해 svelte 개발 능력을 높이고자 합니다.

0. 개요

  • Bun 1.0.11 + SvelteKit 1.20.4
  • TailwindCSS 3.3.5
    • daisyUI 3.9.4
    • theme-change
  • Etc
    • heroicons
    • purgecss

1. 프로젝트 생성

SvelteKit 프로젝트 생성

1
2
3
4
5
6
7
8
bun create svelte@latest bun-tailwind-app
  # - Skeleton project
  # - Javascript with JSDoc

cd bun-tailwind-app
bun install

bun run dev

TailwindCSS 및 daisyUI 설정

코드는 이전에 작성한 포스트 참조

  1. TailwindCSS, tailwind-merge 설치
  2. 한글 폰트, daisyUI 라이브러리 설치
  3. tailwind.config.js 에 daisyUI 설정 추가
  4. app.postcss 에 Tailwind directives 추가
  5. 최상위 +layout.svelte 에 전역 css 추가
  6. +page.svelte 에 데모 코드를 넣어 daisyUI 작동 확인

2. React 를 Svelte 로 바꾸는 방법

참고1: JoyOfCode - Svelte Guide For React Developers

HTML 과 style 을 분리한다.

  • style 은 style 블록으로 분리하여 보내고
  • 순수한 HTML 이 남도록 표준 tag 로 변경
    • className 은 class 로 변경
    • HtmlFor 등은 for 로 변경

useState(초기값) 는 script 블록의 let 변수로 옮겨 적는다.

  • 외부 props 와 연결된 변수에는 export 를 붙여 노출한다.
  • 이벤트와 연결된 update 는 일반 function 으로 만들고
  • 연쇄적인 reactive 가 필요한 경우 $: 블럭으로 추가 기술한다.

original : React

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useState } from 'react'

export function Counter(props) {
  const [count, setCount] = useState(props.count ?? 0)

  return (
    <>
      <p style={_{ fontWeight: 700 }_}>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        Click
      </button>
    </>
  )
}

revision : Svelte

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- CounterWithProps.svelte -->
<script>
  export let count = 0
</script>

<p>{count}</p>
<button on:click={() => (count += 1)}>Click</button>

<style lang="scss">
  p {
    font-weight: 700;
  }
</style>

<!-- 
  // 사용 예시
  <CounterWithProps count={10} /> // count is 10
  <CounterWithProps />  // count is 0
-->  

전달되는 child 컴포넌트는 slot 으로 처리

  • 스타일 변수는 style:{css변수}={값} 를 통해 전달

original : React

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useState } from 'react'

export function Grid({ children, columns }) {
  return (
    <div
      style={_{
        display: 'grid',
        gridTemplateColumns: `repeat(${columns}, 1fr)`
      }_}
    >
      {children}
    </div>
  )
}

/**
  // 사용 예시
  <Grid columns={4}>
    <div>Column 1</div>
    <div>Column 2</div>
    <div>Column 3</div>
    <div>Column 4</div>
  </Grid>
 **/

revision : Svelte

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
  export let columns
</script>

<div class="grid" style:--columns={columns}>
  <slot />
</div>

<style>
  .grid {
    --columns: 0;

    display: grid;
    grid-template-columns: repeat(var(--columns), 1fr);
  }
</style>

Array.map 로직은 {#each}..{/each} 블록으로 변환

  • key 는 each 블록의 괄호로 처리 (별도의 {#key}..{/key} 도 있긴함)
  • 조건부 class 또는 style 은 class:{조건} 을 이용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const todos = [
  { id: 1, text: 'Todo 1', completed: true },
  { id: 2, text: 'Todo 2', completed: false },
  { id: 3, text: 'Todo 3', completed: false },
  { id: 4, text: 'Todo 4', completed: false }
]

export function Todos() {
  return (
    <ul>
      {todos.map((todo) => (
        <li
          key={todo.id}
          style={_{
            textDecoration: todo.completed
              ? 'line-through'
              : ''
          }_}
        >
          {todo.completed ? `${todo.text} 🎉` : todo.text}
        </li>
      ))}
    </ul>
  )
}

revision : Svelte

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script>
  const todos = [
    { id: 1, text: 'Todo 1', completed: true },
    { id: 2, text: 'Todo 2', completed: false },
    { id: 3, text: 'Todo 3', completed: false },
    { id: 4, text: 'Todo 4', completed: false }
  ]
</script>

<ul>
  {#each todos as todo (todo.id)}
    <li class:completed={todo.completed}>
      {todo.completed ? `${todo.text} 🎉` : todo.text}
    </li>
  {/each}
</ul>

<style>
  .completed {
    text-decoration: line-through;
  }
</style>

이벤트는 함수로 만들어 연결하거나 직접 binding

  • 단순한 값 저장의 경우는 bind:{속성}={변수} 를 이용
  • 이벤트 연결은 on:{이벤트}={함수} 형태로 처리

동작 연결 및 동기화

  • $: 은 상태를 추적하는 useEffect 를 의미한다. (대부분 커버 가능)
  • useEffect (연결 동작)는 script 영역의 적절한 함수로 옮겨 적는다.
    • handleClick 에서 직접 pause 또는 play 를 수행하도록 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useEffect, useRef, useState } from 'react'
import ein from '../assets/video.mp4'

export function Player() {
  const [status, setStatus] = useState('paused')
  const videoEl = useRef(null)

  useEffect(() => {
    status === 'paused' ? pause() : play()
  }, [status])

  function play() {
    videoEl.current?.play()
  }

  function pause() {
    videoEl.current?.pause()
  }

  function handleClick() {
    setStatus(status === 'paused' ? 'playing' : 'paused')
  }

  return (
    <>
      <video ref={videoEl} src={ein} loop />

      <button onClick={handleClick}>
        {status === 'paused' ? 'Play' : 'Pause'}
      </button>
    </>
  )
}

revision : Svelte

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script>
  import ein from '../assets/video.mp4'

  let videoEl = null
  let status = 'paused'

  function play() {
    videoEl.play()
    status = 'playing'
  }

  function pause() {
    videoEl.pause()
    status = 'paused'
  }

  function handleClick() {
    status === 'paused' ? play() : pause()
  }
</script>

<video bind:this={videoEl} src={ein} loop />

<button on:click={handleClick}>
  {status === 'paused' ? 'Play' : 'Pause'}
</button>

연쇄/파생 상태 (Derived State)는 $: reactive 블록으로 변환

부모의 context 를 자식들이 공유하는 형태는 context + writable 이용

  • 부모 컴포넌트가 items, addItem, removeItem 컨텍스트를 공유
    • React 에서는 Context.Provider, Svelte 에서는 setContext
  • 자식 컴포넌트가 context 를 받아 사용
    • React 에서는 useContext, Svelte 에서는 getContext

original : React

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
///////////////////////////////
// context.ts
///////////////////////////////
import { createContext } from 'react'

export const ListContext = createContext(null)


///////////////////////////////
// List.tsx
///////////////////////////////
import { useState } from 'react'
import { ListContext } from './context'

export function List({ children, listItems }) {
  const [items, setListItems] = useState(listItems)

  function addItem() {
    setListItems((items) => [...items, items.length + 1])
  }

  function removeItem() {
    setListItems((items) =>
      items.slice(0, items.length - 1)
    )
  }

  return (
    <ListContext.Provider
      value={_{ items, addItem, removeItem }_}
    >
      <ul>{children}</ul>
    </ListContext.Provider>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
///////////////////////////////
// items.tsx
///////////////////////////////
import { useContext } from 'react'
import { ListContext } from './context'

export function Items() {
  const { items } = useContext(ListContext)

  return (
    <>
      {items.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </>
  )
}

///////////////////////////////
// AddItem.tsx
///////////////////////////////
import { useContext } from 'react'
import { ListContext } from './context'

export function AddItem() {
  const { addItem } = useContext(ListContext)

  return <button onClick={addItem}>Add</button>
}

///////////////////////////////
// RemoveItem.tsx
///////////////////////////////
import { useContext } from 'react'
import { ListContext } from './context'

export function RemoveItem() {
  const { removeItem } = useContext(ListContext)

  return <button onClick={removeItem}>Remove</button>
}

revision : Svelte

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- List.svelte -->
<script>
  import { setContext } from 'svelte'
  import { writable } from 'svelte/store'
  export let listItems = []

  const items = writable(listItems)

  setContext('items', items)
  setContext('addItem', addItem)
  setContext('removeItem', removeItem)

  function addItem() {
    $items = [...$items, $items.length + 1]
  }

  function removeItem() {
    $items = $items.slice(0, $items.length - 1)
  }
</script>

<ul>
  <slot />
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!-- items.svelte -->
<script>
  import { getContext } from 'svelte'

  const items = getContext('items')
</script>

{#each $items as item}
  <li>{item}</li>
{/each}

<!-- AddItem.svelte -->
<script lang="ts">
  import { getContext } from 'svelte'

  const addItem = getContext('addItem')
</script>

<button on:click={addItem}>Add</button>

<!-- RemoveItem.svelte -->
<script>
  import { getContext } from 'svelte'

  const removeItem = getContext('removeItem')
</script>

<button on:click={removeItem}>Remove</button>

Global State Management 도 context + writable 을 사용하면 된다.

Animations 은 svelte 의 animate 와 transition 로 처리

  • 애니메이션의 타겟 tag에 옵션을 직접 설정하면 된다.

참고2: Rewrite app from React to Svelte, what you can expect

  1. CSS 분리 : 코드에 엮인 부분을 style 블록으로 이동
  2. useEffect 코드를 onMount 와 onDestroy 로 분리하여 이동

original : React

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 단순 사용
React.useEffect(() => {
  console.log('MOUNT');  // onMount
  return () => console.log('UNMOUNT');  // onDestroy
}, []);

// reactive 문장 : height 변경시 파생 실행
React.useEffect(() => {
  console.log('HEIGHT changed, new value:', height);
}, [height]);

React.useEffect(() => {
  console.log(state1, state2, state3, state4, state5);
}, [state1, state2, state3, state4, state5]);

revision : Svelte

  • 관찰 대상이 여러개일 경우(Lots Of Deps), 함수를 만들어 일괄 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script>
  onMount(() => {
    console.log('MOUNT');
    return () => console.log('UNMOUNT 1');
  });
  onDestroy(() => {
    console.log('UNMOUNT 2');
  });  

  $: console.log('HEIGHT changed, new value:', height); 

  function doSomethingBigWithLotsOfDeps(state1, state2, state3, state4, state5) {
    /* Do epic shit! */
  }
  
  $: doSomethingBigWithLotsOfDeps(state1, state2, state3, state4, state5);  
</script>

속성, 이벤트, 바인딩, 하위 컴포넌트 등을 변환

original : React

1
2
3
function Outer(props) {
  return <Inner {...props} />;
}

revision : Svelte

  • $$props : 모든 props
  • slots : 전달되는 하위 컴포넌트
  • use:{action} : HTML 요소 바인딩 시에 재사용 되는 로직을 기술
  • on:{event} : 이벤트 함수 바인딩
1
<Inner {...$$props} on:click on:keydown />

TabControl 예시

original : React

1
2
3
4
5
6
7
8
<TabControl>
  <TabPage label='Page 1'>
    Page 1 content
  </TabPage>
  <TabPage label='Page 2'>
    Page 2 content
  </TabPage>
</TabControl>

revision : Svelte

  • svelte:fragment : slot 이름을 특정하여 내용을 삽입할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
<TabControl tabs={[
  { label: 'Page 1', slot: 1},
  { label: 'Page 2', slot: 2},
  ]}>
  <svelte:fragment slot='1'>
    Page 1 content
  </svelte:fragment>
  <svelte:fragment slot='2'>
    Page 2 content
  </svelte:fragment>
</TabControl>

컴포넌트 error 처리

original : React

1
2
3
<ErrorBoundary>
  {(null).read()}
</ErrorBoundary>

revision : Svelte

  • window:unhandledrejection 이벤트를 이용한다.
    • 그러나 이런 경우는, 오류를 일으키는 하위 컴포넌트 사용시에 발생한다.
1
2
3
4
5
6
7
8
<script>
  const onunhandledrejection = async e => {
    console.log('Unhandler error, checking whether crashed', e);
    // pass
  };
</script>

<svelte:window on:unhandledrejection={onunhandledrejection} />

그 외 참고문서

3. React 프로젝트 클론 코딩

다음 글에서 이어서 하자. (너무 길다)

참고 프로젝트

9. Review

  • React 를 Svelte 로 변환할 수 있는 각이 보이지 않는 경우가 있다.
    • 동영상에서는 쉬운 부분만 가르쳐는 경향이 있어서 금방 따라할 수 있을듯 싶지만, 실제 React 코드를 보면 각종 변종이 판친다. 특히 오버 엔지니어링 이라는 일컷는 부분들이 있다.
  • Svelte 에서는 최대한 단순하게 구현하라고 방향을 제시한다.
    • 개발자가 누구냐에 따라 언어와 프레임워크를 막론하고 스파게티 코드가 나올 수 있다.

$:$effect 로 기억할 것!

useEffect 는 대부분 $: 로 대체 가능하다.

  • 그러나 분명하게 드러나는 변수만 관찰하기 때문에 미세 조정은 불가능 (svelte 한계)
    • 예를 들어, 함수 안에 변수가 둘러쌓인 경우 관찰이 불가능함
    • 또는 Array 의 추가만 추적하고, Array item 내용 변경은 구분하지 못함
      • Object, Array 변수는 복제와 재생성을 통해 추적이 가능하도록 하고 있다.
  • 그래서 최근 Svelte 5 에서 Runes 이라는 연산자들을 만들고 있다.
    • 그런데 그 형태가 React, Vue 와 유사해서 반대파의 공격을 많이 받고 있다.
    • Runes 연산자로 인해 코딩 방식이 복잡해지면 Svelte 의 장점을 잃는다는 주장

 
 

끝!   읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.