Wijmo の FlexGrid には、セルの入力コンポーネントの種類を変更できるカスタムエディタという隠し機能があります。
ちょっとクセはあるのですが、使いこなせばお友達に差をつけることができます!
TL; DR
- FlexGrid のカスタムエディタを導入すると入力のバリエーションが広がる
- 一方で、カスタムエディタを(Angular アプリケーションに)導入すると処理性能が劣化する
- カスタムエディタを
NgZone
で性能改善するにはトリッキーなコーディングが必要
ナイスな導入
あるあるだね。
Wijmo ならカスタムエディタでできるよ。
公式サイトのサンプルをキャプってみた。
カスタムエディタとは
それらの必要な処理を一つのクラスにまとめたのが CustomGridEditor
クラスなんだ。
このクラスは Wijmo が製品として提供しているものではなく、自前のものをアプリに組み込む必要がある。
ちょっと長いけどソースコードを示すよ。
app.cutomEditor.ts
import { Control, Key, Point, isUndefined, setCss, setSelectionRange } from '@grapecity/wijmo'; import { FlexGrid, Column, CellRange, CellRangeEventArgs, CellEditEndingEventArgs } from '@grapecity/wijmo.grid'; import { DropDown } from '@grapecity/wijmo.input'; // // *** CustomGridEditor class (transpiled from TypeScript) *** // export class CustomGridEditor { // tslint:disable: variable-name // tslint:disable: deprecation // tslint:disable: no-string-literal private static _isEditing: boolean; // true if any custom editor is active private _grid: FlexGrid; private _col: Column; private _ctl: Control; private _openDropDown: boolean; private _rng: CellRange; /** * Initializes a new instance of a CustomGridEditor. */ constructor(flex: FlexGrid, binding: string, edtClass: any, options: any) { // save references this._grid = flex; this._col = flex.columns.getColumn(binding); // create editor this._ctl = new edtClass(document.createElement('div'), options); // connect grid events flex.beginningEdit.addHandler(this._beginningEdit, this); flex.sortingColumn.addHandler(() => { this._commitRowEdits(); }); flex.scrollPositionChanged.addHandler(() => { if (this._ctl.containsFocus()) { flex.focus(); } }); flex.selectionChanging.addHandler((s, e: CellRangeEventArgs) => { if (e.row !== s.selection.row) { this._commitRowEdits(); } }); // connect editor events this._ctl.addEventListener(this._ctl.hostElement, 'keydown', (e: KeyboardEvent) => { switch (e.keyCode) { case Key.Tab: case Key.Enter: e.preventDefault(); // TFS 255685 this._closeEditor(true); this._grid.focus(); // forward event to the grid so it will move the selection const evt = document.createEvent('HTMLEvents') as any; evt.initEvent('keydown', true, true); 'altKey,metaKey,ctrlKey,shiftKey,keyCode'.split(',').forEach((prop) => { evt[prop] = e[prop]; }); this._grid.hostElement.dispatchEvent(evt); break; case Key.Escape: this._closeEditor(false); this._grid.focus(); break; } }); // close the editor when it loses focus this._ctl.lostFocus.addHandler(() => { setTimeout(() => { // Chrome/FireFox need a timeOut here... (TFS 138985) if (!this._ctl.containsFocus()) { this._closeEditor(true); // apply edits and close editor this._grid.onLostFocus(); // commit item edits if the grid lost focus } }); }); // commit edits when grid loses focus this._grid.lostFocus.addHandler(() => { setTimeout(() => { // Chrome/FireFox need a timeOut here... (TFS 138985) if (!this._grid.containsFocus() && !CustomGridEditor._isEditing) { this._commitRowEdits(); } }); }); // open drop-down on f4/alt-down this._grid.addEventListener(this._grid.hostElement, 'keydown', (e: KeyboardEvent) => { // open drop-down on f4/alt-down this._openDropDown = false; if (e.keyCode === Key.F4 || (e.altKey && (e.keyCode === Key.Down || e.keyCode === Key.Up))) { const colIndex = this._grid.selection.col; if (colIndex > -1 && this._grid.columns[colIndex] === this._col) { this._openDropDown = true; this._grid.startEditing(true); e.preventDefault(); } } // commit edits on Enter (in case we're at the last row, TFS 268944) if (e.keyCode === Key.Enter) { this._commitRowEdits(); } }, true); // close editor when user resizes the window // REVIEW: hides editor when soft keyboard pops up (TFS 326875) window.addEventListener('resize', () => { if (this._ctl.containsFocus()) { this._closeEditor(true); this._grid.focus(); } }); } // gets an instance of the control being hosted by this grid editor get control() { return this._ctl; } // handle the grid's beginningEdit event by canceling the built-in editor, // initializing the custom editor and giving it the focus. private _beginningEdit(grid: FlexGrid, args: CellRangeEventArgs) { // check that this is our column if (grid.columns[args.col] !== this._col) { return; } // check that this is not the Delete key // (which is used to clear cells and should not be messed with) const evt = args.data; if (evt && evt.keyCode === Key.Delete) { return; } // cancel built-in editor args.cancel = true; // save cell being edited this._rng = args.range; CustomGridEditor._isEditing = true; // initialize editor host const rcCell = grid.getCellBoundingRect(args.row, args.col); const rcBody = document.body.getBoundingClientRect(); const ptOffset = new Point(-rcBody.left, -rcBody.top); const zIndex = (args.row < grid.frozenRows || args.col < grid.frozenColumns) ? '3' : ''; setCss(this._ctl.hostElement, { position: 'absolute', left: rcCell.left - 1 + ptOffset.x, top: rcCell.top - 1 + ptOffset.y, width: rcCell.width + 1, height: grid.rows[args.row].renderHeight + 1, borderRadius: '0px', zIndex, // TFS 291852 }); // initialize editor content if (!isUndefined(this._ctl['text'])) { this._ctl['text'] = grid.getCellData(this._rng.row, this._rng.col, true); } else { throw new Error('Can\'t set editor value/text...'); } // start editing item const ecv = grid.editableCollectionView; const item = grid.rows[args.row].dataItem; if (ecv && item && item !== ecv.currentEditItem) { setTimeout(() => { grid.onRowEditStarting(args); ecv.editItem(item); grid.onRowEditStarted(args); }, 50); // wait for the grid to commit edits after losing focus } // activate editor document.body.appendChild(this._ctl.hostElement); this._ctl.focus(); setTimeout(() => { // get the key that triggered the editor const key = (evt && evt.charCode > 32) ? String.fromCharCode(evt.charCode) : null; // get input element in the control const input: HTMLInputElement = this._ctl.hostElement.querySelector('input'); // send key to editor if (input) { if (key) { input.value = key; setSelectionRange(input, key.length, key.length); const evtInput = document.createEvent('HTMLEvents'); evtInput.initEvent('input', true, false); input.dispatchEvent(evtInput); } else { input.select(); } } // give the control focus if (!input && !this._openDropDown) { this._ctl.focus(); } // open drop-down on F4/alt-down if (this._openDropDown && this._ctl instanceof DropDown) { this._ctl.isDroppedDown = true; this._ctl.dropDown.focus(); } }, 50); } // close the custom editor, optionally saving the edits back to the grid private _closeEditor(saveEdits: boolean) { if (this._rng) { const flexGrid = this._grid; const ctl = this._ctl; const host = ctl.hostElement; // raise grid's cellEditEnding event const e = new CellEditEndingEventArgs(flexGrid.cells, this._rng); flexGrid.onCellEditEnding(e); // save editor value into grid if (saveEdits) { if (!isUndefined(ctl['value'])) { this._grid.setCellData(this._rng.row, this._rng.col, ctl['value']); } else if (!isUndefined(ctl['text'])) { this._grid.setCellData(this._rng.row, this._rng.col, ctl['text']); } else { throw new Error('Can\'t get editor value/text...'); } this._grid.invalidate(); } // close editor and remove it from the DOM if (ctl instanceof DropDown) { ctl.isDroppedDown = false; } host.parentElement.removeChild(host); this._rng = null; CustomGridEditor._isEditing = false; // raise grid's cellEditEnded event flexGrid.onCellEditEnded(e); } } // commit row edits, fire row edit end events (TFS 339615) private _commitRowEdits() { const flexGrid = this._grid; const ecv = flexGrid.editableCollectionView; this._closeEditor(true); if (ecv && ecv.currentEditItem) { const e = new CellEditEndingEventArgs(flexGrid.cells, flexGrid.selection); ecv.commitEdit(); setTimeout(() => { // let cell edit events fire first flexGrid.onRowEditEnding(e); flexGrid.onRowEditEnded(e); flexGrid.invalidate(); }); } } }
CustomGridEditor
自体は、基本的にコピペ利用で問題ないと思う。
後述するとおり性能上の課題はあるんだけど。
CustomGridEditor
の定義はいいとして、実際の使い方を見ていこう。
早く本題に行きたい。
カスタムエディタの使い方はちょっと独特
今日の本題に入る前に、少しだけカスタムエディタの予備知識をお話ししておきたいので、しばしお付き合いください。
ここで CustomGridEditor
を利用する側のサンプルコードを見てみよう。
公式サンプルから、当該箇所を抜粋してみたよ。 何か気になることはないかな?
initializeGrid(flex: wjcGrid.FlexGrid) { // add custom editors to the grid new CustomGridEditor(flex, 'date', wjcInput.InputDate, { format: 'd' }); new CustomGridEditor(flex, 'time', wjcInput.InputTime, { format: 't', min: new Date(2000, 1, 1, 7, 0), max: new Date(2000, 1, 1, 22, 0), step: 30 }); new CustomGridEditor(flex, 'country', wjcInput.ComboBox, { itemsSource: this._countries }); new CustomGridEditor(flex, 'amount', wjcInput.InputNumber, { format: 'n2', step: 10 }); /* (中略) */ }
CustomGridEditor
クラスを毎回 new
している……?new CustomGridEditor( foo, bar, baz, ... );
みたいに、コンストラクタに直接パラメータを引き渡しているけど、これは大丈夫かな。
CustomGridEditor
クラスに Angular のサービスを注入したくなったとき、途端に困ったことになる。CustomGridEditor
から NgZone
を使いたくなったとき、どうすればいいと思う?今日の問題とソリューション
カスタムエディタを使うとパフォーマンスが落ちるので手当をしないといけないのにそのままでは NgZone を DI できない
#wijmo の #FlexGrid でカスタムエディタを使うと、 Change Detection が鬼のように走る。
— たーせる (@tercel_s) January 25, 2020
なんなら画面のリサイズや KeyDown イベントのたびに変更検知が100回以上走る。
以前、「矢印キーを押しっぱなしにすると処理落ちする」って呟いたけど、このあたりも覿面に効いてそう。 pic.twitter.com/2lkMS2uNMJ
そうだね。
定石どおり NgZone
を使ってパフォーマンスチューニングしたいけど、一筋縄ではいかない。
/** * Initializes a new instance of a CustomGridEditor. */ constructor(flex: FlexGrid, binding: string, edtClass: any, options: any, private _zone: NgZone) { /* 略 */ }
new
している側のコードを全て横展開修正しなきゃいけなくなるね。そうだね。
「コンストラクタを修正したから、皆さん今からパラメータを全部直してください」なんてアナウンスをしたら弾劾裁判が始まっちゃうよ。
それだけはイヤだ。
なんとか、人様に迷惑をかけない形でパフォーマンスを改善したいよね。
NgZone の実体はどこにある?
NgZone
はシングルトンオブジェクトなので、アプリ内のどこかには存在するんだ。 隠されているけど。NgZone
をどこかから取得できれば、CustomGridEditor
でも NgZone
が使えるんだね。そう。 答えを言ってしまうと、NgZone
は Injector
経由でアクセスできる。
かなりトリッキーな解法になるけど、まずはこんなクラスを定義するよ。
static-injector.ts
import { Injector } from '@angular/core'; export class StaticInjector { // tslint:disable-next-line:variable-name private static _injector: Injector; static get injector() { if (!StaticInjector._injector) { throw new Error('Injector is not initialized yet.'); } return StaticInjector._injector; } static initialize(injector: Injector) { if (!injector) { throw new Error('Cannot set Injector to null.'); } if (!!StaticInjector._injector) { throw new Error('Injector is already initialized.'); } StaticInjector._injector = injector; } }
StaticInjector
を初期化するんだ。app.module.ts
import { NgModule, Injector } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppRoutingModule } from './app-routing.module'; import { WjGridModule } from '@grapecity/wijmo.angular2.grid'; import { AppComponent } from './app.component'; import { StaticInjector } from './static-injector'; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, AppRoutingModule, WjGridModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { // StaticInjector を初期化 constructor(injector: Injector) { StaticInjector.initialize(injector); } }
NgZone
にアクセスできるようになった。 以下のように書いて使う。const zone = StaticInjector.instance.get<NgZone>(NgZone);
そうだね。
これなら注入なしで NgZone
を取れるね。
Injector
を経由させなくても、直接 NgZone
を公開するクラスを作ればよかったんじゃないかな?Injector
を公開するクラスの方が汎用性があってボクは好きだな。使えるようになった NgZone で CustomGridEditor の性能改善
app.customEditor.ts・改 (コンストラクタのみ)
/** * Initializes a new instance of a CustomGridEditor. */ constructor(flex: FlexGrid, binding: string, edtClass: any, options: any, private _zone = StaticInjector.injector.get<NgZone>(NgZone)) { // save references this._grid = flex; this._col = flex.columns.getColumn(binding); // create editor this._ctl = new edtClass(document.createElement('div'), options); // connect grid events flex.beginningEdit.addHandler(this._beginningEdit, this); // イベント処理を極力 Angular の監視外で実行する this._zone.runOutsideAngular(() => { flex.sortingColumn.addHandler(() => { this._zone.run(() => this._commitRowEdits()); }); flex.scrollPositionChanged.addHandler(() => { if (this._ctl.containsFocus()) { flex.focus(); } }); flex.selectionChanging.addHandler((s, e: CellRangeEventArgs) => { if (e.row !== s.selection.row) { this._zone.run(() => this._commitRowEdits()); } }); // connect editor events this._ctl.addEventListener(this._ctl.hostElement, 'keydown', (e: KeyboardEvent) => { switch (e.keyCode) { case Key.Tab: case Key.Enter: e.preventDefault(); // TFS 255685 this._zone.run(() => this._closeEditor(true)); this._grid.focus(); // forward event to the grid so it will move the selection const evt = document.createEvent('HTMLEvents') as any; evt.initEvent('keydown', true, true); 'altKey,metaKey,ctrlKey,shiftKey,keyCode'.split(',').forEach((prop) => { evt[prop] = e[prop]; }); this._grid.hostElement.dispatchEvent(evt); break; case Key.Escape: this._zone.run(() => this._zone.run(() => this._closeEditor(false))); this._grid.focus(); break; } }); // close the editor when it loses focus this._ctl.lostFocus.addHandler(() => { setTimeout(() => { // Chrome/FireFox need a timeOut here... (TFS 138985) if (!this._ctl.containsFocus()) { this._zone.run(() => { this._closeEditor(true); // apply edits and close editor }); this._grid.onLostFocus(); // commit item edits if the grid lost focus } }); }); // commit edits when grid loses focus this._grid.lostFocus.addHandler(() => { setTimeout(() => { // Chrome/FireFox need a timeOut here... (TFS 138985) if (!this._grid.containsFocus() && !CustomGridEditor._isEditing) { this._zone.run(() => this._commitRowEdits()); } }); }); // open drop-down on f4/alt-down this._grid.addEventListener(this._grid.hostElement, 'keydown', (e: KeyboardEvent) => { // open drop-down on f4/alt-down this._openDropDown = false; if (e.keyCode === Key.F4 || (e.altKey && (e.keyCode === Key.Down || e.keyCode === Key.Up))) { const colIndex = this._grid.selection.col; if (colIndex > -1 && this._grid.columns[colIndex] === this._col) { this._openDropDown = true; this._zone.run(() => this._grid.startEditing(true)); e.preventDefault(); } } // commit edits on Enter (in case we're at the last row, TFS 268944) if (e.keyCode === Key.Enter) { this._zone.run(() => this._commitRowEdits()); } }, true); // close editor when user resizes the window // REVIEW: hides editor when soft keyboard pops up (TFS 326875) window.addEventListener('resize', () => { if (this._ctl.containsFocus()) { this._zone.run(() => this._closeEditor(true)); this._grid.focus(); } }); }); }
Wijmo の内部で、window.resize
や window.keydown
イベントが Change Detection の無駄打ちに繋がっていそうな気はしている。
少なくとも Chrome は、キーを押している間ずっと keydown
が発火し続ける。
その都度いちいち Change Detection が走っているから、思うような性能が出ないんだと思う。
これ、もう Wijmo が悪いんじゃなくて Angular の非効率極まりない変更検知が悪いような気がしてきた……。
敢えて試してないけど React や Vue に組み込んだ場合どうなんだろう。 場合によっては Angular を捨t
つづく。
おまけ
カスタムエディタのサンプルの動きが残念な件
カスタムエディタのサンプルデモページ。
— たーせる (@tercel_s) January 25, 2020
これたぶんテストデータがアレなせいで、一見バグっているように見えてしまうのがちょっと残念賞。
時刻のセルがアクティブになると、なぜか元々の値が消えて 7:00 に強制的に書き換わったような動きになる。 #wijmo #flexgrid pic.twitter.com/QeTnTjfRKm
Flyweight パターン
本文中で、僕がサラっと「FlexGrid に限らず、多くのグリッドでは、エディタのインスタンスを(エディタの種類ごとに)一つずつ用意しておき、それを使い回す方式が採られている」 という発言をしていますが、これはいにしえの Windows Forms の時代からそういう思想のもとにコンポーネントが構築されていました。
インスタンスの使い回しは、Flyweight パターンと呼ばれるれっきとしたデザインパターンです。
Flyweight パターンで使っている技法は、一言でいえば、
「インスタンスをできるだけ共有させて、無駄に new しない」
というものです。 インスタンスが必要なときに、いつも new するのではなく、すでに作ってあるインスタンスを利用できるなら、それを共有して使う。 …これが Flyweight パターンの核心です。
【出典】
new
とコードの臭い
本文中で、「CustomGridEditor
を new
して使う」ことに対して違和感を抱くくだりがあります。
今回は話を簡単にするためあまり深くは突っ込みませんでしたが、欄外でちょっとだけ触れておこうと思います。
大抵のプログラミング言語の入門書では、インスタンスを生成する最も初歩的な手法として new
の利用を挙げていますが、一般的にはあまりよい方法ではなく、Factory Method への置き換えを検討すべきといわれています。
コードの臭いには、さまざまな種類があります。 new キーワードの使用 ———— クラスの直接のインスタンス化 ———— は、「不適切な関係」の一例です。 コンストラクターは実装上の詳細であるため、それらを使用すれば、意図しない(そして望ましくない)依存関係がクライアントコードに要求されることになるかもしれません。
(前略)new を使ってインスタンスを生成するとき、new の後には具体的なクラス名を書きます。 この場合、クラス名を変更するのは難しくなります。 なぜなら、プログラム中でインスタンスを new している箇所をすべて変更しなければならないからです。 そんなときには 《Factory Method によるコンストラクタの置き換え》 というリファクタリングを検討しましょう。
【出典】
これに関して、Angular の流儀でよりマシな方法として、Angular After Tutorial でファクトリプロバイダが紹介されています。
まぁ今回に関しては、『既存のクライアントコードを絶対に壊さないこと』とボスから命令されたので、new
は温存する形となってしまいました。
では、また。