Counter 컴포넌트
이 튜토리얼에서는 카운터 컴포넌트를 만들며 wireview의 핵심 개념을 학습합니다.
아래 경로의 myapp/은 시작하기에서 만든 앱입니다. 스타터 템플릿으로 시작했다면 hello/로 읽습니다.
학습 목표#
- Component 클래스 구조 이해
- 이벤트 바인딩과 핸들러
- Pydantic 기반 상태 관리
- 이벤트 수정자 (modifiers)
- URL 상태 저장
1. 기본 카운터#
컴포넌트 정의#
myapp/live.py:
from wireview import Component
class XCounter(Component):
"""숫자를 증가/감소시키는 카운터 컴포넌트"""
class Meta:
template_name = 'myapp/counter.html'
count: int = 0
async def increment(self):
"""카운터 증가"""
self.count += 1
async def decrement(self):
"""카운터 감소"""
self.count -= 1
템플릿#
myapp/templates/myapp/counter.html:
{% load wireview %}
<div {% tag_header %} class="counter">
<h2>Count: {{ count }}</h2>
<div class="buttons">
<button {% on "click" "decrement" %}>-</button>
<button {% on "click" "increment" %}>+</button>
</div>
</div>
사용#
{% load wireview %}
{% component 'XCounter' %}
{% component 'XCounter' count=100 %} <!-- 초기값 지정 -->
2. 이벤트 인자 전달#
핸들러에 인자를 전달할 수 있습니다:
컴포넌트#
class XCounter(Component):
class Meta:
template_name = 'myapp/counter.html'
count: int = 0
async def change_by(self, amount: int):
"""지정된 양만큼 변경"""
self.count += amount
async def set_to(self, value: int):
"""특정 값으로 설정"""
self.count = value
템플릿#
{% load wireview %}
<div {% tag_header %} class="counter">
<h2>Count: {{ count }}</h2>
<div class="buttons">
<button {% on "click" "change_by" amount=-10 %}>-10</button>
<button {% on "click" "change_by" amount=-1 %}>-1</button>
<button {% on "click" "change_by" amount=1 %}>+1</button>
<button {% on "click" "change_by" amount=10 %}>+10</button>
</div>
<button {% on "click" "set_to" value=0 %}>Reset</button>
</div>
타입 힌트 중요성:
amount: int로 선언하면 문자열 "10"이 자동으로 정수 10으로 변환됩니다- Pydantic이 자동 타입 변환 및 검증을 수행합니다
3. 이벤트 수정자#
이벤트 수정자로 이벤트 처리를 세밀하게 제어합니다.
기본 수정자#
<!-- 기본 클릭 -->
<button {% on "click" "submit" %}>Submit</button>
<!-- preventDefault() 호출 -->
<button {% on "click.prevent" "submit" %}>Submit</button>
<!-- stopPropagation() 호출 -->
<button {% on "click.stop" "submit" %}>Submit</button>
<!-- 둘 다 -->
<button {% on "click.prevent.stop" "submit" %}>Submit</button>
키보드 수정자#
<!-- Enter 키에만 반응 -->
<input {% on "keypress.enter" "search" %}>
<!-- Ctrl+Enter -->
<textarea {% on "keydown.ctrl.enter" "submit" %}></textarea>
<!-- 특정 키 -->
<input {% on "keydown.key.escape" "cancel" %}>
keypress는 글자를 입력하는 키에만 나온다. Escape·Tab·화살표 같은 키와 Ctrl·Alt·Meta를 누른 조합에는 브라우저가 keypress를 보내지 않으므로, 그런 키는 keydown에 건다.
디바운스와 쓰로틀#
<!-- 300ms 디바운스 - 입력이 멈춘 후 실행 -->
<input {% on "input.debounce.300" "search" %}>
<!-- 100ms 쓰로틀 - 100ms마다 최대 1회 실행 -->
<div {% on "scroll.throttle.100" "on_scroll" %}>
조합 예제#
<!-- Enter 키로 검색, 기본 동작 방지, 300ms 디바운스 -->
<input
name="query"
{% on "keypress.enter.prevent.debounce.300" "search" %}
>
4. 폼 입력 처리#
암시적 인자#
폼 요소의 값은 자동으로 핸들러에 전달됩니다:
{% load wireview %}
<div {% tag_header %}>
<input name="amount" type="number" value="1">
<button {% on "click" "change_by" %}>Add</button>
</div>
async def change_by(self, amount: int):
# input[name="amount"]의 값이 자동으로 전달됨
self.count += amount
명시적 인자 우선#
명시적으로 전달한 인자가 암시적 인자보다 우선합니다:
<input name="amount" type="number" value="1">
<button {% on "click" "change_by" amount=5 %}>Add 5</button>
<!-- amount=5가 사용됨 -->
5. URL 상태 저장#
카운터 값을 URL에 저장하여 새로고침해도 유지되게 합니다:
컴포넌트#
from wireview import Component, WireviewMeta
class XCounter(Component):
class Meta:
template_name = 'myapp/counter.html'
count: int = 0
@classmethod
def new(cls, wire: WireviewMeta, **kwargs):
# URL에서 count 파라미터 읽기
kwargs.setdefault("count", wire.params.get("count", 0))
return cls(wire=wire, **kwargs)
async def change_by(self, amount: int):
self.count += amount
# URL 쿼리 파라미터 업데이트
self.wire.params["count"] = self.count
async def set_to(self, value: int):
self.count = value
self.wire.params["count"] = self.count
이름이 비슷한 둘을 구분해 둡니다. class Meta:는 컴포넌트 클래스의 설정(템플릿, 구독할 채널 등)이고,
WireviewMeta는 컴포넌트 인스턴스가 연결과 주고받는 창구인 self.wire의 타입입니다(URL 파라미터,
push_js(), put_flash() 같은 클라이언트 명령). 위 new()의 wire 인자가 그것입니다.
동작#
- 초기 로드: URL의
?count=X파라미터로 초기화 - 값 변경: URL이 자동으로 업데이트 (
?count=5) - 새로고침: URL에서 값을 복원
복잡한 값 저장#
리스트나 딕셔너리는 .json 접미사를 사용합니다:
# 리스트 저장
self.wire.params["items.json"] = [1, 2, 3]
# 읽기
items = self.wire.params.get("items.json", [])
6. CSS 스타일링#
로딩 상태#
서버 요청 중 자동으로 추가되는 CSS 클래스:
/* 요청 중인 모든 요소 */
.wireview-loading {
opacity: 0.6;
pointer-events: none;
}
/* 클릭 이벤트 요청 중 */
.wireview-click-loading {
cursor: wait;
}
/* 제출 이벤트 요청 중 */
.wireview-submit-loading {
cursor: wait;
}
조건부 클래스#
{% load wireview %}
<div {% tag_header %}>
<span {% class {'positive': count > 0, 'negative': count < 0, 'zero': count == 0} %}>
{{ count }}
</span>
</div>
7. 완성된 예제#
컴포넌트 (live.py)#
from wireview import Component, WireviewMeta
class XCounter(Component):
"""기능이 풍부한 카운터 컴포넌트"""
class Meta:
template_name = 'myapp/counter.html'
count: int = 0
step: int = 1
min_value: int | None = None
max_value: int | None = None
@classmethod
def new(cls, wire: WireviewMeta, **kwargs):
# URL에서 상태 복원
kwargs.setdefault("count", wire.params.get("count", 0))
return cls(wire=wire, **kwargs)
async def increment(self):
new_value = self.count + self.step
if self.max_value is None or new_value <= self.max_value:
self.count = new_value
self._sync_url()
async def decrement(self):
new_value = self.count - self.step
if self.min_value is None or new_value >= self.min_value:
self.count = new_value
self._sync_url()
async def set_to(self, value: int):
if self.min_value is not None:
value = max(value, self.min_value)
if self.max_value is not None:
value = min(value, self.max_value)
self.count = value
self._sync_url()
async def set_step(self, step: int):
self.step = max(1, step)
def _sync_url(self):
self.wire.params["count"] = self.count
템플릿 (counter.html)#
{% load wireview %}
<div {% tag_header %} class="counter-widget">
<div class="counter-display">
<span {% class {'positive': count > 0, 'negative': count < 0, 'zero': count == 0} %}>
{{ count }}
</span>
</div>
<div class="counter-controls">
<button
{% on "click" "decrement" %}
{% cond {'disabled': min_value is not None and count <= min_value} %}
>
-{{ step }}
</button>
<button
{% on "click" "increment" %}
{% cond {'disabled': max_value is not None and count >= max_value} %}
>
+{{ step }}
</button>
</div>
<div class="counter-settings">
<label>
Step:
<input
type="number"
name="step"
value="{{ step }}"
min="1"
{% on "change" "set_step" %}
>
</label>
<button {% on "click" "set_to" value=0 %}>Reset</button>
</div>
</div>
<style>
.counter-widget {
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
max-width: 300px;
}
.counter-display {
font-size: 3rem;
text-align: center;
margin: 1rem 0;
}
.counter-display .positive { color: green; }
.counter-display .negative { color: red; }
.counter-display .zero { color: gray; }
.counter-controls {
display: flex;
gap: 1rem;
justify-content: center;
}
.counter-controls button {
padding: 0.5rem 1rem;
font-size: 1.2rem;
cursor: pointer;
}
.counter-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.counter-settings {
margin-top: 1rem;
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
}
.counter-settings input {
width: 60px;
}
.wireview-loading {
opacity: 0.6;
}
</style>
사용 예#
{% load wireview %}
<!-- 기본 카운터 -->
{% component 'XCounter' %}
<!-- 범위 제한 카운터 -->
{% component 'XCounter' min_value=0 max_value=100 %}
<!-- 큰 스텝 카운터 -->
{% component 'XCounter' step=10 %}
연습 문제#
- 타이머 컴포넌트: 시작/정지/리셋 버튼이 있는 타이머를 만들어보세요
- 온도 변환기: 섭씨/화씨 변환 컴포넌트를 만들어보세요
- 별점 입력: 1-5점 별점 입력 컴포넌트를 만들어보세요
다음 단계#
카운터를 통해 기본적인 상태 관리와 이벤트 핸들링을 배웠습니다.
다음 튜토리얼에서는 모델을 구독해 다른 사용자의 투표까지 실시간으로 보여 주는 Poll 앱을 만들며 렌더링 최적화를 배웁니다.