WebMvcConfigurer를 implements한 config 클래스를 만들고, 내부에 messageSource 빈 등록을 해야함
resource 내에 properties를 국가별로 별도로 생성한다.
//3. jsp파일에서 태그로 값을 읽는다.
<spring:message code="sample.hello">
properties 파일 내에 텍스트가 jsp 내에서 읽는데, 개행이 필요한 경우
\n이 아닌, < br/> 태그를 사용하면 된다.
jsp와 유사하지만, 프로퍼티 파일이 아닌 제이슨으로 설정해서 더 간단한 것 같다.
src 하위에 locale 폴더 생성 후, 하위에 언어별 폴더와 폴더명과 동일한 이름의 json 파일 생성
폴더명은 무관하지만, 해당 언어를 인지하기 좋게 작성하는 것이 좋음
언어별 같은 키값에 번역 내용을 value로 설정한 json 파일 저장.
src 하위에 i18n.js, main.js 생성
i18n.js
import Vue from 'vue'
import VueI18n from 'vue-i18n'
// json 파일을 읽어들이기 위한 function
const requireLang = require.context(
'@/locales', // 폴더명 입니다.
true,
/\.json$/ // 폴더 아래 json 찾기용
)
const messages = {}
// json file read
for (const file of requireLang.keys()) {
const path = file.replace(/(\.\/|\.json$)/g, '').split('/') // 폴더 패스
path.reduce((o, s, i) => {
if (o[s]) return o[s]
o[s] = i + 1 === path.length
? requireLang(file)
: {}
return o[s]
}, messages)
}
Vue.use(VueI18n);
const i18n = new VueI18n({
locale: 'ko', // 기본 locale
fallbackLocale: 'ko', // locale 설정 실패시 사용할 locale
messages, // 다국어 메시지
silentTranslationWarn: true // 메시지 코드가 없을때 나오는 console 경고 off
})
export default i18n
main.js
import Vue from 'vue'
import App from './App.vue'
import i18n from './i18n'
Vue.config.productionTip = false
new Vue({
i18n,
render: h => h(App),
data: {
foo:"test"
}
}).$mount('#app')
vue.js 환경에서 다국어 작업 (typescript)
vue도 typescript도 써본 적이 없어(사실 프론트 자체를 거의 안해본,,,) chatGPT의 도움을 많이 받아서 다국어 작업을 진행했다.
다국어 작업으로 화면에서 처리가 추가적으로 필요하지만
세팅하고 어떻게 하면 다국어가 적용된 값을 불러올 수 있는지 정리해본다.
//main.ts
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import en from '@/utils/locales/en.json';
import ko from '@/utils/locales/ko.json';
import vi from '@/utils/locales/vi.json';
import jp from '@/utils/locales/jp.json';
import cn from '@/utils/locales/cn.json';
type MessageSchema = typeof en;
const i18n = createI18n<[MessageSchema], 'ko' | 'en' | 'vi' | 'jp' | 'cn'>({
// options
locale: 'ko',
fallbackLocale: 'en',
legacy: false,
messages: {
en: en,
ko: ko,
vi: vi,
jp: jp,
cn: cn
}
// ,
// silentTranslationWarn: true,
// silentFallbackWarn: true
});
const app = createApp(App);
app.use(i18n);
app.use(vuetify).mount('#app'); // vuetify는 생략 가능하다.
main.ts 에서 vue-i18n을 import해주고, 각 언어별 json 파일을 import해준다.
MessageSchema는 한 개의 json 파일을 type으로 지정해주는 것 같다.
그래서 type 지정된 json 내부에 있는 단어가 다른 json 파일에는 없다면 에러가 발생한다.legacy는 false로 해두어야 Composition API에서 사용 가능하다고 한다.
내가 사용하려는 언어별 파일을 messages에 추가한다.(import되있어야 함)
//vite.config.ts
...
import path from 'node:path';
// import vueI18n from '@intlify/vite-plugin-vue-i18n' // 설치
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'; // vite ver4 이상
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
//plugins에 추가
VueI18nPlugin({
include: [path.resolve(__dirname, './src/utils/locales/**')],
})
],
resolve: {
alias: {
...
'vue-i18n': 'vue-i18n/dist/vue-i18n.esm-bundler.js'
}
},
...
});
// /src/components/LanguageSelector.vue
<template>
<div class="lang-selector dropdown">
<div class="selected-lang" @click="toggleDropdown" aria-expanded="false">
<img :src="currentFlag" style="width: 45px; height: 45px" />
<span style="display: block; width: 30%; margin-left: 5px; cursor: pointer">
<img src="/src/assets/images/allow.png" />
</span>
</div>
<ul :class="['dropdown-menu', 'lang-list', { show: showDropdown }]" aria-labelledby="dropdownMenuButton" style="width: 30px">
<li v-for="lang in languages" :key="lang.id" @click="selectLang(lang.id)" class="dropdown-item">
<img :src="lang.src" style="width: 30px; height: 30px" />
</li>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useLanguageSelector } from '@/composables/useLanguageSelector';
export default defineComponent({
name: 'LanguageSelector',
setup() {
return useLanguageSelector();
}
});
</script>
<style scoped>
.lang-selector {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
position: relative;
}
.selected-lang {
display: flex;
align-items: center;
}
.lang-list {
position: fixed; /* Change to fixed to keep it above all other content */
top: 60px; /* Adjust this value to position it correctly below the header */
right: 20px; /* Adjust this value to position it correctly aligned with the button */
display: flex;
flex-direction: column;
background-color: white;
z-index: 300000000; /* Ensure the dropdown is above other content */
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
display: none; /* Initially hidden */
}
.lang-list.show {
display: flex; /* Show the dropdown when it has the 'show' class */
}
.lang-list li {
list-style: none;
}
.lang-list li img {
width: 30px;
height: 30px;
}
</style>
// /src/composables/useLanguageSelector.ts
// LanguageSelectorScript.ts
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
export function useLanguageSelector() {
const showDropdown = ref(false);
const currentLang = ref('ko'); // default language
const languages = [
{ id: 'en', src: '/src/assets/images/lang/en.png' },
{ id: 'cn', src: '/src/assets/images/lang/cn.png' },
{ id: 'jp', src: '/src/assets/images/lang/jp.png' },
{ id: 'vi', src: '/src/assets/images/lang/vi.png' },
{ id: 'ko', src: '/src/assets/images/lang/ko.png' }
];
const currentFlag = ref(`/src/assets/images/lang/${currentLang.value}.png`);
const { locale } = useI18n();
const toggleDropdown = () => {
showDropdown.value = !showDropdown.value;
};
const selectLang = (lang: string) => {
currentLang.value = lang;
currentFlag.value = `/src/assets/images/lang/${lang}.png`;
showDropdown.value = false;
// Save selected language in localStorage or make API call to update language preference
localStorage.setItem('lang', lang);
locale.value = lang;
};
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.lang-selector')) {
showDropdown.value = false;
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
});
return {
showDropdown,
languages,
currentFlag,
toggleDropdown,
selectLang
};
}
처음에는 vue 파일 내부에 script 코드도 포함해서 만들었지만, 그러면 다국어가 반영되어야 하는 코드(script만 필요한 코드) 내부에도 국기를 선택하는 화면이 추가되기 때문에 script와 화면을 구성하는 vue로 나눴다. (vue 파일에 들어간 script 코드는 국기를 선택하면 되어야 하는 동작이 들어가있다.)
locale 디렉토리 내부에 각 국가별 json 파일을 추가하고, images에도 국기 모양을 선택하고(국기 모양으로 헤더에 보여지기 위함)한다.
//header.vue
<script>
...
import LanguageSelector from '@/components/LanguageSelector.vue';
import { useLanguageSelector } from '@/composables/useLanguageSelector';
...
const { currentFlag, selectLang } = useLanguageSelector();
</script>
<template>
...
<LanguageSelector />
...
</template>
헤더에서는 국기를 선택하는 화면과 선택 후 헤더에 포함된 글자도 다국어 처리되어야 하기 때문에 두가지 모두 import한다.
<script setup lang="ts">
...
import { ref, computed, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
...
const { t } = useI18n();
watchEffect(() => {
customizer.setCurrentMenu(t('xxxx')); // Dynamically update based on language
});
const headers = computed(() => [
//변수 내부에서는 computed 함수 호출하고, t('')로 사용 가능
{ title: t('startDt'), key: 'startDt', align: 'center' },
{ title: t('endDt'), key: 'endDt', align: 'center' },
...
]);
</script>
<template>
...
<v-col sm="1" style="margin-right: -22px">
<v-switch
color="facebook"
value=""
false-value=""
:label="$t('overall')"
hide-details
inset
/>
</v-col>
...
<v-btn variant="outlined" color="facebook" width="80" @click="promotionManagement.promotionInfoList()">
<SearchOutlined :style="{ fontSize: '18px', color: 'darkprimary', marginLeft: '-6px' }" />
<span color="darkprimary" style="padding-left: 2px; margin-right: -3px">{{ $t('search') }}</span></v-btn
>
...
</template>
vue에서 watchEffect, computed 를 import한다.
watchEffect는 콜백 함수 안에 반응성 데이터 변화가 감지되면 자동으로 실행
script의 변수 내부에서는 computed 함수 호출하고, t('startDt')로 사용 가능
template 내부에서는 <> 안에 있는 attribute라면, :label="$t('overall')" 으로 사용 가능(:으로 무조건 시작해야함), <><>사이에 있는 값이라면 {{ $t('search') }} 이런 식으로 사용 가능
properties 파일은 <br.> 태그로 개행을 해야하지만(태그 보이기 위해 점찍음),
json의 경우 \n으로 개행이 읽힌다. 오히려 br 태그를 붙이는 경우 프론트 페이지 에러가 난다.