Experience/[Javascript] JS 30

[Javascript] 06. Ajax Type Ahead

winCow 2021. 4. 17. 16:05

1. 배경

검색어 자동완성 기능을 만들고자 한다. 미국의 주나 도시들 중, 입력하는 내용을 포함하는 주나 도시를 표시하도록 하고, 해당 글자에 색깔을 칠하며, 도시의 인구 수를 오른쪽에 입력할 것이다.

 

 

2. HTML

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Type Ahead 👀</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>

  <form class="search-form">
    <input type="text" class="search" placeholder="City or State">
    <ul class="suggestions">
      <li>Filter for a city</li>
      <li>or a state</li>
    </ul>
  </form>
<script>

</script>
</body>
</html>

 

 

3. CSS

html {
  box-sizing: border-box;
  background: #ffc600;
  font-family: 'helvetica neue';
  font-size: 20px;
  font-weight: 200;
}

*, *:before, *:after {
  box-sizing: inherit;
}

input {
  width: 100%;
  padding: 20px;
}

.search-form {
  max-width: 400px;
  margin: 50px auto;
}

input.search {
  margin: 0;
  text-align: center;
  outline: 0;
  border: 10px solid #F7F7F7;
  width: 120%;
  left: -10%;
  position: relative;
  top: 10px;
  z-index: 2;
  border-radius: 5px;
  font-size: 40px;
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.12), inset 0 0 2px rgba(0, 0, 0, 0.19);
}

.suggestions {
  margin: 0;
  padding: 0;
  position: relative;
  /*perspective: 20px;*/
}

.suggestions li {
  background: white;
  list-style: none;
  border-bottom: 1px solid #D8D8D8;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.14);
  margin: 0;
  padding: 20px;
  transition: background 0.2s;
  display: flex;
  justify-content: space-between;
  text-transform: capitalize;
}

.suggestions li:nth-child(even) {
  transform: perspective(100px) rotateX(3deg) translateY(2px) scale(1.001);
  background: linear-gradient(to bottom,  #ffffff 0%,#EFEFEF 100%);
}

.suggestions li:nth-child(odd) {
  transform: perspective(100px) rotateX(-3deg) translateY(3px);
  background: linear-gradient(to top,  #ffffff 0%,#EFEFEF 100%);
}

span.population {
  font-size: 15px;
}

.hl {
  background: #ffc600;
}

 

 

4. Javascript: 데이터 가져오기

const endpoint = 'https://gist.githubusercontent.com/Miserlou/c5cd8364bf9b2420bb29/raw/2bf258763cdddd704f8ffd3ea9a3e81d25e2c6f6/cities.json';

const cities = [];
fetch(endpoint)
  .then(blob => blob.json())
  .then(data => cities.push(...data));

도시의 정보들이 담긴 JSON 파일이 endpoint로 주어져 있다. 이 데이터는 도시의 정보들이 key:value의 형태를 갖춘 객체들의 배열로 이루어져 있다. 이를 fetch 함수를 통해 자바스크립트로 가져오는데, 이 때 반환되는 response 값은 다시 key:value 형태로 바꿔 주어야 하므로 .json 매소드를 통해 변환시켜 주었다. 그 후, 빈 배열인 cities에 push 매소드를 통해 입력해 주었다. 여기서, ...을 입력하는 전개 구문(Spread Syntax)가 사용되었는데, 이름 그대로 객체나 배열들을 펼쳐 주는 역할을 한다. 전개 구문 없이 그냥 입력해 버리면, 모든 도시의 정보들이 하나의 데이터가 되어, cities의 length가 1이 되어버린다. 전개 구문을 이용하면 각각의 도시의 정보들이 하나의 데이터가 되어 length가 1000이 된다.

전개 구문은 이처럼 배열이나 객체의 원소를 다른 배열이나 객체로 옮길 때 이용한다고 보면 되겠다.

 

 

5. Javascript: Main System

function findMatches(wordToMatch, cities) {
  return cities.filter(place => {
    const regex = new RegExp(wordToMatch, 'gi');
    return place.city.match(regex) || place.state.match(regex);
  });
}

우선, 입력한 내용과 일치하는 데이터를 찾아 매칭시키는 함수를 위와 같이 정의하였다. cities 배열에 filter를 걸어 이를 통과한 값들을 반환하는 것이다. filter의 콜백함수로는, match 매소드를 이용하여, 각각의 도시(변수 place)에 대하여 city키 혹은 state키에 대응하는 값을 match시켰다.

match 매소드는 문자열이 정규식과 매치되는 부분을 검색하는 매소드로, string.match(regexp) 형태로 이용한다. regexp는 정규식 개체이다.

RegExp는 정규표현식을 사용하기 위한 객체를 생성하는데, 위 코드에서는 new를 이용한 생성자 방식을 따랐다. 정규표현식은 문자열에서 특정 문자열이 존재하는지 확인하거나, 문자열의 특정 부분을 다른 문자열로 변경할 때 사용한다. 생성자 방식은 new RegExp(pattern, flag)과 같은 형태로 이용하는데, parttern은 string 형태의 정규표현식이고, flag는 검색 옵션과 같다. g는 지정하지 않는 경우, 첫 번째 일치하는 문자만을 검색하며, i를 지정하면 대소문자를 구분하지 않고 검색할 수 있다. 변수 wordToMatch에는 사용자가 입력한 값이 올 것이고, 이를 기준으로 city나 state의 value값 중 일치하는 값이 있는 경우 이를 반환할 것이다.

정규표현식을 사용할 때, 이번 사례와 같이 사용자로부터 값을 입력받는 등, 다른 출처로부터 패턴을 가져오는 경우에는 생성자 함수를 호출하는 방식을 사용해야 하지만, 그렇지 않는 경우에는 리터럴 방식으로 생성할 수 있다.

 

function numberWithCommas(x) {
  return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

가장 위의 numberWithCommas는 글자 사이에 콤마를 넣기 위한 것으로 여기서는 미리 입력된 값을 가져오기만 하였다. 

 

function displayMatches() {
  const matchArray = findMatches(this.value, cities);
  const html = matchArray.map(place => {
    const regex = new RegExp(this.value, 'gi');
    const cityName = place.city.replace(regex, `<span class="hl">${this.value}</span>`);
    const stateName = place.state.replace(regex, `<span class="hl">${this.value}</span>`);
    return `
    <li>
      <span class="name">${cityName}, ${stateName}</span>
      <span class="population">${numberWithCommas(place.population)}</span>
    </li>
    `;
  }).join('');
  suggestions.innerHTML = html;
}

const searchInput = document.querySelector('.search');
const suggestions = document.querySelector('.suggestions');

searchInput.addEventListener('change', displayMatches);
searchInput.addEventListener('keyup', displayMatches);

메인 시스템은 input 창에 키가 입력되었을 때, list를 추가하고 입력된 값고 일치하는 값을 찾아 list에 입력하는 것이다. 그러므로 input 창에 입력된 정보를 가져오기 위해 input을 선택하고, keyup과 change 이벤트에 대해 이벤트 리스너를 추가하여 displayMatches 함수를 실행하도록 한다. displayMatches 함수는 위에서 정의한 findMatches 함수를 이용한다. 이벤트리스너를 통해 전달된 입력값(this.value)이 findMatches에 전달되면, cities의 배열의 모든 요소들이 전달된 입력값과 일치하는지를 확인하는 필터를 거쳐서 일치하는 경우만이 반환된다. 이 반환값이 matchArray 변수로 정의되고, 이를 map API를 이용해 html의 list 형식으로 바꾸는 것이 html 변수이다. 

html 변수는 구체적으로 다음과 같이 정의했다. 먼저 replace매소드를 이용하기 위해 다시 RegExp 객체를 정의하였다. replace 매소드는 어떤 패턴에 일치하는 일부 또는 모든 부분이 교체된 새로운 문자열을 반환한다. string.replace(regexp, substr) 형식을 이용하며, string이 substr로 대체된다. 

이를 바탕으로, html의 반환값은, 클래스명이 각각 name, population인 두 개의 span을 자식으로 두는 list가 되는데, name span은 cityName과 stateName이 다시 자식으로 온다. cityName과 stateName은 각각 해당 도시나 주의 city, state 키에 해당하는 value를 입력된 값의 정규식 표현 방식(`<span class="hl">${this.value}</span>`)으로 대체한다. 이를 join을 통해 문자열로 바꾼 뒤, suggestions의 내부 태그에 입력한다.

 

※ innerHTML은 보안이 취약하므로 document.createElement / appendChild()를 사용하는 것이 좋다. 위 코드에서는 document.createElement는 HTML 요소를 만드는 매소드이고, appendChild()는 HTML의 자식 요소를 만드는 매소드이다. 위 코드를 기준으로 document.createElement('li'), ul.appendChild('li')로 리스트 태그를 만들 수 있다.

 

cities는 json 자료를 가지고 있는 배열로, 위 코드에서는 변하지 않는 값이므로, 사실 굳이 매개변수로 cities를 쓸 필요가 없다. 즉, matchArray를 정의할 때, this.value만을 전달하고, findMatches를 정의할 때, wordToMatch만을 매개변수로 전달해도 이 프로젝트에서는 아무런 문제가 발생하지 않는다. 그러나 만약, 프로젝트의 규모가 커져서, findMatches와 같은 로직의 함수를, countries라는 새로운 배열을 대상으로 만들어야 한다고 가정하자. 이 경우, findMatches와 완전히 같은 로직의 함수를 새롭게 하나 더 만들어야 할 필요가 있다. 그러나 위와 같이 cities를 인자로 전달하도록 코드를 만들면, 전달하는 배열을 바꾸는 것만으로 간단하게 재사용이 가능하다. 즉, 코드의 결합도를 낮춰 재사용성을 향상시킬 수 있는 것이다.