Optimistic UI
서버 왕복이 끝나기 전에 사용자에게 반응을 돌려주는 장치들이다. 로딩 클래스, wire-disabled-with,
그리고 즉시 실행되는 JS 명령 셋으로 나뉜다.
로딩 클래스#
이벤트가 발생하면 그것을 일으킨 엘리먼트에 로딩 클래스가 자동으로 붙는다.
<button {% on "click" "save" %} class="btn">저장</button>
서버 왕복이 진행되는 동안 이 버튼이 갖는 클래스는 다음과 같다.
| 클래스 | 언제 |
|---|---|
wireview-loading |
항상 |
wireview-click-loading |
click 이벤트 |
wireview-submit-loading |
폼 제출 |
wireview-change-loading |
change 이벤트 |
폼 제출이면 폼과 함께, 폼 안에서 wire-disabled-with를 단 submit 버튼에도 붙는다. 서버 핸들러를 부르는
바인딩뿐 아니라 끝에 push가 있는 JS() 체인도 같다.
클래스와 wire-disabled-with는 그 이벤트의 응답이 오면 지워진다. 응답은 이벤트와 같은 ref를 단
렌더나 오류다. 그 사이 같은 컴포넌트에 다른 렌더가 와도(먼저 보내진 join의 답, 처리 중이던 브로드캐스트)
로딩 표시는 남는다. 그 렌더의 morph가 서버 HTML로 요소를 다시 쓰면 다시 붙인다. 연결이 끊기거나 그
컴포넌트가 오류로 다시 join하면 응답이 오지 않으므로 바로 지운다. ref를 되돌려 주지 않는 옛 서버에서는
그 컴포넌트의 다음 렌더(join의 답은 빼고)가 응답을 대신한다(#118).
로딩 상태 꾸미기#
/* 로딩 중 흐리게 */
.wireview-loading {
opacity: 0.7;
cursor: wait;
}
/* 스피너 아이콘 */
.wireview-click-loading::after {
content: "";
display: inline-block;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
margin-left: 0.5em;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
wire-disabled-with#
wire-disabled-with는 세 가지를 한다.
- 클릭 즉시 엘리먼트를 비활성화한다
- 버튼 텍스트를 로딩 문구로 바꾼다
- 작업이 끝나면 원래 상태로 되돌린다
기본 사용#
<button
{% on "click" "save" %}
wire-disabled-with="저장 중..."
>
저장
</button>
클릭하면 버튼이 disabled가 되고 텍스트가 "저장 중..."으로 바뀌었다가, 서버 응답이 오면 "저장"으로
돌아오며 다시 활성화된다.
폼 제출#
바인딩은 폼에 있어도 wire-disabled-with는 submit 버튼에 둔다. 제출하는 동안 그 버튼이 비활성화되고
문구가 바뀐다.
<form {% on "submit" "create_post" %}>
<input type="text" name="title" placeholder="글 제목">
<textarea name="content"></textarea>
<button
type="submit"
wire-disabled-with="글 만드는 중..."
>
글 쓰기
</button>
</form>
아이콘과 함께 (Tailwind/Heroicons)#
<button
{% on "click" "delete_item" id=item.id %}
wire-disabled-with="삭제 중..."
class="flex items-center gap-2"
>
<svg class="w-4 h-4"><!-- trash icon --></svg>
삭제
</button>
주의. wire-disabled-with는 텍스트 콘텐츠만 교체한다. 로딩 중에도 아이콘을 유지해야 하면
이 속성 대신 CSS 기반 로딩 표시를 쓴다.
로딩 클래스와 함께#
둘은 겹쳐 쓸 수 있다.
<button
{% on "click" "process" %}
wire-disabled-with="처리 중..."
class="btn"
>
데이터 처리
</button>
/* 버튼을 흐리게 하고 커서를 바꾼다 */
.btn.wireview-loading {
opacity: 0.6;
cursor: wait;
}
즉시 반응은 JS 명령으로#
서버를 기다리지 않고 UI를 바로 바꾸려면 JS 명령을 쓴다.
from wireview import JS, Component
class Menu(Component):
@property
def toggle_menu_js(self) -> JS:
# 템플릿은 인자를 받는 호출을 쓸 수 없으므로 JS 체인은 컴포넌트가 만든다
return JS().toggle_class("#menu", "hidden").push("toggle_menu")
<button {% on "click" this.toggle_menu_js %} wire-disabled-with="여는 중...">
메뉴 토글
</button>
JS() 명령은 그 자리에서 실행되고, 끝의 push가 서버 핸들러를 부른다. 서버 왕복에 대한 피드백은
wire-disabled-with가 맡는다. 같은 요소에 {% on "click" … %}을 두 번 쓰면 속성 이름이 같아 브라우저가 두 번째를
버리므로, 클라이언트 동작과 서버 호출은 이렇게 한 체인으로 묶는다.
권장 사항#
- 의미 있는 문구를 쓴다. "로딩 중..."보다 "저장 중..."이 낫다
- 짧게 쓴다. 긴 문구는 레이아웃을 흔든다
- 동작을 그대로 반영한다. 버튼이 하는 일과 로딩 문구를 맞춘다
- 시각적 신호를 더한다. opacity·cursor를 바꾸는 CSS와 함께 쓴다
로딩 문구 예#
| 버튼 | 로딩 문구 |
|---|---|
| 저장 | 저장 중... |
| 삭제 | 삭제 중... |
| 제출 | 제출 중... |
| 계정 만들기 | 계정 만드는 중... |
| 메시지 보내기 | 보내는 중... |
| 장바구니에 담기 | 담는 중... |
Phoenix LiveView 대응#
| Phoenix LiveView | django-wireview |
|---|---|
phx-disable-with |
wire-disabled-with |
phx-click-loading |
wireview-click-loading |
phx-submit-loading |
wireview-submit-loading |
phx-change-loading |
wireview-change-loading |
응답 시간 측정#
내장 프로파일링으로 실제 왕복 시간을 잴 수 있다.
// 브라우저 콘솔에서 켠다
wireview.debug.enableProfiling();
// 페이지를 조작한 뒤...
// 리포트를 본다
wireview.debug.profilingReport();
// 끈다
wireview.debug.disableProfiling();
리포트에 담기는 것:
- patch 시간 — DOM morph를 적용하는 데 걸린 시간
- 왕복 시간 — 이벤트를 보낸 시점부터 응답을 받은 시점까지
- 통계 — 항목별 최소·최대·평균·중앙값