Angular でモデル駆動型フォームを実装してみた

Overview

Angular でフォームを実装してみた では, より手軽に実装可能な, テンプレート駆動型のフォームについて記載しました. テンプレート駆動型のフォームでは, 検証ルールをテンプレートに記述していました.

検証ルールはテンプレートに記述するだけではなく, コンポーネント側に記述することも可能です. これが, モデル駆動型 (Reactive 駆動型) のフォームです. テンプレート駆動型のフォームと比較すると, 冗長なコードになってしまいますが, より柔軟に, 複雑な検証ルールを実装できます.

ReactiveFormsModule

モデル駆動型のフォームを実装するには, FormsModule の代わりに, ReactiveFormsModule をインポートする必要があります.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    ReactiveFormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

そして, テンプレートとコンポーネントは以下のようになります.

<form [formGroup]="myForm" (ngSubmit)="show()">
  <div>
    <label>メールアドレス: <input type="email" name="mail" [formControl]="mail" /></label>
    <span *ngIf="mail.errors?.required">メールアドレスは必須です.</span>
    <span *ngIf="mail.errors?.email">メールアドレスを正しい形式で入力してください.</span>
  </div>
  <div>
    <label>パスワード: <input type="password" name="password" [formControl]="password" /></label>
    <span *ngIf="password.errors?.required">パスワードは必須です.</span>
    <span *ngIf="password.errors?.minlength">パスワードは 6 文字以上で入力してください.</span>
  </div>
  <div>
    <label>名前: <input type="text" name="name" [formControl]="name" /></label>
    <span *ngIf="name.errors?.required">名前は必須です.</span>
    <span *ngIf="name.errors?.minlength">名前は 3 文字以上で入力してください.</span>
    <span *ngIf="name.errors?.maxlength">名前は 10 文字以内で入力してください.</span>
  </div>
  <div>
    <label>備考: <textarea name="memo" [formControl]="memo"></textarea></label>
    <span *ngIf="memo.errors?.maxlength">備考は 10 文字以内で入力してください.</span>
  </div>
  <div>
    <button type="submit" [disabled]="myForm.invalid">送信</button> 
  </div>
</form>
import { Component } from '@angular/core';
import {
  FormGroup,
  FormControl,
  FormBuilder,
  Validators
} from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private title = 'app';

  private mail     = new FormControl('', [Validators.required, Validators.email]);
  private password = new FormControl('', [Validators.required, Validators.minLength(6)]);
  private name     = new FormControl('', [Validators.required, Validators.minLength(3), Validators.maxLength(10)]);
  private memo     = new FormControl('', [Validators.required, Validators.maxLength(6)]);

  private myForm = this.builder.group({
    mail     : this.mail,
    password : this.password,
    name     : this.name,
    memo     : this.memo
  });

  constructor(private builder: FormBuilder) {
  }

  show() {
    console.log(this.myForm.value);
  }
}

モデル駆動型のフォームにおいて, 重要となる, FromControl, FormGroup について解説します.

FormControl

FormControl は, コンポーネント内で, そのインスタンスを生成して利用します.

new FormControl(value [, validators])

第 1 引数は, フォームの初期値, 第 2 引数は, (必要であれば) 検証ルールを配列で指定します. 検証ルールには, Validators クラスを利用することができます.

  private mail     = new FormControl('', [Validators.required, Validators.email]);
  private password = new FormControl('', [Validators.required, Validators.minLength(6)]);
  private name     = new FormControl('', [Validators.required, Validators.minLength(3), Validators.maxLength(10)]);
  private memo     = new FormControl('', [Validators.required, Validators.maxLength(6)]);

FormGroup

FormGroup は, FormControl インスタンスを束ねる役割をもっています. これによって, フォーム全体でまとめて, エラーが発生していないかを検証することが可能になります.

FormGroup を利用するには, コンポーネントのコンストラクタで, FormBuilder 型の引数をうけとるようにします (ちなみに, これは依存性注入 (DI) と呼ばれるデザインパターンの 1 種です).

  constructor(private builder: FormBuilder) {
  }

FormControl インスタンスを束ねるには, FormBuilder インスタンスの group メソッドを利用します.

  private myForm = this.builder.group({
    mail     : this.mail,
    password : this.password,
    name     : this.name,
    memo     : this.memo
  });

テンプレートとのひもづけ

コンポーネント側で必要な実装ができれば, あとは, コンポーネントとテンプレートをひもづけるだけです. そのためには, formGroup ディレクティブと formControl ディレクティブを利用します.

<form [formGroup]="myForm" (ngSubmit)="show()">
  <div>
    <label>メールアドレス: <input type="email" name="mail" [formControl]="mail" /></label>
    <span *ngIf="mail.errors?.required">メールアドレスは必須です.</span>
    <span *ngIf="mail.errors?.email">メールアドレスを正しい形式で入力してください.</span>
  </div>
    <!-- ... -->
</form>

Angular でファイルアップローダーを実装してみた

1. Overview

前回に引き続き, Angular でのフォーム実装を学びます. 今回は, ファイルアップローダーの実装をとおして, Angular における, HTTP クラアントの実装と, 非同期処理の実装について学びます.

2. HttpClientModule / HttpClient

Angular で HTTP クライアントを実装するには, HttpClientModule / HttpClient を利用します.

src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

src/app/app.component.ts

import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private title = 'File Uploader by Angular';

  constructor(http: HttpClient) {
  }
}

HttpModule / Http を利用しているサンプルコードなどもありますが, それらは非推奨なので注意してください.

3. RxJS

RxJS (Reactive Extensions Library for JavaScript) とは, 非同期およびコールバックベースのコードを, 関数的, かつ, リアクティブなスタイルで作成するためのライブラリです. Angular では, 非同期処理にこの RxJS を内部的に利用しています.

3. Implement

それでは, 実際に実装をしてみます. まず, 必要となるファイルアップロードサーバーを実装する必要があります. 言語はなんでも構いません. 今回は, Go で実装してみました.

uploader.go

package main

import (
    "encoding/json"
    "io"
    "net/http"
)

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    reader, err := r.MultipartReader()

    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    h := w.Header()

    h.Set("Access-Control-Allow-Origin", "*")
    h.Set("Content-Type", "application/json")

    for {
        part, err := reader.NextPart()

        if err == io.EOF {
            break
        }

        if part.FileName() == "" {
            continue
        }

        res, err := json.Marshal(part)

        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        w.Write(res)
    }
}

func main() {
    http.HandleFunc("/upload", uploadHandler)
    http.ListenAndServe(":8080", nil)
}

Angular の実装です. まずは, テンプレートの実装です.

src/app/app.component.html

<form>
  <input #uploader type="file" accept="image/*" (change)="upload(uploader.files)" />
<form>

#uploader はテンプレート参照変数です. change は, イベントバインディングで, ファイルダイアログでファイルが選択されたときに発火します.

次に, コンポーネントの実装です.

src/app/app.component.html

import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private title = 'File Uploader by Angular';

  constructor(private http: HttpClient) {
  }

  upload(files: Array) {
    if (files.length <= 0) {
      return;
    }

    const file = files[0];
    const formData = new FormData();

    formData.append('file', file, file.name);

    this.http.post('http://localhost:8080/upload', formData)
      .subscribe(
        data => console.log(data),
        error => console.log(error)
      );
  }
}

Angular の処理が関連しているのは, HttpClient#post メソッドの部分です. HttpClient#post メソッドは, 第 1 引数にリクエスト先の URL を, 第 2 引数に POST するデータを指定します. Http#post メソッドは, RxJS の Observable を返します. Observable からデータを取得するには, Observable#subscribe メソッドを利用します. Observable#subscribe メソッドは, Observer インスタンスを引数にとります. Observer は onNext / onError / onCompleted をもっており, Observable によって呼びだされ, それぞれ, ストリームの値, エラー, 完了を通知します (上記の, サンプルでは, onCompleted は利用していませんが …).

以上で実装は完了です.

動作確認するには, まず, ファイルアップロードサーバーをバックグランドで起動しておきます.

$ go run uploader.go &

あとは, npm start を実行して, ローカルサーバーを起動します.

$ npm start

ファイルアップロードをして, ストリームの値が取得できれば成功です.

Angular でフォーム実装してみた

1. Overview

フォームの実装をとおして, Angular (Angular 2 〜) の基礎 (の一部) を学んでみました. その備忘録です.

2. Syntax highlighting of TypeScript by Vim

と … その前に, 私はエディタに Vim を利用しています. Angular では, アプリケーションコードを TypeScript で記述する (Angular 自体も TypeScript で実装されています) ので, TypeScript のシンタックスハイライトを導入します.

プラグインマネージャーは Vundle を利用します.

まずは, 以下のリポジトリを clone します.

$ git clone https://github.com/gmarik/Vundle.vim.git ~/.vim/bundle/Vundle.vim

.vimrc に以下の記述を追加します.

filetype off

set rtp+=~/.vim/bundle/Vundle.vim

call vundle#begin()

Plugin 'gmarik/Vundle.vim'
Plugin 'leafgarland/typescript-vim'

call vundle#end()

filetype plugin on

 

そして, 以下のコマンドを実行します.

vim +PluginInstall +qall

3. Angular CLI

Angular は, クライアントサイドを (あえて) MVC に分類すると, すべてを担う, フルスタックフレームワークです (ちなみに, React は View のみとシンプル). そのため, (個人的主観ではありますが) ファイル構成が複雑で, 環境構築にもコストを要します. アプリケーションを実装するたびにこれらの作業をこなすのは現実的ではありません.

Angular CLI はこの問題を簡単に解決してくれます. Angular CLI を利用することで, コマンド 1 つで, 最低限必要なファイルの作成や環境構, つまり, アプリケーションの雛形生成を自動でおこなってくれます. Amngular CLI を利用するには, npm でインストールします.

$ npm install -g @angular/cli

アプリケーションの雛形を生成するには, ng new を実行します. 今回は, アプリケーション名を form とします.

$ ng new form
$ cd form

 

アプリケーション名でディレクトリが生成されるので, そのディレクトリに移動し, ls でファイルを確認すると, 多くのファイルやディレクトリが生成されていることがわかります.

% ls -A
.editorconfig     README.md         e2e               package-lock.json src               tslint.json
.gitignore        angular.json      node_modules      package.json      tsconfig.json

これは便利ですね.

3. Implement Form

準備ができたのでフォームを実装します. まずは, src/app/app.module.ts に FormsModule を追加します.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

次に, src/app/app.component.ts の AppComponent に, プロパティとメソッドを定義します.

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  private title = 'Form by Angular';
  private user = {
    mail     : 'xxx@gmail.com',
    password : '',
    name     : 'rilakkuma',
    memo     : ''
  };

  show() {
    Object.values(this.user).forEach(value => alert(value));
  }
}

そして, テンプレートである, app.component.html を以下のように記述します.

<form #myForm="ngForm" (ngSubmit)="show()" novalidate>
   <div>
     <label>メールアドレス : <input type="email" name="mail" [(ngModel)]="user.mail" #mail="ngModel" required email /></label>
     <span *ngIf="mail.errors?.required">メールアドレスは必須です</span>
     <span *ngIf="mail.errors?.email">メールアドレスを正しい形式で入力してください</span>
   </div>
   <div>
     <label>パスワード : <input type="password" name="password" [(ngModel)]="user.password" #password="ngModel" required minlength="6"></label>
     <span *ngIf="password.errors?.required">パスワードは必須です</span>
     <span *ngIf="password.errors?.minlength">パスワードは 6 文字以上で入力してください</span>
   </div>
   <div>
     <label>名前 : <input type="text" name="name" [(ngModel)]="user.name" #name="ngModel" required minlength="3" maxlength="10" /></label>
     <span *ngIf="name.errors?.required">名前は必須です</span>
     <span *ngIf="name.errors?.minlength">名前は 3 文字以上で入力してください</span>
     <span *ngIf="name.errors?.maxlength">名前は 18 文字以上で入力してください</span>
   </div>
   <div>
     <label>備考 : <textarea name="memo" [(ngModel)]="user.memo" #memo="ngModel" maxlength="10"></textarea></label>
     <span *ngIf="name.errors?.maxlength">備考は 10 文字以内で入力してください</span>
   </div>
   <div>
     <button type="submit" [disabled]="myForm.invalid">送信</button>
   </div>
</form>

詳細を解説していきます.

まず, form タグには, それぞれ ngFrom ディレクティブ, ngSubmit イベントバインディング, novalidate 属性 (ブラウザによるネイティブの検証を無効にする属性です. Angular 4 〜 では, 自動で付与してくれるので不要です) を指定しています.

<form #myForm="ngForm" (ngSubmit)="show()" novalidate>

ngForm ディレクティブは, submit 時に, フォーム全体の検証をするために利用します.

<button type="submit" [disabled]="myForm.invalid">送信</button>

API が変わっているだけで, AngularJS での, ng-form, ng-submit と大差ないことに気づかれたでしょうか … ?

入力フォーム (email, password, text, textarea) です.

<label>メールアドレス : <input type="email" name="mail" [(ngModel)]="user.mail" #mail="ngModel" required email /></label>

Angular でのフォーム検証を有効にするためは, 双方向バインディング必要となるので, ngModel ディレクティブと要素を識別するための, name 属性が必須になります.

[(ngModel)]="user.mail"

では, AppComponent#user プロパティと双方向バインディングしています. また,

#mail="ngModel"

は, テンプレート参照変数で, ngModel ディレクティブを代入することで, フォーム要素の状態にアクセスできるようにします.

required, email, minlength, maxlength などは, 必要に応じて指定します.

そして, 不正な値であった場合のエラー表示に活躍するのが, *ngIf ディレクティブです (AngularJS での ng-if ディレクティブ).

<span *ngIf="mail.errors?.required">メールアドレスは必須です</span>

*ngIf ディレクティブは, その値が true であった場合のみ, 指定した要素を DOM に追加します. *ngIf ディレクティブと, フォームの検証の成否を組み合わせることで, 簡単にエラーメッセージの表示を実装することが可能です.

フォームの検証の成否の構文を一般化すると,

入力要素名 (テンプレート参照変数名).errors?.検証型

となります. ? は, Angular 2 〜 で利用可能な, Safe Navigation Operator で, errors が null や undefined であってもエラーを発生させることがありません (つまり, 安全にプロパティアクセス可能になります). 検証型には, フォームに指定した, required, email, minlength, maxlength などを指定します.

あとは, フォームに不正な値が入っていれば, submit ボタンが disabled となるように実装します.

<button type="submit" [disabled]="myForm.invalid">送信</button>

[disabled] は, プロパティバインティングです. そこに, (フォームを参照するテンプレート変数).invalid とすることで, それが実装できます. ここでは, myForm が, フォームを参照するテンプレート変数となるので, myForm.invalid と指定します.

最後に,

$ npm start

を実行することで, ng serve が実行され, 簡易サーバーが起動するので, localhost:4200 にアクセスして, フォームが機能していれば完成です !

Web Audio Library XSound 2.4.0 / 2.4.1 Released

1. Overview

久々の Web Audio API ネタです. XSound 2.4.0 からノイズの生成に対応しました. 2.4.0 ではホワイトノイズ (白色雑音), 2.4.1 ではピンクノイズの生成が可能になりました.

2. What is Noise ?

そもそもノイズ (雑音) とは何か ? から解説します. 世の中に存在する音のほとんどは周期的な波形をしており, 基本周波数をもつ sin 波とその整数倍の周波数をもつ sin 波 (倍音) の合成によって構成されます (それを周波数視点で分解するのがフーリエ変換です). しかしながら, 非周期的な波形をもつ音も存在しており, その典型例がノイズと呼ばれる音になります. ノイズにはいくつか種類がありますが, 有名でよく利用されるホワイトノイズとピンクノイズの特徴と, それを Web Audio API で生成するための実装を紹介します.

3. White Noise

ホワイトノイズ (白色雑音) の命名の由来は, そのスペクトルが白色のスペクトルと同じ (どの帯域でも同じ振幅をとる) であることから名づけられました. ホワイトノイズの実装はとても簡単です. なぜなら, 乱数を生成するだけだからです (ただし, 振幅が -1 〜 1 に収まるように値を調整する必要はあります). JavaScript の Math.random メソッドは 0 以上 1 未満の値を返すので, -0.5 したあと, 2 倍にすることで, -1 〜 1 の値に収まるようにしています.

// processor は ScriptProcessorNode のインスタンス
processor.onaudioprocess = event => {
    const outputLs = event.outputBuffer.getChannelData(0);
    const outputRs = event.outputBuffer.getChannelData(1);

    // bufferSize は ScriptProcessorNode のバッファサイズ
    for (let i = 0; i < bufferSize; i++) {
        outputLs[i] = 2 * (Math.random() - 0.5);
        outputRs[i] = 2 * (Math.random() - 0.5);
    }
}

4. Pink Noise

ピンクノイズの命名の由来は, そのスペクトルがピンク色のスペクトルと同じ (高周波数ほど振幅が減衰する) であることから名づけられました. ピンクノイズの実装は少し複雑 (すみませんが, 私も完全に理解できていません …) なので, 実装のみを紹介します.

// processor は ScriptProcessorNode のインスタンス
processor.onaudioprocess = event => {
    const outputLs = event.outputBuffer.getChannelData(0);
    const outputRs = event.outputBuffer.getChannelData(1);

    let b0 = 0;
    let b1 = 0;
    let b2 = 0;
    let b3 = 0;
    let b4 = 0;
    let b5 = 0;
    let b6 = 0;

    // bufferSize は ScriptProcessorNode のバッファサイズ
    for (let i = 0; i < bufferSize; i++) {
        const white = (Math.random() * 2) - 1;

        b0 = (0.99886 * b0) + (white * 0.0555179);
        b1 = (0.99332 * b1) + (white * 0.0750759);
        b2 = (0.96900 * b2) + (white * 0.1538520);
        b3 = (0.86650 * b3) + (white * 0.3104856);
        b4 = (0.55000 * b4) + (white * 0.5329522);
        b5 = (-0.7616 * b5) - (white * 0.0168980);

        outputLs[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + (white * 0.5362);
        outputRs[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + (white * 0.5362);

        outputLs[i] *= 0.11;
        outputRs[i] *= 0.11;

        b6 = white * 0.115926;
    }
}

う〜ん … 原理がよくわかりません w. 実は, ホワイトノイズに適切な Low-Pass Filter をかけることでも生成できるので, 原理がわからないと使いたくない場合は, その実装でもいいかもしれません.

5. References

webpack 3 から webpack 4 へアップデートする際にハマりそうなこと

1. Overview

なにかと破壊的な変更が多い webpack ですが, v3 から v4 にアップデートする際にも破壊的な変更があるので, 自分の備忘録として残しておきます.

2. webpack-cli

webpack 4 からは, コマンドライン機能が webpack-cli に分離されました. したがって, npm install する際に以下のようにする必要があります.

$ npm install --save-dev webpack webpack-cli

この対応は, あっという間に完了するかと思います.

3. mini-css-extract-plugin

以下の, webpack.config.js は v3 であれば正常に動作するものです.

const webpack            = require('webpack');
const ExtracktTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  entry: ['./src/main.js', './src/main.css'],
  output: {
    filename: 'app.js',
    path: `${__dirname}`
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: [
          ExtracktTextPlugin.extract({
            use: [
              'css-loader',
              'postcss-loader'
            ]
          })
        ]
      },
      {
        test: /\.png$/,
        use: 'url-loader'
      }
    ]
  },
  plugins: [
    new webpack.LoaderOptionsPlugin({
      options: {
        config : {
          path: './postcss.config.js'
        }
      }
    }),
    new ExtracktTextPlugin({
      filename: './app.css'
    })
  ],
  devtool: 'source-map'
};

ところが, v4 からは, extract-text-webpack-plugin を使うとエラーが発生します. 代わりに, mini-css-extract-plugin を使う必要があります. それを使って書き直した webpack.config.js です.

const webpack            = require('webpack');
const ExtracktTextPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: ['./src/main.js', './src/main.css'],
  output: {
    filename: 'app.js',
    path: `${__dirname}`
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: [
          ExtracktTextPlugin.loader,
          'css-loader',
          'postcss-loader'
        ]
      },
      {
        test: /\.png$/,
        use: 'url-loader'
      }
    ]
  },
  plugins: [
    new webpack.LoaderOptionsPlugin({
      options: {
        config : {
          path: './postcss.config.js'
        }
      }
    }),
    new ExtracktTextPlugin({
      filename: './app.css'
    })
  ],
  devtool: 'source-map'
};

CSS の module.rules.use が変わっていることに着目してください. extract-text-webpack-plugin では extract というクラスメソッドがありましたが, mini-css-extract-plugin では, それがありません. 代わりに, 上記のように loader を指定する必要があります.

Media Source Extensions API

1. Overview

Media Source Extensions API (以下, MSE) は, W3C によって標準化されている HTTP ダウンロードを利用してストリーミング再生するために作られた JavaScript API です. この記事では, MSE を利用して, 簡単な MPEG-DASH コンテンツを再生するための実装を紹介します.

2. Create resources

再生するコンテンツのリソースを生成します. 生成には, ffmpeg と MP4Box が必要になるので, brew などでインストールしておきます.

$ brew install ffmpeg
$ brew install MP4Box

次に, 対象となる MP4 動画を ffmpeg でエンコードします.

ffmpeg -i ./input.mp4 \
  -vcodec libx264 \
  -vb 500k \
  -r 30 \
  -x264opts no-scenecut \
  -g 15 \
  -acodec aac \
  -ac 2 \
  -ab 128k \
  -frag_duration 5000000 \
  -movflags frag_keyframe+empty_moov \
  ./encoded.mp4

そして, MP4Boxを利用して, 動画を分割し, セグメントファイルとプレイリストを生成します.

MP4Box -frag 4000 \            
  -dash 4000 \
  -rap \
  -segment-name sample \
  -out ./output.mp4 \
  ./encoded.mp4

以上で, プレイリストである output.mpd と output.m4s と連番になったセグメントファイルが生成されます. MPD (Media Presentation Description) ファイルの実体は, セグメントファイルをどの順番で再生するかが記述された XML ファイルです.

3. Media Source Extensions API

リソースの準備ができたので, MSE を利用した JavaScript の実装を紹介します.

'use strict';

class MSE {
    constructor(video, file) {
        this.getDescription(video, file);
        this.segmentIndex = 0;
        this.mediaAppended = false;
    }

    // MPD ファイルを取得し, MIME タイプやコーデックの情報を取得する
    getDescription(video, file) {
        const xhr = new XMLHttpRequest();

        xhr.open('GET', file, true);
        xhr.responseType = 'document';

        xhr.onload = () => {
            this.mpd = xhr.response;

            const representation = this.mpd.querySelector('Representation');
            const mimeType       = representation.getAttribute('mimeType');
            const codecs         = representation.getAttribute('codecs');

            this.type = `${mimeType}; codecs="${codecs}"`;

            this.initVideo(video);
        };

        xhr.send(null);
    }

    // セグメントを追加できるようにする
    initVideo(video) {
        this.mediaSource = new MediaSource();
        this.mediaSource.addEventListener('sourceopen', this.initSourceBuffer.bind(this), false);

        video.src = window.URL.createObjectURL(this.mediaSource);
    }

    // 初期化情報が入ったセグメント (初期化セグメント) とメディア本体のセグメントを追加できるようにする
    initSourceBuffer() {
        this.sourceBuffer = this.mediaSource.addSourceBuffer(this.type);
        this.sourceBuffer.addEventListener('updateend', this.appendMediaSegment.bind(this), false);
        this.appendInitSegment();
    }

    // セグメントファイルのロードが完了したタイミングで, SourceBuffer に追加する
    appendSegment(event) {
        this.sourceBuffer.appendBuffer(event.currentTarget.response);

        if (this.mediaAppended) {
            this.sourceBuffer.removeEventListener('updateend', this.appendMediaSegment.bind(this), false);
        }
    }

    // 初期化セグメントが SourceBuffer に追加されて更新されたら, メディア本体のセグメントファイル (メディアセグメント) を取得して, SourceBuffer に追加する. この処理を, メディアセグメントの数だけ繰り返す.
    appendMediaSegment() {
        const xhr = new XMLHttpRequest();

        const segmentURL = this.mpd.querySelectorAll('SegmentURL')[this.segmentIndex++];

        if (!segmentURL) {
            this.mediaAppended = true;
            return;
        }

        xhr.open('GET', segmentURL.getAttribute('media'), true);
        xhr.responseType = 'arraybuffer';
        xhr.onload = this.appendSegment.bind(this);
        xhr.send(null);
    }

    // MDP から セグメントファイルの URL を取得する
    appendInitSegment() {
        const xhr = new XMLHttpRequest();

        xhr.open('GET', this.mpd.querySelector('Initialization').getAttribute('sourceURL'), true);
        xhr.responseType = 'arraybuffer';
        xhr.onload = this.appendSegment.bind(this);
        xhr.send(null);
    }
}

new MSE(document.querySelector('video'), 'output.mpd');

これで, MPEG-DASH コンテンツを再生するための最小限の実装ができました.

Media Source Extensions API リポジトリ (Safari のみ)

参考 : フロントエンドエンジニアのための動画ストリーミング技術基礎

React Context API

1. Overview

React v16.3 から Context API というのが導入されました. この API を利用することによって Redux と同じようなデータフローを簡単に実装可能になるようです.

2. Implement

重要になるコンポーネントが, React#createContext メソッドによって生成される, Provider と Consumer です. その名前のとおり, Provider の value props に渡した値が, Consumer に渡ってきます.

2 – 1. Provider

Redux での, Provider, createStore に相当する.

2 – 2. Consumer

react-redux の connect に相当する.

'use strict';

import React from 'react';

const { Provider, Consumer } = React.createContext();

const Counter = () => (
    <Consumer>
        {({ state, actions }) => (
            <React.Fragment>
                <span>{state.count}</span>
                <button type="button" onClick={actions.increment}>+1</button>
                <button type="button" onClick={actions.decrement}>-1</button>
            </React.Fragment>
        )}
    </Consumer>
);

export default class App extends React.Component {
    state = {
        count : 0
    };

    constructor(props) {
        super(props);

        this.onClickIncrement = this.onClickIncrement.bind(this);
        this.onClickDecrement = this.onClickDecrement.bind(this);
    }

    onClickIncrement() {
        this.setState(prevState => {
            return { count : (prevState.count + 1) };
        });
    }

    onClickDecrement() {
        this.setState(prevState => {
            return { count : (prevState.count - 1) };
        });
    }

    render() {
        return (
            <Provider
                value={{
                    state   : this.state,
                    actions : {
                        increment : this.onClickIncrement,
                        decrement : this.onClickDecrement
                    }
                }}
            >
                <Counter />
            </Provider>
        );
    }
}

3. Conclusion

小規模なアプリであれば, わざわざ Redux (や MobX) を導入しなくても, Context API でいけるのでは ? と思いました.

Context API のサンプルリポジトリ

Async Clipboard API

1. Overview

Async Clipboard API は, 非同期でクリップボードを扱うAPIです。Async Clipboard APIは, Promiseを返すので, 直感的に扱えるようになっています. 従来の API でも、Flash や document.execCommand を利用することでクリップボードを操作することができました。しかしながら, バグも多くブラウザ間の整合性が取れているとは言い難く, 実際に使おうとすると様々な問題に直面しました.

さらに, document.execCommand によるクリップボード操作は同期的であるため, 大きなデータを操作するとメインスレッドをブロックする可能性があり, 結果としてUXを損ないます。

そもそも, document.execCommand は元々は DOM 操作のための古いAPIであり, 仕様の策定もあまり進んでいません. したがって, クリップボードの操作のためには, 新しいAPIが必要だったのです.

2. Async Clipboard API

2 – 1. Write to Clopboard

クリップボードにプレインテキストを書き込むには, navigator.clipboard オブジェクトの writeText メソッドを利用します. また, このメソッドは Promise を返すので, then や async / await を利用して適切な処理を実装します.

navigator.clipboard.writeText('Click Copy Button');

プレインテキスト以外のデータ (HTML など) を書き込むには, DataTransfer オブジェクトと navigator.clipboard オブジェクトの write メソッドを利用します (しかしながら, Chrome 67 でもバグがあるので, 現状は利用できません).

const data = new DataTransfer();
data.items.add(`${source.value}`, 'text/html');

navigator.clipboard.write(data); // ここでエラーが発生する

2 – 2. Read from Clopboard

簡易的なコピーボタンの実装などであれば, これで十分に利用可能ですが, クリップボードからテキストを読み込むには, navigator.clipboard オブジェクトの readText メソッドを利用します. また, このメソッドも Promise を返すので, then や async / await を利用して適切な処理を実装します.

const text = await navigator.clipboard.readText();
console.log(text)

2 – 3. Others

Async Clipboard API では上記の API 以外にも, クリップボードの変化を受けとる navigator.clipboard オブジェクトの clipboardchange イベントであったり, Permissions API を利用して, クリップボードへのパーミッションを確認・要求することができます.

デモ

Server Sent Events (SSE) を AudioContext の state 変更に使えないか検証してみた

1. Overview

Chrome 66+ から Web Audio API にも Autoplay Policy Change が適用され, ユーザーのジェスチャー (click など) がないと AudioContext インスタンスの state が suspend のままとなり, 音が鳴らないようになってしまいました. この抜け道を探るべく, Server Sent Evets (SSE) の message イベントをフックにできないか検証してみました.

そして, 結論から述べますと, もちろんダメでした …

2. Code

サーバサイドは, とにかく名前つきイベントと message イベントを発生させることができればよいので, PHP でサクッと実装しました. 簡単に説明すると, 毎秒名前つきイベント (ping) を送信し, カウンターが 0 になったらデータのみのメッセージイベントを送信するというスクリプトです.

server-sent-events.php

<?php
// Server Sent Events の MIME タイプ
header("Content-Type: text/event-stream\n\n");

$counter = mt_rand(1, 10);

while (true) {
  echo "event: ping\n";

  $dateTime = new DateTime();
  $now = $dateTime->format('Y-m-d H:i:s');

  echo 'data: {"time" : "' . $now . '"}';
  echo "\n\n";

  $counter--;

  if ($counter === 0) {
    echo 'data: This is a message at time ' . $now . "\n\n";
    $counter = mt_rand(1, 10);
  }

  ob_end_flush();
  flush();
  sleep(1);
}

server-sent-events.js

let audiocontext = null;

const eventSource = new EventSource('server-sent-events.php');

const onReceive = event => {
    console.log(event.type);
    console.dir(event.data);

    if (audiocontext === null) {
        audiocontext = new AudioContext();
        audiocontext.resume();
    }

    const oscillator = audiocontext.createOscillator();

    oscillator.connect(audiocontext.destination);

    oscillator.start(0);

    if (audiocontext instanceof AudioContext) {
        const p = document.createElement('p');
        p.textContent = audiocontext.state;

        document.body.appendChild(p);
    }
};

eventSource.addEventListener('ping', onReceive, false);

eventSource.onmessage = onReceive;

eventSource.onerror = event => {
    eventSource.close();
};

document.body.addEventListener('click', () => {
    eventSource.close();
}, false);

検証結果は残念でしたが, SSE を実装したのは初めてなので学びになりました. 次は, Go で実装してみたいです.

react-router v4 で onEnter ライクなことを実装する

1. Overview

react-routerにはあった onEnter というなにかしらの <Route /> に入ったときのフックポイントが用意されていました. しかしながら, v4 からはルーターの機能に特化したものとなり, onEnter などのフックポイントが廃止されました. しかしながら, onEnter ライクな処理をすることは可能なのでその実装方法をメモしておきます.

2. Implement routing by react-router v4

react-router v4 によるルーティングはおおよそ以下のような実装となります.

import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

import Home from './components/Home';
import PageA from './components/PageA';
import PageB from './components/PageB';
import PageC from './components/PageC';

export default () => {
  return (
    <Router>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route exact path="/a" component={PageA} />
        <Route exact path="/b" component={PageB} />
        <Route exact path="/c" component={PageC} />
      </Switch>
    </Router>
  );
};

3. onEnter

onEnter ライクな処理となる関数を実装します.

const render = (Component) => ({ history, match }) => {
  // ページローディングなどを表示する: 例 YouTube のページ上部に表示されるプログレスバーなど

  return <Component history={history} match={match} />;
};

ちょっと見づらいかもしれませんが, 関数が関数を返す, いわゆるクロージャ (高階関数) となっています.
history は, ネイティブの history オブジェクトではなく react-router が定義する history オブジェクトで, match は パスからパラメータを取得するためのオブジェクトです.

そして, ルーティングを以下のように実装します.

import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

import Home from './components/app';
import PageA from './components/PageA';
import PageB from './components/PageB';
import PageC from './components/PageC';

const render = (Component) => ({ history, match }) => {
  // ページローディングなどを表示する: 例 YouTube のページ上部に表示されるプログレスバーなど

  return <Component history={history} match={match} />;
};

export default () => {
  return (
    <Router>
      <Switch>
        <Route exact path="/" render={render(Home)} />
        <Route exact path="/a" render={render(PageA)} />
        <Route exact path="/b" render={render(PageB)} />
        <Route exact path="/c" render={render(PageC)} />
      </Switch>
    </Router>
  );
};

注目すべきは, component props ではなく, render props になっていることです (ちなみに, component props と render props は共存できません).

これで onEnter ライクな処理が可能になります.