20191021のvue.jsに関する記事は17件です。

LaravelにVue.jsを導入したらバグった

練習用の学習プロジェクトを仕様変更しようと思い立ち、せっかくだから勉強中のVue.jsを導入してみようとやってみた。

・・初っ端からいきなりレンダリングがバグった。

スクリーンショット 2019-10-21 23.38.00.png

<input type = "number" id = "text_num{{$item->id}}" name="text_num{{$item->id}}" 
style="display:inline-block;width:30px;height:20px" min="1" max="9" value=1>

ちょっとこのブログだとワードラップするのでわかりにくいけど、
上のコードは真ん中で改行が入ってます。
結構長めのタグで画面を開発画面を大きくはみ出て読めないので改行いれてました。

今まで問題ありませんでしたが、なぜかbladeにvueを混ぜるとタグの間に改行入れると写真のように改行したところから下のコードがむき出しでレンダリングされました。
とりあえず改行して一行にしたら治りました。
何かエスケープコードかアノテーションみたいなのでキチンと直す方法があるのかもしれませんが今のところわかりません。

コメントのご指摘より追記

改行のせいじゃなくて、全角スペースが入っている事が根本的な原因だったようです。
失礼しました。

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

URLに『#(ハッシュ記号)』がつかないようにしたい

事象

画像の様にURLに『#(ハッシュ記号)』がついてしまう。
スクリーンショット 2019-10-21 22.19.52.png

環境

  • Rails: 5.2.3
  • vue: 3.10.0
  • vue-router: 3.1.3"

補足

VueRouterの生成時にmode:'history'を指定する方法や下記記事の方法が自分の環境では働かなかった。
Laravel + vue-routerでハッシュ記号(シャープ記号)をなくしてちゃんと動くようにする方法

解決策

下記のとおり、VueRouter生成時にmodeとhashとroutesの最後にpathのリダイレクトを追加する。

export default new VueRouter({
   mode: 'history',
   hash: false,
  routes: [
    {
      path: '/hoge',
      name: 'hoge',
      component: Hoge
    },
    (中略)
     { 
       path: '*', 
       redirect: '/' 
     }
  ],
})

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

初心者によるプログラミング学習ログ 132日目

100日チャレンジの132日目

twitterの100日チャレンジ#タグ、#100DaysOfCode実施中です。
すでに100日超えましたが、継続。

100日チャレンジは、ぱぺまぺの中ではプログラミングに限らず継続学習のために使っています。

132日目は、

学習したことの一部

オブジェクトと配列構文

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link rel="stylesheet" href="main.css">
    <title>Document</title>

</head>
<body>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  <div id="app">
    <h1 style="color: red; background-color: blue;">hello</h1><!--スタイル属性、css-->
    <h1 :style="{color: textColor, 'background-color': bgColor}">hello</h1><!--バインド、オブジェクトで囲う-->
    <h1 :style="stlyeObject">hello</h1>
    <h1 :style="[stlyeObject,baseStyle]">hello</h1><!--スタイルオブジェクトたくさん、配列構文で対処-->
  </div>

 <script>
   new Vue ({
     el: '#app',
     data: {
          textColor: 'red',
          bgColor: 'blue',

      stlyeObject: {
           color: 'red',
          'background-color': 'blue'
         },
         baseStyle: {
           fontSize: '60px'
         }
       } 
   })
  </script>
 </body>
</html>


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

CSSで波を作ってみる

CSSで波を作ってみる

揺らぐ円を描いたら、波も作ってみたくなった

ので!!

調べてみたところ、すでに記事があったので参考にさせていただきました。

理想形???(結果)

See the Pen Wave by YamaOK (@yamaok) on CodePen.

やったこと

基本的には参考記事の通りです。
工夫したところといえば、vueで可変にしたところでしょうか。

    const waves = new Vue({
        el:'#waveBack',
        data:{
            // 波の数を指定
            waveCount:28,
            // 波の動き/形を2通り指定
            features:[
                {radius:9,duration:8},
                {radius:10,duration:9},
            ],
        },
        methods: {
            // animationを動的に作成
            getCss:function(wave){
                let feature = this.features[wave % this.features.length]
                // 左から順に重ねるが、2つセットで等間隔に配置する
                let left = -10 + wave * 3 + wave%2 * 3
                return `left:${left}em;bottom:2em;border-radius:${feature.radius}em;animation-duration:${feature.duration}s`
            }
        },
    })
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

LIFF v2でLINEログイン、QRスキャン、LIFFからメッセージ送信などを試すハンズオン #ヒーローズリーグ

(もしかしたらLIFF v2ハンズオンは国内初かもしれない。)

この資料について

福井ハッカソン@ #ヒーローズリーグ 2019 by MAのハンズオンで実施した内容になります。

自己紹介

LINEのAPI群

https://speakerdeck.com/n0bisuke/line-apishao-jie

LIFFとは

LINE Frontend Frameworkの略で、LINEアプリの内部ブラウザ上でLINEの情報を活用したWebアプリケーションを動作させることができるプラットフォームの事を指します。
LIFF環境上で動作するWebアプリケーションのことをLIFFアプリと呼びます。

公式文面: https://developers.line.biz/ja/docs/liff/overview/

LIFFアプリはこんな雰囲気です。↓

Messaging APIの新機能LIFFの使い方を解説します。

これまでのLIFFで出来たこと

既存のLIFFでは大まかに以下の機能が扱えます。

  • ユーザーの情報取得
  • LINEにメッセージ送信
  • 外部のBLEデバイスにアクセス(ただしこれはLINE Thingsという機能になり、ちょっと別カテゴリ感なので割愛)

現状見ている限りだと、v1とv2で特段何かが出来なくなったって感じではなさそうです。
なのでこの辺の機能は

LIFF v2のアップデート

2019/10/16のリリースLIFF v2がリリースされました。

大まかにアップデートされた内容は

1. 外部ブラウザでもLIFFアプリを扱うことが出来るようになった

→ 今までのLIFFアプリはLINEのブラウザ上からしかアクセス出来ませんでした。

2. LINEログインが扱えるようになった

→ 今までのLIFFアプリはLINEのブラウザ上からしかアクセス出来なかったので、ログインはしている前提だったのですが、v2から外部ブラウザでも扱えるようになったことにより、 外部ブラウザからのアクセス時のみ LINEログインを扱うことができます。

3. QRコードスキャンが扱えるようになった

→ LIFF上からLINEアプリのQRコードスキャン機能を呼び出せるようになりました。

4. LIFFアプリを動作環境を細かく取得できるようになった

→ 外部ブラウザでもLIFFアプリが動くようになったので、外部ブラウザでの動作なのか、LINEアプリ内での動作なのかなど、動作環境を細かく取得できるようになりました。

ハンズオン

今回は これらの機能を丸っと触れてみるハンズオンになります。
また、管理のしやすさなどを加味してVue.jsを利用しています。

事前準備

  1. LINE BOTを作ったことがある
  2. Node.jsが動く環境がある
  3. ngrokが動作する環境がある
  4. ローカルサーバーを起動する環境がある
    • VSCodeのLive Serverプラグインが便利です

1~3までは「1時間でLINE BOTを作るハンズオン」をやっておくと良いです。

今回の到達目標

1 or 2で選んで行きましょう。前での紹介としては2で進んで行きますが、1のフォローもします。

  1. LINE BOTを作ったことが無い人 -> 「1時間でLINE BOTを作るハンズオン」を試してLINE BOTを作ってみるのもOK(STEP3まで)
  2. LINE BOTを作ったことがある人 -> このページをこのままお進み下さい。

1. ngrokなどでトンネリング

ngrok http <利用するポート名>という形で起動させておきます。

$ ngrok http 5500

生成されるhttps://xxxxxx.ngrok.ioのアドレスをコピーしておきましょう。次の手順で利用します。

補足

ngrokがインストール出来なさそうな人はserveoを利用すると良さそうです。特にインストールなどせずにsshでトンネリングが出来ます。Node.jsやnpmがそもそも入ってないよーって人はこちらの手段でもOKです。

servioの利用

$ ssh -o ServerAliveInterval=60 -R <利用したいドメイン名>:80:localhost:<ポート> serveo.net

実際はこんな感じ

$ ssh -o ServerAliveInterval=60 -R n0bisuke:80:localhost:3000 serveo.net
Forwarding HTTP traffic from https://n0bisuke.serveo.net
Press g to start a GUI session and ctrl-c to quit

この場合は次の手順で設定するエンドポイントURLはhttps://xxxxxx.serveo.netを設定しましょう。

2. LIFFアプリの登録

LINE DevelopersのLINE BOTの管理画面からLIFFのタブを選択し、追加ボタンを押しましょう。

エンドポイントURL先ほどのngrokのアドレスを指定し、キャプチャのようにチェックをして追加するを押しましょう。

作成したLIFFアプリケーションを確認し、LIFF URLline://app/xxxxxxxxのxxxxxxxxをメモしておきましょう。

3. 外部ブラウザでの挙動とLINEログインを試す

まずは外部ブラウザでの挙動とLINEログインを試していきます。

3.1 コードを書いていく

任意のフォルダを作成し、その中にindex.htmlscript.jsを作成しましょう。

index.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
        <title>LIFF v2 ハンズオン</title>
    </head>
    <body>
        <div id="app">
            <button @click=logout>ログアウトする</button>

            <h1>{{displayName}}</h1>
            <p>{{userId}}</p>
            <p>{{statusMessage}}</p>
            <img :src=pictureUrl alt="profile" width="200px" />
        </div>

        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
        <script src="https://static.line-scdn.net/liff/edge/2.1/sdk.js"></script>
        <script src="script.js"></script>
    </body>
</html>
script.js
'use strict';

const app = new Vue({
    el: '#app',
    data: {
        displayName: '',
        userId: '',
        statusMessage: '',
        pictureUrl: '',
    },

    methods: {
        //プロフィール取得関数
        getProfile: async function(){
            const accessToken = liff.getAccessToken();
            const profile = await liff.getProfile();
            this.displayName = profile.displayName; //LINEの名前
            this.userId = profile.userId; //LINEのID
            this.pictureUrl = profile.pictureUrl; //LINEのアイコン画像
            this.statusMessage = profile.statusMessage; //LINEのステータスメッセージ
        },

        //ログアウト処理の関数
        logout: async function(){
            if (liff.isLoggedIn()){
                alert('ログアウトします。');
                liff.logout();
                window.location.reload();
            }
        },

    },

    //ページを開いた時に実行される
    mounted: async function(){
        await liff.init({
            liffId: 'xxxxxxxxxxx' // ! 先ほどメモしたものを入力してください。
        });

        //LINE内のブラウザかどうか
        if(liff.isInClient()){
            alert('LINE内のブラウザ');
            this.getProfile(); //LINE内で開いた場合は特にログイン処理なしで大丈夫
        }else{
        //外部ブラウザかどうか
            if(liff.isLoggedIn()){
                alert('外部ブラウザ');
                this.getProfile();
            }else{
                liff.login();
            }
        }
    }
});

この状態でindex.htmlでローカルサーバーを起動させましょう。

最初の手順でngrokでトンネリングさせているポート(5500)を指定して起動してください。

3.2 Webサイトにアクセスして試してみる

https://xxxxx.ngrok.ioのアドレスをPC上のブラウザなどから開いてみましょう。

こんな感じでログイン画面に遷移します。

無事にログインできるとこんな感じでアラートが出ます。

3.3 ここで繋がらない人FAQ

  • LIFFのIDちゃんとありますか?
    • ソースコード上で一箇所だけ書き換えがあるので注意
  • http://localhost:5500みたいなアクセスしてませんか?
    • https://xxxx.ngrok.ioのアドレスで

3.4 LINEアプリ内からもアクセスしてみる

LINEアプリからline://app/xxxxxxxxxxxのアドレスにアクセスしてみましょう。
何かしらの方法でLINEのBOTや会話などにこのアドレスを投稿して試してみるのが良いです。

4. QRスキャンとメッセージ送信を試してみる

QRとメッセージ送信を試します。

4.1 コードを書く

index.htmlscript.jsを更新します。

index.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
        <title>LIFF v2 ハンズオン</title>
    </head>
    <body>
        <div id="app">
            <button @click=logout>ログアウトする</button>

            <button @click=sendMessage>メッセージ送信</button>
            <button @click=QR>QRスキャン</button>

            <h1>{{displayName}}</h1>
            <p>{{userId}}</p>
            <p>{{statusMessage}}</p>
            <img :src=pictureUrl alt="profile" width="200px" />
        </div>

        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
        <script src="https://static.line-scdn.net/liff/edge/2.1/sdk.js"></script>
        <script src="script.js"></script>
    </body>
</html>
script.js
'use strict';

const app = new Vue({
    el: '#app',
    data: {
        displayName: '',
        userId: '',
        statusMessage: '',
        pictureUrl: '',
    },

    methods: {
        //プロフィール取得関数
        getProfile: async function(){
            const accessToken = liff.getAccessToken();
            const profile = await liff.getProfile();
            this.displayName = profile.displayName; //LINEの名前
            this.userId = profile.userId; //LINEのID
            this.pictureUrl = profile.pictureUrl; //LINEのアイコン画像
            this.statusMessage = profile.statusMessage; //LINEのステータスメッセージ
        },

        //ログアウト処理の関数
        logout: async function(){
            if (liff.isLoggedIn()){
                alert('ログアウトします。');
                liff.logout();
                window.location.reload();
            }
        },

        //QRコードの利用の関数
        QR: async function(){
            if(!liff.isInClient()) {
                alert('LINEから開いて下さい');
                return;
            }
            //QR読み込み
            const res = await liff.scanCode();
            const msg = `読み取ったコードの中身は「${res.value}」です`;
            alert(msg);
        },

        //LINEにメッセージ送信の関数
        sendMessage: async function(){
            if(!liff.isInClient()) {
                alert('LINEから開いて下さい');
                return;
            }
            //メッセージ送信
            await liff.sendMessages([
                {
                  type:'text',
                  text:'Hello, World!'
                }
            ]);

            alert('メッセージを送信しました。');
        }
    },

    //ページを開いた時に実行される
    mounted: async function(){
        // alert(liff.getOS());
        await liff.init({
            liffId: 'xxxxxxxxxx' // ! 先ほどメモしたものを入力してください。
        });

        //LINE内のブラウザかどうか
        if(liff.isInClient()){
            console.log('LINE内のブラウザ');
            this.getProfile(); //LINE内で開いた場合は特にログイン処理なしで大丈夫
        }else{
        //外部ブラウザかどうか
            if(liff.isLoggedIn()){
                console.log('外部ブラウザ');
                this.getProfile();
            }else{
                liff.login();
            }
        }
    }
});

4.2 LINEアプリからアクセスして試してみる

この機能は外部ブラウザからのアクセスでは利用できないので、LINEアプリからline://app/xxxxxxxxxxxのアドレスにアクセスして試してみましょう。

5 チャレンジ課題

時間が余った人はチャレンジしてみましょ!

5.1 公式ドキュメントを参考に送信するメッセージをカスタマイズしてみよう

参考: https://developers.line.biz/ja/reference/liff/#send-messages

5.2 QRコードで読み込んだ文字列をLINEに送信してみよう

組み合わせチャレンジです。


まとめ

お疲れ様でした。LIFF v2の機能を試すハンズオンを行いました。
目玉機能はLINEログインとQRスキャンだと思うのですが、個人的には外部ブラウザでLIFFの活用が出来るようになったというところだと思ってます。

既存のWebサービスにもLIFFアプリの機能を活用できるポイントも増えてくるかもしれないので、LINE Thingsのように、LINEがスマートフォンアプリやチャットプラットホームからさらに活用したユースケースが出てくると思います。

みんなで活用考えてみましょう!

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

初心者こそ、Docker を使おう!

初心者こそ、Docker を使うべきです。

初心者こそ、Docker で node.js の 練習環境を作って快適に練習しよう!

対象者: dotinstall 等で javascript(node.js) を練習されているかた
練習環境を想定していますので、ここでは本格的な環境は想定していません。
nodeのversion管理面倒なんだけどと思っているなら、とても参考になるでしょう。
version違うけど、動くからと思っている人には、効果が薄いかもしれません.(笑)

まぁ、node環境しかり、python環境しかり、練習する前に環境を作る手間が誰でも手間ですが、初心者にとっては苦痛の何者でもありません。
簡単なお試しであれば、ウエブ上でもできますが、ある程度好みをいれるとなるとローカル環境を作ることになります。
様々なヴァージョンのnode, python を入れるのに nodenv, pyenv, anyenv 等はなかなかクールで愛用していますが、Dcoker に練習環境をいれるのは、さらにクールです。
 練習用コンテナを作って用が済めば、コンテナを消すのも簡単
会社のwindowsでdocker で作業をして、 macbook で 喫茶店で気になるところをチェックする、 家に帰って iMac で Docker を立ち上げ作業を続ける等もやろうと思えば出来るでしょう。(笑)
初心者でも、こんな環境が手に入るのです。 使わない理由がありません。

今回の作業内容は、githubにおいてありますので、うまく行かない場合は clone してつかってください.

  • 動かないと、やる気が半減するので置いておきます
  • https://github.com/atoris1192/docker-node-env
  • 最初複雑に見えるかもしれませんが、同じパターン作業なので慣れます.

  • この作業は、うまくいかない場合ためしてください、 最初は、導入からどうぞ

git clone https://github.com/atoris1192/docker-node-env.git

git reset --hard c026b7e19331000aa0a

cd docker-node-env
docker-composer up
docker-compose run --service-ports node bash

cd src
yarn
npx parcel index.pug

導入

Docker for Mac 或いは Docker for Windows を入れる  
入れ方は、検索してみてくださいね (DockerをMacにインストールする) などQiitaの記事丁寧でいいんじゃないでしょうか
Docker のアカウントも作って、DcokerHubにも入れるようにしておいてください。

Dockerfiledocker-compose.yml ファイルを作ります。
この2つで、Docker を操作します。
イメージは、最初からnodeが入っている公式のを使います。
vim 等を入れていますが、要らなければ削除してください
yarn も要らないものは、削除すればいいでしょう、必要になれは後からいれればいいのですから。

Dockerfile

FROM node:10.15.3
WORKDIR /tmp/src
RUN ["apt-get", "update"]
RUN ["apt-get", "install", "-y", "vim"]
RUN ["apt-get", "install", "-y", "tree"]
RUN yarn init -y && yarn add parcel-bundler pug typescript sass stylus firebase
CMD ["/bin/bash"]

イメージを作り直すのにビルドします
最後のドットは忘れないでください。 カレントのDockerfile を指定しています

docker build -t atoris1192/node:0.1.5 .
docker images でローカルにイメージができているのが確認できます。

docker-compose.yml
* 先にビルドしたものを使う

version: "3"
services:
  node:
    container_name: node
    image: atoris1192/node:0.1.5
    # build: .
    # volumes は上書きに注意
    volumes:
      - .:/app
    ports:
      - "1234:1234"
      - "1235:1235"
    working_dir: /app
    command: cp -rp /tmp/src /app
    # command: npx parcel --hmr-port 1235 --hmr-hostname localhost index.pug
    tty: true

docker-compose.yml ファイルがあるディレクトリであることを確認します。
dockder-compose up で環境を作ります。

最初のdocker-compose up は非常に時間がかかります。
早い機種ならすぐにおわるのでしょうが、Macbookで、5分位かかります
Attaching to node # この状態で止まる
node exited with code 0 # これがでればコピーが終わり
src配下にnode_modules, package.json, yarn.lock ができているはずです

docker-compose.ymlがあるディレクトリで

# ホスト(Mac)作業
  docker-compose up  # コピーに時間がかかります Attaching to node で止まっているように見える
  docker-compose ps  # node state が exit0 になっているのを確認します。 copy が終わってstopした状態です
  docker-compose run --service-port node bash # コンテナが立ち上がります. ここからlinux側です。
# コンテナ作業
  uname -a` # linuxが動作しています。
  node --version # node が動作しています。

npm がつかえるので、 npm install で vue.js, nuxt, react等 をいれればコンテナ環境で練習ができる
yarn もつかえますので、yarn global add @vue/cli をすればいいですね。
これで Vue Cli version3 が使えます。
https://qiita.com/atoris/items/6e603e59228f0ccadfd8
使い方は,上記を参考にしてもらえれば幸いです。

  • さて、現在の構成です。
    • node_modulesは、src配下にはいっていますので、npm, yarn を使うのはコンテナに入り、src 配下でしてください。
    • コンテナに入らずに、ホスト(Mac)のターミナルで npm, yarn はしないでください、たぶん nodeのヴァージョンがちがうはずです。node --version すればわかります。 同じであればうごくかもしれませんがファイルが壊れるのでしないほうがいいでしょう
├── Dockerfile
├── docker-compose.yml
└── src
    ├── package.json
    └── yarn.lock

今回は webpack ではなく、 Parcel bundler を使います

今回は、webpackでバンドルされていないものを使います
練習にもってこいのParcel-bundler です
webpack, parcel-bundler も ファイルをコンパイルして纏めるのが仕事ですが、練習に余計なファイルができるのは避けたいのと簡単に、pug, sass, stylus, typescript, 等を練習に混ぜたいためです。

pug で html を仮に作ります。 pug が嫌いな人は、src/index.html を作りましょう

ホスト側(Mac)、コンテナどちらからでもOKです
ターミナルは2つ立ち上げておいたほうが便利でしょう( host用,コンテナ用)
touch src/index.pug

後は、お好きなエディターでホスト側(atom, vscode等)作っていきます。
持ちろん、コンテナ側で vim で作ってもらってもOKです。

src/index.pug

<!DOCTYPE html>
html(lang="en")
  head
    meta(charset="UTF-8")
    meta(name="viewport", content="width=device-width, initial-scale=1.0")
    meta(http-equiv="X-UA-Compatible", content="ie=edge")
    title Document
  body
    div Hello !
  • コンテナ内作業 ( linux 側 uname すればわかります mac なら Darwin と表示されます)
cd src
npx parcel index.pug

ブラウザーで確認します。
http://localhost:1234

Hello がでていれば、Parcel が正常に動いています

エラーになる場合ポートマッピングがされているか確認しましょう

  • ホスト側 docker-compose.ymlがあるディレクトリから実行 docker-compose ps
  • ports が指定されているか確認
  atoris@atorisnoMacBook 08 % doccom ps
            Name                   Command          State                    Ports                
  ------------------------------------------------------------------------------------------------
  08_node_run_ca4fd0d55598   bash                   Up       0.0.0.0:1234->1234/tcp,              
                                                             0.0.0.0:1235->1235/tcp               
  node                       cp -rp /tmp/src /app   Exit 0                      

よくあるのが、何回も試していると二重起動やコンテナがすでにある場合失敗します。


docker container ls -a で確認して、一旦 コンテナを削除してしまいましょう
docker container stop コンテナid
docker container rm コンテナid
docker-compose down など コマンドを駆使して消してください(笑)


docker-compose.yml があるディレクトリで再度
* ホスト(Mac)作業 の項目から試して見てください

無事にブラウザーに表示されたら、そこまでの作業fileを git commit しておきましょう。(host側にgitが入っていれば)

vue.js で簡単なTodo アプリを開発してみます。

  • まず vueコンポーネントが正常に動くか確認します。
  • コンテナでparcel が動いていれば一旦 ctrl + c で止めておきます
  • コンテナ作業 でvue.js のライブラリを入れておきます。
  • package.json がなければ、yarn init -yで作っておきます あるはずですが

  • コンテナ作業

  cd src
  yarn add vue
  • vue も無事入っていますね

pacage.json

{
  "name": "src",
  "version": "1.0.0",
  "main": "index.js",
  "license": "mit",
  "dependencies": {
    "firebase": "^7.2.1",
    "parcel-bundler": "^1.12.4",
    "pug": "^2.0.4",
    "sass": "^1.23.0",
    "stylus": "^0.54.7",
    "typescript": "^3.6.4",
    "vue": "^2.6.10"
  }
}
  • vue でコードを書いていきます。

src/index.pug

<!DOCTYPE html>
html(lang="en")
  head
    meta(charset="UTF-8")
    meta(name="viewport", content="width=device-width, initial-scale=1.0")
    meta(http-equiv="X-UA-Compatible", content="ie=edge")
    title Document
  body
    p index.pug
    #app
    script(src="./index.js")

src/index.js

import Vue from 'vue/dist/vue.esm';
import MyApp from './MyApp.vue';

new Vue({
  el: '#app',
  components: {
    MyApp,
  },
  template: '<my-app></my-app>'
})

src/MyApp.vue

<template lang="pug">
  div
    p MyApp
    Hello
</template>
<script lang="ts">
import Vue from 'vue';
import  Hello  from './Hello.vue';
export default Vue.extend({
  components: {
    Hello,
  }
})
</script>

src/Hello.vue

<template lang="pug">
  div
    p Hello.vue
    p {{ name }}
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
  data() {
    return({
      name: 'Hello !!!'
    })
  }
})
</script>
  • 現在のファイル構成です。
.
├── Dockerfile
├── docker-compose.yml
└── src
    ├── Hello.vue
    ├── MyApp.vue
    ├── dist
    ├── index.js
    ├── index.pug
    ├── package.json
    └── yarn.lock
  • コンテナが止まっている場合は再度立ち上げます、立ち上がっていれば parcel コマンドを実行
  • ホスト側
docker-compose ps
docker-compose run --service-port node bash
  • Parcel オプションを追加して立ち上げます
    • もし時間がかかってうまく起動しない場合オプション抜きで試してみてください
    • node-hmr が使えないだけです
  • コンテナ作業
cd src
npx parcel --hmr-port 1235 --hmr-hostname localhost index.pug
# npx parcel index.pug

ブラウザーにすべて表示されていれば、OKです。
すべてのコンポーネントが動いています

index.pug

MyApp

Hello.vue

Hello !!!

今回 Todo アプリを作るところまでいけなかったので、次回があります。(笑)

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

VueとReact(Hooksも)をアニメーション実装から比較する

Vueで、アニメーションするコンポーネントを作ったので、
ついでにReactでも作ってみると実装方法が違ったので比較する

作ったものは、ハンバーガーボタン(押したらバツになるやつ)
svgをGSAPのTweenMaxでclickイベントをトリガーにアニメーションさせることでハンバーガーボタンを作成する

とりあえずsvgをただTweenMaxでアニメーション

DOMを取得してTweenMaxでアニメーションする例
ボタンを押せばアニメーション

See the Pen SvgTween by Saito Takashi (@7_asupara) on CodePen.

Vueで作成する

See the Pen VueHamburger by Saito Takashi (@7_asupara) on CodePen.

Vue
<template>
  <div class="button" v-on:click="toggle"> <!-- クリックイベントを付与 -->
    <svg :viewbox="viewbox" :width="size" :height="size" style="overflow: visible">
      <!-- svgの各属性に変数をバインディング -->
      <line
        x1="0"
        :y1="line1Y1"
        :x2="size"
        :y2="getTopLimit()"
        :stroke="stroke"
        :stroke-width="strokeWidth"
      />
      <line
        :x1="line2X1"
        :y1="halfSize"
        :x2="size"
        :y2="halfSize"
        :stroke="stroke"
        :stroke-width="strokeWidth"
      />
      <line
        x1="0"
        :y1="line3Y1"
        :x2="size"
        :y2="getBottomLimit()"
        :stroke="stroke"
        :stroke-width="strokeWidth"
      />
    </svg>
  </div>
</template>

<script>
export default {
  data() { // dataオブジェクト 変更を通知したい変数とかはここで定義
    return {
      size: 50,
      stroke: 'black',
      strokeWidth: 6,
      speed: 0.4,
      line1Y1: 0,
      line2X1: 0,
      line3Y1: 0,
      menuCloseFlg: false
    };
  },
  computed: { // 加工した返り値の変数を作りたい場合はここで定義
    viewbox: function () {
      return `0 0 ${this.size} ${this.size}`
    },
    halfSize: function () {
      return this.size / 2
    }    
  },
  mounted () { // mountedではcomputedが動かないのでmethodで初期化
    this.line1Y1 = this.getTopLimit()
    this.line3Y1 = this.getBottomLimit()
  },
  methods: {
    getTopLimit () {
      return this.strokeWidth / 2
    },
    getBottomLimit () {
      return this.size - (this.strokeWidth / 2)
    },
    toggle () { // クリックイベント
      if (this.menuCloseFlg) {
        TweenMax.to(
          this.$data,
         this.speed,
          {
            line1Y1: this.getTopLimit(),
            line2X1: 0,
            line3Y1: this.getBottomLimit(),
            ease: Expo.easeIn
          }
        )
      } else {
        TweenMax.to(
          this.$data,
          this.speed,
          {
            line1Y1: this.getBottomLimit(),
            line2X1: this.size,
            line3Y1: this.getTopLimit(),
            ease: Expo.easeIn
          }
        )
      }
      this.menuCloseFlg = !this.menuCloseFlg
    }
  }
}
</script>

Vueでは、dataオブジェクトを変更すると、自動で画面にも反映(rerender)してくれる
なので、svgの動かしたい属性にdataオブジェクトのプロパティを付与してその値を変更すれば勝手に画面に反映してくれる

この例の場合は、toggleメソッドでDOMではなく、svg属性に割り当てたdataオブジェクトの値を直接TweenMaxで変更してアニメーションさせている
この特性のおかげで値の変更が直感的にできるので、アニメーションを扱う上でとてもVueはいいと思う

svgを使用した動的なUIが簡単に作れそう

Reactで作成する

とりあえずClassComponentを使って作成する

See the Pen ReactHamburger by Saito Takashi (@7_asupara) on CodePen.

React
import React from 'react';

class App extends React.Component {
  constructor(){
     super();
     // 必要な変数の定義
     this.size = 50;
     this.speed = 0.4;
     this.strokeWidth = 6;
     this.halfSize = this.size / 2;
     this.halfStrokeWidth = this.strokeWidth / 2;
     this.topLimit = this.halfStrokeWidth;
     this.bottomLimit = this.size - this.halfStrokeWidth;
     this.viewbox = `0 0 ${this.size} ${this.size}`;
     this.stroke = 'black'

     // 変更を通知したい変数はここでstateとして定義
     this.state = { closeFlg: false };

     // DOMノードを取得するための準備
     this.line1Ref = null;
     this.line2Ref = null;
     this.line3Ref = null;
  }

  handleClick = () => {
    // svgの属性ではなく、DOMノードを直接Tweenさせる
    if (!this.state.closeFlg) {
      TweenMax.to(this.line1Ref, this.speed, { attr: { y1: this.bottomLimit }, ease: Expo.easeIn })
      TweenMax.to(this.line2Ref, this.speed, { attr: { x1: this.size }, ease: Expo.easeIn})
      TweenMax.to(this.line3Ref, this.speed, { attr: { y1: this.topLimit }, ease: Expo.easeIn })
    } else {
      TweenMax.to(this.line1Ref, this.speed, { attr: { y1: this.topLimit }, ease: Expo.easeIn })
      TweenMax.to(this.line2Ref, this.speed, { attr: { x1: 0 }, ease: Expo.easeIn })
      TweenMax.to(this.line3Ref, this.speed, { attr: { y1: this.bottomLimit }, ease: Expo.easeIn })
    }

    // Reactでは変更の通知はsetStateが必須
    this.setState(prevState => ({
      closeFlg: !prevState.closeFlg,
    }))
  }

  // svgのlineタグにrefを付与してDOMノードを取得する
  render() {
    return(
      <div className="button" onClick={this.handleClick}>
        <svg viewBox={this.viewbox} width={this.size} height={this.size} style={{ overflow: 'visible' }}>
          <line
            ref={ c => this.line1Ref = c}
            x1="0"
            y1={this.topLimit}
            x2={this.size}
            y2={this.topLimit}
            stroke={this.stroke}
            strokeWidth={this.strokeWidth}
          />
          <line
            ref={ c => this.line2Ref = c}
            x1="0"
            y1={this.halfSize}
            x2={this.size}
            y2={this.halfSize}
            stroke={this.stroke}
            strokeWidth={this.strokeWidth}
          />
          <line
            ref={ c => this.line3Ref = c}
            x1="0"
            y1={this.bottomLimit}
            x2={this.size}
            y2={this.bottomLimit}
            stroke={this.stroke}
            strokeWidth={this.strokeWidth}
          />
        </svg>
      </div>
    );
  }
}

Reactでは、変数(state)変更の通知をsetStateを使って行って初めて画面に反映(rerender)される
(Reactはstate変更の通知をするかどうかをプログラマーがコントロールしたいので、setStateを実行することを採用している)
なので、Vueのように値を変更するだけでは画面に反映されない

TweenMaxのようなトゥイーン系のライブラリはフレーム毎の値の変更をループでよしなにやってくれるが、svgの属性値の変更をする場合、この中にsetStateをねじ込むことができないので変更の通知ができなくアニメーションされないはず
そこで、ReactでTweenしたい場合は、Refを使用してDOMノードを取得しDOMに対してTweenMaxでアニメーションする(要はjQueryとかと同じで昔ながらの方法)

Vueより手間が多くなり、複雑なアニメーションはめんどくさそうだ

ReactHooksで作成する

Reactでは、ClassComponentが滅びてReactHooksとかいうのを使うのがスタンダードになるらしいのでこいつのもついでに作ったが、結構めんどくさかった
ReactにはFunctinalComponentとClassComponentがあって、ClassComponentでしかstateが利用できなかったが、FunctinalComponentでもReactHooksを利用してstateを扱えるようになったらしい

See the Pen ReactHooksHambergur by Saito Takashi (@7_asupara) on CodePen.

ReactHooks
import React from 'react';

function App() {
  const size = 50;
  const speed = 0.4;
  const strokeWidth = 6;
  const halfSize = size / 2;
  const halfStrokeWidth = strokeWidth / 2;
  const topLimit = halfStrokeWidth;
  const bottomLimit = size - halfStrokeWidth;
  const viewbox = `0 0 ${size} ${size}`;
  const stroke = 'black'

  // React.useStateで、stateのgetterとsetterの定義
  // const [getter, setter] = React.useState(デフォルト値)
  const [closeFlg, setCloseFlg] = React.useState(false);
  const [clicked, setClicked] = React.useState(null);

  // useRefでDOMノードの取得 ClassComponentとだいたい同じ
  const line1Ref = React.useRef(null);
  const line2Ref = React.useRef(null);
  const line3Ref = React.useRef(null);

  // クリックイベント closeFlgをトグルするだけ
  const toggle = () => {
    setCloseFlg(!closeFlg);
  };

  // useEffect SideEffect(副作用?)を実行するやつ
  React.useEffect(() => {
    if (closeFlg) {
      TweenMax.to(line1Ref.current, speed, { attr: { y1: bottomLimit }, ease: Expo.easeIn })
      TweenMax.to(line2Ref.current, speed, { attr: { x1: size }, ease: Expo.easeIn})
      TweenMax.to(line3Ref.current, speed, { attr: { y1: topLimit }, ease: Expo.easeIn })
    } else {
      TweenMax.to(line1Ref.current, speed, { attr: { y1: topLimit }, ease: Expo.easeIn })
      TweenMax.to(line2Ref.current, speed, { attr: { x1: 0 }, ease: Expo.easeIn})
      TweenMax.to(line3Ref.current, speed, { attr: { y1: bottomLimit }, ease: Expo.easeIn })
    }
  }, [closeFlg]);

  return (
    <div className="button" onClick={toggle}>
      <svg viewBox={viewbox} width={size} height={size} style={{ overflow: 'visible' }}>
        <line
          ref={line1Ref}
          x1="0"
          y1={topLimit}
          x2={size}
          y2={topLimit}
          stroke={stroke}
          strokeWidth={strokeWidth}
        />
        <line
          ref={line2Ref}
          x1="0"
          y1={halfSize}
          x2={size}
          y2={halfSize}
          stroke={stroke}
          strokeWidth={strokeWidth}
        />
        <line
          ref={line3Ref}
          x1="0"
          y1={bottomLimit}
          x2={size}
          y2={bottomLimit}
          stroke={stroke}
          strokeWidth={strokeWidth}
        />
      </svg>
    </div>
  );
}

Refの使い方とstateをsetしないといけないのはだいたいClassComponentと同じ

ただ、クリックイベントを普通にFunctionalComponentのメソッドとして定義してもライフサイクルが考慮されないのか、そこでDOMノードにアクセスしてTweenしようとしても何も実行されない(画面にレンダリングされる前に定義されるからかな?よくわからん)

ReactHooksでは、useEffectとかいうのがComponentのライフサイクル(ClassComponentでいうcomponentDidMountとかcomponentDidUpdateとか)を管理しているみたいなので、これを利用する
クリックイベントには、stateのcloseFlgのトグル処理のみ記述し、
useEffectで実行したい処理(第一引数)と監視するstate(第二引数)を指定し、closeFlgが変更されたら実行されるようにする(componentDidUpdateにあたるかな, VueならWatcher使えば同じような実装になるような)

アニメーションに限定していえば、慣れたらいけるかもやけど全然直感的じゃないのでめんどくさく感じたし、useEffectがなんか慣れない

まとめ

両者を比較すると、Vueに比べてReactはデータを厳格に扱うことを目指していると思われる
その分、Vueは今回の場合に限らず直感的にコードが書けると思う

ページ数が小規模でアニメーションが多めのインタラクティブなLP、コーポレートサイトが作りたければVueを使うべきだと思う
一方Reactは、大規模なシステム等でデータを厳格に扱いたい場合は優位だと思う
これらの中間のものは好きな方を勝手に選ぼう

ただ、今回はsvgのTweenでアニメーションしたので違いがでたが、CSSとか代替の方法もあると思うので楽な方法を検討すればいい

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

obnizOSがM5StickCに対応したので試してみた

obnizのページを見ていたら、M5StickC用のobnizOSの説明があったので、試してみました。
勉強がてら、以下のページにある「BLEセントラルコンソール」をM5stickCに移植してみました。

BLEセントラルコンソール
 https://obniz.io/ja/webapp/7

BLEセントラルコンソール

ブラウザから、M5StickCのBLEセントラル機能を使って、周辺のBLEデバイスを操作します。
(ESP32の機能を使っているので、M5StickCでなくても動きます)

以下の機能を有しています。
・obniz_idを入力して、obnizに接続します。
・BLEデバイスを探索します。
・BLEデバイスに接続し、PrimaryServiceをDiscoveryします。
・各PrimaryServiceにあるCharacteristicを一覧表示します。
・各Characteristicに対して、Read/Writeします。

こんな感じのWebページです。

image.png

obnizにはBLEセントラルのJavascript APIがあるので、そこまで高度な知識がなくても実装できました。
また、オリジナルのBLEセントラルコンソール のソースコードが非常に参考になりました。こちらのコードを8割がた流用させていただいています。

今回再構築したのは、私がVue使いであるためです。
ということで、以下の技術を使っています。

  • Vue
  • Bootstrap(v3.4.1)
  • アロー関数などの最新Javascript

M5StickCへのobnizOSの書き込み

以下に記載の通りにやれば、特に詰まることはありませんでした。
 https://obniz.io/ja/doc/obnizos/os_install

ただし、書き込み時に失敗することがあり、ボーレートを落とすことで成功しました。

obniz_cli flashos -b 115200

書き込みが完了し、再起動すると、デバイスキーとお近くのWiFiのSSID/パスワードを入力する必要があります。
上記のためには、Teratermなどのコンソールを接続する必要があります。

ソースコード

以下のソースコードを、どこかにホスティングして、ブラウザからアクセスします。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

  <title>BLEセントラルコンソール | obniz App</title>

  <script src="https://unpkg.com/obniz/obniz.js" crossorigin="anonymous"></script>
  <script src="https://unpkg.com/m5stickcjs/m5stickc.js"></script>
  <script src="https://unpkg.com/vue"></script>
</head>
<body>
    <div id="obniz-debug"></div>
    <br>
    <div id="top" class="container">
        <h1>BLE Central Console</h1>
        <br>
        <div class="form-inline">
          <label>obniz_id</label> <input type="text" class="form-control" v-model="obniz_id"> <button class="btn btn-default" v-on:click="obniz_connect()">Connect</button><br>
          <label>firmware version</label> {{firmware_ver}}
        </div>

        <div class="row">
          <div class="col-md-6">
            <h3>Devices</h3>
            <select class="form-control" v-model="select_device" v-on:change="device_change()" size="8">
              <option v-for="(device, index) in devices" v-bind:value="device">{{device.address + (device.localName ? ' (' + device.localName + ')' : '')}}</option>
            </select>
            <button class="btn btn-default" v-on:click="device_clear()">Clear</button>
            <button class="btn btn-primary" v-on:click="device_connect()" v-if="!isConnected">Connect</button>
            <button class="btn btn-primary" v-on:click="device_disconnect()" v-else>Disonnect</button>

            <h3>Services</h3>
            <select class="form-control" v-model="select_service" v-on:change="service_change()" size="8">
              <option v-for="(service, index) in services" v-bind:value="service">{{service.uuid}}</option>
            </select>

            <h3>Characteristics</h3>
            <select class="form-control" v-on:change="characteristic_change()" v-model="select_characteristic" size="6">
              <option v-for="(characteristic, index) in characteristics" v-bind:value="characteristic">{{characteristic.uuid}}</option>
            </select>
          </div>

          <div class="col-md-6">
            <h3>Detail</h3>
            <div class="panel panel-default">
              <div class="panel-body">
                <div v-if="detail_mode=='device'">
                  <label>device address</label> {{select_device.address}}<br>
                  <label>rssi</label> {{select_device.rssi}}<br>
                  <label>advertise data raw</label> {{array2string(select_device.adv_data)}}<br>
                  <label>scan response data raw</label> {{array2string(select_device.scan_resp)}}<br>
                  <label>device meanings</label>
                  <ul>
                    <li v-for="(meaning, index) in device_meanings">
                      <label>{{meaning.title}}</label> {{meaning.infomations}}
                    </li>
                  </ul>
                </div>
                <div v-if="detail_mode=='service'">
                  <label>service uuid</label> {{select_service.uuid}}<br>
                  <label>service name</label> {{service_name}} (defined by <a href="https://www.bluetooth.com/specifications/gatt/services">Bluetooth specification</a>)<br>
                </div>
                <div v-if="detail_mode=='characteristic'">
                  <label>characteristic uuid</label> {{select_characteristic.uuid}}<br>
                  <label>characteristic name</label> {{characteristic_name}} (defined by <a href="https://www.bluetooth.com/specifications/gatt/characteristics">Bluetooth specification</a>)<br>
                  <div class="form-inline">
                    <label>value type</label>
                    <label class="radio-inline"><input type="radio" value="binary" v-model="value_type" v-on:change="change_type()" checked>binary</label>
                    <label class="radio-inline"><input type="radio" value="text" v-model="value_type" v-on:change="change_type()">text</label>
                  </div>
                  <label>value</label><br>
                  <button class="btn btn-primary" v-on:click="characteristic_read">read</button> {{characteristic_read_value}}<br>
                  <button class="btn btn-primary" v-on:click="characteristic_write">write</button> <input type="text" class="form-control" v-model="characteristic_write_value" placeholder="hex string (ex: f94c8c...) "/><br>
                </div>
              </div>
            </div>
          </div>
        </div>


        <div class="modal fade" id="progress">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h4 class="modal-title">{{progress_title}}</h4>
                    </div>
                    <div class="modal-body">
                        <center><progress max="100" /></center>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script src="js/start.js"></script>
</body>

こちらがJavascriptです。少々長いですが。

start.js
'use strict';

var obniz = null;
var currentPeripheral = null;

var vue_options = {
    el: "#top",
    data: {
        progress_title: '',

        obniz_id: "",
        firmware_ver: '',
        isConnected : false,
        detail_mode: 'none',
        select_device: null,
        devices: [],
        select_service: null,
        services: [],
        select_characteristic: null,
        characteristics: [],
        characteristic_read_data: null,
        characteristic_read_value: '',
        characteristic_write_value: '',
        device_meanings: [],
        value_type: 'binary',
    },
    computed: {
    },
    methods: {
        // CharacteristicのWriteボタンを押下したとき
        characteristic_write: function(){
            let chara = this.select_characteristic;
            console.log("write value on charactaristic (" + chara.uuid + ")");
            let valString = this.characteristic_write_value;
            let data;
            if (this.value_type === "binary") {
              data = splitByLength(valString, 2).map((elm) => {
                return parseInt(elm, 16)
              });
            } else {
              data = [];
              for (let i = 0; i < valString.length; i++) {
                data.push(valString.charCodeAt(i));
              }
            }
            chara.write(data);

            chara.onwrite = (results) => {
              chara.read();
            }
        },
        // CharacteristicのReadボタンを押下したとき
        characteristic_read: function(){
            let chara = this.select_characteristic;
            console.log("read value on charactaristic (" + chara.uuid + ")");
            chara.read();
        },
        // CharacteristicからReadした値をページに反映したい時
        change_type: function(){
            var data = this.characteristic_read_data;
            let str;
            if (this.value_type === "binary") {
              str = "0x" + data.map((elm) => {
                return elm.toString(16).padStart(2, "0")
              }).join("");
              if (str.length === 2) {
                str = "null";
              }
            } else {
              str = String.fromCharCode.apply(null, data);
              if (str.length === 0) {
                str = "null";
              } else {
                str = '"' + str + '"';
              }
            }
            this.characteristic_read_value = str;
        },
        // CharacteristicのUUIDから名称を検索
        showDetailCharacteristic: function(chara) {
            let name = characteristicUuidList[parseInt(chara.uuid, 16)];
            if( name )
                this.characteristic_name = name;
            else
                this.characteristic_name = '';
        },
        // Characteristicが選択されたとき
        characteristic_change: function(){
            let chara = this.select_characteristic;
            this.showDetailCharacteristic(chara);
            this.characteristic_read_value = '';
            this.characteristic_write_value = '';
            this.detail_mode = "characteristic";

            chara.read();
            console.log("read value on charactaristic (" + chara.uuid + ")");

            chara.onread = (data) => {
              // BLEデバイスからのRead完了時
              let currentChara = this.select_characteristic;
              if (chara === currentChara) {
                this.characteristic_read_data = data;
                this.change_type();
              }
            }
        },
        // ServiceのUUIDから名前を検索
        showDetailService: function(service){    
            let name = serviceUuidList[parseInt(service.uuid, 16)];
            if( name )
                this.service_name = name;
            else
                this.service_name = '';
        },
        // ServiceにあるCharacteristicを探索
        findCharacteristics: function(service) {
            console.log("discovering characteristics on service(" + service.uuid + ")");
            this.characteristics = [];
            service.discoverAllCharacteristics();
            service.ondiscovercharacteristic = (chara) => {
                this.characteristics.push(chara);
            }
        },
        // Serviceが選択されたとき
        service_change: function(){
            this.showDetailService(this.select_service);
            this.findCharacteristics(this.select_service);
            this.detail_mode = "service";
        },
        // DeviceのPrimaryServiceを探索
        findService: function() {
            console.log("discovering services on device(" + splitByLength(currentPeripheral.address, 2).join(":") + ")");
            this.services = [];
            currentPeripheral.discoverAllServices();
            currentPeripheral.ondiscoverservice = (service) => {
                this.services.push(service);
            };
        },
        // DeviceへのConnectボタンが押下されたとき
        device_connect: function(){
            let device = this.select_device;

            device.onconnect = () => {
                // BLEデバイスの接続時
                console.log("connected to " + splitByLength(device.address, 2).join(":"));
                this.isConnected = true;
                currentPeripheral = device;

                obniz.led.on();
                this.findService();
            };

            device.ondisconnect = () => {
                // BLEデバイスの切断時
                console.log("disconnected from " + splitByLength(device.address, 2).join(":"));
                this.services = [];
                this.characteristics = [];
                this.isConnected = false;

                currentPeripheral = null;
                obniz.led.off();

                console.log("start ble scan repeatly");
                this.startScanRepeatly();
            };

            obniz.ble.scan.end();
            device.connect();
            console.log("connecting to " + splitByLength(device.address, 2).join(":"));
        },
        // Deviceへの接続の切断
        device_disconnect: function(){
            currentPeripheral.disconnect();
        },
        // AdvertiseDataの解析
        showDetailDevice: function(peripheral) {
            this.device_meanings = [];
            peripheral.analyseAdvertisement();
            for (let row of peripheral.advertise_data_rows) {
                let data = advDataAnalyze(row);
                this.device_meanings.push( data );
            }
        },
        // デバイスが選択されたとき
        device_change: function(){
            this.showDetailDevice(this.select_device);
            this.detail_mode = "device";
        },
        // デバイス一覧をクリア
        device_clear: function(){
            if (this.isConnected)
                return;

            obniz.ble.scan.end();
            this.devices = [];

            console.log("clear all device data and rescan");
            obniz.ble.scan.start({duration: 30});
        },
        // obniz接続完了後の初期処理
        setup: function(){
            obniz.ble.scan.onfind = (peripheral) =>{
                // BLEデバイスの発見時
                if (undefined === this.devices.find((elm) => {
                    return elm.address === peripheral.address
                })) {
                    let address = splitByLength(peripheral.address, 2).join(":");
                    console.log("find new peripheral : " + address + (peripheral.localName ? "(" + peripheral.localName + ")" : ""));

                    this.devices.push(peripheral);
                }
            };
        },
        // BLEデバイスのスキャンの継続
        startScanRepeatly: function() {
            obniz.ble.scan.end();
            if (!this.isConnected) {
                console.log("scan repeating");
                obniz.ble.scan.start({duration: 30});
                setTimeout(this.startScanRepeatly, 35 * 1000);
            }
        },
        // obnizデバイスの接続
        obniz_connect: function(){
//            obniz = new Obniz(this.obniz_id);
            obniz = new M5StickC(this.obniz_id);

            obniz.onconnect = async () => {
                // obnizデバイスの接続時
                console.log("obniz connected.");
                this.firmware_ver = obniz.firmware_ver;

                this.setup();

                console.log("start ble scan repeatly");
                this.startScanRepeatly();
            };
        },
        array2string: function(err){
            return array2string(err);
        }
    },
    created: function(){
    },
    mounted: function(){
    }
};
var vue = new Vue( vue_options );

function splitByLength(str, length) {
    let resultArr = [];
    if (!str || !length || length < 1) {
        return resultArr;
    }
    let index = 0;
    let start = index;
    let end = start + length;
    while (start < str.length) {
        resultArr[index] = str.substring(start, end);
        index++;
        start = end;
        end = start + length;
    }
    return resultArr;
}

function array2string(arr) {
    if (!arr || !Array.isArray(arr)) {
        return "undefined";
    }
    if (arr.length === 0) {
        return "[ ]";
    }
    return "[" + arr.map((elm) => {
        return "0x" + parseInt(elm).toString(16).padStart(2, "0");
    }).join(", ") + "]";
}

// 以降は、解析表示用です。

function advDataAnalyze(row) {
    let title;
    let infomations = [];
    let bytes = row.slice(1);
    switch (row[0]) {
    case 0x01:
        title = "Flags";
        let data = {
            0x01: "LE Limited Discoverable Mode",
            0x02: "LE General Discoverable Mode",
            0x04: "BR/EDR Not Supported ",
            0x08: "Simultaneous LE and BR/EDR to Same Device Capa- ble (Controller)",
            0x10: "Simultaneous LE and BR/EDR to Same Device Capa- ble (Host)",
            0x20: "unknown flag - 0x20",
            0x40: "unknown flag - 0x40",
            0x80: "unknown flag - 0x80",
        };
        for (let key in data) {
            if (parseInt(key) & bytes[0]) {
                infomations.push(data[key]);
            }
        }
        break;

    case 0x02: // Incomplete List of 16-bit Service Class UUID
    case 0x03: // Complete List of 16-bit Service Class UUIDs
        title = "16-bit Service UUIDs";
        for (let j = 0; j < bytes.length; j += 2) {
            let uuid = bytes.slice(j, j + 2).toString('hex').match(/.{1,2}/g).reverse().join('');
            infomations.push("uuid - " + uuid);
        }
        break;

    case 0x06: // Incomplete List of 128-bit Service Class UUIDs
    case 0x07: // Complete List of 128-bit Service Class UUIDs
        title = "128-bit Service UUIDs";
        for (let j = 0; j < bytes.length; j += 16) {
            let uuid = bytes.slice(j, j + 16).toString('hex').match(/.{1,2}/g).reverse().join('');
            infomations.push("uuid - " + uuid);
        }
        break;

    case 0x08: // Shortened Local Name
    case 0x09: // Complete Local Name»
        title = "Local Name";
        infomations.push(String.fromCharCode.apply(null, bytes));
        break;

    case 0x0a: // Tx Power Level
        title = "Tx Power Level";
        infomations.push(bytes[0]);
        break;

    case  0x14: // List of 16 bit solicitation UUIDs
        title = "16-bit solicitation UUIDs";
        for (let j = 0; j < bytes.length; j += 2) {
        let uuid = bytes.slice(j, j + 2).toString('hex').match(/.{1,2}/g).reverse().join('');
        infomations.push("uuid - " + uuid);
    }

    break;

    case  0x15: // List of 128 bit solicitation UUIDs
        title = "128-bit solicitation UUIDs";
        for (let j = 0; j < bytes.length; j += 16) {
            let uuid = bytes.slice(j, j + 16).toString('hex').match(/.{1,2}/g).reverse().join('');
            infomations.push("uuid - " + uuid);
        }
        break;

    case 0x16: // 16-bit Service Data, there can be multiple occurences
        title = "16-bit Service Data";
        let serviceDataUuid = bytes.slice(0, 2).toString('hex').match(/.{1,2}/g).reverse().join('');
        let serviceData = bytes.slice(2, bytes.length);
        infomations.push("uuid - " + serviceDataUuid);
        infomations.push("serviceData - " + array2string(serviceData));
        break;

    case 0x20: // 32-bit Service Data, there can be multiple occurences
        title = "32-bit Service Data";
        let serviceData32Uuid = bytes.slice(0, 4).toString('hex').match(/.{1,2}/g).reverse().join('');
        let serviceData32 = bytes.slice(4, bytes.length);
        infomations.push("uuid - " + serviceData32Uuid + "<br/>serviceData - " + array2string(serviceData32));
        break;

    case 0x21: // 128-bit Service Data, there can be multiple occurences
        title = "128-bit Service Data";
        let serviceData128Uuid = bytes.slice(0, 16).toString('hex').match(/.{1,2}/g).reverse().join('');
        let serviceData128 = bytes.slice(16, bytes.length);
        infomations.push("uuid - " + serviceData128Uuid + "<br/>serviceData - " + array2string(serviceData128));
        break;

    case 0xff: // 128-bit Service Data, there can be multiple occurences
        if (bytes[0] === 0x4c
                && bytes[1] === 0x00
                && bytes[2] === 0x02
                && bytes[3] === 0x15
                && bytes.length === 25) {
            title = "Manufacturer Specific Data - iBeacon";
            let uuidData = bytes.slice(4, 20);
            let uuid = "";
            for (let i = 0; i < uuidData.length; i++) {
                uuid = uuid + uuidData[i].toString(16).padStart(2, "0");
                if (i === (4 - 1) || i === (4 + 2 - 1) || i === (4 + 2 * 2 - 1) || i === (4 + 2 * 3 - 1)) {
                uuid += "-";
            }
        }

        let major = "0x" + ((bytes[20] << 8) + bytes[21]).toString(16).padStart(4, "0");
        let minor = "0x" + ((bytes[22] << 8) + bytes[23]).toString(16).padStart(4, "0");
        let power = "0x" + (bytes[24]).toString(16).padStart(2, "0");

        infomations.push("uuid : " + uuid);
        infomations.push("major : " + major);
        infomations.push("minor : " + minor);
        infomations.push("power : " + power);

        } else {
        title = "Manufacturer Specific Data";
        infomations.push(array2string(row.slice(1)));
        }
        break;

    default :
        title = "unhandled type";
        infomations.push(array2string(row.slice(1)));
        break;
    }

    title += "(0x" + row[0].toString(16).padStart(2, "0") + ")";
    return {title, infomations};
}

const serviceUuidList = {
    0x1800: "Generic Access",
    0x1811: "Alert Notification Service",
    0x1815: "Automation IO",
    0x180F: "Battery Service",
    0x1810: "Blood Pressure",
    0x181B: "Body Composition",
    0x181E: "Bond Management Service",
    0x181F: "Continuous Glucose Monitoring",
    0x1805: "Current Time Service",
    0x1818: "Cycling Power",
    0x1816: "Cycling Speed and Cadence",
    0x180A: "Device Information",
    0x181A: "Environmental Sensing",
    0x1826: "Fitness Machine",
    0x1801: "Generic Attribute",
    0x1808: "Glucose",
    0x1809: "Health Thermometer",
    0x180D: "Heart Rate",
    0x1823: "HTTP Proxy",
    0x1812: "Human Interface Device",
    0x1802: "Immediate Alert",
    0x1821: "Indoor Positioning",
    0x1820: "Internet Protocol Support Service",
    0x1803: "Link Loss",
    0x1819: "Location and Navigation",
    0x1827: "Mesh Provisioning Service",
    0x1828: "Mesh Proxy Service",
    0x1807: "Next DST Change Service",
    0x1825: "Object Transfer Service",
    0x180E: "Phone Alert Status Service",
    0x1822: "Pulse Oximeter Service",
    0x1829: "Reconnection Configuration",
    0x1806: "Reference Time Update Service",
    0x1814: "Running Speed and Cadence",
    0x1813: "Scan Parameters",
    0x1824: "Transport Discovery",
    0x1804: "Tx Power",
    0x181C: "User Data",
    0x181D: "Weight Scale",
};

const characteristicUuidList = {
    0x2A7E: "Aerobic Heart Rate Lower Limit",
    0x2A84: "Aerobic Heart Rate Upper Limit",
    0x2A7F: "Aerobic Threshold",
    0x2A80: "Age",
    0x2A5A: "Aggregate",
    0x2A43: "Alert Category ID",
    0x2A42: "Alert Category ID Bit Mask",
    0x2A06: "Alert Level",
    0x2A44: "Alert Notification Control Point",
    0x2A3F: "Alert Status",
    0x2AB3: "Altitude",
    0x2A81: "Anaerobic Heart Rate Lower Limit",
    0x2A82: "Anaerobic Heart Rate Upper Limit",
    0x2A83: "Anaerobic Threshold",
    0x2A58: "Analog",
    0x2A59: "Analog Output",
    0x2A73: "Apparent Wind Direction",
    0x2A72: "Apparent Wind Speed",
    0x2A01: "Appearance",
    0x2AA3: "Barometric Pressure Trend",
    0x2A19: "Battery Level",
    0x2A1B: "Battery Level State",
    0x2A1A: "Battery Power State",
    0x2A49: "Blood Pressure Feature",
    0x2A35: "Blood Pressure Measurement",
    0x2A9B: "Body Composition Feature",
    0x2A9C: "Body Composition Measurement",
    0x2A38: "Body Sensor Location",
    0x2AA4: "Bond Management Control Point",
    0x2AA5: "Bond Management Features",
    0x2A22: "Boot Keyboard Input Report",
    0x2A32: "Boot Keyboard Output Report",
    0x2A33: "Boot Mouse Input Report",
    0x2AA6: "Central Address Resolution",
    0x2AA8: "CGM Feature",
    0x2AA7: "CGM Measurement",
    0x2AAB: "CGM Session Run Time",
    0x2AAA: "CGM Session Start Time",
    0x2AAC: "CGM Specific Ops Control Point",
    0x2AA9: "CGM Status",
    0x2ACE: "Cross Trainer Data",
    0x2A5C: "CSC Feature",
    0x2A5B: "CSC Measurement",
    0x2A2B: "Current Time",
    0x2A66: "Cycling Power Control Point",
    0x2A65: "Cycling Power Feature",
    0x2A63: "Cycling Power Measurement",
    0x2A64: "Cycling Power Vector",
    0x2A99: "Database Change Increment",
    0x2A85: "Date of Birth",
    0x2A86: "Date of Threshold Assessment",
    0x2A08: "Date Time",
    0x2A0A: "Day Date Time",
    0x2A09: "Day of Week",
    0x2A7D: "Descriptor Value Changed",
    0x2A00: "Device Name",
    0x2A7B: "Dew Point",
    0x2A56: "Digital",
    0x2A57: "Digital Output",
    0x2A0D: "DST Offset",
    0x2A6C: "Elevation",
    0x2A87: "Email Address",
    0x2A0B: "Exact Time 100",
    0x2A0C: "Exact Time 256",
    0x2A88: "Fat Burn Heart Rate Lower Limit",
    0x2A89: "Fat Burn Heart Rate Upper Limit",
    0x2A26: "Firmware Revision String",
    0x2A8A: "First Name",
    0x2AD9: "Fitness Machine Control Point",
    0x2ACC: "Fitness Machine Feature",
    0x2ADA: "Fitness Machine Status",
    0x2A8B: "Five Zone Heart Rate Limits",
    0x2AB2: "Floor Number",
    0x2A8C: "Gender",
    0x2A51: "Glucose Feature",
    0x2A18: "Glucose Measurement",
    0x2A34: "Glucose Measurement Context",
    0x2A74: "Gust Factor",
    0x2A27: "Hardware Revision String",
    0x2A39: "Heart Rate Control Point",
    0x2A8D: "Heart Rate Max",
    0x2A37: "Heart Rate Measurement",
    0x2A7A: "Heat Index",
    0x2A8E: "Height",
    0x2A4C: "HID Control Point",
    0x2A4A: "HID Information",
    0x2A8F: "Hip Circumference",
    0x2ABA: "HTTP Control Point",
    0x2AB9: "HTTP Entity Body",
    0x2AB7: "HTTP Headers",
    0x2AB8: "HTTP Status Code",
    0x2ABB: "HTTPS Security",
    0x2A6F: "Humidity",
    0x2A2A: "IEEE 11073-20601 Regulatory Certification Data List",
    0x2AD2: "Indoor Bike Data",
    0x2AAD: "Indoor Positioning Configuration",
    0x2A36: "Intermediate Cuff Pressure",
    0x2A1E: "Intermediate Temperature",
    0x2A77: "Irradiance",
    0x2AA2: "Language",
    0x2A90: "Last Name",
    0x2AAE: "Latitude",
    0x2A6B: "LN Control Point",
    0x2A6A: "LN Feature",
    0x2AB1: "Local East Coordinate",
    0x2AB0: "Local North Coordinate",
    0x2A0F: "Local Time Information",
    0x2A67: "Location and Speed Characteristic",
    0x2AB5: "Location Name",
    0x2AAF: "Longitude",
    0x2A2C: "Magnetic Declination",
    0x2AA0: "Magnetic Flux Density - 2D",
    0x2AA1: "Magnetic Flux Density - 3D",
    0x2A29: "Manufacturer Name String",
    0x2A91: "Maximum Recommended Heart Rate",
    0x2A21: "Measurement Interval",
    0x2A24: "Model Number String",
    0x2A68: "Navigation",
    0x2A3E: "Network Availability",
    0x2A46: "New Alert",
    0x2AC5: "Object Action Control Point",
    0x2AC8: "Object Changed",
    0x2AC1: "Object First-Created",
    0x2AC3: "Object ID",
    0x2AC2: "Object Last-Modified",
    0x2AC6: "Object List Control Point",
    0x2AC7: "Object List Filter",
    0x2ABE: "Object Name",
    0x2AC4: "Object Properties",
    0x2AC0: "Object Size",
    0x2ABF: "Object Type",
    0x2ABD: "OTS Feature",
    0x2A04: "Peripheral Preferred Connection Parameters",
    0x2A02: "Peripheral Privacy Flag",
    0x2A5F: "PLX Continuous Measurement Characteristic",
    0x2A60: "PLX Features",
    0x2A5E: "PLX Spot-Check Measurement",
    0x2A50: "PnP ID",
    0x2A75: "Pollen Concentration",
    0x2A2F: "Position 2D",
    0x2A30: "Position 3D",
    0x2A69: "Position Quality",
    0x2A6D: "Pressure",
    0x2A4E: "Protocol Mode",
    0x2A62: "Pulse Oximetry Control Point",
    0x2A78: "Rainfall",
    0x2B1D: "RC Feature",
    0x2B1E: "RC Settings",
    0x2A03: "Reconnection Address",
    0x2B1F: "Reconnection Configuration Control Point",
    0x2A52: "Record Access Control Point",
    0x2A14: "Reference Time Information",
    0x2A3A: "Removable",
    0x2A4D: "Report",
    0x2A4B: "Report Map",
    0x2AC9: "Resolvable Private Address Only",
    0x2A92: "Resting Heart Rate",
    0x2A40: "Ringer Control point",
    0x2A41: "Ringer Setting",
    0x2AD1: "Rower Data",
    0x2A54: "RSC Feature",
    0x2A53: "RSC Measurement",
    0x2A55: "SC Control Point",
    0x2A4F: "Scan Interval Window",
    0x2A31: "Scan Refresh",
    0x2A3C: "Scientific Temperature Celsius",
    0x2A10: "Secondary Time Zone",
    0x2A5D: "Sensor Location",
    0x2A25: "Serial Number String",
    0x2A05: "Service Changed",
    0x2A3B: "Service Required",
    0x2A28: "Software Revision String",
    0x2A93: "Sport Type for Aerobic and Anaerobic Thresholds",
    0x2AD0: "Stair Climber Data",
    0x2ACF: "Step Climber Data",
    0x2A3D: "String",
    0x2AD7: "Supported Heart Rate Range",
    0x2AD5: "Supported Inclination Range",
    0x2A47: "Supported New Alert Category",
    0x2AD8: "Supported Power Range",
    0x2AD6: "Supported Resistance Level Range",
    0x2AD4: "Supported Speed Range",
    0x2A48: "Supported Unread Alert Category",
    0x2A23: "System ID",
    0x2ABC: "TDS Control Point",
    0x2A6E: "Temperature",
    0x2A1F: "Temperature Celsius",
    0x2A20: "Temperature Fahrenheit",
    0x2A1C: "Temperature Measurement",
    0x2A1D: "Temperature Type",
    0x2A94: "Three Zone Heart Rate Limits",
    0x2A12: "Time Accuracy",
    0x2A15: "Time Broadcast",
    0x2A13: "Time Source",
    0x2A16: "Time Update Control Point",
    0x2A17: "Time Update State",
    0x2A11: "Time with DST",
    0x2A0E: "Time Zone",
    0x2AD3: "Training Status",
    0x2ACD: "Treadmill Data",
    0x2A71: "True Wind Direction",
    0x2A70: "True Wind Speed",
    0x2A95: "Two Zone Heart Rate Limit",
    0x2A07: "Tx Power Level",
    0x2AB4: "Uncertainty",
    0x2A45: "Unread Alert Status",
    0x2AB6: "URI",
    0x2A9F: "User Control Point",
    0x2A9A: "User Index",
    0x2A76: "UV Index",
    0x2A96: "VO2 Max",
    0x2A97: "Waist Circumference",
    0x2A98: "Weight",
    0x2A9D: "Weight Measurement",
    0x2A9E: "Weight Scale Feature",
    0x2A79: "Wind Chill",
  };

以下のところでは、M5StickCのクラスを使っています。

// obniz = new Obniz(this.obniz_id);
obniz = new M5StickC(this.obniz_id);

これまでどおり、Obnizを使ってもよいですが、obniz.led.on()/obniz.led.off() を使っています。M5StickCの内蔵LEDを使う場合は、new M5StickCの方を使ってください。

使い方

使い方は簡単です。
「obniz_id」のところに、お手持ちのobnizデバイスのobniz_idを入力して、「Connect」ボタンを押下します。
接続が完了すると、obnizデバイスに書き込んであるファームウェアのバージョンが表示されます。
そうすると、自動的にobnizデバイスの周りにあるBLEデバイスを探索し始めます。
見つかったデバイスは、「Devices」のところに追加されていきます。
Deviceを選択すると、Detailのところに、アドレスやらAdvertiseDataやらScanResponse等々が表示されます。

次に、接続したいBLEデバイスを「Devices」から選択し、Connectボタンを押下します。
そうすると、「Services」のところに、PrimaryServiceの一覧が表示されます。

さらに、「Services」の中のPrimaryServiceを選択すると、それに属するCharacteristicが「Charactersitics」に表示されます。

Characteristicを選択すると、そのCharacteristicにReadし、その値がDetailのところに表示されます。書き込みをしたい場合には、Detailのテキストボックスに16進数文字列で入力して「Write」ボタンを押下します。

補足

以下のページにあるWebアプリが、M5StickCで動かないのは、使っているobniz.jsのバージョンが古いだけなので、単にそこを直せば動くようです。

BLEセントラルコンソール
 https://obniz.io/ja/webapp/7

以下の部分です。

<script src="https://unpkg.com/obniz@2.3.0/obniz.js" crossorigin="anonymous"></script>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js + TypeScript で親子コンポーネントのデータの受け渡し

SPA ログイン画面の例です。
記法は vue-property-decorator、CSS は bulma を使っています。
似たようなソースは共通のコンポーネントにしたい! 
コード中にコメントしてみます。

親コンポーネント (login.vue)

pages/login.vue
<template>
  <div>
    <!-- Email 入力 (自作の子コンポーネント) -->
    <!-- v-model="users.email"で親の users.email を双方向に書き換えられるようにしています。 -->
    <!-- その他の name= や type= (任意の名前) はカスタム属性です。親 -> 子へデータを渡します。-->
    <!-- 例えば name="email" だと、子に @Prop で name という箱を用意してやると "email" という文字列を受け取れます。 -->
    <base-input
      v-model="users.email"
      name="email"
    />
    <!-- パスワード入力 (自作の子コンポーネント) -->
    <base-input
      v-model="users.password"
      name="password"
      type="password"
    />
    <!-- ログインボタン (自作の子コンポーネント) -->
    <!-- 子の @Emit で clicked (任意の名前) を発火し、さらに private login() を発火させます。発火リレー。 -->
    <base-button
      @clicked="login"
      label="login"
      view="primary"
      validation="true"
    />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import LoginInterface from '@/interfaces/LoginInterface';

@Component
export default class Login extends Vue {
  private users: LoginInterface = { email: '', password: '' };

  // base-button の @clicked から発火されます。
  private login(): void {
    this.$stores.auth.create(this.users);
  }
}
</script>

ちなみにインターフェイスはこんな感じです。

LoginInterface.ts
export default interface LoginInterface {
  email: string;
  password: string;
}

子コンポーネント (BaseInput.vue)

components/atoms/BaseInput.vue
<template>
  <div class="field">
    <div class="control">
      <!-- ポイントは v-model:value="inputValue" -->
      <!-- private get inputValue() を通して親の値をもらい、private set inputValue(value: string) で親に値を送ります。 -->
      <!-- :placeholder はプレースホルダー名を vue-i18n で言語ファイルから取り出せるようにしています。 -->
      <!-- v-validate は plugin で定数ファイルからバリデーションルールを取り出せるようにしています。 -->
      <input
        :class="inputClass"
        :placeholder="$t(`label.${name}`)"
        v-validate="this.$config.validators[name]"
        :data-vv-as="$t(`label.${name}`)"
        :name="name"
        v-model:value="inputValue"
        :type="type"
        autofocus=""
      >
      {{ errors.first(name) }}
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';

@Component
export default class BaseInput extends Vue {
  // @Prop は、先ほど親でカスタム属性に指定した変数名にします。
  @Prop({ default: '' })
  private name!: string;
  @Prop({ default: '' })
  private value!: string;
  @Prop({ default: 'text' })
  private type!: string;

  // ゲッター (computed)
  private get inputValue(): string {
    return this.value;
  }

  // セッター
  private set inputValue(value: string) {
    // デコレーターの @Emit を使用する方法もあるのですが冗長なのでこちらで。
    this.$emit('input', value);
  }

  // class は親からカスタム属性で設定できるようにゲッターで管理してみました。
  // https://jp.vuejs.org/v2/guide/class-and-style.html#HTML-%E3%82%AF%E3%83%A9%E3%82%B9%E3%81%AE%E3%83%90%E3%82%A4%E3%83%B3%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0
  private get inputClass(): object {
    return {
      'input': true,
      'is-large': true,
    };
  }
}
</script>

子コンポーネント (BaseButton.vue)

components/atoms/BaseButton.vue
<template>
  <div class="field">
    <!-- ポイントは @click="clicked" -->
    <!-- @Emit() private clicked() を通じて親の clicked() をコールします。 -->
    <button
      :class="buttonClass"
      @click="clicked"
      :disabled="validation && disabled"
      :name="label"
    >
      {{ $t(`label.${label}`) }}
    </button>
  </div>
</template>

<script lang="ts">
import { Component, Emit, Prop, Vue } from 'vue-property-decorator';

@Component
export default class BaseButton extends Vue {
  @Prop({ default: '' })
  private label!: string;
  @Prop({ default: '' })
  private view!: string;
  @Prop({ default: false })
  private validation!: boolean;

  // @Emit('clicked') と同義です。親と同じ function 名の場合は @Emit() で省略可能です。
  @Emit()
  private clicked(): void {}

  private get disabled(): boolean {
    // vee-validate
    return this.$validator.errors.items.length > 0;
  }

  private get buttonClass(): object {
    return {
      'button': true,
      'is-block': true,
      'is-info': true,
      'is-large': this.view === 'primary',
      'is-fullwidth': true,
    };
  }
}
</script>

備考

以前投稿した「Vue.js で簡単なログイン画面 (トークン認証) を作ってみた」 を TypeScript で書き直したソースの抜粋です。

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

Nuxt.js TypeScript - 実践TypeScript アップデート -

今年6月に、実践TypeScript という書籍で、Nuxt.js に TypeScript を導入する方法を執筆させて頂きました。「書籍で扱っている Nuxt.js のバージョンが少し古いけど、今現在はどうするのが良いの?」という読者の方から寄せられた疑問に、アップデートとして私的見解をここにまとめました。(といっても、すでに日本語の良記事で紹介されている内容と変わりなく、n番煎じですmm)

typescript.nuxtjs.org

少し日が経ちましたが、Nuxt.js の 公式TypeScript導入ガイド が公開されています。このガイドは、最新の Nuxt 2.10 以降向けとして書かれています。更新が目的であれば、Migration from Nuxt 2.8 を必ず確認するようにしましょう。

custom server framework で異なる型定義

基本的に 公式TypeScript導入ガイド に準拠します。しかしながらここでは、「custom server framework を選択していない・選択している」という観点が考慮されていません。書籍で Nuxt.js の型定義に触れているところは、次の2節です。この二つは「custom server framework を選択していない・選択している」という、大きな違いがあります。

  • 11章1節「TypeScript ではじめる Nuxt.js」
  • 11章3節「Nuxt.js と Express」

Nuxt.js のアップデートに伴い、前者に大きな変化がありました。読み進めるにあたり、どちらのユースケースを想定しているのか、まず確認してください。本記事は主に、執筆時点で v2.7.1 であった前者に対し言及しています。

Introduction

Nuxt.js 関連の型定義は次の3つのパッケージに分割・外部化され、Nuxt 2.9以降のコアから削除されました。

  • @nuxt/types
  • @nuxt/typescript-build
  • @nuxt/typescript-runtime

必須になるのは@nuxt/typescript-buildです。@nuxt/typescript-runtimeは後述する nuxt-ts の利用有無に伴い、必要に応じてインストールすることになります。@nuxt/typesはどちらにも含まれるため、直接のインストールは不要です。

Setup

基本的に必要になることは書いてあるとおりです。順番に書いてある通りで問題なく設定できるかと思います。

  • Installation:@nuxt/typescript-buildのインストール
  • Configuration:nuxt.config.jsに追記。tsconfig.jsonを作成。

Runtime (optional)

@nuxt/typescript-runtimets-nodeを内包しており、ラッパーであるnuxt-tsを提供しています。そのためこのパッケージは、dependencies に追加する様に注意しましょう。これを利用すると、設定の煩雑さが軽減され、以降の migration コスト軽減が期待できます。

custom server framework を選択していない場合、ビルトインサーバーが利用されます。modulesserverMiddlewaresの型定義はModules (Runtime)Server Middlewares (Runtime)のように、内包されている@nuxt/typesから提供される型で事足ります。

nuxt-ts 使う?使わない?

custom server framework を選択していないのであれば、@nuxt/typescript-runtimeが提供するnuxt-tsは利用した方が手早く環境構築できると思います。書籍の11章1節「TypeScript ではじめる Nuxt.js」に書かれている環境構築方法は、nuxt-tsを利用する方法に移行することをお薦めします。

Components

Component の Props 型安全を実現するため、書籍ではPropType型を利用する型情報付与を紹介しています。公式ガイドの様に、PropOptions型でアサーションを付与する方法には触れていませんが、こちらの様に記述することも可能です。ユースケースに併せて選択してください。

export default Vue.extend({
 props: {
    user: {
      type: Object,
      required: true
    } as PropOptions<User>
  }
})

Store

Store に関しては「Vuex をそもそも利用しない」という選択もありますが、必要なシーンが出た場合には、参考にしてみてください。

Class-based

vuex-module-decoratorsはドキュメントの WARNING にあるとおり、セキュリティ問題が含まれているそうです。安全に扱うためには選択しないほうが現状ベターでしょう。vuex-class-componentは、デコレータでモジュールを定義するアプローチです。Nuxtとの完全な互換性はまだありませんが、新しいAPIをリリースしたそうです。

Vanilla

通常の JavaScript で記述する様な Vuex Store 定義に対し、型定義を付与する方法です。書籍の11章2節で紹介している通り、より型安全にするための余地があります。vuex-guardian は Vanilla での記述をサポートする型生成ツールで、こちらを利用すると、書籍に記されているよりも簡単に型情報の付与を行うことができます。

Configuration

nuxt.config.ts 向けの型定義です。nuxt.config を TS で書く場合に利用します。

nuxt.config.ts
import { Configuration } from '@nuxt/types'
const config: Configuration = {
  mode: 'universal',
  head: {}
  ...
}
export default config

Middlewares

custom server framework でExpressなどのフレームワークを選択している場合、11章3節で紹介している様に、手詰まりになるケースが想定されます。@nuxt/types/app/index.d.ts を拡張するなど、型定義強化の余地があります。

ServerMiddleware

custom server framework でExpressなどのフレームワークを選択している場合、@nuxt/types で提供されている ServerMiddleware型は利用せず Express など外部モジュールから import した型を利用した方が良さそうです。Express サーバーの RouteHandler 相当をここに記述することになるためです。

免責事項

Lint、Plugin や Modules など、書籍で触れられなかった項目についても typescript.nuxtjs.org では言及されています。これらは 書籍内容更新という観点からはそれるため割愛させていただいています。ご了承ください。

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

Nuxt.jsで学ぶ、Vue.jsコンポーネント設計の基本

はじめに

Nuxt.js(以下Nuxt)は、Vue.js(以下Vue)をサーバーサイドで動かす目的以外にも、ディレクトリ構造やVueエコシステムのライブラリがセットになっているため、設計の工数を削減する目的で採用するケースもあると思います!

お中元に迷ったときのヨックモックみたいですね?

本記事では、Nuxtのディレクトリ構造を土台とし、Vueのコンポーネント設計について、各レイヤー(ディレクトリ)のコンポーネントが担うべき責務をまとめました。

Vue3.0がやってくると、Composition API 導入に伴い設計のベストプラクティスが変化すると思いますが、Vue2系を触ってきた個人的な総括の気持ちで書いています。
※SSR(BFF)とライフサイクルをテーマに、もう一つ記事を書くつもりでいます。

Vueコンポーネントの基本とNuxtでの例

Vueコンポーネントは下記図のように、ツリー状にネストし構築されていきます。
components.png
※Vue.js公式サイト コンポーネントによる構成より

DOMもツリーなので、Webエンジニアには馴染みやすい概念ですよね。

Nuxtでは下記図のように、Layout > Page > Optional Component というツリー構造でVueコンポーネントがネストされていきます。
※参考: Nuxt公式サイトのビュー概要図

Vueコンポーネントの責務について

NuxtではasyncDataメソッドをVueコンポーネントに実装可能ですが、このメソッドはPageコンポーネント以外で利用することはできません

Nuxtを使った人は、一度はasyncDataメソッドをPage以外の場所で使おうとしたのではないかと思いますが、なぜこのような作りになっているのでしょう。

asyncDataのようにコンポーネント毎(ツリーの階層ごと)で可能な処理が異なる = 責務が明確化されている ことで、外部のAPIやStore(VueのデファクトだとVuex)に依存している箇所が明確になる、テスタビリティが向上する、メンテしやすいCSS設計へ貢献するといった効果を見込めます。

そんなNuxtですが、Componentsディレクトリ以下ではコンポーネント設計の指針を設定していません。

すべてのロジックをPageとStoreに詰め込むと、Storeとのやり取りを行うPageコンポーネントが肥大化しやすいため、次章ではStoreのやり取りをComponents以下に持たせる設計の定番を紹介します。

Presentational and Container パターン

Reactで提唱されたこちらの名作記事とともに、モダンフロントエンドのコンポーネント分割における最も有名なパターンだと思います。

Redux(Fluxを基にした状態管理ライブラリ)公式サイトでは次のように言及されています。

Presentational Container
見た目に関すること 動作に関すること
Storeとの疎通禁止 Storeと疎通する

VueではStoreとしてVuexを採用するケースが多い(Nuxtでは標準)と思いますが、コンポーネント分割はReactなどと同様のルールが採用可能です。

コンポーネントをネストする規則

原典ではコンポーネントをネストする際のルールはかなり自由です。
Containerの中にContainerもPresentationalも入れてOKですし、反対にPresentationalの中にContainerもPresentationalも入れてOKとされています。
しかし実際の運用では、PresentationalはContainerを読み込めない、といったルールを設けることをオススメします。

アメブロでの実装例では、Atomic DesignのOrganismsをContainerとし、MoleculesとAtomsをPresentationalとして設定することで役割をより明確化しています。

※ Atomic Design について
Atomic Design の運用は、通常フロントエンドエンジニアだけでは実現できません。デザイナーの協力が不可欠で、チームごとに適用すべきかどうか異なると思います。

Nuxt設計例

以上の話をふまえ、Nuxtアプリケーションの設計の具体例を紹介します。

ディレクトリ構成

├── components
│   ├── container // pageごとにcontainerを管理する。このレイヤーではComponentの使いまわしを意識しない
│   │   ├── page1
│   │   ├── page2
│   │   └── shared // Containerを複数のページでimportする場合に用いる
│   └── presentational // Atomic designでの一例。他にはbuttonなどのパーツでディレクトリ切るのも?‍♀️
│       ├── molecules
│       └── atoms
├── pages
│   ├── page1
│   └── page2
│       ├── edit.vue
│       └── index.vue
├── layouts
│   ├── default.vue
│   └── error.vue
└── store
    ├── store1.ts
    └── index.ts

役割早見表

Storeとの疎通 外部APIとの通信 テストコード Style Props/Slot import可能
Container × なるべく書かない Presentational配置のみ Pageに依存する場合のみ Store, Presentational
Presentational × × なるべく書かない (molecules)Presentational, (atoms)×
Page なるべく書かない ContainerとPresentational配置のみ × Store, Container, Presentational
Layout × × × PageとContainerとPresentational配置のみ × Page, Container, Presentational
Store - - - ×

※Componentのテストは難しいので(特にメンテナンス…)、アプリケーションにとって重要なロジックは、テスタビリティを保ちやすいStoreに寄せていくのを強くオススメします。

StateをPageに持たせるかStoreに持たせるか

NuxtはStoreのAction以外でも、asyncDataのようにComponentから外部APIと通信することを想定されたメソッドが存在しています。

Vue.jsはComponent内にローカルStateを持つことが簡単で、Vuexを用いたFluxパターンとv-modelに代表される双方向データバインディングを共存させることが出来ます。

個人的にはどちらを選ぶかというよりも、アプリケーションにとって重要なStateであるならばStoreに持たせて、そうでないならComponentのローカルStateとして取り扱うといった使い分けが良いと考えています。

※テスタビリティまで考えるとすべてのロジックをStoreに寄せていった方がメンテしやすいです。一方そのようにしてFluxに準拠するならば、Reactを採用すべきでVueを採用するメリットが薄くなります。
TypeScriptとの相性であったりフロントエンドのテスタビリティを含め、より安全なアプリケーションが求められる環境では、Reactの方が適正が高い(結果として工数が少なくなる)と最近は感じます。そのためVue3.0は期待大?‍♀️

おまけ: サンプルコード

コードを読んだ方が理解しやすい方向け。
7ケタの郵便番号を入力すると、次のページへ進めるボタンが押せるページの部分的なサンプルコードを記載しておきます。

ポイント
・StoreにcommitしてるのはContainerのみ。Presentationalは必ずPropsで受け取る。
・PageはStoreと疎通しているがgetterで参照してるだけ。この設計はテストしやすくオススメ。
・型定義などは@typesに記述するか、Storeに寄せていきそこからimportして使うことをオススメ。
・今回解説を省略してますが、小さなatomsはSassのmixinsを用いて作るとコードが少なくなり楽です。

~/pages/inquiry/edit.vue
<template>
  <main class="edit">
    <TitleIcon class="titleIcon" title="お問い合わせ" icon="inquiry" />
    <UserInput class="buttonLink" />
    <ButtonLink
      class="buttonLink"
      link="/inquiry/confirm"
      text="確認へ進む"
      :disabled="$store.getters['user/isCompleteInput']"
    />
  </main>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'

import UserInput from '~/components/container/inquiry/UserInput.vue'
import TitleIcon from '~/components/presentational/atoms/TitleIcon.vue'
import ButtonLink from '~/components/presentational/atoms/ButtonLink.vue'

@Component({
  components: {
    UserInput, TitleIcon, ButtonLink
  }
})
export default class InquiryEdit extends Vue {}
</script>

<style lang="scss" scoped>
.titleIcon {
  margin-bottom: 40px;

  @include isPc() {
    margin-bottom: 60px;
  }
}

.buttonLink {
  margin-bottom: 80px;

  @include isPc() {
    margin-bottom: 120px;
  }
}
</style>
~/components/container/inquiry/UserInput.vue
<template>
  <div>
    <h2 class="heading">郵便番号</h2>
    <ValidateNumberInput
      :value="postalCode"
      :digit="7"
      placeholder-text="7ケタの郵便番号を入力してください"
      @input="inputCode"
    />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'

import ValidateNumberInput from '~/components/presentational/molecules/ValidateNumberInput.vue'

// https://github.com/championswimmer/vuex-module-decorators#accessing-modules-with-nuxtjs
import { userStore } from '~/store'

@Component({
  components: {
    ValidateNumberInput
  }
})
export default class UserInput extends Vue {
  public postalCode: string = userStore.postalCode

  public inputCode(v: string, err: string): void {
    this.postalCode = v
    if (err) {
      userStore.setPostalCode('')
      return
    }

    userStore.setPostalCode(v)
  }
}
</script>

<style lang="scss" scoped>
.heading {
  @include heading()
}
</style>
~/components/presentational/molecules/ValidateNumberInput.vue
<template>
  <div>
    <NumberInput
      v-model="number"
      class="numberInput"
      :icon="icon"
      :is-error="error.length > 0"
      :placeholder-text="placeholder"
    />
    <ErrorMessage :text="error" />
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'nuxt-property-decorator'

import NumberInput from '~/components/presentational/atoms/NumberInput.vue'
import ErrorMessage from '~/components/presentational/atoms/ErrorMessage.vue'

@Component({
  components: {
    NumberInput,
    ErrorMessage
  }
})
export default class ValidateNumberInput extends Vue {
  @Prop() digit!: number
  @Prop() value!: string
  @Prop() placeholder!: string

  public error: string = ''

  private validateNumber(v: string | null): boolean {
    if (v === null) return false

    const regexp = new RegExp(`^[0-9]{${this.digit}}$`)
    return regexp.test(v)
  }

  get number(): string {
    return this.value
  }

  set number(v: string): void {
    this.error = ''
    if (!this.validateNumber(v)) {
      this.error = this.errorMessage
    }
    this.$emit('input', v, this.error)
  }

  get errorMessage(): string {
    return `${this.digit}桁の数字を入力してください。`
  }

  get icon(): string | null {
    if (!this.number) return null
    if (this.error !== '') {
      return 'errorIcon'
    }

    return 'okIcon'
  }
}
</script>

<style lang="scss" scoped>
.numberInput {
  margin-bottom: 8px;
}
</style>
~/components/presentational/atoms/ErrorMessage.vue
<template>
  <transition name="fade">
    <p v-show="text" class="errorMessage">{{ text }}</p>
  </transition>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'nuxt-property-decorator'

@Component
export default class ErrorMessage extends Vue {
  @Prop() text!: string
}
</script>

<style lang="scss" scoped>
.errorMessage {
  color: $error;
}

.fade-enter-active {
  transition: opacity 0.3s;
}

.fade-enter {
  opacity: 0;
}
</style>
~/store/user.ts
import { Module, VuexModule, Mutation } from 'vuex-module-decorators'

export interface User {
  postalCode: string
}

@Module({ stateFactory: true, name: 'user', namespaced: true })
export default class extends VuexModule {
  public postalCode: User['postalCode'] = ''

  @Mutation
  public setPostalCode(code: string): void {
    this.postalCode = code
  }

  public get isCompleteInput(): boolean {
    return this.postalCode !== ''
  }
}

※動作確認してないので、間違った部分あったらごめんなさい?

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

Vue router でのルーティングが NavigationDuplicated で動作しなときは

Vue/Javascript 入門1週間程度なのでよくわかっていないですが、以下のような書き方がサンプルコードとしてころがっています。(コピペコーダー)

that.$router.replace("hoge");

それに無名関数を2つつけてください(何故直るかは知りません)
vue router の定義がこっそり変わったのかもしれません。

that.$router.replace("hoge", () => {}, () => {});

1日ぐらいむだにしました

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

LIFF v2 で追加されたAPIを試してみる

先日、LINE アプリ上でWeb アプリケーションを動かせるLIFF (LINE Front-end Framework) のv2 がリリースされました。

v2 では、LINE アプリ以外のブラウザでもLIFF SDK だけでLINE Login でき、LIFF API も一部使用できる 外部ブラウザ対応 が追加され、LINE アプリ外でもLIFF アプリとして動かすことができるようになったことで、ますます色んな場面でLIFF アプリが利用できるようになりました。
また、LIFF API としても、LIFF アプリからQR コードリーダーを呼び出してスキャンできたり、ユーザーのメールアドレスを取得できたり、LINE アプリ内か外部ブラウザ化を判別できたりと、大幅に機能アップしています。
LIFF v2 の詳細は、こちらをご覧ください。

今回は、LIFF v2 で利用できる機能を試すために簡単なLIFF アプリを作ってみました。

デモ動画

なお、下のボタンを押してLINE で友だち登録していただければ、実際に試していただけます。

友だち追加

また、外部ブラウザからは、こちら にアクセスして試していただけます。
なお、外部ブラウザではLIFF の仕様で以下の機能は使えません。以下の機能を試したい方はLINE で友だち登録してお試しください。

  • QR コードスキャン
  • LINE アプリへのメッセージ送信

下の画像が外部ブラウザでサンプルアプリにアクセスした際のものです。

LIFFV2Sample.png

外部ブラウザで動いているかどうかを判別できますので、上記の外部ブラウザでは使用で出来ない機能のボタンを押せないように制御することも簡単です。

LIFF アプリの構成

今回作ったLIFF アプリは以下を利用して開発しています。
すべてJavaScript でコーディングできます。

  • LIFF SDK
    • v2.1
    • LIFF アプリ開発用のSDK
  • node.js
    • v10.16.0
  • npm
    • v6.9.0
  • Vue.js
    • v2.6.10
    • Web アプリケーションのUI フレームワーク
  • Vuetify.js
    • v2.1.5
    • Vue.js 用のマテリアルデザインコンポーネントフレームワーク

実行環境

ローカル開発環境ではトンネリングツールの ngrok を利用しましたが、運用ではNetlify を利用しています。

Netlify は静的Web サイトのホスティングなどができるサービスです。
Github やBitbucket などのリポジトリにPush したVue.js などのソースコードをビルドしてSPA として展開してくれる機能もあります。
今回は、Github のリポジトリ にVue.js のソースコードをローカルリポジトリからPush し、そのタイミングでNetlify にてビルドが実行され、ビルドされたモジュールがデプロイされ、運用環境として公開されるように設定しました。

ソースコード

今回作ったサンプルアプリのソースコードは、Github のリポジトリ で公開しています。

サンプルアプリの解説

サンプルアプリ本体はApp.vue です。

App.vue(23行目付近)
        <!-- Component -->
          <LiffV2Login v-if="loggedIn === false" />
          <LiffV2Function v-else />

また、ユーザーがLINE アプリのブラウザでアクセスしている場合は、自動でLINE Login に認証されるため「SIGN OUT」ボタンは非表示にしています。
このように、ユーザーのアクセスがLINE アプリのブラウザからか、外部ブラウザからかを簡単に判別できるので、LINE アプリ用、外部ブラウザ用で分けて開発する必要がないのもありがたいですね。

App.vue(9行目付近)
      <v-btn large color="error" @click="doLogout" v-show="loggedIn === true && inClient === false">Sign out</v-btn>

ユーザーログイン状態に応じて表示するコンポーネントを切り替える

ユーザーのログイン状態に応じて、2つあるコンポーネントを切り替えています。
未ログインの場合はログインをボタンを配置したコンポーネントを、ログイン状態時にはLIFF 機能を実行するボタンを配置したコンポーネントを表示します。

LiffV2Login コンポーネント

ログインボタンを配置しているだけのコンポーネントです。
LINE Login への認証も liff.login() だけで、とても簡単に認証を利用できます。

LiffV2Login.vue(24行目付近)
  methods: {
    doLogin: function () {
      liff.login()
    }
  }
LiffV2Function コンポーネント

LIFF v2 の機能を試すボタンを配置したコンポーネントです。

ログイン状態で利用できる機能のボタンはログイン状態だけを確認しています。

LiffV2Function.vue(20行目付近)
        <!-- getAccessToekn -->
        <div class="ma-4">
          <v-btn
            rounded
            class="custom-transform-class text-none"
            color="primary"
            @click="getAccessToken"
            :disabled="loggedIn === false"
          >getAccessToekn</v-btn>
        </div>

ログイン状態かつ、LINE アプリ無いブラウザで利用できる機能のボタンは両方の状態を確認してボタンを有効にするかどうかを判別しています。

LiffV2Function.vue(72行目付近)
        <!-- sendMessages -->
        <div class="ma-4">
          <v-btn
            rounded 
            class="custom-transform-class text-none"
            color="success"
            @click="sendMessages"
            :disabled="loggedIn === false || inClient === false"
          >sendMessage</v-btn>
        </div>

LIFF API を実行するコードもとてもシンプルで、簡単にLIFF 機能を利用できます。

LiffV2Function.vue(138行目付近)
    getAccessToken: function() {
      this.accessToken = liff.getAccessToken()
      const text = `Your Access Token is [${this.accessToken}]`
      console.log(text)
      this.showSnackbar(text)
    },
LiffV2Function.vue(162行目付近)
    sendMessages: function() {
      const text = `I am ${this.name} !!`
      const msg = `Message is [${text}]`
      console.log(msg)
      this.showSnackbar(msg)
      liff
        .sendMessages([
          {
            type: "text",
            text: text
          }
        ])
        .then(() => {
          console.log("message sent")
        })
        .catch(err => {
          console.log("error", err)
        })
    },

LIFF ID の作成

LIFF アプリを利用するには、LIFF ID を取得する必要があります。
LIFF ID 取得や設定方法については、@h-takauma さんの記事「LIFF v2でLINEログインを試してみた」 に詳しく書いてありますので、そちらを参考にしてください。

いろいろ試してみたい方へ

自分でいろいろと試してみたい、という方には、@n0bisuke さんが、LIFF v2 のハンズオン資料 を公開されていますので、こちらも参考にしてください。

最後に

LIFF はv2 になって新機能の追加はもちろんですが、外部ブラウザでも使えるようになったことがとても大きいですね。
外部ブラウザに対応したことで、LINE アプリ外とLINE アプリを連携させる、という概念ができたことで、利用する場面が大きく広がりました。
LINE でアプリ開発 = チャットボット開発、というイメージの方も多いと思いますが、LIFF v2 のリリースでそんな垣根は取っ払われたと思います。
多くの方が普段から使っているLINE という特性を活用したWeb アプリを開発できますので、これまで触れていなかった方々も、これを機会に開発してみてください。

また、開発コンテンストのヒーローズ・リーグ 2019 (Mashup Awards から名称変更) では、LINE リーグ も開催されます。
LIFF を活用した作品を開発し、こちらに応募して賞獲得を目指してみてはいかがでしょうか。

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

Capacitor を 試してみる(router & ionicなし)

次に請けるプロジェクトでCapacitorを使うそうなので試してみる。

Capacitor を 試してみる(router & ionicあり)


ベースプロジェクトを作る

Vueプロジェクトをベースにしてみます。

Vue CLI を入れる

yarn cache clean; yarn global add @vue/cli

Vue プロジェクト生成&プロジェクトへ移動

# default (babel, eslint) を選択
vue create test
cd test

Capacitor

インストール

yarn add @capacitor/cli @capacitor/core

初期化

npx cap init test com.xxxxx.test

webDir 変更

capacitor.config.json
{
  "appId": "com.xxxxx.test",
  "appName": "test",
  "bundledWebRuntime": false,
  "npmClient": "yarn",
- "webDir": "www"
+ "webDir": "dist"
}

add 前に build して webDir(dist) を生成

yarn run build

Android / iOS 追加

npx cap add android; npx cap add ios

変更を加える

src/components/HelloWorld.vue
<template>
  <div class="hello">
+   <button @click="this.toast">toast</button>
    <h1>{{ msg }}</h1>
    <p>
      For a guide and recipes on how to configure / customize this project,<br>
      check out the
      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
    </p>
    <h3>Installed CLI Plugins</h3>
    <ul>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
    </ul>
    <h3>Essential Links</h3>
    <ul>
      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
    </ul>
    <h3>Ecosystem</h3>
    <ul>
      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
    </ul>
  </div>
</template>

<script>
+ import { Plugins } from "@capacitor/core";
+ const { Toast } = Plugins;

export default {
  name: 'HelloWorld',
  props: {
    msg: String
+ },
+ methods: {
+   async toast() {
+     await Toast.show({
+       text: "Toast"
+     });
+   }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

変更反映

yarn run build; npx cap copy

Android / iOS 開発環境を開く

npx cap open android
npx cap open ios

Android上で確認してみる

toastボタンを押下すると、Toastが表示される。

Screenshot_20191021-112823.jpg

Vue.js / Capacitor がそれぞれ正しく動作することを確認できた。

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

[Vue.js]業務でやらかしたことメモ

about

業務でやらかしたことの供養。
自責の念を込めて、繰り返さないようにメモです。
随時更新してきます(やらかしたくない)

目次

  • 型ミス

型ミス

以下のような記述をし、ビルドエラーを起こした。

 props: {
            dialogOtherAlertMessage: { type: String, default: false }
        },

何がだめって、typeはStringなのに、defaultがBoolean
正しくは以下でした。

 props: {
            dialogOtherAlertMessage: { type: String, default: () => "" }
        },

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

【Vue.js】YouTube Data APIをaxiosで取得し表示するサンプル(Firebase・Vue CLI v4.0.4)

環境メモ
⭐️Mac OS Mojave バージョン10.14
⭐️Firebase Hosting
⭐️Vue CLI v4.0.4

YouTube Data APIをaxiosで取得し表示するサンプル
Firebase HostingとVue CLI v4.0.4を使います。

↓↓実際に動かしてみた動画
https://twitter.com/nonnonkapibara/status/1185956946630692864

先に、YouTube APIを取得する。
詳細は下記に記載してます。

【Vue.js】Vue.jsで使う為のYouTube動画検索「YouTube Data API v3」のAPIキー取得
https://qiita.com/nonkapibara/items/591cdb2ab9aea7ea55b9

プロジェクトを作成する。詳細は、下記に記載しています。

【Vue.js】FirebaseプロジェクトでVue CLI v4.0.4を作成する(Firebase・Vue CLI v4.0.4)
https://qiita.com/nonkapibara/items/6146106c524b652f49db

ファイル構成

スクリーンショット 2019-10-21 0.51.26.png

①App.vue

TOPページの上のリンクに「Search Video」を追加する
スクリーンショット 2019-10-21 0.53.04.png

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/video">Search Video</router-link>
    </div>
    <router-view/>
  </div>
</template>

スクリーンショット 2019-10-21 0.56.14.png

②components - SearchVideo.vue

「YouTube Search list (Vue.js CLI)」のページ
スクリーンショット 2019-10-21 0.59.22.png

<template>
<div>
  <div><font size="6" color="#c71585">YouTube Search list (Vue.js CLI)</font></div>
  <br>
  <input size="40" v-model="keyword" placeholder="検索キーワードを入力">
  <button @click="search_video">検索</button>
  <table cellspacing="0" cellpadding="5" v-show="results">
    <tr>
      <th width="50">
        <font>No</font>
      </th>
      <th width="200">
        <font>Video</font>
      </th>
      <th width="700">
        <font>Contents</font>
      </th>
    </tr>

    <tr v-for="(movie, index) in results" v-bind:key="movie.id.videoId">
      <!-- No -->
      <td valign="top" width="50">{{ index + 1 }}</td>
      <!-- Video -->
      <td valign="top" width="300">
        <a v-bind:href="'https://www.youtube.com/watch?v=' + movie.id.videoId">
          <img width="300" height="200" v-bind:src="movie.snippet.thumbnails.medium.url">
        </a>
      </td>
     <!-- titleとdescription -->
      <td align="left" valign="top" width="700">
        <font size="5" color="#c71585"><b>{{ movie.snippet.title }}</b></font>
        <br>
        {{ movie.snippet.description}}</td>
    </tr>
  </table>
</div>
</template>

<script>
import axios from 'axios';

export default {
  name: "SearchVideo",
  data: function() {
    return {
      results: null,
      keyword: "nonkapibara",
      order: "viewCount", // リソースを再生回数の多い順に並べます。
      params: {
        q: "", // 検索クエリを指定します。
        part: "snippet",
        type: "video",
        maxResults: "20", // 最大検索数
        key: "★★★★★KEYをここに入れる★★★★★"
      }
    };
  },
  props: {
    msg: String
  },
  methods: {
    search_video: function() {
      this.params.q = this.keyword;
      var self = this;
      axios
        .get("https://www.googleapis.com/youtube/v3/search", {
          params: this.params
        })
        .then(function(res) {
          self.results = res.data.items;
        })
    }
  }
};
</script>

<style>

table {
  border-collapse: collapse;
  border: solid 2px #c71585;/*表全体を線で囲う*/
}
table th {
  color: #fff0f5;/*文字色*/
  background: #ff69b4;/*背景色*/
  border: dashed 1px #c71585;
}

table td {
  background: #fff0f5;
  border: dashed 1px #c71585;
}

</style>

③ router - index.js

  {
    path: '/video',
    name: 'video',
    component: () => import('../views/Video.vue')
  }

スクリーンショット 2019-10-21 1.04.40.png

④ views - Video.vue

スクリーンショット 2019-10-21 1.07.45.png

<template>
  <div class="app">
    <SearchVideo/>
  </div>
</template>

<script>
import SearchVideo from '@/components/SearchVideo.vue'

export default {
  name: 'app',
  components: {
    SearchVideo
  }
}
</script>

完成!!

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

【Vue.js】Vue.jsで使う為のYouTube動画検索「YouTube Data API v3」のAPIキー取得

環境メモ
⭐️Mac OS Mojave バージョン10.14
⭐️Firebase Hosting
⭐️Vue CLI v4.0.4

↓↓実際に動かしてみた動画
https://twitter.com/nonnonkapibara/status/1185956946630692864

YouTubeの動画検索を行うには、Googleアカウントで「YouTube Data API v3」のAPIキーを取得します。
Googleアカウントでログイン後、下記のAPIライブラリページを開きます
https://console.developers.google.com/apis/library

vue001.png

「YouTube Data API v3」を選択し、有効にします。
vue002.png

vue003.png

ライブラリの「作成」を選択する

vue004.png

新しいプロジェクトを作成する

プロジェクト名を入力して「作成」ボタンを押す

vue005.png

ダッシュボードが開く

ダッシュボードが開くので「認証情報を作成」ボタンを押す
https://console.developers.google.com/apis/dashboard
vue006.png

APIキーを選択する

vue007.png

APIキーができる。

キーを制限を選択する

vue008.png

APIの制限をかける

vue009.png

vue010.png

vue011.png

YuTubeのAPIの詳細は、下記に記載しています。

YouTubeData API
https://developers.google.com/youtube/v3/docs/search/list

スクリーンショット 2019-10-21 0.38.34.png

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