← Posts

Handling Anchor Tag Click Events in Vanilla JS SPA

Understanding the closest method and how to use it

·2 min read
#closest#vanilla-js#spa
<nav class="nav">
  <a href="/" class="nav__link" data-link>Dashboard</a>
  <a href="/posts" class="nav__link" data-link>Posts</a>
  <a href="/settings" class="nav__link" data-link>Settings</a>
</nav>

이 영상에서 SPA를 구현하기 위해, a 태그를 클릭 했을 때 data-link 속성이 있는 경우 History API를 이용해 페이지를 이동하도록 구현한다.

// https://github.com/dcode-youtube/single-page-app-vanilla-js/blob/master/frontend/static/js/index.js#L54-L63
document.addEventListener('DOMContentLoaded', () => {
  document.body.addEventListener('click', (e) => {
    if (e.target.matches('[data-link]')) {
      e.preventDefault()
      navigateTo(e.target.href)
    }
  })

  router()
})

하지만, 다음과 같은 경우에는 예상과 다르게 동작했다.

<a href="/posts" class="nav__link" data-link>
  <span>Posts</span>
</a>

이 경우, 이벤트 버블링으로 인해 e.targetspan이 되기 때문에 e.target.hrefundefined가 된다.

이벤트 버블링

이벤트 버블링은 이벤트가 발생한 요소에서 시작해서 상위 요소로 전달되는 현상을 말한다.

그래서 e.targeta 태그가 아닌 경우에는 e.target의 부모 노드를 찾아야 한다.

첫번째 방법: 부모 노드를 재귀적으로 찾기

target(Element) 안에 parentElement를 통해 부모 노드를 확인해서 a 태그가 나올 때까지 재귀적으로 찾는 방법을 생각해보았다.

하지만 재귀는 무한 루프에 빠질 수 있는 위험이 있기 때문에, 다른 방법이 있는지 찾아보았다.

두번째 방법: closest

Element 메서드 중 closest라는 메서드가 있다.

Element.closest()

Element.closest() 메서드는 CSS selector를 인자로 받아서, 해당 selector와 일치하는 노드를 찾을 때까지 부모 노드를 탐색한다. 만약 일치하는 노드를 찾지 못하면 null을 반환한다.

이 메서드를 이용하면 위의 문제를 해결할 수 있다.

document.addEventListener('DOMContentLoaded', () => {
  document.body.addEventListener('click', (e) => {
    const closestLink = e.target.closest('[data-link]')
    if (closestLink) {
      e.preventDefault()
      navigateTo(closestLink.href)
    }
  })

  router()
})

세번째 방법: pointer-events: none 사용하기

a 태그의 자식 요소를 클릭했을 때, 부모 요소의 이벤트를 막는 방법으로 pointer-events: none을 사용할 수 있다.

a > * {
  pointer-events: none;
}

하지만, 만약 a 태그의 자식 요소에 클릭 이벤트를 추가하고 싶은 경우에는 이 방법을 사용할 수 없다. 그래서 이 방법은 추천하지 않는다.

결론

이 문제를 해결하기 위해, closest 메서드를 사용하는 것이 가장 좋은 방법이라고 생각한다.

<nav class="nav">
  <a href="/" class="nav__link" data-link>Dashboard</a>
  <a href="/posts" class="nav__link" data-link>Posts</a>
  <a href="/settings" class="nav__link" data-link>Settings</a>
</nav>

In this video, to implement a SPA, when an anchor tag is clicked and has the data-link attribute, the History API is used to navigate to the page.

// https://github.com/dcode-youtube/single-page-app-vanilla-js/blob/master/frontend/static/js/index.js#L54-L63
document.addEventListener('DOMContentLoaded', () => {
  document.body.addEventListener('click', (e) => {
    if (e.target.matches('[data-link]')) {
      e.preventDefault()
      navigateTo(e.target.href)
    }
  })

  router()
})

However, in the following case, it behaved unexpectedly.

<a href="/posts" class="nav__link" data-link>
  <span>Posts</span>
</a>

In this case, due to event bubbling, e.target becomes the span element, so e.target.href becomes undefined.

Event Bubbling

Event bubbling refers to the phenomenon where an event starts from the element where it occurred and propagates up to parent elements.

Therefore, when e.target is not an a tag, we need to find the parent node of e.target.

First Approach: Recursively Finding the Parent Node

I considered using the parentElement property within the target (Element) to check the parent node and recursively traverse up until an a tag is found.

However, since recursion carries the risk of falling into an infinite loop, I looked for alternative approaches.

Second Approach: closest

Among the Element methods, there is a method called closest.

Element.closest()

The Element.closest() method takes a CSS selector as an argument and traverses up through parent nodes until it finds a node that matches the selector. If no matching node is found, it returns null.

Using this method, we can solve the problem described above.

document.addEventListener('DOMContentLoaded', () => {
  document.body.addEventListener('click', (e) => {
    const closestLink = e.target.closest('[data-link]')
    if (closestLink) {
      e.preventDefault()
      navigateTo(closestLink.href)
    }
  })

  router()
})

Third Approach: Using pointer-events: none

When a child element of an anchor tag is clicked, you can use pointer-events: none to prevent the child from receiving the event.

a > * {
  pointer-events: none;
}

However, if you want to add click events to child elements of the anchor tag, this approach cannot be used. Therefore, this method is not recommended.

Conclusion

To solve this problem, I believe using the closest method is the best approach.