단어 검색

2023년 3월 9일

intro

이번 글에서는 단어를 입력해 얻은 단어의 뜻을 모달에 띄우는 동작을 구현하는 것이 목표
단어 입력에 대한 결과에는 4개의 경우가 있다.

  • 올바른 단어
  • 존재하지 않지만 유사한 단어가 있는 단어
  • 존재하지 않는 단어
  • 빈 단어

구현할 기능 미리 보기

우선 기능 구현에 앞서, 필요한 라이브러리를 설치한다.

VueUse

terminal
npm i @vueuse/core
  • VueUse는 vue의 composition 유틸리티 컬렉션이다. localStorage를 편하게 이용하기 위해 사용
  • 웹 스토리지는 문자형 데이터 타입만 지원하기에 다른 타입을 이용하려면 JSON 형태로 변환해 가면서 사용해야 한다. 하지만 VueUse를 이용해 localStorage를 사용하면 JSON 형태로 변환할 필요 없이 한번의 선언으로 storage.value로 간단하게 이용할 수 있다.

pinia

terminal
yarn add pinia
// or with npm
npm install pinia
  • pinia는 vue용 저장소 라이브러리로 component, page 간에 state를 공유하게 해준다.
main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

import './assets/main.css';
const pinia = createPinia();

createApp(App).use(pinia).mount('#app');

tailwindcss

terminal
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init
postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  }
}
tailwind.config.js
/** @type {import('tailwindcss').Config} */
const defaultTheme = require('tailwindcss/defaultTheme');
module.exports = {
  content: [
    "./src/**/*.{vue, html, js}"
  ],
  theme: {
    extend: {},
    screens:{
      'xs':'281px',
      ...defaultTheme.screens,
    },
  },
  plugins: [],
}
  • 세팅된 유틸리티 클래스를 활용하여 스타일링하는 프레임워크

iconify/vue

terminal
npm i @iconify/vue
  • vue 용 iconify (오픈 소스 아이콘)

axios

terminal
npm i axios
  • http 통신 라이브러리

main

처음에는 모달 컴포넌트를 하나만 만들어 모달 컴포넌트에 <slot>과 header 컴포넌트에서 <Teleport>를 이용해 모달 컴포넌트를 호출하면서 <slot>에 내용을 넣어 주는 식으로 구현했었다. 컴포넌트를 최대한 적게 만들고 싶어 이런 방식으로 했지만 각 경우에 따라 값을 다르게 넣어줘 html 코드가 너무 길어졌기에 마음에 들지 않았다.
그래서 구현 방식을 바꿨는데 바꾼 방식에서는 각 경우에 따른 모달 컴포넌트를 만들어 그 컴포넌트들에서 모달 컴포넌트를 호출해 <slot>에 값을 넣어줬다. 이 방식은 컴포넌트가 많아지는 단점이 있지만 코드를 분리했기에 가독성 측면에서 더 낫다고 생각했다.


구조

structure
┣ assets
┃ ┣ main.css
┣ api
┃ ┣ index.js
┣ components
┃ ┣ ModalCaseNomal.vue
┃ ┣ ModalCaseNotExist.vue
┃ ┣ ModalCaseSimilar.vue
┃ ┣ TheHeader.vue
┃ ┣ TheModal.vue
┃ ┣ WordInput.vue
┣ stores
┃ ┣ Modal.js
┃ ┣ InputWord.js

code

components/TheModal.vue

components/TheModal.vue
<script setup>
import { Icon } from '@iconify/vue';
import { onMounted, onUnmounted } from 'vue';
import { useModalStore } from '../stores/Modal';

const modalStore = useModalStore();

onMounted(() => {
    document.body.classList.add('overflow-y-hidden');

})
onUnmounted(() => {
    document.body.classList.remove('overflow-y-hidden');
})
</script>

<template>
    <div class="fixed z-[999] inset-0 w-full h-full bg-[#656C85CC]" tabindex="0" >
        <div class="relative flex justify-center mx-2 xs:mx-9 sm:mx-10 mt-[80px]">
            <div class="relative py-6 px-6 w-[480px] h-[400px] bg-white rounded-md border border-slate-400 overflow-auto">
                <!-- 나가기 -->
                <div class="flex justify-end"><Icon icon="ion:close" @click="modalStore.modalExit" width="30" heihgt="30"></Icon></div>

                <div class="modal_word">
                    <slot name="word" />
                </div>

                <slot name="means" />
                
                <div class="modal_footer">
                    <slot name="footer" />
                </div>
            </div>
        </div>
    </div>
</template>
  • 각 경우에 모달 컴포넌트들의 뼈대, 각 <slot>의 name에 맞는 content들이 들어가 렌더링 됨
  • document.body.classList.add('overflow-y-hidden')을 통해 모달 컴포넌트가 마운트 되었을 때 화면을 스크롤 할 수 없게 만듦

stores/Modal.js

stores/Modal.js
import { defineStore } from 'pinia';

export const useModalStore = defineStore('modal', {
    state: () => ({
        inputModal: false,
        inputNotExistModal: false,
        inputSimilarModal: false,
    }),
    actions: {
        modalExit() {
            this.inputModal = false;
            this.inputNotExistModal = false;
            this.inputSimilarModal = false;
        }
    }
});
  • 모달을 관리하는 스토어
  • 각 경우에 맞는 모달 state를 관리한다. true 일 때 화면에 렌더링
  • modalExit(): 모든 모달의 state를 false로 바꿔 모달창을 끔

stores/InputWord.js

stores/InputWord.js
import { defineStore } from 'pinia';
import { ref } from 'vue'; 
import { useModalStore } from './Modal';
import { fetchWordSearch } from '../api';

export const useInputWordStore = defineStore('inputWord', () => {
    const modalStore = useModalStore();

    const modalWord = ref('');
    const modalMeans = ref('');
    const modalSimilarWords = ref('');

    function dataInitialization() {
        modalWord.value ='';
        modalMeans.value = [];
        modalSimilarWords.value = [];
    }
 
    function createSimilarWords(wordAndMean) {
        const MAX = wordAndMean.length >= 3 ? 3 : wordAndMean.length;
        return wordAndMean.slice(0, MAX).map(item => item[1]);
    }

    function checkWordSame(word1, word2) {
        const w1 = word1.toLowerCase();
        const w2 = word2.toLowerCase();
        if (w1 === w2) {
            return true;
        }
        return false;
    }

    function caseNotExistenceWord(targetWord) {
        dataInitialization();
        modalWord.value = targetWord;
        modalMeans.value = ['없는 단어입니다.', '다른 단어를 입력해 주세요.'];
        modalStore.inputNotExistModal = true;
    }
    function caseSimilarWord(targetWord, wordAndMean) {
        dataInitialization();
        modalWord.value = `${targetWord}와(과) 유사한 단어들`;
        modalSimilarWords.value = [...createSimilarWords(wordAndMean)];
        modalStore.inputSimilarModal = true;
    }

    function caseNomalWord(wordAndMean) {
        dataInitialization();
        modalWord.value = wordAndMean[0][1];
        modalMeans.value = wordAndMean[0][2].split(',');
        modalStore.inputModal = true;
    }

    function wordAndMeanSplit(data) {
        const searchWordMean = [];
        data.data.items.lan.forEach(item => {
            searchWordMean.push(item.item.split('|'));
        });
        return searchWordMean;
    }

    async function wordSearch(searchWord) {
        const data = await fetchWordSearch(searchWord);

        if (data.data.items.lan.length === 0) {
            caseNotExistenceWord(searchWord);
        }
        else {
            const wordAndMean = [...wordAndMeanSplit(data)];
            checkWordSame(searchWord, wordAndMean[0][1]) ? caseNomalWord(wordAndMean) : caseSimilarWord(searchWord, wordAndMean);
        }
    }

    function caseNomalCreate() {
        return [modalWord.value, modalMeans.value];
    }

    function caseNotExistCreate() {
        return [modalWord.value, modalMeans.value];
    }

    function caseSimilarCreate() {
        return [modalWord.value, modalSimilarWords.value];
    }
    
    return {
        caseNotExistenceWord,
        caseSimilarWord,
        caseNomalWord,
        wordSearch,
        caseNomalCreate,
        caseNotExistCreate,
        caseSimilarCreate,
    }
});
  • 입력한 단어의 결괏값을 다루고 결과에 따른 모달 컴포넌트들에게 데이터를 전달하는 스토어
  • modalWord: 모달 창에 띄울 단어
  • modalMeans: 모달 창에 띄울 단어의 뜻들 (존재하지 않는 단어일 경우 없는 단어임을 알리는 문장이 들어감)
  • modalSimilarWords: 모달 창에 띄울 유사한 단어들 (유사한 단어일 경우)
  • wordSearch(searchWord): 단어를 입력하면 호출되는 함수, fetchWordSearch(searchWord)의 결괏값에 따라 존재하지 않는 단어(caseNotExistenceWord), 유사한 단어(caseSimilarWord), 평범한 단어(caseNomalWord)를 호출해 modal, modalMeans, modalSimilarWords에 저장
  • caseNomalCreate(), caseNomalCreate(), caseNomalCreate(): 각 모달들이 렌더링될 때 필요한 데이터들을 리턴(modal, modalMeans, modalSimilarWords)

components/ModalCaseNomal.vue

components/ModalCaseNomal.vue
<script setup>
import TheModal from './TheModal.vue';
import { useInputWordStore } from '../stores/InputWord';
import { useModalStore } from '../stores/Modal';
import { ref } from 'vue';

const modalStore = useModalStore();
const wordStore = useInputWordStore();
const word = ref('');
const means = ref('');

const [modalWord, modalMeans] = wordStore.caseNomalCreate();
word.value = modalWord;
means.value = modalMeans;

</script>

<template>
    <TheModal>
        <template #word>
            {{ word }}
        </template>
        <template #means>
            <li v-for="mean in means" :key="mean" class="modal_means">
                {{ mean }}
            </li>
        </template>
        <template #footer>
            <div class="flex gap-x-4 justify-end">
                <button @click="modalStore.modalExit" class="modal_btn bg-neutral-400 hover:bg-neutral-500">cancel</button>
                <button class="modal_btn bg-emerald-400 hover:bg-emerald-600">add</button>
            </div>
        </template>
    </TheModal>
</template>

components/ModalCaseNotExist.vue

components/ModalCaseNotExist.vue
<script setup>
import TheModal from './TheModal.vue';
import { useInputWordStore } from '../stores/InputWord';
import { useModalStore } from '../stores/Modal';
import { ref } from 'vue';

const modalStore = useModalStore();
const wordStore = useInputWordStore();
const word = ref('');
const means = ref('');

const [modalWord, modalMeans] = wordStore.caseNotExistCreate();
word.value = modalWord;
means.value = modalMeans;

</script>

<template>
    <TheModal>
        <template #word>
            {{ word }}
        </template>
        <template #means>
            <li v-for="mean in means" :key="mean" class="modal_means">
                {{ mean }}
            </li>
        </template>
        <template #footer>
            <div class="flex">
                <button @click="modalStore.modalExit" class="modal_btn px-3.5 bg-blue-500 hover:bg-blue-600">ok</button>
            </div>
        </template>
    </TheModal>
</template>

components/ModalCaseSimilar.vue

components/ModalCaseSimilar.vue
<script setup>
import TheModal from './TheModal.vue';
import { useInputWordStore } from '../stores/InputWord';
import { useModalStore } from '../stores/modal';
import { ref } from 'vue';

const modalStore = useModalStore();
const inputWordStore = useInputWordStore();
const word = ref('');
const similarWords = ref([]);

const [modalWord, modalSimilarWords] = inputWordStore.caseSimilarCreate();
word.value = modalWord;
similarWords.value = modalSimilarWords;

function similarWordClick(targetWord) {
    modalStore.modalExit();
    inputWordStore.wordSearch(targetWord);
}

</script>

<template>
    <TheModal>
        <template #word>
            {{ word }}
        </template>
        <template #means>
            <div v-for="similarWord in similarWords" :key="similarWord" @click="similarWordClick(similarWord)" class="modal_means hover:text-xl cursor-pointer h-[32px] leading-[32px] hover:leading-[32px]">
                {{ similarWord }}
            </div>
        </template>
        <template #footer>
            <div class="flex">
                <button @click="modalStore.modalExit" class="modal_btn px-3.5 bg-blue-500 hover:bg-blue-600">ok</button>
            </div>
        </template>
    </TheModal>
</template>
  • 유사한 단어를 클릭했을 때 inputWordStore의 wordSearch()를 호출해 클릭한 유사한 단어를 검색

components/TheHeader.vue

components/TheHeader.vue
<script setup>
import { Icon } from '@iconify/vue';
import { useModalStore } from '@/stores/modal';
import WordInput from './WordInput.vue';
import ModalCaseNomal from './ModalCaseNomal.vue';
import ModalCaseNotExist from './ModalCaseNotExist.vue';
import ModalCaseSimilar from './ModalCaseSimilar.vue';

const modalStore = useModalStore();

</script>

<template>
    <div class="fixed top-0 z-20 flex items-center w-full h-16 min-h-16 max-h-16 justify-between py-2 px-2 xs:px-6 sm:px-10 md:px-24 lg:px-48 2xl:px-80 bg-[#2E4559]">
        <!-- logo -->
        <div class="text-2xl md:text-4xl font-bold text-white">voca</div>
        <!-- input -->
        <div class="relative w-3/5 md:w-1/2">
            <WordInput />
        </div>
        <!-- btn -->
        <Icon icon="ph:trash" width="34" height="34" color="#e4e4e7" />
        
        <!-- modal -->
        <Teleport to="body">
            <Transition name="slide-fade">
                <ModalCaseNomal v-if="modalStore.inputModal" />
            </Transition>
            <Transition name="slide-fade">
                <ModalCaseSimilar v-if="modalStore.inputSimilarModal" /> 
            </Transition>
            <Transition name="slide-fade">
                <ModalCaseNotExist v-if="modalStore.inputNotExistModal" /> 
            </Transition>
        </Teleport>
    </div>
</template>

<style scoped>
.slide-fade-enter-active {
    transition: all 0.3s ease;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
    transform: translatex(-20px);
    opacity: 0;
}
</style>
  • 헤더 컴포넌트, WordInput 컴포넌트에서 입력을 받아 모달의 state가 true가 되면 <TelePort>를 이용해 body에서 모달을 렌더링
  • <Transition>을 통해 slide-fade 효과를 줌(왼쪽에서 오른쪽으로 모달이 나타나는 애니메이션)

components/WordInput.vue

components/WordInput.vue
<script setup>
import { Icon } from '@iconify/vue';
import { useInputWordStore } from '../stores/InputWord';
import { ref } from 'vue';

const inputWordStore = useInputWordStore();
const wordSearch_input = ref(null);
const inputWord = ref('');


function inputWordClear() {
    inputWord.value = '';
}

function caseEmptyWord() {
    window.alert("단어를 입력해 주세요.");
}

function wordSearch(searchWord) {
    if (searchWord === '') {
        caseEmptyWord();
        return;
    }
    inputWordStore.wordSearch(searchWord);
    inputWord.value = '';
}

</script>

<template>
    <div>
        <div class="h-9 px-3 border flex items-center bg-white border-black" ref="input_container">
            <button v-show="inputWord.length>0" @click="inputWordClear"><Icon icon="ph:x-bold"></Icon></button>
            <input ref=wordSearch_input placeholder="단어를 입력해주세요" :value="inputWord" @keyup.enter="wordSearch(inputWord)" @input="event => inputWord = event.target.value" class="w-full px-2 focus:outline-0"/>
            <button class="inputTag" @click="wordSearch(inputWord)"><Icon icon="ion:search" width="24" height="24"/></button>
        </div>
    </div>
</template>
  • 단어 입력 컴포넌트
  • wordSearch(searchWord):
    • 빈 단어를 입력한 경우 window.alert("단어를 입력해 주세요.")를 호출
    • 빈 단어가 아닌 경우 inputWordStore의 wordSearch(searchWord)를 호출

assets/main.css

assets/main.css
/* @import './base.css'; */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .card_word {
    @apply cursor-pointer font-bold h-[64px] text-3xl hover:text-4xl leading-[64px] hover:leading-[64px] text-ellipsis overflow-hidden whitespace-nowrap;
  }
  .card_content {
    @apply h-[32px] font-medium ml-1 text-ellipsis overflow-hidden whitespace-nowrap;
  }
  .modal_word {
    @apply text-4xl my-2 xs:my-5 pb-4 font-bold;
  }
  .modal_means {
    @apply font-semibold text-lg;
  }
  .modal_btn {
    @apply py-1 px-2.5 rounded-md text-white font-semibold;
  }
  .modal_footer {
    @apply absolute bottom-0 right-0 p-6;
  }
  .modal_timetext {
    @apply flex flex-col items-end text-sm my-4 text-neutral-400;
  }
}
  • main.css 중복으로 자주 사용되는 css 클래스 정의

App.vue

App.vue
<script setup>
import TheHeader from './components/TheHeader.vue';
</script>

<template>
  <header class="pb-16">
      <TheHeader></TheHeader>
  </header>

  <main class="pt-[32px] px-10 md:px-24 lg:px-32 2xl:px-80 min-h-screen">

  </main>
</template>

<style scoped>
* {
 --bg-color: #F2E399;
 --bg-transparent: rgba(255, 255, 255, 0);
 --border-color: #BFBFBF; 
}

main {
  font-size: 16px;
  line-height: 32px;
  background: linear-gradient(
    to bottom,
    var(--border-color) 0%,
    var(--border-color) 1px,
    var(--bg-transparent) 1px,
    var(--bg-transparent) 100%
  );
  background-size: 100% 32px;
  background-color: var(--bg-color);
  overflow-y: auto;
}

@media (max-width: 281px) {
  * {
    font-size: 14px;
  }
}
</style>
  • 줄공책을 만드는 css (style main{...})

api/index.js

api/index.js
import axios from 'axios';

const config = {
    baseUrl: '/search/language/v1/search.json?cate=lan&q='
};

function fetchWordSearch(word) {
    return axios.get(`${config.baseUrl}${word}`);
}

export {
    fetchWordSearch
}

vite.config.js

vite.config.js
import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  server: {
    port: 8082,
    proxy: {
      '/search': {
        target: 'https://suggest.dic.daum.net',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/search/, ''),
        secure: false,
        ws: true,
      }
    }
  }
})
  • cors 문제로 프록시 서버에서만 이용가능(개발 모드에서만 가능)


댓글