단어 검색
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 문제로 프록시 서버에서만 이용가능(개발 모드에서만 가능)