컴포넌트 시스템
VaisX의 컴포넌트 시스템은 Props, Events, Slots, Context 네 가지 메커니즘으로 컴포넌트 간 데이터와 동작을 공유합니다.
컴포넌트 기본 구조
.vaisx 파일 하나가 컴포넌트 하나입니다. 다른 컴포넌트를 <template> 내에서 태그로 사용합니다.
<!-- Button.vaisx -->
<script>
P { label: String }
</script>
<template>
<button class="btn">{label}</button>
</template>
<style>
.btn {
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
}
</style>
<!-- app/page.vaisx — Button 컴포넌트 사용 -->
<script>
import Button from "../components/Button.vaisx"
</script>
<template>
<Button label="클릭하세요" />
</template>
Props
Props 선언 — P
P { } 블록으로 컴포넌트가 받을 Props를 선언합니다. 각 필드는 이름과 타입으로 구성됩니다.
<script>
P {
title: String,
count: Int,
isActive: Bool,
items: Array<String>
}
</script>
<template>
<div class={isActive ? "active" : ""}>
<h2>{title}</h2>
<span>{count}개</span>
</div>
</template>
기본값 지정
Props에 기본값을 지정할 수 있습니다.
<script>
P {
title: String = "제목 없음",
size: String = "medium",
disabled: Bool = false
}
</script>
<template>
<button
class={"btn btn-" + size}
:disabled={disabled}
>
{title}
</button>
</template>
컴포넌트에 Props 전달
<!-- 정적 값 -->
<Card title="안녕하세요" count={42} isActive={true} />
<!-- 동적 바인딩 -->
<Card :title={post.title} :count={post.views} :isActive={isSelected} />
<!-- 스프레드 전달 -->
<Card {...postProps} />
Props 유효성 검사
Props 타입은 컴파일 타임에 검사됩니다. 잘못된 타입을 전달하면 빌드 에러가 발생합니다.
<!-- 컴파일 에러: String이 필요한데 Int 전달 -->
<Card title={42} />
<!-- 컴파일 에러: 필수 Props 누락 -->
<Card />
Events
emit으로 이벤트 발생
자식 컴포넌트에서 부모로 데이터를 전달할 때 emit을 사용합니다.
<!-- Counter.vaisx -->
<script>
P { initialValue: Int = 0 }
count := $state(initialValue)
F increment() {
count += 1
emit change(count) # 부모에게 새 값 전달
}
F decrement() {
count -= 1
emit change(count)
}
</script>
<template>
<div class="counter">
<button @click={decrement}>-</button>
<span>{count}</span>
<button @click={increment}>+</button>
</div>
</template>
부모에서 이벤트 수신
@이벤트명={핸들러}로 자식 컴포넌트의 이벤트를 수신합니다.
<!-- app/page.vaisx -->
<script>
import Counter from "../components/Counter.vaisx"
total := $state(0)
F handleChange(newValue: Int) {
total = newValue
console.log("카운터 변경:", newValue)
}
</script>
<template>
<Counter
:initialValue={5}
@change={handleChange}
/>
<p>현재 값: {total}</p>
</template>
여러 이벤트 선언
컴포넌트는 여러 이벤트를 발생시킬 수 있습니다.
<!-- SearchInput.vaisx -->
<script>
P { placeholder: String = "검색..." }
query := $state("")
F handleInput(e) {
query = e.target.value
emit input(query) # 입력마다 발생
}
F handleSubmit() {
emit submit(query) # 제출 시 발생
emit clear() # 인자 없는 이벤트
}
</script>
<template>
<div class="search">
<input
:value={query}
:placeholder={placeholder}
@input={handleInput}
/>
<button @click={handleSubmit}>검색</button>
</div>
</template>
<!-- 부모에서 수신 -->
<SearchInput
@input={handleInput}
@submit={handleSearch}
@clear={clearResults}
/>
Slots
Slots은 부모 컴포넌트가 자식 컴포넌트의 내부 콘텐츠를 제어할 수 있게 합니다.
기본 Slot
{slot}이 자식 내용이 삽입될 위치입니다.
<!-- Card.vaisx -->
<template>
<div class="card">
<div class="card-body">
{slot}
</div>
</div>
</template>
<style>
.card {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.card-body { padding: 16px; }
</style>
<!-- 사용 -->
<Card>
<h2>카드 제목</h2>
<p>카드 내용입니다.</p>
</Card>
이름 있는 Slot
여러 위치에 콘텐츠를 삽입할 때 이름 있는 Slot을 사용합니다.
<!-- Modal.vaisx -->
<template>
<div class="modal-overlay">
<div class="modal">
<header class="modal-header">
{slot name="header"}
</header>
<main class="modal-body">
{slot} <!-- 기본 슬롯 -->
</main>
<footer class="modal-footer">
{slot name="footer"}
</footer>
</div>
</div>
</template>
<!-- 사용 -->
<Modal>
<template slot="header">
<h2>확인</h2>
</template>
<!-- 기본 슬롯 내용 -->
<p>정말로 삭제하시겠습니까?</p>
<template slot="footer">
<button @click={cancel}>취소</button>
<button @click={confirm}>확인</button>
</template>
</Modal>
Slot 기본값
Slot에 내용이 전달되지 않았을 때 표시할 기본 콘텐츠를 지정합니다.
<!-- Button.vaisx -->
<template>
<button class="btn">
{slot}
<!-- 슬롯 기본값: 내용이 없을 때 사용됨 -->
{slot default="클릭"}
</button>
</template>
Scoped Slot
자식 컴포넌트의 데이터를 Slot 콘텐츠에서 사용할 수 있습니다.
<!-- List.vaisx -->
<script>
P { items: Array<Item> }
</script>
<template>
<ul>
@each items as item, index {
<li>
<!-- item과 index를 슬롯에 노출 -->
{slot item={item} index={index}}
</li>
}
</ul>
</template>
<!-- 사용: 슬롯 데이터를 let으로 받음 -->
<List :items={products}>
<template let:item let:index>
<span>{index + 1}. {item.name} — {item.price}원</span>
</template>
</List>
Context
Context는 Props를 단계별로 내려보내지 않고 컴포넌트 트리에서 데이터를 공유하는 방법입니다.
Context 제공 — setContext
부모 컴포넌트에서 setContext로 데이터를 제공합니다.
<!-- ThemeProvider.vaisx -->
<script>
import { setContext } from "vaisx"
P {
theme: String = "light"
}
# 하위 컴포넌트 모두에서 접근 가능
setContext("theme", {
current: theme,
toggle: F() {
# theme 토글 로직
}
})
</script>
<template>
<div class={"app theme-" + theme}>
{slot}
</div>
</template>
Context 소비 — getContext
하위 컴포넌트에서 getContext로 데이터를 받습니다.
<!-- Button.vaisx — 어느 깊이에 있어도 theme에 접근 가능 -->
<script>
import { getContext } from "vaisx"
themeCtx := getContext("theme")
</script>
<template>
<button class={"btn btn-" + themeCtx.current}>
{slot}
</button>
</template>
반응형 Context
Context에 $state를 결합하면 값이 변경될 때 하위 컴포넌트도 자동으로 업데이트됩니다.
<!-- AuthProvider.vaisx -->
<script>
import { setContext } from "vaisx"
user := $state(null)
isLoading := $state(true)
A F login(credentials) {
isLoading = true
result := await authenticate(credentials)
user = result.user
isLoading = false
emit login(user)
}
F logout() {
user = null
emit logout()
}
# $state 값을 context로 제공
setContext("auth", { user, isLoading, login, logout })
</script>
<template>
@if isLoading {
<p>로딩 중...</p>
} @else {
{slot}
}
</template>
<!-- UserProfile.vaisx — 어느 곳에서든 auth 접근 -->
<script>
import { getContext } from "vaisx"
auth := getContext("auth")
</script>
<template>
@if auth.user {
<div class="profile">
<img :src={auth.user.avatar} />
<p>{auth.user.name}</p>
<button @click={auth.logout}>로그아웃</button>
</div>
} @else {
<a href="/login">로그인</a>
}
</template>
서버 컴포넌트
<script context="server">로 선언된 컴포넌트는 서버에서만 렌더링됩니다. 클라이언트에 JavaScript를 전송하지 않아 초기 로드가 빠릅니다.
<!-- BlogPost.vaisx — 서버 컴포넌트 -->
<script context="server">
P { slug: String }
# 서버에서만 실행 — DB 직접 접근 가능
post := getPostBySlug(slug)
</script>
<template>
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div class="content">{post.content}</div>
</article>
</template>
서버 컴포넌트 내에서 클라이언트 컴포넌트를 사용하면 해당 부분만 클라이언트 JavaScript가 포함됩니다.
<!-- BlogPage.vaisx — 서버 컴포넌트 -->
<script context="server">
import BlogPost from "../components/BlogPost.vaisx"
import LikeButton from "../components/LikeButton.vaisx" # 클라이언트 컴포넌트
P { slug: String }
post := getPostBySlug(slug)
</script>
<template>
<BlogPost :slug={slug} />
<!-- LikeButton만 클라이언트 JS 포함 -->
<LikeButton :postId={post.id} :initialLikes={post.likes} />
</template>
컴포넌트 수명주기
VaisX는 컴파일 타임 반응성을 기반으로 하여 전통적인 수명주기 훅 대신 $effect로 수명주기를 처리합니다.
<script>
# 컴포넌트 마운트 시 실행 ($effect는 첫 렌더링 후 실행)
$effect {
console.log("컴포넌트 마운트됨")
# 정리 함수: 컴포넌트 해제 시 실행
R () => {
console.log("컴포넌트 해제됨")
}
}
# 특정 상태 변화에 반응
query := $state("")
$effect {
I query.length > 2 {
performSearch(query)
}
}
</script>
컴포넌트 패턴
컨테이너/프레젠테이션 분리
<!-- PostListContainer.vaisx — 데이터 관리 -->
<script context="server">
import PostList from "./PostList.vaisx"
#[server]
A F load() -> PageData {
posts := await fetchPosts()
R PageData { posts }
}
</script>
<template>
<PostList :posts={posts} />
</template>
<!-- PostList.vaisx — 표시만 담당 -->
<script>
P { posts: Array<Post> }
</script>
<template>
<ul class="post-list">
@each posts as post {
<li>
<a :href={"/posts/" + post.slug}>{post.title}</a>
</li>
}
</ul>
</template>
재사용 가능한 로직 — 함수 모듈
반응성 로직을 일반 Vais 모듈로 추출하여 여러 컴포넌트에서 재사용합니다.
<!-- lib/useCounter.vais -->
F useCounter(initial: Int = 0) {
count := $state(initial)
doubled := $derived(count * 2)
F increment() { count += 1 }
F decrement() { count -= 1 }
F reset() { count = initial }
R { count, doubled, increment, decrement, reset }
}
<!-- Counter.vaisx -->
<script>
import { useCounter } from "../lib/useCounter.vais"
{ count, doubled, increment, decrement, reset } := useCounter(0)
</script>
<template>
<div>
<p>{count} (×2 = {doubled})</p>
<button @click={decrement}>-</button>
<button @click={reset}>리셋</button>
<button @click={increment}>+</button>
</div>
</template>