관련 객체들의 구상 클래스들을 지정하지 않고도 관련 객체들의 모음을 생성할 수 있도록 하는 생성패턴입니다
당신의 코드가 관련된 제품군의 다양한 패밀리들과 작동해야 하지만 해당 제품들의 구상 클래스들에 의존하고 싶지 않을 때 사용하세요
- Abstract Product:
- 인터페이스로 선언
- Product 그룹을 구성하는 Product 타입의 집합의 동작을 기술
- Concrete Product:
- Abstract Product 를 구현하는 클래스들
- 각 Abstract Product들은 Product 그룹에 존재하는 모든 바리에이션을 구현해야 함
- Abstract Factory:
- 인터페이스로 선언
- 각각의 Abstract Product들을 생성하기 위한 여러 메소드들의 집합
- 리턴 타입은 Abstract Product이어야 함
- Concrete Factory:
- Abstract Factory의 생성 메소드를 구현
- Concrete Factory의 메소드들은 해당 Product 그룹 내부의 동일한 바리에이션을 생산해냄
- 하지만 리턴 타입은 Abstract Product(팩토리를 사용하는 클라이언트와의 결합도를 낮추기 위함)
- OOP와 디자인 패턴을 활용하기 위해 UI 컴포넌트를 클래스로 만들기
- 드래그 앤 드랍은 HTML 스펙 상 터치 디바이스와 마우스를 사용하는 조작이 별도로 구분됨
- 전자는
touch*
이벤트, 후자는drag*
이벤트를 이용하여 구현됨 - MDN 문서 참고
export interface Component {
root: () => HTMLElement;
children?: Component[];
template(): string;
render(): void;
hydrate(): void;
}
export interface ComponentConstructor<T = any> {
new (root: () => HTMLElement, props?: T): Component;
}
- Component 는
root
를 가지는 인터페이스 - 각각 DOM구조, 렌더링, 이벤트 핸들러 관리를 담당하는
template
,render
,hydrate
메소드를 가짐
function mount(x: ComponentConstructor, root: HTMLElement = document.body) {
const app = new x(() => root);
app.render();
app.hydrate();
}
- 컴포넌트를 하나 받아 html에 렌더링해주는
mount
함수 선언
- Product 는 UI 구성요소들이 해당
- 드래그 앤 드랍을 구현하기 위해 아래 컴포넌트들을 구현
DndContainer
(ProductA)DndItem
(ProductB)DndIndicator
(ProductC)
- 먼저
Abstract Product
부터 정의
// DndContainer.ts
import { Component } from '../Component';
import { IDndItem, ToString } from './shared';
import { DndItem } from './DndItem';
import { DndComponentFactory } from './Factory';
export interface DndContainer<T extends ToString> extends Component {
props: { factory: DndComponentFactory<T>; items: IDndItem<T>[] };
draggingIndex: number;
hoveringIndex: number;
startDrag(evt: Event): void;
hoverOnItem(evt: Event): void;
drop(evt: Event): void;
}
// DndItem.ts
import { Component } from '../Component';
import { DndComponentFactory } from './Factory';
import { DndIndicator } from './Indicator';
import { ToString } from './shared';
export interface DndItem<T extends ToString> extends Component {
props: { factory: DndComponentFactory<T>; draggingIndex: number; index: number; data: T };
upperIndicator: DndIndicator;
lowerIndicator: DndIndicator;
focus(evt: Event): void;
focusOut(evt: Event): void;
}
// DndIndicator.ts
import { Component } from '../Component';
export interface DndIndicator extends Component {
enabled: boolean;
enable(): void;
disable(): void;
}
- 각 인터페이스들은 Component 인터페이스를 상속받아 확장함
startDrag
,drop
과 같이 각 객체의 고유 동작을 추상화함props
필드의 프로퍼티로DndComponentFactory
객체가 이미 정의되어 있는데, 이후 설명
- 이들을 구현하는
Concrete Product
는Web
,Mobile
의 두가지 Product Group으로 구분하여 구현 - 또한
Web
그룹과Mobile
그룹의 공통 동작은 abstract 클래스로 공통화- abstract 클래스는 그 자체로서 바로 인스턴스화 불가능
// DndContainer.ts
// Abstract DndContainer class
export abstract class DndContainerImpl<T extends ToString> implements DndContainer<T> {
children: DndItem<T>[] = [];
draggingIndex: number = -1;
hoveringIndex: number = -1;
constructor(
public root: () => HTMLElement,
public props: DndContainer<T>['props'],
) {
this.hoverOnItem = this.hoverOnItem.bind(this);
this.startDrag = this.startDrag.bind(this);
this.drop = this.drop.bind(this);
this.moveItem = this.moveItem.bind(this);
this.alignChildren();
}
protected alignChildren(): void {
this.children = this.props.items.map((child, idx) =>
this.props.factory.createDndItem(() => this.root().querySelector(`.dnd-item-slot:nth-child(${idx + 1})`)!, {
factory: this.props.factory,
draggingIndex: this.draggingIndex,
index: idx,
data: child.data,
}),
);
}
public abstract startDrag(evt: Event): void;
public abstract hoverOnItem(evt: Event): void;
protected moveItem(): void {
const draggingItem = this.props.items[this.draggingIndex];
this.props.items.splice(this.draggingIndex, 1);
this.props.items.splice(this.hoveringIndex, 0, draggingItem);
}
public drop(_evt: Event): void {
this.moveItem();
this.draggingIndex = -1;
this.hoveringIndex = -1;
this.render();
}
public abstract template(): string;
public render(): void {
this.root().innerHTML = this.template();
this.alignChildren();
this.children.forEach((item) => item.render());
this.children.forEach((item) => item.hydrate());
}
public abstract hydrate(): void;
}
// Concrete Web DndContainer class
export class WebDndContainer<T extends ToString> extends DndContainerImpl<T> {
public startDrag(evt: DragEvent): void {
const target = evt.target as HTMLElement;
const index = Array.from(target.parentElement?.children || []).indexOf(target);
this.draggingIndex = index;
this.children[index].root().style.opacity = '0.4';
this.alignChildren();
this.children.forEach((child) => child.render());
this.children.forEach((child) => child.hydrate());
}
public hoverOnItem(evt: Event): void {
evt.preventDefault();
const target = evt.target as HTMLElement;
const slot = target.closest('.dnd-item-slot')!;
const index = Array.from(slot.parentElement?.children || []).indexOf(slot);
if (index === this.hoveringIndex) return;
this.hoveringIndex = index;
}
public template(): string {
return `<div draggable="true" class="dnd-item-slot"></div>`.repeat(this.props.items.length);
}
public hydrate(): void {
this.root().addEventListener('dragstart', this.startDrag);
this.root().addEventListener('dragover', this.hoverOnItem);
this.root().addEventListener('drop', this.drop);
}
}
// Concrete Mobile DndContainer class
export class MobileDndContainer<T extends ToString> extends DndContainerImpl<T> {
public startDrag(evt: Event): void {
const target = evt.target as HTMLElement;
const slot = target.closest('.dnd-item-slot')!;
const index = Array.from(slot.parentElement?.children || []).indexOf(slot);
this.draggingIndex = index;
}
public hoverOnItem(evt: TouchEvent): void {
evt.preventDefault();
const x = evt.touches[0].clientX;
const y = evt.touches[0].clientY;
const target = document.elementFromPoint(x, y) as HTMLElement;
const slot = target.closest('.dnd-item-slot')!;
const index = Array.from(slot.parentElement?.children || []).indexOf(slot);
this.hoveringIndex = index;
}
public template(): string {
return `<div class="dnd-item-slot"></div>`.repeat(this.props.items.length);
}
public hydrate(): void {
this.root().addEventListener('touchstart', this.startDrag);
this.root().addEventListener('touchmove', this.hoverOnItem);
this.root().addEventListener('touchend', this.drop);
}
}
// DndItem.ts
// Abstract DndItem class
export abstract class DndItemImpl<T extends ToString> implements DndItem<T> {
upperIndicator: DndIndicator;
lowerIndicator: DndIndicator;
constructor(
public root: () => HTMLElement,
public props: DndItem<T>['props'],
) {
this.focus = this.focus.bind(this);
this.focusOut = this.focusOut.bind(this);
this.upperIndicator = this.props.factory.createDndIndicator(
() => root().querySelector('.upper-indicator-slot')!,
);
this.lowerIndicator = this.props.factory.createDndIndicator(
() => root().querySelector('.lower-indicator-slot')!,
);
}
public abstract focus(evt: Event): void;
public abstract focusOut(evt: Event): void;
public template(): string {
const label = this.props.data.toString() ?? '';
return `
<div class="upper-indicator-slot"></div>
<div class="dnd-item">
<span class="dnd-dash">-</span>
<span class="dnd-label">${label}</span>
</div>
<div class="lower-indicator-slot"></div>
`;
}
public render(): void {
this.root().innerHTML = this.template();
this.upperIndicator.render();
}
public abstract hydrate(): void;
}
// Concrete Web DndItem class
export class WebDndItem<T extends ToString> extends DndItemImpl<T> {
public focus(evt: DragEvent): void {
evt.preventDefault();
if (this.props.draggingIndex === -1 || this.props.index === this.props.draggingIndex) return;
this.props.index < this.props.draggingIndex ? this.upperIndicator.enable() : this.lowerIndicator.enable();
this.upperIndicator.render();
this.lowerIndicator.render();
}
public focusOut(evt: Event): void {
evt.preventDefault();
if (this.props.draggingIndex === -1 || this.props.index === this.props.draggingIndex) return;
this.props.index < this.props.draggingIndex ? this.upperIndicator.disable() : this.lowerIndicator.disable();
this.upperIndicator.render();
this.lowerIndicator.render();
}
public hydrate(): void {
this.root().addEventListener('dragenter', this.focus);
this.root().addEventListener('dragleave', this.focusOut);
}
}
// Concrete Mobile DndItem class
export class MobileDndItem<T extends ToString> extends DndItemImpl<T> {
public focus(evt: Event): void {
evt.preventDefault();
this.root().style.opacity = '0.4';
}
public focusOut(evt: Event): void {
evt.preventDefault();
this.root().style.opacity = '1';
}
public hydrate(): void {
this.root().addEventListener('touchstart', this.focus);
this.root().addEventListener('touchend', this.focusOut);
}
}
// DndIndicator.ts
export class DndIndicatorImpl implements DndIndicator {
public enabled: boolean = false;
constructor(public root: () => HTMLElement) {}
public enable(): void {
this.enabled = true;
}
public disable(): void {
this.enabled = false;
}
public template(): string {
return this.enabled
? `<div class="indicator" style="width: 300px; height: 2px; background-color: white;"></div>`
: '';
}
public render(): void {
this.root().innerHTML = this.template();
}
public hydrate(): void {}
}
-
DndIndicator 객체는 두 Product 그룹 공통으로 구현되어 Product 그룹별 구분 없이 통합하여 구현
-
현재까지의 클래스 구조도
DndContainer (Interface) - DndContainerImpl (Abstract Class)
|- WebDndContainer (Concrete Class)
|- TouchDndContainer (Concrete Class)
DndItem (Interface) - DndItemImpl (Abstract Class)
|- WebDndItem (Concrete Class)
|- TouchDndItem (Concrete Class)
Indicator (Interface)
|- IndicatorImpl (Concrete Class)
- 이러한 Product들을 만들어내는 책임을 가지는 Factory 역시도 인터페이스를 통해 한단계 추상화하는 것이 Abstract Factory 패턴의 핵심
Web
그룹과Touch
그룹을 만들어내는 팩토리의 동작을 정의하는 인터페이스 선언
// Factory.ts
import { DndContainer, WebDndContainer, MobileDndContainer } from './DndContainer';
import { DndItem, WebDndItem, MobileDndItem } from './DndItem';
import { DndIndicator, DndIndicatorImpl } from './Indicator';
import { ToString } from './shared';
export interface DndComponentFactory<T extends ToString> {
createDndItem(root: () => HTMLElement, props: DndItem<T>['props']): DndItem<T>;
createDndContainer(root: () => HTMLElement, props: DndContainer<T>['props']): DndContainer<T>;
createDndIndicator(root: () => HTMLElement): DndIndicator;
}
- DndComponentFactory 는 각 Product에 해당하는 컴포넌트 객체들을 생성하는 메소드들을 가짐
- 두 그룹(
Web
,Touch
)은 각각 이 인터페이스를 구현한 Concrete 팩토리를 가져야 함
// Web 그룹을 위한 WebDndComponentFactory
export class WebDndComponentFactory<T extends ToString> implements DndComponentFactory<T> {
createDndItem(root: () => HTMLElement, props: DndItem<T>['props']): DndItem<T> {
return new WebDndItem(root, props);
}
createDndContainer(root: () => HTMLElement, props: DndContainer<T>['props']): DndContainer<T> {
return new WebDndContainer(root, props);
}
createDndIndicator(root: () => HTMLElement): DndIndicator {
return new DndIndicatorImpl(root);
}
}
// Touch 그룹을 위한 TouchDndComponentFactory
export class MobileDndComponentFactory<T extends ToString> implements DndComponentFactory<T> {
createDndItem(root: () => HTMLElement, props: DndItem<T>['props']): DndItem<T> {
return new MobileDndItem(root, props);
}
createDndContainer(root: () => HTMLElement, props: DndContainer<T>['props']): DndContainer<T> {
return new MobileDndContainer(root, props);
}
createDndIndicator(root: () => HTMLElement): DndIndicator {
return new DndIndicatorImpl(root);
}
}
- 이 Factory 객체를 사용하는 클라이언트 객체들은 각 Factory의 구현에 의존하지 않고
DndComponentFactory
인터페이스에 의존하여야 함(Dependency Inversion Principle) - 또한 각 Factory 객체 메소드들은 Concrete Product 들이 아닌 Abstract Product들을 리턴하여 사용처에서는 Concrete Product 객체들에 의존하지 않게 됨
- 아까 Concrete Product 클래스의 생성자를 다시 보면,
export interface DndItem<T extends ToString> extends Component {
props: { factory: DndComponentFactory<T>; draggingIndex: number; index: number; data: T };
upperIndicator: DndIndicator;
lowerIndicator: DndIndicator;
focus(evt: Event): void;
focusOut(evt: Event): void;
}
export abstract class DndItemImpl<T extends ToString> implements DndItem<T> {
upperIndicator: DndIndicator;
lowerIndicator: DndIndicator;
constructor(
public root: () => HTMLElement,
public props: DndItem<T>['props'],
) {
this.focus = this.focus.bind(this);
this.focusOut = this.focusOut.bind(this);
this.upperIndicator = this.props.factory.createDndIndicator(
() => root().querySelector('.upper-indicator-slot')!,
);
this.lowerIndicator = this.props.factory.createDndIndicator(
() => root().querySelector('.lower-indicator-slot')!,
);
}
// ...
DndIndicator
객체를 필드로 가지는데, 이를props.factory
를 통해 생성함으로서 위에서 언급한 Concrete Product에 직접 의존하지 않는 구조를 가지게 됨
- 최종적으로 이 팩토리와 Product 들을 사용하는 Client 클래스는 아래와 같이 구현가능
// Application.ts
import { Component } from './Component';
import { Sidebar, SidebarImpl } from './Sidebar';
import { Button, ButtonImpl } from './atomics/Button';
import {
DndComponentFactory,
MobileDndComponentFactory,
WebDndComponentFactory,
DndContainer,
isMobileDevice,
} from './dnd';
import { bootstrapCss } from './style';
export class Application implements Component {
public dndComponentFactory: DndComponentFactory<string>;
public dndContainer: DndContainer<string>;
constructor(public readonly root: () => HTMLElement) {
this.dndComponentFactory = isMobileDevice() ? new MobileDndComponentFactory() : new WebDndComponentFactory();
this.dndContainer = this.dndComponentFactory.createDndContainer(() => root().querySelector('.dnd-slot')!, {
factory: this.dndComponentFactory,
items: [
{ id: '1', data: 'hello' },
{ id: '2', data: 'world' },
{ id: '3', data: 'this' },
{ id: '4', data: 'is' },
{ id: '5', data: 'a' },
{ id: '6', data: 'dnd' },
{ id: '7', data: 'container' },
],
});
}
template() {
return `
<div class="dnd-slot"></div>
<div class="button-slot"></div>
`;
}
render(): void {
this.root().innerHTML = this.template();
this.dndContainer.render();
this.hydrate();
}
hydrate(): void {
this.dndContainer.hydrate();
}
}
- 아래와 같이 Client는 Application의 설정 또는 환경값에서 Mobile기기인지 판단하여 알맞은 Factory 객체를 인스턴스화하여 사용함
- 이때 dndComponentFactory 필드는 위에서 언급했듯 인터페이스 타입이기 때문에 구체적인 구현에 대해 의존하지 않음
this.dndComponentFactory = isMobileDevice() ? new MobileDndComponentFactory() : new WebDndComponentFactory();
- 최종 클래스 구조도
DndContainer (Interface) - DndContainerImpl (Abstract Class)
|- WebDndContainer (Concrete Class)
|- TouchDndContainer (Concrete Class)
DndItem (Interface) - DndItemImpl (Abstract Class)
|- WebDndItem (Concrete Class)
|- TouchDndItem (Concrete Class)
Indicator (Interface)
|- IndicatorImpl (Concrete Class)
DndComponentFactory (Interface)
|- WebDndComponentFactory (Concrete Class)
|- TouchDndComponentFactory (Concrete Class)
Application
|- DndComponentFactory
|- DndContainer
|- DndItem
|- DndIndicator
- Factory 를 Concrete Product 클래스들의 생성자에 파라미터로서 전달하는 구조로 구현함
- Dependency Injection 을 생각하면 이 방법이 최선인 것 같은데..
- Concrete Product 클래스 내부에서 인스턴스화 하는 코드가 들어가면 어떤 단점이 발생할까?
- ref: 위키피디아 - 의존성 주입
- 위 문서에서는
클래스는 더 이상 객체 생성에 대한 책임이 없으며, 추상 팩토리 디자인 패턴에서처럼 팩토리 객체로 생성을 위임할 필요가 없다.
라는 설명이 있음
- 위 문서에서는