20200713のvue.jsに関する記事は8件です。

Vueitfyのv-list-itemでネストしたpathの子のページに行くと親もactiveになってしまう問題

あらすじ

  1. /items/items/editのようなリンクのあるサイドバーを実現したい
  2. デフォルトでは/items/editを開くと/itemsもアクティブになるので困る
  3. vue-router:exactのpropをBooleanで渡して完全一致か否かを指定する
  4. exactでは/items?category=プロテインの様にQuery Paramを指定するとアクティブにならない
  5. 特定ページにいる場合、:topropに渡すpathにもquery paramを渡してあげる
  6. 解決!!

解決コード

<template>
  <v-app>
    <v-navigation-drawer v-model="drawer" app>
      <v-list dense nav>
        <template v-for="(page, index) in pageList">
          <!-- ネストしていない構造 -->
          <v-list-item
            v-if="typeof page.items !== 'object'"
            :key="index"
            :to="page.to"
            :exact="page.exact"
          >
            <v-list-item-action>
              <v-icon>{{ page.icon }}</v-icon>
            </v-list-item-action>
            <v-list-item-content>
              <v-list-item-title v-text="page.title" />
            </v-list-item-content>
          </v-list-item>

          <!-- ネストしている構造 -->
          <v-list-group
            v-else
            :key="index"
            :prepend-icon="page.icon"
          >
            <template v-slot:activator>
              <v-list-item-content>
                <v-list-item-title v-text="page.title" />
              </v-list-item-content>
            </template>
            <v-list-item
              v-for="(childPage, cIndex) in page.items"
              :key="cIndex"
              :to="childPage.to"
              :exact="childPage.exact"
            >
              <v-list-item-icon>
                <v-icon>{{ childPage.icon }}</v-icon>
              </v-list-item-icon>
              <v-list-item-content>
                <v-list-item-title v-text="childPage.title" />
              </v-list-item-content>
            </v-list-item>
          </v-list-group>
        </template>
      </v-list>
    </v-navigation-drawer>
  </v-app>
</template>

<script>
export default {
  computed: {
    pageList () {
      const route = this.$route
      // NOTE: どうしてもクエリーパラメーターを使いたい場合は当該pathにいる場合にrouteからfullPathを取得して当てる
      const itemsPath = route.path === '/items' ? route.fullPath : '/items'
      return [
         // ホームはexactをtrueにしなくても問題無い
        {
           icon: 'home',
           title: 'ホーム',
           to: '/',
           exact: true,
        },
        {
           icon: 'shopping_cart',
           title: '商品',
           // `/items/`以下に反応されたら困るのでexactをfalse
           // NOTE: しかし、このままだとクエリーパラメーターを使えない
           items: [
             {
               title: '商品一覧',
               // to: '/items'
               to: itemsPath,
               exact: true,
             },
             // 新規と編集の両方を許可したいのでexacrtはfalse
             {
               title: '商品登録',
               to: '/items/edit',
               exact: false,
             },
           ]
        },
      ]
    }
  }
}
</script>

解説

デフォルトでは/items/editを開くと/itemsもアクティブになるので困る

vue-touterはデフォルトでは部分一致でアクティブの判定をしてしまいます。
/items/items/editのような構造のpathがある場合はexactで完全一致でアクティブ判定をするように指定してあげます。

https://router.vuejs.org/ja/api/#exact

この時、exactはBooleanで指定できるので
完全一致にしたいものだけexactをtrueにしていしてあげる事もできます。

  <v-list-item
     v-if="typeof page.items !== 'object'"
     :key="index"
     :to="page.to"
     :exact="page.exact"
  >

exact(完全一致)ではQuery Paramを指定するとアクティブにならない

exactでは/items?category=プロテインのようにQuery Paramを指定するとアクティブにならない

GitHubのissuesなどで色々漁った結果、公式で対応する予定は無さそうな雰囲気がしたので無理やり対応してみます。

https://github.com/vuejs/vue-router/issues/2040

特定ページにいる場合、クエリーも渡す

参考
How do I style an active route that has query params? - by stackoverflow

ちょっと強引な解決方法になりますが、もし他にいい方法が有ればコメントで教えて下さい!!

<script>
export default {
  computed: {
    pageList () {
      const route = this.$route
      // NOTE: どうしてもクエリーパラメーターを使いたい場合は当該pathにいる場合にrouteからfullPathを取得して当てる
      const itemsPath = route.path === '/items' ? route.fullPath : '/items'

      return [{
         icon: 'shopping_cart',
         title: '商品一覧',
         to: itemsPath,
         exact: true,
      }]
    }
  }
}
</script>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

nuxt plugins では process.client は undefined なので注意(axios のクライアント側エラー処理を書く時のハマりどころ)

これは何

axios のクライアント側エラー処理を書く時にはまったところについて共有のための記事です。
process.clientは使えないので、そもそもクライアント用とサーバ用でファイルを分けようという話です。

バージョン情報

{
    "nuxt": "^2.11.0",
    "@nuxt/typescript-build": "^0.5.2",
    "@nuxt/typescript-runtime": "^0.3.3",
    "@nuxtjs/axios": "^5.9.5"
}

トラブルについて

クライアントサイドでその処理が実行されるか、というのを制御できるprocess.clientプロパティがnuxtアプリケーションではよく使われます。

plugins読み込みの時にはこのプロパティが設定されていないのでundefinedになってしまいます。

plugins/plugin.ts
if (process.client) {
  // 何か処理
}

と書くと、一見動くように見えるのですが、実際にはprocess.clientundefinedになるのでifの中の処理は常に実行されませんでした。

具体的に起こった問題は以下のようなもの

  • サーバ側処理もクライアント側処理も同じプラグインファイルに書いていた
  • クライアント側の処理をして問題なければreturnして処理おわり、その後にサーバサイドの処理をして問題あればnuxt errorを吐くみたいな処理を書いていた
  • そもそもifに入っていないのでreturnで処理が止まらず、クライアント側のクライアントエラー(4xx)でもnuxt errorを吐いて死んでしまう

解決策

clientとserverでそれぞれ実行する処理をファイル名を分けて、それぞれnuxt設定ファイルで読み込みます。

nuxt.config.ts
  plugins: [
    '@/plugins/axios.client.ts',
    '@/plugins/axios.server.ts',
  ],

他にもmodeを書く方法もあるようですが、ファイル名だけをみた時にどちらで実行されるのか?というのがはっきり分かるので、ファイル名で分けたほうが個人的にはいいかなと思います。

補足

process.clientundefined になってしまうのですが、process オブジェクト自体は定義されているようでした。
これはnuxtがnodejsのインターフェースの拡張しているもののようです。

image.png

plugin のなかで console.log(process);すると以下のようになりました。

image.png

.browser を使えばフロントかサーバかの判定はできるようですが、すでにnuxtでは非推奨になっていました。

参考

https://ja.nuxtjs.org/api/configuration-plugins
https://ja.nuxtjs.org/guide/plugins/
https://stackoverflow.com/questions/58146662/process-server-is-undefined-in-nuxt-js-modules

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

[SPA]自分なりに脆弱性を潰しながらフロントエンドに認証情報を扱う

最近、新規事業で完全0-1のプロダクト開発をした際に
Rails/Vue.jsのSPAでいかにセキュアに認証情報を扱うかについて調べた結果を書いていきます
「それおかしくない?」とか「脆弱性つぶせてなくない?」とかあればコメントで指摘していただけるととても助かります

どう実装するか

実装の意図や詳細については下の方で解説します

Rails側

  • 認証情報をsecure属性とhttponly属性をつけたcookieでフロントエンドに返す
    • domain属性はAPIのドメインのみにする(特に設定変えなければデフォルトでそうなってるはず)
  • 独自ヘッダの有無のチェックを認証処理に組みこむ
  • gemrack-corsを導入する
    • originsをSPAのURLのみに制限する
    • credentialstrueにしておく

Vue.js側

  • withCredentialsオプションをtrueに設定する(axiosやxhr)
    • fetchならcredentials: 'include'とかかな
  • リクエストに独自ヘッダをつける
    • なんでもいいし中身も空文字でよくて、ヘッダがあることが重要

なぜ上記のような実装をするのか

以下の3つがポイントになります

  • httponly属性とsecure属性が有効でdomain属性にはAPIのドメインのみを設定したcookieでアクセストークンを持たせる
  • 独自ヘッダが必要であること
  • corsでoriginsを制限されていること

cookieについて

なぜhttponlyにするのか

  • XSSや悪意あるパッケージなどのjsによりcookieにあるトークンを抜かれないようにするため
    • jsから抜かれる危険性はlocalstorageを使わない理由でもある
  • 代わりに、cookieにあるトークンを非同期通信でもリクエストするためにwithCredentialsオプションを有効にする

なぜsecureにするのか

  • 非SSLのリクエストを盗聴されないように、とかっていうよくある理由

なぜdomainをAPIのドメインのみに制限するのか

  • サブドメをワイルドカードにしたりとか他のドメインを許容する必要がないため
    • 少なくとも今回のケースでは複数ドメインをまたいでcookieの共有は必要なかった
    • withCredentialを有効にしてcookieとして飛ばすからフロントエンドのjsからcookieを操作できる必要はない
      • フロントのurlからはcookie見えないけどAPIのドメインへのcookieとしてちゃんと存在しているのでwithCredential有効にしておけばちゃんとリクエストされる
  • 恥ずかしながら自分は最初「フロントのドメインでも扱えなきゃいけないよなぁ」と思い込んでいました。。。

独自ヘッダについて

  • htmlのformによるCSRF対策
    • htmlのform(同期通信)では独自ヘッダを付与することはできないため

CORSについて

  • 独自ヘッダと併用することでjsによるCSRF対策となる
    • originsでちゃんと制限かけることで外からの不正なリクエストを弾く
      • ただし、それだけでは不十分で、ブラウザ上ではエラーになるがリクエスト自体は飛んで処理されてしまう
      • 不正なGETには有効だがPOSTは処理されてしまうので困る
    • POSTは条件次第(formDataを使うこと)で単純リクエストになり、プリフライトリクエストが走らない場合があるため独自ヘッダによりプリフライトを強制させる
      • これによりプリフライトリクエストで止める事ができるので不正なリクエストを飛ばせなくすることができる
  • 独自ヘッダは2度美味しい
  • ちなみに、rack-corsの設定でcredentials: trueにしないとフロントでwithCredential: trueのリクエストを弾いてしまう

実装のサンプル

一部抜粋して書いていきます
参考記事などは下の方にまとめてます

rails側

rack-corsなどの設定

config/application.rb
# ...割愛

    # apiモードのrailsでcookiesを使えるようにするため
    # コントローラ側に `include ActionController::Cookies` も必要
    config.middleware.use ActionDispatch::Cookies

    config.middleware.insert_before 0, Rack::Cors do
      allow do
        # 複数のorigins制限するためドメインの配列
        # 環境変数はjsonにすると配列が簡単に扱えるやんという最近見つけたtips
        origins JSON.parse(ENV.fetch('CORS_DOMAINS_JSON') { '[]' })
        resource "*",
          # axiosなどで `withCredential: true` にした上で、そのcookieを受け取るため
          credentials: true,

          headers: :any,
          methods: [:get, :post, :patch, :delete]
      end
    end

# ...割愛

認証情報をcookieで返す

  • アクセストークンの期限はDBにあるのでpermanent
  • 開発環境はsecure外したいので環境変数COOKIE_SECUREで操作
# ...割愛

cookies.permanent[:access_token] = {
  value: access_token,
  httponly: true,
  secure: ENV['COOKIE_SECURE'].present?,
}
cookies.permanent[:refresh_token] = {
  value: refresh_token,
  httponly: true,
  secure: ENV['COOKIE_SECURE'].present?,
}

# ...割愛

認証処理

access_token = cookies[:access_token]

# ...割愛(access_tokenチェックの処理など)

raise 適当な例外クラス if request.headers[:'X-REQUESTED-BY-MY-APP'].nil?

Vue.js側

withCredentialsを有効にする

src/main.js
// ...割愛

Vue.prototype.$http = axios.create(
  { baseURL: process.env.VUE_APP_API_URI, withCredentials: true },
);

// ...割愛

独自ヘッダをつける

  • Vue.prototype.$httpにインスタンス入れることでどこからでもthis.$httpで使える
  • interceptorsを使うことですべてのリクエストでヘッダ設定の処理を走らせる
src/main.js
// ...割愛

Vue.prototype.$http.interceptors.request.use((request) => ({
  ...request,
  headers: {
    ...request.headers,
    ...{ 'X-REQUESTED-BY-MY-APP': '' },
  },
}));


// ...割愛

疑問

XSS脆弱性があっても認証情報を抜かれないための対策とか、外からのCSRFの対策はしたけど
XSSでXHRのコードをインジェクトされて外部ではなく本来のバックエンドに勝手にリクエストを飛ばされないようにするための対策ってどうすればいいんだろう。。。
根本的にXSSを塞ぐしかないってことになるのかな

参考記事

rails-apiでcookieを使う
RailsでAPIにCORSを設定する
rack-corsでCORS設定をする
さよならCSRF(?) 2017
CORS: OPTIONSリクエスト(preflight request)を避ける

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

[SPA]脆弱性を潰して認証情報を扱う[アクセストークン]

最近、新規事業で完全0-1のプロダクト開発をした際に
Rails/Vue.jsのSPAでいかにセキュアに認証情報を扱うかについて調べた結果を書いていきます
「それおかしくない?」とか「脆弱性つぶせてなくない?」とかあればコメントで指摘していただけるととても助かります

どう実装するか

実装の意図や詳細については下の方で解説します

Rails側

  • 認証情報をsecure属性とhttponly属性をつけたcookieでフロントエンドに返す
    • domain属性はAPIのドメインのみにする(特に設定変えなければデフォルトでそうなってるはず)
  • 独自ヘッダの有無のチェックを認証処理に組みこむ
  • gemrack-corsを導入する
    • originsをSPAのURLのみに制限する
    • credentialstrueにしておく

Vue.js側

  • withCredentialsオプションをtrueに設定する(axiosやxhr)
    • fetchならcredentials: 'include'とかかな
  • リクエストに独自ヘッダをつける
    • なんでもいいし中身も空文字でよくて、ヘッダがあることが重要

なぜ上記のような実装をするのか

以下の3つがポイントになります

  • httponly属性とsecure属性が有効でdomain属性にはAPIのドメインのみを設定したcookieでアクセストークンを持たせる
  • 独自ヘッダが必要であること
  • corsでoriginsを制限されていること

cookieについて

なぜhttponlyにするのか

  • XSSや悪意あるパッケージなどのjsによりcookieにあるトークンを抜かれないようにするため
    • jsから抜かれる危険性はlocalstorageを使わない理由でもある
  • 代わりに、cookieにあるトークンを非同期通信でもリクエストするためにwithCredentialsオプションを有効にする

なぜsecureにするのか

  • 非SSLのリクエストを盗聴されないように、とかっていうよくある理由

なぜdomainをAPIのドメインのみに制限するのか

  • サブドメをワイルドカードにしたりとか他のドメインを許容する必要がないため
    • 少なくとも今回のケースでは複数ドメインをまたいでcookieの共有は必要なかった
    • withCredentialを有効にしてcookieとして飛ばすからフロントエンドのjsからcookieを操作できる必要はない
      • フロントのurlからはcookie見えないけどAPIのドメインへのcookieとしてちゃんと存在しているのでwithCredential有効にしておけばちゃんとリクエストされる
  • 恥ずかしながら自分は最初「フロントのドメインでも扱えなきゃいけないよなぁ」と思い込んでいました。。。

独自ヘッダについて

  • htmlのformによるCSRF対策
    • htmlのform(同期通信)では独自ヘッダを付与することはできないため

CORSについて

  • 独自ヘッダと併用することでjsによるCSRF対策となる
    • originsでちゃんと制限かけることで外からの不正なリクエストを弾く
      • ただし、それだけでは不十分で、ブラウザ上ではエラーになるがリクエスト自体は飛んで処理されてしまう
      • 不正なGETには有効だがPOSTは処理されてしまうので困る
    • POSTは条件次第(formDataを使うこと)で単純リクエストになり、プリフライトリクエストが走らない場合があるため独自ヘッダによりプリフライトを強制させる
      • これによりプリフライトリクエストで止める事ができるので不正なリクエストを飛ばせなくすることができる
  • 独自ヘッダは2度美味しい
  • ちなみに、rack-corsの設定でcredentials: trueにしないとフロントでwithCredential: trueのリクエストを弾いてしまう

実装のサンプル

一部抜粋して書いていきます
参考記事などは下の方にまとめてます

rails側

rack-corsなどの設定

config/application.rb
# ...割愛

    # apiモードのrailsでcookiesを使えるようにするため
    # コントローラ側に `include ActionController::Cookies` も必要
    config.middleware.use ActionDispatch::Cookies

    config.middleware.insert_before 0, Rack::Cors do
      allow do
        # 複数のorigins制限するためドメインの配列
        # 環境変数はjsonにすると配列が簡単に扱えるやんという最近見つけたtips
        origins JSON.parse(ENV.fetch('CORS_DOMAINS_JSON') { '[]' })
        resource "*",
          # axiosなどで `withCredential: true` にした上で、そのcookieを受け取るため
          credentials: true,

          headers: :any,
          methods: [:get, :post, :patch, :delete]
      end
    end

# ...割愛

認証情報をcookieで返す

  • アクセストークンの期限はDBにあるのでpermanent
  • 開発環境はsecure外したいので環境変数COOKIE_SECUREで操作
# ...割愛

cookies.permanent[:access_token] = {
  value: access_token,
  httponly: true,
  secure: ENV['COOKIE_SECURE'].present?,
}
cookies.permanent[:refresh_token] = {
  value: refresh_token,
  httponly: true,
  secure: ENV['COOKIE_SECURE'].present?,
}

# ...割愛

認証処理

access_token = cookies[:access_token]

# ...割愛(access_tokenチェックの処理など)

raise 適当な例外クラス if request.headers[:'X-REQUESTED-BY-MY-APP'].nil?

Vue.js側

withCredentialsを有効にする

src/main.js
// ...割愛

Vue.prototype.$http = axios.create(
  { baseURL: process.env.VUE_APP_API_URI, withCredentials: true },
);

// ...割愛

独自ヘッダをつける

  • Vue.prototype.$httpにインスタンス入れることでどこからでもthis.$httpで使える
  • interceptorsを使うことですべてのリクエストでヘッダ設定の処理を走らせる
src/main.js
// ...割愛

Vue.prototype.$http.interceptors.request.use((request) => ({
  ...request,
  headers: {
    ...request.headers,
    ...{ 'X-REQUESTED-BY-MY-APP': '' },
  },
}));


// ...割愛

疑問

XSS脆弱性があっても認証情報を抜かれないための対策とか、外からのCSRFの対策はしたけど
XSSでXHRのコードをインジェクトされて外部ではなく本来のバックエンドに勝手にリクエストを飛ばされないようにするための対策ってどうすればいいんだろう。。。
根本的にXSSを塞ぐしかないってことになるのかな

参考記事

rails-apiでcookieを使う
RailsでAPIにCORSを設定する
rack-corsでCORS設定をする
さよならCSRF(?) 2017
CORS: OPTIONSリクエスト(preflight request)を避ける

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

StorybookでCannot read property 'query' of undefinedだと?!

はじめに

あれ、最近ビジュアルリグレッションテストが落ちるな?と見てみると、なんとも見慣れないエラーが出ていました。それがCannot read property 'query' of undefinedです。

スクリーンショット 2020-07-13 14.09.55.png

storybookで確認してみるとこんな感じ↓↓

スクリーンショット_2020-07-13_13_54_28.png

reg-suitとstorybookを利用して、PR度に自動差分が出るようにしているのですが、毎度引っかかるようになってしまいました。まぁ、ちゃんと毎回確認しなかった自分が悪いです。はい。

原因

どうやら起因となったのは、以下を含むコンポーネントを作ったことのようです

<nuxt-link :to="{ path: 'hoge', query: $route.query }">hoge</nuxt-link>

そうコンポーネント内にrouteをみる記述があり、そのままだとstorybookではVueRouterを認識してくれないようです。

対応

これは絶対に誰かすでに詰まってるだろうし、addonあるっしょ!と思ってググってたらやはり見つけました。でも意外とスター少ない...(利用しているパッケージにはどんどんスターつけていきましょう!)

storybook-router
スクリーンショット 2020-07-13 14.15.37.png

デコレータは、実際には VueRouter インスタンスをラップする関数です。storybook内でナビゲーションのプロトタイプを構築したい場合や、 ルータ自体をより制御したい場合に使用することができます。

VueRouterをラップしたものであるとシンプルでありますが、こういうのあるってありがたいですね!

では、早速インストールして、以下のように対象の設定ファイルに追記するだけ。自分の場合はルートの.storybook/preview.jsに設定記述をまとめてるのでそちらに書きます。コンポーネントストーリーファイルごとに設定している人は、そちらに書きましょう。

(略)
import { addDecorator } from '@storybook/vue'
import StoryRouter from 'storybook-vue-router'

addDecorator(StoryRouter())

これで完了。特定のルートにだけ含めたいなどの場合には、StoryRouterの第2引数にオプションを渡せば良さそうです。

さいごに

storybookを導入しているかつ、this.$router.namethis.$router.queryを使っているコンポーネントがあってエラーが出る場合は、参考にしてみてください

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

Vue.js 削除ボタンを作ってスロットを理解する

スロットとは

スロット(slot)は、Vue.jsにおける親コンポーネントから子コンポーネントにデータを渡す手段の一つです。
スロットという名前はハーデスリゼロなど様々な機種がある遊技マシン...のことではなく「差し込み口」という意味で使われています。

つまり、コンポーネントに外からコンテンツの差し込みを受け付けるという目的で使用されます。

スロットはその性質上、再利用の高いコンポーネントによく使用されます。再利用の高いコンポーネントは、Atomic DesignにおけるAtomのようなものが代表されます。

まずはさっそくスロットの基礎的な使い方を見ていきましょう。

スロットコンテンツ

スロットを使用コンポーネントとして、汎用的に利用する削除ボタンを実装します。
コンポーネントの内容は、Vuetifyのv-buttonをラップしただけ簡単なものです。

DeleteBtn.vue
<template>
  <v-btn
    color="error"
    dark
    min-width="300"
    rounded
  >
    <slot></slot>
  </v-btn>
</template>

<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
  name: 'DeleteBtn',
})
</script>

見た目はこんな感じです。
小さなコンポーネントですが、このボタンはいろんなページで使用するためコンポーネント化することでデザインを共通化することができます。

スクリーンショット 20200712 15.22.48.png

<slot></slot>を置換する

子コンポーネント側のテンプレートに<slot>タグを記述すると、その場所ではスロットコンテンツが埋め込まれます。
今はボタンのテキストが表示されていない状態ですが、親からスロットコンテンツを渡して表示させてみましょう。

親からスロットコンテンツを渡すには、以下のようにスロットコンテンツをテンプレート上でタグで囲います。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>削除ボタン</delete-btn>
    </v-col>
  </v-row>
</template>

<script lang="ts">
import Vue from 'vue'
import DeleteBtn from '~/components/atom/DeleteBtn.vue'
export default Vue.extend({
  components: {
    DeleteBtn
  },
})
</script>

これは次のように出力されます。

スクリーンショット 20200712 15.57.50.png

スロットには、HTML要素やコンポーネントを入れることもできます。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn><h1>削除ボタン</h1></delete-btn>
    </v-col>
    <v-col class="text-center">
      <delete-btn><v-icon color="black">fab fa-github</v-icon></delete-btn>
    </v-col>
  </v-row>
</template>

<script lang="ts">
import Vue from 'vue'
import DeleteBtn from '~/components/atom/DeleteBtn.vue'
export default Vue.extend({
  components: {
    DeleteBtn
  },
  auth: false,
  layout: 'unauthorized'
})
</script>

スクリーンショット 20200712 16.02.48.png

フォールバックコンテンツ

スロットに対して、コンテンツがない場合に描画されるデフォルトのコンテンツを指定することができます。
フォールバックコンテンツを使用するには、<slot>タグの中に記述します。

DeleteBtn.vue
<template>
  <v-btn
    color="error"
    dark
    min-width="300"
    rounded
  >
    <slot>DELETE</slot>
  </v-btn>
</template>

親コンポーネントでスロットを指定しなかった場合にはDELETEというテキストが出力され、指定された場合にはその文字が出力されます。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <!-- スロットコンテンツを指定せず -->
      <delete-btn></delete-btn>
    </v-col>
    <v-col class="text-center">
      <!-- スロットコンテンツを指定 -->
      <delete-btn>削除</delete-btn>
    </v-col>
  </v-row>
</template>

<script lang="ts">
import Vue from 'vue'
import DeleteBtn from '~/components/atom/DeleteBtn.vue'
export default Vue.extend({
  components: {
    DeleteBtn
  }
})
</script>

スクリーンショット 20200712 16.09.20.png

名前付きスロット

この削除ボタンにさらに機能を追加しましょう、
ボタンをクリックしたときに、「本当に削除しますか?」というダイアログが出現するようにします。

とりあえず簡単にVuetifyのダイアログを追加します。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <v-dialog v-model="dialog" max-width="290">
      <v-card>
        <v-card-title class="subtitle-1">
          削除します。よろしいですか?
        </v-card-title>
        <v-card-text>
          この操作は取り消せません。
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn color="primary" text @click="clickCancel">
            キャンセル
          </v-btn>
          <v-btn color="error" text @click="clickOK">
            削除する
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </span>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'DeleteButton',
  data() {
    return {
      dialog: false
    }
  },
  methods: {
    openDialog() {
      this.dialog = true
    },
    closeDialog() {
      this.dialog = false
    },
    clickCancel() {
      this.closeDialog()
      this.$emit('clickCancel')
    },
    clickOK() {
      this.closeDialog()
      this.$emit('clickOK')
    }
  }
})
</script>

このコンポーネントを汎用的に利用するために、ダイアログのメッセージもスロット化したいはずです。
しかし、次のようにそのまま<slot>タグで囲むとうまく動作しません。

DeleteBtn.vue
<!-- 誤ったスロットの使いかた -->
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <v-dialog v-model="dialog" max-width="290">
      <v-card>
        <v-card-title class="subtitle-1">
          <slot>削除します。よろしいですか?</slot>
        </v-card-title>
        <v-card-text>
          <slot>この操作は取り消せません。</slot>
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn color="primary" text @click="clickCancel">
            <slot>キャンセル</slot>
          </v-btn>
          <v-btn color="error" text @click="clickOK">
            <slot>削除する</slot>
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </span>
</template>

なぜなら、スロットが複数あるため親コンポーネントからどのスロットに対してコンテンツを差し込めばよいか判断することができないからです。

このように、スロットが複数ある場合にはスロットに名前を付けて利用します。<slot>要素はnameという属性を持っているのでこれを利用して名前を定義します。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <v-dialog v-model="dialog" max-width="290">
      <v-card>
        <v-card-title>
          <slot name="dialogTitle">削除します。よろしいですか?</slot>
        </v-card-title>
        <v-card-text>
          <slot name="dialogText">この操作は取り消せません。</slot>
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn color="primary" text @click="clickCancel">
            <slot name="cancelBtn">キャンセル</slot>
          </v-btn>
          <v-btn color="error" text @click="clickOK">
            <slot name="okBtn">削除する</slot>
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </span>
</template>

name属性を持たない<slot>は暗黙的にdefaultという名前を持ちます。

親コンポーネントからスロットコンテンツを指定するには、<template>に対してv-slotディレクティブでスロット名を与えます。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        削除
        <template v-slot:dialogTitle>
          {{ item.id }}のデータを本当に削除しますか?
        </template>

        <template v-slot:okBtn>
          はい、削除します。
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

<script lang="ts">
import Vue from 'vue'
import DeleteBtn from '~/components/atom/DeleteBtn.vue'
export default Vue.extend({
  components: {
    DeleteBtn
  },
  data() {
    return {
      item: {
        id: '12345'
      }
    }
  }
})
</script>

<template>で囲まれていな要素は、デフォルトスロットに対するものとして扱われます。
これは、次のように描画されます。

スクリーンショット 20200712 17.10.14.png

名前付きスロットの省略記法

v-slotディレクティブにも省略記法が使用できます。
省略記法では、v-slot:の代わりに#を使用します。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        削除
        <template #dialogTitle>
          {{ item.id }}のデータを本当に削除しますか?
        </template>

        <template #okBtn>
          はい、削除します。
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

Propsとスロットの違い

ここまでは基礎的なスロットの使い方をみてきました。しかし、ある程度Vue.jsを触ったことがある人なら、次のように思ったんじゃないでしょうか。

「結局これってPropsとやってることは変わらないんじゃ・・・わざわざスロットを使わなくてもいいのでは?」

例えば、先程の例では次のようにPropsを使ったコンポーネントに書き換えることもできそうです。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      {{ btnText }}
    </v-btn>
    <v-dialog v-model="dialog" max-width="290">
      <v-card>
        <v-card-title>
          {{ dialogTitle }}
        </v-card-title>
        <v-card-text>
          {{ dialogText }}
        </v-card-text>
        <v-divider name="dialogText"></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn color="primary" text @click="clickCancel">
            {{ cancelBtn }}
          </v-btn>
          <v-btn color="error" text @click="clickOK">
            {{ okBtn }}
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </span>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'DeleteBtn',
  props: {
    btnText: {
      type: String,
      required: false,
      default: 'DELETE'
    },
    dialogTitle: {
      type: String,
      required: false,
      default: '削除します。よろしいですか?'
    },
    dialogText: {
      type: String,
      required: false,
      default: 'この操作は取り消せません。'
    },
    cancalBtn: {
      type: String,
      required: false,
      default: 'キャンセル'
    },
    okBtn: {
      type: String,
      required: false,
      default: '削除'
    }
  },
  data() {
    return {
      dialog: false
    }
  },
  methods: {
    openDialog() {
      this.dialog = true
    },
    closeDialog() {
      this.dialog = false
    },
    clickCancel() {
      this.closeDialog()
      this.$emit('clickCancel')
    },
    clickOK() {
      this.closeDialog()
      this.$emit('clickOK')
    }
  }
})
</script>
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn
        btnText="削除"
        :dialogTitle="`${item.id}のデータを本当に削除しますか?`"
        okBtn="はい、削除します。"
      />
    </v-col>
  </v-row>
</template>

このような仕事をさせるのなら、Propsとスロットに違いはないようにも思えます。
実際にはどのようにPropsとスロットを使い分けていくのでしょうか?

Propsは値を渡し、スロットは描画内容を渡す

Propsと比較したときに挙げられるスロットの役割とし、親コンポーネントに描画内容を任せるという点があります。

例えば、作成中の削除ボタンに次のような機能を持たせたいとします。

  • 重要なデータAを削除する際には、ダイアログの文字を大きく太文字で表示させる
  • そこそこ大切なデータBを削除するときはダイアログの文字の前にアイコンを表示させる
  • それ以外のデータを削除するときにはデフォルトのダイアログを表示

この機能をPropsを使って実装しようする場合、データの状態に関するロジックを子コンポーネントに記述しなくてはいけなくなってしまいます。

DeleteBtn.vue
<v-card-title v-if="dataLevel === 3" class="display-1">
  {{ dialogTitle }}
</v-card-title>
<v-card-title v-else-if="dataLevel === 2">
  <v-icon>fas fa-exclamation-triangle</v-icon>{{ dialogTitle }}
</v-card-title>
<v-card-title v-else>
  {{ dialogTitle }}
</v-card-title>

このように、データの状態が増えるたびに分岐が増えてしまい、コンポーネントが肥大化してしまします。さらには親の状態の増加によって本来手を加えるべきではない子コンポーネントに記述しなければいけない状態となり、汎用的に使用できるコンポーネントとは言えなくなっていしまいます。

この時、スロットを利用すればコンポーネントを使う側が描画内容を決定すること可能です。

ただこれなら、それぞれのデータの状態に合わせた削除ボタンコンポーネントを作成すれば、子コンポーネント側の肥大化は解消することができます。(DataLevel3DeleteBtn.vueとDataLevel2DeleteBtn.vueを作成して、それぞれに描画内容をもたせる)

ただし、その場合には動作は同じだが描画内容だけが違うコンポーネントを作成することになり、DRY原則からしてあまりイケてない実装になってしまいます。

スロットは、再利用の高いコンポーネントを作成するときに効果を発揮するともいえるでしょう。

スコープ付きスロット

通常のスロットのスコープを確認する

ここからは、基礎的な内容から更に一歩進んだものとなります。
通常、v-slotディレクティブからアクセスできるデータはそのコンポーネント自身のデータになります。

次の例で確認してみましょう。親コンポーネントと子コンポーネントは、同じuserというプロパティをもっています。

Hello.vue
<template>
  <div>Hello, <slot></slot></div>
</template>

<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
  data() {
    return {
      user: {
        name: 'aaa'
      }
    }
  }
})
</script>
App.vue
<template>
  <div>
    <Hello>{{ user.name }}</Hello>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import Hello from '~/components/atom/Hello.vue'
export default Vue.extend({
  components: {
    Hello
  },
  data() {
    return {
      user: {
        name: 'bbb'
      }
    }
  },
})
</script>

この時、描画される内容は子のもつuserプロパティなのか、親の持つuserプロパティなのかどちらでしょうか?
スロットは子コンポーネントに描画されるので、一見子コンポーネントプロパティが使われるようにも思えますが、実際に使用されるのは親コンポーネントの持つプロパティです。

スクリーンショット 20200712 20.32.59.png

これは、デフォルトの動作では例えスロットを使用したとしても、子コンポーネントのプロパティにはアクセスできないことを意味します。
試しに、親コンポーネントのuserプロパティを削除してみると、エラーが発生します。

親から子のプロパティを参照する

一切子のプロパティを親から参照できないとなると不便になることも多いので、スロットには子のプロパティを参照するための機能が提供されています。

子コンポーネントから親コンポーネントのスロットコンテンツとしてプロパティを渡す場合、<slot>要素の属性としバインドします。

Hello.vue
<template>
  <div>Hello, <slot :user="user"></slot></div>
</template>

<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
  data() {
    return {
      user: {
        name: 'aaa'
      }
    }
  }
})
</script>

<slot>要素にバインドされた属性は、スロットプロパティ と呼ばれます。これは、親コンポーネント内でスロットの名前を指定することでスロットプロパティを受け取ることができます。

App.vue
<template>
  <div>
    <Hello>
      <!-- slotPropという名前はコンポーネント内で一意である名前であれば好きな名前を使用することができます。 -->
      <template #default="slotProp">
        {{ slotProp.user.name }}
      </template>
    </Hello>
  </div>
</template>

子コンポーネントのプロパティが描画されています。

スクリーンショット 20200712 20.50.09.png

デフォルトスロットの省略記法

スロットがデフォルトスロットだけの場合には、<template>タグで名前を指定せずとも、次のように記述することができます。

App.vue
<template>
  <div>
    <Hello v-slot="slotProp">
      {{ slotProp.user.firstName }}
    </Hello>
  </div>
</template>

スロットプロパティの分割代入

v-slotでは、JavaScriptの式を記述することができます。ですので、分割代入を利用すれば、よりきれいにプロパティを取得することができます。

App.vue
<template>
  <div>
    <Hello v-slot="{ user }">
      {{ user.firstName }}
    </Hello>
  </div>
</template>

実践的な例

スコープ付きスロットを利用した、より実践的な例を見ていきましょう。
もう一度、先程の削除ボタンコンポーネントに登場していただきます。

今度は、ダイアログ自体をスロットとして提供できるようにしましょう。
新たに、<v-dialog>全体をスロットで囲んでいます。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <slot name="dialog">
      <v-dialog v-model="dialog" max-width="290">
        <v-card>
          <v-card-title>
            <slot name="dialogTitle">削除します。よろしいですか?</slot>
          </v-card-title>
          <v-card-text>
            <slot name="dialogText">この操作は取り消せません。</slot>
          </v-card-text>
          <v-divider name="dialogText"></v-divider>
          <v-card-actions>
            <v-spacer></v-spacer>
            <v-btn color="primary" text @click="clickCancel">
              <slot name="cancelBtn">キャンセル</slot>
            </v-btn>
            <v-btn color="error" text @click="clickOK">
              <slot name="okBtn">削除する</slot>
            </v-btn>
          </v-card-actions>
        </v-card>
      </v-dialog>
    </slot>
  </span>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'DeleteBtn',
  data() {
    return {
      dialog: false
    }
  },
  methods: {
    openDialog() {
      this.dialog = true
    },
    closeDialog() {
      this.dialog = false
    },
    clickCancel() {
      this.closeDialog()
      this.$emit('clickCancel')
    },
    clickOK() {
      this.closeDialog()
      this.$emit('clickOK')
    }
  }
})
</script>

親コンポーネントから異なるタイプのダイアログを描画するために、次のように記述したいはずでしょう。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        <template #dialog>
          <v-dialog v-model="dialog" persistent min-width="500">
            <v-card>
              <v-card-title class="headline">
                本当に削除しますか?
              </v-card-title>
              <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn color="primary" @click="clickCancel">
                  キャンセル
                </v-btn>
                <v-btn color="error" @click="clickOK">
                  削除する
                </v-btn>
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

お察しの通り、これはうまく動作しませんなぜならダイアログの表示を制御するdialogプロパティは子コンポーネントが持っているからです。(v-modelで渡しているところです。)
親にスロットプロパティとして渡してみます。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <slot name="dialog" :dialog="dialog">
      <v-dialog v-model="dialog" max-width="290">
        <v-card>
          <v-card-title>
            <slot name="dialogTitle">削除します。よろしいですか?</slot>
          </v-card-title>
          <v-card-text>
            <slot name="dialogText">この操作は取り消せません。</slot>
          </v-card-text>
          <v-divider name="dialogText"></v-divider>
          <v-card-actions>
            <v-spacer></v-spacer>
            <v-btn color="primary" text @click="clickCancel">
              <slot name="cancelBtn">キャンセル</slot>
            </v-btn>
            <v-btn color="error" text @click="clickOK">
              <slot name="okBtn">削除する</slot>
            </v-btn>
          </v-card-actions>
        </v-card>
      </v-dialog>
    </slot>
  </span>
</template>
App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        <template #dialog="{ dialog }">
          <v-dialog v-model="dialog" persistent min-width="500">
            <v-card>
              <v-card-title class="headline">
                本当に削除しますか?
              </v-card-title>
              <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn color="primary" @click="clickCancel">
                  キャンセル
                </v-btn>
                <v-btn color="error" @click="clickOK">
                  削除する
                </v-btn>
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

しかし、残念なことにこれは好ましい実装ではありません。なぜなら、スロットプロパティの値をv-modelで直接更新してしまっているからです。スロットプロパティで渡された値はあくまで参照だけに留めるべきで、直接値を更新してしまうのは禁じ手です。

親のスロット内での変更を、どのように子に伝えればよいのでしょうか?

スロットプロパティでメソッドを渡す

実は、スロットプロパティには子のメソッドを渡すことも可能です。
スロットプロパティの中では渡したメソッドは子コンポーネントのものなので、子の値を変更することができるというわけです。

メソッドの渡し方は通常の記法変わりません。

DeleteBtn.vue
<template>
  <span>
    <v-btn color="error" dark min-width="300" rounded @click.stop="openDialog">
      <slot>DELETE</slot>
    </v-btn>
    <slot
      name="dialog"
      :dialog="dialog"
      :closeDialog="closeDialog"
      :clickCancel="clickCancel"
      :clickOK="clickOK"
    >
      <v-dialog v-model="dialog" max-width="290">
        <v-card>
          <v-card-title>
            <slot name="dialogTitle">削除します。よろしいですか?</slot>
          </v-card-title>
          <v-card-text>
            <slot name="dialogText">この操作は取り消せません。</slot>
          </v-card-text>
          <v-divider name="dialogText"></v-divider>
          <v-card-actions>
            <v-spacer></v-spacer>
            <v-btn color="primary" text @click="clickCancel">
              <slot name="cancelBtn">キャンセル</slot>
            </v-btn>
            <v-btn color="error" text @click="clickOK">
              <slot name="okBtn">削除する</slot>
            </v-btn>
          </v-card-actions>
        </v-card>
      </v-dialog>
    </slot>
  </span>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'DeleteButton',
  data() {
    return {
      dialog: false
    }
  },
  methods: {
    openDialog() {
      this.dialog = true
    },
    closeDialog() {
      this.dialog = false
    },
    clickCancel() {
      this.closeDialog()
      this.$emit('clickCancel')
    },
    clickOK() {
      this.closeDialog()
      this.$emit('clickOK')
    }
  }
})
</script>

ついでにキャンセルボタンをOKボタンをクリックした際のイベントも渡しておきましょう。
親側の記述は次のようになります。
v-modelを分解して:value@inputを使用します。

App.vue
<template>
  <v-row>
    <v-col class="text-center">
      <delete-btn>
        <template #dialog="{ dialog, closeDialog, clickCancel,clickOK }">
          <v-dialog :value="dialog" @input="closeDialog" dark min-width="500">
            <v-card>
              <v-card-title class="headline">
                本当に削除しますか?
              </v-card-title>
              <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn color="primary" @click="clickCancel">
                  キャンセル
                </v-btn>
                <v-btn color="error" @click="clickOK">
                  削除する
                </v-btn>
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
      </delete-btn>
    </v-col>
  </v-row>
</template>

これで、親からスロットを利用してダイアログを差し替えることができました。

タイトルなし.gif

終わりに

私自身も、Propsとスロットの違いがよくわかっておらず、スロットからは避けていました。
スロットを利用すると、再利用性の高いコンポーネントが作成できたりと、作成の幅が広がります。
さらに、ライブラリのコンポーネントを使用する機会も多いと思いますがそういったものはAPIとしてスロットを公開していることが多いです。
スロットを理解することで、ライブラリをより効果的に使用することも期待できます。

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

Nuxt.jsを使ってみた

前提

Node.jsインストール済み

本題

$ npx create-nuxt-app hello

npxは、npmパッケージのダウンロードと実行を1度に行います。
※ホットリローディングに対応しているのはdev(開発環境)のみ。
npm run build、npm run startは本番環境にアップロードする前などに使用するもので開発時には基本的には使わない。

マスタッシュ構文

page/index.vue
<template>
  <div class="container">
    <p>{{ message }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: "Hello Nuxt.js!"
    };
  }
};
</script>

<style>
</style>

ページ遷移

pageフォルダ内に新規ファイルを作成

page/test.vue
<template>
  <selection class="container">
    <h1>Test</h1>
    <hr />
    //追記
    <router-link to="/">Test Page</router-link>
  </selection>
</template>

Vue Routerを使用。
Nuxt.jsでは自動で設定してくれている。

page/index.vue
<template>
  <div class="container">
    <p>{{ message }}</p>
    <hr />
    //追記
    <router-link to="/test">Top Page</router-link>
  </div>
</template>

パラメーターのバリデーション

数字出ないといけない場合

<script>
export default {
  data() {
    return {
      message: 'user/_id.vueを表示中'
    }
  },
  validate({ params }) {
    return /^\d+$/.test(params.id)
  }
};
</script>

エラーページ

404の場合

layouts/error.vue
<template>
  <div>
    <h1 v-if="error.statusCode === 404">Error! 404</h1>
  </div>
</template>

<script>
export default {
  props: ['error']
};
</script>

※エラーページには含めてはいけない。

HTMLヘッダーのカスタマイズ

ロゴを変える場合

nuxt.config.js
head: {
  //省略
  link: [
    { rel: 'stylesheet', href: 'URL'}
  ]
},
//省略
pages/index.vue
<style>
.title {
  font-family: '変更',
  //省略
}
</style>

非同期通信

axiosインストール

$ npm install axios

データはJSONPlaceholderを使用。
/usersデータを使用。

データ取得

pages/index.vue
<template>
  <section class="container">
    <div>
      <ul>
        <li v-for="user in users" :key="user.id">
          {{ user.id }}, {{ user.name }}, {{ user.company.name }}
        </li>
      </ul>
    </div>
  </section>
</template>

<script>
const axios = require('axios')
let url = '/userのURLを添付'

export default {
  asyncData({ params, error }) {
    return axios.get(url)
      .then((res) => {
        return { users: res.data }
      })
      //エラー処理
      .catch((e => {
        error({ users: e.response.status, message: e.message })
      }))
  }
}
</script>

asyncDataはNuxt.jsが用意しているメソッド。
コンポーネントを初期化する前に非同期の処理を行えるようにするメソッド。

画像の表示

画像をassetsディレクトリ配下に設置。

pages/index.vue
<template>
  <section class="container">
    <div>
      <img src="~/assets/〇〇.jpg">
    </div>
  </section>
</template>

<script>
export default {
}
</script>

ストア作成

storeディレクトリ配下にindex.jsを作成。

store/index.js
import Vuex from "vuex";

const createStore = () => {
  return new Vuex.Store({
    state: function() {
      return {
        message: "Hello Vuex!"
      };
    }
  });
};

export default createStore;
pages/index.vue
<template>
  <div class="container">
    <div>
      <p>{{ $store.state.message }}</p>
    </div>
  </div>
</template>

<script>
export default {};
</script>

ミューテーションの利用

store/index.js
//省略

const createStore = () => {
  return new Vuex.Store({
    state: function() {
      return {
        message: "Hello Vuex!"
      };
    },
    mutations: {
      updateMessage: function(state) {
        state.message = "Updated!";
      }
    }
  });
};
//省略
pages/index.vue
<template>
  <div class="container">
    <div>
      <p>{{ $store.state.message }}</p>
      <button @click="$store.commit('updateMessage')">Update</button>
    </div>
  </div>
</template>

<script>
export default {};
</script>

ミューテーションの機能をコンポーネントから呼び出すには、commitメソッドを利用する。

ミューテーションへ値を渡す

pages/index.vue
<template>
  <div class="container">
    <div>
      <p>{{ $store.state.message }}</p>
      <button @click="$store.commit('updateMessage', 'Commit with payload')">Update</button>
    </div>
  </div>
</template>
store/index.js
const createStore = () => {
  return new Vuex.Store({
    state: function() {
      return {
        message: "Hello Vuex!"
      };
    },
    mutations: {
      updateMessage: function(state, payload) {
        state.message = payload;
      }
    }
  });
};

ミューテーションへ値を渡したい時は、commitメソッドの第二引数に渡したい値を入れ、
ミューテーション側(index.js)の第二引数で値を受け取り利用する。

アクションの利用

これまでステートの値を操作するまでにアクションを飛ばし、ミューテーションを呼んでいたため、アクションを経由するようにする。

pages/index.vue
<template>
  <div class="container">
    <div>
      <p>{{ $store.state.message }}</p>
      <button @click="$store.dispatch('updateMessageAction', 'Dispatch with payload')">Dispatch</button>
    </div>
  </div>
</template>
store/index.js
const createStore = () => {
  return new Vuex.Store({
    state: function() {
      return {
        message: "Hello Vuex!"
      };
    },
    mutations: {
      updateMessage: function(state, payload) {
        state.message = payload;
      }
    },
    actions: {
      updateMessageAction(context, payload) {
        context.commit("updateMessage", payload);
      }
    }
  });
};

ストアのモジュールモードの利用

・クラシックモード
 1つのファイル(index.js)に記述
・モジュールモード
 複数のファイルに記述

●モジュールモード動作の条件
・index.jsがストアオブジェクトをexportしない
・または、index.jsがstoreフォルダ配下に存在しない

storeディレクトリ配下にhello.jsを作成。

store/hello.js
export const state = () => ({
  message: "Hello Vuex!"
});

export const mutations = {
  updateMessage: function(state, payload) {
    state.message = payload;
  }
};

export const actions = {
  updateMessageAction(context, payload) {
    context.commit("updateMessage", payload);
  }
};
pages/index.vue
<template>
  <div class="container">
    <div>
      <p>{{ $store.state.hello.message }}</p>
      <button
        @click="$store.dispatch('hello/updateMessageAction', 'Dispatch with payload')"
      >Dispatch</button>
    </div>
  </div>
</template>

store/index.jsは削除。

SPA開発

firebaseインストール

$ npm install --save firebase@6.2.4

$ npm install --save vuexfire@3.0.1

環境変数の設定

$ npm install --save @nuxtjs/dotenv@1.3.0

.envファイルを作成。

FIREBASE_PROJECT_ID = 'IDを入力'
nuxt.config.js
/*
   ** Nuxt.js modules
   */
  modules: ["@nuxtjs/dotenv"],

Firebaseとの連携

pluginsにfirebase.jsファイルを作成。

plugins/firebase.js
import firebase from "firebase";

const config = {
  projectId: process.env.FIREBASE_PROJECT_ID
};

if (!firebase.apps.length) {
  firebase.initializeApp(config);
}

export default firebase;
``

##ストアの作成
storeフォルダ配下にindex.jsを作成。

```store/index.js
import { vuexfireMutations } from "vuexfire";

export const mutations = {
  ...vuexfireMutations
};

storeフォルダ配下にlist.jsを作成。

store/list.js
//firebaseの初期化設定ファイル
import firebase from "~/plugins/firebase";
//vuexfireのfirestoreActionをインポート
import { firestoreAction } from "vuexfire";

//データベース設定
const db = firebase.firestore();
const listsRef = db.collection("lists");

export const state = () => ({
  lists: []
});

export const actions = {
  //FirestoreActionを呼び出す
  init: firestoreAction(({ bindFirestoreRef }) => {
    bindFirestoreRef("lists", listsRef);
  }),
  add: firestoreAction((context, name) => {
    //未入力でなければ下記内容のデータをfirebaseへ保存
    if (name.trim()) {
      listsRef.add({
        name: name,
        done: false,
        created: firebase.firestore.FieldValue.serverTimestamp()
      });
    }
  }),
  remove: firestoreAction((context, id) => {
    listsRef.doc(id).delete();
  }),
  toggle: firestoreAction((context, todo) => {
    listsRef.doc(list.id).update({
      done: !list.done
    });
  })
};

コンポーネントの作成

pagesフォルダ配下にlists.vueを作成。

pages/lists.vue
<template>
  <div>
    <div class="form">
      <form @submit.prevent="add">
        <input v-model="name" />
        <button>Add</button>
      </form>
    </div>
  </div>
</template>

<script>
import moment from "moment";

export default {
  data: function() {
    return {
      name: "",
      done: false
    };
  },
  created: function() {
    this.$store.dispatch("lists/init");
  },
  methods: {
    add() {
      this.$store.dispatch("lists/add", this.name);
      this.name = "";
    }
  }
};
</script>

firebaseに保存されていればOK。

listsリストを表示

保存したデータを取り出す。

pages/lists.vue
<template>
  <div>
    <!-- 追記 -->
    <ul>
      <li v-for="list in lists" :key="list.id">
        {{ list.done }} {{ list.name }} {{ list.created.toDate() | dateFilter }}
      </li>
    </ul>
    <div class="form">
      <form @submit.prevent="add">
        <input v-model="name" />
        <button>Add</button>
      </form>
    </div>
  </div>
</template>

<script>
import moment from "moment";

export default {
  data: function() {
    return {
      name: "",
      done: false
    };
  },
  created: function() {
    this.$store.dispatch("lists/init");
  },
  methods: {
    add() {
      this.$store.dispatch("lists/add", this.name);
      this.name = "";
    }
  },
  computed: {
    lists() {
      return this.$store.state.lists.lists;
    }
  },
  filters: {
    dateFilter: function(date) {
      return moment(date).format("YYYY/MM/DD HH:mm:ss");
    }
  }
};
</script>

削除機能

pages/lists.vue
<template>
  <div>
    <ul>
      <li v-for="list in lists" :key="list.id">
        {{ list.done }} {{ list.name }} {{ list.created.toDate() | dateFilter }}
        //追記
        <button @click="remove(liste.id)">×</button>
      </li>
    </ul>
    <div class="form">
      <form @submit.prevent="add">
        <input v-model="name" />
        <button>Add</button>
      </form>
    </div>
  </div>
</template>

<script>
//省略
export default {
  //省略
  methods: {
    add() {
      this.$store.dispatch("lists/add", this.name);
      this.name = "";
    },
    //追記
    remove(id) {
      this.$store.dispatch("lists/remove", id);
    }
  },
  //省略
};
</script>

完了・未完了チェックボックス

pages/lists.vue
<template>
  <div>
    <ul>
      <li v-for="list in lists" :key="list.id">
        //追記
        <span v-if="list.created">
          <input type="checkbox" :checked="list.done" @change="toggle(list)" />
          <span :class="{ done: list.done }">{{ list.name }} {{ list.created.toDate() | dateFilter }}</span>
          <button @click="remove(list.id)">×</button>
        </span>
      </li>
    </ul>
    <div class="form">
      <form @submit.prevent="add">
        <input v-model="name" />
        <button>Add</button>
      </form>
    </div>
  </div>
</template>

<script>
//省略
export default {
  //省略
  methods: {
    add() {
      this.$store.dispatch("lists/add", this.name);
      this.name = "";
    },
    remove(id) {
      this.$store.dispatch("lists/remove", id);
    },
    toggle(list) {
      this.$store.dispatch("lists/toggle", list);
    }
  },
  //省略
};
</script>

//追記
<style>
li > span > span.done {
  text-decoration: line-through;
}
</style>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js3.0移行(is属性を使ったカスタム要素)

Vue.js 2.x の is 属性を使ったカスタム要素の移行

is 属性を使っている箇所を v-is に置き換えます。

Vue.js 2.x

<tbody>
  <tr is="my-component" v-for="(foo, index) in bar" :baz="foo" :key="index">
</tbody>

components: {
  'my-component': myComponent
},

Vue.js 3.x

<tbody>
  <tr v-is="'my-component'" v-for="(foo, index) in bar" :baz="foo" :key="index">
</tbody>

※componentオプションを使ってコンポーネントを登録した場合
コンポーネントの登録方法によって、記述方法が変わることに注意。
詳細はRFC参照。
https://github.com/vuejs/rfcs/pull/149

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