20200314のvue.jsに関する記事は11件です。

Rails + Vue.js + AjaxでCRUDのサンプルプロジェクト [Hello World]

Vue.js初心者が公式サイトで基礎を学んだ後に作るVue.js + AjaxによるCRUD(作成/読み込み/更新/削除)のサンプルプロジェクトです。

vue_crud_1.png

動作確認はChrome、FireFox、Microsoft Edge、IE11です。恐らくマックさんのブラウザでも動作するはずです。

DEMO

https://www.petitmonte.com/rails-demo/vue_crud

ソース一式

https://github.com/TakeshiOkamoto/mpp_vue_crud

※学習用の為、ライセンスはパブリックドメイン

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

API Gatewayでリクエストして、Lambdaで処理させて、AppSyncで受け取る

API Gatewayでリクエストして、Lambdaで処理させて、AppSyncで受け取る

この記事はサーバーレスWebアプリ Mosaicを開発して得た知見を振り返り定着させるためのハンズオン記事の1つです。

以下を見てからこの記事をみるといい感じです。

はじめに

検出した顔を並べて表示しました。次はいよいよ顔にモザイクをかけてゆきます。
処理をキックするためにAPI GatewayとLambdaを利用します。また、実行結果はAppSyncのSubscriptionで受け取ります。

顔の位置情報を渡す

前回の記事(Lambda(Python) + Rekognition で顔検出)では顔を検出して切抜き画像を作って表示するだけでした。この時に顔の位置情報(座標)が分かってます。モザイク処理する時にこの座標が必要になりますのでAppSync経由で渡してあげましょう。
(キックした後にまた検出し直すこともできますが、それだと無駄が多いですよね。)

以前書いた顔検出するサンプルコードにpointsという位置情報を入れておくための変数を追加します。

lambda_function.py
def uploadImage(image, localFilePath, bucket, s3Key, group, points):
    logger.info("start uploadImage({0}, {1}, {2}, {3})".format(localFilePath, bucket, s3Key, group))
    try:
        cv2.imwrite(localFilePath, image)
        s3.upload_file(Filename=localFilePath, Bucket=bucket, Key=s3Key)
        apiCreateTable(group, s3Key, points)
    except Exception as e:
        logger.exception(e)
        raise e
    finally:
        if os.path.exists(localFilePath):
            os.remove(localFilePath)

def apiCreateTable(group, path, points):
    logger.info("start apiCreateTable({0}, {1}, {2})".format(group, path, points))
    try:
        query = gql("""
            mutation create {{
                createSampleAppsyncTable(input:{{
                group: \"{0}\"
                path: \"{1}\"
                points: \"{2}\"
              }}){{
                group path points
              }}
            }}
            """.format(group, path, points))
        _client.execute(query)
    except Exception as e:
        logger.exception(e)
        raise e

def detectFaces(bucket, key, fileName, image, group, dirPathOut):
    logger.info("start detectFaces ({0}, {1}, {2}, {3}, {4})".format(bucket, key, fileName, group, dirPathOut))
    try:
        response = rekognition.detect_faces(
            Image={
                "S3Object": {
                    "Bucket": bucket,
                    "Name": key,
                }
            },
            Attributes=[
                "DEFAULT",
            ]
        )

        name, ext = os.path.splitext(fileName)
        imgHeight = image.shape[0]
        imgWidth = image.shape[1]
        index = 0
        for faceDetail in response["FaceDetails"]:
            index += 1
            faceFileName = "face_{0:03d}".format(index) + ext
            box = faceDetail["BoundingBox"]
            x = max(int(imgWidth * box["Left"]), 0)
            y = max(int(imgHeight * box["Top"]), 0)
            w = int(imgWidth * box["Width"])
            h = int(imgHeight * box["Height"])
            points = "{0},{1}|{2},{3}|{4},{5}|{6},{7}".format(x, y, x+w, y, x+w, y+h, x, y+h)
            logger.info("BoundingBox({0},{1},{2},{3})".format(x, y, w, h))

            faceImage = image[y:min(y+h, imgHeight-1), x:min(x+w, imgWidth)]

            localFaceFilePath = os.path.join("/tmp/", faceFileName)
            uploadImage(faceImage, localFaceFilePath, bucket, os.path.join(dirPathOut, faceFileName), group, points)
            cv2.rectangle(image, (x, y), (x+w, y+h), (0, 0, 255), 3)

        processedFileName = "faces-" + fileName
        processedFilePath = "/tmp/" + processedFileName
        uploadImage(image, processedFilePath, bucket, os.path.join(dirPathOut, processedFileName), group, points)
    except Exception as e:
        logger.exception(e)
        raise e

AppSyncのスキーマも更新しましょう。
AWSコンソール > AppSync > 目的のAPI > スキーマ
input(引数)とtype(戻り値)のデータに points: String を加えておきます。

input CreateSampleAppsyncTableInput {
    group: String!
    path: String!
    points: String
}
type SampleAppsyncTable {
    group: String!
    path: String!
    points: String
}

フロントのWebアプリ側も更新します。
getListやsubscriptionでpointsを受け取るため、graphqlファイルを更新します。

src/graphql/queries.js
export const listSampleAppsyncTables = `query listSampleAppsyncTables($group: String) {
  listSampleAppsyncTables(
    limit: 1000000
    filter: {
      group: {eq:$group}
    }
  )
  {
    items 
    {
      group
      path
      points
    }
  }
}
`;
src/graphql/subscriptions.js
export const onCreateSampleAppsyncTable = `subscription OnCreateSampleAppsyncTable($group: String) {
    onCreateSampleAppsyncTable(group : $group) {
        group
        path
        points
    }
}
`;

さぁこれで、検出した顔の座標をクライアント側に渡すことができるようになりました。
受け取ったpointsをメンバ変数にセットしたり、ログに出したりして確認してみましょう。

src/components/List.vue
 :
<script>
 :
    async getList() {
      this.group = this.$route.query.group;
      console.log("group : " + this.group);
      if(!this.group){
          return;
      }

      let apiResult = await API.graphql(graphqlOperation(listSampleAppsyncTables, { group : this.group }));
      let listAll = apiResult.data.listSampleAppsyncTables.items;
      for(let data of listAll) {
        let tmp = { path : data.path, image : "", points : data.points };
        let list = [...this.dataList, tmp];
        this.dataList = list;
        console.log("path : " + data.path);
        console.log("points : " + data.points);
        Storage.get(data.path.replace('public/', ''), 
          { level: 'public', expires: dataExpireSeconds }).then(result => {
        tmp.image = result;
          console.log("image : " + result);
        }).catch(err => console.log(err));
      }

      API.graphql(
          graphqlOperation(onCreateSampleAppsyncTable, { group : this.group } )
      ).subscribe({
          next: (eventData) => {
            let data = eventData.value.data.onCreateSampleAppsyncTable;
            let tmp = { path : data.path, image : "", points : data.points };
            let list = [...this.dataList, tmp];
            this.dataList = list;
            console.log("path : " + data.path);
            console.log("points : " + data.points);
            Storage.get(data.path.replace('public/', ''), 
              { level: 'public', expires: dataExpireSeconds }).then(result => {
              tmp.image = result;
              console.log("image : " + result);
            }).catch(err => console.log(err));
          }
      });
    },
 :

ink (8).png
はい、ちゃんと顔の位置情報が受け取れてますね。

顔を選択するための実装は今回は割愛し、検出された全ての顔にモザイクをかけるようにしたいと思います。
全ての顔にモザイクをかける処理を実行するためのボタンを設置し、押下したらAPI Gatewayに対して処理をリクエストします。モザイク処理された画像は他の画像と同様にS3にアップロードしたらAppSync経由でパスを通知し、クライアント側でそれを受け取るような流れです。

API GatewayとLambdaのセットアップ

以下のような流れでAPI GatewayとLambdaをセットアップしてゆきます。

Lambdaの作成

先にLambdaの関数を作成しておきます。
AWSコンソール > Lambda > 関数 > 関数の作成
一から作成, 任意の関数名, ランタイムにはPython3.6, 実行ロールは「基本的な Lambda アクセス権限で新しいロールを作成」を選択して作成します。
関数名は「sample_lambda_apply」としておきました。

関数コードはひとまずインラインで以下のようにしておきます。

lambda_function.py
import json
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    logger.info("Hello from lambda! - sample_lambda_apply")
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda! - sample_lambda_apply')
    }

LambdaのIAMロール編集

LambdaがS3操作やAppSyncアクセスできるようにします。
AWSコンソール > IAM > ロール
Lambdaと一緒に作成された sample_lambda_apply-role-xxxxxxxx というロールを表示します。
アクセス権限タブの「+インラインポリシーの追加」から追加してください。
Screenshot 2020-03-14 at 08.39.26.png
ポリシーのJSONは以下のような感じになります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "appsync:GraphQL"
            ],
            "Resource": [
                "arn:aws:s3:::sample-vue-project-bucket-work/*",
                "arn:aws:appsync:ap-northeast-1:888888888888:apis/xxxxxxxxxxxxxxxxxxxxxxxxxx/*"
            ],
            "Effect": "Allow"
        }
    ]
}

API Gatewayの作成

続いて、API Gatewayを作成します。
AWSコンソール > API Gateway
APIを作成ボタンを押下し、APIタイプを選択画面でREST APIを選択します。
REST, 新しいAPI, 任意の名前を設定し, エンドポイントタイプはリージョンとし、APIの作成ボタンを押下します。
Screenshot 2020-03-09 at 21.39.49.png

processという名前の子リソースを作成し、そこにPOSTメソッドを作成してゆこうと思います。リソースを作成する際、CORSを有効にするチェックはONにしておきましょう。
Screenshot 2020-03-09 at 21.48.40.png

POSTのセットアップでは、統合タイプはLambda関数とし、Lambda関数は先程作成した「sample_lambda_apply」を選択し、保存ボタンを押下します。
Screenshot 2020-03-09 at 22.06.40.png

POSTに対してもCORSの有効化を行う必要があります。
Screenshot 2020-03-09 at 23.26.34.png

CORSの有効化をしないと、クライアント側からリクエストした際に以下のような例外が発生すると思います。

Access to XMLHttpRequest at 'https://j2byqj306a.execute-api.ap-northeast-1.amazonaws.com/work/process' from origin 'https://fed9513d88324171b593944f5acca30f.vfs.cloud9.ap-northeast-1.amazonaws.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Error: Network Error
    at createError (createError.js?2d83:16)
    at XMLHttpRequest.handleError (xhr.js?b50d:81)

CORSの有効化をしたら、APIのデプロイも再実施しましょう。
念の為テストを実行してstatusCode=200が返ってくることも確認しておきましょう。
Screenshot 2020-03-09 at 22.16.24.png

確認できたら、APIのデプロイから任意のステージ名を設定し、デプロイを行っておいてください。
Screenshot 2020-03-09 at 22.58.58.png
Screenshot 2020-03-09 at 23.00.11.png
Screenshot 2020-03-09 at 23.01.00.png

VueのWebアプリからAPI Gatewayに対してリクエストする

WebアプリからAPI Gatewayを呼べるようにします。

src/components/List.vue
 : 
    <v-list>
      <v-list-item v-for="data in this.dataList" :key="data.path">
        <v-list-item-content>
          <a :href="data.image" target=”_blank”>
            <v-list-item-title v-text="data.path"></v-list-item-title>
          </a>
        </v-list-item-content>
        <v-list-item-avatar>
          <v-img :src="data.image"></v-img>
        </v-list-item-avatar>
      </v-list-item>
    </v-list>

    <v-btn v-if="dataList.length > 0" @click="processMosaic">
      PROCESS MOSAIC
    </v-btn>
 : 
<script>
import { API, graphqlOperation, Storage } from 'aws-amplify';
import { listSampleAppsyncTables } from "../graphql/queries";
import { onCreateSampleAppsyncTable } from "../graphql/subscriptions";

import axios from 'axios';
const apiUrl = "https://j2byqj306a.execute-api.ap-northeast-1.amazonaws.com/work/process";
const config = {headers: {
    'Content-Type': 'application/json'
}}
 : ![Screenshot 2020-03-09 at 23.33.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/394775/db920be5-a8e0-8f5c-55bf-b5e0590994f5.png)

    processMosaic() {
      let pointsList = [];
      let orgKey = "";
      for(let index = 0; index < this.dataList.length; index++) {
        let data = this.dataList[index];
        if(data.points != "-"){
          pointsList.push(data.points);
        }else if(data.path.startsWith("processed") == false){
          orgKey = data.path;
        }
      }
      this.myGuid = this.getGUIDString(new Date());
      let requestData = { guid: this.myGuid, orgKey: orgKey, pointsList: pointsList }; 
      console.log(requestData);

      axios
      .post(apiUrl, requestData, config)
      .then(response => {
          let result = response.data
          console.log(result)
      })
      .catch(error => console.log(error))
    }, 

    getGUIDString(date){
      let random = date.getTime() + Math.floor(100000 * Math.random());
      random = Math.random() * random;
      random = Math.floor(random).toString(16);
      return random;
    }, 
 : 

PROCESS MOSAICボタンを押したらstatusCode=200が返ってくることを確認しておきましょう。

Lambdaの実装

Lambdaファンクションの実装をしてゆきます。
以前の記事「Lambda + OpenCVで画像処理 (グレー画像作成)」と同じ要領で必要なライブラリをインストールし、lambdafunction.pyを実装し、zip圧縮して、Lambdaにデプロイします。

必要なライブラリのインストール

$ pip install opencv-python -t .
$ pip install gql -t .

実装

lambda_function.py
# coding: UTF-8
import json
import boto3
import os
import datetime

import numpy as np
import cv2

import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

s3 = boto3.client("s3")
BUCKET_INPUT = "sample-vue-project-bucket-work"
BUCKET_OUTPUT = "sample-vue-project-bucket-work"

from gql import gql, Client
from gql.transport.requests import RequestsHTTPTransport
ENDPOINT = "https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql"
API_KEY = "da2-XXXXXXXXXXXXXXXXXXXXXXXXXX"
_headers = {
    "Content-Type": "application/graphql",
    "x-api-key": API_KEY,
}
_transport = RequestsHTTPTransport(
    headers = _headers,
    url = ENDPOINT,
    use_json = True,
)
_client = Client(
    transport = _transport,
    fetch_schema_from_transport = True,
)

def lambda_handler(event, context):
    try:
        logger.info(event)

        guid = event["guid"]
        orgKey = event["orgKey"]
        pointsList = event["pointsList"]

        pathList = orgKey.split("/")
        name, ext = os.path.splitext(os.path.basename(orgKey))
        fileName = "mosaic.w2or3w.com." + guid + ext
        dirPath = os.path.dirname(orgKey)
        dirName = os.path.basename(dirPath)
        rootDirName = pathList[0]

        localTmpPath = u'/tmp/' + os.path.basename(orgKey)

        s3.download_file(Bucket = BUCKET_INPUT, Key = orgKey, Filename = localTmpPath)
        before = cv2.imread(localTmpPath)
        after = cv2.imread(localTmpPath)
        after = mosaicFromPointsList(pointsList, before, after)

        uploadAppliedImage(after, BUCKET_OUTPUT, os.path.join(rootDirName, "processed", dirName), fileName)

        return {
            'statusCode': 200,
            'body': json.dumps("completed")
        }

    except Exception as e:
        logger.exception(e)
        return {
            "statusCode": 500,
            "body": json.dumps("failed")
        }

    finally:
        if os.path.exists(localTmpPath):
            os.remove(localTmpPath)

def mosaicFromPointsList(pointsList, before, after):
    try:
        height = before.shape[0]
        width = before.shape[1]

        mosaicImg = mosaic(before, 0.08)

        mask = np.tile(np.uint8(0), (height, width, 1))
        for points in pointsList:
            pointList = points.split('|')
            lt = Point(pointList[0])
            rt = Point(pointList[1])
            rb = Point(pointList[2])
            lb = Point(pointList[3])
            contours = np.array(
                [
                    [lt.x, lt.y],
                    [rt.x, rt.y],
                    [rb.x, rb.y],
                    [lb.x, lb.y],
                ]
            )
            cv2.fillConvexPoly(mask, contours, color=(255, 255, 255))

        after = np.where(mask != 0, mosaicImg, after)

        return after

    except Exception as e:
        logger.exception(e)
        raise e

def mosaic(src, ratio=0.1):
    try:
        small = cv2.resize(src, None, fx=ratio, fy=ratio, interpolation=cv2.INTER_NEAREST)
        return cv2.resize(small, src.shape[:2][::-1], interpolation=cv2.INTER_NEAREST)
    except Exception as e:
        logger.exception(e)
        raise e

def uploadAppliedImage(img, bucket, dirPath, name):
    tmp = "/tmp/" + name
    guid = os.path.basename(dirPath)
    s3key = dirPath + "/" + name

    try:
        cv2.imwrite(tmp, img)
        s3.upload_file(Filename=tmp, Bucket=bucket, Key=s3key)
        apiCreateMosaicTable(guid, s3key)

    except Exception as e:
        logger.exception(e)
        raise e
    finally:
        if os.path.exists(tmp):
            os.remove(tmp)

def apiCreateMosaicTable(guid, s3key):
    logger.info("apiCreateMosaicTable : guid={0}, s3key={1}".format(guid, s3key))

    time = datetime.datetime.now()
    time = time + datetime.timedelta(minutes=30)
    epocTime = int(time.timestamp())

    try:
        query = gql("""
            mutation create {{
                createSampleAppsyncTable(input:{{
                group: \"{0}\"
                path: \"{1}\"
                points: \"-\"
                deleteTime: {2}
              }}){{
                group path points
              }}
            }}
            """.format(guid, s3key, epocTime))
        _client.execute(query)
    except Exception as e:
        logger.exception(e)
        raise e

class Point:
    def __init__(self, text):
        tmp = text.strip("("")")
        tmpList = tmp.split(',')
        self.x = int(tmpList[0])
        self.y = int(tmpList[1])

モザイク処理の詳細についてはこちらの記事(画像に様々な形のモザイクをかける(Python, OpenCV))も見てみてください。

Lambdaにデプロイしたら、Webアプリに追加したPROCESS MOSAICボタンを押下しましょう。顔にモザイク処理された画像が追加されましたね。
ink (10).png

API GatewayにCognito認証を設定する

現状、API Gatewayには認証制限を設けてません。呼び放題です。
フロント側はCognito認証を利用してますので、このCognito認証をAPI Gatewayにも適用させましょう。

AWSコンソール > API Gateway > 作成したAPI > オーソライザー
「+オーソライザーの作成」ボタンを押下。Cognitoユーザープールのオーソライザーを作成します。
Screenshot 2020-03-14 at 21.29.21.png

POSTメソッドリクエストを編集します。
認可に対して作成したCognitoユーザープールオーソライザーを指定、HTTPリクエストヘッダに「Authorization」を必須にして追加します。
Screenshot 2020-03-14 at 21.39.17.png

そして、APIをデプロイしましょう。
WebアプリのPROCESS MOSAICボタンを押すと、以下のような例外が帰ってくるようになりました。

Access to XMLHttpRequest at 'https://j2byqj306a.execute-api.ap-northeast-1.amazonaws.com/work/process' from origin 'https://fed9513d88324171b593944f5acca30f.vfs.cloud9.ap-northeast-1.amazonaws.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
List.vue?9185:114 Error: Network Error
    at createError (createError.js?2d83:16)
    at XMLHttpRequest.handleError (xhr.js?b50d:81)

CORSの有効化をしない時の例外と同じ感じでイマイチ判断つかないですが、とにかく失敗し、Cognito認証が効いてるらしいことが確認できました。

それでは、Webアプリの実装を更新してゆきましょう。
API GatewayにリクエストするHeaderに、Cognito認証でLocal Storageに保存されているidTokenの値を設定してあげます。
Screenshot 2020-03-14 at 23.06.36.png

<script>
 :
      const currSession = await Auth.currentSession();
      config.headers["Authorization"] = currSession.getIdToken().getJwtToken();

      axios
      .post(apiUrl, requestData, config)
      .then(response => {
          let result = response.data
          console.log(result)
      })
      .catch(error => console.log(error))
 :

WebアプリのPROCESS MOSAICボタンを押すと、成功するようになりましたね。

あとがき

久しぶりにこのシリーズの記事を追加しました。
薄々分かっていましたが、やっぱりこの記事は内容が盛りだくさんになってしまいました。

最近AppSync関係の記事にお熱で、本当はAppSyncのデータソースを複数登録するヤツをやりたいと思っているのです。モザイク処理をキックする手段として、この記事でも書いた通りAPI Gatewayを利用しているのですが、AppSyncだけでイケるんじゃね?って思いまして。

それを実際にやってみて記事を書くにあたり、API Gatewayでやってるこの記事が必要だなと思い、超重たい腰を上げて書いた次第です。

後回しにした上にこんな不純な動機で、ごめんね、API Gateway。

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

Vue.js、Capacitorでアプリ開発

ウェブ開発ノウハウを利用してスマートフォンアプリの開発を行うハイブリッドアプリについて、ソリューションの一つとしてCapacitor+Vue.jsを考えてみました。
メリットは、最初のプロジェクトの作成が非常に簡単にできることですので、プロジェクトの作成手順を紹介します。

Vue.jsプロジェクトの作成

https://qiita.com/jjjjjj/items/a43922631f1350bb9c65
を参照

Capacitorのインストール

以下を参考にしました。
https://capacitor.ionicframework.jp/docs/getting-started

$ npm install --save @capacitor/core @capacitor/cli

Capacitor初期設定

$ npx cap init

index.htmlファイルの場所を変更

capacitor.config.jsonのwebDirをwwwからdistに変更する。

プラットフォームのインストール

$ npx cap add ios
$ npx cap add android

Vueプロジェクトをネイティブにコピーする

$ npx cap copy ios
$ npx cap copy android

IDEを開いてビルド、実行する

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

CakePHP4 で Vue.js ( Laravel-Mix ) を使えるようにする

CakePHP4 を docker-compose で動くようにする

の続きです。

CakePHP4 のプロジェクトから Vue.js を使えるようにします。
Vue.js は Laravel-Mix を使って導入します。

この記事でわかること

事前準備

僕の Mac の各種バージョンは以下です。

バージョン
docker Docker version 19.03.5, build 633a0ea
docker-compose docker-compose version 1.25.4, build 8d51620a
CakePHP4 4.0.4
Vue.js 2.6.11
laravel-mix 5.0.1

手順の流れの説明

Laravel の仮プロジェクト作り、そこから必要なファイルをコピーします。
その後、CakePHP4 のディレクトリ構成に合わせた変更や docker-compose の変更を行います。

Laravel 仮プロジェクトの作成

CakePHP プロジェクトと同列(同じディレクトリ)に Laravel の仮プロジェクトを作成します。
ここでは CakePHPプロジェクト「cakephp-vue-study」のディレクトリにいるところ始めるので、まずは1つ上のディレクトリへ移動します。

ローカル環境に依存したくないで一時的なコンテナを立ち上げて入ります。
一時的なコンテナが独自なのは、最低限必要なライブラリを入れているためです。
( https://github.com/katsuhiko/docker-php-fpm-base/blob/master/Dockerfile )

cd ..
docker run --rm -it -v "$(pwd):/home/app" -w /home/app katsuhikonagashima/php-fpm-base:7.4-buster /bin/bash

ここからはコンテナ内での作業になります。
composer を取得して、Laravelプロジェクトを作ります。
その後、ui として Vue.js を導入します。

curl -sS https://getcomposer.org/installer | php

php composer.phar create-project --prefer-dist laravel/laravel laravel-template

cd laravel-template
php ../composer.phar require laravel/ui
php artisan ui vue
exit

コンテナ内での作業はここまでです。

必要なファイルをコピー

CakePHP4プロジェクトへ必要なファイルをコピーします。
Vue.js の各種ファイルは ./assets/ 配下に格納して開発していきます。
Laravel に合わせて ./resources/ 配下でも良いと思いますが、自分は locale 系のファイルと一緒にあることに違和感を感じるので ./assets/ 配下にしています。

cd ./cakephp-vue-study/

cp ../laravel-template/package.json ./
cp ../laravel-template/webpack.mix.js ./

mkdir -p ./assets
cp -r ../laravel-template/resources/js ./assets/js
cp -r ../laravel-template/resources/sass ./assets/sass

ライブラリのインストール

npm install も一時的なコンテナを立ち上げて実施します。
一応、現時点で LTS となっている node 12系を使っています。

docker run --rm -it -v "$(pwd):/home/app" -w /home/app node:12 npm install

必要になると思うので、このタイミングで vue-router を入れても良いと思います。

docker run --rm -it -v "$(pwd):/home/app" -w /home/app node:12 npm install --save-dev vue-router

webpack.mix.js の変更

./webpack.mix.js をCakePHP4 のディレクトリ構成にあった設定へ変更します。
./assets/./webroot/assets/ 配下へビルドした結果が格納されるようにします。

webpack.mix.js
mix.setPublicPath('webroot')
    .js('assets/js/app.js', 'assets/js')
    .sass('assets/sass/app.scss', 'assets/css')
    .sourceMaps().webpackConfig({devtool: 'source-map'});

javascript, css ファイルにタイムスタンプをつける

javascript, css ファイルのキャッシュが表示されてしまう問題に対応するために CakePHP の Asset.timestamp 機能を使います。
./config/app.php を変更します。

本番でも有効にしたいので、Asset.timestamp を force を指定します。

config/app.php
    'Asset' => [
        'timestamp' => 'force',
        // 'cacheTime' => '+1 year'
    ],

docker-compose.yml への記述追加

./docker-compose.yml で Vue.js に関するファイルが変更された場合、ビルドを実施するコンテナ watch を追加します。

docker-compose.yml
services:
  # ... 以下が追加の記述です。
  watch:
    image: node:12
    container_name: watch
    working_dir: /home/app
    command: "npm run watch"
    volumes:
      - ./:/home/app
    networks:
      - frontend

コンテナを立ち上げ直します。

docker-compose down
docker-compose up -d

./webroot/assets/js , ./webroot/assets/css 配下に js ファイルや map ファイルができていたら適切にビルドできています。

※ Windowsの場合、変更ファイルの検知ができない可能性があるため command: "npm run watch"command: "npm run watch-poll" にする必要があると思います。

.gitignore への記述追加

ライブラリとビルドで作られるファイルを ./.gitignore へ追加しソース管理の対象から外します。

.gitignore
# Laravel Mix specific files #
##############################
/node_modules
/webroot/assets
/webroot/mix-manifest.json

動作確認のための変更

簡易的な対応を実施して、Vue.js が動作していることを確認します。

routes.php の変更

./config/routes.php で、どんなアクセスがあっても同じ PagesController#index() が呼び出されるようにします。

config/routes.php
$routes->scope('/', function (RouteBuilder $builder) {
    $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([
        'httpOnly' => true,
    ]));
    $builder->applyMiddleware('csrf');

    //$builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);
    $builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']);

    // Vue.js のルートページ表示用です。
    $builder->connect('/*', ['controller' => 'Pages', 'action' => 'index']);

    $builder->fallbacks();
});

PagesController の変更

./src/Controller/PagesController.php へ index メソッドを追加します。

src/Controller/PagesController.php
class PagesController extends AppController
{
    // この index メソッドを追加します。
    public function index(): ?Response
    {
        return $this->render();
    }
    // ...
}

Pages/index.php の追加

./templates/Pages/index.php ファイルを追加します。
CakePHP4 から ctp ファイルではなく、php ファイルになったようです。
確認のため、example-component タグを記述しています。

templates/Pages/index.php
<div id="app">
    <example-component></example-component>
</div>
<?= $this->Html->script('/assets/js/app.js')?>

動作確認

http://localhost し以下の文言が表示されていることを確認してください。

Example Component
I'm an example component.

補足) テストの追加と実行方法

./tests/TestCase/Controller/PagesControllerTest.php へ PagesController で追加したメソッドのテストを追加します。

tests/TestCase/Controller/PagesControllerTest.php
class PagesControllerTest extends TestCase
{
    // ... 以下の2つのテストを追加します。
    /**
     * @return void
     */
    public function testVueルートページを表示()
    {
        $this->get('/');
        $this->assertResponseOk();
        $this->assertResponseContains('<div id="app">');
    }

    /**
     * @return void
     */
    public function testどんなアクセスでもVueルートページを表示()
    {
        $this->get('/hoge/bar');
        $this->assertResponseOk();
        $this->assertResponseContains('<div id="app">');
    }
    // ...
}

コンテナを使って、テストを実行します。

docker exec -it app php composer.phar test

コミット前に composer check を行うと test と codesniffer による check を行えます。

docker exec -it app php composer.phar check

この composer で実行するコマンドは、 composer.json の scripts に記載があります。

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

CakePHP4 で Vue.js ( Laravel-Mix ) を使えるようにして docker-compose で動くようにする

CakePHP4 を docker-compose で動くようにする

の続きです。

CakePHP4 のプロジェクトから Vue.js を使えるようにします。
Vue.js は Laravel-Mix を使って導入します。

この記事でわかること

事前準備

僕の Mac の各種バージョンは以下です。

バージョン
docker Docker version 19.03.5, build 633a0ea
docker-compose docker-compose version 1.25.4, build 8d51620a
CakePHP4 4.0.4
Vue.js 2.6.11
laravel-mix 5.0.1

手順の流れの説明

Laravel の仮プロジェクト作り、そこから必要なファイルをコピーします。
その後、CakePHP4 のディレクトリ構成に合わせた変更や docker-compose の変更を行います。

Laravel 仮プロジェクトの作成

CakePHP プロジェクトと同列(同じディレクトリ)に Laravel の仮プロジェクトを作成します。
ここでは CakePHPプロジェクト「cakephp-vue-study」のディレクトリにいるところ始めるので、まずは1つ上のディレクトリへ移動します。

ローカル環境に依存したくないで一時的なコンテナを立ち上げて入ります。
一時的なコンテナが独自なのは、最低限必要なライブラリを入れているためです。
( https://github.com/katsuhiko/docker-php-fpm-base/blob/master/Dockerfile )

cd ..
docker run --rm -it -v "$(pwd):/home/app" -w /home/app katsuhikonagashima/php-fpm-base:7.4-buster /bin/bash

ここからはコンテナ内での作業になります。
composer を取得して、Laravelプロジェクトを作ります。
その後、ui として Vue.js を導入します。

curl -sS https://getcomposer.org/installer | php

php composer.phar create-project --prefer-dist laravel/laravel laravel-template

cd laravel-template
php ../composer.phar require laravel/ui
php artisan ui vue
exit

コンテナ内での作業はここまでです。

必要なファイルをコピー

CakePHP4プロジェクトへ必要なファイルをコピーします。
Vue.js の各種ファイルは ./assets/ 配下に格納して開発していきます。
Laravel に合わせて ./resources/ 配下でも良いと思いますが、自分は locale 系のファイルと一緒にあることに違和感を感じるので ./assets/ 配下にしています。

cd ./cakephp-vue-study/

cp ../laravel-template/package.json ./
cp ../laravel-template/webpack.mix.js ./

mkdir -p ./assets
cp -r ../laravel-template/resources/js ./assets/js
cp -r ../laravel-template/resources/sass ./assets/sass

ライブラリのインストール

npm install も一時的なコンテナを立ち上げて実施します。
一応、現時点で LTS となっている node 12系を使っています。

docker run --rm -it -v "$(pwd):/home/app" -w /home/app node:12 npm install

必要になると思うので、このタイミングで vue-router を入れても良いと思います。

docker run --rm -it -v "$(pwd):/home/app" -w /home/app node:12 npm install --save-dev vue-router

webpack.mix.js の変更

./webpack.mix.js をCakePHP4 のディレクトリ構成にあった設定へ変更します。
./assets/./webroot/assets/ 配下へビルドした結果が格納されるようにします。

webpack.mix.js
mix.setPublicPath('webroot')
    .js('assets/js/app.js', 'assets/js')
    .sass('assets/sass/app.scss', 'assets/css')
    .sourceMaps().webpackConfig({devtool: 'source-map'});

javascript, css ファイルにタイムスタンプをつける

javascript, css ファイルのキャッシュが表示されてしまう問題に対応するために CakePHP の Asset.timestamp 機能を使います。
./config/app.php を変更します。

本番でも有効にしたいので、Asset.timestamp へ force を指定します。

config/app.php
    'Asset' => [
        'timestamp' => 'force',
        // 'cacheTime' => '+1 year'
    ],

docker-compose.yml への記述追加

./docker-compose.yml で Vue.js に関するファイルが変更された場合、ビルドを実施するコンテナ watch を追加します。

docker-compose.yml
services:
  # ... 以下が追加の記述です。
  watch:
    image: node:12
    container_name: watch
    working_dir: /home/app
    command: "npm run watch"
    volumes:
      - ./:/home/app
    networks:
      - frontend

コンテナを立ち上げ直します。

docker-compose down
docker-compose up -d

./webroot/assets/js , ./webroot/assets/css 配下に js ファイルや map ファイルができていたら適切にビルドできています。

※ Windowsの場合、変更ファイルの検知ができない可能性があるため command: "npm run watch"command: "npm run watch-poll" にする必要があると思います。

.gitignore への記述追加

ライブラリとビルドで作られるファイルを ./.gitignore へ追加しソース管理の対象から外します。

.gitignore
# Laravel Mix specific files #
##############################
/node_modules
/webroot/assets
/webroot/mix-manifest.json

動作確認のための変更

簡易的な対応を実施して、Vue.js が動作していることを確認します。

routes.php の変更

./config/routes.php で、どんなアクセスがあっても同じ PagesController#index() が呼び出されるようにします。

config/routes.php
$routes->scope('/', function (RouteBuilder $builder) {
    $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([
        'httpOnly' => true,
    ]));
    $builder->applyMiddleware('csrf');

    //$builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);
    $builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']);

    // Vue.js のルートページ表示用です。
    $builder->connect('/*', ['controller' => 'Pages', 'action' => 'index']);

    $builder->fallbacks();
});

PagesController の変更

./src/Controller/PagesController.php へ index メソッドを追加します。

src/Controller/PagesController.php
class PagesController extends AppController
{
    // この index メソッドを追加します。
    public function index(): ?Response
    {
        return $this->render();
    }
    // ...
}

Pages/index.php の追加

./templates/Pages/index.php ファイルを追加します。
CakePHP4 から ctp ファイルではなく、php ファイルになったようです。
確認のため、example-component タグを記述しています。

templates/Pages/index.php
<div id="app">
    <example-component></example-component>
</div>
<?= $this->Html->script('/assets/js/app.js')?>

動作確認

http://localhost し以下の文言が表示されていることを確認してください。

Example Component
I'm an example component.

補足) テストの追加と実行方法

./tests/TestCase/Controller/PagesControllerTest.php へ PagesController で追加したメソッドのテストを追加します。

tests/TestCase/Controller/PagesControllerTest.php
class PagesControllerTest extends TestCase
{
    // ... 以下の2つのテストを追加します。
    /**
     * @return void
     */
    public function testVueルートページを表示()
    {
        $this->get('/');
        $this->assertResponseOk();
        $this->assertResponseContains('<div id="app">');
    }

    /**
     * @return void
     */
    public function testどんなアクセスでもVueルートページを表示()
    {
        $this->get('/hoge/bar');
        $this->assertResponseOk();
        $this->assertResponseContains('<div id="app">');
    }
    // ...
}

コンテナを使って、テストを実行します。

docker exec -it app php composer.phar test

コミット前に composer check を行うと test と codesniffer による check を行えます。

docker exec -it app php composer.phar check

この composer で実行するコマンドは、 composer.json の scripts に記載があります。

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

Vue.jsプロジェクトのセットアップ

Vue.jsのプロジェクトを最初から作る手順を紹介します。

前提条件

  • npm、yarnがインストール済みであること

@vue/cliのインストール

グローバルに@vue/cliをインストールしてvueコマンドを使用できるようにします。

$ npm install -g @vue/cli

vueプロジェクトの作成

$ vue create project-name
? Please pick a preset: (Use arrow keys)
❯ default (babel, eslint) 
  Manually select features 

開発環境の起動

$ yarn serve

起動後、ブラウザからhttp://localhost:8080/で表示できます。

production用build

$ yarn build

成功すると、distディレクトリにファイルが作られます。

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

Nuxt.jsとvue-awesome-swiperでスワイパーする

Nuxt.jsでスワイパーを作ります。

前提条件

  • Nuxt.jsとPugとStylusがインストールされていること

インストール

$ npm i --save vue-awesome-swiper

使い方

touchコマンドでプラグイン用ファイルを作成する

$ touch plugins/vue-awesome-swiper.js
plugins/vue-awesome-swiper.js
import Vue from 'vue'
import VueAwesomeSwiper from 'vue-awesome-swiper'

// require styles
import 'swiper/dist/css/swiper.css'

Vue.use(VueAwesomeSwiper /* , { default global options } */)

nuxt.config.jsでプラグインを読み込む

nuxt.config.js
module.exports = {
  plugins: [
    { src: '~/plugins/vue-awesome-swiper', mode: 'client' }
  ]
}

client-onlyの中にswiper、その下にswiper-slideを欲しいスライド分だけ配置する。
swiperoptions属性に渡しているオブジェクトで、スワイパーのオプションを設定する。
この例では1セットに3枚のスライドを表示し、スライドをループさせる状態にある。

pages/test.vue
<template lang="pug">
.test
  client-only
    swiper(:options="swiperOption")
      swiper-slide(v-for="item in 10" :key="item")
        .test-item {{ `Slide ${item}` }}
</template>

<script>
export default {
  data: () => ({
    swiperOption: {
      slidesPerView: 3, // 1セットに何枚のスライドを表示するか
      loop: true, // スライドをループさせるか
    }
  })
}
</script>

<style lang="stylus">
.test
  width 100vw
  height 100vh

.swiper-container
  width 100%
  height 100%

.test-item
  display flex
  justify-content center
  align-items center
  width 100%
  height 100%
</style>

その他のオプションは参考文献のgithubを参照してください。

参考文献

この記事は以下の情報を参考にして執筆しました。

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

Vue.jsの$emitでpromiseしたいでござるの巻

先日書いた記事で、出来るだけtemplateで$emitした方が良いと書きました。

<template>
 <dl>
  <dt>名前入力</dt>
  <dd>
   <input
    type="text"
    @input="$emit('customEvent', hoge('hogehoge'))"
   />
  </dd>
 </dl>
</template>

こういう感じにemitは直接templateから指定して、引数からメソッドを呼んで必要に応じて成型した戻り値を親コンポーネントへ送ります。

みたいなことを書いたわけですが、$emit時に返す値がAPIの読み込みなどの非同期な場合に上手くいかない事がありました。

なのでそういう場合は

hoge(e){
  axios.get('fuga', e).then(res => {
    this.$emit('fuga', res.data)
  })
}

みたいな感じに、イベントから呼び出した関数内でemitするパターンになってしまうのですが…。

async hoge(e){
  const param = await axios.get('fuga', e).then(res => res.data)
  // 実際にはここでparamの加工とかする
  return param
}

とか言う感じでpromiseで返してあげると

<template>
  <div id="app">
    <SampleComponent @customEvent="fuga" />
  </div>
</template>

<script>
export default {
  components: {
    SampleComponent 
  },
  methods:{
    fuga(e){
      e.then(result => alert(result))
    }
  }
}
</script>

親側で受け取った際に、引数にPromiseオブジェクトで帰ってくるので、引数にthenとか付けれました。
まぁ、よく考えれば、そりゃそうだろうって感じですが…。

<button @click="hoge().then(res => $emit('hogehoge', res))">TEST2</button>

ちなみに、Promiseオブジェクトで送るのがなんか嫌でこう書いたら動くんですが、vue側でエラーを出してきます。

[Vue warn]: Property or method "then" is not defined on the instance but referenced during render. Make sure that this property is reactive, either in the data option, or for class-based components, by initializing the property.

こんな感じのエラーになりました。英語に疎いのでちょっと意味が…。

とまぁ、何の役にも立ちそうにない小話です。

できれば$emitがpromiseにも対応してくれればいいなぁと思った今日この頃です。

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

【瞬時に解決】vue.jsのエラー:Elements in iteration expect to have 'v-bind:key' directives vue/require-v-for-key

vue cliを使用してプロジェクトを作成していると、このようなエラーに遭遇するかもしれません。

Elements in iteration expect to have 'v-bind:key' directives vue/require-v-for-key

対処法は意外にも簡単でした。
vue.jsの公式ドキュメントにも解決策が載っています。

たとえばv-forを使って、monkeyの名前をliタグの中に出したいとしましょう。

<template>
    <ul>
        <li v-for="monkey in monkeys">{{ monkey }}</li>
    </ul>
</template>

<script>

export default {
  data() {
    return {
      monkeys: ['Taro', 'Hanako', 'Jiro']
    }
  }
}
</script>

こう書くとエラーが発生してしまいアウトプットされません。

ところが以下のようにv-bind:key="monkey"liタグのなかに追加するとエラーが消えます:point_down:

解決!

<template>
    <ul>
        <li v-for="(monkey, dog) in monkeys" v-bind:key="dog">{{ monkey }}</li>
    </ul>
</template>

どうやらv-forを使うときは、v-bind:key=""を付け加えてあげる必要があるそうです。これはvue.jsの公式ドキュメントでも推奨されています。
v-bind:key=""の中身は、inの手前にくるもの(この場合は(monkey, dog))とそろえる必要があります。ただし、最初のオブジェクトとかぶるのはNGのようで、他とかぶらない名前を(monkey, dog このようにしてオブジェクトのあとにカンマで区切って付け加えるといいようです。ここではdogという名前をつけました。括弧にくくらないと、またエラーが出てしまいます。たとえば以下のようでは効きません。

誤り:heavy_multiplication_x:

<template>
    <ul>
        <li v-for="monkey,dog in monkeys" v-bind:key="dog">{{ monkey }}</li>
    </ul>
</template>

//以下も効きません。 

<template>
    <ul>
        <li v-for="monkey in monkeys" v-bind:key="dog">{{ monkey }}</li>
    </ul>
</template>

?ご注意ください。

このようにして解決することができました!

とはいえ初心者なので、もし何かあればご指摘ください:speaking_head:

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

nuxtでGTMタグを<head>と<body>に埋め込む(非SPA、モジュール不使用、シンプル)

クライアント案件で、GTMタグを埋め込みたいとの連絡をいただいた。

通常の静的サイトであれば、GTM用のタグを
<head>内と<body>直後の全ページに設置するだけで良いのだけれど、
これをnuxtでgenerate出力した場合のサイトにシンプルに適用する方法について共有します。
(20/02/19 時点で動作確認)

対象サイト

  • nuxt.js(v2.0.0)で構築
  • universalモード(非SPA)
  • generateで生成した静的Webサイト(下層ページあり)

やりたいこと

GTMから発行される下記タグを全ページに設置する(あるいはそれと同等のことをする)。

  • <head></head>
*.html
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');</script>
<!-- End Google Tag Manager -->
  • <body>直後に
*.html
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->

いろんなやり方ある・・・が、自社案件じゃないと歯がゆい

公式モジュール

nuxt公式が出しているGoogleTagManager用のモジュールを使ってみる。

ところが情報色々ぐぐってみると、
デフォルトではページビューの計測オプションがfalseになっており、
計測したい場合はこちらのオプション有効化することに加え、
管理画面側でページビューを拾うためのタグの設定などが必要だそう。

先方管理画面のログイン権限がないような今回のケースだと、
担当者にタグの設定を一発で的確に指示できる自信があまりないし、
設定の不備による計測ミスなど状況をややこしくしたくない、、
実装サイドだけで完結させられないか、他の方法を検討。

自前でプラグイン実装

これも公式にのっている方法で、
プラグインファイルを作ってnuxt.config.jsで読み込めばいいよというやつ。

この方法は、nuxtに対して「毎回遷移時にコード送ってね」というコードを書いているので、
(つまるところそれを簡単に使えるようしてくれているのが先程のモジュールなわけで、)
nuxtの内部で処理されるため、<head>とか<body>にコードが出力されるわけではない。
し、どちらにせよイベント送信してゴニョゴニョするってことは
受信側でタグ設定が必要になりそうなので、ナシ。

いやいや、もっとシンプルに・・該当の箇所に埋め込みたいだけなのだ・・?
ということで調べて、たどり着きました。

【結論】nuxt.config.js(Vue meta)で記述できる

nuxt.jsのAPI:headを利用し、vue-metaのルールに則って記述するだけで大丈夫でした。

nuxt.config.js に下記を追記しましょう。

nuxt.config.js
const gtmID = 'GTM-XXXXXXX'
const gtmHeadTag = `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtmID}');`
const gtmBodyTag = `<iframe src="https://www.googletagmanager.com/ns.html?id=${gtmID}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`

export default {
  head: {
    script: [
      {
        hid: 'gtmHead',
        innerHTML: gtmHeadTag
      }
    ],
    noscript: [
      {
        hid: 'gtmBody',
        innerHTML: gtmBodyTag,
        pbody: true
      }
    ],
    __dangerouslyDisableSanitizersByTagID: {
      'gtmHead': ['innerHTML'],
      'gtmBody': ['innerHTML']
    }
  }
}

コンパイル後のソース

*.html
<head>...
<script data-n-head="ssr" data-hid="gtmHead">(function (w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-XXXXXXX');</script>
...</head>
<body>
  <noscript data-n-head="ssr" data-hid="gtmBody" data-pbody="true"><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
...

解説(補足)

script:[]

<script>タグを<head></head>内に埋め込めます。
共通で読み込みたいsrcファイルなどを指定(公式)する際にも使えますが、
innerHTMLというキーに文字列を渡すと、<script>~<script>内に文字列を挿入できます。

https://vue-meta.nuxtjs.org/api/#script

noscript:[]

<noscript>タグを<body></body>内に埋め込めます。
オプションで、挿入位置を指定できます。
<body>直後の場合はpbody:true</body>直前の場合はbody:trueを指定します。

https://vue-meta.nuxtjs.org/api/#noscript
https://vue-meta.nuxtjs.org/api/#noscript-text

__dangerouslyDisableSanitizersByTagID:

①inject許可

vue.jsで構築されたアプリケーションは、DOM攻撃(XSS等)の危険性があるので、
管理外の<head><body>へのタグの追加・変更に堅いです。
ここで指定されたキーバリューは、例外となりinjectを許可されます。

https://vue-meta.nuxtjs.org/api/#dangerouslydisablesanitizersbytagid

※注:同様に__dangerouslyDisableSanitizersというのがありますが、
こちらは指定したタグ全般に対し例外適用されるので、ID指定のほうを使いましょう。

②escapeされない

また、ここで指定された値はrawData扱いされるため、
htmlに出力される際escapeされないというところもポイントとなります。

https://vue-meta.nuxtjs.org/api/#add-other-raw-data

やってみていただくとわかるのですが、こちらのオプションをつけずに出力した場合、
<body>側に埋め込んだ<iframe>タグは下記のように&lt;iframeと出力されてしまいます。
(これを解決するのに、DOM Parserなども試したが駄目で、ハマりました・・)

*.html
<body>
  <noscript data-n-head="ssr" data-hid="gtmBody" data-pbody="true">&lt;iframe src=&quot;https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX&quot; height=&quot;0&quot; width=&quot;0&quot; style=&quot;display:none;visibility:hidden&quot;&gt;&lt;/iframe&gt;</noscript>

その他注意

vue-meta公式だとIDに設定するキーの名前がvmidになっているのですが、
nuxtの場合はhidにしてあげないと効きません。(こちらも地味にハマった、、)

おわり

シンプルな実装で、計測も無事確認できたので、同じような状況の方はためしてみてください?
(ちなみにこのスクリプト挿入はGTMタグ以外にも応用可能です)

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

Vue.js:カッコイイナビゲーションを作る

最近マイブームの「アレ、どうやって作るんだろう?」的なメモです。
たまに見かける「メニューを開くと文字がフワフワっと出てくる」アレ。
実は以前から「どうやって作るんだろう??」と思ってたんです。

transition-delayを付けても出来そうなんですけど、
何か1文字ずつズレて表示されてるし、
人力で手作業でやってるワケないしな〜と思ったので、調べました。

調べた結果、おそらくSplitTextっていうエフェクトの
カスタムの一種だろうと思いました。
gsapっていうJavascriptアニメーションフレームワークがあるんですけど、
それの高機能なバージョンに付属している機能です。

SplitText2.gif

Vue.jsで試してみる。

今回もVue.jsです。前回の投稿で「≡」を作った時のソースの流用をします。
https://qiita.com/DaisukeNishi/items/999709a4636f600ffcfa

App.vue
<template>
  <div id="app">
    <header class="header">
      <h1 class="title">
        Website
      </h1>
      <div
        class="menu"
        data-state="hoge"
        data-mouse="fuga"
        @click="click"
        @mouseenter="enter"
        @mouseleave="leave"
        >
        <div class="line line-1"></div>
        <div class="line line-2"></div>
        <div class="line line-3"></div>
        <div class="line-close line-close-1"></div>
        <div class="line-close line-close-2"></div>
      </div>
    </header>
    <div class="cnt-cover">
      <div class="cover"></div> <!--白背景-->
    </div>
    <nav class="split-text">
       <div id="quote" style="text-align:left;">
         メニュー表示<br/>
         会社概要<br/>
         プライバシーポリシー<br/>
         あいうえお<br/>
         かきくけこ
       </div>
    </nav>
    <img alt="Vue logo" src="./assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>
App.vue.scss
.cnt-cover {
  position: fixed;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 10;
  pointer-events: none
}

.cnt-cover .cover {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: #fff;
  z-index: 1;
  will-change: opacity;
  opacity: 0;
}

.split-text {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  color: #000;
  opacity: 0;
}

(あれっ!?しまった、書いてる途中に気がついたんですが、
これopacityだけだとメニューの後ろにあるリンクが押せなくなりますね。
z-indexも一緒に変えれば動くとおもう・・・)→(未検証)

App.vue
<script>
import gsap from 'gsap';
import SplitText from './js/SplitText.js'
import $ from 'jquery';
import HelloWorld from './components/HelloWorld.vue'
export default {
  name: 'App',
  components: {
    HelloWorld
  },
  methods:{
    //ハンバーガーメニューのイベント
    click(e){
      if(e.target.dataset.state !== 'batsu'){
        e.target.dataset.state = 'batsu';
        this.show();
      }else{
        e.target.dataset.state = 'hamburger';
        this.hide();
      }
    },
    enter(e){
      e.target.dataset.mouse = 'enter';
    },
    leave(e){
      e.target.dataset.mouse = 'leave';
    },
    show(){
      //gsapで表示
      const wt = $(".cnt-cover .cover");
      const tl = new gsap.timeline({});
      tl.to(wt, .15, { autoAlpha:1 });
      //jQueryで表示
      $(".split-text").css("opacity","1")
      //テキストアニメーション
      const tl2 = gsap.timeline(),
        mySplitText = new SplitText("#quote", {type:"words,chars"}), 
        chars = mySplitText.chars;
      gsap.set("#quote", {perspective: 400});
      tl2.from(chars, {duration: 0.8, opacity:0, scale:0, y:80, rotationX:180, transformOrigin:"50% 50% -20",  ease:"back", stagger: 0.01}, "+=0");
    },
    hide(){
      //gsapで非表示
      const wt = $(".cnt-cover .cover ");
      const tl = new gsap.timeline({});
      tl.to(wt, 0.15, { autoAlpha:0 });
      //jQueryで非表示
      $(".split-text").css("opacity","0")
    },
  },
}
</script>

使用方法の説明にはこう書いてあります。
an array of all the divs that wrap each character
divの中にある全ての文字をアニメーションさせる、的な意味かと。
多分、親要素に書いてあげれば子要素に適用できるはず。

変数をconst3連続で定義してます。
こちらのSplitTextimportしてないとエラーになります。

App.vue.js
const tl2 = gsap.timeline(),
mySplitText = new SplitText("#quote", {type:"words,chars"}), 
chars = mySplitText.chars;

パースペクティブの意味はちょっと調べてないですが、文字数制限的な感じかと。
ココで要素のidを指定しておきます。

App.vue.js
gsap.set("#quote", {perspective: 400});
App.vue.js
tl2.from(
  chars, {
    duration: 0.8,  //スピード
    opacity:0,      //開始時の透明度
    scale:0,        //開始時の大きさ
    y:80,           //開始時のy軸?
    rotationX:180,  //開始時にx軸の方向に180度倒す
    transformOrigin:"50% 50% -20", //元の大きさとか?
    ease:"back",    //イージングとか
    stagger: 0.01   //ぐらつきの度合い
  },
"+=0");             //?なんだろう?

動きました。細かいセッティングが可能です。

プラグインが微妙に高い。

App.vue.js
import SplitText from './js/SplitText.js'

こちら、本来はgsapのパッケージに入ってるのだろうと思います。
有料プラグインは、買うしかないんじゃないですかね商用ライセンスだし。

いやはや「試しにコレ使ってみたいな」で1万円は高いッス。。高いッスよ・・・
CodePenでは無料で使えるらしいですが、まず案件に使えるのかを試したい。
JSプラグインを有料で買うって文化が、日本にはまだないので稟議を通らないと思うんですよね。。
オシャレなデザイン事務所様だったら稟議通るかもですけど。

お試しでローカルで使ってみたい(案件で使う時は買う)という話だったら、
β版のSplitTextがどこかに転がってないか探してみたら良いかと思います。
動作保証できないですが、案外バージョンが合わなくても動く気がします。
https://github.com/wh1100717/me.emptystack.net/blob/9b54f1aba0e5140bb873df63c1c5ea614e622e4f/asset/gsap/utils/SplitText.js
https://github.com/pslhrd/PORTFOLIO_V2/blob/50a0224456ed4de14d88f8cfb550d80e425183c1/src/js/SplitText.js

■参考資料

https://goworkship.com/magazine/text-effect-typography/
https://greensock.com/SplitText
https://codepen.io/collection/KiEhr?grid_type=list
https://www.webprofessional.jp/fancy-web-animations-made-easy-greensock-plugins/

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