Angular リアクティブフォームでのリロード対策
前回Angularのリアクティブフォームについて勉強しました。
このリアクティブフォームで、リロードやブラウザが予期せずに閉じられてしまった場合に備えて、JavaScriptのLocalStorageやSessionStorageを使ったデータバックアップのコードを書いてみました。
イベント駆動型フォーム(ngModelを使ったフォーム)では違った対応方法が必要になります(最後に参考としてその方法を紹介しています)。リアクティブフォームとイベント駆動型フォームの違いについては別記事でも紹介しています。
ソースコード(モジュール)
本体のソースコードは次のようになります。
web-storaget-for-angular-form.ts
import { FormControl, FormGroup } from '@angular/forms';
export class WebStorageForAngularForm {
/* Angular Formcontrolの値をリロード時に保持する */
/* 保存可能な要素はinput textarea select(option) */
/* formControlNameは階層構造にあるものも含めて一意になっている前提 */
static readonly TYPE_SESSION: string = "sessionStorage";
static readonly TYPE_LOCAL: string = "localStorage";
private strType: string;
private strMainKey: string;
private blnEnable: boolean;
private blnInit: boolean = false;
private fg: FormGroup;
private map: Map<string,FormControl>;
constructor(strMainKey: string, strType: string, fg: FormGroup, strIgnoreControlNames:string ="", strTargetControlNames: string="") {
//strIgnoreControlNamesには保存対象外の要素の名前を,で区切って入れる
//strTargetControlNamesには保存対象の要素の名前を,で区切って入れる
//両方に値があった場合はstrTargetControlNamesが優先される
let wkList: string[];
this.strType = strType;
this.strMainKey = strMainKey;
this.blnEnable = this.storageAvailable(strType);
this.blnInit = false;
this.fg = fg;
this.map = new Map<string,FormControl>();
if (strTargetControlNames !=='') {
//対象要素が指定されていたら
wkList = strTargetControlNames.split(',').map(v => v.trim());
this.getFormControls(this.fg,1,wkList);
} else if(strIgnoreControlNames !=='') {
//対象外要素が指定されていたら
wkList = strIgnoreControlNames.split(',').map(v => v.trim());
this.getFormControls(this.fg,-1,wkList);
} else {
wkList=[];
this.getFormControls(this.fg,0,wkList);
}
}
private getFormControls(fg: FormGroup, intType: number, list: string[]):void {
//対象のformControlNameと、
Object.keys(fg.controls).forEach(key => {
let wk:any = fg.get(key);
if (wk instanceof FormGroup) {
this.getFormControls(fg.get(key) as FormGroup,intType, list);
} else if(wk instanceof FormControl){
switch(intType) {
case 0:
//そのまま追加
this.map.set(key,fg.get(key) as FormControl);
break;
case 1:
//存在したら追加
if (list.includes(key)) {
this.map.set(key,fg.get(key) as FormControl);
}
break;
case -1:
//存在しなかったら追加
if (!list.includes(key)) {
this.map.set(key,fg.get(key) as FormControl);
}
break;
}
}
});
}
storageAvailable(strType: string) : boolean {
//利用可能かチェック
//https://developer.mozilla.org/ja/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
let storage: any;
try {
storage = window[strType];
const x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);
return true;
} catch(e) {
console.log('ストレージが利用できません');
return false;
}
}
setValue(strSubKey: string, strValue: any):boolean {
if (!this.blnEnable) return false;
let strComplexKey: string = this.makeKey(strSubKey);
//console.log("save ! key is:"+ strComplexKey + " value is:"+strValue);
try {
window[this.strType].setItem(strComplexKey,strValue);
return true;
} catch(e) {
console.log('ストレージ保存エラー');
return false;
}
}
getValue(strSubKey: string):string {
if (!this.blnEnable) return "";
let strComplexKey: string = this.makeKey(strSubKey);
try {
let wk : string = window[this.strType].getItem(strComplexKey);
//console.log("restore ! "+strComplexKey+" value:"+wk);
if (wk===null) {
return "";
} else {
return wk;
}
} catch(e) {
console.log('ストレージ取得エラーkey='+strComplexKey);
return "";
}
}
removeValue(strSubKey: string):boolean {
if (!this.blnEnable) return false;
let strComplexKey: string = this.makeKey(strSubKey);
try {
window[this.strType].removeItem(strComplexKey);
return true;
} catch(e) {
console.log('ストレージ削除エラーkey='+strComplexKey);
return false;
}
}
removeAll():boolean {
//mainKey単位でデータを全削除
//formに対するイベントをセットしてある場合は、フォームの値を消さないとまた復活する。
//FormGroup.reset()でフォームの全値をクリアできる。
if (!this.blnEnable) return false;
let strTarget = this.makeKey("");
try {
Object.keys(window[this.strType]).forEach(key=>{
if (key.startsWith(strTarget)) {
window[this.strType].removeItem(key);
}
});
} catch(e) {
console.log('ストレージ削除エラー(All)');
return false;
}
return true;
}
startSyncWebStorage() {
//実行後から変更がストレージに保存される
//読み込み時はこれより先にデータ復元をする必要がある。
this.startSyncWebStrageSub(this.fg);
}
private startSyncWebStrageSub(fg: FormGroup):void {
//変更検知時に実行する関数
const func = ((formControlKey,value) => { this.setValue(formControlKey,JSON.stringify(value)); }).bind(this);
Object.keys(fg.controls).forEach(key => {
if (fg.get(key) instanceof FormGroup) {
this.startSyncWebStrageSub(fg.get(key) as FormGroup);
} else {
//対象のcontrolかチェック
if (this.map.has(key)) {
//FormControlの変更時に関数を実行するようにする
(fg.get(key) as FormControl).valueChanges.subscribe(value => func(key,value));
}
}
});
}
restoreFromWebStrage(): void {
//データをストレージから復元
let value: any;
let json: any;
for (let [key, control] of this.map) {
value = this.getValue(key);
if (typeof value === 'undefined' || (value === null)) {
continue;
}
if (value==='') {
this.map.get(key).setValue('');
continue;
}
try {
json = JSON.parse(value);
this.map.get(key).setValue(json);
} catch(e) {
//console.log(e);
this.map.get(key).setValue(value);
}
}
}
static clearByOrigin(strType: string):boolean {
//オリジン単位でクリア
try {
window[strType].clear();
} catch(e) {
console.log('ストレージクリアエラー');
return false;
}
}
private makeKey(strSubKey: string): string {
return this.strMainKey+"."+strSubKey;
}
}
ソースコード(コンポーネント)
コンポーネントでは次のようにして利用します。
some.component.ts
...
/* 必要なimport */
...
import { FormBuilder } from '@angular/forms';
import { WebStorageForAngularForm } from '../web-storage-for-angular-form';
@Component({
selector: 'app-some',
templateUrl: './some.component.html',
styleUrls: ['./some.component.css']
})
export class SomeComponent implements OnInit {
//保存用クラスの定義
webstorage: WebStorageForAngularForm;
//リアクティブフォームの定義
formGp = this.fb.group({
onamae: [''],
furigana: [''],
denwa: [''],
jusyo: [''],
});
//コンストラクタ(FormBuilderを設定しています)
constructor(private fb: FormBuilder) { }
ngOnInit(): void {
//インスタンス生成
this.webstorage = new WebStorageForAngularForm('customer-edit',WebStorageForAngularForm.TYPE_LOCAL,this.formGp);
//前データリストア
this.webstorage.restoreFromWebStrage();
//フォーム入力内容同期開始
this.webstorage.startSyncWebStorage();
}
}
解説
- コンストラクタ
インスタンス生成時に、3~5つの引数を受けます。
第1引数はキーのプレフィックスです。LocalStorageやSessionストレージはオリジン(≒ドメイン+ポート)単位でデータを保持するため、同じオリジンでデータが競合しないようにコンポーネント毎にプレフィックスを設けます。
第2引数はLocalStorage(TYPE_LOCAL)かSessionStorage(TYPE_SESSION)を指定します。
第3引数はFormGroupインスタンスを渡します。
第4引数には対象外にしたいFormControlName要素(ここではonamaeやdenwa等)を,で区切って指定します。
対象外の要素でなく、対象の要素を指定したい場合は第5引数に,で区切って指定します。第5引数の値が存在する場合は第4引数の値は無視されます。
- storageAvailable()
LocalStorageやSessionStorageが利用中のブラウザで使用可能かチェックします。イニシャル時にチェックして利用不可だった場合は、保存作業をしません。
- setValue(サブキー、値)
データをStorageに保存します。コンストラクタに渡されたキーのプレフィックスとサブキーを合成して保存します。
- getValue(サブキー、オブジェクト)
コンストラクタに渡されたキーのプレフィックスとサブキーを合成して、保存済データを呼び出します。
- removeValue(サブキー)
コンストラクタに渡されたキーのプレフィックスとサブキーを合成して、保存済データを削除します。サンプルのコード内では利用していません。
- removeAll()
コンストラクタに渡されたキーのプレフィックスのついた保存済データをすべて削除します。サンプルのコード内では利用していません。
- startSyncWebStorage()
コンストラクタに渡された条件で、フォームデータとStorageを同期します。
- restoreFromWebStrage()
コンストラクタに渡された条件で、Storageからデータを復元します。必ずstartSyncWebStorage()の前に呼び出してください。
- static clearByOrigin(strType)
TYPE_LOCALかTYPE_SESSIONを指定して表示中のオリジンのデータをすべて削除します。removeAllとの混同を避けるためstaticにしています。サンプルでは利用していません。
privateメソッドに関しては各メソッドの補助的な利用なので説明を省略します。
イベント駆動型での対応方法(参考)
ちなみにイベント駆動型で同様の処理したい場合は、詳細は書きませんが、おおむね次のようなコードになると思います。まずHTML側で各要素の双方向データバインディングの書式を分割して変更イベントを設定します。
component.html
<input [ngModel]="username" (ngModelChange)="username=$event;saveLocalStorage($event,'username')" name="username">
この例では、(ngModelChange)でスクリプトの変数であるusernameに$eventをセットした後someFunction関数を呼んでいます。$eventにはinputに入力された文字列が入りますので、前半でHTML側からスクリプトへのバインディングをしているという事になります。後半の関数を使って、LocalまはたSessionストレージにデータを保存します。この時キーとなる変数名を文字列'username'として渡しています。キー名を変数名と同値にしておいて、復元時にスクリプト側の変数と引き合わせます。
実際にイベント駆動型フォームでタグを記述する際は、name属性を記述するかstandaloneオプションの設定をしないと次のようなエラーがでますが、コードが分かりづらくなるので省略しています。
core.js:5967 ERROR Error: If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions. Example 1: <input [(ngModel)]="person.firstName" name="first"> Example 2: <input [(ngModel)]="person.firstName" [ngModelOptions]="{standalone: true}">
コンポーネント側で次のように記述します。
component.ts
...
//データ保存
saveLocalStorage(value,paramName) {
localStorage.setItem(paramName,JSON.stringify(value));
}
//データ復元
//thisはコンポーネントになります。
//コンポーネントのトップレベルにLocalStorageのキーと同じ文字列で変数が定義されていると仮定しています。
restoreFromLocalStorage() {
for(let i = 0; i < localStorage.length;i++) {
this[localStorage.key(i)]=JSON.parse(localStorage.getItem(localStorage.key(i)));
}
}
...
ngModelに引き当てる変数名と、イベントに渡す変数名、要素のname等、記述する側にとってみれば少し冗長なものに思えます。なにかもう少しうまい書き方はないのでしょうか?