npm package: @makecode/component-manager
Description: 마이크로프론트 컴포넌트 로더
Author: sung-min
Latest version: 1.0.11

# @makecode/component-manager

**@makecode/component-manager**는 커스텀 태그를 기반으로, 동적으로 커스텀 엘리먼트 스크립트를 로드하고 정의할 수 있는 라이브러리입니다. **Micro-Frontend** 아키텍처를 지원하며, 다양한 컴포넌트를 손쉽게 동적으로 로드하고 관리할 수 있습니다.

---

## 주요 기능

- **커스텀 엘리먼트 동적 로드**
  - HTML 속성 기반으로 필요한 컴포넌트 script 를 동적으로 로드합니다.
  - 컴포넌트에 필요한 style 를 로드합니다.
- **버전 관리**
  - `define` 속성에 `tagName@version` 형식을 사용하여 특정 버전의 컴포넌트를 로드할 수 있습니다. (서버에 관련 코드 구현 필요)
- **Micro-Frontend 지원**
  - 독립적으로 배포된 컴포넌트를 동적으로 로드하여 마이크로프론트엔드 구조를 구축할 수 있습니다.
- **중복 로드 방지**
  - 이미 로드되었거나 로드 중인 컴포넌트 script 는 다시 로드(define)하지 않습니다.
- **HTML `data-*` 속성 동기화**
  - `layout-block`의 `data-*` 속성을 로드된 커스텀 엘리먼트에 자동으로 동기화합니다.
- **Shadow DOM 지원**
  - `shadow` 속성을 통해 Shadow DOM을 설정할 수 있습니다.
- **최신 ES2015 브라우저 호환**
  - 최신 브라우저 환경에서 안정적으로 작동하며, Vite 빌드 도구를 사용하여 배포 가능합니다.

---

## 설치

```bash
npm install @makecode/component-manager
```

---

## 사용법

### 1. 커스텀 엘리먼트 정의

#### React 기반 커스텀 엘리먼트 정의

기본적인 단일 컴포넌트 정의 방법

```javascript
import React from 'react';
import ReactDOM from 'react-dom/client';

function ReactComponent({ name }) {
  return <div>Hello from React, {name}!</div>;
}

class ReactCustomElement extends HTMLElement {
  connectedCallback() {
    const name = this.getAttribute('data-name') || 'React';
    //ReactDOM.render(<ReactComponent name={name} />, this);
    ReactDOM.createRoot(this).render(<ReactComponent name={name} />);
  }
}
if (!customElements.get('react-custom-element')) {
  customElements.define('react-custom-element', ReactCustomElement);
}
```

여러 컴포넌트 정의 방법

```javascript
import React from 'react';
import ReactDOM from 'react-dom/client';

// 첫 번째 React 컴포넌트
function FirstComponent({ name }) {
  return <div>Hello from First Component, {name}!</div>;
}

// 두 번째 React 컴포넌트
function SecondComponent({ name }) {
  return <div>Welcome to Second Component, {name}!</div>;
}

// 커스텀 엘리먼트 등록 함수
function defineReactCustomElement(tagName, Component) {
  class ReactCustomElement extends HTMLElement {
    connectedCallback() {
      const name = this.getAttribute('data-name') || 'React';
      //ReactDOM.render(<Component name={name} />, this);
      ReactDOM.createRoot(this).render(<Component name={name} />);
    }
  }

  if (!customElements.get(tagName)) {
    customElements.define(tagName, ReactCustomElement);
  }
}

// 여러 커스텀 엘리먼트 등록
defineReactCustomElement('first-component', FirstComponent);
defineReactCustomElement('second-component', SecondComponent);
```

#### Vue 기반 커스텀 엘리먼트 정의

Vue 3.x 기반 컴포넌트 정의

```javascript
import { createApp, defineComponent, h } from 'vue';

// 첫 번째 Vue 컴포넌트
const FirstComponent = defineComponent({
  props: ['name'],
  render() {
    return h(
      'div',
      {},
      `Hello from Vue 3 First Component, ${this.name || 'Vue 3'}!`,
    );
  },
});

// 두 번째 Vue 컴포넌트
const SecondComponent = defineComponent({
  props: ['name'],
  render() {
    return h(
      'div',
      {},
      `Welcome to Vue 3 Second Component, ${this.name || 'Vue 3'}!`,
    );
  },
});

// 커스텀 엘리먼트 등록 함수
function defineVue3CustomElement(tagName, Component) {
  class Vue3CustomElement extends HTMLElement {
    connectedCallback() {
      const name = this.getAttribute('data-name') || 'Vue';
      const app = createApp(Component, { name });
      app.mount(this);
    }
  }

  if (!customElements.get(tagName)) {
    customElements.define(tagName, Vue3CustomElement);
  }
}

// 여러 커스텀 엘리먼트 등록
defineVue3CustomElement('vue3-first-component', FirstComponent);
defineVue3CustomElement('vue3-second-component', SecondComponent);
```

Vue 2.x 기반 컴포넌트 정의

```javascript
import Vue from 'vue';

// 첫 번째 Vue 컴포넌트
const FirstComponent = Vue.extend({
  props: ['name'],
  render(h) {
    return h(
      'div',
      `Hello from Vue 2 First Component, ${this.name || 'Vue 2'}!`,
    );
  },
});

// 두 번째 Vue 컴포넌트
const SecondComponent = Vue.extend({
  props: ['name'],
  render(h) {
    return h(
      'div',
      `Welcome to Vue 2 Second Component, ${this.name || 'Vue 2'}!`,
    );
  },
});

// 커스텀 엘리먼트 등록 함수
function defineVue2CustomElement(tagName, Component) {
  class Vue2CustomElement extends HTMLElement {
    connectedCallback() {
      const name = this.getAttribute('data-name') || 'Vue';
      this._instance = new Component({
        propsData: { name }, // props 값을 인스턴스에 전달
      }).$mount(this);
    }

    disconnectedCallback() {
      if (this._instance) {
        this._instance.$destroy();
        this._instance = null;
      }
    }
  }

  if (!customElements.get(tagName)) {
    customElements.define(tagName, Vue2CustomElement);
  }
}

// 여러 커스텀 엘리먼트 등록
defineVue2CustomElement('vue2-first-component', FirstComponent);
defineVue2CustomElement('vue2-second-component', SecondComponent);
```

Vue 2.x 싱글 파일 컴포넌트 방식으로 여러 커스텀 엘리먼트 정의

```javascript
//store.js에서 Vuex 스토어를 생성
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export const store = new Vuex.Store({
  state: {
    count: 0,
  },
  mutations: {
    increment(state) {
      state.count++;
    },
    decrement(state) {
      state.count--;
    },
  },
  actions: {
    asyncIncrement({ commit }) {
      setTimeout(() => {
        commit('increment');
      }, 1000);
    },
  },
  getters: {
    getCount: state => state.count,
  },
});

// 또는 모듈
// const moduleA = {
//   state: { count: 0 },
//   mutations: {
//     increment(state) {
//       state.count++;
//     },
//   },
//   getters: { getCount: state => state.count },
// };
// const store = new Vuex.Store({
//   modules: { moduleA },
// });
```

```vue
<template>
  <div>
    <h3>First Component</h3>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script>
// FirstComponent.vue
export default {
  computed: {
    count() {
      return this.$store.getters.getCount;
    },
  },
  methods: {
    increment() {
      this.$store.commit('increment');
    },
  },
};
</script>
```

```vue
<template>
  <div>
    <h3>Second Component</h3>
    <p>Count: {{ count }}</p>
    <button @click="decrement">Decrement</button>
  </div>
</template>

<script>
// SecondComponent.vue
export default {
  computed: {
    count() {
      return this.$store.getters.getCount;
    },
  },
  methods: {
    decrement() {
      this.$store.commit('decrement');
    },
  },
};
</script>
```

```javascript
import Vue from 'vue';
import { store } from './store';
import FirstComponent from './FirstComponent.vue';
import SecondComponent from './SecondComponent.vue';

// 커스텀 엘리먼트 등록 함수
function defineCustomElement(tagName, Component) {
  class VueCustomElement extends HTMLElement {
    connectedCallback() {
      const name = this.getAttribute('data-name') || 'Vue';
      new Vue({
        store, // Vuex 스토어 주입
        render: h =>
          h(Component, {
            props: {
              name,
            },
          }),
      }).$mount(this);
    }
  }

  if (!customElements.get(tagName)) {
    customElements.define(tagName, VueCustomElement);
  }
}

// 컴포넌트 등록
defineCustomElement('first-component', FirstComponent);
defineCustomElement('second-component', SecondComponent);
```

### 2. HTML에서 사용

#### 기본 사용 예제

```html
<!-- React 커스텀 엘리먼트 -->
<layout-block
  define="react-custom-element"
  src="https://cdn.test.com/react.bundle.js"
></layout-block>
<layout-block
  define="react-custom-element"
  src="http://localhost:4001/index.js"
  stylesheet="http://localhost:4001/assets/main.css"
  shadow="{ 'mode': 'closed', 'delegatesFocus': true }"
  data-name="Test React"
  >TEST</layout-block
>

<!-- Vue 커스텀 엘리먼트 -->
<layout-block
  define="vue-custom-element"
  src="https://cdn.test.com/vue.bundle.js"
></layout-block>
<layout-block
  define="vue2-custom-element"
  src="http://localhost:4002/index.js"
  data-name="Test Vue"
  lazy="true"
></layout-block>
```

#### Micro-Frontend 사용 예제

```html
<!-- Micro-Frontend Components -->
<layout-block
  define="header-module"
  src="https://cdn.test.com/api/v1/header?test=true"
></layout-block>
<layout-block
  define="product-list-module"
  src="https://cdn.test.com/api/v1/product-list?test=true"
></layout-block>
<layout-block
  define="footer-module@1.0.0"
  src="https://cdn.test.com/api/v1/footer?test=true"
></layout-block>
```

---

### 3. JavaScript에서 사용

#### Import 방식

```javascript
import '@makecode/component-manager';
```

#### 속성 변경으로 컴포넌트 변경

```javascript
const customTag = document.querySelector('layout-block');
customTag.setAttribute('src', 'https://cdn.example.com/api/v1/script'); // src 먼저 변경
customTag.setAttribute('define', 'example-component@1.2.0'); // 컴포넌트 로드 실행
customTag.setAttribute('data-title', 'Dynamic Title'); // data-* 속성 변경
document.body.appendChild(customTag);
```

---

### 4. Vite 빌드 도구와 함께 사용

`@makecode/component-manager`는 최신 브라우저 환경을 대상으로 설계되었습니다. Vite 빌드 도구를 사용하여 컴포넌트를 손쉽게 번들링할 수 있습니다.

#### Vite 설치 및 설정

1. Vite 설치

```bash
npm install --save-dev vite
```

2. `vite.config.js` 파일 생성

```javascript
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    target: 'es2015', // 커스텀 엘리먼트 호환성 확보
  },
});
```

3. Vite 빌드 실행

```bash
npx vite build
```

4. `dist` 폴더에서 생성된 번들 파일을 배포합니다.

---

## Shadow DOM 사용 예제

layout-block에 shadow 속성을 추가하면 `Shadow DOM을 사용하여 커스텀 엘리먼트를 격리된 DOM 영역에 렌더링`할 수 있습니다.

Shadow DOM을 open 모드로 설정:

```html
<layout-block
  define="example-component"
  src="https://cdn.test.com/test.js"
  shadow="open"
></layout-block>
```

Shadow DOM이 closed 모드인 경우:

```html
<layout-block
  define="example-component"
  src="https://cdn.test.com/test.js"
  shadow="closed"
></layout-block>
```

Shadow DOM 옵션을 JSON 형식으로 설정할 수 있습니다.

```html
<layout-block
  define="example-component"
  src="https://cdn.test.com/test.js"
  shadow='{"mode": "open", "delegatesFocus": true}'
></layout-block>
```

shadow 속성을 변경하면 새롭게 Shadow DOM을 생성하지 않으며, 이미 생성된 경우 경고 메시지가 표시됩니다.

```html
<layout-block
  define="example-component"
  src="https://cdn.test.com/test.js"
  shadow="open"
></layout-block>

<script>
  const customTag = document.querySelector('layout-block');

  // 이미 Shadow DOM이 attach되었기 때문에 아래 변경은 무시됩니다.
  customTag.setAttribute('shadow', 'closed');
</script>
```

### Shadow DOM 활용 시 주의사항

- Shadow DOM은 한 번 생성되면 제거하거나 재생성할 수 없습니다.
- shadow 속성이 설정되지 않으면 Shadow DOM 없이 커스텀 엘리먼트가 DOM에 추가됩니다.
- Shadow DOM을 사용하면 CSS 격리가 가능하며, 컴포넌트 스타일링에 독립성을 제공합니다.

## stylesheet 속성 사용 예제

`stylesheet` 속성을 사용하면 동적으로 로드된 커스텀 엘리먼트에 외부 CSS 파일을 연결할 수 있습니다.

```html
<layout-block
  define="example-component"
  src="https://cdn.example.com/api/v1/component"
  stylesheet="https://cdn.example.com/styles.css"
></layout-block>
```

### 주의사항

- stylesheet 속성은 반드시 CSS 파일의 URL을 가리켜야 합니다.
- 로드 실패 시 브라우저의 onerror 이벤트를 통해 에러가 로깅됩니다.
- stylesheet 속성은 Shadow DOM과 일반 DOM 모두에서 사용 가능합니다. Shadow DOM을 사용하는 경우, CSS는 Shadow DOM 내부에 적용됩니다.

---

## API

### Layout-Block 속성

| 속성 이름    | 설명                                                                                                            | 예제                                                                                        |
| ------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `define`     | 로드할 컴포넌트의 태그 이름. `tagName@version` 형식을 사용할 수 있습니다.                                       | `<layout-block define="example-component@1.0.0"></layout-block>`                            |
| `src`        | 컴포넌트를 로드할 Script SRC 입니다.                                                                            | `<layout-block define="example-component" src="https://cdn.example.com/api/v1/component">`  |
| `shadow`     | Shadow DOM을 사용할지 여부를 설정합니다. `open`, `closed`, `JSON` 형식 가능.                                    | `<layout-block shadow="open"></layout-block>`                                               |
| `stylesheet` | 커스텀 엘리먼트에 연결할 외부 CSS 파일 경로를 설정합니다. (해당 속성 사용할 경우, shadow 속성과 함께 사용 추천) | `<layout-block define="example-component" stylesheet="https://cdn.example.com/styles.css">` |
| `lazy`       | 화면에 보이는 시점에 렌더링 합니다.                                                                             | `<layout-block define="example-component" lazy="true">`                                     |
| `data-*`     | 사용자 지정 데이터 속성으로, 동적으로 로드된 커스텀 엘리먼트에 전달됩니다.                                      | `<layout-block define="example-component" data-title="Dynamic Title"></layout-block>`       |

---

## 서버 설정 예제

아래는 Express를 사용하여 컴포넌트를 제공하는 예제 서버 코드입니다.

```javascript
const express = require('express');
const path = require('path');
const app = express();

const PORT = process.env.PORT || 3000;
const MODULES_DIR = path.resolve(__dirname, 'modules');
const allowedModules = {
  'header-module': path.join(MODULES_DIR, 'header.js'),
  'product-list-module': path.join(MODULES_DIR, 'product-list.js'),
  'footer-module': path.join(MODULES_DIR, 'footer.js'),
  'footer-module@1.0.0': path.join(MODULES_DIR, 'footer.js'),
};

app.get('/api/component', (req, res) => {
  const { define } = req.query;

  if (!define) {
    res.status(400).send('Component query parameter is required.');
    return;
  }

  const [tagName, version] = define.includes('@')
    ? define.split('@')
    : [define, undefined];
  const filePath = allowedModules[define] || allowedModules[tagName];
  if (filePath) {
    res.sendFile(filePath, err => {
      if (err) {
        console.error(`Failed to send module "${define}":`, err);
        res.status(500).send('Internal Server Error.');
      }
    });
  } else {
    res.status(404).send('Module not found.');
  }
});

app.use(express.static('public', { maxAge: '1d' }));

app.listen(PORT, () => {
  console.log(`Server is running at http://localhost:${PORT}`);
});
```

---

## Micro-Frontend 아키텍처 지원

`@makecode/component-manager`를 사용하면 독립적으로 배포된 컴포넌트를 손쉽게 로드하고 관리할 수 있습니다. 이를 통해 다음과 같은 Micro-Frontend 아키텍처를 구현할 수 있습니다:

1. **모듈별 독립 배포**
   - 각 컴포넌트를 독립적으로 개발하고 CDN 또는 서버에 배포.
2. **동적 로드 및 통합**
   - `layout-block` 태그를 사용하여 필요한 컴포넌트를 동적으로 로드하고 DOM에 통합.
3. **버전 관리**
   - `define` 속성을 통해 원하는 버전의 컴포넌트를 선택적으로 로드.

---

## 라이선스

MIT