JavaScript 훅
서드파티 JS 라이브러리(Chart.js, Mapbox, CodeMirror 등)를 컴포넌트에 붙이는 장치다. Phoenix LiveView의 훅과 같은 모양이다.
빠른 시작#
1. 훅을 정의한다#
window.wireview.hooks.ChartHook = {
mounted() {
// 엘리먼트가 페이지에 들어왔을 때
const config = JSON.parse(this.el.dataset.config);
this.chart = new Chart(this.el, config);
},
updated() {
// DOM morph가 끝난 뒤
this.chart.update();
},
destroyed() {
// 엘리먼트가 사라질 때
this.chart.destroy();
}
};
2. 템플릿에서 쓴다#
<div wire-hook="ChartHook" data-config='{"type": "line", "data": {...}}'>
</div>
훅 파일을 어디에 두나#
앱의 static 아래 hooks/에 둔다. 프로젝트가 <script> 태그를 쓸 필요가 없다.
myapp/
└── static/
└── myapp/ ← 앱 라벨 (Django의 정적 파일 네임스페이스)
└── hooks/ ← wireview의 규약
└── chart.js
{% wireview_header %}가 설치된 앱 전부에서 이 디렉터리를 찾아 defer로 싣는다. 파일은
자기 훅 이름을 자기가 쓴다.
// myapp/static/myapp/hooks/chart.js
window.wireview.hooks.Chart = {
mounted() { ... },
};
왜 페이지마다 전부 싣나#
수집은 페이지가 아니라 프로젝트 단위다. 그 페이지가 쓰는 훅만 실으면 boost 내비게이션에서
깨진다 — 경계 안에서 이동할 때 wireview는 body만 갈아 끼우므로 목적지 문서의 <head>에 있던
<script>는 실행되지 않는다. 첫 로드에서는 되고 이동해서 들어가면 안 되는 결함이 된다.
부수 효과로 수집이 요청과 무관해진다 — 기동 때 한 번 정해진다.
순서는 보장된다#
defer 스크립트는 문서 순서대로 실행되므로 훅 파일이 도는 시점에 window.wireview는 이미 있다.
그리고 wireview는 defer 스크립트가 전부 실행된 뒤에야 컴포넌트를 join한다 — 훅 파일이 늦게
도착해도 첫 렌더 때 훅이 자리에 있다는 뜻이다.
이 보장이 필요한 이유는 실패가 조용하기 때문이다. 등록되지 않은 훅은 오류를 내지 않고 콘솔 경고
한 줄(Hook "X" not registered)만 남기며, 컴포넌트는 정상으로 보인다. manage.py check의
wireview.W011이 그 짝을 먼저 잡는다 — 템플릿이 부르는 이름을 등록하는 파일이 없을 때.
직접 싣고 싶다면#
번들러가 있는 프로젝트는 수집을 끄고 같은 파일들을 자기 번들에 넣으면 된다.
WIREVIEW = {"COLLECT_HOOKS": False}
끄면 wireview.W011도 함께 조용해진다 — 비교할 대상이 없기 때문이다. 직접 실을 때도
번들이 wireview보다 먼저 실행되지 않게 해야 한다. 인라인 <script>는 defer가 안 되므로
window.wireview가 없는 시점에 돌아 실패한다.
동작하는 예제는 examples/hooks/에 있다.
훅 수명주기#
| 콜백 | 언제 | 쓰임새 |
|---|---|---|
mounted() |
엘리먼트가 들어오고 첫 렌더가 끝난 뒤 | 라이브러리 초기화 |
beforeUpdate() |
DOM morph 직전 (동기) | 스크롤 위치·선택 영역 저장 |
updated() |
DOM morph가 끝난 뒤 | 상태 복원, 라이브러리 갱신 |
destroyed() |
엘리먼트가 DOM에서 빠질 때. 컴포넌트가 페이지를 떠날 때(부모가 그리지 않음, boost 이동)도 뿌리에 단 훅까지 모두 | 자원 정리 |
disconnected() |
WebSocket이 끊겼을 때 | 오프라인 표시 |
reconnected() |
WebSocket이 다시 붙고 컴포넌트가 다시 join할 때. 같은 훅 인스턴스다 | 데이터 새로고침 |
navigated() |
boost 이동이 끝난 뒤, 이동 전부터 있었고 이동 뒤에도 남은 훅에게 한 번. 주로 sticky 컴포넌트의 훅 | 새 <body>에 페이지 전체 효과 다시 걸기 (boost) |
훅은 자기를 감싼 가장 가까운 컴포넌트의 것이다. 중첩된 컴포넌트 안의 훅은 바깥 컴포넌트가 아니라 안쪽
컴포넌트의 push_event를 받는다. 렌더가 엘리먼트를 옮기기만 하면(id가 같은 목록 항목의 순서가 바뀜)
훅은 그대로 살아 있고 destroyed()도 mounted()도 다시 불리지 않는다.
부모의 렌더가 새로 그린 컴포넌트(LiveComponent, 또는 {% if %}가 다시 그린 {% component %})의 훅도 그 렌더가 패치될 때
마운트된다. 그렇게 그려진 {% component %}는 그때 페이지가 join한다 — 그 joined()가 돌고, 그 안의
wire-viewport-*는 그 컴포넌트의 것으로 관찰된다. 그 LiveComponent가 joined()에서
보낸 push_event는 렌더보다 먼저 패치를 기다리지 않고 도착하지만, 페이지는 그 요소가 들어올 다음 프레임까지
붙들었다가 마운트된 훅에 전한다(전에는 훅이 아예 마운트되지 않았고 이벤트는 아무 데도 닿지 않았다).
이미 화면에 있는 컴포넌트의 핸들러가 렌더로 새 훅을 그리고 같은 핸들러에서 그 훅에 push_event를 보내도
마찬가지다 — 그 컴포넌트에 예약된 패치가 있으면 이벤트는 그 패치 뒤에 간다. 부모의 패치가 이미 join된
중첩 컴포넌트(LiveComponent 포함) 안에 그린 훅은 그 중첩 컴포넌트의 것으로 마운트되고, 그 패치가 지나간
중첩 컴포넌트의 훅도 beforeUpdate()·updated()를 받는다.
훅 컨텍스트#
콜백 안의 this가 주는 것.
| 속성 | 타입 | 뜻 |
|---|---|---|
this.el |
HTMLElement |
wire-hook 속성이 붙은 DOM 엘리먼트 |
| 메서드 | 뜻 |
|---|---|
this.pushEvent(event, payload, callback) |
서버로 이벤트를 보낸다 |
this.handleEvent(event, callback) |
서버가 보내는 이벤트를 받는다 |
정의 객체에서는 함수만 this로 복사된다. 위 이름(pushEvent, handleEvent)과 __로 시작하는 이름은
wireview의 것이다 — 같은 이름으로 메서드를 쓰면 내장 동작을 덮는다. this.__hookId 같은 __ 멤버는 내부이고
바뀔 수 있다.
서버와 주고받기#
서버로 보내기 (pushEvent)#
window.wireview.hooks.InfiniteScroll = {
mounted() {
this.observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
this.loadMore();
}
});
this.observer.observe(this.el.querySelector('.sentinel'));
},
loadMore() {
// 콜백과 함께 서버로 보낸다
this.pushEvent("load_more", { page: this.page }, (response) => {
console.log("Server response:", response);
if (response.hasMore) {
this.page++;
} else {
this.observer.disconnect();
}
});
this.page = (this.page || 1) + 1;
},
destroyed() {
this.observer.disconnect();
}
};
서버에서 받기 (handleEvent)#
window.wireview.hooks.Notification = {
mounted() {
// 서버가 push하는 이벤트의 핸들러를 등록한다
this.handleEvent("show_toast", ({ message, type }) => {
this.showToast(message, type);
});
this.handleEvent("highlight", ({ color }) => {
this.el.style.backgroundColor = color;
setTimeout(() => {
this.el.style.backgroundColor = '';
}, 1000);
});
},
showToast(message, type) {
// 토스트 구현
}
};
서버 쪽 핸들러#
from wireview import Component
class Dashboard(Component):
class Meta:
template_name = "dashboard.html"
items: list = []
page: int = 1
async def handle_hook_event(self, hook_id: str, event: str, payload: dict):
"""Handle events from JavaScript hooks.
Args:
hook_id: Unique identifier of the hook instance
event: Event name sent by the hook
payload: Event data from the hook
Returns:
Response data sent to the hook's callback (or None)
"""
if event == "load_more":
page = payload.get("page", 1)
new_items = await self._fetch_items(page)
self.items.extend(new_items)
return {
"hasMore": len(new_items) == 20,
"count": len(new_items)
}
return None
async def _notify_user(self, message: str):
"""Push an event to every hook in this component (a helper, so ``_``)."""
await self.push_event("show_toast", {
"message": message,
"type": "success"
})
async def _highlight_item(self, hook_id: str):
"""Push an event to one specific hook."""
await self.push_event(
"highlight",
{"color": "yellow"},
hook_id=hook_id
)
렌더가 건드리지 않게 (wire-update="ignore")#
훅이나 서드파티 위젯이 엘리먼트를 바꿔도, 그 컴포넌트가 다시 렌더되면 morph가 템플릿 모양으로 되돌린다.
updated()에서 다시 입히면 대부분 가려지지만, 재연결 때처럼 훅이 늦게 마운트되는 사이에는 틈이 보인다.
wire-update="ignore"를 단 엘리먼트는 첫 렌더 뒤로 어떤 렌더도 건드리지 않는다. 속성, 자식, 텍스트
모두다. Phoenix의 phx-update="ignore"와 같다.
<div wire-hook="Banner" wire-update="ignore" hidden></div>
서버는 그 자리를 모른 채 계속 렌더하고 diff도 보낸다. 브라우저가 그 부분만 적용하지 않는다. 그래서 그 안에
서버가 바꿔야 하는 값을 두지 않는다. 엘리먼트가 템플릿에서 빠지면({% if %}) 페이지에서도 빠진다.
한 엘리먼트에 훅 여러 개#
이름을 공백으로 나열한다.
<div wire-hook="Sortable Draggable Tooltip">
<!-- 내용 -->
</div>
각 훅은 자기 인스턴스와 수명주기를 갖는다.
예제#
Chart.js#
window.wireview.hooks.Chart = {
mounted() {
const config = JSON.parse(this.el.dataset.config);
this.chart = new Chart(this.el, config);
// 서버가 보내는 데이터 갱신을 받는다
this.handleEvent("update_data", ({ datasets }) => {
this.chart.data.datasets = datasets;
this.chart.update();
});
},
beforeUpdate() {
// morph 전에 차트 상태를 저장한다
this.chartState = {
animation: this.chart.options.animation
};
},
updated() {
// morph 후에 애니메이션을 복원한다
this.chart.options.animation = this.chartState.animation;
},
destroyed() {
this.chart.destroy();
}
};
CodeMirror#
window.wireview.hooks.CodeEditor = {
mounted() {
this.editor = CodeMirror(this.el, {
mode: this.el.dataset.mode || "javascript",
lineNumbers: true
});
// 변경을 디바운스해서 서버로 보낸다
let timeout;
this.editor.on("change", () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
this.pushEvent("content_changed", {
content: this.editor.getValue()
});
}, 300);
});
// 서버가 보내는 내용을 받는다
this.handleEvent("set_content", ({ content }) => {
this.editor.setValue(content);
});
},
destroyed() {
this.editor.toTextArea();
}
};
스크롤 위치 보존#
window.wireview.hooks.PreserveScroll = {
beforeUpdate() {
// morph 전에 저장
this.scrollTop = this.el.scrollTop;
},
updated() {
// morph 후에 복원
this.el.scrollTop = this.scrollTop;
}
};
오프라인 표시#
window.wireview.hooks.ConnectionStatus = {
mounted() {
this.updateStatus(true);
},
disconnected() {
this.updateStatus(false);
},
reconnected() {
this.updateStatus(true);
},
updateStatus(connected) {
this.el.classList.toggle('connected', connected);
this.el.classList.toggle('disconnected', !connected);
this.el.textContent = connected ? '연결됨' : '다시 연결하는 중...';
}
};
권장 사항#
destroyed()에서 반드시 정리한다. observer 해제, 라이브러리 인스턴스 파괴, 이벤트 리스너 제거.- 상태 보존은
beforeUpdate()에서. 스크롤 위치·포커스·선택 영역을 morph 전에 저장한다. - 초기화는
mounted()에서. 엘리먼트가 페이지에 올라오기 전에 초기화하지 않는다. - 재연결을 다룬다. 끊긴 동안 바뀌었을 데이터를
reconnected()에서 새로 읽는다. 끊긴 동안의pushEvent는 버려지므로(#97) 서버가 꼭 알아야 하는 것은reconnected()에서 다시 보낸다. - 폴링 대신
handleEvent()를 쓴다. 데이터가 바뀌면 서버가 밀어 준다. - 훅 하나는 한 가지만 한다. 필요하면 한 엘리먼트에 여러 개를 붙인다.
API#
Component.handle_hook_event()#
async def handle_hook_event(
self,
hook_id: str,
event: str,
payload: dict[str, Any],
) -> Any: ...
클라이언트 훅이 pushEvent()로 보낸 이벤트를 받는다.
hook_id— 훅 인스턴스의 고유 식별자event— 훅이 보낸 이벤트 이름payload— 훅이 보낸 데이터- 반환 — 훅의 콜백으로 돌아갈 응답 데이터 (없으면
None)
Component.push_event()#
async def push_event(
self,
event: str,
payload: dict[str, Any] | None = None,
hook_id: str | None = None,
) -> None: ...
클라이언트 훅으로 이벤트를 보낸다.
event— 보낼 이벤트 이름payload— 데이터 (기본: 빈 dict)hook_id— 특정 훅 인스턴스만 (None이면 전체)
DOM morph 콜백#
wireview.dom.onBeforeElUpdated()#
갱신 중 엘리먼트가 morph되기 직전에 실행되는 콜백을 더한다. 서버 렌더 결과가 덮어써 버릴 클라이언트 쪽 속성이나 상태를 지키는 데 쓴다. 여러 번 부르면 콜백이 모두 더해져 더한 순서대로 돌고, 돌려받은 함수를 부르면 그 콜백만 빠진다.
const remove = wireview.dom.onBeforeElUpdated((fromEl, toEl) => {
// fromEl: 지금 DOM에 있는 엘리먼트
// toEl: 그것을 대체할 새 엘리먼트
});
remove(); // 더 이상 필요 없을 때
JS가 붙인 속성 지키기
// data-js-* 속성을 보존한다
wireview.dom.onBeforeElUpdated((fromEl, toEl) => {
for (const attr of fromEl.attributes) {
if (attr.name.startsWith('data-js-')) {
toEl.setAttribute(attr.name, attr.value);
}
}
});
Alpine.js 상태 보존
wireview.dom.onBeforeElUpdated((fromEl, toEl) => {
if (fromEl._x_dataStack) {
window.Alpine.clone(fromEl, toEl);
}
});
CSS 트랜지션 유지
wireview.dom.onBeforeElUpdated((fromEl, toEl) => {
if (fromEl.hasAttribute('data-transitioning')) {
toEl.setAttribute('data-transitioning', fromEl.getAttribute('data-transitioning'));
}
});
Phoenix LiveView 대응#
| 기능 | Phoenix LiveView | django-wireview |
|---|---|---|
| 설정 위치 | LiveSocket 생성자 옵션 |
wireview.dom.onBeforeElUpdated() |
| 콜백 시그니처 | (fromEl, toEl) |
(fromEl, toEl) |
| 반환값 | 무시 | 무시 |
| 호출 대상 | 모든 노드 | 엘리먼트 노드만 |