第4版が利用可能です。こちらで読んでください

第21章プロジェクト: スキル共有ウェブサイト

知識があれば、他の人々のロウソクに火を灯しましょう。

マーガレット・フラー
Picture of two unicycles

スキルシェアリング ミーティングとは、共通の関心を持つ人々が集まり、自分が知っていることについて小規模で非公式なプレゼンテーションを行うイベントです。ガーデニングのスキルシェアリングミーティングでは、セロリの栽培方法を説明する人がいるかもしれません。あるいは、プログラミングのスキルシェアリンググループでは、Node.jsについて説明するために参加できます。

このようなミートアップ(コンピューターに関するものである場合は、ユーザーグループと呼ばれることも多い)は、視野を広げ、新しい開発について学ぶ、または単に同じような関心を持つ人と出会うための素晴らしい方法です。多くの大きな都市にはJavaScriptのミートアップがあります。通常は無料で参加でき、私が参加したものはフレンドリーで歓迎的なものばかりでした。

この最後のプロジェクトの章では、スキルシェアリングミーティングで行われる講演を管理するためのウェブサイトを構築することを目標とします。メンバーの一人のオフィスで定期的に集まって一輪車について話す少人数のグループを想像してください。ミーティングの以前の主催者は別の町に移り住み、そのタスクを引き継ぐ人が現れませんでした。中央の主催者なしで、参加者が互いに講演を提案し、議論できるシステムが必要となります。

前のと同様に、この章のコードの一部はNode.js用に記述されており、見ているHTMLページで直接実行することはおそらくできません。プロジェクトの完全なコードは、https://eloquentjavascript.dokyumento.jp/code/skillsharing.zipからダウンロードできます。

設計

このプロジェクトには、Node.jsで記述されたサーバー部分と、ブラウザで記述されたクライアント部分があります。サーバーはシステムのデータを保存し、クライアントに提供します。また、クライアント側のシステムを実装するファイルも提供します。

サーバーは、次のミーティングで提案された講演のリストを保持し、クライアントはそのリストを表示します。各講演には、発表者の名前、タイトル、要約、およびそれに関連付けられたコメントの配列があります。クライアントを使用すると、ユーザーは新しい講演を提案(リストに追加)、講演を削除、および既存の講演にコメントすることができます。ユーザーがこのような変更を行うたびに、クライアントはHTTPリクエストを行い、サーバーに変更を伝えます。

Screenshot of the skill-sharing website

このアプリケーションは、提案された現在の講演とそのコメントのライブビューを表示するように設定されます。どこかの誰かが新しい講演を送信したり、コメントを追加したりすると、ブラウザにページを開いているすべての人がすぐにその変更を確認できます。これは少し難しい課題です。Webサーバーがクライアントに接続を開く方法がなく、特定のWebサイトを現在見ているクライアントを知る良い方法もないからです。

この問題に対する一般的な解決策はロングポーリングと呼ばれ、Nodeの設計の動機の一つでもあります。

ロングポーリング

変更があったことをクライアントにすぐに通知するには、そのクライアントへの接続が必要です。Webブラウザは従来、接続を受け入れず、クライアントは多くの場合、このような接続をブロックするルーターの背後にあるため、サーバーが接続を開始することは現実的ではありません。

クライアントが接続を開いて維持するように配置して、サーバーが必要に応じて情報を送信するために使用できるようにすることができます。

しかし、HTTPリクエストは、単純な情報の流れしか許しません。クライアントがリクエストを送信し、サーバーが単一レスポンスで返答し、それでおしまい。最新のブラウザでサポートされているWebSocketsと呼ばれるテクノロジーは、任意のデータ交換のための接続を開くことを可能にします。しかし、それらを適切に使用するのはやや複雑です。

この章では、より単純な手法であるロングポーリングを使用します。この手法では、クライアントは通常のHTTPリクエストを使用してサーバーに新しい情報を継続的に要求し、サーバーは新しいレポートがない場合、応答を遅らせます。

クライアントが常にポーリングリクエストを開いていることを確認する限り、利用可能になった後すぐにサーバーから情報を受信します。たとえば、Fatmaがブラウザでスキルシェアリングアプリケーションを開いている場合、そのブラウザは更新のリクエストを行い、そのリクエストへの応答を待っています。Imanがエクストリームダウンヒル一輪車に関する講演を送信すると、サーバーはFatmaが更新を待っていることに気づき、新しい講演を含む応答を保留中のリクエストに送信します。Fatmaのブラウザはデータを受信し、画面を更新して講演を表示します。

接続がタイムアウトするのを防ぐため(アクティビティの不足のために中断される)、ロングポーリング手法は通常、各リクエストの最大時間を設定します。その時間経過後、サーバーは新しいレポートがない場合でも応答し、その後クライアントは新しいリクエストを開始します。定期的にリクエストを再開することで、一時的な接続障害やサーバーの問題からクライアントが回復できるようになり、手法の堅牢性も向上します。

ロングポーリングを使用しているビジーなサーバーには、数千もの待機中のリクエスト、つまりTCP接続が開いている可能性があります。各接続に個別の制御スレッドを作成することなく多くの接続を簡単に管理できるNodeは、このようなシステムに適しています。

HTTPインターフェース

サーバーまたはクライアントの設計を開始する前に、それらが接触するポイントについて考えてみましょう。それは、それらが通信するHTTPインターフェースです。

リクエストとレスポンス本文の形式としてJSONを使用します。第20章のファイルサーバーと同様に、HTTPメソッドとヘッダーを有効活用しようとします。インターフェースの中心は/talksパスです。/talksで始まらないパスは、静的ファイル(クライアント側のシステムのHTMLとJavaScriptコード)を提供するために使用されます。

/talksへのGETリクエストは、次のようなJSONドキュメントを返します。

[{"title": "Unituning",
  "presenter": "Jamal",
  "summary": "Modifying your cycle for extra style",
  "comments": []}]

新しい講演を作成するには、/talks/UnituningのようなURLにPUTリクエストを行います。ここで、2番目のスラッシュの後の部分は、講演のタイトルです。PUTリクエストの本文には、presenterプロパティとsummaryプロパティを持つJSONオブジェクトを含める必要があります。

講演のタイトルには、URLに通常は表示されないスペースやその他の文字が含まれる可能性があるため、このようなURLを作成する際には、タイトル文字列をencodeURIComponent関数でエンコードする必要があります。

console.log("/talks/" + encodeURIComponent("How to Idle"));
// → /talks/How%20to%20Idle

アイドルに関する講演を作成するためのリクエストは、次のようになります。

PUT /talks/How%20to%20Idle HTTP/1.1
Content-Type: application/json
Content-Length: 92

{"presenter": "Maureen",
 "summary": "Standing still on a unicycle"}

このようなURLは、講演のJSON表現を取得するためのGETリクエストと、講演を削除するためのDELETEリクエストもサポートします。

講演にコメントを追加するには、/talks/Unituning/commentsのようなURLにPOSTリクエストを行い、authorプロパティとmessageプロパティを持つJSON本文を使用します。

POST /talks/Unituning/comments HTTP/1.1
Content-Type: application/json
Content-Length: 72

{"author": "Iman",
 "message": "Will you talk about raising a cycle?"}

ロングポーリングをサポートするために、/talksへのGETリクエストには、新しい情報がない場合にサーバーが応答を遅らせるように指示する追加のヘッダーを含めることができます。キャッシングの管理を目的とした通常の一対のヘッダーであるETagIf-None-Matchを使用します。

サーバーは、レスポンスにETag(エンティティタグ)ヘッダーを含めることができます。その値は、リソースの現在のバージョンを識別する文字列です。クライアントは、後でそのリソースを再度リクエストするときに、同じ文字列を保持するIf-None-Matchヘッダーを含めることで、条件付きリクエストを行うことができます。リソースが変更されていない場合、サーバーはステータスコード304(「変更なし」)で応答し、クライアントにキャッシュされたバージョンがまだ有効であることを伝えます。タグが一致しない場合、サーバーは通常どおり応答します。

クライアントがサーバーに講演リストのどのバージョンを持っているかを伝え、サーバーがそのリストが変更された場合にのみ応答するようなものが必要です。しかし、304レスポンスをすぐに返す代わりに、サーバーはレスポンスを遅らせ、新しい情報が利用可能になった場合、または指定された時間が経過した場合にのみ返します。ロングポーリングリクエストを通常の条件付きリクエストと区別するために、Prefer: wait=90という別のヘッダーを付与します。これは、クライアントが応答を最大90秒間待機することをサーバーに伝えます。

サーバーは、講演が変更されるたびに更新するバージョン番号を保持し、それをETag値として使用します。クライアントは、講演が変更されたときに通知を受けるために、次のようなリクエストを行うことができます。

GET /talks HTTP/1.1
If-None-Match: "4"
Prefer: wait=90

(time passes)

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "5"
Content-Length: 295

[....]

ここで説明したプロトコルは、アクセス制御を行いません。誰でもコメントしたり、講演を変更したり、削除したりできます。(インターネットにはいたずら好きが多いので、さらなる保護なしでこのようなシステムをオンラインにすることは、おそらくうまくいかないでしょう。)

サーバー

プログラムのサーバーサイド部分を構築することから始めましょう。このセクションのコードはNode.js上で実行されます。

ルーティング

私たちのサーバーは、createServerを使用してHTTPサーバーを起動します。新しいリクエストを処理する関数では、サポートする様々な種類のリクエスト(メソッドとパスによって決定されます)を区別する必要があります。これは長いif文の連鎖で行うことができますが、より良い方法があります。

ルーターは、リクエストを処理できる関数にディスパッチするのに役立つコンポーネントです。たとえば、正規表現/^\/talks\/([^\/]+)$//talks/ の後にトークタイトルが続く)に一致するPUTリクエストを、特定の関数で処理できるようにルーターに指示できます。さらに、正規表現で括弧で囲まれたパスの意味のある部分(この場合はトークタイトル)を抽出し、ハンドラー関数に渡すことができます。

NPMには多くの優れたルーターパッケージがありますが、ここでは原則を説明するために自分で作成します。

これは、後でサーバーモジュールからrequireするrouter.jsです。

const {parse} = require("url");

module.exports = class Router {
  constructor() {
    this.routes = [];
  }
  add(method, url, handler) {
    this.routes.push({method, url, handler});
  }
  resolve(context, request) {
    let path = parse(request.url).pathname;

    for (let {method, url, handler} of this.routes) {
      let match = url.exec(path);
      if (!match || request.method != method) continue;
      let urlParts = match.slice(1).map(decodeURIComponent);
      return handler(context, ...urlParts, request);
    }
    return null;
  }
};

このモジュールはRouterクラスをエクスポートします。ルーターオブジェクトでは、addメソッドを使用して新しいハンドラーを登録し、resolveメソッドでリクエストを解決できます。

後者は、ハンドラーが見つかった場合はレスポンスを返し、そうでない場合はnullを返します。一致するものが見つかるまで、ルートを順番に(定義された順序で)試します。

ハンドラー関数は、context値(この場合はサーバーインスタンスになります)、正規表現で定義したグループのマッチ文字列、およびリクエストオブジェクトを使用して呼び出されます。生のURLには%20スタイルのコードが含まれている可能性があるため、文字列はURLデコードする必要があります。

ファイルの提供

リクエストがルーターで定義されたリクエストタイプのいずれにも一致しない場合、サーバーはそれをpublicディレクトリのファイルのリクエストとして解釈する必要があります。第20章で定義されているファイルサーバーを使用してこのようなファイルを提供することは可能ですが、ファイルに対するPUTおよびDELETEリクエストをサポートする必要も望みもなく、キャッシングのサポートなどの高度な機能が必要になります。そのため、代わりにNPMから堅牢で、よくテストされた静的ファイルサーバーを使用しましょう。

私はecstaticを選択しました。これはNPM上にある唯一のそのようなサーバーではありませんが、うまく機能し、私たちの目的に合っています。ecstaticパッケージは、設定オブジェクトを使用して呼び出すことができる関数をエクスポートし、リクエストハンドラー関数を生成します。rootオプションを使用して、サーバーがファイルを探す場所を指定します。ハンドラー関数はrequestresponseパラメーターを受け入れ、createServerに直接渡して、ファイルのみを提供するサーバーを作成できます。ただし、最初に特別に処理する必要があるリクエストを確認したいので、別の関数でラップします。

const {createServer} = require("http");
const Router = require("./router");
const ecstatic = require("ecstatic");

const router = new Router();
const defaultHeaders = {"Content-Type": "text/plain"};

class SkillShareServer {
  constructor(talks) {
    this.talks = talks;
    this.version = 0;
    this.waiting = [];

    let fileServer = ecstatic({root: "./public"});
    this.server = createServer((request, response) => {
      let resolved = router.resolve(this, request);
      if (resolved) {
        resolved.catch(error => {
          if (error.status != null) return error;
          return {body: String(error), status: 500};
        }).then(({body,
                  status = 200,
                  headers = defaultHeaders}) => {
          response.writeHead(status, headers);
          response.end(body);
        });
      } else {
        fileServer(request, response);
      }
    });
  }
  start(port) {
    this.server.listen(port);
  }
  stop() {
    this.server.close();
  }
}

これは、前の章のファイルサーバーと同様のレスポンスに関する規則を使用しています。ハンドラーは、レスポンスを記述するオブジェクトを解決するPromiseを返します。また、その状態も保持するオブジェクトにサーバーをラップします。

トークをリソースとして

提案されたトークは、サーバーのtalksプロパティに格納されます。これは、プロパティ名がトークタイトルであるオブジェクトです。これらは/talks/[title]でHTTPリソースとして公開されるため、クライアントがそれらを使用するために使用できる様々なメソッドを実装するハンドラーをルーターに追加する必要があります。

単一のトークをGETするリクエストのハンドラーは、トークを検索し、トークのJSONデータまたは404エラーレスポンスのいずれかで応答する必要があります。

const talkPath = /^\/talks\/([^\/]+)$/;

router.add("GET", talkPath, async (server, title) => {
  if (title in server.talks) {
    return {body: JSON.stringify(server.talks[title]),
            headers: {"Content-Type": "application/json"}};
  } else {
    return {status: 404, body: `No talk '${title}' found`};
  }
});

トークの削除は、talksオブジェクトから削除することで行われます。

router.add("DELETE", talkPath, async (server, title) => {
  if (title in server.talks) {
    delete server.talks[title];
    server.updated();
  }
  return {status: 204};
});

後で定義するupdatedメソッドは、変更について待機中のロングポーリングリクエストに通知します。

リクエストボディの内容を取得するために、読み取り可能なストリームからすべてのコンテンツを読み取り、文字列を解決するPromiseを返すreadStreamという関数を定義します。

function readStream(stream) {
  return new Promise((resolve, reject) => {
    let data = "";
    stream.on("error", reject);
    stream.on("data", chunk => data += chunk.toString());
    stream.on("end", () => resolve(data));
  });
}

リクエストボディを読み取る必要があるハンドラーの1つは、新しいトークを作成するために使用されるPUTハンドラーです。これは、提供されたデータに文字列であるpresentersummaryプロパティがあるかどうかを確認する必要があります。システム外部から来るデータはナンセンスである可能性があり、内部データモデルを破損したり、不正なリクエストが来たときにクラッシュしたりしたくありません。

データが有効そうであれば、ハンドラーは新しいトークを表すオブジェクトをtalksオブジェクトに格納し、このタイトルで既存のトークを上書きする可能性があり、再度updatedを呼び出します。

router.add("PUT", talkPath,
           async (server, title, request) => {
  let requestBody = await readStream(request);
  let talk;
  try { talk = JSON.parse(requestBody); }
  catch (_) { return {status: 400, body: "Invalid JSON"}; }

  if (!talk ||
      typeof talk.presenter != "string" ||
      typeof talk.summary != "string") {
    return {status: 400, body: "Bad talk data"};
  }
  server.talks[title] = {title,
                         presenter: talk.presenter,
                         summary: talk.summary,
                         comments: []};
  server.updated();
  return {status: 204};
});

トークにコメントを追加する方法は同様です。readStreamを使用してリクエストのコンテンツを取得し、結果のデータを確認し、有効そうであればコメントとして格納します。

router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
           async (server, title, request) => {
  let requestBody = await readStream(request);
  let comment;
  try { comment = JSON.parse(requestBody); }
  catch (_) { return {status: 400, body: "Invalid JSON"}; }

  if (!comment ||
      typeof comment.author != "string" ||
      typeof comment.message != "string") {
    return {status: 400, body: "Bad comment data"};
  } else if (title in server.talks) {
    server.talks[title].comments.push(comment);
    server.updated();
    return {status: 204};
  } else {
    return {status: 404, body: `No talk '${title}' found`};
  }
});

存在しないトークにコメントを追加しようとすると、404エラーが返されます。

ロングポーリングのサポート

サーバーの最も興味深い側面は、ロングポーリングを処理する部分です。/talksへのGETリクエストが入ってきた場合、それは通常のリクエストかロングポーリングリクエストのどちらかです。

クライアントにトークの配列を送信する必要がある場所が複数あるため、最初にそのような配列を構築し、レスポンスにETagヘッダーを含めるヘルパーメソッドを定義します。

SkillShareServer.prototype.talkResponse = function() {
  let talks = [];
  for (let title of Object.keys(this.talks)) {
    talks.push(this.talks[title]);
  }
  return {
    body: JSON.stringify(talks),
    headers: {"Content-Type": "application/json",
              "ETag": `"${this.version}"`,
              "Cache-Control": "no-store"}
  };
};

ハンドラー自体は、If-None-MatchPreferヘッダーが存在するかどうかを確認するためにリクエストヘッダーを確認する必要があります。Nodeは、名前が大文字と小文字を区別しないように指定されているヘッダーを、小文字の名前で格納します。

router.add("GET", /^\/talks$/, async (server, request) => {
  let tag = /"(.*)"/.exec(request.headers["if-none-match"]);
  let wait = /\bwait=(\d+)/.exec(request.headers["prefer"]);
  if (!tag || tag[1] != server.version) {
    return server.talkResponse();
  } else if (!wait) {
    return {status: 304};
  } else {
    return server.waitForChanges(Number(wait[1]));
  }
});

タグが指定されていない場合、またはサーバーの現在のバージョンと一致しないタグが指定されている場合、ハンドラーはトークのリストで応答します。リクエストが条件付きで、トークが変更されていない場合は、Preferヘッダーを参照して、レスポンスを遅らせるか、すぐにレスポンスするかを確認します。

遅延リクエストのコールバック関数は、サーバーのwaiting配列に格納され、何かが起こったときに通知されます。waitForChangesメソッドは、リクエストが十分に待機したときに304ステータスで応答するタイマーもすぐに設定します。

SkillShareServer.prototype.waitForChanges = function(time) {
  return new Promise(resolve => {
    this.waiting.push(resolve);
    setTimeout(() => {
      if (!this.waiting.includes(resolve)) return;
      this.waiting = this.waiting.filter(r => r != resolve);
      resolve({status: 304});
    }, time * 1000);
  });
};

updatedで変更を登録すると、versionプロパティが増加し、すべての待機中のリクエストがウェイクアップします。

SkillShareServer.prototype.updated = function() {
  this.version++;
  let response = this.talkResponse();
  this.waiting.forEach(resolve => resolve(response));
  this.waiting = [];
};

これでサーバーコードは完了です。SkillShareServerのインスタンスを作成し、ポート8000で起動すると、結果として得られるHTTPサーバーは、/talks URLの下にあるトーク管理インターフェースとともに、publicサブディレクトリからファイルを配信します。

new SkillShareServer(Object.create(null)).start(8000);

クライアント

スキル共有ウェブサイトのクライアントサイド部分は、小さなHTMLページ、スタイルシート、JavaScriptファイルの3つのファイルで構成されています。

HTML

ディレクトリに対応するパスに直接リクエストが行われた場合に、ウェブサーバーがindex.htmlという名前のファイルを配信しようとすることは広く使用されている規則です。私たちが使用するファイルサーバーモジュールであるecstaticは、この規則をサポートしています。パス/へのリクエストが行われると、サーバーはファイル./public/index.html(私たちが指定したルートである./public)を探し、見つかった場合はそのファイルを返します。

したがって、ブラウザが私たちのサーバーを指しているときにページを表示させたい場合は、public/index.htmlに配置する必要があります。これが私たちのインデックスファイルです。

<!doctype html>
<meta charset="utf-8">
<title>Skill Sharing</title>
<link rel="stylesheet" href="skillsharing.css">

<h1>Skill Sharing</h1>

<script src="skillsharing_client.js"></script>

これにより、ドキュメントタイトルが定義され、スタイルシートが含まれます。スタイルシートは、いくつかのスタイルを定義し、特にトーク間にスペースがあることを確認します。

最後に、ページの上部にヘッディングを追加し、クライアントサイドアプリケーションを含むスクリプトを読み込みます。

アクション

アプリケーションの状態は、トークのリストとユーザーの名前で構成され、{talks, user}オブジェクトに格納します。ユーザーインターフェースが状態を直接操作したり、HTTPリクエストを送信したりすることは許可されていません。むしろ、ユーザーが実行しようとしていることを記述するアクションを発行する可能性があります。

handleAction関数は、このようなアクションを受け取り、実行します。状態の更新が非常に単純なため、状態の変更は同じ関数で処理されます。

function handleAction(state, action) {
  if (action.type == "setUser") {
    localStorage.setItem("userName", action.user);
    return Object.assign({}, state, {user: action.user});
  } else if (action.type == "setTalks") {
    return Object.assign({}, state, {talks: action.talks});
  } else if (action.type == "newTalk") {
    fetchOK(talkURL(action.title), {
      method: "PUT",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({
        presenter: state.user,
        summary: action.summary
      })
    }).catch(reportError);
  } else if (action.type == "deleteTalk") {
    fetchOK(talkURL(action.talk), {method: "DELETE"})
      .catch(reportError);
  } else if (action.type == "newComment") {
    fetchOK(talkURL(action.talk) + "/comments", {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({
        author: state.user,
        message: action.message
      })
    }).catch(reportError);
  }
  return state;
}

ページが読み込まれたときに復元できるように、ユーザーの名前をlocalStorageに格納します。

サーバーに関係する必要があるアクションは、前に説明したHTTPインターフェースにfetchを使用してネットワークリクエストを行います。サーバーがエラーコードを返す場合に、返されたPromiseが拒否されるようにするラッパー関数であるfetchOKを使用します。

function fetchOK(url, options) {
  return fetch(url, options).then(response => {
    if (response.status < 400) return response;
    else throw new Error(response.statusText);
  });
}

このヘルパー関数は、指定されたタイトルのトークのURLを作成するために使用されます。

function talkURL(title) {
  return "talks/" + encodeURIComponent(title);
}

リクエストが失敗した場合、ページが何もせずに説明なしで停止したままになってほしくありません。そのため、少なくともユーザーに何か問題が発生したことを伝えるダイアログを表示するreportErrorという関数を定義します。

function reportError(error) {
  alert(String(error));
}

レンダリングコンポーネント

アプリケーションをコンポーネントに分割するという、19章で見たものと同様のアプローチを使用します。ただし、一部のコンポーネントは更新する必要がないか、更新時に常に完全に再描画されるため、それらをクラスとしてではなく、DOMノードを直接返す関数として定義します。たとえば、ユーザーが自分の名前を入力できるフィールドを表示するコンポーネントを以下に示します。

function renderUserField(name, dispatch) {
  return elt("label", {}, "Your name: ", elt("input", {
    type: "text",
    value: name,
    onchange(event) {
      dispatch({type: "setUser", user: event.target.value});
    }
  }));
}

DOM要素を構築するために使用されるelt関数は、19章で使用したものと同じです。

コメントのリストと新しいコメントを追加するためのフォームを含むトークをレンダリングするためにも、同様の関数を使用します。

function renderTalk(talk, dispatch) {
  return elt(
    "section", {className: "talk"},
    elt("h2", null, talk.title, " ", elt("button", {
      type: "button",
      onclick() {
        dispatch({type: "deleteTalk", talk: talk.title});
      }
    }, "Delete")),
    elt("div", null, "by ",
        elt("strong", null, talk.presenter)),
    elt("p", null, talk.summary),
    ...talk.comments.map(renderComment),
    elt("form", {
      onsubmit(event) {
        event.preventDefault();
        let form = event.target;
        dispatch({type: "newComment",
                  talk: talk.title,
                  message: form.elements.comment.value});
        form.reset();
      }
    }, elt("input", {type: "text", name: "comment"}), " ",
       elt("button", {type: "submit"}, "Add comment")));
}

"submit"イベントハンドラーは、"newComment"アクションを作成した後にform.resetを呼び出して、フォームの内容をクリアします。

中程度の複雑さのDOMを作成する場合、このプログラミングスタイルは非常に分かりにくくなります。広く使用されている(非標準の)JavaScript拡張であるJSXと呼ばれるものがあり、スクリプトにHTMLを直接記述できます。これにより、(何を美しいと考えるかによって異なりますが)コードをより美しくすることができます。このようなコードを実行する前に、スクリプトに対してプログラムを実行して、擬似HTMLをここで使用しているものと非常によく似たJavaScript関数呼び出しに変換する必要があります。

コメントはレンダリングが簡単です。

function renderComment(comment) {
  return elt("p", {className: "comment"},
             elt("strong", null, comment.author),
             ": ", comment.message);
}

最後に、ユーザーが新しいトークを作成するために使用できるフォームは、次のようにレンダリングされます。

function renderTalkForm(dispatch) {
  let title = elt("input", {type: "text"});
  let summary = elt("input", {type: "text"});
  return elt("form", {
    onsubmit(event) {
      event.preventDefault();
      dispatch({type: "newTalk",
                title: title.value,
                summary: summary.value});
      event.target.reset();
    }
  }, elt("h3", null, "Submit a Talk"),
     elt("label", null, "Title: ", title),
     elt("label", null, "Summary: ", summary),
     elt("button", {type: "submit"}, "Submit"));
}

ポーリング

アプリを起動するには、現在のトークリストが必要です。最初の読み込みはロングポーリングプロセスと密接に関連しているため(読み込みからのETagはポーリング時に使用する必要がある)、/talksに対してサーバーをポーリングし続け、新しいトークセットが利用可能になったときにコールバック関数を呼び出す関数を記述します。

async function pollTalks(update) {
  let tag = undefined;
  for (;;) {
    let response;
    try {
      response = await fetchOK("/talks", {
        headers: tag && {"If-None-Match": tag,
                         "Prefer": "wait=90"}
      });
    } catch (e) {
      console.log("Request failed: " + e);
      await new Promise(resolve => setTimeout(resolve, 500));
      continue;
    }
    if (response.status == 304) continue;
    tag = response.headers.get("ETag");
    update(await response.json());
  }
}

これは、ループとリクエストの待機を容易にするためのasync関数です。各反復で、トークのリストを(これが最初のリクエストでない場合は、ロングポーリングリクエストにするヘッダーを含めて)取得する無限ループを実行します。

リクエストが失敗すると、関数はしばらく待ってから再試行します。このように、ネットワーク接続がしばらくの間途切れてから復旧した場合、アプリケーションは回復して更新を続行できます。setTimeoutを介して解決されたPromiseは、async関数を強制的に待機させる方法です。

サーバーが304応答を返す場合、それはロングポーリングリクエストがタイムアウトしたことを意味するため、関数はただちに次のリクエストを開始する必要があります。応答が通常の200応答の場合、その本文はJSONとして読み取られ、コールバックに渡され、そのETagヘッダー値は次の反復のために保存されます。

アプリケーション

次のコンポーネントは、ユーザーインターフェース全体を結び付けます。

class SkillShareApp {
  constructor(state, dispatch) {
    this.dispatch = dispatch;
    this.talkDOM = elt("div", {className: "talks"});
    this.dom = elt("div", null,
                   renderUserField(state.user, dispatch),
                   this.talkDOM,
                   renderTalkForm(dispatch));
    this.syncState(state);
  }

  syncState(state) {
    if (state.talks != this.talks) {
      this.talkDOM.textContent = "";
      for (let talk of state.talks) {
        this.talkDOM.appendChild(
          renderTalk(talk, this.dispatch));
      }
      this.talks = state.talks;
    }
  }
}

トークが変更されると、このコンポーネントはそれらをすべて再描画します。これは簡単ですが、無駄でもあります。演習で改めて説明します。

アプリケーションは次のように開始できます。

function runApp() {
  let user = localStorage.getItem("userName") || "Anon";
  let state, app;
  function dispatch(action) {
    state = handleAction(state, action);
    app.syncState(state);
  }

  pollTalks(talks => {
    if (!app) {
      state = {user, talks};
      app = new SkillShareApp(state, dispatch);
      document.body.appendChild(app.dom);
    } else {
      dispatch({type: "setTalks", talks});
    }
  }).catch(reportError);
}

runApp();

サーバーを実行し、http://localhost:8000の2つのブラウザーウィンドウを互いの隣に開くと、一方のウィンドウで実行するアクションは、もう一方のウィンドウにすぐに表示されることがわかります。

演習

次の演習では、この章で定義されたシステムの変更が含まれます。演習に取り組むには、最初にコードをダウンロードし(https://eloquentjavascript.dokyumento.jp/code/skillsharing.zip)、Nodeをインストールし(https://node.dokyumento.jp)、npm installを使用してプロジェクトの依存関係をインストールしてください。

ディスク永続化

スキル共有サーバーは、データをメモリにのみ保持します。つまり、サーバーがクラッシュしたり、何らかの理由で再起動されたりすると、すべてのトークとコメントが失われます。

サーバーを拡張して、トークデータをディスクに保存し、再起動時に自動的にデータをリロードするようにします。効率については心配しないでください。機能する最も簡単な方法を実行してください。

私が思いつく最も簡単な解決策は、talksオブジェクト全体をJSONとしてエンコードし、writeFileを使用してファイルにダンプすることです。サーバーのデータが変更されるたびに呼び出されるメソッド(updated)が既にあります。ディスクに新しいデータを書き込むように拡張できます。

たとえば./talks.jsonなどのファイル名を選択します。サーバーの起動時に、readFileを使用してそのファイルの読み取りを試行し、成功した場合は、ファイルの内容を初期データとして使用できます。

ただし、注意が必要です。talksオブジェクトは、in演算子を確実に使用できるように、プロトタイプのないオブジェクトとして開始されました。JSON.parseは、Object.prototypeをプロトタイプとする通常のオブジェクトを返します。JSONをファイル形式として使用する場合は、JSON.parseによって返されたオブジェクトのプロパティを新しいプロトタイプのないオブジェクトにコピーする必要があります。

コメントフィールドのリセット

トークの全面的な再描画は、通常、DOMノードとその同一の置換との違いを判別できないため、非常にうまく機能します。ただし、例外があります。あるブラウザーウィンドウでトークのコメントフィールドに入力し始め、別のウィンドウでそのトークにコメントを追加すると、最初のウィンドウのフィールドが再描画され、その内容とフォーカスが両方とも削除されます。

複数の人が同時にコメントを追加する激しい議論では、これは迷惑です。解決策を考え出すことができますか?

これを行う最良の方法は、おそらく、トークコンポーネントオブジェクトをsyncStateメソッドとともに作成し、トークの変更されたバージョンを表示するように更新できるようにすることです。通常の動作では、トークを変更できる唯一の方法はコメントを追加することだけなので、syncStateメソッドは比較的単純にすることができます。

難しい部分は、変更されたトークリストが入力されると、既存のDOMコンポーネントリストと新しいリストのトークを調整する必要があることです。つまり、削除されたトークのコンポーネントを削除し、変更されたトークのコンポーネントを更新する必要があります。

これを行うには、トークタイトルの下にトークコンポーネントを格納するデータ構造を保持すると、特定のトークにコンポーネントが存在するかどうかを簡単に判断できます。その後、新しいトークの配列をループ処理し、それぞれについて、既存のコンポーネントを同期するか、新しいコンポーネントを作成できます。削除されたトークのコンポーネントを削除するには、コンポーネントをループ処理し、対応するトークがまだ存在するかどうかを確認する必要があります。