원문 캡처 · avanelsas.github.io
원문 캡처 · avanelsas.github.io

BareMirror는 웹 컴포넌트에서 DOM을 다시 만들 때 생기는 포커스·스크롤·컴포넌트 상태 손실과 불필요한 갱신을 줄이기 위해 만든 함수 모음이다. ClojureScript로 작성됐지만 JavaScript와 TypeScript에서 사용할 수 있는 일반 ES 모듈로 제공된다.

BareDOM을 만든 개발자는 웹 컴포넌트가 특정 프레임워크에 묶이지 않는다는 장점이 있지만, 낮은 수준의 API만으로 상태와 DOM 갱신 문제를 다루기는 어렵다고 설명한다. BareMirror는 이 문제를 해결하기 위해 개발됐으며, BareDOM과 함께 쓰도록 만들었지만 다른 웹 컴포넌트 라이브러리에서도 사용할 수 있다고 한다.

웹 컴포넌트의 대표적인 어려움은 화면을 다시 그릴 때 기존 DOM 노드가 교체돼 입력 포커스, 선택 영역, 스크롤 위치, 애니메이션과 컴포넌트 내부 상태가 사라질 수 있다는 점이다. DOM을 직접 갱신하는 방법은 복잡하고, 실수하면 오래된 상태가 화면에 남을 수 있다. 페이지 전체를 다시 그리면 큰 목록이나 복잡한 컴포넌트에서 불필요한 작업이 늘어난다. 목록의 항목을 추가·삭제·이동할 때도 노드의 정체성과 상태를 보존하려면 신중한 조작이 필요하다.

또한 서버가 거부한 상태 변경을 컴포넌트가 화면에 표시하는 불일치가 생길 수 있다. 개발자는 이를 앱이 상태 변경을 결정하고 컴포넌트는 현재 상태만 표시하는 방식으로 다룬다. 이벤트 처리에는 DOM을 찾고 동작을 연결하는 보일러플레이트 코드도 필요하다. BareMirror는 이벤트를 수행할 동작과 행의 키를 담은 선언적 메시지로 바꾼다.

BareMirror에서 상태는 앱이 보유한다. 함수들은 노드를 배치하고 값을 기록하며 이벤트를 메시지로 바꾼다. 이전 화면과 새 화면을 통째로 비교하는 대신 키를 기준으로 노드를 관리하며, 유지되는 노드는 다시 만들지 않는다. 별도의 화면 상태를 메모리에 보관하지 않고 각 DOM 노드에 키, 마지막으로 기록한 항목과 속성 값을 저장한다.

작동 흐름은 이벤트, 메시지, 단계 함수, 상태, 뷰, 렌더링, 페이지로 이어지는 7단계다. 사용자 이벤트를 메시지로 바꾼 뒤 순수 단계 함수가 상태 변화를 결정한다. 새 상태를 뷰로 변환하고 필요한 위치에만 DOM을 갱신한다.

두 가지 개념이 핵심이다. part는 목록 안의 노드를 키로 표시하고, 템플릿은 노드의 고정된 구조와 동적 값을 쓸 위치인 hole을 지정한다. 템플릿은 데이터를 바탕으로 한 번 만든다. 이후 각 hole에 새 값을 넣고 변경된 값만 DOM에 쓴다. BareMirror 코드가 추가하는 번들 크기는 BareDOM을 불러오는 페이지에서 약 7KB다. BareDOM을 제외하면 공유 기반 파일인 base.js를 불러오기 때문에 약 78KB가 추가된다고 설명한다.

예를 들어 템플릿은 다음처럼 작성한다.

clojure
[:li
 [:x-checkbox {:checked :done? :on {"x-checkbox-change-request" :toggle}}]
 [:span :text]
 [:x-button {:on {"press" :remove}} "Remove"]]

template/split은 고정된 노드와 동적 위치, 이벤트를 구분한다. 항목 {:done? true :text "Buy milk"}에 대한 writes 결과는 {"row.0" {:attrs {:checked true}}, "row.1" {:text "Buy milk"}}가 된다. parts/write-parts!는 지정된 노드에 텍스트, 속성, 프로퍼티를 기록하며, 값이 달라진 경우에만 프로퍼티를 쓴다. 쓰기 대상에 해당하는 노드가 없으면 쓰기 전에 오류를 발생시킨다.

clojure
(defn- fill [item hole]
 (hole item))

(defn- hole-writes
 "The part name of `hole` and its writes for `item`."
 [item {:keys [part attrs text props]}]
 [part (cond-> {}
 attrs (assoc :attrs (update-vals attrs (partial fill item)))
 text (assoc :text (fill item text))
 props (assoc :props (update-vals props (partial fill item))))])

(defn writes
 "The writes of `item` for the holes of `split-template`, by part name."
 [split-template item]
 (into {} (map (partial hole-writes item)) (:holes split-template)))

목록 배치는 plan 함수가 현재 키 배열과 원하는 키 배열을 비교해 계산한다. 기본 동작은 제거와 배치 두 가지이며, 삽입과 이동은 모두 배치로 처리한다. 이미 있는 키는 기존 노드를 유지하고, 새 키에는 새 노드를 만든다. 이 계획 함수는 변경 사항만 계산하며 DOM을 직접 바꾸지 않는다.

clojure
(plan/plan {:todo ["a" "b" "c" "d" "e"]}
 {:todo ["a" "d" "b" "c" "x" "e"]})
 ;; => {:remove []
 ;; :place [{:key "x" :in :todo :before "e"}
 ;; {:key "d" :in :todo :before "b"}]}

배치 계산은 양쪽 배열에서 같은 시작·끝부분을 제외하고, 가운데 항목의 현재 순위를 구한 다음 가장 긴 증가 부분 수열을 유지한다. 나머지 키는 원하는 순서의 오른쪽 이웃 앞에 배치한다. 이 방식으로 이미 올바른 위치에 있는 노드의 이동을 줄인다. 이후 read-places가 DOM의 현재 키를 읽고, plan이 변경 계획을 만들며, perform!이 지정된 노드만 삽입·이동·삭제한다.

개발자가 제공한 데모는 일반 JavaScript로 만든 작업 목록과 BareMirror를 적용한 목록을 나란히 보여준다. 데모 설명에 따르면 왼쪽 구현은 작업을 바꿀 때마다 전체가 갱신되고, 오른쪽은 필요한 부분만 갱신한다. 다른 사용자가 같은 목록을 조작할 때 포커스가 입력창에서 사라지는 문제도 포커스된 노드를 다시 만들지 않으면 막을 수 있다고 설명한다. 작업이 1,000개일 때 전체 재렌더링은 문제가 되지만, 직접 최적화한 코드로 속도를 높일 수도 있다. 개발자는 요점이 직접 구현할 수 없다는 것이 아니라, 이를 매번 손으로 구현하는 일이 많다는 데 있다고 말한다.

개발자는 Lit도 비교 대상으로 든다. Lit은 마지막 렌더링 결과를 메모리에 보관해 비교하고 DOM을 다시 읽지 않기 때문에 더 빠르다고 설명한다. BareMirror는 DOM을 매 렌더링마다 읽어 DOM을 유일한 기준으로 삼는다. 이 방식은 더 느리지만, DOM에 쓰기 전 변경할 값을 살펴볼 수 있고 무거운 행을 목록 사이에서 옮길 때는 다소 빠를 가능성이 있다고 주장한다. Lit은 목록 간 이동에서 노드를 삭제하고 새로 만들지만 BareMirror는 기존 노드를 유지한다고 설명한다.

개발자가 제시한 1,000개 항목 데모 측정에서는 대부분의 작업에서 Lit 3.3.0의 시간이 BareMirror보다 짧았다. BareDOM 웹 컴포넌트 기준으로 1,000개 생성은 BareMirror 109.2ms, Lit 91.2ms였고, 전체 교체는 각각 121.1ms와 100.8ms였다. 일반 HTML 요소 기준으로는 1,000개 생성이 20.6ms와 17.8ms, 전체 교체가 23.6ms와 20.4ms였다. 개발자는 이를 바탕으로 Lit이 더 성숙하고 빠른 해결책이라고 평가한다.

BareMirror는 아직 알파 단계라 BareDOM 저장소에 포함돼 있다. 개발자는 현재 작동하며, 블로그를 구동하는 오픈소스 블로그 생성기 BareBlog 등 여러 프로젝트에서 사용한다고 밝혔다. 자세한 구현과 이전 글은 BareDOM, BareMirror 설명서, 웹 컴포넌트와 ClojureScript에 관한 글에서 확인할 수 있다.