20191205のvue.jsに関する記事は22件です。

「あのデザインってなんっていうの?」早見表

概要

Frontendは「色々なComponentを使いこなせる」のが重要になってきています。

しかし一方で、「実現したいデザインの名称がわからなくて、検索できない!」って問題が往々にしてあります。

「リストを下にスワイプすると検索ボックスが出てくるiosのやつってなんて名前で検索すれば良いの!?」ってなっちゃいます。

ということで、Githubで見つけたComponent Libraryを眺めながら見つけた名称を随時まとめてみました!

(もしかしたら一般的でない名称も混ざってるかもしれないので、そのときはご指摘ください。)

名称一覧

DataTable | DataGrid

ソート、フィルタリング、ページングなどの機能を備えたテーブル。

DataTable.png

ex) Vue Datatable

[Float | Floating | Sticky] [Thead | Header]

スクロールしていくとテーブルのヘッダが固定化されるテーブル。

floatingThead.png

ex) vue-floatthead

Toast | Notifications | FlashMessage | SnackBar

画面上部や画面下部に一時的にメッセージを出す機能。

Notifications.png

ex) VueNotifications
ref) SnackBars

Loaders | Spinners

Loading中にでるくるくる回るもの。

Screen Shot 2561-11-13 at 19.04.49.png

ex) vue-simple-spinner

Skeleton Screen

ロード中などにテキストや画像のモックアップを出すもの。

Screen Shot 2561-11-13 at 19.07.00.png

ex) tb-skeleton
ref) Building Skeleton Screens with CSS Custom Properties

ProgressBar | LoadingBar

ロード状況に合わせて画面上部に伸びるバー。

Screen Shot 2561-11-13 at 19.11.40.png

ex) Vue 2 Loading Bar

Tooltip | Popover

要素の補足情報。

Screen Shot 2561-11-13 at 19.13.10.png

ex) v-tooltip

Overlay | Modal | Alert | Dialog | Lightbox | Popup

ユーザが操作するまで親ウィンドウに操作を戻さない子ウィンドウ。

Screen Shot 2561-11-13 at 19.14.56.png

ex) vuedals

Parallax

ウィンドウのスクロールとは違う速度で画像をスクロールさせる効果。

ex) vue-parallax

AccordionMenu

広がる要素を持つメニュー。

Screen Shot 2561-11-13 at 19.20.40.png

ex) vue-accordion

Drawar

ホーム画面とは別に用意されている表示領域。

Screen Shot 2561-11-13 at 19.24.28.png

ex) vue-drawer-layout
ref) ドロワー

Carousel | Slider | Swiper

複数の要素がスライドして切り替わるやつ

Screen Shot 2561-11-13 at 19.30.53.png

ex) Slick for Vue.js

InfiniteScroll | InfiniteLoading | InifiniteList

最後の要素まで行くと、次のページを自動でロードして表示してくれるやつ。

Screen Shot 2561-11-13 at 19.35.10.png

ex) vue-infinite-loading

PullToReflesh | SwipeToReflesh

(主に下に)引っ張ると、要素のリフレッシュが走るもの。

Screen Shot 2561-11-13 at 19.43.07.png

ex) vue-pull-refresh
ref) Android swipe to refresh

FlashCard

Screen Shot 2561-11-13 at 19.46.49.png

ex) Vue Flashcard

Particle

粒子っぽいデザイン

Screen Shot 2561-11-13 at 19.50.37.png

ex) vue-particles

Affix

コンテンツと連動して動く目次

Screen Shot 2561-11-13 at 19.54.41.png

ex) Qiitaの記事の右側にある目次
ex) vue-affix

ContextMenu

右クリックなどで出るメニュー

Screen Shot 2561-11-13 at 19.58.55.png

ex) vue-context-menu

FloatLabel

Screen Shot 2561-11-13 at 20.02.03.png

ex) vue-float-label

Wizard | Stepper

作業ステップをわかりやすく表記するもの。

Screen Shot 2561-11-13 at 20.04.02.png

ex) vue-form-wizard

Tour | PageGuide

アプリケーションのガイド

Screen Shot 2561-11-13 at 20.06.54.png

ex) vue-tour

WaterfallLayout | Masonry

Pinterestみたいな配置の要素

Screen Shot 2561-11-13 at 20.10.16.png

Headroom

Swipeで消えるHeader

ex) vue-headroom

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

自作した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)で作成したものです。

1.ダークモード実装前
スクリーンショット 2019-12-05 20.13.06.png

image.png

2.ダークモード実装後
image.png

image.png

最後に

以上WebページをCSSでダークモードにする方法でした。
なんとなくダークモードの方がテンション上がる気がする笑

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsで関数型コンポーネントをつかった話

この記事は、 大阪工業大学 Advent Calendar 2019の5日目の記事です。

はじめに

つい先日のお話。新規サービス作ろうね〜ということで新しいタスクが降ってきました。
新しいアプリじゃん!!

ということで、このサービス開発の一部分にAtomic Designと関数型コンポーネントを使ってみたよってのが今回のおはなし。

自己紹介

某でフロントまわりを触ってます。ケモミミが好き。
大学自体はすでにOBだけど、参加条件的にはOKみたいなんでやります!

Atomic Design

Atomic Designは、ざっくり書くと画面を段階に分けてパーツの作成・組み合わせを行う手法です。
Atomic Design
最小の要素(Atoms)から作って
Atomsを組み合わせて1つの機能をもつ要素(Molecules)を作って
Atoms,Moleculesを組み合わせて、画面を構成するいち要素(Organisms)を作って
最後、作った要素群を利用してテンプレート・ページを構成していく感じ。
(画像はわかりにくくてすいません:bow:

Atomic Designの詳細と、それをVue.jsに落とし込むところについては、他の方の記事をお探しくださいな。

今回Atomic Designで組んだコンポーネントのうち、一部のAtomsを関数型コンポーネントで組んでみました。

関数型コンポーネント

※Vue.jsの公式リファレンスでは関数型コンポーネントの説明にRender関数を利用していますが、今回は主に単一ファイルコンポーネントでやっていきます

公式曰く、関数型コンポーネントは状態を持たず、描画コストの少ないコンポーネントを作ることができるやつですね。
(具体的になんで描画コストが少なく済むのかをちゃんと理解してない:innocent:

単一ファイルコンポーネントで使うときは、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>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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",
]

Screen Shot 2019-12-05 at 17.57.23.png

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",
]

Screen Shot 2019-12-05 at 18.07.07.png

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を書こうと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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-sample

amplifyの初期化などをします

$ npx amplify-app

処理が完了すると amplify というディレクトリが生成されています。
その中にはAmplifyで利用するAWSサービスの情報が入っていきます。
amplify/backend/api/<datasourcename>/schema.graphql の内容を書き換えていきます。

schema.graphql
enum 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
  })
)

このようにDynamoDBにPUTされます。
スクリーンショット 2019-12-05 17.11.26.png

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 というフラグが立ちます。
スクリーンショット 2019-12-05 17.12.26.png

GraphQLで書くと…

const deleteBody = `
  mutation {
    deleteData(
      input: {
        id: "1234567"
      }
    )
  }
`
await API.graphql(graphqlOperation(deleteBody))

さいごに

GraphQLのquery文で結構コード量が増えたり、query文の生成がめんどくさかったりとするのですが、AmplifyDataStoreを使うと、そういったところを解消してくれるそうですね。
プロダクションレベルで使うためには認証周りだったりなどを詰めていかないといけませんが、とても使う価値がありそうです。
ではまた!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CompositionAPIを使ってcompositionを分離した状態でテストする

最初に

ついにComposition APIがrfc上でmergeされました:bangbang:
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.ts
import { 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.ts
import { 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.ts
import 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.ts
import { 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.ts
import { 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.ts
import 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と同じように書けますね:ok_hand:

最後に

どうでしたでしょうか。VueのComposition APIを利用することで、単体テストも、より書きやすくなった印象があります。
Componentに依存していたロジックなども別ファイルにすることでロジックそのもののテストも容易となり、
Componentのテストも、UIの操作や表示などにより特化した形になります。
以前よりは簡単にテストがかけるようになっていますので、テスト駆動を始めたい方などもぜひComposition APIから始めていきましょう!!
明日は@dayjournalさんです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

マイページを作った話

はじめに(軽く自己紹介を...)

今年度、駒場祭において、「マイページ」というページを実装しました。
htmlすら触るのがほぼ初めてだった私が、このマイページというページを仕様書の作成段階から(一応は)作り上げたました。その実装過程で、localStorageやqueryパラメータなどの機能を用いたので、その経緯を是非ともご紹介したいと思い、この記事を書いております。

ここから、始めましょう。一から……いいえ、ゼロから!

駒場祭公式ウェブサイト/マイページについて

今年度、駒場祭公式サイトの新たな試みとして、お気に入り登録機能と、お気に入り登録した企画等を表示するマイページを作成するというプロジェクトが持ち上がりました。
実際に完成したものはここから見てみてください。

仕様の決定

Cookie取得の難点

「お気に入り情報を登録」と聞くと、最初に挙げられる選択肢はおそらくCookieではないでしょうか。
私たちもはじめは、Cookieを取得することで、ユーザーの変更を保存しようと考えました。

しかし、ここで問題が生じます。

EUの「GDPR(一般データ保護規則)」などの影響で、ユーザーのcookie取得の際にユーザーの同意等が必要となるのです。
この、cookieの取得に対する是非を何回も聞かれるようでは非常に面倒になります。というわけで、とりあえずはcookie以外の方法を考えることにしました。

localStorageという選択

さて、cookie取得を封じたとして、ではなにを使おうか...となったときに見つけたのが、「localStorage」でした。localStorageは、簡単に言ってしまえば、「ブラウザに情報を保存する」機構です。
これを用いれば、お気に入りボタン的なのを押して、対応する企画idを一旦localStorageに保存することで、マイページにお気に入り登録した企画を表示できるようになりました。
ですが、このlocalStorage、上で紹介したように、「ブラウザに」情報を保存するものですので、次にブラウザ間のデータのやり取りと、他の媒体で情報を取得することを考えました。

ログイン機構の難点

お気に入りした情報をいかに保存、他の媒体で閲覧できるようにするか。
まず最初に思いついたのが「ログイン」機構です。

ですが、ここにも問題があります。ログイン情報の取得は「個人情報の取得」に該当することがあり、ユーザーに利用規約を提示し、同意をもらう必要が生じかねません。
しかし、お気に入り登録のデータを見るくらいのページでユーザーにそこまで求めてしまっては、ユーザビリティが低下してしまいます。

そうこうしているうちに公開日が迫ってきたので、とりあえずはログイン機構もデータの受け渡しも実装しないまま、第1版を公開しました。

シェアとクエリパラメータの導入

さて、とりあえず暫定的にデータの受け渡し機構を作らないまま公開をしましたが、やはり受け渡しをしないわけにもいきません。

色々考えて出てきたのが、「URLのクエリパラメータ」です。これは、URLの後ろに/?x=hogehogeのような感じで値をのせることができます。
このxhogehogeの部分に、お気に入りした企画などのグッズの情報をのせて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月ごろにぜひ「駒場祭」で検索してみてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

G Suite(Google Apps ScriptとVue.jsとSpreadsheetで作る、フォームからの入力をSpreadsheetに保存するツール

SpreadsheetID SheetName
123456789012345678901234567890123456789012345 HistName
コード.gs
function 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>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

G Suite(Google Apps Script)とVue.jsとSpreadsheetで作る、フォームからの入力をSpreadsheetに保存するツール

SpreadsheetID SheetName
123456789012345678901234567890123456789012345 HistName
コード.gs
function 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>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vue-routerは先に定義されたルートほど優先度が高いと言うお話。

vue-routerの優先度は先に定義されたものが高くなる

はい、少しハマりました。

ハマった点はこちら

※一部省略しています。

router/index.js
const routes = [
    {
        path: '/',
        component: App,
    },
    {
        path: '/:path',
        name: 'week',
        component: Weekly,
    },
    {
        path: '/request',
        name: 'request',
        component: Request
    },
]

最初はこんな感じで書いていました。
上から順に
・homeのパス
・動的にルートを変更
・requestへのパス

こんな感じですね。
どうしてハマったかと言いますと、追加機能を/requestで定義してたんですね。
だけど、タイトルの通りvue-routerは先に定義されたルートの方が優先度高いので、同じものがあったら先に定義されたルートをマッチさせるんですね。

router/index.js
const 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.js
const 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.js
const 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は先に定義されたルートほど優先度が高いと言うお話でした。
ドキュメントは読む!!これにつきますね。

もっとこうした方がいいなどのアドバイスや、その考え方は間違っているなどあればご指摘いただければ嬉しく思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

tree

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

vue.jsでデータをツリー構造・階層構造で表示する方法

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ざっくりFirestore + Vue.jsの使い方

この記事はただの集団 Advent Calendar 2019の5日目の記事です。
( 内容が薄くて大変申し訳ありません:bow_tone1: )

ざっくりFirestore + Vue.jsの使い方

フロントエンドでも簡単にサーバーサイド込みで実装できると噂のFirebase
いじってみたので簡単にですがFirebaseのサービスの一つFirestore についてまとめてみました。

● Firestoreについて

FirestoreRealtime 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.js
import 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で通知設定なども機会があれば記事を書いて見ようかと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

簡易GPSメモPWA『めもいち』をvue-cli で作ってみた話

 たまに地図上にメモを残せないかな?と考えていたのですが、Googleマップはちょっと敷居高いし、何か良い方法はないかな?と思って、「めもいち」と言うPWAサイトを自作してみたお話です。(ソースコードはGitHubリポジトリを参照のこと。

Screenshot_20191203-093629.png

制作の動機

 もともと携帯電話の基地局を歩いて探すことをしていて(かなりマニアック)GoogleMapを使ってたのですが、いちいちその場でサイト開いて…とかめんどくさい。もっと気楽にマーキングできないか?と考えて、とりあえず個人的メモをOpenStreetMapに書き込もうと考えました。偶然 leaflet.js ってライブラリを見つけたのもありました。

そもそも独自性はあるのか?

 作る前に、代用できる物ってないのかな?って考えたんですよね。一般のGPSロガーとか、スマホのカメラの位置情報と地図の関連付けとか別にスマホがあれば使えるし… 言った先での写真とかなら instagram とか 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 で地図を表示する無料で最短の道
 素材作成のために Shade13Adobe 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 &copy; <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配列をそのまま利用してます。

 まだ、万人向けには直すべきところは多いと思いますが、とりあえず自分でちゃんと使える形にはなったので、要望とかがあれば、機能追加しようかな?と思います。

 最後まで読んでくださり、ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでフォームバリデーションをつくろう!ー実装編ー

はじめに

こちらはサポーターズColab で開催の勉強会の説明資料その2です。
その1は、Vue.jsでフォームバリデーションをつくろう!ー環境構築編ーです。

Vue.jsでフォームバリデーションを作ってみよう! の内容を分割、アップデートしたものです。


この記事に書いてあること

  • 簡易アンケートフォームの作成
    • VueRouterを使ったリンクの作成
    • コンポーネントの実装
    • フォームの選択を解除したときのフォームバリデーション実装

この記事で省いていること

  • コードの一部処理
    • サーバーへのデータ送信
    • セキュリティ面のケア
  • Vue.jsの深いお話

環境

  • 端末
    • Mac OS X 10.15.1
  • インストール済みのライブラリ

単一ファイルコンポーネントの記述方法について

実装に入る前に単一ファイルコンポーネントついて説明します。
単一ファイルコンポーネントは、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では、パスを存在しないためです。

screencapture-localhost-8080-done-2019-12-02-23_57_45

そのため、前述で作成したsrc/views/Done.vuerouter/index.jsに設定します。

Javascript:router/index.js
import 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にあるボタンリンクをクリックで画面が遷移し、設定した文字が表示されます。

be3cc373579717c6c2690d55a7154e73


ここからアンケートをつくる準備を進めていきます。

コンポーネントを作成

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を用い、 子から親へデータを渡す際はカスタムイベントを用います。


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を参照ください。

現時点ではこのような状態かと思います。
screencapture-localhost-8080-about-2019-12-04-23_11_42


ボタンの削除

前述で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文字以上を入力した状態でフォームの選択を外して、下記の挙動が再現されます。

3b64e42643e2116ceaa97106946ca0af.gif


このアンケートフォームにはいろいろな機能が足りていません。
ここからカスタマイズをして、自分なりの最高のUIのアンケートを実装してみてください!


その他のオプション

今回は使用していませんが、Vue.jsを理解する上で重要なオプションをご紹介します。

computed
任意のデータを処理を返す算出プロパティ。

watch
特定のデータや算出プロパティの状態を監視、データの変化で処理を実行するプロパティ。


さいごに

ここまで頑張ってフォームのバリデーションを実装しましたが、世の中にはかんたんにバリデーションを設定できる素晴らしいライブラリが提供されています。

これらを使えば、よりよいUXのフォームバリデーションをスムーズに開発ができます。
本当にいい時代に生まれました。


他にも、Vue.jsにはたくさんのサポートライブラリがあり、いろんなことが実現できる環境が整っています。

今回のハンズオンでは、Vue.jsの中でも一部機能にしか触れておりません。
もっと知りたい!と思って頂けた方は、ぜひ公式ドキュメントを読んだり、コミュニティに参加したりしてみてください!

ここまでご参加(お読み)頂き、ありがとうございました??

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.jsでフォームバリデーションをつくろう!ー環境構築編ー

はじめに

こちらはサポーターズColab で開催の勉強会の説明資料その1です。

Vue.jsでフォームバリデーションを作ってみよう! の内容を分割、アップデートしたものです。


この記事に書いてあること

  • VueCLI を使ったManually select featuresの環境構築の手順
  • 各項目のかんたんな説明

この記事で省いていること

  • CLIの説明
  • 各ライブラリの説明

環境

  • 端末
    • Mac OS X 10.15.1
  • インストール済みのライブラリ

開発環境のインストール

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.x3.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 features

Babelと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 にアクセスしてみてください。
下記が表示されていれば、環境構築は完了です!

20191205_localhost8080_1

上部の「About」をクリックすると、別のページが表示されます。

20191205_localhost8080-2


おわりに

VueCLIを使ったVue.jsの開発環境の構築手順をご紹介しました。

今回CLIを使用しましたが、こちらの方法について公式ドキュメントのインストール — Vue.jsには下記のように書かれております。

CLI は Node.js および関連するビルドツールに関する事前知識を前提としています。Vue またはフロントエンドビルドツールを初めて使用している場合、CLI を使用する前に、ビルドツールなしでガイドを参照することを強くお勧めします。

…フロントエンドで求められる知識の幅は広いですね?

Vue.jsでフォームバリデーションをつくろう!ー実装編ーに続きます!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Svelte3入門】ToDoリストをチュートリアルと照らし合わせて作ろみゃあ!

はじめよっけ!

この記事はAteam Brides Inc. Advent Calendar 2019 6日目の記事です。

こんにちは。株式会社エイチームブライズ@mkin です。

さて、今日は React や Vue よりも早いと言われている Svelte を使って簡単なアプリを作っていきます。

この記事は実際に私が ToDoリストを作った順番に、チュートリアルを逆引きしながら作った実録でもあるので、読むだけでも開発体験をトレースできるんじゃないかと、淡く期待しています :blush:

React も Vue も好きな筆者が Svelte を触った感想は「...めっちゃ分かりやすいがぁ!! :rage1:」というもの。この開発体験を少しでも多くの方に共有したくてこの記事を書きます。

※Svelte は2019-04-23にリリースされ大幅に改善したバージョン3を指します。

Svelte ってなんなのー?

私も数週間前までその存在を知りませんでした。
職場のフレンズたちが Svelte で盛り上がっているとき、私は冷めた目をしていました。
でも、知らないものを批判しちゃいかんと思い直し、まず調べたのがこちら。

image.png

Svelte がホームページで謳っていることは次の3つ...

  1. Write less code: より少ないコードで
  2. No virtual DOM: 仮想DOMを使わずに
  3. Truely reactive: 真のリアクティブ

順に説明しますね。

1. Write less code: より少ないコードで

まずは以下の動画をみてください。
calc.gif

単純な計算機能ですが、これを 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です。

とのこと:open_mouth:

開発環境を作ったるがや!

前置きはここまでにして、さっそく開発を始めていきましょう。

  • 私の開発環境はこちらです。
    • 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 .

そしたら、こんなプロジェクトができているので、、、
image.png
さっそく動かしてみましょう。
npm run devをコンソールで実行するとローカルホストが起動するので
http://localhost:5000/ にブラウザでアクセスしてください。
下記のように表示されたら成功。あなたの Svelte 生活の始まりですっ:rocket:

image.png

実装に入る前に...Svelte の拡張機能を入れとこっけ!

VSCode で開発するようなら、以下の拡張機能を入れておくとインテリセンスやシンタックスハイライトが効き、開発が捗るでしょう。

Svelte

image.png

Svelte 3 Snippets

image.png

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リストの雛形ができます:fork_and_knife::ring::dancers:

image.png

Svelte の機能を使ってToDoリストに機能をつけてこっかー

大枠が完成したところで、以下の手順で機能を実装していきたいと思います。

  1. タスクの直書きを配列に変更する
  2. 「タスク追加」ボタンでタスクを追加する
  3. 「絞り込み」機能で表示対象を切り替える

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. 「タスク追加」ボタンでタスクを追加する

次に「タスク追加」ボタンに対してクリックイベントを追加していきたい。(自分に負けない!)
image.png

/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>

ここでは、以下のチュートリアルが役に立ちます。

3. 「絞り込み」機能で表示対象を切り替える

さぁ、最後の工程です! フィルタリングして見たい情報に絞り込んでやろうではありませんかっ!
(おーっ!!!!!)
image.png

/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>

ここでは、以下のチュートリアルが役に立ちます。

ここまでできたらToDoも完成です!
さぁ!みなさんもToDoを追加していきましょう!!:movie_camera:

ezgif.com-video-to-gif.gif

ちょっと待って!! 何かおかしいがー

さっきの動画で何かマズい点あったの気づきました?
そうです。そもそも恋人がいないんですよ!
そうです。動かして分かったんですが、このToDoリストには具合が悪い点がいくつかあったんです。それは...

  1. 初期カーソルが タスク入力欄にフォーカスされてないから入力しづらい
  2. 「タスク追加」ボタンをクリックした後にタスク入力欄がクリアされない

そこで、最後のお仕置きをしたいと思いました。

初期化処理で操作性を高めたいがー

上記の問題は初期化処理を追加することで解決しました。
では、さっそく...

/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>

頑張れ!自分に負けるな!
todolist.gif

最後に、Svelte いいがやっ!

コード量が少ないから見晴らしがよくて、とっつきやすいんじゃないかなと思います:two_hearts:
Svelte でもコンポーネント管理できるし、Storybook for Svelteもあるのでデザインシステムも作れる。そのうえ、SSRとしてSapper という Next/Nuxt のようなフレームワークも用意されている。React Native / Vue Native よろしく Svelte Naitveというのもある。(誰だ!やり過ぎって言ったやつ! 俺も同感だよっ!)
React / Vue を触ったことがある方にはすごく馴染みやすい技術だと思うので、試してみてもらえると嬉しいです!

また、昨日の @oekazuma の記事「君はVue,Reactの次に来るSvelteを知っているか?」も併せて読んでいただけると嬉しいです:relaxed:

それでは、明日は @fussy113 の「1ヶ月〇〇○円で速度改善!?事業でも個人開発でも導入できる画像リサイズのAPI」です。どうぞご期待ください:laughing:

私たちのチームで働こまい?

alt
エイチームは、インターネットを使った多様な技術を駆使し、幅広いビジネスの領域に挑戦し続ける名古屋の総合IT企業です。
そのグループ会社である株式会社エイチームブライズでは、一緒に働く仲間を募集しています!

上記求人をご覧いただき、少しでも興味を持っていただけた方は、まずはチャットでざっくばらんに話をしましょう。
技術的な話だけでなく、私たちが大切にしていることや、お任せしたいお仕事についてなどを詳しくお伝えいたします!

Qiita Jobsよりメッセージお待ちしております!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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を見直すことで、再発見があるかもと思い知らされた...

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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' }
    ]

グラフコンポーネント

  • グラフの種類とデータ、オプションを渡してあげると描画してくれるようにしています。 piechart.png
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%積み上げ棒グラフを描画

100barchart.png

  • chartOptionにisStacked: 'percent'を追加する
      chartOptions: {
        title: '会社の損益',
        subtitle: '売上',
        width: 500,
        height: 500,
        isStacked: 'percent'
      }

アノテーションを付ける

  • グラフを描画していると、グラフに数値を表示したくなります。 100barchartannotation.png
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さんです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

スクリーンショット 2019-12-04 23.10.03.png

お決まりのあれが出ましたね!!!でももうこいつとはおさらばです。容赦なく生成されれたコンポーネントを削除します。

$ 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>

さてここまででで画面の様子を見てみましょうか。

スクリーンショット 2019-12-04 23.23.17.png

渋い!!!!!!
非常に渋いサイトが出来たので、適当に 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.vuethis.$emit('input', picture) として返します。

※ なお、ここのイベント名が input なのは、 v-modelv-bind:valuev-on:input のシンタックスシュガーだからです。 そういえば props として value を受け取っていましたね。

a.gif

小さい方が 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>

b.gif

無事、謎の音楽ファイルをアップロードしようとしたら怒られちゃいましたね。削除もできて便利になりました。

終わりの言葉

サァ画像プレビューが簡単にできちゃいましたね!!!!!!

本当は画像ファイルを ajax でバックエンドにアップして表示、ダウンロードするところまでちゃんと説明したかったんですが、今回はシンプルに base64 で画面表示する感じですませてみました。

そうそう、フルットといえば、モニカの季節ですね!!!皆さんメリークリスマス!!!!!!!!!!!!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む