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

[Electron, Vue.js] IpcRenderer.on()でthis.countに代入しようとしたら失敗した

動機

ElectronのipcRenderer.on()内でthis.countに受け取った値を代入したかったのにうまく行かなかったので
解決はしたが原因がわかりません…

目次

うまくいかない例

  mounted() {
    ipcRenderer.on("setCount", (_, arg) => {
      this.count = arg;
    });
  },

うまくいく例

なかなかグロテスクなコードになりました…
thisをコールバックの外に出してみたら何故かうまく行った

  async mounted() {
    this.count = await ((): Promise<string> =>
      new Promise((resolve) => {
        ipcRenderer.on("setCount", (_, arg) => {
          resolve(arg);
        });
      }))();
  },

関数を分けるなら

  methods: {
    async waitIpcMessage(): Promise<string> {
      return new Promise((resolve) => {
        ipcRenderer.on("setCount", (_, arg) => {
          resolve(arg);
        });
      });
    }
  },
  async mounted() {
    this.count = await this.waitIpcMessage();
  }

最後に

なぜこれならうまくいくのか知っている方は教えてください...
thisが外に出てるからうまくいくっぽい?よくわかりません

話は逸れますが非同期周りの書き方ってかなり複雑ですよね

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

JavaScriptでFormのバリデーション

この記事を書いた経緯

自分が独学時代にポートフォリオ作ったのはいいがバリデーションを実装していなかった。
独学でまずポートフォリオを作ろうと思うとそこまで考えられない方も多いと思う。

だからこそバリデーションを入れておくとワンランク上のポートフォリオを作れると思う。

というわけでここでは簡単なバリデーションを紹介したいと思います。

会員登録フォーム、ログインフォーム、お問い合わせフォームなど色々使い回しできるのようにしています。

HTMLの構成

ログインフォームを想定して実装しています。

index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
    <form id="signup">
        <p>
            <label for="name">ニックネーム</label>
            <input type="text" id="name">
        </p>
        <p>
            <label for="email">メールアドレス</label>
            <input type="email" id="email">
        </p>

         <p>
            <label for="password">パスワード</label>
            <input type="password" id="password">
        </p>

         <p>
            <label for="confirmPassword">パスワード確認</label>
            <input type="password" id="confirmPassword">
        </p>
        <p>
            <input type="submit" value="Signup">
        </p>
    </form>

    <script src="main.js"></script>
</body>
</html>

javaScript構成(関数定義部分)

main.js
//定数formを定義
const form = document.getElementById('signup');

//関数宣言

// 空チェック関数
//  (...args) -> 可変長引数
const isValidRequiredInput = (...args) => {
  let validator = true;
  for (let i=0; i < args.length; i=(i+1)|0) {
      if (args[i] === "") {
          validator = false;
      }
  }
  return validator
};

// ニックネームの文字数制限の関数
const isValidName = (name) =>{
    if(name.length < 4){
        return false
    }
    return true
}


// メール形式チェックの関数
const isValidEmailFormat = (email) => {
  const regex = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/
  return regex.test(email)
}

// パスワード一致チェック確認の関数
const isValidPassword = (password, confirmPassword) => {
  if(password !== confirmPassword){
      return false
  }
  return true
}

javaScript構成(関数呼び出し部分)

main.js
// フォームのSignupボタン押されたら実行
form.addEventListener('submit', e => {
    e.preventDefault();

    // フォームの値取得
    const name = form.name.value;
    const email = form.email.value;
    const password = form.password.value;
    const confirmPassword = form.confirmPassword.value;

    // 空チェック
    if(!isValidRequiredInput(name, email, password, confirmPassword)){
        // 値が空でないかチェック
        alert('必須項目が未入力です。');
        return false
    }

    // ニックネームの文字数制限
    if(!isValidName(name)){
        alert('ニックネームは4文字以上で入力ください。')
        return false
    }

    // emailの形式チェック
    if(!isValidEmailFormat(email)){
        alert('メールアドレスの形式が不正です。もう1度お試しください。')
        return false
    }

    // パスワード一致チェック    
    if(!isValidPassword(password, confirmPassword)){
        alert('パスワードが一致しません。もう1度お試しください。')
        return false
    }
});

ポイント

空チェックの関数は引数を可変長引数にしています。
こうすることで今回の会員登録フォーム以外にも同じサイト内に、
ログインフォームやお問い合わせフォームなどがあり、
渡す引数の数が変わってもそのまま同じ関数を使うことができます。

わざわざフォームごとに関数定義しなくて済むので楽ですね。

main.js
// 空チェック関数
//  (...args) -> 可変長引数
const isValidRequiredInput = (...args) => {
  let validator = true;
  for (let i=0; i < args.length; i=(i+1)|0) {
      if (args[i] === "") {
          validator = false;
      }
  }
  return validator
};

//下記のように引数が変化しても呼び出し可能です。

// ログインの場合
if(!isValidRequiredInput(email, password)){
    // 値が空でないかチェック
    alert('必須項目が未入力です。');
    return false
}

// お問い合わせの場合
if(!isValidRequiredInput(name, email, description)){
    // 値が空でないかチェック
    alert('必須項目が未入力です。');
    return false
}

少しでも誰かの参考になれば幸いです。

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

ASP.Net Core(ver 2.1)でフォーム画面からのリクエストパラメータをPost通信する(第1回)

【実行環境】
PC:Windows10
開発環境:Visual Studio2019 Community
C#フレームワーク:ASP.Net Core (version 2.1)

ASP.Net Core(version 2.1)でのHTMLフォーム画面から登録内容を入力してPost通信してサーバ側に送る、という一連のサンプルコードがなかなか見当たらないので作ってみた。

この記事はシリーズ化する予定。
第1回は

タグを使わずにPost通信する方法を考えてみた。
入力画面と確認画面は以下の通り。

【★入力画面★】
登録フォーム入力画面.png

【★確認画面★】
登録フォーム確認画面.png

プロジェクトのディレクトリ構造は以下の通り。
【ディレクトリ構造】

Project
├ wwwroot
│ ├ js
│ │ └ TorokuForm.js
│ ├     
│ ├ css
│ ├     
│ ├ lib
│ │ ├ bootstrap
│ │ │ └ js
│ │ │   └ bootstrap.min.js
│ │ ├ jquery
│ │ │ └ jquery.min.js
│ │ └     
│ ├ pages
│ │ ├ TorokuForm.html
│ │ └ TorokuFormConfirm.html
│ └     
├ Controllers
│ └ TorokuFormController.cs
├     
└ Dto
  └ TorokuFormEntity.cs
TorokuForm.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>登録フォーム</title>
    <!--共通ライブラリ-->
    <link rel="stylesheet" href="" />
    <script type="text/javascript" src="../lib/bootstrap/js/bootstrap.min.js"></script>
    <script type="text/javascript" src="../lib/jquery/jquery.min.js"></script>
    <!--個別ファイル-->
    <script type="text/javascript" src="../js/TorokuForm.js"></script>
</head>
<body>
    <!--名前(姓)-->
    <div class="nameSeiArea">
        <div class="nameSeiLabelTitle">
            名前(姓)<br />First Name
        </div>
        <div class="nameSeiInputTitle">
            <input type="text" class="nameSeiInputClass" name="nameSei" id="nameSeiInputId" data-toggle="tooltip" title="名前(姓)を入力してください" required />
        </div>
    </div>
    <!--名前(名)-->
    <div class="nameMeiArea">
        <div class="nameMeiLabelTitle">
            名前(名)<br />Last Name
        </div>
        <div class="nameMeiInputTitle">
            <input type="text" class="nameMeiInputClass" name="nameMei" id="nameMeiInputId" data-toggle="tooltip" title="名前(名)を入力してください" required />
        </div>
    </div>
    <!--半角フリガナ(姓)-->
    <div class="hankakuHuriganaSeiArea">
        <div class="hankakuHuriganaSeiLabelTitle">
            半角フリガナ(姓)<br />Half-Width Kana(First Name)
        </div>
        <div class="hankakuHuriganaSeiInputTitle">
            <input type="text" class="hankakuHuriganaSeiInputClass" name="hankakuHuriganaSei" id="hankakuHuriganaSeiInputId" data-toggle="tooltip" title="半角フリガナ(姓)を入力してください" required />
        </div>
    </div>
    <!--半角フリガナ(名)-->
    <div class="hankakuHuriganaMeiArea">
        <div class="hankakuHuriganaMeiLabelTitle">
            半角フリガナ(名)<br />Half-Width Kana(Last Name)
        </div>
        <div class="hankakuHuriganaMeiInputTitle">
            <input type="text" class="hankakuHuriganaMeiInputClass" name="hankakuHuriganaMei" id="hankakuHuriganaMeiInputId" data-toggle="tooltip" title="半角フリガナ(姓)を入力してください" required />
        </div>
    </div>
    <!--性別-->
    <div class="sexArea">
        <div class="sexLabelTitle">
            性別<br />Sex
        </div>
        <div class="sexInputTitle">
            <label class="radioBtnClass" id="radioBtnClassId">
                <input type="radio" name="sex" class="sexClass" value="男性" id="sexMen" checked />男性 Men
                <input type="radio" name="sex" class="sexClass" value="女性" id="sexWomen" />女性 Women
            </label>
        </div>
    </div>
    <!--会社名または学校名-->
    <div class="comnanySchoolNameArea">
        <div class="comnanySchoolNameLabelTitle">
            会社名または学校名<br />Your Company or school Name
        </div>
        <div class="comnanySchoolNameInputTitle">
            <input type="text" class="comnanySchoolNameInputClass" name="comnanySchoolName" id="comnanySchoolNameInputId" data-toggle="tooltip" title="会社名または学校名を入力してください" required />
        </div>
    </div>
    <!--所属先名-->
    <div class="departmentNameArea">
        <div class="departmentNameLabelTitle">
            所属先名<br />Your Department Name
        </div>
        <div class="departmentNameInputTitle">
            <input type="text" class="departmentNameInputClass" name="departmentName" id="departmentNameInputId" data-toggle="tooltip" title="所属先名を入力してください" required />
        </div>
    </div>
    <!--メールアドレス-->
    <div class="emailAddressArea">
        <div class="emailAddressLabelTitle">
            メールアドレス<br />E-mail Address
        </div>
        <div class="emailAddressInputTitle">
            <input type="text" class="emailAddressInputClass" name="emailAddress" id="emailAddressInputId" data-toggle="tooltip" title="メールアドレスを入力してください" required />
        </div>
    </div>
    <!--郵便番号-->
    <div class="addressArea">
        <div class="addressLabelTitle">
            郵便番号<br />Post Address
        </div>
        <div class="addressInputTitle">
            <input type="text" class="addressInputClass" name="address" id="addressInputId" data-toggle="tooltip" title="郵便番号を入力してください" required />
        </div>
        <div class="addressBtnArea">
            <button class="btn btn-primary" id="addressBtnId">自動検索</button>
        </div>
    </div>
    <!--都道府県名-->
    <div class="prefectureArea">
        <div class="prefectureLabelTitle">
            都道府県名<br />Prefecture
        </div>
        <div class="prefectureInputTitle">
            <input type="text" class="prefectureInputClass" name="prefecture" id="prefectureInputId" />
        </div>
    </div>
    <!--市区町村名-->
    <div class="cityNameArea">
        <div class="cityNameLabelTitle">
            市区町村名<br />City Name
        </div>
        <div class="cityNameInputTitle">
            <input type="text" class="cityNameInputClass" name="cityName" id="cityNameInputId" />
        </div>
    </div>
    <!--町名・番地名-->
    <div class="chomeBanchiNameArea">
        <div class="chomeBanchiNameLabelTitle">
            町名・番地名<br />Chome Banchi Name
        </div>
        <div class="chomeBanchiNameInputTitle">
            <input type="text" class="chomeBanchiNameInputClass" name="chomeBanchiName" id="chomeBanchiNameInputId" />
        </div>
    </div>
    <!--電話番号-->
    <div class="phoneArea">
        <div class="phoneLabelTitle">
            電話番号<br />Phone
        </div>
        <div class="phoneInputTitle">
            <input type="text" class="phoneInputClass" name="phone" id="phoneInputId" data-toggle="tooltip" title="電話番号を入力してください" required />
        </div>
    </div>
    <!--FAX-->
    <div class="faxArea">
        <div class="faxLabelTitle">
            Fax番号<br />Fax
        </div>
        <div class="faxInputTitle">
            <input type="text" class="faxInputClass" name="fax" id="faxInputId" />
        </div>
    </div>
    <!--パスワード-->
    <div class="passwordArea">
        <div class="passwordLabelTitle">
            パスワード<br />Password
        </div>
        <div class="passwordInputTitle">
            <input type="password" class="passwordOneceInputClass" name="password" id="passwordOneceInputId" data-toggle="tooltip" title="パスワードを入力してください" required />
            <input type="password" class="passwordTwiceInputClass" name="password" id="passwordTwiceInputId" data-toggle="tooltip" title="パスワードをもう一度入力してください" required />
        </div>
    </div>
    <!--メルマガ購読-->
    <div class="mailMagagineArea">
        <div class="mailMagagineLabelTitle">
            メルマガ購読する<br />Mail Magagine Subscribe
        </div>
        <div class="mailMagagineInputTitle">
            <label class="mailMagagineLabel">
                <input type="checkbox" class="mailMagagineInputClass" name="mailMagagine" id="mailMagagineInputId" />購読する
            </label>
        </div>
    </div>

    <div class="contentsCheckBtnArea">
        <button class="btn contentsCheckBtnClass" id="contentsCheckBtnId">内容確認</button>
    </div>
</body>
</html>
TorokuFormConfirm.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>登録フォーム(確認画面)</title>
    <!--共通ライブラリ-->
    <link rel="stylesheet" href="../lib/bootstrap/css/bootstrap.min.css" />
    <script type="text/javascript" src="../lib/bootstrap/js/bootstrap.min.js"></script>
    <script type="text/javascript" src="../lib/jquery/jquery.min.js"></script>
    <!--個別ファイル-->
    <script type="text/javascript" src="../js/TorokuFormConfirm.js"></script>
</head>
<body>
    <!--名前(姓)-->
    <div class="nameSeiArea">
        <div class="nameSeiLabelTitle">
            名前(姓)<br />First Name
        </div>
        <div class="nameSeiInputTitle">
            <input type="text" class="nameSeiInputClass" name="nameSei" id="nameSeiInputId" data-toggle="tooltip" title="名前(姓)を入力してください" required />
        </div>
    </div>
    <!--名前(名)-->
    <div class="nameMeiArea">
        <div class="nameMeiLabelTitle">
            名前(名)<br />Last Name
        </div>
        <div class="nameMeiInputTitle">
            <input type="text" class="nameMeiInputClass" name="nameMei" id="nameMeiInputId" data-toggle="tooltip" title="名前(名)を入力してください" required />
        </div>
    </div>
    <!--半角フリガナ(姓)-->
    <div class="hankakuHuriganaSeiArea">
        <div class="hankakuHuriganaSeiLabelTitle">
            半角フリガナ(姓)<br />Half-Width Kana(First Name)
        </div>
        <div class="hankakuHuriganaSeiInputTitle">
            <input type="text" class="hankakuHuriganaSeiInputClass" name="hankakuHuriganaSei" id="hankakuHuriganaSeiInputId" data-toggle="tooltip" title="半角フリガナ(姓)を入力してください" required />
        </div>
    </div>
    <!--半角フリガナ(名)-->
    <div class="hankakuHuriganaMeiArea">
        <div class="hankakuHuriganaMeiLabelTitle">
            半角フリガナ(名)<br />Half-Width Kana(Last Name)
        </div>
        <div class="hankakuHuriganaMeiInputTitle">
            <input type="text" class="hankakuHuriganaMeiInputClass" name="hankakuHuriganaMei" id="hankakuHuriganaMeiInputId" data-toggle="tooltip" title="半角フリガナ(姓)を入力してください" required />
        </div>
    </div>
    <!--性別-->
    <div class="sexArea">
        <div class="sexLabelTitle">
            性別<br />Sex
        </div>
        <div class="sexInputTitle">
            <input type="text" class="sexClass" name="sex" id="sexId" />
        </div>
    </div>
    <!--会社名または学校名-->
    <div class="comnanySchoolNameArea">
        <div class="comnanySchoolNameLabelTitle">
            会社名または学校名<br />Your Company or school Name
        </div>
        <div class="comnanySchoolNameInputTitle">
            <input type="text" class="comnanySchoolNameInputClass" name="comnanySchoolName" id="comnanySchoolNameInputId" data-toggle="tooltip" title="会社名または学校名を入力してください" required />
        </div>
    </div>
    <!--所属先名-->
    <div class="departmentNameArea">
        <div class="departmentNameLabelTitle">
            所属先名<br />Your Department Name
        </div>
        <div class="departmentNameInputTitle">
            <input type="text" class="departmentNameInputClass" name="departmentName" id="departmentNameInputId" data-toggle="tooltip" title="所属先名を入力してください" required />
        </div>
    </div>
    <!--メールアドレス-->
    <div class="emailAddressArea">
        <div class="emailAddressLabelTitle">
            メールアドレス<br />E-mail Address
        </div>
        <div class="emailAddressInputTitle">
            <input type="text" class="emailAddressInputClass" name="emailAddress" id="emailAddressInputId" data-toggle="tooltip" title="メールアドレスを入力してください" required />
        </div>
    </div>
    <!--郵便番号-->
    <div class="addressArea">
        <div class="addressLabelTitle">
            郵便番号<br />Post Address
        </div>
        <div class="addressInputTitle">
            <input type="text" class="addressInputClass" name="address" id="addressInputId" data-toggle="tooltip" title="郵便番号を入力してください" required />
        </div>
        <div class="addressBtnArea">
            <button class="btn btn-primary" id="addressBtnId">自動検索</button>
        </div>
    </div>
    <!--都道府県名-->
    <div class="prefectureArea">
        <div class="prefectureLabelTitle">
            都道府県名<br />Prefecture
        </div>
        <div class="prefectureInputTitle">
            <input type="text" class="prefectureInputClass" name="prefecture" id="prefectureInputId" />
        </div>
    </div>
    <!--市区町村名-->
    <div class="cityNameArea">
        <div class="cityNameLabelTitle">
            市区町村名<br />City Name
        </div>
        <div class="cityNameInputTitle">
            <input type="text" class="cityNameInputClass" name="cityName" id="cityNameInputId" />
        </div>
    </div>
    <!--町名・番地名-->
    <div class="chomeBanchiNameArea">
        <div class="chomeBanchiNameLabelTitle">
            町名・番地名<br />Chome Banchi Name
        </div>
        <div class="chomeBanchiNameInputTitle">
            <input type="text" class="chomeBanchiNameInputClass" name="chomeBanchiName" id="chomeBanchiNameInputId" />
        </div>
    </div>
    <!--電話番号-->
    <div class="phoneArea">
        <div class="phoneLabelTitle">
            電話番号<br />Phone
        </div>
        <div class="phoneInputTitle">
            <input type="text" class="phoneInputClass" name="phone" id="phoneInputId" data-toggle="tooltip" title="電話番号を入力してください" required />
        </div>
    </div>
    <!--FAX-->
    <div class="faxArea">
        <div class="faxLabelTitle">
            Fax番号<br />Fax
        </div>
        <div class="faxInputTitle">
            <input type="text" class="faxInputClass" name="fax" id="faxInputId" />
        </div>
    </div>
    <!--パスワード-->
    <div class="passwordArea">
        <div class="passwordLabelTitle">
            パスワード<br />Password
        </div>
        <div class="passwordInputTitle">
            <input type="text" class="passwordOneceInputClass" name="password" id="passwordOneceInputId" data-toggle="tooltip" title="パスワードを入力してください" required />
        </div>
    </div>
    <!--メルマガ購読-->
    <div class="mailMagagineArea">
        <div class="mailMagagineLabelTitle">
            メルマガ購読する<br />Mail Magagine Subscribe
        </div>
        <div class="mailMagagineInputTitle">
            <input type="text" class="mailMagagineInputClass" name="mailMagagine" id="mailMagagineInputId" />購読する
        </div>
    </div>

    <div class="backToPrePage">
        <button class="btn btn-dark backToPrePageClass" id="backToPrePageId">前画面に戻る</button>
    </div>
    <div class="tourokuToDb">
        <button type="button" class="btn btn-primary tourokuToDbClass" id="tourokuToDbId">登録する</button>
    </div>
</body>
</html>
TorokuForm.js
$(document).ready(function () {

    //確認画面から戻ってきたときの処理
    var localData = localStorage.getItem('form_data'); //ローカルストレージから値を取得する。
    localData = JSON.parse(localData); //ローカルストレージから取得した値をオブジェクトデータに戻す。
    if (localData !== null) {
        for (var index in localData) {
            var data = localData[index];
            var formName = data['name'];
            var formVal = data['value'];
            //もう一度値をセットする。
            $('[name=' + formName + ']').val(formVal);
        }

    }

    //内容確認ボタンが押下された時の処理
    $("#contentsCheckBtnId").click(function () {
        var nameSei = $("#nameSeiInputId").val(); //名前(姓)
        var nameMei = $("#nameMeiInputId").val(); //名前(名)
        var hankakuHuriganaSei = $("#hankakuHuriganaSeiInputId").val(); //半角フリガナ(姓)
        var hankakuHuriganaMei = $("#hankakuHuriganaMeiInputId").val(); //半角フリガナ(姓)
        var sex = $("input[name='sex']:checked").val(); //性別 var sex = $("input[name='sex']:checked").parent().text();
        var comnanySchoolName = $("#comnanySchoolNameInputId").val(); //会社名または学校名
        var departmentName = $("#departmentNameInputId").val(); //所属先
        var emailAddress = $("#emailAddressInputId").val(); //E-mail
        var address = $("#addressInputId").val(); //郵便番号
        var prefecture = $("#prefectureInputId").val(); //都道府県名
        var cityName = $("#cityNameInputId").val(); //市区町村名
        var chomeBanchiName = $("#chomeBanchiNameInputId").val(); //町名・番地名
        var phone = $("#phoneInputId").val(); //電話番号
        var fax = $("#faxInputId").val(); //Fax番号
        var passwordTwice = $("#passwordTwiceInputId").val(); //パスワード
        var mailMagagine = ""; //メルマガの購読希望の判定
        if ($("#mailMagagineInputId").prop("checked") === true) {
            mailMagagine = "購読希望する";
        } else {
            mailMagagine = "購読希望しない";
        }

        var sendData = {
            nameSeiData: nameSei,
            nameMeiData: nameMei,
            hankakuHuriganaSeiData: hankakuHuriganaSei,
            hankakuHuriganaMeiData: hankakuHuriganaMei,
            sexData: sex,
            comnanySchoolNameData: comnanySchoolName,
            departmentNameData: departmentName,
            emailAddressData: emailAddress,
            addressData: address,
            prefectureData: prefecture,
            cityNameData: cityName,
            chomeBanchiNameData: chomeBanchiName,
            phoneData: phone,
            faxData: fax,
            passwordTwiceData: passwordTwice,
            mailMagagineData: mailMagagine
        };

        //フォームの内容をJSONデータで一括取得するコード
        var form = $(".tourokuForm"); // 値を保存しておきたいフォーム
        var formData = form.serializeArray(); //serializeArray()でフォームの内容をオブジェクト化
        //var formJson = JSON.stringify(formData); //JSON.stringifyメソッドでそのデータをJSON化させる
        var formJson = JSON.stringify(sendData); //JSON.stringifyメソッドでそのデータをJSON化させる
        localStorage.setItem('form_data', formJson); //Jsonデータをローカルストレージに保存する。

        location.href = "TorokuFormConfirm.html"; //TorokuFormConfirm.htmlへ遷移する。

    });
});
TorokuFormConfirm.js
//import { parseJSON } from "jquery";

$(document).ready(function () {

    var localData = localStorage.getItem('form_data'); //ローカルストレージから値を取り出す。
    localData = JSON.parse(localData); //ローカルデータから取得したJSONデータをオブジェクトへ戻す

    //ローカルストレージにデータがなければ null が返ってくるので、nullで分岐
    if (localData !== null) {
        $("#nameSeiInputId").val(localData.nameSeiData); // 
        $("#nameMeiInputId").val(localData.nameMeiData); //
        $("#hankakuHuriganaSeiInputId").val(localData.hankakuHuriganaSeiData); //
        $("#hankakuHuriganaMeiInputId").val(localData.hankakuHuriganaMeiData); //
        $("#sexId").val(localData.sexData);
        $("#comnanySchoolNameInputId").val(localData.comnanySchoolNameData); //会社名
        $("#departmentNameInputId").val(localData.departmentNameData); //所属先名
        $("#emailAddressInputId").val(localData.emailAddressData); //Emailアドレス
        $("#addressInputId").val(localData.addressData); //郵便番号
        $("#prefectureInputId").val(localData.prefectureData); //都道府県名
        $("#cityNameInputId").val(localData.cityNameData); //市町村名
        $("#chomeBanchiNameInputId").val(localData.chomeBanchiNameData); //町名・番地名
        $("#phoneInputId").val(localData.phoneData); //電話番号
        $("#faxInputId").val(localData.faxData); //Fax
        $("#passwordOneceInputId").val("お客様が入力したパスワード"); //パスワード
        var inputPassWord = localData.passwordTwiceData; //パスワードは確認画面で表示せず、変数inputPassWordに格納する。
        $("#mailMagagineInputId").val(localData.mailMagagineData); //メルマガ購読確認

        //一連の処理が終わったタイミングなどで、ローカルストレージの情報を削除する。
        localStorage.removeItem('form_data');
    }

    //前画面に戻る時の処理
    $("#backToPrePageId").click(function () {
        //前画面に戻るボタンを押下したときの処理
        backToPrePageSetLocalStorage();
    });

    //DBに登録するときの処理
    $("#tourokuToDbId").click(function () {
        torokuToDb();
    });
});

/*
//前画面に戻るボタンを押下したときの処理
function backToPrePageSetLocalStorage() {
    //フォームの内容をJSONデータで一括取得するコード
    var form = $(".tourokuForm");
    var formData = form.serializeArray();//serializeArray()でフォームの内容をオブジェクト化
    var formJson = JSON.stringify(formData); //JSON.stringifyメソッドでそのデータをJSON化させる

    localStorage.setItem('form_data', formJson); //ローカルストレージに保存する。
    location.href = "TorokuForm.html";
}
*/

//DBに登録する処理
function torokuToDb() {

    //formタグのパラメーターをそれぞれの変数に格納する。
    var nameSei = $("#nameSeiInputId").val(); //名前(姓)
    var nameMei = $("#nameMeiInputId").val(); //名前(名)
    var hankakuHuriganaSei = $("#hankakuHuriganaSeiInputId").val(); //半角フリガナ(姓)
    var hankakuHuriganaMei = $("#hankakuHuriganaMeiInputId").val(); //半角フリガナ(姓)
    var sex = $("#sexId").val(); //性別
    var comnanySchoolName = $("#comnanySchoolNameInputId").val(); //会社名または学校名
    var departmentName = $("#departmentNameInputId").val(); //所属先
    var emailAddress = $("#emailAddressInputId").val(); //E-mail
    var address = $("#addressInputId").val(); //郵便番号
    var prefecture = $("#prefectureInputId").val(); //都道府県名
    var cityName = $("#cityNameInputId").val(); //市区町村名
    var chomeBanchiName = $("#chomeBanchiNameInputId").val(); //町名・番地名
    var phone = $("#phoneInputId").val(); //電話番号
    var fax = $("#faxInputId").val(); //Fax番号
    var passwordTwice = $("#passwordTwiceInputId").val(); //パスワード
    var mailMagagine = $("#mailMagagineInputId").val(); //メルマガの購読希望の判定

    var torokuFormEntity = {
        nameSeiData: nameSei,
        nameMeiData: nameMei,
        hankakuHuriganaSeiData: hankakuHuriganaSei,
        hankakuHuriganaMeiData: hankakuHuriganaMei,
        sexData: sex,
        comnanySchoolNameData: comnanySchoolName,
        departmentNameData: departmentName,
        emailAddressData: emailAddress,
        addressData: address,
        prefectureData: prefecture,
        cityNameData: cityName,
        chomeBanchiNameData: chomeBanchiName,
        phoneData: phone,
        faxData: fax,
        passwordTwiceData: passwordTwice,
        mailMagagineData: mailMagagine
    };

    var m = JSON.stringify(torokuFormEntity);
    console.log(m);

    //POST通信を実行する。
    $.ajax({
        url: "../api/TorokuForm/postTorokuData",
        type: "POST",
        data: JSON.stringify(torokuFormEntity), // m
        contentType: "application/json; charset=utf-8",
        success: function (data) {
            alert("OK通信");
        },
        error: function (xhr) {
            alert(xhr.status);
        }
    });

}
TorokuFormController.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using WebApplication8.Dto;
using WebApplication8.Setting;


namespace WebApplication8.Controllers
{
    [Route("api/TorokuForm")] //[Route("api/[controller]")]
    [ApiController]
    public class TorokuFormController : ControllerBase
    {

        /// <summary>
        /// 設定記述オブジェクト
        /// </summary>
        private readonly IOptions<DBSetting> options = null;

        /// <summary>
        /// インスタンス生成時に設定記述オブジェクトの取り込みを行う。
        /// </summary>
        public TorokuFormController(IOptions<DBSetting> options)
        {
            //設定記述子
            this.options = options;
        }

        [HttpPost("postTorokuData")]
        public void postTorokuData([FromBody]TorokuFormEntity torokuFormEntity)
        { 
            //処理を書く。
        }
    }
}
TorokuFormEntity.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace WebApplication8.Dto
{
    public class TorokuFormEntity
    {
        public string nameSeiData { get; set; }
        public string nameMeiData { get; set; }
        public string hankakuHuriganaSeiData { get; set; }
        public string hankakuHuriganaMeiData { get; set; }
        public string sexData { get; set; }
        public string comnanySchoolNameData { get; set; }
        public string departmentNameData { get; set; }
        public string emailAddressData { get; set; }
        public string addressData { get; set; }
        public string prefectureData { get; set; }
        public string cityNameData { get; set; }
        public string chomeBanchiNameData { get; set; }
        public string phoneData { get; set; }
        public string faxData { get; set; }
        public string passwordTwiceData { get; set; }
        public string mailMagagineData { get; set; }
    }
}

確認画面にある登録ボタンを送信すると、無事Controllerクラス「TorokuFormController.cs」にリクエストパラメータが渡されている。

【結果】
サーバ通信後のコード.png

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

Googleスプレッドシートのデータをシェルスクリプトで読み込んで整形し、JSファイルとして書き出す。

ときどき更新されるスライドショーのようなもののために、静的にJavaScriptファイルをたくさん作る必要がありました。いろいろと試行錯誤した結果、Googleスプレッドシートでデータを管理して、シェルスクリプトで読み込んで処理するというものになりました。シェルスクリプトはよく知らないので、もっといい方法がある気がします。ひとまずは事足りたのでこちらにメモしておきます。

Googleスプレッドシートの内容

日付 画像1 画像2 画像3
2020/10/01 A B C
2020/10/02
2020/10/03 A C F
2020/10/04 B D C
2020/10/05

日付と画像ファイル名が指定されている。わけあって画像名に.jpgなどの拡張子はつけられない。ブランクのところは更新が必要ない。

出力されるJSファイルの内容

各行ごとに以下のようなファイルが生成される。上記のスプレッドシートの場合は日付のみの行を除いた3行分、3ファイルを生成されます。
JavaScriptのファイル名は日付を整形した1001.jsのような名前にします。各列は画像名として、images/A.jpgのようにします。
ここでは、配列をグローバル変数に入れているだけのJavaScriptですが、単純なテキストとして出力するだけなので、ある程度どんな形のものでもできるだろうと思います。
静的なファイルが必要ないならば、GASでスプレッドシートを整形するプログラムを書いて、「Webアプリケーションとして導入」したほうがいいと思います。

//1001.js
window.example = [
    "images/A.jpg",
    "images/B.jpg",
    "images/C.jpg"
];

直接スプレッドシートをさわれない時

自分管理のスプレッドシートであれば特に必要ないですが、そうでないときは新しいスプレッドシートを作って、importrangeでデータを取り込みます。コピーしてもいいけど、元のスプレッドシートが更新される場合にはこのほうがいいと思います。
適宜filterなどを使ってデータを絞り込んだりします。今回はB列が空の場合は読み込まないようにしているのと、見出し行はいらないので2行目以降を読み込むようにしています。

=filter(
    importrange(
        "https://docs.google.com/spreadsheets/d/スプレッドシートのID/edit",
        "シート名!A2:D"
    ),
    not(
        isblank(
            importrange(
                "https://docs.google.com/spreadsheets/d/スプレッドシートのID/edit",
                "シート名!B2:B"
            )
        )
    )
)

一番左上のセルにこの数式を入れるだけです。スプレッドシートのIDとシート名は読み替えてください(以下同)。

csvとして公開する

Googleスプレッドシートのメニューから、
ファイル > ウェブに公開 > カンマ区切り形式(.csv)
でcsvとして公開できます。
あたりまえですが、公開されてしまうので、機密情報的なものは扱えません。

公開URLをメモしておく。
https://docs.google.com/spreadsheets/d/e/.../pub?output=csv
...は省略部分(以下同)。

シェルスクリプトを書く

このcsvをシェルスクリプトで読み込んで処理します。今回の流れとしては、以下のようになります。
1. curlでcsvを読み込んでファイルに出力
2. while readで一行ずつ処理
3. 各行をカンマ区切りにして、一列ずつ取り出す
4. sedで日付のフォーマットを変える
5. 処理内容をjsファイルとして出力する

正直、シェルスクリプトは書きなれないので、もっとスマートにできると思いますが、ご容赦ください。

#!/bin/bash
status=`curl -w "%{http_code}" -o example.csv -L https://docs.google.com/.../pub?output=csv`

if test $status -eq 200; then
  while read row; do
    row=`echo ${row} | tr -d '\r\n'`
    columns=(${row//,/ })
    columns[0]=`echo ${columns[0]} | sed -E 's/[0-9]{4}\/([0-9]{1,2})\/([0-9]{1,2})/\1\2-/'`
    echo -e "window.example = [\\n  \"images/${columns[1]}.jpg\",\\n  \"images/${columns[2]}.jpg\",\\n  \"images/${columns[3]}.jpg\"\\n];" > ${columns[0]}.js
  done < example.csv
fi

シェルスクリプトの解説

curlでcsvファイルを取得

上記のURLはリダイレクトされるので、-Lオプションをつけてリダイレクトを有効にします。つけないとリダイレクト前のページを取得してしまってうまくいきません。
また、今回は、レスポンスが 200 (成功)のときだけ処理するようにしたいので、 -w オプションでステータスコードを取得します。
-wオプションを付けると%{variable_name}の形式で指定したいろいろな値を抽出できます。ステータスコードの場合は%{http_code}と指定します。
-o オプションで内容をファイルに書き出します。

$ curl -w "%{http_code}" -o example.csv -L https://docs.google.com/.../pub?output=csv

ステータスコードは変数に代入します。curlの実行結果を代入することになるので、右辺は`(バッククォート)か$()で囲みます。

status=`curl -w "%{http_code}" -o example.csv -L https://docs.google.com/.../pub?output=csv`

ステータスコードのエラー処理

エラー処理というか、ステータスコードが 200 (成功)の場合だけ処理します。
test コマンドで変数 status を評価。

if test $status -eq 200; then
...
fi

略式の書き方だと

if [ $status -eq 200 ]; then
...
fi

となります。

csvを一行ずつ処理する

while read にリダイレクト(<)でファイルを読み込んで一行ずつ処理する。
done < example.csvで先程出力したcsvファイルを読み込む。
row に各行が入ります。

  while read row; do
  ...
  done < example.csv

(参考)ステータスコードもファイル出力もいらない場合

ちなみに、curlでwオプションをつけない、かつoオプションでファイル出力もしない場合、以下のようにすればいい。

csv=`curl -L https://docs.google.com/.../pub?output=csv`
while read row; do
...
done < <("$csv")

<(list)でコマンドの実行結果や変数展開をファイルのように扱うことができるようです。詳しくは、bashのマニュアルなどでProcess Substitutionを参照ください。
変数csvは改行を含んでいるので、"$csv"とダブルクォートで囲って展開しないとうまくいかない。
<(curl -L https://docs.google.com/.../pub?output=csv)のようにcurlコマンドを直接指定しても動作します。

各行を「,」区切りで列ごとに分けてリストにする

row=`echo ${row} | tr -d '\r\n'`
columns=(${row//,/ })

シェルスクリプトの配列(リスト)は、var=(val1 val2 val3)のように記述します。
${parameter/pattern/string}というパラメータ展開の式を使って、カンマ区切りのリストをスペース区切りに変換します。pattern/で始まる場合は、parameterを展開した値の、 patternにマッチするすべての部分がstringに置換されます。今回は${row//,/ }なので、変数rowを展開した値(たとえば、2020/10/01,A,B,Cという文字列)の、,(カンマ)がすべて(スペース)に置換されます。
行末に改行が残ってしまうので、tr -d '\r\n'で削除しています。単純にecho -n ${row}でもいけるかなと思って、row=`echo -n ${row}`としたものの、うまくいきませんでした。たぶん、\r(CR)が残ってしまうのかと想像します。

(参考)各列に個別の処理が必要な場合など

冗長だけど、例のスプレッドシートのように一行あたりの列が少ないならば、以下のように一列ずつ cut コマンドで、cut -d , -f 1 のように、1列分ずつとりだしてもいいと思う。

column1=`echo ${row} | cut -d , -f 1`
column2=`echo ${row} | cut -d , -f 2`
column3=`echo ${row} | cut -d , -f 3`
column4=`echo ${row} | cut -d , -f 4`

各列に別の処理が必要な場合はこのようにすればいいと思う。
column1=echo ${row} | cut -d , -f 1 | hogehoge...
column2=echo ${row} | cut -d , -f 1 | fugafuga...

しかし、そんな場合はループで処理するほうがいいかもしれません。その場合は以下のようになるのかなと思います。適宜if文などを駆使して、各列にいろいろな処理を書くようにすればいいのかなと思います。今回は日付部分だけsedで整形するだけなので、こういう処理はやってません。

i=0
for col in ${columns[@]}; do
    columns[$i]=`echo ${col} | hogehoge...`
    let i++
done

for..inループの中でリストの項目を一つずつ参照するときに、let i++の値を加算して、配列のインデックスとして利用しているが、ここはもう少しいい方法があるんじゃないかと思って探したけれど、どうも見つけられなかった。ちなみにcol=`echo ${col} | ...`のようにしてもうまくいかない。

日付部分のフォーマット変更

sed コマンドで 2020/10/01 のような書式の日付を1001のように変更する。しかし、このくらいであれば、Googleスプレッドシートで書式を変更したほうがいいと思う。

columns[0]=`echo ${columns[0]} | sed -E 's/[0-9]{4}\/([0-9]{1,2})\/([0-9]{1,2})/\1\2/'`

環境によってはEオプションじゃなくてrかもしれない。

JavaScriptファイルの出力

echoとリダイレクと(>)でJavaScriptファイルをつくります。-eオプションを付けるとエスケープが有効になります。
echo -e "JSファイルの内容" > ${columns[0]}.jsという文になります。

echo -e "window.example = [\\n  \"images/${columns[1]}.jpg\",\\n  \"images/${columns[2]}.jpg\",\\n  \"images/${columns[3]}.jpg\"\\n];" > ${columns[0]}.js

参考

Bash Reference Manual
curl
curl コマンド 使い方メモ - Qiita
sedでこういう時はどう書く? - Qiita
標準出力をファイルのように扱う方法、例えば2つのコマンドの出力結果のdiffを取るとか - Qiita

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

form_withのlocal: trueオプションがいらないパターン

コメント機能実装時コメントを非同期で更新したい時にActionCableを使った。
コメントを投稿するとjsファイルは動いているが画面の読み込みが起きていた。おまけにコメントの送信ボタンを押すと、ボタンの要素にdisabled属性が付与されたりした。原因はform_withのlocal: trueオプションだったようだ。非同期を実現するにはlocal: trueオプションは不要のようだ。

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

【jquery,js】エンコード、デコードを実装する [encodeURI,decodeURI,try] [js12_20210308]

処理の概要

テキストエリアに表示されたコードを、エンコード・デコードボタンを押下した時、エンコーディング、デコーディングを行う。

処理のフロー:

 (1)ページ読み込み時にinit()メソッドにより、テキストエリアに文字列が出力される
 (2)デコード、エンコードボタン押下時にURIメソッドにてコーディングされる
 (3)デコードの場合は、エラーによりプログラムが異常終了するので、tryでcatchをしてあげる

画面イメージ

画像1

画像2

画像3

ソースコード

index.html
<body onload="init()">
    <textarea id="beforeEncodeText"></textarea><br>
    <input type="button" id="encodeButton" value="エンコード">
    <input type="button" id="decodeButton" value="デコード"><br>
    <textarea id="afterEncodeText"></textarea>
</body>

リファクタリングする前

main.js
function init(){
    var lobjText = ""
    var defaultStr = [
        ["https://www.google.co.jp/#q=%E6%97%A5%E6%9C%AC%E8%AA%9E"],
        ["--------------------"],
        ["https://www.google.co.jp/#q=日本語"]
    ]

    $.each(defaultStr,function(){
        lobjText = lobjText + this + "\n";
    });
    $("#beforeEncodeText").val(lobjText)
};

$(function(){
    $("#encodeButton").click(function(){
        var encodeText = $("#beforeEncodeText").val()
        encodeText = encodeURI(encodeText);
        $("#afterEncodeText").val(encodeText);
    }); 

    $("#decodeButton").click(function(){
        var dencodeText = $("#beforeEncodeText").val()

        try {
            dencodeText = decodeURI(dencodeText);
        } catch(e) {
            console.log("err : " + e)
        }

        $("#afterEncodeText").val(dencodeText);
    }); 
});

ポイント

html:
(1)なし
js:
(1)URIはhttpやftpにてパスを指定する際に使用する。パスには日本語が指定できないため、%と16進数で表記される。
(2)URL内にクエリなどで日本語を指定したい場合はエンコードメソッドを使用する
(3)デコードはエンコードの逆で元の文字列に置き換えます。
(4)デコードは失敗するとエラーが出るため、trycatchを使用する。引数のeにはエラーの内容が入っている(例えば%FFはデコード出来ない)

参考資料

JavaScript(仕事の現場でサッと使える!デザイン教科書) p97

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

vimeo API で発火したイベントを確認したい

初めに

vimeo API で発火(登録)したイベントを確認したくて、簡単に表示できる方法を探した。

video.jsの場合

videoJSonメソッドは配列を渡せるらしく、下記のような記述が可能だそうだ。

video.js
video.on(['loadstart', 'loadedmetadata', 'loadeddata', 'play', 'playing', 'pause', 'suspend', 'seeking', 'seeked', 'waiting', 'canplay', 'canplaythrough', 'ratechange', 'ended', 'emptied', 'error', 'abort'], (e) => {
    console.log(`EVENT: ${e.type}`);
});

[Video.js] JavaScriptで動画を再生する

Parameters

name Type Required Description
first String or Component yes The event type or other component
second function or String yes The event handler or event type
third function yes The event handler

vimeoの場合

vimeoのonメソッドは…

on(event: string, callback: function): void

残念、文字列型しか渡せないようです。

実装

イベントはcuechangeを除きHTML5のvideoで使われるものと同じそうなので、該当のイベントを配列にして出力することで解決した。

//You can listen for events in the player by attaching a callback using .on():

player.on('eventName', function(data) {
    // data is an object containing properties specific to that event
});
//The events are equivalent to the HTML5 video events (except for cuechange, which is slightly different).

//To remove a listener, call .off() with the callback function:
vimeo.js
var items = [ 'audioprocess', 'canplay', 'canplaythrough', 'complete', 'durationchange', 'emptied', 'ended', 'loadeddata', 'loadedmetadata',
 'pause', 'play', 'playing', 'progress', 'seeked', 'ratechange', 'seeking', 'stalled', 'suspend', 'timeupdate', 'volumechange', 'waiting' ];

items.forEach(function(value) {
  player.on(value, function() {
    console.log(value);
  });
});

引用

[Video.js] JavaScriptで動画を再生する

MDN Web Docs 動画埋め込み要素

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

Firestoreの公式ドキュメントに載っているコードをasync/awaitで書き直してみる

はじめに

Firebaseのデータストアの1つであるFirestoreの公式ドキュメントのサンプルコードが全てPromiseチェーンになっているので、一部のコードをasync/awaitで書き直してみます。

Promiseチェーンとは

hoge.js
var docRef = db.collection("cities").doc("SF");

docRef.get().then((doc) => {
    if (doc.exists) {
        console.log("Document data:", doc.data());
    } else {
        // doc.data() will be undefined in this case
        console.log("No such document!");
    }
}).catch((error) => {
    console.log("Error getting document:", error);
});

上記のような then で非同期処理の結果を受け取る方法をPromiseチェーンと呼びます(正式名称かは不明)。

この記述を行うと、連続して非同期処理を行った時にコードの可読性が低下する要因となります。
例えば、非同期処理が3連続すると以下のようなコードになります。

fuga.js
var docRef = db.collection("cities").doc("SF");
var docRef2 = db.collection("cities").doc("LA");
var docRef3 = db.collection("cities").doc("DC");

docRef.get().then((doc) => {
  if (doc.exists) {
    console.log("Document data:", doc.data());
  } else {
    // doc.data() will be undefined in this case
    console.log("No such document!");

    // データがなかった場合、docRef2のデータを取得する
    docRef2.get().then((doc) => {
      if (doc.exists) {
        console.log("Document data:", doc.data());
      } else {
        // doc.data() will be undefined in this case
        console.log("No such document!");

        // データがなかった場合、docRef3のデータを取得する
        docRef3.get().then((doc) => {
          if (doc.exists) {
            console.log("Document data:", doc.data());
          } else {
            // doc.data() will be undefined in this case
            console.log("No such document!");
          }
        }).catch((error) => {
          console.log("Error getting document:", error);
        })
      }
    }).catch((error) => {
      console.log("Error getting document:", error);
    });
  }
}).catch((error) => {
    console.log("Error getting document:", error);
});

このように連続したPromiseチェーンはコードのネストが深くなりやすく、可読性が低くなります。

ドキュメントを取得する

公式ドキュメントのコード

test.firebase.js
var docRef = db.collection("cities").doc("SF");

docRef.get().then((doc) => {
  if (doc.exists) {
    console.log("Document data:", doc.data());
  } else {
    // doc.data() will be undefined in this case
    console.log("No such document!");
  }
}).catch((error) => {
  console.log("Error getting document:", error);
});

async/awaitを使用したコード

refactoring.firebase.js
var docRef = db.collection("cities").doc("SF");

const getDoc = async() => {
  try {
    const doc = await docRef.get();
    if (doc.exists) {
      console.log("Document data:", doc.data());
    } else {
      // doc.data() will be undefined in this case
      console.log("No such document!");
    }
  } catch(error) {
    console.log("Error getting document:", error);
  }
}

// この処理は非同期で実行される
getDoc();

3連続した非同期処理をasync/awaitに書き換える

refactoring.firebase.js
var docRef = db.collection("cities").doc("SF");
var docRef2 = db.collection("cities").doc("LA");
var docRef3 = db.collection("cities").doc("DC");

const getDoc = async() => {
  try {
    const doc = await docRef.get();
    if (doc.exists) {
      console.log("Document data:", doc.data());
      return;
    }
    // doc.data() will be undefined in this case
    console.log("No such document!");

    // データがなかった場合、docRef2のデータを取得する
    const doc2 = await docRef2.get();
    if (doc.exists) {
      console.log("Document data:", doc2.data());
      return;
    }
    // doc.data() will be undefined in this case
    console.log("No such document!");

    // データがなかった場合、docRef3のデータを取得する
    const doc3 = await docRef3.get();
    if (doc.exists) {
      console.log("Document data:", doc3.data());
      return;
    }
    // doc.data() will be undefined in this case
    console.log("No such document!");
  } catch(error) {
    console.log("Error getting document:", error);
  }
}

// この処理は非同期で実行される
getDoc();

Promiseチェーンが1つの場合は、ネストがあまり深くならないのでasync/awaitの恩恵をあまり感じませんが、複数の処理を実行する時は、ネストが深くなるのを抑えてくれます。

コレクションから複数のドキュメントを取得する

公式ドキュメントのコード

test.firebase.js
db.collection("cities").where("capital", "==", true)
  .get()
  .then((querySnapshot) => {
    querySnapshot.forEach((doc) => {
      // doc.data() is never undefined for query doc snapshots
      console.log(doc.id, " => ", doc.data());
    });
  })
  .catch((error) => {
    console.log("Error getting documents: ", error);
  });

async/awaitを使用したコード

refactoring.firebase.js
const getDocs = async() => {
  try {
    const querySnapshot = await db.collection("cities").where("capital", "==", true);
    querySnapshot.forEach((doc) => {
      // doc.data() is never undefined for query doc snapshots
      console.log(doc.id, " => ", doc.data());
    })
  } catch(error) {
    console.log("Error getting documents: ", error);
  }
}

// この処理は非同期で実行される
getDocs();

まとめ

Firebaseのコードは全てPromiseチェーンで記述されているため、何も考えずにコードを書いていくと、ネストが深くなりがちです。
async/awaitを使用して、ネストが少ないコードを心がけましょう!

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

【Javascript】Leafletでポップアップを全て開く or 全て閉じる

概要

LeafletでMap上に表示する複数のポップアップを全て表示したり、任意のタイミングで全て閉じる方法で詰まったのでメモ。

全て開く

方法は簡単でbindPopupする時に、openPopup()と付け加えるだけ。
注意点としてはポップアップはデフォルトでは一つずつしかマップで開けないようになっているため
autoCloseというオプションをfalseに設定しておく必要がある。

// マーカーの見た目を定義
var divIcon = L.divIcon({
  html: '<div>マーカー</div>',
  className: 'marker', 
  iconSize: [100, 20]
});
// マップに追加する
marker = L.marker([35.6896, 139.6918], {icon:divIcon }).addTo(map);
// バインド時にautoCloseをfalseに設定
marker.bindPopup(popuphtml,{autoClose:false}).openPopup();

上記をMapに追加する時に設定してやれば、例えばページを開いた時に全てのポップアップを表示するという動作も可能。

全て閉じる

開いたpopupを閉じる手段の一つとしてはclosePopup()がある。

// マーカーを閉じる
marker.closePopup();

ただ、この方法ではautoClose:falseに設定した複数のpopupを一斉に閉じる事ができず、「最後に開いたpopupを一つだけ閉じる」という動作が行われる。

この問題を解決するなら、Layerに閉じたいマーカーを入れて、eachLayerを使って回すという方法が手っ取り早い。

// マップに追加するマーカーをまとめるレイヤーを作成
markertGroup = new L.layerGroup();

// マーカーの定義を省略(=marker)
// マップ追加時にレイヤーに登録する
markertGroup.addLayer(marker).addTo(map);
// 表示済みのレイヤーを削除する
markertGroup.eachLayer(function (layer) {
    markertGroup.removeLayer(layer);
});

上記のようにeachLayerでremoveLayerを回してやれば、簡単にmap上の複数の要素を削除する事ができる。
内部にif文を加えて捕捉の条件を付け加えれば「特定の値を持つ要素のみの削除」なども可能

marker.id= 1;
const number = 1;
markertGroup.eachLayer(function (layer) {
    // marker.idの値が1のものを削除する
    if (layer.id == number) {
       markertGroup.removeLayer(layer);
    }
});

参考

レイヤーがポリゴンであるLeaflet LayerGroupで特定のレイヤーを見つける

Documentation - Leaflet

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

axiosでREST APIの通信を行う時にSafariではエラーが起きたときの対処法

皆さんこんにちは!

Windowsで開発している僕にとってはSafari特有のエラー程苦しむものは無いです。

ただ、この時代スマートフォンの使用率が圧倒的に高い10代、20代のほとんどはiPhoneを使っていると思います。

僕の周りでもiPhoneの使用率が圧倒的に高いです。

なので、iPhoneのエラーも無視するわけにはいかない。。。

今日はそんなこんなでaxiosでLaravelで作成したバックエンドとの通信でWindows環境で開発しているので、エラー内容は確認できなかったのですが、おそらくCORSエラーが原因でAPI通信を行うことができませんでした。

結論から言いますと、SafariではChromeのように上手くキャッシュの処理を行うことができないそうです。

なので、このキャッシュの問題を解決してあげることでSafariでもAPI通信を行うことができます!

それでは、説明を見ていきましょう!

はじめに

今回は、Vueでの説明となります。

もし、Reactなどを使っている場合は適時変更して下さい。

また、バックエンドではLaravelでCORSドメインの設定などを行います。

もし、Laravel以外でAPIを作成している場合も適時変更してください。

axiosでCookie設定

axiosのデフォルトでは、withCredentialsというCookieを使えるようにするためのプロパティの設定がOFFになっています。

なので、これをONにしてあげましょう!

main.js
// axiosのプロトタイプ宣言
Vue.prototype.$axios = axios
// Cookieの使用を可とする
axios.defaults.withCredentials = true

これでaxiosがデフォルトでCookieを使用することができます。

CORSドメイン設定

次に、axiosでCookieの情報を受け取るためにバックエンドで適切なヘッダーを追加してあげましょう!

> php artisan make::middleware ApiCors
Http/Middleware/ApiCors.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class ApiCors
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        // すべてのレスポンスに CORS 用のヘッダーを追加する必要はないので URL から判断する
        $paths = explode('/', $request->getPathInfo());
        if ($paths[1] === 'api') {
            return $next($request)
            ->header('Access-Control-Allow-Origin', config('cors.allowed_origins'))
            ->header('Cache-Control', 'no-cache max-age=3600')
            ->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
            ->header('Access-Control-Allow-Credentials', 'true')
            ->header('Access-Control-Allow-Headers', 'X-XSRF-TOKEN, Authorization, content-type, Transfer-Encoding, Accept, Accept-Encoding, Accept-Language');
        }
        return $next($request);
    }
}

$paths = explode('/', $request->getPathInfo());if ($paths[1] === 'api')/api/apis``のようなAPI通信を行う時だけこのミドルウェアを適用するようにしています。

Access-Control-Allow-Origin

Access-Control-Allow-OriginではCORSドメインを許可するドメインを登録しています。

例えば、http://localhost:3000からのリクエストを許可したい場合はここにhttp://localhost:3000と入力してください。

これを*のようにすると全てのドメインからリクエストが可能なので、データ抜き放題となるので注意して下さい。

Access-Control-Allow-Methods

Access-Control-Allow-Methodsでは、使用できるメソッドを書きます。ほとんどの場合は上記のようにしてOKです。

Access-Control-Allow-Credentials

Access-Control-Allow-Credentialstrueにすることで、先ほどaxiosで設定したCookie情報のリクエストを受け取れるようにしています。

Access-Control-Allow-Headers

最後に、Access-Control-Allow-Headersを説明します。

これが重要です!

色々あるのですが2つだけ説明します。

X-XSRF-TOKENはaxiosで発行したトークン情報です。PHPで言うと、formCSRFのようなものです。これを受け取るためにこのヘッダーを追加します。

次に、content-typeです。

これは、GETやHEADの場合は必要ないのですが、以下のようなリクエストを行う場合content-typeapplicatio/jsonとなります。

this.$axios
        .post(process.env.VUE_APP_LARAVEL_SITE_URL + '/api/participants', {
          name: 'akki'
        })

そして、content-typeのデフォルトではapplication/jsonは対応していません。

なので、このようなapplication/jsonの形を受け取るのを許可するためにcontent-typeを追加します。

ちなみに場合によっては、content-typeでエラーが出ます。その時はContent-Typeと設定して見て下さい。

ミドルウェアの適用

最後に作成したこのミドルウェアを適用させましょう!

App/Http/Middleware/Karnel.phpを開いてください。

Karnel.php
<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    /**
     * The application's global HTTP middleware stack.
     *
     * These middleware are run during every request to your application.
     *
     * @var array
     */
    protected $middleware = [
        // \App\Http\Middleware\TrustHosts::class,
        // CORSドメインの設定を追加
        \App\Http\Middleware\ApiCors::class,
        \App\Http\Middleware\TrustProxies::class,
        \Fruitcake\Cors\HandleCors::class,
        \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
    ];
    // 省略
}

必ず$middlewareに追加してください!!

なぜかと言うと、$middlewareに追加しないとOPTIONSリクエストに今作成したミドルウェアを適用することができません!

絶対に$middlewareに追加!!!

いかがだったでしょうか?

このエラーで3日程時間を費やされたので、本当に苦労しました。

ぜひ皆さんのお力になれたらなと思います。

以上、「axiosでREST APIの通信を行う時にSafariではエラーが起きたときの対処法」でした!

また、何か間違っていることがあればご指摘頂けると幸いです。

他にも初心者さん向けに記事を投稿しているので、時間があれば他の記事も見て下さい!!

あと、最近「ココナラ」で環境構築のお手伝いをするサービスを始めました。

気になる方はぜひ一度ご相談ください!

Thank you for reading

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

送信ボタンのdisabledを取り除く

機能

アプリにitem(商品)に関連するコメントを投稿する機能を実装時
非同期で即時更新されてコメントするボタンを押すとコメントが表示される。

問題点

コメントするボタンを押すとボタンが機能しなくなった。
一回送信すると画面のリロードをしないとボタンが使えない状態になる

原因

コメントするボタンを押すとボタンの要素の中にdisabledという属性が増えている。
これは、要素の機能を停止させている原因だと言うことが判明した。

解決方法

シンプルにコメントする処理の最後にボタンの要素を取得後removeAttributeによって要素内からdisabledを取り除くことで解決した。

この解決にたどり着くまでにclickイベント発火時にdisabledを取り除こうとしていたがclickした頃にはボタンが使えない状態になるようで処理が実行されなかった。

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

ウェブサイト作成用備忘録・10号:二重発火防止メモ【コピペでプレビュー】

突然ですが…

1・ある要素をクリックすると、javascript(jquery)でクリックした要素にClassが追加され、CSSで設定した全体で2秒間のアニメーションが実行される。

2・同じ要素をもう一度クリックすると、最初のアニメーションが逆再生されて元の状態に戻る。(以降ループ…)

というコードを作成したとします。

仮に以下の様な内容の場合

<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,user-scalable=yes,initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<style>

  body {
    margin: 0;
    width: 100vw;
    height: 100vh;
  }

  #click {
    cursor: pointer;
    background-color: #000;
    transition: all 2s;
  }

  #click.on {
    background-color: #fff;
  }

</style>
</head>
<body id="click" class=""></body>
<script type="text/javascript">

  jQuery(document).ready(function(){

    $("#click").click(function() {
      $("#click").toggleClass("on");
    });

  });
</script>
</html>

上記のコードは画面をクリックする事で、2秒間掛けて背景色が変化するのですが…(コピペで確認可能です)

・javascriptが発火してから処理が完了するまでの時間は長く見積もっても0.05秒
・要素にClassが追加され、CSSアニメーションが完了するまでに2秒

javascriptとCSSの動作は独立している為、javascriptが発火してからアニメーションが完了するまであと1.95秒残っているのに、javascriptの方は次の入力を受け付けてしまう為、ダブルクリックしてしまうとアニメーションが完了する前に次のアニメーションが開始され、現在のアニメーションが中断されてしまいます。

この課題を解決する方法を自分なりに考えた結果…

対策
⇒クリック制限クラスとsetTimeoutと組み合わせる事で、CSSアニメーションが完了するタイミングまでjavascriptの処理を遅延させる

対策前
$("#click").click(function() {←クリックで発火
$("#click").toggleClass("on");←アニメーションクラスの切り替え
});
対策後
<style>
.lock {
  cursor: none;←カーソルの表示を変更
  pointer-events: none;←要素のクリックイベントを無効化
}
</style>
$("#click").click(function() {←クリックで発火
$("#click").addClass("lock");←クリック制限クラスを追加
$("#click").toggleClass("on");←アニメーションクラスの切り替え(CSSアニメーション終了まで2秒)
setTimeout(function(){ 
$("#click").removeClass("lock");
},2000);←CSSアニメーション終了(2秒)後にクリック制限クラスを解除
});

対策版はコチラ ↓

<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,user-scalable=yes,initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<style>

  body {
    margin: 0;
    width: 100vw;
    height: 100vh;
  }

  #click {
    cursor: pointer;
    background-color: #000;
    transition: all 2s;
  }

  #click.on {
    background-color: #fff;
  }

  .lock {
    cursor: none;
    pointer-events: none;
  }

</style>
</head>
<body id="click" class=""></body>
<script type="text/javascript">

  jQuery(document).ready(function(){

    $("#click").click(function() {
      $("#click").addClass("lock");
      $("#click").toggleClass("on");
      setTimeout(function(){ 
        $("#click").removeClass("lock");
      },2000);
    });

  });

</script>
</html>

最初はif関数で発火条件を指定してみたり、クリック要素そのものを削除したりと色々試行錯誤しましたが、現状ではこの方法が使い勝手が良いと感じました。

あくまで自分用のメモですが、参考になれば幸いです。

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

ニジボックスで実施してきた研修

ニジボックスの研修を何個か企画して実施したので、その内容を紹介します。

JavaScript 研修

JavaScript の研修です。古川が担当しました。研修の内容としては JavaScript の基本文法から文法内容を使って簡単な関数を書くパートが前半、後半は小さいアプリケーションを何のフレームワークも使わずに書いてみるところまで、ほぼ丸一日かかる6時間の研修です。

リクルートの JavaScript Bootcamp の内容がベースですね。

かなりインタラクティブな講義で、参加者がただ座学的に受けるというよりは、聞いた内容に対して質問してもらい、受けた質問から展開して、色々説明します。

イベントループの話みたいな若干マニアックな話から、ページネーションをどうやって実装するかという実践的な話もしてました。

image.png

この画像はページネーションをどうやって実装するかを解説してるところですね。
ページネーションの元ネタはここにまとまってます。

スピードハッカソン

こちらも既に記事になっていました。リクルートでも実際やっていた活動をそのままニジボックスに転用した感じですね。

スピードハッカソンは実際のサービスを使って高速化をするハッカソンです。 Lighthouse の性能のスコアで競います。

第一回目のスピードハッカソン研修では、ニジボックスのブログサイトを使って高速化しました。実際に高速化をしようとすると、チューニングのポイントがわかるだけじゃなく、ブラウザの中の動きやネットワークの動きなどを見るきっかけになり、それがフロントエンドのエンジニアリングの理解にも繋がるので非常に効果的な研修でした。

JavaScript/TypeScript 研修

先週と今週(3/4と3/11)で行われている研修ですね。最初に JavaScript の研修をやった後、 TypeScript で作ったアプリを書き換えるという事をします。

リクルート内のメンバーが講師になり、一緒になって研修します。

スクリーンショット 2021-03-08 11.37.34.png

JavaScriptのコードを「あーでもない」「こーでもない」と一緒に議論しながら進めることができて、育成しながら一つのアプリケーションを TypeScript できっちりしたコードにするところまでを目指します。

ニジボックス研修について

ニジボックスの開発現場は必ずしもモダンな開発現場ばかりではなく、むしろレガシーな環境のが多いです。ただし、その中でエンジニアリングが不要なわけではありません。レガシー環境であろうとモダンな環境であろうと、一般的な原理原則(テストをしっかり書く、コードの変更可能性を最小限にする、など)を理解している必要があります。

どんな現場に配属されたとしても役に立つような知識をインプットし、全体の底上げをしつつ、徐々に教えるレベルも上げていきたいと思っています。

次回以降の研修では、「ブラウザがどうやって動いてるか」や「フレームワークを入れずに作ったアプリをフレームワークを導入することでどう変わるか」といった基礎と応用の両方を教えられるようにしていきたいと思います。

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

フロントエンドVue.js バックエンドFlaskの開発で躓いたこと色々

GoogleのOAuthをする場合

元々はFlaskのみで実装していたプロジェクトだったのだが、途中からフロントエンドをvue.jsに引き継いだため、OAuth部分をFlask側のコードで実行しようとしていた。
これだとCORSエラーが出てしまう。
CORSエラーへの対応について調べてみると、Flask,Vue.jsともにCORS対応方法についての文献がでてくる。
しかしこれをやっても超えられない。何故か。

色々と探してみたのだが、そもそもそういう使い方は出来ないっぽい。探しすぎてソースを見失ってしまったが、つまりそういうことだったんだと思う。
素直にVue.js側でvue-google-oauth2を使った実装を行う事によってVue.js→Google→Vue.jsという通信によってtokenが取得できるのでそれを使う。

認証されないGoogleAPIキー

これはそのままこれ
https://qiita.com/kenken1981/items/9d738687c5cfb453be19

どうも、Google認証の「OAuth 2.0 クライアント ID」は、一度あるポートで使うと、その後で違うポートで利用することはできないらしい。

ということで、別途フロントエンド用のキーを作って、vue.js側のポートを指定することで解決。

認証されないGoogleAPIキー2

承認済みの JavaScript 生成元
については、127.0.0.1 は使えない。
https://qiita.com/kenken1981/items/b6cb3e536668a3cef520

Google認証は、「127.0.0.1」というIPアドレスは認めておらず、「actual urls」というらしい?いわゆるaaa.jpやbbb.comといった形式でないとエラーを返す模様。

Vue.js側で環境変数が読み込まれない

Vue.js側は yarn build することでdistフォルダに静的ファイルを吐き出す形で利用しているのだが、どうもVue.js側で環境変数を読み込んでくれない。
.envに書いてあろうが読み込まれない。何故か。

buildする時に、その設定が必要だった。
詳細についてはここにある通り。
https://qiita.com/go6887/items/2e254d31b5a4af42f813

vue.js が
/frontend
内に入っていて、/frontoend でビルドコマンドを打つ形にしてありましたが
/frontend/.env.development
というファイルを作って、中に変数を記述。

package.json には
"build": "cross-env NODE_ENV=development vue-cli-service build --mode development --dest ../dist",

みたいな形に書いておく事によって環境変数が読み込まれた。

変更履歴

2021/3/8 初稿up

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

Javascriptの配列とObjectのコピー関連メモ

自分用のメモ

Array

ポインタ有りの場合

const arr1 = ['a', 'b', 'c', 'd'];
const arr2 = arr1;
arr2[0] = 'e';

console.log(arr1); //- ['e', 'b', 'c', 'd']
console.log(arr2); //- ['e', 'b', 'c', 'd']
//- arr1[0]も同じ値になる

ポインタ無しの場合

const arr1 = ['a', 'b', 'c', 'd'];
const arr2 = arr1.slice();
arr2[0] = 'e';

console.log(arr1); //- ['a', 'b', 'c', 'd']
console.log(arr2); //- ['e', 'b', 'c', 'd']
//- arr1[0]とは同じ値にならない

Object

ポインタ有りの場合

const obj1 = {
  data1: 'a',
  data2: 'b',
  data3: 'c',
  data4: 'd'
};
const obj2 = obj1;
obj2.data1 = 'e';

console.log(obj1); 
/*
{
  data1: 'e',
  data2: 'b',
  data3: 'c',
  data4: 'd'
}
*/
console.log(obj2); 
/*
{
  data1: 'e',
  data2: 'b',
  data3: 'c',
  data4: 'd'
}
*/
//- obj1.data1も同じ値になる

ポインタ無しの場合

const obj1 = {
  data1: 'a',
  data2: 'b',
  data3: 'c',
  data4: 'd'
};
const obj2 = Object.assign({}, obj1);
obj2.data1 = 'e';

console.log(obj1); 
/*
{
  data1: 'a',
  data2: 'b',
  data3: 'c',
  data4: 'd'
}
*/
console.log(obj2); 
/*
{
  data1: 'e',
  data2: 'b',
  data3: 'c',
  data4: 'd'
}
*/
//- obj1.data1とは同じ値にならない
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Stimulusをはじめよう

Stimulusとは何か

Stimulusとは、JavaScriptで書かれたクライアントサイドのライブラリです。
Basecampによって開発され、2021/12にversion2がリリースされました。
皆さんの知るところでいうとstack overflowが採用していたりします。

StimulusはHTMLを中心に考え、JavaScirptで書かれた振る舞いをHTMLから呼び出すことができるように設計されています。
それはonClickでJavaScriptコードを呼び出していた太古のコードに少し似ています。
さぁ、私と共にWeb開発を再発見しましょう!

基本原理

StimulusはHTMLに書かれた振る舞いを与えます。
それは最初にレンダリングされたHTMLだけでのことではなく、後から挿入されたHTMLに対しても同じです。

StimulusはMutationObserverを利用してDOMの変更を常に監視し、振る舞いを与えるべきHTMLが検出された時、即座にアタッチするようにできています。

HELLO WORLD

まずはStimulusを読み込みます。
難しく構える必要はありません、最も簡単な方法はCDNで読み込むことです。

<script type="module">
  import {Controller, Application} from "https://cdn.skypack.dev/stimulus@2.0.0";
</script>

Stimulusを読み込むとApplicationとControllerという2つのクラスが提供されます。
ApplicationはHTMLを監視し、振る舞いをアタッチすることを責務としたクラスであり、Controllerは振る舞いを定義するために用いるクラスになります。

まずはApplicationクラスを使ってHTMLの監視を開始しましょう、それはstartメソッドを呼び出すだけです。

let app = Application.start();

次にControllerクラスを使って振る舞いを定義しましょう。 Controllerを継承したクラスを作り、それをappに登録するとすぐに使えるようになります。

app.register('hello', class extends Controller {
  connect() {
    alert('HELLO WORLD')
  }
})

connectはStimulusが最初から提供するLifecycle Callbackです。 (LifeCycleCallbackにはinitialize, connect, disconnectの3つがあります。)

振る舞いを与える対象の要素がHTML中に現れたらその度に発火します。

これで準備が整いました。
さっそくHTMLからこの振る舞いを呼び出してみましょう

<div data-controller="hello"></div>

HTML側にdata-controller属性を付与すると、Stimulusがそれを検知し、対応する名前で登録されたcontrollerをアタッチします。

これによりconnectライフサイクルコールバックが呼び出されアラートがでます。

完全なコード(codepen)

イベントに合わせて振る舞いを与える

StimulusではHTML側にdata-action属性を付与することでclickやinputといったイベントに対する振る舞いを呼び出すことができるようになります。

<div data-controller="notifier">
  <button type="button" data-action="notifier#notify">click me</button>
</div>

data-action属性の値は{event}->{controller}#{method_name}のフォーマットで記述します。
対応する振る舞いを記述しましょう。

app.register('notifier', class extends Controller {
  notify(evt) {
    alert('HELLO WORLD')
  }
})

これでclick meボタンをクリックした際にnotify関数が呼び出すことができるようになります。

See the Pen QIITA_STIMULUS_HELLO_ACTION by nazomikan (@nazomikan) on CodePen.

要素にアクセスする

StimulusではHTML側にdata-{controller}-target="{name}"属性を付与し、値に設定した{name}をJavaScript側の静的プロパティに設定すると{name}Targetというgetterが生成され、アクセスできるようになります。

<div data-controller="hello">
  <p data-hello-target="world">hello world</p>
  <button type="button" data-action="click->hello#reverse">reverse</button>
</div>
app.register('hello', class extends Controller {
  static targets = ['world'];

  reverse(evt) { // 文字を反転させる例
    this.worldTarget.textContent = this.worldTarget.textContent
      .split('').reverse().join('');
  }
})

See the Pen QIITA_STIMULUS_HELLO_TARGET by nazomikan (@nazomikan) on CodePen.

また、data-{controller}-target="{name}"を指定した要素が複数ある場合は{name}Targetsという名前で配列としてアクセスすることもできます。

状態を持つ

UIが状態を持つことはよくあります。 モーダルが開いてる時・閉じてる時、ディスクロージャーが開いてる時・閉じてる時など。
Stimulus version2にはそういった状態をHTML側にもつためのAPIも提供されています。

data-controllerを指定した要素に対してdata-{controller}-{name}-value="{value}"属性を付与し、属性名中の{name}をJavaScript側の静的プロパティvaluesに型と共に設定すると{name}Valueというgetter/setterが生成され、アクセスできるようになります。

<div data-controller="counter"
  data-counter-num-value="0"
>
  <p data-counter-target="view">0</p>
  <button type="button" data-action="click->counter#plus1">+1</button>
</div>
app.register('counter', class extends Controller {
  static targets = ['view'];
  static values = {num: Number}; 

  plus1(evt) {
    this.viewTarget.textContent = ++this.numValue;
  }
})

See the Pen QIITA_STIMULUS_HELLO_VALUES by nazomikan (@nazomikan) on CodePen.

上記のコードはHTML中のdata-counter-num-valueの値にthis.numValueという値でアクセスできるようになり、++によりsetter経由でdata属性の値を更新し、その結果をtextContentに流し込むことで状態を持つことができているわけです。

static valuesにはObjectを指定します。 キーがvalue名({name})になり、値がそのデータ型のコンストラクタになります。
(この情報を元にthis.xxxValueでアクセスしたときに自動的にキャストされた状態でアクセスできるようになるわけです)

このValue APIはさらに奥深い機能を持っています。

それはValueChangedCallbackとよばれる機能で、{name}ValueChangedという名前の関数を定義しておくと、valueの値がsetter経由で変更された場合にコールバックを実行してくれるというものになります。

これをもとにさきほどのコードをリファクタリングすると以下のようにかけます。(より恩恵を感じるためにminus1ボタンも実装してみましょう。)

<div data-controller="counter"
  data-counter-num-value="0"
>
  <p data-counter-target="view">0</p>
  <button type="button" data-action="click->counter#plus1">+1</button>
  <button type="button" data-action="click->counter#minus1">-1</button>
</div>
app.register('counter', class extends Controller {
  static targets = ['view'];
  static values = {num: Number};

  plus1(evt) {
    ++this.numValue;
  }

  minus1(evt) {
    --this.numValue;
  }

  numValueChanged() {
    this.viewTarget.textContent = this.numValue;
  }
})

See the Pen QIITA_STIMULUS_HELLO_VALUES2 by nazomikan (@nazomikan) on CodePen.

より洗練されたコードになりました。

plus1/minus1ではvalueを変更するだけにとどめ、その変更により呼ばれたコールバック内でviewを変更させることにより、ビジネスロジックからDOM操作を排除することに成功しています。

Classを管理する

Stimulusの最後のAPIはClass APIです。 これはさほど難しい概念ではありません。

ディスクロージャーが開いた、モーダルがオープンした。 そういう状態を見た目に反映させるためにclassをHTMLに振りたいときは度々あります。

そういったclassをJavaScriptに直で書いてしまうケースはこれまで度々あったのではないでしょうか?

しかしながら、StimulusはHTMLを中心においたライブラリです。 HTMLのもつべき状態はHTML中に存在させるべきです。

Class APIはそれを実現するために、HTML中に記述しておいたclass名にJavaScriptからプロパティとしてアクセスするためのAPIです。

data-controllerを指定した要素に対してdata-{controller}-{name}-class="{className}"という属性を付与し、属性名中の{name}をJavaScript側の静的プロパティclassesに設定すると{name}Classというgetterが生成され、{className}にアクセスできるようになります。

さきほどのValues APIのコードを流用して、counterの値が10を超えたら赤色を付与するコードを書いてみましょう。

<div data-controller="counter"
  data-counter-num-value="0"
  data-counter-over10-class="red"
>
  <p data-counter-target="view">0</p>
  <button type="button" data-action="click->counter#plus1">+1</button>
  <button type="button" data-action="click->counter#minus1">-1</button>
</div>
app.register('counter', class extends Controller {
  static targets = ['view'];
  static values = {num: Number};
  static classes = ['over10']

  plus1(evt) {
    ++this.numValue;
  }

  minus1(evt) {
    --this.numValue;
  }

  numValueChanged() {
    this.viewTarget.textContent = this.numValue;
    this.element.classList.toggle(this.over10Class, this.numValue >= 10)
  }
})

See the Pen QIITA_STIMULUS_HELLO_CLASS by nazomikan (@nazomikan) on CodePen.

ここではover10という名前でClass APIを利用しました。

numValueが変更された時に動くnumValueChangedコールバック時に、numValueが10を超えていたら、起点要素にover10Classというgetterプロパティを経由してredクラスをつけたりはずしたりするように実装されています。

これは非常に単純な作用に見えますが、非常に奥深く、機能の汎化に役立ちます。

JavaScript内で直に特定のclassの付与してしまっていては、このcontrollerは同名のclassをふる時にしか再利用できませんが、class apiを利用することでHTML側を変えるだけでJavaScriptはそのまま再利用できるという汎用性を手にできるのです。

最後に

以上でStimulusのもつAPIについての説明は終わりになります。

え? これだけしかAPIないの? それで実装困らないの? と思うかもしれません。
しかしこれが全てです。

覚えることが少なく、学習コストが低いのもStimulusの大きなメリットの一つと言えるでしょう。

今後はStimulusを用いた実装のコツについて書いていきたいと思います。

それでは幸せなStimulus Lifeを。

links

Stimulusの実装の勘所 - primitive controller -

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

アニメーション

アニメーションをまとめます。

チェックボックス

チェックマークがいい感じにつく

ezgif.com-gif-maker.gif

<input type="radio" name="test" id="test1">
<label for="test1">チェックボックス</label>
input[type="radio"] {
    display: none;
}

input[type="radio"]+label {
    display: block;
    position: relative;
    padding-left: 25px;
    margin-bottom: 0px;
    cursor: pointer;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
}

input[type="radio"]+label:last-child {
    margin-bottom: 0;
}

input[type="radio"]+label:before {
    content: '';
    display: block;
    width: 15px;
    height: 15px;
    border: 1px solid #6cc0e5;
    position: absolute;
    left: 0;
    top: 3px;
    opacity: .8;
    -webkit-transition: all .12s, border-color .08s;
    transition: all .12s, border-color .08s;
}

input[type="radio"]:checked+label:before {
    width: 10px;
    top: -1px;
    left: 5px;
    border-radius: 0;
    opacity: 1;
    border-top-color: transparent;
    border-left-color: transparent;
    -webkit-transform: rotate(45deg);
    transform: rotate(45deg);
}

ツールチップ

text.blade.php
<button type="button" class="btn--circle btn--circle-c btn--shadow tooltip1 bookshelf_store_js" data-id="{{ $novel->id }}">
    <i class="fas fa-book-medical"></i>
    <div class="tooltip_content">本棚に追加</div>
</button>
text.scss
//ツールチップ
.tooltip1 {
    position: relative;
    cursor: pointer;
    display: inline-block;

    i {
        margin: 0;
        padding: 0;
    }

    & .tooltip_content {
        display: none;
        position: absolute;
        margin: 1.5em 0;
        padding: 7px 10px;
        min-width: 120px;
        max-width: 100%;
        color: #555;
        font-size: 0.9rem;
        background: #FFF;
        border: solid 2px $border_color;
        box-sizing: border-box;
        box-shadow: 1px 1px 5px $border_color;
        z-index: 2;

        &:before {
            content: "";
            position: absolute;
            top: -24px;
            left: 50%;
            margin-left: -15px;
            border: 12px solid transparent;
            border-bottom: 12px solid #FFF;
            z-index: 2;
        }

        &:after {
            content: "";
            position: absolute;
            top: -30px;
            left: 50%;
            margin-left: -17px;
            border: 14px solid transparent;
            border-bottom: 14px solid $border_color;
            z-index: 1;
        }
    }

    &:hover .tooltip_content {
        display: inline-block;
        top: 35px;
        left: -40px;
    }

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

初学者のJavaScript 開閉機能のプログラミング

jQueryでtoggleClassメソッドを利用した開閉機能

  $(document).ready(function(){
    $('#button').on('click', function(){
      $('#hoge').toggleClass('show');
    });
  });

HTML要素のhoge(ID名)にtoggleClassでshowをクリック時に追加、削除するプログラム。クラスセレクタshowにCSSを記述しておくことで、表示/非表示をクリック時に切り替えることができる。

showのCSS
.show {
  transform: translate3d(-300px, 0, 0);
}

アニメーション
#hoge {
  transition: transform 0.3s;
}

showが付加されるとページ全体が左に300px移動する(-300px)。すると、ページ左に見切れているサイドメニューがページ全体がスライドすることで見られるようになる仕組み。transitionによってアニメーションを付加している。

さいごに

プログラミングの初学者です。その日に学んだことを学習の一環としてアウトプットしています。より深く学習していきたいと考えておりますので、ご指摘等いただけますと幸いです。

参考図書:確かな力が身につくJavaScript「超」入門 第2版 著者:加納祐東

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

React+Amplify+AppSync+TypeScript+Recoilで認証機能つきチャットアプリを作る

React+Amplify+AppSync+TypeScript+Recoilで認証機能つきチャットアプリを作る方法を紹介します。

完成するアプリのデモは以下です。
左右の画面に異なるユーザーでログインし、チャットを行っています。
ezgif.com-gif-maker.gif

本記事で作成するアプリのアーキテクチャーは下記です。
Amplify Console Static Web Site Hostingでフロンドエンドのコードをホスティングします。
AWS AppsyncでGraphQL APIを提供し、データベースはDynamoDBを使用します。
Amazon Cognitoをユーザー認証に用いています。

image.png

バージョン

使用した環境は以下の通りです。

$ npx create-react-app --version
4.0.3
$ node -v
v14.16.0
$ npm -v
6.14.11
$ amplify -v
4.44.2

Amplify CLIが未インストールの場合は、公式ドキュメントを参考にインストールします。

アプリの雛形作成

create-react-appでアプリの雛形を作ります。

$ npx create-react-app chat --template typescript

yarn startでサンプルアプリが起動すれば成功です。

$ cd chat
$ yarn start

image.png

続いて、amplify initでプロジェクトにAmplify用の設定を追加します。

$ amplify init
? Enter a name for the project chat
? Enter a name for the environment production
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
? What javascript framework are you using react
? Source Directory Path:  src
? Distribution Directory Path: build
? Build Command:  npm run-script build
? Start Command: npm run-script start
? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use default ← amplify configureで指定したプロファイル名を指定

アプリの雛形作成は以上で完了です。

認証機能の実装

認証機能を実装していきます。

バックエンド/インフラ

amplify add authで認証機能を追加します。

$ amplify add auth
? Do you want to use the default authentication and security configuration? Default configuration
? Warning: you will not be able to edit these selections.
? How do you want users to be able to sign in? Username
? Do you want to configure advanced settings? No, I am done.

続いて、amplify pushでクラウドへ変更を反映します。

$ amplify push
? Are you sure you want to continue? Yes

バックエンド/インフラの実装は以上で完了です。

フロントエンド

パッケージインストール

まずはAmplify関連のパッケージをインストール。

$ yarn add aws-amplify @aws-amplify/ui-react

続いて、Material-UIをインストール。

$ yarn add @material-ui/core @material-ui/icons

最後に、Recoilをインストール。

$ yarn add recoil

コンポーネントの実装

App.tsxを下記のように書き換えます。
ログイン画面は@aws-amplify/ui-reactのコンポーネントを用いて作成しています。
ログアウトはhandleClick内でaws-amplifyAuth.signOut()することで実現しています。
また、後述するRecoilのatomにログインユーザー名を格納しています。

App.tsx
import React, { useState } from "react";
import Amplify, { Auth } from "aws-amplify";
import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react";
import {
  AuthState,
  onAuthUIStateChange,
  CognitoUserInterface,
} from "@aws-amplify/ui-components";
import awsconfig from "./aws-exports";
import { RecoilRoot } from "recoil";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import ExitToAppIcon from "@material-ui/icons/ExitToApp";

Amplify.configure(awsconfig);

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    appBar: {
      zIndex: theme.zIndex.drawer + 1,
    },
    toolBar: {
      display: "flex",
    },
    signOut: {
      marginLeft: "auto",
      display: "flex",
    },
  })
);

const App = () => {
  const classes = useStyles();
  const [authState, setAuthState] = useState<AuthState>();
  const [user, setUser] = useState<CognitoUserInterface | undefined>();

  React.useEffect(() => {
    return onAuthUIStateChange((nextAuthState, authData) => {
      setAuthState(nextAuthState as AuthState);
      setUser(authData as CognitoUserInterface);
    });
  }, []);

  const handleClick = () => {
    Auth.signOut();
  };

  return authState === AuthState.SignedIn && user ? (
    <div>
      <RecoilRoot>
        <AppBar className={classes.appBar}>
          <Toolbar className={classes.toolBar}>
            <Typography variant="h6" noWrap>
              ChatApp
            </Typography>
            <div onClick={handleClick} className={classes.signOut}>
              <IconButton
                aria-label="display more actions"
                edge="end"
                color="inherit"
              >
                <ExitToAppIcon />
              </IconButton>
            </div>
          </Toolbar>
        </AppBar>
      </RecoilRoot>
    </div>
  ) : (
    <AmplifyAuthenticator>
      <AmplifySignUp
        slot="sign-up"
        formFields={[
          { type: "username" },
          { type: "password" },
          { type: "email" },
        ]}
      />
    </AmplifyAuthenticator>
  );
};

export default App;

Recoilの実装

src/recoil/ChatState.tsxを作成し、下記のように書きます。
1件の投稿を意味するpostStateと投稿リストを意味するpostListStateを作成します。
また、投稿のメッセージだけをget/setするためにmessageStateを作成しています。

ChatState.tsx
import { atom, selector, DefaultValue, RecoilState } from "recoil";
import produce from "immer";

export interface PostState {
  id: string;
  message: string;
  owner: string;
  user: string;
  createdAt: string;
}

const defaultValue: PostState = {
  id: "",
  message: "",
  owner: "",
  user: "",
  createdAt: "",
};

const atomKeyName: string = "postState";

export const postState = atom({
  key: atomKeyName,
  default: defaultValue,
});

export const messageState: RecoilState<string> = (() => {
  const propName: keyof PostState = "message";
  return selector<string>({
    key: atomKeyName + "/" + propName,
    get: ({ get }) => {
      return get(postState)[propName];
    },
    set: ({ set, get }, newValue) => {
      const tempValue: string =
        newValue instanceof DefaultValue ? defaultValue[propName] : newValue;
      const imValue = produce<PostState>(get(postState), (draft) => {
        draft[propName] = tempValue;
      });
      set(postState, imValue);
    },
  });
})();

const postListDefaultValue: PostState[] = [];

export const postListState = atom({
  key: "postListState",
  default: postListDefaultValue,
});

以上で認証機能の実装は完了です。
下記の手順で動作確認してみましょう。

  1. yarn start
  2. ブラウザで http://localhost:3000 にアクセスする
  3. Create acccountをクリックする
  4. Username、Password、Emailを入力し、CREATE ACCOUNTをクリックする
  5. 入力したメールアドレスに送付されたConfirmation Codeを入力し、CONFIRMをクリックする
  6. Username、Passwordを入力しログインする
  7. ヘッダーにChatAppと表示されればログイン成功です image.png

チャットの実装

続いて、チャットの実装をしていきます。

バックエンド/インフラ

GraphQL APIの作成

amplify add apiでGraphQL APIを作成します。

$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: chat
? Choose the default authorization type for the API Amazon Cognito User Pool
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)
? Do you want to edit the schema now? No

GraphQLのスキーマを編集

amplify/backend/api/chat/build/schema.graphqlに生成されたスキーマを編集します。
すべての投稿を作成日順に取得するために@keyを使用しています。

type Post
  @model
  @key(
    name: "SortByCreatedAt"
    fields: ["owner", "createdAt"]
    queryField: "listPostsSortedByCreatedAt"
  ) {
  id: ID!
  message: String!
  owner: String
  user: String
  createdAt: AWSDateTime
}

@model@keyの説明は公式ドキュメントをご確認ください。
https://docs.amplify.aws/cli/graphql-transformer/model
https://docs.amplify.aws/cli/graphql-transformer/key

GraphQL APIのデプロイ

amplify pushでクラウドにGraphQL APIをデプロイします。

$ amplify push
? Are you sure you want to continue? Yes
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target typescript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.ts
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
? Enter the file name for the generated code src/API.ts
GraphQL endpoint: https://xxxxxxxxx.appsync-api.us-east-1.amazonaws.com/graphql

最後の行のURLがApp Syncが提供するGraphQL APIのURLになります。
このURLは自動生成されるsrc/aws-exports.jsというファイルに自動で書き込まれています。

フロントエンド

コンポーネントの実装

チャット機能をもつContentコンポーネントを実装します。
src/Content.tsxを作成し、下記コードのように書きます。
登録ボタン押下時にGraphQLのmutationsによりデータをDynamoDBに登録しています。
また、Contentコンポーネントの初回呼び出し時にGraphQLのqueriesにより投稿一覧を取得しています。全投稿を作成日時順に取得するため、ownerchatという固定の値を入れています。
さらに、Contentコンポーネントの初回呼び出し時にGraphQLのsubscriptionsを呼び出すことで、新規投稿をsubscribeしています。自分自身の投稿の場合もsubscribeしているので、自分自身の投稿の場合はsetPostしないようにする必要があります。

Content.tsx
import React, { useEffect } from "react";
import { useRecoilState } from "recoil";
import { postListState, messageState, PostState } from "./recoil/ChatState";
import { API, graphqlOperation } from "aws-amplify";
import { GraphQLResult } from "@aws-amplify/api";
import { listPostsSortedByCreatedAt } from "./graphql/queries";
import { createPost } from "./graphql/mutations";
import { onCreatePost } from "./graphql/subscriptions";
import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem";
import Chip from "@material-ui/core/Chip";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import Container from "@material-ui/core/Container";
import { CreatePostMutation, ListPostsSortedByCreatedAtQuery } from "./API";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
      paddingTop: theme.spacing(10),
      paddingBottom: theme.spacing(10),
      backgroundColor: "white",
    },
    input: {
      display: "flex",
    },
    myMessage: {
      display: "flex",
      justifyContent: "flex-start",
    },
    otherMessage: {
      display: "flex",
      justifyContent: "flex-end",
    },
  })
);

interface ContentProps {
  userName?: string;
}

const Content = (props: ContentProps) => {
  const classes = useStyles();
  const [posts, setPosts] = useRecoilState(postListState);
  const [message, setMessage] = useRecoilState(messageState);

  const handleClick = () => {
    postPost();
  };

  const postPost = async () => {
    const post = (await API.graphql(
      graphqlOperation(createPost, {
        input: { message: message, owner: "chat", user: props.userName },
      })
    )) as GraphQLResult<CreatePostMutation>;
    const postData = post.data?.createPost as PostState;
    setPosts([...posts, postData]);
    setMessage("");
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setMessage(event.target.value);
  };

  useEffect(() => {
    async function getPosts() {
      const res = (await API.graphql(
        graphqlOperation(listPostsSortedByCreatedAt, { owner: "chat" })
      )) as GraphQLResult<ListPostsSortedByCreatedAtQuery>;
      const postData = res?.data?.listPostsSortedByCreatedAt
        ?.items as PostState[];
      setPosts(postData);
    }
    getPosts();
  }, [setPosts]);

  useEffect(() => {
    // @ts-ignore
    const subscription = API.graphql(graphqlOperation(onCreatePost)).subscribe({
      next: (eventData: any) => {
        const post = eventData.value.data.onCreatePost;
        if (post !== undefined && post.user !== props.userName) {
          setPosts([...posts, post]);
        }
      },
    });
    return () => subscription.unsubscribe();
  }, [posts]);

  const postList: JSX.Element[] = [];

  for (const post of posts) {
    if (post.user === props.userName) {
      postList.push(
        <ListItem key={post.id} className={classes.myMessage}>
          <Chip label={post.message}></Chip>
        </ListItem>
      );
    } else {
      postList.push(
        <ListItem key={post.id} className={classes.otherMessage}>
          <Chip label={post.message}></Chip>
        </ListItem>
      );
    }
  }

  return (
    <Container maxWidth="lg" className={classes.container}>
      <div className={classes.input}>
        <TextField value={message} onChange={handleChange} />
        <Button variant="contained" color="secondary" onClick={handleClick}>
          登録する
        </Button>
      </div>
      <List>{postList}</List>
    </Container>
  );
};

export default Content;

作成したContentコンポーネントをAppコンポーネントから呼び出します。

App.tsx
import React, { useState } from "react";
import Amplify, { Auth } from "aws-amplify";
import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react";
import {
  AuthState,
  onAuthUIStateChange,
  CognitoUserInterface,
} from "@aws-amplify/ui-components";
import awsconfig from "./aws-exports";
import Content from "./Content";
import { RecoilRoot } from "recoil";
import { createStyles, Theme, makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import ExitToAppIcon from "@material-ui/icons/ExitToApp";

Amplify.configure(awsconfig);

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    appBar: {
      zIndex: theme.zIndex.drawer + 1,
    },
    toolBar: {
      display: "flex",
    },
    signOut: {
      marginLeft: "auto",
      display: "flex",
    },
  })
);

const App = () => {
  const classes = useStyles();
  const [authState, setAuthState] = useState<AuthState>();
  const [user, setUser] = useState<CognitoUserInterface | undefined>();

  React.useEffect(() => {
    return onAuthUIStateChange((nextAuthState, authData) => {
      setAuthState(nextAuthState as AuthState);
      setUser(authData as CognitoUserInterface);
    });
  }, []);

  const handleClick = () => {
    Auth.signOut();
  };

  return authState === AuthState.SignedIn && user ? (
    <div>
      <RecoilRoot>
        <AppBar className={classes.appBar}>
          <Toolbar className={classes.toolBar}>
            <Typography variant="h6" noWrap>
              ChatApp
            </Typography>
            <div onClick={handleClick} className={classes.signOut}>
              <IconButton
                aria-label="display more actions"
                edge="end"
                color="inherit"
              >
                <ExitToAppIcon />
              </IconButton>
            </div>
          </Toolbar>
        </AppBar>
        <Content userName={user.username} />
      </RecoilRoot>
    </div>
  ) : (
    <AmplifyAuthenticator>
      <AmplifySignUp
        slot="sign-up"
        formFields={[
          { type: "username" },
          { type: "password" },
          { type: "email" },
        ]}
      />
    </AmplifyAuthenticator>
  );
};

export default App;

以上でチャット機能の実装は完了です。
下記手順で動作確認してみましょう。

  1. yarn start
  2. ログイン
  3. 投稿内容をテキストボックスに入力
  4. 投稿する ボタンを押す
  5. 投稿内容がリスト表示されていることを確認
  6. 別のユーザーでログインする
  7. 投稿内容をテキストボックスに入力
  8. 投稿する ボタンを押す
  9. 自分の投稿内容が左側に、別のユーザーの投稿が右側に表示されていれば成功 image.png

フロントエンドをホスティング

最後に、作成したフロントエンドのコードをAmplify Console Static Web Site Hostingでホスティングしましょう。

$ amplify hosting add
? Select the plugin module to execute Hosting with Amplify Console (Managed hosting with custom domains, Continuous deployment)
? Choose a type Manual deployment

$ amplify publish
? Are you sure you want to continue? Yes

コンソールに出力されたURLにアクセスできれば成功です。

以上で、本記事で作成する認証機能つきチャットアプリの作成は完了です。

最後に

作成した環境を削除するには下記を実行してください。

$ amplify delete
? Are you sure you want to continue? This CANNOT be undone. (This will delete all the environments of the project from the cloud and wipe out all the local files created by Amplify CLI) Yes

本記事で作成した認証機能つきチャットアプリの全体のソースコードは下記で公開しています。
https://github.com/shimi7o/chat

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

開設後3週間で収益10万円を得た個人開発サイトに立ち向かった話の全部を公開する

こんにちは。ぬこすけです。

みなさん、Qiitaでいいね数4000越えしたジャバ・ザ・ハットリさんの名記事をご存知でしょうか?

ジャバ・ザ・ハットリさんはエンジニアのための技術書ランキングサイト「テックブックランク」を運営していますが、私もゴリゴリライバルサイトを立ち上げたというお話をしようと思います。

立ち上げたといっても実はWebサイトを公開してから1年ほど経っているのですが、その間のサイトの収益やユーザー数などの話にも触れていきます。個人開発をしている方、あるいはこれからしようとしている方にも役立つはずなので、一読いただければ嬉しいです。

パクりサイト作りました。

こんなサイト作りました。
IT技術書おすすめ入門/参考書ランキング | ぬこぷろ
ぬこぷろの画面キャプチャ.png

簡単に言うと、ITに関連する書籍をGoogleの検索結果やQiitaの記事、Twitterから独自に点数化し、ランキング形式で紹介するサイトです。

え?似たようなサイトもどこかで見たことある?
はい、冒頭でお話したジャバ・ザ・ハットリさんのテックブックランクのパクリです。

とは言ってもさすがにマルパクリというわけではないです。
ぬこぷろは主に次の点で違います。

  • ランキング付けの根拠となる情報ソースが複数であること。
  • ターゲットユーザーがIT初心者であること。

まず、テックブックランクと違う点はランキング付けの根拠となる情報ソースが複数であることです。テックブックランクはQiitaに特化して本を点数化していますが、ぬこぷろはQiitaだけでなくGoogleの検索結果やTwitterから点数化しています。

次に違う点はターゲットユーザーがIT初心者であることです。テックブックランクはエンジニア向けのサイトですが、ぬこぷろはIT初心者(「本当にIT初心者」だけでなく「すでに特定にIT分野に精通しているけど、他の分野は初心者」も含む)をターゲットユーザーにしています。

ただし、ターゲットユーザーが違うといえどニーズの競合は起きます。IT初心者であれエンジニアであれ、技術書を探す際は「python 参考書」や「python 入門書」でググるので、そのような検索キーワードに対してぬこぷろとテックブックランクはライバルということになります。
※この記事のタイトルを「開設後3週間で収益10万円を得た個人開発サイトに立ち向かった」と表現したゆえんです。

テックブックランクのすごいところ

ぬこぷろがテックブックランクをパクったわけには相応の理由があります。
ここでみなさんに質問です。テックブックランクのすごいところは何だと思いますか?もちろん、書籍とQiitaの記事を関連づけるという発想もすごいですが、私が一番にすごいと思うところは「書籍の評価を定量化している」ところです。
いらすとや_プレゼンテーションをしている男性のイラスト.png
実は世の中の書籍を紹介しているサイトで、定量的に本を評価しているサイトはほとんどないです。試しに「python 本」とかでググってみてください。上位に表示されているサイトの多くは文章で書籍を紹介するブログサイトです。本を定量的な指標で評価しているのって、Amazonのレビューくらいではないでしょうか。

そして観点は変わりますが、Googleは「独自性」や「多様性」を好みます。「独自性」に関して言えば、次のようにGoogleはオリジナリティのあるコンテンツを評価します。

オリジナルで有用なコンテンツを持つ高品質なサイトが、より上位に表示されるようになります。
引用元:Google ウェブマスター向け公式ブログ

「多様性」に関して言えば、Googleは検索結果に多様な種類のサイトを上位表示させています。例えば「python 本」とかでググった際には、ECサイトの商品一覧ページや複数の本を書評したブログ記事などが上位に表示されています。これはひとえに「python 本」とググるユーザーと言っても「価格を比較したいユーザー」もいれば「pythonに関連する本の書評を知りたい」などの多様なユーザーが想定されるからだと考えられます。
※これは俗にいう「QDD(Query Deserves Diversity)」と呼ばれるもので、SEO関係者が推測するGoogleのアルゴリズムです。Googleの公式に断言はしていないです。

再掲しますが、世の中の書籍を紹介しているサイトで、定量的に書籍を評価しているサイトは少ないです。それゆえ「書籍を定量的に評価をしている」サイトは「独自性」もあり、「多様性」を好むGoogleの検索結果において上位に食い込むのではないかという仮説を立てました。ユーザーにとっても数値としてわかりやすい指標があった方が参考書を選ぶ際に有益なはずです。

なお、ぬこぷろ開発前の企画段階でテックブックランクは一定のキーワードで上位表示されていたので「これはイケるぞ!」と確信しました。

実際、結果はどうだったのでしょうか?
ぬこぷろはすでに一定のキーワードでは成果が出ていて、例えば「typescript 参考書」「gcp 参考書」などでググると検索結果に1ページ目に出たりします(2021年2月時点)。

アイディアが思い浮かばないならとりあえずパクれ!

多くのエンジニアはかく言うなり。「個人開発したいんだけど、アイディア思い浮かばないんだよね」と。実際、私の周りのエンジニアも同じことを言います(私もそうでした)。そんな人たちにこう言いたい。「とりあえず既存あるものをパクれ」と。実際、私の尊敬するジャバ・ザ・ハットリさんも次のように名言を残しています。

前述のテックブックランクには先行している成功事例をとことんまで研究してコピーした。コピーすれば見えてくるものがたくさんある。普段はユーザーとして使っていただけのサイトも、その実装をコピーすることで「あーアレはこういう意図でそうしていたのか!」と気付く点が多々ある。

コピーを後ろめたく思う必要は無い。法に触れるようなパクリや著作権侵害は論外だが、成功事例のコピーはどんどんやるべき。フェイスブックは世界初のSNSなんかじゃないし、YouTubeもビデオシェアリングサイトとしてはかなり後発。Googleも世界初の検索エンジンじゃないのは誰もが知っている。
俺様のセンスにまかせていいのはスティーブ・ジョブズだけ。ジョブズ以外の人はとにかく成功事例を研究しまくって模倣すべし。

引用元:開設後3週間で収益10万円を得た個人開発サイトでやったことの全部を公開する

たとえ何かをパクったとしても、それがマルパクリになることはありません。「自分だったらこうする」「こうした方が良いんじゃないか」という案が必ず出てきます。私の場合は「Qiitaだけでなく他のメディアをソースに本を点数化できないか?」「このコンテンツなら他のターゲットユーザーにスイッチしても需要があるのでは?」など思い浮かびました。

あなたが普段使っているWebサイトやスマホアプリを触ってみてください。「自分だったらこういうデザインにする」「自分だったらもっとパフォーマンス上げられる」など、どんな改善点でも構いません。改善点がいくつか思いついたら、もうそれがあなたの個人開発のアイディアです!

VS モチベーション

個人開発の最大の敵はモチベーションです。みなさんも最初は意気込んで何かを作ったものの、今は放置されているプロジェクトも多いのではないでしょうか?私も数年前Ruby on Railsでブログを投稿できるCMSを作ろうと思い立ったものの、結局モチベーションが続かず、世に出ないままお蔵入りでした。

しかし、今も開発してるぬこぷろは約1年(当時自分のスキルにない技術での開発だったので、勉強時間いれるとプラス半年ほど)、平日も土日もほぼ毎日開発しています。継続的にモチベーションを続いた理由を3点紹介します。

  1. 多目的であれ
  2. さっさと世に出す
  3. 色々な技術やプログラムを構築しておく

1. 多目的であれ

1つしか目的がないよりも、たくさん目的があった方がモチベーションが高まります。私の多目的を4つ紹介します。

①技術力の向上

いらすとや_勉強しているイラスト.png
ぬこぷろの開発を決意した当時、自分の技術スタックに不安を感じていました。というのも、業務で扱っていたプロダクトは、VueやReactの台頭で少しづつ影が薄れているjQuery、テンプレートエンジンは誰も知らないApache Velocity、プログラムとして完成されたサーバーサイドはほぼ改修することなく設定ファイルのjsonをひたすらいじるのみ、みたいな状況でした。
加えて、色々なモダンな技術にチャレンジしたいという性分もあいまり、このような状況に不満を持っていました。

現状への不安と不満から、新しい技術の取得を目的の1つに設定しました。当時の私の技術スタックにはなかったPythonやDjango, React, Docker, GCPなど、色々と勉強しながらぬこぷろの開発に取り組みました。

色々な技術に触れられる楽しさもありながら、自身の技術力が向上している実感も日々得られたことがモチベーションが続いた1つの理由です。「技術力が向上している実感」に関して言えば、業務において度々感じました。例えば、所属するチームが変わりPythonを使うことになってもすでに自分の技術スタックにあったので問題なく開発できましたし、Vueを使うことになってもReactでの前提知識があったので理解しやすかったです。

②自分ブランドの向上

いらすとや_黒毛和牛のイラスト.png
個人で開発したプロダクトは様々なシーンで自分の評価に役立ちます。例えば、もし転職活動をしている人であれば面接官に評価されますし、フリーランスの人であれば顧客への宣伝の材料にもなるでしょう。

個人でプロダクトを作るのは結構難易度高めです。私もそうですが、普段業務でエンジニアとして開発していても、ゼロから自分で全てを構築する経験はあまりないと思います。インフラの構築はインフラエンジニアが社内で別でいるでしょうし、アプリケーションも何人かで分担して開発するのがほとんどでしょう。何なら企画の部署も別にあるかもしれません。

企画やインフラなど幅広い知識が求められることに加え、問題解決も全て自分でやる必要があります。後ほど「頼れる者は自分のみ」の章でお話しますが、これがまた結構苦労します。

個人でプロダクトを作るということは、アプリケーションの企画/開発はもちろん、インフラからマーケティングまで全て自分でやる必要があります。問題解決も自分でやらなきゃいけません。このゼロから自分で作り上げた実績というのは間違いなく他人にアピールできるでしょう。

③お金欲しい

これは誰もが思います。サラリーマンとして以外の収入源に憧れ、個人開発をしています。実際儲かっているかどうかに関しては後述します。

④実験台

いらすとや_化学の実験をしている人のイラスト.png
個人で作ったサイトは自分がしたいことの実験台にもなります。ぬこぷろの場合は2つの実験台としての意味がありました。

1つ目はSEO(検索エンジン最適化)施策の実験台です。唐突ですが、私は「自称SEOおじさん」なる者でした。自称SEOおじさんとは、2021年5月にGoogleモバイル検索のランキング要因に組み込まれるCore Web VitalやGoogleが検索結果の強調化スニペットとしてサポートしている構造化データなど、やたらSEOに詳しいおじさんです。このおじさんには1つ問題があります。それは「知識だけで実際に成果を出したことがない」ということです。そのおじさんがまさしく私でした。

実際にどういう施策をうてば検索順位を上げることができるのか、サイトへの流入数を上げることができるのかなどSEO施策の実験台としてPDCAを回すことを生きがいにサイトを改善しています。

2つ目はモダンな技術を取り入れる実験台です。普段の業務だと、他の技術へのスイッチングやライブラリのメジャーアップデートはプロダクトへのインパクトが大きいため、気軽にできないことが多いでしょう。個人で開発しているプロダクトであれば自由に技術のスイッチングやライブラリのメジャーアップデートをすることができます。ぬこぷろの例で言うと、初期はフレームワーク無のReactを使っていましたが、パフォーマンスの限界を理由にNext.jsに移行しました。またReactやNext.jsなど依存ライブラリのアップデートはガンガンしており、常に最新バージョンの最新機能を使っています。

このようにぬこぷろは最新技術の導入としての実験台にもしています。これも1つモチベーションになっています。

2. さっさと世に出す

いらすとや_短距離走のイラスト.png
モチベーションを維持する方法の1つ目は「多目的であること」でした。続いて2つ目は「さっさと世に出す」ことです。みなさんの中にも個人開発で「あの機能もいれなきゃ、この機能もいれなきゃ」と完璧を求めて開発を続けるうちにいつの間にかお蔵入りになったものもあるのではないでしょうか?私の場合は前述のRuby on Railsで構築したCMSのブログがまさしくそうでした。

このような経験もあり、ぬこぷろはさっさと世に出しました。最初のぬこぷろのサイトはデータベースから取ってきた生の値を、ちょっとBootstrapで味付けしてテーブルレイアウトで表示するだけのサイトでした。しょぼいサイトでも一度世に出すと「一般公開しているしもっと良いサイトにしなきゃ」というような義務感のようなものも芽生えます。また、一度公開すれば閲覧数のような数値もフィードバックとして手に入れるので、これもまたモチベーションにもなります

なお、モチベーションの観点以外にも「さっさと世に出す」メリットはあります。もし開発しているプロダクトが1日500円の収益を生み出すものであれば、公開しない日数分は機会損失です。またWebに限った話ではありますが、Googleがサイトを認識・評価するのには時間がかかるので早めにサイトを公開した方が得策です。

3. 色々な技術やプログラムを構築しておく

フロント側とバックエンド側で違う技術構成を採用することもおすすめします。これは「飽き」対策です。ぬこぷろの場合はフロントはReact/Next.js、APIはDjango、本の点数化などをするバッチプログラムはPythonという構成を取っています。そうすることでフロントの開発に飽きたらAPI, APIの開発に飽きたらバッチプログラムの開発をする、というように「飽き」対策をしています。

また、プロジェクトを複数用意するのも1つの手です。後述の副産物の章でもお話しますが、ぬこぷろ以外にもOSSを開発していたりします。ぬこぷろ自体の開発が飽きたら、OSSの方の開発を進めるということもしています。

個人開発の良いところの1つでもありますが、開発の優先度は自分で決められるので、「飽きたら違うプログラムをいじる」みたいなことができます。

甘い世界じゃない

みなさんお待ちかねの収益の話です。「開設後3週間で収益10万円いきました?」と誰もが思う質問に対しては、答えはノーです。収益立てるのはホントにムズいです。

ぬこぷろの場合は収益源としてGoogle AdSenseとAmazonアソシエイト・プログラムの2つですが、双方とも審査に合格するのに約10ヵ月かかりました。Google AdSenseは2回、Amazonアソシエイト・プログラムは5回審査に落ちています。

また、収益の元となる閲覧数やユーザー数ですが、Google検索で流入してくるようになってきたのもつい最近です。ぬこぷろのサイトを公開したのが2020年3月頃ですが、2021年1月頃にようやくデイリーで2桁のユーザーが訪問してくれるようになりました。逆に言えば、それ以前は1日数人訪問してくれるかどうかくらいのレベルでした。

▼ぬこぷろのユーザー数の推移
月単位のぬこぷろのユーザー数の推移.png

「じゃあ実際いくら稼いでるの?」と気になる人も多いと思うので差し支えない範囲でお答えしておくと、2021年2月時点でトータルで飲み会1回分くらいです。これが現実!

というわけで「収益10万円」の世界はかなり遠そうです。

自動化?効率化?んなもん後回し

「自動化」を駆使していかに運用を「効率化」することはエンジニアの習性みたいなものです。特に個人開発であれば自由に開発できるので、そうした効率化をプロダクトの開発初期に思いつき、実行したいと思うかもしれません。しかし、もしプロダクトとして成功を狙っているのであれば効率化は後回しにすべきだと思います。なぜならプロダクトを利用するユーザーはあなたの効率化なんで知ったこっちゃないからです。もしリリースを自動化したとしても、エンドユーザーにとってはそれは関係ありません。優先すべきはユーザーの目に見えるところです。

いらすとや_パソコンを見ている人のイラスト.png

ぬこぷろをリリースしてから1年ほどですが、いまだにAPIのリリースはGCEにsshしてGitコマンドやらDockerコマンドやらを打っていますし、書籍の点数化などをするPythonのバッチプログラムも私のMacBook Airで手動で実行しています。SSG(静的サイトジェネレータ)を利用しているのでバックエンドのデータを変わればフロント側もリリースする必要がありますが、「バッチプログラムの処理が完了したあとフロント側のデプロイも自動で開始」みたいな高尚なことはしていません。手動です。

もちろん、自動化はしたいですしどう自動化するかも頭の中ではすでに組み上がっているのですが、まずはある程度の規模のユーザーが利用してくれるようなプロダクトにすることが先決です。

とは言いつつも誤解のないように言っておくと、あくまで個人開発なので効率化に手をつけるのは自由です。大枠の優先度としてはユーザーの目に見えるところではありますが、先ほどのモチベーションの話でお話した通り、飽きとの戦いにもなってくるので、時には気分転換に効率化を主眼にした開発もしても良いとは思います。

圧倒的スピード感

個人開発の意思決定は早いです。「あ、こうした方が良いな」と思ったらすぐに開発できます。それが例えプロダクトへのインパクトが大きくとも、です。ぬこぷろの場合、サーバーやフレームワークの移行を1年弱でバンバンしてきました。具体的には次のようになります。

  • スタイルフレームワーク
    • Bootstrap → Material-Ui → CSS Modules → Tailwind CSS
  • APIサーバー
    • Heroku → Google Compute Engine(GCE)
  • Webフロント
    • React(クラスベース) → React(関数ベース) → Next.js
  • ホスティングサーバー
    • Netlify → Vercel

普段の業務で上記のようなスイッチングをしようとすると、関係者間の合意を得る必要ですが、個人での開発であれば「やった方が良い」と思ったらすぐに実行することができます

2021年3月現在も、APIサーバーをGKEでのKubernetesへの移行も検討しています。

頼れる者は自分のみ

チーム開発であればわからないことや実装の最適解を周りの知見のある人に聞くことができますが、個人開発だとそうはいきません。日本語でググって情報が出てくれば良いですが、時には英語でググり、さらにはGitHubのissueを読み漁ることになります。issue読みあさっても情報が出てこないのでしまいには自分でissueを起票することもありました(「リポジトリ見せなきゃ話にならん」と言われて即クローズされましたが笑)。

特に最新の技術を使うと英語のissue漁りに陥りがちです。私の場合はNext.jsがそうでした。
例えば、Next.jsではページ単位でデータを取得して静的ファイルにビルドする機能がありますが、ヘッダーやフッターなどページの雛形になる _app.js ではビルド時にデータ取得ができないという問題がありました。ぬこぷろではサイドメニューをAPIから取ってきたデータを表示したいということもあり、かなり困りました。

ぬこぷろのサイドメニュー.png

Next.jsのissueでも上がっているのですが、2021年2月時点ではまだこの問題は解決されていません。結局、当時取った回避策としてはNext.jsの設定ファイルで無理やりデータをフェッチし環境変数としてアプリケーションに注入することにしました(現在は先述のissueにもコメントがある通り、next-plugin-prevalというライブラリを使って回避しています)。

このようにわからないことは英語issueを読み漁ったり、最適解も自分で手探りで探す必要があり、個人開発は苦労します。ただ逆に言えば、「自分ブランドの向上」でもお話しましたが自分で問題を解決する能力は身につきます。

無料プランに徹したサーバー構築

テックブックランクも無料プランに徹したサーバー構築をしているそうですが、ぬこぷろも同じく無料に徹しています。ホスティングサービスで利用しているVercelは無料プランですし、バックエンドのGCEも永久無料枠です。データベースも商用利用でも無料なPostgreSQLを使っています。

個人開発において無料であることはかなり大事です。なぜなら個人開発の成果が出るのには時間がかかるからです。「甘い世界じゃない」でもお話しましたが、ぬこぷろは1年弱くらいほぼユーザーが来ない状況でした。もちろん、その間の収益はほぼゼロです。もし毎月数千円のコストがかかっていたとしたら、モチベーションも続かずにサイトも閉鎖していたでしょう。

無料でインフラを構築する方法は様々あると思いますが、せっかくなのでぬこぷろの開発で経験した無料サービスについて簡単に共有します。個人開発する方の参考になればと思います。

  • ホスティングサービス

    • Netlify
      masterブランチにプッシュするだけで自動的にデプロイできるので便利。SSL化も無料。 SPA(シングルページアプリケーション)としてぬこぷろを構築していた時は利用していましたが、Next.jsで構築し直すタイミングでVercelに移行しました。その理由は次でお話します。
    • Vercel
      Next.jsでアプリを作るならまずこれ。VercelはNext.jsをメンテナンスしている会社が運営しており、Next.jsで作られたアプリをデプロイする場合は基本設定無しでいけます。Netilifyと同様、masterブランチにプッシュすると自動的に本番環境にデプロイされることに加え、master以外のブランチでプッシュすると、本番相当の環境(いわゆるステージング環境)で自動でデプロイが走ります。このステージング環境にはブランチごとで自動でドメインが付与され、本番リリース前にサイトの状態を確認することができます。あと、SSLも無料です。
  • APIサーバー

    • Heroku
      Herokuもmasterブランチにプッシュするだけで自動デプロイできます。加えて、フレームワークによってはデータベースも無料で利用できます。ぬこぷろの場合はDjangoを使っているのですが、Django + PostgreSQL のセットで楽にデプロイできる仕組みがHerokuにありました。 このようにHerokuは便利ですが、一定の制約があるので注意が必要です。例えば、http通信で30秒で強制タイムアウトだったり、データベースも無料枠は1万レコードまで、などの制約もあります。
    • Google Compute Engine(GCE)
      Herokuは便利ですがPaaSなので一定の制約があります。もし自由にインフラを構築したいのであればIaaSであるGCEがおすすめです。永久無料枠を使ってサーバーを構築することができます。ただし、サーバーのスペックとしてはちょっと弱いので(無料なので文句は言えません!)要注意。構築についてはQiitaの「これから始めるGCP(GCE) 安全に無料枠を使い倒せ」という記事ががわかりやすかったです。

石の上にも1年

個人開発に限らずですが、バンバンアイディアを出してバンバンリリースすべし、という意見を目にします。私もこの意見に賛成ですが、少なくともWebサイトに関しては1年は運用してから次の新規開発に着手するかを判断すべきだと思います。なぜなら1年経たないと作ったWebサイトがウケるかどうかわからないからです。通常、Webサイトを公開してからユーザーがGoogle検索でサイトに定常的に流入(いわゆる自然検索流入)してくるまでに数ヶ月はかかります。ぬこぷろの場合もそうでした。

1年間のぬこぷろのGoogle検索結果での表示回数推移.png

上のグラフはぬこぷろのGoogle検索結果での表示回数の推移を表したものです。ご覧の通り、サイトを公開してから約半年はユーザーの目に触れることのない、影の存在でした。半年後に表示回数が微増したものの、10~12月はほぼ横ばいが続き、2021年1月頃にやっと増加してきました。このタイミングで「甘い世界じゃない」で少しお話しましたが、1日数十人のユーザーが訪問してきてくれるようになりました。やっとユーザーにウケるコンテンツを作れているかどうかの問えるところまで来ています。

「Webサイト公開したけど全然人来ないから次のアイディア行ってみよう!」と考えている方、ちょっと待ってください。諦めるのはまだ早いです。バズによる流入を狙っているのなら話は別ですが、世の中のサイトの流入の50%以上を占める自然検索での集客を考えているのならば1年は粘ってみてください!

副産物

個人開発をしていると思わぬ副産物が生まれることもあります。
私の場合、ぬこぷろの開発からOSSが生まれました。

autoload_module

このPythonライブラリは、指定したディレクトリ配下のPythonファイルを動的に読み込み、ファイル内で定義されたクラスや関数オブジェクトを返却するものです。これは元々、書籍の点数化などをするPythonプログラム内で実装していましたが、「この仕組みは汎用的に使えるものだし、普通に便利な機能なのでは?」と思い、ライブラリとして切り出し、Githubで公開しました。

2020年の11月ごろに公開しましたが、ありがたいことに2021年2月時点で約3,000ほどダウンロードしてもらっています。本業のぬこぷろの開発から生まれた、ちょっとしたサイドプロジェクトでしたが思わぬ副産物でした。

ぬこぷろの開発でちょっと疲れたり、飽きが到来したときはautoload_moduleの開発をしたりしています。これもまたモチベーションの話と関わってきますが、本業のぬこぷろの開発への良いアクセントになっています。

簡単に記事も書いているので良かったらご覧ください!

技術的な自慢をさせてください

エンジニアたるもの、自分が開発したプロダクトの技術的にすごいところを自慢したいもの。
私も一応エンジニアの端くれなので、ぬこぷろの技術的なポイントをいくつか共有させてください!

  • 全て静的ファイル?
    Next.jsのSSG(静的サイトジェネレータ)機能を使って、ブラウザ上で表示されるリソースは全て静的ファイルにしています。ユーザーがアクセスした時にサーバーで動的にHTMLファイル生成することもないですし、ブラウザでAPIを叩くこともありません。静的ファイル化によって、パフォーマンス最適化はもちろん、フロント側でバックエンドのAPIサーバーにアクセスすることがないので、「アクセス数に比例してGCPの料金が跳ね上がる」みたいな懸念をする必要もなくなります。

  • パフォーマンスチューニング:zap:
    ユーザーが快適にサイトを閲覧できるように、サイトのパフォーマンスも気にかけています。先述のSSGに加え、 react-windowを使った膨大なリストのレンダリング効率化、 react-lazyloadを使った遅延読み込みなど、様々な実装を施しています。
    技術的に書籍のデータを全件フロント側に返却しなくてはならない制約や、広告などのサードパーティのスクリプトの影響でLight Houseのパフォーマンススコアは芳しくはないですが、パフォーマンス向上のため可能な限りの努力をしています。
    なお、この記事を投稿してからちょっと古いですが、Reactでのパフォーマンスチューニングの記事(【2020】Reactパフォーマンスチューニング ~LightHouse Score 爆上げ物語~)も書いているので、ぜひご参考ください。

  • SEOを考慮した仮想無限スクロール? 無限スクロールにはGoogleが推奨するお作法があります。実はこれを実装しようするとそこそこ難易度高いです。ReactでのSEOライクな無限スクロールの実装についての情報はほとんど調べても出てこないですし、ましてやreact-windowに限った話だともう皆無です(一応issueとして上がっていますがベストプラクティスはなさそう)。 react-windowの仕様上、完璧ではないですがぬこぷろはできるだけGoogleが推奨する無限スクロールで実装しています。

まだまだ未熟者

いらすとや_滝に打たれる修行僧のイラスト.png
私もぬこぷろもまだまだ未熟者です。

私自身、元々文系出身ですし、未経験エンジニアとして転職しました。情報系の大学でプログラミングをしてきた人やエンジニアから社会人スタートしている人たちと比べると、まだまだ私もエンジニアとして未熟だと思います。

そしてぬこぷろにも課題はたくさんあります。例えば、本の評価づけはまだまだチューニングが必要ですし、静的ファイルにビルドする時もサーバーへの負荷がかなり高かったり、色々課題はあります。

今後もぬこぷろの課題を一つ一つ解決していきながら、私自身も成長いきたいです。

最後に、この言葉を引用させてもらいます。

やってみなはれ

これはサントリー創業者の鳥井信治郎の言葉です。

創業者鳥井信治郎は、どんな苦境に陥ちこんでも自身とその作品についての確信を捨てず、そして、たたかれてもたたかれてもいきいきとした破天荒の才覚を発揮しつづけた人であった。 それを最も端的に伝える言葉として彼がことあるごとに口にした日本語が『やってみなはれ』である。
引用元:やってみなはれ精神が生み出したフロンティア製品

別にサントリー社員でも何でもないのですが、私はこの言葉が好きです。
失敗を恐れて行動できなかったり、何かに悩んで一歩踏み出せないことはたくさんあると思います。けれど、失敗から学ぶことも多いですし、一歩踏み出すと何か得られるものもあります。

開発でも同じことです。「このアイディアが本当にユーザーにウケるのかわからない」、「何を作れば良いかわからない」と考えて行動をストップさせているのはもったいないです。
小難しく考えず「やってみなはれ」なスタンスで、プレモル:beer:でも飲みながら気楽のコーディングしましょう!Good Luck!

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

[Tips] Jest で private readonly な値をモックする方法

はじめに

Jest でクラスの private readonly な変数を差し替えたい時に若干引っかかったのでメモっておきます。タイトルでは Jest とありますが、本記事の内容は JavaScript でモックする際の有効な手法の 1 つとして利用することが可能です。

Object.defineProperty を利用して値を差し替える

結論から言うと変数を差し替えたい場合は下記のような記述になります。

const mockValue = "";
Object.defineProperty(service, "privateReadOnlyValue", {
  value: mockValue,
});

ちなみに関数を差し替えたい場合は下記のような記述になります。

Object.defineProperty(service, "privateSumFunction", {
  value: jest.fn((a, b) => a + b),
});

各種テストケースで使いまわしているインスタンスの private readonly な変数をモックした場合、値をリストアしたいケースも出てきました。その場合の記述としては、下記が有効でした。

// tmpService 変数に service インスタンスを clone して利用する
const tmpService = Object.create(service);
Object.defineProperty(tmpService, "privateReadOnlyValue", {
  value: "",
});

おわりに

Object.definePropertyObject.create を駆使すれば大体のケースでは事足りそうです :relaxed:

参考リンク

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

営業の私がGoogle Apps Script【GAS】を使って営業の業務を自動化・効率化していく話。

はじめに

※この記事は随時更新しています。(最終更新日:2021/3/8)

私は小さなIT企業で営業をしています。
もともとはエンジニアでしたが、会社都合で入社から3年後にして営業をしています。(謎)
営業の業務って本当に無駄な業務が多いんですよね・・・。

特に私の今いる会社では、人数がとても少ないので、一言で営業といっても単に顧客へのセールスだけが仕事というわけではありません。

契約等の事務処理、契約書作成、プロダクト開発、マーケティング、広報・・・
あげればキリがありません。もはや万屋です。

[営業]という切り口から、現在の繰り返しの業務を上げると、たくさん出てきます。
・CRMでの案件作成
・見積書の作成・提出
・契約書の作成・提出・受領
・契約書の締結
・稟議起案・押印
・CRMでの案件情報更新
・顧客への案内(契約書、見積書の送付等)

こんな同じことの繰り返し業務を営業は当たり前のようにやっているわけです。
もう、やめませんかこんな単純な作業と訴えたいのが私の思いであり、誰もやっていないところだったので、
自分が上記の業務を全部効率化・自動化したったろ!というのが志であります。

というわけで、この記事では、私が日頃行う業務をGASをつかってどうやってどのくらい効率化・自動化できるかをまとめて行くことにします。

会社で使っているツール一覧

  • プラットフォーム:Googple Gsuite
  • コミュニケーション:Slack
  • SCM:Salesforce
  • BIツール:TableauOnline(念の為)
  • 稟議:承認Time

自動化・効率化の前にやるべきこと

業務を自動化する上で、実際にコーディング(自動化)する前にやるべきことがあります。

  • 現状の業務フローを整理(大枠からPCの操作レベルまで詳細を整理)
  • 自動化・効率化するためのフロー設計
  • モックアップ作成

とはいえ、上記ができるのは過去に経験者であればできると思いますが、私は一連の流れを経験したことがないので、
できるだけ上記をまず優先的にやりつつも、試験的にコーディングを並行してやって行くことにします。

業務フローの整理(大枠)

  1. 見積書作成
    1. [会社名]・[見積N],[金額]などの項目を見積書に記入
    2. 完成した見積書をPDF形式でダウンロードして指定のフォルダに保存
  2. 申込書の作成
    1. 申込書に必要な情報記入
    2. EXCEL形式で新しくファイルを指定のフォルダに保存
  3. 作成した見積書・申込書・利用規約をメールでクライアントに送る

フロー設計

 ※随時更新します。

モックアップ

※随時更新します。

コード

 ※随時更新します。

まとめ

 ※随時更新します。

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

Form SubmitでAPIコールすると失敗

フロントエンド(HTML/CSS/JavaScript)強化中で、いろいろ試している所です。

HTMLでFormを作成して、画面操作を契機としてJavaScript(Vue.js)の処理を連動させてAPIコールする、というコードを書きましたが、つまづきました。

いろいろと試してみた結果、
・<input type="submit" だと失敗
・<input type="button" だと成功
という結果となりました。

開発内容

Vue.jsを利用して、画面操作を契機にAPIコールすることを試すだけが目的ですので、開発内容は非常に単純です。
要件:
・郵便番号をテキストボックスから入力し、ボタンクリックしたことを契機にAPIをコール。
・利用したAPI: https://zipaddress.net/
・APIのレスポンスから、都道府県名を取得してアラート表示。
・APIコールでExceptionが発生した場合: アラート「Fail to call API」
・APIのレスポンスに、異常値が設定されていた場合: アラート「Error was returned」

最初に書いたコード

practice_submit.html
<!DOCTYPE html><html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script>
</head>
<body>
    <div id="apiTest">
        <h2>Form Click</h2>
        <form method="post">
        <tr>
            <td>郵便番号</td>
            <td><input type="text" v-model="zipcode"/></td>
        </tr>
            <input type="submit" @click="getAddress" value="送信"/>
        </form>
    </div>
    <script type="text/javascript" src="./practicevue.js"></script>
</body>
</html>
practice.js
new Vue({
    el: "#apiTest",
    data: {
        zipcode: ""
    },
    methods: {
      getAddress: function() { 
        var url = new URL('https://api.zipaddress.net/');
        var param = {zipcode: this.zipcode};
        url.search = new URLSearchParams(param);

        fetch(url)
        .then(response => {
          return response.json();
        })
        .then(function(jsonData){
            if(jsonData.code == '200') {
              alert(jsonData.data.pref);
            } else {
              alert('Error was returned');
            }
        }).catch(err => {
          alert('Fail to call API');
        });
      }
    }
})

画面

以下のように、郵便番号を入力するためのテキストボックスと送信ボタンがあるだけの単純な画面です。
スクリーンショット 2021-01-21 0.22.44.jpg

発生した問題

上記のvuepractice.js で、fetch(url)を行った結果、Exceptionが発生してしまったようです。
以下のアラートメッセージが表示されたので、APIコールしたサーバからエラーが返されたのではなく、
「 }).catch(err => { 」
に該当して、HTTPリクエスト送信処理自体で例外が発生した、と考えられます。

スクリーンショット 2021-01-21 0.13.40 copy.jpg

        }).catch(err => {
          alert('Fail to call API');
        });

解決方法

いろいろ試してみましたが、HTMLのinput属性を、submitからbuttonに変更するだけで、解決しました。
変更前: <input type="submit" @click="getAddress" value="送信"/>
変更後: <input type="button" @click="getAddress" value="送信"/>

郵便番号に「1000000」を設定して送信ボタンをクリックした結果、想定通りに都道府県名を正常に取得できました。

スクリーンショット 2021-01-24 18.15.20.jpg

修正後のHTML

上記の通り、1行変更しただけですが、修正後のHTMLは以下です。

practice_submit.html(修正後)
<!DOCTYPE html><html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script>
</head>
<body>
    <div id="apiTest">
        <h2>Form Click</h2>
        <form method="post">
        <tr>
            <td>郵便番号</td>
            <td><input type="text" v-model="zipcode"/></td>
        </tr>
            <input type="button" @click="getAddress" value="送信"/>
        </form>
    </div>
    <script type="text/javascript" src="./practicevue.js"></script>
</body>
</html>

考察

buttonと、submitの違いだけでなぜこうなる...?
いまいちわかりませんでしたが、これで良しとしました。

しかし、ここで気付きました。
「submit」しないのでれば、formである必要がないじゃないか!!

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

ElectronのexecuteJavascriptでError: Script failed to executeが出る件

はじめに

初投稿です...
ElectronのBrowserViewでexecuteJavascriptを使った際、一見正しそうなスクリプトがエラーを吐く問題で少々悩んだので備忘録として。

問題のソースコード

要素の取得に失敗したらfalse、成功したら真を返したい

      const result = await browserView.webContents.executeJavaScript(`
        const anko = document.getElementById("anko"); 
        return anko == null ? false : true;
      `)

正しいソースコード

どうやらreturnで返しちゃだめらしいです。(そんなん知らんよ…)
最後に評価された値が返却されるらしい。

      const result = await browserView.webContents.executeJavaScript(`
     let val = true;
        const anko = document.getElementById("anko"); 
        anko == null ? false : true;
      `)

現在作っているアプリ

Youtube Live用のコメントビュワーです(Electron製)(ベータ版)
配信する方はぜひどうぞ
- https://tubug.netlify.app

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