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

指定した画像の上にスタンプのせるやつ作った

はじめに

指定した画像の上にスタンプのせるサービスあるじゃないですか
勉強になるかと思ったので似たようなものを作ってみました

どんなやつ

gif.gif

url
https://nena3.github.io/gurasan_stamp/

source
https://github.com/nena3/gurasan_stamp

使っている技術

Vue.js, JavaScript, Canvas
サーバーはGithubPagesを使用

仕様

画像をアップロード

アプロドされた画像を表示

アイコンをすきなとこに動かす

合成した画像を出力
画像出力部分は
DOMを動かして最終的にCanvasでそれを再現するような仕様になっている

詰まったところ

canvasのrotateがちょっと難しかった

参考
https://weblike-curtaincall.ssl-lolipop.jp/blog/?p=594

こめんと

iphoneとか古いブラウザでは動かないかもしれない

あとめっちゃ使いづらいしサングラスのアイコンとか透けとるやないかい

サンタの帽子もフチが透けとるやないかい

おわりに

GithubPagesすげえ!

GithubPagesがあれば簡単なwebサービスはタダでサクサクつくれますね

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

plunkerでvue その38

概要

plunkerでvueやってみた。
transition使ってみた。

成果物

https://embed.plnkr.co/zoWbhXfFCJlveQD9v7IY/

以上。

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

Vue.jsで高校の文化祭投票システムを作った話

Vue.jsで高校の文化祭投票システムを作った話

この記事はVue#2アドベントカレンダーの25日目です?

はじめに

目次

H2タグだけまとめた見出しです

番号 目次
1 はじめに
2 利用したAWSのサービス
3 フロント部分について
4 バックエンド部分について
5 画面遷移時の処理
6 不正投票対策
7 投票処理
8 投票完了処理
9 PC用投票ページ
10 つまずいた点など
11 小ネタ
12 反省点
13 ソースコード(GitHub)
14 参考記事(覚えている範囲で)
15 終わりに

注意事項

できるだけ正確な記事を書こうと心がけておりますが、至らない点があるかもしれません。ご了承ください。
また、そのような間違いを発見された場合は、TwitterのDMまたはコメント欄でご指摘頂けますと大変助かります。

記事の概要

Vue.jsを使って高校の文化祭大賞投票システムを作ったので、後輩への引き継ぎ資料がてら記事を書いてみました。

デモ用ページ

以下のページがデモ用のページとなっており、実際の挙動を確認できます。
https://vote-test.shinbunbun.info/
※スマホからしかアクセスできません。PCからアクセスする場合はChromeのデベロッパーツール等を使ってください。

ソースコードはこちらです。

去年のシステム

去年はLINEBotを使いました。(詳しくはこちら

上の記事にも書いている通り、失敗ではないもののなかなか上手く行かない点がいくつかありました。

今年のシステム

去年の反省を踏まえ、今年は以下のようにWebで投票ができるシステムにしました。
一般出展団体に3票、飲食出展団体に1票投票できます。

制作スケジュール

元々Webで作るという構想が浮かんだのは6月あたりです。ちなみに当時はVue.jsどころかフロント自体ほとんど書いたことがない状態でしたが、文化祭本番は10月末だったので、7月中にパンフレットを完成させて8月からVue.jsを勉強しつつ制作に着手すれば全然間に合いそうだなーとか舐めたことを考えていました。

ですが、パンフレット制作が9月に食い込んでしまい、Vue.jsの勉強すら未着手のまま本番2週間前に突入するという最悪の事態に...。

とりあえずVue.jsを学ばなくてはと思い、行き帰りの電車や休み時間などをフル活用して1週間で猫本を読了し、本番1週間前から制作をはじめました。2日くらい学校へ行くのを忘れていたような気もしますが、総作業時間50時間でどうにか完成させました。

アーキテクチャ

フロント部分はVue.jsで書いてS3に静的ホスティング、バックエンドはNode.jsで書いてLambdaで動かし、DBはDynamoDBを使用しました。
S3の静的ホスティングだけではTLS通信ができないため、CloudFrontとACMをを使用しました。(詳細はCloudFrontACM

Untitled (3).png

利用したAWSのサービス

API Gateway

https://aws.amazon.com/jp/api-gateway/

簡単にAPIの作成ができるサービスです。Lambda関数をAPI経由で叩く際必要になります。

Lambda

https://aws.amazon.com/jp/lambda/

イベント発生時に任意のコードを実行することができるFaaSです。今回はAPI Gatewayと連携させて、APIが叩かれたタイミングで関数を実行するといった風に使用しました。

CloudFront

https://aws.amazon.com/jp/cloudfront/

高速コンテンツ配信ネットワーク (CDN) サービスです。キャッシュサーバーみたいなものですね。世界中にエッジサーバーがあるので、世界中どこからでも高速にアクセスできます。また、一定時間データをキャッシュしてくれるので、オリジンサーバへ負担を軽減させることができます。大規模なサービスではDDoS攻撃対策にも利用されているらしいです。また、サーバ証明書を設定することもできます。今回はこちらの目的で利用しました。

ACM(AWS Certificate Manager)

https://aws.amazon.com/jp/certificate-manager/

無料でサーバ証明書が取得できるサービスです。

Route53

AWSが提供する権威DNSサーバです。EC2やS3、CloudFrontと簡単に連携できるようになっています。

DynamoDB

NoSQLのデータベースです。超早い。

フロント部分について

前述の通り、フロント部分はVue.jsを使用しました。

VueCLI

VueCLIを使ってプロジェクトを作成しました。

Vue Router

Vue Routerを使ってSPAを構築しました。親ルートは以下のような構成になっています

  • 「/」
  • 「/kougakusai」(一般出展団体投票ページ用親ルート)
  • 「/food-gp」(飲食出展団体投票ページ用親ルート)
  • 「/policy」(プライバシーポリシー)
  • 「/thanks」(投票完了ページ)
  • 「/invalidate」(不正投票検知時に表示するページ)

/kougakusai

一般出展団体投票ページ用の親ルートです。子ルートは以下のような構成になっています。

  • 「/kougakusai」(投票方法選択画面)
  • 「/kougakusai/exhibitList」(出展一覧から選ぶ)
  • 「/kougakusai/vote-number」(投票番号から選ぶ)
  • 「/kougakusai/keyword」(キーワードから選ぶ)

/food-gp

飲食出展団体投票ページ用の親ルートです。子ルートは以下のような構成になっています。

  • 「/food-gp」(投票方法選択画面)
  • 「/food-gp/exhibitList」(出展一覧から選ぶ)
  • 「/food-gp/vote-number」(投票番号から選ぶ)
  • 「/food-gp/keyword」(キーワードから選ぶ)

基本的に一般出展団体投票ページと同じような構成になっています。投票可能な団体数が違うなど微妙に異なるところはあるものの画面部分はほぼ同じため、それぞれ共通部分は単一ファイルコンポーネント化しています。

/policy

プライバシーポリシーのページです。ここは1ページしかないため構成解説は割愛します。

/thanks

投票完了後のサンクスページです。一般出展団体の投票完了後と飲食出展団体の投票完了後に表示されます。ここは画面部分も行う処理もまったく同じため、同一ページを使用しています。

/invalidate

不正投票を検知した際に表示するページです。router.beforeEachで不正投票検知の処理を行い、そこで引っかかった場合はこのページに飛びます。(不正投票検知についての詳細は不正投票対策

Vuex

Vuexについてはあまり勉強している時間がなかったため、あまり活用することはできませんでした。本来の使い方として正しいかは分かりませんが、いくつかのファイルで共通して使用する処理はmutations、セッション内で保持しておきたい値はstateに登録するという使い方をしました。

state

  • division
    • 現在見ているページが「/kougausai」以下か「/food-gp」以下かを判別するものです。
    • /food-gpでも述べた通り共通処理はコンポーネント化しています。しかし微妙に違う処理を行うところがあるため、コンポーネント側でその処理を分けるために使用しています。
  • loading
    • 現在読み込み中かどうかを判別するために使用します。
    • trueならローディング画面を表示、falseなら非表示という使い方をしています。
    • 詳細はローディング画面

mutations

  • loadingStart
    • stateのloadingをtrueに変更します。
  • loadingEnd
    • stateのloadingをfalseに変更します。
  • addDivision
    • stateのdivisionを変更します。
  • kougakusaiVote
    • 一般出展団体の投票処理を行います。
    • 主に投票先団体をaxiosでLambdaに飛ばす処理を行なっています。
    • 詳細は一般出展団体
  • foodGpVote
    • 飲食出展団体の投票処理を行います。
    • 主に投票先団体をaxiosでLambdaに飛ばす処理を行なっています。
    • 詳細は飲食出展団体
  • voteComplete
    • 投票完了時の処理です。
    • 一般出展団体の投票時のみ使用します。
    • 以下のように、1団体または2団体に投票した時点で投票完了ボタンを押すとこの処理が走ります。
    • 詳細は投票完了処理

  • invalidate
    • 不正投票が検知された時に走ります。
    • Cookieに不正投票フラグを登録します。

getters

  • returnJsonData
    • divisionがkougakusaiの場合は一般出展団体一覧のjsonを、food-gpの場合は飲食出展団体のjsonを返します。
    • 各出展団体のデータは以下のような形のjsonファイルになっています。

スクリーンショット 2019-10-02 19.50.13.png

単一ファイルコンポーネント

いくつかのページで共通する処理は単一ファイルコンポーネントにしました。

  • choose-voting-way
    • 投票方法の選択画面です。
    • /kougakusai、/food-gpで使用しているコンポーネントです。
    • division(Vuexのstate)を確認して、/kougakusaiの場合と/food-gpの場合の処理を分けています。
  • exhibit-list
    • 出展一覧から投票するページです。
    • /kougakusai/exhibitList、/food-gp/exhibitListで使用しています。
    • choose-voting-wayと同じように、division(Vuexのstate)を確認して、/kougakusaiの場合と/food-gpの場合の処理を分けています。
  • help
    • ヘルプ画面のモーダルウィンドウです。
    • modalウィンドウのコンポーネントをもとに作成しています。
  • keyword
    • キーワードから投票するページです。
    • v-modelでinputに入力されたキーワードを取得しています。
  • LoadingOverlay
  • modal
    • モーダルウィンドウ用のコンポーネントです。
    • モーダルウィンドウの骨組みやスタイルを定義しています。
  • myfooter
    • フッターです。
    • 全ページ共通のため、App.vueで読み込んでいます。
  • myheader
    • ヘッダーです。
    • 全ページ共通のため、App.vueで読み込んでいます。
  • vote-number
    • 投票番号から投票するページです。
    • /kougakusai/vote-number、/food-gp/vote-numberで使用しています。
    • choose-voting-wayと同じように、division(Vuexのstate)を確認して、/kougakusaiの場合と/food-gpの場合の処理を分けています。

バックエンド部分について

バックエンド部分の概要

APIGateway、Lambda、DynamoDBを使用しました。
言語はNode.jsで書いています。

API

以下のように、機能によってAPIエンドポイントを分けました。

  • /kougakusai
    • 一般出展団体投票処理用のエンドポイントです。
    • 詳細は一般出展団体
  • /food-gp
    • 飲食出展団体投票処理用のエンドポイントです。
    • 詳細は飲食出展団体
  • /transition
    • フロントエンド側で不正投票の疑いがある挙動を検知した場合又は初回アクセス時に叩かれます。
    • 本当に不正投票なのかの最終チェックを行います。
    • 最初は遷移時に毎回叩こうと思っていたので「/transition」という名前で作成しました。変更するのが面倒くさかったのでそのままの名前にしています。
  • /vote-complete

画面遷移時の処理

// ルーターナビゲーションの前にフック
router.beforeEach(async (to, from, next) => {

  store.commit('loadingStart');
  console.log('router.js, beforeEach');
  console.log(`path:${to.path}`);

  if (to.path !== '/invalidate') {

    const access = Cookies.get('access');
    const invalidate = Cookies.get('invalidate');
    if (invalidate === 'true') {
      // 不正投票検知ページへ遷移
      console.log('invalidate: true');
      next({
        name: 'invalidate'
      });
    } else {
      //CookieとIPアドレスを使用した不正投票判定処理
      console.log('invalidate: false');
      if (!access) {
        console.log(`accessなし: ${access}`);
        //・
        //・省略
        //・
        } else {
        console.log(`accessあり: ${access}`);
      }
    }
  }
  console.log('せんい');
  next();
});
  • ローディングアニメーションを開始するために、loading(storeのstate)の値をtrueに変更する
  • パスが/invalidateだった場合
    • 不正投票検知のページへ遷移
  • そうでない場合

不正投票対策

Cookie&IPアドレスでチェックしています。まずフロント側でCookieのチェックを行い、そこで不正投票の兆候が見られた場合はバックエンド側でIPアドレスのチェックをするようにしています。
そのほかにも、PC・タブレットからのアクセスをブロックしたり(複数端末での投票防止)、Route53を使って海外からのアクセスをブロッキングしたり(VPNを使用した不正投票対策)するなどの対策を講じました。
ただ、PCのブロックに関してはChromeのデベロッパーツールを使えば回避できてしまうので、何か良い方法はないかなーと考えています。

不正投票が検出された場合は以下の画面を表示し、投票ページへのアクセスをブロックします。

スクリーンショット 2019-12-23 17.33.16.png

以下はCookieとIPアドレスを使った不正投票対策の解説です。

不正投票判定処理

不正投票判定処理のフロント側

      console.log('invalidate: false');
      if (!access) {
        console.log(`accessなし: ${access}`);
        // let err;
        // let responseData;
        try {
          await axios.post('api_endpoint', {
            'path': to.path,
            'sessionId': Cookies.get('sessionId')
          }
            /*, {
                        withCredentials: true
                      } */
          ).then((data) => {
            if (data.data.status === 'pathFoully') {
              console.log('pathFoully');
              Cookies.set('invalidate', 'true');
              next({
                path: '/invalidate'
              });
            } else if (data.data.status === 'ipFoully') {
              console.log('ipFoully');
              Cookies.set('invalidate', 'true');
              Cookies.set('sessionId', data.data.sessionId);
              next({
                path: '/invalidate'
              });
            } else if (data.data.status === 'sessionFoully') {
              console.log('sessionFoully');
              Cookies.set('invalidate', 'true');
              Cookies.set('access', 'true');
              next({
                path: '/invalidate'
              });
            } else if (data.data.status === 'success') {
              console.log('success');
              Cookies.set('access', 'true');
              Cookies.set('sessionId', data.data.sessionId);
            }
          });
        } catch (e) {
          alert(`エラーが発生しました。最初からやり直して下さい。app.vue,router.beforeEach:${e}`);
          console.error(e);
          next({
            name: 'choosing_page'
          });
          // err = true;
        }
      } else {
        console.log(`accessあり: ${access}`);
      }
  • Cookieに、初回アクセス時に登録されるCookieである「access」が保存されているか確認する
  • 保存されていた場合
    • 何もせず終了
  • 保存されていなかった場合
    • 不正投票判定用のAPI(バックエンド)を叩く
    • 不正投票が検知された場合
    • 検知された不正投票の種類(data.data.status)によってCookieを更新する
    • 全種類共通して、'invalidate'には'true'を登録する(不正投票フラグ)
    • 検知されなかった場合(data.data.statusが'success'だった場合)
    • 初回アクセスなので、Cookieの'access'に'true'、'sessionId'にはAPIからのレスポンスに含まれているセッションIDを登録する

不正投票判定処理のバックエンド側

const transitionFunc = async (event) => {

    //レスポンスのインスタンスを生成
    let response = new Response();
    //ヘッダーからIPアドレスを取得
    const ip = event.requestContext.identity.sourceIp;
    //リクエストボディからセッションIDを取得
    let sessionId = JSON.parse(event.body).sessionId;

    //セッションIDが存在する場合
    if (sessionId) {

        //不正投票の可能性があるため、エラーレスポンスを返す
        response.statusCode = 200;
        response.body = JSON.stringify({
            status: "sessionFoully"
        });
        response.headers = {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Credentials': true
        };
        return response;
        //セッションIDが存在しない場合
    } else {
        //IPアドレスをキーにDBを検索する
        let param = {
            TableName: 'kougakusai2019',
            IndexName: 'ip-index',
            KeyConditionExpression: "#k = :val",
            ExpressionAttributeValues: {
                ":val": ip
            },
            ExpressionAttributeNames: {
                "#k": "ip"
            }
        };
        let promise = await new Promise((resolve, reject) => {
            dynamoDocument.query(param, (err, data) => {
                if (err) {
                    reject(`$sendToGroup query err : {err}`);
                } else {
                    resolve(data);
                }
            });
        });

        //レコードが存在した場合
        //セッションIDを再生成してDBに再登録後、エラーレスポンスを返す
        if (promise.Items[0]) {
            console.log("ipアルヨ");
            //セッションID生成
            sessionId = uuidv4();
            //DBにIPアドレスとセッションIDを登録
            await sessionIdRegister(sessionId, ip);

            //レスポンスをreturn
            response.statusCode = 200;
            response.body = JSON.stringify({
                status: "ipFoully",
                sessionId: sessionId
            });
            response.headers = {
                'Access-Control-Allow-Origin': '*',
                'Access-Control-Allow-Credentials': true
            };
            return response;

            //レコードが存在しなかった場合
        } else {
            console.log("ipナイヨ");
            //ボディから送信元のパスを取得する
            const path = JSON.parse(event.body).path;
            //パスがルートだった場合
            if (path === '/') {
                //普通に初回アクセスなので、初回アクセス時の処理を行い、レスポンスを返す
                response = await firstAccessFunc(event);
                return response;

                //パスがルートでない場合
                //不正投票の可能性があるため、セッションIDを再登録後エラーレスポンスを返す
            } else {
                sessionId = uuidv4();
                await sessionIdRegister(sessionId, ip);

                response.statusCode = 200;
                response.body = JSON.stringify({
                    status: "pathFoully"
                });
                response.headers = {
                    'Access-Control-Allow-Origin': '*',
                    'Access-Control-Allow-Credentials': true
                };
                return response;
            }
        }
    }
};
  • sessionIdが存在するか確認する
    • 存在する場合
    • Cookieの一部を不正に削除または偽装している可能性が高いため、不正投票と判断してstatus :'sessionFoully'を返す
    • 存在しない場合
    • IPアドレスがDBに保存されているか確認
      • 保存されている場合
      • 新しいsessionIdを発行してIPアドレスと一緒にDBへ再登録後、status: 'ipFoully'を返す
      • 保存されていない場合
      • 閲覧しているページのパスを確認
        • トップページの場合
        • 初回アクセスと判定し、status: 'success'を返す
        • トップページでない場合
        • IPアドレスを偽装してアクセスしている可能性が高いため、不正投票と判断してstatus: 'pathFoully'を返す

投票処理

一般出展団体

一般出展団体投票処理のフロント部分

    async kougakusaiVote(state, payload) {
      state.loading = true;
      const id = payload[0];
      const name = payload[1];
      const clubName = payload[2];
      const router = payload[3];
      const sessionId = Cookies.get('sessionId');
      if (Cookies.get('name')) {
        const votedNames = JSON.parse(Cookies.get('name'));
        let i = 1;
        let flag = false;
        console.log(Object.keys(votedNames).length);
        Object.keys(votedNames).forEach(() => {
          console.log(votedNames[i]);
          if (votedNames[i] === name) {
            flag = true;
          }
          i++;
        });
        if (flag) {
          alert('同じ団体に2回以上投票する事は出来ません。もう一度投票する団体を選択して下さい');
          router.push({
            name: 'kougakusai-root'
          });
          return;
        }
      }
      let err = false;
      let data;
      try {
        data = await axios.post('htttps://XXX/kougakusai', {
          'id': id,
          'sessionId': sessionId
        }
          /*, {
                    withCredentials: true
                  } */
        );
      } catch (e) {
        alert(`エラーが発生しました。お手数ですがお近くの執行部員までお問い合わせください。store,mutations,kougakusaiVote:${e}`);
        console.error(e);
        err = true;
        state.loading = false;
      }
      if (data.data.status === 'idsFoully') {
        Cookies.set('invalidate', 'true');
        Cookies.set('voteSession', '3');
        router.push({
          name: 'invalidate'
        });
      }
      if (err) {
        let voteSession = parseInt(Cookies.get('voteSession'), 10);
        voteSession++;
        Cookies.set('voteSession', voteSession);
        this.$store.commit('addDivision', 'none');
        router.push({
          name: 'choosing_page'
        });
      } else {
        let voteSession = parseInt(Cookies.get('voteSession'), 10);
        voteSession++;
        Cookies.set('voteSession', voteSession);
        if (voteSession === 1) {
          Cookies.set('name', JSON.stringify({
            1: name
          }));
          Cookies.set('clubName', JSON.stringify({
            1: clubName
          }));
        } else {
          let names = JSON.parse(Cookies.get('name'));
          let clubNames = JSON.parse(Cookies.get('clubName'));
          names[voteSession] = name;
          clubNames[voteSession] = clubName;
          Cookies.set('name', JSON.stringify(names));
          Cookies.set('clubName', JSON.stringify(clubNames));
        }
        // this.$store.commit('addDivision', 'none');
        console.log(router);
        if (voteSession === 3) {
          router.push({
            name: 'thanks'
          });
        } else {
          router.push({
            name: 'kougakusai-root'
          });
        }
      }
      state.loading = false;
    }

storeのmutationsに登録されており、投票ボタンを押した際に呼び出されます。
最初に、引数から投票する団体の投票番号(id)、出展名、出展団体名を取り出します。
同じ出展団体に複数回投票することは出来ないので、投票済みの投票団体ではないことを確認し、一般出展団体投票用のAPIを叩きます。
API側での処理が正常に完了した場合は、Cookieを更新して次のページへ遷移します。

一般出展団体投票処理のバックエンド部分

const kougakusaiFunc = async(event) => {
    const response = new Response();
    const ip = event.requestContext.sourceIp;
    const sessionId = JSON.parse(event.body).sessionId;
    const id = JSON.parse(event.body).id;
    console.log(sessionId);

    let param = {
        TableName: 'kougakusai2019',
        KeyConditionExpression: "#k = :val",
        ExpressionAttributeValues: { ":val": sessionId },
        ExpressionAttributeNames: { "#k": "sessionId" }
    };
    let promise = await new Promise((resolve, reject) => {
        dynamoDocument.query(param, (err, data) => {
            if (err) {
                reject(`err : ${err}`);
            }
            else {
                resolve(data);
            }
        });
    });
    //console.log(promise.Items[0].ids.values.length);
    console.log(`promise: ${promise.Items[0]}`);
    //console.log(`promise.Items[0].ids: ${promise.Items[0].ids}`);
    if (promise.Items[0].ids) {
        const ids = promise.Items[0].ids.values;
        ids[ids.length++] = id;
        if (ids.length > 3) {
            response.statusCode = 200;
            response.body = JSON.stringify({ status: "idsFoully" });
            response.headers = {
                'Access-Control-Allow-Origin': '*',
                'Access-Control-Allow-Credentials': true
            };
            return response;
        }
        else {
            param = {
                TableName: 'kougakusai2019',
                Key: { //更新したい項目をプライマリキー(及びソートキー)によって1つ指定
                    sessionId: sessionId
                },
                ExpressionAttributeNames: {
                    '#i': 'ids'
                },
                ExpressionAttributeValues: {
                    ':ids': dynamoDocument.createSet(ids)
                },
                UpdateExpression: 'SET #i = :ids'
            };
            await new Promise((resolve, reject) => {
                dynamoDocument.update(param, (err, data) => {
                    if (err) {
                        reject(err);
                    }
                    else {
                        resolve(data);
                    }
                });
            });
        }
    }
    else {
        param = {
            TableName: 'kougakusai2019',
            Key: { //更新したい項目をプライマリキー(及びソートキー)によって1つ指定
                sessionId: sessionId
            },
            ExpressionAttributeNames: {
                '#i': 'ids'
            },
            ExpressionAttributeValues: {
                ':ids': dynamoDocument.createSet([id])
            },
            UpdateExpression: 'SET #i = :ids'
        };
        await new Promise((resolve, reject) => {
            dynamoDocument.update(param, (err, data) => {
                if (err) {
                    reject(err);
                }
                else {
                    resolve(data);
                }
            });
        });
    }

    response.statusCode = 200;
    response.body = JSON.stringify({
        status: "success",
        sessionId: sessionId
    });
    response.headers = {
        'Access-Control-Allow-Origin': '*'
    };
    return response;
};

一般出展団体投票用APIでは、フロント側から受け取った投票先団体の投票番号をDBへ登録する処理を行なっています。
まず、リクエストからipアドレス、セッションID、投票先団体の投票番号(id)を取得します。
その後、セッションIDをキーにDBを叩き、そのユーザーが投票済みの団体を取得します。もしすでに3団体投票済みの場合は不正投票の可能性があるので、エラーレスポンスを返します。投票済み団体数が2団体以下の場合は、DBへ投票先団体の投票番号を追加して正常終了のレスポンスを返します。

飲食出展団体

飲食出展団体投票処理のフロント部分

    async foodGpVote(state, payload) {
      state.loading = true;
      const id = payload[0];
      const name = payload[1];
      const clubName = payload[2];
      const router = payload[3];
      const sessionId = Cookies.get('sessionId');
      let err = false;
      let data;
      try {
        data = await axios.post('https://XXXXX/food-gp', ({
          // 'division': 'food-gp',
          'id': id,
          'sessionId': sessionId
        }));
      } catch (e) {
        alert(`エラーが発生しました。お手数ですがお近くの執行部員までお問い合わせください。store,mutations,food-gpVote:${e}`);
        console.error(e);
        err = true;
      }
      if (data.data.status === 'idsFoully') {
        Cookies.set('invalidate', 'true');
        Cookies.set('food-gp', 1);
        router.push({
          name: 'invalidate'
        });
      }
      if (err) {
        this.$store.commit('addDivision', 'none');
        router.push({
          name: 'choosing_page'
        });
      } else {
        Cookies.set('name', JSON.stringify({
          1: name
        }));
        Cookies.set('clubName', JSON.stringify({
          1: clubName
        }));
        Cookies.set('food-gp', 1);
        // this.$store.commit('addDivision', 'none');
        console.log(router);
        router.push({
          name: 'thanks'
        });
      }
      state.loading = false;
    },

一般出展団体と同様、引数から投票先団体の情報を取得します。飲食出展団体は1団体しか投票できないため投票済み団体の確認をする必要はないので、とくに何もせず飲食出展団体投票用のAPIを叩きます。API側の処理が成功したらサンクスページへ遷移します。

飲食出展団体投票処理のバックエンド部分

//飲食出展団体投票処理用の関数
const foodGpFunc = async (event) => {

    //レスポンスのインスタンスを生成
    const response = new Response();
    //ボディからセッションIDを取得
    const sessionId = JSON.parse(event.body).sessionId;
    //ボディから投票先団体の投票番号を取得
    const id = JSON.parse(event.body).id;
    console.log(sessionId);

    //セッションIDをキーにしてDBを検索する
    let param = {
        TableName: 'kougakusai2019',
        KeyConditionExpression: "#k = :val",
        ExpressionAttributeValues: {
            ":val": sessionId
        },
        ExpressionAttributeNames: {
            "#k": "sessionId"
        }
    };
    let promise = await new Promise((resolve, reject) => {
        dynamoDocument.query(param, (err, data) => {
            if (err) {
                reject(`err : ${err}`);
            } else {
                resolve(data);
            }
        });
    });

    //DBから取得したデータに投票番号が存在する場合(既に投票済みの場合)
    //不正投票の可能性があるので、エラーレスポンスを返す
    if (promise.Items[0].foodId) {
        response.statusCode = 200;
        response.body = JSON.stringify({
            status: "idsFoully"
        });
        response.headers = {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Credentials': true
        };
        return response;

        //まだ投票していない場合
    } else {

        //DBに投票先団体の投票番号を登録
        param = {
            TableName: 'kougakusai2019',
            Key: {
                sessionId: sessionId
            },
            ExpressionAttributeNames: {
                '#i': 'foodId'
            },
            ExpressionAttributeValues: {
                ':foodId': id
            },
            UpdateExpression: 'SET #i = :foodId'
        };
        await new Promise((resolve, reject) => {
            dynamoDocument.update(param, (err, data) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(data);
                }
            });
        });
    }

    //レスポンスをreturn
    response.statusCode = 200;
    response.body = JSON.stringify({
        status: "success",
        sessionId: sessionId
    });
    response.headers = {
        'Access-Control-Allow-Origin': '*'
    };
    return response;
};

大まかな構造は一般出展団体投票用APIと変わりないですが、飲食出展団体は1団体しか投票ができないので、そこの部分だけ少し違う処理になっています。

投票完了処理

1団体または2団体まで投票した時点で投票を完了することができる機能です

フロント側

async voteComplete(state, router) {
      state.loading = true;
      console.log(router);
      Cookies.set('voteSession', 3);
      let err = false;
      try {
        await axios.post('https://XXXXX/vote-complete', ({
          'status': 'voteComplete',
          'sessionId': Cookies.get('sessionId')
        }));
      } catch (e) {
        alert(`エラーが発生しました。お手数ですがお近くの執行部員までお問い合わせください。store,mutations,kougakusaiVote:${e}`);
        console.error(e);
        err = true;
      }
      if (err) {
        this.$store.commit('addDivision', 'none');
        router.push({
          name: 'choosing_page'
        });
      } else {
        router.push({
          name: 'thanks'
        });
      }
      state.loading = false;
    }

投票完了処理には0.5~1秒ほどかかることがあるので、最初にstate.loadingをtrueにしてローディングアニメーションを開始します。
その後、Cookieを更新してから投票完了処理を行うAPIを叩きます。
処理が正常に完了した場合は、ローディングアニメーションを終了してサンクスページへ遷移します。

バックエンド

//投票完了処理用の関数
const voteCompletefunc = async (event) => {
    //レスポンスのインスタンスを生成
    const response = new Response();
    //ボディからセッションIDを取得
    const sessionId = JSON.parse(event.body).sessionId;
    console.log(`sessionId: ${sessionId}`);

    //セッションIDをキーにDBを検索
    let param = {
        TableName: 'kougakusai2019',
        KeyConditionExpression: "#k = :val",
        ExpressionAttributeValues: {
            ":val": sessionId
        },
        ExpressionAttributeNames: {
            "#k": "sessionId"
        }
    };
    let promise = await new Promise((resolve, reject) => {
        dynamoDocument.query(param, (err, data) => {
            if (err) {
                reject(`err : ${err}`);
            } else {
                resolve(data);
            }
        });
    });

    //DBより取得したデータから投票済み団体の投票番号を取得
    const ids = promise.Items[0].ids.values;
    console.log(`ids: ${ids}`);
    //idsの長さ(投票済み団体の数)を取得
    const idsLength = ids.length;
    console.log(`ids: ${ids.length}`);

    //1団体に投票済みの場合
    if (idsLength === 1) {
        //ダミーデータを追加
        ids[2] = 200;
        ids[3] = 0;

        //2団体に投票済みの場合
    } else {
        //ダミーデータを追加
        ids[3] = 0;
    }
    console.log(`ids2: ${ids}`);

    //ダミーデータを追加した投票番号一覧をDBへ再登録
    param = {
        TableName: 'kougakusai2019',
        Key: {
            sessionId: sessionId
        },
        ExpressionAttributeNames: {
            '#i': 'ids'
        },
        ExpressionAttributeValues: {
            ':ids': dynamoDocument.createSet(ids)
        },
        UpdateExpression: 'SET #i = :ids'
    };
    await new Promise((resolve, reject) => {
        dynamoDocument.update(param, (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });

    //レスポンスをreturn
    response.statusCode = 200;
    response.body = JSON.stringify({
        status: "success",
        sessionId: sessionId
    });
    response.headers = {
        'Access-Control-Allow-Origin': '*'
    };
    return response;
};

ボディからセッションIDを取得してそれをキーにDBを検索し、DBより取得したデータから投票番号を取り出して投票済み団体数をチェックします。投票済み団体数に応じて投票番号の配列にダミーデータを追加して、投票番号の配列をDBに再登録しています。

PC用投票ページ

スマホを持っていない人向けで校門前の受付に投票用PCを設置しました。インターネットに接続することができない環境だったので、投票データは配列としてlocalStorageに保存しておき、後から読み出して集計を行いました。

つまずいた点など

大量のバグ

大量のバグが発生しました。思い出したくない。

ドメイン利用制限事件

文化祭本番前日である2019年9月27日23時04分。最終チェックをするために投票ページへアクセスしようとした僕は、突如投票ページにアクセス出来なくなっていることに気がついたのだった...。

ということで、本番前日の夜に突如として投票ページにアクセス出来なくなるという、冷や汗じゃすまないレベルの事件が起きました。

結果としては、ドメインのメール認証をし忘れていたせいで利用制限がかかってしまったというインシデントでした。夜のうちに原因解明と利用制限解除ができたのでことなきを得ましたが、もしこれが本番真っ只中で起こっていたらと思うと冷や汗がでます。僕の当時の焦りを少しでも伝えられればと思い、当時の状況を時系列順に並べてみました。終始半泣きです。

時間 出来事
2019/9/27 21:17 ドメイン利用制限がかかる
21:22 【重要】[お名前.com] ドメイン利用制限設定 完了通知というメールが届く
23:04 投票ページにアクセス出来なくなったことが判明
23:16 DNS系のエラーであることが判明
23:30 whoisなどを調べた結果、ドメインに利用制限がかかっていることに気付く
23:35 メールが届いていたことに気付く
23:39 認証を通す
23:51 利用制限解除を確認
23:53 投票ページにアクセスできるようになっていることを確認

悲痛の叫び

マジで焦った

小ネタ

ローディング画面

こんなやつです。

ダウンロード (2).gif

文字の部分は猫本のサポートページに載っていたものを使わせて頂きました。

クルクル回るやつもどこかの記事から拾ってきたものなのですが、どの記事だったかは忘れました...。

router.beforeEachが実行されるとすぐにstore.commit('loadingStart')でローディング状態に入ります。フロント側での不正投票検出処理などが完了し、router.afterEachに入るとstore.commit('loadingEnd')でローディング状態を終了します。投票時にも同じローディングアニメーションを使用しています。

モーダルウィンドウ

出展団体を表示する際にモーダルウィンドウを使用しました。

こちらも猫本のサポートページに載っていたものを参考にしました。

表示するhtmlはこちらのサイトなどを参考にしながらBootstrapのカードを利用して作成しました。

集計

DynamoDBtoCSVを利用してdynamoDBのデータをすべてcsvで取得し、vscodeで正規表現を使った置換で余計なものを消してからExcelのピボットテーブルを使って集計を行いました。来年は集計用のLambda関数を作成して一発で集計ができるようにしたい所存。

ボタン等の画像

イラレで作成し、svgで書き出して埋め込みました。

スクリーンショット 2019-12-23 17.57.49.png

反省点

不正投票を厳格化しすぎた

Cookieの改竄やIPアドレスの改竄が検出された場合は一発で投票をブロックする仕様になっていたのですが、それでは初回アクセス時と違うブラウザを使用した時点でブロックされてしまいます。また、スマホの場合は基地局が変わったり電源をオンオフしたりWi-Fiに接続したりするだけでIPアドレスが変わってしまうのですが、それも不正投票扱いとなってしまいます。

アクセス解析をしたところ、不正投票ページへのアクセスが一定数あったので、意図せず不正投票扱いになってしまった人が結構な量いたのではないかと思います。

これでは流石に利便性が最悪なので、同一IPでUAが違う場合は不正投票扱いにせずCookieを再登録することで対応する、Cookieは登録されているが前回アクセス時とIPが違う場合も不正投票扱いにせずDBのIPアドレスを更新するなどしたいなと思っています。

コード汚い&変数名が適当すぎる

制作スケジュールでも書いた通りめちゃめちゃ急いで作ったので、コードが汚い上に変数名が目を覆いたくなるレベルの適当さです。リーダブルコード読んで出直してきます。幸い来年まではまだ1年弱あるのでゆっくり直していければと思っています。ちなみに宿題は3日前くらいからまとめてやるタイプです。

宣伝不足

これは本番当日の話ですが、1日目に投票の宣伝をすっかり忘れていたせいで投票数が3桁しかないというとんでもないことが起ってしましました。2日目はラミネート加工したQRコードを中学生に持たせて宣伝しながら校内を回ってもらったところ、1日目の3倍に増えました。そもそも投票の存在を知らないお客さんが多かったので、宣伝をきちんとして周知させていくこともかなり大事だなと思いました。

ソースコード(GitHub)

https://github.com/shinbunbun/vote-system-2019

GitHubにソースコードをあげました。人様に見せられるコードではないので公開しようか迷ったのですが、マサカリを投げて欲しいので公開します。

今回発見できた不具合(改善点)は反省点で書きましたが、それ以外にも大量のバグがあると思います。また、Vue.jsを使ったのがはじめてだったのできちんとした使い方ができていないと思います。(とくにVuex)どんなに些細なことでも良いので、「ここはこうした方が良いよー」とか「ここ間違ってんじゃオラ」みたいなものがあれば、コメント欄、TwitterのDM、GitHubのPRやissueなどでガンガン教えて頂けますと助かります。

参考記事(覚えている範囲で)

以下の記事を参考させて頂きました。ありがとうございます。

終わりに

長いですねこれ。1000行を超えるQiita書いたのはじめてです。ここまで読んでいる人はいないんじゃないかなーと思いながらこの文章を書いています。あと、思いつきで書き進めたので多分文章めちゃくちゃです。ごめんなさい。長すぎて誰も読まない気はしましたが、後輩のために頑張って書きました。僕が卒業しても引き継いでね。

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

javascript非同期処理~Promise,async,awaitとコールバック関数~

きっかけ

仕事でvue.jsを使うことになって非同期処理について勉強していたところ、Promiseとかasyncとかawaitとかで?????となったのでふんわりな理解だったコールバック関数について学び直そうと決意

現状の私の理解

  • コールバック関数は関数に入れる関数。それ以外は...

非同期処理でやれることの例

  • ボタンがクリックされた後に何かを表示する
  • ファイルの読み込みが完了した後に何かをする

非同期処理のイメージ

  • 処理を関数単位で考えた時
    • 実行中の関数の中で非同期処理が発火する
    • 実行中の関数の終了を持ってコールバック関数を実行する スクリーンショット 2019-10-02 8.07.51.jpg

※こちらのイメージは拾い画なのですが拾い元が特定できなくてリンクを載せることができません。。。知ってる方いましたら教えて...

関数を実行するときと値として扱う際の書き方の違い

  • カッコ【()】をつけるかつけないか
hoge.js
const max1 = Math.max(1, 2); // これはカッコがついてるので関数の計算結果が入る
const max2 = Math.max; // これはカッコがないので関数自体が入る
  • カッコなしを実行しようとするとnot a functionエラーが発生する

相手に実行してもらうのがコールバック関数

  • 以下で考えるとconsole.logがコールバック関数となる。
huga.js
setTimeout(function() {
  console.log('Hello!');
}, 2000);

関数を受け取る関数を高階関数と呼ぶ

  • さっきのコードで言うとsetTimeoutが高階関数。

非同期処理の書き方

  • 1.非同期処理関数はコールバック関数を受け取る高階関数にする
  • 2.利用者は「終わったら実行したい処理」をコールバック関数として渡す
  • 3.非同期処理関数は処理が終わったらコールバック関数を呼び出す

DOMのイベントで非同期処理

  • クリックイベントにコールバック関数(console.log)を紐付け
piyo.js
document.querySelector('.my-button').addEventListener('click', function(event) {
  console.log('clicked!');
});

- 関数名でも紐付け可能

puyo.js
function callback(event) {
  console.log('Hello'!);
}

document.querySelector('.my-button').addEventListener('click', callback);

非同期処理を記述するPromiseの基本

  • 今までの内容で非同期処理には高階関数とコールバック関数が必要なことがわかったと思うが、処理が多くなるとネストが多段になって読みにくくなってしまう。
  • そこでPromiseと言う仕組みを使うことで簡潔に記述できる
  • 使い方とサンプルは以下
    • 1.まずPromiseをnewすることによりPromiseオブジェクトを作成。
    • 2.Promiseのコンストラクタには、実行したい処理を書いた関数を渡し、処理が済んだらresolve関数を呼び出すことで終了を明示する。
    • 3.Promiseオブジェクトのthenメソッドに、Promise終了後に処理したい関数を渡す。
    • 4.「Promiseの実行が済んだ後にxxxする」
promise.js
const promise = new Promise((resolve, reject) => {
    setTimeout(() => { 
        console.log('hello');//実行したい処理
        resolve();//実行したい処理が終わったことを明示。thenメソッドへ。
    }, 500);
});

promise.then(() => console.log('world!'));

Promise〜値を渡す1〜

  • resolveには値を渡すこともできる
  • resolve関数に渡した値は、thenメソッドで受け取ることができる。
  • 以下サンプル(XHRのレスポンスをresolve関数に渡している)
resolve.js
const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', 'foo.txt');
    xhr.addEventListener('load', (e) => resolve(xhr.responseText));
    xhr.send();
});

promise.then((response) => console.log(response));

Promise〜値を渡す2〜

  • rejectresolve同様、値を渡すことができる
  • rejectが呼ばれたらcatchが実行されてエラー終了させる
reject.js
const promise = new Promise((resolve, reject) => reject('error'));

promise.then(() => console.log('done')) // thenは実行されない
       .catch((e) => console.log(e)); // 「error」とだけ表示される

thenを繋げるthenチェーン!!!

  • これがPromiseのいいところみたいでthenを繋げて書くことができる。
then.js
function printAsync(text, delay) {
    const p = new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(text);
            resolve();
        }, delay)
    });

    return p;
}

printAsync('hello', 500)
//500ミリ秒ごとに「hello」「world」「lorem」「ipsum」と表示する
    .then(() => printAsync('world', 500))
    .then(() => printAsync('lorem', 500))
    .then(() => printAsync('ipsum', 500));

Promiseを使わない時と使った時のソース比較サンプル(ファイルオープン)

  • 使わないとき
nothen.js
function openFile(url, onload) {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.addEventListener('load', (e) => onload(e, xhr));
    xhr.send();
}

openFile('foo.txt', (event, xhr) => {
   openFile('bar.txt', (event, xhr) => {
     openFile('baz.txt', (event, xhr) => {
         console.log('done!');
     });
   });
});
  • 使った時
then.js
function openFile(url) {
    const p = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url);
        xhr.addEventListener('load', (e) => resolve(xhr));
        xhr.send();
    });

    return p;
}

openFile('foo.txt')
    .then((xhr) => openFile('bar.txt'))
    .then((xhr) => openFile('baz.txt'))
    .then((xhr) => console.log('done!'));

複数のPromiseの終了を待つPromise.all

  • 上記と同じ処理を書き換えてみる
  • 配列で渡してthenは1個で書ける。
  • thenの結果は配列で返ってくるため注意
promise.all
function openFile(url) {
    const p = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url);
        xhr.addEventListener('load', (e) => resolve(xhr));
        xhr.send();
    });

    return p;
}

const promise = Promise.all([openFile('foo.txt'),
                             openFile('bar.txt'),
                             openFile('baz.txt')]);
promise.then((xhrArray) => console.log('done!'))

async関数とawait

  • やっと来ました。これについて知りたくて遡りしてたら結構知ってないといけないことが多かった。。。

  • awaitはPromiseを同期的に展開する(ように見せかける)機能。

  • Promiseオブジェクトを返す関数を呼び出す前につけると取得を待ってから次の処理へ行ってくれる

  • awaitは仕様条件があり、asyncがついた関数の中でしか利用できないと言う条件がある。

  • さっきのファイルオープンの処理をasync/awaitを使って書くと以下のようになる

async.js
function openFile(url) {
    const p = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url);
        xhr.addEventListener('load', (e) => resolve(xhr));
        xhr.send();
    });

    return p;
}

async function loadAllFiles() {
    //ファイルオープンを待って次の処理へ
    const xhr1 = await openFile('foo.txt');
    //ファイルオープンを待って次の処理へ
    const xhr2 = await openFile('bar.txt');
    //ファイルオープンを待って次の処理へ
    const xhr3 = await openFile('baz.txt');
    console.log('done!');
}

loadAllFiles();

async/awaitのエラー検出

  • try-catchが書けるのでその中に書いていく
async.js
function openFile(url) {
    const p = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url);
        xhr.addEventListener('load', (e) => resolve(xhr));
        xhr.send();
    });

    return p;
}

async function loadAllFiles() {
  try {
    //ファイルオープンを待って次の処理へ
    const xhr1 = await openFile('foo.txt');
    //ファイルオープンを待って次の処理へ
    const xhr2 = await openFile('bar.txt');
    //ファイルオープンを待って次の処理へ
    const xhr3 = await openFile('baz.txt');
    console.log('done!');
  } catch(error) {
    const {
      status,
      statusText
      } = error.response;
      console.log(`Error! HTTP Status: ${status} ${statusText}`);
    }
  }
}

loadAllFiles();


あとは書いて覚える!!!!!

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

で、Vuexってどうやって使うの? state, getters, mutations, actions...

Vuexを使ってログイン状態を管理しよう

この記事の目的

こんにちは。
現在、Vue + Go を使ってログイン機能を作っている初学者です。
Vue歴1ヶ月です。

初心者なりにVuexで使う state, getters , mutations, actions を理解したので、アウトプットしたいと思います。
Vuexの概念というよりは、どうやって使えば動くのかといったことを、初学者の方に見ていただければ幸いです。

間違ってる箇所もあると思いますがご容赦ください。

概念などを詳しく知りたい方は、公式サイトへ

1.そもそもVuexってなんのためにあるの

公式曰く
Vuex は Vue.js アプリケーションのための 状態管理パターン + ライブラリです。
らしい。

この状態管理パターンライブラリっていうのがよくわからないが
とか メソッド をなどを、他の.vue/.jsファイルからでも取り出すことのできる箱と自分は認識してます。
どのファイルからでも取り出せるからpropsみたいに依存関係を気にしなくていい。というのがメリットですね。

2.はじめに..

まず、npmパッケージでvuexをインストールしてください。

 npm install vuex

Vuexを扱うための箱を作ります。

store.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
import router from '../router.js'

Vue.use(Vuex)
export default new Vuex.Store({
})

main.jsにも作った箱(store.js)をインポートします。

main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router.js'
import store from './store/store.js'

new Vue({
  router,
  store,
  render: h => h(App),
}).$mount('#app')

ログイン画面を作ります。
ログインの仕組みはid, passwordを入力して、サーバー側にhttpRequestを飛ばすというものです。

サーバー側はidとpasswordを読み取ってDBの値と一致していればログイン成功とし、アクセストークンを返すという作りになっています。
今回はサーバー側の詳細は省きます。

また、ログインした後じゃないとメインページなどにアクセス出来ないような制御をかけます。
(main.jsに後々記載します。)

発火するメソッドに this.$store.dispatchという見慣れない記載がありますが、これこそがstore.js内のactionsを呼び出す記法です。

今回はactions内のloginというメソッドを起動しています。
actionsの中でどのような処理が行われているかは後々説明します。

Login.vue
<template>
  <div>
    <h1>ログイン</h1>
    <lable for="id">Id:</lable>
    <input id="id" placeholder="id" type="text" v-model="id" />
    <br />
    <label for="password">Password:</label>
    <input id="password" placeholder="password" type="text" v-model="password" />
    <button @click="login">ログイン</button>
    <hr />
    <router-link to="/">もどる</router-link>
  </div>
</template>

<script>
export default {
  data() {
    return {
      id: "",
      password: "",
    };
  },
    methods: {
    login() {
      this.$store.dispatch('login', {
        id : this.id,
        password: this.password,
      })
    },
  }
};

</script>

次にstore.jsに機能を実装していきます。
今回Vuexで使う機能は4つ state, getters, mutations, actions です。

3.state

stateは、状態管理を行う機能です。
要はintや配列などの管理します。

今回はログイン後に作成したアクセストークンと、ログインフラグを保存するためのstateを用意します。

初期値は空とfalseにしています。

store.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
import router from '../router.js'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        idToken: "",
        loggedIn: false
    }
})

4.getters

gettersはstateに格納してある値を、
.vue/.jsファイルなどから呼び出すときに使用します。

条件などを指定して、取り出し方を変更したりもできますが、
今回はそのまま取得します。

store.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
import router from '../router.js'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        idToken: "",
        loggedIn: false
    },
    getters: {
        idToken: state => state.idToken,
        loggedIn: state => state.loggedIn
    }
})

5.mutations

mutationsはメソッドを管理する機能です。
stateの情報を変更するにはこのmutationsを使用します。(ここ重要)

ポイントはmutationsは常に同期的に動作するという点です。(ここ重要)
これは次に話す、actionsとの大きな違いです。

ちなみにmutationの 動詞型 : mutate は変異する,変化するという意味です。
なので、Vuexではstateの情報を変化させるという意味で、覚えとくと忘れないと思います。

今回は以下の機能を、mutationsに実装します。

  1. 引数にidTokenをとる。
  2. idTokenの値を更新する。
  3. localStorageにtoken情報を追加。
  4. loggedInをtrueにする。
store.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
import router from '../router.js'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        idToken: "",
        loggedIn: false
    },
    getters: {
        idToken: state => state.idToken,
        loggedIn: state => state.loggedIn
    },
    mutations : {
        storeIdToken(state, idToken) {
            state.idToken = idToken;
            localStorage.setItem("jwt", JSON.stringify(idToken))
            state.loggedIn = true
        }
    }
})

6.actions

actionsの説明は公式を引用します。

  • アクションは、状態を変更するのではなく、ミューテーションをコミットします。
  • アクションは任意の非同期処理を含むことができます。

つまりactionsはmutationsを非同期化する機能しかないということです。

actionsは{commit}を引数にとり、
内部でcommit(mutations内のメソッド) を記述することで、mutationsのメソッドを呼び出す事ができます。
第2引数はactionsを呼び出すメソッドから何らかの値をもってくることが出来ます。

今回は、idとpasswordをLogin.vueから持ってきます。
actions内の処理は以下

  1. postによって、持ってきたidとpasswordを送ります。(axiosの説明は省略します)
  2. responseが返ってきたら、mutationsのstoreIdToken()を起動。
  3. VueRouterによって/mainに飛ばします。
store.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
import router from '../router.js'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        idToken: "",
        loggedIn: false
    },
    getters: {
        idToken: state => state.idToken,
        loggedIn: state => state.loggedIn
    },
    mutations : {
        storeIdToken(state, idToken) {
            state.idToken = idToken;
            localStorage.setItem("jwt", JSON.stringify(idToken))
            state.loggedIn = true
        }
    },
    actions: {
        login({commit}, auth){
            axios
            .post("/api/login", {
              id: auth.id,
              password: auth.password,
            })
            .then(response => {
                commit('storeIdToken', response.data.token)
                router.push('main')
            });
        }
    }
})

7. 完成

これで後はlocalhostを起動させて、Login.vueのボタンを押せば

  1. @clickでLogin.vueのlogin()メソッドが起動
  2. 内部のthis.$store.dispatchが起動(id, passwordの情報を持っている)
  3. Vuexの機構により、store.jsのactionsが起動
  4. サーバー側にid,passowordを送信
  5. Requestが成功し、Responseを受け取ったら、mutationsのstoreIdTokenが起動
  6. storeIdToken内の処理が起動する。

という流れになります。

Login.vue
<template>
  <div>
    <h1>ログイン</h1>
    <lable for="id">Id:</lable>
    <input id="id" placeholder="id" type="text" v-model="id" />
    <br />
    <label for="password">Password:</label>
    <input id="password" placeholder="password" type="text" v-model="password" />
    <button @click="login">ログイン</button>
    <hr />
    <router-link to="/">もどる</router-link>
  </div>
</template>

<script>
export default {
  data() {
    return {
      id: "",
      password: "",
    };
  },
    methods: {
    login() {
      this.$store.dispatch('login', {
        id : this.id,
        password: this.password,
      })
    },
  }
};

</script>

最後にmain.jsにRoutingの制御を実装します。

VueRouterの内容になるので、詳しくは省略しますが、
store.getters.loggedInでloggedInの状態を取ってきてtrueじゃないとログインページに戻されてしまうという感じです。
なので、storeIdtoken()にて、state : loggedInをtrueにしてたというわけです。

main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router.js'
import store from './store/store.js'

Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App),
}).$mount('#app')

router.beforeEach((to, from, next) => {
  if(to.matched.some(url => url.meta.isPublic) || store.getters.loggedIn) {
      next()
  } else {
      next('login')
  }
});

おまけ

VueRouterの内容がちょこちょこでてきてしまったので、
router.jsの情報も追記します。

router.js
import Vue from "vue";
import Router from "vue-router";

const Home = () => import("./views/Home.vue");
const Header = () => import("./components/Header.vue");
const Login = () => import("./views/Login.vue");
const Main = () => import("./views/Main.vue");

Vue.use(Router);

export default new Router({
  mode: "history",
  routes: [
    {
      path: "/",
      components: {
        default: Home,
        header: Header
      },
      meta: {
        isPublic: true
      }
    },
    {
      path: "/login",
      components: {
        default: Login,
        header: Header,
      },
      meta: {
        isPublic: true
      }
    },
    {
      path: "/main",
      components: {
        default: Main,
        header: Header
      }
    }
  ]
});

最後に

いかがだったでしょうか。
Vuexのmutationsとactionsの違いがいまいちわからない!って方や
Vueでログイン機能をどうやって実装するのかと考えている方の助けになれば幸いです。

また、今回初めての投稿だったんですが、書き上げるまでに結局2日間もかかっちゃいました...
普段から投稿してくださっている方々の労力はいかに..
いつもありがとうございます。

勉強させていただいたUdemy講義 めちゃくちゃわかりやすいです。
https://www.udemy.com/course/vue-js-complete-guide/


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

今更ながら vue.js を触ってみる。

. 概要

以下の環境で Vue.js を触ってみます。
- Visual Studio 2019 Community
- vue.js v2.6.10

. 手順

1.新しいプロジェクトを作成する。
[ASP.NET Core Web アプリケーション]を選択する。
無題.png
2.新しいプロジェクトを構成する。
任意のプロジェクト名を入力し、保存場所を指定する。
無題.png
2.新しい ASP.NET Core Web アプリケーションを作成する
[空]のプロジェクトを選択する。
無題.png
作成した直後のソリューションエクスプローラはこのとおり。
無題.png
コントローラとビューのフォルダを各々追加します。
無題.png
ルーティングが動作するよう「Startup.cs」に以下を実装する。

 public void ConfigureServices(IServiceCollection services)
 {
     services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
 }
 // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
 public void Configure(IApplicationBuilder app, IHostingEnvironment env)
 {
     if (env.IsDevelopment())
     {
         app.UseDeveloperExceptionPage();
     }
     app.UseHttpsRedirection();
     app.UseStaticFiles();
     app.UseCookiePolicy();

     app.UseMvc(routes =>
     {
         routes.MapRoute(
             name: "default",
             template: "{controller=Home}/{action=Index}/{id?}");
     });
 }

2.この状態で一度実行する。
玉戯が表示されます、(特に意味はありません、)
無題.png
コントローラとビュー共に問題ないと確認出来ました。
2.vue.js の配置する場所の作成
wwwroot を作成する。
無題.png
[wwwroot]を作成し、その配下 vue,js を追加します。
こんな構成になります。※Vue.js は追加済です。
無題.png
2.vue.js を プロジェクトへ追加する。
2 vue.js を 使用し画面へ表示数。
無題.png

表示できました。
```C#




<div id='app'>
    <ul>
        <li v-for='product in products'>
            <h2>{{ product }}</h2>
        </li>
    </ul>
</div>


<script type="text/javascript">
    const app = new Vue({
        el: '#app',
        data: {
            products: []
        },
        created: function () {
            this.products = ['たまねぎ', 'にんじんじん'];
        }
    });
</script>



コントローラの実装

 using Microsoft.AspNetCore.Mvc;

 namespace WebApplication1.Controllers
 {
     public class HomeController 
         : Controller
     {
         public IActionResult Index()
         {
             return View();
         }
     }
 }

無事表示する事が出来ました!!
無題.png

以上です!!

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

Vue.jsでスクロール時のイベントを定義する

やりたいこと

わかりづらいので、具体的にやりたかったことを書きます。

自分が作っていたアプリでは、「ページを下のほうにスクロールしたらトップへ戻るボタンを出したい」といった、「スクロールしたときに何かする」的な処理が結構ありました。

そのたびに、↓こんな感じにcreateddestroyedでイベントを登録・解除するのがめんどいな、と。

<script>
export default {
  created () {
    window.addEventListener('scroll', this.onScroll)
  },
  destroyed () {
    window.removeEventListener('scroll', this.onScroll)
  },
  methods: {
    onScroll () {
      // Do something
    }
  }
}
</script>

そこで、↓こんな感じに書くだけで、スクロールしたときのフックを定義できるようにしました。

<script>
export default {
  onScroll () {
    // Do something
  }
}
</script>

実装

公式ドキュメントをぱっと見した限り、ディレクティブをカスタマイズする手段はあるようですが、上記のようなフックのカスタマイズは見つかりませんでした。

なので、GlobalなMixinを作って実現しました。

main.js
import Vue from 'vue'
// ..
import GlobalMixin from './GlobalMixin'

Vue.mixin(GlobalMixin)

// ..

↓onScrollが定義されていた場合、windowのscrollイベントに登録し、destroyedで削除します。

GlobalMixin.vue
<script>
export default {
  mounted () {
    // Register onScroll event
    if (this.$options.onScroll) {
      window.addEventListener('scroll', this.$options.onScroll)
    }
  },
  destroyed () {
    // Unregister onScroll event
    if (this.$options.onScroll) {
      window.removeEventListener('scroll', this.$options.onScroll)
    }
  }
}
</script>

これだけです。

(余計なお世話かもしれませんが、↑このmixinはむやみに拡張しないでください)

そして、冒頭のように各コンポーネントでonScrollという名前でイベントを定義するだけです。

<script>
export default {
  data () {
    return {
      showButton: false
    }
  },
  onScroll () {
    this.showButton = window.scrollY > 100
  }
}
</script>

余談

公式のカスタムディレクティブの例でまさにスクロール時のイベントを定義するサンプルがあったんですが、ちょっとしっくりこないです…。

<div id="app">
  <h1 class="centered">Scroll me</h1>
  <div
    v-scroll="handleScroll"
    class="box"
  >
    <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. A atque amet harum aut ab veritatis earum porro praesentium ut corporis. Quasi provident dolorem officia iure fugiat, eius mollitia sequi quisquam.</p>
  </div>
</div>

@scroll的な感じで、.boxがスクローラブルな要素で、そのonscrollに定義される感じがするけど、結局window全体のonscrollに定義してるし、そもそも外からイベントを定義したいケースも思いつかない。

handleScrollの引数に渡したelementを直接弄ってるのも手続き的で気持ちよくないです…。

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

Vue.jsでスクロール時のイベントを便利に定義する

やりたいこと

わかりづらいので、具体的にやりたかったことを書きます。

自分が作っていたアプリでは、「ページを下のほうにスクロールしたらトップへ戻るボタンを出したい」といった、「スクロールしたときに何かする」的な処理が結構ありました。

そのたびに、↓こんな感じにcreateddestroyedでイベントを登録・解除するのがめんどいな、と。

<script>
export default {
  created () {
    window.addEventListener('scroll', this.onScroll)
  },
  destroyed () {
    window.removeEventListener('scroll', this.onScroll)
  },
  methods: {
    onScroll () {
      // Do something
    }
  }
}
</script>

そこで、↓こんな感じに書くだけで、スクロールしたときのフックを定義できるようにしました。

<script>
export default {
  onScroll () {
    // Do something
  }
}
</script>

実装

公式ドキュメントをぱっと見した限り、ディレクティブをカスタマイズする手段はあるようですが、上記のようなフックのカスタマイズは見つかりませんでした。

なので、GlobalなMixinを作って実現しました。

main.js
import Vue from 'vue'
// ..
import GlobalMixin from './GlobalMixin'

Vue.mixin(GlobalMixin)

// ..

↓onScrollが定義されていた場合、windowのscrollイベントに登録し、destroyedで削除します。

GlobalMixin.vue
<script>
export default {
  mounted () {
    // Register onScroll event
    if (this.$options.onScroll) {
      window.addEventListener('scroll', this.$options.onScroll)
    }
  },
  destroyed () {
    // Unregister onScroll event
    if (this.$options.onScroll) {
      window.removeEventListener('scroll', this.$options.onScroll)
    }
  }
}
</script>

これだけです。

(余計なお世話かもしれませんが、↑このmixinはむやみに拡張しないでください)

そして、冒頭のように各コンポーネントでonScrollという名前でイベントを定義するだけです。

<script>
export default {
  data () {
    return {
      showButton: false
    }
  },
  onScroll () {
    this.showButton = window.scrollY > 100
  }
}
</script>

余談

公式のカスタムディレクティブの例でまさにスクロール時のイベントを定義するサンプルがあったんですが、ちょっとしっくりこないです…。

<div id="app">
  <h1 class="centered">Scroll me</h1>
  <div
    v-scroll="handleScroll"
    class="box"
  >
    <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. A atque amet harum aut ab veritatis earum porro praesentium ut corporis. Quasi provident dolorem officia iure fugiat, eius mollitia sequi quisquam.</p>
  </div>
</div>

@scroll的な感じで、.boxがスクローラブルな要素で、そのonscrollに定義される感じがするけど、結局window全体のonscrollに定義してるし、そもそも外からイベントを定義したいケースも思いつかない。

handleScrollの引数に渡したelementを直接弄ってるのも手続き的で気持ちよくないです…。

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