一般消費者向けWebサイトの構築では、HTML5の活用がすすんでおり、見栄えや使い勝手の良い画面を、効率良く開発できるようになりました。
一方、企業向け業務Webアプリについては技術的課題が多く、これまでHTML5の導入は困難とされてきました。しかし、技術的課題は解消しつつあります(次項で説明)。これからは、他の技術と同様に導入コストに見合う効果が問われる段階になってきます。
本記事では、業務アプリにおいてHTML5の効果的な導入ステッブについて解説します。
まず、業務アプリ開発におけるHTML5の現状把握をします。下記のような技術的障壁は解消しつつあります。
1)WebブラウザがHTML5に未対応
業務アプリの安定稼動のため、HTML5未対応の古いバージョンのブラウザを利用している企業が多くあります。しかし、来年4月以降、Internet Explorer 11未満はサポート切れになるため、HTML5に対応したブラウザが前提になります。
2)JavaScript特有の実装スキル
HTML5の開発言語はJavaScript限定でした。JavaScriptのオブジェクト指向開発はJava等とは全く異なるため、新たなスキル取得が必要でした。しかし、TypeScriptの利用で、Javaと同じクラス定義による開発が可能になりました。型定義、名前空間、実行前のコードチェックなど大規模開発に必要な機能も提供され、Java等の経験者も抵抗なく利用できます
3)開発効率が悪い
JavaScriptには標準のクラスライブラリが付属しません。自社でライブラリを開発したり、オープンソースのライブラリを組み合わせたり、追加の作業が発生していました。しかし、Angular2等の開発フレームワークにより、アプリ開発に必要な機能(クラスライブラリ、MVC構造、自動テスト等)が提供され、開発効率が大幅に改善しました。
技術的な課題は解消しつつありますが、HTML5はブラウザで利用する技術です。HTML5の本格的導入は、サーバー集中処理から、ブラウザで分散処理するアーキテクチャの変更を意味します。アプリの移行作業や設計方針、運用の変更も必要になります。
実際は、現状のサーバー集中型のままHTML5を一部活用するレベルから始め、徐々に分散型に移行してHTML5のフル活用を目指すのが現実的です。
弊社では、以下のような段階的導入を推奨しています。
HTML5導入トライアルの位置づけです。現在のシステムの一部の画面にHTML5による機能追加を加えたプロトタイプを作成します。ユーザや管理者への導入効果アピールと、開発環境・開発ノウハウの基礎スキルを獲得します。追加機能のお勧めが無限スクロールです。手軽に導入できて、誰でも効果が実感できます。
・対象範囲
一部の画面にHTML5による機能を追加します。
・導入効果
操作性の大幅改善が確認できます。例えば無限スクロールの場合、大量データを短時間で目視検索できます。
・補足
無限スクロールは、業種・業務を問わず業務アプリに含まれるリスト表示画面に適用でき、サーバー集中型のままで実装できるので開発工数は最小限で済みます。
[参考]
無限スクロールの実装
http://www.staffnet.co.jp/201608081810e/
アプリ単位の導入であれば、HTML5の導入効果を顕著に示すモバイルアプリから始めるのがお勧めです。モバイルに要求される、迅速な画面切り替え、ネットワーク圏外での利用を実現した上で、Webアプリのメリットであるクロスプラットフォームでの動作、アプリバージョンの集中管理が可能です。モバイルOS用のアプリ開発とくらべ、コストを大幅に削減できます。
・対象範囲
モバイル向けアプリ開発
・導入効果
ひとつのソースコードで、スマートフォン・タブレット・PCすべてに対応できるため、プラットフォームごとの開発と比べ、大幅なコスト削減ができます。
・補足
サーバー集中型のままでも運用可能ですが、軽快な操作感やネットワーク圏外対応ができないため、分散型で開発します。すでにモバイルOS用アプリを導入済であっても、保守・運用コスト削減のためにWebアプリへ移行することもあります。
Webアプリ全体の分散型への移行で、コスト削減、システム全体の機能と性能向上を実現して、大きな導入効果が期待できます。
・対象範囲
全システム
・導入効果
Webの根本的制約から解放され、コスト削減が期待できます。
・モバイルで重要なネットワーク圏外のアプリ利用
・通信完了を待つことなく瞬時に行われる画面遷移
・サーバーとネットワークの負荷軽減によるインフラコストの削減
・補足
システム基盤再構築を検討中の場合は、このステップから始めることもあります。
弊社では、「Angular2とTypeScriptによる モダンWeb開発セミナー」を開催します。
Angular2の実践的な知識とノウハウを習得できます。
http://www.staffnet.co.jp/hp/semi/modern/
無限スクロールは、件数の多いリスト表示に便利なユーザーインターフェースです。
例えば、Googleで検索すると、まず10件分の結果が表示され、残りは複数の画面に分割されます。画面の一番下に、下図のようなページ送りアイコンが表示されます。
求めるデータが1ページ目に無いときは、ページ送りを繰り返しています。この操作を面倒に感じませんか。ExcelやWordのように全体を高速でスクロールしたいと思いませんか。
無限スクロールを使うと、件数の多いリスト表示であっても、画面の切り替えなしに、どこまでもスクロールして表示できます。無限スクロールを体験した人で、いままでのページ送り操作に戻りたいと言う人はいません。
[参考]
無限スクロールのサンプルビデオ
操作性の向上だけでなく、大量の書類・図面・写真などの流し見をして、素早くチェックすることも可能になり、業務効率が向上します。無限スクロールは、ブラウザに処理を分散する「モダンWeb」ならではの機能です。幅広い用途に効果があります。
無限スクロールは、通信中もユーザーの操作を妨げない非同期通信を使用し、画面表示とデータ受信を同時に行います。
次に表示するデータを次々と事前取得することで、どこまでもスクロールできます。表示済データを廃棄することで、どんなに大量のデータであっても、容量オーバーにはなりません。また、すべてのデータを受信してからの表示ではなく、最小限のデータが準備できた時点で表示が始まりますので、ページ送り方式に比べて大幅に待たされることはありません。
無限スクロール用のJavaScriptライブラリは多数公開されています。いくつか試しましたが例外処理が不十分だったり、ライブラリのファイルサイズが大きかったりしたため、Angular2で新たに実装したところ、無限スクロール実装部分は、300行程度で済みました。
2016年8月12日更新
import {Component, OnInit} from '@angular/core'; import {RequestOptions} from "@angular/http"; import {HttpService} from "../service/http.service" import {DataService} from "../service/data.service"; import {SharedService} from "../service/shared.service"; import {I18nDatePipe} from '../pipe/i18nDate.pipe'; import {Router, ActivatedRoute} from "@angular/router"; import {TransitionService} from "../service/transition.service"; const htmlStr: string = require('component/reporthistory.component.html'); const cssStr: string = require("component/reporthistory.component.css"); const cssNav: string = require("../assets/css/navBar.css"); @Component({ selector: 'report-history', template: htmlStr, styles: [cssStr, cssNav], pipes: [I18nDatePipe] }) export class ReporthistoryComponent implements OnInit { record; nextRecord; prevRecord; startRow = 1; endRow = 1; BLOCK_SIZE = 30; isInitLoadDone = false; HEADER_HEIGHT = 50; BLOCK_HEIGHT; isEnd = false; isNextBufferReady = false; isPrevBufferReady = false; customerId; constructor(private httpService: HttpService, private dataService: DataService, private sharedService: SharedService, private router: Router, private route: ActivatedRoute, private transitionService: TransitionService) { } ngOnInit() { this.initData(); } //初期処理 initData() { //モバイル画面の判定 this.sharedService.onResize(); //UrlからcustomerId取得 let customerId; this.route.params .map(params => params['Id']) .subscribe((Id) => { console.log("@@@@ Id:" + Id); this.customerId = Id; }); //スクロール位置の初期化 setTimeout(()=> { window.scroll(0, 0) }, 1); //位置固定ヘッダで隠れる部分を位置補正 let bodyEl= document.getElementById("myBody") bodyEl.style.paddingTop = this.HEADER_HEIGHT + "px"; this.BLOCK_HEIGHT=bodyEl.clientHeight/3; //初期表示データ取得 this.getRecord(1, this.BLOCK_SIZE * 4) .then((result: any)=> { let data = result.data.data; if (data.length !== this.BLOCK_SIZE * 4) { alert("データ件数が不足しています"); return; } this.record = data.slice(0, this.BLOCK_SIZE * 3);//アクティブなバッファ this.nextRecord = data.slice(-this.BLOCK_SIZE * 3);//下方向の次バッファ this.isNextBufferReady = true;//下方向の次バッファ有効フラグ this.endRow = this.BLOCK_SIZE * 3;//バッファ末尾データの行番号 this.isInitLoadDone = true;//初期処理完了フラグ }); } //ーーーーーーー--------s----------- // メニュー選択処理 //ーーーーーーー------------------- onMenuClick(str, event) { event.preventDefault(); event.stopPropagation(); switch (str) { case "back"://顧客情報画面へ戻る this.transitionService.canTransition = true; this.router.navigate(['/customerDetail', this.customerId]); break; } } //ーーーーーーー------------------- //スクロールイベント処理 //ーーーーーーー------------------- onScroll(event) { //初期処理完了前のスクロールイベントは無視 if (!this.isInitLoadDone) return; //画面のレイアウト情報取得 let html = document.documentElement;//html要素 let pageHeight = html.scrollHeight;//全体の高さ let clientHeight = html.clientHeight; //表示域の高さ let scrollPos = window.pageYOffset;//スクロール位置 let bufferHeight = pageHeight / 3;//Buffer Blockの表示域の高さ let bottom_margine = //下方向スクロール可能サイズ pageHeight - clientHeight - scrollPos; let top_margine = scrollPos; //上方向スクロール可能サイズ //バッファ枯渇の場合はBottomで反転バウンド(下方向) if (!this.isEnd && bottom_margine === 0) { setTimeout(()=> { scrollBy(0, -20) }, 1); return; } //バッファ枯渇の場合はTopで反転バウンド(上方向) if (this.startRow !== 1 && top_margine === 0) { setTimeout(()=> { scrollBy(0, 20) }, 1); return; } //バッファ追加の判定(下方向) if (bottom_margine < bufferHeight * 0.5 && !this.isEnd && this.isNextBufferReady) { this.isNextBufferReady = false; console.log("@@@@ onScrollDown"); this.onScrollDown(); return; } //バッファ追加の判定(上方向) if ((scrollPos < bufferHeight * 0.5) && (this.startRow !== 1) && (this.isPrevBufferReady || this.prevRecord === null)) { this.isPrevBufferReady = false; console.log("@@@@ onScrollUp"); this.onScrollUp(); return; } } //バッファ追加(下方向) onScrollDown() { this.log(); //次の上方向バッファを取得 this.prevRecord = this.record.slice(0, this.BLOCK_SIZE * 3); this.isPrevBufferReady = true; //表示データ書き換え this.record = this.nextRecord; //表示位置カウンタの更新 this.startRow += this.BLOCK_SIZE; this.endRow += this.record.length - this.BLOCK_SIZE * 2; //表示位置補正 let bodyEl= document.getElementById("myBody") this.BLOCK_HEIGHT=(bodyEl.clientHeight+50)/3; let offset = 0 - this.BLOCK_HEIGHT; console.log("@@@@ Scroll offset: " + offset); setTimeout(()=> { window.scrollBy(0, offset) }, 1); //次のデータを準備 setTimeout(()=> { this.getNextBuffer(); }, 1); } //バッファ追加(上方向) onScrollUp() { //次の下方向バッファを取得 this.nextRecord = this.record.slice(0, this.BLOCK_SIZE * 3); this.isNextBufferReady = true; //表示データ書き換え this.record = this.prevRecord; //表示位置カウンタの更新 this.startRow -= this.record.length - this.BLOCK_SIZE * 2; this.endRow -= this.BLOCK_SIZE; //表示位置補正 let offset = this.BLOCK_HEIGHT; console.log("@@@@ Scroll offset: " + offset); setTimeout(()=> { window.scrollBy(0, offset) }, 1); //次のデータを準備 setTimeout(()=> { this.getPrevBuffer(); }, 1); } //次に追加するバッファ作成(下方向) getNextBuffer() { this.getRecord(this.endRow + 1, this.BLOCK_SIZE) .then((result: any)=> { let rec = result.data.data; if (rec.length === this.BLOCK_SIZE) {//ブロックサイズ分の追加データあり this.isEnd = false; this.nextRecord = this.record .slice(-this.BLOCK_SIZE * 2).concat(rec); } else if (rec.length === 0) { //追加データなし this.isEnd = true; } else { //末尾データでブロックサイズ分のデータなし this.isEnd = true; this.record = this.record.concat(rec); } //バッファ準備完了 this.isNextBufferReady = true; }) } //次に追加するバッファ作成(上方向) getPrevBuffer() { this.getRecord(this.startRow - this.BLOCK_SIZE, this.BLOCK_SIZE) .then((result: any)=> { let rec = result.data.data; this.prevRecord = rec.concat (this.record.slice(0, this.BLOCK_SIZE * 2)); //バッファ準備完了 this.isPrevBufferReady = true; this.isEnd = false; }); this.log(); } //サーバーからデータ取得 getRecord(begin: number, size: number): Promise<any> { console.log("@@@@@ getRecord begin=" + begin + " size=" + size + " startRow=" + this.startRow + " endRow=" + this.endRow); let arr = new Array(size); //HTTPリクエスト条件設定 const config = new RequestOptions(); config.url = this.httpService.AJAX_URL + "history"; config.body = ({begin: begin, size: size}); //HTTPリクエスト開始 let promise = this.httpService.send("post", config); promise.then( (result: any)=> { return result; }, (error)=> { alert("通信エラー" + error.message); return "error"; }); return promise; } log() { if (this.record) console.log("@@@@@ current=" + this.record[0].id + "|" + this.record[this.record.length - 1].id); if (this.nextRecord) console.log("@@@@@ next=" + this.nextRecord[0].id + "|" + this.nextRecord[this.nextRecord.length - 1].id); if (this.prevRecord) console.log("@@@@@ prev=" + this.prevRecord[0].id + "|" + this.prevRecord[this.prevRecord.length - 1].id); } }
弊社では、「Angular2とTypeScriptによる モダンWeb開発セミナー」を開催します。
Angular2の実践的な知識とノウハウを習得できます。
http://www.staffnet.co.jp/hp/semi/modern/