February 2016

Volume 31 Number 2

ASP.NET - ASP.NET と React によるプログレッシブ エンハンスメント

Graham Mendick

Web コンテンツ配信の信頼性は運にも左右されます。ユーザーが使用しているネットワークの速度やブラウザーの機能はまちまちです。こうした予測不可能な要素に対応する開発テクニックがプログレッシブ エンハンスメント (PE) です。PE の基本は HTML のサーバー側レンダリングです。これは、コンテンツ配信が成功する可能性を最大限に高める唯一の手段です。最新ブラウザーのユーザーには、JavaScript を土台に階層化することで、エクスペリエンスを強化します。

AngularJS や Knockout のようなデータ バインド ライブラリの登場により、シングル ページ アプリケーション (SPA) の評価が高まりました。SPA は PE とは正反対に、クライアント側で HTML をレンダリングすることで、Web の予測不可能な性質を無視します。低速ネットワークを利用するユーザーは読み込み時間が長くなり、ユーザーや検索エンジンが低性能のブラウザーを使用しているとコンテンツはまったく表示されません。しかし、SPA にはこうした問題点を上回る魅力があります。

SPA と PE との差は圧倒的で、JavaScript を拡張してサーバーレンダリング型のアプリケーションを SPA に変えるのは非常に困難です。

それでも、PE はなくなってしまったわけではありません。単に息を潜めていただけです。そして React という JavaScript ライブラリの出現により、再び表舞台に登場しました。React はサーバーでもクライアントでも動作するため、両方の長所を生かせます。最初はサーバーレンダリング型アプリケーションとして作成しても、スイッチを切り替えれば、クライアントレンダリング型アプリケーションにすることができます。

TodoMVC プロジェクト (todomvc.com、英語) では、1 つの Todo SPA がさまざまな JavaScript データバインド ライブラリを使用してビルドされています。そのため、使用するライブラリを開発者が決めることができます。優れたプロジェクトですが、クライアント側レンダリングにしか対応していないため、実装は容易ではありません。今回は、React と ASP.NET を使用して、このプロジェクトの機能を落としたバージョンを、プログレッシブ エンハンスメント SPA としてビルドして、この点を補完します。読み取り専用の機能に重点を置き、Todo リストの閲覧と、アクティブな作業や完了した作業をフィルター選択できるようにします。

サーバーでのレンダリング

以前の PE アプローチでは、Razor を使用して ASP.NET MVC アプリケーションを作成し、Todo リストをサーバーでレンダリングしていました。今回、このアプリケーションを SPA に拡張することに決めたので、作業は振り出しに戻り、レンダリング ロジックを JavaScript で再実装しなければなりません。新しい PE アプローチでは、Razor ではなく React を使用して ASP.NET MVC アプリケーションを作成し、Todo リストをサーバーでレンダリングします。こうすることで、アプリケーションは、クライアントレンダリング用コードにもなります。

まず、TodoMVC という新しい ASP.NET MVC プロジェクトを作成します。ビュー層以外はこのコードに変わった点はありません。Models フォルダーには IEnumerable 型の todo を返す TodoRepository が保持され、HomeController 内部にはこのリポジトリを呼び出す Index メソッドがあります。ビュー層は少し違います。Todo リストを Razor ビューに渡すのではなく、React に渡して、サーバー側で HTML を生成します。

サーバーで JavaScript を実行するには、Node.js が必要です。Node.js は nodejs.org (英語) からダウンロードできます。Node.js には、npm という独自のパッケージ マネージャーがあります。NuGet を使用して .NET パッケージをインストールするのと同じように、npm を使用して React をインストールします。コマンド プロンプトに「cd」と入力して TodoMVC プロジェクト フォルダーに切り替え、「npm install react」コマンドを実行します。

次に、Scripts フォルダーに app.jsx というファイルを作成します (.jsx ファイル拡張子については後ほど説明します)。このファイルには、一般的な ASP.NET MVC プロジェクトの Razor ビューの代わりに、React のレンダリング ロジックを含めます。Node.js は、React モジュールを読み込むためにモジュール読み込みシステムを使用するため、app.jsx の先頭に require ステートメントを追加します。

var React = require('react');

React UI は複数のコンポーネントで構成されます。各コンポーネントには、入力データを HTML に変換するレンダリング関数があります。入力データはプロパティとして渡します。app.jsx の内部で、List コンポーネントを作成します。このコンポーネントは複数の todo 項目を受け取り、リストの項目として表される各 todo のタイトルと共に、todo 項目を非順序リストとして出力します。

var List = React.createClass({
  render: function () {
    var todos = this.props.todos.map(function (todo) {
      return <li key={todo.Id}>{todo.Title}</li>;
    });
    return <ul>{todos}</ul>;
  }
});

React コードは、JavaScript と HTML 風の構文を組み合わせた JSX という言語で記述するため、ファイルの拡張子に .jsx が付きます。このコードをサーバーで実行しますが、Node.js は JSX を理解しないため、まずファイルを JavaScript に変換しなくてはなりません。JSX を JavaScript に変換することを「トランスパイル」と呼びます。そのトランスパイラの 1 つが Babel です。オンラインの Babel トランスパイラ (babeljs.io/repl、英語) に app.jsx のコンテンツを貼り付けると、トランスパイル後の出力から app.js ファイルが作成されます。しかし、app.jsx はかなり頻繁に変更されるため、この手順は自動的に行う方が効率的です。

app.jsx から app.js に自動変換する場合、Gulp を使用します。Gulp は、ソース ファイルの変換に役立つよう、さまざまなプラグインが付属する JavaScript タスク ランナーです。後ほど、ブラウザー用の JavaScript をまとめた Gulp タスクを記述します。現時点では、app.jsx をサーバーの Node.js 内部で使用できるよう、Babel トランスパイラ経由で app.jsx を渡すタスクが必要です。次のコマンドを実行して、npm から Gulp と Babel プラグインをインストールします。

npm install gulp gulp-babel babel-preset-react

パッケージ名をスペースで区切ることで、1 つのコマンドで複数のパッケージをインストールします。TodoMVC プロジェクト フォルダー内に gulpfile.js を作成し、このファイルにトランスパイル タスクを追加します。

var babel = require('gulp-babel');
gulp.task('transpile', function(){
  return gulp.src('Scripts/app.jsx')
    .pipe(babel({ presets: ['react'] }))
    .pipe(gulp.dest('Scripts/'))
});

タスクは 3 つの手順で作成します。まず、Gulp が app.jsx ソール ファイルを受け取ります。次に、Babel トランスパイラを経由するようにファイルをパイプします。最後に、app.js 出力ファイルを Scripts フォルダーに保存します。このタスクを実行可能にするには、メモ帳を使用して TodoMVC プロジェクト フォルダーに package.json ファイルを作成し、package.json ファイルを指すスクリプト エントリを用意します。

{
  "scripts": {
    "transpile": "gulp transpile"
  }
}

コマンド ラインから、「npm run transpile」を使用してトランスパイル タスクを実行します。これにより app.js ファイルが生成されます。JSX が JavaScript に置き換えられているため、このファイルは Node.js 内部で実行できます。

今回は React をビュー層として使用しているため、todo 項目をコントローラーから List コンポーネントに渡し、HTML が返されるようにします。Node.js では、app.js 内部のコードはプライベートなので、app.js を明示的にエクスポートしなければパブリックにすることはできません。List コンポーネントを外部で作成できるように、app.jsx から getList 関数をエクスポートします。このとき、app.js を更新するためにトランスパイル タスクを忘れず実行します。

function getList(todos) {
  return <List todos={todos} />;
}
exports.getList = getList;

HomeController は C# で記述し、getList 関数は JavaScript で記述しています。この言語境界をまたいで呼び出しを行うには、Edge.js (tjanczuk.github.io/edge) を使用します。Edge.js は、「Install-Package Edge.js」を実行して NuGet から入手できます。Edge.js は、2 つのパラメーターを持つ関数を 1 つ返す Node.js コードを含む C# の文字列が渡されると想定しています。1 つは C# から渡されたデータを保持するパラメーターで、もう 1 つは JavaScript データを C# に戻す際に使用するコールバックです。「npm install react-dom」を実行して React のサーバー側レンダリング機能を組み込んだら、Edge.js を使用して、渡された todo 項目の配列から List コンポーネントの HTML を返す関数を作成します。

private static Func<object, Task<object>> render = Edge.Func(@"
  var app = require('../../Scripts/app.js');
  var ReactDOMServer = require('react-dom/server');
  return function (todos, callback) {
    var list = app.getList(todos);
    callback(null, ReactDOMServer.renderToString(list));
  }
");

Node.js コードから、Edge.js は C# Func を作成します。この Func は HomeController で "render" という変数に代入します。todo リストを指定して render を呼び出すと、HTML が返されます。Edge.js への呼び出しは非同期なので、async/await パターンを使用して、この呼び出しを Index メソッドに追加します。

public async Task<ActionResult> Index()
{
  var todos = new TodoRepository().Todos.ToList();
  ViewBag.List = (string) await render(todos);
  return View();
}

動的な ViewBag に返される HTML を追加しているため、Razor ビューから HTML にアクセスできます。React がすべての作業を行っているとはいえ、HTML をブラウザーに送信してサーバー レンダリングを完了する Razor のコードが 1 行必要です。

<div id="content">@Html.Raw(ViewBag.List)</div>

この新しい PE アプローチは、以前のアプローチに比べて手間がかかるように感じられます。しかし、この新しいアプローチにより、サーバー側レンダリング コードをクライアント側レンダリング コードに変換できるようになります。サーバーレンダリング型アプリケーションから SPA への変換に関して言えば、以前のアプローチでも必要とするような重複した操作はありません。

サーバーでのフィルター処理

アクティブな Todo 項目または完了した Todo 項目だけを表示できるよう、todo 項目をフィルター選択可能にする必要があります。フィルター処理はハイパーリンクを、ハイパーリンクはルーティングを意味します。ここまでに Razor を React に置き換え、JavaScript レンダラーがクライアントとサーバーの両方で機能するようにしました。ここでは、ルーティングにも同じ操作を行います。今回は、ASP.NET MVC 付属のルーティング ソリューションではなく、Navigation ルーター (grahammendick.github.io/navigation、英語) を使用します。このルーターは、クライアントとサーバーの両方で機能する JavaScript ルーターです。

このルーターを組み込むには、「npm install navigation」を実行します。Navigation ルーターは、各ステートがアプリケーション内の異なるビューを表すステート マシンと考えることができます。app.jsx で、todo "list" ビューを表すステートを使用してルーターを構成します。このステートは、オプションの "filter" パラメーターを使って route に割り当てます。したがって、フィルター URL は "/active" や "/completed" のようになります。

var Navigation = require('navigation');
var config = [
  { key: 'todoMVC', initial: 'list', states: [
    { key: 'list', route: '{filter?}' }]
  }
];
Navigation.StateInfoConfig.build(config);

以前の PE アプローチでは、フィルターのロジックをコントローラー内部に収容していました。新しいアプローチでは、フィルターのロジックを React コード内に収めるため、フィルターのロジックを SPA に切り替えるときにクライアントで再利用できます。List コンポーネントは、filter を受け取り、todo の completed ステータスをチェックして、表示するリスト項目を決定します。

var filter = this.props.filter;
var todoFilter = function(todo){
  return !filter || (filter === 'active' && !todo.Completed)
    || (filter === 'completed' && todo.Completed);
}
var todos = this.props.todos.filter(todoFilter).map(function(todo) {
  return <li key={todo.Id}>{todo.Title}</li>;
});

フィルター選択した Todo リストの下にフィルターのハイパーリンクが含まれるように、List コンポーネントから返された HTML を変更します。

<div>
  <ul>{todos}</ul>
  <ul>
    <li><a href="/">All</a></li>
    <li><a href="/active">Active</a></li>
    <li><a href="/completed">Completed</a></li>
  </ul>
</div>

エクスポートする "getList" 関数には、新しいフィルター プロパティを List コンポーネントに渡すために、追加のパラメーターが必要です。フィルターをサポートするために app.jsx に加える変更はこれが最後です。ここで、Gulp トランスパイル タスクを再実行して、新しい app.js を生成します。

function getList(todos, filter) {
  return <List todos={todos} filter={filter} />;
}

選択するフィルターは URL から抽出する必要があります。フィルターをコントローラーに渡すために、ASP.NET MVC の route を登録したくなるかもしれません。しかし、それでは、既に Navigation ルーターで構成済みの route が重複することになります。代わりに、Navigation ルーターを使用してフィルター パラメーターを取り出します。まず、C# RouteConfig クラスから route パラメーターが含まれる部分をすべて削除します。

routes.MapRoute(
  name: "Default",
   url: "{*url}",
  defaults: new { controller = "Home", action = "Index" }
);

Navigation ルーターには、URL 解析用の navigateLink 関数があります。この関数に URL を渡すと、抽出されたデータが StateContext オブジェクトに格納されます。これで、route パラメーターの名前をキーに使用して、このデータにアクセスできるようになります。

Navigation.StateController.navigateLink('/completed');
var filter = Navigation.StateContext.data.filter;

この route パラメーター抽出コードを Edge.js レンダリング関数にプラグインします。その結果、現在の URL からフィルターを取得して、getList 関数に渡すことができます。しかし、サーバーの JavaScript は現在要求の URL にアクセスできません。そのため、function の 1 つ目のパラメーターを使って、C# から (todo と一緒に) URL を渡す必要があります。

return function (data, callback) {
  Navigation.StateController.navigateLink(data.Url);
  var filter = Navigation.StateContext.data.filter;
  var list = app.getList(data.Todos, filter);
  callback(null, ReactDOMServer.renderToString(list));
}

対応する変更を HomeController の Index メソッドに加えるには、サーバー側の要求から取得した URL と Todo リスト両方を格納したオブジェクトを、render の呼び出しに渡します。

var data = new {
  Url = Request.Url.PathAndQuery,
  Todos = todos
};
ViewBag.List = (string) await render(data);

フィルター処理の用意ができたら、サーバー側でのビルド フェーズは完了です。サーバー側でレンダリングされるようにしてから、Todo リストはすべてのブラウザーと検索エンジンで表示可能にしました。クライアントで Todo リストをフィルター処理して、最新のブラウザー用にエクスペリエンスを拡張するのが計画です。Navigation ルーターがブラウザー履歴を管理し、クライアント側でフィルター選択した Todo リストをブックマーク可能なままにします。

クライアントでのレンダリング

Razor を使用して UI をビルドしていたら、当初から SPA という最終目標に近付いていなかったでしょう。以前の PE が使用されなくなった理由は、レンダリング ロジックを JavaScript で複製しなければならないからです。しかし、React を使用すると、すべての app.js コードをクライアントで再利用できるため、正反対の状況が生まれます。サーバーで React を使用して List コンポーネントを HTML にレンダリングしたときのように、クライアントで React を使用して同じコンポーネントを DOM にレンダリングします。

クライアントで List コンポーネントをレンダリングするには、Todo 項目にアクセスする必要があります。Todo 項目を使用できるようにするため、サーバー レンダリングの一部として Todo 項目を JavaScript 変数に格納します。HomeController の ViewBag に todo リストを 追加することで、Razor ビュー内部の JavaScript 配列に Todo 項目をシリアル化することができます。

<script>
  var todos = 
    @Html.Raw(new JavaScriptSerializer().Serialize(ViewBag.Todos));
</script>

Scripts フォルダー内に、クライアント レンダリング ロジックを収める client.js ファイルを作成します。このコードは、サーバー レンダリングの処理用に Edge.js に渡した Node.js コードと同じように見えますが、環境の違いに対応できるように調整を加えています。そのため、URL はサーバー側要求ではなくブラウザーの location オブジェクトから取得し、React では HTML 文字列ではなく content div に List コンポーネントをレンダリングします。

var app = require('./app.js');
var ReactDOM = require('react-dom');
var Navigation = require('navigation');
Navigation.StateController.navigateLink(location.pathname);
var filter = Navigation.StateContext.data.filter;
var list = app.getList(todos, filter);
ReactDOM.render(list, document.getElementById('content'));

app.jsx にコードを 1 行追加して、既定のハッシュ履歴ではなく HTML5 History を使用していることを Navigation ルーターに指示します。これを行わないと、navigateLink 関数は URL が変化したと認識し、適合するようにブラウザー ハッシュを更新します。

Navigation.settings.historyManager = 
  new Navigation.HTML5HistoryManager();

client.js スクリプト参照を Razor ビューに直接追加すれば、クライアント レンダリングに必要な変更は最後になります。残念ながら、現実はそれほど単純ではありません。Node.js モジュール読み込みシステムに対する client.js 内部の require ステートメントは、ブラウザーによって認識されません。そこで、browserify という Gulp プラグインを使用して、client.js と client.js に必要なすべてのモジュールを 1 つの JavaScript ファイルにまとめるタスクを作成します。このタスクを Razor ビューに追加できます。このプラグインを組み込むには、「npm install browserify vinyl-source-stream」を実行します。

var browserify = require('browserify');
var source = require('vinyl-source-stream');
gulp.task('bundle', ['transpile'], function(){
  return browserify('Scripts/client.js')
    .bundle()
    .pipe(source('bundle.js'))
    .pipe(gulp.dest('Scripts/'))
});

ひとまとめにしたタスク (バンドル タスク) は、app.jsx への最新の変更が含まれる場合を除き、実行しないようにします。常にトランスパイル タスクが最初に実行されるよう、バンドル タスクの依存関係を作成します。Gulp タスクの 2 つ目のパラメーターでタスクの依存関係が列挙しているのがわかります。package.json の scripts セクションに、バンドル タスク用のエントリを追加します。「npm run bundle」コマンドを実行すると bundle.js が作成されるので、この bundle.js を参照するスクリプト参照を Razor ビューの最後に追加します。

<script src="~/Scripts/bundle.js"></script>

HTML のサーバー側レンダリングにより、JavaScript の読み込みと実行が完了するまでコンテンツを表示できないため、todomvc.com (英語) のアプリケーションよりも高速に起動するアプリケーションが作成されます。また、アプリケーションで JavaScript が読み込まれるとクライアント レンダリングが実行されます。対照的に、このレンダリングで DOM が更新されることはありません。しかし、サーバー側でレンダリングしたコンテンツに React をアタッチして、以降の Todo リストのフィルターをクライアントで処理することができます。

クライアントでのフィルター処理

以前の PE アプローチでは、クラス名を切り替えて Todo 項目の表示を制御することで、クライアントでのフィルター処理を実装していました。しかし、補助となる JavaScript ルーターがなければ、ブラウザー履歴はいとも簡単に壊れます。URL の更新を怠ると、フィルター処理されたリストをブックマークできなくなります。新しい PE アプローチではと、クライアントには最初から Navigation ルーターが動作しているため、ブラウザー履歴は変更されることなく保持されます。

フィルター ハイパーリンクがクリックされたときに URL を更新するには、クリック イベントをインターセプトして、ハイパーリンクの href をルーターの navigateLink 関数に渡す必要があります。Navigation ルーターには React プラグインが用意され、所定の方法でハイパーリンクを作成すると、この操作が自動的に処理されます。たとえば、「<a href="/active">Active</a>」と記述するのではなく、プラグインが提供する RefreshLink React コンポーネントを使用する必要があります。

var RefreshLink = require('navigation-react').RefreshLink;
<RefreshLink toData={{filter: 'active'}}>Active</RefreshLink>

「npm install navigation-react」を実行してプラグインを組み込んだら、3 つのフィルター ハイパーリンクを等価な RefreshLink コンポーネントに置き換え、app.jsx の List コンポーネントを更新します。

UI と URL を同期した状態に維持するには、URL が変化したとき (フィルター ハイパーリンクがクリックされた場合だけでなく、ブラウザーの戻るボタンが押された場合も含みます) は必ず Todo リストをフィルター処理する必要があります。イベント リスナーを個別に追加しなくても、ナビゲーションが発生するたびに呼び出されるリスナーを 1 つ Navigation ルーターに追加するだけでかまいません。このナビゲーション リスナーは、ルーター構成の一部として作成した "list" ステートにアタッチする必要があります。まず、構成に含まれるキーを使用して、Navigation ルーターからこのステートにアクセスします。

var todoMVC = Navigation.StateInfoConfig.dialogs.todoMVC;
var listState = todoMVC.states.list;

ナビゲーション リスナーは、ステートの "navigated" プロパティに割り当てられる関数です。URL が変化すると、Navigation ルーターは必ずこの関数を呼び出し、URL から抽出したデータを渡します。client.js のコードを、新しいフィルターを使用して "content" div に再レンダリングするナビゲーション リスナーと List コンポーネントに置き換えます。残りは React が実行し、DOM を更新して新しくフィルター処理された Todo 項目を表示します。

listState.navigated = function(data){
  var list = app.getList(todos, data.filter);
  ReactDOM.render(list, document.getElementById('content'));
}

フィルター処理を実装する際、最初のクライアント レンダリングをトリガーしたコードを client.js から誤って削除しました。この機能は、client.js の最後で "Navigation.start" の呼び出しを追加して再度組み込みます。これにより、現在のブラウザー URL をルーターの navigateLink 関数 (ナビゲーション リスナーを開始して、クライアント レンダリングを実行します) に効果的に渡すことができます。バンドル タスクを再実行して、最新の変更を app.js と bundle.js に反映します。

新しい PE アプローチは、現代の錬金術と言えます。サーバー側でレンダリングされるアプリケーションを卑金属として、SPA という金に変化させます。しかし、この変換が機能するには、JavaScript ライブラリから作成され、サーバーとブラウザーで同じように動作する特殊な卑金属が必要です。それが、Razor と ASP.NET MVC ルーティングに代わる React と Navigation ルーターです。これは、Web における新しい化学です。

基準適合テスト

PE の目的は、最新ブラウザーでは高度なエクスペリエンスを提供しつつ、すべてのブラウザーで機能するアプリケーションを生み出すことです。ですが、この高度なエクスペリエンスを構築するにあたり、今回は旧バーションのブラウザーで Todo リストを機能させることをやめました。SPA 変換には、Internet Explorer 9 などでサポートされていない HTML5 History API を使用しています。

PE で重要なことは、すべてのブラウザーに同じエクスペリエンスを提供することではありません。Internet Explorer 9 では、Todo リストが SPA である必要はありません。HTML5 History がサポートされていないブラウザーでは、サーバー側でレンダリングされるアプリケーションを使用しても問題ありません。bundle.js を動的に読み込むように Razor ビューを変更し、HTML5 History をサポートするブラウザーにのみ Razor ビューが送信されるようにします。

if (window.history && window.history.pushState) {
  var script = document.createElement('script');
  script.src = "/Scripts/bundle.js";
  document.body.appendChild(script);
}

このチェックを「cutting the mustard」(基準適合テスト) と呼びます。要件を満たすブラウザーのみが、JavaScript を受け取るに値すると見なされるためです。ブラウザーが要求を満たすかどうかによって、同じ絵がうさぎに見えたり、アヒルに見えたりするような錯覚が Web 上に生み出されます。つまり、最新のブラウザーで Todo リストを見れば SPA ですが、旧バーションのブラウザーで見ると従来のクライアント/サーバー アプリケーションになります。


Graham Mendick は、すべての人がアクセスできる Web の存在を信じ、Isomorphic JavaScript から生まれたプログレッシブ エンハンスメントの新たな可能性に心を躍らせています。彼は、自身が生み出した Navigation JavaScript ルーターが、Isomorphic (同一構造) な開発を行う助けになることを願っています。Twitter は @grahammendick (英語) でフォローできます。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Steve Sanderson に心より感謝いたします。
Steve Sanderson は、Microsoft の ASP.NET チームに所属する Web 開発者です。現在彼が注目するのは、リッチな JavaScript アプリケーションをビルドする開発者が効果的に ASP.NET を利用できるようにする方法です。