Angular essentials - 컴포넌트

  • SUNGMIN SHIN
  • 270 Minutes
  • 2018년 7월 27일

Angular essentials

(https://book.naver.com/bookdb/book_detail.nhn?bid=13761643)


07. 컴포넌트


7.1 컴포넌트란?

Angular의 애플리케이션은 컴포넌트(Component)를 중심으로 구성(CBM, Component Based Development)된다.
컴포넌트의 역활은 애플리케이션의 화면을 구성하는 뷰(View)를 생성, 관리하는 것이며 Angular는 이러한 컴포넌트를 조립하여 하나의 완성된 애플리케이션을 작성한다.


7.1.1 웹 컴포넌트

웹 애플리케이션의 뷰는 내용(Content)과 구조(Structure)를 담당하는 HTML과 스타일(디자인, 레이아웃 등)을 담당하는 CSS 조합으로 생성되며 DOM과 이벤트의 관리를 위해 JavaScript 또한 필요 함.

컴포넌트는 독립적이고 완결된 뷰를 생성하기 위해 HTML, CSS, JavaScript를 하나의 단위로 묶는 것으로 Angular에서의 컴포넌트는 W3C의 표준인 웹 컴포넌트(Web Component)를 기반으로 한다.

컴포넌트가 동작가능한 하나의 부품으로 부품화가 되려면 다른 컴포넌트의 간섭을 받지 않도록 독립된 스코프가 필요 한데 기존 객체지향 개발의 경우 로직을 클래스 단위로 부품화할 수 있지만 뷰를 부품화하는 것은 곤란하다. (CSS는 상속과 캐스케이딩이 적용되어 다른 CSS 룰셋의 영향을 받기 때문)

웹 컴포넌트는 웹 애플리케이션에서 재사용이 가능하도록 캡슐화된 HTML 커스텀 요소를 생성하는 웹 플랫폼 API의 집합으로 아래와 같은 기능을 지원한다.

  1. HTML Template - 컴포넌트의 뷰를 생성할 수 있어야 함
  2. Shadow DOM - 외부로부터의 간섭을 제어하기 위해 스코프(Scope)를 분리하여 DOM을 캡슐화(encapsulation)할 수 있어야 함
  3. HTML Import - 외부에서 컴포넌트를 호출할 수 있어야 함
  4. Custom Element - 컴포넌트를 명시적으로 호출하기 위한 명칭(alias)을 선언하여 마치 네이티브 HTML 요소와 같이 사용할 수 있어야 함

즉 컴포넌트는 HTMl, CSS, JavaScript 모두 독립적인 스코프를 가져야 하며 이를 위해 Angular는 W3C의 웹 컴포넌트를 기반으로 컴포넌트를 제공


7.1.2 컴포넌트 트리

컴포넌트는 재사용이 용이한 구조로 분할하여 작성하며 분할된 컴포넌트를 조립하여 코드의 중복 없이 UI를 생성한다.
(어떠한 복잡한 화면이라도 하나의 컴포넌트 생성하고 관리할 수 있지만 재사용이 가능한 부분들까지 화면 전체로 구성하는 것은 컴포넌트를 사용하는 취지에 부합하지 않는다 그리고 컴포넌트를 분할하고 조립하여 화면을 구성하는 것은 재사용과 유지보수 관점에서 매우 바람직하다.)

대부분의 웹 애플리케이션은 아래의 그림처럼 블록 구조를 갖는 데 HTML5의 시멘틱 태그를 사용하면 의미론적으로 명확한 구조를 가질 수 있다.
블록 구조


위 그림과 같은 블록 구조를 컴포넌트로 전환하면 아래의 그림과 같은 구조를 갖는다.


컴포넌트 트리

이를 컴포넌트 트리라고 하는데 Angular 애플리케이션은 분할된 컴포넌트로 구성되기 때문에 컴포넌트 간에 컴포넌트 트리로 표현되는 ‘부모-자식’ 관계가 형성된다.
컴포넌트 간의 ‘부모-자식’ 관계는 데이터와 이벤트가 왕래하는 상태 정보 흐름의 통로가 되어 상태 공유가 이루어지기 때문에 Angular 애플리케이션에서 매우 중요한 의미를 갖는다.


7.2 컴포넌트 기본 구조


7.2.1 컴포넌트의 코드 구조

컴포넌트의 기본 구조 확인을 위해 Angular CLI로 프로젝트를 생성하자
프로젝트를 생성하면 프로젝트 명과 일치하는 새로운 프로젝트 폴더와 함께 스캐폴딩(프로젝트 기본 골격)이 작성된다.

참고 - Scaffolding(Devlog)


1
ng new hello

프로젝트가 생성되면 ng serve 명령어로 생성한 프로젝트(hello)를 실행한다.
ng serve 명령어가 실행되면 webpack을 사용하여 소스코드와 의존 모듈을 자바스크립트로 번들링하고 Angular CLI가 내장하고 있는 개발용 서버를 실행한다.


1
2
cd hello
ng serve --open

ng new 명령어로 생성된 프로젝트는 루트 컴포넌트와 루트 모듈을 각각 1개씩 갖는다.
루트 모듈은 프로젝트에서 최상위 모듈로 main.ts에 의해 부트스트랩되며
컴포넌트 트리 상의 최상위 컴포넌트인 루트 컴포넌트는 루트 모듈에 의해 부트스트랩 된다. (아래 그림 참고)

참고 - 부트스트랩(Bootstrap)


Angular 애플리케이션의 처리 흐름

이렇게 생성된 컴포넌트 /app/app.component.ts 를 살펴보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* /src/app/app.component.ts  */

// 1. 임포트 영역(import)
import { Component } from '@angular/core';


// 2. @Component 데코레이터 영역(@Component Decorator)
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ]
})


// 3. 컴포넌트(Component) 클래스(class) 영역
export class AppComponent {
title:boolean = 'app';
}


1. 임포트 영역
컴포넌트에 필요한 의존 모듈을 임포트한다.
Angular 라이브러리의 모듈은 ‘@’가 붙어있으며 npm을 사용하여 설치한 의존 모듈과 함께 경로를 표기하지 않는다. (그 이외의 경우는 상대 경로를 명시하여야 한다.)


2. @Component 데코레이터 영역
@Component 데코레이터의 인자는 메타데이터 객체를 전달한다.
메타데이터 객체는 셀렉터, 템플릿, 스타일 정의 등의 컴포넌트 생성에 필요한 정보를 담고 있다.


3. 컴포넌트 클래스 영역
컴포넌트 뷰를 관리하기 위한 로직을 담은 클래스를 정의한다.
컴포넌트의 내부 관심사인 뷰의 관리에 집중해야하며 애플리케이션 공통 괌심사는 서비스로 분리하여야 한다.


주의해야 할 것은 @Component 데코레이터는 자신의 바로 아래에 위치한 클래스를 컴포넌트로 인식하기 때문에 컴포넌트 클래스는 @Component 데코레이터의 바로 아래 위치시켜야 한다.
(즉 @Component 데코레이터와 컴포넌트 클래스의 사이에 아무것도 존재해서는 안됨)


7.2.2 컴포넌트의 기본 동작 구조

1
2
3
4
<!-- src/app/app.component.html -->
<h1>
Welcome to {{ title }}
</h1>

위 html은 @Component 데코레이터의 templateUrl 프로퍼티에 설정된 템플릿으로 컴포넌트의 뷰는 HTML과 Angular 고유의 템플릿 문법으로 작성한다.
{{ title }}은 템플릿 문법의 하나인 인터폴레이션으로 컴포넌트 클래스의 데이터를 템플릿에 바인딩하는 데 이러한 방식을 데이터 바인딩(Data Binding)이라고 한다.
( ‘/src/app/app.component.ts’의 컴포넌트 클래스의 프로퍼티 title의 값이 {{ title }}에 바인딩 됨 )


컴포넌트는 데이터 바인딩에 의해 템플릿과 컴포넌트 클래스의 데이터를 유기적으로 연계하는 데 기본적인 동작구조는 아래 그림과 같다.

컴포넌트의 기본 동작 구조


7.3 컴포넌트 작성 실습


7.3.1 네이밍 컨벤션

Angular Style Guide에서 권장하는 네이밍 패턴은 아래와 같다.

기능을 명확히 설명하는 구성요소의 이름.구성요소 타입.ts

구성 요소의 이름이 여러 단어로 구성되는 경우, 하이픈으로 구별된 케밥 표기법(kebab-case)을 사용하는 것이 좋음

1
todo-list.component.ts


7.3.2 컴포넌트 클래스(Component Class)

컴포넌트 클래스는 아래와 같이 파일명을 기반으로 파스칼 표기법(pascal-case)에 따라 명명한다.

1
2
// src/app/hello/hello.component.ts
export class HelloComponent {}

선언한 클래스는 모듈화하여 외부에 공개하기 위해 export 클래스를 사용하였다. TypeScript와 Angular에서의 모듈은 아래와 같이 다른 개념을 가지고 있다.

ES6(TypeScript)의 모듈
애플리케이션을 구성하는 개별적 요소로 일반적으로는 파일단위로 분리되어 있으며 필요에 따라 어플리케이션은 명시적으로 모듈을 로드

Angular의 모듈
관련된 Angular 구성요소를 하나로 묶어 애플리케이션을 구성하는 하나의 단위로 만드는 역활을 하며 컴포넌트, 디렉티브, 서비스등의 Angular 구성요소는 모듈에 등록되어야 사용할 수 있다.

@Component 데코레이터가 선언되지 않은 이 시점에서의 클래스는 컴포넌트 클래스가 아닌 일반 클래스 상태이다.


7.3.3 컴포넌트 클래스(Component Class)

클래스를 컴포넌트화 하기 위해서는 @Component 데코레이터를 클래스의 바로 앞(위)에 호출하여 해당 클래스가 컴포넌트 클래스임을 알려야한다. (데코레이터는 ES7-Stage-2(Draft, 초안) 단계에 있는 스펙으로 자세한 내용은 아래 참고)

1
2
3
// src/app/hello/hello-component.ts
@Component()
export class HelloComponent { }

Angular에서 데코레이터는 중요한 개념으로 사용되며 아래와 같이 4가지 유형의 데코레이터를 제공한다.

참고 - 우아한 설계의 첫걸음, ES7의 decorator


7.3.4 Angular 라이브러리 모듈 임포트

@Component 데코레이터는 Angular Core 패키지에 정의되어 있다.
import 키워드를 사용하여 Angular Core패키지를 임포트 한다. (Angular 라이브러리 모듈의 경우 @가 붙어 있으며 경로를 명시하지 않음)

1
2
3
4
import { Component } from '@angular/core';

@Component()
export class HelloComponent { }


@Angular/core 패키지에는 Component 외에도 많은 모듈로 구성되어 있으나 위에서는 객체 디스트럭처링(비구조화 할당)을 통해 필요한 모듈만 임포트하였다.

참고 - 비구조화 할당(MDN)


7.3.5 메타데이터

@Component 데코레이터의 다른 역활은 컴포넌트 설정 정보를 담고 있는 메타데이터 객체를 인자로 전달받아서 컴포넌트 클래스에 반영하는 것으로 전달할 메타데이터 객체의 중요 프로퍼티는 아래와 같다.

1
2
3
4
5
6
7
8
9
import { Component } from '@angular/core';

@Component({
selector: 'app-hello', // selector
templateUrl: './hello.component.html', // tempalteUrl, template
styleUrls: ['./hello.component.css'] // styleUrls, Styles
})

export class HelloComponent { }


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Component } from '@angular/core';

@Component({
selector: 'app-hello', // selector
template: // template(inline)
`
<h1>hello</h1>
`,
styles: [ // styles(inline)
`
h1 {background:red; border:1px solid #000}
`
]
})

export class HelloComponent { }


selector 프로퍼티

selector는 컴포넌트의 뷰를 마크업으로 표현할 때 사용하는 이름으로 템플릿에서 HTML 요소의 태그 이름처럼 사용한다.
다른 애플리케이션의 selector나 HTML 요소(element)와 충돌을 방지하기 위해 접두사(prefix)를 추가하여 케밥 표기법으로 아래의 예와 같이 명명하도록 권장하고 있다.

1
<app-hello></app-hello>


templateUrl / template 프로퍼티

컴포넌트는 화면을 구성하는 뷰를 생성하고 관리하는 것이 역활이므로 반드시 뷰(템플릿)를 가져야 한다. 컴포넌트의 뷰(template)는 templateUrl 또는 template 프로퍼티에 선언한다.
templateUrl 프로퍼티에는 외부 파일로 작성한 템플릿의 상대 경로를 지정한다. 템플릿을 외부 파일로 분리하는 것이 관심사가 다른 뷰(template)와 로직(Component Class)을 분리한다는 측면에서 바람직하지만 뷰(템플릿)가 간단한 경우에는 template 프로퍼티에 문자열의 형태로 직접 기술할 수도 있다.(인라인 템플릿) 이때 ES6의 템플릿 문자열(template string)인 백틱(backtick)문자 을 사용하는 데 일반적인 문자열과 달리 줄바꿈, 들여쓰기 등 템플릿 문자열 내의 모든 화이트 스페이스(white-space)가 그대로 적용된다.

참고 - Template Literals(MDN)


stylesUrl / styles 프로퍼티

템플릿을 위한 스차일을 선언하는 프로퍼티로 templateUrl / template 프로퍼티와 같이 외부 파일이나 인라인 형태로 작성할 수 있다.
프로퍼티 명을 보면 알 수 있 듯 프로퍼티의 값은 배열로 지정한다.

1
2
3
4
5
6
7
@Component({
StyleUrls: [
'./base.component.css',
'./main.component.css',
'./page.component.css'
]
})


뷰 캡슐화(view Encapsulation)
컴포넌트 스타일은 해당 컴포넌트만을 위한 것으로 엘리먼트 선택자를 사용해도 다른 컴포넌트에 속해있는 요소들에게는 적용되지 않는다. 이 같은 특성은 웹 컴포넌트의 Shadow DOM을 구현한 것으로 컴포넌트의 DOM을 캡슐화(encapsulation)하여 외부로부터의 간섭을 제어한다. 이는 마치 변수의 스코프와 유사 한데 기존 CSS는 전역변수라고 하면 컴포넌트의 CSS는 지역변수라고 할 수 있다.


7.3.6 컴포넌트 클래스와 템플릿의 연동

컴포넌트 클래스는 템플릿의 상태(state)를 관리한다. 데이터 바인딩(Data Binding)을 통해 템플릿에 데이터를 제공하거나 템플릿에서 발생한 이벤트를 처리한다.

1
2
3
4
5
6
7
8
// src/app/hello/hello.component.ts
export class HelloComponent {
name: string;

setName(name: string) {
this.name = name;
}
}

위 코드를 더 자세히 보면 아래 그림과 같다.

컴포넌트 클래스와 템플릿의 연동

  1. #inputYourName 는 템플릿 참조 변수(template reference variable)로 템플릿 내의 DOM요소에 대한 참조로서 템플릿 내에서 변수처럼 사용한다. 여기서 inputYourName.value는 input(#inputYourName)의 value를 취득하여 버튼의 클릭 이벤트 핸들러인 setName의 인자로 전달
  2. 클릭 이벤트가 발생하면 컴포넌트 클래스에 정의된 setName를 호출한다. 이때 인자로 전달된 input의 value값이 컴포넌트 클래스의 name 프로퍼티에 저장된다. button 엘리먼트의 (click)은 이벤트 바인딩(event binding이라 하며 click 이벤트 발생 시 지정한 함수(핸들러 함수)를 호출한다.
  3. 컴포넌트 클래스의 name 프로퍼티에 저장된 값(input 요소의 value)은 템플릿 문법인 인터플레이션( {{ name }} )에 의해 h2 요소에 삽입된다.

이제 hello 컴포넌트는 완성되었지만 사용을 위해서는 몇 가지 작업이 더 필요하다.


7.3.7 컴포넌트의 호출

루트 컴포넌트(EX> appComponent)의 경우 루트 모듈이 부트스트랩하기 때문에 애플리케이션이 실행되면 루트 컴포넌트의 뷰가 브라우저에 표시된다. 하지만 루트 컴포넌트가 아닌 컴포넌트는 다른 텀포넌트의 호출에 의해 브라우저에 랜더링 된다. (즉 루트 컴포넌트가 아니라면 호출없이 브라우저에 랜더링 될 수 없다는 이야기)

컴포넌트를 호출하는 방법은 호출하고자 하는 컴포넌트의 selector(@Component 데코데이터에 메타데이터로 지정한)를 html엘리먼트의 형태로 컴포넌트의 템플릿에 포함하는 것으로 가능하다. 이때 호출 된 컴포넌트는 호출한 컴포넌트의 자식 컴포넌트가 된다. 컴포넌트의 부모, 자식 관계를 데이터와 이벤트가 왕래하는 정보 흐름의 통로가 되어 이를 통해 상태 공유가 이루어지기 때문에 Angular 애플리케이션에서 중요한 의미를 갖는다.

1
2
<!-- src/app/app.component.html -->
<app-hello></app-hello>


7.3.8 모듈에 컴포넌트 등록

hello 컴포넌트를 모듈(EX> app.module.ts)에 등록하는 것이 컴포넌트 사용의 마지막 단계로 모듈은 관련된 Angular 구성요소를 하나로 묶어 애플리케이션을 구성하는 하나의 단위를 만드는 역활을 한다.
(단 이때 angular cli의 명령어를 통해 컴포넌트를 추가했다면 자동으로 모듈에 등록된다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';

/* 1. import를 통해 HelloComponent를 가져옴(상대경로로 작성하며 확장자는 생략해도 무방) */
import { HelloComponent } from './hello/hello.component';

/* 2. @NgModule 데코레이터에 인자로 전달되는 메타데이터의 declarations 프로퍼티에 'HelloComponent'를 선언 */
@NgModule({
declarations: [
AppComponent,
HelloComponent
],
imports: [
BrowserModule
],
providers: [],
bootstrap: [
AppComponent
]
})

export class AppModule {}


declarations 프로퍼티에는 모듈에 소속될 구성요소(컴포넌트, 디렉티브, 파이프)의 리스트를 지정한다. 그 외 @NgModule 데코레이터의 인자로 사용되는 메타데이터 객체의 중요 프로퍼티는 아래와 같다.


7.4 템플릿과 템플릿 문법

7.4.1 템플릿이란?

템플릿은 HTML과 Angular 고유의 템플릿 문법으로 UI의 최소단위인 컴포넌트의 뷰를 정의하는 데 동적으로 변하는 데이터는 컴포넌트 클래스(Component Class)가 관리하며 템플릿 문법의 데이터 바인딩에 의해 정적(static) HTML에 포함된다.

템플릿의 뷰 생성과정

Angular는 템플릿과 컴포넌트 클래스로 뷰와 모델(데이터와 비즈니스 로직)을 분리한다.

Angular는 컴포넌트 기반의 개발 프레임워크이기 때문에 MVC, MVVM 패턴과 일치하지는 않지만 템플릿은 뷰(View)를 나타내고 컴포넌트 클래스는 컨트롤러(Controller)와 뷰모델(ViewModel)의 일부를 담당한다고 할 수 있다.

참고 - MVC / MVP / MVVM / MVI


MVC와 MVVM



DOM은 상태(state)를 가지고 있으며 이 상태는 뷰와 모델은 분리되어 있지만 공유되어야 한다. (예를 들어 checkbox 엘리먼트의 체크여부 상태등등)

DOM의 상태가 변화하면 템플릿은 변화를 감지하고 변화된 상태를 컴포넌트 클래스로 전달하고 이때 컴포넌트 클래스는 비즈니스 로직을 실행하고 템플릿에 실행 결과를 공유하고 이 결과를 가지고 템플릿은 DOM을 업데이트 한다.

Angular의 뷰와 모델

이전의 웹 애플리케이션은 DOM을 직접 조작하는 방식으로 동작하지만 Angular의 템플릿은 선언형 프로그래밍 방식으로 뷰와 모델의 관계를 관리한다. 변화 감지 매커니즘 위에서 동작하는 데이터 바인딩을 통해 템플릿과 컴포넌트 클래스를 긴밀히 연결하고 동기화를 유지한다.

데이터 바인딩과 변화 감지

템플릿은 Angular에만 존재하는 개념은 아니며 브라우저의 요청에 동적으로 대응하여야 하는 웹 애플리케이션의 경우 서버 측에서 템플릿을 사용하는 경우가 많다.


7.4.2 템플릿 문법

템플릿 문법은 템플릿을 작성하기 위한 Angular 고유의 확장 표기법으로 템플릿과 컴포넌트 클래스 간 데이터 공유를 위한 데이터 바인딩과 동적으로 DOM구조, 스타일등을 변경할 수 있는 빌트인 디렉티브 등을 지원한다.
(정적인 뷰는 HTML만으로 정의할 수 있지만 컴포넌트와 연계하여 동적으로 변화하는 뷰를 정의하려면 템플릿 문법이 필요하다)

Angular에서 제공하는 템플릿 문법은 아래와 같다.(자세한 내용은 뒤에 나옴)

템플릿에서는 금지항목도 아래와 같이 금지항목도 존재한다.

템플릿 내 사용금지 항목 비고
script 요소 보안 상의 문제로 사용 금지
대입연산자(=, +=, -=)
증감연산자(++, –)
비트연산자(|, &)
객체 생성 연산자(new)
템플릿 표현식 내에서 데이터를 변경할 수 있는 연산은 사용 금지한다.
전역 스코프를 갖는 빌트인 객체 window, document, location, console 등

또한 html, body, base 요소는 템플릿 내 사용금지 항목은 아니지만 사용해서는 안되는 데 그 이유는 모든 컴포넌트는 루트 컴포넌트의 자식이고 루트 컴포넌트는 html, body의 자식 요소이기 때문이며 base 요소는 head 내에 포함되는 요소로 상대 경로의 루트를 정의하는 요소인데 Angular에서는 src/index.html에 base 요소를 사용하여 상대 경로 루트를 정의해 두었기 때문에 사용할 이유가 없다.

MDN - base


7.5 데이터 바인딩

7.5.1 데이터 바인딩이란?

구조화된 웹 애플리케이션을 구축하기 위해서는 뷰와 모델의 분리가 필수인데 하지만 분리된 뷰와 모델은 유기적으로 동작해야하는 모순이 생기게 되는 데 이 모순을 데이터 바인딩이 해결해준다.

데이터 바인딩은 뷰와 모델을 하나로 연결하는 것을 의미하며 Angular에서의 데이터 바인딩은 템플릿(View)와 컴포넌트 클래스의 데이터(model)를 하나로 묶어 유기적으로 동작하게 하는 것을 말하며 데이터 바인딩을 사용하는 것은 템플릿의 정적 HTML과 컴포넌트의 동적 데이터를 하나로 묶어 브라우저에 표시할 완성된 뷰를 만들기 위함이다.

1
2
3
4
$(function(){
var title = 'app works!';
$('header h1').text(title); // DOM과 연동
});

위의 코드는 jQuery에 의한 DOM 조작으로 기존의 웹 애플리케이션은 자바스크립트 DOM API를 사용하여 DOM을 직접 조작(manipulation)하는 방식이기 때문에 뷰와 모델 간의 관계를 느슨하게 결합하기 어려운 구조를 가지며 DOM을 조작하기 위해서는 DOM의 구조를 파악하고 있어야 함과 동시에 구조가 변경되면 자바스크립트의 로직도 변경될 가능성이 높다.


1
2
3
4
5
6
7
8
9
10
@Component({
selector: 'hello-app',
template: `
<h1> {{ title }} </h1>
`
})

export class helloComponent {
title: string = 'app works!';
}

Angular는 DOM에 직접 접근하지 않고 템플릿과 컴포넌트 클래스의 상호 관계를 선언하는 방식( 선언형 프로그래밍 )으로 뷰와 모델의 관계를 관리하는 데 이 때 사용되는 것이 데이터 바인딩이며 이를 통해 템플릿과 컴포넌트 클래스는 연결된다. 이러한 데이터 바인딩은 템플릿 문법으로 기술되며 이렇게 기술된 템플릿은 JIT또는 AOT 컴파일러에 의해 브라우저가 이해할 수 있는 자바스크립트도 컴파일 된다.

위 예제의 경우 템플릿에서 직접 컴포넌트 클래스의 프로퍼티를 참조하기 때문에 DOM에 접근하고 조작하는 코드($(‘div’)라던지 document.getElementById와 같은)를 작성할 필요가 없다.
그러므로 DOM의 구조를 파악하고 있을 필요도 템플릿(DOM)의 구조가 변경되어도 컴포넌트 클래스를 변경할 필요가 없다.

이렇게 Angular의 데이터 바인딩은 뷰와 모델의 관계를 기존의 웹 애플리케이션의 방식보다 느슨하게 결합하므로 뷰와 모델을 보다 깔끔하게 분리할 수 있으며 코드도 더 간결하게 개발할 수 있다.

  • JIT(Just in Time), AOT(Ahead of time) 컴파일
    JIT는 실행시점에 소소코드를 번역한다. 설치는 빠르지만 실행시점에 느리게 된다.
    번역한 정보를 메모리에 올려야 하기 때문에 메모리를 많이 먹는다.
    AOT는 설치시점에 소스코드를 번역한다. 설치가 느리고, 번역을 해서 따로 파일을 저장하기 때문에 용량을 많이 먹게 된다.
    하지만 실행시점에 미리 번역한 파일을 실행하므로 빠르게 실행이 가능하다.
    즉, JIT는 실행 디바이스에서 매번 번역해야 하므로 느리고, AOT는 미리 번역해서 저장해 두기 때문에 빠르다.
    출처 - 개발자로 살아남기


7.5.2 변화 감지(Change Detection)

변화감지는 뷰와 모델의 동기화를 유지하기 위해 상태변화를 감지하고 이를 반영하는 것(즉 상태 변화를 감지하여 뷰에 반영하는 것)으로 데이터 바인딩은 변화 감지 매커니즘의 토대위에서 수행된다.
Angular는 양방향 바인딩과 단방향 바인딩을 모두 지원하며 zone.js 라이브러리를 사용하여 네이티브 DOM이벤트를 사용하여도 변화 감지가 수행되도록 개선되었다.
(기존 AngularJS에서는 양방향 바인딩만을 지원하였으며 AngularJS에서 제공하는 이벤트 만을 사용하여야 하는 등의 제약이 있었음)

사실 Angular는 양방향 바인딩을 지원하지 않으며 실제 동작은 이벤트 바인딩과 프로퍼티 바인딩의 조합으로 이루어진다.


변화감지

뷰의 상태 변화는 DOM 이벤트를 캐치하는 것으로 감지할 수 있다. 하지만 모델은 HTML요소가 아니므로 이벤트가 발생하지 않기때문에 모델 변화 감지를 위한 별도의 조치가 필요하다.
(모델이 변경된다는 것은 컴포넌트 클래스의 프로퍼티 값이 변경되는 것을 의미한다.)

위 예제에서 클릭 이벤트에 의해 컴포넌트 클래스의 name 프로퍼티 값이 변화하였다. Angular는 컴포넌트 클래스의 프로퍼티 값이 변경되는 상황(즉 어떤 경우 모델이 변화하는 지)에 주목하는 데 사실 모델이 변화할 가능성이 있는 경우는 그다지 많지 않다.

위와 같은 비동기처리가 수행될 때 컴포넌트 클래스의 데이터가 변경될 수 있으며 이러한 모델의 변경 상황들을 감시한다.

이를 위해 zone.js는 addEventListener, Timer 함수, XMLHttpRequest, Promise등을 몽키패치 한다.

1
2
3
4
5
6
7
8
9
10
11
12
// node_modules/zone.js/dist/zone.js
function zoneAwareAddEventListener() {...}
function zoneAwareRemoveEventListener() {...}
function patchTimer() {...}
function zoneAwarePromise() {...}
...

window.prototype.addEventListener = zoneAwareAddEventListener;
window.prototype.removeEventListener = zoneAwareRemoveEventListener;
window.prototype.promise = zoneAwarePromise;
window.prototype.setTimeout = patchTimeout;
...

zone.js는 위와 같이 일부 함수를 프락시(proxy)로 재정의하여 대체한다. 즉 이벤트 또는 promise가 프락시로 래핑되는 데 이러한 개념을 몽키패치라고 한다.

몽키패치
표준속성(메서드)에서 확장하는 개념을 말하며 JS에서는 표준에서 비표준을 통한 확장을 일컫는 용어로 사용
즉 개발 편의성을 위해 비표준 기능을 확장하는 것
(예를 들어 표준메서드로 존재하는 Array.indexOf의 기능을 확장하는 사용자 정의 메서드로 대체하는 것)


참고 - javascript 프록시(DailyEngineering)


모델을 변화시킬 수 있는 비동기 처리가 호출되면 패치를 통해 호출을 후킹한다.
비동기 처리 호출을 후킹할 수 있다는 것은 변화를 감지할 수 있다는 의미로 이 후킹 로직 내에서 변화 감지를 수행하고 변화가 감지될 때마다 Digest loop를 실행하여 모델의 변화를 뷰에 반영한다.


7.5.3 데이터 바인딩

Angular는 단방향, 양방향 데이터 바인딩을 지원하여 기존의 웹 프로그래밍에서 사용하는 DOM조작 방식보다 간편하게 데이터를 가져와서 뷰에 표현할 수 있다.

데이터 바인딩 데이터의 흐름 문법
인터폴레이션 컴포넌트 클래스 -> 템플릿 {{ expression }}
프로퍼티 바인딩 컴포넌트 클래스 -> 템플릿 [property]=”expression”
어트리뷰트 바인딩 컴포넌트 클래스 -> 템플릿 [attr.attribute-name]=”expression”
클래스 바인딩 컴포넌트 클래스 -> 템플릿 [class.class-name]=”expression”
스타일 바인딩 컴포넌트 클래스 -> 템플릿 [style.style-name]=”expression”
이벤트 바인딩 컴포넌트 클래스 <- 템플릿 (event)=”statement”
양방향 데이터 바인딩 컴포넌트 클래스 <-> 템플릿 [(ngModel)]=”property”


표현식(expression)
표현식은 값, 변수, 연산자의 조합으로 이 조합은 하나의 값으로 평가될 수 있는 식이다.
(EX> 1+1, ‘안녕’+’하세요’, ‘hello’ 등등등)


인터폴레이션(interpolation)

표현식을 두 개의 중괄호로 열고닫은 형식으로 표현하는 템플릿 문법으로 단방향 데이터 바인딩에 사용하며 표현식의 평과 결과(expression)를 문자열로 반환하여 템플릿에 바인딩한다.

인터폴레이션
{{ expression }}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<p>name: {{ name }}</p>
<p>age: {{ age }}</p>
<p>admin: {{ admin }}</p>
<p>address: {{ address }}</p>
<p>gender: {{ gender }}</p>
<p>sayHi: {{ sayHi() }}</p>
<p>age * 10: {{ age * 10 }}</p>
<p>age > 10: {{ age > 10 }}</p>
<p>'string': {{ 'string' }}</p>
`
})
export class AppComponent {
name: string = 'angular';
age: number = 20;
admin: boolean = true;
address = {
city: 'seoul',
country: 'korea'
};

sayHi() {
return `Hi! my name is ${this.name}`;
}
}

컴포넌트 클래스의 프로퍼티가 문자열이 아닌 경우 문자열로 변환되며 존재하지 않는 프로퍼티에 접근하는 경우(gender) 에러 발생 없이 아무 것도 출력하지 않는다.


프로퍼티 바인딩

프로퍼티 바인딩은 표현식의 평가 결과를 HTML요소의 DOM 프로퍼티에 바인딩한다.

1
2
프로퍼티 바인딩
<element [property]="expression">...</element>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Component } from '@angular/core";

@Component({
selector: 'app-root',
template: `
<!-- input 요소의 value 프로퍼티에 컴포넌트 클래스 프로퍼티 name의 값을 바인딩 -->
<input type="text" [value]="name" />

<!-- p 요소의 innerHTML 프로퍼티에 컴포넌트 클래스 프로퍼티 contents의 값을 바인딩 -->
<p [innerHTML]="contents"></p>

<!-- img 요소의 src 프로퍼티에 컴포넌트 클래스 프로퍼티 imageUrl의 값을 바인딩 -->
<img [src]="imageUrl"><br />

<!-- button 요소의 disabled 프로퍼티에 컴포넌트 클래스 프로퍼티 isDisabled의 값을 바인딩 -->
<button [disabled]="isDisabled">disabled button</button>
`
})
export class AppComponent {
name:string = 'shin';
contents:string = 'contentssssssss';
imageUrl:string = 'img/common/img.jpg';
isDisabled:boolean = true;
}

인터폴레이션({{ expression }})은 순수한 문자열을 반환하기 때문에 HTML 콘텐츠, 어트리뷰트의 값 등 템플릿의 어디에서나 사용할 수 있다.

1
2
<p> {{ contents }} </p>
<p [innerHTML]="contents"></p>

위 두 코드는 동일한 결과를 출력한다. Angular는 뷰를 랜더링하기 이전에 인터폴레이션을 프로퍼티 바인딩으로 변환하는 과정을 거친다 사실 인터폴레이션은 프로퍼티 바인딩의 문법적 설탕이다.

참고 - syntactic sugar란 무엇인가?

프로퍼티 바인딩에는 객체를 포함한 모든 값을 사용할 수 있는 데 이는 DOM 노드 객체의 프로퍼티에는 객체를 포함한 모든 값을 할당할 수 있기 때문이다. 이 특성을 이용하여 부모 컴포넌트에서 자식 컴포넌트로 값을 전달하는 경우 프로퍼티 바인딩을 사용한다.(7.9절에서 자세히 기술 됨)


어트리뷰트 바인딩

어트리뷰트 바인딩은 표현식의 평가 결과를 HTML 어트리뷰트에 바인딩한다.

1
2
어트리뷰트 바인딩
<element [attr.attribute-name]="expression"> ... </element>

프로퍼티와 어트리뷰트는 모두 속성으로 번역되어 같은 것으로 오해할 수 있으나 이들은 서로 다른 것으로 어트리뷰트는 HTML문서에 존재하는 것으로 어트리뷰트의 값은 변하지 않지만 프로퍼티는 DOM 노드 객체에 있는 것으로 동적으로 변한다.

1
<input id="inpText" type="text" value="이름을 입력해주세요" />

위 input 요소(element)에는 id, type, value라는 3가지 어트리뷰트를 가지고 있다.
브라우저가 위 코드를 파싱하면 DOM 노드 객체 HTMLInputElement가 생성되고 이 객체는 다양한 프로퍼티를 소유한다.
input 요소의 모든 어트리뷰트는 HTMLInputElement객체의 attributes 프로퍼티로 변환되고 이 것은 getAttribute 메서드로 취득할 수 있다.

참고 - Parsing이란? Parser란?


1
2
3
document.getElementById('inpText').getAttribute('id');
document.getElementById('inpText').getAttribute('type');
document.getElementById('inpText').getAttribute('value');

HTMLInputElement 객체의 attributes 프로퍼티

DOM 노드 객체의 attributes 프로퍼티는 원래 변하지 않는 초기 기본 값을 나타내므로 input 요소가 사용자 입력에 의해 상태(값)이 변경된다고 하더라도 attributes 프로퍼티의 value값은 변하지 않는다.
하지만 DOM은 상태(state, 예를 들어 input 요소의 값등)를 가지고 있으며 사용자에 의해 변경될 수 있다. 따라서 DOM 노드 객체는 상태 변화를 관리하기 위한 프로퍼티를 갖는다.
HTMLInputElement 객체를 예를 들면 사용자에 의해 상태가 변경되더라도 attributes 프로퍼티의 value값은 바뀌지 않지만 HTMLInputElement의 프로퍼티인 value의 값은 상태에 따라 변경된다.
이해를 돕기 위해 아래의 예를 보자.

value 프로퍼티는 attributes.value와는 다르게 동작하는 데 이와 같이 주의할 점은 HTML 어트리뷰트와 프로퍼티(상태 변화를 관리하기 위한)가 언제나 1:1로 매핑되는 것이 아니라는 것이다.

이와 같이 input요소의 value 어트리뷰트는 DOM(input 요소가 파싱된 후) 노드 객체 프로퍼티의 초기 값을 의미하며 DOM 노드 객체 프로퍼티는 현재 상태의 값을 의미한다.

정리

  • 엘리먼트를 브라우저가 파싱하면 DOM노드 객체가 생성
  • 이때 엘리먼트의 어트리뷰트(value, id, type등) 값은 생성된 DOM 노드 객체의 attributes 프로퍼티로 변환되고 어트리뷰트중 프로퍼티로 매핑해야 하는 것은 매핑된다.
  • 매핑된 어트리뷰트와 프로퍼티는 모두 1:1 매핑되는 것은 아니고 어트리뷰트만 존재하는 경우, 프로퍼티만 존재하는 경우 혹은 1:! 매핑되었지만 서로 다르게 동작하는 등 종류에 따라 차이가 있다.

어트리뷰트와 프로퍼티의 차이점을 바탕으로 Angular가 아래 코드를 어떻게 출력할 것인지 예측해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<!-- 프로퍼티 바인딩 -->
<input type="text" [value]="name" />

<!-- 어트리뷰트 바인딩 -->
<input type="text" [attr.value]="name" />
`
})
export class AppComponent {
name:string = 'kim';
}


위 컴포넌트는 아래와 같이 변환될 것이다.

1
2
3
4
5
<!-- 프로퍼티 바인딩의 결과 -->
<input type="text" />

<!-- 어트리뷰트 바인딩의 결과 -->
<input type="text" value="kim" />

td 요소의 “colspan” 어트리뷰트와 같이 매핑하는 프로퍼티가 존재하지 않는 경우는 반드시 어트리뷰트 바인딩을 사용해야 한다.

1
<td [attr.colspan]="tableLength"> ... </td>


클래스 바인딩

클래스 바인딩은 HTML요소의 class 어트리뷰트에 클래스 추가 또는 삭제할 수 있으며 상황에 따라 다항, 단항 두가지 방식으로 사용할 수 있다.

1
2
<element [class.class-name]="booleanExpression"> ... </element>
<element [class]="class-name-list"> ... </element>


단항 클래스 바인딩

단항 클래스 바인딩은 좌변에는 ‘class’ 뒤에는 html 요소에 반영할 class 어트리뷰트 명을 지정하고 우변에는 참이나 거짓으로 평가될 수 있는 표현식을 바인딩 한다.

1
2
<!-- 컴포넌트 클래스 프로퍼티 isError의 값이 참인 경우 class="alert"가 적용 -->
<div [class.alert]="isError"> ... </div>

표현식이 참인 경우 지정한 class 어트리뷰트 명이 추가되고 그렇지 않은 경우 삭제되는 데 기존에 다른 class가 존재하는 경우에는 존재하는 class에 영향 없이 추가, 삭제되며 추가하려는 class가 이미 존재하는 경우라면 표현식에 따라 표기되거나 삭제 된다.

1
2
3
4
5
6
7
<div class="rounded" [class.alert]="isError"> ... </div>


<!-- isError가 true인 경우 -->
<div class="rounded alert"> ... </div>
<!-- isError가 false인 경우 -->
<div class="rounded"> ... </div>


1
2
3
4
5
6
7
<div class="alert" [class.alert]="isError"> ... </div>


<!-- isError가 true인 경우 -->
<div class="alert"> ... </div>
<!-- isError가 false인 경우 -->
<div> ... </div>


다항 클래스 바인딩

다항 클래스 바인딩은 좌변에는 “class”를 지정하고 우변에는 HTML 요소에 반영할 클래스 이름을 리스트로 바인딩한다. (이때 리스트의 구분은 공백)

1
<div [class]="className1 className2"> ... </div>

단항 클래스 바인딩과의 가장 큰 차이는 기존에 선언되어 있는 class명을 대체한다는 것이다.

1
2
3
4
<div class="className1 className2" [class]="className3 className4"> ... </div>

<!-- 적용 후 -->
<div class="className3 className4"> ... </div>

클래스 바인딩은 주로 하나의 클래스를 조건에 의해 추가, 삭제하는 용도로 사용되며 여러 개의 클래스를 지정할때도 사용할 수 있으나 ngClass 디렉티브를 사용하면 좀 더 세밀하게 제어할 수 있다.


스타일 바인딩

스타일 바인딩은 HTML요소의 style 어트리뷰트에 스타일을 바인딩 한다.

1
2
스타일 바인딩
<element [style.style-property]="expression">

만약 지정하는 스타일 값에 단위가 필요하다면 아래와 같이 사용할 수 있다.

1
<div [style.background-color]="'white'" [style.font-size.px]="'16'"> ... </div>

주의할 것은 style 어트리뷰트에 의해 이미 스타일이 지정되어 있을 때 중복되지 않은 스타일은 추가되고 중복된 스타일은 대체된다. 즉 스타일 바인딩은 기존의 style 어트리뷰트보다 우선한다.

스타일 프로퍼티의 이름(border-radius 등)은 케밥 표기법(kebab-case) 또는 카멜 표기법(camelCase)를 사용한다.

또한 아래와 같이 삼항연산자를 응용하여 조건에 따라 다른 스타일을 지정할 수도 있다.

1
<button [style.background-color]=" isActive ? '#ccc' : '#fff' "> ... </button>

스타일 바인딩은 주로 하나의 인라인 스타일을 조건에 의해 추가하는 용도로 사용되며 여러개의 인라인 스타일을 추가해야 하는 경우 ngStyle 디렉티브를 사용한다.


이벤트 바인딩

이벤트 바인딩(event binding)은 뷰의 상태 변화(체크박스 체크, input에 텍스트 입력, 버튼 클릭 등)에 의해 이벤트가 발생하면 이벤트 핸들러#%EC%9D%B4%EB%B2%A4%ED%8A%B8_%ED%95%B8%EB%93%A4%EB%9F%AC)를 호출하는 것을 말한다.
앞서 살펴본 데이터 바인딩은 모두 컴포넌트 클래스 -> 템플릿의 형태로 데이터가 이동하였지만 이벤트 바인딩은 템플릿 -> 컴포넌트 클래스로 데이터가 이동한다.

1
2
이벤트 바인딩
<element (event)="statement"> ... </element>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<!-- 1 -->
<input type="text" [value]="name" (input)="setName($event)" />
<!-- 2 -->
<button (click)="clearName()">clear</button>
<!-- 3 -->
<p>name: {{ name }}</p>
`
});
export class AppComponent {
name:string = '';

setName(event) {
event.target.value;
}

clearName() {
this.name = '';
}
}
  1. 사용자의 텍스트 입력에 의해 input 이벤트가 발생하면 이벤트 바인딩을 통해 이벤트핸들러 setName를 호출한다. 이때 ‘$event’를 통해 DOM 이벤트 객체를 이벤트 핸들러로 전달할 수 있다.($event 객체의 종류는 이벤트에 따라 결정 됨) Angular는 여기서 DOM이벤트 객체를 사용하기 때문에 Event 객체의 프로퍼티나 메서드에 자유롭게 접근할 수 있다.
  2. 버튼이 클릭되면 click이벤트가 발생되며 이벤트 바인딩을 통하여 이벤트 핸들러 clearName를 호출하는 데 clearName은 컴포넌트 프로퍼티 ‘name’에 빈 문자열을 할당한다.
  3. name 프로퍼티는 인터폴레이션에 의해 템플릿에 바인딩된다.

이벤트 바인딩에는 input이나 click이벤트 이외에도 다양한 표준 DOM이벤트를 사용할 수 있다.

참고 - 이벤트 객체 / Event reference


양방향 데이터 바인딩

양반향 데이터 바인딩(Two-way Data Binding)은 뷰와 컴포넌트 클래스의 상태 변화를 상호 반영하는 것을 말한다.

1
2
양방향 바인딩
<element [(ngModel)]="property"> ... </element>

ngModel 디렉티브를 이벤트 바인딩 ‘()’ 과 프로퍼티 바인딩 ‘[]’ 형식으로 기술한 후 우변에는 뷰와 컴포넌트 클래스가 공유할 프로퍼티를 기술한다.
ngModel 디렉티브를 사용하기 위해서는 FormsModule을 모듈(EX> app.module.ts)에 등록하여야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
FormsModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }


1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<input type="text" [(ngModel)]="name">
<p>{{ name }}</p>
`,
styleUrls: ['./app.component.css']
})
export class AppComponent {
name: string = '';
}

컴포넌트 클래스의 name 프로퍼티는 템플릿의 input 요소와 양방향바인딩 되어 있다. 즉 input 요소의 value 프로퍼티나 컴포넌트 클래스의 name 프로퍼티가 변화하면 양쪽 모두 동일한 값으로 변화한다.

사실 angular는 양방향 데이터 바인딩을 지원하지 않는다. 양방향 바인딩의 문법인 ‘[()]’ 에서 추측할 수 있듯 이벤트 바인딩과 프로퍼티 바인딩의 축약 표현(shorthand syntax)으로 실제 동작은 이벤트 바인딩과 프로퍼티 바인딩의 조합으로 이루어진다. 이벤트, 프로퍼티 바인딩을 통해 위의 예제와 동일하게 동작하도록 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<input type="text" [value]="name" (input)="name=$event.target.value">
<p>{{ name }}</p>
`,
styleUrls: ['./app.component.css']
})
export class AppComponent {
name: string = '';
}

즉 ngModel은 이벤트 바인딩과 프로퍼티 바인딩으로 구현되는 양방향 바인딩을 간편하게 작성할 수 있도록 돕는 디렉티브로서 사용자 입력에 관련한 DOM요소(form 컨트롤 요소)에서만 사용할 수 있다.

ngModel을 이벤트, 프로퍼티 바인딩으로 표현하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<input type="text" [ngModel]="name" (ngModelChange)="name=$event">
<p>{{ name }}</p>
`,
styleUrls: ['./app.component.css']
})
export class AppComponent {
name: string = '';
}

프로퍼티 바인딩 ‘[ngModel]’은 사용자 입력에 관련된 DOM요소의 프로퍼티(위 예제에서는 input의 value)를 업데이트하고 이벤트 바인딩 ‘(ngModelChange)’는 이벤트를 수신하고 이벤트 핸들러를 통해 DOM의 변화를 외부(컴포넌트 클래스 프로퍼티?)에 알린다. 이때 ngModelChange는 $event에서 사용자 입력에 관련된 프로퍼티의 값(위 예제에서는 target.value)를 내부적으로 추출하여 이벤트를 emit한다.

양방향 바인딩은 반드시 ngModel만을 사용해야하는 것은 아니며 커스텀 양방향 데이터 바인딩도 작성할 수 있다.(14.2.3에서 자세히 다룸)


7.6 빌트인 디렉티브

7.6.1 빌트인 디렉티브란?

디렉티브(Directive, 지시자)는 “DOM의 모든 것(모양이나 동작)을 관리하기 위한 지시(명령)으로 HTML요소 또는 어트리뷰트의 형태로 사용하거나 디렉티브가 사용된 요소에게 무언가를 하라는 지시를 전달한다.

디렉티브는 애플리케이션 전역에서 사용할 수 있는 공통 관심사를 컼포넌트에서 분리하여 구현한 것으로 컴포넌트의 복잡도를 낮추고 가독성을 향상시킨다 (컴포넌트도 뷰를 생성하ㅗ 이벤트를 처리하는 등의 DOM을 관리하기 때문에 큰 의미에서 디렉티브로 볼 수 있다.)

아래는 디렉티브가 선언된 요소(호스트 요소)의 텍스트 컬러를 파란색으로 변경하는 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// directives/text-blue.directive.ts
import { Directive, ElementRef, Renderer2 } from '@angular/core';

@Directive({
selector: '[text-blue]'
})

export class TextBlueDirective {
constructor(
el: ElementRef,
renderer: Renderer2
) {
renderer.setStyle(el.nativeElement, 'color', 'blue');
}
}

textBlue 디렉티브는 요소(element)의 어트리뷰트(attribute)로 사용한다.(@Directive 데코레이터의 selector 메타데이터 값이 ‘[]’인 것을 참고) 단 디렉티브는 모듈(EX> app.module.ts)의 declarations 프로퍼티에 등록이 되어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// app.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<div text-blue>TextBlueDirective</div>
`,
styleUrls: ['./app.component.css']
})
export class AppComponent {
name: string = '';
}

Angular 디렉티브는 아래와 같이 3가지 유형의 디렉티브를 제공한다. (이전 버전인 AngularJs에서는 70개 이상의 빌트인 디렉티브가 존재하였음)


7.6.2 빌트인 어트리뷰트 디렉티브

ngClass

여러 개의 CSS 클래스를 추가, 제거하는 디렉티브
(단 한 개의 클래스를 추가, 제거할때는 클래스 바인딩( [class] )을 사용하는 것이 좋다.)

1
2
ngClass 디렉티브
<element [ngClass]="문자열 | 배열 | 객체">...</element>

ngClass 디렉티브는 바인딩된 문자열이나 배열 또는 객체를 HTML 요소의 class 어트리뷰트에 반영하는데 ngClass 디렉티브에 바인딩할 수 있는 값은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
1> CSS 클래스 이름이 공백 문자로 구분된 문자열
문자열에 나열된 모든 CSS 클래스 이름이 class 어트리뷰트에 반영된다.
<div [ngClass]="'text-bold color-blue'">...</div>

2> CSS 클래스 이름의 요소로 구성된 배열
배열의 모든 요소인 모든 CSS 클래스 이름이 class 어트리뷰트에 반영된다.
<div [ngClass]="['text-bold', 'color-blue']">...</div>

3> CSS 클래스 이름을 프로퍼티 이름으로, boolean 타입을 프로퍼티 값으로 갖는 객체
프로퍼티 값이 true인 프로퍼티만이 class 어트리뷰트에 반영된다.
<div [ngClass]="{ 'text-bold': true, 'color-blue': false }">...</div>


클래스 바인딩( [class] )의 경우 기존의 class 어트리뷰트를 삭제하는 것과 달리 ngClass 디렉티브는 기존 class 어트리뷰트를 병합하여 작성한다.

1
2
3
4
<div class="class1 class2" [ngClass]="'class2 class3'">...</div>

<!-- 위 코드는 아래와 같이 적용 -->
<div class="class1 class2 class3">...</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// app.component.ts(ngClass)
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<ul>
<!-- 문자열(String)에 의한 클래스 지정 -->
<li [ngClass]="stringCssClasses">bold blue</li>

<!-- 배열(Array)에 의한 클래스 지정 -->
<li [ngClass]="arrayCssClasses">italic red</li>

<!-- 객체(Object)에 의한 클래스 지정 -->
<li [ngClass]="objectCssClasses">bold red</li>

<!-- 컴포넌트 메서드(반환 값은 Object)에 의한 클래스 지정 -->
<li [ngClass]="getCssClasses('italic-blue')">italic-blue</li>
</ul>
`,
styles: [
`
.text-bold { font-weight: bold; }
.text-italic { font-style: italic; }
.color-blue { color: blue; }
.color-red { color: red; }
`
]
})
export class AppComponent {
state: boolean = true;

stringCssClasses:string = 'text-bold color-blue';
arrayCssClasses = [ 'text-italic', 'color-red' ];
objectCssClasses = {
'text-bold': this.state,
'text-italic': !this.state,
'color-blue': this.state,
'color-red': !this.state
};

getCssClasses(flag: string) {
let classes;

if ( flag == 'italic-blue' ) {
classes = {
'text-bold': !this.state,
'text-italic': this.state,
'color-blue': !this.state,
'color-red': this.state
};
} else {
classes = {
'text-bold': this.state,
'text-italic': !this.state,
'color-blue': this.state,
'color-red': !this.state
};
}

return classes;
}
}


ngStyle

여러 개의 인라인 스타일을 추가, 제거한다.
(한 개의 인라인 스타일을 추가, 제거할때는 스타일 바인딩 ( [style] )을 사용하는 것이 좋다)

1
2
ngStyle 디렉티브
<element [ngStyle]="객체">...</element>

ngStyle 디렉티브에 바인딩된 객체는 CSS 프로퍼티를 프로퍼티 이름으로, CSS프로퍼티의 값을 프로퍼티 값으로 갖는다. 이때 값에 단위가 필요한 경우 CSS 프로퍼티에 단위를 추가한다.
ngClass 디렉티브와 마찬가지로 기존에 style어트리뷰트에 의해 이미 지정되어 있는 값이 있는 경우 병합된다.

1
2
3
4
<div style="color: blue;" [ngStyle]="{ 'color': 'red', 'width.px': 100, 'height.px': 200 }">...</div>

<!-- 위 코드는 아래와 같이 적용 -->
<div style="color: red; width: 100px; height: 200px">...</div>


7.6.2 빌트인 구조 디렉티브

Angular가 제공하는 구조 디렉티브로 DOM 요소의 반복 생성(ngFor)과 조건에 의한 추가 또는 제거를 수행(ngIf)하여 뷰의 구조를 변경한다.


ngIf

우변의 표현식의 연산에 따라 DOM을 추가, 제거한다. 이때 우변의 표현식의 결과는 반드시 true, false(Boolean)로 평가될 수 있어야 한다.
(DOM을 숨기는 것이 아닌 추가, 제거이며 제거된 요소는 DOM에 남아있지 않고 완전히 제거되어 불필요한 자원의 낭비를 방지한다.)

1
<element *ngIf="expression">...</element>

ngIf 디렉티브 앞에 붙은 *는 문법적 설탕으로 위 코드는 아래와 같이 변환된다.

1
2
3
<ng-template [ngIf]="expression">
<element>...</element>
</ng-template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<input type="text" [class.on]="name.length > 0" [(ngModel)]="name">
<p *ngIf="name.length > 0">{{ name }}</p>
`,
styles: [
`
input.on{border-color:red}
p{padding:20px;border:1px solid red;}
`
]
})
export class AppComponent {
name: string = '';
}

Angular4부터 ngIf else가 추가되었다. 우변의 표현식이 참이면 호스트 요소를 DOM에 추가하고 거짓이면 else 이후에 기술한 ng-template 디렉티브의 자식을 DOM에 추가한다
이때 ng-template 디렉티브에는 else 또는 then 이후에 지정한 템플릿 참조변수를 지정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<input type="checkbox" [(ngModel)]="selectValue">

<!-- ifElse1 -->
<p *ngIf="selectValue; else elseBlock">1체크</p>
<ng-template #elseBlock><p>1체크되지 않았습니다.</p></ng-template>

<!-- ifElse2 -->
<p *ngIf="selectValue; then thenBlock2 else elseBlock2"></p>
<ng-template #thenBlock2><p>2체크</p></ng-template>
<ng-template #elseBlock2><p>2체크되지 않았습니다.</p></ng-template>

<!-- if -->
<p *ngIf="selectValue; then thenBlock3"></p>
<ng-template #thenBlock3><p>3체크</p></ng-template>
`,
styles: [
`
p{padding:20px;border:1px solid red;}
`
]
})
export class AppComponent {
selectValue: string = 'null';
}


ngFor

ngFor 디렉티브는 컴포넌트 클래스의 컬렉션(배열과 같은 이터러블 객체)을 반복하여 ngFor 디렉티브가 선언된 요소(호스트 요소) 및 하위 요소를 DOM에 추가한다. ngFor 디렉티브에 바인딩된 ES6의 for…of와 유사하게 동작한다.

1
2
3
<element *ngFor="let item of items">...</element>

<element *ngFor="let item of items; let i = index; let odd = odd; trackBy: trackById">...</element>

ngIf와 마찬가지로 ngFor디렉티브 앞에 붙은 *(asterisk)는 문법적 설탕으로 실제로는 아래의 코드로 변환된다.

1
2
3
4
5
6
7
<ng-template ngFor let-item [ngForOf]="items">
<element>...</element>
</ng-template>

<ng-template ngFor let-item [ngForOf]="items" let-i="index" let-odd="odd" [ngForTrackBy]="trackById">
<element>...</element>
</ng-template>

위 코드는 컴포넌트 클래스의 프로퍼티 items를 바인딩한 후 items의 요소 개수만큼 순회하며 개별 요소를 item에 할당한다. item(템플릿 입력변수)은 호스트 요소 및 하위 요소에서만 유효한 로컬 변수로 items에 해당하는 바인딩 객체는 일반적으로 배열을 사용하지만 반드시 배열만 사용할 수 있는 것은 아니다. (ES6의 for…of에서 사용할 수 있는 이터러블(iterable)이라면 사용이 가능하다)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { Component } from '@angular/core';

interface User {
id: number,
name: string
}

@Component({
selector: 'app-root',
templateUrl: `
<input type="text" placeholder="이름을입력하세요" #userName>
<button (click)="addUser(userName.value)">add user</button>

<ul>
<li *ngFor="let user of users">
id: {{ user.id }} / name: {{ user.name }} <button (click)="removeUser(user.id)">remove user</button>
</li>
</ul>
`,
styleUrls: ['./app.component.css']
})
export class AppComponent {
users: User[] = [
{ id:1, name: 'Lee' },
{ id:2, name: 'Kim' },
{ id:3, name: 'Beak' }
];

// addUser - user 추가
addUser(name: string): void {
this.users.push( { id: this.getNewUserId(), name: name } );
}

// removeUser - user 제거
removeUser(id: number): void {
this.users = this.users.filter( (item) => item.id != id );
}

// getNewUserId - addUser시 id값 반환
getNewUserId(): number {
return this.users.length ? Math.max( ...this.users.map( (item) => item.id ) ) + 1 : 1;
}
}

ngFor 디렉티브를 사용하여 컴포넌트 클래스의 프로퍼티 users(배열)의 length만큼 반복하며 li요소와 하위 요소를 DOM에 추가한다. ngFor 디렉티브에서 사용된 user(템플릿 입력변수)는 users배열의 개별 요소를 일시적으로 저장하며 호스트요소(user) 및 하위 요소에서만 유효한 로컬 변수이다.

MDN - Array.prototype.filter
제공된 함수로 구현된 테스트를 통과하는 모든 요소가 있는 새로운 배열을 생성

MDN - Math.max
0이상의 숫자 중 가장 큰 숫자를 반환

MDN - Array.prototype.map
배열 내의 모든 요소 각각에 대하여 제공된 함수(callback)를 호출하고, 그 결과를 모아서 새로운 배열을 반환

사용 예


ngFor 디렉티브는 컬렉션 데이터(users 배열)가 변경되면 컬렉션과 연결된 모든 DOM요소를 제거하고 다시 생성한다. 이는 컬렉션의 변경사항을 추적(tracking)할 수 없기 때문으로 크기가 매우 큰 컬렉션을 다루는 경우을 다루는 경우 퍼포먼스 상의 문제를 발생시킬 수 있다.
이러한 이유로 ngFor디렉티브에서는 퍼포먼스를 향상시키기 위한 기능으로 trackBy를 제공한다.
(일반적인 경우 ngFor는 충분히 빠르기 때문에 trackBy에 의한 퍼포먼스 최적화는 기본적으로 필요 없기때문에 퍼포먼스 문제가 발생하는 경우에만 사용하면 된다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { Component } from '@angular/core';

interface User {
id: number,
name: string
}

@Component({
selector: 'app-root',
templateUrl: `
<input type="text" placeholder="이름을입력하세요" #userName>
<button (click)="addUser(userName.value)">add user</button>

<ul>
<li *ngFor="let user of users; let i = index; trackBy: trackByUserId">
{{ i }} ) id: {{ user.id }} / name: {{ user.name }} <button (click)="removeUser(user.id)">remove user</button>
</li>
</ul>
`,
styleUrls: ['./app.component.css']
})
export class AppComponent {
users: User[] = [
{ id:1, name: 'Lee' },
{ id:2, name: 'Kim' },
{ id:3, name: 'Beak' }
];

// addUser - user 추가
addUser(name: string): void {
this.users.push( { id: this.getNewUserId(), name: name } );
}

// removeUser - user 제거
removeUser(id: number): void {
this.users = this.users.filter( (item) => item.id != id );
}

// getNewUserId - addUser시 id값 반환
getNewUserId(): number {
return this.users.length ? Math.max( ...this.users.map( (item) => item.id ) ) + 1 : 1;
}

trackByUserId(index: number, user: User) {
// user.id를 기준으로 변경을 트래킹한다.
return user.id; // or index
}
}

위 코드는 user객체의 id 프로퍼티를 사용하여 users배열의 변경을 트래킹할 수 있도록 trackByUserId 메서드를 추가하였다. 이때 user 객체의 id프로퍼티는 유니크하여야 한다. user객체의 id프로퍼티를 사용하지 않고 trackByUserId에 인자로 전달된 index를 사용하여도 무방하다.

addUser나 removeUser버튼을 클릭하면 user를 추가/삭제하는 데 이때 users의 변경을 DOM에 반영하는 데 이때 trackBy를 사용하지 않는 경우 ngFor는 DOM을 다시 생성하지만 trackBy를 사용하여 user.id를 기준으로 컬렉션의 변경을 트래킹하기 때문에 퍼포먼스가 향상된다.


ngSwitch

switch 조건에 따라 여러 요소 중에 하나의 요소를 선택하여 DOM에 추가하는 디렉티브로 자바스크립트의 switch문과 유사하게 동작한다.

1
2
3
4
5
<element [ngSwitch]="expression">
<element *ngSwitch="'case1'">...</element>
<element *ngSwitch="'case2'">...</element>
<element *ngSwitchDefault>...</element>
</element>

ngSwitch 디렉티브의 *역시 ngIf, ngFor와 마찬가지로 문법적 설탕으로 위의 코드는 아래와 같이 변환된다.

1
2
3
4
5
6
7
8
9
10
11
<element [ngSwitch]="expression">
<ng-template [ngSwitchCase]="'case1'">
<element>...</element>
</ng-template>
<ng-template [ngSwitchCase]="'case2'">
<element>...</element>
</ng-template>
<ng-template ngSwitchDefault>
<element>...</element>
</ng-template>
</element>


7.7 템플릿 참조 변수(template reference variable)

DOM 요소에 대한 참조를 담고 있는 변수로 템플릿의 요소 내에서 ‘#’(해시 기호)를 변수 명 앞에 추가하여 선언하면 템플릿 참조 변수에 대한 해당요소에 대한 참조가 할당되고 템플릿 내의 자바스크립트 코드에서는 해시 기호 없이 참조한다.
템플릿 참조 변수는 템플릿 내에서만 유효하며 컴포넌트 클래스에 어떠한 부수 효과(side effect)도 주지 않는다.

1
2
템플릿 참조 변수
<element #myElement>...</element>

템플릿 참조 변수는 아래의 코드처럼 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<input type="email" [(ngModel)]="email" placeholder="이메일을 입력해주세요." />
<p #text class="hello" [class.show]="email.value">
<span>{{ email }}</span>
</p>

<p>
<span *ngIf="checkEmail(email); then thenBlock else elseBlock"></span>
<ng-template #thenBlock><span class="on">사용가능</span></ng-template>
<ng-template #elseBlock><span class="off">사용불가</span></ng-template>

</p>

<!-- dom의 다양한 프로퍼티를 사용할 수 있음 -->
<p>
classList: {{ text.classList.value }}<br>
textContent: {{ text.textContent }}<br>
textLength: {{ text.textContent.length }}
</p>
`,
styles: [
`
p{padding:20px;border:1px solid red;}
.hello.show{ display:block; }
span.on{ color:green;font-weight:bold }
span.off{ color:red;font-weight:bold }
`
]
})
export class AppComponent {
email: string = '';
className: string;

checkEmail(email: string) {
const mailRegExp = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;

return mailRegExp.test(email);
}
}


7.8 세이프 네비게이션 연산자

세이프 네비게이션 연산자 ‘?’는 컴포넌트 클래스의 프로퍼티 값이 null 또는 undefined일때 발생하는 에러를 회피하기 위해 사용하는 연산자로 선언되지 않았거나 null 혹은 undefined인 프로퍼티의 값을 참조할 때 세이프 네비게이션 연산자를 사용하는 경우 에러를 발생시키지 않은 채 처리를 종료한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<!-- 인터폴레이션에서는 null이나 undfined인 경우 아무 것도 표시하지 않는다. -->
<p>{{ obj }}</p>

<!-- 하지만 참조하는 대상의 프로퍼티가 null이나 undefined인 경우 에러 발생 -->
<!-- <p>{{ obj.name }}</p> -->

<!-- 이때 세이프 네비게이션 연산자를 사용하면 에러 없이 아무 것도 표시하지 않는다. -->
<!-- <p>{{ obj?.name }}</p> -->
`,
styles: [``]
})
export class AppComponent {}


7.9 컴포넌트 간의 상태 공유

7.9.1 컴포넌트의 계층적 트리 구조

Angular 애플리케이션은 컴포넌트를 중심으로 구성되는 데 컴포넌트는 재사용이 용이한 구조로 분할하여 작성하며 분할된 컴포넌트를 조립하여 가능한 중복 없이 UI를 생성합니다. 컴포넌트는 독립적인 존재이지만, 다른 컴포넌트와 결합도(의존도)를 낮게 유지하면서 다른 컴포넌트와 상태 정보를 교환할 수 있어야 한다.

분할된 컴포넌트를 조립한다는 것은 컴포넌트를 다른 컴포넌트에서 사용하는 것을 말하며 이 것은 컴포넌트 간에 계층적(Hierarchy) 구조가 형성될 수 있음을 의미한다. 분할된 컴포넌트를 조립하여 구성된 애플리케이션은 컴포넌트 간의 부모-자식 관계로 표현되는 계층적 트리 구조를 갖는다.

컴포넌트의 계층적 트리 구조(부모-자식 관계)는 데이터와 이벤트가 왕래 하는 상태 정보 흐름의 통로가 되며 이를 통해 다른 컴포넌트와의 상태 공유가 이루어지기 때문에 중요한 의미를 갖다.

이 계층적 구조는 DOM트리와 유사한 형태를 가지게 되는데 이를 컴포넌트 트리라고 한다.

컴포넌트 트리와 컴포넌트 간 상태 공유

컴포넌트는 계층적 트리 구조상에서 상호 작용을 통해 동작하므로 다른 컴포넌트와의 상태 정보 공유는 필수이며 매우 중요한 의미를 갖는다. (Angular는 컴포넌트 간에 상태 정보를 공유할 수 있는 다양한 방법을 제공한다.)

아래와 같이 angular-CLI나 파일을 생성하는 형태로 자식 컴포넌트를 추가한다.

1
2
[ angular-CLI ]
ng g user-list

생성만으로는 루트 컴포넌트(EX> app.component.ts)와 자식 컴포넌트(user-list)는 아직 어떤 연결고리도 없는 상태이므로 부모 컴포넌트를 담당할 루트 컴포넌트에 user-list.component.ts의 디렉티브(@Component의 메타데이터 중 ‘select’ 프로퍼티에 지정한 이름)를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// app.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<app-user-list></app-user-list>
`,
styles: []
})
export class AppComponent {
title = 'app works!';
}


7.9.2 부모 컴포넌트에서 자식 컴포넌트로 상태 전달

@Input 데코레이터

form요소를 가지고 있는 부모 컴포넌트의 경우 사용자에 의해 상태(state)가 변경되면 이를 자식 컴포넌트와 공유할 필요가 있는 데 이러한 경우 부모 컴포넌트는 프로퍼티 바인딩을 통해 자식컴포넌트에게 상태정보를 전달한다.
이렇게 전달한 상태정보는 @Input 데코레이터를 통해 컴포넌트 프로퍼티(입력 프로퍼티)에 바인딩한다.

부모 컴포넌트에서 자식 컴포넌트로 상태 전달

이때 자식 컴포넌트는 어떤 컴포넌트가 상태 정보를 전달하였는지는 알 필요가 없고(?) 단지 전달된 정보의 타입만 알 필요가 있다. 이것은 다른 컴포넌트와 결합도를 낮게 유지하면서 다른 컴포넌트와 상태 정보를 교환할 수 있다는 것을 의미한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { Component } from '@angular/core';
import { User } from './model/user.model';

@Component({
selector: 'app-root',
template: `
<div class="container">
<div class="row">
<div class="form-inline">
<div class="form-group" style="margin:30px 0;">
<label for="name">Name:</label>
<input type="text" id="name" class="form-control" placeholder="이름을 입력하세요" #name>

<label for="role">Role:</label>
<select id="role" #role>
<option>Admin</option>
<option>Developer</option>
<option>Designer</option>
</select>

<button class="btn btn-default" type="button" (click)="addUser(name.value, role.value)">ADD USER</button>

<app-user-list [users]="users"></app-user-list>

</div>
</div>
</div>
</div>
`,
styles: []
})
export class AppComponent {
users: User[];

constructor() {
this.users = [
new User(1, 'Lee', 'Admin'),
new User(2, 'kim', 'Developer'),
new User(3, 'park', 'Designer'),
];
}

// addUser
addUser(name: string, role: string) {
this.users.push(new User(this.getNextId(), name, role));
}

// getNextId
getNextId(){
let lastestId = Math.max(...this.users.map( ({id}) => id));
return (this.users.length > 0) ? lastestId + 1 : 1;
}
}

부모 컴포넌트(app.component.ts)의 users 프로퍼티의 일관성을 유지하기 위해 모델 클래스(/model/user.model)를 추가한다.
(모델 클래스는 일관성을 유지하기 위한 인터페이스의 역활을 수행한다.)

1
2
3
4
5
6
7
8
9
10
// ./model/user.model.ts
export class User {
constructor(
private id: number,
private name: string,
private role: string
) {
// ts에서는 위와 같이 접근 제한자를 사용하면 암묵적으로 프로퍼티로 정의되어 별도의 초기화가 없어도 됨
}
}
1
2
3
// app.component.ts
import { Component } from '@angular/core';
import { User } from './model/user.model';

부모 컴포넌트(app.component.ts)는 아래와 같이 프로퍼티 바인딩을 통해 자식 컴포넌트에게 상태 정보를 전달하였다.

1
<app-user-list [users]="users"></app-user-list>

자식 컴포넌트(user-list.component.ts)는 부모 컴포넌트가 전달한 상태 정보를 @Input 데코레이터를 통해 컴포넌트 프로퍼티 users에 바인딩한다.
(@Input 데코레이터는 ‘@angular/core’ 모듈에 정의 되어 있다.)

1
2
3
4
5
import { Component, Input } from '@angular/core';
...
export class UserListComponent {
@Input() user: User[];
}

부모 컴포넌트가 전달한 상태 정보 ‘users’는 자식 컴포넌트의 @Input 데코레이터 바로 뒤에 있는 ‘users’ 프로퍼티에 바인딩된다. 이때 부모, 자식 컴포넌트는 동일한 ‘users’ 객체에 대한 참조를 갖는다. 따라서 참조를 공유하고 있는 ‘users’를 어느 한쪽에서 변경하면 모두에게 반영된다.

@Input 데코레이터 바로 뒤의 프로퍼티명 ‘users’는 부모 컴포넌트에서 실행한 프로퍼티 바인딩의 프로퍼티 명은 ‘users’는 반드시 일치하여야 한다.

프로퍼티 명 일치

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { Component, Input } from '@angular/core';
import { User } from '../model/user.model';

@Component({
selector: 'app-user-list',
template: `
<table class="table">
<thead>
<tr>
<th>NO</th>
<th>ID</th>
<th>NAME</th>
<th>ROLE</th>
</tr>
</thead>
<tbody *ngFor="let user of userList; let idx = index;">
<tr>
<td>{{ idx }}</td>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.role }}</td>
</tr>
</tbody>
</table>
`,
styles: []
})
export class UserListComponent {
@Input() users: User[];

constructor() { }
}

만일 부모 컴포넌트에서 실행한 프로퍼티 바인딩의 프로퍼티명과는 다른 프로퍼티 명을 자식 컴포넌트에서 사용하려면 @Input 데코레이터에 프로퍼티 바인딩의 프로퍼티명을 인자로 전달하고 사용하고자 하는 프로퍼티명을 선언한다.

1
2
3
export class UserListComponent {
@Input('users') myPropName: User[];
}

프로퍼티명 변경

이때 @Input 데코레이터에 전달한 문자열은 부모 컴포넌트에서 실행한 프로퍼티 바인딩의 프로퍼티 명과 반드시 일치해야한다.


@Input 데코레이터와 setter를 이용한 입력 프로퍼티 조작

setter와 getter를 사용하여 부모 컴포넌트가 전달한 데이터가 자식 컴포넌트의 입력 프로퍼티에 바인딩되는 시점에 필요한 로직을 동작시킬 수 있다.

stter를 이용한 입력 프로퍼티 조작

아래는 사용자 역활(role) 별로 사용자를 카운트하는 기능을 추가한 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { Component, Input } from '@angular/core';
import { User } from '../model/user.model';

@Component({
selector: 'app-user-list',
template: `
<div class="box box-info">
<div class="box-body">
<div class="table-responsive">
<table class="table no-margin">
<thead>
<tr>
<th>NO</th>
<th>ID</th>
<th>NAME</th>
<th>ROLE</th>
</tr>
</thead>
<tbody *ngFor="let user of users; let idx = index;">
<tr>
<td>{{ idx }}</td>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.role }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel panel-default">
<div class="panel-body">
<p>Admin: {{ cntAdmin }}</p>
<p>Developer: {{ cntDeveloper }}</p>
<p>Designer: {{ cntDesigner }}</p>
</div>
</div>
`,
styles: []
})
export class UserListComponent {
// _user는 내부에서만 사용할 private 프로퍼티
private _users: User[];

// 역활별 사용자 카운터
cntAdmin: number;
cntDeveloper: number;
cntDesigner: number;

// 부모 컴포넌트에서 전달한 정보(users)에서 필요한 정보를 추출하여 컴포넌트 프로퍼티에 바인딩한다.
@Input()
set users(users: User[]) {
// setter는 users를 할당(전달)할 때
this.cntAdmin = users.filter( ({role}) => 'Admin' ).length;
this.cntDeveloper = users.filter( ({role}) => 'Developer' ).length;
this.cntDesigner = users.filter( ({role}) => 'Designer' ).length;
this._users = users;
}

get users(): User[] {
// getter는 users를 참조할 때
return this._users;
}
}

위 예제에서 ‘setter’는 부모 컴포넌트가 전달한 데이터가 @Input 데코레이터에 의해 입력 프로퍼티로 바인딩 될 때 동작한다. 단순히 데이터를 전달 받아서 입력 프로퍼티에 바인딩하는 것에 그치지 않고 ‘setter’를 사용하여 부모 컴포넌트가 전달한 user에서 역활별로 사용자를 카운트하여 컴포넌트 프로퍼티에 할당하였다. 이와 같이 부모 컴포넌트가 전달한 데이터에서 필요한 값을 추출하거나 검사, 변형할 때 setter는 유용하다.

요약

  • @Input 데코레이터는 부모 컴포넌트에서 자식 컴포넌트로 상태를 전달할 때 사용
  • 상태를 전달할 때는 프로퍼티 바인딩을 통해 전달
  • 부모 컴포넌트에서 전달한 프로퍼티 명과 다른 프로퍼티 명으로 지정하여 사용할 수도 있다.
  • setter 사용하면 입력 프로퍼티로 바인딩 될 때 필요한 값을 변형, 추출하는 등 로직을 추가할 수 있다.


7.9.3 자식 컴포넌트에서 부모 컴포넌트로 상태 전달

@Output 데코레이터와 EventEmitter

자식 컴포넌트는 @Output 데코레이터와 함께 선언된 컴포넌트 프로퍼티(출력프로퍼티로 아래 그림에서는 myEvent)를 EventEmitter 객체로 초기화 한다. 그리고 부모 컴포넌트로 상태를 전달하기 위해 emit() 메서드를 사용하여 이벤트를 발생시키면서 상태를 전달한다. 부모 컴포넌트는 자식 컴포넌트가 전달한 상태를 이벤트 바인딩을 통해 접수한다.

자식 컴포넌트에서 부모 컴포넌트로 상태 전달

이때 이벤트를 방출할 컴포넌트는 어떤 컴포넌트에 이벤트가 전달되는지 알 필요가 없다. 이 것은 다른 컴포넌트와 결합도를 낮게 유지하면서 다른 컴포넌트와 상태 정보를 교환할 수 있다는 것을 의미한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// user-list.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { User } from '../model/user.model';

@Component({
selector: 'app-user-list',
template: `
<div class="box box-info">
<div class="box-body">
<div class="table-responsive">
<table class="table no-margin">
<thead>
<tr>
<th>NO</th>
<th>ID</th>
<th>NAME</th>
<th>ROLE</th>
<th>ACTION</th>
</tr>
</thead>
<tbody *ngFor="let user of users; let idx = index;">
<tr>
<td>{{ idx }}</td>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.role }}</td>
<td>
<button type="button" class="btn btn-danger btn-sm" (click)="remove.emit(user)">
<span class="glyphicon glyphicon-remove"></span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel panel-default">
<div class="panel-body">
<p>Admin: {{ cntAdmin }}</p>
<p>Developer: {{ cntDeveloper }}</p>
<p>Designer: {{ cntDesigner }}</p>
</div>
</div>
`,
styles: []
})
export class UserListComponent {
// _user는 내부에서만 사용할 private 프로퍼티
private _users: User[];

// 역활별 사용자 카운터
cntAdmin: number;
cntDeveloper: number;
cntDesigner: number;

// 부모 컴포넌트에서 전달한 정보(users)에서 필요한 정보를 추출하여 컴포넌트 프로퍼티에 바인딩한다.
@Input()
set users(users: User[]) {
// setter는 users를 할당(전달)할 때
this.cntAdmin = users.filter( ({role}) => 'Admin' ).length;
this.cntDeveloper = users.filter( ({role}) => 'Developer' ).length;
this.cntDesigner = users.filter( ({role}) => 'Designer' ).length;
this._users = users;
}

get users(): User[] {
// getter는 users를 참조할 때
return this._users;
}

// 부모 컴포넌트에게 상태 정보를 전달하기 위해 출력 프로퍼티(remove)를 EventEmitter객체로 초기화 한다.
// 이때 제네릭으로 데이터형을 'User'로 설정
@Output() remove = new EventEmitter<User>();
}

부모 컴포넌트에게 상태 정보를 전달하기 위해 User 타입의 EventEmitter 객체를 생성하였다

1
@Output() remove = new EventEmitter<User>();

EventEmitter 객체는 커스텀 이벤트를 발생시키는 emit 메서드를 가지고 있다.
사용자가 삭제 버튼을 클릭하면 emit 메서드를 통해 커스텀 이벤트를 발생 시키고 emit 메서드는 인자를 전달하여 부모 컴포넌트에게 상태 정보를 전달한다.

부모 컴포넌트(app.component.ts)로 자식 컴포넌트가 전달한 상태 정보를 접수하여 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// app.component.ts
import { Component } from '@angular/core';
import { User } from './model/user.model';

@Component({
selector: 'app-root',
template: `
<div class="container">
<div class="row">
<div class="form-inline">
<div class="form-group" style="margin:30px 0;">
<label for="name">Name:</label>
<input type="text" id="name" class="form-control" placeholder="이름을 입력하세요" #name>

<label for="role">Role:</label>
<select id="role" #role class="form-control">
<option>Admin</option>
<option>Developer</option>
<option>Designer</option>
</select>

<button class="btn btn-default" type="button" (click)="addUser(name.value, role.value)">ADD USER</button>
</div>
</div>
<!-- 추가(이벤트 바인딩으로 자식 컴포넌트가 전달한 상태(remove)를 접수 -->
<!-- 자식 컴포넌트가 인자로 전달한 'user'는 $event에 들어 있음 -->
<app-user-list [users]="users" (remove)="removeUser($event)"></app-user-list>
</div>
</div>
`,
styles: []
})
export class AppComponent {
users: User[];

constructor() {
this.users = [
new User(1, 'Lee', 'Admin'),
new User(2, 'kim', 'Developer'),
new User(3, 'park', 'Designer'),
];
}

// addUser
addUser(name: string, role: string) {
this.users.push(new User(this.getNextId(), name, role));
}

// getNextId
getNextId(){
let lastestId = Math.max(...this.users.map( ({id}) => id));
return (this.users.length > 0) ? lastestId + 1 : 1;
}

// removeUser
removeUser(user: User) {
this.users = this.users.filter( ({id}) => id !== user['id'] );
}
}

부모 컴포넌트는 이벤트 바인딩을 통해 자식 컴포넌트가 발생시킨 이벤트를 접수한다.

1
<app-user-list [users]="users" (remove)="removeUser($event)"></app-user-list>

이때 자식 컴포넌트가 emit 메서드를 호출하면서 인자로 전달한 상태 정보는 $event에 들어있다.

1
2
3
removeUser(user: User) {
this.users = this.users.filter( ({id}) => id !== user['id'] );
}


7.9.4 Stateful 컴포넌트와 Stateless 컴포넌트

앞의 예제를 살펴보면 부모 컴포넌트에서 상태 변화(사용자 추가)가 발생한 경우, 부모 컴포넌트는 자신의 users 배열에 상태 변화를 반영한 후 프로퍼티 바인딩을 통해 자식 컴포넌트에 상태 정보인 users 배열을 전송하였다. 이때 부모 컴포넌트와 자식 컴포넌트는 동일한 users 배열에 대한 참조를 갖는다. 따라서 어느 한쪽에서 users 배열을 변경하면 모두에게 변경이 반영된다.

그런데 자식 컴포넌트에서 상태 변화(사용자 제거)가 발생한 경우, 자식 컴포넌트는 직접 users 배열을 변경하지 않고 이벤트 바인딩을 통해 부모 컴포넌트에게 users 배열의 변경을 위임하였다. 자식 컴포넌트가 직접 users 배열을 변경하면 이벤트 바인딩(예제에서는 remove)을 통해 상태를 공유하지 않아도 될 텐데 왜 이렇게 번거롭게 상태를 주고받는 것일까?

부모 컴포넌트와 자식 컴포넌트 모두 상태 객체(users)에 대한 동일한 참조를 갖는다. 따라서 참조를 공유하고 있는 상태 객체(users)를 어느 한쪽에서 변경하면 모두에게 변경이 반영되는데 이는 상태 정보의 변화를 예측하기 어렵게 만든다. 앞의 예제와 같이 간단한 구조로 되어 있다면 문제가 되지 않겠지만 복잡한 계층적 구조를 갖는 어플리케이션의 경우 컴포넌트마다 상태 정보를 마음대로 변경할 수 있다면 상태 변경을 추적하기 어렵고 의도하지 않은 상태 정보의 변경이 발생하여 문제가 될 수 있다.

따라서 애플리케이션의 상태 정보를 저장하고 변경할 수 있는 Stateful 컴포넌트(smart 컴포넌트)상태 정보를 참조하여 화면에 출력할 뿐 직접 변경하지 않는 Stateless 컴포넌트(dumb 컴포넌트) 로 구분할 필요가 있다.

Stateful 컴포넌트(smart 컴포넌트)는 애플리케이션의 현재 상태 정보를 관리하며 필요에 따라 서버 자원에 접근할 수 있고 Stateless 컴포넌트를 사용하여 뷰를 표현한다.
Stateless 컴포넌트(dumb 컴포넌트)는 부수효과(side effect)를 발생 시키지 않는 순수 함수(pure function)와 유사하게 단순히 프로퍼티 바인딩을 통해 상태 정보를 전달받아서 뷰를 랜더링하고 필요에 따라 이벤트를 방출할 뿐 그 외의 부수 효과를 발생 시키지 않는다.
부수 효과는 복잡도를 증가시키므로 부순수(impure)한 stateful 컴포넌트를 최대한 줄이는 것은 부수 효과를 최대한 억제하는 것과 같다. 이것은 디버깅을 쉽게 만든다.


7.9.5 원거리 컴포넌트 간의 상태 공유

복잡한 컴포넌트 트리 구조를 갖는 애플리케이션의 경우, 부모-자식 관계를 뛰어넘어 텀포넌트 간의 상태 공유가 필요할 수 있다.
컴포넌트 A->B->C가 존재하는 경우 A에서 변경된 상태를 C에도 공유할 필요가 있을 때 지금까지 살펴본 프로퍼티 바인딩과 이벤트 바인딩을 통해 상태를 공유할 수 있다. 이때 상태를 공유할 필요가 없는 B에까지 상태를 전달하여야 하는 데 이러한 불필요한 상태 공유를 피하고자 상태 공유를 위한 서비스를 사용할 수 있다. 이에 대한 자세한 사항은 11.7절 서비스 중재자 패턴에서 살펴보도록 하자.


7.10 부모 컴포넌트에서 자식 요소로의 접근

Angular 어플리케이션을 작성하다보면 부모 컴포넌트에서 자식 요소(자식 컴포넌트. 네이티브 DOM요소)에 접근이 필요한 경우가 있다. 템플릿 내부에서는 템플릿 참조 변수를 사용하여 자식 컴포넌트의 프로퍼티에 접근하거나 메서드를 호출할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// counter/counter.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'counter',
template: `<h1>{{ title }}</h1>`
})
export class CounterComponent {
count: number = 0;

increase() {
this.count++;
}

decrease() {
this.count--;
}
}

위와 예제(counter.component.ts)는 자식 컴포넌트로 부모 컴포넌트에서 자식 컴포넌트 counter의 참조를 담고 있는 템플릿 참조 변수를 사용하여 자식 컴포넌트에 접근할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Component } from '@angular/core';
import { CounterComponent } from './counter/counter.component';

@Component({
selector: 'app-root',
template: `
<h3>템플릿 참조 변수를 사용하여 자식 컴포넌트에 접근</h3>
<counter #counter></counter>
<button (click)="increase(counter)">+</button>
<button (click)="decrease(counter)">-</button>

<h3>템플릿 참조 변수를 사용하여 자식 네이티브 DOM요소에 접근</h3>
<h1 #title>Color</h1>
<button (click)="title.style.color = 'red'">change text color</button>
`
})
export class AppComponent {
increase(counter: CounterComponent) {
counter.increase();
}
decrease(counter: CounterComponent) {
counter.decrease();
}
}

하지만 템플릿 참조변수는 템플릿 내에서만 유효한 지역 변수이므로 컴포넌트 클래스에서 직접 템플릿 참조 변수를 사용할 수 없다. 단 템플릿에서 이벤트 핸들러를 통해 템플릿 참조 변수가 담긴 자식 컴포넌트의 인스턴스를 부모 컴포넌트 클래스로 보낼 수는 있다. (위 예제의 템플릿 내 increase와 decrease 참조)

템플릿 참조변수를 사용할 수 없는 제한적인 상황에서 부모 컴포넌트 클래스에서 직접 자식 요소(자식 컴포넌트, 네이티브 DOM요소)에 접근하기 위해서는 아래의 데코레이터를 사용한다.

이 데코레이터들은 접근이 필요한 자식 요소를 탐색하고 탐색된 요소의 참조를 데코레이터가 장식한 프로퍼티에 바인딩한다.


7.10.1 @ViewChild와 @ViewChildren

컴포넌트 템플릿에 배치된 자식 요소(자식 컴포넌트, 네이티브 DOM요소)를 ViewChild라고 한다. 이름에서 알 수 있듯
@ViewChild는 탐색 조건에 부합하는 1개의 요소를 취득할 때 사용하고
@ViewChildren은 탐색 조건에 부합하는 여러 개의 요소를 한꺼번에 취득할 때 사용한다.


@ViewChild

@ViewChild 데코레이터의 인자로 탐색 대상 클래스명을 지정하고 그 결과를 바인딩할 프로퍼티를 지정한다.

1
2
// @ViewChild(탐색대상 클래스명) 프로퍼티
@ViewChild(ChildComponent) myChild: ChildComponent;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// child/child.component.ts(자식 컴포넌트)
import { Component } from '@angular/core';

@Component({
selector: 'child',
template: `<div *ngIf="isShow">{{ contentText }}</div>`,
styles: [`
div{width:100px;height:100px;margin-top:10px;color:#fff;text-align:center;line-height:100px;background:gray;}
`]
})
export class ChildComponent {
// 부모 컴포넌트가 자식 텀포넌트의 뷰를 감추거나 보이기 위해 직접 접근할 프로퍼티
isShow: boolean = true;
contentText: string = 'Child';

// 부모 컴포넌트가 자식 컴포넌트의 contentText 프로퍼티를 변경하기 위해 직접 접근할 메서드
changeText(text: string) {
this.contentText = text;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// app.component.ts
import { Component, ViewChild } from '@angular/core';
import { ChildComponent } from './child/child.component';

@Component({
selector: 'app-root',
template: `
<h3>Parent</h3>
<button (click)="toggle()">Toggle Child</button><br><br>

<input type="text" #text placeholder="변경될 글자를 입력해주세요.">
<button (click)="changeText(text.value)">Change Child's Text</button>

<child></child>
`,
styles: []
})
export class AppComponent {
// 자식 컴포넌트(ChildComponent)를 myChild 프로퍼티에 바인딩
@ViewChild(ChildComponent) myChild: ChildComponent;

toggle() {
// 자식 컴포넌트의 프로퍼티를 직접 변경할 수 있음
this.myChild.isShow = !this.myChild.isShow;
}

changeText(text: string) {
// 자식 컴포넌트의 메서드를 직접 실행할 수 있음
this.myChild.changeText(text);
}
}

부모 컴포넌트(여기선 app.component)는 @ViewChild 데코레이터를 통해 자식 컴포넌트(ChildComponent)를 탐색하여 취즉한 자식 컴포넌트 ChildComponent의 인스턴스를 myChild 프로퍼티에 바인딩하였다.

@ViewChild는 1개의 자식 요소만을 가져올 수 있기 때문에 만일 자식요소 중 ChildComponent가 여러개 탐색되었을 경우 첫 번째 ChildComponent의 인스턴스를 가져온다.

프로퍼티 ‘myChild’에는 자식 컴포넌트 ChildComponent의 인스턴스가 바인딩되어 있으므로 프로퍼티 myChild를 통해 자식 컴포넌트의 프로퍼티, 메서드에 접근할 수 있다.
(단 접근 제한자가 public으로 공개된 프로퍼티, 메서드에만 접근할 수 있다., 별도로 접근제한자를 지정하지 않는 경우 public)


@ViewChildren

@ViewChildren 데코레이터의 인자로 탐색 대상 클래스명을 지정하고 그 결과를 바인딩할 프로퍼티를 지정한다. @ViewChildren 데코레이터는 여러 개의 자식 요소를 한꺼번에 취득하는 데 이때 취득된 자식 요소를 바인딩할 프로퍼티의 타입은 QueryList이다.

1
2
// @ViewChildren(탐색대상 클래스명) 프로퍼티
@ViewChildrend(ChildComponent) myChildren: QueryList<ChildComponent>;

자식 컴포넌트(child.component.ts)를 아래와 같이 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// child/child.component.ts
import { Component, Input } from '@angular/core';
import { Checkbox } from '../app.component';

@Component({
selector: 'child',
template: `
<input type="checkbox"
[id]="checkbox.id"
[checked]="checkbox.checked"
/>
<label [for]="checkbox.id">{{ checkbox.label }}</label>
`
})
export class ChildComponent {
// 부모 컴포넌트가 직접 접근할 프로퍼티 checkbox(타입은 부모에 선언된 interface Checkbox)
@Input() checkbox: Checkbox;
}


부모 컴포넌트(app.component.ts)도 아래와 같이 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// app.component.ts
import { Component, ViewChildren, QueryList } from '@angular/core';
import { ChildComponent } from './child/child.component';

export interface Checkbox {
id: number;
label: string;
checked: boolean;
}

@Component({
selector: 'app-root',
template: `
<button type="button" (click)="selectAll()"> Select All </button>
<button type="button" (click)="deSelectAll()"> DeSelect All </button>
<button type="button" (click)="toggle()"> toggle </button>
<ul>
<li *ngFor="let checkbox of checkboxs">
<child [checkbox]="checkbox"></child>
</li>
</ul>
`,
styles: []
})
export class AppComponent {
checkboxs: Checkbox[] = [
{ id: 1, label: 'HTML', checked: true },
{ id: 2, label: 'CSS', checked: false },
{ id: 3, label: 'JavaScript', checked: false }
];
active: boolean = false;

// @ViewChildren 데코레이터는 여러 개의 자식 요소를 취득한다.
// 이때 취득 된 자식 요소를 바인딩할 프로퍼티의 타입은 QueryList이다.
@ViewChildren(ChildComponent) myChildren: QueryList<ChildComponent>;


setChecked() {
// 자식 컴포넌트들을 순회하며 자식 컴포넌트의 공개된 프로퍼티 checkbox를 변경한다.
// QueryList는 iterable이라 순회 가능하며 마치 자바스크립트의 배열과 같이 사용할 수 있다.
this.myChildren.forEach( (child) => child.checkbox.checked = this.active );
}

toggle() {
this.active = !this.active;
this.setChecked();
}

selectAll() {
this.active = true;
this.setChecked();
}

deSelectAll() {
this.active = false;
this.setChecked();
}
}

부모 컴포넌트(AppComponent)는 프로퍼티 바인딩을 통해 자식 컴포넌트에게 입력 변수인 checkbox 객체를 전달하고 자식 컴포넌트는 @Input데코레이터를 통해 checkbox객체를 접수한다.
부모 컴포넌트는 @ViewChildren 데코레이터를 통해 자식 컴포넌트(ChildComponent)를 탐색하여 취득한 모든 자식컴포넌트의 인스턴스를 myChildren 프로퍼티에 바인딩하였다.

1
@ViewChildren(ChildComponent) myChildren: QueryList<ChildComponent>;

@ViewChildren의 탐색 결과가 바인딩된 myChildren 프로퍼티의 타입은 QueryList이다. QueryList 클래스는 배열과 같이 순회 가능한 이터러블 객체로 interable 인터페이스를 구현하였으므로 ES6의 for of 루프에 사용할 수 있고 ngFor와 함께 템플릿 내에서도 사용할 수 있다.

또한 QueryList 클래스 내부에는 탐색 결과를 저장하는 배열인 ‘_result’ 프로퍼티를 가지고 있고 이 프로퍼티를 사용하는 자바스크립트 배열 메서드와 동일하게 동작하는 map, filter, find, refuce, forEach, some등의 메서드를 소유하고 있어서 마치 자바스크립트의 배열과 같이 사용할 수 있다. QueryList는 옵저버블한 컬렉션으로 변경사항을 구독할 수 있다.(자세한 내용은 12장에서)

위 예제에서는 QueryList 클래스의 forEach 메서드를 사용하여 자식 컴포넌트 ChildComponent의 인스턴스들이 바인딩되어 있는 myChild를 순회하며 자식 컴포넌트 ChildComponent의 프로퍼티에 접근하여 값을 변경한다.

1
this.myChildren.forEach( (child) => child.checkbox.checked = this.active );

참고


템플릿 참조 변수를 사용한 네이티브 DOM 접근

@ViewChild와 @ViewChildren 데코레이터를 통해 자식 컴포넌트의 인스턴스를 취득할 때 탐색 대상인 자식 컴포넌트의 클래스 명을 데코레이터의 인자로 지정하였다.
(EX> @ViewChild(ChildComponent)) myChildren: QueryList )

또 다른 방식으로 템플릿 참조 변수를 사용하여 자식 요소에 접근할 수 있다. @ViewChild와 @ViewChildren 데코레이터에 인자로 탐색 대상 요소에 지정된 템플릿 참조 변수를 문자열의 형태로 전달한다.

1
2
@ViewChild('h1') myChild: ElementRef;
@ViewChildren('h1, h2, h3') myChildren: QueryList<ElementRef>;

위 방법은 네이티브 DOM요소에만 사용할 수 있는 것은 아니며 자식 컴포넌트에도 템플릿 및 참조 변수를 지정하여 접근할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@import { Component, AfterViewInit, ViewChild, ViewChildren, QueryList, ElementRef } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<h1 #h1>Heading1</h1>
<h2 #h2>Heading1</h2>
<h3 #h3>Heading1</h3>
`
})
export class AppComponent implements AfterViewInit {
// @ViewChild 데코레이터의 인자로 탐색 대상 요소로 지정된 템플릿 참조 변수를 문자열 형태로 전달
// 템플릿 참조 변수를 사용하며 네이티브 DOM을 탐색한 경우, 참색 결과는 ElementRef타입의 인스턴스가 바인딩된다.
@ViewChild('h1') myEle: ElementRef;

// @ViewChildren 데코레이터의 인자로 탐색 대상 요소에 지정된 템플릿 참조 변수의 리스트를 문자열 형태로 전달
// 템플릿 참조 변수를 사용하여 네이티브 DOM을 탐색한 경우, 탐색 결과는 ElementRef 타입 인스턴스의 리스트(QueryList)가 바인딩된다.
@ViewChildren('h2, h3') myEles: QueryList<ElementRef>;

// 생성자 함수에 주입된 ElementRef는 컴포넌트의 호스트 요소(<app-root>)를 반환
constructor(elementRef: ElementRef) {
console.log(elementRef); // <app-root>
}

// ngAfterViewInit는 뷰 초기화가 종료되었을 때 실행되는 컴포넌트 생명주기(life cycle) 메서드로
// @ViewChild, @ViewChildren은 ngAfterViewInit 이전에 초기화 되므로 이 시점에 참조하는 것이 안전
ngAfterViewInit() {
console.log(this.myEle); // h1
this.myEles.forEach( (child) => console.log(child) ); // h2, h3
}
}

@ViewChild, @ViewChildren 데코레이터를 사용하여 자식 컴포넌트를 탐색하는 경우 탐색 결과로 자식 컴포넌트의 인스턴스를 취득할 수 있으며 취득한 인스턴스의 타입은 당연히 인스턴스를 생성한 텀포넌트 클래스가 타입이 된다.

템플릿 참조변수를 사용하여 네이티브 DOM을 탐색한 경우는 탐색 결과로 ElementRef 타입의 인스턴스가 바인딩된다. ElementRef는 네이티브 DOM 객체를 래핑한 nativeElement 프로퍼티를 소유하고 따라서 ElementRef.nativeElement로 접근하면 네이티브 DOM의 프로퍼티에 접근할 수 있다.

@ViewChild, @ViewChildren 데코레이터는 자식 컴포넌트뿐만 아니라 템플릿에 배치된 모든 요소 즉 자식컴포넌트, 네이티브 DOM요소를 직접 탐색하고 접근할 수 있다.

Angular는 DOM에 직접 접근하는 방식을 사용하지 않고 템플릿과 컴포넌트 클래스의 상호 관계를 선언하는 방식으로 뷰와 모델의 관계를 관리한다. 이때 사용되는 것이 데이터 바인딩이며 이를 통해 템플릿은 컴포넌트 클래스와 연결된다.

@ViewChild, @ViewChildren 데코레이터를 통해 DOM에 직접 접근하는 방식은 뷰와 로직 간의 관계를 느슨하게 결합하기 어려운 구조로 만든다. 뷰가 변경되면 로직도 변경될 가능성이 매우 높아지고 로직이 뷰에 종속되기 때문에 이 방법 외에는 다른 해결수단이 없을 때만 한정하여 사용해야 한다.


7.10.2 @ContentChild와 @ContentChildren

콘텐트 프로젝션(content projection)

HTML 요소는 시작 태그와 종료 태그 사이에 콘텐츠(contents)를 포함할 수 있다 이 콘텐츠는 텍스트 일 수도 있고 또 다른 요소 일 수도 있다.

1
<HTML요소>콘텐츠</HTML요소>

Angular는 콘텐트 프로젝션을 통해 자식 컴포넌트의 콘텐츠를 지정할 수 있다. 자식 컴포넌트는 부모 컴포넌트가 전달한 콘텐츠를 전달받아 표시할 위치를 ng-content 디렉티브를 사용하여 지정한다.
예를 들어 아래와 같이 부모 컴포넌트는 자식 컴포넌트를 템플릿에 추가하면서 자식 컴포넌트의 콘텐츠를 지정하였다. 이 콘텐츠는 자식 컴포넌트에 전달 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<!-- 싱글 슬롯 콘텐트 프로젝션 -->
<single-content-projection>
<strong>Single-Slot</strong> <i>content projection</i>
</single-content-projection>

<!-- 멀티 슬롯 콘텐츠 프로젝션 -->
<multi-content-projection>
<footer>Footer Content</footer>
<header>Header Content</header>
<section>Section Content</section>
<div class="my_class">div with .my_class</div>
</multi-content-projection>
`,
styles: []
})
export class AppComponent {}
1
2
3
4
5
6
7
8
9
10
11
12
13
// single-content.projection.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'single-content-projection',
template: `
<h3>Single-slot content projection</h3>
<div>
<ng-content></ng-content>
</div>
`
})
export class SingleContentComponent {}

부모 컴포넌트(app.component)가 자식컴포넌트 single-content.projection.component를 사용할 때 시작 태그와 종료 태그 사이에 콘텐츠를 추가 하였다.

1
2
3
<single-content-projection>
<strong>Single-Slot</strong> <i>content projection</i>
</single-content-projection>

자식컴포넌트 single-content.projection.component는 아래와 같이 ng-content를 가지고 있다.

1
2
3
4
<div>
<!-- ng-content는 부모 컴포넌트가 지정한 콘텐츠와 치환된다. -->
<ng-content></ng-content>
</div>

ng-content는 부모 컴포넌트가 지정한 콘텐츠와 치환되어 결국 아래와 같은 DOM을 생성한다.

1
2
3
<div>
<strong>Single-Slot</strong> <i>content projection</i>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// multi-content-projection-component.ts
import { Component } from '@angular/core';

@Component({
selector: 'multi-content-projection',
template: `
<h3>Multi Slot Content Projection</h3>
<ng-content select="header"></ng-content>
<ng-content select="section"></ng-content>
<ng-content select=".my_class"></ng-content>
<ng-content select="footer"></ng-content>
`
})
export class MultiContentComponent {}

ng-content는 여러 개의 콘텐츠를 한번에 받아들일 수 있는 멀티 슬롯 콘텐츠 프로젝션을 지원한다. 이때 ng-content의 select 어트리뷰트를 사용하여 부모 컴포넌트가 지정한 콘텐츠 내의 요소가 프로젝션 될 위치를 지정한다. (select 어트리뷰트의 값은 css셀렉터와 동일한 것으로 보임)


@ContentChild와 @ContentChildren

컴포넌트 템플릿에 배치된 자식 요소(자식 컴포넌트, 네이티브 DOM요소), 즉 ViewChild의 시작 태그와 종료 태그 사이에 있는 콘텐츠를 ContentChild라고 한다. @ContentChild, @ContentChildren 데코레이터는 이 ContentChild를 를 참색할때 사용하며 이름에서 알 수 있듯 @ContentChild는 탐색 조건에 부합하는 1개의 콘텐츠를 취득할때, @ContentChildren은 여러 개의 콘텐츠를 한꺼번에 취득할 때 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<user-list>
<user #first>Lee</user>
<user>Beak</user>
<user>Kim</user>
</user-list>
`,
styles: []
})
export class AppComponent {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// user-list.component.ts
import { Component, ContentChild, ContentChildren, QueryList, AfterContentInit } from '@angular/core';
import { UserComponent } from './user.component'

@Component({
selector: 'user-list',
template: `
<div>
<ng-content></ng-content>
</div>
<button (click)="changeFirstUserColor()">첫 번째 사용자 색 변경</button>
<button (click)="changeAllUserColor()">모든 사용자 색 변경</button>
`
})
export class UserListComponent implements AfterContentInit {

@ContentChild('first') firstChild: UserComponent;
@ContentChildren(UserComponent) children: QueryList<UserComponent>;

ngAfterContentInit() {
console.log(this.firstChild);
this.children.forEach( (child) => console.log(child) );
}

changeFirstUserColor() {
this.firstChild.randomizeColor();
}

changeAllUserColor() {
this.children.forEach( (child) => child.randomizeColor() );
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// user.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'user',
template: `
<p [style.background-color]="color">
<ng-content></ng-content>
</p>
`
})
export class UserComponent {

colors = [ 'yellow', 'chartreuse', 'crimson' ];
color:string = this.colors[0];

randomizeColor() {
this.color = this.colors[Math.floor(Math.random() * 3)];
}

}

UserListComponent는 부모 컴포넌트(AppComponent)가 추가한 콘텐츠를 ng-content를 통해 프로젝션 하였다. 프로젝션된 3개의 UserComponent를 @ContentChild, @ContentChildren 데코레이터를 통해 탐색한다.

1
2
@ContentChild('first') firstChild: UserComponent;
@ContentChildren(UserComponent) children: QueryList<UserComponent>;

이때 @ContentChild, @ContentChildren을 사용하는 컴포넌트는 ng-content에 의해 어떤 요소가 프로젝션 되는지에 대해 사전에 인지하고 있어야 한다. 이는 자식 컴포넌트가 부모 컴포넌트에 의존하고 있음을 의미하고 따라서 부모 컴포넌트가 콘텐츠로 지정한 요소가 변경되면 ng-content를 통한 프로젝션으로 이를 받아야하는 자식 컴포넌트 또한 영향을 받기 때문에 주의가 필요하다.


7.11 컴포넌트와 스타일

7.11.1 컴포넌트 스타일

Angular 컴포넌트는 동작 가능한 하나의 부품으로 다른 컴포넌트의 간섭을 받지 않는 독립된 스코프의 스타일 정보를 갖는다. 다시 말해 컴포넌트에서 정의한 스타일은 그 컴포넌트에서만 유효하다.

스타일을 정의하는 방법은 @Component 데코레이터 메타데이터 객체의 ‘styles’ 프로퍼티에 직접 정의하는 방법과 ‘styleUrls’ 프로퍼티에 외부 CSS 파일의 경로를 정의하는 방법이 있다.

1
2
3
4
5
6
7
@Component({
styles: [
`
div{border:1px solid red}
`
]
})
1
2
3
@Component({
styleUrls: ['./style.css']
})

컴포넌트에서 정의한 스타일이 태그이름등 공통적으로 영향을 줄 수 있는 셀렉터를 사용하더라고 다른 컴포넌트에 영향을 주지 않는다


7.11.2 뷰 캡슐화(view encapsulation)

Angular는 컴포넌트의 CSS 스타일을 컴포넌트의 뷰에 캡슐화하여 다른 컴포넌트에는 영향을 주지 않는다. 기본적으로 임의의 어트리뷰트를 추가하는 방식(emulated)를 사용하여 뷰 캡슐화를 구현하지만 브라우저가 웹 컴포넌트를 지원한다는 전제하에 웹 컴포넌트의 shadow DOM을 이용하여 뷰 캡슐화를 구현할 수도 있다.

이를 위해 @Component데코레이터 메타데이터 객체의 encapsultation 프로퍼티에 옵션을 지정하여 컴포넌트 별로 뷰 캡슐화 전략을 설정할 수 있다.

ViewEncapsulation 의미
Emulated (기본값) 임의의 어트리뷰트를 추가하는 방식으로 뷰 캡슐화(컴포넌트의 스타일은 해당 컴포넌트에만 적용되게 됨)를 구현
Native 웹 컴포넌트의 Shadow DOM을 사용하여 뷰 캡슐화를 구현한다.
None 스타일 캡슐화를 지원하지 않도록 설정(다른 컴포넌트에도 영향을 주게 됨)


1
2
3
4
5
6
7
import { Component, ViewEncapsulation } from '@angualr/core';

...

@Component({
encapsulation: viewEncapsultation.Native
})


7.11.3 Shadow DOM 스타일 셀렉터

컴포넌트 스타일은 Shadow DOM에 접근에 사용하는 특수한 셀렉터인 Shadow DOM 스타일 셀렉터를 제공하는데 이 셀렉터는 Shadow DOM 스펙에 명시된 셀렉터도 뷰 캡슐화 전략(Emulated, Native)과 관계 없이 사용할 수 있습니다.

Shadow DOM 스타일 셀렉터 의미
:host 호스트 요소(컴포넌트 자신)
:host-context 호스트 요소의 외부(예를 들면 body)의 조건에 의해 컴포넌트의 요소를 선택


:host 셀렉터

:host 셀렉터는 호스트 요소(컴포넌트 자신)을 선택한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* app.component.css */

/* :host는 app.component에 적용 (EX> <app-root>) */
:host{
display:block;
background-color:gray;
}

/* 호스트의 상태(여기서는 class="active")에 따라 스타일을 적용할 때는 아래와 같이 사용 */
:host(.active) {
background-color:red;
}

/* 가상 선택자도 사용 가능 */
:host(:hover) {
border:1px solid blue;
}


:host-context 셀렉터

:host-context 셀렉터는 호스트 요소의 외부 조건, 즉 컴포넌트의 부모 요소를 포함하는 모든 조상 요소의 클래스 선언 상태에 의해 컴포넌트의 요소를 선택하는 경우 사용한다.

1
2
3
4
5
6
7
8
9
10
11
/* child.component.css(<child></child> */

/* <child>에 적용 */
:host{
font-size:12px;
}

/* <child>의 모든 조상 요소중에 하나라도 .active 가졌다면 적용 */
:host-context(.active) {
font-weight:bold;
}


7.11.4 글로벌(전역) 스타일

애플리케이션 전역에 적용되는 글로벌 스타일을 적용하려면 ‘src/styles.css’에 css룰셋을 정의를 하거나 angular.json 파일의 ‘project.my-project.architect.build.options.styles’ 프로퍼티에 글로벌 CSS파일의 경로를 추가한다.


7.11.5 angular CLI로 sass 적용 프로젝트 생성

Angular는 sass, less, stylus와 같은 대 부분의 CSS 프리프로세서를 지원하며 sass를 적용하기 위해서는 아래와 같이 CLI명령어를 사용한다.

1
ng new sass-project --style=scss

이때 생성된 angular.json 파일을 살펴보면 styleext, styles 프로퍼티의 값이 scss로 변경된 것을 확인할 수 있다.

이후 @Component 데코레이터 메타데이터 객체의 ‘styleUrls’ 프로퍼티에 아래와 같이 sass 파일의 경로를 설정한다.

1
2
3
4
5
6
7
@Component({
selector: 'app-root',
template: `
...
`,
styleUrls: ['./app.component.scss']
})