Bab 21 dari Eloquent JavaScript (bab terakhir). Sebelumnya: 20 Node.js. Kembali ke daftar isi: Eloquent JavaScript.

Kalau kamu punya pengetahuan, biarin orang lain menyalakan lilin mereka darinya.

— Margaret Fuller

Ilustrasi dua sepeda roda satu yang disandarkan ke kotak surat

Pertemuan skill-sharing (berbagi keterampilan) adalah acara tempat orang dengan minat bersama berkumpul dan ngasih presentasi kecil dan informal soal hal-hal yang mereka tahu. Di pertemuan skill-sharing berkebun, seseorang mungkin ngejelasin cara membudidayakan seledri. Atau di grup skill-sharing pemrograman, kamu bisa mampir dan ngasih tahu orang soal Node.js.

Di bab proyek terakhir ini, tujuan kita adalah nyiapin website buat ngelola talk yang dikasih di pertemuan skill-sharing. Bayangin sekelompok kecil orang ketemu secara teratur di kantor salah satu anggota buat ngobrol soal sepeda roda satu. Organizer pertemuan sebelumnya pindah ke kota lain, dan gak ada yang maju buat ngambil alih tugas ini. Kita mau sistem yang bakal ngebolehin peserta ngusulin dan mendiskusikan talk di antara mereka tanpa organizer yang aktif.

Sama kaya di bab sebelumnya, sebagian kode di bab ini ditulis buat Node.js, dan ngejalaninnya langsung di halaman HTML yang lagi kamu lihat kemungkinan gak bakal jalan. Kode lengkap buat proyeknya bisa diunduh dari https://eloquentjavascript.net/code/skillsharing.zip.

Desain

Ada bagian server di proyek ini, ditulis buat Node.js, dan bagian client, ditulis buat browser. Server-nya nyimpen data sistem dan nyediainnya ke client. Dia juga nyajiin file yang ngimplementasiin sistem sisi-client.

Server-nya ngejaga daftar talk yang diusulin buat pertemuan berikutnya, dan client-nya nunjukin daftar ini. Setiap talk punya nama penyaji, judul, ringkasan, dan array komentar yang terkait dengannya. Client-nya ngebolehin pengguna ngusulin talk baru (nambahin mereka ke daftar), ngehapus talk, dan mengomentari talk yang ada. Setiap kali pengguna bikin perubahan kaya gitu, client-nya bikin request HTTP buat ngasih tahu server soal itu.

Screenshot website skill-sharing

Aplikasinya bakal disiapin buat nunjukin tampilan live dari talk yang diusulin saat ini dan komentar mereka. Setiap kali seseorang, di mana pun, men-submit talk baru atau nambahin komentar, semua orang yang punya halamannya terbuka di browser mereka harusnya langsung ngelihat perubahannya. Ini menimbulkan sedikit tantangan, gak ada cara buat web server buka koneksi ke client, dan gak ada cara bagus buat tahu client mana yang saat ini lagi ngelihat website tertentu.

Solusi umum buat masalah ini disebut long polling, yang kebetulan salah satu motivasi buat desain Node.

Long Polling

Biar bisa langsung ngasih tahu client bahwa sesuatu berubah, kita butuh koneksi ke client itu. Karena browser web secara tradisional gak nerima koneksi dan client sering di belakang router yang bakal ngeblok koneksi kaya gitu juga, bikin server yang menginisiasi koneksi ini gak praktis.

Kita bisa ngatur biar client-nya buka koneksinya dan ngejaganya biar server bisa pakainya buat ngirim informasi waktu dia perlu. Tapi request HTTP cuma ngebolehin aliran informasi sederhana: client ngirim request, server balik dengan satu respons, dan udah. Teknologi bernama WebSockets bikin mungkin buat buka koneksi buat pertukaran data sembarang, tapi pakai socket kaya gitu dengan benar agak rumit.

Di bab ini, kita pakai teknik yang lebih sederhana, long polling, di mana client terus-menerus minta informasi baru ke server pakai request HTTP biasa, dan server nunda jawabannya waktu dia gak punya sesuatu yang baru buat dilaporin.

Selama client mastiin dia terus-menerus punya request polling yang terbuka, dia bakal nerima informasi dari server dengan cepat setelah dia jadi tersedia. Misalnya, kalau Fatma punya aplikasi skill-sharing kita terbuka di browser-nya, browser itu bakal udah bikin request buat update dan bakal nunggu respons ke request itu. Waktu Iman men-submit talk soal Extreme Downhill Unicycling, server bakal nyadar bahwa Fatma lagi nunggu update dan ngirim respons yang berisi talk baru ke request-nya yang tertunda. Browser Fatma bakal nerima datanya dan ngupdate layar buat nunjukin talk-nya.

Buat nyegah koneksi timeout (dibatalin karena kurangnya aktivitas), teknik long polling biasanya nyetel waktu maksimum buat setiap request, setelah itu server bakal merespons juga, walaupun dia gak punya sesuatu buat dilaporin. Client lalu bisa mulai request baru. Secara berkala memulai ulang request juga bikin tekniknya lebih tangguh, ngebolehin client pulih dari kegagalan koneksi sementara atau masalah server.

Server yang sibuk yang pakai long polling mungkin punya ribuan request yang menunggu, dan jadi koneksi TCP, terbuka. Node, yang bikin gampang ngelola banyak koneksi tanpa bikin thread kontrol terpisah buat masing-masing, cocok banget buat sistem kaya gitu.

Interface HTTP

Sebelum kita mulai ngedesain server atau client, kita pikirin dulu titik tempat mereka bersentuhan: interface HTTP yang lewatnya mereka berkomunikasi.

Kita bakal pakai JSON sebagai format body request dan respons kita. Kaya di file server dari Bab 20, kita bakal nyoba memanfaatkan method dan header HTTP dengan baik. Interface-nya berpusat di path /talks. Path yang gak mulai dengan /talks bakal dipakai buat nyajiin file statis, kode HTML dan JavaScript buat sistem sisi-client.

Request GET ke /talks ngembaliin dokumen JSON kaya gini:

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

Bikin talk baru dilakuin dengan bikin request PUT ke URL kaya /talks/Unituning, di mana bagian setelah garis miring kedua adalah judul talk-nya. Body request PUT-nya harus berisi objek JSON yang punya properti presenter dan summary.

Karena judul talk boleh berisi spasi dan karakter lain yang mungkin gak muncul normal di URL, string judul harus dikodekan dengan fungsi encodeURIComponent waktu ngebangun URL kaya gitu.

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

Request buat bikin talk soal idling mungkin keliatan kaya gini:

PUT /talks/How%20to%20Idle HTTP/1.1
Content-Type: application/json
Content-Length: 92
 
{"presenter": "Maureen",
 "summary": "Standing still on a unicycle"}

URL kaya gitu juga mendukung request GET buat ngambil representasi JSON dari talk dan request DELETE buat ngehapus talk.

Nambahin komentar ke talk dilakuin dengan request POST ke URL kaya /talks/Unituning/comments, dengan body JSON yang punya properti author dan message.

POST /talks/Unituning/comments HTTP/1.1
Content-Type: application/json
Content-Length: 72
 
{"author": "Iman",
 "message": "Will you talk about raising a cycle?"}

Buat mendukung long polling, request GET ke /talks boleh nyertain header ekstra yang ngasih tahu server buat nunda respons kalau gak ada informasi baru yang tersedia. Kita bakal pakai sepasang header yang biasanya dimaksudkan buat ngelola caching: ETag dan If-None-Match.

Server boleh nyertain header ETag (“entity tag”) di respons. Nilainya adalah string yang ngidentifikasi versi resource saat ini. Client, waktu mereka nanti minta resource itu lagi, boleh bikin conditional request dengan nyertain header If-None-Match yang nilainya megang string yang sama itu. Kalau resource-nya gak berubah, server bakal merespons dengan kode status 304, yang berarti “not modified”, ngasih tahu client bahwa versi cache-nya masih terkini. Waktu tag-nya gak cocok, server merespons seperti biasa.

Kita butuh sesuatu kaya ini, di mana client bisa ngasih tahu server versi daftar talk mana yang dia punya, dan server merespons cuma waktu daftar itu berubah. Tapi bukannya langsung ngembaliin respons 304, server harus nunda respons-nya dan balik cuma waktu sesuatu yang baru tersedia atau jumlah waktu tertentu udah berlalu. Buat ngebedain request long polling dari request conditional normal, kita ngasih mereka header lain, Prefer: wait=90, yang ngasih tahu server bahwa client bersedia nunggu sampai 90 detik buat respons-nya.

Server-nya bakal ngejaga nomor versi yang dia update setiap kali talk-nya berubah dan bakal pakai itu sebagai nilai ETag. Client bisa bikin request kaya gini buat diberi tahu waktu talk-nya berubah:

GET /talks HTTP/1.1
If-None-Match: "4"
Prefer: wait=90
 
(waktu berlalu)
 
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "5"
Content-Length: 295
 
[...]

Protokol yang dijelasin di sini gak ngelakuin kontrol akses apa pun. Semua orang bisa mengomentari, memodifikasi talk, dan bahkan ngehapus mereka. (Karena internet penuh preman, naruh sistem kaya gitu online tanpa perlindungan lebih lanjut mungkin gak bakal berakhir baik.)

Server

Kita mulai dengan ngebangun bagian sisi-server dari program. Kode di bagian ini jalan di Node.js.

Routing

Server kita bakal pakai createServer Node buat mulai HTTP server. Di fungsi yang nanganin request baru, kita harus ngebedain antara berbagai jenis request (kaya yang ditentuin method dan path-nya) yang kita dukung. Ini bisa dilakuin dengan rantai panjang statement if, tapi ada cara yang lebih bagus.

Router adalah komponen yang ngebantu men-dispatch request ke fungsi yang bisa nanganinnya. Kamu bisa ngasih tahu router-nya, misalnya, bahwa request PUT dengan path yang cocok dengan regular expression /^\/talks\/([^\/]+)$/ (/talks/ diikuti judul talk) bisa ditangani fungsi tertentu. Selain itu, dia bisa ngebantu ngekstrak bagian bermakna dari path-nya (di kasus ini judul talk-nya), dibungkus tanda kurung di regular expression-nya, dan ngasih mereka ke fungsi handler.

Ada sejumlah package router yang bagus di NPM, tapi di sini kita bakal nulis satu sendiri buat ngilustrasiin prinsipnya.

Ini router.mjs, yang bakal kita import nanti dari modul server kita:

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);
    }
  }
}

Modul-nya ngekspor class Router. Objek router ngebolehin kamu mendaftarkan handler buat method dan pola URL tertentu dengan method add-nya. Waktu request diselesaikan dengan method resolve, router-nya manggil handler yang method dan URL-nya cocok dengan request dan ngembaliin hasilnya.

Fungsi handler dipanggil dengan nilai context yang dikasih ke resolve. Kita bakal pakai ini buat ngasih mereka akses ke state server kita. Selain itu, mereka nerima string kecocokan buat grup apa pun yang mereka definisikan di regular expression mereka, dan objek request-nya. String-nya harus di-URL-decode, karena URL mentahnya mungkin berisi kode gaya %20.

Menyajikan File

Waktu request gak cocok dengan jenis request mana pun yang didefinisikan di router kita, server-nya harus menafsirkannya sebagai request buat file di direktori public. Bisa aja pakai file server yang didefinisikan di Bab 20 buat nyajiin file kaya gitu, tapi kita gak butuh atau mau mendukung request PUT dan DELETE di file, dan kita mau punya fitur canggih kaya dukungan buat caching. Kita pakai static file server yang solid dan teruji dari NPM sebagai gantinya.

Saya milih serve-static. Ini bukan satu-satunya server kaya gitu di NPM, tapi dia bekerja dengan baik dan cocok buat tujuan kita. Package serve-static ngekspor fungsi yang bisa dipanggil dengan direktori root buat ngehasilin fungsi handler request. Fungsi handler-nya nerima argumen request dan response yang disediain server dari "node:http", dan argumen ketiga, fungsi yang bakal dia panggil kalau gak ada file yang cocok dengan request-nya. Kita mau server kita pertama ngecek request yang harus kita tangani secara khusus, kaya yang didefinisikan di router, jadi kita bungkus dia di fungsi lain.

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();
  }
}

Fungsi serveFromRouter punya interface yang sama dengan fileServer, ngambil argumen (request, response, next). Kita bisa pakai ini buat “ngerantai” beberapa handler request, ngebolehin masing-masing entah nanganin request-nya atau ngoper tanggung jawab buat itu ke handler berikutnya. Handler terakhir, notFound, cuma merespons dengan error “not found”.

Fungsi serveFromRouter kita pakai konvensi yang mirip file server dari bab sebelumnya buat respons, handler di router ngembaliin promise yang resolve ke objek yang menggambarkan respons.

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);
}

Talk sebagai Resource

Talk yang udah diusulin disimpan di properti talks server, objek yang nama propertinya adalah judul talk. Kita bakal nambahin beberapa handler ke router kita yang mengekspos ini sebagai resource HTTP di bawah /talks/<title>.

Handler buat request yang GET satu talk harus nyari talk-nya dan merespons entah dengan data JSON talk-nya atau dengan respons error 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`};
  }
});

Ngehapus talk dilakuin dengan ngehapusnya dari objek talks.

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

Method updated, yang bakal kita definisikan nanti, ngasih tahu request long polling yang menunggu soal perubahannya.

Salah satu handler yang perlu baca body request adalah handler PUT, yang dipakai buat bikin talk baru. Dia harus ngecek apakah data yang dia dikasih punya properti presenter dan summary, yang string. Data apa pun yang datang dari luar sistem mungkin omong kosong, dan kita gak mau merusak model data internal kita atau crash waktu request buruk masuk.

Kalau datanya keliatan valid, handler-nya nyimpen objek yang mewakili talk baru di objek talks, mungkin nimpa talk yang ada dengan judul ini, dan lagi manggil updated.

Buat baca body dari request stream, kita bakal pakai fungsi json dari "node:stream/consumers", yang ngumpulin data di stream lalu mem-parse-nya sebagai JSON. Ada ekspor serupa bernama text (buat baca konten sebagai string) dan buffer (buat bacanya sebagai data biner) di package ini. Karena json nama yang sangat generik, import-nya ngganti namanya jadi readJSON buat ngehindarin kebingungan.

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};
});

Nambahin komentar ke talk bekerja serupa. Kita pakai readJSON buat dapet konten request-nya, memvalidasi data hasilnya, dan nyimpennya sebagai komentar waktu dia keliatan valid.

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`};
  }
});

Nyoba nambahin komentar ke talk yang gak ada ngembaliin error 404.

Dukungan Long Polling

Aspek paling menarik dari server-nya adalah bagian yang nanganin long polling. Waktu request GET masuk buat /talks, dia mungkin entah request biasa atau request long polling.

Bakal ada beberapa tempat di mana kita harus ngirim array talk ke client, jadi kita pertama mendefinisikan method pembantu yang ngebangun array kaya gitu dan nyertain header ETag di respons.

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"}
  };
};

Handler-nya sendiri perlu ngelihat header request buat lihat apakah header If-None-Match dan Prefer ada. Node nyimpen header, yang namanya ditentuin buat case insensitive, di bawah nama huruf kecil mereka.

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]));
  }
});

Kalau gak ada tag yang dikasih atau tag dikasih yang gak cocok dengan versi server saat ini, handler-nya merespons dengan daftar talk. Kalau request-nya conditional dan talk-nya gak berubah, kita konsultasi header Prefer buat lihat apakah kita harus nunda respons-nya atau merespons langsung.

Fungsi callback buat request yang ditunda disimpan di array waiting server biar mereka bisa diberi tahu waktu sesuatu terjadi. Method waitForChanges juga langsung nyetel timer buat merespons dengan status 304 waktu request-nya udah nunggu cukup lama.

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);
  });
};

Mendaftarkan perubahan dengan updated naikin properti version dan membangunkan semua request yang menunggu.

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

Itu nutup kode server-nya. Kalau kita bikin instance SkillShareServer dan mulai dia di port 8000, HTTP server hasilnya nyajiin file dari subdirektori public bareng interface pengelola-talk di bawah URL /talks.

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

Client

Bagian sisi-client dari website skill-sharing terdiri dari tiga file: halaman HTML mungil, style sheet, dan file JavaScript.

HTML

Ada konvensi yang dipakai luas buat web server buat nyoba nyajiin file bernama index.html waktu request dibikin langsung ke path yang sesuai dengan direktori. Modul file server yang kita pakai, serve-static, mendukung konvensi ini. Waktu request dibikin ke path /, server-nya nyari file ./public/index.html (./public jadi root yang kita kasih dia) dan ngembaliin file itu kalau ditemuin.

Jadi, kalau kita mau halaman muncul waktu browser diarahkan ke server kita, kita harus naruhnya di public/index.html. Ini file index kita:

<!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>

Dia mendefinisikan judul dokumen dan nyertain style sheet, yang mendefinisikan beberapa style buat, antara lain, mastiin ada ruang antara talk. Dia lalu nambahin heading di atas halaman dan ngeload script yang berisi aplikasi sisi-client.

Action

State aplikasinya terdiri dari daftar talk dan nama pengguna, dan kita bakal nyimpennya di objek {talks, user}. Kita gak ngebolehin interface pengguna langsung memanipulasi state atau ngirim request HTTP. Sebaliknya, dia boleh memancarkan action yang menggambarkan apa yang lagi dicoba dilakuin pengguna.

Fungsi handleAction ngambil action kaya gitu dan bikinnya terjadi. Karena update state kita begitu sederhana, perubahan state ditangani di fungsi yang sama.

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;
}

Kita bakal nyimpen nama pengguna di localStorage biar dia bisa dipulihkan waktu halamannya dimuat.

Action yang perlu melibatkan server bikin request jaringan, pakai fetch, ke interface HTTP yang dijelasin sebelumnya. Kita pakai fungsi pembungkus, fetchOK, yang mastiin promise yang dikembaliin di-reject waktu server ngembaliin kode error.

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

Fungsi pembantu ini dipakai buat ngebangun URL buat talk dengan judul tertentu.

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

Waktu request-nya gagal, kita gak mau halaman kita cuma duduk di sana gak ngelakuin apa-apa tanpa penjelasan. Fungsi bernama reportError, yang kita pakai sebagai handler catch, nunjukin ke pengguna dialog sederhana buat ngasih tahu mereka ada yang salah.

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

Rendering Komponen

Kita bakal pakai pendekatan yang mirip yang kita lihat di Bab 19, mecah aplikasi jadi komponen. Tapi, karena beberapa komponen entah gak pernah perlu diupdate atau selalu sepenuhnya digambar ulang waktu diupdate, kita bakal mendefinisikan mereka bukan sebagai class tapi sebagai fungsi yang langsung ngembaliin node DOM. Misalnya, ini komponen yang nunjukin field tempat pengguna bisa masukin nama mereka:

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

Fungsi elt yang dipakai buat nyusun elemen DOM adalah yang kita pakai di Bab 19.

Fungsi serupa dipakai buat me-render talk, yang nyertain daftar komentar dan form buat nambahin komentar baru.

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")));
}

Event handler "submit" manggil form.reset buat ngebersihin konten form setelah bikin action "newComment".

Waktu bikin potongan DOM yang cukup kompleks, gaya pemrograman ini mulai keliatan agak berantakan. Buat ngehindarin ini, orang sering pakai templating language, yang ngebolehin kamu nulis interface-mu sebagai file HTML dengan beberapa penanda khusus buat nunjukin di mana elemen dinamis pergi. Atau mereka pakai JSX, dialek JavaScript nonstandar yang ngebolehin kamu nulis sesuatu yang sangat dekat sama tag HTML di programmu seolah mereka ekspresi JavaScript. Kedua pendekatan ini pakai alat tambahan buat mem-preprocess kodenya sebelum dia bisa dijalanin, yang bakal kita hindari di bab ini.

Komentar sederhana buat di-render.

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

Terakhir, form yang bisa dipakai pengguna buat bikin talk baru di-render kaya gini:

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"));
}

Polling

Buat mulai app, kita butuh daftar talk saat ini. Karena pemuatan awal erat kaitannya sama proses long polling, ETag dari pemuatan harus dipakai waktu polling, kita bakal nulis fungsi yang terus polling server buat /talks dan manggil fungsi callback waktu set talk baru tersedia.

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());
  }
}

Ini fungsi async biar looping dan nunggu request-nya lebih gampang. Dia ngejalanin loop tak terbatas yang, di setiap iterasi, ngambil daftar talk, entah normal atau, kalau ini bukan request pertama, dengan header disertain yang bikin dia request long polling.

Waktu request-nya gagal, fungsinya nunggu sebentar lalu nyoba lagi. Dengan cara ini, kalau koneksi jaringanmu hilang sebentar lalu balik, aplikasinya bisa pulih dan lanjut ngupdate. Promise yang di-resolve lewat setTimeout adalah cara buat maksa fungsi async buat nunggu.

Waktu server ngasih balik respons 304, itu berarti request long polling timeout, jadi fungsinya harus langsung mulai request berikutnya. Kalau respons-nya respons 200 normal, body-nya dibaca sebagai JSON dan dikasih ke callback, dan nilai header ETag-nya disimpen buat iterasi berikutnya.

Aplikasi

Komponen berikut ngiket seluruh interface pengguna:

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;
    }
  }
}

Waktu talk-nya berubah, komponen ini menggambar ulang semuanya. Ini sederhana tapi juga boros. Kita bakal balik ke itu di latihan.

Kita bisa mulai aplikasinya kaya gini:

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();

Kalau kamu ngejalanin server dan buka dua jendela browser buat http://localhost:8000 di samping satu sama lain, kamu bisa lihat bahwa aksi yang kamu lakuin di satu jendela langsung terlihat di yang lain.

Ringkasan

Ini bab terakhir. Kita ngebangun aplikasi full-stack (server + client) yang tampilannya live buat semua pengguna.

Poin-poin pentingnya:

  1. Long polling: client terus-terusan ngirim request GET yang server tunda jawabannya sampai ada perubahan atau timeout (pakai header ETag, If-None-Match, Prefer: wait=90). Ini bikin tampilan live tanpa WebSocket, dan Node cocok karena bisa ngelola ribuan koneksi tertunda.
  2. Interface HTTP pakai JSON: GET /talks, PUT /talks/<judul>, DELETE /talks/<judul>, POST /talks/<judul>/comments.
  3. Server: Router (method + regex path → handler) buat dispatch, serve-static buat file, readJSON buat baca body, updated/waitForChanges/waiting buat ngelola long polling.
  4. Client: pola action + state (kaya Bab 19), komponen (fungsi render* atau class), pollTalks (loop async tak terbatas), handleAction yang nge-fetch ke server.
  5. Data cuma di memori (hilang kalau server restart) dan gak ada kontrol akses — dua hal yang diperbaiki/dibahas di latihan.

Latihan

Persistensi Disk

Server skill-sharing ngejaga datanya murni di memori. Ini berarti waktu dia crash atau di-restart karena alasan apa pun, semua talk dan komentar hilang.

Perluas server-nya biar dia nyimpen data talk ke disk dan otomatis ngeload ulang datanya waktu dia di-restart. Jangan khawatir soal efisiensi, lakuin hal paling sederhana yang jalan.

Petunjuk / Solusi:

Solusi paling sederhana adalah mengkodekan seluruh objek talks sebagai JSON dan membuangnya ke file dengan writeFile. Udah ada method (updated) yang dipanggil setiap kali data server berubah. Dia bisa diperluas buat nulis data baru ke disk.

Pilih nama file, misalnya ./talks.json. Waktu server mulai, dia bisa nyoba baca file itu dengan readFile, dan kalau itu sukses, server bisa pakai isi file-nya sebagai data awalnya.

import {readFileSync, writeFile} from "node:fs";
 
const fileName = "./talks.json";
 
SkillShareServer.prototype.updated = function() {
  this.version++;
  let response = this.talkResponse();
  this.waiting.forEach(resolve => resolve(response));
  this.waiting = [];
 
  writeFile(fileName, JSON.stringify(this.talks), e => {
    if (e) throw e;
  });
};
 
function loadTalks() {
  try {
    return JSON.parse(readFileSync(fileName, "utf8"));
  } catch (e) {
    return {};
  }
}
 
// Baris yang mulai server harus diubah jadi
new SkillShareServer(loadTalks()).start(8000);

Reset Field Komentar

Menggambar ulang talk secara keseluruhan bekerja cukup baik karena kamu biasanya gak bisa ngebedain node DOM dan penggantinya yang identik. Tapi ada pengecualian. Kalau kamu mulai ngetik sesuatu di field komentar buat talk di satu jendela browser lalu, di yang lain, nambahin komentar ke talk itu, field di jendela pertama bakal digambar ulang, ngehapus baik kontennya maupun fokusnya.

Waktu beberapa orang nambahin komentar di saat yang sama, ini bakal menyebalkan. Bisa kamu nemuin cara buat nyelesaiinnya?

Petunjuk / Solusi:

Cara terbaik buat ngelakuin ini mungkin bikin komponen talk jadi objek, dengan method syncState, biar mereka bisa diupdate buat nunjukin versi talk yang dimodifikasi. Selama operasi normal, satu-satunya cara talk bisa berubah adalah dengan nambahin lebih banyak komentar, jadi method syncState-nya bisa relatif sederhana.

Bagian susahnya adalah waktu daftar talk yang berubah masuk, kita harus mendamaikan daftar komponen DOM yang ada dengan talk di daftar baru, ngehapus komponen yang talk-nya dihapus dan ngupdate komponen yang talk-nya berubah. Buat itu, berguna buat ngejaga struktur data (map) yang nyimpen komponen talk di bawah judul talk.

class Talk {
  constructor(talk, dispatch) {
    this.comments = elt("div");
    this.dom = 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),
      this.comments,
      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")));
    this.syncState(talk);
  }
 
  syncState(talk) {
    this.talk = talk;
    this.comments.textContent = "";
    for (let comment of talk.comments) {
      this.comments.appendChild(renderComment(comment));
    }
  }
}
 
class SkillShareApp {
  constructor(state, dispatch) {
    this.dispatch = dispatch;
    this.talkDOM = elt("div", {className: "talks"});
    this.talkMap = Object.create(null);
    this.dom = elt("div", null,
                   renderUserField(state.user, dispatch),
                   this.talkDOM,
                   renderTalkForm(dispatch));
    this.syncState(state);
  }
 
  syncState(state) {
    if (state.talks == this.talks) return;
    this.talks = state.talks;
 
    for (let talk of state.talks) {
      let found = this.talkMap[talk.title];
      if (found && found.talk.presenter == talk.presenter &&
          found.talk.summary == talk.summary) {
        found.syncState(talk);
      } else {
        if (found) found.dom.remove();
        found = new Talk(talk, this.dispatch);
        this.talkMap[talk.title] = found;
        this.talkDOM.appendChild(found.dom);
      }
    }
    for (let title of Object.keys(this.talkMap)) {
      if (!state.talks.some(talk => talk.title == title)) {
        this.talkMap[title].dom.remove();
        delete this.talkMap[title];
      }
    }
  }
}

🎉 Selamat! Kamu udah nyampe akhir terjemahan Eloquent JavaScript. Dari nilai dan operator sampai ngebangun aplikasi full-stack dengan long polling. Balik ke daftar isi di Eloquent JavaScript buat ngulang bab mana pun.