섀도 DOM을 버리기로 했다

섀도 DOM을 버리기로 했다

웹 컴포넌트, 또는 커스텀 요소에서 섀도 DOM(Shadow DOM)은 빼놓을 수 없다. UI 라이브러리의 기초가 된 “컴포넌트의 재사용”을 DOM 스스로 제공하는 게 커스텀 요소고, 재사용을 하고자 한다면 주변에 아무 영향을 주지 않는 게 좋으니까. 요컨대 재사용을 하기 쉬우려면 ‘순수’해야 한다는 것이고, 이 순수성의 가장 잘 보이는 예시가 섀도 DOM 내부에 전역 CSS를 넣더라도 밖으로 새지 않는다는 점이다. 3,210명이 각각 구현한 커스텀 요소를 한 페이지에 때려넣어도 스타일이 깨지지 않으니 완벽한 재사용이다1.

하지만 새지 않는 건 스타일만이 아니다. 너무 철저하게 ‘그림자’ 속에 묻혀있어서, 평범한 HTML 마크업에서는 당연히 될 것도 되지 않는다. <button type="submit">이 양식을 제출하지 못하는 걸 겪고, 이제 커스텀 요소를 쳐다도 안보려고 한다.

당연히 되어야 하는 버튼

컴포넌트가 꼭 기능을 포함해야 할 필요는 없다. 사전 정의된 스타일만 적용된 것도 컴포넌트라고 한다. 그래서 버튼 컴포넌트를 만들었고, 이왕 컴포넌트화 했으니 “바쁜 상태” 표시 기능을 추가했다. 이 정도면 버튼을 왜 컴포넌트화 했어야 했는지는 변호가 된 것 같다.

나는 커스텀 요소를 쓰고 싶을 때 Lit을 사용했었다. 빌드 툴 설정이 거의 필요 없어서 편하고, 가볍기도 하고. 코드로는 아래와 같겠다.

import { html, LitElement } from 'lit'
import { customElement, property } from 'lit/decorators.js'

@customElement('my-button')
class MyButton extends LitElement {
  @property({ type: Boolean })
  busy = false

  @property({ type: Boolean })
  disabled = false

  @property({ type: String })
  type = 'submit'

  @property({ type: String })
  variant = 'primary'

  render() {
    const interactive = !this.busy && !this.disabled
    return html`
      <button
        aria-disabled=${interactive}
        class=${styles.button({ busy: this.busy, variant: this.variant })}
        type=${this.type}
        @click=${!interactive ? (e) => e.stopPropagation() : undefined}>
        ${this.busy ? '로딩 중...' : this.children}
      </button>
    `
  }
}

이제 이걸 일반 HTML 쓰듯 양식 제출 버튼으로 삼아보자.

<form>
  <label for="text">텍스트 입력</label>
  <input id="text" name="text" required type="text">
  <br>
  <my-button type="submit">제출</my-button>
</form>

텍스트를 입력하고, 제출 버튼을 누르면… 아무 일도 일어나지 않는다. “하지만 type="submit"인걸?” 그러나 제출 이벤트는 커녕 required 속성이 붙은 input 요소의 유효성 검증조차 일어나지 않는다. 아, 섀도 DOM 때문이구나, 망했다. 그런 생각만 들었다.

”캡슐화”

커스텀 요소는 외부에 영향을 주지 않는다 했다. <form>은 외부다. 양식 입장에서 보면 <my-button type="submit"><button type="submit">이 아니라 <my-button type="submit">에 불과하다. <video> 요소의 재생 버튼이 바깥 HTML 세상에서는 없는 거나 마찬가지듯, <my-button>의 섀도 트리에 있는 <button type="submit"> 역시 존재하지 않는 요소다. 버튼은 ‘구현 상세’고, 그걸 숨기는 게 캡슐화니까.

비단 버튼의 문제만이 아니다. 이제 모두들 UI 라이브러리 없으면 살 수 없는 몸이 돼서 잊혀지고 있는 것 같지만 <input>은 엄청나게 많은 일을 저절로 한다.

  • 요소의 ID를 가리키는 <label>을 접근 가능한 레이블로 사용한다.
  • 양식의 요소 목록에 추가된다.
  • 양식 제출 시 제출 데이터에 값을 포함한다.
  • required, pattern 등 유효성 검증에 참여하고, 검증 실패 시 양식 제출을 막는다.
  • 양식 초기화 시 스스로의 값을 초기화한다.
  • 조상 <fieldset> 요소가 비활성화되면 스스로 비활성화된다.
  • 브라우저가 양식을 자동완성할 때 적절히 채워진다.
  • 상태에 따라 CSS :disabled, :invalid 등 의사 클래스로 선택할 수 있다.

더 있을 수도 있다. 아무튼 정말 많이 해준다. 그런데 커스텀 요소 안의 <input>은 아무것도 하지 못한다. 저것들은 다 외부니까.

FACE - Form Associated Custom Element

그래서 나왔다. Form Associated Custom Element - 양식 연결된 커스텀 요소. 이 글은 FACE를 소개하는 글이 아니니까 간단하게만 보면…

  1. 커스텀 요소 클래스에 static formAssociated = true를 추가한다.
  2. 생성자에서 this.attachInternals()를 추가하고, 반환 값인 ElementInternals를 저장한다.
  3. ElementInternals를 잘 써서 유효성 검증 등에 참여한다.
  4. ElementInternals를 잘 써서 FACE 전용 메서드들을 잘 구현한다.

그러니까 formDisabledCallback()을 써서 비활성화 시 어떻게 반응할지 정의하고, formResetCallback()을 써서 초기화 시 어떻게 반응할지 정의하고, … ElementInternals.setValidity()로 검증에 참여하고, … 이런 식이다. 그리고 나는 Shadow DOM: Not by Default에 공감할 수밖에 없다. 그렇다고 Enhance를 쓰겠다는 건 아니지만.

하지만, JavaScript로 만들어진 [원래 없던] 문제를 더 많은 JavaScript로 해결하는 건 물에 빠진 사람에게 물 한 컵 건네주는 것과 같다고 생각합니다.

게다가 FACE를 써도 제출 버튼은 만들 수 없다! 2019년에 만들어진 WICG/webcomponents#814는 아직 열려있다…

몸 비틀기 시작

클릭 제출

적어도, ElementInternals를 통해서 이 커스텀 요소의 조상 <form> 요소는 찾을 수 있게 됐다. 그렇다면 클릭했을 때 그 양식의 requestSubmit()을 호출하면 어떨까? 유효성 검증까지 포함하니까 그렇게 나쁘지 않은 선택일지도?

@customElement('my-button')
class MyButton extends LitElement {
  // ...

  #internals

  static formAssociated = true

  constructor() {
    super()
    this.#internals = this.attachInternals()
  }

  // @click={this.handleClick}
  handleClick() {
    if (this.busy || this.disabled) {
      return
    }

    if (this.type === 'submit' && this.#internals.form) {
      this.#internals.form.requestSubmit()
    }
  }
}

이제 클릭을 하면, 와! 제출이 된다! FACE 만세! 그런데 입력 칸에서 엔터를 누르면 여전히 제출이 안된다!

엔터 키 제출

데스크톱 환경에서 입력 칸에 입력하다가 엔터를 누르면 제출되는 건 뺄 수 없는 UX다. 이게 가끔 안되는 곳이 있는데 너무 너무 불편하다. 내가 싫은 걸 남에게 강요할 수는 없으니 어떻게든 엔터 키로 제출이 되어야 한다.

this.#internals.formkeydown 이벤트를 수신하고, 키가 'Enter'requestSubmit()을 할까..? 아니, 이미 충분히 복잡하다. 날로 먹을 수 있는 좋은 방법이 있다.

<form>
  <input type="text">
  <button hidden type="submit"></button>
  <my-button type="submit">제출</my-button>
</form>

제출 버튼은 hidden 특성을 추가해도 엔터 키로 동작한다는 점! JavaScript를 더 추가하지 않아도 된다. 만세!


그리고 이렇게 <button hidden><my-button>을 한 쌍씩 두 군데의 양식에 더 집어넣은 후 내가 왜 이러고 있나 어처구니가 없어져서 Svelte로 넘어갔다고 한다. 너무 편하다. 끝.

각주

  1. JavaScript를 사용해서 전역 컨텍스트를 변조하는 경우는 제외.

마지막 수정:

CC BY-SA 4.0 unless otherwise noted. See LICENSE.