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 ? '연결됨' : '다시 연결하는 중...';
  }
};

권장 사항#

  1. destroyed()에서 반드시 정리한다. observer 해제, 라이브러리 인스턴스 파괴, 이벤트 리스너 제거.
  2. 상태 보존은 beforeUpdate()에서. 스크롤 위치·포커스·선택 영역을 morph 전에 저장한다.
  3. 초기화는 mounted()에서. 엘리먼트가 페이지에 올라오기 전에 초기화하지 않는다.
  4. 재연결을 다룬다. 끊긴 동안 바뀌었을 데이터를 reconnected()에서 새로 읽는다. 끊긴 동안의 pushEvent는 버려지므로(#97) 서버가 꼭 알아야 하는 것은 reconnected()에서 다시 보낸다.
  5. 폴링 대신 handleEvent()를 쓴다. 데이터가 바뀌면 서버가 밀어 준다.
  6. 훅 하나는 한 가지만 한다. 필요하면 한 엘리먼트에 여러 개를 붙인다.

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)
반환값 무시 무시
호출 대상 모든 노드 엘리먼트 노드만