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

知識があるなら、他の人があなたの知識で自分のろうそくに火を灯せるようにしなさい。

マーガレット・フラー
Illustration showing two unicycles leaned against a mailbox

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

この最後のプロジェクトの章では、スキル共有ミーティングで発表される講演を管理するためのウェブサイトを構築することを目標としています。メンバーの一人のオフィスで定期的に集まり、一輪車について話し合う小規模なグループを想像してみてください。以前のミーティングの主催者が別の町に引っ越してしまい、誰もこの作業を引き継ぐ人がいませんでした。私たちは、参加者が積極的に主催者なしで講演を提案し、議論できるシステムが必要です。

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

設計

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

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

Screenshot of the skill-sharing website

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

この問題の一般的な解決策はロングポーリングと呼ばれており、これはNodeの設計の動機の1つです。

ロングポーリング

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

クライアントが接続を開いて、サーバーが必要なときに情報送信に使用できるように、接続を維持するように手配できます。しかし、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リクエストの本文には、presentersummaryプロパティを持つ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リクエストで行われます。JSON本文には、authormessageプロパティがあります。

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で実行されます。

ルーティング

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

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

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

これは router.mjs であり、後でサーバーモジュールから import します。

export class Router {
  constructor() {
    this.routes = [];
  }
  add(method, url, handler) {
    this.routes.push({method, url, handler});
  }
  async resolve(request, context) {
    let {pathname} = new URL(request.url, "http://d");
    for (let {method, url, handler} of this.routes) {
      let match = url.exec(pathname);
      if (!match || request.method != method) continue;
      let parts = match.slice(1).map(decodeURIComponent);
      return handler(context, ...parts, request);
    }
  }
}

このモジュールは Router クラスをエクスポートします。ルーターオブジェクトを使用すると、add メソッドを使用して、特定のメソッドおよび URL パターンのハンドラーを登録できます。resolve メソッドでリクエストが解決されると、ルーターはリクエストに一致するメソッドと URL を持つハンドラーを呼び出し、その結果を返します。

ハンドラー関数は、resolve に与えられた context 値で呼び出されます。これを使用して、サーバーの状態にアクセスできるようにします。さらに、正規表現で定義したグループの一致文字列とリクエストオブジェクトを受け取ります。生の URL には %20 スタイルのコードが含まれている可能性があるため、文字列は URL デコードする必要があります。

ファイルの提供

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

私は serve-static を選びました。これは NPM 上で唯一のそのようなサーバーではありませんが、うまく機能し、私たちの目的に適合します。serve-static パッケージは、ルートディレクトリを指定して呼び出すとリクエストハンドラー関数を生成できる関数をエクスポートします。ハンドラー関数は、"node:http" からサーバーによって提供される request 引数と response 引数、およびリクエストに一致するファイルがない場合に呼び出す 3 番目の引数(関数)を受け入れます。サーバーは、ルーターで定義されているように、特別に処理する必要があるリクエストを最初にチェックする必要があるため、別の関数でラップします。

import {createServer} from "node:http";
import serveStatic from "serve-static";

function notFound(request, response) {
  response.writeHead(404, "Not found");
  response.end("<h1>Not found</h1>");
}

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

    let fileServer = serveStatic("./public");
    this.server = createServer((request, response) => {
      serveFromRouter(this, request, response, () => {
        fileServer(request, response,
                   () => notFound(request, response));
      });
    });
  }
  start(port) {
    this.server.listen(port);
  }
  stop() {
    this.server.close();
  }
}

serveFromRouter 関数は、(request, response, next) 引数を受け取る、fileServer と同じインターフェースを持ちます。これを使用して、複数のリクエストハンドラーを「チェーン」することができます。これにより、各ハンドラーはリクエストを処理するか、その責任を次のハンドラーに渡すことができます。最終的なハンドラーである notFound は、「見つかりません」エラーで応答するだけです。

serveFromRouter 関数は、前の章のファイルサーバーと同様のレスポンスの規則を使用します。ルーター内のハンドラーは、レスポンスを記述するオブジェクトに解決されるプロミスを返します。

import {Router} from "./router.mjs";

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

async function serveFromRouter(server, request,
                               response, next) {
  let resolved = await router.resolve(request, server)
    .catch(error => {
      if (error.status != null) return error;
      return {body: String(err), status: 500};
    });
  if (!resolved) return next();
  let {body, status = 200, headers = defaultHeaders} =
    await resolved;
  response.writeHead(status, headers);
  response.end(body);
}

リソースとしてのトーク

提案されたトークは、サーバーの talks プロパティに格納されます。これは、プロパティ名がトークのタイトルであるオブジェクトです。これらのトークを /talks/<title> の下の HTTP リソースとして公開するいくつかのハンドラーをルーターに追加します。

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

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

router.add("GET", talkPath, async (server, title) => {
  if (Object.hasOwn(server.talks, title)) {
    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 (Object.hasOwn(server.talks, title)) {
    delete server.talks[title];
    server.updated();
  }
  return {status: 204};
});

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

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

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

リクエストストリームからボディを読み取るために、ストリーム内のデータを収集し、JSON として解析する "node:stream/consumers"json 関数を使用します。このパッケージには、同様のエクスポートである text (コンテンツを文字列として読み取る) と buffer (バイナリデータとして読み取る) があります。json は非常に一般的な名前であるため、混乱を避けるために、インポートでは readJSON に名前が変更されます。

import {json as readJSON} from "node:stream/consumers";

router.add("PUT", talkPath,
           async (server, title, request) => {
  let talk = await readJSON(request);
  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};
});

トークにコメントを追加するのも同様の仕組みです。readJSON を使用してリクエストの内容を取得し、結果のデータを検証し、有効に見える場合はコメントとして保存します。

router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
           async (server, title, request) => {
  let comment = await readJSON(request);
  if (!comment ||
      typeof comment.author != "string" ||
      typeof comment.message != "string") {
    return {status: 400, body: "Bad comment data"};
  } else if (Object.hasOwn(server.talks, title)) {
    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 = Object.keys(this.talks)
    .map(title => this.talks[title]);
  return {
    body: JSON.stringify(talks),
    headers: {"Content-Type": "application/json",
              "ETag": `"${this.version}"`,
              "Cache-Control": "no-store"}
  };
};

ハンドラー自体は、If-None-Match ヘッダーと Prefer ヘッダーが存在するかどうかを確認するためにリクエストヘッダーを調べる必要があります。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 サーバーは public サブディレクトリからファイルを提供し、/talks URL の下にトーク管理インターフェイスを提供します。

new SkillShareServer({}).start(8000);

クライアント

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

HTML

ウェブサーバーが、ディレクトリに対応するパスに直接リクエストが行われたときに、index.html という名前のファイルを提供しようとするのは広く使用されている規則です。私たちが使用しているファイルサーバーモジュールである serve-static は、この規則をサポートしています。パス / に対してリクエストが行われると、サーバーはファイル ./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 {...state, user: action.user};
  } else if (action.type == "setTalks") {
    return {...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 を使用してネットワークリクエストを行います。サーバーがエラーコードを返した場合に返されたプロミスが確実に拒否されるようにする、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);
}

リクエストが失敗した場合、説明なしにページが何もせずに座っているのを望みません。catch ハンドラーとして使用した 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を作成する場合、このスタイルのプログラミングはかなり乱雑に見え始めます。これを避けるために、人々はしばしばテンプレート言語を使用します。これにより、動的な要素がどこにあるかを示す特別なマーカー付きのHTMLファイルとしてインターフェースを記述できます。または、JSXを使用します。これは、JavaScriptの式であるかのように、プログラムで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();

サーバーを実行し、https://127.0.0.1:8000の2つのブラウザウィンドウを並べて開くと、1つのウィンドウで実行したアクションが別のウィンドウにすぐに表示されることがわかります。

演習

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

ディスク永続性

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

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

ヒントを表示...

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

ファイル名を選択します。たとえば、./talks.jsonです。サーバーが起動すると、readFileでそのファイルを読み込もうとすることができ、成功した場合、サーバーはファイルのコンテンツを起動データとして使用できます。

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

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

複数の人が同時にコメントを追加している場合、これは迷惑になります。それを解決する方法を思いつきますか?

ヒントを表示...

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

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

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