- 投稿日:2019-12-11T21:07:48+09:00
Vue.js Vuexで描画状態を管理するウエイト画面を作ってみた
概要
リクエストなどの長い処理で、処理待ちをする際に表示するウエイト画面をコンポーネント化し、
Vuexで状態管理をするように実装したときのメモ。環境
- Vue.js 2.6.10
- Vuex 3.1.2
ウエイト画面
まずはウエイト画面のコンポーネントを実装。
waitingLoader.vue<template> <div class="loader-backdrop"> <span class="loading">Loading...</span> </div> </template> <script> export default {}; </script> <style> .loader-backdrop { width: 100vw; height: 100vh; line-height: 100vh; background-color: rgba(0, 0, 0, 0.5); z-index: 2000; text-align: center !important; position: fixed; top: 0; left: 0; } .loading { color: white; animation: flash 1.5s linear infinite; } @keyframes flash { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } </style>Vuexでの描画状態管理
Vuexの定義部分
*isWaitingShow
で描画状態を管理する
*waitingShow
、waitingHide
で描画状態を切り替えるmain.jsimport Vue from 'vue' import App from './App.vue' import Vuex from 'vuex' Vue.config.productionTip = false Vue.use(Vuex); const store = new Vuex.Store({ modules: { common: { state: { isWaitingShow: false }, mutations: { waitingShow(state) { state.isWaitingShow = true; }, waitingHide(state) { state.isWaitingShow = false; } }, actions: {}, getters: {} } } }); new Vue({ render: h => h(App), store }).$mount('#app')画面部分の実装
this.$store.commit("waitingShow")
でウエイト画面を表示this.$store.commit("waitingHide")
でウエイト画面を非表示- 今回はサンプルのため、ボタンクリック時に表示し、1秒後に非表示になるような実装
App.vue<template> <div id="app"> <waitingLoader v-if="$store.state.common.isWaitingShow" /> <button @click="onShow">ウエイト画面表示</button> </div> </template> <script> import waitingLoader from "./components/waitingLoader/waitingLoader.vue"; export default { name: "app", components: { waitingLoader }, methods: { onShow() { this.$store.commit("waitingShow"); const self = this; setTimeout(function() { self.$store.commit("waitingHide"); }, 1000); } } }; </script> <style> </style>まとめ
こういった画面を実装するときは大体共通化すると思う。
共通化しやすい部分に処理や状態をまとめられると何かとアクセスしやすくなると思い、
Vuexで管理するようにしてみたのが今回の実装。
- 投稿日:2019-12-11T20:44:18+09:00
yumemi.vue memo
アウトプットの重要性
新しいことはメモに
日々なんでもアウトプット
積み重ねNuxtでVueライフを快適に
容易に環境を整えたい
Nuxt.js Vueを快適に作るため
ルーティングが自動的に設定を生成
Vuex store をインポートシンプル
NuxtでかくとVueのコードがシンプルに
SSRだけでなくVueが快適になるよ!Vueでネイティブアプリを Ionic Vue
Native開発
weex-vue-RenderIonic -> web技術でネイティブアプリ風に
iOSでネイティブアプリを作るのは割と簡単
- Ionicコンポーネントを基本使おう宣言的UIってなんなの??
欲しいUIを命令的に書かないで宣言的に書いていくことで可読性が上がる
宣言的に書いた方がすっきりするよねvue-nextのソースコードを読み始める
vue-next -> 乱暴にいうとVersion3
利用されるツール
istanbul -> テスト時のカバー率を確認できる
consoler -> 色付きでコンソールに表示
rollup -> バンドルするやつディレクトリ構成
どこを読むといいのか
packagesに便利な奴がある
vue-nextはディレクトリがわかりやすくcompiler :コンパイル時に使う奴
run-time :実行時に使う奴jQuery使いがVueを使った話
Vueの強み
- タグのようにコンポーネントがかける
- イベントの紐付けをタグとするから見やすい
- 配列要素の描画が簡単Vuexで何をするか、何をしないか
Vuex -> 状態管理の方法のパターンを提供(Fluxなどから影響)
単方向フローを強制する
グローバルデータの中央一元管理安全にグローバルデータを管理したい
Page(View)に提供するものアンチパターン
emit,propの代わりに使う
再利用性が下がる
テストが難しくなる全てのデータをVuexにおく
全てのロジックをVuexにおく
ロジックの単体テストが難しくなる責務以外は全部アンチパターン!
- 投稿日:2019-12-11T20:28:54+09:00
VueとVue Routerで、リダイレクトのない理想の404を目指す
この記事は、Vue #2 Advent Calendar 2019 の14日目の記事です?
SPAだけど、リダイレクトせずURLそのままで404ページを表示したい
VueとVue RouterでSPAを作っていると、404 Not Foundを作りたくなってきますよね!
結構試行錯誤したのでアドベントカレンダーを機に、理想を目指す気持ちで改めて整理してみます。前提
今回は簡単に考えるため以下の通りとします。
- Vue (Nuxtは使わずにSPAする)
- Vue Router
- SEO等については考慮しない※
※本来であれば、ただ404ページを表示させるだけだと「ソフト404エラー」に該当してしまうのであまり良くありません。ただ、SPAを採用している時点で、検索について気にしなくていいサービスやサイトである可能性が高いので、ここではいったん考慮しないでいきます。
※SPAとSEOについては以下の記事が分かりやすかったです。
SPAを開発するエンジニアこそ知るべき、正しく評価されるためのSEO方法1:Vue Routerで設定
公式を見ながら、普通に404のルーティングを設定してみます。
https://router.vuejs.org/ja/guide/essentials/history-mode.htmlapp.jsconst router = new VueRouter({ mode: 'history', routes: [ { name: 'mypage', path: '/', component: mypage, meta: { title: 'マイページ' } }, { name: 'postDetails', path: '/posts/:id(\\d+)/details', component: postDetails, meta: { title: '記事は1つしかない!' } }, { name: 'notFound', path: '*', component: notFound, meta: { title: 'お探しのページは見つかりませんでした' }}, ], } })これで、
example.com/
→マイページ
example.com/posts/1/details
→投稿1のページ
example.com/whoops
→404ページ
が表示できるようになります。でも、このままでは、
example.com/posts/2/details
は、存在しない記事にもかかわらずpostDetailsのコンポーネントが表示されてしまいます。困った!方法2:コンポーネントガードで、404か否かでコンポーネントを出し分ける
URLは
example.com/posts/2/details
のまま、表示させるコンポーネントだけnotFoundにしたくなってきました。そこで、次はVue RouterのコンポーネントガードであるbeforeRouteEnterとbeforeRouteUpdateで記事取得のAPIをチェックしておいて、postsページのコンポーネントを出し分けるようにします。
流れは
- beforeRouteEnterまたはbeforeRouteUpdateの間に、APIで記事を取得しようとする
- 失敗した場合には、notFoundのフラグを立てて、
<template>
側でそれを見てnotFoundコンポーネントを出す- 無事取得できれば、next()を使っていつも通りcreatedのライフサイクルに移行し
<template v-else>
内にあるコンテンツを出すとなります。
postDetails.vue<template> <div> <not-found v-if="notFound" /> <template v-else> <div>記事のコンテンツ</div> </template> </div> </template> <script> import axios from 'axios' import store from '../store/store' export default { beforeRouteEnter(to, from, next) { axios.get('/posts/2/') .then(res => { store.commit('window/setNotFound', false) next() }) .catch(err => { if (err.response.status === 404) { store.commit('window/setNotFound', true) next() } else { next() } }) }, beforeRouteUpdate(to, from, next) { // いったん共通化はせずベタ書きします(後述) axios.get('/posts/2/') .then(res => { store.commit('window/setNotFound', true) next() }) .catch(err => { if (err.response.status === 404) { store.commit('window/setNotFound', true) next() } else { next() } }) }, computed: { notFound() { return this.$store.getters['window/isNotFound'] } } } </script>store.jsimport Vue from 'vue' import Vuex from 'vuex' import window.js export default new Vuex.Store({ modules: { window }, })window.jsconst namespaced = true const state = { notFound: false, } const getters = { isNotFound(state) { return state.notFound } } const mutations = { setNotFound(state, val) { state.notFound = val } } export default { namespaced: namespaced, getters, state, mutations }これで、
example.com/posts/2/details
にアクセスしたとき、晴れてnotFoundが表示されるようになりました。上記の例だと、
example.com/posts/2/details
にアクセスしようとすると、ページとしては200OKが返ってくる- postDetails.vueのライフサイクルをガードして、beforeRouteEnterが呼び出される
axios.get('/posts/2/')
を取得するときにaxiosの404が返ってくる- ストアに404であることを変数で保存する
- コンポーネントガードをnext()で終了し、postDetails.vueの中でcreated()以降のライフサイクルに進む
- ストアの404を参照して、notFoundのコンポーネントが表示される
となります。
beforeRouteEnter内ではthisが使えない
いきなりVuexが出てきてました。何事だ!
なんと、beforeRouteEnter内ではthisが使えないので、普段のコンポーネントのように
this.****
でメソッドにアクセスしたり、data()に変数を保存することは出来ません。なぜなら、その名の通りまだルートにEnterしていないからです。notFoundであることを変数に保存するためには別の方法を使う必要があります。そのため、ここではVuexのwindowというストアを作って保存しています。
※上記のコードでbeforeRouteEnterとbeforeRouteUpdateでメソッドを共通化して…のようなことも考えると思います。が、beforeRouteEnterでthisが使えない一方、beforeRouteUpdateではthisが使えるので、一筋縄ではいかず、ちょっとした工夫が必要になります。(挑戦する人はがんばれ)
方法3:axiosのinterceptorsを使う
しかし、よく考えてみると、ページ読み込み以外にも他のAPIで情報を取得している箇所(たとえば「記事を削除するボタンを押した」ときなども)、すべての場所で404が起こる可能性はあるはずです。
記事を削除するAPIは、必ずしもbeforeRouteEnter/Updateで呼び出される訳ではないことが多いと思います。困った!
それなら、もはやaxiosのレスポンスを受けるときすべてに共通処理を書いてしまえば良さそうです。
ここでは、axiosの最中に処理を挟み込むinterceptorsを利用して共通処理を書いていきます。リクエストの前処理をほどこしたり、今回のようににレスポンスを受け取る前にいろいろやったりと、かなり使い勝手がよいので覚えておくと役に立つ日がくるかも。
axiosHelper.jsimport axios from 'axios' import store from '../store/store' const setup = () => { return (() => { axios.interceptors.response.use( res => { // ここでストアに値を保存する store.commit('window/setNotFound', false) // いつものレスポンス処理が続く return res }, err => { const res = err.response const status = res === undefined ? undefined : res.status if (status === 404) { store.commit('window/setNotFound', false) } return Promise.reject(err) } ) })() } export default { setup }あとは、axiosを使う箇所で
import axiosHelper from './axiosHelper' axiosHelper.setup()と書いてからaxios.getすれば、毎回404レスポンスを受け取っているかどうかチェックできます。
今回は404ページを出しましたが、表示するコンポーネントによっては、リロードを促すOverlayを表示する、ダイアログを出して動作が失敗したことを通知するなど、用途によっていろいろ工夫ができそうです。
まとめ
- SPAで404 not foundを出す方法はいくつかある
- 理想の404は一筋縄では行かないので、その時に必要な方法を選んだり組み合わせたりする
みなさんの理想の404もお待ちしています?
- 投稿日:2019-12-11T19:38:37+09:00
VSCode + Storybook for Vueでインラインテンプレートをシンタックスハイライトする方法
「Storybook」最近やっと使い始めました。
「Storybook for Vue」も使わせてもらってるんですが、インラインテンプレート(文字列テンプレート?)のシンタックスハイライトが効かず、やりづらさを感じていました。
example.story.jsexport default { title: "example story" } export const example `<example-component some-prop="value"></example-component>`調べたら、拡張機能ありました。VueのインラインテンプレートをSyntax Highlightしてくれます。
が、実は依存関係としてこちらの拡張機能のインストールも必要です。
こちらは、
lit-html
というライブラリで使われるhtml
というテンプレートリテラルのタグに対して、HTMLのシンタックスハイライト等を効かせてくれるライブラリなのですが、こちらに相乗りした形のようです。2つともインストールして、
html
タグを使うと、ちゃんとシンタックスハイライトが効きます。example.story.jsexport default { title: "example story" } // vscodeで見ると、シンタックスハイライト効いているはずです export const example html`<example-component some-prop="value"></example-component>`このままだと
html
タグなんて存在せずエラーになるので、受け取った文字列をそのまま返すタグを定義します。example.story.js// 受け取った文字列をそのまま返すタグ const html = String.raw; export default { title: "example story" } export const example html`<example-component some-prop="value"></example-component>`紹介させて頂いたVue Inline Templateですが、TODOのところにインテリセンスのサポートもほのめかしていますので、期待しておきましょう。
以上、小ネタでした。
- 投稿日:2019-12-11T19:16:28+09:00
TypescriptでVuex。direct-vuexでシンプルに
Vuex with T
TypescriptでVuexを使うためのライブラリはいろいろあって、悩みどころです。
その中の一つ、direct-vuexは普通のVuexの書き方に近いので、Vuexのサイトを見たことがある人なら理解しやすいと思います。カウンターを作ってみる
Vue Cli (@vue/cli) で作ったプロジェクトで、定番のカウンターを作ってみます。
説明がほぼありませんが、ソースコードを見ればわかると思います。ストアの作成
state
の書き方に制限があります。参照src/store/index.tsimport Vue from 'vue' import Vuex from 'vuex' import { createDirectStore } from 'direct-vuex' Vue.use(Vuex) export interface CounterState { count: number } const { store, rootActionContext, moduleActionContext } = createDirectStore({ state: (): CounterState => { return { count: 0, } }, mutations: { INCREMENT(state) { state.count += 1 }, }, actions: { // 値を返す時は、戻り値の型を書く必要がある increment(context): number { const { commit, state } = rootActionContext(context) commit.INCREMENT() return state.count }, }, }) export default store export { rootActionContext, moduleActionContext } export type AppStore = typeof store declare module 'vuex' { interface Store<S> { direct: AppStore } }ストアの登録箇所を変更
store.original
とする必要があります。src/main.tsimport Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' Vue.config.productionTip = false new Vue({ router, store: store.original, // 変更 render: (h) => h(App), }).$mount('#app')コンポーネントの作成
ポイントは、
this.$store.direct
で型付けされたラッパーを取り出すところです。src/components/Counter.vue<template> <div> <span>{{ count }}</span> <button @click="increment">+</button> </div> </template> <script lang="ts"> import Vue from 'vue' export default Vue.extend({ computed: { count() { return this.$store.direct.state.count }, }, methods: { async increment() { const count = await this.$store.direct.dispatch.increment() console.log(count) }, }, }) </script>実行
とりあえず動作確認のため、
src/views/Home.vue
にCounterを2つ張り付けてみます。src/views/Home.vue<template> <div class="home"> <Counter /> <Counter /> <img alt="Vue logo" src="../assets/logo.png" /> <HelloWorld msg="Welcome to Your Vue.js App" /> </div> </template> <script> // @ is an alias to /src import HelloWorld from '@/components/HelloWorld.vue' import Counter from '@/components/Counter.vue' export default { name: 'home', components: { HelloWorld, Counter, }, } </script>うまく動作しています。
カウンターのストアをモジュールに切り出してみる
src/store/counter.ts
にモジュールを作成します。src/store/counter.tsimport { createModule } from 'direct-vuex' import { moduleActionContext } from './index' export interface CounterState { count: number } const counter = createModule({ namespaced: true, state: (): CounterState => { return { count: 0, } }, mutations: { INCREMENT(state) { state.count += 1 }, }, actions: { increment(context): number { const { commit, state } = counterActionContext(context) // rootCommitなどもあります commit.INCREMENT() return state.count }, }, }) export default counter export const counterActionContext = (context: any) => moduleActionContext(context, counter)モジュールを登録
src/store/index.ts
を書き換えて、modules
で登録します。src/store/index.tsimport Vue from 'vue' import Vuex from 'vuex' import { createDirectStore } from 'direct-vuex' import counter from './counter' Vue.use(Vuex) const { store, rootActionContext, moduleActionContext } = createDirectStore({ modules: { counter, }, }) export default store export { rootActionContext, moduleActionContext } export type AppStore = typeof store declare module 'vuex' { interface Store<S> { direct: AppStore } }コンポーネントの変更
namespaced: true
でモジュールを作ったので、namespace
経由でアクセスするように変更して完成です。src/components/Counter.vue<template> <div> <span>{{ count }}</span> <button @click="increment">+</button> </div> </template> <script lang="ts"> import Vue from 'vue' export default Vue.extend({ computed: { count() { return this.$store.direct.state.counter.count }, }, methods: { async increment() { const count = await this.$store.direct.dispatch.counter.increment() console.log(count) }, }, }) </script>Composition API を使う場合
ストアのラッパーを
context.root.$store.direct
で取得するだけです。src/components/Counter.vue<template> <div> <span>{{ count }}</span> <button @click="increment">+</button> </div> </template> <script lang="ts"> import Vue from 'vue' import VueCompositionApi, { createComponent, computed } from '@vue/composition-api' Vue.use(VueCompositionApi) // src/main.ts とかに書きます。 export default createComponent({ setup(props, context) { const store = context.root.$store.direct const count = computed(() => store.state.counter.count) async function increment() { const count = await store.dispatch.counter.increment() console.log(count) } return { count, increment, } }, }) </script>まとめ
簡単。
- 投稿日:2019-12-11T18:48:56+09:00
Vue.js + Firebase を用いてポートフォリオ作ってみた
※ 本投稿はTechCommit Advent Calendar 2019 11日目の記事です。
はじめに
普段はWeb製作会社でコーダーのアルバイトをしている大学4回生です。内定先の会社でVue.jsを使っているということもあり、Vue.jsで簡単に何か作成してみようと思いポートフォリオを作成しました。
今回はVue.js + Vuetify + Vue Router を用いてSPAのポートフォリオを作成し、Firebaseにホスティングしたのでその流れ等について書いています!
実際に作成したポートフォリオ
https://vue-portfolio-25939.firebaseapp.com環境
- Vue CLI v4.1.1
- Node.js v12.6.0
- npm v6.9.0
ポートフォリオを作成する前に考えたこと
何を掲載するか
- トップページ - 簡単な自己紹介 - 作品 - スキル - お問い合わせざっとこんな感じで構成することにしました。
Vue.jsでどのようなものが作れるかを確認
「Vue.js ポートフォリオ」で検索すると多くの記事が出てくるので、それを一通り読みました。
参考になった記事
- フロント未学習の大学生が1週間でVue.jsを使ったポートフォリオを作った話
- Vueを学び、SPA対応のポートフォリオサイトを自作するまでの道のり
- 【Vue.js入門】独学1週間でSPA対応の簡易ポートフォリオサイトを自作してみた!
- Vue.jsでドラクエ風のポートフォリオを作った話
- Vue.js + Firebaseでポートフォリオを作ろう!
- Vue.js + Firebase functionsでお問い合わせフォームを作成する
デザインを考える
Cacooというサービスを使って簡単にワイヤーフレームを作ってみました。
シート6枚までは無料で使えるので今回作成するポートフォリオのようにページ数の少ないコンテンツであれば無料枠でも充分です。
実装していくと「やっぱりここをこういう風にしたい」と思ったりもして、多少変更しました。
実際にポートフォリオを作成していく
仕様
- デザインはVuetifyを使用
- SPA構築(Vue Router)
- テキストアニメーション
- モーダルウィンドウ
- お問い合わせフォーム(Firebase Functionsを用いてメールフォーム作成)
- アイコンはfont-awesomeを使用
Vuetify + Vue Router
vue add vuetify
でvuetifyを追加、モードはDefaultを選択。
Vue Routerに関しては、vue create
時にRouterを選択し、vue-routerを使えるようにしました。src/router/index.jsimport Vue from 'vue' import VueRouter from 'vue-router' import Top from '@/components/Top' import Profile from '@/components/Profile' import Work from '@/components/Work' import Skill from '@/components/Skill' import ContactForm from '@/components/ContactForm' Vue.use(VueRouter) const routes = [ { path: '/', component: Top }, { path: '/profile', component: Profile }, { path: '/works', component: Work }, { path: '/skills', component: Skill }, { path: '/contact', component: ContactForm }, ] const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) export default routersrc/main.jsimport Vue from 'vue' import App from './App.vue' import router from './router' import vuetify from './plugins/vuetify'; import '@fortawesome/fontawesome-free/css/all.css' Vue.config.productionTip = false new Vue({ router, vuetify, render: h => h(App) }).$mount('#app')単一ファイルコンポーネントを用いての実装だったので、
App.vue
には以下のように記述。src/App.vue<template> <v-app> <v-content> <header-menu></header-menu> <router-view/> </v-content> </v-app> </template> <script> import HeaderMenu from './components/HeaderMenu'; export default { name: 'App', components: { HeaderMenu, }, data: () => ({ // }), }; </script>ちなみにヘッダーメニュー部分は以下のような感じです。
hidden-sm-and-down
、hidden-md-and-up
をクラスに付与することにより、smサイズの時にテキストを隠し、アイコンを表示。mdサイズの時にアイコンを隠し、テキストを表示させています。src/components/HeaderMenu.vue<template> <v-toolbar color="teal" dark class="d-flex justify-center" > <v-toolbar-items v-for="(item, index) in items" :key="index"> <v-btn text class="hidden-sm-and-down"> <router-link v-bind:to="item.path"> {{ item.title }} </router-link> </v-btn> <v-btn text class="hidden-md-and-up"> <router-link v-bind:to="item.path"> <v-icon medium>{{ item.icon }}</v-icon> </router-link> </v-btn> </v-toolbar-items> </v-toolbar> </template> <script> export default { name: 'app', data () { return { items: [ { title: 'トップ', icon: 'fas fa-home', path: '/' }, { title: 'プロフィール', icon: 'far fa-id-badge', path: '/profile' }, { title: '作品', icon: 'fas fa-folder', path: '/works' }, { title: 'スキル', icon: 'fas fa-code', path: '/skills' }, { title: 'お問い合わせ', icon: 'far fa-envelope', path: '/contact' } ] } } } </script>お問い合わせフォーム
送信サーバとしてgmailを使うため、環境変数に以下を追加
$ firebase functions:config:set gmail.email="gmailID" gmail.password="gmailPassword" admin.email="adminAddress"バリデーションはv-text-fieldのrulesオプションを使って実装しました。
ContactForm.vue(略) <v-text-field v-model="contactForm.name" :rules="contactFormValidation.nameRules" label="名前" required ></v-text-field> (略) <script> import { functions } from '@/plugins/firebase' export default { data: () => ({ contactForm: { name: '', }, contactFormValidation: { valid: false, nameRules: [v => !!v || '名前は必須項目です'], }, }) } </script> (略)ビルドとデプロイ
npm run build
でビルドし、firebase deploy
でデプロイ完了です!まとめ
今回は初めて、vue.jsを用いてポートフォリオを作成しました。今後はサーバーサイドと連携させたものを作りたいと思っています!
間違っているところなどありましたら、コメントで教えていただけると幸いです。
- 投稿日:2019-12-11T18:27:17+09:00
FirebaseとVue.jsを使って息子の教材を作った話
はじめに
はじめまして。ジークスの酒井です。
今回は業務とは別で日曜プログラミングネタになります。中1の息子のために家族で単語カードを作ろうとしてたんですが、あまりに面倒くさいので
ツールを作ってみる事にしました設計方針
・用紙はミシン目や穴があらかじめ空いている単語カード向け用紙を使います。
https://www.a-one.co.jp/product/search/detail.php?id=51163
・シンプルなPWAとして、入力画面 -> 印刷画面 の2画面構成にする
・vue.jsを使って、画面遷移などを実装する
・最後はFirebaseのHostingを使ってWebサイトにアップする
・印刷はpaper.cssにまかせる
paper.css
https://github.com/cognitom/paper-cssいざ実装
まずは2画面を切り替える仕組みを作ってしまいます。
vueのlistというデータに画面名が入っていて、代入すると画面が切り替わる仕組みです。
入力画面:list、印刷画面:printJS側
fc_maker.jsdata: { mode: 'list' }HTML側
index.html<div v-if="mode === 'list'" id="input-list"> 入力画面 </div> <div v-if="mode === 'print'"> 印刷画面 </div>こんなふうに準備しておくと、mode変数に代入するだけで画面を切り替える事ができます。
入力画面はExcelやGoogleスプレッドシートなどからコピペすれば
TAB区切りの文字列になる事を利用して、textareaタグをひとつ用意するだけです。印刷画面
index.html<div v-if="mode === 'print'"> <section class="sheet" v-for="page in pages" v-bind:class="{front: page.mode === 'front'}"> <div v-bind:id="'cardbox' + card.id" class="card" v-for="card in page.cards"><p v-bind:id="'card' + card.id" v-html="card.caption"></p></div> </section> </div>こんな感じで1ページごとにsectionというタグが生成されるようにしておきます。
あとはpaper.cssにまかせるだけでok!
こんなふうに、画面で見ると、1ページごとにプレビューが表示されているんですが
印刷すると、ちゃんと枠の中身が印刷されます。WEBサイトにUP
あとはFirebaseのHostingを使ってアップロードするだけ!
ごめんなさい。Firebaseを使ったのはここだけです^^できあがったもの
俺のフラッシュカード
- 投稿日:2019-12-11T18:25:57+09:00
jsのimportとrequireの違い
はじめに
jsで外部ファイルを読み込む際に、
importと書いてある場合とrequireと書いてある場合があります。この2つの違いがよくわからなかったので確認しました。
モジュールとは
importとrequireの違いを確認する前に、
前提知識となる「モジュール」について簡単に説明します。ある程度の規模のjsアプリを作ると、
1つの大きなjsファイルにすべてのコードを書くのではなく
機能ごとにjsファイルを分けて管理したくなります。そして、その機能ごとに分割したjsファイルを
メインとなるjsファイルで必要に応じて読み込んで利用するイメージです。この分割した機能ごとのjsファイルを「モジュール」と呼びます。
モジュールの読み込み方法
モジュールを読み込むための方法、仕様は
何種類かあり、
それぞれ書き方が違い、動く環境も違います。そのモジュール読み込みの仕様の主要なものとして、
ESM (ECMAScript Modules)と
CJS (CommonJS Modules)があります。
※ほかにもいくつかあるが、主要なのはこの2つ今回のテーマである
import
を使うのがESM方式で、
require
を使うのがCJS方式となります。それでは、
具体的にそれぞれの書き方の違いや動く環境の違いを説明していきます。importとrequireの違い
import(ESM)
概要
import(ESM)は、
ES6で決められたモジュール読み込みの仕様です。ES6の仕様ですので、
import文は
ChromeやFirefoxなどのブラウザでそのまま動かすことができますが、
IEでは動きません。ESのバージョンとかブラウザによって動いたり動かなかったりという話が分からない場合は
こちらの記事でご確認ください。
ES(ECMAScript)とは?jsがブラウザによって動いたり動かなかったりするのはなぜ?書き方
import文の書き方です。
モジュール側と読み込み側それぞれ書き方があります。モジュール側
モジュール側では、いつものように関数やクラスを定義して、
その頭にexport
を付けることで
import可能なモジュールとして定義できます。module.jsexport const helloWorld = function() { console.log('Hello World!!'); }読み込み側
読み込み側では、
import文を使って先ほどのモジュールを読み込みます。main.jsimport { helloWorld } from './module' helloWorld(); // 出力:Hello World!!require(CJS)
概要
require文は、CommonJSの仕様で、
Nodejsの環境で動作してくれる書き方です。Nodejs環境ということはつまり、サーバサイドでの実行ということになります。
多くの場合はブラウザ側でのjs実行になると思いますが、
ブラウザ側ではrequire文は動作しません。書き方
require文のモジュール側、読み込み側それぞれの書き方です。
モジュール側
モジュール側では
module.exports
と書いて、関数やクラスなどを定義します。module.jsmodule.exports = function() { console.log('Hello World!!'); }読み込み側
読み込み側では
require文を使って先ほどのモジュールを読み込みます。main.jsconst helloWorldModule = require('./module.js'); helloWorldModule(); // 出力:Hello World!!import、require両方を、どの環境でも動くようにするには?
上記の通り、
import文は
・Chromeなどでは動くがIEなどES6に対応していないブラウザでは動かない
require文は
・Nodejs(サーバサイド)では動くがブラウザ側実行のjsでは動かない
という動作環境の制限があります。ですが、環境関係なく
import文もrequire文も利用したいということがあると思います。どの環境でも動作するようにするためには、
webpackなどのモジュールバンドルツールを利用する方法があります。webpackというツールのイメージとしては、
・上記の書き方の例でいうmain.js
のような「読み込み側」のファイルを変換対象として指定する
・ツールを実行する
・ファイルに書かれているimport
やrequire
などの文を解析してくれる
・必要な外部ファイル(モジュール)を取ってきて全部1つのファイルとしてまとめて出力する
ということができるものです。webpackは、
importやrequireや今回言及していない別のモジュール構文にも対応しており
これらを読み取ってすべてモジュールを1つのファイルとしてまとめてくれます。(「バンドルする」という)そして、最終的にその1つにまとめられたjsファイルを
htmlで読み込むことで
必要なモジュールの機能がすべて利用できるようになります。今回webpackの具体的な使い方は説明しませんので
別の記事を参考にしてみてください。
Webpackってどんなもの?まとめ
■import
・ES6の仕様
・Chromeなどでは動くがIEなどES6に対応していないブラウザでは動かない
■require
・CommonJSの仕様
・Nodejs(サーバサイド)では動くがブラウザ側実行のjsでは動かないどの環境でも動作させるためには
webpackなどでモジュールバンドルして1つのファイルにまとめてから利用する。備考
Laravel Mix
今回モジュールバンドルツールとしてwebpackを例に挙げましたが、
このwebpackのラッパーツール(より便利に進化させたツール)のLaravel Mixをお勧めしたいです。
webpackの設定をシンプルに、簡単に記述できるツールです。
「Laravel」という名前がついていますがLaravelが関係ないフロントエンドのアプリでも利用可能です。
Laravel Mixとは?webpackをより便利に、簡単に。Laravel以外でも使えるよ。babel
import構文をbabelに通すとrequire文に変換されます。
importのままならChromeなどのブラウザでそのまま動いていたのに、
babelを通したらrequire文に変換されてブラウザ側では実行できなってしまう。
ということがないように気を付けましょう。babelで変換後、webpackに通してモジュールバンドルする必要があります。
babelについてわからない方はこちらを参考に。
polyfill、babelとは?jsをどのブラウザでも動くようにしてくれる。(IE対応)参考
https://www.wakuwakubank.com/posts/466-javascript-module-import-export/
https://matatsuna.hatenablog.com/entry/2017/12/03/150520
https://qiita.com/kamykn/items/45fb4690ace32216ca25
- 投稿日:2019-12-11T17:47:27+09:00
就活用ポートフォリオとしてWebサービス「Asobi」を作りました。
はじめに
こんにちは、ササクラ(@n_sasakura870)と申します。
SIerで働いておりましたが会社が倒産しました。今はWeb業界に転職するべく就活中です。今回は僕のポートフォリオを紹介するとともに、
- どういった技術を使用して開発したか
- どういった反省点、課題点が生まれたか
を解説できればと思います。
作ったもの
Asobi
様々なローカルルールや自分で考えた遊びを記録・共有するサービスです。
URL : http://www.asobi-app.com/
GitHub : https://github.com/sasakura870/asobi作った背景
友達とたまーにローカルでアナログな遊び(トランプ使ったゲームとかレクリエーションとか)をやることがあるんですが、結構面白くて盛り上がるんですよね。ただ、ふとやりたいなーと思ってググるとローカルルールって探してもあんまり出てこないことが多いんです。
なのでそういったローカルな遊びを記録・共有できるサービスがあれば、投稿して後で思い出せると考え、作ってみました。制作日数
GitHubへの最初のコミットからデプロイまでちょうど100日でした。
選定した技術
ここでは使用した技術の紹介と、めぼしい技術の選定理由を説明します。
バックエンド
- Ruby 2.6.3
- Ruby on Rails 6.0.0
- Webpacker 4.39.3
- ActiveStorage
- ActionText
- slim 4.0.1
- kaminari 1.1.1
- ActiveRecord-Import 1.0.3
- counter_culture 2.2.4
- RSpec 3.9
選定理由
ActionText
サービスの要件上、遊びのルール説明にリッチテキストエディタを使用したかったため。
これがまあ簡単に実装できて素晴らしいものだったので、またQiitaに記事を書こうと思います。ActiveRecord-Import
Rails6からバルクインサート機能が実装されたのですが、直接SQLを発行するものでIDのオートインクリメントやvalidation, callbackが効かなかったため。
公式リファレンスはこちらRails6の新しいバルクインサートメソッドに関してはこちらをご覧ください。
Rails6 のちょい足しな新機能を試す85(insert_all upsert_all編)フロントエンド
- Vue.js 2.6.10
- Vue Croppa 1.3.8
- FontAwesome 5.10.2
- sweetalert2 8.18.3
- Tippy.js 5.1.1
- selectize.js 0.12.6
最初CSSフレームワークにBootstrapを採用していましたが気に入ったデザインにならず、最終的にFLOCSSに基づいて自作しました。
自分でCSSを書いてみると、思った100倍楽しかったです。選定理由
Vue.js
jQueryを使用したことはあったのですが、DOMの操作がより簡単そうだったため。
Vue Croppa
ユーザーアイコンのトリミング機能に使用。Cropper.jsと悩みましたが、UIがこちらの方が好みだったのでこちらを採用しました。
公式リファレンスはこちらsweetalert2
アラート機能、トースト機能に使用。ポップで可愛いデザインがAsobiにぴったりだと思い採用しました。
カスタマイズ性が高く、Ajaxを絡めた実装も簡単でした。
公式リファレンスはこちらselectize.js
投稿画面のタグ入力フォームに使用。こちらもカスタマイズ性が高く使いやすかったです。
公式リファレンスはこちらインフラ
- AWS
- VPC
- EC2
- Route 53
- RDS
- PostgreSQL 11.5
- S3
- Nginx 1.16.1
- Unicorn 5.5.1
選定理由
一度HerokuでデプロイしたことがあったのでAWSに挑戦しました。
SSL化したかったのですがまだできていません…。ここはもっと学習しないといけないです。データベース設計
- ユーザーに関する
users
テーブル- ユーザーが投稿する遊びに関する
articles
テーブル- タグに関する
tags
テーブル- いいねに関する
favorites
テーブル- コメントに関する
comments
テーブル- ユーザー同士のフォローを実装する
relationships
テーブル- usersテーブル、articlesテーブルとtagsテーブルの中間テーブルである
tag_map
テーブルQiitaのようなユーザーがタグをフォローできる機能を実装するために、
tag_map
テーブルにポリモーフィック関連を実装しました。主な機能
機能やUIはQiitaを参考に設計しました。
機能の概要はGitHubのREADMEにも記載しているので、こちらではより技術的な部分に踏み込んだ説明をしていきます。ユーザー
登録
Railsチュートリアル第11章を参考に、登録後送られてくるメールのリンクから本登録が完了するシステムを採用しました。
仮登録状態では、一部のページへアクセスした場合に仮登録完了ページへリダイレクトすることで機能を制限しています。
また、仮登録完了ページにメール再送リンクを作成し、Ajaxで登録されたアドレスにメールを再送する処理を実装しています。ゲストログイン
登録せずとも一通りの機能を試してもらえるようにゲストログイン機能を実装しました。
ゲストログイン後は本登録ユーザーと同じように操作することができます。(退会とメールアドレス変更のみ禁止しています。)ユーザーアイコン
Vue.js
とVue Croppa
を使って画像をトリミングしてユーザーアイコンに設定する機能を実装しました。
画像作成後、適応するボタンを押すことでVue Croppaで作成された画像のBase64
形式のデータを送信し、Rails側がそのデータをエンコードしてActiveStorage
に保存する処理が走ります。永続ログイン
ログイン時、またはユーザー設定のアカウント画面から「ブラウザを閉じた後もログイン状態を保持するか」を設定できます。
こちらはRailsチュートリアル第9章を参考に実装しました。投稿
タグ入力にselectize.js
を使用しています。編集時には遊びに関連付いているタグを取得し、フォームに初期値として入れるように実装しています。
本文入力フォームにはActionText
を使用しています。自動生成されるactiontext.scss
のCSSが入力フォームと本文の両方に適応されるのがいいですね。いいねとフォロー
いいねボタンとフォローボタンはVue.js
でコンポーネント化し、Ajaxで処理をするように実装しました。Asobiガチャ
他のWebサービスにはない機能として、「Asobiガチャ」機能を実装しました。
ボタンを押すとAjaxでRails側でランダムに遊びを1つ取得し、そのデータをjson形式でフロントに渡してJavascriptで表示しています。
使いやすさを向上させるため、引いた後に「もう一度引く」ボタンで再度ガチャが引けるようになっています。反省点
頑張らないと投稿出来ないような雰囲気のサービスにしてしまった
遊びのルールを記録・共有するサービスのため、しっかりとした記事が書けるようにリッチテキストエディタを採用しました。
そのためぱっと見で投稿するハードルの高いWebサービスになってしまいました。
もう少し投稿する心理的なハードルが下がるように工夫をしたいと考えています。ドメイン駆動設計に憧れてService層とか作っちゃった
作成途中に
ドメイン駆動開発(DDD)
を知り、「なにこれすげー!」と手を出したのが失敗でした。
Service層を作りながら「どこまでがこのServiceクラスの責務なんだ…?」と悩むタネを増やしてしまい、完成に余計な時間がかかりました。
最終的にHandlerクラス
というものまで作成し、こういった処理の流れになりました。
Controller
は自分のクラス、アクションに合ったHandlerクラス
を呼び出し、Handlerクラス
はServiceクラス
を呼び出しています。
Serviceクラス
は「ログインする」「タグ入力フォームに記載されたタグを取得または作成する」「いいねする」といった最小単位の処理のみ実行することで、様々なHandlerクラス
から再利用できる仕組みです。
…が、振り返れば単純なCRUD
しかしないアプリにこんな大層なアーキテクチャを採用する必要はなかったと感じています。テストをほぼ書いていない
先ほどの
ドメイン駆動開発(DDD)
で各処理を切り分けておきながら、テストを書いていません。何のためのDDDなんだ…
GitHubには制作初期に書いたRSpecのテスト(ほぼ通らない)が置いてあります。DockerとかCircleCIとか触ってみたかった
これは制作中に存在を知ったので手を出しませんでした。次のポートフォリオで採用してみようと思います。
今後の課題点
HTTPS化
AWSの
ELB
やCloudFront
を使ってサイトのHTTPS化を行いたいです(挫折済み)。レスポンシブ対応
サービスの要件上、モバイルからのアクセスの方が多そうなので(実際アクセスされるかは別として)レスポンシブ対応したいです。
入力フォームのエラーメッセージの表示
現状だと新規登録や投稿画面の入力フォームのエラーメッセージが画面上部に表示される仕様です。
これもsubmitの前に、validationチェックし、エラーがあれば入力フォームの近くにそれぞれのエラーメッセージを表示するようにしたいです。ソーシャルログイン機能
Twitterログインを実装してもっと利用しやすい仕組みにしたいと考えています。
作ってみた感想
自分で考えたWebサービスを形にするということは予想以上に大変でした。
Railsチュートリアルと違って道筋がない(当たり前ですが)ので、「この機能で本当にいいのか?」「このUIでいいのか?」「この処理はどこに書けばいいんだ?」と常に迷子になりながらコーディングしていました。その分デプロイできた時は脳汁出まくりで気持ちよかったです。振り返れば大変だった以上に楽しかったです。エラー出しまくりながらやりたいことが実装できた時の感動は他では味わえないです。
このWebサービスを最後まで作りきることが出来たのも、ひとえにQiitaやSlackやもくもく会でアドバイスを下さった皆様のおかげです。本当にありがとうございます。今後もこのWebサービスを改修しながら別のWebサービスも作りつつ、就活に励む所存です。
あとがき
名古屋でもくもく会を開催するのでよかったら参加してください。
https://connpass.com/event/158832/
- 投稿日:2019-12-11T15:25:33+09:00
Vue.js好きの人たちにVuexをおすすめするための実装例
はじめに
vue.jsを使ったコンポーネント設計はとても快適ですね。規模が大きくなるにつれ肥大化するJavaScirpt、HTML、CSSの見通しが格段によくなります。
その半面、深く入れ子になったコンポーネント間のデータの受け渡しのコードも肥大化して、いわゆる「バケツリレー」に疲弊します。
Vuexにより、データの保管場所の特定やアクセス関数を統一することで、データフローがコンポーネントから独立され、コードの見通し、メンテナンス性が格段に向上します。
この記事は、Vuexの実装例を見ることでVuexの効果を伝えることが目的です。Vuexとは?...からちゃんと理解したい人は、 Vuex公式サイトへ。
「Vuex は Vue.js アプリケーションのための 状態管理パターン + ライブラリです。 ....」
Qiita投稿一覧を Vue.js + Vuex (+Nuxt) で作ってみた
以下の画面は、QiitaAPIを使って、投稿一覧を表示するウェブサイトの画面です。
ここでの留意点は
・Qiita投稿は、AxiosでQiitaAPIから取得する。
・タブで他のページに遷移後、このページに戻ったときに、画面の再構築(QiitaAPI呼び出し)はしたくない。
・そのため、投稿一覧をClient側のJavaScriptの変数として保持しておかなければならない。そこで、Vuexをつかった以下のコードをつくりました。
qiita投稿一覧のためのstoreコード
store/qiita.js
store/qiita.js// ---------------------------------------------------------------------------- // State // ---------------------------------------------------------------------------- export const state = () => ({ items: [], // 投稿の配列 }) // ---------------------------------------------------------------------------- // Mutation // ---------------------------------------------------------------------------- export const mutations = { // 投稿を一括登録 setItems (state, {items}) { state.items = items }, // 投稿配列をクリアー clearItems(state) { state.items = [] } } // ---------------------------------------------------------------------------- // Getter // ---------------------------------------------------------------------------- export const getters = { // 投稿記事の配列 items(state) { return state.items }, // itemsが空のとき trueを返す is_empty_items(state) { return (state.items.length < 1) } } // ---------------------------------------------------------------------------- // Actions // ---------------------------------------------------------------------------- import axios from 'axios' export const actions = { // Qiita API呼び出し async fetchItems({commit, getters}) { console.log(" --- getters.is_empty_items items[]が空なら true -> ", getters.is_empty_items) if(getters.is_empty_items) { // axiosで Qiita APIの呼び出し // async/awaitにより非同期処理 // axios.$get(.....).then(res => ....)と書かなくてもよい const items = await this.$axios.$get('/items?query=tag:nuxt.js') // nuxt.config.jsにて baseURL設定 //const items = await this.$axios.$get('https://qiita.com/api/v2/items?query=tag:nuxt.js') commit('setItems', {items}) console.log(" --- items の commit 完了 size -> ", items.length) } } }このコードは、以下のstoreの定義にしたがっています。
storeのデータフロー図
以下はVuex公式サイトにあるデータフロー図です。
state
- データの保管場所。直接参照可
mutations
- stateの更新はここでおこなう。
getters
- gettersに登録された関数の結果はキャッシュされ、その関数が依存しているstateの値が変更されたときにのみ再評価される
actions
- 非同期にstateの更新をおこなうときは、ここからmutationを呼び出す
store/qiita.jsのデータフロー図
上記の定義を store/qiita.jsと重ねると以下のデータフローになります。
Actions
- fatchItems 関数 getterである is_empty_itemsで配列が空であることを確認。空でなければ何もしない axios.get により QiitaAPIの呼び出し。async/awaitはここで使う。 得られた投稿一覧をmutation setItemsに渡す。
Mutations
setItems 関数
投稿をstoreに格納するclearItems 関数
投稿配列を空にするGetters
items 関数
投稿一覧を得るis_empty_items 関数
投稿配列のstoreが空のときtureを返すQiita投稿一覧画面のコンポーネント
上記の store/qiita.js を使用して、"Nuxt.js"のダグがついたQiita投稿を表示するコンポーネントのコードです。
...mapActions(,)
の先頭の...
は スプレッド演算子です。ネット上のVuexの記事上でとてもよく使われているので、この際覚えましょう。
(ヒントはfMap = {a: ()=>{..}, b: ()=>{..}}
だと...fMap
は何を返す?)pages/qiita/index.vue<template> <v-content> <v-container> <v-layout row justify-center> <h2>Nuxt.js のタグが付けられた投稿の一覧</h2> <v-btn class="ml-5" @click="fetchItems">更新</v-btn> <v-btn @click="clearItems">消去</v-btn> </v-layout> <v-layout wrap> <v-row> <v-col cols="12"> <ul> <li v-for="item in items" :key="item.id"> <h3> <span>{{item.title}}</span> <small> by {{item.user.id}}</small> </h3> <h4> <div>{{item.body.slice(0, 130)}}.....</div> <p><a :href="item.url">{{item.url}}</a></p> </h4> </li> </ul> </v-col> </v-row> </v-layout> </v-container> </v-content> </template> <script> import {mapGetters, mapActions, mapMutations} from "vuex" export default { layout: 'vuetify', // Layoutファイルの指定 head() { return { title: "Qiita" } }, // componetの生成前に呼び出す fetch({store}) { store.dispatch('qiita/fetchItems') }, computed: { ...mapGetters('qiita', ['items']) // 以下と同じ //items: { // get() { return this.$store.state['qiita'].items } //}, }, methods: { ...mapActions('qiita', ['fetchItems']), // 以下と同じ //fetchItems() { this.$store.dispatch('qiita/fetchItems') }, ...mapMutations('qiita', ['clearItems']), // 以下と同じ //clearItems() { this.$store.commit('qiita/clearItems') } } } </script>結局、Vuexの何がうれしいの?
- バケツリレーの撤廃
- Vue.jsでは、共通データをコンポーネントに受け渡す場合、親から子へはhtmlの属性でわたして、Propsで受け取ります。子から親へはイベントの引数として渡します。極端なたとえですが、Aの子がB、Bの子がC..と延々にZまで子孫がいたとします。 ここでもし、AとZ間のみ共通で使いたいデータがあるとすると、BからYは自分に必要のないデータを自分の親または子に渡していかなければなりません。このことをVue.jsのバケツリレーといわれています。 Vuexは共通データをどこからでも更新、参照する共通関数群を規定にしたがって定義します。この規定にしたがってが重要な部分です。あとから誰が見てもわかりやすくなります。
- グローバル変数定義の局所化、定形化
- SPAでは、一度サーバから得たデータは極力キャッシュして、通信負荷を削減したい。という動機から無秩序にグローバル変数を使ってしまうことがあります。 Vuexはグローバル定義を規則化することで、共同開発におけるコードの定形化がはかれます。
- WebAPI呼び出しの局所化、定形化
- WebAPI呼び出しをActionとして局所化、定形化することは、メンテナンス性がとても向上することでしょう。そして非同期処理はほぼ、Action内にまとめられることも、とても見通しの良いコードになるでしょう。リアクティブなSPAにおいては、とてもありがたいことです。
- なによりデータフロー設計ファーストになる
- 多くのコンポーネントを作ることは、副作用としてコンポーネント間でのデータの重複や隠蔽がうまれやすくなります。Vuexを習得すれば、開発の初動時にデータフロー設計(store定義)をおこなうこで、それらの問題が解決することはすぐに実感できるはずです。
ところでNuxt.jsはおすすめです
- この投稿に使っているコードはNuxt.jsで稼働しているものです。(nuxt上でないと動かないかも)
- Nuxt.jsをひとことで言うと、Vue.js にVuex(+その他)を組み込んだフレームワークです。
- Vue.jsやVuexの初期定義などの定型的かつややこしい部分をごっそり隠してくれています。
- そもそもVue.jsは、JavaScriptでWEBを作るためのフレームワークなので、Nuxt.jsはフレームワークのフレームワークです。
- SSR(サーバーサイドレンダリング)やPWA(Progressive Web Apps)用のフレームワークとて着目されていますが、従来のvue.jsでの開発にも十分な恩恵を感じられることは、多くの識者がWEB上で語っています。
- また、Railsの思想であるCoC(Convension over Configuration 設定より規定)をとても感じます。(他にもRailsに似てるところがたくさんある)
- Vue.jsをある程度習得した開発者でVuexの必要性を感じるひとは、まずはnuxt.jsに踏み込むほうが、遠回りのようで、近道ではないかと思います。(既存のVue.jsプロジェクトをNuxt化するのは容易だとおもいます)
それでは、引き続き、すてきなVue好きライフをお送りください。
- 投稿日:2019-12-11T13:53:45+09:00
NuxtをCloudRunにデプロイする。
この記事は Nuxt.js Advent Calendar 2019 7日目の記事です。
今回のコード:https://github.com/yujiteshima/cloudrun-test
NuxtをGoogleCloudRunにデプロイする。
NuxtをCloudRunにデプロイする方法について嵌った部分を中心に説明します。
今回やる事
Nuxt.jsの
create-nuxt-app
してだけの状態のアプリををGCPのCloudRunにデプロイしてみるというものです。Cloud Runとは
私のざっくりとした理解です。理解がまだまだ不足している分はどんどん色んなサービスを使いながら理解していきたいと思います。
Cloud Runは自分で作ったコンテナを、GCPのサーバ環境上で動かせるサービスです。
誰もアクセスしていない時は課金されず、誰かがアクセスしてきた時に立ち上がり、立ち上がっている時間だけが課金対象となるサービスです。多くのアクセスがこれば自動でスケールアップして、少なくなれば自動でスケールダウンしてくれるというスケーリングも面倒をみてくれます。
CloudFunctionとの一番の違いはコンテナ上で動かすので、ランタイムが制限されていない事のように思います。
GKEとの違いは、kubernetesのアップデートの管理をCloudRunでは追わなく
良いということ。GKEの方が面倒をみなくてならない事が多い分自由度が高いという感じがします。このFunctionsとGKEの間の性格を持っている為、コンテナで動かしたいけどGKEを使うほどではない、もっと簡単に面倒をみる事が少ないように使いたいという時にCloudRunの存在価値があるのかなと思いました。
Cloud Runを使う準備
GCPのサインアップをされてない方はサインアップして下さい。
請求先(クレジットカードの情報)が必要です。
初めて利用される方は$300の無料クレジットがつくはずです。Cloud SDK
- Cloud SDKをインストールしていない方はインストールして下さい。 https://cloud.google.com/sdk/install?hl=JA
CloudSDKをアップデートしておきます。
Beta版コンポーネントのインストールもしておきます。$ gcloud components update $ gcloud components install betaGCPのコンソールでプロジェクトを作リます。
作成はプロジェクトの作成から作成します。
好きな名前をつけて下さい。よくある重複している名前は勝手に番号を末尾につけてくれます、このプロジェクトの名前は後で何回か使います。
プロジェクトの請求を有効にしておく必要があります。
メニューのお支払いから設定して下さい。
CloudBuildを有効化しておく
クラウドビルドを使うので有効化しておきます。
CloudRunを有効化しておく
Nuxtのプロジェクト作成
はじめからExpress入れて作成します。
Functions等でExpressのミドルウエアとしてNuxtを動かして、SSRのデプロイをした事がある方は馴染みある形だと思いますが、Chose custom server frameworkでExpress選んでおけば、よしなにやってくれます。$ npx create-nuxt-app sample-app
create-nuxt-app v2.11.1 ✨ Generating Nuxt.js project in sample-app ? Project name sample-app ? Project description My exquisite Nuxt.js project ? Author name Yuji Teshima ? Choose the package manager Yarn ? Choose UI framework Bulma ? Choose custom server framework Express ? Choose Nuxt.js modules Axios, Progressive Web App (PWA) Support ? Choose linting tools ESLint, Prettier, Lint staged files ? Choose test framework Jest ? Choose rendering mode Universal (SSR) ? Choose development tools (Press <space> to select, <a> to toggle all, <i> to invert selection) yarn run v1.19.0portの変更
動作確認をしてみます。
$ yarn run dev
するとターミナルにリッスンしているアプリケーションポートが表示されます。
$ READY Server listening on http://localhost:3000
クラウドの実行には、
process.env.PORT
を設定しておく必要があります。
またlocalhost
や127.0.0.1
では無くて0.0.0.0
をホストにしておかなくてはならないので、その設定を書き加えていきます。nuxt.config.jsにserverの設定を書き加えます。
nuxt.config.jsserver: { port: process.env.PORT || 3000, host: '0.0.0.0', timing: false }Dockerfileを作成する
From node:latest WORKDIR /src COPY . . Run yarn install \ --prefer-offline \ --frozen-lockfile\ --non-interractive \ --production=false Run yarn build CMD [ "yarn", "start" ]クラウドビルドの設定をyamlで書く
cloud-build.ymlsteps: - name: gcr.io/cloud-builders/docker args: [ "build", "-f", "Dockerfile", "-t", "gcr.io/gcpコンソールで先程作ったプロジェクトName/コンテナの名前(好きな名前)", ".", ] images: - gcr.io/gcpコンソールで先程作ったプロジェクトName/コンテナの名前(好きな名前)Google Cloud Buildでビルドする
gcloud builds submit --project "gcpコンソールで先程作ったプロジェクトName" --config=./cloud-build.yamlデプロイする
gcloud beta run deploy cloud-run-name --region us-central1 --project "gcpコンソールで先程作ったプロジェクトName" --image gcr.io/gcpコンソールで先程作ったプロジェクトName/コンテナの名前(好きな名前)リージョンはBeta版の時はus-central1に制限されていましたが、2019 年 11 月 14 日にGAになった際に、
asia-northeast1(東京)
も追加されました。デプロイ時にオプションで渡していた値は事前に登録しておく事も出来ます。
例$ gcloud config set project "gcpコンソールで先程作ったプロジェクトName" $ gcloud config set run/region asia-northeast1成功すると、ページのアドレスがターミナルに表示されます。
Service [sample-app] revision [sample-app-00001-puw] has been deployed and is serving 100 percent of traffic at https://sample-app-<生成された値>-uc.a.run.app現時点で表示されたアドレスにリクエストしても、エラーが表示されます。
公開設定の変更
このままだとデプロイしたものを誰でも見れるようにはなっていません。
見れるようにするには、公開設定を変えなくてはなりません。
自身が利用するAPIであればそれに応じた公開設定をする必要があります。
今回は誰でも見れるようにAllUserに変更してデプロイ出来ているか確認しておきます。これで、表示されるはずです。
今回は初期画面が表示されるだけですが、きちんと表示できれば成功です。
まとめ
Docker使い初めの、私のような初級者には、自分が作ったコンテナが実際GCPのサーバ上で動いているというだけで、少し感動しました。
どんどん使っていきたいです。あらためて、使ってみて初めて理解できるという事あると思いました。
- 投稿日:2019-12-11T13:01:27+09:00
Vue.jsのWebアプリケーションで、なぜSEO対策にNuxt.jsは必要なのか。SEOのためにNuxt.jsのVue-metaを導入方法を紹介します。
はじめに
この記事はLinkbal (リンクバル) Advent Calendar 2019の11日目の記事です。Vueの初心者のタンです。
今回は、弊社のプロダクションWebサイトにVueが導入されていますので。Vueに関する見識を最初から学んでいます。
ECサイトプロダクトを提供する弊社はSEO対策に特に気にしないといけないですから、今日は、SEO対策のためにVueのWebアプリケーションにNuxt.jsが必要になことを話したいと思います。Vue.jsのWebアプリケーションでSEO対策が必要になる理由は?
普通のVue.jsでは、シングルページアプリケーション(Single Page Application : SPA)を簡単に作成しています。これは、もともと空のIndex.htmlというファイルが一つしかなくて、純粋にJavaScriptで生成されたアプリケーションです。
Webのコンテンツは JavaScriptが最初にロードされた後、サーバサイドから取り出されて、Index.htmlに書き込まれす。Webのルートの切り替えもJavaScriptもが処理します。ほとんど、Webサイトランキングを失う理由はJavaScriptの不適切な処理のせいです。 実際に、SEOに関してフロントエンドフレームワークのようなVue.jsフレームワークには多くの問題があります。いくつかの以下問題です。
- 単一ページアプリケーション(SPA)フレームワークです。
- ページのロードスピードが遅い
- メタ、カノニカル、およびサイトマップを更新するのが難しい。
Nuxt.jsとは何でしょうか?
Nuxtは、最新のWebアプリケーションを作成するためのVue.jsに基づくプログレッシブフレームワークです。
Vue.js 2.0に加え、Vue-Router、Vue-Meta、Vuex(ストアオプションを使うときのみ)というライブラリをNuxt.jsにインクルードしています。Nuxt.jsの主なメリットはWebアプリケーションの非同期データ、ミドルウェア、ルーティングなどを管理することです。詳しくように https://nuxtjs.org/ を参考できます。SEO対策になんでNuxt.jsは必要なのか??
Nuxt.jsを採用すると、ユニバーサルアプリケーションを簡単に作成できます。
ユニバーサルアプリケーションは、Webサーバーにサイトコンテンツのデータをプリロードし、レンダリングされたHTMLをレスポンスとしてブラウザーにを返しすということです。
なので、SEOを改善し、ロードを高速化できるし、他の様々な利点を提供されます。
ユニバーサルアプリケーションでは、JavaScriptが読み込まれる前に、<head>
や<title>
、<meta>
、<h1>
、などのようなHTMLタグが事前にロードされて、コンテンツがページに表示されます。これらのタグにより、クローラー(Googlebotなど)が正しくページの内容を評価できているよになります。
VueのWebアプリケーションのすべてページをハンドルするNuxt.jsの方法
Nuxt.jsのVue-metaというライブラリは、Webアプリケーションの各ページの
<head>
要素を処理します。 ページはルートを表すNuxtの用語であり、各ページはページフォルダー内にあります。Nuxtでは、アプリケーションのページ内で
<head>
プロパティを定義する3つの方法を提供しています。1. Vueファイルの全てページにデフォルトのメタを定義できます。
nuxt.config.js
ファイル内にメタ情報を定義することは欠かせないです。nuxt.config.jsexport default { head: { titleTemplate: '%s - Nuxt.js', meta: [ { charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' } { hid: 'description', name: 'description', content: 'Meta description' } ] } }
<head>
に設定できるオプション一覧は vue-meta のドキュメント を参照できます。2. Vueファイルの静的ページにもメタ情報を定義できます。
現在のページの HTMLの
<head>
タグを定義するためにhead()
メソッドを使う必要です。
ページのデータを使って独自のメタタグを定義することもできます。head()
メソッド内でthis
変数を使ってデータを取り出すことができます。pages/index.vue<template> <h1>{{ title }}</h1> </template> <script> export default { data () { return { title: 'Hello World!' } }, head () { return { title: this.title, meta: [ { hid: 'description', name: 'description', content: 'My custom description' } ] } } } </script>3. 動的ページのメタタグの設定
動的ページ(アクセスした時の状況に応じて異なる内容が表示されるWebページ)のメタタグを適当にカスタマイズできます。 ユーザープロファイルページはの一つの例です。
パラメータを使って動的なルーティングを定義するには .vue ファイル名またはディレクトリ名に アンダースコアのプレフィックス を付ける必要があります。
pages/ --| users/ -----| _id.vue自動的に以下が生成されます:
router.jsrouter: { routes: [{ name: 'users-id', path: '/users/:id?', component: 'pages/users/_id.vue' }] }静的ページのように動的ページで、
head()
メソッド内でthis
変数を使ってデータを取り出して、ページのメタタグを定義することもできます。
そのユーザープロフィールページのメタタグを以下のように定義します。pages/users/_id.vue<script> head () { let user = this.user; return { title: `${user.fullName} @(${user.userName}) - Nuxt.js`, meta: [{ hid: `iOSUrl`, property: 'al:ios:url', content: `myapp://user?screen_name=${user.userName}` }, { hid: `description`, name: 'description', content: `${user.fullName}'s public profile at Nuxt.js` }] } } </script>小さい注意点は
hid
のプロパティです:Vue-metaを使用すると、元のタグを置き換えるのではなく、重複するタグが作成されます。 ただし、Webサイトをクロールするときにタグが重複しているとSEOルールに違反する可能性があるため、各メタタグに一意のhidプロパティを常に設定して、一意に識別することをお勧めします。 hidプロパティがあると、vue-metaがタグを複製する代わりにタグを置き換えるようにわかります。
hid
プロパティやメタタグが重複
などについてもっと詳しくように、こちらで参考できます。終わりに
Nuxtでは、ユニバーサルアプリケーションで
head
要素をレンダリングする方法を多く制御できます。これはSEO対策にに役立ちます。 nuxt.config.jsファイル内にグローバルデフォルトを定義するための多くのオプションがあり、さらに各ページのheadメソッドにアクセスして、カスタマイズすることができます。上記はSEO対策にNuxt.jsのいくつかの利点を学んだ知識です。私のVueの初心者のような方を助けると望みます。
参考した内容
- 投稿日:2019-12-11T13:01:27+09:00
SEOのためにNuxt.jsのVue-metaを導入方法について。
はじめに
この記事はLinkbal (リンクバル) Advent Calendar 2019の11日目の記事です。Vueの初心者のタンです。
今回は、弊社のプロダクションWebサイトにVueが導入されていますので。Vueに関する見識を最初から学んでいます。
ECサイトプロダクトを提供する弊社はSEO対策に特に気にしないといけないですから、今日は、SEO対策のためにVueのWebアプリケーションにNuxt.jsが必要になることを話したいと思います。Vue.jsのWebアプリケーションでSEO対策が必要になる理由は?
普通のVue.jsでは、シングルページアプリケーション(Single Page Application : SPA)を簡単に作成しています。これは、もともと空のIndex.htmlというファイルが一つしかなくて、純粋にJavaScriptで生成されたアプリケーションです。
Webのコンテンツは JavaScriptが最初にロードされた後、サーバサイドから取り出されて、Index.htmlに書き込まれす。Webのルートの切り替えもJavaScriptもが処理します。ほとんど、Webサイトランキングを失う理由はJavaScriptの不適切な処理のせいです。 実際に、SEOに関してフロントエンドフレームワークのようなVue.jsフレームワークには多くの問題があります。いくつかの以下問題です。
- 単一ページアプリケーション(SPA)フレームワークです。
- ページのロードスピードが遅い
- メタ、カノニカル、およびサイトマップを更新するのが難しい。
Nuxt.jsとは何でしょうか?
Nuxtは、最新のWebアプリケーションを作成するためのVue.jsに基づくプログレッシブフレームワークです。
Vue.js 2.0に加え、Vue-Router、Vue-Meta、Vuex(ストアオプションを使うときのみ)というライブラリをNuxt.jsにインクルードしています。Nuxt.jsの主なメリットはWebアプリケーションの非同期データ、ミドルウェア、ルーティングなどを管理することです。詳しくように https://nuxtjs.org/ を参考できます。SEO対策になんでNuxt.jsは必要なのか??
Nuxt.jsを採用すると、ユニバーサルアプリケーションを簡単に作成できます。
ユニバーサルアプリケーションは、Webサーバーにサイトコンテンツのデータをプリロードし、レンダリングされたHTMLをレスポンスとしてブラウザーにを返しすということです。
なので、SEOを改善し、ロードを高速化できるし、他の様々な利点を提供されます。
ユニバーサルアプリケーションでは、JavaScriptが読み込まれる前に、<head>
や<title>
、<meta>
、<h1>
、などのようなHTMLタグが事前にロードされて、コンテンツがページに表示されます。これらのタグにより、クローラー(Googlebotなど)が正しくページの内容を評価できているよになります。
VueのWebアプリケーションのすべてページをハンドルするNuxt.jsの方法
Nuxt.jsのVue-metaというライブラリは、Webアプリケーションの各ページの
<head>
要素を処理します。 ページはルートを表すNuxtの用語であり、各ページはページフォルダー内にあります。Nuxtでは、アプリケーションのページ内で
<head>
プロパティを定義する3つの方法を提供しています。1. Vueファイルの全てページにデフォルトのメタを定義できます。
nuxt.config.js
ファイル内にメタ情報を定義することは欠かせないです。nuxt.config.jsexport default { head: { titleTemplate: '%s - Nuxt.js', meta: [ { charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' } { hid: 'description', name: 'description', content: 'Meta description' } ] } }
<head>
に設定できるオプション一覧は vue-meta のドキュメント を参照できます。2. Vueファイルの静的ページにもメタ情報を定義できます。
現在のページの HTMLの
<head>
タグを定義するためにhead()
メソッドを使う必要です。
ページのデータを使って独自のメタタグを定義することもできます。head()
メソッド内でthis
変数を使ってデータを取り出すことができます。pages/index.vue<template> <h1>{{ title }}</h1> </template> <script> export default { data () { return { title: 'Hello World!' } }, head () { return { title: this.title, meta: [ { hid: 'description', name: 'description', content: 'My custom description' } ] } } } </script>3. 動的ページのメタタグの設定
動的ページ(アクセスした時の状況に応じて異なる内容が表示されるWebページ)のメタタグを適当にカスタマイズできます。 ユーザープロファイルページはの一つの例です。
パラメータを使って動的なルーティングを定義するには .vue ファイル名またはディレクトリ名に アンダースコアのプレフィックス を付ける必要があります。
pages/ --| users/ -----| _id.vue自動的に以下が生成されます:
router.jsrouter: { routes: [{ name: 'users-id', path: '/users/:id?', component: 'pages/users/_id.vue' }] }静的ページのように動的ページで、
head()
メソッド内でthis
変数を使ってデータを取り出して、ページのメタタグを定義することもできます。
そのユーザープロフィールページのメタタグを以下のように定義します。pages/users/_id.vue<script> head () { let user = this.user; return { title: `${user.fullName} @(${user.userName}) - Nuxt.js`, meta: [{ hid: `iOSUrl`, property: 'al:ios:url', content: `myapp://user?screen_name=${user.userName}` }, { hid: `description`, name: 'description', content: `${user.fullName}'s public profile at Nuxt.js` }] } } </script>小さい注意点は
hid
のプロパティです:Vue-metaを使用すると、元のタグを置き換えるのではなく、重複するタグが作成されます。 ただし、Webサイトをクロールするときにタグが重複しているとSEOルールに違反する可能性があるため、各メタタグに一意のhidプロパティを常に設定して、一意に識別することをお勧めします。 hidプロパティがあると、vue-metaがタグを複製する代わりにタグを置き換えるようにわかります。
hid
プロパティやメタタグが重複
などについてもっと詳しくように、こちらで参考できます。終わりに
Nuxtでは、ユニバーサルアプリケーションで
head
要素をレンダリングする方法を多く制御できます。これはSEO対策にに役立ちます。 nuxt.config.jsファイル内にグローバルデフォルトを定義するための多くのオプションがあり、さらに各ページのheadメソッドにアクセスして、カスタマイズすることができます。上記はSEO対策にNuxt.jsのいくつかの利点を学んだ知識です。私のVueの初心者のような方を助けると望みます。
参考した内容
- 投稿日:2019-12-11T11:47:57+09:00
VueCLIとFirebaseを使って、原稿用紙の下書きができて締め切りを通知してくれるアプリをつくってみた
この記事はNorth Detail Advent Calendar 201917日目の記事です
Vue.jsとFirebaseを利用してPWAでPush通知がくるメモアプリを作成してみました。
ざっくり実装したことなどをご紹介しますm(_ _)m※説明のためにいくつかコードを載せていますが、実際に作成したアプリのコード(Github)とは似て異なります。概要
学生時代に手書きレポートの下書きをPCで打ち込んでいたのですが、実際にマス目に当てはめると段落の切り替えや、数字を1マスに2つ入れる...etcなどのルールがあったりして、文字数=マス目の数にならないので、PCで下書きをして文字数を原稿用紙にあわせても、結局文章の長さが多すぎたり、逆に足りなかったりすることが多々ありました。
ということで、打ち込んだ文章を原稿用紙に当てはめたようにして表示するアプリを作成してみました。作ったもの
- 入力した文章が原稿用紙に収まるかどうかを、実際にマス目に納めてカウントします。
- ログインするとカウントした結果を保存することができます。
- 締切日を設定すると締め切り前日と当日にPush通知が届きます。(8:00と17:00に通知)
文字数オーバーした箇所は赤いマスで表示されます。
「段落の頭を1マス開ける」、「括弧を他の文字と同じますに入れる」など、実際の原稿用紙のルールに則った書式設定ができるので、打った文章が原稿用紙何枚分に収まるのかを確認することができます。実装した機能と、実装内容の概要
文字のカウント
- 入力した本文と、原稿用紙の書式設定をVuex Storeに格納
- Vuexのアクションで入力した文字を配列に分割し、(配列1つに原稿用紙ひとます分の文字を格納)Vuex Storeに格納
- 配列に格納された文字を原稿用紙風に表示
カウントした文章の保存
入力した文章と、原稿用紙の書式設定をFireStoreにて管理。
通知処理
- 原稿用紙風に表示する画面で、「通知する」チェックボックスを押した時に通知用のトークンを取得
- 保存時に、通知用のトークンを保存
- FirebaseFunctionsでpush通知を送信する関数を作成
- 外部のCronを利用して、毎日8:00と17:00にFirebaseFunctionsで作成したpush通知を送信する関数を実行
実装の手順
大まかにですが、実装の手順について紹介しようと思います。
1. プロジェクトの作成
yarnがインストールされている前提です。
1-1. Vue Cliを使ってプロジェクト作成
// vueCLIをインストール yarn global add @vue/cli // 新規プロジェクト作成 vue create <プロジェクト名>上記コマンドでプロジェクトを作成。
デフォルトのプリセットを使うか、手動で選択するかを聞かれるので
Manually select features
を選択。? Please pick a preset: default (babel, eslint) ❯ Manually select features続く設問も下記のように選択。
? Check the features needed for your project: ❯ ◉ Babel ◯ TypeScript ❯ ◉ Progressive Web App (PWA) Support ❯ ◉ Router ❯ ◉ Vuex ❯ ◉ CSS Pre-processors ❯ ◉ Linter / Formatter ◯ Unit Testing ◯ E2E Testing ? Use history mode for router? (Requires proper server setup for index fallback in production) ❯ Yes ? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default) ❯ Scss ? Pick a linter / formatter config ❯ ESLint with error prevention only ? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection) ❯ Lint on save ? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? ❯ In dedicated config filesこれでVueプロジェクトの雛形が生成されるので、下記コマンドを実行するとアプリを立ち上げることができます。
// プロジェクトフォルダに移動 cd <プロジェクト名> // ローカルで実行 yarn run serve1-2. Firebase導入
- Firebaseコンソールにアクセスし、Firebaseのアカウントを作成
- Firebaseコンソール「新規プロジェクト作成」から新規プロジェクトを追加する。
- Firebaseをプロジェクトにインストール
//Firebaseインストール $ yarn global add firebase-tools // Googleアカウントでログイン $ firebase login // Firebaseの設定 $ firebase init ? Which Firebase CLI features do you want to setup for this folder? Press Space to select features, then Enter to confirm your choices. ◯ Database: Deploy Firebase Realtime Database Rules ◉ Firestore: Deploy rules and create indexes for Firestore ◉ Functions: Configure and deploy Cloud Functions ◉ Hosting: Configure and deploy Firebase Hosting sites ◯ Storage: Deploy Cloud Storage security rules ? Select a default Firebase project for this directory: (Use arrow keys) ❯ document-paper-counter-81e10 (document-paper-counter) // コンソールで作成したプロジェクト選択FireStoreの設定はデフォルトのまま
=== Firestore Setup Firestore Security Rules allow you to define how and when to allow requests. You can keep these rules in your project directory and publish them with firebase deploy. ? What file should be used for Firestore Rules? firestore.rules Firestore indexes allow you to perform complex queries while maintaining performance that scales with the size of the result set. You can keep index definitions in your project directory and publish them with firebase deploy. ? What file should be used for Firestore indexes? firestore.indexes.json === Functions Setup A functions directory will be created in your project with a Node.js package pre-configured. Functions can be deployed with firebase deploy. ? What language would you like to use to write Cloud Functions? JavaScript ? Do you want to use ESLint to catch probable bugs and enforce style? No ✔ Wrote functions/package.json ✔ Wrote functions/index.js ✔ Wrote functions/.gitignore ? Do you want to install dependencies with npm now? YesHostingの設定は下記のように設定
=== Hosting Setup Your public directory is the folder (relative to your project directory) that will contain Hosting assets to be uploaded with firebase deploy. If you have a build process for your assets, use your build's output directory. ? What do you want to use as your public directory? dist ? Configure as a single-page app (rewrite all urls to /index.html)? Yes
- config情報を管理画面から取得(今回はGitにコードをあげるために環境変数に格納)
- main.jsで
firebase.initializeApp
を実行main.jsimport firebase from 'firebase/app' // initialize Firebase let config = { apiKey: process.env.VUE_APP_FIRE_BASE_apiKey, authDomain: process.env.VUE_APP_FIRE_BASE_authDomain, databaseURL: process.env.VUE_APP_FIRE_BASE_databaseURL, projectId: process.env.VUE_APP_FIRE_BASE_projectId, storageBucket: process.env.VUE_APP_FIRE_BASE_storageBucket, messagingSenderId: process.env.VUE_APP_FIRE_BASE_messagingSenderId, appId: process.env.VUE_APP_FIRE_BASE_appId } firebase.initializeApp(config) ...略これで、Firebaseの初期設定ができました。
1-3. フォルダ構成
これらの手順で生成されたものを元に、大まかに下記のようなフォルダ構成で作成しました。
├── dist │ └── ビルド後に生成されるファイル │ ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions │ ├── index.js │ ├── package-lock.json │ └── package.json │ ├── public │ ├── 404.html │ ├── favicon.ico │ ├── firebase-messaging-sw.js │ ├── img │ │ └── icons │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.vue │ ├── main.js │ ├── registerServiceWorker.js │ ├── api │ │ └── firebase.js │ ├── assets │ │ └── scss │ │ └── entry.scss │ ├── components │ │ ├── dialog.vue │ │ └── head-menu.vue │ ├── pages │ │ ├── help.vue │ │ ├── input.vue │ │ ├── list.vue │ │ └── result.vue │ ├── router │ │ └── index.js │ └── store │ ├── index.js │ └── modules │ ├── auth.js │ ├── counter.js │ └── list.js └── vue.config.js
引用元: https://vuex.vuejs.org/ja/上記の図の、
VueComponents
に当たる部分がsrc/components
とsrc/pages
になります。
vuex
に当たる部分がstore
内に格納されているものです。
今回の場合、BackendAPI
に当たる部分がFirebaseになるため、Firebaseに関する関数をsrc/api/firebase.js
に集約し、store
フォルダ内のコードから呼び出す方針で実装しました。2. VueでUI部分を作成
今回ここについては省略します。
3. Authentication(ログイン処理)
今回はGoogleアカウントで認証できるように実装しました。
firebase.js...略 const auth = firebase.auth() export default { initFirebase () { auth.onAuthStateChanged(this.onAuthStateChanged.bind(this)) }, login () { var provider = new firebase.auth.GoogleAuthProvider() auth.signInWithPopup(provider) }, logout () { auth.signOut() }, onAuthStateChanged (user) { if (user) { // ユーザがログインしている時の処理 } else { // 未ログイン時の処理 } }, ...略
main.js
に以下を追加main.jsimport Firebase from './api/firebase' Firebase.initFirebase()この処理だけでGoogleアカウントでの認証が完了しました。
4. FireStore(データベース)への書き込み
以前使われていたFirebaseRealtimeDatabaseではデータベース構造をjsonファイルで記述する必要がありましたが、FireStoreでは書き込みのルールのみ指定すれば、自由にカラムを作成できるようです。
firebase.js...略 import 'firebase/firestore' export default { //例) 原稿のデータをfirestoreに保存する関数 saveDocument (document) { return new Promise((resolve, reject) => { const userId = VueCookies.get('userInfo').uid // cookieに保存しておいたユーザIDを取得 database.collection('users').doc(userId).collection('documents') .add(document) .then((ref) => { resolve(ref) }).catch(reject) }) }, }5. CloudMessagingを利用したPush通知
※Push通知に関してはSafariやiOS端末などでは非対応です。iPhoneでは通知を出せません。
5-1. PWA化をしてCloudMessagingを導入する
PWA化とCloudMessagingの導入に関しては、下記記事を参考に実装しました。
注意点として、
firebase.js... 略 let messaging if(firebase.messaging.isSupported()) { messaging = firebase.messaging() } export default { initFirebase () { auth.onAuthStateChanged(this.onAuthStateChanged.bind(this)) if (firebase.messaging.isSupported()) { messaging.usePublicVapidKey(process.env.VUE_APP_FIRE_BASE_publicVapidKey) store.dispatch('auth/checkMessagingisSupported', true) } else { store.dispatch('auth/checkMessagingisSupported', false) } }, ...略上記のように
firebase.messaging.isSupported()
関数でCloudMessagingに対応した環境かどうかを判定しないと
iOS端末など、CloudMessageingが非対応の環境ではページがまっしろになってしまいます。5-2. Push通知をするためのトークンを発行する
複数の端末でログインしても、全ての端末にpush通知が届くように、複数端末トークンを利用しました。
参考ページ
- https://firebase.google.com/docs/cloud-messaging/js/device-group?hl=jaトークン追加のURLをそのまま叩くと、クロスドメイン制約に引っかかってしまうため、下記のエラーが出てしまいます。
Access-Control-Allow-Originそこで、プロキシの設定をします。
vue.config.js
に下記の設定を追加。vue.config.jsmodule.exports = { ...略 devServer: { proxy: 'https://fcm.googleapis.com/', } }これで、
<アプリが立ち上がっているサーバのドメイン>/hoge/some
にリクエストを飛ばすと、devserverを経由して
https://fcm.googleapis.com/hoge/some
にリクエストが送信されるようになります。実際には、開発環境と本番環境でドメインが違うので、環境変数にドメインを格納します。
.envファイルに本番環境のドメインを変数で追加
VUE_APP_API_URL_BASE="<デプロイする先のドメイン>".env.developファイルに、開発環境(localhost)のドメインを追加
VUE_APP_API_URL_BASE="http://localhost:8080"これで、ドメイン部分を変数で指定すると、開発環境でも、本番環境でも、クロスドメイン制約に引っかかることなくリクエストを叩けるようになりました。
firebase.js...略 // 通知用トークンの取得 getNotinotificationKey () { const userId = VueCookies.get('userInfo').uid const url = process.env.VUE_APP_API_URL_BASE + "/fcm/notification?notification_key_name=" + userId let headers = { 'Content-Type':'application/json', 'Authorization': 'key=' + process.env.VUE_APP_FIRE_BASE_serverKey, 'project_id': process.env.VUE_APP_FIRE_BASE_messagingSenderId } return new Promise((resolve, reject) => { axios.get(url, {headers: headers, data: {}}) .then(res => { resolve(res.data) }).catch(err => { reject(err) }) }) }, ...略5-3. CloudFunctionsで通知を送るfunctionを作成
functions/index.js
に通知を送信するfunctionを作成して、deployします。functions/index.js// firestoreにから通知対象のデータと、トークンを取得 async function getNotificationDocument () { try { // notificationListに通知するデータを格納済み const querySnapshot = await database.collection('notificationList').get() return querySnapshot.docs.filter(doc => { return moment().isSame(doc.data().deadline, 'day') }).map(doc => { return { notificationKey: doc.data().notificationKey, title: doc.data().title, id: doc.id } }) } catch (error) { throw error } } // push通知を送信する async function DeadlineDocument(documents) { const url = "https://fcm.googleapis.com/fcm/send" const headers = { 'Authorization': 'key=' + functions.config().vue_app.server_key, } try{ for(let document of documents) { let data = { "notification": { "title": "「" + document.title + "」は今日締め切りです", //通知のタイトル "click_action": "result/" + document.id //通知を押した時の飛び先 }, "to": document.notificationKey //トークン } axios.post(url, data, {headers: headers, useCredentails: true}) await sleep(2000) } return documents } catch (error) { throw error.response.status } } // デプロイする関数 exports.pushNotification = functions.https.onRequest(async (request, response) => { try{ const res = await getNotificationDocument() const today = await pushNotificationTodaysDeadlineDocument(res.today) response.send(today) } catch (error) { response.status(500).send(error) } })下記コマンドでfunctionをデプロイ
$ firebase deploy --only functions Function URL (addMessage): https://MY_PROJECT.cloudfunctions.net/pushNotificationFunctionURLをブラウザで開いて、push通知が送信されれば成功です◎
5-4. Cronで関数を定期実行
Firebaseのみで定期実行をすることができますが、課金が必要です。
https://firebase.google.com/docs/functions/schedule-functions今回は、無料で実装したかったため、外部のCronを利用しました。
- cron-job.orgにアクセスし、TOP画面でサインアップする
- 「Cronjobs」タブの「+Create cronjob」ボタンを押して、新規jobを作成
- titleとFunctionURL、任意のスケジュールを設定し、jobを作成
これで設定がうまくいっていれば、設定したスケジュールで通知されます?
感想
CloudMessagingは初めて利用しましたが、想像以上に簡単にpush通知を実装することができました。
PWA化もVueCLIを利用すれば、手間無く実装できました。PWAはiOSの対応状況が芳しくないので、今後に期待です。。。?
原稿用紙の表示部分は思いつきとチカラワザで実装したので、バグがあるかもしれないです。。
不具合など見つけたらご指摘いただけると助かりますm(_ _)m
- 投稿日:2019-12-11T10:50:26+09:00
JSFiddleでサクッと開発
- 投稿日:2019-12-11T10:42:17+09:00
メール送信画面を、Google Apps ScriptとVue.jsとBootstrapで作る
index.html
index.html<!DOCTYPE html> <html> <head> <base target="_top"> <?!= HtmlService.createHtmlOutputFromFile('css').getContent(); ?> <?!= HtmlService.createHtmlOutputFromFile('customcss').getContent(); ?> </head> <body> <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top"> <a class="navbar-brand" href="#">Navbar</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarsExampleDefault"> <ul class="navbar-nav mr-auto"> <li class="nav-item active"> <a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a> </li> <li class="nav-item"> <a class="nav-link" href="#">Link</a> </li> <li class="nav-item"> <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a> </li> <li class="nav-item dropdown"> <a class="nav-link dropdown-toggle" href="#" id="dropdown01" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown</a> <div class="dropdown-menu" aria-labelledby="dropdown01"> <a class="dropdown-item" href="#">Action</a> <a class="dropdown-item" href="#">Another action</a> <a class="dropdown-item" href="#">Something else here</a> </div> </li> </ul> <form class="form-inline my-2 my-lg-0"> <input class="form-control mr-sm-2" type="text" placeholder="Search" aria-label="Search"> <button class="btn btn-secondary my-2 my-sm-0" type="submit">Search</button> </form> </div> </nav> <main role="main" class="container"> <div id="app"> <div class="starter-template"> <h1>メール送信</h1> <p class="lead">件名と本文と宛先を入力して送信ボタンをクリックすると宛先にメールを送信します。</p> </div><!-- /.starter-template --> <div class="card"> <div class="card-body"> <div class="form-group"> <label for="subject">subject</label> <input class="form-control" type="text" id="subject" placeholder="件名を入力してください" v-model.trim="subject"/> </div> <div class="form-group"> <label for="body">body</label> <textarea class="form-control" id="body" placeholder="本文を入力してください" rows="5" v-model.trim="body"></textarea> </div> <div class="form-group"> <label for="email">Email address</label> <input type="email" class="form-control" id="email" placeholder="name@example.com" v-model.trim="email"> </div> </div> </div> <br> <button type="button" class="btn btn-primary" v-on:click="checkConfirm">送信する</button> </div><!-- /.vue.el.app --> </main><!-- /.container --> <?!= HtmlService.createHtmlOutputFromFile('js').getContent(); ?> </body> <?!= HtmlService.createHtmlOutputFromFile('vue').getContent(); ?> </html>vue.html
vue.html<script src="https://cdn.jsdelivr.net/npm/vue@2.6.0"></script> <script> var app = new Vue({ el: '#app', data: { subject:'', body:'', email:'', setAppData:[], }, methods:{ checkConfirm: function(){ this.setData(); google.script.run .withSuccessHandler(function(arg){ alert("データの登録に成功しました。"); }) .withFailureHandler(function(arg){ console.log(arg); alert("データの登録に失敗しました。"); }).sendEmail(this.setAppData); this.clearData(); }, setData: function(){ this.setAppData = []; this.setAppData.push(this.subject); this.setAppData.push(this.body); this.setAppData.push(this.email); }, clearData: function(){ this.setAppData = []; this.subject = ''; this.body = ''; this.email = ''; }, }, created: function(){ }, }) </script>コード.gs
アプリケーションにアクセスしたユーザーのメールアドレスを取得してCCにセットしているため、
メールはアプリ利用ユーザーにもCCとして送信される。コード.gsfunction doGet() { var html = HtmlService.createTemplateFromFile("index").evaluate().addMetaTag('viewport', 'width=device-width, initial-scale=1, shrink-to-fit=no'); return html; } function sendEmail(setMail){ var subject = setMail[0]; //件名 var body = setMail[1]; //本文 var mailto = setMail[2]; //宛先 var cc = Session.getActiveUser().getEmail(); /* メールを送信 */ GmailApp.sendEmail( mailto, //toアドレス subject, //表題 body, //本文 { cc: cc, } ); }js.html
js.html<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>css.html
css.html<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">customcss.html
customcss.html<style> body { padding-top: 5rem; } .starter-template { padding: 3rem 1.5rem; text-align: center; } .bd-placeholder-img { font-size: 1.125rem; text-anchor: middle; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } @media (min-width: 768px) { .bd-placeholder-img-lg { font-size: 3.5rem; } } </style>
- 投稿日:2019-12-11T10:38:05+09:00
エラーを乗り越えろ!! くじけないRuby初心者のRuby On Rails + Vue.js 環境構築(Ruby 2.6.4, Rails 6.0.1)
Ruby On Rails + Vue.js で何かを作りたいと思って環境構築し始めたところ、次々とエラーが出てきました。環境を構築したいだけなのにエラーで前に進めないのはとても辛いです。構築手順についてはわかりやすい先輩型のQiitaがあったので、今回はそれに沿って構築した際に、詰まった箇所とその対応策を備忘録としてまとめます。
参考文献に沿って構築 + 詰まった箇所
1. まずはこの記事に沿って実行
Ruby初学者のRuby On Rails 環境構築【Mac】
❶Homebrewはインストール済みだったので、アップデート
❷rbenvもインストール済みだったので、Homebrewでアップデート
❸一応cat ~/.bash_profile
でrbenvのパスが通っているか確認
❹せっかくなので今回は本家サイトで安定版と書かれていたRuby2.6.5をインストール...しようと思ったら、listになかったので、2.6.4をインストールした
❺Bundlerはインストール済みだったので、アップデート
❻MySQLはHomebrewでインストール&起動
❼作業フォルダを作成して、作成したフォルダの中へ移動し、Rubyのバージョンを2.6.4に指定
❽bundle initでGemfileを作成、vimでGemfileを開き、# gem "rails"のコメント解除して保存
❾Railsをインストールしてバージョン確認
➓Railsアプリを作成(blogという名前で今回はプロジェクトを作成)
①Railsアプリ起動
② http://localhost:3000/ にアクセスして、下記画面が出たら、Ruby On Railsの環境構築はOK!(いったんCtrl+Cでサーバの起動は止めておく)1.の詰まった箇所
・1-❸で、Qiitaだと、
export PATH="~/.rbenv/shims:/usr/local/bin:$PATH"
だったけど、
私の環境はexport PATH="$HOME/.rbenv/bin:$PATH"
となっていた: は区切りの意味。$PATHはPATHの設定に追加しますよって意味(つけないと、PATHの上書きになってしまうので、それまでの設定がなくなっちゃう)。
なのでQiitaの方は下記2つのパスが通っていることになる。~/.rbenv/shims /usr/local/binでも私の方は
$HOME/.rbenv/binしかパスが通ってないことなのかな、と思って、
echo $PATH
して見たら、~/.rbenv/shims
、/usr/local/bin
共にパス通っていたので、良しとする。(cf. Pathを通すとは、環境変数とは)
・1-➓で、bootsnapのLoadErrorが出てしまった
cannot load such file -- bootsnap/setup (LoadError)→ Gemfileに
gem 'bootsnap', require: false
を追記し、bundle install
し、もう一回プロジェクト作成コマンドを叩く(上書き聞かれたら全部Y)。ここで上書きするので、プロジェクト配下のconfig/boot.rbにrequire 'bootsnap/setup'
の追記は自分でしなくて大丈夫(cf. bootsnapのせいでRails5.2とかが動かない人へ)
・1-➓で、webpackerがないためエラーになった
rails aborted! Don't know how to build task 'webpacker:install' (See the list of available tasks with `rails --tasks`)→ Gemfileに
gem 'webpacker', github: "rails/webpacker"
を追記し、bundle install
し、もう一回プロジェクト作成コマンドを叩く(上書き聞かれたら全部Y)
(cf. Rails6 開発時につまづきそうな webpacker, yarn 関係のエラーと解決方法)・1-➓で、yarnがないため警告がでる
Yarn not installed. Please download and install Yarn from https://yarnpkg.com/lang/en/docs/install/→
brew install yarn
を実行してyarnをインストールし、もう一回プロジェクト作成コマンドを叩く(上書き聞かれたら全部Y)・1-➓で、listenのエラーが出る
rails aborted! LoadError: Could not load the 'listen' gem. Add `gem 'listen'` to the development group of your Gemfile→ ここはだいぶ詰まった。けど、プロジェクト配下のGemfileのdevelopmentの箇所に書かれているlistenの記述をコメントアウトし、それをまるっと、一個上の階層のGemfileに追記(
gem 'listen', '>= 3.0.5', '< 3.2'
)して、bundle install --with development test
を実行して、もう一回プロジェクト作成コマンドを叩く(上書き聞かれたら全部Y)(cf. LoadError: Could not load the 'listen' gem (Rails 5))
・1-➓で、yarnをアップデートせよと出た
error Found 1 errors. ======================================== Your Yarn packages are out of date! Please run `yarn install --check-files` to update. ======================================== To disable this check, please change `check_yarn_integrity` to `false` in your webpacker config file (config/webpacker.yml). yarn check v1.19.2 info Visit https://yarnpkg.com/en/docs/cli/check for documentation about this command.→ アップデートしても治らなかったので、プロジェクト配下のconfig/webpacker.ymlに記述されていた、check_yarn_integrityをfalseにした(2箇所あって、片方はfalseにすでになっていたのでそれはそのままにしてもう一個のtrueの方をfalseにした)ら、エラーが消えた
(cf. yarnが原因でdocker-compose runが実行できないときの対処法)
・1-①で、railsコマンド使えないけど、といった感じのエラーがでる
Rails is not currently installed on this system. To get the latest version, simply type: $ sudo gem install rails You can then rerun your "rails" command.→ バージョン確認
bundle exec rails -v
の時のように、bundle exec rails
を使って、プロジェクトblog
に入ってbundle exec rails server
を実行したら無事起動した!HOGEGE-no-Air-2:pra_ruby HOGE$ cd blog/ HOGEGE-no-Air-2:blog HOGE$ bundle exec rails server => Booting Puma => Rails 6.0.1 application starting in development => Run `rails server --help` for more startup options Puma starting in single mode... * Version 4.3.1 (ruby 2.6.4-p104), codename: Mysterious Traveller * Min threads: 5, max threads: 5 * Environment: development * Listening on tcp://127.0.0.1:3000 * Listening on tcp://[::1]:3000 Use Ctrl-C to stop(cf. bundle exec rails serverで起動時にエラーとなってしまう。。。)
2. 次にVue.jsを下記記事に沿って追加
【Rails6】10分でRails + Vue + Vuetifyの環境を構築する
❶--webpack=vueを指定してrails newでプロジェクトを作成する部分は、1.で作成したプロジェクト
blog
を使いたかったので、プロジェクト配下で、bundle exec rails webpacker:install:vue
を実行し、既存のプロジェクトにVue.jsをインストール
(cf. webpackerを使ってRuby on Rails 6.0とVue.jsを連携する方法(フロントエンド編))
❷プロジェクト配下でbundle exec rails s
を実行し、サーバーを起動(bundle exec rails server
でもok)、確認終わったらCtrl+Cで止めておく
❸プロジェクト配下でbundle exec rails db:create
を実行し、DBを作成
❹プロジェクト配下でbundle exec rails g controller home index
を実行し、コントローラーを作成
❹プロジェクト配下のconfig/routes.rb
を編集してルーティングを設定
❺プロジェクト配下にできていたapp/views/home/index.html.erb
を編集して、再びプロジェクト配下でbundle exec rails s
を実行し、サーバーを起動
❻ http://localhost:3000 に接続して以下の画面が表示されればVueの環境構築は完了。確認終わったらCtrl+Cでサーバを止めておく2.の詰まった箇所
(※特にどこもひっからなかった。強いてあげれば、1.同様、
rails
コマンドは全て、bundle exec rails
に置き換えてプロジェクトは以下で実行した部分)3. 次に2.と同じ記事を参考におまけでVuetifyを追加
❶
yarn add vuetify
をそのままプロジェクト配下で投げ、yarnを使ってvuetifyをインストール
❷プロジェクト配下のapp/javascript/packs/hello_vue.js
を編集し、vuetifyを読み込ませる
❷プロジェクト配下のapp/javascript/app.vue
を編集し、Vuetifyで表示させるファイルを作成
❹プロジェクト配下でbundle exec rails s
を実行し、サーバーを起動
❻ http://localhost:3000 に接続して以下の画面が表示されればVuetifyの環境構築も完了3.の詰まった箇所
(※特にどこもひっからなかった。強いてあげれば、1.2.同様、
rails
コマンドは全て、bundle exec rails
に置き換えてプロジェクトは以下で実行した部分)4. せっかく環境が整ったので、少しいじってみる
※後ほど追記します!
- 投稿日:2019-12-11T10:07:02+09:00
【Vue.js and Nginx on Docker】第四回開発合宿【環境構築エラーの嵐】
こんにちは、株式会社アクシス福岡オフィスの goto です。
今年、31歳未経験でエンジニアの世界に足を踏み入れました。開発合宿メンバーの中では年齢的にはちょい上のロン毛です。会社のアカウントがことごとく goto u なのが不満です。
気がつけば開発合宿も4回目。
今回は弊社新人さんが初参加してくました!ただし、今回もまだ「日帰り合宿」ですw
第一回: やっていくばい!開発合宿@福岡
第二回: 第二回開発合宿を開催: 話題のFlutterでSNSアプリ開発
第三回: 第三回開発合宿: チーム開発本格始動!参加動機
これはもう、「なんか作りたかった」に尽きます。
一人で家でやるのも嫌いじゃないのですが、元々、集中したいときはカフェに行ったり、コワーキングに行ったりと、うるさすぎない程度に周りに人がいる環境のほうが集中できる性質なのもあって、開発合宿の話にはすぐに飛びつきました。
また、業務ではなかなかやらないことをやれたりするのもやはり魅力です。そもそもの開発経験が少ないこともありますので、「存分に失敗できる環境」って大事だと思います。(と、予防線を張っておく・・・)
今はチーム開発をしているので、「どんなものができるかな」というワクワクもありますね。
今回の参加者がやったこと
- チーム・アプリ開発
- バックエンド
- ディレクトリ構成決める
- 実際に実装に入る
- フロントエンド
- Vue.jsで画面実装(をしたかった…)
- AWS Lambda を使ってSlackボット作成
- TensorFlow を使って、普遍的なキーワードを特定ジャンルのキーワードに変換
ハマる環境構築
さて、僕はチーム・アプリ開発でフロントエンド担当(ぼっち)。
前職がフリーランスのHTML, CSSコーダーだったこともあるので、さらにできることを広げるため、Vue.jsに挑戦してます。
さらに同じチームの uehara くん(環境構築・バックエンド担当)が「時代はTypeScriptっしょ」ということで、Vue.js + TypeScriptです。なんとまあモダンなこと。
というわけで、中世から現代に転生したみたいな気持ちで勉強してます。一通りの知識は得たので、あとは実践あるのみ!
吐き出され続けるコンソールエラー
環境は ueharaくんが Docker で構築してくれましたので、意気揚々と
docker-compose up -d
すると、、、なんか出とる。
しかも増える。2秒ごとくらい。
Docker 内に nginx 用コンテナと、アプリ用コンテナの2つが設定されている状態なんですが、どうやらアプリ用コンテナで Websocket 通信を行うJSライブラリ (sockjs) がなんらかの設定不足で通信できない模様。
そのせいで Chrome では
net::ERR_CONNECTION_REFUSED
となってしまっているようです。ビルドは通って、ページは表示される。ホットリロードもされるし、機能的にはなんの問題もなさそうなのに、表示され続けるエラーメッセージ。
見なかったことにしよう。 そんな思いも頭をよぎりましたが、せっかくの機会なので解決してみることにしました。Dockerってほとんど触ったことないですし、業務の環境だと壊すのが怖いですが、これなら、プロジェクトの始めなので、そこまで怖くない。いい勉強になるかなとの思いで、調べ始めました。
これが地獄の入り口だとも知らず。
解決できない
結果から言ってしまえば、 解決できませんでした。
エラーメッセージをググって、
sockjs-node ERR_CONNECTION_REFUSED when accessing from network
あたりを参考に色々やってみるも、上手く行かず。。。uehara くんにも協力を仰ぎ、
nginx/default.conf
の設定を見直してもらってもダメ。その日はタイムアップで、「進捗: 環境構築(未解決)」という結果に。
しかもその後3週間、このレポートを書くために Docker を立ち上げては検証し、エラー解消できず週末が終わる、ということを繰り返していました。なのでレポート書くのに時間がかかったりしています。言い訳ですけど。
さらなる沼 - アプリ入れ直し作戦 -
これはもう、最初に入れてもらった環境が悪いんだ、きっとそうだという、 大変に失礼な思考 にたどり着きました。与えられた環境に文句を言う、典型的な素人ですね。
で、アプリ自体を入れ替えてやろうと、Vue CLIで再インストールしてみました。
するとどうでしょう。
node-sass が無いからコンパイルできないよ
と怒られてしまいました。Error: Missing binding /app/node_modules/node-sass/vendor/linux_musl-x64-72/binding.node Node Sass could not find a binding for your current environment: Linux/musl 64-bit with Node.js 12.x Found bindings for the following environments: - OS X 64-bit with Node.js 12.x This usually happens because your environment has changed since running `npm install`.こいつの解決は意外と簡単で、要はDocker コンテナの環境(Linux)で、ローカル(Mac)の node-sass を見に行こうとしているがために起きたエラーでした。まあ、3時間位かかったんですけど。
Dockerの設定を変更して、ビルドをかける。
コンパイル成功!
ページが表示される!
もうええて!
俺達の冒険はこれからだ!
そんなわけで、結局解決に至っていない状態です。
でも実は、
webpack-dev-server
の sockPort/sockPath なるものの存在 も発見したので、ちょっとそれを試してみたいと思います。解決までやってると、来年末くらいまでこのレポート書けそうになかったので、とりあえず書きました。というわけで、プロジェクトの進捗としては全然ですが、Dockerにちょっとは慣れることができたかなと思います。1年分くらいは
docker-compose build
とdocker-compose up
をした気がする。あとは、お世話になったコンテナ全消し、イメージ全消し、ボリューム全消しの 全消し三銃士 を貼っておきます。
# コンテナ全消し docker container rm -f $(docker container ls -aq) # イメージ全消し docker image rm -f $(docker image ls -aq -f "dangling=true") # ボリューム全消し docker volume rm $(docker volume ls -qf dangling=true)今回のMVP
勝手に決める今回のMVPは、Slackボットを作成した新人 tanaka 君!
「お菓子」と入れたら、今日のおすすめのお菓子を返してくれるSlackボットを作ってました!可愛いか!
おっさんには無い発想です。
次はこれを発展させてなにか作るつもりらしいです。将来有望。
- 投稿日:2019-12-11T09:15:23+09:00
Nuxt.js × Firebase な環境で Auth0 の埋め込みログイン (Embedded Login) を使う
Nuxt.js Advent Calendar 2019 の 11 日目の記事です。
Nuxt.js を Firebase で動かしつつ、ユーザー管理できる仕組みにする場合は Firebase Authentication を使うのが一般的です。
ですが、ベンダーロックインを避けたり、Facebook, Twitter, Google アカウント以外のソーシャルログインを手軽に使いたい等の理由から、現在のプロジェクトで Auth0 を採用しました。また、ログイン画面設計の柔軟性等の観点から Universal Login (ユニバーサルログイン) ではなく、 Lock を利用しない Embedded Login (埋め込みログイン) 方式にて認証しています。Universal Login を利用するほうが手軽に Auth0 を利用できるのですが、ネイティブアプリ対応も予定されていたこともあり、知見を貯めるという意味で Embedded Login 方式ですすめることにしたのですが、これがまた大変でして…この記事を以て供養したいと思います。
免責事項
コードがぼかしてあるのでそのままでは動きません。概念が伝われば幸いです。
Auth0 とは
IDaaS の一つです。
https://auth0.com/jp/前提条件
- Nuxt.js 2.8.1 (プロジェクト開始時点の Nuxt の最新版でした)
- Node 8.16.0 (Cloud Functions の node のサポートバージョンに合わせています)
- Vue Property Decorator 8.2.x
- TypeScript 3.5.3
やりたいこと
- Auth0 の Embedded Login でログインしたい
- Auth0 にログインした時に Firebase にもログインしたい
Embedded Login とは
Auth0 を利用する際のログイン方法の一つです。通常は Universal Login というログイン画面も Auth0 に提供してもらう方法があるのですが、Embedded Login では Lock と呼ばれる Auth0 のログイン画面を自信のドメインで提供できるライブラリを使用するか、 SDK を使用して自前でログイン画面を用意します。また。資格情報を引き回す仕組みも自前で用意します。
https://auth0.com/docs/login/embedded手始め
実は、Nuxt.js x Auth0 の公式サンプルコードが動かない ので、ほかのサンプルコードやリファレンスを参考にゼロベースで組み上げる必要がありました。 (これ去年から動かなかったのか… )
また、 auth0-spa-js という、いかにもお誂え向きと思えるライブラリがありますが、こちらは Embedded Login に対応していないようです。
今回は Firebase にもログインしたいという要件もあったので、こちらの Angular のサンプルコード を参考にしています。認証用サービスクラスの作成
都合上コードの全てはお見せできませんが、参考になりそうな部分だけ抜粋して掲載してます。
ほぼ Angular のサンプルコード と同じです。変えた点としては
- 格納場所、ファイル名はutils/authService.ts
とした
- メアド、パスワードでログインできるようにした
- Angular 独自の機能は省いた(ただし RxJS は一部そのまま使ってます。このへんがつらい)
- IE11 でトークン取得処理がうまく動かなかったので、エンドポイントをコールする際にダミーパラメーターを付けたutils/authService.ts// ※抜粋コードです import auth0, { WebAuth } from 'auth0-js'; import { EventEmitter } from 'events'; import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; import { from, of, timer, Subscription } from 'rxjs'; const localStorageLoggedIn = 'loggedIn'; const localStorageRedirect = 'auth_redirect'; const localStorageExpire = 'expires_at'; class AuthService { public idToken: string; public profile: any; public emitter: EventEmitter; private webAuth: auth0.WebAuth; private connectionName: string; private accessToken: string; private firebaseSub: Subscription | null; private refreshFirebaseSub: Subscription | null; constructor() { this.emitter = new EventEmitter(); // Create Auth0 web auth instance const auth0Config = (process.env.auth0 as any) || {}; this.webAuth = new auth0.WebAuth({ domain: auth0Config.domain, redirectUri: `${window.location.origin}/callback`, clientID: auth0Config.clientId, responseType: 'token id_token', audience: auth0Config.audience, scope: 'openid profile email' }); // Auth0 Connection Name this.connectionName = auth0Config.connection; // Auth0 Access Token this.accessToken = ''; // Subscribe to the Firebase token stream this.firebaseSub = null; // Subscribe to Firebase renewal timer stream this.refreshFirebaseSub = null; // 略 } // User Login public login(email: string, password: string): Promise<any> { return new Promise(resolve => { this.webAuth.login( { realm: this.connectionName, email, password, redirectUri: `${window.location.origin}/callback` }, err => { if (err) { console.error(err); resolve({ errorMessage: err.description }); } else { resolve({ success: true }); } } ); }); } // Handles the callback request from Auth0 public handleAuthentication(): Promise<any> { return new Promise((resolve, reject) => { this.webAuth.parseHash((err, authResult) => { if (err) { reject(err); } else if (authResult) { this.localLogin(authResult); resolve(authResult.idToken || ''); } else { resolve(); } }); }); } // Local login after Auth0 authentication private localLogin(authResult: auth0.Auth0DecodedHash): void { this.idToken = authResult.idToken || ''; this.profile = authResult.idTokenPayload || {}; // Convert the JWT expiry time from seconds to milliseconds const tokenExpiry = this.profile.exp * 1000; localStorage.setItem(localStorageExpire, tokenExpiry.toString()); window.location.hash = ''; // Store access token this.accessToken = authResult.accessToken || ''; localStorage.setItem(localStorageLoggedIn, 'true'); // Get Firebase token this.getFirebaseToken(); } // Get firebase token and start firebase authentication private getFirebaseToken(): void { // Prompt for login if no access token if (!this.accessToken) { this.emitloginEvent(); return; } const getToken$ = () => { const url = `${process.env.apiRoot}auth-authApi/firebase`; const options = { method: 'get', url, headers: { Authorization: `Bearer ${this.accessToken}` }, // NOTE: IE11キャッシュ問題に対応するため、paramsへタイムスタンプ付与 params: { _: Date.now() } } as AxiosRequestConfig; return from( axios(options) .then(res => res) .catch(err => { console.error(`An error occurred fetching Firebase token: ${err.message}`); }) ); }; this.firebaseSub = getToken$().subscribe( res => this.firebaseAuth(res as AxiosResponse), err => console.error(`An error occurred fetching Firebase token: ${err.message}`) ); } // Emit Event for finished login private emitloginEvent(): void { this.emitter.emit(this.loginEventName, { loggedIn: this.loggedInFirebase, state: {} }); } // Firebase authentication private firebaseAuth(tokenObj: AxiosResponse): void { const auth = firebase.auth(); auth .signInWithCustomToken(tokenObj.data.firebaseToken) .then(() => { this.loggedInFirebase = true; // Schedule token renewal this.scheduleFirebaseRenewal(); // https://github.com/auth0-blog/angular-firebase/blob/master/src/app/auth/auth.service.ts#L123 this.loading = false; }) .catch(() => { this.loggedInFirebase = false; }) .finally(() => { this.emitloginEvent(); }); } }サービスクラスのインスタンスを Nuxt に注入
plugins/auth.ts
を作成し、上記のサービスクラスのインスタンスをthis.$auth
に突っ込んで、どこでも呼び出せるようにしました。plugins/auth.tsimport AuthService from '~/utils/authService'; import { Plugin } from '@nuxt/types'; declare module 'vue/types/vue' { interface Vue { $auth: AuthService; } } const auth0Plugin: Plugin = (ctx, inject) => { const auth = new AuthService(); inject('auth', auth); } export default auth0Plugin;あとは
nuxt.config.ts
にもプラグインを読み込ませてやるようにしますnuxt.config.tsplugins: [ '@/plugins/auth' ],ログイン処理
あとは普通にコンポーネントの方でログインしてあげます。
login.vue<template> <div> <div> <label>メルアド</label> <input type="text" v-model="email" /> </div> <div> <label>パスワード</label> <input type="password" v-model="password" /> </div> <div> {{ errorMessage }} </div> <div> <button @click="execLogin">ログイン</button> </div> </div> </template> <script lang="ts"> import { Component, Vue } from 'vue-property-decorator'; @Component export default class Login extends Vue { // data email = ''; password = ''; errorMessage = ''; // methods async execLogin(): Promise<void> { const result = await this.$auth.login(this.mail, this.password, '/top'); if (!result.success) { if (result.errorMessage === 'Wrong email or password.') { this.errorMessage = 'メールアドレスまたはパスワードが間違っています。'; } else if (!result.errorMessage.indexOf('Your account has been blocked after multiple consecutive login attempts.')) { this.errorMessage = 'このアカウントはロックされています。'; } else { this.errorMessage = result.errorMessage; } } } } </script>callback.vue<template> <div>Loading...</div> </template> <script type="ts"> import { Component, Vue } from 'vue-property-decorator'; @Component export default class Callback extends Vue { // lifecycle public created(): void { this.$auth.emitter.addListener(this.$auth.loginEventName, this.handleLoginEvent); try { this.$auth.handleAuthentication(); } catch (error) { console.error(error); alert('認証に失敗しました'); this.$router.push('/'); } } // methods private handleLoginEvent(data) { if (!data.error) { this.$router.push('/memberOnly'); } } } </script>※あくまでNuxt.js側だけの実装です。Allow Calback URL に
/callback
/memberOnly
を追加する等 Auth0 側での設定も必要です。いい点
ログイン画面のデザインに制約を受けません。
悪い点
Auth0のログイン画面が許せない(カスタマイズできる範囲では満足できない)という場合以外に Web で Embedded Login を使うメリットはないです。
今回の抜粋コードも愚直な感じな上に Angular と Vue のキメラ感がキモく、うまい具合にリファクタしたいとずっと考えています。
もっとキレイにできたら全容を GitHub で公開にしたいと思っていますので、今しばらくお待ちいただければと思ってます…が、いつになるかは未定ですAuth0 自体、 Universal Login の利用を推奨しています。
https://auth0.com/docs/guides/login/universal-vs-embeddedNuxt で Auth0 を使う場合は auth0-spa-js で Universal Login を使ったほうが非常に楽です。
おまけ: auth0-spa-js を使う場合
Vueの公式サンプルコードがあるので、そちらを参考にして欲しいのですが、極限までシンプルにするとここまでお気軽に使えます。もちろん Embedded Login ではなく Universal Login になります。もちろん Firebase へのログインも加えるともうちょっと複雑になってきます。
plugins/auth.tsimport { Plugin } from '@nuxt/types'; import createAuth0Client from '@auth0/auth0-spa-js'; import Auth0Client from '@auth0/auth0-spa-js/dist/typings/Auth0Client'; let auth0!: Auth0Client; const domain = process.env.domain; const clientId = process.env.clientId; const redirectUrl = `${window.location.origin}/callback`; class AuthService { auth0!: Auth0Client; constructor() {} public async init(): Promise<void> { const res = await createAuth0Client({ domain, client_id: clientId, redirect_uri: redirectUrl }); this.auth0 = res; } public async login(): Promise<void> { await this.auth0.loginWithPopup(); } public async logout(): Promise<void> { await this.auth0.logout(); } public async getUser(): Promise<any> { const user = await this.auth0.getUser(); return user; } public async isLogin(): Promise<boolean> { const result = await this.auth0.isAuthenticated(); return result; } } declare module 'vue/types/vue' { interface Vue { $auth: AuthService; } } let authInstance!: AuthService const auth0Plugin: Plugin = async (_, inject) => { authInstance = new AuthService(); await authInstance.init(); inject('auth', authInstance); } export default auth0Plugin;pages/login.vue<template> <div> <div v-if="!auth0Pending"> <button @click="auth0Login" v-if="!isLogin"> Login </button> <button @click="auth0Logout" v-if="isLogin"> logout </button> </div> </div> </template> <script lang="ts"> import { Component, Vue } from 'vue-property-decorator'; @Component export default class PagesLogin extends Vue { // data auth0Pending = true; isLogin = false; // lifecycle async created() { await this.checkLogin(); } // methods async checkLogin() { const isAuthed = await this.$auth.isLogin(); const user = await this.$auth.getUser(); if (user) { this.isLogin = true; } this.auth0Pending = false; } async auth0Login() { await this.$auth.login(); await this.checkLogin(); } async auth0Logout() { await this.$auth.logout(); } } </script>状態管理については Auth0 Web SDK のほうが使い勝手がいいかもしれません。
まとめ
Nuxt.js でサービスを作る際の認証システムに Auth0 を使う際は Universal Login, Embedded Login ともに利用可能ですが、ログイン画面にこだわりたい場合以外は Universal Login を使いましょうという話でした。
IDaaS が盛り上がってきているのを感じているので、採用時の比較検証の一助になれば幸いです。それでは!
- 投稿日:2019-12-11T09:02:31+09:00
Nuxt.js(+Bluma) + StorybookでAtomic Designを実現する
この記事は「株式会社オープンストリーム Advent Calendar 2019」の5日目の記事です。
「Vue.jsとAtomic Designを実践した」という事例が増えてきましたね✨
そこで今回はNuxt.jsとStorybookを用意しながらAtomic Designを実践したお話をできればなと思います。(自明的に)この記事の対象としては次の3つになります。
- Nuxt.jsを使いたい(これ一つで完結したWebアプリを作りたい, SPAを簡単に導入したいなどなど)
- Storybookを使いたい
- Atomic Designを取り入れたい
開発環境
macOSと
$ sw_vers ProductName: Mac OS X ProductVersion: 10.15.1 BuildVersion: 19B88 $ node -v v13.2.0 $ npm -v 6.13.1 $ yarn -v 1.21.0Windowsで動作確認をしています。
C:\Windows>ver Microsoft Windows [Version 10.0.18363.476] ... $ node -v v13.2.0 $ npm -v 6.13.1 $ yarn -v 1.21.0Atomic Design
はじめにUIを一番小さい要素である原子(Atoms)に分割して、それを下の図のように順番に組み合わせて意味のあるUIパーツやテンプレート(ページ)を作るデザイン手法です。
( http://atomicdesign.bradfrost.com/chapter-2/ より引用 )(かなりざっくり言ってしまうと)例えばWebページでいうボタンやフォームのラベルなどがAtomsに該当します。
小さい要素であるAtomsから順番に作っておくと、例えば新しい機能を作る中で「同じようなボタンを新しく作りたい」と感じたときは
- すでにあるAtomsからMoleculesを作る
- すでにあるOrganismsに別のデータ(文言など)を投入してTemplatesに追加して配置する
など すでに書いたものを流用することができます。
Atomic Designの参考となる記事
Atomic Designについては、こちらの記事などがわかりやすいです。
https://design.dena.com/design/atomic-design-%E3%82%92%E5%88%86%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%A4%E3%82%82%E3%82%8A%E3%81%AB%E3%81%AA%E3%82%8B/Atomic Designの元となる書籍はこちらから見ることができます。
http://atomicdesign.bradfrost.com/「一番小さい単位であるAtomsではどのくらいまで分割すればいいのか」など、各コンポーネントの大きさについてはこちらがわかりやすいです。
https://speakerdeck.com/nrslib/vue-dot-js-and-atomic-design-guideline-for-components-divisionNuxt.jsでAtomic Designを実現する際のファイル構成
NuxtでAtomic Designを実現するとき、Atoms/Molecules/Organisms/Templatesは
components
以下に作るためコンポーネントとして扱います。Atomsを組み合わせてMoleculesを作る…というようにコンポーネント間の親子関係が発生します。
作るサンプル
スライダーを動かすと「レビューらしきもの」の表示件数を変えることができて、「星を表示」の操作で各「レビューらしきもの」についている星を隠したりするサンプルを考えてみましょう。
バックエンドのところは作りません。そこはNuxt.jsとか他のものでAPIを作って…
SPAのページを遷移してもデータを保持できるようにVuexを使って…も今はナシです?後にしましょうサンプルコードはこちらに置いておきます。
https://github.com/ysd-marrrr/nuxt-atomic-design-20191205プロジェクトの作成
Nuxtのプロジェクトを作るときと同様です。
create-nuxt-app
を使っています?$ npx create-nuxt-app nuxt-atomic-design-20191205 create-nuxt-app v2.12.0 ✨ Generating Nuxt.js project in nuxt-atomic-design-20191205 ? Project name nuxt-atomic-design-20191205 ? Project description My learning Nuxt.js + Atomic Design Project ? Author name ysd-marrrr ? Choose the package manager Yarn ? Choose UI framework Bulma ? Choose custom server framework None (Recommended) ? Choose Nuxt.js modules ? Choose linting tools ESLint, Prettier ? Choose test framework None ? Choose rendering mode Single Page App ? Choose development tools jsconfig.json (Recommended for VS Code)componentsにAtoms/Molecules...を作成するため、ディレクトリを予め用意します、
$ mkdir -p ./components/{atoms,molecules,organisms,templates}Atomic DesignでいうPagesはそのまま
./pages
に置きます。Storybookの設定
こちらもNuxtにStorybookを導入するときと同じようにします。
http://tacamy.hatenablog.com/entry/2019/05/27/113131$ npx -p @storybook/cli sb init --type vueNuxtのコンポーネントをそのまま使いたいので、
../components
を使うように./.storybook/config.js
を修正します。./.storybook/config.jsimport { configure } from '@storybook/vue'; configure(require.context('../components', true, /\.stories\.js$/), module);Storybook導入時のサンプルは要らないので削除します。
$ rm -rf ./storiesStorybookにBulmaのCSSを適用する
Nuxt用のWebpackとStorybook用のWebpackは別物なので、Storybook用の設定を追加します。
StorybookでもBulmaを使いたいので、ファイルを読み込めるようsass-loader
などを追加します。$ yarn add -D node-sass sass-loader mini-css-extract-pluginBulmaでWebpackを使うときと同様にStorybookの
webpack.config.js
を編集します。
https://bulma.io/documentation/customize/with-webpack/#3-5-create-a-webpack-config-webpack-4./.storybook/webpack.config.jsconst path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin') module.exports = ({ config }) => { config.resolve.alias['@'] = path.resolve(__dirname, '../') config.module.rules.push({ test: /\.scss$/, use: [ MiniCssExtractPlugin.loader, { loader: 'css-loader' }, { loader: 'sass-loader', options: { sourceMap: true } } ] }); config.plugins.push(new MiniCssExtractPlugin({ filename: 'css/mystyles.css' })); return config }この設定を追加しないとStorybookを起動したときにコンポーネントが見つからない旨のエラーが出ます
ERROR in ./components/atoms/sliders/Slider.stories.js
Module not found: Error: Can't resolve '@/components/atoms/sliders/Slider.vue' in '~/Projects/nuxt-atomic-design-20191205/components/atoms/sliders'
@ ./components/atoms/sliders/Slider.stories.js 2:0-59 6:14-20
@ ./components sync .stories.js$
@ ./.storybook/config.js
@ multi ./node_modules/@storybook/core/dist/server/common/polyfills.js ./node_modules/@storybook/core/dist/server/preview/globals.js ./.storybook/config.js (webpack)-hot-middleware/client.js?reload=true&quiet=trueまた、MiniCssExtractPluginが設定されていないとStorybookにBulmaが適用できません。
私の環境ではこの後にcore-jsをダウングレードしないと core-jsの不具合でNuxtのビルドができなくなってしまったので、必要に応じでダウングレードします。
https://qiita.com/auau3700/items/27bd33ee8df6d3e505f4$ yarn add core-js@2.6.9
次にStorybookにおける各コンポーネントでBulmaのCSSを読み込ませます。
.storybook
内にCommonCss.vue
を作ります。./.storybook/CommonCss.vue<template> <div class="decorator"> <slot name="story"></slot> </div> </template> <script> export default { name: 'CommonCss' } </script> <style lang="scss"> @charset "utf-8"; @import "~bulma/bulma"; </style>
CommonCss.vue
を最初に読み込むようにconfig.js
を修正します。 この読み込みでトラブルがあると「コンポーネント単体でSCSSが効かない」などおかしな挙動に襲われます(実体験)./.storybook/config.jsimport { configure, addDecorator } from '@storybook/vue'; import CommonCss from './CommonCss.vue'; addDecorator(story => ({ components: { CommonCss }, render(h) { return ( <common-css> <story slot="story"></story> </common-css> ) } })) // automatically import all files ending in *.stories.js configure(require.context('../components', true, /\.stories\.js$/), module);Atoms
コンポーネントを作る際は、コンポーネントの本体となる
.vue
ファイルと、Storybookの設定である.stories.js
を用意します。
また、Atoms/Molecules...の中にも大量にコンポーネントを作るため、意味のある単位でその中にディレクトリを作っておくと良いでしょう。スライダー
$ mkdir ./components/atoms/slidersコンポーネントのファイルもスライダーだけのシンプルなものにしましょう。
./components/atoms/sliders/Slider.vue<template> <input type="range" value="10" min="1" max="20" /> </template>Storybookの
storiesOf()
の第一引数はStorybookの左側に表示される階層なので、./components
と同じにしておきましょう。./components/atoms/sliders/Slider.stories.jsimport { storiesOf } from '@storybook/vue'; import Slider from '@/components/atoms/sliders/Slider.vue'; storiesOf('atoms/sliders/Slider', module).add('default', () => ({ components: { Slider }, template: '<slider />' }));切り替えボタン
$ mkdir ./components/atoms/tab切り替えボタンも同様に作りましょう。BulmaのTabsを流用します。
https://bulma.io/documentation/components/tabs/#stylesボタンの文字は仮のものを入れておきましょう。後で本当の内容に直せます。
レビュー表示部
$ mkdir ./components/atoms/labels $ mkdir ./components/atoms/boxesレビュー表示部も同様です。こちらは
- タイトル
- レーティング(星5つです!)
- 本文
- それらを囲む枠(Box)
に分けます。
ボタンのときと同じく 内容を入れたい衝動 に駆られますが、ここは抑えて仮の文字を入れましょう。 本当の文字はもっと上のレイヤーの Pages で入れるべきです。枠はBulmaのBoxを流用しちゃいましょう。
https://bulma.io/documentation/elements/box/Boxの中にパーツを配置したいため、BoxのコンポーネントではSlotを用います。
./components/atoms/boxes/Box.vue<template> <div class="box"><slot /></div> </template>ちなみに、このAtomsの段階ではBoxのwidthは100%と横幅いっぱいにします。実際に配置するときはCSSフレームワークのグリッドシステムなどで横幅を決めます。
わかりやすいように仮に文字を入れたサンプルですが、影がついているBoxの横幅がいっぱいに広がっています。
Molecules
Atomsを組み合わせてMoleculesを作ります。現時点では コンポーネントのファイルでAtomsコンポーネントを読み込む以外は同じ作りにします。
Storybookの設定はMoleculesになっても変わりはなく、Atomsと同様にコンポーネントを指定します。
./components/molecules/reviews/ReviewBox.stories.jsimport { storiesOf } from '@storybook/vue' import ReviewsBox from '~/components/molecules/reviews/ReviewsBox.vue' storiesOf('molecules/reviews/ReviewsBox', module).add('default', () => ({ components: { ReviewsBox }, template: '<reviews-box />' }))レビューの星
$ mkdir ./components/molecules/ratings星を繰り返し表示できるようにします。
この場合はv-for
で「リストの要素に応じて繰り返す」のではなく「x回繰り返す」実装にするため、次に紹介されているparseInt()
,index in x
のような方法にしました。
v-for
において必須であるv-bind:key
が重複してしまう問題は解決できていません。
https://stackoverflow.com/questions/44617484/vue-js-loop-via-v-for-x-times-in-a-range星を表示する数は仮のものを用意します。
./components/molecules/ratings/Ratings.vue<template> <div class="ratings"> <span v-for="activeStar in stars" :key="activeStar"> <rating-star /> </span> </div> </template> <script> import RatingStar from '@/components/atoms/ratings/RatingStar.vue' export default { components: { RatingStar }, data() { return { stars: 3 } } } </script>スライダー
$ mkdir ./components/molecules/slidersこのMoleculesでは次のAtomsを読み込んで組み合わせていきます。
- どんなことをするスライダーなのか、その名前(実際の値は後述する
props
を使う方法で入れます)- スライダー本体
- スライダーの値を表示する部分
./components/molecules/sliders/DisplayAmountSlider.vue<template> <div class="display-stars-panel columns"> <div class="column is-one-fifth"><default-label /></div> <div class="column is-two-fifths"><slider /></div> <div class="column is-one-fifth"><default-label /></div> </div> </template> <script> import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue' import Slider from '@/components/atoms/sliders/Slider.vue' export default { components: { DefaultLabel, Slider } } </script>Organisms
Moleculesを組み合わせてOrganismsを作ります。
こちらもStorybookの設定はMolecules, Atomsと同様にコンポーネントを読み込ませます。コントロールパネル(スライダー+ボタン)
$ mkdir ./components/organisms/controlsMoleculesと同様にこのコンポーネントで
- スライダー部
- 切り替えボタン
を組み合わせます。
レビュー表示部(子コンポーネントにSlotを使った場合)
このようにSlotを設定した子コンポーネントの タグの中に パーツを配置すると、子コンポーネントで定義した
<slot/>
の位置に入ります。./components/molecules/reviews/ReviewBox.vue<template> <box> <rating-star /> <review-title /> <default-label /> </box> </template> <style lang="scss" scoped></style> <script> import RatingStar from '@/components/atoms/ratings/RatingStar.vue' import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue' import ReviewTitle from '@/components/atoms/labels/ReviewTitle.vue' import Box from '@/components/atoms/boxes/Box.vue' export default { components: { RatingStar, DefaultLabel, ReviewTitle, Box } } </script>Templates
Organismsを組み合わせてTemplatesを作ります。今回は一つだけ作ります。
こちらもStorybookの設定はOrganisms, Molecules, Atomsと同様にコンポーネントを読み込ませます。あとのPagesでデータを入れるので、この段階でまだ実際のデータを入れないようにしましょう。
また、Nuxtの
layouts
と混同しそうになりますが、layouts
は各ページで共通して表示するヘッダーを記述するだけにとどめます。
Templatesはページごとに異なる内容で、Atomsから組み上げたときに作ります。
(ヘッダーを作り込むときはAtomsから必要になるかもしれません)Pages
Templatesを読み込んで(後ほど実データを投入して)Pagesを作ります。 こちらは
components
ディレクトリではなくpages
ディレクトリを使います。実際のデータを入れず、コンポーネントを組み合わせてここまで実装するとこんな感じになると思います。
(ヘッダー部分はlayouts
ディレクトリのファイルにて設定しています)親子コンポーネント間のやり取り(props, events)
ここからは作成した各コンポーネントに実際のデータを入れていく作業になります。
図のほうがわかりやすいので、例えばスライダーを操作したときのデータのやり取りを図にすると次の通りになります。
スライダーをユーザーが操作した際に子コンポーネントに反映させる場合もそうですが、 親のpagesで設定した初期値を子コンポーネントに渡すときも
props
を使います。ラベルに文字を表示する:
props
props
を設定して、渡された値をそのまま表示できるようにします。
後述する「props
を直接変更しないように実装する必要がある」という課題を解決するためにprops
の名前は末尾にProp
とつけています。./components/atoms/labels/DefaultLabel.vue<template> <span>{{ displayTextProp }}</span> </template> <script> export default { props: { displayTextProp: { type: String } } } </script>使う側からは
v-bind
でprops
にデータを投入します。
この実装では、
DefaultLabel
としてラベルを使いまわしていて- 「表示する数を選択」の文言は 表示する件数を制御するスライダーの Moleculesの段階で表示できればいいので
そのまま入れてしまいます。
./components/molecules/silders/DisplayAmountSlider.vue<template> <div class="display-stars-panel columns"> <!-- Moleculesの段階で固定の文言として表示したいので入れる --> <div class="column is-one-fifth"><default-label :display-text-prop="'表示する数を選択'"/></div> <!-- ここから下は親の設定値を動的に反映させるので後で --> <div class="column is-two-fifths"><slider /></div> <div class="column is-one-fifth"><default-label /></div> </div> </template>Storybookで「Atoms単体」として出したいときのために
props
のデフォルト値を設定する
props
にdefault
を設定すると、AtomsだけをStorybookで開いたときに「ラベルだと真っ白…」ということがなくなります。./components/atoms/labels/DefaultLabel.vueprops: { displayTextProp: { type: String, default: 'Type text here' } }また、ESLintの設定によっては
props
のdefault
を設定しないとwarningが出ることがあります。21:5 warning Prop 'displayAmountProp' requires default value to be set vue/require-default-prop
ただし、ボタン(タブ)のデータとなる
props
にもdefault
を設定して、これを子コンポーネント全部に入れるとなると……悲惨なことになります。
props
にdefault
が必要なのはわかりますが…これはまだ解決できていません?♂️... props: { availableOptionsProp: { type: Array, default: function() { return [ { value: 'false', text: 'A' }, { value: 'true', text: 'B' } ] } } }, data: function() { return { availableOptions: this.availableOptionsProp } }子コンポーネントから動的な値を親に送る:
events
/this.$emit()
例えば「スライダーの値を変更したとき、その値を使って何か フロントエンド(クライアント)側で 処理をしたい」という場面があります。
その時にはevent
にメソッドを紐づけて、メソッド内のthis.$emit()
で変更された値を親に送ります。子で処理しようとすると「どこで処理しているのかわからない」「コンポーネントを使いまわしたいけどイベントで勝手に動く」など混乱の原因になります。
./components/atoms/sliders/Slider.vue<template> <div> <input type="range" min="1" max="20" v-model="sliderValue" @change="onSliderChange" /> </div> </template> <script> export default { props: { sliderValueProp: { type: Number, default: 0 } }, data: function() { return { sliderValue: this.sliderValueProp } }, methods: { onSliderChange: function() { this.$emit('sliderUpdate', this.sliderValue) } } } </script>さらにイベントを親に渡したいときは 受け取ったイベントにmethodsを紐づけて、もう一度イベントを送ります。
子コンポーネントからthis.$emit()
の第2引数で送った値はmethods
で定義したメソッドの引数で受け取ることができます。/components/molecules/sliders/DisplayAmountSlider.vue... <div class="column is-two-fifths"><slider @sliderUpdate="onSliderUpdate"/></div> </template> <script> import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue' import Slider from '@/components/atoms/sliders/Slider.vue' export default { components: { DefaultLabel, Slider }, props: { displayAmountProp: { type: Number, default: 0 } }, data: function() { return { displayAmount: this.displayAmountProp } }, methods: { onSliderUpdate: function(newValue){ this.$emit('onSliderUpdate', newValue) } } ...親で処理が終わった後、そのデータを子に反映させたいときは
props
を用います。./pages/index.vue<template> <div class="root-contents"> <main-template :display-amount-prop="displayAmount" :is-display-stars-prop="isDisplayStars" :display-stars-options-prop="displayStarsOptions" :review-data-prop="actualDisplayData" @onDisplaySettingsChanged="onDisplaySettingsChanged" /> </div> </template>さらにpropsを子コンポーネントに渡したいときは、一度propsの値を受けてから再度propsを設定します。
./components/templates/MainTemplate.vue<template> <div class="container"> <controll-panel :display-amount-prop="displayAmount" :is-display-stars-prop="isDisplayStars" :display-stars-options-prop="displayStarsOptionsProp" @onDisplaySettingsChanged="onDisplaySettingsChanged" /> ... </template> <script> ... export default { components: { ControllPanel, ReviewsBox }, props: { displayAmountProp: { type: Number, default: 0 }, ...
v-bind
などでprops
を直接変更させないためにスライダーの値を取得したいときに次の実装をすると問題が出ます。
- スライダーの
value
にprops
を紐づける: 反応しない- スライダーの
v-bind
にprops
を紐づける: 反応するが warningが出るvue.esm.js:628 [Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "sliderValueProp"
そのため「
v-model
に紐づけて変更する用」にdata
を用意しないといけません。
コンポーネントのdata
なのかprops
なのかを区別する必要があるため、props
としている変数は末尾にProps
とつけています。このように
data
も設定するとより子コンポーネントで状態を保持している感が強まるので混乱しないためにも
- 親コンポーネントに変更後の値を渡して
- 親で通信などの処理をしてから
その結果を子に反映させる実装をします。
親から
props
を受け取りたい場合はdata
ではなくcomputed
にしないと反映されない…と言いたいのですが、
props
の値をdata
で受ける実装にしてしまうと、親からデータが渡ってきたときに それが反映されません。反映されない例<script> export default { ... props: { displayAmountProp: { type: Number, default: 0 } }, data() { return { displayAmount: this.displayAmountProp } }, ...この場合
data
の代わりにcomputed
を使います。反映される例<script> export default { ... props: { displayAmountProp: { type: Number, default: 0 } }, computed: { displayAmount() { return this.displayAmountProp } }, ...
props
/events
でコンポーネント間のデータのやり取りをしていて一番ハマったポイントです。
スライダーなどユーザーの操作で変更される可能性があるprops
はcomputed
と併用しましょう。動作確認
組みあがったものの動作を確認したいときは次のコマンドを
$ yarn dev
StorybookでUIパーツを確認したいときは次のコマンドを使います(今頃かよ、という気がしますが)
$ yarn storybook
*Windowsの場合は
C:\Windows\system32
を環境変数に追加しないと、ブラウザを開けずにStorybookが終了します。
https://qiita.com/maedadada/items/97c54b68d5825b60c393おわりに
Atomic Designを実践し、WebアプリのUIをコンポーネントにすることで「この機能増やしてー」と頼まれたときにAtoms/Molecules/Organismsを流用して変更を素早く実現できています。
今のところは?
ただし、その一方で
- Atoms/Molecules/Organismsの3つにUIパーツが全て収まらない
- Atomsを用いたOrganismsが出てきてしまう、など悩ましい状況が出てくる
- コンポーネントを大量に作成したり、大量のpropsやeventsを設定する、となると時間がかかる
そしてAdvent Calendarに間に合わなくなる?♂️といった問題があります。
適切にAtomsなどに分割して仕様変更に対応しやすい構造を目指しましょう。(後ほど使いまわしたい共用部分が発生して)手戻りを許容するのであれば、Atomsを作成して
props
/events
を設定するコストを減らすためにMoleculesから作成するのもアリだと考えています。
Advent Calendar遅れてホント申し訳ない最後に、このサンプルのソースコードはこちらです。
https://github.com/ysd-marrrr/nuxt-atomic-design-20191205\次は @miyatay さんです/
参考
Atomic Design by Brad Frost
http://atomicdesign.bradfrost.com/Atomic Design を分かったつもりになる | DeNA DESIGN BLOG
https://design.dena.com/design/atomic-design-%E3%82%92%E5%88%86%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%A4%E3%82%82%E3%82%8A%E3%81%AB%E3%81%AA%E3%82%8B/Vue.js × Atomic Design - コンポーネント分割の指針 / Vue.js and Atomic Design - Guideline for components division
https://speakerdeck.com/nrslib/vue-dot-js-and-atomic-design-guideline-for-components-divisionNuxt.jsでStorybookを使用してみたメモ - WebTecNote
https://tenderfeel.xsrv.jp/javascript/4121/Nuxt.jsへのStorybookの導入と、Sassの変数や共通CSSを読めるようにする設定 - tacamy--blog
http://tacamy.hatenablog.com/entry/2019/05/27/113131Nuxt.js + Vuetifyのセットアップでcore-js関係の依存関係が見つからないと怒られた
https://qiita.com/auau3700/items/27bd33ee8df6d3e505f4Vue Js - Loop via v-for X times (in a range)
https://stackoverflow.com/questions/44617484/vue-js-loop-via-v-for-x-times-in-a-rangeng s -oコマンド実行時の「spawn cmd ENOENT」エラー対処法
https://qiita.com/maedadada/items/97c54b68d5825b60c393
- 投稿日:2019-12-11T09:02:31+09:00
Nuxt.js(+Bulma) + StorybookでAtomic Designを実現する
この記事は「株式会社オープンストリーム Advent Calendar 2019」の5日目の記事です。
「Vue.jsとAtomic Designを実践した」という事例が増えてきましたね✨
そこで今回はNuxt.jsとStorybookを用意しながらAtomic Designを実践したお話をできればなと思います。(自明的に)この記事の対象としては次の3つになります。
- Nuxt.jsを使いたい(これ一つで完結したWebアプリを作りたい, SPAを簡単に導入したいなどなど)
- Storybookを使いたい
- Atomic Designを取り入れたい
開発環境
macOSと
$ sw_vers ProductName: Mac OS X ProductVersion: 10.15.1 BuildVersion: 19B88 $ node -v v13.2.0 $ npm -v 6.13.1 $ yarn -v 1.21.0Windowsで動作確認をしています。
C:\Windows>ver Microsoft Windows [Version 10.0.18363.476] ... $ node -v v13.2.0 $ npm -v 6.13.1 $ yarn -v 1.21.0Atomic Design
はじめにUIを一番小さい要素である原子(Atoms)に分割して、それを下の図のように順番に組み合わせて意味のあるUIパーツやテンプレート(ページ)を作るデザイン手法です。
( http://atomicdesign.bradfrost.com/chapter-2/ より引用 )(かなりざっくり言ってしまうと)例えばWebページでいうボタンやフォームのラベルなどがAtomsに該当します。
小さい要素であるAtomsから順番に作っておくと、例えば新しい機能を作る中で「同じようなボタンを新しく作りたい」と感じたときは
- すでにあるAtomsからMoleculesを作る
- すでにあるOrganismsに別のデータ(文言など)を投入してTemplatesに追加して配置する
など すでに書いたものを流用することができます。
Atomic Designの参考となる記事
Atomic Designについては、こちらの記事などがわかりやすいです。
https://design.dena.com/design/atomic-design-%E3%82%92%E5%88%86%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%A4%E3%82%82%E3%82%8A%E3%81%AB%E3%81%AA%E3%82%8B/Atomic Designの元となる書籍はこちらから見ることができます。
http://atomicdesign.bradfrost.com/「一番小さい単位であるAtomsではどのくらいまで分割すればいいのか」など、各コンポーネントの大きさについてはこちらがわかりやすいです。
https://speakerdeck.com/nrslib/vue-dot-js-and-atomic-design-guideline-for-components-divisionNuxt.jsでAtomic Designを実現する際のファイル構成
NuxtでAtomic Designを実現するとき、Atoms/Molecules/Organisms/Templatesは
components
以下に作るためコンポーネントとして扱います。Atomsを組み合わせてMoleculesを作る…というようにコンポーネント間の親子関係が発生します。
作るサンプル
スライダーを動かすと「レビューらしきもの」の表示件数を変えることができて、「星を表示」の操作で各「レビューらしきもの」についている星を隠したりするサンプルを考えてみましょう。
バックエンドのところは作りません。そこはNuxt.jsとか他のものでAPIを作って…
SPAのページを遷移してもデータを保持できるようにVuexを使って…も今はナシです?後にしましょうサンプルコードはこちらに置いておきます。
https://github.com/ysd-marrrr/nuxt-atomic-design-20191205プロジェクトの作成
Nuxtのプロジェクトを作るときと同様です。
create-nuxt-app
を使っています?$ npx create-nuxt-app nuxt-atomic-design-20191205 create-nuxt-app v2.12.0 ✨ Generating Nuxt.js project in nuxt-atomic-design-20191205 ? Project name nuxt-atomic-design-20191205 ? Project description My learning Nuxt.js + Atomic Design Project ? Author name ysd-marrrr ? Choose the package manager Yarn ? Choose UI framework Bulma ? Choose custom server framework None (Recommended) ? Choose Nuxt.js modules ? Choose linting tools ESLint, Prettier ? Choose test framework None ? Choose rendering mode Single Page App ? Choose development tools jsconfig.json (Recommended for VS Code)componentsにAtoms/Molecules...を作成するため、ディレクトリを予め用意します、
$ mkdir -p ./components/{atoms,molecules,organisms,templates}Atomic DesignでいうPagesはそのまま
./pages
に置きます。Storybookの設定
こちらもNuxtにStorybookを導入するときと同じようにします。
http://tacamy.hatenablog.com/entry/2019/05/27/113131$ npx -p @storybook/cli sb init --type vueNuxtのコンポーネントをそのまま使いたいので、
../components
を使うように./.storybook/config.js
を修正します。./.storybook/config.jsimport { configure } from '@storybook/vue'; configure(require.context('../components', true, /\.stories\.js$/), module);Storybook導入時のサンプルは要らないので削除します。
$ rm -rf ./storiesStorybookにBulmaのCSSを適用する
Nuxt用のWebpackとStorybook用のWebpackは別物なので、Storybook用の設定を追加します。
StorybookでもBulmaを使いたいので、ファイルを読み込めるようsass-loader
などを追加します。$ yarn add -D node-sass sass-loader mini-css-extract-pluginBulmaでWebpackを使うときと同様にStorybookの
webpack.config.js
を編集します。
https://bulma.io/documentation/customize/with-webpack/#3-5-create-a-webpack-config-webpack-4./.storybook/webpack.config.jsconst path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin') module.exports = ({ config }) => { config.resolve.alias['@'] = path.resolve(__dirname, '../') config.module.rules.push({ test: /\.scss$/, use: [ MiniCssExtractPlugin.loader, { loader: 'css-loader' }, { loader: 'sass-loader', options: { sourceMap: true } } ] }); config.plugins.push(new MiniCssExtractPlugin({ filename: 'css/mystyles.css' })); return config }この設定を追加しないとStorybookを起動したときにコンポーネントが見つからない旨のエラーが出ます
ERROR in ./components/atoms/sliders/Slider.stories.js
Module not found: Error: Can't resolve '@/components/atoms/sliders/Slider.vue' in '~/Projects/nuxt-atomic-design-20191205/components/atoms/sliders'
@ ./components/atoms/sliders/Slider.stories.js 2:0-59 6:14-20
@ ./components sync .stories.js$
@ ./.storybook/config.js
@ multi ./node_modules/@storybook/core/dist/server/common/polyfills.js ./node_modules/@storybook/core/dist/server/preview/globals.js ./.storybook/config.js (webpack)-hot-middleware/client.js?reload=true&quiet=trueまた、MiniCssExtractPluginが設定されていないとStorybookにBulmaが適用できません。
私の環境ではこの後にcore-jsをダウングレードしないと core-jsの不具合でNuxtのビルドができなくなってしまったので、必要に応じでダウングレードします。
https://qiita.com/auau3700/items/27bd33ee8df6d3e505f4$ yarn add core-js@2.6.9
次にStorybookにおける各コンポーネントでBulmaのCSSを読み込ませます。
.storybook
内にCommonCss.vue
を作ります。./.storybook/CommonCss.vue<template> <div class="decorator"> <slot name="story"></slot> </div> </template> <script> export default { name: 'CommonCss' } </script> <style lang="scss"> @charset "utf-8"; @import "~bulma/bulma"; </style>
CommonCss.vue
を最初に読み込むようにconfig.js
を修正します。 この読み込みでトラブルがあると「コンポーネント単体でSCSSが効かない」などおかしな挙動に襲われます(実体験)./.storybook/config.jsimport { configure, addDecorator } from '@storybook/vue'; import CommonCss from './CommonCss.vue'; addDecorator(story => ({ components: { CommonCss }, render(h) { return ( <common-css> <story slot="story"></story> </common-css> ) } })) // automatically import all files ending in *.stories.js configure(require.context('../components', true, /\.stories\.js$/), module);Atoms
コンポーネントを作る際は、コンポーネントの本体となる
.vue
ファイルと、Storybookの設定である.stories.js
を用意します。
また、Atoms/Molecules...の中にも大量にコンポーネントを作るため、意味のある単位でその中にディレクトリを作っておくと良いでしょう。スライダー
$ mkdir ./components/atoms/slidersコンポーネントのファイルもスライダーだけのシンプルなものにしましょう。
./components/atoms/sliders/Slider.vue<template> <input type="range" value="10" min="1" max="20" /> </template>Storybookの
storiesOf()
の第一引数はStorybookの左側に表示される階層なので、./components
と同じにしておきましょう。./components/atoms/sliders/Slider.stories.jsimport { storiesOf } from '@storybook/vue'; import Slider from '@/components/atoms/sliders/Slider.vue'; storiesOf('atoms/sliders/Slider', module).add('default', () => ({ components: { Slider }, template: '<slider />' }));切り替えボタン
$ mkdir ./components/atoms/tab切り替えボタンも同様に作りましょう。BulmaのTabsを流用します。
https://bulma.io/documentation/components/tabs/#stylesボタンの文字は仮のものを入れておきましょう。後で本当の内容に直せます。
レビュー表示部
$ mkdir ./components/atoms/labels $ mkdir ./components/atoms/boxesレビュー表示部も同様です。こちらは
- タイトル
- レーティング(星5つです!)
- 本文
- それらを囲む枠(Box)
に分けます。
ボタンのときと同じく 内容を入れたい衝動 に駆られますが、ここは抑えて仮の文字を入れましょう。 本当の文字はもっと上のレイヤーの Pages で入れるべきです。枠はBulmaのBoxを流用しちゃいましょう。
https://bulma.io/documentation/elements/box/Boxの中にパーツを配置したいため、BoxのコンポーネントではSlotを用います。
./components/atoms/boxes/Box.vue<template> <div class="box"><slot /></div> </template>ちなみに、このAtomsの段階ではBoxのwidthは100%と横幅いっぱいにします。実際に配置するときはCSSフレームワークのグリッドシステムなどで横幅を決めます。
わかりやすいように仮に文字を入れたサンプルですが、影がついているBoxの横幅がいっぱいに広がっています。
Molecules
Atomsを組み合わせてMoleculesを作ります。現時点では コンポーネントのファイルでAtomsコンポーネントを読み込む以外は同じ作りにします。
Storybookの設定はMoleculesになっても変わりはなく、Atomsと同様にコンポーネントを指定します。
./components/molecules/reviews/ReviewBox.stories.jsimport { storiesOf } from '@storybook/vue' import ReviewsBox from '~/components/molecules/reviews/ReviewsBox.vue' storiesOf('molecules/reviews/ReviewsBox', module).add('default', () => ({ components: { ReviewsBox }, template: '<reviews-box />' }))レビューの星
$ mkdir ./components/molecules/ratings星を繰り返し表示できるようにします。
この場合はv-for
で「リストの要素に応じて繰り返す」のではなく「x回繰り返す」実装にするため、次に紹介されているparseInt()
,index in x
のような方法にしました。
v-for
において必須であるv-bind:key
が重複してしまう問題は解決できていません。
https://stackoverflow.com/questions/44617484/vue-js-loop-via-v-for-x-times-in-a-range星を表示する数は仮のものを用意します。
./components/molecules/ratings/Ratings.vue<template> <div class="ratings"> <span v-for="activeStar in stars" :key="activeStar"> <rating-star /> </span> </div> </template> <script> import RatingStar from '@/components/atoms/ratings/RatingStar.vue' export default { components: { RatingStar }, data() { return { stars: 3 } } } </script>スライダー
$ mkdir ./components/molecules/slidersこのMoleculesでは次のAtomsを読み込んで組み合わせていきます。
- どんなことをするスライダーなのか、その名前(実際の値は後述する
props
を使う方法で入れます)- スライダー本体
- スライダーの値を表示する部分
./components/molecules/sliders/DisplayAmountSlider.vue<template> <div class="display-stars-panel columns"> <div class="column is-one-fifth"><default-label /></div> <div class="column is-two-fifths"><slider /></div> <div class="column is-one-fifth"><default-label /></div> </div> </template> <script> import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue' import Slider from '@/components/atoms/sliders/Slider.vue' export default { components: { DefaultLabel, Slider } } </script>Organisms
Moleculesを組み合わせてOrganismsを作ります。
こちらもStorybookの設定はMolecules, Atomsと同様にコンポーネントを読み込ませます。コントロールパネル(スライダー+ボタン)
$ mkdir ./components/organisms/controlsMoleculesと同様にこのコンポーネントで
- スライダー部
- 切り替えボタン
を組み合わせます。
レビュー表示部(子コンポーネントにSlotを使った場合)
このようにSlotを設定した子コンポーネントの タグの中に パーツを配置すると、子コンポーネントで定義した
<slot/>
の位置に入ります。./components/molecules/reviews/ReviewBox.vue<template> <box> <rating-star /> <review-title /> <default-label /> </box> </template> <style lang="scss" scoped></style> <script> import RatingStar from '@/components/atoms/ratings/RatingStar.vue' import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue' import ReviewTitle from '@/components/atoms/labels/ReviewTitle.vue' import Box from '@/components/atoms/boxes/Box.vue' export default { components: { RatingStar, DefaultLabel, ReviewTitle, Box } } </script>Templates
Organismsを組み合わせてTemplatesを作ります。今回は一つだけ作ります。
こちらもStorybookの設定はOrganisms, Molecules, Atomsと同様にコンポーネントを読み込ませます。あとのPagesでデータを入れるので、この段階でまだ実際のデータを入れないようにしましょう。
また、Nuxtの
layouts
と混同しそうになりますが、layouts
は各ページで共通して表示するヘッダーを記述するだけにとどめます。
Templatesはページごとに異なる内容で、Atomsから組み上げたときに作ります。
(ヘッダーを作り込むときはAtomsから必要になるかもしれません)Pages
Templatesを読み込んで(後ほど実データを投入して)Pagesを作ります。 こちらは
components
ディレクトリではなくpages
ディレクトリを使います。実際のデータを入れず、コンポーネントを組み合わせてここまで実装するとこんな感じになると思います。
(ヘッダー部分はlayouts
ディレクトリのファイルにて設定しています)親子コンポーネント間のやり取り(props, events)
ここからは作成した各コンポーネントに実際のデータを入れていく作業になります。
図のほうがわかりやすいので、例えばスライダーを操作したときのデータのやり取りを図にすると次の通りになります。
スライダーをユーザーが操作した際に子コンポーネントに反映させる場合もそうですが、 親のpagesで設定した初期値を子コンポーネントに渡すときも
props
を使います。ラベルに文字を表示する:
props
props
を設定して、渡された値をそのまま表示できるようにします。
後述する「props
を直接変更しないように実装する必要がある」という課題を解決するためにprops
の名前は末尾にProp
とつけています。./components/atoms/labels/DefaultLabel.vue<template> <span>{{ displayTextProp }}</span> </template> <script> export default { props: { displayTextProp: { type: String } } } </script>使う側からは
v-bind
でprops
にデータを投入します。
この実装では、
DefaultLabel
としてラベルを使いまわしていて- 「表示する数を選択」の文言は 表示する件数を制御するスライダーの Moleculesの段階で表示できればいいので
そのまま入れてしまいます。
./components/molecules/silders/DisplayAmountSlider.vue<template> <div class="display-stars-panel columns"> <!-- Moleculesの段階で固定の文言として表示したいので入れる --> <div class="column is-one-fifth"><default-label :display-text-prop="'表示する数を選択'"/></div> <!-- ここから下は親の設定値を動的に反映させるので後で --> <div class="column is-two-fifths"><slider /></div> <div class="column is-one-fifth"><default-label /></div> </div> </template>Storybookで「Atoms単体」として出したいときのために
props
のデフォルト値を設定する
props
にdefault
を設定すると、AtomsだけをStorybookで開いたときに「ラベルだと真っ白…」ということがなくなります。./components/atoms/labels/DefaultLabel.vueprops: { displayTextProp: { type: String, default: 'Type text here' } }また、ESLintの設定によっては
props
のdefault
を設定しないとwarningが出ることがあります。21:5 warning Prop 'displayAmountProp' requires default value to be set vue/require-default-prop
ただし、ボタン(タブ)のデータとなる
props
にもdefault
を設定して、これを子コンポーネント全部に入れるとなると……悲惨なことになります。
props
にdefault
が必要なのはわかりますが…これはまだ解決できていません?♂️... props: { availableOptionsProp: { type: Array, default: function() { return [ { value: 'false', text: 'A' }, { value: 'true', text: 'B' } ] } } }, data: function() { return { availableOptions: this.availableOptionsProp } }子コンポーネントから動的な値を親に送る:
events
/this.$emit()
例えば「スライダーの値を変更したとき、その値を使って何か フロントエンド(クライアント)側で 処理をしたい」という場面があります。
その時にはevent
にメソッドを紐づけて、メソッド内のthis.$emit()
で変更された値を親に送ります。子で処理しようとすると「どこで処理しているのかわからない」「コンポーネントを使いまわしたいけどイベントで勝手に動く」など混乱の原因になります。
./components/atoms/sliders/Slider.vue<template> <div> <input type="range" min="1" max="20" v-model="sliderValue" @change="onSliderChange" /> </div> </template> <script> export default { props: { sliderValueProp: { type: Number, default: 0 } }, data: function() { return { sliderValue: this.sliderValueProp } }, methods: { onSliderChange: function() { this.$emit('sliderUpdate', this.sliderValue) } } } </script>さらにイベントを親に渡したいときは 受け取ったイベントにmethodsを紐づけて、もう一度イベントを送ります。
子コンポーネントからthis.$emit()
の第2引数で送った値はmethods
で定義したメソッドの引数で受け取ることができます。/components/molecules/sliders/DisplayAmountSlider.vue... <div class="column is-two-fifths"><slider @sliderUpdate="onSliderUpdate"/></div> </template> <script> import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue' import Slider from '@/components/atoms/sliders/Slider.vue' export default { components: { DefaultLabel, Slider }, props: { displayAmountProp: { type: Number, default: 0 } }, data: function() { return { displayAmount: this.displayAmountProp } }, methods: { onSliderUpdate: function(newValue){ this.$emit('onSliderUpdate', newValue) } } ...親で処理が終わった後、そのデータを子に反映させたいときは
props
を用います。./pages/index.vue<template> <div class="root-contents"> <main-template :display-amount-prop="displayAmount" :is-display-stars-prop="isDisplayStars" :display-stars-options-prop="displayStarsOptions" :review-data-prop="actualDisplayData" @onDisplaySettingsChanged="onDisplaySettingsChanged" /> </div> </template>さらにpropsを子コンポーネントに渡したいときは、一度propsの値を受けてから再度propsを設定します。
./components/templates/MainTemplate.vue<template> <div class="container"> <controll-panel :display-amount-prop="displayAmount" :is-display-stars-prop="isDisplayStars" :display-stars-options-prop="displayStarsOptionsProp" @onDisplaySettingsChanged="onDisplaySettingsChanged" /> ... </template> <script> ... export default { components: { ControllPanel, ReviewsBox }, props: { displayAmountProp: { type: Number, default: 0 }, ...
v-bind
などでprops
を直接変更させないためにスライダーの値を取得したいときに次の実装をすると問題が出ます。
- スライダーの
value
にprops
を紐づける: 反応しない- スライダーの
v-bind
にprops
を紐づける: 反応するが warningが出るvue.esm.js:628 [Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "sliderValueProp"
そのため「
v-model
に紐づけて変更する用」にdata
を用意しないといけません。
コンポーネントのdata
なのかprops
なのかを区別する必要があるため、props
としている変数は末尾にProps
とつけています。このように
data
も設定するとより子コンポーネントで状態を保持している感が強まるので混乱しないためにも
- 親コンポーネントに変更後の値を渡して
- 親で通信などの処理をしてから
その結果を子に反映させる実装をします。
親から
props
を受け取りたい場合はdata
ではなくcomputed
にしないと反映されない…と言いたいのですが、
props
の値をdata
で受ける実装にしてしまうと、親からデータが渡ってきたときに それが反映されません。反映されない例<script> export default { ... props: { displayAmountProp: { type: Number, default: 0 } }, data() { return { displayAmount: this.displayAmountProp } }, ...この場合
data
の代わりにcomputed
を使います。反映される例<script> export default { ... props: { displayAmountProp: { type: Number, default: 0 } }, computed: { displayAmount() { return this.displayAmountProp } }, ...
props
/events
でコンポーネント間のデータのやり取りをしていて一番ハマったポイントです。
スライダーなどユーザーの操作で変更される可能性があるprops
はcomputed
と併用しましょう。動作確認
組みあがったものの動作を確認したいときは次のコマンドを
$ yarn dev
StorybookでUIパーツを確認したいときは次のコマンドを使います(今頃かよ、という気がしますが)
$ yarn storybook
*Windowsの場合は
C:\Windows\system32
を環境変数に追加しないと、ブラウザを開けずにStorybookが終了します。
https://qiita.com/maedadada/items/97c54b68d5825b60c393おわりに
Atomic Designを実践し、WebアプリのUIをコンポーネントにすることで「この機能増やしてー」と頼まれたときにAtoms/Molecules/Organismsを流用して変更を素早く実現できています。
今のところは?
ただし、その一方で
- Atoms/Molecules/Organismsの3つにUIパーツが全て収まらない
- Atomsを用いたOrganismsが出てきてしまう、など悩ましい状況が出てくる
- コンポーネントを大量に作成したり、大量のpropsやeventsを設定する、となると時間がかかる
そしてAdvent Calendarに間に合わなくなる?♂️といった問題があります。
適切にAtomsなどに分割して仕様変更に対応しやすい構造を目指しましょう。(後ほど使いまわしたい共用部分が発生して)手戻りを許容するのであれば、Atomsを作成して
props
/events
を設定するコストを減らすためにMoleculesから作成するのもアリだと考えています。
Advent Calendar遅れてホント申し訳ない最後に、このサンプルのソースコードはこちらです。
https://github.com/ysd-marrrr/nuxt-atomic-design-20191205\次は @miyatay さんです/
参考
Atomic Design by Brad Frost
http://atomicdesign.bradfrost.com/Atomic Design を分かったつもりになる | DeNA DESIGN BLOG
https://design.dena.com/design/atomic-design-%E3%82%92%E5%88%86%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%A4%E3%82%82%E3%82%8A%E3%81%AB%E3%81%AA%E3%82%8B/Vue.js × Atomic Design - コンポーネント分割の指針 / Vue.js and Atomic Design - Guideline for components division
https://speakerdeck.com/nrslib/vue-dot-js-and-atomic-design-guideline-for-components-divisionNuxt.jsでStorybookを使用してみたメモ - WebTecNote
https://tenderfeel.xsrv.jp/javascript/4121/Nuxt.jsへのStorybookの導入と、Sassの変数や共通CSSを読めるようにする設定 - tacamy--blog
http://tacamy.hatenablog.com/entry/2019/05/27/113131Nuxt.js + Vuetifyのセットアップでcore-js関係の依存関係が見つからないと怒られた
https://qiita.com/auau3700/items/27bd33ee8df6d3e505f4Vue Js - Loop via v-for X times (in a range)
https://stackoverflow.com/questions/44617484/vue-js-loop-via-v-for-x-times-in-a-rangeng s -oコマンド実行時の「spawn cmd ENOENT」エラー対処法
https://qiita.com/maedadada/items/97c54b68d5825b60c393
- 投稿日:2019-12-11T05:55:35+09:00
【モダン技術習得への道】DockerでのNuxt.js / Vuetify開発環境構築による、迅速かつ効率的なプロジェクト作成
概要
- 今回はDockerを利用して、Nuxt.js / Vuetify開発環境を迅速に構築する。
- 下記の2つのフレームワークを利用することで、迅速に効率的におしゃれな画面を作成することができる。
結論
- 下記の2つのコマンドで、Nuxt.js / VuetifyのDocker開発環境が作成・起動され、迅速に開発を始められる。
docker-compose build
docker-compose up -d
- 最終的なフォルダ構成は下記。
. ├── README.md ├── node_modules ├── nuxt.config.js ├── package.json ├── src └── yarn.lock └── .dockerignore └── Dockerfile └── docker-compose.yml
- Nuxt.js / Vuetifyを利用することにより、下記のような画面を素早く効率的に作成することができる。
環境
- Mac OS X 10.14.5
- Node.js v11.11.0
- yarn 1.17.3
- yarnを未導入の場合、こちらでインストールするかnpmでの置き換えを行う。
- Docker 19.03.1
- docker-compose 1.24.1
- Docker・docker-compose未導入の場合、こちらを参考にインストールしておく。
手順
雛形プロジェクトの作成 & ソースフォルダの整理
- 下記のコマンドを入力して、以下の作業を行う。
- Nuxt.js / Vuetifyの雛形プロジェクトの作成
- ソースフォルダの作成・整理
- ソースパスの明記
# フォルダ作成 & 移動 mkdir -p ~/work; cd $_ # 雛形プロジェクト作成 yarn create nuxt-app t_o_d_project # 作成段階で確認される質問には、下記で対応。 # UI frameworkの部分で「Vuetify」を選択 ? Project name t_o_d_project ? Project description My first-rate Nuxt.js project ? Author name xxxxxxx ? Choose the package manager Yarn ? Choose UI framework Vuetify.js ? Choose custom server framework None (Recommended) ? Choose Nuxt.js modules axios ? Choose linting tools eslint ? Choose test framework None ? Choose rendering mode Universal (SSR) # 作成後、プロジェクトフォルダへ移動 cd ~/work/t_o_d_project # ソースフォルダ(src/)作成 & ディレクトリ移動・整理 mkdir -p src; mv pages/ store/ static/ plugins/ components/ assets/ layouts/ middleware/ $_
- 上記の作業によって、下記のディレクトリ構成になっていることを確認する。
. ├── README.md ├── node_modules ├── nuxt.config.js ├── package.json ├── src │ ├── assets │ ├── components │ ├── layouts │ ├── middleware │ ├── pages │ ├── plugins │ ├── static │ └── store └── yarn.lock
- 確認後、プロジェクト直下の
nuxt.config.js
の中身を下記のように修正する。nuxt.config.jsexport default { mode: 'universal', srcDir: 'src', // こちらの1行を追加して、ソースディレクトリパスを明記。Docker関連ファイルの作成
- 作成した雛形プロジェクトをDocker環境で立ち上げるため、以下の3つのファイルをプロジェクト直下に作成する。
- Dockerfile
- docker-compose.yml
- .dockerignore
# Dockerfileとdocker-compose.ymlと.dockerignoreの作成 touch Dockerfile docker-compose.yml .dockerignore
- 上記の作業によって、下記の最終ディレクトリ構成になっていることを確認する。
. ├── README.md ├── node_modules ├── nuxt.config.js ├── package.json ├── src └── yarn.lock └── .dockerignore └── Dockerfile └── docker-compose.yml
- 作成した3つのファイルの中身を下記のようにする。
Dockerfile# 開発環境用 FROM node:10.17.0-alpine as dev-env # コンテナソースパス定義 ENV APP_HOME /app # 必要物インストール・不要物削除 RUN apk update --no-cache && \ apk add --no-cache vim && \ rm -rf /var/cache/apk/* # コンテナソースパス作成・移動 WORKDIR ${APP_HOME} # Nuxt Host定義 ENV NUXT_HOST 0.0.0.0 # パッケージインストール COPY package*.json ./ COPY yarn.lock ./ RUN yarn # コンテナ内へのソースディレクトリのコピー COPY . . # Port公開 EXPOSE 3000 # 開発サーバー立ち上げ CMD [ "yarn", "dev"]docker-compose.ymlversion: '3.7' services: web: # コンテナ指定 build: context: . target: dev-env ports: - 3000:3000 restart: always # ローカルプロジェクトと同期 volumes: - .:/app - /app/node_modules.dockerignorenode_modules .nuxt .DS_Storeイメージの作成・起動・確認
- 上記の関連ファイルの記述後、下記のコマンドをうち、Nuxt環境のDockerイメージの作成・起動を行う。
# Dockerfileがあるパスへ移動 cd ~/work/t_o_d_project # イメージの作成 docker-compose build # イメージの起動 docker-compose up -d
- イメージの起動後、ブラウザ上で
localhost:3000
にアクセスして下記の画面になることを確認する。参考
- 投稿日:2019-12-11T02:05:24+09:00
縦にたたむtransitionを頑張って再利用しやすい形で作る
tl;dr
- UIアニメーションはユーザに対して付属的な情報を効果的に伝えることができるので、ビシバシ使っていきたい
- 特に頑張ってプレーンなCSSでアプリケーションを作っている人は、transitionを作るのが結構辛かったりする
- transitionを気合い入れて再利用しやすい形で作ると、使うときの心理的障壁を下げることができて、アプリケーションの品質向上に寄与する
- アニメーションによってはDOMのラッパーが必要な場合とかもあるけど、最悪VNode触ることでシンプルな使い勝手を確保できる
前提
この記事では縦に折りたたむtransitionをプレーンなCSS(ここではCSSプリプロセッサを使うかどうかは問わず、CSSフレームワークやUIライブラリをベースに構築しない方針を指します)で実装することを通して、再利用しやすいtransitionコンポーネントの作り方について述べていきます。
toB向け管理画面やMVPを作る場合はCSSフレームワークやUIライブラリを主軸にして、CSSをなるべく自分で書かないでアプリケーションを作っていく選択肢もあります。その場合はよく使われるtransitionはあらかじめ用意されていたり、コンポーネントに実装されていたりすると思います。
しかし、
- アプリケーションのあしらいやビジュアル・ブランディングについての軸がある
- 少しリッチめなユーザインタラクションを作っている・作る予定がある
- 細かいインタラクションを調整する必要がある
- 特定のCSSライブラリにフル依存することで学習コストが増す
などプレーンCSSでイチから作る選択肢を選ぶ状況などもあり、その場合は自らの手でtransitionを実装していく必要があります。
とはいえ、プレーンCSSではない技術スタックであっても、必要に応じて独自でtransitionを用意することはありえますし、再利用しやすいtransitionコンポーネントをきちんと作れることはどの環境でも有用なのではないかと思います。UIアニメーションを実装することで提供できる価値
Apple Human Interface Guidelines先輩は
Beautiful, subtle animation throughout iOS builds a visual sense of connection between people and content onscreen. When used appropriately, animation can convey status, provide feedback, enhance the sense of direct manipulation, and help users visualize the results of their actions.
Animation - Visual Design - iOS - Human Interface Guidelines - Apple Developer
(意訳)
iOSのすみずみにある美しく控えめなアニメーションは画面上のコンテンツとユーザの間に視覚的な繋がりを構築するんやで。適切にアニメーションを使うと 状態を伝えたり、フィードバックを与えたり、インタラクティブUIの操作感覚を強化したり、ユーザインタラクションの結果を伝えるための補助ができるんやなあ。
とおっしゃっていますし、Material Design先輩はMotionのUsageに関する項で
- コンテンツの階層関係の説明
- ユーザ操作のフィードバックやユーザ操作に対する状態の表示
- 操作方法を示し、ユーザの学習を助ける
など、使いみちについてかなり具体的に例示しています。
実際、小難しい話をこねくり回す必要もなく、UIアニメーションを利用することで様々な情報を付加してユーザがアプリケーションを利用する手助けになることはとても理解しやすい話です。適切なUIアニメーションが適切に情報を画面表示することができれば、 ユーザの離脱を防いだり、アプリケーションに対するポジティブな感情を醸成して継続率を上げたりと、密かに数字につながっていたりということもあると思います。
それ以外にも特定のイージングやdurationを頻繁に利用することでアプリケーションのVisual Identityを構築する手助けをできれば、認知を強めたり、競合アプリケーションとの差別化を行う一助になるかもしれません。
UIアニメーションを実装するのは辛いし面倒くさい
とはいえUI実装者の皆様は思うところがあるのではないかと存じますが、UIアニメーションを作り込むのは非常に気だるい作業になることが多いと思います。
CSS TransitionやCSS Animationを利用して実装する際は、この書き方で動くだろう!と思って動かしたら、アニメーションが効いてなかったり、行きの動作はするが戻りの動作がしなかったり(なんか思たんとちゃう…)といった気持ちになる経験はありませんか?
そういったツラミな状態になったとしても、CSSではデバッグをするのも難しく、一度悩み始めると無限に時間が溶ける原因になりがちです。(console.log
するとなぜか動くのに、消すと動かなくなったりすると終業意欲がMAXになったりしますよね)また、せっかくUIアニメーションをコンポーネント志向にパッケージングしたのに、使う際に特定のスタイルを付与する必要があったり、逆に特定のスタイルをつけてはならない、などの使い方の制約があると、忘れたり知らなかったりしたときにドツボにハマりがちです。
UIアニメーションを実装するときは再利用性が重要
こうしてUIアニメーション実装に対する心理的障壁が高くなると、
- 部分的にUIからアニメーションが消失していく
- 機能実装の見積もりや実績が膨らんでしまい、逆にアプリケーションのイテレーション計画を阻害する
など、UIアニメーションを実装するメリットを打ち消して有り余るデメリットが発生します。
UIアニメーションを実装する際に(おもたんとちゃう…)という気持ちになる状況は開発ツール側の進化がないと当分解決出来ないと思うので(いい方法あるよという方コメントで教えて下さい!)一旦あきらめるとして、いざ作ったアニメーションを再度使うときに(使い方わからん…)となるのは時間の無駄ですし、逆に再利用しやすいUIアニメーションのコンポーネントを用意できれば、効率的に各所でUIアニメーションを実装できますし、効率よく価値を提供することに繋がります。
なので、UIアニメーションを実装するときは実装されたアニメーションがイケてるかだけではなく、再利用するときに悩まずに使えるか・想像した通りに使えるかも重視すべきです。
Vueでは公式に<transition>
や<transition-group>
が提供されており、これらを使ってうまくパッケージングすることでこれを実現することができます。
次の項から具体的にtransitionコンポーネントを作っていきます。本題
よくペラペラ説教をするおじさんみたいなことばかり喋ってしまってごめんなさい。作ります。
縦にたたむとはどういうことか
そもそもcss transitionで縦にたたむ動作をするためには、たたむ対象となるコンポーネントのstyleに
.target { overflow: hidden; transition: height; }を当てることでできそうだというのはすぐに思い浮かぶと思います1。
しかし、たたむ対象のコンポーネントのoverflowを直接いじると、overflowを自身で設定しているコンポーネントで表示は壊れることが容易に想像できますし、heightをいじるとなれば、レイアウトを高さに対する相対指定で行っていたり、縦flex・gridを利用している場合に崩れるでしょう。
transitionを作ることに関しては、なるべく使うときに考えたり悩んだりしないで使えるものを作るという上記の指針があるので、今回は下図のように
overflow: hidden; transition: height;
だけの表示領域を区切るためだけのラッパーを用意し、縦にたたむスタイルを実装していきます。どういうものを作るか
Vueのtransitionはslotの中に入るコンポーネントが
v-if
orv-show
が切り替わったときにtransitionが発生するというインターフェースになっています。今回作るtransitionもこのインターフェースに沿った形で、<template> <vertical-collapse-transition> <p v-if="show">lorem ipsum...</p> </vertical-collapse-transition> </template> <!-- OR --> <template> <vertical-collapse-transition> <p v-show="show">lorem ipsum...</p> </vertical-collapse-transition> </template>上記のような使い方ができるものを作ります。
内部的なラッパーをどうするか
疑似コードですが、
<template> <transition name="vertical-collapse"> <div style="overflow: hidden; transition: height;"> <slot /> </div> </transition> </template>のようなtransitionコンポーネントを作ってしまうと、
slot
のv-if
やv-show
を変更しても、transition
から見て子コンポーネントの表示が変わっていないように見えるので、transitionが発火しません。こういうときどうすればよいかというと、VNodeを直接触って
slot
の表示状態をラッパーにプロキシしてあげればよいです。実際に表示状態で取りうるパターンとしては4種類あり、
v-if="true"
v-if="false"
v-show="true"
v-show="false"
のどれかになります。
これらの場合にどういったVNodeがslotsに入ってくるかの明確な仕様はドキュメントに特に明記されてなかったので、ぼんやりしたイメージはありましたが実際にconsole.log
して中身を見ていきました。1.
v-if="true"
v-ifはそもそもVNodeを仮想DOMツリーに入れるか入れないかを制御するディレクティブなので、
v-if="true"
の場合はシンプルにslotのVNodeが入ってきます。{ render(h) { const child = this.$slots.default && this.$slots.default[0]; console.log(child); // VNode { tag: "div", ...} } }2.
v-if="false"
一を聞いて十を知る皆さんであれば、仮想DOMツリーに入れないので
undefined
とかnull
的なやつが来るのではないかと想像がついていると思います。正解です。{ render(h) { const child = this.$slots.default && this.$slots.default[0]; console.log(child); // undefined } }ですが、template compiler次第なのか、下記のようなパターンもありました。2
{ render(h) { const child = this.$slots.default && this.$slots.default[0]; console.log(child); // VNode { tag: undefined, ...} } }3.
v-show="true"
ディレクティブに関する情報
vnode.data.directives
に入ってきます。{ render(h) { const child = this.$slots.default && this.$slots.default[0]; console.log(child); // VNode { tag: "div", data: { directives: [{ // name: 'show', // value: true, // ... // }] }, ...} } }4.
v-show="false"
もちろん3と同じ構造で、valueが
false
になっているだけです。{ render(h) { const child = this.$slots.default && this.$slots.default[0]; console.log(child); // VNode { tag: "div", data: { directives: [{ // name: 'show', // value: false, // ... // }] }, ...} } }条件分岐
以上のVNodeの状態をそれぞれ区別して条件分岐しつつ、適切なラッパーを返してあげるとなると、下記のような感じになります。
{ render(h) { // transitionはもともとdefault slotの一個目の要素しか見ないインターフェースなので、ほかは捨ててよい const child = this.$slots.default && this.$slots.default[0]; // TODO: あとで中を実装する const generateTransitionComponent = (children) => h('transition', {}, children); const isEmpty = !child || child.tag === undefined; if (isEmpty) { // -------------- // v-if="false" // -------------- return generateTransitionComponent([]); // transitionの中を空で返す } const vShowDirective = child.data.directives && child.data.directives.find(directive => directive.name === 'show'); const isHidden = vShowDirective && !vShowDirective.value; if (isHidden) { // -------------- // v-show="false" // -------------- child.data.directives = [ // 子のv-showディレクティブを消す ...child.data.directives.filter( directive => directive.name !== 'show' ), ]; return generateTransitionComponent([ h( 'div', // h('div') でラッパーを作る { // ラッパーにv-showディレクティブを移植する directives: [ { name: 'show', value: false, }, ], }, [child] ), ]); } if (vShowDirective && !isHidden) { // -------------- // v-show="true" // -------------- return generateTransitionComponent([ h( 'div', { // ラッパーにv-show="true"をプロキシする directives: [ { name: 'show', value: true, }, ], }, [child] ), ]); } // -------------- // v-if="true" // -------------- // 特に何もせず、ラッパーに子をそのまま入れて返す return generateTransitionComponent([h('div', {}, [child])]); } }縦にたたむ
jsを使ってtransitionを作る場合は、cssのトランジションクラス(
v-enter
などのスタイルを定義するやり方)を利用するよりも、jsフックを使ったほうが圧倒的に自由度が高くておすすめです。とくに、使いやすいtransitionを作るにはtiming function
やduration
もpropsで渡せたほうが汎用的でベターですが、その場合はjsフックだとなおさら書きやすいです。transitionおよびjsフックの詳しい仕様はドキュメントを参照してください。
(宣伝:Vue.jsのドキュメントは有志のメンバーがとてもマメに翻訳を更新しているのでぜひ公式ドキュメントを読んでください!)
https://jp.vuejs.org/v2/guide/transitions.html方針としては
- 非表示 → 表示
beforeEnter
フックでラッパーのheight
を0
にするenter
フックでラッパーのheight
を子のscrollHeight
にする- 表示 → 非表示
beforeLeave
フックでラッパーのheight
を子のscrollHeight
にするenter
フックでラッパーのheight
を0
にするというシンプルなものです。
完成形
実際に上記を実装するとこんな感じになるかと思います。
components/transitions/VerticalCollapseTransition.vue<script> import Vue, { VNode } from 'vue'; export default { props: { duration: { type: Number, default: 100, }, easing: { type: String, default: 'ease-out', }, }, render(h) { // transitionはもともとdefault slotの一個目の要素しか見ないインターフェースなので、ほかは捨ててよい const child = this.$slots.default && this.$slots.default[0]; // transition要素を作る関数 const generateTransitionComponent = childrenOfTransition => h( 'transition', { on: { beforeEnter(el) { el.style.height = '0'; }, enter(el) { el.style.height = `${el.scrollHeight}px`; }, beforeLeave(el) { el.style.height = `${el.scrollHeight}px`; }, leave(el) { // このif文がないと初回の非表示にtransitionがかからない(謎) // タイミング問題だと思われる if (el.scrollHeight !== 0) { el.style.height = '0'; } }, }, }, childrenOfTransition ); const wrapperData = { // ラッパーに常時ついているスタイル style: { overflow: 'hidden', transition: `height ${this.easing} ${this.duration}ms`, }, }; const isEmpty = !child || child.tag === undefined; if (isEmpty) { // -------------- // v-if="false" // -------------- return generateTransitionComponent([]); // transitionの中を空で返す } const vShowDirective = child.data.directives && child.data.directives.find(directive => directive.name === 'show'); const isHidden = vShowDirective && !vShowDirective.value; if (isHidden) { // -------------- // v-show="false" // -------------- child.data.directives = [ // 子のv-showディレクティブを消す ...child.data.directives.filter( directive => directive.name !== 'show' ), ]; return generateTransitionComponent([ h( 'div', // h('div') でラッパーを作る { ...wrapperData, // ラッパーにv-showディレクティブを移植する directives: [ { name: 'show', value: false, }, ], }, [child] ), ]); } if (vShowDirective && !isHidden) { // -------------- // v-show="true" // -------------- return generateTransitionComponent([ h( 'div', { ...wrapperData, // ラッパーにv-show="true"をプロキシする directives: [ { name: 'show', value: true, }, ], }, [child] ), ]); } // -------------- // v-if="true" // -------------- // 特に何もせず、ラッパーに子をそのまま入れて返す return generateTransitionComponent([h('div', wrapperData, [child])]); }, }; </script>エッジケースの処理はまだ足りていないかもしれませんが、自分のプロジェクトではこの実装が活躍してくれています。
まとめ
今回は弊リポジトリの中でも一番複雑なトランジションコンポーネントを開陳してみました。他にもフェードしたりスライドしたりと多種多様なコンポーネントがありますが、どれももっとシンプルな実装になっています。
皆様もトランジションコンポーネントを美しく共通化して、きれいなソースコードのまま、素敵なアニメーションのアプリケーションを実装していけることをお祈り申し上げます。
ぼやき
transition-group
のコンポーネント化はマジでつらい
- 投稿日:2019-12-11T01:19:01+09:00
Vue CLIでビルドするときに、アセットのパスにダイジェストをつける
Vue CLIでは、ビルド時のオプションなどでアセットファイルにダイジェストをつける設定がありません。
下はVue CLIで作ったプロジェクトをビルドしてできた
dist
ディレクトリの中身です。
各ファイルの拡張子の前についている文字列はダイジェストかと思いきや、何回ビルドしても変わりません。
そこで、
vue.config.js
に細工します。このファイルはvue create
した時点では作られないので、自分で作ります。vue.config.jsconst uuid = require('uuid') module.exports = { assetsDir: `assets/${uuid.v1()}` }
yarn add uuid
をしておきます。
assets
ディレクトリができて、その中にダイジェストがついたディレクトリができています。
ちなみに、index.html
内でアセットを読み込むパスも正しく書き換わっています。
これでホスティングの設定で/assets/**/*
にだけキャッシュを強く効かせるなどしやすくなりますね。
- 投稿日:2019-12-11T01:05:00+09:00
Vue2にComposition APIを追加してみた
はじめに
Composition APIのVue2への導入方法と基本的な機能についての実装方法を試した。
基本的に公式のガイドおよびQiitaの既存記事の内容であるが、Vueユーザにとって当たり前(?)の部分が省略されておりVue初心者の自分にとって手探りな部分があったので、やや冗長ながら極力省略せずに実装方法を紹介する。開発環境
- Vue CLI: 4.1.1
- Visual Studio Code: 1.40.2
- Visual Studio Code拡張機能
- Vetur: 0.22.6
- Vue 2 Snippets: 0.1.11
- Vue Peek: 1.0.2
- ESLint 1.9.1
プロジェクト作成
ベース作成
テンプレート作成
Vue CLIを使用してテンプレートを作成する。TypeScriptを導入するがクラススタイルにはしなくてよい。(どちらにせよ後でまるごと書き換える)
? Check the features needed for your project: TS, Linter, Unit ? Use class-style component syntax? NoTypeScriptアップデート(Optional)
Vue CLIで作成した場合、TypeScriptのバージョンは3.5.3になる(2019/12/10時点)ので3.7.3にアップデートした。(詳細は補足で)
Lintルール変更(Optional)
基本的には推奨設定に従うが、個人的な趣味でLintルールを一部変更した。(詳細は補足で)
以下で紹介するコードは変更後ルールに従っているのであしからず。Composition API追加
本題であるComposition APIを利用するためにライブラリを追加する。
依存関係を追加
npm install @vue/composition-apiライブラリの利用を宣言
APIの使用に先立ってライブラリ利用の宣言を追加する。
main.tsimport Vue from 'vue'; import VueCompositionApi from '@vue/composition-api'; // ★追加 import App from './App.vue'; Vue.use(VueCompositionApi); // ★追加 Vue.config.productionTip = false; new Vue({ render: h => h(App), }).$mount('#app');Composition APIでの実装
ローカルな変数・関数
まずはComposition APIによるコンポーネント作成の基本形を示す。
App.vue<template> <div id="app"><!-- ★内容変更 --> <div>{{msg}}</div> <button @click="changeMessage">hello</button> </div> </template> <script lang="ts"> import { createComponent, ref } from '@vue/composition-api'; // ★'vue'に代えてimport export default createComponent({ // ★export defaultの内容全面書き換え setup: () => { const msg = ref('Hello!'); const changeMessage = () => { msg.value = 'What\'s up?'; }; return { msg, changeMessage, }; }, }); </script> <!-- styleは省略 -->
createComponent
関数の戻り値をデフォルトエクスポートする
Vue
は直接は使用しないのでimport宣言ごと除去するcreateComponent
関数の引数オブジェクト内のsetup
プロパティの内容を関数とし、Viewに使用する変数や関数をまとめて戻り値とする。
- プリミティブな値は
ref
関数によりRef
型でラップする(上記コードのmsg
)自作コンポーネントの利用/props/computed
次にVueらしくコンポーネントを親子関係にする。また、propsによる値の受け渡しとcomputedによる自動再計算も合わせて実装する。
子コンポーネント
props
とcomputed
を持つ子コンポーネントを作成する。HelloWorld.vue<template> <div class="hello"> <h1>{{ msg }}</h1> <h1>{{ upperMsg }}</h1> </div> </template> <script lang="ts"> import { createComponent, computed } from '@vue/composition-api'; interface Props { msg: string; } export default createComponent({ props: { msg: { type: String, default: 'Hello, world.', }, }, setup: (props: Props) => { const upperMsg = computed(() => props.msg.toUpperCase()); return { upperMsg, }; }, }); </script> <!-- styleは省略 -->
- props
- 定義:
createComponent
関数の引数オブジェクト内のprops
プロパティにPropsとして利用する内容を記述- Viewでの利用: 上の定義をしておけばそのまま使える
- 加工して利用:
setup
で記述する処理に使用する場合はsetup
に引数を追加する。なお、型は別途interfaceかtypeで定義しておく- computed
createComponent
関数の引数オブジェクト内のsetup
内でcomputed
関数の引数に計算を実行する関数定義を記述する親コンポーネント
App.vueを上記コンポーネントを利用するように修正する。
App.vue<template> ... <HelloWorld :msg="msg"/><!-- ★追加 --> ... </template> <script lang="ts"> import { createComponent, ref } from '@vue/composition-api'; import HelloWorld from './components/HelloWorld.vue'; // ★追加 export default createComponent({ components: { // ★componentsプロパティ追加 HelloWorld, }, setup: () => { ... }, }); </script> <!-- styleは省略 -->
- 子コンポーネントをimportする
createComponent
関数の引数オブジェクト内のcomponents
プロパティに使用するコンポーネントを列挙する- template内で子コンポーネントを使用する(従来通り)
setup内容の外部化/ライフサイクルフック
最後にComposition APIらしく、従来コンポーネントファイル内で実装する必要のあった内容の外部化を試す。ライフサイクルフックへの登録も合わせて試す。
外部setupファイル
公式サイトの例( https://vue-composition-api-rfc.netlify.com/#logic-extraction-and-reuse )に従ってマウスの位置を取得するモジュールを作成(Lintエラー解消のため一部差異あり)
mouse.tsimport { ref, onMounted, onUnmounted } from '@vue/composition-api'; export function useMousePosition () { const x = ref(0); const y = ref(0); const update = (e: MouseEvent) => { x.value = e.pageX; y.value = e.pageY; }; onMounted(() => { window.addEventListener('mousemove', update); }); onUnmounted(() => { window.removeEventListener('mousemove', update); }); return { x, y }; }
- exportする関数内でsetupで本来実行する処理を記述し、リアクティブなオブジェクトを戻り値とする
- ライフサイクルフックに登録する内容は
onMounted
などの対応する関数の引数として記述する外部ファイルのインポート
HelloWorld.vueにmouse.tsをインポートする
HelloWorld.vue<template> ... <div>({{x}}, {{y}})</div><!-- ★追加 --> ... </template> <script lang="ts"> import { createComponent, computed } from '@vue/composition-api'; import { useMousePosition } from '@/util/mouse'; // ★追加 ... export default createComponent({ ... setup: (props: Props) => { const upperMsg = computed(() => props.msg.toUpperCase()); const { x, y } = useMousePosition(); // ★追加 return { upperMsg, x, // ★追加 y, // ★追加 }; }, }); </script> <!-- styleは省略 -->
- mouse.tsをインポートする
setup
関数内で外部化した関数(上の例でのuserMousePosition
)を実行する- 戻り値があり、それをviewで利用する場合は自身の
setup
関数の戻り値に追加する- ローカルで設定したリアクティブオブジェクトと同様にテンプレート内で使用する
成果物
最終的なプログラムを実行すると以下のようになる。
参考
- Composition API RFC | Vue Composition API
- 先取りVue 3.x !! Composition API を試してみる - Qiita
- VeturにTypeScript3.7のOptional Chainingを適用してみる - Qiita
補足
TypeScriptアップデート
- ライブラリアップデート
npm install typescript@3.7.3 --save-dev npm install @typescript-eslint/parser --save-dev
- Visual Studio Codeの設定
- tsファイル表示に右下出現するTypeScriptのバージョンをクリックして「ワークスペースのバージョンを使用」に変更する
- ワークスペース配下のsettings.jsonに以下の記述が追加される
.vscode/settings.json"typescript.tsdk": "node_modules\\typescript\\lib"
- Visual Studio Codeの拡張機能の設定
- Veturをインストールしている前提(Vueを書くならほぼ必須)
- アプリケーションのsettings.jsonに以下の記述を追加する
User/settings.json"vetur.useWorkspaceDependencies": trueLintルール変更
vue create
時にESLint + Standard config
を選択したが、個人的な趣味でcomma-dangle
とsemi
については独自ルールに変更。最終的に以下のようにした。extends: [ 'plugin:vue/essential', '@vue/standard', '@vue/typescript', ], rules: { 'comma-dangle': ['error', { 'arrays': 'always-multiline', 'objects': 'always-multiline', 'imports': 'never', 'exports': 'never', 'functions': 'never', }], 'semi': ['error', 'always'], 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', },ルール変更後、以下のコマンドで既存コードを一括修正。
npm run lint
- 投稿日:2019-12-11T00:16:50+09:00
【Composition API】StoreパターンでVuexを使わずに状態管理をする
はじめに
こんにちは。@chan_kakuです!今回はVue Advent Calendar 2019 11日目の記事です。
みなさんComposition API使っていますか??今年のVue Advent CalendarはComposition APIの話がとても多いですね!
ここからみなさんがComposition APIに非常に関心があることがわかります。
今回は来たるVue 3.0で入るComposition APIをつかってVuexを使わずにStoreパターンを利用して簡易状態管理をしてみたいと思います!そもそもVuexって何?
今更ですが、初めてみたという方のために説明を入れておきます。
Vuexの公式ドキュメントをみてると最初にこのように書いてありますVuex は Vue.js アプリケーションのための 状態管理パターン + ライブラリです。 これは予測可能な方法によってのみ状態の変異を行うというルールを保証し、アプリケーション内の全てのコンポーネントのための集中型のストアとして機能します。 また Vue 公式の開発ツール拡張と連携し、設定なしでタイムトラベルデバッグやステートのスナップショットのエクスポートやインポートのような高度な機能を提供します。
Vuexをよく使うパターンとしては、親子関係ではなく、兄弟関係のコンポーネント間で何かしらの状態を渡したいときなんかに使われます。
ただ、公式ドキュメントにはこのようにも書いてあります。Vuex は、共有状態の管理に役立ちますが、さらに概念やボイラープレートのコストがかかります。これは、短期的生産性と長期的生産性のトレードオフです。
もし、あなたが大規模な SPA を構築することなく、Vuex を導入した場合、冗長で恐ろしいと感じるかもしれません。そう感じることは全く普通です。あなたのアプリがシンプルであれば、Vuex なしで問題ないでしょう。単純な ストアパターン が必要なだけかもしれません。しかし、中規模から大規模の SPA を構築する場合は、Vue コンポーネントの外の状態をどうやってうまく扱うか考える絶好の機会です。Vuex は自然な次のステップとなるでしょう。つまり、公式的にはそこまで大きくないアプリケーションであるにもかかわらず、単に状態管理したいだけのために入れるのはナンセンスで、それなりの規模のアプリケーションで使うことをおすすめしています。
そこで公式ドキュメントに乗っている通りStoreパターンを利用して兄弟関係のコンポーネント同士で状態管理をしてみたいと思います。
早速Composition APIで状態管理してみる
作るもの
導入の前に今から作るもののイメージがあった方が作りやすいと思うので載せておきます
このようにtext-boxに入力したテキストをsetボタンを押すことでstoreにセットされ、getボタンを押すことでstoreの状態を取得することができいます。
導入
まずはいつものごとくvue-cliを使ってプロジェクトを利用していきます
$ vue create adventCalendar
オプションはよしなに好きなものを選んでください
その後、Composition APIをVue 2.xでも利用できる @vue/composition-api を入れていきます
$ yarn add @vue/composition-api
その後先ほどvue-cliで作成したプロジェクトの
/src/main.js
に以下のように記述することでComposition APIを利用することができます。main.jsimport Vue from 'vue' import App from './App.vue' import VueCompositionApi from '@vue/composition-api';// 追加した箇所 Vue.use(VueCompositionApi);// 追加した箇所 Vue.config.productionTip = false new Vue({ render: h => h(App), }).$mount('#app')状態管理
ここからようやくStoreパターンを使った状態管理に入ります
storeパッケージを切って、その配下にstore.js
を作成しますstore.jsexport let store = { state: { message: 'this is first message' }, getMessage() { return this.state.message }, setMessage(newVal) { this.state.message = newVal } }続いて、component側を触っていきます
/src/components/HelloWorld.vue
を以下のように修正していきますHelloWorld.vue<template> <div> <textarea cols="30" rows="10" v-model="state.privateMessage"></textarea> <p>グローバルメッセージ:{{state.globalMessage}}</p> <button @click="setMessage">set</button> <button @click="getMessage">get</button> </div> </template> <script> import { reactive, onMounted } from '@vue/composition-api' import { store } from '@/store/store.js' export default { name: 'HelloWorld', setup() { onMounted(() => { getMessage() }) const state = reactive({ privateMessage: '', globalMessage: '' }) function getMessage() { state.globalMessage = store.getMessage() } function setMessage() { store.setMessage(state.privateMessage) } return { state, getMessage, setMessage } } } </script>Composition APIは基本的に
setUp()
内で使っていくことになります。
onMounted
はVue 2.xまでのライフサイクルであるmountedと同じものになります。他にもいままで同じようにライフサイクルをフックすることができますが一部使えなくなっているものがありますのでご注意ください。
reactive
はVue 2.6で追加されたVue.observable()
と同じものでオブジェクトをリアクティブにしてくれるAPIです。
コンポーネント側ではこのようにstoreの状態(state)のgetter/setterを利用して状態を取得/変更していきます。
template側で利用したいオブジェクト、関数等がある場合はreturnで返してあげることで利用することができます。
ここまでみていただたとおり、従来の書き方で連発していたthis
がなくなっています。
初めて使ったとき個人的にはこれだけでもかなり嬉しく感じました。今回は兄弟コンポーネントでの状態共有をしていきたいので、兄弟コンポーネントを作っていきます。
今回はサンプルなので、HelloWorld.vue
をコピーしたHelloWorld2.vue
を作成し、App.vue
でimportするようにしましたこれで完成です!!
感想
ちょっとしたアプリケーションで状態管理をしたいだけの場合、このようにStoreパターンを利用することで状態管理をすることができました。
Vuexを使うかどうかの判断は難しいですが、個人的な意見としては、いらないものはできるだけ省きたいのでまずはStoreパターンでやってみる等でいいのかなと思ってます。
Composition APIの方は今回の例ではあまり旨味を出せていませんが、もう少し複雑なものになってくると、やりたいこと単位で分けてかけるので、レビューとかでもみやすくなってとてもいい感じだと思ってます!!
みなさんもVue 3.0がくる前にComposition APIにチャレンジしてみましょう!!その他
Composition APIの書き方にまだ慣れていないため、もっとこうした方がいい等の意見がありましたらコメントいただけると助かります。
参考