Qiita投稿一覧サイトの見直し

Qiita投稿一覧サイトの見直し

前回宣言したようにnuxtを使ったアプリケーションの1つである
Qiita投稿一覧サイトを見直したいと思います。

具体的に何をするか

以下の4点に取り組もうと思います。

  1. 各依存関係のバージョンアップ
  2. トップへ移動の処理をvue-scrolltoに代替
  3. スタイルをscssで書き直す
  4. 処理の見直し、機能追加

各依存関係のバージョンアップ

依存関係の(マイナー)バージョンアップを行います。

name before after
nuxt ^1.1.1 ^1.4.2
axios ^0.17.1 ^0.18.0
element-ui ^2.0.11 ^2.4.6
babel-eslint ^7.2.3 ^7.2.3
eslint ^4.3.0 ^4.19.1
eslint-config-standard ^10.2.1 ^10.2.1
eslint-loader ^1.9.0 ^1.9.0
eslint-plugin-html ^2.7.0 ^3.2.2
eslint-plugin-import ^2.0.11 ^2.14.0
eslint-plugin-node ^5.1.1 ^5.2.1
eslint-plugin-promise ^3.5.0 ^3.8.0
eslint-plugin-standard ^3.0.1 ^3.0.1

トップへ移動の処理を「vue-scrollto」に代替

vue-scrolltoをインストールします。

1
npm install --save vue-scrollto

nuxt.config.jsに設定を追加

nuxt.config.jsのbuild内に以下の設定を追加します。

1
2
build: {
vendor: ['axios', 'element-ui', 'vue-scrollto'],

nuxt.config.jsのpluginsに以下の設定を追加します。

1
2
3
plugins: [
{ src: '~plugins/vue-scrollto', ssr: false }
],

vue-scrollto.jsの作成

/plugins/vue-scrollto.jsを作成します。

1
2
3
4
5
6
// plugins/vue-scrollto.js

import Vue from 'vue'

const VueScrolltop = require('vue-scrollto')
Vue.use(VueScrolltop)

スクロール処理をvue-scrolltoに置き換え

自前実装のスクロール処理をvue-scrolltoに置き換えます。

【before】

1
2
// pages/search.vue line:2
<div>
1
2
3
4
5
6
// plugins/vue-scrollto.js line:34~
<div v-if="250 < scrollY" class="page-component-up">
<transition name="fade">
<i class="el-icon-caret-top" @click="scrollTop" />
</transition>
</div>

【after】

1
2
// pages/search.vue line:2
<div id="page_top">
1
2
3
4
5
6
// plugins/vue-scrollto.js line:34~
<div v-if="250 < scrollY" class="page-component-up">
<a href="#" id="return-top" v-scroll-to="'#page_top'">
<i class="el-icon-caret-top" />
</a>
</div>

スタイルをscssで書き直す

まずは現在指定しているスタイルをscssに切り出したいと思います。
加えて、フォント指定もちゃんと行います。

Saasをコンパイルするための設定

まずはSCSSがコンパイル/読み込まれるよう、ローダーのインストールと設定を行います。

node-sassとsass-loaderのインストール

node-sasssass-loaderをインストールします。

1
npm install -D node-sass sass-loader
nuxt.config.jsに設定を追加

nuxt.config.jsに含めたいSCSSのファイルを指定します。

1
2
3
css: [
'@/assets/scss/main.scss'
],

これでSCSSがコンパイルされ、自動的にstyleタグに挿入されるようになります。

Nuxt Sass Resources Loaderの使用

設定を簡単に行うようにするため、nuxt-sass-resources-loaderを用います。

nuxt-sass-resources-loaderのインストール

nuxt-sass-resources-loaderをインストールします。

1
npm install nuxt-sass-resources-loader
nuxt.config.jsに設定を追加
1
2
3
4
5
module.exports = {
modules: [
// provide path to the file with resources
['nuxt-sass-resources-loader', '@/assets/scss/main.scss']
],

scssディレクトリの作成

assetsディレクトリ配下にfontsscssという名前のディレクトリを作成します。
役割は以下のようにします。

directory outline
fonts フォントファイルを配置
scss scssファイルを配置

スタイルをscssに切り出し

scssディレクトリ下にmain.scssを作成します。
粗いですが、いったん各ファイルのスタイルをこのmain.scssに全部まとめます。

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
// main.scss
@charset "utf-8";

html, body {
word-spacing: 1px;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
box-sizing: border-box;
padding: 0;
margin: 0;
height: 100%;
width: 100%;
font-size: 12px;
background-color: #59bb0c;
color: #fff;
a {
text-decoration: none;
&:link, &:visited {
color: #fff;
}
}

*, *:before, *:after {
margin: 0;
}
}
:
:
以下省略

フォント指定を行うscssの作成

フォントの選択

フォントはGoogle Fontsから選びます。今回はKosugi Maruにしました。
ダウンロード後、fontsディレクトリ下に配置します。

フォントの設定

scssディレクトリ下に_fonts.scssを作成します。
mixinを使用します。

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
// _fonts.scss
@mixin font-face($name, $path, $weight: null, $style: null, $exts: ttf) {
$src: null;

$extmods: (
eot: "?",
svg: "#" + str-replace($name, " ", "_")
);

$formats: (
otf: "opentype",
ttf: "truetype"
);

@each $ext in $exts {
$extmod: if(map-has-key($extmods, $ext), $ext + map-get($extmods, $ext), $ext);
$format: if(map-has-key($formats, $ext), map-get($formats, $ext), $ext);
$src: append($src, url(quote($path + "." + $extmod)) format(quote($format)), comma);
}

@font-face {
font-family: quote($name);
font-style: $style;
font-weight: $weight;
src: $src;
}
}

読み込む際はmain.scssに以下のように指定します。

1
2
3
4
5
// main.scss
@import './fonts';

@include font-face('Kosugi Maru', '~/assets/fonts/KosugiMaru-Regular', null, null, ttf);
font-family: 'Kosugi Maru', 'Segoe UI';

このほか、カラーコードなどの定数定義を行う_variales.scss、共通しているスタイルの定義を行う_partials.scssを作成しmain.scssで読み込むようにしました。

処理の見直し、機能追加

  • storeの利用 && actionでデータ取得を行うよう変更
  • async/awaitの利用
  • ページ表示時にcreated()のタイミングデータ取得を行っていた箇所をfetchで代替
  • コンポーネントの細分化(検索部分と検索結果を分離)
  • computedで値の状態管理
  • ローディング画面の追加
  • 投稿時間のフォーマット

に取り組みます。

storeの利用・async/awaitの利用

storeディレクトリ下にindex.js、actions.js、mutations.jsを作成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import mutations from './mutations'
import actions from './actions'
import state from './state'

Vue.use(Vuex)

const store = () => new Vuex.Store({
state,
mutations,
actions
})

export default store
1
2
3
4
5
6
7
8
9
10
11
12
// store/mutations.js
export default {
setItems (state, lists) {
state.lists = lists
},
hideLoading (state) {
state.isLoading = false
},
showLoading (state) {
state.isLoading = true
}
}
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
// store/action.js
import axios from 'axios'

const BASE_URL = 'https://qiita.com/api/v2/'

export default {
async getItems ({ commit }, payload) {
commit('showLoading')
const response = await axios.get(`${BASE_URL}items`, {
headers: {'Content-Type': 'application/json'},
params: {
page: 1,
per_page: 20,
query: payload.keyword
},
timeout: 3000
}).catch((error) => {
console.error(error)
commit('hideLoading')
this.$router.push('/error')
})
commit('setItems', response.data)
commit('hideLoading')
}
}

actionにはデータ取得処理を、stateには取得したデータとローディング中かどうかのフラグを設定します。

created()のタイミングデータ取得を行っていた箇所を fetch で代替

1
2
3
4
5
6
7
8
// pages/search.vue
export default {
fetch ({ store }) {
store.dispatch('getItems', {
keyword: 'nuxt.js'
})
}
}

データ取得処理は先程作成したstoreのactionのgetItemsを呼びます。
また、created() の処理が不要になったので削除します。

コンポーネントの細分化

検索部分をコンポーネント化し、検索結果と分離させます。
今回storeを使ったことによりデータ共有が容易になりました。

イメージ図

左図がこれまで、右図が分離後のイメージ図です。青枠をコンポーネントの区分としています。

SearchForm.vueの作成

componentsディレクトリ下にSearchForm.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
// components/SearchForm.vue
<template>
<el-form :inline="true" :model="searchForm" ref="searchForm" :rules="rules" @submit.native.prevent>
<el-form-item prop="keyword">
<el-input placeholder="search by keyword" prefix-icon="el-icon-search" v-model="searchForm.keyword" @keyup.enter.native="search('searchForm')" />
</el-form-item>
<el-form-item>
<el-button @click="search('searchForm')">search</el-button>
</el-form-item>
</el-form>
</template>

<script lang="babel">
export default {
data () {
return {
searchForm: {
keyword: ''
},
rules: {
keyword: [
{ required: true, message: 'Please input the keyword', trigger: 'blur' },
{ whitespace: true, message: 'Please input the keyword', trigger: 'blur' }
]
}
}
},
methods: {
search (form) {
this.$refs[form].validate((valid) => {
if (!valid) {
return false
}
this.sendRequest()
})
},
sendRequest () {
this.$store.dispatch('getItems', {
keyword: this.searchForm.keyword
})
}
}
}
</script>

ここでもデータ取得処理は先程作成したstoreのactionのgetItemsを呼びます。
空白を許容しないバリデーションルールも追加しました(line 23)。

作成したコンポーネントSearchForm.vueの読み込み

search.vueの検索箇所(el-formタグ部分)を削除し、SearchForm.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
// search.vue
<template>
<div id="page_top">
<el-container>
<el-main>
<search-form />
<search-result />
</el-main>
</el-container>
</div>
</template>

<script lang="babel">
import SearchResult from '~/components/List.vue'
import SearchForm from '~/components/SearchForm.vue'

export default {
layout: 'navbar',
components: {
SearchForm,
SearchResult
},
fetch ({ store }) {
store.dispatch('getItems', {
keyword: 'nuxt.js'
})
}
}
</script>

さりげなくMyListをSearchResultに変更しました。

ローディング画面の追加

ユーザビリティ向上のため、ローディング画面の追加も行います。
ローディング自体はelement-uiに用意されているのでそちらを用います。
storeからisLoadingを取得して判別に用います。

1
2
3
4
5
6
7
8
9
10
11
12
13
// search.vue
<el-main v-loading.fullscreen.lock="isLoading">
:
:
<script lang="babel">
import {mapState} from 'vuex'
:
:
export default {
computed: mapState(['isLoading']),
:
:
}

投稿時間のフォーマット

投稿時間の表記にタイムゾーンを表示しないよう修正します。

momentのインストール

momentをインストールします。

1
npm install -D moment
filter.jsの作成

pluginsディレクトリ下にfilter.jsを作成します。

1
2
3
4
5
6
7
8
9
// plugins/filter.js
import Vue from 'vue'
import moment from 'moment'

Vue.filter('formatDate', function (value) {
if (value) {
return moment(String(value)).format('YYYY/MM/DD HH:mm')
}
})
filter.jsの適用

投稿日時を指定する箇所にfilter.jsで作成した’formatDate’を適用します。

1
<div>{{ element.created_at | formatDate }}</div>
結果

【before】

1
2018-09-04T17:09:51+09:00

【after】

1
2018/09/04 17:09

動作確認

Demo(今回リファクタリングしたもの)
Demo(リファクタリング前)

多少はレベルアップした感出てると思います。

まとめ

記憶が薄れ始めていたので、progateで学んだscssのいい復習になりました。
@extend@importが使えるのは大きいですね。
不要と思われるスタイルは削除しましたが、もっと分割したりきれいにしたりできるんだろうなぁ。
デザインも奥が深い。。。
今回の修正でフォントに一貫性がない問題とページリロードでヘッダー/フッターにマージンが入る問題が修正できました。
フォントはキャッシュされるだろうしCDNで読み込むでもいいのかも。

最初は@nuxtjs/axiosを使おうと考えていましたが、SSRでないとコンソールログにエラーが出る問題に当たりました。
動作に問題なさそうなものの、見栄えを考え今回は止めました。
参照:ci-info module throw error for “process is not defined”

処理についてはasync/awaitstorecomputedで実装できすっきりしました。
fetchvue-scrollto はコード削減にはなっていないし、手を加えなくても問題はなかった(と思う)のですが、使ってみたかったので良しとします。
検索フォームをコンポーネントとして切り出すことも前からやりたいことだったので達成できてよかったです。
今回はstoreを使ってデータを共有しましたが、これくらいの規模なら$emitでも十分と思います。

以前よりNuxt.jsと親しくなれた気がします:)
Qiitaの記事も修正しないと…

ソースコード

https://github.com/aytdm/hello-nuxt/tree/feature/refactoring-use-store-and-scss

参考

ひとりごと

他のアプリケーションにも言えることですが、サービスと銘打つほどの規模じゃないのでサイト呼びで統一することにしました。

Share