- 投稿日:2019-12-05T22:29:50+09:00
「あのデザインってなんっていうの?」早見表
概要
Frontendは「色々なComponentを使いこなせる」のが重要になってきています。
しかし一方で、「実現したいデザインの名称がわからなくて、検索できない!」って問題が往々にしてあります。
「リストを下にスワイプすると検索ボックスが出てくるiosのやつってなんて名前で検索すれば良いの!?」ってなっちゃいます。
ということで、Githubで見つけたComponent Libraryを眺めながら見つけた名称を随時まとめてみました!
(もしかしたら一般的でない名称も混ざってるかもしれないので、そのときはご指摘ください。)
名称一覧
DataTable | DataGrid
ソート、フィルタリング、ページングなどの機能を備えたテーブル。
ex) Vue Datatable
[Float | Floating | Sticky] [Thead | Header]
スクロールしていくとテーブルのヘッダが固定化されるテーブル。
ex) vue-floatthead
Toast | Notifications | FlashMessage | SnackBar
画面上部や画面下部に一時的にメッセージを出す機能。
ex) VueNotifications
ref) SnackBarsLoaders | Spinners
Loading中にでるくるくる回るもの。
Skeleton Screen
ロード中などにテキストや画像のモックアップを出すもの。
ex) tb-skeleton
ref) Building Skeleton Screens with CSS Custom PropertiesProgressBar | LoadingBar
ロード状況に合わせて画面上部に伸びるバー。
Tooltip | Popover
要素の補足情報。
ex) v-tooltip
Overlay | Modal | Alert | Dialog | Lightbox | Popup
ユーザが操作するまで親ウィンドウに操作を戻さない子ウィンドウ。
ex) vuedals
Parallax
ウィンドウのスクロールとは違う速度で画像をスクロールさせる効果。
ex) vue-parallax
AccordionMenu
広がる要素を持つメニュー。
ex) vue-accordion
Drawar
ホーム画面とは別に用意されている表示領域。
ex) vue-drawer-layout
ref) ドロワーCarousel | Slider | Swiper
複数の要素がスライドして切り替わるやつ
ex) Slick for Vue.js
InfiniteScroll | InfiniteLoading | InifiniteList
最後の要素まで行くと、次のページを自動でロードして表示してくれるやつ。
PullToReflesh | SwipeToReflesh
(主に下に)引っ張ると、要素のリフレッシュが走るもの。
ex) vue-pull-refresh
ref) Android swipe to refreshFlashCard
ex) Vue Flashcard
Particle
粒子っぽいデザイン
ex) vue-particles
Affix
コンテンツと連動して動く目次
ex) Qiitaの記事の右側にある目次
ex) vue-affixContextMenu
右クリックなどで出るメニュー
ex) vue-context-menu
FloatLabel
ex) vue-float-label
Wizard | Stepper
作業ステップをわかりやすく表記するもの。
ex) vue-form-wizard
Tour | PageGuide
アプリケーションのガイド
ex) vue-tour
WaterfallLayout | Masonry
Pinterestみたいな配置の要素
Headroom
Swipeで消えるHeader
ex) vue-headroom
- 投稿日:2019-12-05T20:24:53+09:00
自作したWebページをCSSでダークモードにする
序文
最近のiosアップデートでiphoneをダークモードに設定できるようになりました。私個人的にはダークモードの方が好みであり、自分自身のiphone,iPad,MacBookも全てダークモードに設定しております。
そこでふと自身で作成したWebページやポートフォリオをダークモードにできないかと思い、調べて見たところ簡単に設定する事ができましたので、下記に共有いたします。コード
閑話休題、styleの中に下記を追加するだけです。
現在主要なブラウザ、crome,firefox,safariでは対応している模様です。<style> @media (prefers-color-scheme: dark) { body{ background-color: #000; color: #aaa; } } </style>使用例
実際に私の簡単なサイト内で試して見ましたので画像を共有いたします。
*MacBookAir(2018)をダークモードの設定状態で、ブラウザはcromeを使用しております。
*サイトはちなみに自身の本管理用途をして,laravel(+vue,jquery)で作成したものです。最後に
以上WebページをCSSでダークモードにする方法でした。
なんとなくダークモードの方がテンション上がる気がする笑
- 投稿日:2019-12-05T19:46:25+09:00
Vue.jsで関数型コンポーネントをつかった話
この記事は、 大阪工業大学 Advent Calendar 2019の5日目の記事です。
はじめに
つい先日のお話。新規サービス作ろうね〜ということで新しいタスクが降ってきました。
新しいアプリじゃん!!ということで、このサービス開発の一部分にAtomic Designと関数型コンポーネントを使ってみたよってのが今回のおはなし。
自己紹介
某でフロントまわりを触ってます。ケモミミが好き。
大学自体はすでにOBだけど、参加条件的にはOKみたいなんでやります!Atomic Design
Atomic Designは、ざっくり書くと画面を段階に分けてパーツの作成・組み合わせを行う手法です。
最小の要素(Atoms)から作って
Atomsを組み合わせて1つの機能をもつ要素(Molecules)を作って
Atoms,Moleculesを組み合わせて、画面を構成するいち要素(Organisms)を作って
最後、作った要素群を利用してテンプレート・ページを構成していく感じ。
(画像はわかりにくくてすいません)
Atomic Designの詳細と、それをVue.jsに落とし込むところについては、他の方の記事をお探しくださいな。
今回Atomic Designで組んだコンポーネントのうち、一部のAtomsを関数型コンポーネントで組んでみました。
関数型コンポーネント
※Vue.jsの公式リファレンスでは関数型コンポーネントの説明にRender関数を利用していますが、今回は主に単一ファイルコンポーネントでやっていきます
公式曰く、関数型コンポーネントは状態を持たず、描画コストの少ないコンポーネントを作ることができるやつですね。
(具体的になんで描画コストが少なく済むのかをちゃんと理解してない)
単一ファイルコンポーネントで使うときは、templateタグに
functional
を追加すると関数型コンポーネントとして認識されます。HeadOne.vue<template functional> <h1 class="text-2xl font-bold"> <slot /> </h1> </template>こんなイメージ。
ちなみに、vueのdevtoolではこんな感じでfunctional
って表示されます。
値周りは気をつけて
関数型コンポーネントは、値の統合などを明示的に書いてあげる必要があります。
例えばクラスの設定ですね。仮に通常のコンポーネント
Child.vue
を用意したとしてChild.vue<template> <p class="leading-loose">ほげほげ</p> </template>任意のコンポーネントで読み込んであげて、呼び出し時にクラスを足します。
Parent.vue<template> <div> <child class="text-gray" /> </div> </template> <script> import Child from '@/components/Child.vue' export default { components: { Child, }, } <script>これを実行すると、pタグのclassに
text-gray
が追加されます。出力結果<div> <p class="leading-loose text-gray">ほげほげ</p> <div>よくある挙動ですね。
じゃあ関数型コンポーネントだとどうなる?
関数型コンポーネントChild.vue
で今回、上の例と同じように静的なclassを統合させたい時にはChild.vue<template functional> <p class="leading-loose" :class="data.staticClass" > ほげほげ </p> </template>のように書けばOK。
ただ、上の書き方の場合はVue.jsによって操作可能なクラス
v-bind:class
は統合されません。
もし、静的・動的なクラスのどちらも取得して反映させたい時は、ちゃんと動的なクラスも含めるように書くといい感じになります。Child.vue<template functional> <p class="leading-loose" :class="[data.class, data.staticClass]" > ほげほげ </p> </template>自分はこのあたりでちょっとハマってました。
感想
Atomic Designええやん
コードの量は増える感じはあるけど、むっちゃ使いまわしが効くから幸せに。(モノによってはプロジェクトをまたいで再利用できるとも思った)
ただ、色周りの定義ってどう分けたらいいのか困ってた。どうやるのがきれいなんだろうね。関数型コンポーネントを適用させる範囲ひろげたい
知見が足らなかったためにAtomsだけを関数型コンポーネントに置き換えた形になったけど、もうちょっと動作に影響のないレベルで置き換えることはできたよなぁと。
せめてMoleculesの一部までは.....パフォーマンスなんもわからん
これ。
どこかで試してみて、パフォーマンスまわりのデータ比較してみるのも面白そうだなぁ。関数型のイベント周りなんもわからん
この記事書くときに試してみて、すずめの涙ほどですが知見が得られたので下の方のおまけにメモしておきますね。
さいごに
Vue.jsなんもわからん
(書いてることが正しいか確認するためにNuxt.jsを使わず、数ヶ月ぶりにVue.jsベースのプロジェクト作ったら、プロジェクトのテンプレートがwebpack使ってなくて探り探りでさわることに。)あと、Atomic DesignってDRY原則に合ってる認識だけどどうなんでしょう...おしえて...
おまけ: 関数型コンポーネントへの置換
自分の備忘録も兼ねて、対応を残しておきます。
新たに知ったものとかがあれば足したり、間違いがわかれば修正加えます。class
Child.vue<template functional> <p :class="[data.class, data.staticClass]"> ほげほげ </p> </template>親のコンポーネントからクラス名をもらう時は、ちゃんと読み込みを指示する必要があります。
静的・動的なクラスどちらもバインドさせたい場合は[data.class, data.staticClass]
でclass名を取る感じにすれば困らないかと。props
props.
を足す。以上!Child.vue<template functional> <div> <h3>{{props.title}}</h3> <p>{{props.text}}</p> </div> </template> <script> export default { props: { title: { type: String }, text: { type: String }, }, } </script>slot
これは変更せずそのままで
Child.vue<template functional> <p> <slot /> </p> </template>イベントリスナー
特定のイベントを受け渡しする場合
通常時はこんな感じで書くやつ
Child.vue<template> <button @click="$emit('click', $event)">おしてちょ</button> </template>Parent.vue<template> <div> <component-button @click="callMethod" /> </div> </template> <script> import ComponentButton from '@/components/Button.vue' export default { components: { ComponentButton, }, methods: { callMethod() { /* 処理 */ }, }, } </script>関数型コンポーネントではこんな感じで。
Child.vue<template functional> <button @click="listeners.click">おしてちょ</button> </template>
$emit('click', $event)
→listeners.click
雑にイベントのやりとりをさせる場合
Child.vue<template functional> <button v-on="listeners">おしてちょ</button> </template>
- 投稿日:2019-12-05T18:18:25+09:00
Vue.jsとamCharts 4を使って地図を表示する パート2
はじめに
この記事は「Vue.jsとamCharts 4を使って地図を表示する」の続編です。
前回の記事で断念した、地図の一部のみを表示・除外する方法を書いていきます。プロジェクトの作成方法やApp.vueの構成は前回の記事と同じです。
プロジェクト作成
# プロジェクト作成 $ vue create sample-map $ cd sample-map # amchartsインストール $ yarn add @amcharts/amcharts4 $ yarn add @amcharts/amcharts4-geodata # サーバー起動 $ yarn serve日本地図表示
App.vue<template> <div id="chartdiv"></div> </template> <script> import * as am4core from "@amcharts/amcharts4/core" import * as am4maps from "@amcharts/amcharts4/maps" // 日本地図のgeodataを取得 import am4geodata_japanLow from "@amcharts/amcharts4-geodata/japanHigh" export default { mounted () { let map = am4core.create("chartdiv", am4maps.MapChart) map.geodata = am4geodata_japanLow map.projection = new am4maps.projections.Miller() var polygonSeries = map.series.push(new am4maps.MapPolygonSeries()) polygonSeries.useGeodata = true }, beforeDestroy () { if (this.map) { this.map.dispose() } } } </script> <style scoped> #chartdiv { width: 100%; height: 600px; } </style>地図の一部のみを表示する
MapPolygonSeriesのincludeに都道府県のISOコードを指定することでその地域のみ表示することができます。
今回は九州のISOコードを指定しました。polygonSeries.include = [ "JP-40", "JP-41", "JP-42", "JP-43", "JP-44", "JP-45", "JP-46", "JP-47", ]
App.vue
App.vue<template> <div id="chartdiv"></div> </template> <script> import * as am4core from "@amcharts/amcharts4/core" import * as am4maps from "@amcharts/amcharts4/maps" // 日本地図のgeodataを取得 import am4geodata_japanLow from "@amcharts/amcharts4-geodata/japanHigh" export default { mounted () { let map = am4core.create("chartdiv", am4maps.MapChart) map.geodata = am4geodata_japanLow map.projection = new am4maps.projections.Miller() var polygonSeries = map.series.push(new am4maps.MapPolygonSeries()) polygonSeries.useGeodata = true polygonSeries.include = [ "JP-40", "JP-41", "JP-42", "JP-43", "JP-44", "JP-45", "JP-46", "JP-47", ] }, beforeDestroy () { if (this.map) { this.map.dispose() } } } </script> <style scoped> #chartdiv { width: 100%; height: 600px; } </style>地図の一部のみ除外する
先ほどとは逆に、九州のみ除外したい!という需要があるかもしれません。
九州以外のISOコードをincludeに指定することで実現可能ですが、かなり面倒臭いです。
そんな時はexcludeを使います。polygonSeries.exclude = [ "JP-40", "JP-41", "JP-42", "JP-43", "JP-44", "JP-45", "JP-46", "JP-47", ]
App.vue
App.vue<template> <div id="chartdiv"></div> </template> <script> import * as am4core from "@amcharts/amcharts4/core" import * as am4maps from "@amcharts/amcharts4/maps" // 日本地図のgeodataを取得 import am4geodata_japanLow from "@amcharts/amcharts4-geodata/japanHigh" export default { mounted () { let map = am4core.create("chartdiv", am4maps.MapChart) map.geodata = am4geodata_japanLow map.projection = new am4maps.projections.Miller() var polygonSeries = map.series.push(new am4maps.MapPolygonSeries()) polygonSeries.useGeodata = true polygonSeries.exclude = [ "JP-40", "JP-41", "JP-42", "JP-43", "JP-44", "JP-45", "JP-46", "JP-47", ] }, beforeDestroy () { if (this.map) { this.map.dispose() } } } </script> <style scoped> #chartdiv { width: 100%; height: 600px; } </style>おわり
日本語・英語問わずほとんど参考文献の見つからない、地図の一部のみを表示・除外する方法を書いてみました。
前回の記事から半年経ちますが、日本で流行ってる感じは全くしませんね(~_~;)
公式のサンプルがハイレベルすぎて参考にならないのがネックなのかもしれません。
また気が向いたらパート3を書こうと思います。
- 投稿日:2019-12-05T17:13:09+09:00
[AWS]Amplify DataStore を Vue で使う
はじめに
先日 Amplify DataStore というものがリリースされました。
詳しくは公式ブログで確認していただければいいのですが、簡単に使ってみたところ、GraphQLの書き方をしなくてもGraphQLを使えるという感想でした。
今までAmplifyでAppSync(GraphQL)を使っていたやり方と比較しながら解説していこうかと思います。(参考記事)Getting Started
まずVueのプロジェクトを作りましょう。
$ npm install -g @vue/cli $ vue create amplify-datastore-sample $ cd amplify-datastore-sampleamplifyの初期化などをします
$ npx amplify-app処理が完了すると
amplify
というディレクトリが生成されています。
その中にはAmplifyで利用するAWSサービスの情報が入っていきます。
amplify/backend/api/<datasourcename>/schema.graphql
の内容を書き換えていきます。schema.graphqlenum PostStatus { ACTIVE INACTIVE } type Post @model { id: ID! title: String! rating: Int! status: PostStatus! }AWSの環境にデプロイしていきます。
$ npm run amplify-modelgen実行するとAppSyncとDynamoDBがデプロイされます。
これでAWS側の準備は完了です。Settings
必要なパッケージをインストールしておきます。
$ npm i @aws-amplify/core @aws-amplify/datastoreインストールしたパッケージをimportしておきます。
models
というディレクトリはnpm run amplify-modelgen
を実行したときにsrc
以下に生成されます。import { DataStore } from "@aws-amplify/datastore" import { Post, PostStatus } from "./models"Save Data
データを書き込むときは
DataStore.save()
を使います。await DataStore.save( new Post({ title: "My First Post", rating: 10, status: PostStatus.ACTIVE }) )GraphQLで書くと…
const saveBody = ` mutation { putData( input: { title: "My First Post", rating: 10, status: "active" } ) } ` await API.graphql(graphqlOperation(saveBody))Query Data
データを取得するときは
DataStore.query()
を使います。const posts = await DataStore.query(Post)limitや条件を指定することもできます。
await DataStore.query( Post, c => c.status("eq", PostStatus.ACTIVE), { limit: 10 } )GraphQLで書くと…
const queryBody = ` query { queryData(limit: 10){ items { id, title, rating, status } } } ` const posts = await API.graphql(graphqlOperation(queryBody))Delete Data
データを削除するときは
DataStore.delete()
を使います。const todelete = await DataStore.query(Post, "1234567") await DataStore.delete(todelete)ちなみに論理削除となるので、DynamoDBからデータが削除されることはありません。
_deleted
というフラグが立ちます。
GraphQLで書くと…
const deleteBody = ` mutation { deleteData( input: { id: "1234567" } ) } ` await API.graphql(graphqlOperation(deleteBody))さいごに
GraphQLのquery文で結構コード量が増えたり、query文の生成がめんどくさかったりとするのですが、AmplifyDataStoreを使うと、そういったところを解消してくれるそうですね。
プロダクションレベルで使うためには認証周りだったりなどを詰めていかないといけませんが、とても使う価値がありそうです。
ではまた!
- 投稿日:2019-12-05T14:00:32+09:00
CompositionAPIを使ってcompositionを分離した状態でテストする
最初に
ついにComposition APIがrfc上でmergeされました
rfc上でmergeされたこともあって、本格的に触っていく人も増えていくのではないか、と思います。
Composition APIを使うことで型推論がよくなる、IDEの恩恵を受けやすいだけでなく、
少し工夫するだけでなんちゃってSingle Store patternなどができたりします。しかしながら、Composition API自体が新しい、ということもあってテストに関する情報がほとんどありません。
そこで、今回はCompositionを分離した状態でのテストの書き方について説明します。Compositionを分離するって?
公式DocumentだとLogic reuse code organizationと書かれている部分です。
これを使うことで今までコンポーネント内で書いていたstateやstateの変更を行う関数などをすべて別ファイルに引き剥がすことが可能となります。
例えば、単純なカウントアプリを例にとります。<template> <div class="count"> <h1>{{countValue}}</h1> <div class="button-box"> <button class="plus" @click="increment">+</button> <button class="minus" @click="decrement">-</button> </div> </div> </template> <script lang="ts"> import { createComponent } from '@vue/composition-api'; import { ref } from '@vue/composition-api'; export default createComponent({ name: 'Count', setup() { const countValue = ref(0); const increment = () => { countValue.value += 1; }; const decrement = () => { countValue.value -= 1; }; return { countValue, increment, decrement, }; }, }); </script> <style scoped> </style>今回、すべてComposition APIを使って書いていますが、通常の場合、カウントを保存するstateだったり、カウントするための処理をComponentに書くと思います。(Vuexを使えばそんなもんComponentに書かないやん、といわれそうですが...)
Composition APIの場合、このようなロジックをComponent外部の別ファイルに移動させることができるようになっています!
今回の例だと、
const countValue = ref(0); const increment = () => { countValue.value += 1; }; const decrement = () => { countValue.value -= 1; };の部分を別ファイルに移動し、Component側ではこれらを呼び出す、といったことができます。
実際にテストを書いてみる
コード自体はGitHubにあげております。
環境は
ライブラリ バージョン @vue/cli 4.0.5 vue 2.6.10 @vue/composition-api 0.3.2 @vue/test-utils 1.0.0-beta.29 などを利用しています。
Refを使う場合
Refを使う例として、今回、単純なカウントアプリを例にしていきます。
コンポーネントをつくる
components/Count.vue<template> <div class="count"> <h1>{{countValue}}</h1> <div class="button-box"> <button class="plus" @click="increment">+</button> <button class="minus" @click="decrement">-</button> </div> </div> </template> <script lang="ts"> import { createComponent } from '@vue/composition-api'; import { useCount } from '@/composition/count'; export default createComponent({ name: 'Count', setup() { // compositionは次のところで見せます const { countValue, increment, decrement } = useCount(); return { countValue, increment, decrement, }; }, }); </script> <style scoped> </style>Componentとしては単純で、カウンターが表示されて、+/-ボタンがあるだけです。
Compositionを書いてみる
composition/count.tsimport { ref } from '@vue/composition-api'; // compositionを作ってくれる関数 const useCount = () => { // dataみたいなもの const countValue = ref(0); // 1増やすやつ const increment = () => { countValue.value += 1; }; // 1減らすやつ const decrement = () => { countValue.value -= 1; }; // 作ったやつをここで返す return { countValue, increment, decrement, }; }; // eslint-disable-next-line import/prefer-default-export export { useCount };compositionを外部モジュールにする際、関数内で生成し、それをreturnで返す、といった例が多いです。
テストを書いてみる
それでは、実際にテストを書いていきます。
今回はJestを利用しています。Componentをテストする
基本的にはVueのComponentのテストと変わりません。
しかし、compositionAPIを使っている部分(dataとか)をMockする必要があります。tests/components/Count.spec.tsimport { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueCompositionApi from '@vue/composition-api'; import Count from '@/components/Count.vue'; import * as composition from '@/composition/count'; // localVueを使ってComposition APIを有効にする const localVue = createLocalVue(); localVue.use(VueCompositionApi); let incrementMock: jest.Mock; let decrementMock: jest.Mock; describe('Count.vue', () => { beforeEach(() => { jest.mock('@/composition/count'); incrementMock = jest.fn(); decrementMock = jest.fn(); jest.spyOn(composition, 'useCount').mockReturnValue({ countValue: 0 as any, increment: incrementMock, decrement: decrementMock, }); }); // snapshotを使って描画をチェック it('correctly renders initial html', () => { const wrapper = shallowMount(Count, { localVue, }); expect(wrapper.html()).toMatchSnapshot(); }); // mockした関数が呼ばれたかチェック it('call increment when plus buttons is clicked', () => { const wrapper = shallowMount(Count, { localVue, }); wrapper.find('button.plus').trigger('click'); expect(incrementMock).toHaveBeenCalled(); }); // mockした関数が呼ばれたかチェック it('call increment when minus buttons is clicked', () => { const wrapper = shallowMount(Count, { localVue, }); wrapper.find('button.minus').trigger('click'); expect(decrementMock).toHaveBeenCalled(); }); });今回、jestの
spyOn
を使って関数のmockを行いました。jest.spyOn(composition, 'useCount').mockReturnValue({ countValue: 0 as any, increment: incrementMock, decrement: decrementMock, });ここで注意しなければいけないのが、
ref
のmock方法です。
Composition APIのコードを見てみるとexport interface Ref<T> { value: T; }といった記述があるため、この構造と同じようにmock値を作らなければいけない、と思いがちですが、これだとテストが実行できません。
そのため直接mockしたい値を入れる必要があります
。Compositionのテスト
composition/count.spec.tsimport VueCompositionApi from '@vue/composition-api'; import { createLocalVue } from '@vue/test-utils'; import { useCount } from '@/composition/count'; // localVueを使ってComposition APIを有効にする const localVue = createLocalVue(); localVue.use(VueCompositionApi); describe('count.spec.ts', () => { it('increment should work properly', () => { const { countValue, increment, decrement } = useCount(); increment(); expect(countValue.value).toEqual(1); }); it('decrement should work properly', () => { const { countValue, increment, decrement } = useCount(); decrement(); expect(countValue.value).toEqual(-1); }); });ここはjestでモジュールをテストする場合と変わらずに簡単にかけます。
ただし、注意として、// localVueを使ってComposition APIを有効にする const localVue = createLocalVue(); localVue.use(VueCompositionApi);という行を書いてComposition APIを有効にする必要があります。
Reactiveを使う場合
Reactiveを使う例として、今回、単純なTodoアプリを例にしていきます。
コンポーネントをつくる
components/Todo.vue<template> <div class="count"> <input id="todo-input" v-model="text"/> <button class="add-btn" @click="onSubmit">追加</button> <ul> <li v-for="(task, i) in todo.todos" :key="i"> <p>{{task}}</p> <button class="delete-btn" @click="deleteTodo(i)">Delete</button> </li> </ul> </div> </template> <script lang="ts"> import { createComponent, ref } from '@vue/composition-api'; import { useTodo } from '@/composition/todo'; export default createComponent({ name: 'Todo', setup() { // textフォームのv-model const text = ref(''); const { todo, addTodo, deleteTodo } = useTodo(); const onSubmit = () => { addTodo(text.value); text.value = ''; }; return { text, onSubmit, todo, addTodo, deleteTodo, }; }, }); </script> <style scoped> </style>こちらも、基本的には前回のTodoアプリと同様、シンプルな構成になっています。
Compositionを書く
composition/todo.tsimport { computed, reactive } from '@vue/composition-api'; const useTodo = () => { const todo = reactive({ todos: [] as string[], length: computed(() => todo.todos.length), }) as any; const addTodo = (item: string) => { todo.todos.push(item); }; const deleteTodo = (index: number) => { todo.todos.splice(index, 1); }; return { todo, addTodo, deleteTodo, }; }; // eslint-disable-next-line import/prefer-default-export export { useTodo };ロジックとstateがきれいに分離できますね...
今回、computed
なども追加しています。テストを書いてみる
これについてもテストを書いてみます。
Componentをテストする
components/Todo.spec.tsimport { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueCompositionApi from '@vue/composition-api'; import Todo from '@/components/Todo.vue'; import * as composition from '@/composition/todo'; const localVue = createLocalVue(); localVue.use(VueCompositionApi); let addTodoMock: jest.Mock; let deleteTodoMock: jest.Mock; describe('Todo.vue', () => { beforeEach(() => { jest.mock('@/composition/todo'); addTodoMock = jest.fn(); deleteTodoMock = jest.fn(); const TODOS = [ 'アドベントカレンダー', '修論', '筋トレ', ]; jest.spyOn(composition, 'useTodo').mockReturnValue({ todo: { todos: TODOS, length: () => TODOS.length, }, addTodo: addTodoMock, deleteTodo: deleteTodoMock, }); }); it('correctly renders initial html', () => { const wrapper = shallowMount(Todo, { localVue, }); expect(wrapper.html()).toMatchSnapshot(); }); it('correctly call addTodo when `追加` button is clicked', () => { const wrapper = shallowMount(Todo, { localVue, }); wrapper.find('#todo-input').setValue('ポスターセッション'); wrapper.find('.add-btn').trigger('click'); expect(addTodoMock).toHaveBeenCalledWith('ポスターセッション'); expect(wrapper.html()).toMatchSnapshot(); }); it('correctly call deleteTodo when `Delete` button is clicked', () => { const wrapper = shallowMount(Todo, { localVue, }); const INDEX = 1; wrapper.findAll('.delete-btn').at(INDEX).trigger('click'); expect(deleteTodoMock).toHaveBeenCalledWith(INDEX); }); });基本的にカウントの際と同じですが、ReactiveのMockは、Objectで渡してあげます。
Compositionをテストする
composition/todo.spec.tsimport VueCompositionApi from '@vue/composition-api'; import { createLocalVue } from '@vue/test-utils'; import { useTodo } from '@/composition/todo'; const localVue = createLocalVue(); localVue.use(VueCompositionApi); describe('todo.spec.ts', () => { it('addTodo should work properly', () => { const { todo, addTodo, deleteTodo } = useTodo(); addTodo('hogehoge'); expect(todo.todos).toEqual(['hogehoge']); }); it('addTodo should work properly', () => { const TODOS = [ 'アドベントカレンダー', '修論', '筋トレ', ]; const EXPECTED = [ 'アドベントカレンダー', '筋トレ', ]; const { todo, addTodo, deleteTodo } = useTodo(); todo.todos = TODOS; deleteTodo(1); expect(todo.todos).toEqual(EXPECTED); }); it('computed prop `length` should work properly', () => { const TODOS = [ 'アドベントカレンダー', '修論', '筋トレ', ]; const { todo, addTodo, deleteTodo } = useTodo(); todo.todos = TODOS; expect(todo.length).toEqual(TODOS.length); }); });これもCountのcompsitionと同じように書けますね
最後に
どうでしたでしょうか。VueのComposition APIを利用することで、単体テストも、より書きやすくなった印象があります。
Componentに依存していたロジックなども別ファイルにすることでロジックそのもののテストも容易となり、
Componentのテストも、UIの操作や表示などにより特化した形になります。
以前よりは簡単にテストがかけるようになっていますので、テスト駆動を始めたい方などもぜひComposition APIから始めていきましょう!!
明日は@dayjournalさんです。
- 投稿日:2019-12-05T13:55:26+09:00
マイページを作った話
はじめに(軽く自己紹介を...)
今年度、駒場祭において、「マイページ」というページを実装しました。
htmlすら触るのがほぼ初めてだった私が、このマイページというページを仕様書の作成段階から(一応は)作り上げたました。その実装過程で、localStorageやqueryパラメータなどの機能を用いたので、その経緯を是非ともご紹介したいと思い、この記事を書いております。ここから、始めましょう。一から……いいえ、ゼロから!
駒場祭公式ウェブサイト/マイページについて
今年度、駒場祭公式サイトの新たな試みとして、お気に入り登録機能と、お気に入り登録した企画等を表示するマイページを作成するというプロジェクトが持ち上がりました。
実際に完成したものはここから見てみてください。仕様の決定
Cookie取得の難点
「お気に入り情報を登録」と聞くと、最初に挙げられる選択肢はおそらくCookieではないでしょうか。
私たちもはじめは、Cookieを取得することで、ユーザーの変更を保存しようと考えました。しかし、ここで問題が生じます。
EUの「GDPR(一般データ保護規則)」などの影響で、ユーザーのcookie取得の際にユーザーの同意等が必要となるのです。
この、cookieの取得に対する是非を何回も聞かれるようでは非常に面倒になります。というわけで、とりあえずはcookie以外の方法を考えることにしました。localStorageという選択
さて、cookie取得を封じたとして、ではなにを使おうか...となったときに見つけたのが、「localStorage」でした。localStorageは、簡単に言ってしまえば、「ブラウザに情報を保存する」機構です。
これを用いれば、お気に入りボタン的なのを押して、対応する企画idを一旦localStorageに保存することで、マイページにお気に入り登録した企画を表示できるようになりました。
ですが、このlocalStorage、上で紹介したように、「ブラウザに」情報を保存するものですので、次にブラウザ間のデータのやり取りと、他の媒体で情報を取得することを考えました。ログイン機構の難点
お気に入りした情報をいかに保存、他の媒体で閲覧できるようにするか。
まず最初に思いついたのが「ログイン」機構です。ですが、ここにも問題があります。ログイン情報の取得は「個人情報の取得」に該当することがあり、ユーザーに利用規約を提示し、同意をもらう必要が生じかねません。
しかし、お気に入り登録のデータを見るくらいのページでユーザーにそこまで求めてしまっては、ユーザビリティが低下してしまいます。そうこうしているうちに公開日が迫ってきたので、とりあえずはログイン機構もデータの受け渡しも実装しないまま、第1版を公開しました。
シェアとクエリパラメータの導入
さて、とりあえず暫定的にデータの受け渡し機構を作らないまま公開をしましたが、やはり受け渡しをしないわけにもいきません。
色々考えて出てきたのが、「URLのクエリパラメータ」です。これは、URLの後ろに
/?x=hogehoge
のような感じで値をのせることができます。
このx
やhogehoge
の部分に、お気に入りした企画などのグッズの情報をのせてURLを作成すれば、QRコードを作ったり、Twitter/LINEなどでシェアする用のボタンを作ることができます。検討の結果、今回はこれを導入することにしました。
実装
localStorageの面倒臭さ
実装段階に入ります。まずはお気に入り用の星を作成しました。
対応する企画のidをlocalStorageに保存する機構を作ろうと思ったのですが、ここで問題が…localStorageに格納できるデータは、「文字列」だけなのです。すなわち、idを配列として保存することができないのです。
そこで取った方法ですが、まず、js部分で配列を生成し、これを
JSON.stringfy
を用いてString型にしてlocalStorageに保存する。
逆にlocalStorageに保存したデータを読み込むときは、文字列を取ってきて、これをJSON.parse
を用いてObject型にして配列として読み込む。
ということをしました。実際のコードは以下の通り。// 保存部分 let favoriteKikaku = [] favoriteKikaku.push(this.id); localStorage.setItem('favoriteKikaku', JSON.stringify(favoriteKikaku));// 読み込み部分 let favoriteKikaku = localStorage.getItem('favoriteKikaku'); favoriteKikaku = JSON.parse(favoriteKikaku); let favoriteKikakuId = ''; for(let k=0;k<favoriteKikaku.length;k++){ favoriteKikakuId += favoriteKikaku[k]; if(k!=favoriteKikaku.length-1) favoriteKikakuId+=','; }読み込み部分ですが、最後のfor文、
[xxx, xxx, xxx, xxx]
という形の配列に格納されているidをxxx, xxx, xxx, xxx
という形に変えてるんですが…
今見返してみると、汚いコードですね…
relpace
を知らないんですかね…まぁ、ここまで書いたらだいたいlocalStorageのお気持ちを理解できたし、これでlocalStorage部分はほぼ完成しました。
なので、ここまでで初回公開にしました。クエリパラメータの付与
さて、次にすることはクエリパラメータ付与したURLを生成することです。
まず、クエリパラメータの仕様ですが、
https://hogehoge.net/?f1=xxx,xxx,xxx&f2=yyy,yyy,yyyこうあったときに、最後の
?
以降がクエリパラメータとなります。その後は
"key"=String&"key"=String
というように、続きます。実際のコードを見てみましょう。
let kikakuURL = JSON.parse(localStorage.getItem('favoriteKikaku')); this.postingKikakuURL = JSON.stringify(kikakuURL); this.postingKikakuURL=this.postingKikakuURL.replace(/]/g, ""); this.postingKikakuURL=this.postingKikakuURL.replace(/\[/g, ""); this.postingKikakuURL=this.postingKikakuURL.replace(/:/g, "=") this.postingKikakuURL=this.postingKikakuURL.replace(/"/g, ""); let goodsURL = JSON.parse(localStorage.getItem('favoriteGoods')); this.postingGoodsURL = JSON.stringify(goodsURL); this.postingGoodsURL=this.postingGoodsURL.replace(/]/g, ""); this.postingGoodsURL=this.postingGoodsURL.replace(/\[/g, ""); this.postingGoodsURL=this.postingGoodsURL.replace(/:/g, "=") this.postingGoodsURL=this.postingGoodsURL.replace(/"/g, ""); this.shareURL = 'https://www.komabasai.net/70/visitor/mypage?kikaku='+this.postingKikakuURL+'&goods='+this.postingGoodsURL;// こいつ、ようやく
replace
を覚えたんですね。さて、ここでやっていることですが……見たらわかりますかね…
まずlocalStorageから文字列を持ってきて、これをreplace
でクエリパラメータとして変換できる形にしています。
ここで言う"クエリパラメータとして変換できる形"ですが、上でも挙げたように、基本的にはkey=Value
の形になっているものをさします。クエリパラメータを読む
次にやったのは、上で付与したクエリパラメータを読む機構の作成です。
今回は、クエリパラメータで渡された企画のidを、パラメータを読み込んだブラウザのlocalStorageに"追加"で保存するようにしました。コードは以下のような感じですね。やっていることは単純です
let queryKikakuList = (this.$route.query.kikaku || "").split(','); let queryGoodsList = (this.$route.query.goods || "").split(','); if (queryKikakuList == ""){} else { if (localStorage.getItem('favoriteKikaku') == null){ let favoriteKikaku = [] for ( let i=0; i<queryKikakuList.length; i++) { favoriteKikaku.push(Number(queryKikakuList[i])); } localStorage.setItem('favoriteKikaku', JSON.stringify(favoriteKikaku)); } else { let favoriteKikaku = localStorage.getItem('favoriteKikaku'); favoriteKikaku = JSON.parse(favoriteKikaku); for ( let i=0; i<queryKikakuList.length; i++) { if (favoriteKikaku.some(value => value == queryKikakuList[i])) { } else { favoriteKikaku.push(Number(queryKikakuList[i])); console.log(queryKikakuList); }; } localStorage.setItem('favoriteKikaku', JSON.stringify(favoriteKikaku)); } }二次元コードの生成
URLを作っても、それをどうにかして別のデバイスや別ブラウザに送らなくてはなりません。そのために今回は、「URLコピーのボタン」「LINEでシェアボタン」「Twitterでシェアボタン」「QRコード」の4つを表示することにしました。
そのうちのQRコードの生成なのですが、Google Chart APIを使いました。
こいつ自体の使い方は非常に簡単です。以下、コード
document.getElementById('qr').src = "http://chart.apis.google.com/chart?cht=qr&chs=130x130&chco=ED6D2B&chl=https://www.komabasai.net/70/visitor/mypage?kikaku="+this.postingKikakuURL+'&goods='+this.postingGoodsURL;このように、imgのsrcの部分に、
http://chartapis.google.com/chart?cht=qr&chs={{サイズ}}&chco={{カラーコード(#はいらない)}}&chl={{QRのURL}}を入力すれば表示できます。
改善点
- フロントの実装のときにTabのようなものを実装したが、これの実装に名前付きビューを使っていた方が後々見やすかったかなぁ...
- localStorageへのデータの保存ですが、保存が完了するまでに少し時間がかかるため、早くにページを閉じてしまうと、お気に入りの情報が飛んでしまったようです…
- その他、localStorageまわりで、データが飛ぶ等のバグ報告を少し受けたので、次回までには原因究明と修正ができたらな...と思っています
まとめ
- GDPR対策で、Cookieを取得しないとなると、代替案を探すのが少々面倒
- localStorageはGDPR的には良いのだが、使いづらい点もある
- queryパラメータは積極的に使っていきたい
さて、ここまで読んでいただきありがとうございます。
初投稿かつ、やってきたことをタレ流した記事だったので、読み苦しいものであったかもしれませんが、ご容赦ください。
また、GDPR周りについては詳しくは知らないことも多く、多少間違った理解をしているかもしれません。その場合は遠慮なくコメント等でご指摘ください。マイページのようなページの実装にlocalStorageやqueryパラメータを使うことが一般的なのかはわかりませんが、こういう方法もあるんだと思っていただければ幸いです。
最後になりますが、来年度の第71回駒場祭でもウェブサイトは制作する予定なので、来年の11月ごろにぜひ「駒場祭」で検索してみてください。
- 投稿日:2019-12-05T13:00:50+09:00
vue sass-loader エラー:Module build failed: TypeError: this.getResolve is not a function
背景
$ npm install sass-loader --save-devをinstallして、$npm run devを実行すると、
下記のエラーが出た。
Module build failed: TypeError: this.getResolve is not a function原因
sass-loaderのバージョンは最新すぎる
解決策
まず、sass-loaderをuninstallする
$ npm uninstall sass-loader後、低いバージョンをinstallしたら、ok
$ npm install sass-loader@7.3.1 --save-dev
- 投稿日:2019-12-05T12:17:15+09:00
G Suite(Google Apps ScriptとVue.jsとSpreadsheetで作る、フォームからの入力をSpreadsheetに保存するツール
SpreadsheetID SheetName 123456789012345678901234567890123456789012345 HistName コード.gsfunction doGet() { var html = HtmlService.createTemplateFromFile("index").evaluate(); return html; } function appendData(inputData){ var spreadSheetID_write = "123456789012345678901234567890123456789012345"; var sheetName_write = 'HistName'; var sheet = SpreadsheetApp.openById(spreadSheetID_write).getSheetByName(sheetName_write); var dataList = []; for (i in inputData){ var approvedSet = inputData[i]; dataList.push(approvedSet); } sheet.appendRow(dataList); }vue.js<script src="https://cdn.jsdelivr.net/npm/vue@2.6.0"></script> <script> var app = new Vue({ el: '#app', data: { showTemplate: 'App', inputName: '', rndNum: '', setAppData:[], }, methods:{ checkApp: function(){ this.showTemplate = 'Confirm'; }, checkConfirm: function(){ this.showTemplate = 'Thanks'; this.setData(); google.script.run .withSuccessHandler(function(arg){ alert("データの登録に成功しました。"); }) .withFailureHandler(function(arg){ console.log(arg); alert("データの登録に失敗しました。"); }).appendData(this.setAppData); }, setData: function(){ this.setAppData = []; this.rndNum = this.createRndNum(); // inputするデータ配列の作成 this.setAppData.push(this.rndNum); this.setAppData.push(this.inputName); }, createRndNum: function (){ var l = 12; // 生成する文字列に含める文字セット var c = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; var cl = c.length; var r = ""; for(var i=0; i<l; i++){ r += c[Math.floor(Math.random()*cl)]; } return r; }, }, }) </script>index.html<!DOCTYPE html> <html> <head> <base target="_top"> </head> <body> <div id="app"> <template v-if="showTemplate === 'App'"> <input type="text" id="inputName" name="inputName" v-model="inputName"></input> <button type="submit" v-on:click="checkApp">確認画面に進む</button> </template> <template v-if="showTemplate === 'Confirm'"> <input type="text" id="inputName" name="inputName" v-model="inputName" disabled=""></input> <button type="submit" v-on:click="checkConfirm">登録</button> </template> <template v-if="showTemplate === 'Thanks'"> <p>Thanks!</p> <p>{{ setAppData }}</p> </template> </div> </body> <?!= HtmlService.createHtmlOutputFromFile('vue').getContent(); ?> </html>
- 投稿日:2019-12-05T12:17:15+09:00
G Suite(Google Apps Script)とVue.jsとSpreadsheetで作る、フォームからの入力をSpreadsheetに保存するツール
SpreadsheetID SheetName 123456789012345678901234567890123456789012345 HistName コード.gsfunction doGet() { var html = HtmlService.createTemplateFromFile("index").evaluate(); return html; } function appendData(inputData){ var spreadSheetID_write = "123456789012345678901234567890123456789012345"; var sheetName_write = 'HistName'; var sheet = SpreadsheetApp.openById(spreadSheetID_write).getSheetByName(sheetName_write); var dataList = []; for (i in inputData){ var approvedSet = inputData[i]; dataList.push(approvedSet); } sheet.appendRow(dataList); }vue.js<script src="https://cdn.jsdelivr.net/npm/vue@2.6.0"></script> <script> var app = new Vue({ el: '#app', data: { showTemplate: 'App', inputName: '', rndNum: '', setAppData:[], }, methods:{ checkApp: function(){ this.showTemplate = 'Confirm'; }, checkConfirm: function(){ this.showTemplate = 'Thanks'; this.setData(); google.script.run .withSuccessHandler(function(arg){ alert("データの登録に成功しました。"); }) .withFailureHandler(function(arg){ console.log(arg); alert("データの登録に失敗しました。"); }).appendData(this.setAppData); }, setData: function(){ this.setAppData = []; this.rndNum = this.createRndNum(); // inputするデータ配列の作成 this.setAppData.push(this.rndNum); this.setAppData.push(this.inputName); }, createRndNum: function (){ var l = 12; // 生成する文字列に含める文字セット var c = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; var cl = c.length; var r = ""; for(var i=0; i<l; i++){ r += c[Math.floor(Math.random()*cl)]; } return r; }, }, }) </script>index.html<!DOCTYPE html> <html> <head> <base target="_top"> </head> <body> <div id="app"> <template v-if="showTemplate === 'App'"> <input type="text" id="inputName" name="inputName" v-model="inputName"></input> <button type="submit" v-on:click="checkApp">確認画面に進む</button> </template> <template v-if="showTemplate === 'Confirm'"> <input type="text" id="inputName" name="inputName" v-model="inputName" disabled=""></input> <button type="submit" v-on:click="checkConfirm">登録</button> </template> <template v-if="showTemplate === 'Thanks'"> <p>Thanks!</p> <p>{{ setAppData }}</p> </template> </div> </body> <?!= HtmlService.createHtmlOutputFromFile('vue').getContent(); ?> </html>
- 投稿日:2019-12-05T11:23:00+09:00
vue-routerは先に定義されたルートほど優先度が高いと言うお話。
vue-routerの優先度は先に定義されたものが高くなる
はい、少しハマりました。
ハマった点はこちら
※一部省略しています。
router/index.jsconst routes = [ { path: '/', component: App, }, { path: '/:path', name: 'week', component: Weekly, }, { path: '/request', name: 'request', component: Request }, ]最初はこんな感じで書いていました。
上から順に
・homeのパス
・動的にルートを変更
・requestへのパスこんな感じですね。
どうしてハマったかと言いますと、追加機能を/request
で定義してたんですね。
だけど、タイトルの通りvue-routerは先に定義されたルートの方が優先度高いので、同じものがあったら先に定義されたルートをマッチさせるんですね。router/index.jsconst routes = [ { path: '/:path', name: 'week', component: Weekly, } ]こいつですね。こいつにマッチしていたので
Weekly
コンポーネントが表示されてしまっていました。その元となる原因がこちら。
top.blade.php<!doctype html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> @include('common.head') </head> <body> <div id="app"> <header> <the-header-component /> </header> <main> <router-view /> </main> </div> </body> </html>TheHeaderComponent.vue<template> <div class="header"> <a href="/">TODOdo?</a> <div class="request_link_container"> <router-link to="/request"> ご意見・ご要望 </router-link> </div> </div> </template> <script> export default { } </script>こんな感じです。
headerに<router-link to="/request">
を設定しているのですが、/requestに遷移した際、マッチするルートを探し始めます。
上から順に〜〜router/index.jsconst routes = [ { path: '/', component: App, // マッチしませーーーーん!!! next next!!!!! }, { path: '/:path', // こいつがあんじゃん!!!!!! name: 'week', component: Weekly, }, { path: '/request', // 本来はこっち!!!!!! name: 'request', component: Request }, ]ってな風に
Weekly
コンポーネントが表示されてしまっていました。
なので単純に順番を変えて解決しました。404の設定
じゃあ、404の設定はどうするんだ?となりました。
ちなみにこんな感じで、本来は曜日に関するパスでしかWeekly
コンポーネントを表示させたくない。
だけど、このままではどんなパスでもWeekly
コンポーネントが表示されてしまう。app.vue<template> <div class="weekly_card_container"> <div v-for="(week, index) in weeks" :key="index"> <router-link class="weekly_card" :to="{ name : 'week', params : { path: week.path }}"> <div class="weeklytext"> {{week.week}}曜日 </div> </router-link> </div> </div> </template> <script> export default { data() { return { weeks: [ {path: 'mon', week: '月'}, {path: 'tue', week: '火'}, {path: 'wed', week: '水'}, {path: 'thu', week: '木'}, {path: 'fri', week: '金'}, {path: 'sat', week: '土'}, {path: 'sun', week: '日'} ], } } </script>なので使いましたよ。
ナビゲーションガードを。ナビゲーションガード
router/index.jsconst routes = [ { path: '*', component: NotFound }, { path: '/404', component: NotFound }, { path: '/', component: App, }, { path: '/request', name: 'request', component: Request }, { path: '/:path', name: 'week', component: Weekly, beforeEnter: (to, from, next) => { const weekArray = ['mon','tue','wed','thu','fri','sat','sun'] const week = to.params.path if (weekArray.indexOf(week) >= 0) { next() } else { next('/404') } } }, ] const router = new VueRouter({ mode: 'history', routes: routes }) export default routerこの
beforeEnter
と言うものですね。
ナビゲーションガード(beforeEnter)やってることは簡単です。
パスが曜日に関するものでなければ404
のページを表示させるようにしています。ただ、もっと良い方法がありそうな気がします。。。
まとめ
vue-routerは先に定義されたルートほど優先度が高いと言うお話でした。
ドキュメントは読む!!これにつきますね。もっとこうした方がいいなどのアドバイスや、その考え方は間違っているなどあればご指摘いただければ嬉しく思います。
- 投稿日:2019-12-05T11:17:44+09:00
tree
Display a set of data with hierarchies.
■vuejsexamples
https://vuejsexamples.com/tag/tree/■iview
https://www.iviewui.com/components/tree■vue-beauty
https://fe-driver.github.io/vue-beauty/#/components/tree■element-ui
https://element.eleme.cn/2.0/#/zh-CN/component/tree
- 投稿日:2019-12-05T11:17:44+09:00
vue.jsでデータをツリー構造・階層構造で表示する方法
Display a set of data with hierarchies.
■vuejsexamples
https://vuejsexamples.com/tag/tree/■iview
https://www.iviewui.com/components/tree■vue-beauty
https://fe-driver.github.io/vue-beauty/#/components/tree■element-ui
https://element.eleme.cn/2.0/#/zh-CN/component/tree
- 投稿日:2019-12-05T10:59:36+09:00
ざっくりFirestore + Vue.jsの使い方
この記事はただの集団 Advent Calendar 2019の5日目の記事です。
( 内容が薄くて大変申し訳ありません)
ざっくりFirestore + Vue.jsの使い方
フロントエンドでも簡単にサーバーサイド込みで実装できると噂の
Firebase
を
いじってみたので簡単にですがFirebase
のサービスの一つFirestore
についてまとめてみました。● Firestoreについて
Firestore
はRealtime Database
の進化版のようなものです。
ドキュメント指向のいわゆるNoSQL
と言われるタイプのDBで、
MongoDBとかDynamoDBと同じカテゴリに入るやつです。● 開発環境
- Mac : Mojave ~ Catalina
- Node.js : v10.17.0 (nodebrewでバージョン管理)
- yarn : v1.19.1 (brewでインストール)
- Vue CLI : v3.12.1 (globalにインストール)
- Firebase CLI : v7.8.1 (globalにインストール)
■ 初期設定
社内の勉強会で使用した資料ですが、下記を参考に初期設定。
■ Firebaseモジュールをインストール
firebaseモジュールのインストール# firebase SDKをインストール $ yarn add firebase # authとか使うときに便利らしいのでとりあえずいれる $ yarn add firebaseui■ Firebaseモジュールをインポートする準備
/src/plugins/firebaseConfig.jsimport firebase from "firebase/app"; import "firebase/firestore"; import "firebase/storage"; import "firebase/auth"; // ↓は各自異なる値になると思います const firebaseConfig = { apiKey: "hogehoge", authDomain: "hoge.firebaseapp.com", databaseURL: "https://hoge.firebaseio.com", projectId: "hoge", storageBucket: "hoge.appspot.com", messagingSenderId: "123456789", appId: "hogefuga" }; export default firebase.initializeApp(firebaseConfig);/src/plugins/firestore.js// initializeしたものをimportする import firebase from "@/plugins/firebaseConfig"; export default firebase.firestore();/src/plugins/cloudStorage.js// initializeしたものをimportする import firebase from "@/plugins/firebaseConfig"; export default firebase.storage();● Firestoreを試す
あくまでサンプルなので、誰でも全件データが編集削除できるザル仕様になってます。
コード自体も簡略化していますので、そのままだと動かない可能性が・・・■ Create
src/views/Create.vue<template> <div class="create"> <input v-model="inputData.name" type="text"> <button @click="create">送信</button> </div> </template> <script> import Firebase from 'firebase/app' import db from '@/plugins/firestore' export default { data () { return { inputData: { name: '', } } }, methods: { create () { db.collection('records') .add({ name: this.inputData.name, createBy: this.$store.state.user.uid, createAt: Firebase.firestore.Timestamp.now(), updateAt: Firebase.firestore.Timestamp.now() }) .then(docRef => { alert(`Document written with ID: ${docRef.id}`); }) .catch(error => { alert(`Error adding document: ${error}`); }); } } } </script>■ Read
/src/views/Read.vue<template> <div class="read"> <ul> <li v-for="(record, index) in records" :key="index"> {{ record.data.name }} </li> </ul> </div> </template> <script> import Firebase from 'firebase/app' import db from '@/plugins/firestore' export default { data () { return { records:[] } }, methods: { read () { // とりあえず全件取得。 db.collection('records') .get() .then(querySnapshot => { querySnapshot.forEach(doc => { this.records.push({ id: doc.id, data: doc.data() }) }) }) } }, created () { this.read() } } </script>↓取得条件を含めるとこんな感じで書けるらしい。
/src/views/Read.vue<script> // 前略 read () { db.collection('records') .where('createBy', '==', this.$store.state.user.uid) .then(querySnapshot => { querySnapshot.forEach(doc => { this.records.push({ id: doc.id, data: doc.data() }) }) }) } // 後略 </script>■ Update
/src/views/Update.vue<template> <div class="update"> <input v-model="recordId" type="text"> <input v-model="editedData.name" type="text"> <button @click="delete">更新</button> </div> </template> <script> import Firebase from "firebase/app"; import db from "@/plugins/firestore"; export default { data () { return { recordId: 'hoge', editedData: { name: '', updateAt: Firebase.firestore.Timestamp.now() } } }, methods: { // 指定されたIDのdataを更新する update () { db.collection('records') .doc(this.recordId) .update(this.editedData) .then(() => { alert('Document successfully updated!') }) .catch(error => { console.log('Error updating document: ', error) }) } } } </script>■ Delete
/src/views/Delete.vue<template> <div class="delete"> <input v-model="recordId" type="text"> <button @click="delete">削除</button> </div> </template> <script> import Firebase from 'firebase/app' import db from '@/plugins/firestore' export default { data () { return { recordId: 'hoge', } }, methods: { // 指定されたIDのdataを削除する(物理削除) delete () { db.collection('records') .doc(this.recordId) .delete() .then(() => { alert('Document successfully deleted!'); }) .catch(error => { console.log('Error removing document: ', error) }) } } } </script>おまけ
Cloud Storage
も主題として書きたかったのですが、Update,Delete機能が間に合いませんでした。
でも折角なので載せておきます■ Create
/src/views/CreateStorage.vue<template> <div class="createStorage"> <input type="file" @change="onFileChange"> <button @click="fileSubmit()">登録</button> </div> </template> <script> import storage from '@/plugins/cloudStorage' import db from '@/plugins/firestore' export default { data () { return { // uploadするファイルを格納 file: [], // 登録用meta情報 submitData: { filePath: '', createBy: this.$store.state.user.uid }, } }, methods: { onFileChange() { this.submitData.filePath = this.file.name; }, fileInfoRegister(meta_data) { db.collection('meta') .add(meta_data) .then(docRef => { console.log(docRef) }) .catch(error => { console.log(error) }); }, fileSubmit() { if (this.file.name) { // storageのrootディレクトリにファイルを保存する想定 storage.ref() .child(`${this.submitData.filePath}`) .put(this.file) .then(snapshot => { console.log(`Uploaded a file: ${snapshot.metadata.fullPath}`) }) .catch(error => { console.error(`${error.code}:${error.message}`) }) this.fileInfoRegister(this.submitData) this.submitData = {} } } }, } } </script>■ Read
/src/views/ReadStorage.vue<template> <div class="readStorage"> <ul> <li v-for="(refImgUrl, index) in refImgUrls" :key="index"> <img :src="refImgUrl"> </li> </ul> </div> </template> <script> import storage from '@/plugins/cloudStorage' import db from '@/plugins/firestore' export default { data () { return { // dbから取得したもの getImgUrls: [], // storageからrefで取得したもの refImgUrls: [], } }, methods: { fileRead () { db.collection('meta') .where('createBy', '==', this.$store.state.user.uid) .then(querySnapshot => { querySnapshot.forEach(doc => { this.getImgUrls.push({ id: doc.id, data: `${doc.data().parentDir}${doc.data().fileName}` }) }) this.getImgUrls.forEach(ref => { storage .ref() .child(ref.data) .getDownloadURL() .then(url => { this.refImgUrls.push(url) }) .catch(error => { console.log(error) }) }) }) } } created() { this.fileRead() }, } </script>あとがき
FirebaseはJSが読み書きできれば、自分のアイディアを形にできるので触っていてとても楽しいです。
デメリットがあるとすれば、各種rulesファイルの書き方が独特だということでしょうかね。
ちなみにサンプルコードはルールをテストモードで動かしてますので、権限周りは本当にザルです。今回は題材をFirestoreに絞りましたが、
Cloud Storage
のリベンジとFirebase Authentication
での権限設定
Cloud Functions + Firebase Admin SDK
で通知設定なども機会があれば記事を書いて見ようかと思います。
- 投稿日:2019-12-05T09:31:31+09:00
簡易GPSメモPWA『めもいち』をvue-cli で作ってみた話
たまに地図上にメモを残せないかな?と考えていたのですが、Googleマップはちょっと敷居高いし、何か良い方法はないかな?と思って、「めもいち」と言うPWAサイトを自作してみたお話です。(ソースコードはGitHubリポジトリを参照のこと。
制作の動機
もともと携帯電話の基地局を歩いて探すことをしていて(かなりマニアック)GoogleMapを使ってたのですが、いちいちその場でサイト開いて…とかめんどくさい。もっと気楽にマーキングできないか?と考えて、とりあえず個人的メモをOpenStreetMapに書き込もうと考えました。偶然
leaflet.js
ってライブラリを見つけたのもありました。そもそも独自性はあるのか?
作る前に、代用できる物ってないのかな?って考えたんですよね。一般のGPSロガーとか、スマホのカメラの位置情報と地図の関連付けとか別にスマホがあれば使えるし… 言った先での写真とかなら
Swarm(Foursquare)
とか考えましたが、パパッとメモするのにカメラ立ち上げてシャッター音ならして逆に問題大ありだろう…と思って、やっぱり作るしか…となりました。あと、個人の位置をクラウドに保存するのもプライバシーの観点からどうか?と思って、結局ローカルストレージに格納することにしました。ローカルストレージなのでさすがに写真撮影はバッサリ切り落としました。
制作するにあたって
Vue.jsでPWAは前回の「まねかん」である程度はやってたのですが、どうせなら
Webpack
とかWorkbox
とか使いたいって言う頭があったので、とりあえずVue-cli
で作成することにしました。PWAにもするのでWorkboxも使いますが、サーバはレンタルサーバーなので、サーバー側ではNode.js使えない…。(これは仕方ない)テンプレート
制作環境を構築するにあたって参考にした記事は、下記のページです。
・ Vue.jsでPWAアプリを作る
・ Vueのプロジェクトでworkboxを使ってみる。workboxについて説明してみる
・ webpackでビルドする前にeslintで.vueと.jsの構文チェックをする | webpack4.x+babel7+vue.js 2.x 環境構築 2019年3月版 ステップ0004
・ Vue で地図を表示する無料で最短の道
素材作成のためにShade13
とAdobe Photoshop CC
も使ってます。地図を表示させる
地図を初期化しなければいけないので、
mounted()
に地図を表示させます。leaflet.vue(抜粋)<template> <div id="leaflet-vue" /> </template> 〜〜中略〜〜 export default { props: { geoList: { type: Array, default: null, }, selected: { type: String, default: null, }, }, data () { return { leafletMap: null, markers: [], LeyerGroup: null, }; }, watch: { geoList (newVal, oldVal) { this.geoList = newVal; this.GeoMapRender(); }, }, mounted () { // マップを表示させる this.leafletMap = L.map('leaflet-vue', { center: L.latLng(34.77530283508074, 138.01500141620636), zoom: 4, layers: this.points, }).addLayer( L.tileLayer( 'https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token=pk.(mapboxのトークン)', { maxZoom: 18, attribution: 'Map data © <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, ' + '<a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, ' + 'Imagery © <a href="https://www.mapbox.com/">Mapbox</a>', id: 'mapbox.streets', } ) ); }, 〜〜中略〜〜これはそんなに難しくないです。ほとんどleafletのサンプル通り。
マーカーを描画する
leaflet.vue(抜粋)〜〜中略〜〜 methods: { GeoMapRender () { // マーカーのレイヤーグループがあれば削除 if (this.layerGroup) { this.leafletMap.removeLayer(this.layerGroup); } this.markers = []; this.leafletMap.attributionControl.setPrefix(false); // おまじない const _self = this; // マーカーの数だけループ for (const item of this.geoList) { // マーカーを追加する。 const marker = L.marker([item.latitude, item.longitude], { icon: item.marker_type === 'thumbtack' ? iconsThumbtack[item.color] : iconsNeedle[item.color], draggable: 'true', id: item.id, }) .bindPopup( `${item.memo && item.memo.length ? item.memo.replace(/\n/g, '<br />') : ''} <p class="font-italic">at ${moment(item.time).format('YYYY.MM.DD HH:mm:ss')}` ) .openPopup(); // マーカーが移動されたら移動されたマーカーの緯度経度を親に返す marker.on('dragend', function () { const position = this.getLatLng(); const index = this.options.id; const geoItem = _self.geoList.find(v => v.id === index); if (geoItem) { geoItem.latitude = position.lat; geoItem.longitude = position.lng; _self.$emit('onmoveditem', geoItem); } }); // マーカーをタップされたら、マーカーのIDを親に返す marker.on('click', function () { const index = this.options.id; _self.$emit('onselectitem', index); }); this.markers.push(marker); } // マーカーをレイヤーグループに追加してマップに重ねる this.layerGroup = L.layerGroup(this.markers); this.layerGroup.addTo(this.leafletMap); // 最後に追加したマーカーをマップの中心点にする。 if (this.geoList.length > 0) { const lastGeo = Array.from(this.geoList).slice(-1); this.leafletMap.setView([lastGeo[0].latitude, lastGeo[0].longitude], 15); } }, }, 〜〜中略〜〜マーカーは更新されるとマップ上からマーカー用のレイヤーを削除して再描画させます。(こうしないとうまく更新できなかった)
マーカーの管理はもっとちゃんとしないとダメかなぁ?とか思いましたが私の頭ではうまく思いつかなかったです。メイン画面
App.vue(抜粋)<template> <div id="app"> <leaflet-vue :geo-list="geoList" @onmoveditem="onMovedItem($event)" @onselectitem="onSelectItem($event)" /> <div id="form"> <vm-status-indicator pulse :color="statusMode" class="indicator" > {{ statusMessage }} </vm-status-indicator> <vm-status-indicator pulse :color="errorLevel" class="indicator" > {{ errorMessage }} </vm-status-indicator> <div> <select v-model="selectedGeoListItem.marker_type" @change="onChange" > <option disabled value="" > マーカーを選択 </option> <option v-for="item of markerTypes" :key="item.id" :value="item.id" > {{ item.name }} </option> </select> <img :src="`/images/${selectedGeoListItem.marker_type}2_${selectedGeoListItem.color}_x2.png`" class="type_icon" > </div> <div> <span v-for="item of colors" :key="item.type"> <input :id="item.type" v-model="selectedGeoListItem.color" type="radio" :value="item.type" @change="onChange" > <label :for="item.type" :style="`color: ${item.color}`" >■</label> </span> </div> <label> <textarea v-model="selectedGeoListItem.memo" placeholder="ここにメモする内容を書いてください。" @change="onChange" /> </label> <button class="btn-lg" @click="SubmitButtonClick" > {{ buttonCaption }} </button> <button class="btn-lg" :disabled="isButtonDisabled" @click="DeleteButtonClick" > 選択されたメモを削除 </button> </div> </div> </template> import VueGeolocation from 'vue-browser-geolocation'; import moment from 'moment'; import store from 'store2'; import leafletVue from './components/leaflet.vue'; import 'jquery'; import 'vuemerang/dist/vuemerang.css'; 〜〜中略〜〜 const cookiehead = '.Horornis-Simple-GPS-Memo-v1_2_2'; const storejs = store; export default { components: { leafletVue, VueGeolocation, }, data () { return { colors: [ { type: 'clear', color: 'white', }, { type: 'black', color: 'black', }, { type: 'red', color: 'red', }, { type: 'yellow', color: 'yellow', }, { type: 'green', color: 'lawngreen', }, { type: 'blue', color: 'blue', }, { type: 'purple', color: 'magenta', }, ], intervalId: undefined, latitude: 0, longitude: 0, memo: '', geoList: [], statusMode: 'success', statusMessage: '新規', errorLevel: 'default', errorMessage: '誤差: 計測中', templateItem: { id: null, latitude: null, longitude: null, accuracy: null, memo: '', time: moment(), color: 'yellow', marker_type: 'needle', }, color: 'yellow', markerType: 'needle', markerTypes: [ { id: 'needle', name: '針', }, { id: 'thumbtack', name: '画鋲', }, ], selectedId: null, buttonCaption: 'タップしてメモを追加', }; }, computed: { isButtonDisabled () { return this.selectedId === null; }, selectedGeoListItem () { if (this.selectedId) { const item = this.geoList.find(v => v.id === this.selectedId); if (!item.marker_type) item.marker_type = 'needle'; // console.log('selectedItem', item) return item; } return this.templateItem; }, }, mounted () { if (storejs.has(cookiehead)) { this.geoList = storejs.get(cookiehead); } this.gpsCheck(); const _self = this; this.intervalId = setInterval(function () { _self.gpsCheck(); }, 60000); }, beforeDestroy () { clearInterval(this.intervalId); const _self = this; this.intervalId = setInterval(function () { _self.gpsCheck(); }, 60000); }, methods: { gpsCheck () { console.log('Do checking GPS status now'); this.errorLevel = 'default'; this.errorMessage = '誤差: 計測中'; VueGeolocation.getLocation({ enableHighAccuracy: false, // defaults to false timeout: Infinity, // defaults to Infinity maximumAge: 0, // defaults to 0 }).then(coordinates => { if (coordinates.accuracy > 50) { this.errorLevel = 'danger'; this.errorMessage = `誤差: ${Math.round(coordinates.accuracy)}m`; } else if (coordinates.accuracy > 30) { this.errorLevel = 'warning'; this.errorMessage = `誤差: ${Math.round(coordinates.accuracy)}m`; } else if (coordinates.accuracy > 10) { this.errorLevel = 'success'; this.errorMessage = `誤差: ${Math.round(coordinates.accuracy)}m`; } else { this.errorLevel = 'primary'; this.errorMessage = `誤差: ${Math.round(coordinates.accuracy)}m`; } }); }, onChange () { // マーカーがタップされたときの処理 if (this.selectedId) { const newGeoList = this.geoList.filter(v => v.id !== this.selectedId); newGeoList.push(this.selectedGeoListItem); newGeoList.sort((a, b) => { if (a.id < b.id) return -1; if (a.id > b.id) return 1; return 0; }); this.geoList = newGeoList; this.buttonCaption = 'タップしてメモを更新'; this.statusMode = 'warning'; this.statusMessage = '編集中'; } }, onMovedItem (event) { // マーカーが移動されたときの処理 const item = this.geoList.find(v => v.id === event.id); if (item) { item.latitude = event.latitude; item.longitude = event.longitude; } storejs.set(cookiehead, this.geoList); }, onSelectItem (event) { // マーカーがタップされたときの処理 this.selectedId = event; this.buttonCaption = 'タップしてメモを更新'; this.statusMode = 'warning'; this.statusMessage = '編集中'; }, SubmitButtonClick () { // マーカーを追加か編集確定 if (!this.selectedId) { VueGeolocation.getLocation({ enableHighAccuracy: false, // defaults to false timeout: Infinity, // defaults to Infinity maximumAge: 0, // defaults to 0 }).then(coordinates => { const max = this.geoList.length > 0 ? Math.max(...this.geoList.map(v => v.id)) : 0; this.geoList.push({ id: max + 1, latitude: coordinates.lat, longitude: coordinates.lng, accuracy: coordinates.accuracy, memo: this.selectedGeoListItem.memo, time: moment(Date.now()), color: this.selectedGeoListItem.color, marker_type: this.selectedGeoListItem.marker_type, }); if (coordinates.accuracy <= 30) { this.$toasted.show(`メモ "${this.selectedGeoListItem.memo}" を追加しました。`, toastOptionsSuccess); storejs.set(cookiehead, this.geoList); } else { this.$toasted.show( `メモ "${ this.selectedGeoListItem.memo }" を追加しました。<br />測位誤差が大きいのでマーカーの位置を確認してください。`, toastOptionsWarning ); storejs.set(cookiehead, this.geoList); } this.selectedId = null; this.selectedGeoListItem.memo = ''; this.buttonCaption = 'タップしてメモを追加'; this.statusMode = 'success'; this.statusMessage = '新規'; }); } else { this.selectedId = null; this.selectedGeoListItem.memo = ''; this.buttonCaption = 'タップしてメモを追加'; this.statusMode = 'default'; this.statusMessage = '新規'; } }, DeleteButtonClick () { const aItem = this.selectedGeoListItem; this.selectedId = null; this.$toasted.show(`"${aItem.memo}"を削除しました。`, toastOptionsSuccess); this.geoList = this.geoList.filter(v => v.id !== aItem.id); storejs.set(cookiehead, this.geoList); this.selectedId = null; this.buttonCaption = 'タップしてメモを追加'; this.statusMode = 'default'; this.statusMessage = '新規'; }, }, 〜〜中略〜〜総括
そんなにトリッキーなことはやってないつもりですが、無駄は多いかもしれません。マーカーオブジェクトはそれそのものを保存しようか考えましたが、それは逆に扱いづらそうなので、Object配列をそのまま利用してます。
まだ、万人向けには直すべきところは多いと思いますが、とりあえず自分でちゃんと使える形にはなったので、要望とかがあれば、機能追加しようかな?と思います。
最後まで読んでくださり、ありがとうございました。
- 投稿日:2019-12-05T09:14:31+09:00
Vue.jsでフォームバリデーションをつくろう!ー実装編ー
はじめに
こちらはサポーターズColab で開催の勉強会の説明資料その2です。
その1は、Vue.jsでフォームバリデーションをつくろう!ー環境構築編ーです。Vue.jsでフォームバリデーションを作ってみよう! の内容を分割、アップデートしたものです。
この記事に書いてあること
- 簡易アンケートフォームの作成
- VueRouterを使ったリンクの作成
- コンポーネントの実装
- フォームの選択を解除したときのフォームバリデーション実装
この記事で省いていること
- コードの一部処理
- サーバーへのデータ送信
- セキュリティ面のケア
- Vue.jsの深いお話
環境
単一ファイルコンポーネントの記述方法について
実装に入る前に単一ファイルコンポーネントついて説明します。
単一ファイルコンポーネントは、Vueプロジェクトにおける拡張子が.vue
のファイルのことを指します。
<template>
、<script>
、<style>
のタグで構成されています。.vue<template> <!-- HTMLの記述はこちら --> </template> <script> export default { // JavaScriptの記述はこちら } </script> <style scoped> /* CSSの記述はこちら */ </style>各タグの役割については下記の通りです。
template
HTMLの記述箇所。
HTMLのテンプレートエンジンのPUGを導入することも可能。script
JavaScriptの記述箇所。
TypeScirptを導入することも可能。style
CSSの記述箇所。
scoped
をつけることで、styleをコンポーネント内に閉じ込めることができる。
PostCSSやLess、Sass、Scss、Stylusを導入することも可能。単一コンポーネントのタグの順序に関しては、公式ガイドの単一ファイルコンポーネントのトップレベルの属性の順序を参照ください。
ページを追加
完了ページを作っていきます。
こちらの項目は、Vue.jsよりもvue-router
の話が中心です。ファイルを追加
src/views/
にDone.vue
を作成し、下記を記述します。src/views/Done.vue<template> <div class="sucess"> <p>完了しました!</p> </div> </template> <script> export default { } </script> <style scoped> </style>ボタンリンクの追加
Aboutページにボタンリンクを追加し、リンクが遷移できるように設定します。
src/views/
のAbout.vue
に下記を記述します。src/views/About.vue<template> <div class="about"> <h1>This is an about page</h1> <!-- ここから、追加 --> <router-link to="done"> <button type="button" name="done" value="done">done</button> </router-link> <!-- ここまで、追加 --> </div> </template>templete
ボタンリンクを追加しました。
リンクに用いている<router-link>
は、ルーターを使用しているアプリケーションにリンクを追加できるタグです。
デフォルトで、<a>
タグとhref
で描画されます。今回の下記の記述箇所は、
<router-link to="done"> <button type="button" name="done" value="done">done</button> </router-link>下記のようにブラウザに描画されます。
<a href="/done" class="" data-lb-orig-href="http://localhost:8081/done"> <button type="button" name="done" value="done">done</button> </a>詳細は、API リファレンス | Vue Routerを参照ください。
追加したページを設定
現時点で先程作成したボタンリンクをクリックしても、真っ白なページが表示されます。
これは本来のSPAでは、パスを存在しないためです。そのため、前述で作成した
src/views/Done.vue
をrouter/index.js
に設定します。Javascript:router/index.jsimport Vue from 'vue' import VueRouter from 'vue-router' import Home from '../views/Home.vue' import { doesNotReject } from 'assert' Vue.use(VueRouter) const routes = [ { path: '/', name: 'home', component: Home }, { path: '/about', name: 'about', // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: function () { return import(/* webpackChunkName: "about" */ '../views/About.vue') } },//カンマを追加 //------ ここから追加 { path: '/done', name: 'done', component: function () { return import('../views/Done.vue') } }, //----- ここまで追加 ] const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) export default router
- path:作成したページのpathを設定
- name:ルートを特定するための名前を設定(なくてもOK)
- component:表示するファイルを記述
ここまで設定が完了すると、
/about
にあるボタンリンクをクリックで画面が遷移し、設定した文字が表示されます。
ここからアンケートをつくる準備を進めていきます。
コンポーネントを作成
src/components/
の配下にQuestionnaire.vue
をファイルを作成し、下記を記述します。src/components/Questionnaire.vue<template> <div> <p><b>{{title}}</b></p> </div> </template> <script> export default { props: { title : String } } </script> <style scoped> </style>template
script
で宣言したtitle
を表示できるよう記述script
src/views/About.vue
のテンプレートでsrc/components/Questionnaire.vue
を使用するため、About.vue
が親、Questionnaire.vue
が子という関係になります。Vue.jsは単方向のデータフローのため、親と子の間でデータをやり取りする際には宣言が必要です。
親から子へデータを渡す際には
props
を用い、 子から親へデータを渡す際はカスタムイベント
を用います。
props
の詳細は、公式ガイドのpropsを参照カスタムイベント
の詳細は、公式ガイドのカスタムイベントを参照
About.vueからコンポーネントを使用
src/views/About.vue
からsrc/components/Questionnaire.vue
を呼び出します。src/views/About.vue<template> <div class="about"> <h1>This is an about page</h1> <!-- ここから追加 --> <Questionnaire title="アンケート"></Questionnaire> <!-- ここまで追加 --> <router-link to="done"> <button type="button" name="done" value="done">done</button> </router-link> </div> </template> <script> // 上から追加 import Questionnaire from '@/components/Questionnaire.vue'// 追加 export default { name: 'about', components: { Questionnaire } } //下まで追加 </script>template
script
で登録したコンポーネントをQuestionnaire
タグとして使用します。
title
にアンケート
という文字列を親から子に渡しています。script
components
のオプション内にQuestionnaire
をコンポーネントとして定義しています。
入力フォームバリデーションを作成
ここから
src/components/Questionnaire.vue
にフォームを作り、入力した値をバインディングする処理を追加します。フォームの作成とデータバインディングを設定
src/components/Questionnaire.vue<template> <div> <p><b>{{ title }}</b></p> <!-- ここから追加 --> <form> <p>Hello, {{ questionnaire.nickName }}</p> <div> <label>あだ名:</label> <input type="text" name="nickname" placeholder="呼ばれたい名前をどうぞ" v-model="questionnaire.nickName"> </div> <p> <label>TwitterID:</label> <input type="text" name="belong" value="" placeholder="ないかたはスキップOK"> </p> <p> <label>今日の勉強会との関わり:</label> <input type="text" name="connection" value="" placeholder="思いの丈をどうぞ!"> </p> <button type="submit">submit</button> </form> <!-- ここまで追加 --> </div> </template> <script> export default { props: { title : String },//カンマを追加 //------ ここから追加 data: function() { return { questionnaire: { nickName: null, } } } //------ ここまで追加 } </script> <style scoped> </style>templete
データとフォームの入力項目をバインドするには、v-model
を使用します。
v-model
の詳細は、公式ガイドのv-modelを参照ください。script
data
は、アプリケーションで使用するデータを記述します。
data
の詳細は、公式ガイドのdataを参照ください。
ボタンの削除
前述で
src/views/About.vue
にて作成したボタンリンクですが、使用しないため下記箇所を削除します。src/views/About.vue<router-link to="done"> <button type="button" name="done" value="done">done</button> </router-link>
フォームのバリデーションを設定
フォームから選択が外れた際にバリデーションチェックを行う処理を実装します。
今回はTwitterIDのフォームに追加します。TwitterIDの条件は、ユーザー名の登録のヘルプを参照すると…
ユーザー名の長さは15文字までです。名前は50文字までですが、ユーザー名は使いやすいように短くなっています。
ユーザー名には英数字(文字A~Z、数字0~9)しか含めることはできません。ただし、上記のとおりアンダースコア(_)は例外です。希望するユーザー名に、記号やダッシュ、スペースが含まれていないことを確認してください。
上記の通りのため、下記の2つをエラーを表示する条件として追加します。
- 半角英数以外で入力されている場合
- 15文字以上の場合
src/components/Questionnaire.vue<template> <div> <p><b>{{ title }}</b></p> <!-- ここから追加 --> <form v-on:submit.prevent="onSubmit"> <div> <p>Hello, {{ questionnaire.nickName }}</p> <label>あだ名:</label> <input type="text" name="nickname" placeholder="呼ばれたい名前をどうぞ" v-model="questionnaire.nickName"> </div> <div> <label>TwitterID:</label> <input type="text" name="belong" value="" placeholder="ないかたはスキップOK" v-model="questionnaire.twitterID" v-on:change="checkForm"> </div> <div> <label>今日の勉強会との関わり:</label> <input type="text" name="connection" value="" placeholder="思いの丈をどうぞ!"> </div> <p class="error"> {{ validation.result }}</p> <button v-on:click="checkForm">submit</button> </form> <!-- ここまで追加 --> </div> </template> <script> export default { props: { title : String }, data: function() { return { questionnaire: { nickName: null, twitterID: null,//追加 },//カンマを追加 //------ ここから追加 validation:{ result: "", } //------ ここまで追加 } },/*ここにカンマを追加*/ //------ ここから追加 methods: { checkForm: function(event){ var booleanTwitterID = false var inputTwitterID = this.questionnaire.twitterID if(!this.checkString(inputTwitterID)){ this.validation.result = "半角英数字および_のみで入力ください" } else if(!this.checkMaxLength(inputTwitterID)){ this.validation.result = "15文字以内で入力ください" } else { booleanTwitterID = true } if(booleanTwitterID === true){ this.validation.result="" alert('Hello,' + inputTwitterID + '!') } event.preventDefault() }, checkString: function(inputdata){ var regExp = /^[a-zA-Z0-9_]*$/ return regExp.test(inputdata); }, checkMaxLength: function(inputdata){ var booleanLength = false inputdata.length <= 15 ? booleanLength = true : booleanLength = false; return booleanLength } } //------ ここまで追加 } </script> <style scoped> /* ここから追加 */ .error { color: red; } /* ここまでを追加 */ </style>template
v-on:submit.prevent="onSubmit"
は、submit イベントによってページがリロードされないイベント修飾子です。
v-on:change
は、カスタムイベントの一種で変更を取得することができます。script
<script>
内のmethods
は、アプリケーションで使用するメソッドを指定します。
処理の分割やイベントハンドラなどを記述します。
ここまでの記述を該当ファイルに追加すれば、完成です?
TwitterIDのフォームに半角英数字以外や15文字以上を入力した状態でフォームの選択を外して、下記の挙動が再現されます。
このアンケートフォームにはいろいろな機能が足りていません。
ここからカスタマイズをして、自分なりの最高のUIのアンケートを実装してみてください!
その他のオプション
今回は使用していませんが、Vue.jsを理解する上で重要なオプションをご紹介します。
computed
任意のデータを処理を返す算出プロパティ。
- 詳細は、算出プロパティとウォッチャ — Vue.js 内の算出プロパティを参照ください。
watch
特定のデータや算出プロパティの状態を監視、データの変化で処理を実行するプロパティ。
- 詳細は算出プロパティとウォッチャ — Vue.js 内のウォッチャを参照ください。
さいごに
ここまで頑張ってフォームのバリデーションを実装しましたが、世の中にはかんたんにバリデーションを設定できる素晴らしいライブラリが提供されています。
これらを使えば、よりよいUXのフォームバリデーションをスムーズに開発ができます。
本当にいい時代に生まれました。
他にも、Vue.jsにはたくさんのサポートライブラリがあり、いろんなことが実現できる環境が整っています。
今回のハンズオンでは、Vue.jsの中でも一部機能にしか触れておりません。
もっと知りたい!と思って頂けた方は、ぜひ公式ドキュメントを読んだり、コミュニティに参加したりしてみてください!ここまでご参加(お読み)頂き、ありがとうございました??
- 投稿日:2019-12-05T09:12:17+09:00
Vue.jsでフォームバリデーションをつくろう!ー環境構築編ー
はじめに
こちらはサポーターズColab で開催の勉強会の説明資料その1です。
Vue.jsでフォームバリデーションを作ってみよう! の内容を分割、アップデートしたものです。
この記事に書いてあること
- VueCLI を使った
Manually select features
の環境構築の手順- 各項目のかんたんな説明
この記事で省いていること
- CLIの説明
- 各ライブラリの説明
環境
開発環境のインストール
Vue.jsの開発環境の構築方法として、下記の3つがあります。
方法 ファイル 使うケース scriptで直接埋め込む .html/.css/.jsファイルで作成 プロトタイピングや学習目的
個人開発npm を利用したインストール .html/.css/.jsファイルで作成
.vueファイルで作成大規模アプリケーション開発 CLIを利用したインストール .vue
ファイルで作成(以下、単一ファイルコンポーネント)中規模以上のアプリケーション開発 Webpackなどのモジュールハンドラの設定はせずに実装を進めたいので、ハンズオンではCLIを利用したインストールを行っていきます。
公式で提供されているVueCLI
を利用します。インストール手順は、VueCLIの公式サイトにしたがって進めていきます。
vue-cliのインストール
下記コマンドを実行します。
shell$ npm install -g @vue/cliインストールが完了すると下記の表示されます。
shell+ @vue/cli@4.0.5 added 1156 packages from 638 contributors in 62.341s
下記コマンドを叩いて、インストールの有無を確かめることもできます。
shell$ vue --version今回であれば、
@vue/cli 4.0.5
が表示されます。
VueCLIの
2.x
や3.x
系がインストールされていて、4.x
に変更したい方現在のバージョンをアンインストールの上、再度インストールをお願いします。
shell$ npm uninstall -g @vue-cli $ npm install -g @vue/cli
プロジェクトを作成
shell$ vue create my-project
my-project
の部分はファイル名になります。
そのため、自分が管理しやすい名称を指定してください。※
vue create
コマンドを実行時に下記が表示された場合Your connection to the default npm registry seems to be slow. Use https://registry.npm.taobao.org for faster installation? (Y/n)npmレジストリのアクセスに時間がかかる際に表示されます。
急ぎでない場合はn
(No)選択することをおすすめします。
プリセットの選択
Vue CLI v4.0.5 ? Please pick a preset: default (babel, eslint) ❯ Manually select featuresBabelとEslintを含む
default
か、その他のライブラリを選択できるManually select features
があります。
TypeScriptやCSSのプリプロセッサをインストールしたい場合は、Manually select features
を選択しましょう。
ライブラリは、あとから追加することも可能です。ハンズオンでは、
Manually select features
を選択します。
パッケージマネージャーの選択
Vue CLI v4.0.5 ? Please pick a preset: 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インストールしたいライブラリの名称にカーソルをわせ、
space
キーで選択ができます。
a
キーを押下すると全てを選択、解除することができます。
i
キーを押下すると、選択しているものは解除され、解除されているものは選択されます。選択後は、
Enter
を押下してください。ハンズオンでは、ルーティングの制御を行うため、
Router
を選択します。※その他のライブラリはご興味があれば、調べてみてください?
vue-router のモードの選択
? Use history mode for router? (Requires proper server setup for index fall back in production) (Y/n)
histrory
モードを使用するかどうか尋ねられています。デフォルトの設定は、
hash モード
で、URLにハッシュ(#)が付きます。
このハッシュを取り除けるモードがhistrory
モードです。ハンズオンでは
history
モードで実装するため、Y
を入力して、Enterを押下してください。※historyモードについて詳しく知りたい方は、Vue.js Router/HTML5 History モードを参照ください。
Babel、PostCSS、ESLintなどの設定箇所の選択
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedicated config files ❯ In package.js
In dedicated config files
を選択すると、専用の設定ファイルで設定できます。
In package.json
を選択すると、1つのファイルで設定できます。ハンズオンでは筆者の独断と偏見で1つのファイルを推奨したいので、
In package.js
選択して、Enterを押下してください。
設定の保存の選択
? Save this as a preset for future projects? (y/N)今回選択した設定を保存することができます。
ハンズオンの設定を再利用する可能性は低いため、
N
(No)を選択してください。
パッケージマネージャーの選択
※こちらが表示されない場合もあります。
? Pick the package manager to use when installing dependencies: (Use arrow keys) ❯ Use Yarn Use NPM特にこだわりがなければ、
Use NPM
を選択して、Enterを押下してください。以上でインストールの準備が整うので、インストールが開始されます!
インストールが完了すると、下記が表示されます。
? Successfully created project my-project.
ローカル環境を立ち上げる
インストール完了後に表示されていたコマンドを実行します。
? Get started with the following commands: $ cd my-project $ npm run serve実行が完了すると、下記が表示されます。
Compiled successfully in 6040ms 00:00:00 App running at: - Local: http://localhost:8080/ - Network: http://0.0.0.0:8080/ Note that the development build is not optimized. To create a production build, run npm run build.http://localhost:8080 にアクセスしてみてください。
下記が表示されていれば、環境構築は完了です!上部の「About」をクリックすると、別のページが表示されます。
おわりに
VueCLIを使ったVue.jsの開発環境の構築手順をご紹介しました。
今回CLIを使用しましたが、こちらの方法について公式ドキュメントのインストール — Vue.jsには下記のように書かれております。
CLI は Node.js および関連するビルドツールに関する事前知識を前提としています。Vue またはフロントエンドビルドツールを初めて使用している場合、CLI を使用する前に、ビルドツールなしでガイドを参照することを強くお勧めします。
…フロントエンドで求められる知識の幅は広いですね?
- 投稿日:2019-12-05T08:29:07+09:00
【Svelte3入門】ToDoリストをチュートリアルと照らし合わせて作ろみゃあ!
はじめよっけ!
この記事はAteam Brides Inc. Advent Calendar 2019 6日目の記事です。
こんにちは。株式会社エイチームブライズの @mkin です。
さて、今日は React や Vue よりも早いと言われている Svelte を使って簡単なアプリを作っていきます。
この記事は実際に私が ToDoリストを作った順番に、チュートリアルを逆引きしながら作った実録でもあるので、読むだけでも開発体験をトレースできるんじゃないかと、淡く期待しています
React も Vue も好きな筆者が Svelte を触った感想は「...めっちゃ分かりやすいがぁ!!
」というもの。この開発体験を少しでも多くの方に共有したくてこの記事を書きます。
※Svelte は2019-04-23にリリースされ大幅に改善したバージョン3を指します。
Svelte ってなんなのー?
私も数週間前までその存在を知りませんでした。
職場のフレンズたちが Svelte で盛り上がっているとき、私は冷めた目をしていました。
でも、知らないものを批判しちゃいかんと思い直し、まず調べたのがこちら。Svelte がホームページで謳っていることは次の3つ...
- Write less code: より少ないコードで
- No virtual DOM: 仮想DOMを使わずに
- Truely reactive: 真のリアクティブ
順に説明しますね。
1. Write less code: より少ないコードで
単純な計算機能ですが、これを Svelte は React と比べて32%、Vue と比べて55% のコード量で実装できます。
- 442文字: React
- 263文字: Vue
- 145文字: Svelte
2. No virtual DOM: 仮想DOMを使わずに
これは大事なポイントですが、 Svelte はコンパイラーであり、React や Vue のようなフロントエンドフレームワークではありません。これにより、クライアントへ渡されるのは純粋な HTML と javascript と css になり、余分なオーバーヘッドを無くして高速に動きます。余分なオーバーヘッドとはフレームワーク自体の転送容量や、仮想DOMの生成時間、その使用メモリを指します。
3. Truely reactive: 真のリアクティブ
Svelte では 1と2のおかげで React / Vue のように複雑な状態管理を意識せずに書けます。実際、チュートリアルを幾つかやるだけで、その簡潔な書き方におったまげるでしょう。
参考までに、@so99ynoodles さんの記事「ReactとVueを改善したSvelteというライブラリーについて」によると
Svelteは速く、軽いです。
ベンチマークでReactの35倍、Vueの50倍速いです。
Svelteはコンパイラーであるため、実質ライブラリーとしての容量は0kbです。とのこと
開発環境を作ったるがや!
前置きはここまでにして、さっそく開発を始めていきましょう。
- 私の開発環境はこちらです。
- mscOS Mojave
- Visual Studio Code
- npx v6.13.1
Svelte プロジェクトを作成しよっけ!
簡単に開発環境を作るために「The Svelte 3 Quickstart Tutorial」を見ながら進めました。
まずは、以下のコマンドを実行してください。console# ワークスペースへ移動(好きな場所でOK) mkdir svelte cd svelte # Svelteプロジェクトを作成して関連パッケージをインストールする npx degit sveltejs/template todolist cd todolist/ npm install # VSCodeで開く code .そしたら、こんなプロジェクトができているので、、、
さっそく動かしてみましょう。
npm run dev
をコンソールで実行するとローカルホストが起動するので
http://localhost:5000/
にブラウザでアクセスしてください。
下記のように表示されたら成功。あなたの Svelte 生活の始まりですっ実装に入る前に...Svelte の拡張機能を入れとこっけ!
VSCode で開発するようなら、以下の拡張機能を入れておくとインテリセンスやシンタックスハイライトが効き、開発が捗るでしょう。
Svelte
Svelte 3 Snippets
ToDoリストの大枠を組み立ててこっけー
ToDoリストに求める機能はそれほど多くないので、大枠からざっくり作っていこうと思いました。
(逆に大きな機能のときは、それらを構成する機能をテスタブルに作り、終盤でつないでいくことが多いです。)
ということで、まず手始めに知っておいてほしいことは...基本構成、それは HTML、script、そして style だがや!
/src/App.svelte<!-- 自動生成された元の内容はガバっと変えていいよ! --> <!-- HTML部 --> <div> 絞り込み: <button>すべて</button> <button>未完了</button> <button>完了</button> </div> <div> <input type="text"> <button>タスク追加</button> </div> <div> <ul> <li> <input type="checkbox"> レストランを予約する </li> <li> <input type="checkbox"> サプライズ用の指輪を買う </li> <li> <input type="checkbox"> フラッシュモブダンスを練習する </li> </ul> </div> <script> // 後で記述するよ </script> <style> /* TODO: CSS得意なフレンズに頼む */ /* https://svelte.dev/tutorial/styling */ </style>ここまでは特に何も特別なことはありません。ただ、チュートリアルではscript、style、HTMLの構成でしたが、私が少し Nuxt.js を触っていたこともあり、順番を変えています。
さて、上記の実装をしたら、こんなシンプルでハッピーなToDoリストの雛形ができます
Svelte の機能を使ってToDoリストに機能をつけてこっかー
大枠が完成したところで、以下の手順で機能を実装していきたいと思います。
- タスクの直書きを配列に変更する
- 「タスク追加」ボタンでタスクを追加する
- 「絞り込み」機能で表示対象を切り替える
1. タスクの直書きを配列に変更する
実際のシステムであればサーバからデータを取得するのですが、
それは一旦置いといて、サクッと小さく動くものを作りたいと思いました。/src/App.svelte<ul> - <li> - <input type="checkbox"> レストランを予約する - </li> - <li> - <input type="checkbox"> サプライズ用の指輪を買う - </li> - <li> - <input type="checkbox"> フラッシュモブダンスを練習する - </li> + {#each todoList as todo (todo.id)} + <li> + <input type="checkbox" bind:checked={todo.done}> {todo.title} + </li> + {/each} </ul> <script> - // 後で記述するよ + let todoList = [ + { id: 0, done: false, title: 'レストランを予約する'}, + { id: 1, done: false, title: 'サプライズ用の指輪を買う'}, + { id: 2, done: false, title: 'フラッシュモブダンスを練習する'}, + ] </script>ここでは、以下のチュートリアルが役に立ちます。
- Binding / Text inputs:
{todo.title}
変数の値を表示してみよう。- Binding / Checkbox inputs:
{todo.done}
変数の値と checkbox の値を紐付けてみよう。- Logic / Keyed each blocks: ループ処理をしてみよう。
#each
句で各ToDoタスクと(todo.id)
を紐付けることで、Svelte に変化を検知させることができるよ。動かしてみると、先程と表示は変わりませんが、配列からToDoリストを表示するように変更できました。
2. 「タスク追加」ボタンでタスクを追加する
次に「タスク追加」ボタンに対してクリックイベントを追加していきたい。(自分に負けない!)
/src/App.svelte<div> - <input type="text"> - <button>タスク追加</button> + <input type="text" bind:value={title}> + <button on:click={() => add()}>タスク追加</button> </div> ・・・ <script> ... + let title = '' + function add() { + todoList = [...todoList, + { + id: todoList.length, + done: false, + title + }] + } </script>ここでは、以下のチュートリアルが役に立ちます。
- Binding / Text inputs: ←前述と同じ。input 要素に入力した値と title 変数を紐付けてみよう。
- Reactivity / Assignments: button 要素のクリックイベントとjs関数を紐付けてみよう。
3. 「絞り込み」機能で表示対象を切り替える
さぁ、最後の工程です! フィルタリングして見たい情報に絞り込んでやろうではありませんかっ!
(おーっ!!!!!)
/src/App.svelte<div> 絞り込み: - <button>すべて</button> - <button>未完了</button> - <button>完了</button> + <button on:click={() => { condition = null }}>すべて</button> + <button on:click={() => { condition = false }}>未完了</button> + <button on:click={() => { condition = true }}>完了</button> + </div> - {#each todoList as todo (todo.id)} + {#each filteredTodoList(todoList, condition) as todo (todo.id)} <li> <input type="checkbox" bind:checked={todo.done}> {todo.title} </li> {/each} <script> ... + let condition = null + + $: filteredTodoList = (todoList, condition) => { + return condition === null ? todoList : todoList.filter(t => t.done === condition) + } + </script>ここでは、以下のチュートリアルが役に立ちます。
- Reactivity / Declarations: 変数の値を監視して動的に差分変更してみよう。
ここまでできたらToDoも完成です!
さぁ!みなさんもToDoを追加していきましょう!!ちょっと待って!! 何かおかしいがー
さっきの動画で何かマズい点あったの気づきました?
そうです。そもそも恋人がいないんですよ!
そうです。動かして分かったんですが、このToDoリストには具合が悪い点がいくつかあったんです。それは...
- 初期カーソルが タスク入力欄にフォーカスされてないから入力しづらい
- 「タスク追加」ボタンをクリックした後にタスク入力欄がクリアされない
そこで、最後のお仕置きをしたいと思いました。
初期化処理で操作性を高めたいがー
上記の問題は初期化処理を追加することで解決しました。
では、さっそく.../src/App.svelte<div> - <input type="text" bind:value={title}> + <input type="text" bind:value={title} bind:this={initFocus}> <button on:click={add}>タスク追加</button> </div> <script> + import { onMount } from 'svelte' + + onMount(() => { + init() + }) + + let initFocus = null + + function init() { + title = '' + initFocus.focus() + } function add() { todoList = [...todoList, { id: todoList.length, done: false, title }] + init() } </script>※うまく反映されない場合はブラウザのキャッシュクリアをしてみてください。
ここでは、以下のチュートリアルが役に立ちます。
- Lifecycle / onMount: コンポーネントのライフサイクルを理解しましょう。コンポーネントが最初にDOM描画された後に onMountハンドラで書いた処理を実行するよ。
- Bindings / This:
bind:this={変数}
で何でも好きな要素と紐付けてみよう。完成版だぎゃー!!!!!!!!!
/src/App.svelte(完成版)<!-- HTML部 --> <div> 絞り込み: <button on:click={() => { condition = null }}>すべて</button> <button on:click={() => { condition = false }}>未完了</button> <button on:click={() => { condition = true }}>完了</button> </div> <div> <input type="text" bind:value={title} bind:this={initFocus}> <button on:click={() => add()}>タスク追加</button> </div> <div> <ul> {#each filteredTodoList(todoList, condition) as todo (todo.id)} <li> <input type="checkbox" bind:checked={todo.done}> {todo.title} </li> {/each} </ul> </div> <script> import { onMount } from 'svelte' let title = '' let initFocus = null let condition = null let todoList = [ { id: 0, done: false, title: 'レストランを予約する'}, { id: 1, done: false, title: 'サプライズ用の指輪を買う'}, { id: 2, done: false, title: 'フラッシュモブダンスを練習する'}, ] onMount(() => { init() }) function init() { title = '' initFocus.focus() } function add() { todoList = [...todoList, { id: todoList.length, done: false, title }] init() } $: filteredTodoList = (todoList, condition) => { return condition === null ? todoList : todoList.filter(t => t.done === condition) } </script> <style> /* TODO: CSS得意なフレンズ絶賛募集中!! */ /* https://svelte.dev/tutorial/styling */ </style>最後に、Svelte いいがやっ!
コード量が少ないから見晴らしがよくて、とっつきやすいんじゃないかなと思います
Svelte でもコンポーネント管理できるし、Storybook for Svelteもあるのでデザインシステムも作れる。そのうえ、SSRとしてSapper という Next/Nuxt のようなフレームワークも用意されている。React Native / Vue Native よろしく Svelte Naitveというのもある。(誰だ!やり過ぎって言ったやつ! 俺も同感だよっ!)
React / Vue を触ったことがある方にはすごく馴染みやすい技術だと思うので、試してみてもらえると嬉しいです!また、昨日の @oekazuma の記事「君はVue,Reactの次に来るSvelteを知っているか?」も併せて読んでいただけると嬉しいです
それでは、明日は @fussy113 の「1ヶ月〇〇○円で速度改善!?事業でも個人開発でも導入できる画像リサイズのAPI」です。どうぞご期待ください
私たちのチームで働こまい?
エイチームは、インターネットを使った多様な技術を駆使し、幅広いビジネスの領域に挑戦し続ける名古屋の総合IT企業です。
そのグループ会社である株式会社エイチームブライズでは、一緒に働く仲間を募集しています!上記求人をご覧いただき、少しでも興味を持っていただけた方は、まずはチャットでざっくばらんに話をしましょう。
技術的な話だけでなく、私たちが大切にしていることや、お任せしたいお仕事についてなどを詳しくお伝えいたします!Qiita Jobsよりメッセージお待ちしております!
- 投稿日:2019-12-05T08:08:21+09:00
vue hackernews
vueを使ってhackernewsを表示するチュートリアルを以前やったことがあったので
それをまとめます。src/store.js
import Vue from 'vue'; import Vuex from 'vuex'; import types from './types'; Vue.use(Vuex); const BASE_URL = 'https://api.hackernews.io'; export default new Vuex.Store({ state: { newsItems: [], currentNewsItem: {}, loading: false, }, mutations: { [types.SET_NEWS_ITEMS](state, newsItems) { state.newsItems = newsItems; }, [types.SET_CURRENT_NEWS_ITEM](state, newsItem) { state.currentNewsItem = newsItem; }, [types.SET_LOADING](state, loading) { state.loading = loading; }, [types.APPEND_NEWS_ITEMS](state, newsItems) { const uniqueIds = {}; state.newsItems = state.newsItems.concat(newsItems).filter((item) => { if (!uniqueIds[item.id]) { uniqueIds[item.id] = true; return true; } return false; }); }, }, actions: { async [types.GET_NEWS_ITEMS]({ commit }, { type, page = 1 }) { commit(types.SET_LOADING, true); if (page === 1) { commit(types.SET_NEWS_ITEMS, []); } const response = await fetch(`${BASE_URL}/${type}?page=${page}`); const items = await response.json(); setTimeout(() => { if (page === 1) { commit(types.SET_NEWS_ITEMS, items); } else { commit(types.APPEND_NEWS_ITEMS, items); } commit(types.SET_LOADING, false); }, 1000); }, async [types.GET_NEWS_ITEM]({ commit }, id) { commit(types.SET_LOADING, true); const response = await fetch(`${BASE_URL}/item/${id}`); const item = await response.json(); setTimeout(() => { commit(types.SET_CURRENT_NEWS_ITEM, item); commit(types.SET_LOADING, false); }, 1000); }, }, });mutations、actionsについてまとめてあるので解説します。
mutations実際に Vuex のストアの状態を変更できる唯一の方法は、ミューテーションをコミットすることです。Vuex のミューテーションはイベントにとても近い概念です: 各ミューテーションはタイプとハンドラを持ちます。ハンドラ関数は Vuex の状態(state)を第1引数として取得し、実際に状態の 変更を行いますactionsについて
アクションはミューテーションと似ていますが、下記の点で異なります: アクションは、状態を変更するのではなく、ミューテーションをコミットします。 アクションは任意の非同期処理を含むことができます。https://vuex.vuejs.org/ja/guide/actions.htmlに挙げられているコードは以下の通り
const store = new Vuex.Store({ state: { count: 1 }, mutations: { increment (state) { // 状態を変更する state.count++ } }, actions: { increment (context) { context.commit('increment') } } })非同期でのactionsの例
actions: { incrementAsync ({ commit }) { setTimeout(() => { commit('increment') }, 1000) } }ショッピングカートをチェックアウトするアクション
actions: { checkout ({ commit, state }, products) { // 現在のカート内の商品を保存する const savedCartItems = [...state.cart.added] // チェックアウトのリクエストを送信し、楽観的にカート内をクリアする commit(types.CHECKOUT_REQUEST) // shop API は成功時のコールバックと失敗時のコールバックを受け取る shop.buyProducts( products, // 成功時の処理 () => commit(types.CHECKOUT_SUCCESS), // 失敗時の処理 () => commit(types.CHECKOUT_FAILURE, savedCartItems) ) } }src/views/Home.vue
<template> <div class="home"> <div> <news-item v-for="item in newsItems" :key="item.id" :item="item" /> </div> <div v-if="!loading"> <p class="more" @click="loadMore">More</p> </div> <div v-if="loading"> <h3>Loading...</h3> </div> </div> </template> <script> import { value, watch, onCreated } from 'vue-function-api'; import { useState, useActions, useRouter } from '@u3u/vue-hooks'; import types from '../types'; import NewsItem from '../components/NewsItem.vue'; export default { components: { NewsItem, }, setup() { const { route } = useRouter(); const { loading, newsItems } = useState(['loading', 'newsItems']); const { GET_NEWS_ITEMS } = useActions([types.GET_NEWS_ITEMS]); const currentPage = value(1); const setCurrentType = (type) => { currentPage.value = 1; GET_NEWS_ITEMS({ type, page: currentPage.value, }); }; watch(() => route.value.params.type, (type) => { setCurrentType(type); }); onCreated(() => { setCurrentType(route.value.params.type); }); const loadMore = () => { currentPage.value += 1; GET_NEWS_ITEMS({ type: route.value.params.type, page: currentPage.value, }); }; return { loading, newsItems, loadMore, }; }, }; </script>
- 投稿日:2019-12-05T04:56:22+09:00
[Vue.js] dataオブジェクトの返し方
概要
() => ({})Vue.component('button-counter', { data: () => ({ count: 0 }), ...ネットでVueについて調べていた時のこと。
上記のような書き方を見つけて、???となった。いまはむかし
es6以前Vue.component('button-counter', { data: function () { return { count: 0 } }, methods: { hoge: function () { ...今となってはこう書くことはないが、勉強し始めのころは、functionを使って書いていた。
es6以後Vue.component('button-counter', { data: () => { return { count: 0 } }, methods: { hoge() { ...es6を覚えてからは、functionをアロー関数で書くようになり、これでしばらく満足していた。
なにそれ
?Vue.component('button-counter', { data: () => ({ count: 0 }), methods: { hoge() {なんだこの波かっこを丸かっこで囲む書き方は...
returnはどこ行った...?
そんなことを思いつつ、MDNで調べてみた。
調べてみると、アロー関数のページにお目当てのことが書いてあった。高度な構文
// object リテラル式を返す場合は、本体を丸括弧 () で囲みます:
params => ({foo: bar})オブジェクトを返す場合には、丸かっこで囲ってあげれば、書いたまま返してくれるようだ
ほかのプロパティと同じ段にデータが記述されて、可読性も向上!
いい感じ!感想
MDNは最強!
知っているつもりのことでもMDNを見直すことで、再発見があるかもと思い知らされた...
- 投稿日:2019-12-05T02:02:24+09:00
Vueでグラフ描画するなら、Google Chartがオススメ
はじめに
これはVue Advent Calendar 2019 5日目の記事です。
最近、Vue.jsで集計結果をグラフ描画する機会がありました。
Vue グラフ描画
で調べると基本的にCharts.jsが出てきます。
色々、考えt時に、Google chartsが便利だったので紹介したいと思います。google chartsとは
google chartsとは、googleの図表描画ライブラリです。
スプレッドシートとかで使われているグラフを描画できるイメージです。プロジェクト作成
- 素のVue.jsではなく、Nuxt.jsでやっていたためNuxtでプロジェクト作成
yarn create nuxt-app charts-sample # yarnを選択 # cssフレームワークはbulmaを選択 cd charts-sample yarn devインストール
Google chartsのVue向けWrapperのvue-google-chartsです。
yarn add vue-google-charts
- iconを読みこませたいので、google iconを追加
nuxt.config.js(省略) link: [ { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, // 下記を追加 { href: 'https://fonts.googleapis.com/icon?family=Material+Icons', rel: 'stylesheet' } ]グラフコンポーネント
components/atoms/Chart.vue<template> <GChart :type="chartType" :data="chartData" :options="chartOptions" :createChart=" (el, google, type) => { return new google.visualization[type](el) } " /> </template> <script> import { GChart } from 'vue-google-charts' export default { components: { GChart }, props: { chartType: { type: String, default: '' }, chartData: { type: Array, default: () => { return [] } }, chartOptions: { type: Object, default: () => { return {} } } } } </script>pages/index.vue<template> <div class="container"> <chart :chartType="chartType" :chartData="chartData" :chartOptions="chartOptions" /> </div> </template> <script> import Chart from '@/components/atoms/Chart.vue' export default { components: { Chart, Dropdown }, data() { return { chartType: 'PieChart', chartData: [ ['年', '売上', '費用', '収益'], ['2014', 1000, 400, 200], ['2015', 1170, 460, 250], ['2016', 660, 1120, 300], ['2017', 1030, 540, 350] ], chartOptions: { title: '会社の損益', subtitle: '売上', width: 500, height: 500 } } } } </script>100%積み上げ棒グラフを描画
- chartOptionに
isStacked: 'percent'
を追加するchartOptions: { title: '会社の損益', subtitle: '売上', width: 500, height: 500, isStacked: 'percent' }アノテーションを付ける
components/atoms/ChartWithAnnotation.vue<template> <GChart :type="chartType" :data="data" :options="chartOptions" :createChart=" (el, google, type) => { return new google.visualization[type](el) } " @ready="onChartReady" /> </template> <script> import { GChart } from 'vue-google-charts' export default { components: { GChart }, props: { chartType: { type: String, default: '' }, chartData: { type: Array, default: () => { return [] } }, chartOptions: { type: Object, default: () => { return {} } } }, data() { return { viewOption: { calc: 'stringify', type: 'string', role: 'annotation' }, data: null } }, methods: { onChartReady(chart, google) { console.log('annotation') this.addValueLabel(chart, google) }, addValueLabel(chart, google) { const dataArr = this.chartData const data = google.visualization.arrayToDataTable(dataArr) const formatPercent = new google.visualization.NumberFormat({ pattern: '#,##0.0%' }) const view = new google.visualization.DataView(data) const viewColumn = [] const sumObj = this.calcTotal(dataArr) dataArr[0].forEach((val, i) => { viewColumn.push(i) if (i !== 0) { const viewOption = JSON.parse(JSON.stringify(this.viewOption)) viewOption.sourceColumn = i viewOption.calc = (dt, row) => { const amount = dt.getValue(row, i) / sumObj[row] return formatPercent.formatValue(amount) } viewColumn.push(viewOption) } }) view.setColumns(viewColumn) chart.draw(view, this.chartOptions) }, calcTotal(dataArr) { const sumObj = {} dataArr.forEach((arr, i) => { // 1行目はヘッダーなので飛ばす if (i !== 0) { sumObj[i - 1] = 0 arr.forEach((val, j) => { // 2行目以降の1カラム目はタイトルなので飛ばす if (j !== 0) { sumObj[i - 1] += Number(val) } }) } }) return sumObj } } } </script>
- アノテーションのフォーマットは、下記のように指定しています。
- パターンを変えれば数値やパーセント表示以外でも表示することが可能です。
const formatPercent = new google.visualization.NumberFormat({ pattern: '#,##0.0%' })最後に
- google-chartsの紹介でした。
google chartsの良いところ
- グラフが豊富
- データ構造が同じで複数のグラフを描画できる
- アノテーションなど拡張性が高い(別途拡張用のライブラリとか不要)
主観ですがデザインはデフォルトだと少しダサいなと感じますw
昨今、データの分析結果、集計結果の可視化がトレンドなので、vue × google chartで簡単にグラフ描画が可能なので、触ってみてください。
今回のソースコードは、ここにあがってます。明日は@daikidsさんです。
- 投稿日:2019-12-05T01:15:28+09:00
Vue.js で画像アップロード機能をシンプルに作ってみよう!!
ご挨拶
サァ Qiita の皆さんごきげんよう!!!
本日記事アップの木曜日といえば、石黒正数の『木曜日のフルット』ですが、アップといえば画像アップロードですね!!!皆さん画像のアップロードしてますか??????!!!!!!!
Vue.js を使って画像アップロードを実装する機会が何度かあったので、その知見を非常にミニマムにまとめて紹介します!時間がないからといってチープな記事を書く言い訳にはしない!!!書くなら本気の人間は誰のことだ????
オレダァ!!!!!!!!!!!!!!!!!!!!!!!!!!!!
下準備
ではまず面倒なのでプロジェクトは vue-cli でサクッと作ってしまいましょう。 vue-cli をインストールしておきましょう。
$ npm install -g @vue/cli$ vue create sakura
cli で設定どうするか聞かれますが今回はデフォルトでいきましょう。
Vue CLI v4.1.1 ? Please pick a preset: ❯ default (babel, eslint) Manually select featuresプロジェクトが出来たら早速サーバーを起動してみましょう!!!
$ cd sakura $ npm run serveお決まりのあれが出ましたね!!!でももうこいつとはおさらばです。容赦なく生成されれたコンポーネントを削除します。
$ rm src/components/HelloWorld.vueこのままだと
HelloWorld.vue
というコンポーネントがないみたいに怒られるので、仮にUpload.vue
という名前のコンポーネントを作成しておきましょう!!!
それから、親コンポーネントたるApp.vue
も作成したUpload.vue
を呼び出すようにしておきましょう。src/components/Upload.vue<template> <div> アップロード!!!!!!!!!! </div> </template> <script> export default { name: 'Upload' } </script>src/App.vue<template> <div id="app"> <Upload /> </div> </template> <script> import Upload from './components/Upload.vue' export default { name: 'app', components: { Upload } } </script>さてここまででで画面の様子を見てみましょうか。
渋い!!!!!!
非常に渋いサイトが出来たので、適当に git commit して、本題である画像アップロード機能を実装してみましょう!!親コンポーネントの準備
今回の構想では、
Upload.vue
コンポーネントで生成した画像情報を base64エンコードして文字列として親コンポーネントであるApp.vue
で受け取って、画像として再度表示してみたいと思います!!!!!ということで、親コンポーネントの実装を先にやっちゃいましょう!!!!GOGO!!
diff --git a/src/App.vue b/src/App.vue index ca84756..e7a00be 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,7 @@ <template> <div id="app"> - <Upload /> + <upload v-model="picture" /> + <img :src="picture" /> </div> </template> @@ -11,6 +12,11 @@ export default { name: 'app', components: { Upload + }, + data() { + return { + picture: null + } } } </script>ついでにチョットだけスタイルの準備も進めておきましょう。単に気分を上げるためです。
diff --git a/src/App.vue b/src/App.vue index e7a00be..577250f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -20,3 +20,21 @@ export default { } } </script> + +<style> +* { + box-sizing: border-box; +} + +html { + font-size: 62.5%; +} + +body { + color: #2c2d30; + font-size: 1.6rem; + font-family: "Hiragino Kaku Gothic Pro", "ヒラギノ角ゴ Pro W3", "Meiryo", + "メイリオ", "Osaka", "MS PGothic", arial, helvetica, clean, sans-serif; + line-height: 1.5; +} +</style>これで親コンポーネントで画像を受け取って表示する準備ができましたね!!!オラワクワクすっぞ!!!
続いて
Upload.vue
の最低限の実装を行っていきましょう。diff --git a/src/components/Upload.vue b/src/components/Upload.vue index 77a5175..659d3b5 100644 --- a/src/components/Upload.vue +++ b/src/components/Upload.vue @@ -1,11 +1,120 @@ <template> <div> - アップロード!!!!!!!!!! + <label v-if="!value" class="upload-content-space user-photo default"> + <input ref="file" class="file-button" type="file" @change="upload" /> + アップロードする + </label> + + <div v-if="value" class="uploaded"> + <label class="upload-content-space user-photo"> + <input ref="file" class="file-button" type="file" @change="upload" /> + <img class="user-photo-image" :src="value" /> + </label> + </div> </div> </template> <script> export default { - name: 'Upload' + name: 'Upload', + props: { + value: { + type: String, + default: null + } + }, + data() { + return { + file: null + } + }, + methods: { + async upload(event) { + const files = event.target.files || event.dataTransfer.files + const file = files[0] + + if (this.checkFile(file)) { + const picture = await this.getBase64(file) + this.$emit('input', picture) + } + }, + getBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => resolve(reader.result) + reader.onerror = error => reject(error) + }) + }, + checkFile(file) { + let result = true + const SIZE_LIMIT = 5000000 // 5MB + // キャンセルしたら処理中断 + if (!file) { + result = false + } + // jpeg か png 関連ファイル以外は受付けない + if (file.type !== 'image/jpeg' && file.type !== 'image/png') { + result = false + } + // 上限サイズより大きければ受付けない + if (file.size > SIZE_LIMIT) { + result = false + } + return result + } + } } </script> + +<style scoped> +.user-photo { + cursor: pointer; + outline: none; +} + +.user-photo.default { + align-items: center; + background-color: #0074fb; + border: 1px solid #0051b0; + border-radius: 2px; + box-sizing: border-box; + display: inline-flex; + font-weight: 600; + justify-content: center; + letter-spacing: 0.3px; + color: #fff; + height: 4rem; + padding: 0 1.6rem; + max-width: 177px; +} + +.user-photo.default:hover { + background-color: #4c9dfc; +} + +.user-photo.default:active { + background-color: #0051b0; +} + +.user-photo-image { + max-width: 85px; + display: block; +} + +.user-photo-image:hover { + opacity: 0.8; +} + +.file-button { + display: none; +} + +.uploaded { + align-items: center; + display: flex; +} +</style>とりあえずここでは最低限の以下のことができるようにしてみました。これ半分完成だろ・・・
- 画像のローカルマシンからの読み込み
- 画像のサムネイル表示
- 画像のbase64エンコード
- 親コンポーネントにエンコード済み画像データをバインド
では順を追って説明しましょう。
アップロードボタンを押すと change event を拾って
upload
method が呼ばれます。async upload(event) { const files = event.target.files || event.dataTransfer.files const file = files[0] if (this.checkFile(file)) { const picture = await this.getBase64(file) this.$emit('input', picture) } },ここでは event 情報から取得した file に関する情報を取得した後、
checkFile
method に該当 file データを渡して、ファイル形式などが仕様的に問題がないかどうかをチェックしています。checkFile(file) { let result = true const SIZE_LIMIT = 5000000 // 5MB // ローカルマシンからの読み込みをキャンセルしたら処理中断 if (!file) { result = false } // jpeg か png 関連ファイル以外は受付けない if (file.type !== 'image/jpeg' && file.type !== 'image/png') { result = false } // 上限サイズより大きければ受付けない if (file.size > SIZE_LIMIT) { result = false } return result }問題がないと判断された場合、
getBase64
method に再度該当 file データを渡してFileReader
のインスタンスメソッドを利用してエンコードを行います。getBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.readAsDataURL(file) reader.onload = () => resolve(reader.result) reader.onerror = error => reject(error) }) },このエンコード済み画像情報の値を、親コンポーネントである
App.vue
にthis.$emit('input', picture)
として返します。※ なお、ここのイベント名が
input
なのは、v-model
がv-bind:value
とv-on:input
のシンタックスシュガーだからです。 そういえば props としてvalue
を受け取っていましたね。小さい方が
Upload.vue
コンポーネントでサイズをスタイル制御している方、大きいほうが親コンポーネントApp.vue
に渡された情報を読み込んでいる方です。成功しましたね!!では仕上げに削除機能とエラーメッセージ表示機能をつけましょう!!
diff --git a/src/components/Upload.vue b/src/components/Upload.vue index 36dcddb..59b0f4d 100644 --- a/src/components/Upload.vue +++ b/src/components/Upload.vue @@ -10,7 +10,17 @@ <input ref="file" class="file-button" type="file" @change="upload" /> <img class="user-photo-image" :src="value" /> </label> + + <button type="button" class="delete-button" @click="deleteImage"> + 削除する + </button> </div> + + <ul v-if="fileErrorMessages.length > 0" class="error-messages"> + <li v-for="(message, index) in fileErrorMessages" :key="index"> + {{ message }} + </li> + </ul> </div> </template> @@ -25,7 +35,8 @@ export default { }, data() { return { - file: null + file: null, + fileErrorMessages: [] } }, methods: { @@ -38,6 +49,10 @@ export default { this.$emit('input', picture) } }, + deleteImage() { + this.$emit('input', null) + this.$refs.file = null + }, getBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -48,6 +63,7 @@ export default { }, checkFile(file) { let result = true + this.fileErrorMessages = [] const SIZE_LIMIT = 5000000 // 5MB // キャンセルしたら処理中断 if (!file) { @@ -55,10 +71,12 @@ export default { } // jpeg か png 関連ファイル以外は受付けない if (file.type !== 'image/jpeg' && file.type !== 'image/png') { + this.fileErrorMessages.push('アップロードできるのは jpeg画像ファイル か png画像ファイルのみです。') result = false } // 上限サイズより大きければ受付けない if (file.size > SIZE_LIMIT) { + this.fileErrorMessages.push('アップロードできるファイルサイズは5MBまでです。') result = false } return result @@ -114,4 +132,24 @@ export default { align-items: center; display: flex; } + +.delete-button { + background-color: #fff; + border: none; + color: #0074fb; + margin-left: 2rem; + padding: 0; +} + +.delete-button:hover { + text-decoration: underline; +} + +.error-messages { + color: #cf0000; + list-style: none; + margin: 0.4rem 0 0 0; + padding: 0 0.2rem; + font-size: 1.6rem; +} </style>無事、謎の音楽ファイルをアップロードしようとしたら怒られちゃいましたね。削除もできて便利になりました。
終わりの言葉
サァ画像プレビューが簡単にできちゃいましたね!!!!!!
本当は画像ファイルを ajax でバックエンドにアップして表示、ダウンロードするところまでちゃんと説明したかったんですが、今回はシンプルに base64 で画面表示する感じですませてみました。
そうそう、フルットといえば、モニカの季節ですね!!!皆さんメリークリスマス!!!!!!!!!!!!!