Projeto: Site de Compartilhamento de Habilidades
Se você tem conhecimento, deixe que outros acendam suas velas nele.

Um encontro de compartilhamento de habilidades é um evento onde pessoas com um interesse em comum se reúnem e fazem pequenas apresentações informais sobre coisas que sabem. Em um encontro de compartilhamento de habilidades de jardinagem, alguém pode explicar como cultivar aipo. Ou, em um grupo de programação, você pode aparecer e falar sobre Node.js.
Neste capítulo final de projeto, nosso objetivo é configurar um site para gerenciar palestras realizadas em um encontro de compartilhamento de habilidades. Imagine um pequeno grupo de pessoas que se reúne regularmente no escritório de um dos membros para falar sobre monociclo. O organizador anterior dos encontros se mudou para outra cidade, e ninguém se ofereceu para assumir essa tarefa. Queremos um sistema que permita que os participantes proponham e discutam palestras entre si sem um organizador ativo.
Assim como no capítulo anterior, parte do código neste capítulo foi escrita para Node.js, e executá-lo diretamente na página HTML que você está vendo provavelmente não funcionará. O código completo do projeto pode ser baixado em https://eloquentjavascript.net/code/skillsharing.zip.
Design
Há uma parte servidor neste projeto, escrita para Node.js, e uma parte cliente, escrita para o navegador. O servidor armazena os dados do sistema e os fornece ao cliente. Ele também serve os arquivos que implementam o sistema do lado do cliente.
O servidor mantém a lista de palestras propostas para o próximo encontro, e o cliente mostra essa lista. Cada palestra tem um nome do apresentador, um título, um resumo e um array de comentários associados a ela. O cliente permite que os usuários proponham novas palestras (adicionando-as à lista), excluam palestras e comentem em palestras existentes. Sempre que o usuário faz uma mudança desse tipo, o cliente faz uma requisição HTTP para informar o servidor.

A aplicação será configurada para mostrar uma visualização ao vivo das palestras propostas atualmente e seus comentários. Sempre que alguém, em algum lugar, enviar uma nova palestra ou adicionar um comentário, todas as pessoas que tiverem a página aberta em seus navegadores devem ver a mudança imediatamente. Isso traz um pequeno desafio—não há como um servidor web abrir uma conexão com um cliente, nem há uma boa maneira de saber quais clientes estão atualmente visualizando um determinado site.
Uma solução comum para esse problema é chamada long polling, que, por sinal, é uma das motivações para o design do Node.
Long polling
Para poder notificar imediatamente um cliente de que algo mudou, precisamos de uma conexão com esse cliente. Como navegadores normalmente não aceitam conexões e clientes frequentemente estão atrás de roteadores que bloqueariam essas conexões de qualquer forma, fazer o servidor iniciar essa conexão não é prático.
Podemos organizar para que o cliente abra a conexão e a mantenha ativa, de modo que o servidor possa usá-la para enviar informações quando precisar. Mas uma requisição HTTP permite apenas um fluxo simples de informação: o cliente envia uma requisição, o servidor retorna uma única resposta, e pronto. Uma tecnologia chamada WebSockets permite abrir conexãoes para troca arbitrária de dados, mas usar esses sockets corretamente é um pouco complicado.
Neste capítulo, usamos uma técnica mais simples, _long polling_, em que os clientes continuamente pedem novas informações ao servidor usando requisições HTTP comuns, e o servidor atrasa sua resposta quando não tem nada novo para relatar.
Desde que o cliente garanta que sempre haja uma requisição de polling aberta, ele receberá informações do servidor rapidamente assim que estiverem disponíveis. Por exemplo, se Fatma tem nossa aplicação de compartilhamento de habilidades aberta no navegador, esse navegador terá feito uma requisição por atualizações e estará aguardando uma resposta. Quando Iman envia uma palestra sobre Monociclo Radical em Declive, o servidor perceberá que Fatma está esperando por atualizações e enviará uma resposta contendo a nova palestra para a requisição pendente dela. O navegador de Fatma receberá os dados e atualizará a tela para mostrar a palestra.
Para evitar que conexões expirem (sejam abortadas por falta de atividade), técnicas de _long polling_ geralmente definem um tempo máximo para cada requisição, após o qual o servidor responderá de qualquer forma, mesmo que não tenha nada a relatar. O cliente então pode iniciar uma nova requisição. Reiniciar periodicamente a requisição também torna a técnica mais robusta, permitindo que os clientes se recuperem de falhas temporárias de conexão ou problemas no servidor.
Um servidor ocupado que utiliza long polling pode ter milhares de requisições em espera e, portanto, conexões TCP abertas. O Node, que facilita o gerenciamento de muitas conexões sem criar uma thread separada de controle para cada uma, é uma boa escolha para esse tipo de sistema.
Interface HTTP
Antes de começarmos a projetar o servidor ou o cliente, vamos pensar no ponto onde eles se encontram: a interface HTTP pela qual se comunicam.
Usaremos JSON como formato do corpo das requisições e respostas. Como no servidor de arquivos do Capítulo 20, tentaremos fazer bom uso dos métodos HTTP e dos headers. A interface é centrada no caminho /talks. Caminhos que não começam com /talks serão usados para servir arquivo estáticos—o código HTML e JavaScript do sistema do lado do cliente.
Uma requisição GET para /talks retorna um documento JSON como este:
[{"title": "Unituning",
"presenter": "Jamal",
"summary": "Modifying your cycle for extra style",
"comments": []}]
Criar uma nova palestra é feito enviando uma requisição PUT para uma URL como /talks/Unituning, onde a parte após a segunda barra é o título da palestra. O corpo da requisição PUT deve conter um objeto JSON com as propriedades presenter e summary.
Como títulos de palestras podem conter espaços e outros caracteres que normalmente não aparecem em uma URL, as strings de título devem ser codificadas com a função encodeURIComponent ao construir essa URL.
console.log("/talks/" + encodeURIComponent("How to Idle")); // → /talks/How%20to%20Idle
Uma requisição para criar uma palestra sobre ficar parado poderia ser assim:
PUT /talks/How%20to%20Idle HTTP/1.1 Content-Type: application/json Content-Length: 92 {"presenter": "Maureen", "summary": "Standing still on a unicycle"}
Essas URLs também suportam requisições GET para obter a representação JSON de uma palestra e requisições DELETE para excluir uma palestra.
Adicionar um comentário a uma palestra é feito com uma requisição POST para uma URL como /, com um corpo JSON que possui as propriedades author e 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?"}
Para dar suporte a _long polling_, requisições GET para /talks podem incluir headers extras que informam ao servidor para atrasar a resposta caso não haja novas informações disponíveis. Usaremos um par de headers normalmente destinados a gerenciar cache: ETag e If-None-Match.
Servidores podem incluir um header ETag (“entity tag”) em uma resposta. Seu valor é uma string que identifica a versão atual do recurso. Clientes, quando solicitam esse recurso novamente, podem fazer uma requisição condicional incluindo um header If-None-Match cujo valor contém essa mesma string. Se o recurso não mudou, o servidor responderá com o código de status 304, que significa “não modificado”, informando ao cliente que sua versão em cache ainda é atual. Quando a tag não corresponde, o servidor responde normalmente.
Precisamos de algo assim, onde o cliente possa informar ao servidor qual versão da lista de palestras ele possui, e o servidor responda apenas quando essa lista tiver mudado. Mas, em vez de retornar imediatamente uma resposta 304, o servidor deve atrasar a resposta e retorná-la apenas quando algo novo estiver disponível ou quando um certo tempo tiver passado. Para distinguir requisições de long polling de requisições condicionais normais, damos a elas outro header, Prefer: wait=90, que informa ao servidor que o cliente está disposto a esperar até 90 segundos pela resposta.
O servidor manterá um número de versão que atualiza sempre que as palestras mudam e usará isso como valor de ETag. Clientes podem fazer requisições como esta para serem notificados quando as palestras mudarem:
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 [...]
O protocolo descrito aqui não faz nenhum controle de acesso. Qualquer pessoa pode comentar, modificar palestras e até excluí-las. (Como a internet está cheia de vândalos, colocar um sistema desses online sem proteção adicional provavelmente não terminaria bem.)
O servidor
Vamos começar construindo a parte do servidor do programa. O código nesta seção roda em Node.js.
Roteamento
Nosso servidor usará createServer do Node para iniciar um servidor HTTP. Na função que trata uma nova requisição, precisamos distinguir entre os vários tipos de requisições (determinados pelo método e pelo caminho) que suportamos. Isso pode ser feito com uma longa cadeia de if, mas há uma forma melhor.
Um roteador é um componente que ajuda a encaminhar uma requisição para a função que pode tratá-la. Você pode dizer ao roteador, por exemplo, que requisições PUT com um caminho que corresponda à expressão regular / (/talks/ seguido por um título de palestra) podem ser tratadas por uma determinada função. Além disso, ele pode ajudar a extrair as partes relevantes do caminho (neste caso, o título da palestra), envolvidas em parênteses na expressão regular, e passá-las para a função manipuladora.
Existem vários bons pacotes de roteador no NPM, mas aqui vamos escrever um nós mesmos para ilustrar o princípio.
Este é router.mjs, que mais tarde vamos importar em nosso módulo do servidor:
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); } } }
O módulo exporta a classe Router. Um objeto roteador permite registrar manipuladores para métodos específicos e padrões de URL com seu método add. Quando uma requisição é resolvida com o método resolve, o roteador chama o manipulador cujo método e URL correspondem à requisição e retorna seu resultado.
Funções manipuladoras são chamadas com o valor context fornecido a resolve. Usaremos isso para dar acesso ao nosso estado do servidor. Além disso, elas recebem as strings correspondentes a quaisquer grupos definidos em sua expressão regular e o objeto da requisição. As strings precisam ser decodificadas da URL, já que a URL bruta pode conter códigos no estilo %20.
Servindo arquivos
Quando uma requisição não corresponde a nenhum dos tipos definidos em nosso roteador, o servidor deve interpretá-la como uma requisição por um arquivo no diretório public. Seria possível usar o servidor de arquivos definido no Capítulo 20 para servir esses arquivos, mas não precisamos nem queremos dar suporte a requisições PUT e DELETE em arquivos, e gostaríamos de ter recursos avançados como suporte a cache. Vamos usar um servidor de arquivo estático sólido e bem testado do NPM.
Optei por serve-static. Não é o único desse tipo no NPM, mas funciona bem e atende aos nossos propósitos. O pacote serve-static exporta uma função que pode ser chamada com um diretório raiz para produzir uma função manipuladora de requisições. Essa função aceita os argumentos request e response fornecidos pelo servidor de "node:http", e um terceiro argumento, uma função que será chamada se nenhum arquivo corresponder à requisição. Queremos que nosso servidor primeiro verifique as requisições que devemos tratar de forma especial, conforme definido no roteador, então o envolvemos em outra função.
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(); } }
A função serveFromRouter tem a mesma interface que fileServer, recebendo argumentos (request, response, next). Podemos usar isso para “encadear” vários manipuladores de requisição, permitindo que cada um trate a requisição ou passe a responsabilidade para o próximo. O manipulador final, notFound, simplesmente responde com um erro “não encontrado”.
Nossa função serveFromRouter usa uma convenção semelhante à do servidor de arquivos do capítulo anterior para respostas—manipuladores no roteador retornam promises que resolvem para objetos descrevendo a resposta.
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); }
Palestras como recursos
As palestras propostas são armazenadas na propriedade talks do servidor, um objeto cujos nomes de propriedades são os títulos das palestras. Vamos adicionar alguns manipuladores ao nosso roteador que as expõem como recursos HTTP sob /.
O manipulador para requisições que fazem GET de uma única palestra deve procurar a palestra e responder com os dados JSON dela ou com um erro 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`}; } });
Excluir uma palestra é feito removendo-a do objeto talks.
router.add("DELETE", talkPath, async (server, title) => { if (Object.hasOwn(server.talks, title)) { delete server.talks[title]; server.updated(); } return {status: 204}; });
O método updated, que definiremos mais adiante, notifica requisições de long polling em espera sobre a mudança.
Um manipulador que precisa ler corpos de requisição é o manipulador PUT, usado para criar novas palestras. Ele precisa verificar se os dados fornecidos têm propriedades presenter e summary, que devem ser strings. Qualquer dado vindo de fora do sistema pode ser inválido, e não queremos corromper nosso modelo de dados interno nem causar um crash quando requisições ruins chegarem.
Se os dados parecerem válidos, o manipulador armazena um objeto representando a nova palestra no objeto talks, possivelmente sobrescrevendo uma palestra existente com esse título, e novamente chama updated.
Para ler o corpo do stream da requisição, usaremos a função json de "node:stream/, que coleta os dados do stream e depois os interpreta como JSON. Há exportações semelhantes chamados text (para ler como string) e buffer (para ler como dados binários) nesse pacote. Como json é um nome muito genérico, a importação o renomeia para readJSON para evitar confusão.
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}; });
Adicionar um comentário a uma palestra funciona de forma semelhante. Usamos readJSON para obter o conteúdo da requisição, validamos os dados resultantes e os armazenamos como comentário quando parecem válidos.
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`}; } });
Tentar adicionar um comentário a uma palestra inexistente retorna um erro 404.
Suporte a long polling
O aspecto mais interessante do servidor é a parte que lida com _long polling_. Quando uma requisição GET chega para /talks, ela pode ser uma requisição normal ou uma requisição de long polling.
Haverá vários pontos onde precisamos enviar um array de palestras ao cliente, então primeiro definimos um método auxiliar que constrói esse array e inclui um header ETag na resposta.
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"} }; };
O próprio manipulador precisa olhar os headers da requisição para ver se If-None-Match e Prefer estão presentes. O Node armazena headers, cujos nomes não diferenciam maiúsculas de minúsculas, em suas versões em minúsculas.
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])); } });
Se nenhuma tag foi fornecida ou se a tag fornecida não corresponde à versão atual do servidor, o manipulador responde com a lista de palestras. Se a requisição for condicional e as palestras não tiverem mudado, consultamos o header Prefer para ver se devemos atrasar a resposta ou responder imediatamente.
Funções de callback para requisições atrasadas são armazenadas no array waiting do servidor para que possam ser notificadas quando algo acontecer. O método waitForChanges também define imediatamente um temporizador para responder com status 304 quando a requisição tiver esperado tempo suficiente.
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); }); };
Registrar uma mudança com updated aumenta a propriedade version e acorda todas as requisições em espera.
SkillShareServer.prototype.updated = function() { this.version++; let response = this.talkResponse(); this.waiting.forEach(resolve => resolve(response)); this.waiting = []; };
Isso conclui o código do servidor. Se criarmos uma instância de SkillShareServer e a iniciarmos na porta 8000, o servidor HTTP resultante servirá arquivos do subdiretório public junto com uma interface de gerenciamento de palestras sob a URL /talks.
new SkillShareServer({}).start(8000);
O cliente
A parte do cliente do site consiste em três arquivos: uma pequena página HTML, uma folha de estilo e um arquivo JavaScript.
HTML
É uma convenção amplamente usada que servidores web tentem servir um arquivo chamado index.html quando uma requisição é feita diretamente para um caminho que corresponde a um diretório. O módulo de servidor de arquivos que usamos, serve-static, suporta essa convenção. Quando uma requisição é feita para o caminho /, o servidor procura o arquivo ./ (./public sendo a raiz que fornecemos) e retorna esse arquivo, se encontrado.
Assim, se quisermos que uma página apareça quando um navegador acessar nosso servidor, devemos colocá-la em public/. Este é nosso arquivo de índice:
<meta charset="utf-8"> <title>Skill Sharing</title> <link rel="stylesheet" href="skillsharing.css"> <h1>Skill Sharing</h1> <script src="skillsharing_client.js"></script>
Ele define o título do documento e inclui uma folha de estilo, que define alguns estilos para, entre outras coisas, garantir que haja algum espaço entre as palestras. Em seguida, adiciona um cabeçalho no topo da página e carrega o script que contém a aplicação do lado do cliente.
Ações
O estado da aplicação consiste na lista de palestras e no nome do usuário, e vamos armazená-lo em um objeto {talks, user}. Não permitimos que a interface do usuário manipule diretamente o estado nem envie requisições HTTP. Em vez disso, ela pode emitir ações que descrevem o que o usuário está tentando fazer.
A função handleAction recebe uma dessas ações e a executa. Como nossas atualizações de estado são simples, as mudanças de estado são tratadas na mesma função.
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; }
Armazenaremos o nome do usuário em localStorage para que possa ser restaurado quando a página for carregada.
As ações que precisam envolver o servidor fazem requisições de rede usando fetch, para a interface HTTP descrita anteriormente. Usamos uma função wrapper, fetchOK, que garante que a promise retornada seja rejeitada quando o servidor retorna um código de erro.
function fetchOK(url, options) { return fetch(url, options).then(response => { if (response.status < 400) return response; else throw new Error(response.statusText); }); }
Esta função auxiliar é usada para construir uma URL para uma palestra com um determinado título.
function talkURL(title) { return "talks/" + encodeURIComponent(title); }
Quando a requisição falha, não queremos que nossa página simplesmente fique parada sem explicação. A função chamada reportError, que usamos como manipulador de catch, mostra ao usuário um diálogo simples informando que algo deu errado.
function reportError(error) { alert(String(error)); }
Renderização de componentes
Usaremos uma abordagem semelhante à que vimos no Capítulo 19, dividindo a aplicação em componentes. No entanto, como alguns componentes nunca precisam ser atualizados ou são sempre totalmente redesenhados quando atualizados, vamos defini-los não como classes, mas como funções que retornam diretamente um nó DOM. Por exemplo, aqui está um componente que mostra o campo onde o usuário pode inserir seu nome:
function renderUserField(name, dispatch) { return elt("label", {}, "Your name: ", elt("input", { type: "text", value: name, onchange(event) { dispatch({type: "setUser", user: event.target.value}); } })); }
A função elt usada para construir elementos DOM é a mesma que usamos no Capítulo 19.
Uma função semelhante é usada para renderizar palestras, que incluem uma lista de comentários e um formulário para adicionar um novo comentário.
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"))); }
O manipulador do evento "submit" chama form.reset para limpar o conteúdo do formulário após criar uma ação "newComment".
Ao criar partes de DOM moderadamente complexas, esse estilo de programação começa a parecer meio confuso. Para evitar isso, as pessoas frequentemente usam uma linguagem de template, que permite escrever a interface como um arquivo HTML com alguns marcadores especiais para indicar onde elementos dinâmicos devem entrar. Ou usam JSX, um dialeto não padrão de JavaScript que permite escrever algo muito próximo de tags HTML dentro do programa como se fossem expressões JavaScript. Ambas as abordagens usam ferramentas adicionais para pré-processar o código antes que ele possa ser executado, o que evitaremos neste capítulo.
Comentários são simples de renderizar.
function renderComment(comment) { return elt("p", {className: "comment"}, elt("strong", null, comment.author), ": ", comment.message); }
Por fim, o formulário que o usuário pode usar para criar uma nova palestra é renderizado assim:
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
Para iniciar a aplicação, precisamos da lista atual de palestras. Como o carregamento inicial está intimamente relacionado ao processo de long polling—o ETag do carregamento deve ser usado no polling—vamos escrever uma função que fica consultando o servidor em /talks e chama uma função _callback_ quando um novo conjunto de palestras estiver disponível.
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()); } }
Esta é uma função async para que o loop e a espera pela requisição sejam mais simples. Ela executa um loop infinito que, a cada iteração, recupera a lista de palestras—normalmente ou, se não for a primeira requisição, com os headers que a tornam uma requisição de long polling.
Quando uma requisição falha, a função espera um momento e tenta novamente. Dessa forma, se sua conexão de rede cair por um tempo e depois voltar, a aplicação pode se recuperar e continuar atualizando. A promise resolvida via setTimeout é uma forma de forçar a função async a esperar.
Quando o servidor retorna uma resposta 304, isso significa que uma requisição de long polling expirou, então a função deve simplesmente iniciar a próxima requisição imediatamente. Se a resposta for uma resposta normal 200, seu corpo é lido como JSON e passado para o callback, e o valor do header ETag é armazenado para a próxima iteração.
A aplicação
O seguinte componente conecta toda a interface do usuário:
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; } } }
Quando as palestras mudam, esse componente redesenha todas elas. Isso é simples, mas também ineficiente. Voltaremos a isso nos exercícios.
Podemos iniciar a aplicação assim:
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();
Se você executar o servidor e abrir duas janelas do navegador em http://localhost:8000 lado a lado, poderá ver que as ações realizadas em uma janela aparecem imediatamente na outra.
Exercícios
Os exercícios a seguir envolvem modificar o sistema definido neste capítulo. Para trabalhar neles, certifique-se de ter baixado o código (https://eloquentjavascript.net/code/skillsharing.zip), instalado o Node (https://nodejs.org) e instalado as dependências do projeto com npm install.
Persistência em disco
O servidor de compartilhamento de habilidades mantém seus dados apenas em memória. Isso significa que, quando ele sofre um crash ou é reiniciado por qualquer motivo, todas as palestras e comentários são perdidos.
Estenda o servidor para que ele armazene os dados das palestras em disco e recarregue automaticamente esses dados quando for reiniciado. Não se preocupe com eficiência—faça a coisa mais simples que funcione.
Mostrar dicas...
A solução mais simples que consigo imaginar é codificar todo o objeto talks como JSON e gravá-lo em um arquivo com writeFile. Já existe um método (updated) que é chamado sempre que os dados do servidor mudam. Ele pode ser estendido para gravar os novos dados em disco.
Escolha um nome de arquivo, por exemplo ./talks.json. Quando o servidor iniciar, ele pode tentar ler esse arquivo com readFile e, se funcionar, usar o conteúdo do arquivo como dados iniciais.
Reset do campo de comentário
O redesenho completo das palestras funciona bem porque geralmente não dá para perceber a diferença entre um nó DOM e sua substituição idêntica. Mas há exceções. Se você começar a digitar algo no campo de comentário de uma palestra em uma janela do navegador e então, em outra, adicionar um comentário a essa palestra, o campo na primeira janela será redesenhado, removendo tanto seu conteúdo quanto seu foco.
Quando várias pessoas estão adicionando comentários ao mesmo tempo, isso pode ser irritante. Você consegue pensar em uma forma de resolver isso?
Mostrar dicas...
A melhor maneira de fazer isso provavelmente é transformar o componente de palestra em um objeto, com um método syncState, para que ele possa ser atualizado para mostrar uma versão modificada da palestra. Durante a operação normal, a única forma de uma palestra mudar é pela adição de novos comentários, então o método syncState pode ser relativamente simples.
A parte difícil é que, quando uma lista modificada de palestras chega, precisamos reconciliar a lista existente de componentes DOM com as palestras da nova lista—removendo componentes cuja palestra foi excluída e atualizando componentes cuja palestra mudou.
Para fazer isso, pode ser útil manter uma estrutura de dados que armazene os componentes de palestra pelos títulos das palestras, para que você possa facilmente verificar se já existe um componente para uma determinada palestra. Você pode então percorrer o novo array de palestras e, para cada uma, sincronizar um componente existente ou criar um novo. Para excluir componentes de palestras removidas, você também terá que percorrer os componentes e verificar se as palestras correspondentes ainda existem.