20191215のJavaScriptに関する記事は30件です。

ApolloClient(React)で、loadingやerror時の表示処理を共通化する。

元々はこちらで紹介されている方法です。
How to handle loading and error state in a generic way?

ローディング中にコンテンツを覆うモーダルウィンドウ。

const LoadingModal: React.FC = () => (
  <Dialog open>
    <ProgressImage />
  </Dialog>
);

エラーメッセージを表示するダイアログ。

import { ApolloError } from 'apollo-client';

interface ErrorModalProps {
  error: ApolloError;
  onClose(): void;
}

const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
  const [open, setOpen] = useState<boolean>(!!error);
  const handleClose = () => {
    setOpen(false);
    onClose();
  };

  return (
    <Dialog open onClose={handleClose}>
      {error.message}
      <Button onClick={handleClose}>
        close
      </Button>
    </Dialog>
  );
};

PropsでuseQueryORuseMutateのステータス値を受け取り、必要に応じてダイアログを表示するコンポーネント。

interface HandleQueryProps {
  loading: boolean;
  error?: ApolloError;
}

const HandleQuery: React.FC<HandleQueryProps> = ({
  loading,
  error,
  children,
}) => {
  const onError = ()=> {
    setOpen(false);
    // 必要に応じてリダイレクトなど
  };

  return (
    <>
      {children}
      {loading && <LoadingModal />}
      {error && <ErrorModal error={error} onClose={onError} />}
    </>
  );
};

HandleQueryコンポーネント利用例。

const SameComponent: React.FC = () => {
  const { data, loading, error } = useQuery(QUERY);

  return (
    <HandleQuery loading={loading} error={error}>
      <ChildComponent data={data} />
    </HandleQuery>
  );
}

参考情報

APOLLO DOCS > Client(React) > Error handling
React > Error Boundary

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

jQuery + Select2 : ドロップダウンリストの幅を調整する

Select2はjQueryのプラグインで、要素のドロップダウンリストにさまざまな表現や機能が加えられます(「jQuery: プラグインSelect2で要素のドロップダウンリストを操作する」参照)。ただ、デフォルトでは、やたらと幅の狭いドロップダウンリストになってしまうようです(図001)。CSSなどで、あらかじめ幅を定めておくのがよいでしょう。

図001■やたらと幅の狭いドロップダウンリスト

qiita_1912001_01.png

ドロップダウンの幅をリスト項目に合わせる

とはいえ、せっかくJavaScriptコードで扱うのですから、リスト項目に合わせて幅を動的に調整してはしい場合もあるでしょう。そのときには、select2()メソッドにつぎのようなふたつのオプションを渡してください。

$('#select2').select2({
    dropdownAutoWidth: true,
    width: 'auto'
})

これでドロップダウンは、リスト項目の長さに合わせた幅になります(図002)。以下のサンプル001をCodePenに公開しました。なお、コードの中身については、「jQuery: プラグインSelect2で要素のドロップダウンリストを操作する」をお読みください。

図002■リスト項目の幅に合ったドロップダウン

qiita_1912001_04.png

サンプル001■ jQuery + Select2 : Drop-down list with width adjusted

See the Pen jQuery + Select2 : Drop-down list with width adjusted by Fumio Nonaka (@FumioNonaka) on CodePen.

選択された項目に幅を合わせる ー width

widthオプションをautoに定めると、ドロップダウンリストが選択された項目の幅になります(図003)。

$('#select2').select2({
    // dropdownAutoWidth: true,
    width: 'auto'
})

図003■ドロップダウンリストが選択された項目の幅に合う

qiita_1912001_02.png

これだけでよさそうに思えたかもしれません。けれど、選択されていない項目に長いテキストがあったら、ドロップダウンに収まらず見切れてしまいます。このオプションは、あくまで選択された項目をしっかり見せるというだけです。

ドロップダウンのリスト幅を合わせる ー dropdownAutoWidth

選択された項目はさておき、ドロップダウンのリスト幅を調整するのがオプションdropdownAutoWidthです。値をtrueに定めれば、すべての項目が収まるようにリスト幅が決まります(図004)。

$('#select2').select2({
    dropdownAutoWidth: true,
    // width: 'auto'
})

図004■ドロップダウンのリスト幅を項目に合わせる

qiita_1912001_03.png

ということですので、ドロップダウンの幅を項目に合わせて動的に決めるには、ふたつのオプションが必要なのです。

CSSで幅が正しく設定できない

Select2サイトによると、CSSで幅が正しく設定できないことがあるようです。その場合には、インラインでstyle属性により幅を定めることが勧められています。そのときwidthオプションに与える値はresolveです。

$('#select2').select2({
    width: 'resolve'
})
<select id="demo" style="min-width: 300px"></select>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SlackのWebhookをプロキシする仕組みを作る

Slackはさまざまなカスタマイズ機能を持っているのが魅力のツールです。例えばBotを作ったり、カスタムのslash commandを作ったりすることで、プラットフォームの拡張ができます。

Slack Botの作り方はいくつかあるのですが、Slackのリッチな機能を最大限に引き出すには、SlackからのWebhookを受けることが必要になってきます。すなわち、ボタンなどが付いたリッチなメッセージの投稿は難しくないのですが、投稿したメッセージのボタンやメニュー操作は、SlackからWebhookの形で通知される仕組みになっています。

※ この辺りの仕組みの詳細については、まとまっている記事がいくつもあるので省略します。

Slackは当然publicなサービスなので、インターネット経由でWebhookが飛んできます。これを受けて処理するためには、Bot用の公開APIサーバを書くことになるでしょう。

公開サーバ? 何か問題でも?

それじゃあ、単にサーバを立てればいいじゃないかというのはもっともですが、サーバを公開するのはいくつか面倒な点があります。

  • 公開サーバはセキュリティリスクがある
    • もちろん、ちゃんと管理すればいいのですが、publicに露出するものが増えると相応にリスクが増加してしまうのはどうしようもないことです。できればやりたくありません
  • とりわけ会社業務で使う場合、イントラネットにある社内システムや社内ネットワークとの接続性を考慮する必要が出てくる
    • 当然、Botであるからには、社内のいろいろなものと連携させたくなります。

SlackのRTM APIというWebSocketベースのAPIでchatメッセージを受信する場合には、こういった悩みは発生しませんでした(メッセージをpullするAPIを使うので、基本的にインターネットへ接続できるBot実行環境がありさえすればよかった)。できる限りそのお手軽さを保ったまま、セキュリティなどの煩雑な問題をコントロールする方法がないか、ということを考えたくなります。

そうだプロキシしよう

というわけで、次のようなシステムを考えることにしました。

  • Webhookを直接Botサーバに流すのではなく、プロキシ(リバースプロキシ)を経由させる
  • プロキシは単にリクエストを横流しするのではなく、リクエストの検証を行い、不正なリクエストを弾く
    • Slackから事前に払い出された秘密鍵を元に、指定の手順でHMACを計算することで、リクエストの正当性の検証が可能です
    • Verifying requests from Slack
    • SDKがあるので、Slack社オフィシャルの実装をそのまま引っこ抜いてくることができます

具体的には、AWSのAPI GatewayとLambdaを組み合わせた、簡単なプロキシを書きます。

slack-to-intranet.png

パターンとして変則的ではありますが、結果的にAWSのDDoS対策ホワイトペーパーで述べられている、API Gatewayに公開箇所を絞るというプラクティスに沿っているように思います。

Typically, when you must expose an API to the public, there is a risk that the API frontend could be targeted by a DDoS attack. To help reduce the risk, you can use Amazon API Gateway as a “front door” to applications running on Amazon EC2, AWS Lambda, or elsewhere.
By using Amazon API Gateway, you don’t need your own servers for the API frontend and you can obfuscate other components of your application. By making it harder to detect your application’s components, you can help prevent those AWS resources from being targeted by a DDoS attack.

実装例

今回は(ちょっと慣れない言語なのですが)Node.jsでlambdaを書いてみることにします。
フレームワークとしてserverless frameworkを使うとお手軽にAPI Gateway + Lambdaの構成を作ることができました。

https://github.com/saka1/slack-webhook-gatekeeper

バックエンドのSlack App名に対応したURLパスに対してWebhookが飛んでくると、秘密鍵を使ってWebhookの検証をします。あらかじめ、Slack App名と秘密鍵の対応関係はparameter storeに入れておきます。正当なWebhookだった場合にはupstreamにリクエストを飛ばしてプロキシ動作をします。URLパスは複数登録することができるように作りました。

学んだこと

  • AWSのparameter storeはあんまり高速ではなさそう(getに数百msかかる?)だったので、lambdaでキャッシュしたほうが無難そうでした
  • プロキシとBotは1:Nの関係にあるため、プロキシシステムは全てのBot Appの秘密情報を管理しないといけなくなります(そうしなければリクエストの検証ができません)
    • 理想的ではないので、何かうまい回避方法があればなあと思っています

API Gatewayからlambdaを呼ぶには「lambda統合」「lambdaプロキシ統合」の2つがあるらしいのですが、前者を使ったほうがよさそうでした。というか飛んでくるWebhookの検証でHMAC計算が入るため、1バイトでもゴミが入ると正しく動きません。そもそも前者は用途に対して複雑すぎました。

まとめ

この記事では、Slack Botの前段に置くプロキシシステムを検討し、その実装までをやってみました。ざっくり机上で検討 → ざっと実装した割には案外使えそうなものが考案できて、個人的には満足しています。

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

年末まで毎日webサイトを作り続ける大学生 〜58日目 オブジェクト(プロトタイプベース)を使って飼育ゲームを作る〜

はじめに

こんにちは!@70days_jsです。

オブジェクト(プロトタイプベース)を使って飼育ゲームを作りました。
飼育するのは黒い玉です。(たまに希少種として色違いが生まれるよう設定)

58日目。(2019/12/15)
よろしくお願いします。

サイトURL

https://sin2cos21.github.io/day58.html

やったこと

黒い球を飼育するゲームを作りました。(gif)↓
test3.gif

何が起きているか箇条書きします。

  1. 玉は画面内のみ動ける
  2. 玉には寿命がある(寿命を終えると消える)
  3. 玉は1/10以下の確率で色違いが生まれてくる
  4. 玉は檻を外すボタンを押すと画面外へ移動できる(topをすぎるとbottomに戻る、左右も同じ)

html↓

 <body>
    <canvas id="canvas"></canvas>
    <input type="button" value="檻を外す" id="button" />
    <input type="button" value="増やす" id="create" />
  </body>

css↓

* {
  margin: 0;
  padding: 0;
}
#canvas {
  display: block;
  background: white;
}

#button {
  position: absolute;
  top: 10px;
  left: 10px;
}
#create {
  position: absolute;
  top: 10px;
  left: 100px;
}

JavaScript(かなり長いです。162行)↓

//全体で使う変数宣言(カンマ区切りで変数宣言が可能!!)
let canvas = document.getElementById("canvas"),
  button = document.getElementById("button"),
  create = document.getElementById("create"),
  canvasContext = canvas.getContext("2d"),
  radius = 10,
  hairetu = [], //obj格納用の配列
  width = window.innerWidth,
  height = window.innerHeight,
  number = 2,
  maxLife = 500,
  flag = true;

//_____画面サイズ変更に対応するための処理___________
canvas.width = width;
canvas.height = height;

window.addEventListener("resize", function resize() {
  Width = window.innerWidth;
  height = window.innerHeight;
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
});

//檻をつける or つけないに対応するための処理
button.addEventListener("click", function() {
  if (flag) {
    flag = false;
    button.value = "檻をつける";
  } else {
    flag = true;
    button.value = "檻を外す";
  }
});

//Animalを増やす処理
create.addEventListener("click", createAnimal);

//_______________初期化__________________________
function Animal(canvasContext, positionX, positionY) {
  this.canvasContext = canvasContext;
  this.initialize(positionX, positionY);
}

Animal.prototype.initialize = function(positionX, positionY) {
  this.positionX = positionX;
  this.positionY = positionY;
  this.starLife = Math.ceil(maxLife * Math.random());
  this.life = this.starLife;
  if (Math.ceil(Math.random() * 10) === 1) {
    this.color = {
      r: 0,
      g: Math.floor(Math.random() * 255),
      b: Math.floor(Math.random() * 255),
      a: 1
    };
    this.velocity = {
      x: Math.random() * 10,
      y: Math.random() * 10
    };
    this.rare = 1;
  } else {
    this.color = {
      r: 200,
      g: 0,
      b: 0,
      a: 1
    };
    this.velocity = {
      x: Math.random() * 8,
      y: Math.random() * 8
    };
    this.rare = 0;
  }
};
//_________________各種メソッド_____________________
function render() {
  canvasContext.clearRect(0, 0, width, height);
  canvasContext.globalCompositeOperation = "lighter";
  hairetu.forEach(function(obj) {
    obj.render();
  });
  requestAnimationFrame(render); //自動でアニメーションしてくれる関数
}

Animal.prototype.render = function() {
  this.draw();
  this.updatePosition();
  this.updateParams();
  if (flag) {
    this.limitPosition();
  } else {
    this.connectPosition();
  }
};

Animal.prototype.draw = function() {
  ctx = this.canvasContext;
  ctx.beginPath();
  ctx.arc(this.positionX, this.positionY, radius, Math.PI * 2, false);
  ctx.fillStyle = this.updateParams();
  ctx.fill;
  ctx.fill();
  ctx.closePath();
};

Animal.prototype.updatePosition = function() {
  this.positionX += this.velocity.x;
  this.positionY += this.velocity.y;
  var ratio = this.life / this.starLife;
  this.color.a = 1 + ratio;
  this.life -= 1;
};

Animal.prototype.updateParams = function() {
  var col = this.color.r + ", " + this.color.g + ", " + this.color.b;
  var g = this.canvasContext.createRadialGradient(
    this.positionX,
    this.positionY,
    0,
    this.positionX,
    this.positionY,
    radius
  );
  g.addColorStop(0, "rgba(" + col + ", " + this.color.a * 1 + ")");
  g.addColorStop(0.5, "rgba(" + col + ", " + this.color.a * 0.8 + ")");
  g.addColorStop(1.0, "rgba(" + col + ", " + this.color.a * 0 + ")");
  return g;
};

Animal.prototype.limitPosition = function() {
  if (this.positionX > width - radius || 0 >= this.positionX - radius)
    this.velocity.x = -this.velocity.x;
  if (this.positionY > height - radius || 0 >= this.positionY - radius)
    this.velocity.y = -this.velocity.y;
};

Animal.prototype.connectPosition = function() {
  if (this.positionX < 0) this.positionX = width;
  if (this.positionX > width) this.positionX = 0;
  if (this.positionY < 0) this.positionY = height;
  if (this.positionY > height) this.positionY = 0;
};

//______________実行__________________________
function createAnimal() {
  var startPositionX = Math.random() * width,
    startPositionY = Math.random() * height;
  if (0 > startPositionX - radius) startPositionX += radius;
  if (startPositionX >= width - radius) startPositionX -= radius;
  if (0 > startPositionY - radius) startPositionY += radius;
  if (startPositionY >= height - radius) startPositionY -= radius;
  obj = new Animal(canvasContext, startPositionX, startPositionY);
  hairetu.push(obj);
  if (obj.rare === 1) alert("希少種が生まれました!");
}

for (var i = 0; i < number; i++) {
  createAnimal();
}

render();

大体のやることはコメントに書いています。

ポイントを説明してみると、寿命はオブジェクトを削除しているのではなく、alpha値を0にしただけです。(つまり見えないだけで動いてはいる)↓

Animal.prototype.updatePosition = function() {
...
var ratio = this.life / this.starLife;//←ratioの比率はマイナスに増えていく
this.color.a = 1 + ratio;//←マイナスを足しているのでalpha値が0に近づき見えなくなる
this.life -= 1;
};

希少種が1/10以下の確率で生まれてくる部分はこれです。↓

if (Math.ceil(Math.random() * 10) === 1) {
...

ceilは切り上げをしています。randomは0~1までの値を返すので、1/10以下の確率ということになります。

メモ

今日勉強したことのメモを書いておきます。

主題: コールバック関数について

  • Q. コールバック関数の引数は誰が入れている?
    • ex: hoge.addEventListener('click', fuga(e));
    •                   ↑このeは誰が?
  • A. 呼び出し元の関数(この例だとaddEventListenerが入れている)
  • 対処法: 混乱の原因はライブラリで知らぬ間に処理が行われいることにある。のでライブラリを調べるしかない

補足:
- Q. ちなみに呼び出し元の関数の名前は?
- A. 高階関数

function hoge(){ function(){} };

↑高階関数(呼び出し側) ↑コールバック関数(呼び出される側)

感想

コールバックの引数は今までずっと疑問だったので、理解することができてよかったです。

最後まで読んでいただきありがとうございます。明日も投稿しますのでよろしくお願いします。

参考

JavaScript中級者への道【5. コールバック関数】
ブレイクスルーJavaScript フロントエンドエンジニアとして越えるべき5つの壁―オブジェクト指向からシングルページアプリケーションまで(← 本を借りました。リンクはamazonページです)

大変勉強になりました。ありがとうございます!

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

Vue.js / Web Speech API で作る、 PWA対応 英単語学習ソフト

この記事は「PWA Advent Calendar 2019」の18日目の記事です。

今年の春、Progressive Web Apps や Firebase の練習がてら、英単語学習ソフトを開発しました。
(が、そのまま放置していた)

作りっぱなしももったいないので、アドベントカレンダーに乗じてご紹介します。

以下のような特徴があります。

  • Vue.js を利用したMPA(Multi-page Application)
  • Progressive Web Apps 対応。Windows10/スマホにインストールして、オフラインで動作。
  • 英単語の発音をクリックして確認できる (Web Speech API 利用)
  • Firebase のHosting機能を利用して公開
  • 選択肢と回答をランダムに生成。英単語アプリにありがちな「同じ選択肢と回答が繰り返され、出題パターンを覚えてしまう」ことがないようにした。(800の4乗x10問で、組み合わせは4兆通りぐらい?)

be800_1.png

be800_2.png

開発中のメモをもとに、いくつか備忘録を記述します。

Web Speech API による英語音声の確認

アプリを起動すると、英単語と4つの選択肢が表示されます。英単語をクリック・タップすると、英語の発音を確認できます。
Speech Synthesis API という、Web Speech APIの音声合成機能を利用しました。

          pronounce: function () {

            // confirm English word's pronounciation

            let u = new SpeechSynthesisUtterance();
            u.lang = 'en-US';
            u.text = document.getElementById('englishWord').innerHTML;
            u.volume = "1";
            speechSynthesis.speak(u);

          }

※Speech Synthesis API の使い方については拙稿「Web Speech API を 利用して 英単語の音声確認をするアプリを作る」にまとめました。

Service Worker によるデータキャッシュ

html・CSS・効果音などのアセットファイル類を、Service Worker でまとめてキャッシュしています。

バージョンをキャッシュのキーとして登録。バージョン情報に更新があった場合、キャッシュパージ後、ファイルをキャッシュしなおすようにしました。

const CACHE_NAME = `BasicEnglish800-${version}`;

---- 中略 ----

// Service Worker へファイルをインストール

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function (cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

// リクエストされたファイルが Service Worker にキャッシュされている場合
// キャッシュからレスポンスを返す

self.addEventListener('fetch', function (event) {
  if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin')
    return;
  event.respondWith(
    caches.match(event.request)
      .then(function (response) {
        if (response) {
          return response;
        }
        return fetch(event.request);
      }
      )
  );
});

// Cache Storage にキャッシュされているサービスワーカーのkeyに変更があった場合
// 新バージョンをインストール後、旧バージョンのキャッシュを削除する
// (このファイルでは CACHE_NAME をkeyの値とみなし、変更を検知している)

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys.map(key => {
        if (!CACHE_NAME.includes(key)) {
          return caches.delete(key);
        }
      })
    )).then(() => {
      console.log(CACHE_NAME + "activated");
    })
  );
});

※Service Workerの使い方や詳細については拙稿「Progressive Web Apps (PWA) 学習者のメモ その1  (Service Worker)」にまとめました。

Vue.js で問題を生成、回答記録を追跡

Vue.js を利用して、回答数を記録。
最初に10問分の英単語・選択肢・回答を、ランダムに生成。
配列に単語・回答・選択肢のデータを格納。
問題を解いて「次へ」をクリック・タップすることで、配列を切り替え、次の問題を表示。
1問解くたびに、次の問題へ切り替え。

10問終了後に正誤のデータを確認し、回答を記録するようにしました。

      const vm = new Vue({
        el: '#el',
        data() {
          return {
            arr: answerList,
            count: 0,
            choice: quizList,
            result: [],
          }
        },
        methods: {
          check: function (event) {

            // check user's answer each by each

            let target = document.getElementById("answerOptions");

            target.setAttribute('style', 'pointer-events: none;');
            let rightAnswer = this.arr[this.count].Japanese;
            let chosenAnswer = event.target.innerHTML;

            if (rightAnswer === chosenAnswer) {

              correctSound.play();
              message.innerHTML = "正解!";
              addAnswer("");

            } else {

              wrongSound.play();
              message.innerHTML = "残念!";
              addAnswer("×");

            }

            this.count === 9 ? complete() : unComplete();
          },

※Vue.js を利用した回答の切り替え(=配列の切り替え)については、拙稿「Vue.js で 配列とJSONの切り替え表示を行う」にまとめました。

振り返り

一つ一つは単純なコードですが、組み合わせることで、それなりにアプリとして形になった気がしました。

できれば

  • Firebase のユーザー認証を利用して、リアルタイムDBに回答記録を保存
  • 全アプリユーザーの間で、どの単語の誤答率が高いか、統計を取る

までやってみたかったのですが、時間切れで実装できず。
Firebaseの利用はHostingのみとなりました。

そのうち時間を作ってチャレンジしてみたいと思います。

備考

以下のサイトのデータ・情報を元に開発しました。

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

PlayCanvasのレイキャストをもっと見てみる

前回、PlayCanvasのレイキャストの設定について知ってみる、でレイキャストについて話しました。

他のUnityやThree.jsでも通じるようなレイキャストの設定方法だったんじゃないかなと思います。

今回はPlayCanvasの事例の一つでBMW i8のコンフィギュレーターがあるのですが、
ここのドアなどで使用されているレイキャストの四角形…
スクリーンショット 2019-12-05 11.48.16.png

これ、DOMじゃん

前回紹介したレイキャストの例ではElementComponentとEntityを配置してその座標を参照してレイキャスト処理を行なっていました。
3Dオブジェクトの3次元座標とカーソルの2次元座標を演算してレイキャスト処理を行い実装していました。
そこでは、screenToWorld()を使っていました。

今回は、「3次元座標を参照して2次元座標に反映する」、的なことを行います。

試しに前回のレイキャストの記事で説明したものと今回のDOMを追従させる方法をデモってみました。
スクリーンショット 2019-12-05 12.26.04.png

  • 緑がDOM
  • 黄がEntity
  • 赤がElementComponent

になっていて、それぞれクリックでリアクションを返すようにしています。
https://playcanv.as/p/SYcc55E4/

基本的にやることはElementComponentと同じ方法を使っています。
このElementComponentをDOMに置き換えた感じですね。

コードは以下の感じですね。

var DomHotspot = pc.createScript('domHotspot');

DomHotspot.attributes.add("cameraEntity", {type: "entity", title: "Camera Entity"});

DomHotspot.prototype.initialize = function() { // init
    this.directionToCamera = new pc.Vec3();
    this.defaultForwardDirection = this.entity.forward.clone();

    this.btn = document.createElement("div");
    document.body.appendChild(this.btn);
    this.btn.style.position = "fixed";
    this.btn.style.width = "30px";
    this.btn.style.height = "30px";
    this.btn.style.background = "#ffffff";
    this.btn.style.transition = "opacity .5s";
    this.btn.style.zIndex = 10;
    this.btn.addEventListener("mousedown",function(){ this.style.background = "#333333"; });
    this.btn.addEventListener("mouseup",function(){ this.style.background = "#ffffff"; });
};

DomHotspot.prototype.update = function(dt) { // update
    var worldPos = this.entity.getPosition();
    var screenPos = new pc.Vec3();

    this.cameraEntity.camera.worldToScreen(worldPos, screenPos);

    this.directionToCamera.sub2(this.cameraEntity.getPosition(), this.entity.getPosition());
    this.directionToCamera.normalize();
    var dot = this.directionToCamera.dot(this.defaultForwardDirection);

    if (dot < 0) {
        this.btn.style.opacity = 0;
    } else {
        this.btn.style.opacity = 1;
    }

    this.btn.style.transform = "translate(" + screenPos.x + "px," + screenPos.y + "px)";
};

initializeをみてわかりますが、DOMの設定は即興ものなので悪しからず…

sub2とかnormalizeとかdotとかで表示非表示を切り替えていますがその説明は前回の記事から引用しますね。

表示非表示しているのは、カメラとEntityの3次元座標を減算し、その値とEntityのz軸ベクトルを内積演算した結果が0以下の場合は非表示、0以上は表示しているようです。
正直どう言う計算をしているのかわからないかもしれませんが、フローだけ説明すると…
sub2()でカメラとEntityの3次元座標を減算しているのは、カメラの方向を求めています。
こうして求めたカメラの方向をベクトルに変換するためにnormalize()を使って正規化します。
ベクトルの正規化については、ググると色々出てきますので3Dの勉強がてら調べるといいかもしれません。
そして正規化したカメラのベクトルとEntityのz軸ベクトルをdot()を使って内積を求めていきます。

このDOMのポジションを指定する上で一番重要なのが以下です。

var worldPos = this.entity.getPosition();
var screenPos = new pc.Vec3();

this.cameraEntity.camera.worldToScreen(worldPos, screenPos);

このworldToScreen()、他で使用されているscreenToWorld()の逆のことをやっています。
今までは2次元座標を3次元座標に変換していましたが、今回は3次元座標を2次元座標に変換します。

変換した値を何か処理することもできるのですが、BMW i8のプロジェクトのスクリプトを見てみると…
なんとこの値をそのままtransfrom: translate()に入れるだけで良いみたいです。

this.btn.style.transform = "translate(" + screenPos.x + "px," + screenPos.y + "px)";

これだけでいいなんて……
なんて楽なんだ…

このscriptを空っぽのEntityなんかに当てて使います。
スクリーンショット 2019-12-05 12.18.02.png

なんとjsの行数は40未満で出来ていることに驚きました。
かつ、エディターからどこにDOMを配置させるのか視覚的に見れるのも良いですね。

前回から通してレイキャストについて調べてみました。
なかなか難しく感じてしまっていましたが、こう実装してみる意外と簡単に出来てしまうんだなって思いました。

これで、Web3Dコンテンツをたくさん作れるようになれるかなー

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

Vue.jsプラグインで始めるOSS

はじめに

これはVue Advent Calendar 2019の16日目の記事です。

最近、Vue.jsのプラグインを作ってNPMパッケージとして公開する機会がありました。Vueのプラグインを作るのもNPMパッケージを公開するのも初めてでしたが、意外と手軽にできたので、「OSSはなんか敷居が高そうだ...」と思っているエンジニアにもオススメです。

今回、TypeScriptでVueプラグインを実装し、NPMに公開するためのテンプレートを用意してGithubに公開しました。この記事では、そのテンプレートの紹介をします。

テンプレートのご紹介

vue-plugin-ts-templateというテンプレートを用意しました。
中身は本当にシンプルで、一般的なtypescriptのプロジェクトでちょっとだけ実装したコードが入っているだけのものになります。

案外、TypeScriptやeslint、prettierの導入が面倒だったりするので、Vueのプラグインの作り方というよりかは開発環境を一発で作れるという意味の恩恵のほうが強いかもしれません。

プラグインの実装〜NPMパッケージの公開まで

流れはREADMEに書いてありますが、英語になっているので改めて日本語で解説していきたいと思います。

0. 要件

NPMパッケージとして公開するにあたり、NPMのアカウントが必要になるので、公開しようと思う人は作っておくようにしましょう。
また、typescriptはtscでコンパイルしているのでグローバルにインストールしておくと良いでしょう。

1. packageの初期化と依存関係のインストール

まずはテンプレートをダウンロードして依存関係をインストールしましょう。
NPMに公開する際には、パッケージのいろんな情報を入力する必要があります。
下記のコマンドを実行すると、コマンドライン上でインタラクティブに質問が出てくるので自分のパッケージにあったものに修正していきましょう。

$ yarn install
$ yarn init

スクリーンショット 2019-12-15 22.12.42.png

こんな具合に入力していきます。

  • テンプレートには初めから設定値が入っているので、使う場合は上書きしてください。 keywordsは聞かれないようなので、直接package.jsonを編集する必要があります。
  • endopointはデフォルト(index.js)で大丈夫です。tsconfig.jsonでビルドファイルをプロジェクト直下のindex.jsとして出力するようにしています(好みに応じて変えてください)。

2. プラグインの実装

Vueプラグインの実装方法については公式サイトが参考になります。
srcフォルダ内でTypeScriptを書いてください。
テンプレート内ではLoggerをインスタンスメソッドとして追加しています。

https://github.com/gyarasu/vue-plugin-ts-template/blob/master/src/index.ts

src/index.ts
import _Vue from 'vue';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const log = (value: any): void => {
  console.log(value);
};

export default {
  install(Vue: typeof _Vue): void {
    Vue.prototype.$log = log;
  }
};

ちなみに、公開したパッケージはこんな感じで使えます。

main.js
import Vue from 'vue';
import VueLogger from 'vue-logger'; // 公開するパッケージ名

Vue.use(VueLogger);
App.vue
<script>
export default {
  mounted() {
    this.$log('component is mounted!');
  }
};
</script>

3. ビルド

npmスクリプトとしてビルドコマンドも用意しています。

$ yarn build

これだけです。

4. コミット&プッシュ&タグ作成

ビルドまで終わったらNPMに公開できる状態になります。
NPMに公開してるバージョンとGithubのコードのパージョンは揃っていたほうが都合がいいと思うので、公開する前にコードをコミット・プッシュして、バージョンがわかるタグを打っておくようにしましょう。
package.json内にあるバージョン情報も必要に応じて更新しましょう。

5. NPMへの公開

ここまでくればあとは公開するのみです。
プロジェクト直下(package.jsonがいるディレクトリ) で下記のコマンドを実行すればOKです。

$ npm adduser 
$ npm publish ./

npm adduserでは、登録済みのNPMアカウントでログインを行います。
ここでログインしたアカウントにパッケージが追加されることになるので、会社用・仕事用で使い分けている場合などは注意しましょう。

まとめ

Vueでなにかする場合に、プラグインとして使える機能があると結構便利です。
また、プラグインの作成もそんなに難しいものではないですし、OSS活動を始めるにはもってこいの題材かもしれません。
便利なプラグインを作ってVueを盛り上げていきましょう!!

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

スマホでデバイス方向を検知して振動させるだけのクソアプリ

この記事はtomowarkar ひとりAdvent Calendar 2019の15日目の記事です。

はじめに

HTML5を使った3分で作れるクソアプリシリーズ。

Webブラウザから取れる情報ってめちゃくそあるんやで!!

デモページ(スマホで開いてね)

AndroidのChromeでの動作は確認しているよ

https://tomowarkar.github.io/blog_content/motion/mobile.html

コード

デバイスの方向(加速度)を取得して閾値を超えたらバイブレーションをさせるってだけ。

改良すれば振ったか振ってないかの判定もできました(判定ガバガバだったけど)

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="initial-scale=1.0" />
  <title></title>

  <script>
    window.addEventListener("devicemotion", e => {
      x = e.accelerationIncludingGravity.x;
      y = e.accelerationIncludingGravity.y;
      z = e.accelerationIncludingGravity.z;

      if (z > 9) {
        navigator.vibrate(30);
      } 

      let result = document.getElementById("result");
      result.innerHTML =
        "X:" + Math.round(x * 10) / 10 + "<br>" +
        "Y:" + Math.round(y * 10) / 10 + "<br>" +
        "Z:" + Math.round(z * 10) / 10 + "<br>"
    }, true);
  </script>
</head>

<body>
  <div id="result">vibration</div>
</body>

</html>

おわりに

以上明日も頑張ります!!
tomowarkar ひとりAdvent Calendar Advent Calendar 2019

参考

Vibration API
デバイスの方向の検出

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

Redux入門

Reduxってなに?

ReduxとはReactと相性が良いフレームワークのことです。
Redux単体で利用することも可能ですが、ReactとReduxの組み合わせは鉄板でしょう。
stateを容易に管理することの出来るReduxですが、大規模なアプリケーションになればなるほど効果を発揮してくれそうです。

Reduxアプリを構成する機能ってなに?

Reduxを使ったアプリはAction,Reducer、Storeによって構成されています。

Actionとは

何がおきたのかという情報を持つオブジェクトです。

ActionをStoreへ送信(dispatch)すると、Storeのstateが変更されます。
stateの変更ではActionが必ず必要となります。
stateへ通じるルートを攻略する第一段階、まるで門番のような立ち位置の機能ですね。

const action = {
  type: 'SET_wanko',
  text: 'トイプードル'
};

Actionではどういうタイプのアクションなのかを明示するためtypeプロパティが必要となります、他と区別できないと何がなんだかわからなくなりますもんね。

逆に言えばこのActionはその程度の情報しか持っておらず、どのようにstateを変更するのか知らない存在なのです。

Reducerとは

上記したSET_wankoというタイプのアクション受けて、storeから受け取ったstateを変更して返す純粋関数です。
Reducer内では引数変更したり、API呼び出したり、Math.random()等の純粋関数以外の関数を呼び出してはいけません。
結果が毎回同一になるような操作しか扱えないのです。

stateをどう変更するのかactionでは決めれなかったことを指定しています。

function triming(state = [], action) {
  switch (action.type) {
    case 'SET_wanko':
      return state.concat([{ text: action.text, completed: false }]);

    default:
      return state;
  }
}

Storeとは

Storeとはアプリケーションの全てのstateを保持するオブジェクトです。

dispatchされたActionと保持するstateをreducerに渡してstate変更に一役買う立場の存在で、ボスのような風格ですね。
Storeの複製はダメです、ボスは一人だけなのです。
又、stateの変更は必ずActionを経由してください、バグの特定が困難になるのを防ぐためです。
ボスに会うためにはまず名乗って(Action type)からが礼儀ってもんです。

// Action
const action = {
  type: 'SET_wanko',
  text: 'トイプードル'
};

// Reducer
function triming(state = [], action) {
  switch (action.type) {
    case 'SET_wanko':
      return state.concat([{ text: action.text, completed: false }]);

    default:
      return state;
  }
}

// Store
const store = Redux.createStore(triming);

// Actionをdispatchする
// Reducerであるtodosが実行され、Storeが保持しているstateが変更される。
store.dispatch(action);

// stateを取得する
console.log(store.getState()); 
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Next.jsでcookieをシンプルに扱うことができるライブラリ nookies を紹介

Next.jsでcookieを扱うのは大変

Next.jsなどのサーバーサイドレンダリング(以下SSR)をしているフレームワークでcookieを扱うのは面倒くさいですよね。
その理由の一つとして、同じコードでもSSRの場合とクライアントでレンダリングしている場合で挙動が違うということがあります。
例をお見せしましょう

クライアントでレンダリングしている場合

console.log(document.cookie); // accessToken=test1234;

SSRの場合

console.log(document.cookie); // ReferenceError: document is not defined

原因

クライアントサイド(ブラウザ)でレンダリングしている時は、ブラウザに保存されているcookieにアクセスできるが,
SSRの時はブラウザに保存されているcookieにアクセスできません。

SSRの時にcookieを扱うには

SSRでcookieの情報はここに入っています

index.tsx
const TestPage: NextPage<Props> = (props) => {
    return <div>test</div>
}

TestPage.getInitialProps(ctx) {
    // ここ
    console.log(ctx.req.headers.cookie) // accessToken=test1234;     

    return {};
}

同じライブラリをクライアントとSSRで共有していたりすると、条件分岐などが大変ですね
そんな時に nookies を使います
https://www.npmjs.com/package/nookies

使い方

以下の例で示すようにクライアントサイドの場合ctxを渡さずに、SSRならctxを渡せば、cookieをオブジェクトに整形して返してくれます。

tool.ts
import { parseCookies } from 'nookies';
import { NextPageContext } from 'next';

export function printCookie(ctx?: NextPageContext) {
    const cookie = parseCookies(ctx);
    console.log(cookie) // { accessToken: 'test1234' }
}

また、cookieの追加もクライアントとSSR分け隔てなく行ってくれます

set_cookie.ts
import { setCookie, destoroyCookie } from 'nookies';
import { NextPageContext } from 'next';

export function setCookie(ctx?: NextPageContext, token: string) {
        setCookie(ctx, 'accessToken', token, {
            maxAge: 30 * 24 * 60 * 60,
        });
}

// ついでにcookie削除(動作確認してません)
export function destoroyCookie(ctx?: NextPageContext) {
    destroyCookie(ctx, 'accesstToken')
}

ライブラリを読んでみた(箇条書きです!)

https://github.com/maticzav/nookies

nookies/src/index.ts
const isBrowser = () => typeof window !== 'undefined' // 今の環境がSSRかクライアントサイドレンダリングか調べてるらしいです

.
.

if (ctx && ctx.req && ctx.req.headers && ctx.req.headers.cookie) { 
    return cookie.parse(ctx.req.headers.cookie as string, options) // SSRだったらctx.req.headers.cookieに入っているcookieをparseして返却
} 

.
.

if (isBrowser()) { 
   return cookie.parse(document.cookie, options) //クライアントだったらdocument.cookieにあるcookieをparseして返却
} 

.
.

ctx.res.setHeader('Set-Cookie', cookiesToSet) // SSRならレスポンスヘッダーにcookieをセットする

.
.

if (isBrowser()) { |
    document.cookie = cookie.serialize(name, value, options) // クライアントならクッキーをセット
}

まとめ

以上です。いかがでしたでしょうか?
参考になりましたら幸いです。

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

Next.js + AWS Amplify + Graphqlで作るサーバーレスアプリケーション環境構築

概要

こんにちは、先日AWSのre:Inventで発表されたAWS Amplify DataStoreがめちゃくちゃ便利すぎて驚きを隠せない、都内でフロントエンドエンジニアをしていますかめぽんです。
今まで、VueやNuxtでの開発がメインでやってきておりましてReact、Nextでの開発経験が少なくまたTypescriptでなにか出来ないかなと探しておりました。
最近だとモバイル開発のためのAWSのサービスをインテグレートしたAWS版firebaseのようなAmplifyというサービスが出ています。内容をみてみると、何やら爆速でサーバーレスアプリケーションが出来そうだなと感じたので、Next.js + AmplifyでTodoアプリを作ってみました。巷では、Nuxt.js + firebaseの組み合わせの記事がかなり多かったですが、こちらのアーキテクチャでも実装出来たのでその方法を体系的にまとめて見ました。
Next + Amplifyでの開発がなんとなくわかるようになると思うので、ぜひ最後まで読んでいただけると嬉しいです。

Next.jsとは

top_next.png

こちらはもはや説明不要かもしれませんが、ZEIT社が開発したサーバーサイドレンダリング対応のwebアプリケーションを構築できるReact製フレームワークです。

pages配下の自動ルーティングやダイナミックルート、SPA/SSRに始まり静的サイトジェネレートなアプリはもちろん、最近だとゼロコンフィグでTypescriptがそのまま使えたり、AMP対応、apiディレクトリによるapiの実装などかなりDXがよくなってきています。もちろん導入も手軽にできるので開発スピードを格段に高めることが出来ます。

AWS Amplifyとは

top_amplify.png

AWS AmplifyはAWSのサービスを仕様したmBaasの一種で、webアプリケーション作成、設定、開発をかなり簡単にすることが出来、スケーラブルでもあるためサービスの規模に応じてオートスケールさせることも可能です。似たようなサービスではgoogle社のfirebaseがあります。バックエンドの資材を自分で準備しなくてもAmplifyのコマンドで必要なソースコードやモデルなどを準備してくれます。本来であれば、設定やプロビジョニング、分析などを全て自前で行っていかなければいけませんが、選択したもに関してAmplifyはそれらを管理してくれます。認証、オフラインデータ、解析、プッシュ通知、AR/VR、botなどを必要に応じてAWSサービスをアプリケーションに対してインテグレートします。

Next.js + Amplifyは何が嬉しいのか

初期開発スピードの爆速化

昨今では、マーケットの変化が非常に早くユーザーニーズも目まぐるしく変わっています。それに加えて、サービスやプロダクトにおける提供すべきUXも何が適切かわかりにくくなってきている中で、ビジネスサイドと開発サイドで共通認識として持っていなければならないのが仮説検証を高速に回し、フィードバックをUXに還元することです。DX時代とその未来における「ユーザーエクスペリエンス」についての基本を抑えるという記事を書かせていただいたのですが、サービスのローンチだけでなくそこに至る検証もスピード感を持って行うことが重要です。そこで、技術選定やアーキテクチャをどうするのかは悩む部分だと思いますが、いかに早く社会実装するかという観点で見るのそれ自体は早く解決すべき問題です。もちろん軽視すべき問題ではないというのが前提です。

そういった中で、Next.js + Amplifyの組み合わせはそれを解決することができると考えています。両者の環境構築で必要な時間は、独自で設計に応じてAWSサービスの選定をしたり構築することに比べても時間がかからずすぐにlocalhostなどで確認出来ますし、必要に応じてバックエンドサービスを提供してくれます。

適切な型やモデルを定義した上でのオートスケール

Next.jsではゼロコンフィグでTypescriptを動かせますし、AmplifyではAppSyncというサービスを含んでいてGraphqlでのデータのやりとりをします。そこで、必要なデータの型を定義してくれるのでフロントエンドとバックエンドで共通で型を使用することが容易です。
フレームワークが何かよりも最重要ビジネスルールは何かを抑える方が大事で、それを定義した上でオートスケールできるのでデータの保全性を担保しつつ希望に応じて対応することが出来ます。

Next.jsのセットアップ

早速Next.jsのセットアップを始めていきます。

ディレクトリ構成

最終的なディレクトリ構成は以下のようになっています。実際のディレクトリから必要な部分だけ掲載してますので、全ファイルを確認したい場合はGithubにコードを準備してますのでぜひ参考にしてみてください。amplifygraphqlディレクトリに関しては以降のAWS Amplifyのセットアップにて自動生成されます。

├─ amplify
│  ├─ #current-cloud-backend
│  │  ├─ amplify-meta.json
│  │  ├─ api
│  │  │  └ todo
│  │  │   ├─ build
│  │  │   ├─ parameters.json
│  │  │   ├─ resolvers
│  │  │   ├─ schema.graphql
│  │  │   ├─ stacks
│  │  │   └─ transform.conf.json
│  │  └─ backend-config.json
│  ├─ backend
│  │  ├─ amplify-meta.json
│  │  ├─ api
│  │  │  └ todo
│  │  │   ├─ build
│  │  │   ├─ parameters.json
│  │  │   ├─ resolvers
│  │  │   ├─ schema.graphql
│  │  │   ├─ stacks
│  │  │   └─ transform.conf.json
│  │  ├─ awscloudformation
│  │  │  └ nested-cloudformation-stack.yml
│  │  └─ backend-config.json
│  └─ team-provider-info.json
├─graphql/
│ ├─queries.ts
│ ├─mutations.ts
│ ├─subscriptions.ts
│ └─schema.json
├─pages/
│ ├─index.tsx
│ └─todo.tsx
├─components/
│ └─templates/
│   ├─head.tsx
│   └─navigation.tsx
├─store/
├─aws-exports.js
├─package.json
├─.gitignore
├─next.config.js
├─next-env.d.ts
└─tsconfig.json

必要なモジュールのインストールとpackage.jsonの編集

作業ディレクトリが出来たら、以下コマンドで必要なモジュールの準備をしましょう。

npm install --save react react-dom next
npm install --save-dev @types/node @types/react

インストール出来きたら、package.jsonのscriptsを以下のように編集します。

package.json
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start"
  }

各種コンポーネントの準備

ここでは、共通で使うコンポーネントの定義をします。ナビゲーション用とhead部用のコンポーネントを準備します。

navigations.tsx
import * as React from 'react';
import Link from 'next/link'

const Navigation: React.FC = () => {
  return (
    <div>
      <Link href="/">
        <p>Index</p>
      </Link>
      <Link href="/about">
        <p>About</p>
      </Link>
      <Link href="/todo">
        <p>Todo</p>
      </Link>
    </div>
  )
}
export default Navigation

head用のコンポーネントに関してはお好みで設定してみてください。

head.tsx
import * as React from 'react'
import Head from 'next/head'
import info from '../../package.json'

const defaultOGURL = ''
const defaultOGImage = ''

interface Props {
  title: string,
  description?: string,
  url?: string,
  ogImage?: string,
}

const head: React.FC<Props> = props => {
  return (
    <Head>
      <meta charSet="UTF-8" />
      <title>{props.title || ''}</title>
      <meta name="description" content={props.description || info.description} />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta property="og:url" content={props.url || defaultOGURL} />
      <meta property="og:title" content={props.title || ''} />
      <meta
        property="og:description"
        content={props.description || info.description}
      />
      <meta name="twitter:site" content={props.url || defaultOGURL} />
      <meta name="twitter:card" content="summary_large_image" />
      <meta name="twitter:image" content={props.ogImage || defaultOGImage} />
      <meta property="og:image" content={props.ogImage || defaultOGImage} />
      <meta property="og:image:width" content="1200" />
      <meta property="og:image:height" content="630" />

      <link rel="icon" sizes="192x192" href="/static/touch-icon.png" />
      <link rel="apple-touch-icon" href="/static/touch-icon.png" />
      <link rel="mask-icon" href="/static/favicon-mask.svg" color="#49B882" />
      <link rel="icon" href="/static/favicon.ico" />
  </Head>
  )
};

export default head

pagesの準備

次にpagesコンポーネントの準備です。

pages/index.tsx
import * as React from 'react';
import Head from '../components/templates/head'
import Navigation from '../components/templates/navigation'

const Index: React.FC = () => {
  return (
    <div>
      <Head title="Index page" />
      <Navigation />
      <p>Hello world</p>
      <p>Index</p>
    </div>
  )
}
export default Index

pages/todo.tsx
import * as React from "react";
import Head from '../components/templates/head'
import Navigation from '../components/templates/navigation'

const Todo = () => {

  return (
    <div>
      <Head title="todo" />
      <Navigation />
      <h2>Todo with amplify</h2>
    </div>
  )
};

export default Todo;

この状態で npm run devのコマンドを実行し、http://localhost:3000/にアクセスしてみましょう。以下のような画面にが表示されたら成功です。試しに/todoにもアクセスしてみてtodoページが表示されるかもみてみましょう。

スクリーンショット 2019-12-15 18.17.02.png

Next.jsの準備は一旦以上です。

AWS Amplifyのセットアップ

Amplifyのインストールと初期セットアップ

なにはともあれ、amplify/cliのグローバルインストールをします。

npm install -g @aws-amplify/cli

インストールが完了したら、amplify -vでバージョンを確認しましょう。以下のような表記になっていたらインストール完了です。

Scanning for plugins...
Plugin scan successful
3.17.0

次にAWSアカウントの紐付けを行います。コンソールにて

amplify configure

とコマンドを打つとIAMユーザーを作成するためにブラウザが立ち上がります。アカウントがあればログイン、なければ新規作成を行いましょう。

aws_account.png

スクリーンショット 2019-11-09 3.09.57.png

accessKeyIdsecretAccessKeyIdが順番に出るので、それをIAMユーザーの作成画面に貼ります。

スクリーンショット 2019-12-15 18.37.54.png

画面を進めて行くとIAMユーザー作成画面側にアクセスキーIDが表示されるので、コンソール側に貼り付けてEnterを押します。Profile nameを決めた後、Enterを押しSuccessfully set up the new user.のメッセージが出たら完了です。

Amplifyをプロジェクトで扱えるようにする

ここからは実際にNext.js上でamplifyを使っていく流れを説明していきます。
amplifyバックエンドの様々なサービスを扱えるようにするため、各種リソースをプロジェクトフォルダ内に作成します。
以下コマンドを打ってみましょう。

amplify init

以下のように、使用言語やフレームワーク、ディレクトリ情報などをインタラクティブ形式で進めていきます。

スクリーンショット 2019-11-09 3.47.46.png

Initializing project in the cloud...のメッセージが出るとバックエンドの資材を初期化&準備し始めるので待ちましょう。
Your project has been successfully initialized and connected to the cloud!が出たら準備が整う合図になります。

バックエンドAPI(Graphql)の準備

次にAPIの準備をするために、以下コマンドを打ちます。

amplify add api

そうすると、プロジェクト内で扱うapiの種類や名前、スキーマの設定をインタラクティブに決めていきます。今回はGraphqlを使用していきます。
以下、質問例になります

スクリーンショット 2019-12-15 18.49.16.png

スクリーンショット 2019-12-15 18.52.35.png

GraphQL schema compiled successfullyのメッセージが出たら、Graphqlでのapi実行に必要なファイル等が自動生成されます。そのあと、schemaファイルに変更をかけたいならば自分で編集します。今回は以下の形式のスキーマにします。

type Todo @model {
  id: ID!
  description: String
  isDone: Boolean
}

次に以下コマンドでデプロイをします。

amplify push

ここでも以下のようにインタラクティブに質問を進めていきます。

スクリーンショット 2019-12-15 15.40.57.png

デプロイが完了すると、バックエンドのリソースが自動生成されます。

Next.jsとamplifyの結合

必要なバックエンドリソースが揃ったら、いよいよNext.jsの実装に入っていきます。
まずは必要なnpmモジュールをインストールします。

npm install --save @aws-amplify/api @aws-amplify/core @aws-amplify/pubsub

次にNext.jsのセットアップの時に作ったpages/todo.tsxを編集します。

基本的にamplifyモジュールのインポートとconfigの処理をかけます。amplifyの処理を書きたいときは基本的にpagesで以下の処理をかけておきます。

pages/todo.tsx
import Amplify from '@aws-amplify/core';
import PubSub from '@aws-amplify/pubsub';
import API, { graphqlOperation } from '@aws-amplify/api';

import awsmobile from '../aws-exports';

Amplify.configure(awsmobile);
API.configure(awsmobile);
PubSub.configure(awsmobile);

次に使用したバックエンドapiを使えるようにします。graphqlで作っているので、そこからquery、mutations, subscriptionsをimportします。ここでは全てインポートしてますが、実際には使う分だけで大丈夫です。

pages/todo.tsx
import { createTodo, deleteTodo, updateTodo } from '../graphql/mutations';
import { getTodo, listTodos } from '../graphql/queries';
import { onCreateTodo, onUpdateTodo, onDeleteTodo } from '../graphql/subscriptions';

少しだけ説明すると、QueryとMutaionsは従来のCRUDに対応させると以下のようになります。

昨日 CRUD graphql
作成 CREATE Mutation
取得 READ Query
更新 UPDATE Mutation
削除 DELETE Mutation

加えてgraphqlではSubscription(購読)というものがあります。これは、端的にいうとサーバー側からのPushのようなものです。バックエンド側のデータに変更がかかった場合などに検知をして値を知らせてくれるものです。

Queryの使い方

query.ts
export const getTodo = `query GetTodo($id: ID!) {
  getTodo(id: $id) {
    id
    description
    isDone
  }
}
`;

importしたlistTodosのクエリを以下のようにAPI.graphql(graphqlOperation)を使ってアクセスします。通信が成功するとdataに取得した値が入ってくるのでそれをpropsで渡したり、リストレンダリング用のローカルステートに渡してあげるとReact側でレンダリングすることが出来ます。

Todo.getInitialProps = async (props) => {
  const data = await API.graphql(graphqlOperation(listTodos));
  return {...props, ...data};
};

Mutationsの使い方

mutations.ts
export const createTodo = `mutation CreateTodo($input: CreateTodoInput!) {
  createTodo(input: $input) {
    id
    description
    isDone
  }
}
`;

こちらは登録用のファンクションです。API.graphql(graphqlOperation(createTodo, inputData));にて第一引数にMutatio、で第二引数で登録するデータを入れます。

pages/todo.ts
const submitTodo = async (list: Array<string>, todo: string) => {
  const id = Math.floor(Math.random() * Math.floor(1000))
    const inputData = {
    input: {
      id,
      description: todo,
      isDone: false
    }
  }

    try {
    await API.graphql(graphqlOperation(createTodo, inputData));
  } catch (e) {
    console.log(e);
  }
};

Subscriptionの使い方

subscription.ts
export const onCreateTodo = `subscription OnCreateTodo {
  onCreateTodo {
    id
    description
    isDone
  }
}
`;
pages/todo.tsx
API.graphql(graphqlOperation(onCreateTodo)).subscribe({
    next: e => {
        // 購読する値の取得
        const todo = e.value.data.onCreateTodo

        // 値をセットする処理を書く
        ...
    }
})

Todoアプリの実装

以下に今回の実装例を載せておきます。

pages/todo.tsx
import * as React from "react";
import { useState } from 'react';
import Amplify from '@aws-amplify/core';
import PubSub from '@aws-amplify/pubsub';
import API, { graphqlOperation } from '@aws-amplify/api';

import awsmobile from '../aws-exports';
import {
  createTodo,
  deleteTodo,
  updateTodo
} from '../graphql/mutations';
import { getTodo, listTodos } from '../graphql/queries';
import { onCreateTodo, onUpdateTodo, onDeleteTodo } from '../graphql/subscriptions';

import Head from '../components/templates/head'
import Navigation from '../components/templates/navigation'

Amplify.configure(awsmobile);
API.configure(awsmobile);
PubSub.configure(awsmobile);

interface TodoType {
  id: number,
  description: string
  isDone: boolean
}

interface DataProp {
  data: {
    listTodos?: {
      items: Array<TodoType>
    }
  }
}

const Todo = (props: DataProp) => {
  const { items: todoItems } = props.data.listTodos;

  const [todo, setTodo] = useState('');
  const [list, setList] = useState([]);

  // 新規追加でTodoを追加する
  const submitTodo = async (list: Array<string>, todo: string) => {
    const id = Math.floor(Math.random() * Math.floor(1000))
    const inputData = {
      input: {
        id,
        description: todo,
        isDone: false
      }
    }
    try {
      await API.graphql(graphqlOperation(createTodo, inputData));
    } catch (e) {
      console.log(e);
    }
  };

  // 既存のTodoを削除する
  const deleteItem = async (id) => {

    const deleteData = {
      input: {
        id
      }
    }

    try {
      await API.graphql(graphqlOperation(deleteTodo, deleteData));
    } catch (e) {
      console.log(e);
    }
  };

  return (
    <div>
      <Head title="todo" />
      <Navigation />
      <h2>Todo with amplify</h2>
      <input style={{
        border: 'solid 1px #ddd',
        padding: 10,
        borderRadius: 4,
        fontSize: 18,
        WebkitAppearance: 'none',
        color: '#333'
      }} value={todo} type="text" placeholder="please write todo" onChange={e => setTodo(e.target.value)} />
      <button style={{
        padding: 10,
        background: '#F06292',
        color: '#eee',
        borderRadius: 4,
        fontSize: 18,
        WebkitAppearance: 'none'
      }} onClick={() => submitTodo(list, todo)}>add Todo</button>
      <ul className="ListContainer">{
        todoItems.map( item => (
          <li key={item.id} className="ListItem">
            <span className="title">{item.description}</span>
            <span>{item.isDone}</span>
            <input type="button" value="delete" onClick={() => deleteItem(item.id)} />
          </li>
        ))
      }</ul>
    </div>
  )
};

Todo.getInitialProps = async (props) => {

  const data = await API.graphql(graphqlOperation(listTodos));

  try {
    const client = API.graphql(graphqlOperation(onCreateTodo));
    if ("subscribe" in client) {
      client.subscribe({
        next: e => {
          console.log(e);
        }
      });
    }
  } catch (e) {
    console.error(e);
  }

  return {...props, ...data};
};

export default Todo;

試しにあらかじめ以下のようにデータをセットしておきます。(このデータ自体はGraphqlでポストしておいたデータになります。)
スクリーンショット 2019-12-15 20.52.26.png

その状態で、npm run devでサーバーを起動して/todoにアクセスしてみてTodoが表示されていれば成功です。

スクリーンショット 2019-12-15 20.50.52.png

まとめ&感想

ここまで最後ま目を通していただいてありがとうございます。

巷ではサーバーレスWebアプリケーション開発はおそらく非常に人気で、Nuxt.js + Firebaseの情報が非常に多く見受けられます。非常に便利で僕も好きな技術の一つですが、逆にNext.jsがあまり無くバージョンアップにより魅力的な昨日が増えてきています。また、React製なのでVueに比べると 壊しやすいと感じていまして、それでいうとNext.jsもかなりアリかなと思っております。
AWS Amplify自体も元は一つ一つのAWSサービスから成り立っているため使いやすさだけでなくスケーリングやSLAの面でも非常におすすめかと思います。

しかしながらどの技術選定においても、仮説検証を高速に回し、フィードバックをUXに還元することが大事かなと思っています。その選択肢の一つとして非常に魅力的なので今後少しづつ使っていければと思います。

参考

https://aws-amplify.github.io/docs/js/api
https://qiita.com/G-awa/items/a5b2cc7017b1eceeb002

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

React hooksが何故うれしいのか、Reactの今までを含めて解説

はじめに

アルプ株式会社でフロントエンドをしているmura-と申します。
弊社サービスのSaaSを管理するtoBむけプラットフォーム ScalebaseではReactを使ってフロントを構築しており、最近話題になっているhooksも導入しています。
昨年から発表はありましたが、今年の2月に、正式にhooksが利用できるようになったReact v16.8.0がリリースされました。

hooksはReact界にとってかなり良いインパクトを残し、hooksとはなにか、どう便利なのかという記事や話題をたくさん目にするようになったと思います。
hooksを導入することのメリット自体は調べればたくさんでてきますが、この記事ではReactの今まで提供されたコンポーネント変遷を交えて、最終的になぜhooksが導入されもてはやされるようになったかという視点で説明していきます。

TL;DR

忙しい方は 今までの問題点とhooksが解決すること をご覧ください

Reactの変遷

基本的なComponentの定義

hooksを駆使したものを除き、Componentの定義には createClass()React.Component を継承したClassでの定義があります。

React.createClassを使った、React初期のComponentの定義

Class構文に対応している他の言語であれば、Component指向にしようとするとClassで表現するのを思いつきますが、当時Class構文に対応したES2015がまだ正式にリリースされてなかったのもあり、React.createClass() でClass構文を使わずComponentを作成するメソッドが用意されていました。

See the Pen Use React Create Class by Kazuki Murahama (@mura-the-looper) on CodePen.

createClassによる定義でも今と同じようにライフサイクルメソッドやStateの初期値、渡すPropsなどを引数のオブジェクトに定義する必要がありました。

当時はVisual Studio CodeがなくTypeScriptもサポートされてなかったですし、普通のエディタでカジュアルに触ると、長大なオブジェクトの定義でカンマのつけ忘れなどでsyntax errorを起こしてしまうも多くあったことを覚えています。

React.Componentを継承したClassBasedなコンポーネントの定義

Reactは、v0.13から正式にES2015のClass構文に対応しました。

React.ComponentをextendsしてClassを定義すると、そのClassのインスタンスメソッドとしてライフサイクルメソッドを定義できます。creactClassと同じく各インスタンスは render() が必ず実行されます。

See the Pen Use React Class based component by Kazuki Murahama (@mura-the-looper) on CodePen.

Classですので、constractorになにか処理をさせたり、Stateをインスタンス変数として定義できます。

定義において基本的には、React.createClassでやっていたことがClassで書けるようになったことにより、よりComponentらしく書けるようになりました。

※ライフサイクルメソッドはv16.3から大きく変更しています。
参考: React v16.3 changes

パフォーマンスを考慮したコンポーネントの定義

React.Component だけだとシンプルに書きたい場合やパフォーマンスを考慮したときに難点がでてきました。それを解消するためのStateless Functional Componentと React.PureComponent が用意されました。

関数で定義するStateless functinal compnentの定義の仕方

Classを使って書けるのは開発者としては嬉しいですが、Stateすら持たず、ただ render() だけしたいだけのComponentなど、通常のClassによる定義がオーバースペックになる場合がありました。

そこでシンプルでカジュアルに定義できるStateless functional component (SFC)がv0.14で用意されました。

See the Pen StatelessFunctinalComponent by Kazuki Murahama (@mura-the-looper) on CodePen.

JSXをreturnする関数を書けばいいだけなのでだいぶ見通しもいいです。メモリを確保する必要がないことと、React自体がSFCのパフォーマンスを最適化させているのでパフォーマンス的にも向上がはかれます。( 45%速くなった事例もあるようです。)

ただし、個人的には大きなデメリットがあり、SFCを定義したあとでStateをもたせたくなった場合、通常のClassに書き換えざるを得ず大変でした。だったら、最初から通常のComponentを定義しよう、と思っている人もいたのではないかと思います。

React.PureComponentを使ったコンポーネントの定義

Reactでは親のコンポーネントのStateが変わったときなど、その下に含まれる子コンポーネントまで再描画されます。仮に子コンポーネントに渡しているPropsに差分がなくても、再描画してしまうので、子コンポーネントで難しい計算などをしていた場合パフォーマンスに影響がでたりします。shouldComponentUpdate() を使うことでコンポーネントを再描画しないようフラグをたてることができますが、毎回それを書くのは手間ですし忘れます。

v15.3でリリースされた React.PureComponent は、基本的には React.Component と変わらないのですが、自動的に shouldComponentUpdate() を実行して必要以上に再描画しないよう、取り計らってくれます。(ただし、Shallowな比較なのでObjectやArrayのPropsを渡している場合は注意が必要です)

hooksが登場するまでに使われていたComponentと用途のまとめ

諸々経緯があり、いろんなComponentが用意されていますが、hooksが登場するv16.8以前は、通常は下記のような用途で使われていました

Componentの種類 用途 備考
React.createClass()による定義 ES2015などモダンな環境でない場合に使われる。そうでなければ通常使わない。 最近はReactDOMのようにReact本体からも切り離されている
React.Componentを継承したClass Stateをもったり、親Componentとして使われる。通常使われるComponent。
Stateless Functional Component (関数Component) Stateを持たない、難しいことをさせたりしないテンプレートのような用途で使われる。
React.PureComponentを継承したClass Stateをもち、子コンポーネントなどで複雑な計算などをする場合使われる。 v16.6で追加された React.memo() を使えば, Stateさえ持たなければSFCでも役割を担うことができるようになった。もちろん通常のComponentで shouldComponentUpdate() を定義すれば同等なことができるのでプロジェクト次第では使われてないかもしれません。

Componentに共通する振る舞いを与える

ここまでComponent自体について紹介しましたが、定義したComponentに対して、ロジックが一元化された任意の振る舞いやデータを与えたいケースもあります。Componentに任意の振る舞いを与えるために、どうしてきたかということも紹介します。

React.createClass() にのみ存在したMixin

Mixinはかなり便利な機能でした。reactのライフサイクルメソッドとして、componentDidMountcomoponentDidUpdate などがありますが、任意のComponentに好きに振る舞いを注入させることができました。

React.jsのmixinについて

Highr-order Componentを使って振る舞いを与える

ClassBasedなReactではMixinがサポートされなくなりました。Mixinは問題があり ますし、公式でもHigher−order Componentを使えとアナウンスされてました。

Higher-order Component(HoC)自体はReact自体の機能ではなく、あるComponentにPropsや機能などを渡して返すためのイディオムです。Redux Reactの connect() など、ライブラリが作るデータや機能を、Componentに渡すときなどによく使われる手法です。

// react reduxのconnectを使ってReduxのStateやPropsを渡す
connect()(MyComponent)
connect(mapState)(MyComponent)
connect(
  mapState,
  null,
  mergeProps,
  options
)(MyComponent)

See the Pen abzZQrg by Kazuki Murahama (@mura-the-looper) on CodePen.

HoCに関する詳細は下記をごらんください。

高階(Higher-Order)コンポーネント

render propを使って振る舞いを与える

Reactが提供する render prop というものを使って振る舞いを与えることもできます。
render propとHoC、好きな方法を使うことができますが、HoCは名前衝突したり記述が煩雑になったりしますし、型の定義が大変ですのでrender propの方が書きやすいとは思います。

See the Pen RenderPropsComponent by Kazuki Murahama (@mura-the-looper) on CodePen.

hooksを使ったComponentの定義と、今までの問題点とhooksが解決すること

ここまで、長くなってしまいましたが今まで紹介したことを踏まえて、hooksを比較してみます。

hooksで定義したComponent

hooksを使うと、下記のようにComponentが定義できます。

See the Pen React Functional Component by Kazuki Murahama (@mura-the-looper) on CodePen.

まず、大きな点として、React.Componentを使ってClassとして定義しなくて良くなった点が大きいです。

Stateless functinal component(SFC)について先程紹介しましたが、Functional Componentなのに状態を持つことができるようになった、と言うことができます。ここでは仕組みについて言及はしませんが、reactは useState などのAPIを提供したおかげで、関数コンポーネントでStateを扱うことができるようになりました。

また、今まで componentDidMount などで扱っていたであろう、コンポーネントに対する副作用も useEffect などを使って表現できます。

ただそれだけ、といえばそれだけなのですが、今までのReactの問題点を考えるとかなりインパクトがあることがわかります。

今までの問題点とhooksが解決すること

  • 今までのReactの問題点
    • どのコンポーネントを使っていくか都度考えないといけない。
      • SFCを定義したとしてもあとからStateをもたせたくなってClassに書き換える手間がある。
      • PureComponentを使ったとしても結局最適化できない場合があり shouldComponentUpdate() を定義する手間が発生する。など
    • ライフサイクルメソッドの複雑・煩雑さ
      • 一番最初に動作するライフサイクルメソッドはどれか、どのライフサイクルメソッドで副作用がおきるか、関連するロジックであってもライフサイクルごとに定義しないといけないので、いろんなロジックが混ざるとかなり複雑になりバグを生む。
    • Wrapper Hell
      • HoC, render propなどを駆使したとて結局、Componentのネストが深くなってしまい可読性がおちる

※上記は主観もふくまれますので hooksが実装された動機もごらんください

  • hooksが解決すること
    • Componentは基本的にすべてFunctional Componentを使って定義してよい
    • ライフサイクルメソッドから開放され、ロジックはreturnするJSXの前にすべて表現できる そのおかげで下記のメリットがある
      • コードは上から下に実行され、宣言的に書ける
      • ロジック部分を明確にわけることができ、一元的に扱える
      • 状態を直接扱えるため、HoCやrender propを使う必要がなく、Wrapper hellを避けることができる
        • ライブラリもhooksに対応していればそのライブラリが提供する値や関数を直接扱える

先に紹介したReact Reduxも hooksを提供しており connect() を使う必要がなく下記のように扱えます。

import React from 'react'
import { useSelector } from 'react-redux'

export const CounterComponent = () => {
  const counter = useSelector(state => state.counter)
  return <div>{counter}</div>
}

さらなるhooksについての概要はこちらをごらんください
フック早わかり

hooksの問題点

銀の弾丸は存在しませんが、やはりhooksも同様です。下記のような問題点があります

  • Stateが変更されたとき、従来と同じくComponentは再描画される。上から下に実行されるがゆえに、値や関数を必要に応じてメモ化する必要がある。メモ化をうまく扱えないと無限ループを起こしてしまう可能性もある。
  • hooksは制限があり、いつでもどこでも動かせるわけではない。例えばif文のなかで useEffect() は実行できない。
  • メモ化などを含め、新たな考え方が必要になるため、hooksは難しいと感じるケースもある

注意

Context APIなどここでは紹介しきれてないこともたくさんありますがご容赦ください。

おわりに

個人的にも今までComponentの設計やHoC、render prop、Wrapper Hellなど様々なことに悩まされてきましたが、hooksを使うことでかなり見通しのよいComponentを定義できるようになったと実感しています。すべてのライブラリがhooksを提供するわけではありませんが、よく使われている主要なライブラリもhooksを提供しはじめてます。hooksを使うにはReactのバージョンアップしないといけないなどの手間もあると思いますが、それを差し引いてもメリットが大きいのでまだ導入してない方はぜひ使ってみてください。
なにか間違ってる箇所とかありましたらツッコミいただけますと幸いです!※特にサンプルコードは作り込む時間ががが

弊社のサービスScalebaseでも、絶賛hooksに書き換え中です。興味ある方、お手伝いいただける方はぜひご連絡ください!技術に関するお話だけでも大歓迎です。
twitter: https://twitter.com/mura_cx

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

京都の公共施設予約システムの抽選予約を自動化してみる

JavaScript 2 Advent Calendar 2019 の15日目の記事です。

TL;DR

  • 京都市の施設予約のサイトを刷新して欲しい

Background

サークルで毎月京都市の体育館の抽選に応募しているのですが、一日ずつ抽選予約しなければならないのでとても大変...。
だいたい1月あたり10日程度の抽選予約をするのですが、1アカウントで完了するのに15〜20分ほどかかる...。
サークル内でアカウントがだいたい20アカウントほどあり、手分けしているにせよ 20垢x20分=400分 を全員が無駄に負担していることになるので、どうにか自動化して世界を平和にしたい。

と思って、自動化を始めたのですが、サイトがなかなかにレガシーでとても大変だったので、自動化の流れを puppeteer の一つのユースケースとしてつらつらと書いていきます。

そしてこのbotが動かなくなるのは良いので強く本サイトの改善を京都の片隅より祈っております...

※ puppeteerに関しての基本的なことは特に書かないので 公式ドキュメント をご参照ください

※ 抽選が毎月1日〜9日なので抽選自体の全体のキャプチャーが取れませんでした。来月の抽選時に更新するかもしないかもしれません。

自動化したサイト

京都府・市町村共同 公共施設案内予約システム
https://g-kyoto.pref.kyoto.lg.jp/reserve_j/core_i/init.asp?SBT=1

URLで察するASP.NET。別に悪いとは言っているわけではないが、個人的にはなかなかいい思い出がありません。
ちなみにドメインのルート(https://g-kyoto.pref.kyoto.lg.jp/ )に以下のような画面が表示されます。
スクリーンショット 2019-12-15 15.31.19.png
なんだろう...一周回って、Health CheckをRootでやってるのかな?、と見ることができる人もいるかもしれない。

あとは不要な処理をしようとするととてもユーザーを不安にさせる文言がみれます。
スクリーンショット 2019-12-15 16.14.30.png
不正なアクセスはドキッとするのでやめてほしいです。

なんとなく辛そうな予感がしてくる。

Flow

基本的には以下のようなユーザー操作ができれば抽選予約はできそうです。

puppeteer.launch({ headless: false }).then(async browser => {
  // サイトに遷移
  // ログイン
  // 施設選択
  // 日付選択
  // 利用用途入力
  // 確定
})

headless: false に関しては突然別のwindowを開いて謎に内容をsyncする場面があり、 headlessでは対処できなかったのでfalseにしています。後ほどその場面がでてきます。

実装

サイトに遷移

特に問題なく、サイトに遷移します。

const pages = await browser.pages()

// 0番目のタブ
const page = pages[0]

// サイトに遷移
await page.goto('https://g-kyoto.pref.kyoto.lg.jp/reserve_j/core_i/init.asp?SBT=1')

ログインする

まずはログインをしないと予約までできないのでログインします。
1. TOPでマイメニューボタンを押す
2. formを入力してログインをする
だけです。

マイメニューボタンを押す

スクリーンショット 2019-12-15 15.44.47.png
ボタンを押したいのでコードをみます。
スクリーンショット 2019-12-15 15.49.17.png
f...fra...frameset!!!!

個人的には初めて使っているサイトをみたのですが、framesetはHTML5でサポートしていません。(僕が小学生くらいのときでもiframe使ってた気が...)
このframesetがあるおかげで簡単に書けるボタンのクリックが中のフレームを参照しなければなりません。
そしてこの処理はすべての画面で同じ構成です。
この構成をするのであれば中のframeのURLのみが変わるかと思いきや、ページ自体のURL遷移がある場合とframe内のみ更新される場合がありました。
なので毎回処理のたびにpageからframeを参照する必要があります。

frameを参照する
// サイトに遷移

// 画面(frame)のロードを待つ
await page.waitFor('frame')

// frameの取得 (絶対にあると仮定)
const frame = page.mainFrame().childFrames()[0]

これでやっとframe内のコンテンツにアクセスできます。
すべてのコンテンツがframe内にあるのですべての(click, type, select..等)の処理でframeを参照する必要があります。

やっとコンテンツにアクセスできるようになったのでマイメニューのボタンを押したいのでdevtoolでどう押すか要素の確認をします。
スクリーンショット 2019-12-15 16.18.41.png
スクリーンショット 2019-12-15 16.18.53.png
type='image'...!! 最近使わない人が多いと思います。
そして、 onclick='i40_click();return false;'
すべての関数に i01_clickiNN_clickの関数が仕様書にガッチリ書かれていたのか、ASP.NET関連の話なのかはわかりませんが、jQuery以前の時代につくられた感じはします。
できれば name でボタン選択したかったのですが、TOPページのすべてのボタンが name='btn_riyouhouhou_click' です。
判断できるものがないので、ここはしょうがなく

マイメニューボタンのクリック
// サイトに遷移
// frameの取得

// マイメニューボタンをクリック
frame.click('input[value="マイメニュー"]')

です。
あまり参照にマルチバイトは使いたくないですし、valueにマルチバイトは使わないほうが良いと個人的には思います。

ログインする

スクリーンショット 2019-12-15 15.45.50.png
やっとログインができそうです。
formにid, passwordを入力して、OKボタンを押せば良さそうです。
スクリーンショット 2019-12-15 16.51.00.png
ここは意外と普通ですね。

id,passwordを入力
await frame.type('input[name="txt_usr_cd"]', USER_ID)
await frame.type('input[name="txt_pass"]', USER_PASSWORD)

OKボタンを押します。
スクリーンショット 2019-12-15 16.52.39.png
こういう name を求めていましたが、value が全角のスペース込の "O K" なのがとてもユニークです。

OKボタンをクリック
await frame.click('input[name="btn_ok"]')

前途多難なログインが完了しました。

施設選択

TOPから施設名検索ページへ移動します。もうおなじみです

await frame.click('input[value="施設名検索"]')

スクリーンショット 2019-12-15 17.03.38.png
フルで施設名を入力して、所在地を指定せずに検索すればよさそうです。
以下が、テキスト入力と選択するボタンです
スクリーンショット 2019-12-15 17.05.03.png
このページでは cmdXXX_click の命名規則のようです。

// 施設名入力
await frame.type('input[name="txt_keyword"]', FACILITY_NAME)

// 所在地を指定せずに検索ボタンを押す
await frame.click('input[name="btn_shortcut"]')

つぎのページで検索結果が出てくるので施設のリストから "抽選" ボタンをクリックします。
スクリーンショット 2019-12-15 17.12.40.png
基本的には抽選結果がひとつだけでてくるという想定ですがこのinputにはnameが存在しません。
幸いpuppeteerが優秀なのでaltで指定します。
スクリーンショット 2019-12-15 17.17.24.png

await frame.click('input[alt="抽選予約画面へ"]')

施設選択が完了しました。

日付選択

スクリーンショット 2019-12-15 17.19.35.png

UIをみて分かる通り最難関です。
ユーザー操作のflowとしては
1. 年度選択
2. 月選択
3. 日選択
4. 施設内の使いたい場所選択
5. 時間帯選択
6. コートの面数選択
7. 次へボタンをクリック
の7段階です。

年・月・日付のテキストには、全てに同じクラスがつけられていて、判断ができない状態だったのでXPathで文字列を検索してクリックします。

const year = "予約年"
const month = "予約月"
const day = "予約日"

const escapeXpathString = (str: string) => {
  const splitedQuotes = str.replace(/'/g, `', "'", '`)
  return `concat('${splitedQuotes}', '')`
}

// 任意のテキストリンクをクリックする関数
const clickByText = async (page: puppeteer.Page, text: string) => {
  const frame = page.mainFrame().childFrames()[0]
  const escapedText = escapeXpathString(text)
  const linkHandlers = await frame.$x(`//a[contains(text(), ${escapedText})]`)
  if (linkHandlers.length > 0) {
    linkHandlers[0].click()
  } else {
    // throw error
  }
}

// 年選択
await clickByText(`${year}年`) 
await frame.waitFor() // loadを待つ

// 月選択
await clickByText(`${month}月`)
await frame.waitFor() // loadを待つ

// 日選択
await clickByText(`${day}`)
await frame.waitFor() // loadを待つ

無事に申し込みたい日の申し込みたい時間の施設を選択すると山場です。
使いたいコートの領域を選択するために別windowでの操作が始まります。
スクリーンショット 2019-12-15 17.46.31.png
browserから別のtargetが開かれると targetcreated というイベントが呼ばれるのでその中でハンドリングします。
closeされたときは targetdestroyed が呼ばれるのでメインwindowでそれをハンドリングします。

puppeteer.launch({ headless: false }).then(async browser => {
  // 別のtargetが開いた
  browser.on('targetcreated', async (target) => {
    const page = await target.page();

    // page内でalert等が呼ばれた際のイベント
    // 確認alertが何回も出てくるのですべてokを押す
    page.on('dialog', (dialog) => {
      dialog.accept();
    })

    if (/Lot_i/.test(target.url())) { // domainの確認
        // なぜか初期選択が可能になるまでに時間を要する(特にajaxをしているわけではない)
        await page.waitFor(2000);

        // コート一面を選択
        await page.select('select[name="men_1_1"]', '1');

        // なぜかセレクターを変更した後にreloadが走る
        // なぜか初期選択が可能になるまでに時間を要する(特にajaxをしているわけではない)
        await page.waitFor(2000);

        // コート確定 (ここでmainのwindowにsyncされるはず)
        await page.click('input[value="O K"]');
    }
  })

  // 任意の時間帯が選択されているかを確認
  const isSelectedTimeSpan = (page: puppeteer.Page) => {
    // なぜかページ全体がリロードされるのでframeの再取得が必要
    const frame = page.mainFrame().childFrames()[0]
    return frame.evaluate(() => {
      return document.querySelectorAll('img[alt="選択中"]').length > 1;
    })
  }

  // コート選択のtargetが閉じた
  browser.on('targetdestroyed', async (target) => {
    // 一応URLでフィルター
    if (/Lot_i/.test(target.url())) {
      // たまに別windowで選択した内容がsyncされないのでsyncを確認
      if (await isSelectedTimeSpan(page)) {
        // つぎへを押して利用用途入力へ
        await frame.click(`input[alt="次へ"]`)
      } else {
        // もう一度コートを選択(日付を選択して別windowを開く)
      }
    }
  })
})

利用用途入力/確定

あとはだいたいようなコードを書けばよいので省略します。

特につまったポイント

  • pageがrelaodされる場合とframeがreloadされる場合がある
    • puppeteerの各操作APIをwrapして毎回pageからframeを取得することで解決
    • つぎの操作をするために何を待つか、の判断が大変
  • なぜか2秒またなければならないことがある
    • わからない。なにしてるの

まとめ

最初はAPI Requestをみて自動化しようと思いましたが、難解すぎたのでpuppeteerで実装する方向に舵を切りました。
レガシーなサイトに苦しめられつつもなんとか自動化できて全体の予約がワンクリック(ワン・コマンド)でできるようになりました。
時間としても全アカウントを回してだいたい30分前後。自動化してもそんなにかかるのかよ...とも思いましたが、APEX LEGENDSを30分プレイしている間に全部終わってくれるのでとても助かります。
単純作業を繰り返してみんなのサイコパスが曇らない、という点でとても良き開発でした。

面白いので京都市の施設予約サイトの中身をinspectorで覗いてみてください。
https://g-kyoto.pref.kyoto.lg.jp/reserve_j/core_i/init.asp?SBT=1

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

京都の公共施設予約システム(ヤバい)の抽選予約を自動化してみる

JavaScript 2 Advent Calendar 2019 の15日目の記事です。

TL;DR

  • 京都市の施設予約のサイトを刷新して欲しい

Background

サークルで毎月京都市の体育館の抽選に応募しているのですが、一日ずつ抽選予約しなければならないのでとても大変...。
だいたい1月あたり10日程度の抽選予約をするのですが、1アカウントで完了するのに15〜20分ほどかかる...。
サークル内でアカウントがだいたい20アカウントほどあり、手分けしているにせよ 20垢x20分=400分 を全員が無駄に負担していることになるので、どうにか自動化して世界を平和にしたい。

と思って、自動化を始めたのですが、サイトがなかなかにレガシーでとても大変だったので、自動化の流れを puppeteer の一つのユースケースとしてつらつらと書いていきます。

そしてこのbotが動かなくなるのは良いので強く本サイトの改善を京都の片隅より祈っております...

※ puppeteerに関しての基本的なことは特に書かないので 公式ドキュメント をご参照ください

※ 抽選が毎月1日〜9日なので抽選自体の全体のキャプチャーが取れませんでした。来月の抽選時に更新するかもしないかもしれません。

自動化したサイト

京都府・市町村共同 公共施設案内予約システム
https://g-kyoto.pref.kyoto.lg.jp/reserve_j/core_i/init.asp?SBT=1

URLで察するASP.NET。別に悪いとは言っているわけではないが、個人的にはなかなかいい思い出がありません。
ちなみにドメインのルート(https://g-kyoto.pref.kyoto.lg.jp/ )に以下のような画面が表示されます。
スクリーンショット 2019-12-15 15.31.19.png
なんだろう...一周回って、Health CheckをRootでやってるのかな?、と見ることができる人もいるかもしれない。

あとは不要な処理をしようとするととてもユーザーを不安にさせる文言がみれます。
スクリーンショット 2019-12-15 16.14.30.png
不正なアクセスはドキッとするのでやめてほしいです。

なんとなく辛そうな予感がしてくる。

Flow

基本的には以下のようなユーザー操作ができれば抽選予約はできそうです。

puppeteer.launch({ headless: false }).then(async browser => {
  // サイトに遷移
  // ログイン
  // 施設選択
  // 日付選択
  // 利用用途入力
  // 確定
})

headless: false に関しては突然別のwindowを開いて謎に内容をsyncする場面があり、 headlessでは対処できなかったのでfalseにしています。後ほどその場面がでてきます。

実装

サイトに遷移

特に問題なく、サイトに遷移します。

const pages = await browser.pages()

// 0番目のタブ
const page = pages[0]

// サイトに遷移
await page.goto('https://g-kyoto.pref.kyoto.lg.jp/reserve_j/core_i/init.asp?SBT=1')

ログインする

まずはログインをしないと予約までできないのでログインします。
1. TOPでマイメニューボタンを押す
2. formを入力してログインをする
だけです。

マイメニューボタンを押す

スクリーンショット 2019-12-15 15.44.47.png
ボタンを押したいのでコードをみます。
スクリーンショット 2019-12-15 15.49.17.png
f...fra...frameset!!!!

個人的には初めて使っているサイトをみたのですが、framesetはHTML5でサポートしていません。(僕が小学生くらいのときでもiframe使ってた気が...)
このframesetがあるおかげで簡単に書けるボタンのクリックが中のフレームを参照しなければなりません。
そしてこの処理はすべての画面で同じ構成です。
この構成をするのであれば中のframeのURLのみが変わるかと思いきや、ページ自体のURL遷移がある場合とframe内のみ更新される場合がありました。
なので毎回処理のたびにpageからframeを参照する必要があります。

frameを参照する
// サイトに遷移

// 画面(frame)のロードを待つ
await page.waitFor('frame')

// frameの取得 (絶対にあると仮定)
const frame = page.mainFrame().childFrames()[0]

これでやっとframe内のコンテンツにアクセスできます。
すべてのコンテンツがframe内にあるのですべての(click, type, select..等)の処理でframeを参照する必要があります。

やっとコンテンツにアクセスできるようになったのでマイメニューのボタンを押したいのでdevtoolでどう押すか要素の確認をします。
スクリーンショット 2019-12-15 16.18.41.png
スクリーンショット 2019-12-15 16.18.53.png
type='image'...!! 最近使わない人が多いと思います。
そして、 onclick='i40_click();return false;'
すべての関数に i01_clickiNN_clickの関数が仕様書にガッチリ書かれていたのか、ASP.NET関連の話なのかはわかりませんが、jQuery以前の時代につくられた感じはします。
できれば name でボタン選択したかったのですが、TOPページのすべてのボタンが name='btn_riyouhouhou_click' です。
判断できるものがないので、ここはしょうがなく

マイメニューボタンのクリック
// サイトに遷移
// frameの取得

// マイメニューボタンをクリック
frame.click('input[value="マイメニュー"]')

です。
あまり参照にマルチバイトは使いたくないですし、valueにマルチバイトは使わないほうが良いと個人的には思います。

ログインする

スクリーンショット 2019-12-15 15.45.50.png
やっとログインができそうです。
formにid, passwordを入力して、OKボタンを押せば良さそうです。
スクリーンショット 2019-12-15 16.51.00.png
ここは意外と普通ですね。

id,passwordを入力
await frame.type('input[name="txt_usr_cd"]', USER_ID)
await frame.type('input[name="txt_pass"]', USER_PASSWORD)

OKボタンを押します。
スクリーンショット 2019-12-15 16.52.39.png
こういう name を求めていましたが、value が全角のスペース込の "O K" なのがとてもユニークです。

OKボタンをクリック
await frame.click('input[name="btn_ok"]')

前途多難なログインが完了しました。

施設選択

TOPから施設名検索ページへ移動します。もうおなじみです

施設名検索へ
await frame.click('input[value="施設名検索"]')

スクリーンショット 2019-12-15 17.03.38.png
フルで施設名を入力して、所在地を指定せずに検索すればよさそうです。
以下が、テキスト入力と選択するボタンです
スクリーンショット 2019-12-15 17.05.03.png
このページでは cmdXXX_click の命名規則のようです。

施設名検索
// 施設名入力
await frame.type('input[name="txt_keyword"]', FACILITY_NAME)

// 所在地を指定せずに検索ボタンを押す
await frame.click('input[name="btn_shortcut"]')

つぎのページで検索結果が出てくるので施設のリストから "抽選" ボタンをクリックします。
スクリーンショット 2019-12-15 17.12.40.png
基本的には抽選結果がひとつだけでてくるという想定ですがこのinputにはnameが存在しません。
幸いpuppeteerが優秀なのでaltで指定します。
スクリーンショット 2019-12-15 17.17.24.png

抽選予約画面へ
await frame.click('input[alt="抽選予約画面へ"]')

施設選択が完了しました。

日付選択

スクリーンショット 2019-12-15 17.19.35.png

UIをみて分かる通り最難関です。
ユーザー操作のflowとしては
1. 年度選択
2. 月選択
3. 日選択
4. 施設内の使いたい場所選択
5. 時間帯選択
6. コートの面数選択
7. 次へボタンをクリック
の7段階です。

年・月・日付のテキストには、全てに同じクラスがつけられていて、判断ができない状態だったのでXPathで文字列を検索してクリックします。

Date選択
const year = "予約年"
const month = "予約月"
const day = "予約日"

const escapeXpathString = (str: string) => {
  const splitedQuotes = str.replace(/'/g, `', "'", '`)
  return `concat('${splitedQuotes}', '')`
}

// 任意のテキストリンクをクリックする関数
const clickByText = async (page: puppeteer.Page, text: string) => {
  const frame = page.mainFrame().childFrames()[0]
  const escapedText = escapeXpathString(text)
  const linkHandlers = await frame.$x(`//a[contains(text(), ${escapedText})]`)
  if (linkHandlers.length > 0) {
    linkHandlers[0].click()
  } else {
    // throw error
  }
}

// 年選択
await clickByText(`${year}年`) 
await frame.waitFor() // loadを待つ

// 月選択
await clickByText(`${month}月`)
await frame.waitFor() // loadを待つ

// 日選択
await clickByText(`${day}`)
await frame.waitFor() // loadを待つ

無事に申し込みたい日の申し込みたい時間の施設を選択すると山場です。
使いたいコートの領域を選択するために別windowでの操作が始まります。
スクリーンショット 2019-12-15 17.46.31.png
browserから別のtargetが開かれると targetcreated というイベントが呼ばれるのでその中でハンドリングします。
closeされたときは targetdestroyed が呼ばれるのでメインwindowでそれをハンドリングします。

利用コート面選択
puppeteer.launch({ headless: false }).then(async browser => {
  // 別のtargetが開いた
  browser.on('targetcreated', async (target) => {
    const page = await target.page();

    // page内でalert等が呼ばれた際のイベント
    // 確認alertが何回も出てくるのですべてokを押す
    page.on('dialog', (dialog) => {
      dialog.accept();
    })

    if (/Lot_i/.test(target.url())) { // domainの確認
        // なぜか初期選択が可能になるまでに時間を要する(特にajaxをしているわけではない)
        await page.waitFor(2000);

        // コート一面を選択
        await page.select('select[name="men_1_1"]', '1');

        // なぜかセレクターを変更した後にreloadが走る
        // なぜか初期選択が可能になるまでに時間を要する(特にajaxをしているわけではない)
        await page.waitFor(2000);

        // コート確定 (ここでmainのwindowにsyncされるはず)
        await page.click('input[value="O K"]');
    }
  })

  // 任意の時間帯が選択されているかを確認
  const isSelectedTimeSpan = (page: puppeteer.Page) => {
    // なぜかページ全体がリロードされるのでframeの再取得が必要
    const frame = page.mainFrame().childFrames()[0]
    return frame.evaluate(() => {
      return document.querySelectorAll('img[alt="選択中"]').length > 1;
    })
  }

  // コート選択のtargetが閉じた
  browser.on('targetdestroyed', async (target) => {
    // 一応URLでフィルター
    if (/Lot_i/.test(target.url())) {
      // たまに別windowで選択した内容がsyncされないのでsyncを確認
      if (await isSelectedTimeSpan(page)) {
        // つぎへを押して利用用途入力へ
        await frame.click(`input[alt="次へ"]`)
      } else {
        // もう一度コートを選択(日付を選択して別windowを開く)
      }
    }
  })
})

利用用途入力/確定

あとはだいたい同じようなコードを書けばよいので省略します。

特につまったポイント

  • pageがreloadされる場合とframeがreloadされる場合がある
    • つぎの操作をするために何を待つか、の判断が大変
    • puppeteerの各操作APIをwrapして毎回pageからframeを取得することで解決
  • なぜか2秒またなければならないことがある
    • わからない。なにしてるの

まとめ

最初はAPI Requestをみて自動化しようと思いましたが、難解すぎたのでpuppeteerで実装する方向に舵を切りました。
レガシーなサイトに苦しめられつつもなんとか自動化できて全体の予約がワンクリック(ワン・コマンド)でできるようになりました。
時間としても全アカウントを回してだいたい30分前後。自動化してもそんなにかかるのかよ...とも思いましたが、APEX LEGENDSを30分プレイしている間に全部終わってくれるのでとても助かります。
単純作業を繰り返してみんなのサイコパスが曇らない、という点でとても良き開発でした。

面白いので京都市の施設予約サイトの中身をinspectorで覗いてみてください。
https://g-kyoto.pref.kyoto.lg.jp/reserve_j/core_i/init.asp?SBT=1

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

HTMLのtextarea でインデント、アンインデント

HTMLのtextarea でインデント、アンインデント

HTMLのtextareaでタブを入力すると次のフィールドにフォーカスが移動しますが、そのままタブを入力したい。ついでに複数行選択しているときは、まとめてインデントしたい。更にスペースでのインデント、アンインデントにも対応したい。
ということで、書いてみました。
作ってから IE/Edge で setRangeText がサポートされていない事に気づいたけれど、このまま載せておきます...

indent_sample.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" context="text/html; charset=UTF-8" />
  <meta charset="UTF-8" />
  <title>インデントのサンプル</title>
<script type="text/javascript">
'use strict';

window.onload = function() {
    const onKeyDown = function(ev) {
        if (((ev.keyCode != 9) && (ev.keyCode != 32)) || ev.ctrlKey || ev.altKey) { return true; }
        ev.preventDefault();
        const str = (ev.keyCode == 32) ? " " : "\t", TABWIDTH = 4, CRLF = [13, 10];
        let e = ev.target, start = e.selectionStart, end = e.selectionEnd, sContents = e.value, top = e.scrollTop;
        if ((start == end) || !sContents.includes("\n")) {
            e.setRangeText(str, start, end, "end");
            return;
        }
        if (CRLF.indexOf(sContents.charCodeAt(end - 1)) < 0) {
            for ( ; end < sContents.length; end++) {
                if (CRLF.indexOf(sContents.charCodeAt(end)) >= 0) { break; }
            }
        }
        for ( ; start > 0; start--) {
            if (CRLF.indexOf(sContents.charCodeAt(start - 1)) >= 0) { break; }
        }
        let v = sContents.substring(start, end).split("\n");
        for (let i = 0; i < v.length; i++) {
            if (v[i] == "") { continue; }
            if (!ev.shiftKey) {     //indent
                v[i] = str + v[i];
            } else {                //unindent
                if (str == "\t") {
                    for (let j = 0, c = " "; (j < TABWIDTH) && (c == " "); j++) {
                        c = v[i].substring(0, 1);
                        if ((c == " ") || (j == 0 && c == "\t")) { v[i] = v[i].substring(1); }
                    }
                } else if (v[i].substring(0, 1) == " ") {
                    v[i] = v[i].substring(1);
                }
            }
        }
        e.setRangeText(v.join("\n"), start, end, "select");
    }

    document.getElementById("txtSample").addEventListener("keydown", onKeyDown);
}
</script>
</head>
<body>
<ul>
<li>文字列を未選択でスペース → スペース入力</li>
<li>文字列を未選択でタブ → タブ入力</li>
<li>文字列を選択してスペース → スペースインデント</li>
<li>文字列を選択してタブ → タブインデント</li>
<li>文字列を選択してシフト+スペース → スペースアンインデント</li>
<li>文字列を選択してシフト+タブ → タブアンインデント</li>
</ul>
<textarea id="txtSample" rows="30" style="width:100%;resize:none;"></textarea>
</body>
</html>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaScriptで配列の要素の重複をチェックする

業務で学んだJavaScriptで配列の要素の重複をチェックする方法について書きます。

コード

引数で渡した配列の要素に重複がある場合、trueを返します。重複がない場合は、falseを返します。

function isDuplicated(elements) {
  // Setを使って、配列の要素を一意にする
  const setElements = new Set(elements);
  return setElements.size !== elements.length;
}

Setについて

Setオブジェクト

プリミティブ値・オブジェクト参照を問わず、あらゆる型で一意の値を格納できます。

Setの要素数の求め方

Set に含まれる要素の数を知りたい場合は Set.prototype.size を使います。

.lengthの値は0になってしまうので使うことができません。

// 重複がある場合
const foods = ["りんご", "ケーキ", "ブロッコリー", "りんご"];

console.log(isDuplicated(foods));
// => true

// 重複がない場合
const countries = ["日本", "アメリカ", "中国", "インド"];

console.log(isDuplicated(foods));
// => false

 
以上です!この記事が役に立つと嬉しいです!

参考

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

【Vue.js/Nuxt.js】モーダルを使ってリストの1件を削除すると違うやつが消える

こちらのサイトを参考に、モーダルを使ってリストからidを指定して1件削除しようとしたら、なぜかリストの最後だけ削除されてしまう。

(実際はvuexを使ってたり、追加・変更ボタンもあるが簡略化)

coping.vue
<template>
  <div>
    <table>
      <thead>
        <tr>
          <th width="30%">名前</th>
          <th width="50%">説明</th>
          <th></th>
        </tr>
        <tr v-for="(coping, index) in coping_list" :key="index" class="coping">
          <td>
            <input v-model="coping.name" type="text" />
          </td>
          <td>
            <input v-model="coping.detail" type="text" />
          </td>
          <!-- 押されたら削除モーダルを開く -->
          <td @click="openDeleteModal()">
            <i class="fas fa-times"></i>
          </td>
          <!-- 削除しますか?のモーダルを開く
          「削除」で1件削除(closeDeleteModal()を呼び出す)
          「キャンセル」でモーダルを閉じる(deleteCoping()を呼び出す)
           -->
          <DeleteModal
            v-if="is_delete_modal"
            @close="closeDeleteModal()"
            @delete="deleteCoping(coping.id)"
          />
        </tr>
      </thead>
    </table>
  </div>
</template>

<script>
import DeleteModal from '~/components/DeleteModal.vue'
export default {
  components: {
    DeleteModal
  },
  data() {
    return {
      coping_list: [
        { id: 1, name: 'aaa', detail: 'AAA' },
        { id: 2, name: 'bbb', detail: 'BBB' }
      ],
      // モーダルの表示・非表示を管理
      is_delete_modal: false
    }
  },
  methods: {
    // IDで指定したコーピングの削除
    // モーダルからcoping.idを指定すると、なぜか最後のIDが指定されてしまう
    deleteCoping(copingId) {
      // 引数に持ったID以外のリストを作る
      this.coping_list = this.coping_list.filter(
        (coping) => coping.id !== copingId
      )
      this.closeDeleteModal()
    },
    // モーダルを表示する
    openDeleteModal() {
      this.is_delete_modal = true
    },
    // モーダルを非表示にする
    closeDeleteModal() {
      this.is_delete_modal = false
    }
  }
}
</script>

2019-12-15_17h48_53.png
2019-12-15_17h49_52.png
2019-12-15_17h50_27.png

原因

削除モーダルをテーブルの行ごとに作っていたのが誤作動の原因。モーダルを呼び出したときに最後のモーダルだけが実行されていた。

解決策

削除モーダルを1つだけにする。
削除するIDは削除用IDとしてdata()に持つ

coping.vue
<template>
  <div>
    <table>
      <thead>
        <tr>
          <th width="30%">名前</th>
          <th width="50%">説明</th>
          <th></th>
        </tr>
        <tr v-for="(coping, index) in coping_list" :key="index" class="coping">
          <td>
            <input v-model="coping.name" type="text" />
          </td>
          <td>
            <input v-model="coping.detail" type="text" />
          </td>
          <!-- 押されたら削除モーダルを開く
          この時に押された行のIDを渡す -->
          <td @click="openDeleteModal(coping.id)">
            <i class="fas fa-times"></i>
          </td>
        </tr>
      </thead>
    </table>
    <!-- 削除モーダルはテーブルの外に出す
    deleteCoping()の引数はdata()から貰う-->
    <DeleteModal
      v-if="is_delete_modal"
      @close="closeDeleteModal()"
      @delete="deleteCoping(delete_id)"
    />
  </div>
</template>

<script>
import DeleteModal from '~/components/DeleteModal.vue'
export default {
  components: {
    DeleteModal
  },
  data() {
    return {
      coping_list: [
        { id: 1, name: 'aaa', detail: 'AAA' },
        { id: 2, name: 'bbb', detail: 'BBB' }
      ],
      // モーダルの表示・非表示を管理
      is_delete_modal: false,
      // 削除用のID
      delete_id: null
    }
  },
  methods: {
    // IDで指定したコーピングの削除
    // モーダルからcoping.idを指定すると、なぜか最後のIDが指定されてしまう
    deleteCoping(copingId) {
      // 引数に持ったID以外のリストを作る
      this.coping_list = this.coping_list.filter(
        (coping) => coping.id !== copingId
      )
      this.closeDeleteModal()
    },
    openDeleteModal(copingId) {
      this.is_delete_modal = true
      // ここで削除用IDを設定
      this.delete_id = copingId
    },
    closeDeleteModal() {
      this.is_delete_modal = false
    }
  }
}
</script>

ちゃんと指定したIDのリストを削除してくれるようになった。めでたしめでたし。

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

new Vue() に渡されたオプションオブジェクトの行方を探るべく、我々は vue/src/core の奥地へと向かった

qnote Advent Calendar 2019 の16日目です。

はじめに

こんにちは。今日も元気に npm run してますか?
Vue.js 、いいですよね、ドキュメントも豊富で簡単でとっても便利。
しかしフレームワークとして簡単に使えてしまうあまり、 Vue の中身を気にすることはあまりないのではないでしょうか。
今日はそんな Vue の中身を覗いて、その謎を少しだけ解明してみることにしましょう。
取り上げるのは、 Vue インスタンスに渡されるオプションオブジェクトの行方です。

オプションオブジェクトの行方

オプションオブジェクトは大まかに、下記の流れで各オプションとして機能するように定義されていきます。

  1. new Vue() に渡される
  2. initMixin()vm.$options が定義される
  3. init...() メソッドでリアクティブシステムへの追加などが行われる
  4. 我々の手に届く

ではオプションオブジェクトの長く険しい道のりを、一緒に追っていきましょう。

vm.$options が定義されるまで

new Vue()

全ての始まり、コンストラクタ関数 Vue()
ここにオプションオブジェクトを渡すことで、 Vue インスタンスが生成されます。
これが定義されている箇所は vue/src/core/instance/index.js です。

vue/src/core/instance/index.js
function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

this._init() にオプションを渡していますね。
この中身を追ってみましょう。

initMixin()

this._init()vue/src/core/instance/init.js で定義されています。

vue/src/core/instance/init.js
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

    let startTag, endTag
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      startTag = `vue-perf-start:${vm._uid}`
      endTag = `vue-perf-end:${vm._uid}`
      mark(startTag)
    }

    // a flag to avoid this being observed
    vm._isVue = true
    // merge options
    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
...

ここで気になるコメントがありました。
/* istanbul ignore if */
イスタンブール? :flag_tr: :thinking: :question:
調べてみたら、テストのカバレッジを調べてくれるツールのようでした
イスタンブールといえば、飛んでイスタンブールしか思い浮かばなかったのですが、新たな知識を得ることができました。

話がそれましたが、オプションは mergeOptions() に渡されているようですね。

mergeOptions()

mergeOptions()vue/src/core/util/options.js で定義されています。

vue/src/core/util/options.js
/**
 * Merge two option objects into a new one.
 * Core utility used in both instantiation and inheritance.
 */
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  if (process.env.NODE_ENV !== 'production') {
    checkComponents(child)
  }

  if (typeof child === 'function') {
    child = child.options
  }

  normalizeProps(child, vm)
  normalizeInject(child, vm)
  normalizeDirectives(child)

  // Apply extends and mixins on the child options,
  // but only if it is a raw options object that isn't
  // the result of another mergeOptions call.
  // Only merged options has the _base property.
  if (!child._base) {
    if (child.extends) {
      parent = mergeOptions(parent, child.extends, vm)
    }
    if (child.mixins) {
      for (let i = 0, l = child.mixins.length; i < l; i++) {
        parent = mergeOptions(parent, child.mixins[i], vm)
      }
    }
  }

  const options = {}
  let key
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
  function mergeField (key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

mergeOptions() には3つの引数が渡されています。
parent として渡される resolveConstructorOptions(vm.constructor)よくわからなかったので説明を省略いたします。
オプションオブジェクトは child として第2引数に渡されていますね。
第3引数には自身である Vue インスタンスが vm として渡されています。

checkComponents(child)
ここでは components オプションの値をチェックし、変な名前が使用されていないか、などをチェックしています。

normalizeProps(child, vm)
normalizeInject(child, vm)
normalizeDirectives(child)
この3つの関数はそれぞれ props , inject , directives オプションの内容の解析を行なっています。

次に、child がもつ extendmixin を考慮した処理が行われています。
mixin の数だけ mergeOptions を繰り返し、定義していることがわかります。

if (child.mixins) {
  for (let i = 0, l = child.mixins.length; i < l; i++) {
    parent = mergeOptions(parent, child.mixins[i], vm)
  }
}

その後オプションは一旦、空のオブジェクトとして定義され、 mergeField()parentchild のオプションがマージされ、プロパティ毎の結果がオプションオブジェクトに格納されていきます。

最後にマージされたオプションオブジェクトが return され、 vm.$options に入るわけですね。

data オプションがリアクティブシステムに追加されるまで

全部のオプションの行方を追うのは大変なので、今回は data がリアクティブシステムに追加されるまでに焦点を当ててみましょう。
再び initMixin() に戻ります。
vm.$options が定義されたのち、 様々な init...() を経て、 initState(vm) にインスタンスが渡っています。

vue/src/core/instance/init.js
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')
...

initState()

initState()vue/src/core/instance/state.js に定義されています。

vue/src/core/instance/state.js
export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

メソッドの名前から、 initProps() では props を、 initMethods() では methods を定義していることがわかります。
読みやすいですね。
では、 initData() の中身を見ていきましょう。

initData()

initData()initState() と同じ vue/src/core/instance/state.js に定義されています。

vue/src/core/instance/state.js
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}

data オブジェクトの key の数だけ while で回して、 propsmethods ですでに定義されている名前でないかをチェックしています。
キーの名前は methodsprops が優先ということですね。

そして isReserved(key) でキー名が _ または $ から始まっていないことをチェックして、 proxy() に渡しています。

vue/src/core/instance/state.js
export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

これで data のプロパティに、 vm インスタンスから代理アクセスできるようになります。
_ または $ から始まるプロパティには、公式リファレンスにもある通りvm.$data._property としてアクセスします。

さていよいよ大詰めです。 observe() の中身を見ていきましょう。

observe()

observe()vue/src/core/observer/index.js に定義されています。

vue/src/core/observer/index.js
/**
 * Attempt to create an observer instance for a value,
 * returns the new observer if successfully observed,
 * or the existing observer if the value already has one.
 */
export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

new Observer()Observer インスタンスを作成しています。

vue/src/core/observer/index.js
/**
 * Observer class that is attached to each observed
 * object. Once attached, the observer converts the target
 * object's property keys into getter/setters that
 * collect dependencies and dispatch updates.
 */
export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
...

data はオブジェクトなので walk() に渡り、 defineReactive(obj, keys[i]) に渡されています。

defineReactive()

vue/src/core/observer/index.js
/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

ここで Object.defineProperty() を使用しています。

Object.defineProperty

この仕組みを利用して、リアクティブシステムを可能にしているわけですね。
この Object.defineProperty が使用できない関係上、 Vue は IE8 以下をサポートしていないらしいです

さいごに

以上がオプションオブジェクト、というか data がリアクティブシステムに登録されるまでの流れでした。
お疲れ様でした。

Vue の中身ってそういえば気にしたことなかったな、と思い読んでみたのですが、なんとなく使っていたリアクティブシステムの仕組みを知ることができてよかったです。
頭のいい人たちが書いたコードだけあって、とても読みやすくて勉強になりました。
ただ読んだ本人(私)があまり頭がよくないので、間違って理解して書いている可能性もあります。
もし間違っている箇所があればご指摘くださると大変ありがたいです。

最後になりましたが、ここまでお読みいただきありがとうございました!

参考にさせていただいたページ
https://itnext.io/a-deep-dive-in-the-vue-js-source-code-4601a3f5584
https://github.com/ohhoney1/Vue.js-Source-Code-line-by-line

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

自動更新できる「Youtube検索を利用した静的サイト」を無料で作る

成果物

制作したサイト
https://mini2019.netlify.com/
ソース
https://github.com/hideboh/gatsby-starter-mini

動機

僕はYoutubeが好きでよく見ます。
色々なYoutuberの考えを知ることができて勉強になるからです。

そして僕はミニマリストなので、ミニマリスト関係の人気動画を毎日チェックしたいなと思いました。

作るもの

指定のキーワード※に関する最新の人気動画のリストを表示するサイト。
※今回は「ミニマリスト」
なお自動更新であり無料である。

実はこれだけなら、Youtubeのサイトの検索結果ページをブックマークしておけば済む話です。
例:検索結果のページ

今回は勉強のため自分でサイトを作ります。
また自分のサイトなら、レイアウトや検索条件のカスタマイズができるようになります。

利用する技術、サービス

  • Reactベース静的サイトジェネレータGatsby
  • 高機能ホスティングサービスNetlify
  • Netlify Functions
  • Node.js
  • node-youtube
  • cron-job.org

全てJavaScriptで書きます。
全て無料です。クレジットカードの登録も要りません。
基本的にサーバーレスなものだけなので、サーバー運用は必要ありません。

手順

準備

Netlifyは静的コンテンツを配信してくれるWebサービスです。
https://rightcode.co.jp/blog/information-technology/netlify-github-up
Githubに静的サイトのソースファイルを置くと、Netlifyがサイトとして公開してくれます。
また静的サイトジェネレーターにも対応しているので高機能な静的サイトが作れます。
今回は静的サイトジェネレータとしてGatsbyを利用します。
Gatsbyは色々なデータからサイトを構築するSource Pluginsが充実しています。
そのためYoutube検索結果のように特殊なデータをソースとするようなサイトに適しています。

上記サイトを参考に試しにNetlifyを使ってみることをお勧めします。
アカウントも作成してください。

個人サイトを公開する

まずはNode.jsにgatsby-cliをインストールします。
npm install -g gatsby-cli
これはローカルでgatsbyサイトを立ち上げるために利用します。

Gatsbyはstarterというものをテンプレートにサイトを生成します。
starterはこちらから探せます。(v1は古いのでv2を推奨)
https://www.gatsbyjs.org/starters/?v=2
今回は動画の一覧を表示するだけなので、こちらのシンプルなものを使います。
https://www.gatsbyjs.org/starters/gatsbyjs/gatsby-starter-default/
「Visit demo」ボタンからデモサイトが見れますが、本当にシンプルです。
シンプルイズベスト!

starterをNetlifyにデプロイします。
上記のページでここ↓からGitHubのページに移ります。
image.png

そして下の方のページのこのボタンを押します。
image.png

そのまま画面に沿ってGitHubとNetlifyを連携します。
するとstarterが自分のリポジトリにコピーされます。
そしてNetlifyにデブロイされて、一分くらい待つとサイトが公開されます。
ここがサイトへのリンクです。
image.png
簡単でしょ?

URLはランダムにつけられてしまいますが、変更ができます。
ここから、
image.png
ここで変更します。
image.png

今回はこちらのURLにしました
https://mini2019.netlify.com/

GitHub上のソースは以下です。
https://github.com/hideboh/gatsby-starter-mini

以降、GitHub上のファイルを更新すると自動でNetlifyにデプロイされてサイトが更新されます。

次にローカルの開発環境を構築します。
Gitが利用できる環境で、先ほど自動作成されたリポジトリをcloneします。

$ git clone https://github.com/hideboh/gatsby-starter-mini.git

次にstarterで利用しているプラグインをインストールします。

$ cd gatsby-starter-mini/
(yarnが未インストールならインストールをする)
($ npm install -g yarn)
$ yarn
(時間がかかります)

外部API化の準備

Gatsbyの入力データにできるようにYoutubeの検索を外部API化します。
これにはNetlify Functionsを利用します。
https://qiita.com/Sr_Bangs/items/7867853f5e71bd4ada56
概要はこちらの通りですが、Gatsbyで利用する場合は手順が異なります。

こちらのプラグインを使います。
https://www.gatsbyjs.org/packages/gatsby-plugin-netlify-functions/

インストールします。
yarn add gatsby-plugin-netlify-functions

gatsby-config.jsのpluginsの配列に以下を追加します。

    {
      resolve: `gatsby-plugin-netlify-functions`,
      options: {
        functionsSrc: `${__dirname}/src/functions`,
        functionsOutput: `${__dirname}/functions`,
      },
    },

functionsSrcはスクリプトのソースのフォルダ、
functionsOutputはトランスパイルされたファイルが出力されるフォルダです。
このフォルダを実際に作ってください。

次にnetlifyにスクリプトのフォルダを設定するために、以下のファイルをプロジェクトのルートフォルダに作成します。

netlify.toml
[build]
  functions = "functions"

試しに、APIを呼ぶとHello Worldを返すだけの、簡単なスクリプトを作成します。
Netlify Functionsの環境はNode.jsなのでJavaScriptで書きます。
以下のファイルを作ります。

/src/functions/hello.js
exports.handler = function (event, context, callback) {
  callback(null, {
    statusCode: 200,
    body: 'Hello World'
  });
}

commitしてGitHubにpushします。
そしてNetlifyのDeploysタブを見ると、ビルドが進行しているのが分かると思います。
image.png

この部分をクリックするとビルドログを確認することができます。

Building->Publishedの状態になったら、Functionsタブを見てください。

image.png

ここをクリックして、EndpointのURLを開いてみると、Hello Worldが表示されると思います。

Youtubeで検索する

では今度はHello WorldではなくYoutubeの検索結果を返すスクリプトを作ります。

Node.jsでyoutube検索を行うには以下のライブラリを利用します。
https://qiita.com/K_ichi/items/186028bee1fce633a1f0

youtube-nodeをインストールします。
$ yarn add youtube-node

[Youtube APIの利用]の章を参考にAPIキーを生成します。
ただし前述のような方法でstarterからボタンを押してNetlifyにデプロイした場合は、GitHubがパブリックリポジトリになります。
したがって、APIキーをそのままソースに書くと情報漏洩になってしまいます。
そのため、Netlifyの環境変数として保存してください。
Netlify Functionsの中で外部APIを扱う
あるいは、ボタンではなくgatsby-cliでGatsbyの初期化をした場合はプライベートリポジトリにもできます。
この場合はソースに保存しても自分しか見れないので大丈夫です。

今回はYOUTUBE_KEYという名前でNetlifyの環境変数に保存しました。
なおローカルで実行したい場合は、ローカルにも保存する必要があります。
例えばVSCodeの場合はこちらの方法で保存できます。
https://garafu.blogspot.com/2017/05/vscode-environment-variables.html
ただし、launch.jsonを公開してしまうと同じように情報漏洩になるため、.gitignoreに追加してアップロードしないようにしてください。
なおスクリプトはhandler関数を定義しただけで呼んでいないので、VSCodeでデバッグするときは以下のように明示的に呼んであげてください。

src/run.js
const api = require('./functions/youtube'); 
api.handler();

このときLaunch.jsonのprogramを以下にします。
"${workspaceFolder}\\src\\run.js"

さてyoutube-nodeを利用して、目的の「ミニマリストに関する最新の人気動画」を検索します。
ここで、

  • ミニマリストに関する→ ミニマリストというワードにヒットする
  • 最新→ 直近一週間
  • 人気→ 再生回数が多い順

と定義します。

src/functions/youtube.js
exports.handler = function (event, context, callback) {
    var Youtube = require('youtube-node');
    var youtube = new Youtube();

    youtube.setKey(process.env.YOUTUBE_KEY);

    //再生回数の多い順
    youtube.addParam('order', 'viewCount');
    //直近一週間
    let dt = new Date();
    dt.setDate(dt.getDate() - 7);
    youtube.addParam('publishedAfter', dt.toISOString());

    youtube.search('ミニマリスト', 5, function (error, result) {
        if (error) {
            console.log(error);
            callback(null, {
                statusCode: 200,
                body: 'error'
            });
        }
        callback(null, {
            statusCode: 200,
            body: JSON.stringify(result, null, 2)
        });
    });
};

こうするとAPIを呼べば、動画の情報をJSON形式で返すようになります。
ここまでの変更をデブロイして、APIを使える状態にしておきます。

検索結果をサイトに反映する

今度はこのAPIで動画情報を取得してGatsbyに入力します。
外部APIをソースにするプラグインを利用します。
https://www.gatsbyjs.org/packages/gatsby-source-apiserver/

インストールします。
$ yarn add gatsby-source-apiserver

gatsby-config.jsのpluginsの配列に以下を追加します。

    {
      resolve: 'gatsby-source-apiserver',
      options: {
        url: 'https://mini2019.netlify.com/.netlify/functions/youtube',
        method: 'get',
        headers: {
          'Content-Type': 'application/json'
        },
        typePrefix: 'internal__',
        name: `youtubes`,
      }
    },

urlには先ほど作ったAPIのURLを設定します。

このようにすると、Gatsbyの生成時にAPIの取得結果をGraphQLという形式で取得できるようになります。
https://mottox2.com/posts/202
こちらを参考にbrowser IDEを立ち上げてクエリを作成します。
gatsby-source-apiserverのクセのあるところとして、エラー回避のため自動的にid=dummyのDummy Nodeが追加されます。
そのためクエリにidを追加し、
id !== "dummy"
のようにして除去する必要があります。

今回は動画のvideoIdとタイトル、そして上述のidを取得するクエリにしました。

export const query = graphql`
query {
  allInternalYoutubes {
    edges {
      node {
        items {
          snippet {
            title
          }
          alternative_id {
            videoId
          }
        }
        id
      }
    }
  }
}
`

これをsrc/pages/index.jsに追加します。

そしてgraphqlで取得したデータを利用して、htmlの構造をJSXという記法で書きます。
JSXではJavaScript上にhtmlを書くことができます。
https://ja.reactjs.org/docs/introducing-jsx.html
Reactで使われているものです。
(DeNAのJSXとは別物です。)
Gatsbyの中身はReactなので、同じ形式で書きます。

不要なもの消したり整えたりして、最終的にindex.jsはこうなりました。
https://github.com/hideboh/gatsby-starter-mini/blob/master/src/pages/index.js

これでGatsbyの部分は完成です。

なお、こちらの方法を使うと動画をプレーヤーとして埋め込むこともできます。
https://support.google.com/youtube/answer/171780?hl=ja

自動更新する

上記のままだとサイトは更新されません。
定期的にビルド→デプロイがされるようにします。

Netlifyでは、アクセスしたときにビルド、デプロイが実行されるBuild hook用のAPIが作成できます。
https://hyme.site/blog/posts/effective-public-flow/
の「NetlifyのBuild hook用のAPIの作成」を参照ください。

作成出来たら、このAPIに定期的にアクセスするようにします。
https://www.lancork.net/2014/08/cron-job-org/
このサイトに登録して、ジョブを作成します。
POSTのリクエストにしてください。
ただし一度ジョブを作成して、それからEditしないとPOSTに設定できません。
しかもこのサイトはChromeとの相性が悪いのか、既存のジョブをEditしようとすると、入力補完で内容が上書きされてしまうのでご注意ください。
何とも使いにくいサイトです。
今回はサイトを毎日見たいので、一日ごとに実行するようにしました。

まとめ

以上で、自動更新できる「Youtube検索を利用した静的サイト」を無料で作ることができました。

この仕組みを応用すると、いろいろなデータを入力としてサイトを構築することができます。
例えば僕は、好きなゲーム実況グループの投稿コンテンツを、色々なサイトから取得してまとめるサイトを作りました。
https://napori.netlify.com/
このサイトでは、

  • niconico(動画、生放送)
  • Youtube
  • Twitter
  • Google

のAPIやスクレイピングを利用してデータを取得しています。

注意事項

gatsby-plugin-netlify-functionsによりデブロイされるAPIは、もともとユーザーがサイトを訪問した時に参照されることを想定していると思われます。
そのためビルドの時に参照するとタイミングが早すぎて、前にデプロイされたAPIが参照されてしまいます。
JSONの構造を変えたときは、graphqlが失敗してビルドエラーになるので注意してください。
そのため一度、gatsby-config.jsのgatsby-source-apiserverの部分と、index.jsのgraphqlの部分を無効にして、APIをデプロイする必要があります。
これでは大変なので、functions用のリポジトリは別にした方が良いかもしれません。(僕はそうしてます)

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

Elm と Ionic でモバイルアプリっぽい UI を作る

はじめに

Ionic を使い Elm でもモバイルアプリっぽい UI を作りたいと思って試してみました!どのように動くか気になる方はこちらのデモを見てください。この記事では Elm 上で Ionic を使う方法、その上でできること、できないことをまとめます。

Ionic とは

Ionic は、iOS や Android、Web などクロスプラットフォームでアプリを開発するための UI フレームワークです。以前は Angular でしか使うことができませんでしたが Stencil という Ionic チームが開発したフレームワークを使うことで React や Angular、Vue などでも使うことができるようになりました。どのようなコンポーネントが用意されているのか、また使い方などは UI Components - Ionic Documentation を見るとおおよそわかると思います。

Stencil とは

Stencil は全てのブラウザで実行できる、標準仕様に従った Web Components を生成するためのフレームワークです。Ionic はこのフレームワークを使って開発されています。個人的にプロパティやイベントを含めドキュメントを自動生成できるところが、ドキュメントの手動更新による間違いを減らすことができて便利そうでした。

Docs Readme Auto-Generation - Stencil

Stencil is able to auto-generate readme.md files in markdown. This is an opt-in feature and will save the readme files as a sibling to the component within the same directory. When this feature is used it can be useful for others to easily find and read formatted docs about one component.

Web Components とは

Elm と他のフレームワークを組み合わせる - Qiita

Web Components は,再利用可能なカスタム要素を作成し,ブラウザ上で利用するための技術,HTML Templates や Custom Elements,Shadow DOM をまとめた総称です.これまで React や Angular,Vue などのフレームワークを使用して実現していたカスタムコンポーネントや Scoped CSS などの機能を,標準の機能のみで実現することができます.

Ionic を使うための準備をする

Ionic を React や Angular、Vue 以外で使う場合は CDN、jsDelivr から配信されるファイルを使う、または npm で @ionic/core を追加することになります。ここで @ionic/core を使う場合、基本的には webpack などのバンドラーが必要になります。もし試すだけであれば CDN から配信されるファイルを使う方が簡単かもしれませんね。

CDN から配信されるファイルを使う

ここでは CDN から配信されるファイルを linkscript タグを使って HTML ファイルに追加する必要があります。そのため elm make--output オプションを使い JavaScript ファイルとして出力するようにします。次にドキュメントに従って次のタグを HTML ファイルに追加します。

It's recommended to use jsdelivr to access the Framework from a CDN. To get the latest version, add the following inside the <head> element in an HTML file, or where external assets are included in the online code editor:

<script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css"/>

@ionic/core を使う

外部の CDN に依存したくない場合、バンドラーを使って自前で @ionic/core をバンドルする必要があります。これは少し面倒です。特に理由がないのであれば CDN を使う方法をオススメします。

まず、エントリーファイルに @ionic/core から必要となるファイルをインポートします。ここでは CSS ファイルを読み込めるようにする必要があり、webpack であれば css-loader を使う、または copy-webpack-plugin を使ってコピーするのもありかもしれません。

import { defineCustomElements } from "@ionic/core/loader";
import "@ionic/core/css/ionic.bundle.css";
import "ionicons/icons";

defineCustomElements(window);

また、Ionic には Ionicons というものがあり Ionic 上で使うアイコンを SVG として管理しています。そのため ion-icon などのタグを使いたい場合、適切な場所に SVG ファイルを配置する必要があります。import "ionicons/icons" すると SVG ファイルがインポートされるのでバンドラーのファイル出力先に svg フォルダを作り、その下に SVG ファイルを配置するようにします。例として webpack では次のように設定します。

{
  test: /\.svg$/,
  loader: [
    {
      loader: 'file-loader',
      options: { name: 'svg/[name].[ext]' }
    }
  ]
}

CDN から配信されるファイルを使う場合と比べるとちょっと面倒ですね!

Elm 上で Ionic のコンポーネントを扱えるようにする

node 関数や on 関数を使って Ionic のタグやプロパティ、イベントを Elm 上でも使えるようにします。これは使いたいコンポーネント毎に node 関数を記述していく必要があります。

import Html exposing (node)
import Html exposing (Attribute, attribute)
import Html.Events exposing (on, targetValue)

button =
  node "ion-button"

datetime =
  node "ion-datetime"

displayFormat =
  attribute "display-format"

onChange : (String -> msg) -> Attribute msg
onChange handler =
  on "ionChange" (Json.map handler targetValue)

タグやプロパティ、イベント毎に分けて作っておくと Html モジュールと同じように使えて便利です。

.
├── Ionic
│   ├── Attributes.elm
│   └── Events.elm
└── Ionic.elm

1 directory, 3 files

これで Ionic を使う準備が整いました!

使ってみる

基本的には Elm の Html と同じように記述します。

type alias Model =
  { datetime : String }

type Msg
  = DateTimeChanged String

view : Model -> Html Msg
view model =
  div
    []
    [ button [] [ text "Hello World" ]
    , datetime
        [ onChange DateTimeChanged
        , displayFormat "YYYY/MM/DD"
        , value model.datetime
        ]
        []
    ]

ここで使った ion-datetimeion-button 以外にも ion-refresherion-selection-toggle など、便利なコンポーネントが沢山用意されていて面白いです!

ion-action-sheet や ion-alert を使う

Ionic には画面にオーバーレイを表示し、ユーザが選択肢をタップすることで処理が先に進む ion-action-sheetion-alert といったコンポーネントも用意されています。これらは仕組み上、Elm だけでは表示することはできません。Elm の Ports を使って JavaScript 側でオーバーレイを表示、特定の選択肢が選択された場合に Elm 側に Ports を使って情報を送り返すといった処理が必要になります。また、次の例では ion-alert にはない create メソッドを使うため ion-alert ではなく ion-alert-controller を使用しています。

port createAlert : { header : String, message : String } -> Cmd msg

port onClickOkButton : (() -> msg) -> Sub msg

subscriptions : Model -> Sub Msg
subscriptions model =
  onClickOkButton (\_ -> OKButtonClicked)
ports.createAlert.subscribe(async ({ header, message }) => {
  const alertController = document.querySelector("ion-alert-controller");
  const alert = await alertController.create({
    header,
    message,
    buttons: [{
      text: "OK",
      handler: handler: () => ports.onClickOkButton.send(null)
    }]
  });
  await alert.present();
});

ページ遷移時のアニメーションが使えない

Ionic にはページ遷移時のアニメーションも用意されています。具体的には ion-routerion-route というコンポーネントが用意されていて、このコンポーネントに url 属性を指定することでルーティング、そして component 属性に HTML タグ名を指定することで、ページ遷移時にモバイルアプリの画面遷移のようなアニメーションが発火します。とても便利なコンポーネントですが、この component 属性に指定できるのは特定の Ionic コンポーネントと HTML タグ名だけで id や class などを指定することができず、Elm から使う方法はありません。

しかし @ionic/react など、内部的に customElements.define を使っていないフレームワークへの実装ではページ遷移を実現しています。どのようにページ遷移を実現しているのか @ionic/react-router のソースコードを読んでみたところ、まずルーター自体をそのフレームワークに合わせたもの、ここでは react-router で実装し直し、アニメーション用の関数を React コンポーネントに対して使っているようです。もし Elm で使う場合、Elm のページ遷移が発生したら Ports を介して JavaScript 側でページ遷移のアニメーションを行うことで実現できそう。これはまた別の機会に!試してみたいですね!

JavaScript のみで実装された ion-routerion-route のサンプルはあまり見かけないので例として、次のようなページ毎に customElements.define をしているソースコードはアニメーションも含めページ遷移が正しく動作します。

<script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css"/>

<script>
customElements.define('page-a', class extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
<ion-content padding>
  <ion-button href="#/b">Go to Page-B</ion-button>
</ion-content>
`;
  }
});

customElements.define('page-b', class extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
<ion-content padding>
  <ion-button href="#/">Go to Page-A</ion-button>
</ion-content>
`;
  }
});
</script>

<ion-app>
  <ion-router>
    <ion-route url="/" component="page-a"></ion-route>
    <ion-route url="/b" component="page-b"></ion-route>
  </ion-router>
  <ion-nav></ion-nav>
</ion-app>

まとめ

まだちょっと問題はありますが、Elm でも Ionic を使ってモバイルアプリっぽい UI を作ることができました。簡単なものであれば十分使うことができそうで、特に PWA にしてフルスクリーンで起動できるととても面白そうですね。この記事で行ったこととは逆に Elm を Web Components として React や Angular、Vue などで使う方法もあります。気になる方は Elm と他のフレームワークを組み合わせる - Qiita を見てください!

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

無秩序な既存プロジェクトのコーディングスタイルをチームで無理なく揃えていく

ZOZOテクノロジーズのむーさん@murs313です。
WEARが大好きで入社し、今はWEARのwebアプリの開発をしています。

WEARは今年のハロウィンで7才になりました。
歳を重ね、様々な人がコードを書き、コーディング規約もなく、WEARのコードはこげな状態になっておりました…。

ほげえええええええ!! インデント!!!インデントォォ!!!
ファイルによって改行コードも違うやないか…!!なんてこった\(^o^)/
このままではくしゃみと鼻水でコードが書けない…。私はコーディングスタイルを統一する決意をしたのでした。

プロジェクトのコーディングスタイルなので、ひとりでやっても意味がありません。チームでやっていきたいです!!と話したら、チームメンバーも賛成してくれました。

まずは書き方の振れ幅が大きいJavaScriptをESLintでチェックすることにしました。

やること

  • CircleCIでESLintによるJSファイルのコーディングスタイルのチェックを行う。ただし無理がないように、そのプルリクエストで触ったファイルだけをチェックする。
  • 運用が大変にならないように、errorにするルールは少なめにする。
  • errorが出ても面倒くさくならないように、一発でfixできるshellを作っておく。

★この記事では、各サービスの詳しい解説はあまりせず、チームで無理なくやっていくための工夫を主に紹介していきます。

手順

1. コーディング規約を決める

コーディング規約がない場合、まずはチームでコーディング規約を決めることから始めます。
細かいところはまずはスタンダードに乗っかると楽なので、「みんなが常日頃気になっているところ」だけ話しておくのが良いと思います。

  • インデント(ソフトインデント / ハードインデント)
  • 改行コード(LF / CRLF)
  • 括弧の位置 等々…

話し合いの段階でESLintの推奨設定やJavaScript Standard Styleを見ておくと、「推奨や標準はこうなんだ」と参考になります。

2. ESLintの導入

前述の通りこの記事では解説を割愛しますが、こちらの記事がとても素敵です。(npmの基礎から解説されていて、パッケージもグローバルインストールしていません)
以下淡々と導入手順を記していくので、よく分からなかったら上記の記事を参照してみてください。

npmが動く環境であることを前提とします。
package.jsonがないプロジェクトなら、下記コマンドでpackage.jsonを作ります。

$ npm init -y

ESLintをインストール。

$ npm install --save-dev eslint

ESLintを簡単に実行できるように、スクリプトを定義しておきましょう。package.json"scripts"を下記のように書き換えます。

package.json
  "scripts": {
    "lint": "eslint",
    "fix": "eslint --fix"
  },

3. ESLintのルールを整備する

いよいよコーディングルールを設定していきましょう。
プロジェクトフォルダの直下に.eslintrc.jsonというファイルを作ります。

.eslintrc.json
{
  "extends": "eslint:recommended"
}

"eslint:recommended"はESLintの推奨設定に則ることを意味します。
ESLintのコーディングルールは恐ろしいほどたくさんあり、ひとつずつチェックするのは無謀であるため、ここから調整していくのがおすすめです。(参考:ESLintのルールを全部手動で設定するのは大変だからやめておけ

先ほどスクリプト定義したので、下記のようにコマンドを打つとESLintが動きます。

$ npm run lint [jsファイルのパス]

errorやwarnがたくさん出るかもしれませんが、先ほどの素敵記事を参考に、jQueryのプロジェクトなら"env""jquery"を追加したり、"globals"に変数を設定したり、みんなで決めたルールに沿って"rules"を追加したりして調整していきます。

eslintrc.json
{
  "env": {
    "browser": true,
    "jquery": true
  },
  "extends": "eslint:recommended",
  "rules": {
    "no-redeclare": "warn",
    "no-unused-vars": "warn",
    "camelcase": "warn",
    "linebreak-style": ["error", "unix"],
    "indent": ["error", "tab", { "SwitchCase": 1 }],
    "eol-last": ["error", "always"],
    "no-trailing-spaces": "error",
    "no-multiple-empty-lines": ["error", { "max": 1 }],
    "space-before-blocks": ["error", "always"],
    "quotes": ["error", "single"],
    "semi": ["error", "always"],
    "comma-style": ["error", "last"],
    "space-before-function-paren": ["error", "never"],
    "func-call-spacing": ["error", "never"],
    "block-spacing": ["error", "always"],
    "array-bracket-spacing": ["error", "never"],
    "no-array-constructor": "error",
    "spaced-comment": ["error", "always"]
  }
}

★私はESLintでfixできるものは遠慮なくerrorにし、簡単にfixできないものはwarnにしました。CircleCIが落ちても、直せば通ることを簡単に体感してもらうためです。

4. 差分ファイルだけlintとfixができるshellを書く

2つのshellを作って、今回はプロジェクトディレクトリ直下に起きます。

  • lint.sh
    masterとの差分があるjsファイルだけlintします。
    CircleCIに載せる用。ローカルでも動きます。
  • lint-fix.sh
    masterとの差分があるjsファイルだけfixします。
    ローカルでESLintが直せるerrorを直します。
lint.sh
#!/bin/bash

echo 'CIRCLE_BRANCH: ' ${CIRCLE_BRANCH}
TARGET_BRANCH=${CIRCLE_BRANCH}

# masterブランチへのmerge時はチェックしない
if [ "$TARGET_BRANCH" = 'master' ]; then
  echo 'SKIP on merge into master'
  exit 0
fi

# ローカルでの実行用にカレントブランチをセットする
if [ "$TARGET_BRANCH" = '' ]; then
  TARGET_BRANCH=$(git rev-parse --abbrev-ref HEAD)
fi
echo 'TARGET_BRANCH: ' $TARGET_BRANCH

BASE_BRANCH=origin/master
echo 'BASE_BRANCH: ' $BASE_BRANCH

files=$(git diff --name-only $TARGET_BRANCH $BASE_BRANCH | grep -E '.js$')

error=false
for file in ${files}; do
  npm run lint -s -- ${file}
  result=$?
  if [ $result -ne 0 ]; then
    error=true
  fi
done

if $error; then
  exit 1
fi

exit 0

lint.shはこちらの記事から拝借しました。

lint-fix.sh
#!/bin/bash

# origin/masterをベースブランチにセット
BASE_BRANCH=origin/master
echo 'BASE_BRANCH: ' $BASE_BRANCH

files=$(git diff --name-only $BASE_BRANCH | grep -E '.js$')

error=false
for file in ${files}; do
  npm run fix -s -- ${file}
  result=$?
  if [ $result -ne 0 ]; then
    error=true
  fi
done

if $error; then
  exit 1
fi

exit 0

ファイルができたら、適当なjsファイルに差分を作ってから、下記のように実行して試してみましょう。

$ ./lint.sh
$ ./lint-fix.sh

5. CircleCIに導入

CircleCI側のリポジトリの追加ができている前提で、.circleci/config.ymlに下記を記述します。

.circleci/config.yml
version: 2
jobs:
  build:
    docker:
      - image: circleci/node:10.16.0
    steps:
      - checkout
      - run: npm install
      - run: ./lint.sh

nodeが使えるimageを用意して、./lint.shしています。
ここでは当時最新のLTS(long term support)だったnode v10系を使っています。

これをプッシュすれば、CircleCIによるlintが走るはずです!
プルリクの最後の方にこんな感じで表示されると思います。
CircleCIが落ちたバージョン
スクリーンショット 2019-12-15 16.49.31.png
CircleCIが通ったバージョン
スクリーンショット 2019-12-15 16.50.35.png

6. チームメンバーに説明・各々の環境やエディタで使えるように設定

1番大事です。下記のようなことを伝えましょう。

  • プルリクにこんなの付くよ
  • エラーメッセージはこうやって見るよ
  • レビュワーになったら、CircleCIが落ちていたら伝えてね

また、チームメンバー各々の環境やエディタで使えるように、nodeの環境構築やプラグインを入れる時間を取ると良いと思います。

最後に

この記事に書いたシステムの導入は今年7月に行われた開発合宿で行いました。足湯コーディングめっちゃ良かった〜〜〜!
足湯コーディングしたい方はJOIN US!!!

スペシャルサンクス

  • 弊社の素敵文化・フロントエンド共有会で相談に乗ってくれた@AmatsukiKuさん
  • 合宿で罵詈雑言と共にCircleCIのことを教えてくれた@inductor(呼び捨て)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React-Draggableで<Draggable>内のインプットから勝手にフォーカスが外れる問題

React-Draggableを使ったコンポーネントでテキスト入力ができなくなる問題が発生したので対処法をメモ

環境

Chrome 78.0
React 16.8.6
React-Draggable 4.1.0

起きた問題

タイトルのまんまですが

<Draggable>
  <div>
    <input type="text" />
  </div>
</Draggable>

上記のコンポーネントで、inputに文字を入力しようとしてフォーカスを当てても瞬時にフォーカスが外れて文字が入力できない問題が発生。

解決方法

https://github.com/mzabriskie/react-draggable/issues/314
こちらのissueに解決方法がありました。

Draggableにcancelというプロパティでdraggableの対象外にする要素のセレクタを渡せばいける。

<Draggable cancel="input[type=text]">
  ...
</Draggable>

どうやらドラッグ中に意図せぬテキストの選択が発生しないようにフォーカスをすぐに外す処理が入っているのが原因のようです。
なのでテキスト入力部分だけはdraggableの適用対象外とすることで解決できます。

過去には
enableUserSelectHack={false}というプロパティを渡す必要があったようですが、
現在のバージョン(4.1.0)ではcancelだけでいけるようです。

参考

https://github.com/mzabriskie/react-draggable/issues/314

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

[JS]メモ

頻繁に調べているなあと思うものをメモ
※随時更新

関数の省略
let myFunc = function(name) {
  console.log(name);
}
// ↓
let myFunc = (name) => {
  console.log(name);
}
VueをCDNで使う雛形
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Vue App</title>
</head>
<body>
    <div id="app">
        {{ message }}
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script>
        const app = new Vue({
            el: '#app',
            data: {
                title: '',
                message: 'Hello Vue!'
            }
        });
    </script>
</body>
</html>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaScriptのprototype宣言・使用

prototype?

・継承を実現できる
・すべてのオブジェクト(複数の値をひとまとめにした値)がプロトタイプをベースに作られている。
・メモリの無駄消費を防げる

使用方法

宣言とオブジェクト構造

const Dog = function(name){
    this.name = name;
}

const poti = new Dog('ポチ');
console.log(poti);
結果
Dog {name: "ポチ"}

メソッド定義

prototype使用
const Dog = function(name){
    this.name = name;
}
Dog.prototype.getName = function(){
    return this.name;
}
prototype未使用
const Dog = function(name){
    this.name = name;

    this.getName = function(){
        return this.name;
    }
}

prototypeを使用した場合と使用しない場合では、new されて違うインスタンスを生成した場合、使用した場合はプロトタイプを使って宣言したメソッドを参照するが、使わない場合だとgetNameがインスタンスの数分コピーされるので、無駄にメモリを消費する。

ただ、上記のプロトタイプを使った書き方をすると、ほかのプロトタイプなども作りたい場合まとまりがなくなっていってしまう。そこで違う書き方ができる。

const Dog = function(name){
    this.name = name;
}

Dog.prototype = {
    getName : function(){
        return this.name;
    }
};

こうすることで、対象のオブジェクトごとにまとめることができる。(プロトタイプを使用しながら)

参考にさせていただいたサイト

https://www.sejuku.net/blog/47722

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

JavaScriptのprototype

prototype?

・継承を実現できる
・すべてのオブジェクト(複数の値をひとまとめにした値)がプロトタイプをベースに作られている。
・メモリの無駄消費を防げる

使用方法

宣言とオブジェクト構造

const Dog = function(name){
    this.name = name;
}

const poti = new Dog('ポチ');
console.log(poti);
結果
Dog {name: "ポチ"}

メソッド定義

prototype使用
const Dog = function(name){
    this.name = name;
}
Dog.prototype.getName = function(){
    return this.name;
}
prototype未使用
const Dog = function(name){
    this.name = name;

    this.getName = function(){
        return this.name;
    }
}

prototypeを使用した場合と使用しない場合では、new されて違うインスタンスを生成した場合、使用した場合はプロトタイプを使って宣言したメソッドを参照するが、使わない場合だとgetNameがインスタンスの数分コピーされるので、無駄にメモリを消費する。

ただ、上記のプロトタイプを使った書き方をすると、ほかのプロトタイプなども作りたい場合まとまりがなくなっていってしまう。そこで違う書き方ができる。

const Dog = function(name){
    this.name = name;
}

Dog.prototype = {
    getName : function(){
        return this.name;
    }
};

こうすることで、対象のオブジェクトごとにまとめることができる。(プロトタイプを使用しながら)

参考にさせていただいたサイト

https://www.sejuku.net/blog/47722

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

金曜の夜になったら会社の Slack 通知を自動でミュートしたい

はじめに

「休日は会社の Slack をミュートしておきたい!」という要望は普通にあると思うのですが、
2019年12月15日現在、Slackには特定の曜日に自動で「おやすみモード」にする機能はありません。

そこで色々と試してみたのですが Zapier(または IFTTT)で Slack API を叩く方法が無料かつ最も簡単にできたので、
本記事ではその手順を解説していきます。

手順

まず大まかにやることをまとめると、

  • Slack 側で API を利用できるよう設定する
  • IFTTT / Zapier 側で「時刻が金曜の21時であるとき」「Webhook / JavaScriptから API を叩く」アプレットを作る

の2つです。

Slack側の設定

Slack 側では Web API の利用を許可し OAuth トークンを取得する必要があります。

そのためには Slack App を作成しなくてはならないので、以下では必要最低限な App の作り方を解説します。

まず Slack API のページにアクセスし、中央の [Start Building] ボタンを押しましょう。

スクリーンショット 2019-12-15 1.14.03.png

APP 名を適当に入力し、どのワークスペースに作成するかを選択後、右下の [Create App] を押します。

スクリーンショット 2019-12-15 1.14.56.png

すると何かゴチャゴチャしたページに飛ぶので、Add features and functionality 節の右下辺りの [Permissions] を選択します。

スクリーンショット 2019-12-15 1.20.38.png

このページでどういう API の使用を許可するのかを設定します。

Scope 節の [Add an OAuth Scope] ボタンを押しましょう。
(大きい緑のボタンではなく下の白いボタンの方なので注意)

スクリーンショット 2019-12-15 1.23.14.png

今回利用するAPI の仕様によると、権限として dnd:write が必要と書いてあるので、検索し選択します。

スクリーンショット 2019-12-15 1.26.09.png

その後ページの一番上に戻ると、App がインストールできるようになっています。

スクリーンショット 2019-12-15 1.26.48.png

インストールに成功すると API を叩くのに必要な OAuth トークンが表示されます。

このトークンを知っている人は誰でも API を叩けるので管理には一定の注意が必要です。
(今回の場合はおやすみモードの切り替えができるだけと思いますが一応)

Slack 側の設定はこれで完了です。トークンだけ後々使用します。

IFTTT / Zapier側の手順

IFTTT と Zapier、どちらを選ぶべきか

どちらも似たようなサービスですが、基本的にはZapierの方が高機能と言えます。

IFTTT はアクションが一つしか登録できないなど制限は多いですが、
Webhook が無料で利用でき UI もわかりやすいため、単純な用途であれば IFTTT をオススメします。

Zapier はよりカスタマイズ性が高く、何よりNode.js や Python のコードを実行することができます
機能を細かく調整したい場合や拡張性を持たせたい場合は Zapier がいいと思います。

IFTTT の Webhook の使い方はググればたくさん出てくると思うので、
本記事では Zapier の Node.js からAPIを叩く方法をご紹介します。

Zapierの手順

Zapier への登録方法は割愛します。

ログイン後、右上の [Make a Zap!] ボタンを押してください。

スクリーンショット 2019-12-15 1.46.04.png

まずはトリガーとなる App を選択してと言われるので、時刻をトリガーとする [Schedule by Zapier] を選択します。

スクリーンショット 2019-12-15 12.34.41.png

次に、どういう条件でトリガーさせるかを聞かれます。

トリガーさせたい曜日が1つだけの場合は every week, 複数ある場合は everyday を選択するといいです。

今回は説明のため everyday を選択します。

スクリーンショット 2019-12-15 12.36.43.png

上で everyday を選択するとトリガーさせる時刻を聞かれます。
金曜の午後9時以降は会社のことを忘れたいので「9pm」を選択し、CONTINUE します。

スクリーンショット 2019-12-15 12.38.50.png

次の画面で [TEST & CONTINUE] というボタンが出るので、押下してトリガーの設定を完了します。

次はアクションの設定です。下の [Do this...] をクリックしましょう。

スクリーンショット 2019-12-15 12.42.17.png

App は Code by Zapier を選択します。

スクリーンショット 2019-12-15 12.44.30.png

Node.js の ver 10.x.x を選択します。

スクリーンショット 2019-12-15 12.45.29.png

次に実行したいコードを記述していくのですが、その前に Zapier の Code のしくみをざっくり解説します。

Input Data

Code の設定項目には「Input Data」と「Code」があるのですが、まずはInput Dataから説明していきます。

スクリーンショット 2019-12-15 12.53.34.png

Input Dataでは、一つ前のトリガーやアクションからどういうデータをどのように受け取るかを設定します。

具体的にはここで「データのプロパティ名」と「データの種類」を入力しておくと、
コード内でinputData.プロパティ名の形でそのデータを取得できるようになります。

今回はスケジュールトリガーから曜日データを受けとりたいので、Pretty Day Of Week(整形された曜日情報)を、dayOfWeekのプロパティ名で受け取れるように設定しています。

Code

Code 節には実行するコードを書くのですが、少しクセがあって、

  • コード全体が async function にラップされている
  • オブジェクトまたはオブジェクトの配列をoutputという定義済み変数に入れなければならない

となっています。

outputに入ったものが次のアクションに渡されるしくみになっていて、
次のアクションがない場合でも必ず値を入れなければなりません。

また async function なのでawaitを使用することが可能です。
というか非同期の場合にawaitを使わないとoutputに何も入ってないよ!とエラーになる可能性があります。

実際のコードは以下のようになりました。

code
const https = require('https');

const endpoint = 'https://slack.com/api/dnd.setSnooze'
const token = '<Slackで取得したトークンを入れてね!>'

if(inputData.dayOfWeek === 'Friday') {   
    const numMinutes = 60 * (48 + 9) // 2 days and 9 hours.
    const url = `${endpoint}?token=${token}&num_minutes=${numMinutes}`

    // オブジェクトまたはオブジェクトの配列を確実に返さないとエラーになる
    output = await fetch(url).catch(error => {
        return error
    })
}

※細かいことを言えば、トークンを直書きしているのでZapier の中の人が見ようと思えば見ることができます。それが気になる方はセキュリティ強化された Zapier Platform を利用するなどしてください。

上記コードを入力後、[CONTINUE] を押して次のような表示が出れば・・

スクリーンショット 2019-12-15 13.17.00.png

あとは作ったアプレットを ON にするだけで全て完了です!
お疲れ様でした。

補足

  • シンプルな要件のわりにはそれなりに実現が大変でした。公式で機能を作ってくれるといいですね。

  • 今回の方法だと週末以外の休日には対応できませんが、カレンダーと連携させるとより柔軟にミュートができるかもしれません。

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

【GAS初級者向け】Google Apps Scriptで バイトの給与を予測するアプリを作ろう

私はバイト掛け持ちなう!なJKです。ある時「今月、どのくらいお給料が入るかな?」と気になったので、Googleカレンダーに入力しているシフトから大まかな給与を計算して表示するスクリプトを書きました。GoogleスプレッドシートとGoogleカレンダーの連携方法を解説する記事です。

Google Apps Scriptとは

Google Apps Script (以下GAS)は、Googleのサービスをクラウド上でプログラムを組むことで操作できる、JavaScriptをベースとしているサービスです。

今回は、GoogleカレンダーとGoogleスプレッドシートを連携させて、バイト給与をスプレッドシートに表示させるツールを作ります。

準備

Googleスプレッドシート で任意のスプレッドシートを作り、メニューからツール→スクリプトエディタを選択してください。
image.png

次のような画面が開けばOKです。
image.png

保存しようとすると次のようなダイアログが出るので、好きな名前に変更して「OK」を押しましょう。
image.png

実装

スプレッドシートを開いたら実行するスクリプトを書く

function onOpen() { //スプレッドシートが開かれたときに実行する
  var objSpreadsheet = SpreadsheetApp.getActiveSpreadsheet(); //開いているスプレッドシートを取得
  var objSheet = objSpreadsheet.getActiveSheet(); //開いているスプレッドシートのシートを取得
  objSheet.getRange('C3').setValue("わあ!"); 
  //開いているスプレッドシートのシートの、'C3'に位置するセルの値を'わあ!'にする。
}

では、実際に実行してみましょう。「▶」というボタンをクリックします。
image.png

実行すると次の画像のような「Authorization required」というダイアログが出ますね。
「<アプリ名> needs your permission to access your data on Google」とは、「<アプリ名> は、Google上のデータにアクセスするための許可が必要です」という意味です。Googleスプレッドシートの編集などの許可が必要なコードを書いて、初めての実行だとダイアログが表示されます。
image.png
「許可を確認」を押します。許可するアカウントをクリックし次に進むと、次の画像のように「このアプリは確認されていません」というページになります。「よく知っている信頼できるデベロッパーの場合に限り続行してください。」とあります。今回、このアプリを作ったのは自分自身なので、信頼できますね。
ここで「詳細」をクリックします。

image.png

「詳細」をクリックすると「<アプリ名>(安全ではないページ)に移動」とかかれたところがあるので、クリックします。
image.png

「Googleドライブのスプレッドシート作成や編集を許可しますよ」といった内容のページになるので、確認して「許可」します。これで実行の準備が整いました。

再度「▶」のボタンを押します。うまく思い通りに表示されたか、スプレッドシートを確認しましょう。次のようになっていれば成功です!

image.png

書いたコードはスプレッドシートが開かれたときに実行するものなので、きちんと動いているのか確認するために、表示された「わあ!」という文字列を消して再読み込みしてみます。スクリプトが実行されて自動的に文字列が表示されたら成功です!

Tips: 指定のセルの内容を書き換える方法

先程も出てきましたが、指定のセルの値を設定する方法は次のコードです。

スプレッドシートのシート.getRange(場所).setValue(); 

例えば、シートの左上に「左上だぴょーん」という文字列を表示したかったら、スプレッドシートのシート.getRange('A1').setValue('左上だぴょーん');というコードで表示できます。

カレンダーから予定を取得する

カレンダーIDの取得

さて、今回のアプリではカレンダーからバイトの予定を取得します。予定を取得するときには、「カレンダーID」が必要です。カレンダーIDを調べましょう。

Googleカレンダーにアクセスして、予定を取得したいカレンダーのオーバーフローメニューをクリックし、「設定と共有」をクリックします。
image.png

下の方に「カレンダーの統合」という見出しがあり、そのすぐ下に「カレンダー ID」という項目があります。ほにゃららほにゃらら@group.calendar.google.comという文字列が表示されています。(ほにゃららほにゃららの部分はそれぞれ異なります)これが「カレンダーID」です。

イベントとそれぞれの時間を取得する

次のようなコードで、Googleカレンダーから予定を取得できます。

CalendarApp.getCalendarById('ほにゃららほにゃらら@group.calendar.google.com').getEvents(予定を取得する範囲(初めの日にち,終わりの日にちという形));

このコードを使って、次のような一ヶ月間の予定を取得する関数を作ります。

//特定の日付から一ヶ月間の予定たちを取得する関数
function getEvent(date){
  var myCal = CalendarApp.getCalendarById('ほにゃららほにゃらら@group.calendar.google.com'); 
  var startDate = new Date(date);
  var endDate = new Date(date);
  endDate.setMonth(endDate.getMonth()+1);//受け取った日付の一ヶ月後の日にちを設定
  return myCal.getEvents(startDate,endDate);//受け取った日付〜受け取った日付の一ヶ月後の予定を取得して渡す
}

次に、特定の名前の予定(CalendarEventObject)が合計で何時間なのかを調べる関数を作ります。
こんな技を組み合わせます。
- 予定の始まる日時(Date型)は予定.getStartTime()で取得できる
- 予定の終わる日時(Date型)は予定.getEndTime()で取得できる
- 日時(Date型).getTime()で日時の「時間(ミリ秒)」を取得できる
- 予定のタイトル(string型)は予定.getTitle()で取得できる

// 合計で何時間の予定かを取得する関数
function getHour(str,date){ //getHour(予定の名前, 日付) という形で使う
  var myEvents = getEvent(date); //受け取った日付から一ヶ月間の予定を取得
  var workHours = 0;
  for each(var evt in myEvents){ //一ヶ月間の予定らそれぞれにたいして処理する
    if(evt.getTitle()==str){ //もし予定の名前が引数で設定された予定の名前なら
      workHours += (evt.getEndTime().getTime() - evt.getStartTime().getTime())/3600000;
      //予定の終わりの時間から始まりの時間を引いた値を「働いた時間」に足す(ミリ秒の形式なので、(6000(60秒*1000)*60(分)=) 3600000 で割る)
    }
  }
  return workHours; //足し合わせ終わった変数を返り値として渡す
}

これで、特定の予定が月にどれくらいの時間入っているのか、知ることができました。

スプレッドシートで表示する

勤務時間(予定の時間)をスプレッドシートで表示します。onOpen()関数を次のように変更します。

?、?となっている部分は任意の文字列に変更してください。私の場合はバイト先1は?、バイト先2は?という予定で登録しているのでこのようなコードになっています。

function onOpen() { //スプレッドシートが開かれたときに実行する
  var objSpreadsheet = SpreadsheetApp.getActiveSpreadsheet();//開いているスプレッドシートを取得
  var objSheet = objSpreadsheet.getActiveSheet(); //開いているスプレッドシートのシートを取得

  var today = new Date(); //今日の日にち
  var date = today.setDate(1); //今日の日にちの「日」を1日にすることで「今月1日」になる

  objSheet.getRange('C3').setValue(getHour("?",date)); //「今月1日」から一ヶ月間の"?"というタイトルの予定が入っている時間をスプレッドシートのC3に表示
  objSheet.getRange('D3').setValue(getHour("?",date)); //「今月1日」から一ヶ月間の"?"というタイトルの予定が入っている時間をスプレッドシートのD3に表示
}

動くか確認します。「▶」ボタンを押して実行します。すると、先程と同じダイアログが表示されます。これは新しく「カレンダーを操作するコード」を書いたからですね。前述の手順にそって許可してもう一度実行してみましょう。
次のように表示されれば成功です!
image.png

コードはこれで完成です。コード全体は次のような感じになっています。

function onOpen() { //スプレッドシートが開かれたときに実行する
  var objSpreadsheet = SpreadsheetApp.getActiveSpreadsheet();//開いているスプレッドシートを取得
  var objSheet = objSpreadsheet.getActiveSheet(); //開いているスプレッドシートのシートを取得

  var today = new Date(); //今日の日にち
  var date = today.setDate(1); //今日の日にちの「日」を1日にすることで「今月1日」になる

  objSheet.getRange('C3').setValue(getHour("?",date)); //「今月1日」から一ヶ月間の"?"というタイトルの予定が入っている時間をスプレッドシートのC3に表示
  objSheet.getRange('D3').setValue(getHour("?",date)); //「今月1日」から一ヶ月間の"?"というタイトルの予定が入っている時間をスプレッドシートのD3に表示
}

//特定の日付から一ヶ月間の予定たちを取得する関数
function getEvent(date){
  var myCal = CalendarApp.getCalendarById('1mejv6q2oj3oppeckvq0dgikcc@group.calendar.google.com'); 
  var startDate = new Date(date);
  var endDate = new Date(date);
  endDate.setMonth(endDate.getMonth()+1);//受け取った日付の一ヶ月後の日にちを設定
  return myCal.getEvents(startDate,endDate);//受け取った日付〜受け取った日付の一ヶ月後の予定を取得して渡す
}

// 合計で何時間の予定かを取得する関数
function getHour(str,date){ //getHour(予定の名前, 日付) という形で使う
  var myEvents = getEvent(date); //受け取った日付から一ヶ月間の予定を取得
  var workHours = 0;
  for each(var evt in myEvents){ //一ヶ月間の予定らそれぞれにたいして処理する
    if(evt.getTitle()==str){ //もし予定の名前が引数で設定された予定の名前なら
      workHours += (evt.getEndTime().getTime() - evt.getStartTime().getTime())/3600000;
      //予定の終わりの時間から始まりの時間を引いた値を「働いた時間」に足す(ミリ秒の形式なので、(6000(60秒*1000)*60(分)=) 3600000 で割る)
    }
  }
  return workHours; //足し合わせ終わった変数を返り値として渡す
}

取得した予定から給与を計算する

さて、どのくらい働いているかはわかりましたが、このままでは給与の計算が面倒ですね。自動で計算をしましょう。コードを書く方法もありますが、単純な計算で可変でもないので、スプレッドシートで計算させたほうが早いです。

任意のセルに=C3(先程表示させた働く時間のセルの位置)*時給と入力しましょう。すると、働く時間 * 時給でおおよそのもらえる給与がわかるようになりました。
image.pngこんな感じです。

これで、働いた時間ともらえると予想される給与がわかるようになりました。

見た目を整えて完成!

このままだと「なんの数字だ?」となってしまうので、お好みで見た目を整えます。
image.png

いい感じです。ふとした時にスプレッドシートを開くだけの給与予測ツールができました。

おわりに

今回はごく簡単なものを作りましたが、アレンジを加えて「先月と今月の比較」や「有給・休日の計算」を実装することもできます。環境構築がいらなく、簡単にツールを作って動かせるGASで是非様々なツールを作ってみてください!

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

JqueryのinArrayを使う際に、気を付ける。

動機

phpのin_arrayみたいな関数がJSで使えないかなと思い、調べていると$.inArrayというのがあることを知って、使ってみたが意図しない挙動をしたのでメモ

解説

間違った書き方
const a = 10;
const b = [1,2,3,4,5];
if($.inArray(a, b)){
    alert('');
}

この処理を実行すると条件式を満たし、alertが実行される。JqueryのinArrayは、配列に含まれていたらそのインデックスを返し、含まれていなかったら-1を返す。

const a = 10;
const b = [1,2,3,4,5];
if($.inArray(a, b) !== -1){
    alert('');
}

この処理を実行すると、条件式を満たさずalertは実行されない。含まれていなかったら-1が返ってくると、覚えておく

直感的な書き方

const a = 10;
const b = [1,2,3,4,5];
if(!b.includes(a)){
    alert('');
}

arrayのprototypeにincludesという関数が用意されている。これのほうが直感的に書けて見やすいと思う。

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

Temporal dead zoneと消えない変数

ChromeやNodeJSのJavascriptコンソール画面で動作確認する場合、
以下の様に間違ってエラーになってしまうことがあります。

const obj = JSON.parse(""); // JSON形式じゃない文字列を指定
// Uncaught SyntaxError: Unexpected end of JSON input

JSON形式の文字列で指定するところに空文字を指定した場合ですが、Uncaught SyntaxErrorとなってしまいます。

じゃあ間違えたのだからと訂正して再度実行すると

const obj = JSON.parse("[]"); // JSON形式の文字列を指定
// Uncaught SyntaxError: Identifier 'obj' has already been declared

既に宣言済みなのでエラーとなります。

それでは、既に宣言済みなら変数が存在するのだと思って参照してみると

console.log(obj); 
// Uncaught ReferenceError: obj is not defined

宣言されていないとエラーとなります。

グローバルに変数が残っているかなと思ってdeleteを実行しても消えている様子は無いです。

delete obj;
// false
const obj = JSON.parse("[]");
// Uncaught SyntaxError: Identifier 'obj' has already been declared

MDNのlet変数やconst変数の説明を見てみるとTemporal dead zoneの存在について紹介されています。

undefined の値で始まる var 変数と異なり、 let 変数は定義が評価されるまで初期化されません。変数を宣言より前で参照することは ReferenceError を引き起こします。ブロックの始めから変数宣言が実行されるまで、変数は "temporal dead zone" の中にいるのです。

どうやら、宣言されたconstlet変数はTemporal dead zoneの中に残ってしまっていて参照できない状態になっているようです。

varletconstなどJavascriptの変数はどこに宣言したとしても、スコープの先頭で宣言されてしまったことになる。つまり変数の巻き上げが行われます。
エラーが発生するなどして変数の初期化が行われなかった場合、letconstについてはTemporal dead zoneに変数があるため、宣言はされているが、参照出来ない状態となるらしいです。この状態だと変数を再使用することは出来なくなってしまいます。

対応策としては、
リロードなどをして最初から実行し直すか、
もしくは、varを使うか、

もしくはスコープを指定するか、

{
  const obj = JSON.parse("");
}

もしくはletを使っていったん宣言だけすれば最初にundefinedがセットされます。

let obj;
// undefined
obj = JSON.parse("");
// Uncaught SyntaxError: Unexpected end of JSON input
obj = JSON.parse("");
// Uncaught SyntaxError: Unexpected end of JSON input

DevToolやNodeJSのコンソール画面で操作するときにぐらいしか意識しないかと思いますが。

闇の魔術とはちょっと違うかも知れないけれど、Temporal dead☠️ zoneの呼び方が闇っぽいのでここに載せておきました。

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

Web Speech Apiの紹介(ブラウザ上での音声認識の話題)

はじめに

みなさんこんにちは。株式会社みんなのウェディングでデザイナーをしている私です。
12月もそろそろ折り返し地点ですね。くふうアドベントカレンダーも後半に差し掛かって来ましたね。
今回はWeb Speech Apiの音声認識についての紹介です。
詳しい実装方法などには触れてないです。こんなのもあるよ!程度です。

Web Speech Apiとは

https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API

Web Speech API は、音声データをウェブアプリに組み入れることを可能にします。Web Speech API は、SpeechSynthesis (Text-to-Speech; 音声合成) と SpeechRecognition (Asynchronous Speech Recognition; 非同期音声認識) の 2 つの部分から成り立っています。

音声認識の方は、リンク先で確認できる通り対応ブラウザがほぼ限られていますが、音声合成に関してはほとんどのブラウザで一応使えるみたいですね。今回は前者のみについて触れます。これを用いることで、ブラウザ上での音声認識が用意に実装できます。

特徴を挙げるとすれば、お金がかからないこと、使い始めるのが容易なことでしょうか。特に登録などもいらないので、「まずは音声認識を使ってみよう」というシチュエーションなどに適していると考えられます。

使い方

私が試しに使ってみたときは、こちらの記事を参考にしました。
丁寧に書かれているので、今すぐ使ってみたい方はこちらを読んでいただくのが早いです。
https://paiza.hatenablog.com/entry/2016/07/05/%E9%9F%B3%E5%A3%B0%E8%AA%8D%E8%AD%98%E5%85%A5%E9%96%80%EF%BC%81Web_Speech_API%E3%82%92%E4%BD%BF%E3%81%84Chrome%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%82%92%E9%9F%B3%E5%A3%B0%E6%93%8D%E4%BD%9C%E3%81%99

Web Speech Apiの音声認識を使ってやったこと

卒論書いてた頃の私が、好きな二次元キャラクターと会話を試みる動画を発掘しました。
権利の関係でここで紹介することはできませんが、そういう使い方もできます。

この時やったのはかなり単純な実装で、
1. 好きなキャラクターの「おはよう」「おやすみ」ボイスを録音する
2. Apiで認識したテキストが「おはよう」だった場合、「おはよう」ボイスを返す…みたいな実装をする
3. ブラウザ上で、ボタンを押したら音声認識をスタートするようにする
みたいな感じだったと思います。
コードは適当だったのでお見せできないです。はずかC

Web Speech Apiの音声認識でできそうなこと

今は限られたブラウザ(と、いうか、Chromeだけ…)にしか対応していませんが、このApiでできそうなことは色々ありそうです。

例えば、キャンペーンページなんかで、画面に向かってあるキーワードを発話させるようなものなんかは、良さそうな気がします。流行りの商品プレゼントキャンペーンページに取り入れたら、ただ大量の情報を流し見している現代の中でも、より強い印象をユーザーに与えることが可能になったりするのではないでしょうか。

あとは、ブラウザゲームへの応用も効きそうです。もうすぐ2020年になるのに何言ってんだって感じですが…私が遊んだ時のように、キャラクターに特定のワードを話しかけると反応が返ってくる、という機能は、昔からコンシューマゲームでもたまに見かけました。

もっと簡単なところだと、自社サイトでの音声検索の実装が容易になりそうです。

Web Speech Api の現実

現在、この機能はおそらく様々な理由から、Chromeにしか対応していないという状況です。
なので、おそらく現実的な利用シーンとしては、ブラウザ上で音声認識を使った機能を実装したい時の、テスト利用でしょうか。
もし、自社サイトで音声認識使いたい!となった場合は、Google Cloud Speech APIなどを利用することになるでしょう。ちなみにGoogle Cloud Speech APIの場合は60分の無料枠があるようです。

終わりに

さて、単純にこんなのもあるよ!といった紹介に終わってしまいましたが、いかがだったでしょうか。

最初は、「このApiを使って、擬似恋人をJSで作ればクリスマスも安心♪さみしくない♡」みたいなノリで書き始めたのですが、やめました。
ただ、Web Speech Apiは調べてみると、丁寧に詳細を説明してくれている記事がたくさん出てきて面白いので、ぜひこの際に皆様も調べて見てください?終わりです

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