본문으로 바로 가기
로고
개발

[JS] 프레임워크 없이 SPA 만들기 - 4 (웹 컴포넌트)

읽는 시간 14분
[JS] 프레임워크 없이 SPA 만들기 - 4 (웹 컴포넌트) 글의 썸네일"

#들어가며...

지난 포스팅까지는 SPA 라우터와 동적라우팅을 다루어봤다. 이번 편에서는 "웹 컴포넌트"에 대해서 살펴보자. 이번에 다룰 주요 키워드는 다음과 같다.

  1. 캡슐화: 웹 컴포넌트의 기본 개념
  2. 재사용성: 컴포넌트가 갖는 효과적인 활용성
  3. 커스텀 엘리먼트: 웹 컴포넌트의 기본 구성 요소
  4. Shadow DOM: 스타일과 구조의 독립적으로 관리하는 메커니즘

그럼 웹 컴포넌트를 파헤쳐보자!

#캡슐화

웹 컴포넌트를 캡슐화의 원칙을 따른다. 이는 각 컴포넌트가 독립적으로 작동하고, 외부 환경의 영향을 최소화해서 예측 가능한 동작을 보장하는 것을 의미한다. 캡슐화를 통해서 개발자는 컴포넌트의 내부 로직을 세세하게 알 필요 없이 사용할 수 있다.

#직접 확인해보자!

빈 html 파일에 <video> 태그를 추가해봤다.

그리고 개발자 도구 옵션(설정?) 에서 환경설정 -> 요소 -> 사용자 에이전트 그림자 DOM 표시를 체크해주자.

개발자 도구 옵션(크롬)
개발자 도구 옵션(크롬)
video 태그 내부의 모습
video 태그 내부의 모습

이 처럼 우리는 <video> 라는 태그 하나만 써도 내부에는 복잡한 구조의 태그들의 모음이라는 것을 알 수 있다.
이 외에도 <select> 등 이 있다.

#재사용성

이건 뭐 리액트나 뷰 등 을 사용해봤다면, 당연히 알고있으리라 생각한다.

#커스텀 엘리먼트

커스텀 엘리먼트는 웹컴포넌트의 주요 특징 중 하나이다. 기존의 HTML 태그를 확장하거나 완전히 새로운 태그를 생성할 수 있다. 이를 통해서 개발자는 웹페이지에 독특한 기능과 스타일을 추가할 수 있다.

#기존의 <button> 태그를 확장해보자.

버튼을 눌렀을 때, 간단한 애니메이션이 적용되는 버튼 컴포넌트를 만들어볼것이다.

js 코드
js
class CustomAnimatedButton extends HTMLButtonElement {
  constructor() {
    super();
 
    this.addEventListener('click', () => {
    this.animate([
                  { transform: 'scale(1)', opacity: 1 },
                  { transform: 'scale(1.1)', opacity: 0.5 },
                  { transform: 'scale(1)', opacity: 1 }
                ],{
      duration: 300
      });
    });
  }
}
 
customElements.define('custom-animated-button', CustomAnimatedButton, { extends: 'button' });
js
class CustomAnimatedButton extends HTMLButtonElement {
  constructor() {
    super();
 
    this.addEventListener('click', () => {
    this.animate([
                  { transform: 'scale(1)', opacity: 1 },
                  { transform: 'scale(1.1)', opacity: 0.5 },
                  { transform: 'scale(1)', opacity: 1 }
                ],{
      duration: 300
      });
    });
  }
}
 
customElements.define('custom-animated-button', CustomAnimatedButton, { extends: 'button' });

웹 컴포넌트는 class 문법을 이용해서 생성한다. 특정 태그를 확장해서 사용할 땐, extends 뒤에 HTML특정 Element 를 상속받아야 한다. 그리고 마지막에 define에서도 옵션으로 extends : '태그이름' 을 해줘야 한다.

확장한 컴포넌트를 사용할 때는 <button is="custom-animated-button">Click me!</button><button is="custom-animated-button">Click me!</button> 처럼 태그는 원래대로 쓰고 is 속성에 내가 define에서 지정했던 "custom-animated-button" 을 넣어줘서 사용한다.

#완전히 새로운 태그 생성해보기

프로필 카드를 나타내는 완전히 새로운 태그를 생성해보자.

js 코드
js
class ProfileCard extends HTMLElement {
  constructor() {
    super();
 
    const shadow = this.attachShadow({ mode: 'open' });
 
    const container = document.createElement('div');
    container.innerHTML = `
        <img src="${this.getAttribute('image-src')}" alt="Profile Image">
        <h2>${this.getAttribute('name')}</h2>
        <p>${this.getAttribute('description')}</p>
      `;
 
     shadow.appendChild(container);
  }
}
 
customElements.define('profile-card', ProfileCard);
js
class ProfileCard extends HTMLElement {
  constructor() {
    super();
 
    const shadow = this.attachShadow({ mode: 'open' });
 
    const container = document.createElement('div');
    container.innerHTML = `
        <img src="${this.getAttribute('image-src')}" alt="Profile Image">
        <h2>${this.getAttribute('name')}</h2>
        <p>${this.getAttribute('description')}</p>
      `;
 
     shadow.appendChild(container);
  }
}
 
customElements.define('profile-card', ProfileCard);

새로운 태그를 생성할때는 HTMLElement를 상속받는다. 코드에 보이는 Shadow DOM은 아래에서 설명하겠다.

이렇게 커스텀 엘리먼트를 사용하면, 웹 개발에 더 다양한 가능성과 유연성을 얻을 수 있다.

#Shadow DOM

쉐도우 돔은 웹페이지의 일부분을 캡슐화해서 그 안의 요소와 스타일이 외부의 영향을 받지 않도록 한다. 확장프로그램 코드를 까보다 보면 심심치않게 볼 수 있는 것 같다..

독립적인 돔 구조를 가지며, 메인 돔과는 별개로 작동한다.

#Shadow DOM의 사용

Shadow DOM을 생성하려면, 일반적으로 주요 요소에 shadow root 를 붙이고, 이를 통해서 쉐도우 돔에 접근하고 조작한다.

위에 CodeSandBox의 프로필 카드 커스텀 컴포넌트의 코드를 살펴보자

js
class ProfileCard extends HTMLElement {
  constructor() {
    super();
 
    const shadow = this.attachShadow({ mode: 'open' }); // 쉐도우 돔 생성
    // this.attachShadow({ mode: 'open' });
 
    const container = document.createElement('div');
    container.innerHTML = `
      <img src="${this.getAttribute('image-src')}" alt="Profile Image">
      <h2>${this.getAttribute('name')}</h2>
      <p>${this.getAttribute('description')}</p>
    `;
      shadow.appendChild(container);
      // this.shadowRoot.appendChild(container);
    }
  }
 
  customElements.define('profile-card', ProfileCard);
js
class ProfileCard extends HTMLElement {
  constructor() {
    super();
 
    const shadow = this.attachShadow({ mode: 'open' }); // 쉐도우 돔 생성
    // this.attachShadow({ mode: 'open' });
 
    const container = document.createElement('div');
    container.innerHTML = `
      <img src="${this.getAttribute('image-src')}" alt="Profile Image">
      <h2>${this.getAttribute('name')}</h2>
      <p>${this.getAttribute('description')}</p>
    `;
      shadow.appendChild(container);
      // this.shadowRoot.appendChild(container);
    }
  }
 
  customElements.define('profile-card', ProfileCard);

생성할 때 modeopen 또는 closed로 설정할 수 있다.

  1. open : 자바스크립트로 shadow root 에 접근할 수 있다.
  2. closed : 자바스크립트로 shadow root 에 접근할 수 없다. 그리고 this.attachShadow({ mode: 'closed' })this.attachShadow({ mode: 'closed' })null을 반환한다.

왜 Shadow DOM을 사용하는가?

  1. 스타일 격리 - 스타일과 마크업을 완전히 격리시키기 때문에. 스타일 충돌 없이 외부 환경에서 컴포넌트를 사용할 수 있게 해준다.
  2. 재사용 가능한 컴포넌트 - 완전히 독립된 컴포넌트를 만들 수 있어서 다른 프로젝트나 페이지에서도 재사용 할 수 있다.

#스타일 격리 예시 코드

쉐도우 돔을 쓰지않은 컴포넌트는 메인 돔 스타일에 영향을 주고 받지만, 쉐도우 돔은 독립적으로 스타일이 적용된 모습을 볼 수 있다.

css 변수는 예외사항으로 Shadow DOM 스타일에 영향을 줄 수 있다.

이 외에 :host ::slotted 등을 활용해서 컴포넌트를 만들 수 있다.

Shadow DOM styling (javascript.info)

#우리 SPA 코드에 컴포넌트 적용해보기

간략하게 웹 컴포넌트에 대해서 알아보았으니 우리 소스코드를 컴포넌트로 변경해보자!

우리가 만든 페이지에서 컴포넌트로 추출할만한게 뭐가 있을까? 굉장히 단순한 정적페이지라서 굳이 컴포넌트화 할 필요는 없지만, 헤더 네비게이션과 새로고침이 일어나지 않는 <a> 태그 그 자체를 컴포넌트로 추출해보면 좋은 연습이 될 것 같다.

우선 js 폴더에 components 라는 폴더를 만들어준다.

우리의 목표
우리의 목표

#컴포넌트 적용 전 코드 1

원래 사용하던 <a> 태그는 뭔가 분산되어 있는 느낌이었다.

레이아웃 코드 수정
js
// layout.js
export const layout = (content) => {
// 나머지 코드...
 
paths.forEach((path) => {
  const navLink = document.createElement("a");
  navLink.setAttribute("class", "nav__link");
  navLink.setAttribute("data-link", "nav__link");
  navLink.setAttribute("href", path.href);
  navLink.textContent = path.text;
 
  nav.appendChild(navLink);
});
 
// 나머지 코드
};
 
 
// index.js
document.addEventListener("DOMContentLoaded", () => {
// index.js 에서 이벤트 위임을 이용해서 새로고침이 일어나지 않게 만들었었다.
document.body.addEventListener("click", (e) => {
  if (e.target.matches("[data-link]")) {
    e.preventDefault();
    navigateTo(e.target.href);
  }
});
 
router();
});
레이아웃 코드 수정
js
// layout.js
export const layout = (content) => {
// 나머지 코드...
 
paths.forEach((path) => {
  const navLink = document.createElement("a");
  navLink.setAttribute("class", "nav__link");
  navLink.setAttribute("data-link", "nav__link");
  navLink.setAttribute("href", path.href);
  navLink.textContent = path.text;
 
  nav.appendChild(navLink);
});
 
// 나머지 코드
};
 
 
// index.js
document.addEventListener("DOMContentLoaded", () => {
// index.js 에서 이벤트 위임을 이용해서 새로고침이 일어나지 않게 만들었었다.
document.body.addEventListener("click", (e) => {
  if (e.target.matches("[data-link]")) {
    e.preventDefault();
    navigateTo(e.target.href);
  }
});
 
router();
});
NavLink/index.js
js
// NavLink/index.js
import { navigateTo } from "../../router.js";
import { styles } from "./style.js";
 
class NavLink extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = styles;
  }
 
  // 1)
  connectedCallback() {
    this.#render();
    this.#addEventListeners();
  }
 
  // 2)
  disconnectedCallback() {
    this.#removeEventListeners();
  }
 
  // 3)
  #handleClick = (e) => {
    const anchor = e.target.closest("a");
    if (anchor.matches("[data-link]")) {
      e.preventDefault();
      navigateTo(anchor.href);
    }
  };
 
  // 4)
  #addEventListeners() {
    this.shadowRoot
      .querySelector("a")
      .addEventListener("click", this.#handleClick);
  }
 
  #removeEventListeners() {
    this.shadowRoot
      .querySelector("a")
      .removeEventListener("click", this.#handleClick);
  }
 
  #render() {
    const link = this.#createLinkElement();
    this.shadowRoot.appendChild(link);
  }
 
  #createLinkElement() {
    const link = document.createElement("a");
    link.className = "nav__link";
    link.dataset.link = "";
    link.href = this.getAttribute("href");
 
    const slotEl = document.createElement("slot");
    link.appendChild(slotEl);
 
    this.#applyColorAttribute(link);
 
    return link;
  }
 
  #applyColorAttribute(link) {
    if (this.hasAttribute("color")) {
      link.style.color = this.getAttribute("color");
    }
  }
}
 
customElements.define("nav-link", NavLink);
 
 
// NavLink/style.js
export const styles = `<style>
.nav__link {
  align-items: center;
  color: #fff;
  display: inline-flex;
  height: 100%;
  padding: 0 1rem;
  text-decoration: none;
}
 
.nav__link:hover {
  background: rgba(255, 255, 255, 0.05);
}
</style>`;
NavLink/index.js
js
// NavLink/index.js
import { navigateTo } from "../../router.js";
import { styles } from "./style.js";
 
class NavLink extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = styles;
  }
 
  // 1)
  connectedCallback() {
    this.#render();
    this.#addEventListeners();
  }
 
  // 2)
  disconnectedCallback() {
    this.#removeEventListeners();
  }
 
  // 3)
  #handleClick = (e) => {
    const anchor = e.target.closest("a");
    if (anchor.matches("[data-link]")) {
      e.preventDefault();
      navigateTo(anchor.href);
    }
  };
 
  // 4)
  #addEventListeners() {
    this.shadowRoot
      .querySelector("a")
      .addEventListener("click", this.#handleClick);
  }
 
  #removeEventListeners() {
    this.shadowRoot
      .querySelector("a")
      .removeEventListener("click", this.#handleClick);
  }
 
  #render() {
    const link = this.#createLinkElement();
    this.shadowRoot.appendChild(link);
  }
 
  #createLinkElement() {
    const link = document.createElement("a");
    link.className = "nav__link";
    link.dataset.link = "";
    link.href = this.getAttribute("href");
 
    const slotEl = document.createElement("slot");
    link.appendChild(slotEl);
 
    this.#applyColorAttribute(link);
 
    return link;
  }
 
  #applyColorAttribute(link) {
    if (this.hasAttribute("color")) {
      link.style.color = this.getAttribute("color");
    }
  }
}
 
customElements.define("nav-link", NavLink);
 
 
// NavLink/style.js
export const styles = `<style>
.nav__link {
  align-items: center;
  color: #fff;
  display: inline-flex;
  height: 100%;
  padding: 0 1rem;
  text-decoration: none;
}
 
.nav__link:hover {
  background: rgba(255, 255, 255, 0.05);
}
</style>`;
  1. connectedCallback 은 웹 컴포넌트가 DOM에 연결될 때 호출되는 메서드다.
    리액트 생명주기의 componentDidMount()나 useEffect(() => {}, [])componentDidMount()나 useEffect(() => {}, []) 과 비슷한 느낌이라고 이해하면 쉬울 것 같다.

  2. disconnectedCallback메서드는 componentWillUnmount() 나 useEffect의 리턴 부분과 비슷하다.
    여기서 이벤트 삭제를 해서 메모리 낭비를 줄였다.

  3. 컴포넌트 자체에 <a> 태그의 기본 동작을 무시하고 navigate 하는 기능을 여기서 구현하고, 이벤트 삭제 메서드도 정의 해줬다.

Navigation/index.js
js
import { styles } from "./style.js";
import "../NavLink/index.js"; // 1) 컴포넌트를 사용하기 위해 js파일을 import 해줘야 한다.
 
class Navigation extends HTMLElement {
  #paths = []; // 2) 네비게이션 경로 옵션을 받을 배열
  #template = document.createElement("template");
 
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.#initTemplate();
  }
 
  // private 변수인 paths를 조작하기 위한 setter 메서드
  set paths(val) {
    this.#paths = val;
    this.#render();
  }
 
  #initTemplate() {
    this.#template.innerHTML = `
${styles}
<nav></nav>
  `;
    this.shadowRoot.appendChild(this.#template.content.cloneNode(true));
  }
 
    #render() {
      const nav = this.shadowRoot.querySelector("nav");
 
      while (nav.firstChild) {
        nav.removeChild(nav.firstChild);
      }
 
      this.#paths.forEach((path) => {
        // 여기서 우리가 만든 커스텀 nav-link를 이용한다.
        // nav-link에는 이미 data-link와 class이름이 추가되어 있어서 href와 표시될 text만 입력받음
        const navLink = document.createElement("nav-link");
        navLink.setAttribute("href", path.href);
        navLink.textContent = path.text;
        nav.appendChild(navLink);
      });
    }
  }
 
  customElements.define("navigation-menu", Navigation);
Navigation/index.js
js
import { styles } from "./style.js";
import "../NavLink/index.js"; // 1) 컴포넌트를 사용하기 위해 js파일을 import 해줘야 한다.
 
class Navigation extends HTMLElement {
  #paths = []; // 2) 네비게이션 경로 옵션을 받을 배열
  #template = document.createElement("template");
 
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.#initTemplate();
  }
 
  // private 변수인 paths를 조작하기 위한 setter 메서드
  set paths(val) {
    this.#paths = val;
    this.#render();
  }
 
  #initTemplate() {
    this.#template.innerHTML = `
${styles}
<nav></nav>
  `;
    this.shadowRoot.appendChild(this.#template.content.cloneNode(true));
  }
 
    #render() {
      const nav = this.shadowRoot.querySelector("nav");
 
      while (nav.firstChild) {
        nav.removeChild(nav.firstChild);
      }
 
      this.#paths.forEach((path) => {
        // 여기서 우리가 만든 커스텀 nav-link를 이용한다.
        // nav-link에는 이미 data-link와 class이름이 추가되어 있어서 href와 표시될 text만 입력받음
        const navLink = document.createElement("nav-link");
        navLink.setAttribute("href", path.href);
        navLink.textContent = path.text;
        nav.appendChild(navLink);
      });
    }
  }
 
  customElements.define("navigation-menu", Navigation);

우리는 이 <navigation-menu>을 이용해서 layout.js를 개선해볼것이다.

전 후 비교
전 후 비교
js
import "../components/Navigation/index.js"; // 컴포넌트 import
 
export const layout = (content) => {
  const fragment = document.createDocumentFragment();
 
  const header = document.createElement("header");
  // const nav = document.createElement("nav"); // 이제 nav를 만들지 않아도 된다.
 
  const paths = [
    { href: "/", text: "" },
    { href: "/posts", text: "게시글" },
    { href: "/settings", text: "설정" },
  ];
 
 
  /*
  paths.forEach((path) => {
    const navLink = document.createElement("a");
    navLink.setAttribute("class", "nav__link");
    navLink.setAttribute("data-link", "nav__link");
    navLink.setAttribute("href", path.href);
    navLink.textContent = path.text;
 
    nav.appendChild(navLink);
  });
  */
  //header.appendChild(nav);
 
  const navComponent = document.createElement("custom-nav"); // 커스텀 컴포넌트를 만들고
  navComponent.paths = paths; // paths 설정만 해주면 간편하게 네비게이션을 생성해서 사용할 수 있다.
  header.appendChild(navComponent);
 
  fragment.appendChild(header);
  fragment.appendChild(content);
 
  return fragment;
};
js
import "../components/Navigation/index.js"; // 컴포넌트 import
 
export const layout = (content) => {
  const fragment = document.createDocumentFragment();
 
  const header = document.createElement("header");
  // const nav = document.createElement("nav"); // 이제 nav를 만들지 않아도 된다.
 
  const paths = [
    { href: "/", text: "홈" },
    { href: "/posts", text: "게시글" },
    { href: "/settings", text: "설정" },
  ];
 
 
  /*
  paths.forEach((path) => {
    const navLink = document.createElement("a");
    navLink.setAttribute("class", "nav__link");
    navLink.setAttribute("data-link", "nav__link");
    navLink.setAttribute("href", path.href);
    navLink.textContent = path.text;
 
    nav.appendChild(navLink);
  });
  */
  //header.appendChild(nav);
 
  const navComponent = document.createElement("custom-nav"); // 커스텀 컴포넌트를 만들고
  navComponent.paths = paths; // paths 설정만 해주면 간편하게 네비게이션을 생성해서 사용할 수 있다.
  header.appendChild(navComponent);
 
  fragment.appendChild(header);
  fragment.appendChild(content);
 
  return fragment;
};

이 부분 말고도 일반 <a> 태그를 사용하는 부분이 몇 군데 있는데 그건 깃에 <nav-link>를 사용하는 코드로 수정해서 올려두었다.

#마치며

바닐라JS로 웹 컴포넌트를 구성해보았다. 무척 흥미로웠다. 여러 라이브러리나 프레임워크의 내부 역시 이와 같은 원리로 구현되어 있을까? 라는 궁금증이 생겼다. 이렇게 직접 기본부터 다루어보니 웹 개발에 대한 깊은 이해를 얻을 수 있었고, 다양한 도구를 사용할 때도 그 기반이 어떻게 동작하는지에 대한 호기심이 생겼다. 바닐라JS의 순수함을 다시 한번 느껴볼 수 있는 유익한 시간이었다. 리액트로 먼저 컴포넌트를 접해서 리액트에 비유하게 되는 것도 재미있었다. 순서가 바뀐 기분??

(ps. 위에서 설명한 메서드들 말고도 정말 다양한 메서드들이 있다. static 메서드인 observedAttributesattributeChagedCallback 메서드를 함께 쓰면 useEffect와 비슷하게 사용할 수도 있다.)

#참고자료

shadow DOM 사용하기 - Web API | MDN (mozilla.org)

(1) Learn Web Components In 25 Minutes - YouTube

<template>: 콘텐츠 템플릿 요소 - HTML: Hypertext Markup Language | MDN (mozilla.org)

사용자 정의 요소 사용하기 - Web API | MDN (mozilla.org)