WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

JavaScript の非同期関数

by WebSurfer 2022年11月23日 12:12

JavaScript の習得が遅々として進まない自分には、昔は無かった class とか => とか、さらには非同期関数とかが出てきて、Q&A サイトなどでの話題についていけない状況にあります。(汗)

というわけで、JavaScript の非同期についてちょっとだけ勉強してみました。以下に勉強したことを忘れないように書いておきます。

まず、非同期関数ですが、MDN のドキュメント「非同期関数」の説明が自分的には分かりやすいと思いました。

そのドキュメントによると "非同期関数は async キーワードで宣言され、その中で await キーワードを使うことができます" ということで、ドキュメントのサンプルコード(下に転載します)の asyncCall を非同期関数と呼ぶようです。

function resolveAfter2Seconds() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('resolved');
    }, 2000);
  });
}

async function asyncCall() {
  console.log('calling');
  const result = await resolveAfter2Seconds();
  console.log(result);
  // expected output: "resolved"
}

asyncCall();

非同期関数の利点は、"async および await キーワードを使用することで、プロミスベースの非同期の動作を、プロミスチェーンを明示的に構成する必要なく、よりすっきりとした方法で書くことができます" ということだそうです。

ということで、上で言う「プロミスベースの非同期の動作」すなわちコードに出てくる Promise, resolve が JavaScript における非同期関数の核心のようです。

まず、Promise というのは何かですが、自分的に分かりやすかったのは「現代の JavaScript チュートリアル」の Promise と「JavaScript Primer」の [ES2015] Promise という記事でした。(MDN のドキュメント「プロミスの使用」も読みましたがほとんど分かりませんでした)

記事に書いてあったことを要約して以下に書きます。(解釈が間違っているところもあるかも)

Promise は非同期処理の最終的な完了もしくは失敗を表すオブジェクトで、基本的に以下のように作成します。

let promise = new Promise((resolve, reject) => {
  // 処理
});

上の Promise コンストラクタの引数に渡している関数 (resolve, reject) => { // 処理 } は executor と呼ばれる関数で、resolve と reject という 2 つの引数を取ります。

resolve と reject は JavaScript に定義済みのコールバックで、以下のように executor の結果に応じていずれかを呼び出す必要があります。

  • resolve(value) – 正常に終了した場合。Promise の result プロパティを value に設定。
  • reject(error) – エラーが発生した場合。Promise の result プロパティを error に設定。

具体例は上のコードの resolveAfter2Seconds 関数を見てください。Promise オブジェクトを生成してそれを戻り値として返すようにしています。

asyncCall 関数が resolveAfter2Seconds 関数を呼ぶと、resolveAfter2Seconds 関数は Promise オブジェクトを生成して即座に戻り値として返します。

Promise コンストラクタの引数の executor 関数は new Promise の時点で自動的かつ即座に実行されます。

executor 関数内の setTimeout により 2 秒後に resolve('resolved') が呼ばれて、Promise オブジェクトの result プロパティは 'resolved' という文字列に設定されます。

asyncCall 関数内で resolveAfter2Seconds 関数に await が付与されているところに注目してください。await よって、resolveAfter2Seconds が戻り値として返した Promise が resolve されるまで次の行の console.log(result); には進まず待機します。

さらに await によって Promise オブジェクトの result プロパティのオブジェクトが取り出されて渡されます。上のコード例では resolve('resolved') としているので 'resolved' という文字列が渡されます。

そのあたりは C# の非同期処理で使う Task<T>, async, await と同様なようです。

実際に使ってみると理解が深まると思って、先の記事「canvas の画像をアップロード (その 2)」の JavaScript のコードに非同期関数を適用してみました。そのコードを載せておきます。

input type="file" でファイルの選択が完了すると発生する change イベントのリスナを非同期関数式にして、その中で Promise を使った関数 CreateDataUrl と CreateImage を await を付与して呼び出すようにしています。詳しくはコード内のコメントを見てください。

@{
    ViewData["Title"] = "Canvas2";
}

<h1>Canvas2</h1>

<input type="file" id="file1" />
<input id="button1" type="button" value="Upload" style="display:none;" />
<br />
<div id="msg"></div>
<canvas id="mycanvas"></canvas>
<div id="result"></div>

@section Scripts {
    <script type="text/javascript">
        //<![CDATA[

        const maxFileSize = 500000;
        const allowedContentType = "image/jpeg";

        const maxWidth = 500;
        const maxHeight = 500;

        let inputFile, uploadButton, msgDiv, myCanvas, resultDiv;

        // input type="file" から取得できる File オブジェクトを引数
        // に渡す。FileReader オブジェクトを生成して readAsDataURL 
        // メソッドで Data url を生成し、resolve コールバックの引数
        // に設定する 
        const CreateDataUrl = file => {
            return new Promise(resolve => {
                const reader = new FileReader();
                reader.addEventListener('loadend', 
                                e => resolve(e.target.result));
                reader.readAsDataURL(file);
            });
        };

        // Data url を引数に渡す。Image オブジェクトを生成しその 
        // src に Data url を代入。ロード完了後の Image オブジェ
        // クトを resolve コールバックの引数に設定する
        const CreateImage = dataUrl => {
            return new Promise(resolve => {
                const img = new Image();
                img.addEventListener('load', 
                                     e => resolve(e.target));
                img.src = dataUrl;
            });
        };

        // DOMContentLoaded イベントで初期設定を行う
        window.addEventListener('DOMContentLoaded', () => {

            inputFile = document.getElementById("file1");
            uploadButton = document.getElementById("button1");
            msgDiv = document.getElementById("msg");
            myCanvas = document.getElementById("mycanvas");
            resultDiv = document.getElementById("result");

            if (window.File && window.FileReader && window.FileList) {

                uploadButton.addEventListener('click', uploadImage);

                // リスナを非同期関数式にして、リスナの中で Promise 
                // を使った関数 CreateDataUrl と CreateImage を 
                // await を付与して呼び出す。
                inputFile.addEventListener('change', async () => {
                    resultDiv.innerText = "";

                    if (ClientValidate(inputFile) == false) {
                        uploadButton.style.display = "none";
                        myCanvas.style.display = "none";
                        return;
                    }

                    //  選択された画像の Data url を取得
                    let dataUrl = 
                        await CreateDataUrl(inputFile.files[0]);

                    // 画像をロードした Image オブジェクトを取得
                    let img = await CreateImage(dataUrl);

                    // canvas に画像を描画
                    DrawImageOnCanvas(img);
                });
            }
            else {
                inputFile.style.display = "none";
                myCanvas.style.display = "none";
                msgDiv.innerText =
                    "File API がサポートされていません。";
            }
        });

        // 選択されたファイルの検証のためのヘルパ関数
        function ClientValidate(fileUpload) {
            msgDiv.innerText = "";

            if (fileUpload.files[0] == null) {
                msgDiv.innerText = "ファイルが未選択です。";
                return false;
            }

            if (fileUpload.files[0].type != allowedContentType) {
                msgDiv.innerText = "選択されたファイルのタイプが " +
                    allowedContentType + " ではありません。";
                return false;
            }

            if (fileUpload.files[0].size > maxFileSize) {
                msgDiv.innerText = "ファイルのサイズが " +
                    maxFileSize + " バイトを超えています。";
                return false;
            }

            return true;
        }

        // 画像が読み込み済みの Image オブジェクトを引数に受け取る。
        // その画像を指定されたサイズに縮小して canvas に描画
        function DrawImageOnCanvas(image) {
            // オリジナル画像のサイズ
            const w = image.width;
            const h = image.height;

            let targetW, targetH;
            const context = myCanvas.getContext('2d');

            if (w <= maxWidth && h <= maxHeight) {
                // w, h ともに制限 maxWidth, maxHeight 以内 ⇒
                // そのままのサイズで canvas に描画
                myCanvas.setAttribute('width', w);
                myCanvas.setAttribute('height', h);
                context.drawImage(image, 0, 0);
            }
            else if (w < h) {
                // w, h どちらかが制限オーバーで h の方が大きい ⇒
                // 高さを maxHeight に縮小
                targetH = maxHeight;
                // 幅は高さの縮小比率で縮小
                targetW = Math.floor(w * targetH / h);
                myCanvas.setAttribute('width', targetW);
                myCanvas.setAttribute('height', targetH);
                context.drawImage(image, 0, 0, targetW, targetH);
            }
            else {
                // w, h どちらかが制限オーバーで w の方が大きい ⇒
                // 幅を maxWidth に縮小
                targetW = maxWidth;
                // 高さは幅の縮小比率で縮小
                targetH = Math.floor(h * targetW / w);
                myCanvas.setAttribute('width', targetW);
                myCanvas.setAttribute('height', targetH);
                context.drawImage(image, 0, 0, targetW, targetH);
            }

            uploadButton.style.display = "inline-block";
            myCanvas.style.display = "block";
        }

        // canvas の画像データを DataURL 形式で取得し、JSON 文
        // 字列を組み立てて fetch API でサーバーに送信する
        async function uploadImage() {
            const context = myCanvas.getContext('2d');
            const dataUrl = context.canvas.toDataURL("image/jpeg");
            const params = {
                method: "POST",
                body: '{"imgBase64":"' + dataUrl + '"}',
                headers: { 'Content-Type': 'application/json' }
            }
            const response = await fetch("/api/Canvas", params);
            if (response.ok) {
                const message = await response.text();
                resultDiv.innerText = message
            } else {
                resultDiv.innerText = "アップロード失敗";
            }
        }

        //]]>
    </script>
}

非同期関数がある程度理解できれば、先の記事「canvas の画像をアップロード (その 2)」のコードに比べてかなり読みやすくなったような気はします。

Tags: , , , , ,

JavaScript

JavaScript で Html Escape

by WebSurfer 2021年5月18日 11:25

ユーザー入力文字列のサニタイジング(無害化)のためのエスケープ処理を JavaScript で行うためのコードを備忘録として書いておきます。以下のコードは ES6 をサポートしてない IE11 でも動くことは確認しました。

<script type="text/javascript">
    //<![CDATA[

    function htmlEscape(str) {
        if (!str) return;
        return str.replace(/[<>&"'`]/g, function (match) {
            const escape = {
                '<': '&lt;',
                '>': '&gt;',
                '&': '&amp;',
                '"': '&quot;',
                "'": '&#39;',
                '`': '&#x60;'
            };
            return escape[match];
        });
    }

    // 以下は検証用
    var str = '& < > ` " ' + "'";
    console.log(htmlEscape(str));

    // 結果 (IE11 も同様):
    // &amp; &lt; &gt; &#x60; &quot; &#39;

    //]]>
</script>

そんな情報はググればすぐ出てくると言われるかもしれませんが、基本的にネットの記事は自分で検証して確認するまで信用できない疑り深い性格なものですから。(笑)

クロスサイトスクリプティング(XSS)対策の一つに、ユーザー入力文字列のサニタイジング(無害化)がありますが、その目的で上記コードは文字列内の < > & " ' ` の 6 文字を &lt; &gt; &amp; &quot; &#39; &#x60; にエスケープ(変換)しています。

ASP.NET MVC などではフレームワーク組み込みでサニタイジング機能が含まれていますので、Html ヘルパーや Tag ヘルパーを使っている限り上のスクリプトの使い道はないです。

では、どういうときに使うかと言うと、例えば以下のように、Web API などから取得した文字列で html ソースを組み立てる場合でしょうか。

function getCars() {
  $.ajax({
    type: "POST", 
    url: "Api/GetCarsByDoors", 
    data: '{"doors":' + $("#ddlDoors").val() + '}', 
    contentType: "application/json; charset=utf-8",
 
    success: function (cars) {      
      $('#output').empty();
      $.each(cars, function (index, car) {
        $('#output').append(
          '<p><strong>' + car.Make + ' ' +
          car.Model + '</strong><br />Description: ' +
          htmlEscape(car.Description) + '</p>');
        });
      }
  });
}

Tags: , ,

JavaScript

ASP.NET Core MVC 検証属性の自作

by WebSurfer 2020年2月3日 14:52

@IT の連載に「自作の検証属性を定義する(クライアントサイド編)」という .NET Framework MVC アプリにクライアント側で JavaScript による検証を含めて検証属性を自作する記事があります。ASP.NET Core 3.1 MVC ではそれと同等の検証属性をどのように実装するかという話です。

Custom Validator

(注:プロパティにまたがる検証を行うための CustomValidationAttribute の話ではなく、一つのフィールドの内容を独自の条件で検証するためのものです)

.NET Framework MVC との違いは IClientValidatable の代わりに IClientModelValidator インターフェイスを継承し AddValidation メソッドを実装するところです。

Model、検証属性の定義、クライアント側での検証用 JavaScript のコードを以下にアップしておきます。注意点はそれらに書きましたので 見てください。

Model

Model は .NET Framework MVC のものと全く同じです。自作の検証属性 InArrayAttribute は Publish プロパティに付与されています。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Globalization;

namespace MvcCoreApp.Models
{
  public class Book
  {
    [Key]
    [Display(Name = "ISBNコード")]
    [Required(ErrorMessage = "{0}は必須です。")]
    [RegularExpression(
      "[0-9]{3}-[0-9]{1}-[0-9]{3,5}-[0-9]{3,5}-[0-9A-Z]{1}",
      ErrorMessage = "{0}はISBNの形式で入力してください。")]
    public string Isbn { get; set; }

    [Display(Name = "書名")]
    [Required(ErrorMessage = "{0}は必須です。")]
    [StringLength(100, 
      ErrorMessage = "{0}は{1}文字以内で入力してください。")]
    public string Title { get; set; }

    [Display(Name = "価格")]
    [Range(100, 10000, 
      ErrorMessage = "{0}は{1}~{2}の間で入力してください。")]
    public int? Price { get; set; }

    [Display(Name = "出版社")]
    [StringLength(30, 
      ErrorMessage = "{0}は{1}文字以内で入力してください。")]
    [InArray("翔泳社,技術評論社,秀和システム," + 
      "毎日コミュニケーションズ,日経BP社,インプレスジャパン")]
    public string Publish { get; set; }

    [Display(Name = "刊行日")]
    [Required(ErrorMessage = "{0}は必須です。")]
    public DateTime Published { get; set; }
  }
}

自作の検証属性

出版社のリストとの比較検証を行う InArrayAttribute を定義します。IClientModelValidator を継承しているところと、AddValidation メソッドの実装に注目してください。(ちなみに .NET Framework MVC では IClientValidatable インターフェイスの GetClientValidationRules メソッドを使います)

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

namespace MvcCoreApp.Models
{
  [AttributeUsage(AttributeTargets.Property, 
                  AllowMultiple = false)]
  public class InArrayAttribute : 
               ValidationAttribute, IClientModelValidator
  {
    // 値リストを表すプライベート変数
    private string _opts;

    // コンストラクタ(値リストとエラーメッセージを設定)
    public InArrayAttribute(string opts)
    {
      this._opts = opts;
      this.ErrorMessage = 
            "{0} は「{1}」のいずれかで指定してください。";
    }

    // プロパティの表示名と値リストでエラーメッセージ作成
    public override string FormatErrorMessage(string name)
    {
      return String.Format(CultureInfo.CurrentCulture, 
                           ErrorMessageString, name, _opts);
    }

    // サーバー側での検証
    // 値リストに入力値が含まれているかをチェック
    public override bool IsValid(object value)
    {
      // 入力値が空の場合は検証をスキップ 
      if (value == null) { return true; }

      // カンマ区切りテキストを分解し、入力値valueと比較 
      if (Array.IndexOf(_opts.Split(','), value) == -1)
      {
        return false;
      }
      return true;
    }

    // IClientModelValidator が実装するメソッド。
    // 検証対象の html 要素 (input) に控えめな JavaScript
    // による検証のための属性 (data-val 他) と値を追加
    public void AddValidation(
            ClientModelValidationContext context)
    {
      MergeAttribute(context.Attributes, "data-val", "true");
      var errorMessage = FormatErrorMessage(
          context.ModelMetadata.GetDisplayName());
      MergeAttribute(context.Attributes, 
                     "data-val-inarray", errorMessage);
      MergeAttribute(context.Attributes, 
                     "data-val-inarray-opts", this._opts);
    }

    // 上の AddValidation メソッドで使うヘルパーメソッド
    private bool MergeAttribute(
                  IDictionary<string, string> attributes, 
                  string key, string value)
    {
      if (attributes.ContainsKey(key))
      {
        return false;
      }
      attributes.Add(key, value);
      return true;
    }
  }
}

検証用 JavaScript(View に組み込み)

View にインラインで書いていますがコード自体は @IT の記事のものと同じです。検証用 JavaScript ライブラリに自作の検証スクリプトをどのように追加し、連携を取って動くようにするかという点がキモです。後者については、Unobtrusive Client Validation in ASP.NET MVC 3 という記事の中の Single value validators というセクションに記述がありました。正直読んでもよく分からなかったですが。(汗)

@model MvcCoreApp.Models.Book

・・・中略・・・

<div class="form-group">
  <label asp-for="Publish" class="control-label"></label>
  <input asp-for="Publish" class="form-control" />
  <span asp-validation-for="Publish" class="text-danger">
  </span>
</div>

・・・中略・・・

@section Scripts {
  @{
    await 
    Html.RenderPartialAsync("_ValidationScriptsPartial");
  }

  <script type="text/javascript">
    $.validator.addMethod("inarray",
      function (value, element, parameters) {
        // 入力値が空の場合は検証をスキップ
        value = $.trim(value);
        if (value === '') {
          return true;
        }

        // カンマ区切りテキストを分解し、入力値valueと比較
        if ($.inArray(value, parameters.split(',')) === -1) {
          return false;
        }
        return true;
      });

    $.validator.unobtrusive.adapters.
                addSingleVal('inarray', 'opts');
  </script>
}

Tags: , , ,

Validation

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年5月  >>
2829301234
567891011
12131415161718
19202122232425
2627282930311
2345678

View posts in large calendar