20210128のJavaScriptに関する記事は24件です。

IE11でテンプレートリテラルは使えない

index.html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>JavaScriptドリル</title>
</head>
<body>
  <script src="script.js">  </script>

</body>
</html>
script.js
//キーイベント
document.addEventListener('keydown',function(event){
    var keyName = event.key;
    if(event.ctrlKey){
        console.log(`keydown:${keyName}`);
    } else if (event.shiftKey) {
        console.log(`keydown:${keyName}`);
    } else {
        console.log(`keydown:${keyName}`);
    }
});
 ie

Chrome

image.png

IE

image.png

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

A&Gのサイトにリクエストぶん投げて番組表をJSONにして出力するプログラム組んだ

まえがき

前回、推し声優のラジオ番組をON AIR一時間前に通知するという記事を投稿しました。
ここで通知のために、他の方が作られたAPIを利用してA&Gの番組放送情報をJSONで取得してましたが、午前中の番組データの一部を正しく取得できないという不具合を検知してしまいました。
修正ソースをプルリクしてもよかったのですが、どうせなら自分で作ろうかなと思った次第です。

これまでのシステム構成

以下の図のとおりです。
A&Gbot.png

新しいのはこんな感じ

AGbot.png

…ほぼ代り映えしないことに気づきました。
要は、前回まではAPIがJSONの形式でデータを返してくれていましたが、
今回の改修によって、A&Gのサイト(https://www.agqr.jp/timetable/streaming.html)にリクエストを投げて、
HTMLをテキストで取得します。
(画面イメージを載せたかったのですが、上記HPの画像・テキストの再頒布は禁止されてるようでしたので、興味ある方は上記リンクから飛んでみて見てください。)

その取得したHTMLのテキストから、番組情報を取得していく、という感じです。

どうやってJSONにしていく?

実際にリクエストを投げてかえってくるテキスト

一部のみ掲載。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>超!A&amp;G+番組表 | 超!A&amp;G | 文化放送</title>
        <meta name="keywords" content="ラジオ,AM,1134,JOQR,文化放送,アニメ,ゲーム,ネットショッピング,プレゼント,メールマガジン">
        <meta name="description" content="東京のAM/FMラジオ局、AM1134kHz、FM91.6MHz、文化放送の超!A&Gホームページ。">

        <link rel="stylesheet" type="text/css" href="/css/style.css">

        <script type="text/javascript" src="/js/jquery-1.11.1.min.js"></script>
        <script type="text/javascript" src="/js/jquery.bxslider.min.js"></script>
        <script type="text/javascript" src="/js/jquery.tabs.js"></script>
        <script type="text/javascript" src="/js/common.js"></script>
        <script type="text/javascript" src="/js/today.js"></script>

        <!-- -->
        <script>
            (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
            (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
            m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
            })(window,document,'script','//www.google-analytics.com/analytics.js','ga');
            ga('create', 'UA-3483122-9', 'auto');
            ga('send', 'pageview');
        </script>

        <style>
            .bg-repeat .bnr a[href^=http] img {
                display: none;
            }
            .schedule-ag .timetb-ag {
                margin-top: 0px;
            }
            .cf:after {
                content: " ";
                height: 0;
                font-size: 0;
                visibility:hidden;
                float: none;
                display: block;
                clear: both;
            }
            .schedule-ag .timetb-ag thead ,
            .schedule-ag .timetb-ag thead th ,
            .schedule-ag .timetb-ag thead td {
                background: none;
                background-color: #f07597;
                font-size: 13px;
            }
            .schedule-ag .timetb-ag thead td.today {
                background: none;
                background-color: #e30068;
                font-size: 18px;
            }
        </style>
    </head>
    <body>
        <!-- ▼header start -->
        <header class="header">
            <ul class="cf">
                <li class="logo"><h1><a href="/"><span>超!A&amp;G</span></a></h1></li>
                <li class="logo02"><a href="http://www.joqr.co.jp/" target="_blank"><span>文化放送</span></a></li>
                <li class="bnr"><a href="http://www.joqr.co.jp/blog/keitai/" target="_blank"><img src="https://www.agqr.jp/include/topbig/%E6%96%B0%EF%BC%89%E7%89%B9%E5%A4%A7%E3%83%90%E3%83%8A%E3%83%BC%EF%BC%88%E3%81%B2%E3%82%87%E3%82%8D%E3%81%A3%E3%81%A8%EF%BC%89760-90.gif"></a></li>
            </ul>
        </header>
        <!-- //header end-->

        <div class="header-play">
            <div class="inner cf">
                <div class="leftSide">
                    <ul>
                        <li><a href="http://radiko.jp/#QRR" target="_blank"><img src="https://icraft.hs.llnwd.net/agqr/img/btn_header_play_radiko.gif" class="ro"></a></li>
                        <li><a href="/timetable/radio.php"><img src="https://icraft.hs.llnwd.net/agqr/img/bnr_header_am_list.png" class="ro"></a></li>
                    </ul>
                </div>

                <div class="rightSide">
                    <ul>
                        <li><a href="javascript:wopen();"><img src="https://icraft.hs.llnwd.net/agqr/img/btn_header_play_ag.gif" class="ro"></a></li>
                        <li><a href="/timetable/streaming.html"><img src="https://icraft.hs.llnwd.net/agqr/img/bnr_header_ag_list.png" class="ro"></a></li>
                    </ul>
                </div>
            </div>
        </div>

        <!-- ▼contents start -->
        <div class="container cf">
            <div class="schedule-ag">
                <div class="title cf">
                    <div class="leftSide">
                        <ul>
                            <li><img src="/schedule/img/title_ag.png" alt="超!A&amp;G  番組表" title="超!A&amp;G  番組表"></li>
                            <li><a href="/timetable/radio.php"><img src="/schedule/img/bnr_am.png" alt="AM番組表はこちら" title="AM番組表はこちら"></a></li>
                        </ul>
                    </div>

                    <div class="rightSide">
                        <ul>
                            <li><img src="/schedule/img/icon_m.gif" alt="動画あり" title="動画あり"> 動画あり</li>
                            <!--<li><img src="/schedule/img/icon_p.gif" alt="パーソナリティ" title="パーソナリティ"> パーソナリティ</li>-->
                            <li><img src="/schedule/img/icon_l.gif" alt="生放送" title="生放送"> 生放送</li>
                            <li><img src="/schedule/img/icon_f.gif" alt="初回放送" title="初回放送"> 初回放送</li>
                            <li><img src="/schedule/img/icon_r.gif" alt="リピート放送" title="リピート放送"> リピート放送</li>
                        </ul>
                    </div>
                </div><script type="text/javascript" src="/js/jquery.jscrollpane.min.js"></script><script type="text/javascript" src="/js/jquery.mousewheel.js"></script><script type="text/javascript" src="/js/schedule.js"></script>
                <span style="display: block; float: none; clear: both; height: 0;"></span>

                <table class="timetb-ag" cellpadding="0" cellspacing="0" style="width: 1250px;">
                    <thead  class="scrollHead">
                        <tr>
                            <th style="width:42px;"></th>
                                <td style="width:171px;">01/25(月)</td>
                                <td style="width:170px;">01/26(火)</td>
                                <td style="width:170px;">01/27(水)</td>
                                <td style="width:170px;">01/28(木)</td>
                                <td style="width:170px;">01/29(金)</td>
                                <td style="width:170px;">01/30(土)</td>
                                <td style="width:170px;">01/24(日)</td>
                            </tr>
                        </thead>
<tbody class="scrollBody scroll-pane"><tr>
<th style="width: 42px;" class="time1" rowspan="60">6</th>
<td style="width: 171px;"  rowspan="60" class="bg-repeat">
<div class="time">
06:00<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_m.gif"></span>
</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
<a href="http://www.joqr.co.jp/blog/2h/" target="_blank">A&G ARTIST ZONE樋口楓のTHE CATCH</a>
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
樋口楓(VTuber)
</div>
</td>

<td style="width: 171px;"  rowspan="60" class="bg-repeat">
<div class="time">
06:00<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_m.gif"></span>
</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
<a href="http://www.joqr.co.jp/blog/2h/" target="_blank">A&G ARTIST ZONE 亜咲花のTHE CATCH</a>
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
亜咲花
<a href="mailto:asaka@joqr.net"><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_mail.gif"></a>
</div>
<div class="bnr"><a href="http://www.joqr.co.jp/blog/2h/" target="_blank"><img src="/timetable/%E3%82%AD%E3%83%A3%E3%83%83%E3%83%81150X50.jpg" alt="A&G ARTIST ZONE THE CATCH" title="A&G ARTIST ZONE THE CATCH"></a></div>
</td>

<td style="width: 170px;"  rowspan="60" class="bg-repeat">
<div class="time">
06:00<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_m.gif"></span>
</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
<a href="http://www.joqr.co.jp/blog/2h/" target="_blank">A&G ARTIST ZONE petit fleursのTHE CATCH</a>
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
petit fleurs(森中花咲、御伽原江良)
<a href="mailto:petit@joqr.net"><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_mail.gif"></a>
</div>
<div class="bnr"><a href="http://www.joqr.co.jp/blog/2h/" target="_blank"><img src="/timetable/%E3%82%AD%E3%83%A3%E3%83%83%E3%83%81150X50.jpg" alt="A&G ARTIST ZONE THE CATCH" title="A&G ARTIST ZONE THE CATCH"></a></div>
</td>

<td style="width: 170px;"  rowspan="60" class="bg-repeat">
<div class="time">
06:00<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_m.gif"></span>
</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
<a href="http://www.joqr.co.jp/blog/2h/" target="_blank">A&G ARTIST ZONE 煌めき☆アンフォレントのTHE CATCH</a>
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
煌めき☆アンフォレント
<a href="mailto:kirafore@joqr.net"><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_mail.gif"></a>
</div>
<div class="bnr"><a href="http://www.joqr.co.jp/blog/2h/" target="_blank"><img src="/timetable/%E3%82%AD%E3%83%A3%E3%83%83%E3%83%81150X50.jpg" alt="A&G ARTIST ZONE THE CATCH" title="A&G ARTIST ZONE THE CATCH"></a></div>
</td>

<td style="width: 170px;"  rowspan="60" class="bg-repeat">
<div class="time">
06:00<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_m.gif"></span>
</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
<a href="http://www.joqr.co.jp/blog/2h/" target="_blank">A&G ARTIST ZONE伊東歌詞太郎 のTHE CATCH</a>
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
伊東歌詞太郎
<a href="mailto:kashitaro@joqr.net"><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_mail.gif"></a>
</div>
</td>

<td style="width: 170px;"  rowspan="60" class="bg-repeat">
<div class="time">
06:00<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_m.gif"></span>
</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
prediaのミュージック+プレミアム
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
predia
<a href="mailto:sag@joqr.net"><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_mail.gif"></a>
</div>
</td>

<td style="width: 170px;"  rowspan="60" class="bg-repeat">
<div class="time">
06:00<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_m.gif"></span>
</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
prediaのミュージック+プレミアム
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
predia
<a href="mailto:sag@joqr.net"><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_mail.gif"></a>
</div>
</td>

</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
</tr>
<tr>
<th style="width: 42px;" class="time1" rowspan="60">7</th>
<td style="width: 171px;"  rowspan="30" class="bg-repeat">
<div class="time">
07:00</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
<a href="https://twitter.com/pokaradi_qr" target="_blank">白石晴香のぽかぽかたいむ</a>
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
白石晴香
<a href="mailto:poka@joqr.net"><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_mail.gif"></a>
</div>
</td>

どうデータを取るか?

幸いなことに、ページに含まれるtbodyは一つで、この中に情報が表示されてきます。
また、一つの番組の情報はtbodyの中にあるtdタグ内部に情報が詰まっています。
- 1行目のrowspan: 放送時間
- 1行目のclass: 放送の種類
- bg-repeat: 再放送
- bg-f: 初回放送
- bg-l: 生放送
- 3行目: 放送開始時間
- 5行目: 番組タイトル

ただし、tdタグ内部は二つのパターンがあるみたいです。

<td style="width: 171px;"  rowspan="60" class="bg-repeat">
<div class="time">
06:00<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_m.gif"></span>
</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
<a href="http://www.joqr.co.jp/blog/2h/" target="_blank">A&G ARTIST ZONE樋口楓のTHE CATCH</a>
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
樋口楓(VTuber)
</div>
</td>
<td style="width: 170px;"  rowspan="30" class="bg-repeat">
<div class="time">
07:00</div>
<div class="title-p" style="word-break: break-all; width: 150px;">
早見沙織の ふり~すたいる♪
</div>
<div class="rp">
<!--<span><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_p.gif"></span>-->
早見沙織
<a href="mailto:frst@joqr.net"><img src="https://icraft.hs.llnwd.net/agqr/schedule/img/icon_mail.gif"></a>
</div>
</td>

違いは4,5行目。
前者は/spanの後に改行が入ってdivが来ますが、後者は/span divと来ています。
なのでこいつらは後者の形で統一できるように、HTTPリクエストを投げてテキストデータを取得した段階で、後者の形になるように置換をかけます。

これでtbodyの中にある番組情報を取得する準備が整いました。
実際の手順は、

  1. 取得したテキストから、一行ずつファイルを読み込んで行きます
  2. 読み込んだ一行の分のテキストの中にtbodyが出現し始めたら、データ取得の開始
  3. tdの開始タグが登場してから、特定の行数を目印にtdの終了タグが現れるまで情報を取得します。取得した情報は、オブジェクトに格納してきます。
  4. 取得した一つの番組の情報(=オブジェクト)をどんどん配列に追加していきます。
  5. tbodyの終了タグが現れたら、読み込みを終了します。

A&GのHPには一週間分の番組情報が載っているので、これで一週間分の番組情報が取得できました。
次に、この情報達を1日ごとにJSONファイルに割り振っていきます。

どう一日ずつのデータに割り振るか?

実際のデータは月~日までありますが、説明は月~金に割愛しています。
下の図の通り、A&GのHPでは一番左の列には常に月曜日の放送データが来るので、

2021-01-28-23-19-34.png

のようなa~qのデータは、一週間分を取得した段階では、

2021-01-28-23-20-46.png

となっています。
ここから月~金のそれぞれの配列の中に番組情報を格納していきます。
繰り返しになりますが、A&Gの番組表は一番左が常に月曜日になるので、
配列の0~4番目はそれぞれ月~金に該当します。
が、ここで問題になるのはaやfのようなデータです。
こいつらを適切な曜日に振り分けるためには、例えばaの場合だと、
aの放送終了時間と、fの放送開始時間を照らし合わせます。
aの放送開始時間=fの放送開始時間ならば、fは月曜日の番組となります。
そうでなければ、bの放送開始時間=fの放送時間ならば、fは火曜日の番組となります。
そんな感じでデータを振り分けた結果が、下の画像のような感じです。

2021-01-28-23-26-11.png

あとは、曜日ごとに振り分けた配列データを、JSONに出力してやれば完了です。

実際のソースとJSONファイル

https://github.com/ysknsid25/shallor

あとがき

JavaScriptでソースを書いてるので、どこかのクラウドにnginx + node.jsでAPIサーバーさえ立ててやれば外部の方にもサービス公開できそうかな…
コストがどのくらいかかるかわからんので、やってないが…

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

mapについて

javascriptのmapについて理解不足だったので、まとめてみます。

mapとは

配列内の要素をコールバックで処理(加工)して、配列としてreturnするメソッド。

index.js
const names = ["田中", "山田", "佐藤", "鈴木"];

const members = map((secondName, index) => {
return `${index + 1}番目は${secondName}です`
});

console.log(members);

出力結果
["1番目は田中です。","2番目は山田です。","3番目は佐藤です。","4番目は鈴木です。"]

第一引数secondNameはnameで定義した配列。
第二引数はindexは配列membersの各要素のインデックス番号となります。

終わりに

filterも後々まとめたいと思います。

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

React入門!!の前に知っておきたいNode.jsとJavaScriptの知識

対象読者とこの記事から何が得られるのか

そろそろReactの勉強をはじめてみたい、勉強したいと思っているけど、以下のようなことを考えて前に進めない方向けの記事です。

  • JavaScriptの知識レベルはこれくらいで足るのか?
  • 他に知っておくことはないのかな?

この記事を読むことでReact入門の前に知っておきたいNode.jsとJavaScriptの知識を得ることができると思います。

Node.jsをざっと知る

Node.jsを簡単に言うと

JavaScriptをブラウザではなくPythonやRubyなどと同じようにターミナル上で動かすことができるようにするための実行環境のことであり、バックエンド言語としてJavaScriptを使うことができ、ファイル操作などのOSの機能にアクセスできます。

公式ページだと以下のように記載されています。

Node.js はスケーラブルなネットワークアプリケーションを構築するために設計された非同期型のイベント駆動の JavaScript 環境です。

Node.jsが注目を浴びたのはバックエンドも同じ言語(JavaScript)で書けたら効率的ではないかというのが最大の理由です。フロントエンド開発はJavaScriptほぼ一択ということもあり、こういう流れになったのではないかと思います。

なぜReactアプリ開発でNode.jsが必要なのか

ではなぜバックエンド開発のために開発されたNode.jsがReactアプリ(フロントエンド)開発で必要になってきたのでしょうか。

Reactのような大規模なアプリケーションを開発するとなると、様々なパッケージが必要になり、そのパッケージたちが特定のバージョンで依存しあっています。Node.jsはそのパッケージのインストールと整合性の管理を解決するために必要であり、npm(Node Package Manager)がその解決を担っています。

もともとはバックエンド開発のためにnpmは使われるパッケージ管理システムであったが、フロントエンド用のパッケージを提供するのにも使われるようになり、最近では使用用途としてはフロントエンドのほうが多くなってきているそうです。

その他以下のような用途でも使われます。

  • JavaScriptやCSSファイルをバンドルできる
  • ブラウザ実行時にpolyfill※するのではなく 最初からコンパイルしておける
  • ローカル環境で開発用のHTTPサーバを起動してアプリケーションを稼働させることができる
  • ユニットテストやE2Eテストなどのテストを実行できる
  • ローカル環境で構文解析等実行できる

※polyfillとは最近の機能をサポートしていない古いブラウザーで、その機能を使えるようにするためのコードです。

Node.jsをインストール

公式サイトからインストールしたり、MacならHomebrew・Windowsならwingetなどでインストールできますが、プロジェクトごとに異なるバージョンの環境を共存させる必要があるので、バージョンマネージャー(nvmやnodenv)を使ってインストールしましょう。

Node.jsでプロジェクト作成

作成したいディレクトリに移動してターミナルで以下コマンドでプロジェクトを作成できます。

対話形式でプロジェクト名など聞かれますので、全て入力するとpackage.jsonが作成されます。

$ npm init

-yをつければ、全てデフォルトのままプロジェクト作成できます。

$ npm init -y

npm-scripts

package.jsonのscriptsに記載されているコマンドを以下で実行できます。

$ npm run xxx

では試しにnpm-scriptsでプログラムを実行してみましょう。
まずindex.jsを作成する。

index.js
console.log("Hello World!!");

package.jsonにstartでのコマンドを追加する。
node.jsではnode+ファイル名 でそのファイルのプログラムを実行できます。

"scripts": {
  "start": "node index.js"
}

以下コマンドで実行する。

$ npm run start

以下出力されれば成功です。

Hello World!!

start,stop,restart,testの場合はrunを省略して実行できます。

$ npm start

Reactアプリ開発ではcreate-react-appというコマンドで新規プロジェクトが作成できますが、こちらで作られたプロジェクトだと以下が記載されています。たとえばnpm run startを実行するとreact-scripts startが実行され、ローカル環境でReactアプリが起動できます。react-scriptsはcreate-react-appで作られたプロジェクトの中でBabelやwebpackなどが裏で動かすことができます。

"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject"
},

Yarnとは

npmコマンドのFacebookによる改良版で記述が短い・実行が速いなどのメリットがある。
Yarnがグローバルインストールされていた場合はCreate React App使用時にyarnがデフォルトになるので、npmコマンドを使いたいなら--use-npmオプションを指定しましょう。

Yarnのコマンド

  • yarn (install) はプロジェクトのルートディレクトリに存在するpackage.jsonの内容を参照して、依存関係のあるパッケージをすべてインストールする
  • yarn add xxx で指定したパッケージをインストールする
  • yarn remove xxx で指定したパッケージをアンインストールする
  • yarn upgrade xxx で指定したパッケージを最新バージョンに更新する
  • yarn info xxx で指定したパッケージの情報を表示する

上記のnpm run xxxは以下コマンドで同じことができます。

yarn xxx

JavaScriptをざっと知る

変数宣言について

  • varは再宣言も再代入も可。スコープはブロックをすり抜ける。
  • letは再代入のみ可。スコープはブロック内。
  • constはどちらも不可。スコープはブロック内。

constを第一選択肢として、どうしても仕方ない場合のみletを使う。
varは使用しないようにしましょう。

var name1 = "瀬戸熊";
name1 = "佐々木"      // 再代入可
var name1 = "滝沢"    // 再宣言可

let name2 = "瀬戸熊";
name2 = "佐々木"      // 再代入可
let name2 = "滝沢"    // 再宣言不可:'name2' has already been declared

const name3 = "瀬戸熊";
name3 = "佐々木"      // 再代入不可:"name3" is read-only
const name3 = "滝沢"  // 再宣言不可:'name3' has already been declared

スコープに関してもvarを使うと以下のようになり、安全ではないのでletやconstを使うべきである。

var name1 = "高宮";
if (true) {
  var name1 = "二階堂";
  var name2 = "魚谷";
  console.log(name1); // 二階堂
  console.log(name2); // 魚谷
}
console.log(name1); // 二階堂(ブロック内でも書き換え可能なので危険)
console.log(name2); // 魚谷(ブロック内で定義したものがブロック外でも参照できる)

letだとブロック内で書き換えや定義しても、ブロック外に影響を与えない。

let name1 = "高宮";
if (true) {
  let name1 = "二階堂";
  let name2 = "魚谷";
  console.log(name1); // 二階堂
  console.log(name2); // 魚谷
}
console.log(name1); // 高宮
console.log(name2); // name2 is not defined

データ型について

プリミティブ型

以下7種類ある。

  • Boolean型
  • Number型
  • BigInt型
  • String型
  • Symbol型
  • Null型
  • Undefined型

falsyな値

MDNによると、falsyとは偽値 (falsy または falsey な値) は、 Boolean コンテキストに現れたときに偽とみなされる値です。

以下8種類あります。

  • false
  • 0
  • -0
  • 0n
  • Nan
  • ""(空文字)
  • null
  • undefined

以下はifブロックを実行しません。

if (false){}
if (null){}
if (undefined){}
if (0){}
if (-0){}
if (0n){}
if (NaN){}
if (""){}

関数

関数宣言文と関数式

定義の方法は関数宣言文による定義と関数式による定義があるがconstを使った関数式による定義が推奨されています。
無名関数は定義時に名前を与えられない関数のことで、関数式は変数に無名関数を入れているようなもの。
変数に関数式を代入することになるので、関数宣言文と違って先に定義しておかないと使えません。

// 関数宣言文による定義
function Introduce1(name) {
  return `私の名前は${name}です。`
} 
// 関数式による定義 
const Introduce2 = function (name) {
  return `私の名前は${name}です。`
};

アロー関数

アロー関数は引数が一つだと括弧の省略ができる。(推奨はされていないらしいが・・・)
また、retern文が1行だとreturnも省略できます。

// アロー関数式
const Introduce3 = (name) => {
  return `私の名前は${name}です。`
};
// アロー関数式、さらに省略記法
const Introduce4 = name => `私の名前は${name}です。`;

デフォルト引数

デフォルト値が設定された引数は省略が可能。
以下の例では第二引数を省略するとageが18で代用される。

const Introduce5 = (name, age = 18) => `私の名前は${name}です。${age}歳です。`;
console.log(Introduce5("白鳥", 32)); // 私の名前は白鳥です。32歳です。
console.log(Introduce5("松本")); // 私の名前は松本です。18歳です。

Rest Parameters

最後の引数に...を付けることで残りの引数を配列として受け取れる。

const Names = (name1, name2, ...rest) => {
  console.log(name1);   // 瀬戸熊
  console.log(name2);   // 佐々木
  console.log(rest);    // ["滝沢","二階堂","高宮"]
};
Names('瀬戸熊', '佐々木', '滝沢', '二階堂', '高宮');

Reactで開発する上で良く使う構文

プロパティ名のショートハンド

プロパティのキー名と値を同じにする。

const name = "瀬戸熊";
const obj = { name };  // = const obj = { name: name };
console.log(name); // { name: "瀬戸熊" }

分割代入

配列とオブジェクトの値を分割して代入する。

const data = [180, 70]
const [height, weight] = data;
console.log(`私の身長は${height}cmで、体重は${weight}kgです。 `); // 私の身長は180cmで、体重は70kgです。

const user = { name: "瀬戸熊", age: 50 };
const { name, age } = user;
console.log(`私の名前は${name}です。${age}歳です。`); // 私の名前は瀬戸熊です。50歳です。

スプレッド構文

const names1 = ["瀬戸熊", "佐々木", "滝沢"];
const names2 = [...names1, "二階堂", "高宮"];
console.log(names2); // [ "瀬戸熊","佐々木","滝沢","二階堂","高宮" ]

const users1 = { name: "瀬戸熊", age: 48, sex: "" };
const users2 = { ...users1, group: "renmei", grade: "A1" };
console.log(users2); // { name: "瀬戸熊", age: 50, sex: "男", group: "renmei", grade: "A1" }

オブジェクトのコピー

分割代入を用いてオブジェクトをコピーできます。

しかし、こちらはシャローコピーと言ってオブジェクトの深さが1段階までしか有効ではない。

オブジェクトの深さが2段階目の値を変更すると、コピー元の値まで変更されてしまう。

const user1 = { name: "瀬戸熊", age: 50, sex: "" };
const user2 = { ...user1 };
console.log(user2); // { name: "瀬戸熊", age: 50, sex: "男" }
console.log(user2 === users1); // false

const user3 = { name: "瀬戸熊", age: 50, sex: "", group: { group: "連盟", grade: "A1" } };
const user4 = { ...user3 };
console.log(user4);  // user4:  { name: '瀬戸熊', age: 50, sex: '男', group: { group: '連盟', grade: 'A1' } }
user4.group.group = "協会";
console.log(user3);  // user3:  { name: '瀬戸熊', age: 50, sex: '男', group: { group: '協会', grade: 'A1' } }
console.log(user4);  // user4:  { name: '瀬戸熊', age: 50, sex: '男', group: { group: '協会', grade: 'A1' } }

完全なコピー(ディープコピー)の方法はいくつかあるが、JSONパースを用いる方法は以下の通り。

プロパティにDateオブジェクトや関数が入ってた場合はうまく動かないので注意。

const user3 = { name: "瀬戸熊", age: 50, sex: "", group: { group: "連盟", grade: "A1" } };
const user4 = JSON.parse(JSON.stringify(user3));
console.log(user4);  // user4:  { name: '瀬戸熊', age: 50, sex: '男', group: { group: '連盟', grade: 'A1' } }
user4.group.group = "協会";
console.log(user3);  // user3:  { name: '瀬戸熊', age: 50, sex: '男', group: { group: '連盟', grade: 'A1' } }
console.log(user4);  // user4:  { name: '瀬戸熊', age: 50, sex: '男', group: { group: '協会', grade: 'A1' } }

他にはLodashのcloneDeep()を使う方法などある。

ショートサーキット評価(短絡評価)

  • || は左辺がfalsyな値だと評価が右辺に渡される。
  • ?? は左辺がnullかundefinedだと評価が右辺に渡される。
  • && は左辺がtruthyな値だと評価が右辺に渡される

|| はちょっと前に賛否ありましたが、一応よくでてくるので記載しておきます・・・。

const name1 = '瀬戸熊';
const name2 = '佐々木';

true && console.log(name1); // 瀬戸熊
false && console.log(name1); // 出力なし
true || console.log(name2); // 出力なし
false || console.log(name2); // 佐々木
null ?? console.log(name2); // 佐々木
undifined ?? console.log(name2); // 佐々木

配列・オブジェクトの処理

map,filter,find,findIndex,every,some

const dataset = [1, 2, 3, 4, 5, 6, 7, 8, 9];
console.log(dataset.map((data) => data * 3)); // [ 3, 6, 9, 12, 15, 18, 21, 24, 27 ]
console.log(dataset.filter((data) => data > 5)); // [ 6, 7, 8, 9 ]
console.log(dataset.find((data) => data > 5)); // 6
console.log(dataset.findIndex((data) => data > 5)); // 5
console.log(dataset.every((data) => data > 5)); // false
console.log(dataset.some((data) => data > 5)); // true
  • map():与えられた関数を配列のすべての要素に対して呼び出し、その結果からなる新しい配列を生成する。
  • filter():与えられた関数によって実装されたテストに合格したすべての配列からなる新しい配列を生成する。
  • find():提供されたテスト関数を満たす配列内の 最初の要素の値を返します。見つからなかった場合はundefinedを返す。
  • findIndex():配列内の指定されたテスト関数を満たす最初の要素の位置を返します。テスト関数を満たす要素がない場合を含め、それ以外の場合は-1 を返します。
  • every():配列内のすべての要素が指定された関数で実装されたテストに合格するかどうかを真偽値で返します。
  • some():配列の少なくとも一つの要素が、指定された関数で実装されたテストに合格するかどうかを真偽値で返します。

reduce,sort

  • reduce():配列の各要素に対して (引数で与えられた) reducer関数を実行して、単一の出力値を生成します。
  • sort(): 配列の要素をソートします。既定のソート順は昇順で、要素を文字列に変換してから、UTF-16コード単位の値の並びとして比較します。
const dataset = [1, 2, 3, 4, 5, 6, 7, 8, 9];
console.log(dataset.reduce((a, b) => a + b)); // 45
console.log(dataset.sort((a, b) => a > b ? -1 : 1)); // [ 9, 8, 7, 6, 5, 4, 3, 2, 1 ]

forEach,for...of

  • forEach():メソッドは与えられた関数を、配列の各要素に対して一度ずつ実行します。
  • for...of:反復可能オブジェクトなどに対して、反復的な処理をするループを作成します。

本来なら使わないほうがいいが、どうしても使う場合はforEach推奨。

const dataset = [1, 2, 3, 4, 5, 6, 7, 8, 9];
dataset.forEach((data) => {
  if (data % 2 === 0) {
    console.log(`${data}は偶数です。`);
  }
});
for (let data of dataset) {
  if (data % 2 === 0) {
    console.log(`${data}は偶数です`);
  }
}

includes

特定の要素が配列に含まれているかどうかを true または false で返します。

const dataset = [1, 2, 3, 4, 5];
console.log(dataset.includes(1)); // true
console.log(dataset.includes(7)); // false

また、以下のような||演算子が繰り返し使われるコードを完結に書けます。

if ( x === 'a' || x === 'b' || x === 'c' ) {
  console.log('ok')
}
if (['a', 'b', 'c'].includes(x)) {
  console.log('ok')
}

Object.keys,values,entries

  • Object.keys():プロパティのキーのリストを配列で取得できる
  • Object.values():プロパティ値のリストを配列で取得できる
  • Object.entries():プロパティのキーと値が対になった2次元配列を取得できる
const user = {
  id: 1,
  name: '瀬戸熊',
  age: 50,
};
console.log(Object.keys(user));
// [ 'id', 'name', 'age']

console.log(Object.values(user));
// [ 1, '瀬戸熊', 50 ]

console.log(Object.entries(user));
// [
//   [ 'id', 1 ],
//   [ 'name', '瀬戸熊' ],
//   [ 'age', 50 ],
// ]

非同期処理

Promise

PromiseはES2015から導入されたJavaScriptの組み込みオブジェクトで、非同期処理の最終的な完了処理 (もしくは失敗) およびその結果の値を表現するものであり、Promiseを使うことによって、非同期処理の完了を待って次の処理を行うというのがJavaScriptでもできるようになります。

以下例文

  • 最初のresolve()に渡したものが.then()の引数のvalueになり、その.then()内でreturnしたものが次の.thenのvalueになる。
  • reject()に渡したものが、errorとしてcatch()で受け取れる。
  • finally()に渡された関数は必ず最後に実行される。(ES2018から)
const promise = new Promise((resolve, reject) => {
  if (isSuccess) {
    resolve('成功1');
  } else {
    reject(new Error('失敗'));
  }
});
promise.then((value) => {
    console.log(value);
    return '成功2';
  })
  .then((value) => {
    console.log(value);
  })
  .catch((error) => {
    console.error(error);
  })
  .finally(() => {
    console.log('完了');
  });

axiosを使ってAPIをたたいてみる
async/awaitを使わない場合

const getUser = (userId) => {
  return new Promise(function (resolve, reject) {
    axios
      .get(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then((response) => resolve(response.data))
      .catch((error) => reject(error.response.status));
  });
};

getUser(1)
  .then((user) => {
    console.log(user);
  })
  .catch((error) => {
    console.log(error);
  })
  .finally(() => {
  });

async/awaitを使った場合

関数宣言時にasyncキーワードを付与するとその関数は、返される値がPromise.resolve()によってラップされたものになる。

asyncをつけた非同期関数内では他の非同期関数をawaitをつけて呼び出すことができる。

const getUser2 = async (userId) => {
  try {
    const response = await axios.get(
      `https://jsonplaceholder.typicode.com/users/${userId}`
    );
    return response.data;
  } catch (error) {
    throw error.response.status;
  }
};
const main = async () => {
  try {
    const user = await getUser2(1);
    console.log(user);
  } catch (error) {
    console.error(error);
  } finally {
  }
};
main();

モジュール

名前付きエクスポート

1ファイルでいくらでもエクスポートできる。

src/export.js
const NAME = "瀬戸熊";
const AGE = 50;
const Introduce = (name) => `私の名前は${name}です`
export { NAME, AGE, Introduce };

or

export const NAME = "瀬戸熊";
export const AGE = 50;
export const Introduce = (name) => `私の名前は${name}です`

名前付きエクスポートの場合は{}をつけてインポートする

src/inport.js
import { NAME, AGE, Introduce } from "./export"

デフォルトエクスポート

1ファイル1回までしかエクスポートできない

src/export.js
export const NAME = "瀬戸熊";
export const AGE = 50;
const Introduce = (name) => `私の名前は${name}です`
export default Introduce;

デフォルトエクスポートの場合は{}はつけないでインポートする。名前も自由に命名できる。

src/inport.js
import Introduce, { NAME, AGE } from "./export"

まとめ

React入門の前に知っておきたいNode.jsとJavaScriptの知識を簡単にまとめました。

今回記載した項目が大体頭に入っていれば、Reactに入門してもよいのではないかと思います。

このほかにもクラスやthisの扱いなど学ぶべきものはいっぱいありますが、Reactが関数コンポーネント主体になってきたのであまり使わない印象があるので、一旦は飛ばしてもよいかと思い省略しました。(理解はしておいたほうがいいですが)

あとTypeScriptはReactでの開発においてマストなものになりつつあるので、早めにTypeScriptでReactを書けるように学んでいったほうがいいと思います。

間違っていたり、これも理解しておくべきなどありましたら、指摘していただけると幸いです。

最後まで読んでいただきありがとうございました!!

参考

MDN

Polyfill (ポリフィル)
Falsy (偽値)
Array.prototype.map()
Array.prototype.filter()
Array.prototype.find()
Array.prototype.findIndex()
Array.prototype.every()
Array.prototype.some()
Array.prototype.reduce()
Array.prototype.sort()
Array.prototype.includes()
Promise

Qiita

Node.jsとはなにか?なぜみんな使っているのか?
Promiseの使い方、それに代わるasync/awaitの使い方

りあクト

りあクト! TypeScriptで始めるつらくないReact開発 第3.1版【Ⅰ. 言語・環境編】

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

QRコードをJavaScriptで作る方法

はじめに

QRコードを作りたいと思い制作しました。
jquery-qrcode.jsというライブラリを使用しました。

最終形態

スクリーンショット (16).png
ここまでやってみましょう。

QRコードを生成するHTMLとJavaScriptを書く

まずは、jquery-qrcode.jsをダウンロードする。

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Make-QR-Code</title>
  </head>
  <body>
    <div class="qr-input">
      <input type="text" name="" value="" id="ip-qrcode" placeholder="Text"><br>
      <div class="option">
        <input type="number" pattern="\d*" onpaste="return false" name="" value="" placeholder="size(px)" id="size">
        <button type="button" id="make">Make</button>
      </div>
    </div>
    <div class="" id="op-qrcode"></div>
    <a class="hidden" id="link"></a>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="jquery.qrcode.min.js" charset="utf-8"></script>
    <script src="qr-code.js" charset="utf-8"></script>
  </body>
</html>

1つ目のinputでテキストを入力します。
2つ目のinputでサイズを指定します。

qr-code.js
function canvas() {
  $('canvas').attr('id', 'canvas'); //canvasにidをつける
};

function hidden() {
  $('#op-qrcode').empty(); //#op-qrcodeの子要素削除
};

$('#make').on('click', function(){ //makeボタンが押された場合の処理
  hidden(); //二回目にmakeがクリックされた場合の処理
  var input = $('#ip-qrcode').val(); //テキストを取得
  var size = $('#size').val(); //サイズを取得
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') { //テキストが入力されていなかった場合の処理
    alert('文字を入力してください。\nPlease enter the characters.');
  } else if (size > 0) { //サイズが指定されている場合の処理
    $('#op-qrcode').qrcode({text: text, width: size, height: size});
    canvas();
  } else { //サイズが指定されていない場合の処理
    $('#op-qrcode').qrcode({text: text, width: 1000, height: 1000});
    canvas();
  }
});

jquery-qrcode.jsでは$('#qrcode').qrcode({'qrcode'}) と書くとQRコードが生成されます。便利です。
width:1000,height:1000とすると、1000px × 1000pxで生成してくれます。

途中経過1

スクリーンショット (12).png
一応生成されましたがデザイン的にNGですよね。何より、生成されたコードがデカすぎます。
ということで、cssを書いていきます。

cssを書く

main.css と qr-code.cssを書きます。

main.css
body {
  margin: 20px;
  padding: 20px 30px;
}

.hidden {
  display: none;
}

* {
 -webkit-appearance: none;
}

input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{
  -webkit-appearance:none;margin:0
}

input[type=number]{
  -moz-appearance:textfield
}

特に重要ではなさそうですが、input[type=number]::-webkit-inner-spin-buttonのくだりで横にあるスピンボタン(矢印)を消します。
スクリーンショット (18).png

qr-code.css
input {
  padding: 12px 14.4px;
  font-size: 18px;
  border-radius: 3px;
  border: 1px solid #ddd;
  box-sizing: border-box;
  margin-bottom: 1rem
}

input:focus {
  box-shadow: 0 0 5px 0 rgba(251, 63, 109, 0.75);
  border: 1px solid #fff;
  outline: 0;
}

button {
  background-color: #fff;
  color: #fb3f6d;
  border: 1px solid #fb3f6d;
  padding: 12px 14.4px;
  font-size: 18px;
  border-radius: 3px;
  margin-bottom: 16px;
  width: 88px;
  cursor: pointer;
}

button:hover {
  background-color: #fff5f7;
  transition-duration: 0.2s
}

button:focus {
  background-color: #fff5f7;
  outline: none;
  box-shadow: 0 0 5px 0 rgba(251, 63, 109, 0.75);
}

canvas {
  width: 250px;
  height: 250px;
  margin: 10px;
}

#size {
  width: 120px;
  text-align: center;
}

#size::placeholder {
  text-align: center;
}

input:focus::placeholder {
  visibility: hidden;
}

#make {
  margin-right: 10px;
}

#ip-qrcode {
  width: 100%;
  font-size: 20px
}
index.html
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="qr-code.css">

忘れずにhtmlでcssを指定しましょう。

途中経過2

スクリーンショット (14).png
いい感じですね。
ですが、ダウンロードやサイズを指定するのは面倒くさいのでワンクリックでサイズ指定できるようにしていきましょう。

UI改善1

index.html
<button class="hidden" type="button" id="down" onclick="download()">Download</button>
<button type="button" name="button" id="500">500px</button>
<button type="button" name="button" id="1000">1000px</button>
<button type="button" name="button" id="1500">1500px</button>

<button type="button" id="make">Make</button>のあとに突っ込みます。

qr-code.css
#down {
  background-color: #fb3f6d;
  width: 130px;
  color: #fff;
  border: 1px solid #da2652;
  margin-bottom: 1rem;
  cursor: pointer;
}

#down:hover {
  background-color: #fff5f7;
  border: 1px solid #fb3f6d;
  color: #fb3f6d;
  transition-duration: 0.2s
}

#down:focus {
  color: #fb3f6d;
  border: 1px solid #fb3f6d;
  background-color: #fff5f7;
  outline: none;
  box-shadow: 0 0 5px 0 rgba(251, 63, 109, 0.75);
}

追加します。

qr-code.js
function canvas() {
  $('canvas').attr('id', 'canvas');
  $('#down').removeClass('hidden') //新しく追加
};

function hidden() {
  $('#op-qrcode').empty();
  $('#down').addClass('hidden') //新しく追加
};

$('#500').on('click', function(){
  hidden();
  var input = $('#ip-qrcode').val();//inputのvalueを取得
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') {
    alert('文字を入力してください。\nPlease enter the characters.');
  } else {
    $('#op-qrcode').qrcode({text:text, width:500, height:500});
    canvas();
  }
});

$('#1000').on('click', function(){
  hidden();
  var input = $('#ip-qrcode').val();//inputのvalueを取得
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') {
    alert('文字を入力してください。\nPlease enter the characters.');
  } else {
    $('#op-qrcode').qrcode({text:text, width:1000, height:1000});
    canvas();
  }
});

$('#1500').on('click', function(){
  hidden();
  var input = $('#ip-qrcode').val();//inputのvalueを取得
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') {
    alert('文字を入力してください。\nPlease enter the characters.');
  } else {
    $('#op-qrcode').qrcode({text:text, width:1500, height:1500});
    canvas();
  }
});

function download() {
  var canvas = document.getElementById('canvas');
  var link = document.getElementById('link');
  link.href = canvas.toDataURL('image/png');
  link.download = 'qrcode.png';
  link.click();
};

途中経過3

スクリーンショット (15).png
いいですね。
もっと便利にしていきます。

UI改善2 ショートカット

shortcut.jsを使います。
ダウンロードをして

index.html
<script src="shortcut.js" charset="utf-8"></script>

忘れずにqr-code.jsの前に突っ込んでください。

qr-code.js
shortcut.add('Ctrl+D',function() {
  download()
}); //Ctrl+Dでダウンロード

shortcut.add('Ctrl+M',function() {
  hidden();
  var input = $('#ip-qrcode').val();//inputのvalueを取得
  var size = $('#size').val();
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') {
    alert('文字を入力してください。\nPlease enter the characters.');
  } else if (size > 0) {
    $('#op-qrcode').qrcode({text: text, width: size, height: size});
    canvas();
  } else {
    $('#op-qrcode').qrcode({text: text});
    canvas();
  }
}); //Ctrl+Mで生成

shortcut.add('Ctrl+Shift+C',function() {
  hidden()
}); //Ctrl+Shift+Cでクリア 隠しコマンド

shortcut.jsはshortcut.add('Ctrl+Enter',function() {処理})とすると簡単にショートカットを作れる。

index.html
<button type="button" id="make" title="Ctrl+M">Make</button>
<button class="hidden" type="button" id="down" onclick="download()" title="Ctrl+D">Download</button>

コマンドを認知しやすいようにtitleを追加します。

完成

スクリーンショット (16).png

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Make-QR-Code</title>
    <link rel="stylesheet" href="main.css">
    <link rel="stylesheet" href="qr-code.css">
  </head>
  <body>
    <div class="qr-input">
      <input type="text" name="" value="" id="ip-qrcode" placeholder="Text"><br>
      <div class="option">
        <input type="number" pattern="\d*" onpaste="return false" name="" value="" placeholder="size(px)" id="size">
        <button type="button" id="make" title="Ctrl+M">Make</button>
        <button class="hidden" type="button" id="down" onclick="download()" title="Ctrl+D">Download</button>
        <button type="button" name="button" id="500">500px</button>
        <button type="button" name="button" id="1000">1000px</button>
        <button type="button" name="button" id="1500">1500px</button>
      </div>
    </div>
    <div class="" id="op-qrcode"></div>
    <a class="hidden" id="link"></a>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="jquery.qrcode.min.js" charset="utf-8"></script>
    <script src="shortcut.js" charset="utf-8"></script>
    <script src="qr-code.js" charset="utf-8"></script>
  </body>
</html>
qr-code.js
function canvas() {
  $('canvas').attr('id', 'canvas'); //canvasにidをつける
  $('#down').removeClass('hidden')
};

function hidden() {
  $('#op-qrcode').empty(); //#op-qrcodeの子要素削除
  $('#down').addClass('hidden')
};

$('#make').on('click', function(){ //makeボタンが押された場合の処理
  hidden(); //二回目にmakeがクリックされた場合の処理
  var input = $('#ip-qrcode').val(); //テキストを取得
  var size = $('#size').val(); //サイズを取得
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') { //テキストが入力されていなかった場合の処理
    alert('文字を入力してください。\nPlease enter the characters.');
  } else if (size > 0) { //サイズが指定されている場合の処理
    $('#op-qrcode').qrcode({text: text, width: size, height: size});
    canvas();
  } else { //サイズが指定されていない場合の処理
    $('#op-qrcode').qrcode({text: text, width: 1000, height: 1000});
    canvas();
  }
});

$('#500').on('click', function(){
  hidden();
  var input = $('#ip-qrcode').val();//inputのvalueを取得
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') {
    alert('文字を入力してください。\nPlease enter the characters.');
  } else {
    $('#op-qrcode').qrcode({text:text, width:500, height:500});
    canvas();
  }
});

$('#1000').on('click', function(){
  hidden();
  var input = $('#ip-qrcode').val();//inputのvalueを取得
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') {
    alert('文字を入力してください。\nPlease enter the characters.');
  } else {
    $('#op-qrcode').qrcode({text:text, width:1000, height:1000});
    canvas();
  }
});

$('#1500').on('click', function(){
  hidden();
  var input = $('#ip-qrcode').val();//inputのvalueを取得
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') {
    alert('文字を入力してください。\nPlease enter the characters.');
  } else {
    $('#op-qrcode').qrcode({text:text, width:1500, height:1500});
    canvas();
  }
});

function download() {
  var canvas = document.getElementById('canvas');
  var link = document.getElementById('link');
  link.href = canvas.toDataURL('image/png');
  link.download = 'qrcode.png';
  link.click();
};

shortcut.add('Ctrl+D',function() {
  download()
}); //Ctrl+Dでダウンロード

shortcut.add('Ctrl+M',function() {
  hidden();
  var input = $('#ip-qrcode').val();//inputのvalueを取得
  var size = $('#size').val();
  var text = unescape(encodeURIComponent(input));//日本語対応
  if (input == '') {
    alert('文字を入力してください。\nPlease enter the characters.');
  } else if (size > 0) {
    $('#op-qrcode').qrcode({text: text, width: size, height: size});
    canvas();
  } else {
    $('#op-qrcode').qrcode({text: text});
    canvas();
  }
}); //Ctrl+Mで生成

shortcut.add('Ctrl+Shift+C',function() {
  hidden()
}); //Ctrl+Shift+Cでクリア 隠しコマンド
main.css
body {
  margin: 20px;
  padding: 20px 30px;
}

.hidden {
  display: none;
}

* {
 -webkit-appearance: none;
}

input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{
  -webkit-appearance:none;margin:0
}

input[type=number]{
  -moz-appearance:textfield
}
qr-code.css
input {
  padding: 12px 14.4px;
  font-size: 18px;
  border-radius: 3px;
  border: 1px solid #ddd;
  box-sizing: border-box;
  margin-bottom: 1rem
}

input:focus {
  box-shadow: 0 0 5px 0 rgba(251, 63, 109, 0.75);
  border: 1px solid #fff;
  outline: 0;
}

button {
  background-color: #fff;
  color: #fb3f6d;
  border: 1px solid #fb3f6d;
  padding: 12px 14.4px;
  font-size: 18px;
  border-radius: 3px;
  margin-bottom: 16px;
  width: 88px;
  cursor: pointer;
}

button:hover {
  background-color: #fff5f7;
  transition-duration: 0.2s
}

button:focus {
  background-color: #fff5f7;
  outline: none;
  box-shadow: 0 0 5px 0 rgba(251, 63, 109, 0.75);
}

canvas {
  width: 250px;
  height: 250px;
  margin: 10px;
}

#size {
  width: 120px;
  text-align: center;
}

#size::placeholder {
  text-align: center;
}

input:focus::placeholder {
  visibility: hidden;
}

#make {
  margin-right: 10px;
}

#ip-qrcode {
  width: 100%;
  font-size: 20px
}

#down {
  background-color: #fb3f6d;
  width: 130px;
  color: #fff;
  border: 1px solid #da2652;
  margin-bottom: 1rem;
  cursor: pointer;
}

#down:hover {
  background-color: #fff5f7;
  border: 1px solid #fb3f6d;
  color: #fb3f6d;
  transition-duration: 0.2s
}

#down:focus {
  color: #fb3f6d;
  border: 1px solid #fb3f6d;
  background-color: #fff5f7;
  outline: none;
  box-shadow: 0 0 5px 0 rgba(251, 63, 109, 0.75);
}

まとめ

jquery-qrcode.jsを用いれば簡単に作れることが分かりました。
個人的に使いやすいUIにすることができたと思います。

ソース↓
https://github.com/risuney/qr-code

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

AjaxのPOSTでデータを送信しようとしてうまくいかないとき

大参考にさせていただきました
ajaxのPOST送信で403が返却される場合の対処方法

環境

spring boot
spring security
thymeleaf

SpringSecurityを使っているときはCSRFトークンが必要

SpringSecurityを使ってログイン機能を作った場合、そのアプリケーション全体でCSRF対策が有効になる

すごくシンプルに言うと、

POSTメソッドを使うときは、入力データだけじゃなくてCSRFトークンも送らないとアクセス禁止になる!

th:actionでトークンが自動で挿入される

参考

Ajaxじゃなくて普通にformで画面遷移する場合は、

<form th:action="@{hoge}" method="post">

</form>

のように書けば

<form action="/login" method="post">
    <!-- Spring MVCの機能と連携して出力されたCSRFトークン値のhidden項目 -->
    <input type="hidden"
        name="_csrf" value="63845086-6b57-4261-8440-97a3c6fa6b99" />
    <!-- omitted -->
</form>

超便利!!

でもAjaxのときは自動で生成されず、Forbiddenが返ってくる

chromeのデベロッパーツールでヘッダーを確認してみると、

image.png

403のステータスコード。アクセスしたらアカンって言われてる。

俺がええって言うてんのに!

SpringSecurityが勝手に「だってCSRFトークンないからあきまへん」って頑なになっとるんですな。

CSRFトークンを手動でAjaxのヘッダーにセットする

// htmlファイルの最後にCSRFをセット
// ちょっと変な場所
</body>
<meta th:name="_csrf" th:content="${_csrf.token}"/>
<meta th:name="_csrf_header" th:content="${_csrf.headerName}"/>
</html>
</body>の直前だとエラーになります。

ちょっと変な場所だけど、ここじゃないとダメでした

<head>の中に書いても読み込まれません

テンプレートエンジンを利用している場合、layoutファイルに次の様な書いてある場合は、個別のhtmlファイルにmetaを挿入しても読み込まれない

<head xmlns:th="http://www.thymeleaf.org"
    th:fragment="base_header(title, scripts, links)">


base_header(title, scripts, links)
この部分で、個別のファイルで<head>の中に書いてある
title
script
link
は読み込めるようになっている

metaはない → headにmetaを追加してもCSRFトークンはセットできない

postで403が返ってきたらCSRFトークンが送信できているか確認しよう!

これほんと初歩でしょうけど。

これで丸一日かかってしまいました。。。

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

inputのtype別に、puppeteerでの入力方法をまとめた

inputタグに設定できるtype属性は今のところ、以下の20種類

button, checkbox, color, date, datetime-local, email, file, hidden, image, month, number, password, radio, search, submit, tel, text, time, url, week

puppeteerで簡単に入力できるもの、専用の入力方法が用意されているの、クセが強いもの、など様々だったのでまとめた。
なお、本ページの例は全タグにidがふってある優しい世界なので、要素の特定は別途頑張ってください。

確認環境

  • Windows10 2004
  • node v14.15.4
  • puppeteer 5.5.0
  • Chromium 90.0.4400.0

text系

以下のtypeを、まとめてtext系と呼ぶことにする。

email, number, password, search, tel, text, url

htmlだとこんなかんじ

<input type="text" id="text1">
<input type="email" id="email1">
<input type="search" id="search1">
<input type="tel" id="tel1">
<input type="url" id="url1">
<input type="number" id="number1">
<input type="password" id="password1">

submit時にvalidationがかかるもの、入力した文字が見えないものなど、それぞれ違いはあるが、入力する上では全てtype()を利用すれば良い

await page.type("#text1", "text");
await page.type("#email1", "example@example.com");
await page.type("#search1", "search text");
await page.type("#tel1", "09012345678");
await page.type("#url1", "https://example.com/");
await page.type("#number1", "42");
await page.type("#password1", "yourpassword");

button系

以下のtypeを、まとめてbutton系と呼ぶことにする。

button, image, reset, submit

htmlだと以下

<input type="reset" id="reset1">
<input type="button" id="button1" value="ボタン">
<input type="submit" id="submit1">
<input type="image" id="image1" src="image.png" alt="image-alt-text">

それぞれ役割があるが、入力(クリック)はどれもclick()でOK

await page.click("#button1");
await page.click("#image1");
await page.click("#reset1");
await page.click("#submit1");

radio, checkbox

ラジオボタンやチェックボックスも、素直にclick()でOK

<input type="radio" id="radio1">
<input type="checkbox" id="checkbox1">
await page.click("#radio1");
await page.click("#checkbox1");

file

<input type="file" id="file1">

type="file"には、専用のメソッドであるwaitForFileChooser()が用意されている。
使い方は、waitForNavigation()と同様に、クリック前に実行しておき、resolve後、ファイルを指定する。headlessでなくてもファイル選択のUIは表示されない。

const [fileChooser] = await Promise.all([
    page.waitForFileChooser(),
    page.click('#file1'),
]);
await fileChooser.accept(["/path/to/file"]);

もしくは、以下でも可。個人的にはネストが深くならないこちらのほうが好み。読みやすさは大差ない。

const fileChooserPromise = page.waitForFileChooser();
await page.click('#file1');
await (await fileChooserPromise).accept(["/path/to/file"]);

range

スライドバーが表示されるtype="range"

image.png

<input type="range" id="range1">

単純な値指定は難しいため、focus()でフォーカスを合わせて1右矢印キー左矢印キーを適切な回数押すと良い。(1回でstep属性の値だけ変化、デフォルト1)

await (await page.$("#range1")).focus();
for(let i = 0; i < 20; i++) {
    await page.keyboard.press("ArrowRight");
}

color

<input type="color" id="color1">

puppeteerの闇を感じることができるtype="color"。
クリックするとカラーピッカーが表示されるが、puppeteerに専用のメソッドは用意されていない2ため、このカラーピッカーを操作する必要がある。

image.png

やってみた結果が以下。

await (await page.$("#color1")).click();
await page.waitForTimeout(300)
await page.keyboard.press("Tab")
await page.keyboard.press("Tab")
await page.keyboard.press("Tab")
await page.keyboard.type("255")
await page.keyboard.press("Tab")
await page.keyboard.type("0")
await page.keyboard.press("Tab")
await page.keyboard.type("0")
await page.keyboard.press("Enter")

まずクリックでカラーピッカーを表示させ、タブを3回押すことでRGB値の入力欄に移動。
タブで移動しながらR,G,Bそれぞれの値を入れることで入力している。

クリック後300ms待っているのは、カラーピッカーが表示されるまでに若干時間がかかるため。ここは環境によって変える必要があるだろう。
DOM要素ならwaitForSelector()などで待つことができるが、カラーピッカーはDOM要素でない(・・・よね?)ため仕方なくwaitForTimeout()を利用している。

もうお気づきだろうと思うが、完全にChromiumのカラーピッカーのUIに依存した書き方になっているため、Firefoxではおそらく動かない(未検証)し、今後Chromiumのバージョンアップで動作しなくなる可能性もある。闇だ。

なお、puppeteerでは、ページ上でJavaScriptを動作させることもできるため、以下の書き方もできる。これなら1行だ。

await (await page.$("#color1")).evaluate((node) => {node.value = "#FF0000"});

ただ、この書き方だと、changeイベントが発火しないため、テスト内容によっては利用できない。

date系

以下のtypeを、まとめてdate系と呼ぶことにする。

date, datetime-local, month, time, week

image.png

htmlでは以下。

<input type="date" id="date1">
<input type="datetime-local" id="datetime-local1">
<input type="month" id="month1">
<input type="time" id="time1">
<input type="week" id="week1">

まず、入力ボックスの右側のカレンダーや時計をクリックするとピッカーが表示されるが、これを使おうとしてはいけない。マウスでしか開けない(多分)し、ピッカーはDOM要素でないため、開いた後の操作が難しい。ブラウザごとの差異も激しい。
キーボードで日付/時刻を入力するのが最善だ。

その上で、以下の点に気をつける必要がある。3

  1. 項目が一意に特定可能になると、次の項目に自動で遷移する
    • 「月」に5を入力すると自動で「日」にフォーカスが移るが、1を入力してもフォーカスはそのまま(10-12月があるため)
  2. 最大275760年9月13日まで入力できる4
    • つまり「年」に4桁入力しても、「月」にフォーカスを移してくれない
  3. min,max属性の指定によっては、入力不可な項目が出てくる
    • type="date"で、min="2020-01-01" max="2020-12-31"の場合、「年」は2020に固定され、フォーカスが当たるといきなり「月」の入力になる。
  4. ロケールによって年月日の順番が異なる

1.の解決策は2つ。
1つ目は、タブや右矢印などでフォーカスを移すこと。
「月」に1と入力したあとでタブを押せば、フォーカスが「日」に移ってくれる。
ただし、puppeteer上では月をtype()したあと、月が1のときのみタブをpress()してまた日をtype()する、という少し面倒な書き方になる。5
おすすめは次の2つ目だ。「月」を0埋めし、必ず2桁入力する。puppeteerのコードとしては、タブを押すよりだいぶ簡単になる。[^js_padding]

2.はほとんど1.と同種の問題だ。
「年」を入力したあと、タブでフォーカスを移してもよいが「年」の頭に00を加えて6桁入力するのが楽だ。type()のみですむ。
また、自動化対象のコードを変更できる場合、maxを指定してしまっても良い。max="9999-12-31"としておく6と、4桁入力した時点で「月」にフォーカスが移ってくれる。あと7900年後くらいまでは困る人もいないだろう。

3.は少し厄介だ。
「年」が入力不可だと分かっているのなら単純に「月」と「日」だけ入力すればよい(dateの場合)のだが、例えば「現在の日付から半年後まで」という仕様の場合、1-6月と7-12月で年の入力可否が変わってしまう。「1年後の前日の日付まで」という仕様なら1月1日のみCIが落ちるかもしれない。7
解決策としては、minとmax属性を読み込んで場合分け、が愚直な方法だろうか。
尤も、自動テストという文脈なら、テストの外部要因(時刻)でテスト内容や結果が変わるテストはイマイチなので、現在時刻をDIできる設計にするのが望ましい。8

4.は解決策を調査中である。ブラウザ上で、年月日がどの順番で表示されているのか、を取得する方法があれば知りたい。
もしくは、Chromiumの起動オプションでロケールを指定して年月日の順番を固定させる方法が利用できるかもしれない。(未調査)

ということを踏まえて、入力するコードは以下となる。(3と4は検討外)

await page.type("#date1", "0020210127");
await page.type("#datetime-local1", "00202101270812");
await page.type("#month1", "00202012");
await page.type("#time1", "0212");
await page.type("#week1", "00201943");

3や4が問題になる場合、changeイベントが発火しない、などの差分が許容できるなら、以下の書き方も利用できる。

await (await page.$("#date1")).evaluate((node) => {node.value = "2021-01-27"});
await (await page.$("#datetime-local1")).evaluate((node) => {node.value = "2021-01-27T08:12"});
await (await page.$("#month1")).evaluate((node) => {node.value = "2020-12"});
await (await page.$("#time1")).evaluate((node) => {node.value = "02:12"});
await (await page.$("#week1")).evaluate((node) => {node.value = "2019-W43"});

hidden

hiddenは普通はpuppeteerから基本的に書き換えないし、書き換えられない。
どうしてもやりたければ、evaluate()でやるしかない。ブラウザ自動操作の枠を超えているような気もするが。

<input type="hidden" id="hidden1">
await (await page.$("#hidden1")).evaluate((node) => {node.value = "hidden-value"});

おまけ

inputとともによく使われる以下についても簡単に。

textarea tag

<textarea id="textarea_tag1"></textarea>

textareaは、text系と同じで、type()でOK

await page.type("#textarea_tag1", "textarea");

button tag

<button id="button_tag1">button</button>

ボタンは、名前の通りbutton系、click()でOK

await page.click("#button_tag1");

select tag

<select id="select1">
    <option value="1" id="select1_1">option_1</option>
    <option value="2" id="select1_2">option_2</option>
    <option value="3" id="select1_3">option_3</option>
</select>

<select id="select_multi1" multiple>
    <option value="1" id="select_multi1_1">option_1</option>
    <option value="2" id="select_multi1_2">option_2</option>
    <option value="3" id="select_multi1_3">option_3</option>
</select>

image.png

selectは、専用のメソッドが用意されている。select()だ。
selectはmultipleか否かでUIが大きく異なるがselect()は両方に対応している。
第2引数以降に選択するoptionのvalueを指定する。

await page.select("#select1", "2");
await page.select("#select_multi1", "2", "3");

  1. focus()ではなく、click()をすると、値が変わることがある(中央付近をクリックしているような挙動)ため、不可。 

  2. issueとなってはいるが、進展なさそう 

  3. この注意点も現在のChromiumの実装に依存している。他のブラウザでは状況は異なるだろうし、Chromiumのバージョンアップでも変わる可能がある 

  4. 1970/1/1から1億日らしい 

  5. ここでは「月」のみの説明をしているが、「年」「時」でも同様の問題は起こり得る 

  6. もちろん、もっと妥当なmaxがあるのなら、それを記載するのが望ましい 

  7. ここでは「年」のみの説明をしているが、「月」「日」「時」でも同様の問題は起こり得る 

  8. 一例: https://qiita.com/suin/items/bcd7488df4403a53d7d9 

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

[d3pie.js]円グラフを書こう

d3pieで円グラフを書きます

円グラフ javascriptで調べてたらいろいろなライブラリが見つかったので、その中の一つ「d3pie」で円グラフを書こうと思います。

d3.jsとは?

公式ページ。Data-Driven Documents の略。Dが3つあるからd3ですね。
データからSVGを描画してくれるライブラリです。

d3pie.jsとは?

公式ページ。d3.jsをもとにした、円グラフを描画するライブラリのようです。便利そうだけどQiitaであんまり見かけなかったので今回記事にしてみました。
バージョンが3~4年前から更新されてないのでもしかしたらメジャーじゃないのかも・・・。

書きます

html
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>
  <!-- グラフ表示 -->
  <div id="myChart"></div>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.3.1/d3.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/d3pie@0.2.1/d3pie/d3pie.min.js"></script>
  <script>
    var pie = new d3pie("myChart", {
      data: {
        content: [
          { label: "国語", value: 70 },
          { label: "英語", value: 80 },
          { label: "社会", value: 60 },
          { label: "理科", value: 40 },
          { label: "数学", value: 100 },
        ],
      },
    });
  </script>
</body>

</html>

出来たグラフはこんな感じです。↓
image.png

デフォルトでグラフのまわりに凡例書いてくれたりして、なかなか見やすいですね。
良くある、円グラフの上に凡例があるの見にくいよ><って人にはおすすめかもしれません。

細かい設定も出来ます。

<script>タグのこの部分、設定が細かく出来たりするので見てみましょう。

    var pie = new d3pie("myChart", {
      data: {
        content: [
          { label: "国語", value: 70 },
          { label: "英語", value: 80 },
          { label: "社会", value: 60 },
          { label: "理科", value: 40 },
          { label: "数学", value: 100 },
        ],
      },
    });

まずはヘッダーを追加してみたり

    var pie = new d3pie("myChart", {
      "header": {
        "title": {
          "text": "期末試験の結果",
          "fontSize": 20,
          "font": "Meiryo UI"
        },
        "subtitle": {
          "text": "2021-01-25",
          "color": "#999999",
          "fontSize": 13,
          "font": "Meiryo UI"
        },
        "titleSubtitlePadding": 9
      },
      data: {
        content: [
          { label: "国語", value: 70 },
          { label: "英語", value: 80 },
          { label: "社会", value: 60 },
          { label: "理科", value: 40 },
          { label: "数学", value: 100 },
        ],
      },
    });

image.png

また、footerを追加してみたり。

    var pie = new d3pie("myChart", {
      "header": {
        "title": {
          "text": "期末試験の結果",
          "fontSize": 20,
          "font": "Meiryo UI"
        },
        "subtitle": {
          "text": "2021-01-25",
          "color": "#999999",
          "fontSize": 13,
          "font": "Meiryo UI"
        },
        "titleSubtitlePadding": 9
      },
      "footer": {
        "text": "図10-1.○○",
        "color": "#999999",
        "fontSize": 18,
        "font": "open sans",
        "location": "bottom-center"
      },
      data: {
        content: [
          { label: "国語", value: 70 },
          { label: "英語", value: 80 },
          { label: "社会", value: 60 },
          { label: "理科", value: 40 },
          { label: "数学", value: 100 },
        ],
      },
    });

image.png

ほかにも、凡例のところをグラフからどれくらい離すか、またテキストの大きさはどうするか、などの設定が変えられそうでした。円グラフの表示順とかもソートが出来そうです。
試しながらいろいろ使ってみようと思います。

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

slick+lightboxのモーダルでかっこいいパターン表示を作るまで

既にslick実装済みの環境に、モーダルを追加して、なにかのレイアウトとかデザインをかっこよく数パターン表示できるようにした際のまとめ。

モーダルって何?

以下がわかりやすいですかね。クリックするとその画像だけの別ページが最前面に表示されてくれるイメージ。
https://goworkship.com/magazine/modal-windows-mobileui/

手順

wordpress環境だけど、プラグイン使わずにlightbox実装したい。

重くなっちゃうので、なるべくプラグインは使わない思想のもと。
lightboxはめちゃくちゃお手軽で簡単なので、以下の記事を参考に実装。
ぐぐるといくつか出てきますが、これが一番わかりやすかったです。
https://www.axisjp.co.jp/news/1247

lightboxは以下からDLですね.
https://github.com/lokesh/lightbox2/releases

で、上記参考ページのとおりに実装したんだけど、
slickを当初横スライドにしていたはずなのに、縦並びになってしまう・・・。

モーダルがなにか悪さしてるんでしょう。
ぐぐる。

jsの読み込みタイミングがポイント

slickとlightbox-plus-jquery.min.jsをどちらもfooter.phpで読むこむように指定していたけども、
jsの読み込み順序で、縦並びになっちゃったり上手く作用してくれないことがあるらしい。
jsが干渉する、干渉してしまう、っていうのはこういうことなんだな。学び。

http://creatornote.nakweb.com/%E3%80%90%E8%A7%A3%E6%B1%BA%E3%80%91slick%E3%81%A7%E3%81%AA%E3%81%9C%E3%81%8B%E7%B8%A6%E3%81%AA%E3%82%89%E3%81%B3%E3%81%AE%E3%81%BE%E3%81%BE%E3%82%B9%E3%83%A9%E3%82%A4%E3%83%89%E3%81%99%E3%82%8B/

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

JSON について メモ

そもそもJSONって何の略?

JSONは"JavaScript Object Notation"の略で、
直訳すると「JavaScriptオブジェクト記法」
...まだ、あんまりイメージが湧かないので、
更に意訳も入れてもう少しかみ砕いてみた。

JSONは「JavaScriptのオブジェクトの書き方を元にしたデータ定義方法」
こんな感じだろうか。

JSONとは?

とりあえず、何の略なのか調べて、日本語訳もしてみたけど、
具体的に何なの?とまだ頭の中がクエスチョンマークの為、もう少し調べてみよう。

Wikiには、↓のように書いてあった。

JavaScript Object Notation(JSON、ジェイソン)はデータ記述言語の1つである。軽量なテキストベースのデータ交換用フォーマットでありプログラミング言語を問わず利用できる。名称と構文はJavaScriptにおけるオブジェクトの表記法に由来する。

(引用元: Wikipedia, 2021/01/28)

この文章を見ると、何となくJSONがデータのやり取りをする為の言語(フォーマット)だというのが分かる。
では実際に、どのように、どのようなデータをやり取りするのか。
筆者なりに、実際にシステム開発に携わったことがない人にもわかるように、
分かりやすくまとめてみる。

例えば、あるWebサイトに登録しているユーザがログインするとする。
まずログインする為には、画面からユーザ固有のユーザIDとパスワードなどが必要である。
それで画面から、

ユーザID: hogehoge@sample.com
PASSWORD: testtest

などど入力してログインボタンを押してみる。

すると、画面側からデータの管理しているところへ「このユーザ、いる?」と確認するために、
確認したいユーザのデータ(この話で言うと、ユーザIDとPASSWORD)を送ることになる。
その送るときのデータの書き方がいくつかあるのだけど、その中の一つがJSONということ。

それで、実際どのような形で送るのかというと、
最初にも書いた通り、JavaScriptのオブジェクト型に似た形式で送る。

例えば、↓みたいな感じ。

{
  "user_id": "hogehoge@sample.com",
  "password": "testtest"
}

この書き方では、{ } (中括弧)でひとまとまりのデータを表していて、
上の例で考えると、"1ユーザがログインする為に必要なひとまとまりの情報"
認識してもらえればいい。

ひとまとまりのデータなので、この設定はこの値、あの設定はあの値という風に書けるのが特徴。
例でも、ユーザIDは "user_id": "hogehoge@sample.com"、パスワードは "password": "testtest"と
書いてまとめておくことが可能。

それで前に書いた内容に戻って、本来これは元々JavaScriptで扱うオブジェクトの書き方を真似てるって話だが、
↓ がJavaScriptで同じデータをオブジェクト型で表現したときの書き方。

var user_login = {
  user_id: "hogehoge@sample.com",
  password: "testtest"
}

上の例ではJavaScriptの変数というものを使っていて、
変数であるuser_loginに、JavaScriptのオブジェクト型のデータを代入している。

これを見ると、JSONとJavaScriptのオブジェクトの書き方はほとんど同じであることが分かる。
しいて言うのであれば、user_idとかpasswordとかの項目名の部分に""(ダブルクォーテーション)が付いていないだけ。

だから、本当にJSONはJavaScriptのオブジェクトの書き方をほぼ丸パクしているのが分かる。

まとめ

そんなこんなで、ここまでJSONのことを簡単に触れてきたけど、
最後にもう一度簡単にまとめると、

JSONとは...

何らかのデータのやり取りをするときに使う、
データを渡すためのフォーマット(書き方)の一つで、
それはJavaScriptをほとんど真似して作られた

ってこと。

あとがき

今回、自分の中での整理も含めて、初めてQiitaに投稿する記事として書いてみた。
何か読んでみて個々の書き方わかりづらいとか、そういうコメント等があれば、
是非今後のご参考までに遠慮せず教えて下さい。
最後まで読んでくれてありがとう。

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

bulletml.jsの研究 その10

概要

bulletml.jsの研究です。
技を調査。

ワインダー

<bulletml>
    <action label="top">
        <repeat>
            <times>10</times>
            <action>
                <fire>
                    <direction type="absolute">2</direction>
                    <bulletRef label="winderBullet"/>
                </fire>
                <actionRef label="winderSequence">
                    <param>31</param>
                </actionRef>
                <wait>200</wait>
            </action>           
        </repeat>
    </action>
    <bullet label="winderBullet">
        <speed>3</speed>
    </bullet>
    <fire label="fireWinder">
        <direction type="sequence">$1</direction>
        <bulletRef label="winderBullet"/>
    </fire>
    <action label="roundWinder">
        <fireRef label="fireWinder">
            <param>$1</param>
        </fireRef>
        <repeat> 
            <times>11</times>
            <action>
                <fireRef label="fireWinder">
                    <param>30</param>
                </fireRef>
            </action>
        </repeat>
        <wait>5</wait>
    </action>
    <action label="winderSequence">
        <repeat> 
            <times>12</times>
            <actionRef label="roundWinder">
                <param>30</param>
            </actionRef>
        </repeat>
        <repeat> 
            <times>12</times>
            <actionRef label="roundWinder">
                <param>$1</param>
            </actionRef>
        </repeat>
        <repeat> 
            <times>12</times>
            <actionRef label="roundWinder">
                <param>30</param>
            </actionRef>
        </repeat>
    </action>
</bulletml>

間隔の短い高速弾で「弾の壁」を作り、自機の動きを制限することを目的とした攻撃です。
ワインダー自体を徐々に動かす、幅を狭める等で変化をつけることが出来ます。
固定方向弾と同じく単独では意味をなさないため、他の攻撃と組み合わせて使います。

成果物

https://embed.plnkr.co/plunk/U5n4NxPuZpYvvovb

以上。

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

bulletml.jsの研究 その9

概要

bulletml.jsの研究です。
技を調査。

ウィップ

<bulletml>
    <action label="top">
        <repeat>
            <times>22</times>
            <action>
                <fire>
                <direction type="aim">0</direction>
                <bullet>
                        <action>
                            <changeDirection>
                            <direction type="aim">0</direction>
                            <term>0</term>
                            </changeDirection>
                        </action>
                    </bullet>
                </fire>
                <repeat>
                    <times>5</times>
                    <action>
                        <fire>
                        <direction type="aim">0</direction>
                            <speed>1.5</speed>
                        <bullet>
                                <action>
                                    <changeDirection>
                                    <direction type="aim">0</direction>
                                    <term>0</term>
                                    </changeDirection>
                                </action>
                            </bullet>
                        </fire>
                        <wait>10</wait>
                    </action>
                </repeat> 
                <wait>200</wait>
            </action>
        </repeat>
    </action>
</bulletml>

回転砲台が発射方向を変化させていく攻撃だとするならば、こちらは発射速度を変化させていく攻撃です。
先に撃った遅い弾を、後から撃つ速い弾が追い越して行きます。
プレイヤーにとっては、遅い弾がガイドになってくれるため、単発で発射されたのではとても避けられないような速い弾でも回避できる攻撃となります。
発射間隔や発射方向を変化させることでバリエーションを広げることができます。

成果物

https://embed.plnkr.co/plunk/PMvGKalEZBFcwQuo

以上。

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

選択済みのラジオボタンを再度押して解除できるようにする

選択済みのラジオボタンに対して、チェックボックスのように再度押すことで解除できるようにするJavaScriptのサンプル。
複数グループにも対応。

動作デモ

test.html
<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset='utf-8'>
<title>ラジオボタン 解除対応サンプル</title>
</head>
<body>
groupA:
<label><input type='radio' name='groupA' value='1'>1</label>
<label><input type='radio' name='groupA' value='2'>2</label>
<label><input type='radio' name='groupA' value='3'>3</label>
<br>

groupB:
<label><input type='radio' name='groupB' value='1'>1</label>
<label><input type='radio' name='groupB' value='2'>2</label>
<label><input type='radio' name='groupB' value='3'>3</label>
<br>

groupC:
<label><input type='radio' name='groupC' value='1'>1</label>
<label><input type='radio' name='groupC' value='2'>2</label>
<label><input type='radio' name='groupC' value='3'>3</label>
<br>

<script>
'use strict';
{
    window.addEventListener('DOMContentLoaded', function() {
        const
            radioElements = document.querySelectorAll('input[type=radio]'),
            state = {};

        for(let i = 0; i < radioElements.length; ++i) {
            radioElements[i].addEventListener('click', function() {
                if(state[this.name] === this.value) state[this.name] = this.checked = false;
                else state[this.name] = this.value;
            });
        }
    });
}
</script>
</body>
</html>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

bulletml.jsの研究 その8

概要

bulletml.jsの研究です。
技を調査。

回転砲台

<bulletml>
    <action label="top">
        <repeat>
            <times>22</times>
            <action>
                <fire>
                <direction type="sequence">23</direction>
                <bullet>
                        <action>
                            <changeDirection>
                            <direction type="sequence">0</direction>
                            <term>0</term>
                            </changeDirection>
                        </action>
                    </bullet>
                </fire>
                <repeat>
                    <times>100</times>
                    <action>
                        <fire>
                        <direction type="sequence">23</direction>
                        <bullet>
                                <action>
                                    <changeDirection>
                                    <direction type="sequence">0</direction>
                                    <term>0</term>
                                    </changeDirection>
                                </action>
                            </bullet>
                        </fire>
                        <wait>5</wait>
                    </action>
                </repeat> 
                <wait>200</wait>
            </action>
        </repeat>
    </action>
</bulletml>

発射方向を変化させながら連続で撃つ攻撃です。
弾が螺旋状になることから、渦巻き弾とも呼ばれます。

成果物

https://embed.plnkr.co/plunk/15HhxjaFYtUpFPFT

以上。

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

JavaScript Primerを読んで学んだこと(1/3)

はじめに

この記事はJavaScriptの初心者向けに書かれています。
JavaScriptを体系的に学べるサイト「JavaScript Primer」を読み、個人的に重要だと感じたことをまとめました。こちらはその第1回目(全3回)になります。
JavaScriptを学習する際に参考にしていただければ幸いです。

JavaScriptとは

ウェブサイトを操作したら表示が変わったり、ウェブサイトのサーバーと通信してデータを取得したりと、現在のウェブサイトには欠かせないプログラミング言語です。

JavaScriptの大まかな特徴としては、以下が挙げられます。

  1. オブジェクト指向言語である
  2. 大文字と小文字を区別する
  3. 文はセミコロンで区切られる
  4. JavaScriptの仕様は毎年更新される

変数と宣言

JavaScriptには、「これは変数です」という宣言をするキーワードとして constletvarの3つがあります。

const

  • 再代入できない変数の宣言と、その変数が参照する値(初期値)を定義する
  • constによる変数宣言と初期値の定義は、必ず同時に行う

以下の例では、bookTitleという変数にJavaScript Primerという文字列を初期値として定義しています。
これ以降に変数bookTitleに別の値を代入しようとするとエラーになります。

const bookTitle = "JavaScript Primer";
bookTitle = "hoge"; // => TypeError: invalid assignment to const 'bookTitle'

let

  • constとは異なり、値の再代入が可能な変数を宣言できる
  • 変数宣言と同時に初期値を定義する必要がなく、定義しなかった場合は自動的にundefinedという値に設定される(undefinedは値が未定義ということを表す値)
let bookTitle; // `bookTitle`は自動的に`undefined`という値になる
bookTitle = "JavaScript Primer"; //再代入が可能

var

  • 値の再代入が可能な変数を宣言できる

使い方はletとほとんど同じですが、以下のように同じ名前の変数を再定義できてしまうという問題があります。

// letで変数"x"を定義する
let x;
// 同じ変数名の変数"x"を定義するとSyntaxErrorとなる
let x; // => SyntaxError: redeclaration of let x

// varで変数"x"を定義する
var x = 1;
// 同じ変数名の変数"x"を定義できる
var x = 2;
// 変数xは2となる

このようにvarには問題がありますが、ほとんどすべてのケースでvarconstletに置き換えが可能です。
そのため、これから書くコードに対してvarを利用することは避けたほうがよいでしょう。

リテラル

ダブルクォートとシングルクォート

"(ダブルクォート)と'(シングルクォート)はまったく同じ意味となります。

テンプレートリテラル

`(バッククォート)で囲んだ範囲を文字列とするリテラルです。
テンプレートリテラル内で${変数名}と書いた場合に、その変数の値を埋め込むことができます。

const str = "文字列";
console.log(`これは${str}です`); // => "これは文字列です"

演算子

厳密等価演算子(===)

厳密等価演算子は、左右の2つのオペランドを比較します。同じ型で同じ値である場合に、trueを返します。
オペランドがどちらもオブジェクトであるときは、オブジェクトの参照が同じである場合に、trueを返します。

等価演算子(==)

等価演算子は、2つのオペランドを比較します。同じデータ型のオペランドを比較する場合は、厳密等価演算子(===)と同じ結果になります。
しかし、等価演算子はオペランド同士が異なる型の値であった場合に、同じ型となるように暗黙的な型変換をしてから比較します。

// 文字列を数値に変換してから比較
console.log(1 == "1"); // => true
// "01"を数値にすると`1`となる
console.log(1 == "01"); // => true
// 真偽値を数値に変換してから比較
console.log(0 == false); // => true

値を比較する際は常に厳密等価演算子(===)を使うことで、暗黙的な型変換をせずに値を比較できます。

三項演算子(?と:)

三項演算子は条件式を評価した結果がtrueならば、?の後の式の評価結果を返します。条件式がfalseである場合は、:の後の式の評価結果を返します。

条件式 ? Trueのとき処理する式 : Falseのとき処理する式;

以下の例では、hoge === ○の評価結果により"A" または "B" どちらかを返します。

const hoge = 1;
const valueA = hoge === 1 ? "A" : "B";
console.log(valueA); // => "A"
const valueB = hoge === 2 ? "A" : "B";
console.log(valueB); // => "B"

暗黙的な型変換

先ほども出てきた等価演算子(==)のように、JavaScriptではオペランド同士が同じ型となるように暗黙的な型変換をすることがあります。

次のコードでは、数値の1と文字列の"2"をプラス演算子で処理しています。プラス演算子(+)は、数値の加算と文字列の結合を両方実行できるように多重定義されています。そのため、数値の1を文字列の"1"へ暗黙的に変換してから、文字列結合します。

1 + "2"; // => "12"
// 演算過程で次のように暗黙的な型変換が行われる
"1" + "2"; // => "12"

また、次のコードでは数値の1から文字列の"2"を減算しています。JavaScriptには文字列に対するマイナス演算子(-)の定義はないので、暗黙的な型変換が行われます。これにより、文字列の"2"を数値の2へ暗黙的に変換してから、減算します。

1 - "2"; // => -1
// 演算過程で次のように暗黙的な型変換が行われる
1 - 2; // => -1

このように、暗黙的な型変換は意図しない結果になることが多いので、次に示す明示的な型変換を行い避けることが望ましいです。

明示的な型変換

JavaScriptでは、以下のような関数を用いることで任意の値を様々な型に変換することができます。

Boolean(0); // => false
String(null); // => "null"
Number("1"); // => 1

Boolean( )を使用した際に、どの値がtrueでどの値がfalseになるかは、次のルールによって決まります。

  • falsyな値はfalseになる
  • falsyでない値はtrueになる

falsyな値とは次の7種類の値のことを言います。

  • false
  • undefined
  • null
  • 0
  • 0n
  • ""(空文字列)
  • NaN("Not-a-Number"の略称で、数値ではないがNumber型の値を表現している)

関数と宣言

functionキーワード

JavaScriptでは、関数を定義するためにfunctionキーワードを使います。

// 関数宣言
function 関数名(仮引数1, 仮引数2) {
    // 関数が呼び出されたときの処理
    // ...
    return 関数の返り値;
}
// 関数呼び出し
const 関数の結果 = 関数名(引数1, 引数2);
console.log(関数の結果); // => 関数の返り値

定義した関数の仮引数よりも呼び出し時の引数が少ない場合は、余った仮引数にはundefinedという値が代入されます。
逆に、関数の仮引数に対して引数の個数が多い場合、あふれた引数は単純に無視されます。

関数式

関数式とは、関数を値として変数へ代入している式のことを言います。関数宣言は文でしたが、関数式では関数を値として扱っています。

// 関数式
const 変数名 = function(仮引数) {
    // 関数を呼び出したときの処理
    // ...
    return 関数の返り値;
};
// 関数呼び出し
console.log(変数名(引数)); // => 関数の返り値

関数式ではfunctionキーワードの右辺に書く関数名は省略できます。
このような名前を持たない関数を匿名関数と呼びます。

Arrow Function

関数式にはArrow Functionと呼ばれる書き方もあり、functionキーワードよりも短く書くことができます。
矢印のような=>(イコールと大なり記号)を使い、匿名関数を定義する構文です。

// 関数宣言
// Arrow Functionを使った関数定義
const 変数名 = (仮引数) => {
    // 関数を呼び出したときの処理
    // ...
    return 関数の返す値;
};
// 関数呼び出し
console.log(変数名(引数)); // => 関数の返り値

また、Arrow Functionには省略記法があり、次の場合は更に短く書けます。

  • 関数の仮引数が1つのときは( )を省略できる
  • 関数の処理が1つの式である場合に、ブロック({ })とreturn文を省略できる
    • その式の評価結果をreturnの返り値とする
// 仮引数の数と定義
const fnA = () => { /* 仮引数がないとき */ };
const fnB = (x) => { /* 仮引数が1つのみのとき */ };
const fnC = x => { /* 仮引数が1つのみのときは()を省略可能 */ };
const fnD = (x, y) => { /* 仮引数が複数のとき */ };
// 値の返し方
// 次の2つの定義は同じ意味となる
const mulA = x => { return x * x; }; // ブロックの中でreturn
const mulB = x => x * x;            // 1行のみの場合はreturnとブロックを省略できる

オブジェクト

オブジェクトはプロパティ(キーと値が対になったもの)の集合です。
オブジェクトを作成するには、オブジェクトリテラル({ })を利用します。

const obj = {
    // キー: 値
    key: "value"
};

プロパティへのアクセス

オブジェクトのプロパティにアクセスする方法として、ドット記法(.)とブラケット記法([ ])があります。
それぞれの記法でプロパティ名を指定すると、その名前を持ったプロパティの値を参照できます。

const obj = {
    key1: "value1"
    key2: "value2"
};
// ドット記法で参照
console.log(obj.key1); // => "value1"
// ブラケット記法で参照
console.log(obj["key2"]); // => "value2"

プロパティの追加と削除

オブジェクトは、一度作成した後もその値自体を変更できるというミュータブル(mutable)の特性を持ちます。そのため、作成したオブジェクトに対して、後からプロパティを追加・削除することができます。

プロパティの追加方法は単純で、作成したいプロパティ名へ値を代入するだけです。そのとき、オブジェクトに指定したプロパティが存在しないなら、自動的にプロパティが作成されます。

// 空のオブジェクト
const obj = {};
// `key`プロパティを追加して値を代入
obj.key = "value";
console.log(obj.key); // => "value"

また、オブジェクトのプロパティを削除するにはdelete演算子を利用します。削除したいプロパティをdelete演算子の右辺に指定して、プロパティを削除できます。

const obj = {
    key1: "value1",
    key2: "value2"
};
// key1プロパティを削除
delete obj.key1;
// key1プロパティが削除されている
console.log(obj); // => { "key2": "value2" }

上記のコード例で、constで宣言したオブジェクトのプロパティを変更できていることに疑問を抱いた方がいるかもしれません。
JavaScriptのconstは値を固定するのではなく、変数への再代入を防ぐためのものです。そのため上記の例では、obj変数への再代入は防げますが、変数に代入された値であるオブジェクトの変更は防げません。

続きについて

この記事はJavaScript Primerを読んで学んだことの第1回目(全3回)になります。
第2回、第3回についてもいずれ執筆予定です。

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

railsでコメント投稿機能を非同期化する(jQueryなし)

開発環境

Mac OS Catalina 10.15.7
ruby 2.6系
rails 6.0系

前提

同期でのコメント投稿機能は実装済みとする
JavaScriptのフレームワークは使っていません

各テーブルとアソシエーションは以下の通り

users テーブル

Column Type Options
nickname string null: false
email string null: false, unique: true
encrypted_password string null: false

Association

  • has_many :posts
  • has_many :comments
  • has_many :likes

postsテーブル

Column Type Options
title string null: false
explanation text
category_id integer null: false
animal_name string
user references null: false, foreign_key: true

Association

  • belongs_to :user
  • has_many :comments

commentsテーブル

Column Type Options
user references null: false, foreign_key: true
post references null: false, foreign_key: true
content string null: false

Association

  • belongs_to :user
  • belongs_to :post

部分テンプレートに切り出す

まずは差し替えたい部分を切り出します。
自分の場合はコメント部分を切り出す事にしました。
切り出したものはapp/views/commentsのなかに配置しましょう。(commentsディレクトリが無ければ作ってください。)

_comment.html.erb
# このIDは削除する時に使うもので、コメント投稿の非同期化には関係ありません。
<p id="comment_<%= comment.id %>">
  <strong><%= link_to comment.user.nickname, user_path(comment.user.id) ,class: "comment-user"%>:</strong>
  <%= comment.content %>
  <% if user_signed_in? && current_user.id == comment.user.id %>
    <span><%= link_to '[削除する]', post_comment_path(post.id, comment.id), method: :delete, class: "comment-delete", remote: true %></span>
  <% end %>
</p>
show.html.erb
<div class="comment-container">
   <div class = "comment-box">
      <h2>気になった投稿にコメントしよう!</h2>
      <% if user_signed_in? %>
        <%= form_with(model: [@post, @comment], remote: true) do |form| %>
          <%= form.text_area :content, placeholder: "コメントする", rows: "2" %>
          <%= form.submit "コメントを送信する" %>
        <% end %>
      <% else %>
        <strong><p class = "alert">※※※ コメントの投稿には新規登録/ログインが必要です ※※※</p>
        </strong>
      <% end %>
      <div class="comments" id="comments">
        <h4><コメント一覧></h4>
        <% @comments.each do |comment| %>
          # この部分を切り出しました。
          <%= render "comments/comment", post: @post, comment: comment %>
        <% end %>
      </div>
    </div>
 </div>

ポイントは部分テンプレート内で使う変数を渡してあげる事です。

# この部分のこと
post: @post, comment: comment 

form_withをremote: trueにする

次にform_withのlocal: trueをremote: trueに変更します。

変更前

show.html.erb
<%= form_with(model: [@post, @comment], local: true) do |form| %>
  <%= form.text_area :content, placeholder: "コメントする", rows: "2" %>
  <%= form.submit "コメントを送信する" %>
<% end %>

変更後

show.html.erb
<%= form_with(model: [@post, @comment], remote: true) do |form| %>
  <%= form.text_area :content, placeholder: "コメントする", rows: "2" %>
  <%= form.submit "コメントを送信する" %>
<% end %>

これで、コントローラーのcreateアクションのビューの参照先が、create.js.erbに変わりました。(コントローラーはlocal: trueにするとhtml.erbのビューを、remote: trueにするとjs.erbのビューを探しに行きます。)

コントローラーのリダイレクトの記述を削除する

せっかくremote: trueにしてもリダイレクトしてしまうので、削除しましょう。

変更前

comments_controller.rb
class CommentsController < ApplicationController

  def create
    @comment = Comment.create(comment_params)
    redirect_to post_path(params[:post_id])
  end

  def destroy
    @comment = Comment.find(params[:id])
    @comment.destroy
    redirect_to post_path(params[:post_id])
  end

  private
  def comment_params
    params.require(:comment).permit(:content).merge(user_id: current_user.id, post_id: params[:post_id])
  end

end


変更後

comments_controller.rb
class CommentsController < ApplicationController

  def create
    @comment = Comment.create(comment_params)
  end

  def destroy
    @comment = Comment.find(params[:id])
    @comment.destroy
  end

  private
  def comment_params
    params.require(:comment).permit(:content).merge(user_id: current_user.id, post_id: params[:post_id])
  end

end

create.js.erbを編集する

commentsディレクトリにcreate.js.erbファイルを作り、以下のように編集します。

create.js.erb
var element = document.querySelector(".comments")
element.innerHTML += '<%= j(render partial: "comments/comment", locals: {post: @comment.post, comment: @comment}) %>'
document.querySelector("#comment_content").value = ""

コードを説明すると、まず1行目で、コメントの一覧表示がされるcommentsクラスを持つ要素を取得して、elementという変数に代入しています。(詳しくは上記のshow.html.erbを参照してください)

その後、変数elementにinnerHTMLを使い、切り出したcomment.html.erb(コメントの中身の部分)を追加しています。
また、_comment.html.erbでは変数commentと変数postが使われているので、commentsコントローラーで変数@commentに保存したコメントを代入する記述を書き(上記comments
controller.rb参照)、create.js.erbでlocalsオプションを使って、データを渡してあげましょう。

これでコメント投稿の非同期化は完成です。

しかし、このままだと、コメントの投稿フォームに、投稿したコメントが残ってしまうので、最後にコメントの中身を削除するために、投稿フォームをIDで取得して、空にする記述を書いています。

長くなりましたが、以上です。
参考になれば幸いです。

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

JavaScriptのディレクトリ packsとchannelsの役割

前提

rubyのバージョン ruby-2.6.5
Railsのバージョン Rails:6.0.0

結論(内容)

packs
js全体に指示をかけるディレクトリ
読んで字のごとくパッケージのように全体に指示

channels
特定のjsファイルにおける指示
細かい指示

image.png

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

「is not defined」 jQuery導入時のエラーの解消例

前提

・jQueryをインストールしていること
(インストールついての詳細はjQueryの公式ページにて確認。)

・筆者が使用しているバージョンの詳細
jQueryのバージョン "jquery": "^3.5.1"
rubyのバージョン ruby-2.6.5
Railsのバージョン Rails:6.0.0

・表題のエラーが起きる理由はケースバイケースで、今回のケースはあくまでも例の1つ。

「is not defined」のエラー例とその解消の詳細がわかりやすく書いてあるサイト↓
https://for-someone.com/blog/4792/

結論

自身の記述内容のjQueryのバージョンと、インストールしていたjQueryのバージョンに差異が、「is not defined」のエラーの原因。
→インストールしたバージョンのjQueryのscriptをviewのhead内にコピペで置き換えることでエラー解消できる。

エラー画面

image.png

エラーが起こるまでの経緯

フォロー機能を実装するにあたり、jQueryを使用するため公式ページに沿ってjQueryをインストールした。

コーディング完了し、挙動確認する際、フォーローのカウントが実装できていることを確認

しかし、フォローボタンをクリックするタイミングで色が変色するよう発火の記述をしていたが変色しない。

ブラウザを更新するとボタンの色が変色する

コンソールを開き、挙動確認すると、「is not defined」のエラーが起こる

image.png
↓(ブラウザ更新しないと変色できない)
image.png

エラー解消方法

①jQueryのバージョンの確認はpackage.jsonで確認
image.png

image.png

②「jQueryのバージョン script」ブラウザ上で検索

③サイト内の適したjQueryのバージョンのscriptをコピー

④自身のviewのhead内にペースト
(筆者はapp/view/layouts/application.html.erbのhead内に記述)

⑤挙動確認→OK

まとめ

インストールしたバージョンのjQueryのscriptをviewのhead内にコピペで置き換えることでエラー解消

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

Date Range Picker の月タイトルが月年になっているのを年月にしたい

背景

javascript で日付の期間を選択できる良いライブラリが無いか探していたところ、Date Range Picker がとても優れていた。日本語化もできるが、一点だけできなかったのが月ヘッダーの年月が月年になっていたところ。そこで、無理やり年月に変更する。

変更後画像
スクリーンショット 2021-01-28 11.48.19.png

$('.daterange').on('showCalendar.daterangepicker', function(ev, picker) {
  $('.daterangepicker .month').each(function() {
    var month_year;
    month_year = $(this).text().split(' ');
    $(this).text(month_year[1] + '' + month_year[0]);
  });
});



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

Wordpressのプラグイン実行時に不具合が起きた際見直すべきポイント

解決したい課題

wordpressのプラグイン:Contact form 7で送信完了したメールが受信できない。

課題の詳細

以前までは受信できていたメールが、受信できなくなった。
フォーム上は送信完了したという表示がされる。

ググって一番見かけたサーバ違いによる不具合でsmtpでの解決が多かったけど、サーバは同じのため、該当しない。
https://blitzgate.co.jp/blog/250/

結論

function.phpを見直す!
jsの動きを制御するプラグインが入ってしまっていた…。

詳細

実装初期、contact form 7のjsを使用したいページ(以下の例だと、/lp)以外で読み込まないように、
function.phpに以下を挿入していた。
この当時は通常に受信できていた。

しかい、パスをlpから変更したため、jsが読み込まれなくなっていた。

 function my_contact_enqueue_scripts(){
 wp_deregister_script('contact-form-7');
 wp_deregister_style('contact-form-7');
 if (is_page('lp')) {
    if (function_exists( 'wpcf7_enqueue_scripts')) {
         wpcf7_enqueue_scripts();
    }
 }
 }
 add_action( 'wp_enqueue_scripts', 'my_contact_enqueue_scripts');

考えてみればそのとおりだし単純なことに気づくのに、時間がかかってしまった。
contact formに限らず、
なにかの変更のためにfunction.phpに加えた修正がなにかに影響していること
は多々あるだろうから、なにかが上手く行かないときは、そのズバリ事象+function.phpを見直してみると良い。

あとそもそも論としては、動いていた時期と動かなくなった時期があるのであれば、その間に加えた変更を一つ一つ検証して切り分けるのが正道。
だが、開発が重なっているとそれを細かく実施できるかどうか、、、という工数も懸念される。

大枠で切り分けつつ、定番のミスを抑えておくことが肝心。

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

Rails 6.1対応版: APIモードのRailsに対してCrossOriginなSPAからSession認証する方法

本記事では、APIモードのRailsに対してCrossOriginなSPAからSession(Cookie)認証する方法を解説します。

モダンなフロントエンド開発だと、Auth0やFirebaseを使った認証例が多く見られますが、バックエンドにRailsを使った認証例はまだまだ少ないと感じています。
JWT認証ではなくCookie認証となると、その数はさらに少ないようです。

実際にやってみるといろいろな障壁があることがわかったので、やるべきことをまとめることにしました。

背景

Next.jsアプリをVercelに、データ永続化とユーザ認証を目的としたAPIモードのRailsアプリをHerokuにデプロイしています。
この状況でNext.jsアプリからRailsに対して認証を試みると、CrossOriginなリクエストであるが故に様々な障壁があります。

Untitled_Portfolio_Site_-_Cacoo.png

  • 本記事に記載する内容はバックエンド側がメインです。(フロントエンド側の実装は需要があれば追々記載予定)
  • Railsの認証はdeviseを使用しています
    • シンプルなCookieを使ったSession認証なので、本記事に記載している内容はdeviseを使っていなくても同様に実践できる想定です。
  • Next.jsではCSRのみを使用しています(SSR, SSG, ISRは使用していない)
    • Next.jsに特化した記載は無いので、本記事に記載している内容はNext.jsを使っていなくても同様に実践できる想定です。

やるべきこと

クライアントからのリクエスト時にCookieを送信する

RailsのSessionストレージにCookieを使う前提なので、フロントエンドからのリクエスト時にCookieを送信するよう設定します。
axiosを使っている場合は、以下のように withCredentials: true を指定することで、Cookieを送信できます。

// 一部のリクエストでCookieを送信する
import axios from 'axios'

const res = await axios.get(
  currentUserPath,
  { withCredentials: true } // このオプションを追加する
)

// 全てのリクエストでCookieを送信する
import axiosBase from 'axios'

const axios = axiosBase.create({
  baseURL: process.env.NEXT_PUBLIC_API_SERVER,
  headers: {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest',
  },
  withCredentials: true, // このオプションを追加する
})

const res = await axios.get(currentUserPath)

RailsにてCORS設定する

Gemfileに rack-cors を追加します。
rails new したタイミングでコメントアウトされていることが多いので、コメントを外してください。

Gemfile
# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem 'rack-cors'  # コメントを外す

Gemfileを更新したら bundle install しておきましょう。

$ bundle install

開発環境用のCORS設定を記述します。

config/environments/development.rb
require 'active_support/core_ext/integer/time'

Rails.application.configure do
  # ・・・省略・・・

  # cors
  config.middleware.insert_before 0, Rack::Cors do
    allow do
      origins 'http://localhost:3001'  # リクエスト元となるOriginsを記載する
      resource '*',
               headers: :any,
               methods: %i[get post put patch delete options head],
               credentials: true  # trueとすること
    end
  end
end

origins にはCORSを許可するオリジン(≒URL)を記載してください。
今回はNext.jsの開発環境サーバが http://localhost:3001 だったため、そのオリジンのみ許可しています。
ワイルドカード * とすることもできますが、セキュリティリスクを最小限とするためにも、許可するオリジンは最小限としたほうが良いです。

headersmethods は必要に応じて見直してください。
credentials は レスポンスに Access-Control-Allow-Credentials ヘッダを付与するため、 trueとする必要があります。

参考: RailsでAPIにCORSを設定する

RailsにてCookie, CookieStoreを使えるようにする

APIモードのRailsでは、RackにてCookieやCookieStoreが有効化されていません。
次のように記述することで、これらを有効化します。

config/application.rb
module SampleApplication
  class Application < Rails::Application
    # ・・・省略・・・

    # Cookies
    config.middleware.use ActionDispatch::Cookies               # 追加する
    config.middleware.use ActionDispatch::Session::CookieStore  # 追加する
  end
end

参考: Rails の API モードでセッションやクッキーを使えるようにする

CookieのSameSite属性をNone, Secure属性をtrueにする

RailsからのレスポンスヘッダーのSameSite属性はデフォルト Lax となっています。
CrossOriginでCookieをやり取りするためにはこれを None とする必要があります。

rails_same_site_cookie というgemをインストールすることで、SameSite属性を None かつ Secure属性を true にします。

Gemfile
gem "rails_same_site_cookie", "~> 0.1.8"  # 追加する

Gemfileを更新したら bundle install しておきましょう。

$ bundle install

参考: 【Rails】SameSiteとSecure属性の付与〜Railsのセキュリティ対策〜

しかし、私の環境ではこのGemをインストールしただけではSameSite属性が None となりませんでした。
これはRails 6.1で追加された下記オプションが影響しているようです。

rails6.1で加わった新しいアプリケーション設定であるnew_framework_defaults_6_1.rbの各項目をさらっと解説

このため、application.rb に設定を追記することで、SameSite属性を None とします。

config/application.rb
module SampleApplication
  class Application < Rails::Application
    # ・・・省略・・・

    # Cookies
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use ActionDispatch::Session::CookieStore
    config.action_dispatch.cookies_same_site_protection = :none  # 追加する
  end
end

先の手順で追加した rails_same_site_cookie はSecure属性を true とする役割を担っているので、アンインストールせずに残しておきます。

まとめ

以上の方法でCrossOriginなSPAからAPIモードのRailsに対してSession認証ができるようになりました。

プロトコルもフレームワークもブラウザも、セキュリティ強化の一環で制約をかける方向に進化しているが故、一昔前に比べると随分と考慮すべきことが増えたなあという印象です。

参考

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

【JavaScript】パネルでポンを1ミリも知らないぷよらーが5日で作る話【ゲーム】

こんなんなりましたけど

See the Pen PanePon by z0ero (@z0ero) on CodePen.


こちら完成品のサンプルとなっております。
デモ用にAI同士の対戦を延々と流すモードにしてますが実際にはちゃんとプレイできるモードもありますよ。
今回はこれを作る時にどんなことを考えて作ったかを綴っていこうと思います。
これを読んで「俺もなんか作ろうかな」とか思ってくれたらいいなと思います。

前置き

パネポンって知ってる?

パネルでポン(通称パネポン)は昔スーファミで出たパズルゲームです。
タイトルくらいは知ってたけどほとんどやったことないんで今回作る前にちょっと調べました。

基本的なルール

  1. 6 * 12のフィールドに6色のパネルが配置されている
  2. カーソルを操作して隣接した2枚のパネルを入れ替えることができる
  3. 同じ色のパネルが縦か横に3つ以上並ぶと消える
  4. パネルは重力が働いており自然落下する
  5. ある消滅の結果自然落下により新たな消滅が発生した場合連鎖したと言う
  6. パネルは時間とともに下から湧いてくる
  7. 画面上部までパネルが積み重なったら終わり

対戦もある

1人でひたすらパネルを消し続けるモードもあるが基本的にはCPUと対戦してストーリーを進めたり
対人戦で高め合ったりするのがメインらしい。

  • 対戦では消したパネルに応じて相手に『おじゃまパネル』を飛ばす攻撃ができる。
  • おじゃまパネルは複数個連結した状態で上から落下してきて入れ替え操作も並べて消すこともできない。
  • おじゃまは隣接した色パネルを消すことで『解凍』でき、これによって通常の色パネルに変身する。

ぷよぷよかな?

ぷよらーなら既視感のある単語がちらほら出てましたがパネポンがちょっと違うのは『アクティブ連鎖』というやつ。
落ちものパズルって連鎖中はそれ眺めてるのが普通だけどパネポンは常にパネル移動ができちゃいます!
パネルが消えてる間に別のパネルを動かして次の連鎖を作れてしまいます。
ぷよぷよは前に作ったことあったけどこれはまたチャレンジしがいのあるゲームだなということでで作ってることに。

実装編

コアとなるアルゴリズムを実装する

まず

  • 盤面にパネルを並べて
  • それを入れ換えできるようにして
  • 3つ並んだら消えて
  • 下が空白なら上のパネルが落下してくる

ところまで作ってしまいます。
描画等にライブラリの類は使いません。HTML5+JavaScriptでフルスクラッチします。

実装タスクは
パネルクラスを作成 > 6*12個生成して配列に格納 > canvas作ってパネル描画 > ゲームループ作成 >
キー入力作成 > カーソル描画 > パネル落下 > パネル入れ替え > パネル消去 > せり上がり

の順で進めていきました。実は最初一人プレイしか作るつもりなかったんでおじゃまのことは全く考えてません。なんならゲームオーバー処理も作る気ありませんでした(笑)
そして、思い立ってから40分。以下のものが出来上がりました。

See the Pen 40分 by z0ero (@z0ero) on CodePen.

十字キーでカーソルを移動してAでパネルを入れ替えます。
・・・うーんひどい(笑)一応それっぽい動きはできますがバグもあるし本家と違い過ぎるし粗削りですね。

パネル周りの処理

状態遷移

const NEUTRAL = 0;  // 静止状態
const EXTINCT = 1;  // 発火中
const VANISH = 2;   // 消滅中
const FALLING = 3;  // 落下中
const MOVEDOWN = 4; // 落下完了
const SWAPPING_R = 5; // 入れ替え中
const SWAPPING_L = 6; // 入れ替え中
const SWAPPED = 7; // 入れ替え完了

パネルオブジェクトは状態カウンタパネルタイプ(色)だけ持っています。タイプが-1のパネルは空白マスを表します。
パネルには上記に示したような状態があり、空白マスを含めたすべてのパネルがいずれかの状態にあります。
フィールド処理による外的状態遷移と、パネル個別の内的状態遷移があり、前者は他のパネルとの作用によるもので、後者はパネルの内部カウンタが一定に達することによって起こります。

フィールド処理

各パネルの状態更新とパネル同士の相互作用による状態遷移を処理します。
毎フレーム全パネルに対して更新処理を実行するんですが、左下から右上へ向かって処理するようにしてます。
そうすると1回の走査で連鎖判定を除く落下・入れ替え等のすべての状態遷移処理が完了します。

例えば落下処理では、あるパネルが落下状態になった時に列ごと一緒に落下するためには上にあるパネルも
同時に落下状態にしなくてはなりませんが、落下状態かどうかは下のパネルを見てから判断するので
先に下のパネルから更新されていれば同一フレームで落下状態になれるわけです。
↓赤パネルが落下すると同時に上の青と紫も落下しないとパネルの間に隙間ができる
fall.png
入れ替えはやや特殊な操作で、左側のパネルが「スワップ完了フレーム」だったら右パネルと入れ替えしてしまいます。入れ替え操作は必ず左右同時に開始され、入れ替え中のパネルは決して他の状態に遷移しない仕様なので、このタイミングで入れ替えても良いことが保障されています。むしろこれによって右パネルに対しては入れ替え後の特別な処理が不要になり、普通のパネルとして更新処理をすればよくなります。
swap.png

消滅処理

同色で3つ以上連結しているパネルをフィールドから探します。全パネルに対して右2マス下2マスに同色が揃っているかチェックをしているだけですね。消えるパネルさえ検出できればいいのでこれでいけますが、後々連鎖数カウントなどを実装するときにそれではまずいことが分かってきます・・・。

1人プレイ完成

See the Pen 1人プレイ by z0ero (@z0ero) on CodePen.


最初のバグだらけバージョンを実装後に本家の動画を見たりルールを調べたりしているうちに「もうちょいちゃんと作ってみるか」と思い、試行錯誤の末、再現度の上がった1人プレイモードが完成しました。
断っておくと今回はリポジトリすら作らず全部勢いで作ってしまったためあまり途中のコードが残っておりません!マイクロコミット厨も真っ青のチェンジリストは以下です。
  • せり上がり制御
  • Sキーでせり上がりスピードアップ
  • css背景
  • パネルの立体感&柄
  • ゲームオーバー演出&コンティニュー
  • 初期配置&湧きパネルが勝手に連結しないよう配置
  • パネル消滅時に1枚ずつ消えていく演出

確かここまでが1日の成果だったと思います。実装が楽しくて一番モチベがあった段階ですね(笑)

せり上がり

せり上がりは最下段の下から次の1段がじわじわ湧いてくる処理です。次の牌の予告、いわゆるネクストの役割を果たしています。パネルが消えている間などはこのせり上がりが停止するので、その間に次の手を用意できれば盤面が埋まってても意外と復活出来ます。
それとは別にユーザー操作によって高速にせり上げることもできます。この操作は常に可能なので、どんどんパネルを補充してアグレッシブに攻めることができます。
そして予告パネルが完全にせり上がるとすぐに次の予告パネルを抽選するんですが、この時パネルが天井に着いてしまっていたらその時点で負けというルールにしました。この負けの条件が本家見てもよくわからなかったんですよね。合ってるのかな?

ゲームオーバー演出

gameover.png
ゲームオーバー画面です。
上まで積み重なった盤面がボヤ~っとボケていくと同時に手前に「GAME OVER」の文字がボヤ~っと浮かび上がってきます。この演出にはcssのfilter:blur()を使用しています。こういうエフェクトが気軽に使えちゃうのがhtmlで作るメリットですよねぇ。トランジションもcssの機能で行っているのでコードではゲームオーバー時にスタイルを設定しているだけであとは勝手にアニメーションしてくれます。便利だ・・・

あと、この英字フォントなんですがGoogle Fontsさんからお借りしています。最初はデフォルトフォントでやったんですがそうすると幅広すぎて横がはみ出ちゃうし、そもそも線が細いのでダサい。
そういう時に以下のようにcssに1行追加するだけでいい感じのフォントが使えちゃうので非常に便利です。フォントのライセンスは個別で違うので確認してから使用しましょう。

style.css
@import url('https://fonts.googleapis.com/css?family=Anton');

勝手に連結しない配置

ゲーム開始時に盤面の下半分にパネルが敷き詰められた状態で開始する仕様になっていますが、完全なランダム配置だと普通に連結しちゃって勝手にパネルが消えてしまいます。
それを避けるためにパネル配置時に縦と横2マス隣を調べて連結が発生しない色になるよう抽選しています。せり上がりで出てくるパネルも抽選時の盤面を見て連結しないよう抽選してます。一切パネルを操作せず放置すれば連結が起きずにそのままゲームオーバーになるはずです。

CPU対戦対応

See the Pen CPU対戦 by z0ero (@z0ero) on CodePen.


ここから開始時にメニュー画面が付きます。
最大4人まで同時プレイが可能になります。が、対CPUのみしかないのとおじゃまが実装されていないのでただただ他のCPUが自滅するのを待つだけのサバイバルゲームになっています。
ここまでで4日近くかかったと思います。面白さよりめんどくささが勝つ処理が多かったのでモチベと実装速度が落ちてます(笑)
  • エフェクト実装
  • 複数対戦実装
  • CPU実装
  • 連鎖カウント・得点計算・おじゃま計算

エフェクト

ここでのエフェクトはパネルを消した時に飛び散る星や相手のフィールドに飛ぶ攻撃などの賑やかしのことです。
そんなに重要じゃあない癖に見た目の調整が面倒で結構時間とられちゃう箇所なんですよね・・・。
ここまで実装を避けてましたが、対戦モードとなるとやはり派手なエフェクトは必須!(個人の感想です)なので重い腰を上げて作っていきましょう。

まず、表示物1つ1つに対応しているエフェクトクラスというのを作りました。
エフェクトクラスがやっているのは対象となるオブジェクトに対して指定されたプロパティを指定時間かけて指定した値に遷移させていくという単純なものです。対象オブジェクトはどんなオブジェクトでもいいように作ってはいたんですが、結局表示物の位置を動かしたりするのに使うので、今回実装したエフェクトでは全てdomのstyleオブジェクトを指定しました。
styleオブジェクトの値はやっかいで、160pxなどの単位付き数値文字列が多いです。補間したいのは数値の部分だけなので特殊対応が必要になります。全てのstyle値で補間処理を使えるようにしようと思うとrgba(255,128,0,0.5)solid 0px #fffなどが面倒なので今回は「文字列+数値+文字列」というパターンのプロパティだけ対応しました。

        const t = this.ease(this.counter / this.frame);
        for(let p in this.properties){
            const parts = /([^-\d.]*)([-\d.]+)(\D*)/.exec(this.startProperties[p]);
            const value = parseFloat(parts[2]);
            const property = this.properties[p];
            this.target[p] = parts[1] + (isNaN(property) ? property(t, value) : value + t * (property - value)) + parts[3];
        }

これが補間処理です。元のstyle値をいったん正規表現でバラシてから数値部分だけを補間して再結合するという処理をしてます。うーん、イケてない・・・w

エフェクトアニメーションはゲーム側の更新と同期させたかったのでゲームループから更新しています。一方描画は盤面と違ってdom要素で描画しています。最初はエフェクトも素直にcanvas描画で作ろうとしていましたが、canvasで作っていたのは盤面の部分だけ、しかしエフェクトは余裕で盤面外まで飛び出るので、そうすると全画面canvas化しないといけないなぁと思い結局domを使ってしまいました。(今にして思えば全画面canvasありだったな:thinking:
なので、「アニメーションはjs」で「表示はdom」というちょっと古臭い感じに(今と違って昔はこれが当たり前だったのだよ・・・)
これで一応エフェクトは作れたんですがとにかく設計が中途半端に・・・:sob:誰かいい作り方教えて・・・

複数対戦実装

multi.png
さて、めんどくさそうな複数対戦です。
が、実はJavaScriptの仕様のおかげで図らずもここまでの実装だけでマルチプレイヤーは実現できています。
ここまでのゲームコードは大体以下のような構造で実装していました。

function start() { // エントリ関数
    // canvas等dom要素作成
    document.body.append(...);
    ...
    // ゲーム変数初期化
    ...
    setInterval(() => {
        // ゲームループ
        ...
    }, 16);
}

ゲーム変数は1プレイヤー分しか宣言してないですし、ゲームループも1人分の更新処理しかしていません。しかし、それらはstart関数を呼び出すたびにインスタンス化されsetIntervalに渡しているクロージャによって束縛されるため実質ゲームインスタンスの生成になっているわけです。つまり以下のようなGameクラスを実装しているのにほぼ等しいことになります。

class Game() {
    constructor() {
        // dom作成/ゲーム変数初期化
        ...
    }
    update() {
        // ゲームループ
        ...
    }
}
// start()の呼び出しはnew Game()に相当する
let game = new Game();
setInterval(game.update.bind(game), 16);

そのため複数人対戦にするためにあと必要なことは、インスタンス間の調停を行うゲームコントローラと横並べにするレイアウト処理の実装だけになります。
そこで、これまでの処理を以下のように作り変えます。(実際のコードとは多少異なります)

function start(controller, panel) { // エントリ関数
    // canvas等dom要素作成
    panel.append(...);
    ...
    // ゲーム変数初期化
    ...
    controller.addEventListener('frame', () => {
        // ゲームループ
        ...
    });
}
// コントローラにゲームインスタンスを追加
let controller = new GameController();
start(controller, createElement('div'));   // プレイヤー1
start(controller, createElement('div'));   // プレイヤー2
...

ゲームコントローラクラスを作成して各インスタンスの勝敗状況などを監視したり、インスタンスからのメッセージ(攻撃)などを受け付けることにしました。ゲームループもsetIntervalではなくコントローラのフレームイベントで処理するようにします。

また、直接document.bodyに追加していたcanvasなどを与えられた親要素に追加するようにして、各インスタンスのレイアウト処理をコントローラに管理させることにしました。

GameControllerクラス

  • 各インスタンスとそれぞれのルートdiv要素を管理したりそのレイアウト処理をする
    • レイアウト処理では拡縮にstyle.transform.scaleを使用しています。そのため、canvasがドットバイドットで表示されないので、無駄に重かったり(縮小時)見た目が汚くなったり(拡大時)しちゃいます。ははは(放棄)
  • あるプレイヤーの攻撃を別のプレイヤーへ飛ばす
    • 攻撃を飛ばす処理ですが、本家ではどういうアルゴリズムで攻撃を飛ばしてるのか全然わかりませんでした。ぷよぷよでは1つの攻撃は他プレイヤー全員に攻撃力が分配されるんですが、パネポンでは1撃は誰か1人に飛んでいるっぽかったので今回は完全にランダムで1人選んで飛ばしています。
  • 各インスタンスの死亡イベントを受け取ってゲーム全体の勝敗判定を行う
    • まず1人プレイだった場合、死亡時の「GAME OVER」判定だけ行います。2人以上の場合「WIN」「LOSE」と同一フレーム決着の「DRAW」を判定します。判定結果はsettleイベントで各インスタンスに通知しています。
  • コンティニューや毎フレーム更新のイベント
    • GameControllerはEventTargetを継承しているのでdispatchEventメソッドで各インスタンスにイベントを投げることができます。これによって同期をとることができます。
  • エフェクト管理
    • エフェクト管理は各インスタンスでは行っておらずコントローラが一括して行います。攻撃エフェクトはコントローラが発火していますし、ゲームインスタンスは勝敗決定後に毎フレーム更新をしなくなるので、インスタンスでエフェクトの更新を行うと表示途中のエフェクトが画面に残ってしまうためです。

CPU実装

さぁ対戦を実装したからには戦う相手が必要です。対人戦ってサクッと作れないかなと思い調べてみたんですが、ブラウザ上でP2P対戦は無理(?)っぽいし、PlayFabとかのサービス使う方法もよくわかんなかったんで、、、もうCPU戦でいいやと。(対人誰か作って)

AIアルゴリズムが思いつかないのでまずはゲーム操作処理の抽象化を行いました。
今まではユーザーのキー入力を見て直接カーソル移動と入れ替え操作をしていたんですが、間にPlayerクラスというのを挟んでこれを通して操作することにします。

    class Player {
        moveUp = false;       // カーソル上移動
        moveDown = false;     // カーソル下移動
        moveRight = false;    // カーソル右移動
        moveLeft = false;     // カーソル左移動
        swap = false;         // パネル入れ替え
        haste = false;        // スピードアップ
        reset = () => {};     // 状態初期化
        update = () => {};    // 更新関数
    }
    player = new Player();
    player.update = /*任意の操作処理*/

ゲームの操作は上6つのフラグで行えます。そしてこれらのフラグを設定するupdate関数にどんな処理を割り当てるかでユーザー操作とAI操作を切り替えることができます。ゲームインスタンス生成時の引数としてプレイヤータイプが渡されるのでそれに応じてupdateの内容を切り替えています。

AIのアルゴリズム

適当にAI実装していきます。基本的には自分がプレイしてるときに考えてることをコード化する感じになります。

サーチフェーズ

消せそうなパネル列を見つけるフェーズです。フローをmermaidで書いてみたんですがレイアウトが見づらすぎて草生えました。
flowchart.png
ある程度高く積み上がってたら先にそれを均して、それが無ければ消しに行くシンプルなAIです。しかも縦の連結しかサーチしてません笑。基本的に落ちものパズルのAIって考え方は比較的簡単だけど実装が面倒なんですよね(言い訳)
そして最終的に入れ替え操作をする座標をリスト化した「コマンド」を作成して移動フェーズへ遷移します。ちなみに取れる行動が見つからなかった場合ランダムな座標で入れ替え操作を行いまくる発狂状態になります。
あとチャートに入れ忘れてたんですが一定以上積み上がってなかった場合には常にスピードアップ操作を行ってアグレッシブにパネルをツモりにいきます。

ムーブフェーズ

サーチフェーズで決定したコマンドを実行するフェーズです。

コマンド
(3,9)
(4,8)
(0,7)

コマンドは上記のような座標リストです。
コマンドから1つずつ座標を取り出してカーソルがその位置へ移動するようmove~系のフラグを設定します。コマンドの座標にたどり着いたらそこで入れ替え操作が可能になるまで待機して、可能だったら入れ替え操作を実行します。これをコマンドが空になるまで繰り返します。全ての移動が完了したら再びサーチフェーズへ遷移します。

CPUの強さ

CPUには強さに関わる2つのオプション設定があり1つはウェイトでもう1つは貪欲さです。
ウェイトはムーブフェーズにおける待機フレームを意味します。つまり操作の速さを調整することができます。
貪欲さはサーチフェーズで消せるパネルを探すときに最低何連結で探すかを調整するものになります。パネポンは3連結で消しただけでは攻撃が発生しません。なので貪欲値を4または5に設定しておくことで常に攻撃的なアクションを取ることになります。ただしピンチなときは3連結も許容することで保身行動を取らせます。

連鎖カウント・得点計算・おじゃま計算

同時消しは同時に消えたパネルの数でボーナス点が発生することです。連鎖はあるパネルの消滅で落下したパネルが更に消滅することを言います。ここまでの実装では同時消しのパネル数が正しくカウントできていないのでまずそこを修正します。
この修正は簡単で消えたパネルにフラグを付けておくことで同じパネルを2度カウントしないようにすればいいだけです。
同時消し.gif

↑赤のパネルがクロスしていて真ん中のパネルは2つの結合で共有されているが同時消しカウントは5。

連鎖はちょっとややこしいです。ちなみに今回本家の仕様をちゃんと調査せずに独自解釈で実装したので本家と挙動が異なります。
連鎖.gif
上の例では2連鎖目が同時消しになっています。2連鎖ボーナス×1と6個消しボーナス×1がつきます。
3連鎖は黄色と紫の2回あります。どちらも連鎖の原因が2連鎖目のパネルだったためタイミングや場所が異なっても3連鎖目として判断されます。
同様に4連鎖目の青は3連鎖目の黄色が原因となるため4連鎖目として判断されています。
この挙動を実装するためにパネルにコンボIDコンボカウントを持たせました。全ての1連鎖目にはコンボIDの新規割り振りが行われ、それを起点とする連鎖にはこのIDが受け継がれていきます。これにより一連の連鎖を区別できるようになります。同時にコンボカウントも1増やしながら継承していくことで正しく連鎖数をカウントできるようになります。

おじゃま実装

カミングスーン

小ネタ

エフェクト3種

エフェクトの動きやcssの作り方について

星エフェクト

See the Pen star by z0ero (@z0ero) on CodePen.


星はsvgで四芒星を作ってエフェクト発生時にsvg.cloneNode(true);をした要素を動かしています。元となるSVGは1ピクセルのサイズで作っているので実際に欲しいサイズにスケールをかけて使います。スケールするとストロークの太さも太くなってしまうのでstrokeWidth = 1/sizeを設定して常に1ピクセル幅のストロークになるようにしています。

コンボエフェクト

2連鎖以上すると消えたパネルの辺りに連鎖数の数字が現れて何度かバウンドします。このバウンドはイージング関数で実装しています。

See the Pen bounce by z0ero (@z0ero) on CodePen.


放物線自体は正弦曲線を用いています。バウンドするたびにその高さが1つ前の半分になり、ぴったりN回バウンドしたところで終了します。上のデモでは左からバウンド回数が2~6回となっています。バウンド回数が違っても必ず同じ時間で終了するように各バウンドにかかる時間の比率を計算しています。
このイージング関数は実はここ以外にもおじゃま落下時の盤面の揺れにも使用しています。汎用性が高く制御もしやすいので非常に便利ですね。

攻撃エフェクト

なんらかの攻撃が発生すると画面を縦横無尽に飛んでいく光の玉です。

See the Pen orb by z0ero (@z0ero) on CodePen.


見た目自体はcssのbox-shadowプロパティなどを組み合わせて作っています。動きは2次ベジェ関数を使用しています。
2次ベジェ
t => (1 - t) * (1 - t) * from + 2 * (1 - t) * t * mid + t * t * to;

Bezier.gif
tを0~1に変化させたとき座標fromからtomid付近を通りつつ曲線移動する軌跡を得られます。
今回の攻撃エフェクトはフィールド上で発生してから誰かのフィールド上部へ飛んでいく動きなのでfromとtoはそれになります。midですが以下の式で求めました。

    const vx = toX - fromX;
    const vy = toY - fromY;
    const midX = toX - vx;
    const midY = toY - vy * 0.5;

fromから見たtoと逆の位置に設定しています。こうすることで飛んでいくときに少しだけ"溜め"が入り攻撃に重みが感じられるようになります。

おじゃま予告

stock.png

控えてるおじゃまパネルの量と数を予告するフィールド上部に出るやつですね。本家では3~5個までのおじゃまは小さいブロックをとかみたいに並べて表示して、6個以上の場合は上図のようにでかいパネルの上に×段数を表示していました。
なので最初同じように作ろうと思ったんですが、フィールドサイズを変更したときにうまくいかないことに気づいたため上図左のように細いバーとして表示することにしました。うまくいかないというのはおじゃま実装のところでも書いたようにおじゃまのパケットはフィールド幅/2~フィールド幅-1フィールド幅 * 段数単位であるのでフィールド幅が大きい時に本家の表示方法では細かい四角が大量に重なってしまい訳が分からなくなります。
この細いバーはその長さでおじゃま量を表しています。(ただしその微妙な違いを読み取るのは至難の業:sweat_smile:

パネルの細かいアニメーション

パネルにはいくつか細かい動きが付いています。

感想

今回の製作を通して初めてパネポンの面白さに気づけましたね。これまで1回くらいしか触ったことなかったんですが、デバッグプレイして上達してくるといろいろテクニックがあることに気づいて奥が深いなという印象でした。更にこの独特なリアルタイム性のおかげで連鎖作るのがぷよぷよより難しいです!ぜひ皆さんに遊んでみてほしいですねー(ただ個人的に延々パネルを繋げる作業が辛くなってきて最終的にはAI同士の対戦眺めてるのが一番おもしれーなってなっちゃいましたテヘ)
あとゲーム製作的な面でいうと1ファイルjsゲームの面白さっていうのを伝えたいですね。ランタイムを入れる必要も無ければソースと実行ファイルの配布が同時に行えるので、ゲーム作り初心者から中級者以上までゲームプログラミングの根本的な面白さを楽しめると思います。(例:7行テトリスなど)みんなも腕試しでゲームプログラミング、しよう!

あとがき:
この記事書くのめっちゃ時間かかりました・・・。前回の記事からすごい久々だったんでリハビリがてらライトな内容で気軽に書こうと思ったんですけど、すごい長くなっちゃって途中で後悔してました(ヽ''ω`)一応今後もこんな感じでネタがあれば記事書いていこうとは思いますが短くまとめていきたいですね。

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

React.memo / useCallback / useMemo を書きながら学ぶ

Reactの組み込みフックであるuseCallbackuseMemoの説明をします。

また、useCallbackは、React.memoという Reactの API と併用するので、React.memoの解説もします。

useCallback とは

一言で言うと、パフォーマンス向上のためのフックです。

具体的に言うと、コールバック関数をメモ化して、不要な関数インスタンスの作成を抑制します。

これによってパフォーマンスを向上させています(不要な関数インスタンスの作成が重い時は特に)。

ちなみに、メモ化とはプログラム高速化のための最適化の1つです。関数の定義や実行の結果を再利用するために一時的に保持しています。

依存配列の要素(deps)が変化した場合にのみメモ化した値を再計算します。

const callback = useCallback(コールバック関数, [依存配列])

依存している値がなければ、空の配列OKです。

const callback = useCallback(コールバック関数, [])

依存している値が更新されれば、関数が再生成されます。

例を1つ

const callback = useCallback(() => console.log(count), [count])

上記の場合、countが更新されない限り、関数は再生成されません。

サンプルコード

こんなん作るとします。

スクリーンショット 2021-01-28 0.17.11.png

各ボタンクリック時の結果は、なんとなくわかりますよね?

スクリーンショット 2021-01-28 0.49.04.png

今の状態だと、どのボタンを押しても、Titleコンポーネントとか(再レンダリングしても結果同じなやつ)も含め、全てのコンポネントが再レンダリングされます。

console.log仕込んでるので、それがわかります。

8de0ba44bab2230c72d3dddad8c8241a (1).gif

サンプルコード
App.js
import React, { useState } from "react";
import "./styles.css";
import Title from "./components/Title";
import Count from "./components/Count";
import Button from "./components/Button";

const App = () => {
  const [height, setHeight] = useState(150);
  const [weight, setWeight] = useState(50);

  const incrementHeight = () => setHeight(height + 1);
  const incrementWeight = () => setWeight(weight + 1);
  return (
    <>
      <Title />
      <Count text={"身長"} count={height} />
      <Count text={"体重"} count={weight} />
      <Button handleClick={incrementHeight}>身長+1</Button>
      <Button handleClick={incrementWeight}>体重+1</Button>
    </>
  );
};

export default App;
components/Title.js
import React from "react";

const Title = () => {
  console.log("Title");
  return <h1>身長と体重の入力</h1>;
};

export default Title;
components/Button.js
import React from "react";

const Button = ({ handleClick, children }) => {
  console.log(`Button - `, children);
  return <button onClick={handleClick}>{children}</button>;
};

export default Button;
components/Count.js
import React from "react";

const Count = ({ text, count }) => {
  console.log(`Count - `, text);
  return (
    <p>
      {text} : {count}
    </p>
  );
};

export default Count;

☝️のサンプルコードの問題点

どのボタンを押しても、全てのコンポネントが再レンダリングされることが、パフォーマンスが悪いとしましょう。

そこで、パフォーマンス改善に役立つReact.memouseCallbackを試してみましょう。

そこで、React.memoの出番

React.memo とはコンポーネントをメモ化(計算結果を再利用するために保持)するReactのAPIです。

キャッシュみたいなもんです。

もう少し細かい言い方をすると、HOCで、React.memoを使うことで、propsの値が変わらないなら、関数コンポネントのレンダリングを抑制することができるAPIです。

なので、以下のようなコンポネントをメモ化するとパフォーマンス上有効です。

  • レンダーコストが高いコンポネント
  • 頻繁に再レンダーされるコンポネントの子コンポネント

やってみましょう。変更点は、赤枠のみ。?

musing-almeida-g5ogr_-_CodeSandbox.png

身長+1ボタンをクリックしたら...

以下のコンポネントが再レンダリングされます。

  • 身長のCountコンポネント
  • 身長のButtonコンポネント
  • 体重のButtonコンポネント(? なんでこいつが再レンダリングされるんだ? してほしくない)

以下のコンポネントは再レンダリングされません。

  • 体重のCountコンポネント
  • Titleのコンポネント

musing-almeida-g5ogr_-_CodeSandbox.png

なぜ「身長+1ボタン」をクリックしたら体重のButtonコンポネントがレンダリングされるのか?

App コンポネントが再レンダリングされるたびに、関数も再生成され、再生成の前後で関数は等価ではありません。

なので、React.memoで子コンポネントをメモ化しても コールバック関数をpropsとして渡す場合は子コンポコンポネントは必ず再レンダリングされます。

この問題を解決するのがuseCallbackです。

次に、usecallbackの出番

変更点は、赤枠。?

musing-almeida-g5ogr_-_CodeSandbox.png

実行結果

399b7c1abfe524dedf2410cad071f1b0.gif

useCallbackを使うことでコンポネントの再レンダリングを最適化することができました。

このように、子コンポネントにコールバック関数をpropsとして渡す場合は使ってみるといいと思います。

useCallbackの注意点

前述の通り、useCallbackReact.memoと併用するものなので、次のような使い方をしても再レンダリングをスキップできません

  • React.memoでメモ化していないコンポネントにuseCallbackでメモ化したコールバック関数を渡すとき
  • useCallbackでメモ化したコールバック関数を、それを生成したコンポーネント自身で利用するとき

useCallbackの疑問点...依存関係の配列にsetState関数を含める必要があるか?

ない。

コード書いてると見かけるので、調べてみましたが、公式ドキュメント見ると不要だそうです。

Note

React guarantees that setState function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.


React は再レンダリングでsetState関数の等価に保たれ、変化しないことを保証します。
従って useEffectuseCallbackの依存リスト(依存配列)にはsetState関数を含めなくてもいいです。

https://reactjs.org/docs/hooks-reference.html#basic-hooks

useMemo とは

useCallback同様、パフォーマンス最適化用のHookです。

useCallbackとの違いは以下です。

  • useCallbackは関数自体をメモ化
  • useMemoは関数の結果をメモ化(メモ化された値を返すHook)

useMemoは、値を算出するための不要な再計算をスキップすることでパフォーマンスを向上させます。

サンプルコード

こんなん作るとします。useCallbackの時とほとんど同じです。

身長だけ、奇数か偶数か判定されています。

スクリーンショット 2021-01-28 2.01.17.png

差異があるファイルだけ以下に記載しておきます。

サンプルコード
components/App.js
import React, { useState, useCallback } from "react";
import "./styles.css";
import Count from "./components/Count";
import Button from "./components/Button";

const App = () => {
  const [height, setHeight] = useState(150);
  const [weight, setWeight] = useState(50);

  const incrementHeight = useCallback(() => setHeight(height + 1), [height]);
  const incrementWeight = useCallback(() => setWeight(weight + 1), [weight]);

  const isEven = () => {
    console.log("身長");
    return height % 2 === 0;
  };

  return (
    <>
      <p>
        身長 {height}  {isEven() ? "偶数" : "奇数"}
      </p>
      <Count text={"身長"} count={height} />
      <Count text={"体重"} count={weight} />
      <Button handleClick={incrementHeight}>身長+1</Button>
      <Button handleClick={incrementWeight}>体重+1</Button>
    </>
  );
};

export default App;

コードを実行すると、どちらのボタンをクリックしても、発火します。

理由は、useCallbackの時と同様に、コンポネントが再生成されたタイミングでisEven関数も再作成、実行されるからです。

本来は、この関数は身長ボタンがクリックされたときだけ実行するべきですよね?

でも、体重ボタンをクリックしても発火してしまう。。

これを問題として、useMemoを使って、身長ボタンがクリックされたときだけisEvenが実行されるようにしましょう。

サンプルコード
components/App.js
import React, { useState, useCallback, useMemo } from "react";
import "./styles.css";
import Count from "./components/Count";
import Button from "./components/Button";

const App = () => {
  const [height, setHeight] = useState(150);
  const [weight, setWeight] = useState(50);

  const incrementHeight = useCallback(() => setHeight(height + 1), [height]);
  const incrementWeight = useCallback(() => setWeight(weight + 1), [weight]);

  const isEven = useMemo(() => {
    console.log("身長");
    return height % 2 === 0;
  }, [height]);

  return (
    <>
      <p>
        身長 {height}  {isEven ? "偶数" : "奇数"}
      </p>
      <Count text={"身長"} count={height} />
      <Count text={"体重"} count={weight} />
      <Button handleClick={incrementHeight}>身長+1</Button>
      <Button handleClick={incrementWeight}>体重+1</Button>
    </>
  );
};

export default App;

変更点は、赤枠。?

musing-almeida-g5ogr_-_CodeSandbox.png

useMemoは、値(今回は bool値)を算出するための不要な再計算(今回なら「体重+1」ボタンクリック時のisEven内の計算処理)をスキップさせています。

「体重+1」ボタンクリック時は、useMemoisEven処理を再実行させずに、結果(bool値)のみを返しています。

仮に、isEven処理が重い場合、「体重+1」ボタンクリック時は、結果(bool値)のみを返すので、パフォーマンス向上につながると思います。

今回は以上です。

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

【Rails,AWSを使用】僕が作成したポートフォリオの紹介をします。

はじめに

フレームワークにRuby on Rails、インフラにAWSのEC2を使用してポートフォリオを作成しました。

正直なところ全体の完成度は6割程度でまだまだ改善の余地はあるのですが、一旦はサービスとして形になったので、振り返りの意味も含めアプリについて詳しく書いた記事を投稿しようと思います。

(不具合や未完成の部分については随時改善していきます)

アプリの概要

アプリ名:オフリード(英語で「リードを手放し自由にする」という意味)
URL:https://www.offlead-dog.com/
GitHub:https://github.com/yuuta-matsumoto/off_lead

ドッグトレーナーと犬を飼う人のマッチングサービスです。

【ドッグトレーナーとは】
犬に対してしつけや訓練を行う職業です。よく、ブリーダーと勘違いされる方が多いのですが、ブリーダーはペットの交配や出産、繁殖を手がけて市場に流通させる職業なのでドッグトレーナーとは異なります。
また、ドッグトレーナーの中でも職域によって仕事内容は変わってきます。
例えば、警察犬を訓練する人と一般家庭の飼い犬を預かって訓練する人では必要なスキルや仕事内容が大きく変わるそうです。

開発に至る経緯

サービスの内容はドッグトレーナーのマッチングサービスですが、目的はドッグトレーニングを普及させ、犬の殺処分問題の解決に少しでも貢献することです。

僕が殺処分問題を知ったのは高校生の時でした。
たまたまテレビから流れてきた映像のあまりの残酷さに衝撃を受けた事を今でも覚えています。
それ以来、なんとかならないのかなという気持ちを漠然と持ち続けてきました。

最初は保護犬を探せるサイトを作ろうと思ったのですが、競合するサービスが複数あったので、それならドッグトレーナーを気軽に探せるマッチングサービスはどうかと考えました。
きちんとしつけされた犬が増えれば、それが原因で捨てられる犬も減り、間接的にだが殺処分問題に貢献できるのではないか。そんな考えを経て開発に取り掛かりました。

使用イメージ

ユーザー一覧画面

ユーザー一覧.gif

ユーザーのマイページ

簡易的ですがSPAっぽくしています。
ユーザー詳細画面.gif

投稿詳細・口コミ投稿・いいね

投稿詳細⇨口コミ投稿.gif

使用技術

フロントエンド

  • jQuery 3.5.1
  • HTML&CSS/SCSS
  • Bootstrap4.5.3

バックエンド

  • Ruby on Rails 6.0.3
  • Ruby 2.6.3
  • RSpec 3.9

インフラ

  • MySQL 5.7.33
  • Nginx 1.22.2
  • AWS (EC2, ALB, ACM, RDS, Capistrano, Route53, VPC, EIP)

コンセプト

【ドッグトレーニングをもっと身近に】

欧米ではドッグトレーニングが文化として根付いている国もあるのですが、日本ではまだ言葉の意味すら知らない人が多いのが現状です。また、正しい飼い方の知識を持つ日本人も決して多いとは言えないでしょう。
とはいえ、人間の子育てにそれぞれのやり方があるように、犬のしつけも自由でいいはずです。
ですがそれは飼い方に対して、ある程度の知識を持っている前提の話だと僕は思います。

ドッグトレーニングは、本質的には「人間のトレーニング」です。
なぜならその先何年もの日々を共に過ごすのは、ドッグトレーナーではなく飼い主自身だからです。
また、犬の問題行動の8〜9割が飼い主自身によって引き起こされているという説もあります。

サービス名のオフリードには、英語で「犬のリードを手放し自由にさせる」という意味があるそうです。
犬と人間が今よりも良い形で共存する為にドッグトレーニングの普及は必要不可欠であると考えています。

DB設計

ER図

スクリーンショット 2021-01-27 21.10.30.png

テーブル設計

テーブル名         説明           
users 登録ユーザー情報
posts ドッグトレーナーが投稿するプラン
relationships フォロー・フォロワー関係を管理
likes いいねを管理
reviews 口コミを投稿
messages DM機能のメッセージ内容を管理
entries usersとroomsの中間テーブル
rooms メッセージルームを管理

機能一覧

ユーザー認証

  • ログイン、ログアウト、会員登録
  • プロフィール編集
  • ゲストログイン

投稿機能

  • ドッグトレーナーがプランを投稿(プラン名、内容、金額)

口コミ投稿機能

  • 投稿されたプランに対しての口コミ
  • 星評価
  • プロフィール画面で投稿一覧が見られる

メッセージ機能(メンテナンス中)

  • ユーザー同士で1対1のメッセージが可能
  • メッセージボタンを押すと新規メッセージルームが作成される。
  • (作成済みの場合は既存のメッセージルームにリダイレクト)

画像アップロード(CarrierWaveを使用)

  • プロフィール画像のアップロード
  • プラン投稿時の画像アップロード

フォロー機能

  • フォローボタンを押すとフォロ、フォロー解除が行える
  • プロフィール画面でフォロー中、フォロワーが見れる

いいね機能

  • 投稿されたプランにいいねができる
  • プロフィール画面でいいね一覧を確認できる

工夫したポイント

開発の際に現職のドッグトレーナーの方からの意見を聞き、その内容を取り入れました。

アプリの概要が決まり、要件定義など諸々の準備が整っていざ開発、という時に僕の中でふと「これって本当にニーズあるの?」という疑問の生まれました。
当たり前ですが、ユーザーの潜在的なニーズを満たすサービスでなければ使ってもらうことはできません。
自分なりに考えを落とし込んだつもりでしたが、実際のユーザーに意見が聞きたい、そう思いツイッターを通じて現職のドッグトレーナー10名にお声がけしたところ、ありがたい事に2名もご返信を頂き話を聞くことができました。

実際に話を聞いてみると自分の知らなかった事や考えつかなかった事がたくさん聞けたのですがその中でも以下の2つは特に参考になりました。

  • ドッグトレーナーは個人事業主が多く、そのほとんどは自分でWEBサイトを運用している。
    ⇨オフリードがプラットフォームになればドッグトレーナー側にもメリットはある。

  • サイト内でユーザー同士が直接やりとりできた方が成約率は上がると思う。
    ⇨当初予定にはなかったDM機能を実装する。

それまでの僕は、いかにサービスを完成させるかだけに注力していました。ユーザーファーストで考える事の大切さを肌で感じる事のできた貴重な体験だったと思います。
ご協力頂いたお二人からは「ニッチな領域だが発想は面白い」「完成したらぜひ協力させてください」と言って頂けました。

ただ、ユーザーの話を鵜呑みにしてそれを採用することは誰にでもできるので、今後似たような機会があった際には、ユーザーの意見を知った上で鵜呑みにせず、何を提供すればそのニーズを満たせるのかの最適解を探せるエンジニアになりたいです。

苦労したポイント

独学での作業で、知り合いのエンジニアもいなかった為、最初から最後まで苦労しっぱなしですが、その中でフロントエンド、バックエンド、AWSに分けて紹介していこうと思います。

フロントエンド

  • 細かいスタイルの調整(SCSS)

フロントエンド

  • メッセージ機能の実装
    ⇨多対多のDB構造を理解するまでの時間がかかりました。
    また、コントローラーの記述が複雑で他の機能を実装する時よりも苦労した記憶があります。

  • モデルのアソシエーション
    ⇨全体的にActive Recordの関連付けでは苦労しました。

AWS

  • プリコンパイル
    ⇨デプロイ時にasset:precompileが実行できず、3日以上ハマってしまいました。
    最終的にSprocketsに移行することで解決。

  • Capistranoの導入
    ⇨Unicornの設定で手こずったり、環境変数の渡し方を間違えていたり複数箇所でつまづいてしまいました。
    このあたりから公式のリファレンスを参照することを意識していました。

最後に

総じて技術的な内容よりも、エピソードや自分の思っていることをメインに綴った記事になってしまいました。
技術的にはまだまだ未熟なので引き続き学習を続けていこうと思います。

今後の実装・改修予定

  • メッセージ機能(1対1のメッセージルームを正常に作成)
  • デザイン全体の改善
  • 通知機能
  • 絞り込み検索
  • ユーザーの属性毎にインターフェイスを分ける(ドッグトレーナーとその他のユーザー)
  • Vue.jsを用いたSPA化
  • 安全なテストコードの記述
  • 決済機能の導入

追加機能の実装やアプリの改修を行った際には随時、記事にまとめて投稿していく予定です。
最後までご覧頂きありがとうございました。

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