FRONT-END/JAVASCRIPT

자바스크립트 디자인 패턴 #2. Event Delegation 패턴

살수다 2017. 12. 29. 16:58
반응형

이벤트 델리게이션 패턴

이벤트 델리게이션(delegation) 패턴은 매우 실용적으로 사용할 수 있는 디자인 패턴이다.

이 패턴은 다수의 HTML에 이벤트를 걸어야할 때, 그리고 활발한 이벤트 처리가 필요할 때 구성하면 좋다.

다수의 DOM 모두에 이벤트리스너를 부여하는 것이 아니라, 대표 DOM에만 이벤트를 걸어서 처리하는 패턴이다.


일반적으로 간단한 사이트를 개발할 때 동적으로 움직이거나 동작하는 메뉴와 화면에 개별적으로 이벤트 리스너를 할당하여 관리한다.

따라서 이벤트 리스너를 할당하고자 하는 DOM의 수가 많지 않아서 큰 문제가 없다.

하지만 DOM이 아주 많고 이벤트도 복잡하고 다양한다면, 일일이 모든  DOM에 이벤트 리스너를 할당하는 것은 초기화 단계에서 컴퓨팅 자원을 많이 소모하는 문제가 있을 수 있다.

따라서 하나의 부모 DOM을 만들어서 이벤트를 처리하는 것이 바로 이벤트 델리게이션 패턴이다.


이벤트 델리게이션 패턴의 작동원리는 HTML에서 이벤트 버블링을 통해 이벤트를 상위 DOM으로 전달할수 있다는 데 기인한다.

HTML은 기본적으로 트리구조의 DOM을 표현한다.

따라서 자식DOM 요소에 발생한 이벤트는 포괄적으로 보면 부모 DOM요소에서도 발생한것으로 인지할 수 있다.

이처럼 이벤트가 발생하였을 때 부모와 자식 DOM 사이에 해당 이벤트를 전파할 때 캡처링 -> 대상 -> 버블링이라는 3 단계를 거친다.


이벤트 버블링과 캡처링

버블링과 캡처링은 정반대로 동작한다.

버블링은 특정  DOM에서 이벤트가 발생하면 가장 하위DOM부터 상위의 부모 DOM으로 한단계씩 전파된다.

캡처링은 이벤트가 최상위의 부모 DOM부터 가장 하위 DOM까지 부모에서부터 전파되는 것을 의미한다.


  <style>

div {

border: 1px solid black;

}

.divOutside {

width: 200px;

height: 200px;

background-color: lightgreen;

}

.divMiddle {

width: 150px;

height: 150px;

background-color: lightblue;

}

.divInside {

width: 100px;

height: 100px;

background-color: pink;

position: relative;

}

.divFloat {

position: absolute;

left: 210px;

width: 50px;

height: 50px;

background-color: lightgray;

}

.highlight {

background-color: black;


}

  </style>


<div id="divBubblingOutside" class="divOutside">

<div id="divBubblingMiddle" class="divMiddle">

<div id="divBubblingInside" class="divInside">

Bubbling

<div id="divBubblingFloat" class="divFloat">

</div>

</div>

</div>

</div>

<div id="divCapturingOutside" class="divOutside">

<div id="divCapturingMiddle" class="divMiddle">

<div id="divCapturingInside" class="divInside">

Capturing

<div id="divCapturingFloat" class="divFloat">

</div>

</div>

</div>

</div>




(function(){

document.getElementById("divBubblingOutside")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Outside bubbling");

this.classList.toggle("highlight");

});

document.getElementById("divBubblingMiddle")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Middle bubbling");

this.classList.toggle("highlight");

});

document.getElementById("divBubblingInside")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Inside bubbling");

this.classList.toggle("highlight");

});

document.getElementById("divBubblingFloat")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Float bubbling");

this.classList.toggle("highlight");

});


document.getElementById("divCapturingOutside")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Outside Capturing");

this.classList.toggle("highlight");

}, true);

document.getElementById("divCapturingMiddle")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Middle Capturing");

this.classList.toggle("highlight");

}, true);

document.getElementById("divCapturingInside")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Inside Capturing");

this.classList.toggle("highlight");

}, true);

document.getElementById("divCapturingFloat")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Float Capturing");

this.classList.toggle("highlight");

}, true);

}())

이벤트 버블링은 가장 자식 요소인 divBubblingFloat를 클릭하면 이벤트 핸들러를 걸어놨던 그 부모 요소들까지 모든 이벤트 핸들러가 순서대로 호출된다.

이렇게 클릭 이벤트가 발생했을 때 대상이 되는 해당 DOM 요소부터 이벤트 핸들러 콜백이 실행되어, 트리를 따라 하나씩 상위 부모 DOM요소를 타고 올라가는 방식이 이벤트 버블링이다.

이벤트 캡처링은 이와 반대로 부모 DOM요소부터 이벤트 핸들러 콜백이 호출되어, 하나씩 자식 DOM 요소로 내러가는 방식이다.


특정 DOM에서 이벤트가 발생하면 해당 DOM의 dispatchEvent()라는 함수를 통해서 이벤트를 전달한다.

이때 이벤트 전달은 propagation path라는 전파경로에 따라서 수행된다.

이벤트 전달되는 단계는 크게 세 단계로 이루어지는데,

이벤트 캡처링이 일어나고 그 다음 이벤트 대상에 해당하는 이벤트 핸들러가 호출된 다음, 이벤트 버블링으로 이어진다.

따라서 캡처링과 버블링을 위한 두가지 이벤트 핸들러 같은 DOM에 설정해놓으면, 캡처링으로 설정해 놓은 이벤트 핸들러가 먼저 호출되고 이후 버블링 단계에서 버블링을 위한 이벤트 핸들러가 순서대로 호출된다.


DOM 수정시 전달 경로

일단 이벤트가 발생되고 나면 이벤트 핸들러 안에서 DOM이 수정되더라도 이벤트 전달은 중단되지 않고, 원래의 경로를 통해서 이벤트가 전파된다.


document.getElementById("divCapturingOutside")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Outside Capturing");

this.classList.toggle("highlight");

}, true);


document.getElementById("divCapturingMiddle")

.addEventListener('click',function(){

this.classList.toggle("highlight");

document.getElementById("divCapturingInside")

.removeChild(document.getElementById("divCapturingFloat"));

alert("Middle Capturing, deleting divCapturingFloat");

this.classList.toggle("highlight");

}, true);

document.getElementById("divCapturingInside")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Inside Capturing");

this.classList.toggle("highlight");

}, true);

document.getElementById("divCapturingFloat")

.addEventListener('click',function(){

this.classList.toggle("highlight");

alert("Float Capturing");

this.classList.toggle("highlight");

}, true);

이벤트 캡처링 단계중 divCapturingMiddle의 이벤트 핸들러에서 이벤트 대상인 divCapturingFloat를 삭제하고 있다.

이벤트 핸들러 전달 중 최종으로 도착하는 divCapturingFloat가 삭제되었을 때, 표준에 명시된 대로 전달 경로가 수정되지 않는지 확인할 수 있다.

실행결과를 살펴보면, 이벤트 전파 순서에 따라 이벤트 캡처링 단계가 끝나고 이벤트 대상인 divCapturingFloat의 이벤트 핸들러 호출 순서가 되었을 때, divCapturingFloat가 DOM에서 삭제 되어 보이지 않는다.

그런데도 이벤트 핸들러가 그대로 호출되고 있는 것을 확인 할 수 있다.


이벤트 전달 세 단계

캡처링 -> 대상 -> 버블링


캡처링 단계 : 이벤트 객체가 window 객체로부터 대상의 부모까지 순서대로 전달되는 단계이다.

대상 단계:  이벤트 객체가 이벤트 대상에 도달하는 단계로 이 단계에서 이벤트가 버블링 단계를 거치지 않는다고 명시하면, 이벤트 객체는 다음단계를 생략한다.

버블링 단계: 이벤트 객체가 대상의 부모부터 window 객체까지 역순으로 전파된다.

캡처링 단계에서는 이벤트가 발생한 대상의 부모들을 window 객체부터 시작하여 순서대로 호출한다.

그리고 부모가 캡처링으로 정의되었다면 이벤트 대상에 도달하기 전에 호출되어야한다.

대상 단계에서는 이벤트가 대상에 도달했을  때 버블링 할것인지를 정할 수 있으며, 만일 버블링하지 않으면 이후의 버블링 단계는 생략한다.

마지막으로 버블링 단계는 다시 이벤트 대상부터 트리를 따라서 부모 DOM 요소를 따라 핸들러를 처리한다.


이처럼 이벤트가 발생한 대상 DOM 이외 부모 DOM 에서 이벤트 핸들러를 호출할 수 있는 시점이 각각 캡처링 단계와 버블링 단계에서 두번 발생한다.

이벤트 델리게이션 패턴은 첫 번째 단계인 캡처링에서 먼저 이벤트를 잡어서 처리한다면 성능상으로 조금 더 효율적으로 구현할 수 있다.

물론 구버전 브라우저에서는 이벤트 캡처링을 지원하지 않을 수도 있어서 기본적으로 이벤트 버블링 동작으로 예상하고 개발하는 것이 좋을 수도 있다.

하지만 현재 대부분 브라우저가 기본적으로 지원해주므로 캡처링으로 하는 것이 좋을 것이다.


이벤트 델리게이션 패턴

-다수의 DOM에 한꺼번에 이벤트 리스너를 할당해야 할 때

-동적인 DOM에 이벤트리스너를 그때그때 할당해야 할 때

예) 웹으로 스프레드시트와 같은 테이블 기능을 개발한다고 하면 스프레드시트에는 각 칸마다 이벤트 리스너를 할당해야 할 것이다.

화면 100개 이상의 칸이 있다고  하면 자바스크립트로 모든 칸을 루프로 돌면서 처리할 수 있다.


<div id="tableWrapper">

<div id="row0">

<div id="cell0000">00.00</div>

<div id="cell0001">00.01</div>

<div id="cell0002">00.02</div>

<div id="cell0003">00.03</div>

<div id="cell0004">00.04</div>

<div id="cell0005">00.05</div>

<div id="cell0006">00.06</div>

<div id="cell0007">00.07</div>

<div id="cell0008">00.08</div>

<div id="cell0009">00.09</div>

</div>

<div id="row1">

<div id="cell0100">01.00</div>

<div id="cell0101">01.01</div>

<div id="cell0102">01.02</div>

<div id="cell0103">01.03</div>

<div id="cell0104">01.04</div>

<div id="cell0105">01.05</div>

<div id="cell0106">01.06</div>

<div id="cell0107">01.07</div>

<div id="cell0108">01.08</div>

<div id="cell0109">01.09</div>

</div>

</div>


(function(){

var x, y, cell;

for(x=0; x<2; x++){

for(y=0; y<10; y++){

cell = document.getElementById("cell"+("0"+x).substr(-2)+("0"+y).substr(-2));

cell.onmouseover = function() {

this.style.backgroundColor = "#EEEEEE";

};


cell.onmouseout = function(){

this.style.backgroundColor = "#FFFFFF";

};

cell.onclick = function(){

alert(this.innerHTML);

};

}

}

}());

직관적으로 쉽게 알수 있지만, 이렇게 루프를 돌면서 이벤트리스너를 할당하는 것은 컴퓨터자원을 상당히 소모한다.

그리고 만약 초기화 단계에서 이렇게 이벤트 리스너를 100개 이상 할당하게 되면 초기화 단계 동안 브라우저가 잠시 자바스크립트를 실행하느라 멈칫거리게 되어 사용자 경험이 나빠질 수 있다.


초기화 단계에서 많은 이벤트 리스너를 할당하기보다는 간단하게 하나의 부모 DOM에 이벤트 리스너를 할당하여 관리하는 방법이 있다.

이것이 바로 이벤트 델리게이션 디자인 패턴이다.


(function(){


var wrapper = document.getElementById("tableWrapper");

wrapper.addEventListener("mouseover",function(e){

var target = e.target || e.srcElement;

var that = target;

console.log(target);

console.log(that.id.indexOf("cell"));


if(that.id && that.id.indexOf("cell")> -1){

that.style.backgroundColor = "#EEEEEE";

}

}, true);

wrapper.addEventListener("mouseout",function(e){

var target = e.target || e.srcElement;

var that = target;

if(that.id && that.id.indexOf("cell")> -1){

that.style.backgroundColor = "#FFFFFF";

}

}, true);

wrapper.addEventListener("click",function(e){

var target = e.target || e.srcElement;

var that = target;

if(that.id && that.id.indexOf("cell")> -1){

alert(that.innerHTML);

}

}, true);


}());

위와 같이 wrapper 하나에 이벤트 리스너를 할당하면 이벤트 전파 경로를 통해서 이벤트가 전파될 때 부모 DOM에서도 처리할 수 있다.

이렇게 이벤트를 처리하면 직접 루프를 돌면서 할당하는 것보다 직관적이지 않지만, 자바스크립트 소스의 초기화 속도는 많이 개선된다.


하지만 이벤트 캡처링이나 버블링을 해야 해서 만약<body>처럼 아주 상위 DOM에 이벤트를 걸게 되면 각각의 모든 이벤트를 처리하는데 필요한 자원이 직접 HTML DOM마다 이벤트를 거는 것보다 많을 수 있다.

따라서 이러한 초기화 단계에서의 사용자 경험과 이벤트가 발생하고 나서의 사용자 경험의 차이를 잘 고려하여 적당한 선의 wrapper를 결정해서 이벤트 델리게이션 패턴을 사용하는 것이 좋다.


DOM의 id 따라 이벤트 처리 분기

이벤트 델리게이션 패턴을 사용하면서 class나 data-* 등과 같은 속성을 이용해 어느 DOM에서 클릭 이벤트가 일어났는지 구분하여 처리할 수도 있다.

예) 다양한 버튼이 있는 비디오 컨트롤 패널


<video id="videoBunny" width="480" height="240" controls>

  <source src="http://media.w3.org/2010/05/bunny/movie.ogv" type="video/ogg">

</video>


<div id="controlPanel">

<button id="play">Play</button>

<button id="pause">Pause</button>

<button id="volumeUp">Volume+</button>

<button id="volumeDown">Volume_</button>

</div>

 <script>


(function(){

var divControlPanel = document.getElementById("controlPanel"),

videoBunny = document.getElementById("videoBunny");


divControlPanel.addEventListener("click",function(e){

var target = e.target || e.srcElement;

console.log(target);

if(target.id === "play"){

videoBunny.play();

} else if(target.id === "pause"){

videoBunny.pause();

} else if(target.id === "volumeUp"){

if(videoBunny.volume <= 0.9){

videoBunny.volume += 0.1;

}else{

videoBunny.volume = 1;

}

} else if(target.id === "volumeDown"){

if(videoBunny.volume >= 0.1){

videoBunny.volume -= 0.1;

}else{

videoBunny.volume = 0;

}

}


event.stopPropagation();

}, true);

}());

각 버튼에 대한 클릭 이벤트를 처리하기 위해서 4개의 이벤트 핸들러를 할당하는 것이 아니라, 이벤트 델리게이션 패턴을 사용하여 4개 버튼의 공통 부모인 divControlPanel에 하나의 이벤트 핸들러를 할당하였다.

그리고 해당 이벤트 핸들러 안에서 어떠한 버튼이 클릭되었는지에 따라  다르게 처리하도록 하였다.

그리고 각 버튼에서 클릭 이벤트가 발생하면 캡처링 단계에서 이벤트 핸들러가 호출되어 이벤트 대상인 event.target을 보고 어느 버튼이 클릭되었는지 판단하여 다르게 처리한다.

이 때 event.stopPropagation() 함수를 호출하면 이후 이벤트는 전파되지 않아 이벤트 대상인 버튼이나 이후 이벤트 버블링 단계에 핸들러를 설정해도 호출되지 않는다.


(function(){

var divControlPanel = document.getElementById("controlPanel"),

videoBunny = document.getElementById("videoBunny");


divControlPanel.addEventListener("click",function(e){

var target = e.target || e.srcElement;

console.log(target);

if(target.id === "play"){

videoBunny.play();

} else if(target.id === "pause"){

videoBunny.pause();

} else if(target.id === "volumeUp"){

if(videoBunny.volume <= 0.9){

videoBunny.volume += 0.1;

}else{

videoBunny.volume = 1;

}

} else if(target.id === "volumeDown"){

if(videoBunny.volume >= 0.1){

videoBunny.volume -= 0.1;

}else{

videoBunny.volume = 0;

}

}


event.stopPropagation();

}, true);


divControlPanel.addEventListener("click",function(){

alert("Bubbling event linstener");

});

document.getElementById("play").addEventListener("click",function(){

alert("Target event listener");

});


}());

이벤트 캡처링 단계의 핸들러 이외에 이벤트  버블링과 이벤트 대상 단계에 대한 핸들러를 추가로 할당하여 해당 단계에 다다르면 알림창을 뜨도록 하였다.

하지만 이를 실행해보면, 이벤트 캡처링 단계에서 이벤트가 처리되고  event.stopPropagation()을 호출하여 이벤트 단계를 중단하므로 알림창이 뜨지 않고 이벤트 대상과 이벤트 버블링 단계의 핸들러는 실행되지 않는다.


이벤트 캡처링을 통해서 클릭 이벤트 처리를 부모 DOM에서 완료한 다음 이벤트에 대한 전파를 event.stopPropagation() 함수로 막는다면 브라우저상에서 이벤트 대상 단계와 이벤트 버블링 단계의 추가적인 이벤트 전달로 인한 자원 소모를 조금이라도 아낄수 있다.

클릭 이벤트를 처리하는 영역이 넓어지는 만큼 추가로 불필요한 이벤트에 대한 처리까지 수행하게 되므로 wrapper의 설정은 웹페이지의 동작을 고려하여 설정해야한다.


출처-속깊은 자바스크립트 양성익 지음

반응형