Vue.js - iOS Segment 만들기

이미지 출처(https://vuejs.org/images/logo.png)

실무에서 웹뷰 작업을 하면서 iOS의 Segment UI를 자주 사용하여서 Vue로 된 컴포넌트 파일 하나를 만들어 두려고 한다.

구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<!-- Segment.vue -->
<template>
<div style="overflow: hidden;">
<ul class="segment" :style="getUlStyle()">
<li v-for="item in items" :key="item.id"
:style="getLiStyle(item)"
:class="{'on': item.isOn}"
@click="selectItem(item)"
>
{{item.title}}
</li>
</ul>
</div>
</template>
 
<script>
export default {
props: ['items', 'onColor', 'onTextColor', 'textColor', 'align', 'width'],
methods: {
getUlStyle() {
const UlStyle = {};
const customStyle = {};
 
if( this.width ) customStyle.width = this.width;
switch(this.align) {
case 'left':
customStyle.margin = '0';
break;
case 'right':
customStyle.margin = '0';
customStyle.float = 'right';
break;
default:
}
if( this.onColor ) customStyle.borderColor = this.onColor;
 
return Object.assign({}, UlStyle, customStyle);
},
getLiStyle(item) {
const liStyle = {
width: 100 / this.items.length + '%',
color: item.isOn ? '#fff' : 'rgb(0, 122, 255)'
};
const customStyle = {};
 
if( this.onColor ) customStyle.borderColor = this.onColor;
if( this.textColor && !item.isOn ) customStyle.color = this.textColor;
 
if( item.isOn ) {
if( this.onTextColor ) liStyle.color = this.onTextColor;
if( this.onColor ) liStyle.backgroundColor = this.onColor;
}
 
return Object.assign({}, liStyle, customStyle);
},
selectItem(item) {
if( item.isOn ) return;
 
this.items.forEach(v => {
v.isOn = false;
});
item.isOn = true;
 
if( typeof item.callback === 'function' ) {
item.callback(item);
}
}
}
}
</script>
 
<style lang="scss" scoped>
.segment {
margin: 0 auto;
width: 100%;
overflow: hidden;
border: 1px solid rgb(0, 122, 255);
border-radius: 4px;
box-sizing: border-box;
li {
float: left;
height: 35px;
line-height: 35px;
font-size: 15px;
color: rgb(0, 122, 255);
border-left: 1px solid rgb(0, 122, 255);
text-align: center;
background-color: #fff;
box-sizing: border-box;
cursor: pointer;
&.on {
color: #fff;
background-color: rgb(0, 122, 255);
}
&:first-child {
border-left: none;
}
}
}
</style>

사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<template>
<div>
<Segment :items="testItems"/>
<br/>
<Segment :items="testItems" width="300px" align="left"/>
<br/>
<Segment :items="testItems" width="300px"/>
<br/>
<Segment :items="testItems" width="300px" align="right"/>
<br/>
<Segment :items="testItems" width="75%" onColor="#ff7359" textColor="#ff7359"/>
</div>
</template>
<script>
import Segment from './Segment';
export default {
components: {
Segment
},
data() {
return {
testItems: [
{
id: 1,
title: '첫번째',
isOn: true
},
{
id: 2,
title: '두번째',
isOn: false,
callback: (item) => {
// item: get current object
}
},
{
id: 3,
title: '세번째',
isOn: false
}
]
}
}
}
</script>

결과

결과 이미지

img태그 retina사이즈 대응

이슈

img태그 사용시 특정 retina 디스플레이에서 2배수, 3배수 이미지가 적용되지 않았음.

해결

기존 retina 디스플레이의 이미지 대응을 위해서 img태그의 srcset이라는 어트리뷰트를 아래와 같이 사용하고 있었다.

1
2
3
<img src="testSrc.png"
srcset="testSrc.png 1x, testSrc@2x.png 2x, testSrc@3x.png 3x"
alt="테스트 이미지">

하지만 특정 디스플레이에서 제대로 적용되지 않음을 발견하였고, 그 이유가 srcset속성은 IE, Android 5.0 미만의 버전에서 지원하지 않는다고 한다. (링크)
리서칭 결과 3가지 정도의 방안을 찾았다.

    1. javascript를 이용하여 retina 디스플레이를 체크하고 해당 배수의 이미지로 교체
    1. svg 사용
    1. default 이미지를 2배 or 3배수 사용 후 원본 사이즈로 고정

세가지 다 작은 단점들이 조금씩 존재하였지만 결론부터 말하면 3번 방향으로 해결하였다.

1번의 경우 이미지 하나를 표현하기 위해 여러개의 요청을 발생시키게 되는 문제가 생길 수 있고, 2번은 IE8 미지원 및 이미지화(?)되어 버린 리소스들은 다시 작업이 필요하였고 3번은 retina가 아닌 디스플레이에서는 더 큰 용량의 이미지를 사용하여야 했다.

고민해본 결과, 매우 큰 이미지는 잘 사용하지 않는 편이여서 2배수 or 3배수 이미지를 default로 사용하기로 하였다.

1
2
3
4
<img src="testSrc@2x.png"
width="400" height="250"
srcset="testSrc.png 1x, testSrc@2x.png 2x, testSrc@3x.png 3x"
alt="테스트 이미지">

추후 리소스 정리를 진행한다면, 모바일 기반 제품들은 svg로 갈아타면 좋을듯하다.

SVG - 심심해서 만들어본 라이언

사용할 썸네일이 없다..

결국 제작한 라이언을 캡쳐하여 썸네일로 사용해 보았는데, 저작권 문제 있지 않을지 조금 걱정이..

계기

제목에서 언급하였듯 심심해서 만들어 보았다.
SVG쪽을 나중에 해봐야지 해봐야지 하고 미루어만 둔 상태이었는데, 어떤 문법을 가지고 있는지 기초 정도만 알아보자라는 생각으로 시작하게 되었다.

작업(?)물

See the Pen SVG Ryan (라이언 만들어보기) by keun hyeok (@small) on CodePen.

맺음

라이언의 얼굴까지는 상당히 쉽다고 느끼며 코드를 작성하였으나 몸통 부분에서 path를 사용하면서 부터 조금 어려움을 느낀것 같다. ( 몸통은 하지말까 라는 생각을 중간에 하기도.. )

뭔가 조금 어색하지만 나름 라이언 비슷(?)하게 생긴 결과물이 나와서 만족

javascript 변경해서 특정 이벤트를 등록하거나, css 애니메이션을 입혀보는 작업도 재미있을듯 하다.

Vue.js - Vue-cli를 이용한 vue 프로젝트 살펴보기 2

이미지 출처(https://vuejs.org/images/logo.png)

컴포넌트 사용하기

이전 글에서 예제로 사용한 카운트 앱을 통하여 학습해봅시다.
카운트 앱을 2가지로 분리할 예정입니다.

  • Count
    • CountValue
    • CountController

첫째로 value가 표시되는 영역, 두번째로 value값을 조정하는 영역입니다.

기존 예제 ( App.vue )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<template lang="html">
<div id="wrap">
<div>
<span>{{num}}</span>
<div>
<button @click="increment">증가</button>
<button @click="decrement">감소</button>
</div>
</div>
</div>
</template>
 
<script>
export default {
data() {
return {
num: 0
}
},
methods: {
increment() {
// 증가
this.num++;
},
decrement() {
// 감소
this.num--;
}
}
}
</script>

위와 같던 예제를…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template lang="html">
<div id="wrap">
<Count />
</div>
</template>
 
<script>
import Count from './containers/count';
 
export default {
components: {
Count
}
}
</script>

이렇게 만들 예정입니다. 일단 App.vue 를 위처럼 수정해주세요.

Directory

컴포넌트와 컨테이너를 담을 폴더들을 생성합니다. 전체적인 디렉토리 구조는 아래와 같이 작성하였습니다.

  • app
    • node_modules
    • src
      • assets
      • components
        • countValue.vue
        • countController.vue
      • containers
        • count.vue
      • App.vue
      • main.js
    • index.html
    • package.json
    • etc…

강조된 부분이 기존 vue-cli에서 생성해주지 않은 부분으로 직접 생성해야하는 폴더, 파일 입니다.

컴포넌트 작성

Count 라는 컨테이너(page)부터 살펴보도록 합시다.
Count 앱의 모든 기능은 이 컨테이너 안으로 들어가져 있습니다.

count.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<template lang="html">
<div>
<countValue :cv="num"/>
<countController @increment="increment" @decrement="decrement"/>
</div>
</template>
 
<script>
import countValue from '../components/countValue';
import countController from '../components/countController';
 
export default {
components: {
countValue,
countController
},
data() {
return {
num: 0
}
},
methods: {
increment() {
// 증가
this.num++;
},
decrement() {
// 감소
this.num--;
}
}
}
</script>

13번 라인을 보면 components가 있습니다.
이는 이 count라는 컨테이너에서 사용할 컴포넌트들을 정의하는 부분입니다.
윗줄에서 import로 불러온, countValue와 countController 2가지의 컴포넌트들을 사용한다고 정의한 것이지요.

정의한 컴포넌트들은 html 영역인 template에서 사용됩니다.
3, 4번 라인이 해당하는 부분입니다.
중요한 내용은 아니지만, 저는 camel case(대문자로 연결) 방식을 사용하였으나 kebab case(하이픈으로 연결)를 권장한다는 글을 본거 같아서 언급해봅니다.

다시 예제의 컴포넌트를 봅시다.
현재 카운트 앱의 데이터 및 행동들은 모두 count라는 컨테이너가 가지고 있습니다. 우리는 이것들을 각 자식 컴포넌트에게 props를 통하여 넘겨주어야 합니다.

3번 라인, :cv="num" cv라는 이름으로 num 데이터를 자식 컴포넌트에게 넘겨주고 있습니다. 자식 컴포넌트인 countValue는 cv를 props를 통하여 받아와야 합니다.

countValue.vue

1
2
3
4
5
6
7
8
9
<template lang="html">
<span>{{cv}}</span>
</template>
 
<script>
export default {
props: ['cv']
}
</script>

Vue.js - Component(컴포넌트) 1 에서 설명했던 props 사용법과 동일합니다.

이제 count.vue의 4번라인을 봅시다.
이 부분도 이전에 설명했던 부분입니다. Vue.js - Component(컴포넌트) 2 - 부모 자식간 통신의 예제에서 언급했던 기능이지요.
컴포넌트를 통해 커스텀 이벤트를 정의한 뒤, 자식 컴포넌트에서 $emit을 사용하여 부모의 이벤트를 발생시키는 방법입니다.

countController.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template lang="html">
<div>
<button @click="increment">증가</button>
<button @click="decrement">감소</button>
</div>
</template>
 
<script>
export default {
methods: {
increment() {
// 부모의 increment라는 이벤트 호출
this.$emit('increment');
},
decrement() {
// 부모의 decrement 이벤트 호출
this.$emit('decrement');
}
}
}
</script>

싱글 파일 컴포넌트를 사용한 Vue 앱을 만들어보았습니다.

마침

포스트도 오랜만이고, 글을 며칠을 나누어 쓰다 보니, 앞뒤의 내용도 헷갈리고 많이 어렵네요. ㅠ
그래도.. 현재 포스트까지의 내용을 이해하실 수 있다면 다른 vue를 사용하여 여러 가지 시도를 해보시기 충분하지 않을까 합니다.
앞으로 Router와 Vuex를 활용하는 방법까지 배우신다면 실무에서도 무리가 없지 않을까 생각해봅니다.

Vue.js - Vue-cli를 이용한 vue 프로젝트 살펴보기 1

이미지 출처(https://vuejs.org/images/logo.png)

이번에는 vue-cli가 만들어준 Vue 프로젝트를 살펴보도록 하겠습니다.
그전에 한가지 짚고 가야 할 점이 있습니다.

vue-cli는 각 컴포넌트를 .vue 확장자를 가진 싱글 파일 컴포넌트로 만들어주기 때문에 지금부터는 기존까지의 Vue 코딩 방식과는 다른 방식으로 코딩하겠습니다.
문법 자체는 거의 동일하다보니 큰 문제없이 잘 진행할 수 있을 것입니다.

싱글 파일 컴포넌트

그래서 싱글 파일 컴포넌트가 무엇 인가?
.vue 확장자를 파일을 Webpack 또는 Browserify와 같은 도구를 이용하여 빌드하여 Vue.js를 사용하는 것 입니다.
우리가 만든 app이라는 프로젝트의 /src/App.vue를 살펴봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<template>
<div id="app">
<img src="./assets/logo.png">
<h1>{{ msg }}</h1>
<h2>Essential Links</h2>
<ul>
<li><a href="https://vuejs.org" target="_blank">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank">Twitter</a></li>
</ul>
<h2>Ecosystem</h2>
<ul>
<li><a href="http://router.vuejs.org/" target="_blank">vue-router</a></li>
<li><a href="http://vuex.vuejs.org/" target="_blank">vuex</a></li>
<li><a href="http://vue-loader.vuejs.org/" target="_blank">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank">awesome-vue</a></li>
</ul>
</div>
</template>
 
<script>
export default {
name: 'app',
data() {
return {
msg: 'Welcome to Your Vue.js App'
}
}
}
</script>
 
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
 
h1,
h2 {
font-weight: normal;
}
 
ul {
list-style-type: none;
padding: 0;
}
 
li {
display: inline-block;
margin: 0 10px;
}
 
a {
color: #42b983;
}
</style>

마크업과 스크립트 그리고 css를 모두 한 파일에 작성합니다.

template

해당 컴포넌트의 마크업을 작성하고 스크립트에서 설정한 데이터 및 메서드 등을 연결시켜 줍니다.

script

컴포넌트의 데이터, 메서드, 라이프사이클 등 이전 까지 Vue 생성자에서 사용한 부분들을 이곳에서 작성합니다. 그리고 만들어진 컴포넌트를 반환해줍니다.

style

해당 컴포넌트의 css를 정의합니다. 이 style태그에 scoped 라는 속성을 추가하면 해당 컴포넌트에 css가 종속됩니다.

앞으로는 이 싱글 파일 컴포넌트 방식으로 Vue 앱을 작성하겠습니다.

카운트 앱

App.vue의 모든 코드를 지우고 맛보기를 위해 간단한 카운트 앱을 만들어 보려 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
<template lang="html">
 
</template>
 
<script>
export default {
 
}
</script>
 
<style lang="css">
</style>

우선 기본 틀(?)을 생성 합시다.
(제가 사용하는 에디터는 Vue.js 관련된 문법 플러그인과 같은 기능을 추가하여서 단축키로 쉽게 기본 틀을 작성할 수 있습니다. 자신의 사용하는 에디터에서 한번 찾아보시는 것을 추천합니다.)

이제 카운트 앱의 기능을 생각 해봅시다.
카운트 앱은 기준이 되는 숫자, 증가 행위, 감소 행위가 있을 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template lang="html">
<div>
<span>{{num}}</span>
</div>
</template>
 
<script>
export default {
data() {
return {
num: 0
}
},
methods: {
increment() {
// 증가
this.num++;
},
decrement() {
// 감소
this.num--;
}
}
}
</script>
 
<style lang="css">
</style>

한번 확인해 보시길 바랍니다. 기존 Vue.js와 거의 비슷하지요?
게다가 webpack에서 babel이 기본으로 셋팅되어 있어서 es5이상의 문법을 사용 할 수 있습니다.

자 이제 데이터와, 기능(메서드)를 만들었으니 이벤트를 연결 합니다. 이 또한 기존 Vue.js와 동일합니다.

1
2
3
4
5
6
7
8
9
10
11
<template lang="html">
<div>
<div>
<span>{{num}}</span>
<div>
<button @click="increment">증가</button>
<button @click="decrement">감소</button>
</div>
</div>
</div>
</template>

카운트 앱의 모든 기능이 완성 되었습니다.
이제 npm run dev를 사용하여 실행해봅시다. 이미 실행 되어있다면 웹 페이지를 띄워봅시다. 기본적으로 핫 로딩을 지원하기 때문에 바로 적용되어 있을 것 입니다.

모든 기능이 잘 동작하는데 뭔가 조금 아쉽네요. 스타일을 입혀봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<template lang="html">
<div id="wrap">
<div>
<span>{{num}}</span>
<div>
<button @click="increment">증가</button>
<button @click="decrement">감소</button>
</div>
</div>
</div>
</template>
 
<script>
export default {
data() {
return {
num: 0
}
},
methods: {
increment() {
// 증가
this.num++;
},
decrement() {
// 감소
this.num--;
}
}
}
</script>
 
<style lang="css">
*{margin: 0; padding: 0;}
html,body {height: 100%;}
#wrap {
display: flex;
height: 100%;
justify-content: center;
align-items: center;
}
#wrap > div {
text-align: center;
}
span {
display: inline-block;
margin-bottom: 10px;
color: #53a9f9;
font-size: 32px;
font-weight: 700;
}
button {
padding: 0 8px;
min-width: 60px;
height: 34px;
line-height: 34px;
color: #fff;
font-size: 16px;
outline: none;
border: none;
border-radius: 3px;
background-color: #88c5ff;
}
</style>

이제 우리는 Vue.js 프로젝트 생성도 해보았고 하나의 앱을 만들어 보았습니다.
하지만 아직 모든 부분을 살펴본게 아니기 때문에 다음장까지는 이어서 vue-cli가 만들어준 프로젝트를 살펴보겠습니다.

Vue.js - Vue-cli를 이용한 vue 프로젝트 생성

이미지 출처(https://vuejs.org/images/logo.png)

프로젝트 생성

여태까지 Vue.js에 대한 아주아주 기본적인 문법들을 살펴보았습니다. 이제 Vue.js를 사용하여 프로젝트를 구성해보려고 합니다.
프로젝트를 구성해보기 앞서 vue-cli라는 것을 설치 하겠습니다.

vue-cli는 vue 프로젝트를 매우 간편하게 구성하도록 도와주는 보일러 플레이트 입니다. 리액트를 사용해보셨다면 create-react-app과 같은 기능을 도와준다고 생각하면 되겠습니다.

1
npm i -g vue-cli

vue-cli설치가 완료 되었다면 이제 vue프로젝트를 생성해봅시다.
실행문법은 다음과 같습니다.

1
vue init <template-name> <project-name>

템플릿 네임은 해당 프로젝트를 어떠한 환경으로 설정 할 것인지 정하는 것 이고, 프로젝트 네임은 해당 프로젝트의 이름을 입력합니다.
프로젝트 네임은 사용자가 원하는 이름으로 설정하면 되지만 템플릿 네임은 vue-cli에서 제공하는 몇가지 안을 보고 선택해야 합니다.

  • webpack
  • webpack-simple
  • browserify
  • browserify-simple
  • pwa
  • simple

위 와같은 템플릿을 제공하고 있으며 각 템플릿 별 자세한 설명은 vue-cli 깃헙을 확인해보시길 바랍니다.

저희가 사용할 템플릿은 webpack-simple 입니다. 이름을 보면 유추할 수 있겠지만 webpack을 사용하는 프로젝트를 생성해줍니다.

1
vue init webpack-simple app

명령어를 입력하면 몇 가지 환경에 대한 질문을 합니다. 가벼운 연습 예제로 사용할 예정이니 전부 엔터로 넘어갑니다.
모든 질문이 끝나면 첫 번째 vue 프로젝트가 생성되었습니다.

structure
위 vue-cli 버전은 2.9.2 입니다.

실행

자 이제 프로젝트도 만들었으니 실행을 시켜보아야겠지요?

1
2
cd app
npm i

방금 만든 app 프로젝트로 들어가서 package.json에 명시된 모듈들을 설치해줍시다.
설치가 완료되었다면

1
npm run dev

명령어를 실행하면 자동으로 웹 브라우저에 프로젝트가 실행될 것 입니다.
이제 Vue.js 프로젝트를 시작할 모든 준비가 완료되었습니다.

Vue.js - Component(컴포넌트) 2

이미지 출처(https://vuejs.org/images/logo.png)

지난 시간에 이어 컴포넌트를 사용하는 몇가지 방법을 더 알아보고자 합니다.
조금 더 쉽게 템플릿을 만드는 방법과 각 컴포넌트의 통신 방법을 예제를 들어 설명 하도록 하겠습니다.

컴포넌트 template

1
2
3
4
5
6
7
8
9
10
11
<div id="app">
<test-component></test-component>
<test-component></test-component>
<test-component></test-component>
</div>
 
<template id="templateId">
<div>
{{text}}
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
Vue.component('test-component', {
template: '#templateId',
data: function() {
return {
text: '이곳에 컴포넌트의 마크업을 진행!!'
}
}
});
var app = new Vue({
el: '#app'
});

template 기능을 이용하면 복잡한 마크업의 컴포넌트를 생성할 수 있습니다.
사용방법은 위 예제와 같이 template: '#templateId' 형태로 html의 template 태그의 id와 template 속성의 값을 매핑 시켜줍니다. 템플릿 내부는 기존 배워왔던 Vue.js의 문법과 동일하게 사용 가능합니다.

부모 자식간 통신

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="app">
<parent></parent>
</div>
 
<template id="parent">
<child @eat="parentEat" :parent-calorie="calorie"></child>
</template>
 
<template id="child">
<div>
<p>parent 먹은 칼로리 : {{parentCalorie}}</p>
<p>child 먹은 칼로리 : {{calorie}}</p>
<button @click="childEat">밥먹자!</button>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Vue.component('parent', {
template: '#parent',
data: function() {
return {
calorie: 0
}
},
methods: {
parentEat: function() {
this.calorie += 800;
}
}
});
Vue.component('child', {
template: '#child',
props: ['parentCalorie'],
data: function() {
return {
calorie: 0
}
},
methods: {
childEat: function () {
this.calorie += 500;
this.$emit('eat');
}
}
});
var app = new Vue({
el: '#app'
});

See the Pen EbmrOB by keun hyeok (@small) on CodePen.

기본적으로 데이터는 부모에서 자식으로 향하는 단방향 데이터 흐름을 가지고 있습니다.
위 예제는 자식 컴포넌트에서 부모의 데이터를 수정하는 예제입니다.

자식은 1끼에 500 칼로리를 섭취하지만 부모는 1끼에 800칼로리를 섭취합니다. 각각 부모와 자식은 칼로리라는 데이터를 가지고 있으며 자식이 밥을 먹을때만 부모도 함께 먹습니다.

밥을 먹는 이벤트를 발생시키는 트리거(버튼)는 자식이 가지고 있습니다. 이 트리거는 childEat라는 메서드를 실행 시켜줍니다. 이 메서드는 자식의 칼로리를 500 증가시켜주며 $emit()을 사용하여 eat이라는 이벤트를 발생시킵니다. eat은 child 컴포넌트를 사용(?)하는 부분에서 찾아볼 수 있습니다.

@eat="parentEat" 이런식으로 말이지요.

eat이라는 이벤트가 감지(발생)되면 parentEat이라는 메서드를 실행하게 되는 것 이지요.
이 parentEat은 부모의 칼로리를 800 증가 시켜줍니다. 이로써 자식 컴포넌트에서 부모의 데이터를 접근 할 수 있게 되었습니다.

이해가 잘 안된다면 아래 이미지를 한번 살펴봅시다.
부모자식통신

컴포넌트 간 통신

부모와 자식이 아닌 관계에서도 컴포넌트간의 통신이 필요할 때 가 있을 수 있습니다. 다음 예제는 빈 Vue 인스턴스를 이벤트 버스로 이용한 예제 입니다.

1
2
3
4
5
6
7
8
9
10
<div id="app">
<component1></component1>
<component2></component2>
</div>
<template id="c1">
<div>
<input type="text" v-model="text">
<button @click="sendData">컴포넌트 1 버튼</button>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Vue.component('component1', {
template: '#c1',
data: function() {
return {
text: ''
}
},
methods: {
sendData: function() {
eventBus.$emit('send', this.text);
}
}
});
Vue.component('component2', {
template: '<p>{{text}}</p>',
data: function() {
return {
text: ''
}
},
methods: {
showText: function(t) {
this.text = t
}
},
created: function() {
eventBus.$on('send', this.showText);
}
});
var eventBus = new Vue(); // 이벤트 버스
var app = new Vue({
el: '#app'
});

See the Pen BmREmB by keun hyeok (@small) on CodePen.

input에 값을 입력 후 버튼을 눌러봅시다. input에 들어간 값이 텍스트로 출력 되는 것을 확인 할 수 있습니다.
이 방식 또한 위의 부모 자식간의 통신과 비슷합니다.

컴포넌트1에서 $emit() 을 이용하여 이벤트 트리거를 생성 한 후 컴포넌트2 에서 $on()을 이용하여 이벤트를 감지하는 방법입니다. 단지 전역에 있는 eventBus라는 통로를 이용한다는 점이 다를 뿐 이지요.

html5 geolocation api 를 이용한 현 위치 날씨 알아보기

html5의 스펙인 Geolocation을 이용하여 현재 위치의 날씨를 알려주는 기능을 만들어 볼까 합니다.
service worker와 firebase에서 제공하는 fcm 을 이용하여 웹앱 형태로 추 후 만들어 볼 예정이기도 합니다.

React를 이용한 무언가를 만들어 보아야지 하고 결심하고 시작한 작업이지만 만들다 보니 React의 중요성이 크지 않게 되었네요.

설치

1
2
npm i -g create-react-app
create-react-app weatherPush

create-react-app 도구를 사용하여 react 프로젝트를 생성합니다.

폴더 구조

complte-folder-structure
작업이 끝나면 이러한 형태의 구조를 가지게 됩니다. ( firebase와 ServiceWorker 관련 파일은 무시해주세요. 아직 진행 중.. )

링크

깃허브 링크

핵심 코드

핵심인 App.js 정도만 살펴보려 합니다.

App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import React, {Component} from 'react';
import './App.css';
import MyMap from '../components/map';
import WeatherInfo from '../components/weatherInfo';
import mapApi from '../config/googleMap';
import weatherConfig from '../config/weather'
import axios from 'axios';
import Promise from 'bluebird';
 
class App extends Component {
constructor(props) {
super(props);
 
this.state = {
position: [],
city: '',
countryCode: '',
weatherList: [],
formatted_address: '',
weatherDate: 0
}
}
componentWillMount() {
new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition((p) => {
// 아래 api 위도 경도가 조금 정확하지 않아서 html5 스펙인 geolocation 사용
this.setState({
position: [p.coords.latitude, p.coords.longitude]
}, () => {
resolve(p.coords)
})
});
}).then((coords) => {
return new Promise((resolve, reject) => {
axios.get('https://freegeoip.net/json/').then((res) => {
const data = res.data;
this.setState({
city: (data.city || data.region_name),
countryCode: data.country_code
}, () => {
resolve({
city: (data.city || data.region_name),
countryCode: data.country_code,
coords: coords
})
});
}).catch((err) => {
console.error(err);
});
})
}).then((data) => {
return new Promise((resolve, reject) => {
axios.get(`http://api.openweathermap.org/data/2.5/forecast?id=524901&APPID=${weatherConfig.key}&q=${data.city.toLowerCase()},${data.countryCode.toUpperCase()}`).then((res) => {
this.setState({
weatherList: res.data.list
}, () => {
resolve(data.coords)
});
});
})
}).then((coords) => {
const geocoder = new window.google.maps.Geocoder;
const latlng = {
lat: coords.latitude,
lng: coords.longitude
};
geocoder.geocode({
'location': latlng
}, (results, status) => {
this.setState({
formatted_address: results[results.length - 1].formatted_address
})
});
});
}
 
render() {
const {position, weatherList, formatted_address} = this.state;
return (
<div className="app">
<h1 className="app-title">날씨 알림 서비스</h1>
<div className="map-wrap">
<MyMap apiKey={mapApi.apiKey} center={position} zoom={16}></MyMap>
</div>
<section className="info-area">
<p>
{formatted_address
? `당신은 ${formatted_address}에 위치하시군요.`
: '위치를 탐색중입니다...'}
</p>
<ul className="weather-info-wrap">
{weatherList.map((v, i) => {
const now = new Date();
const date = new Date(v.dt_txt).getDate();
if (now.getDate() + this.state.weatherDate >= date) {
const hour = v.dt_txt.split(' ')[1].substr(0, 2);
return (<WeatherInfo data={v} key={i} hour={hour} date={date}/>)
}
})}
</ul>
</section>
<button className="btn" onClick={() => {
this.setState({
weatherDate: this.state.weatherDate + 1
});
}}>더 보기</button>
</div>
);
}
}
 
export default App;

각 api요청을 위한 axios, 순차적인 비동기 작업이 필요하여 bluebird(Promise)를 사용하였습니다.
각 api요청 및 상태 작업을 살펴보도록 하겠습니다.

 

1
2
3
navigator.geolocation.getCurrentPosition((p) => {
// 좌표 값 get
});

html5 스펙에서 추가된 geolocation 입니다. 현재 위치의 위도 경도 값을 구할 수 있습니다. 이제 이 좌표를 이용하여서 google map에서 현 위치를 표시 해 줄 것 입니다.

 

1
2
3
4
5
axios.get('https://freegeoip.net/json/').then((res) => {
// get 좌표 or 지역 name
}).catch((err) => {
console.error(err);
});

ip, 좌표, 지역 등의 값을 제공해줍니다. geolocation에 비해 좌표가 정확하지 않아 날씨 api에서 필요한 지역 정보만 사용하였습니다.

 

1
2
3
axios.get(`http://api.openweathermap.org/data/2.5/forecast?id=524901&APPID=${weatherConfig.key}&q=${data.city.toLowerCase()},${data.countryCode.toUpperCase()}`).then((res) => {
// 날씨 정보 get
});

지역정보를 통하여 해당 지역의 날씨에 대한 정보를 가져옵니다. 위의 freegeoip와 지역 네이밍 규칙이 비슷하더군요.
이 데이터를 가공하여 사용자에게 정보를 보여줍니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
const geocoder = new window.google.maps.Geocoder;
const latlng = {
lat: coords.latitude,
lng: coords.longitude
};
geocoder.geocode({
'location': latlng
}, (results, status) => {
this.setState({
formatted_address: results[results.length - 1].formatted_address
})
});

google의 geoCoder 를 이용하여 상세한 지역 정보를 가져옵니다. (우편번호, 구, 군 등..)
위의 freegeoip를 사용하지 않고 이 정보를 잘 가공만 한다면 문제없이 사용 가능하지 않을까 합니다. ( 코드 분량과 수고가 좀 필요할 것 같아서 분리 하였습니다. )

화면

view

더보기를 누르면 state.weatherDate 값이 증가하며 그 다음날의 날씨를 불러올 수 있습니다. 해외에서 제공하는 api때문인지 날씨가 묘하게 다른감이 없지 않아 있더군요. 개인 연습용으로 작업하는 것이라 무시하고 진행하였습니다.

apis

날씨 : 링크
지역(freegeoip) : 링크
지도 : api 링크, 외부 컴포넌트 링크

마치며

firebase에서 제공하는 메세징 서비스(fcm)와 service worker를 추가하여 웹앱 형태로 동작하도록 추가적인 작업을 진행 해보려합니다.
제 검색능력이 부족한지 자료를 구하기가 많이 힘드네요. 현재 많은 삽질을 겪고 있습니다. 가끔 시간을 내어서 완성시켜 봐야겠습니다.

html/css 를 이용한 라디오 버튼 커스터마이징

가끔 웹페이지를 둘러보면 라디오 버튼과 같은 기능을 가지고 있으면서도 스크립트를 사용하여 기능 구현을 하는 경우를 보았습니다. 대부분 흔히 알고 있는 동그란 라디오 버튼과 모양이 많이 달라서 스크립트를 사용한 것 같더군요. 최근 작은 프로젝트를 진행하면서 라디오 버튼을 커스터마이징 해야 할 일이 생겨서 작업한 코드를 남겨볼까 합니다.

우선 예제의 코드펜 링크입니다.
https://codepen.io/small/pen/BwoMRM?editors=1010

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<p>grid css 1</p>
<div style="width: 200px;">
<div class="radio-items">
<div class="col-6">
<input id="a1" class="only-sr checked" type="radio" name="temp1" value="1" checked>
<label for="a1">1</label>
</div>
<div class="col-6">
<input id="a2" class="only-sr" type="radio" name="temp1" value="2">
<label for="a2">2</label>
</div>
</div>
</div>
<br/><br/>
<p>grid css 2</p>
<div style="width: 420px;">
<div class="radio-items">
<div class="col-3">
<input id="b1" class="only-sr checked" type="radio" name="temp2" value="1" checked>
<label for="b1">1</label>
</div>
<div class="col-3">
<input id="b2" class="only-sr" type="radio" name="temp2" value="2">
<label for="b2">2</label>
</div>
<div class="col-3">
<input id="b3" class="only-sr" type="radio" name="temp2" value="3">
<label for="b3">3</label>
</div>
<div class="col-3">
<input id="b4" class="only-sr" type="radio" name="temp2" value="4">
<label for="b4">4</label>
</div>
</div>
</div>
<br/><br/>
<p>grid css 3 (decimal)</p>
<div style="width: 520px">
<div class="radio-items">
<div class="col-2"> <!-- width auto important, 소수점 백그라운드 이슈로 인해 auto 설정 -->
<input id="c1" class="only-sr checked" type="radio" name="temp3" value="1" checked>
<label for="c1">1</label>
</div>
<div class="col-2">
<input id="c2" class="only-sr" type="radio" name="temp3" value="2">
<label for="c2">2</label>
</div>
<div class="col-2">
<input id="c3" class="only-sr" type="radio" name="temp3" value="3">
<label for="c3">3</label>
</div>
<div class="col-2">
<input id="c4" class="only-sr" type="radio" name="temp3" value="4">
<label for="c4">4</label>
</div>
<div class="col-2">
<input id="c5" class="only-sr" type="radio" name="temp3" value="5">
<label for="c5">5</label>
</div>
<div class="col-2">
<input id="c6" class="only-sr" type="radio" name="temp3" value="6">
<label for="c6">6</label>
</div>
</div>
</div>

label의 명시적 연결을 통해 스크립트의 사용 없이 충분히 원하는 모양으로 커스터마이징이 가능합니다. <input type="file">등 다른 input 요소들 또한 같은 방법으로 커스터마이징을 많이 하곤 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@mixin ie8 {
.ie8 {
@content;
}
}
 
/* reset */
*{margin: 0; padding: 0}
body {padding: 20px;}
 
/* temp grid */
.col-2 {width: 16.66%;}
.col-3 {width: 25%;}
.col-6 {width: 50%;}
 
.only-sr {
overflow: hidden !important;
position: absolute !important;
left: -9999px !important;
width: 1px;
height: 1px;
}
.radio-items {
display: table;
width: 100%;
border: 1px solid #454a60;
border-radius: 4px;
box-sizing: border-box;
> div {
display: table-cell;
height: 49px;
line-height: 49px;
border-left: 1px solid #454a60;
text-align: center;
}
> div:first-child {
border-left: none;
width: auto !important;
}
label {
display: block;
width: 100%;
height: 100%;
color: #454a60;
vertical-align: middle;
box-sizing: border-box;
cursor: pointer;
}
input[type="radio"]:checked + label{
background-color: #454a60;
color: #fff;
}
}
@include ie8 {
// ie8 에서는 :checked 미지원하기 때문에 class로 대체, 추가적인 스크립트가 필요합니다.
// 조건부 주석을 이용하여 html에 ie8이라는 클래스를 추가합시다.
.radio-items {
input[type="radio"].checked + label {
background-color: #454a60;
color: #fff;
}
}
}

sass 를 사용하였습니다. 아래 코드펜에서 compiled css를 확인해주세요. (scss 영역에 마우스 올리면 view compiled 버튼이 생깁니다.)

See the Pen Custom radio button by keun hyeok (@small) on CodePen.

핵심 키워드는 label의 명시적 연결과 css의 선택자의 조합입니다.
이 방법을 스크립트의 사용 없이 IE9 까지 정상적으로 지원이 가능합니다.
scss 하단을 보시면 ie8의 경우 :checked가 아닌 .checked를 사용한 것을 보실 수 있습니다.
IE8의 경우는 :checked css selector가 지원되지 않기 때문에 간단한 클래스 추가 삭제의 기능이 필요합니다. (여기서부터는 글의 제목과 조금 상이해지네요.)

1
2
3
4
5
6
7
8
9
10
11
<!--[if IE 8]><html class="ie ie8" lang="ko"><![endif]-->
<!--[if (gt IE 9)|!(IE)]><!--><html lang="ko"><!--<![endif]-->
 
.
.
.
<!-- 각 radio-items에서 checked된 라디오 버튼에 checked 클래스를 기본적으로 부여합니다. -->
<input id="a1" class="only-sr checked" type="radio" name="temp1" value="1" checked>
.
.
.

우선 조건부 주석을 사용하여 html 요소에 ie8 클래스를 입력하고, 기본으로 체크가 되어있는 라디오 버튼들에게 checked 클래스를 줍니다. 그리고 하단의 javascript를 이용하여 checked클래스 토글 기능을 등록합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// util function
function hasClass(target, className) {
if( (' ' + target.className + ' ').replace(/[\n\t]/g, ' ').indexOf(' ' + className + ' ') > -1 ) return true;
return false;
}
function removeClass(target, className){
var elClass = ' ' + target.className + ' ';
while(elClass.indexOf(' ' + className + ' ') !== -1){
elClass = elClass.replace(' ' + className + ' ', '');
}
target.className = elClass;
}
function addClass(target, className){
target.className += ' ' + className;
}
// util function end
 

if( hasClass( document.getElementsByTagName('html')[0], 'ie8' ) ) { // ie8 일 경우
var radios = document.querySelectorAll('input[type="radio"]'),
i,
len = radios.length;
 
for( i = 0; i < len; i++ ) {
radios[i].attachEvent('onchange', function(e) {
var siblingsChecked = this.parentNode.parentNode.querySelector('.checked'); // 이전 checked 버튼

removeClass(siblingsChecked, 'checked'); // checked 삭제
addClass(this, 'checked'); // checked 부여
});
}
}

요약해보면 ie8일 경우 자신에게 checked 클래스를 추가하고 기존의 checked 클래스를 삭제하는 이벤트를 등록하였습니다. 간단한 코드인데도 꽤나 길어지네요. DOM 라이브러리를 사용하시기를 추천합니다.