[es6+] WeakMap을 이용한 이벤트 델리게이션

개요top

DOM이벤트는 개별 엘리먼트에 걸어줄 수도 있지만 부모 쪽에 걸고 버블링이나 캡쳐링을 이용하는 방법도 있습니다.
우선 자식 엘리먼트가 자주 생성되고 삭제되는 상황이라면 일일히 이벤트를 거는 작업도 피곤할 뿐 아니라 GC문제도 발생하기 때문에 상대적으로 이슈가 적은 부모쪽에 한 번만 걸고 델리게이션을 통해서 자식 이벤트를 처리하려는 거죠.
그 전까지는 다양한 방법은 엘리먼트에 마킹했는데 WeakMap이 나오면서 더 이상 추가적인 마킹없이 델리게이션을 구현할 수 있습니다.

자료구조top

우선 간단한 HTML을 생각해보죠.

<div id="wrapper">
 <div>wrapper</div>
</div>
<div>
 <div id="self">self</div>
</div>

 
1. wrapper의 경우 부모 쪽에 이벤트를 걸려하고
2. self는 본인에게 걸 생각입니다.
 
WeapMap에 해당 엘리먼트를 베이스로 하는 리스너 저장소를 생각해봅시다.

  1. 엘리먼트를 키로 하여 다시 Map을 값으로 넣고
  2. 그 Map에 이벤트 이름별로 Set을 넣는다.

결국 엘리먼트에는 이벤트별로 리스너가 들어가기 때문에 2단 구조가 필요한 셈입니다.
이렇게 만들어지는 WeakMap은 싱글톤이면 충분하니 익명 클래스를 사용해도 충분합니다.

//이벤트리스너를 관리하는 구조체
const ev = new (class extends WeakMap{
  constructor(){super();}

  //리스너추가 - 엘리먼트, 이벤트명, 리스너
  addEv(el, event, listener){
    //1. 엘리먼트별 Map생성
    if(!this.has(el)) this.set(el, new Map);
    const channel = this.get(el);

    //2. 이벤트별 Set생성
    if(!channel.has(event)) channel.set(event, new Set);

    //3. 리스너는 이벤트별 Set에 추가
    channel.get(event).add(listener);
    return this;
  }

  //이벤트별 Set얻기
  getEv(el, event){
    if(!this.has(el) || !this.get(el).has(event)) return;
    return this.get(el).get(event);
  }
});

간단히 2단 자료 구조로 자바식으로 표현하자면 Map<element, Map<eventName, Set>> 인 상태입니다.

통합 리스너top

이제 위에서 작성한 listeners를 검색하여 실제 리스너를 호출해주는 통합이벤트 처리 리스너를 작성할 차례입니다.
구현은 일반적인 버블링 기준으로 진행합니다.

//통합리스너-버블링으로 검색해가자!
const listener =e=>{
  let {target, type} = e;
  do{
    //현재 타겟에 리스너가 설정되었다면
    const list = ev.getEv(target, type);
    if(list){
      //돌면서 실행!
      list.forEach(f=>f({target, type}));
      break;
    }
  //아니라면 부모로 이동
  }while(target = target.parentNode);
};

간단한 구조로 딱히 설명할게 없네요. e.target은 항상 가장 마지막의 자식이 되므로 역으로 추적해가면 됩니다.
이제 이를 body에 걸겠습니다.

'click,mousedown,mouseup,touchstart,touchend'.split(',')
  .forEach(ev=>document.body.addEventListener(ev, listener));

주요 이벤트만 걸었습니다(몇몇 이벤트는 델리게이션에 적합하지 않습니다)

리스너 등록 및 실제 사용top

이제 listeners에 원하는 리스너를 등록하여 사용할 수 있습니다.

//대상의 id를 표시한다
const f =({target:{id}})=>console.log(id);

//각각 이벤트를 등록한다.
'wrapper,self'.split(',').forEach(
  id=>ev.addEv(document.querySelector('#' + id), 'click', f)
);

이제 wrapper를 클릭하면 부모인 wrapper쪽에 걸린 이벤트가 작동하고
self를 클릭하면 본인에게 걸린 이벤트가 작동하는 것을 볼 수 있습니다.

결론top

WeapMap은 객체를 키로 잡을 수 있기 때문에 객체 자체를 건드리지 않고 다양한 작업을 할 수 있습니다. 특히 Weak참조의 특성 상 키 대상 객체가 사라지면 같이 메모리에서 제거되므로 이를 활용하여 다양한 분야에 사용될 수 있죠. 엘리먼트 하나하나에 이벤트를 걸지 않는 델리게이션 방식에서 특정 리스너가 엘리먼트와 매칭되게 하는 부분에서 굉장히 적합하다 할 수 있습니다.

전체 코드는 아래와 같습니다.

<div id="wrapper">
 <div>wrapper</div>
</div>
<div>
 <div id="self">self</div>
</div>
const ev =(_=>{
  const ev = new (class extends WeakMap{
    constructor(){super();}
    addEv(el, event, listener){
      if(!this.has(el)) this.set(el, new Map);
      const channel = this.get(el);
      if(!channel.has(event)) channel.set(event, new Set);
      channel.get(event).add(listener);
      return this;
    }
    getEv(el, event){
      if(!this.has(el) || !this.get(el).has(event)) return;
      return this.get(el).get(event);
    }
  });
  const listener =e=>{
    let {target, type} = e;
    do{
      const list = ev.getEv(target, type);
      if(list){
        list.forEach(f=>f({target, type}));
        break;
      }
    }while(target = target.parentNode);
  };
  'click,mousedown,mouseup,touchstart,touchend'
    .split(',')
    .forEach(ev=>document.body.addEventListener(ev, listener));
  return ev;
})();


const f =({target:{id}})=>console.log(id);
'wrapper,self'
  .split(',')
  .map(v=>document.querySelector('#' + v))
  .forEach(el=>ev.addEv(el, 'click', f));

실행해보기 – 콘솔을 보세요