-
Notifications
You must be signed in to change notification settings - Fork 76
Expand file tree
/
Copy path21_skillsharing.html
More file actions
627 lines (463 loc) · 56 KB
/
Copy path21_skillsharing.html
File metadata and controls
627 lines (463 loc) · 56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
<!doctype html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Proyecto: Sitio web de intercambio de habilidades :: Eloquent JavaScript</title>
<link rel=stylesheet href="css/ejs.css"><script>
var page = {"type":"chapter","number":21}</script></head>
<article>
<nav><a href="20_node.html" title="previous chapter" aria-label="previous chapter">◂</a> <a href="index.html" title="cover" aria-label="cover">●</a> <button class=help title="help" aria-label="help"><strong>?</strong></button>
</nav>
<h1>Proyecto: Sitio web de intercambio de habilidades</h1>
<blockquote>
<p><a class="p_ident" id="p-SJKzjhO5gX" href="#p-SJKzjhO5gX" tabindex="-1" role="presentation"></a>Si tienes conocimiento, permite que otros enciendan sus velas con él.</p>
<footer>Margaret Fuller</footer>
</blockquote><figure class="chapter framed"><img src="img/chapter_picture_21.jpg" alt="Ilustración que muestra dos monociclos apoyados en un buzón"></figure>
<p><a class="p_ident" id="p-+g+6iPOOgI" href="#p-+g+6iPOOgI" tabindex="-1" role="presentation"></a>Una reunión de intercambio de habilidades es un evento en el que personas con un interés compartido se reúnen y dan pequeñas presentaciones informales sobre cosas que saben. En una reunión de intercambio de habilidades de jardinería, alguien podría explicar cómo cultivar apio. O en un grupo de intercambio de habilidades de programación, podrías pasarte y contarle a la gente sobre Node.js.</p>
<p><a class="p_ident" id="p-RGBud2T7DO" href="#p-RGBud2T7DO" tabindex="-1" role="presentation"></a>En este último capítulo de proyecto, nuestro objetivo es configurar un sitio web para gestionar las charlas impartidas en una reunión de intercambio de habilidades. Imagina un pequeño grupo de personas que se reúnen regularmente en la oficina de uno de los miembros para hablar sobre monociclos. El organizador anterior de las reuniones se mudó a otra ciudad y nadie se ofreció a asumir esta tarea. Queremos un sistema que permita a los participantes proponer y discutir charlas entre ellos, sin un organizador activo.</p>
<p><a class="p_ident" id="p-OXFL+WFEZc" href="#p-OXFL+WFEZc" tabindex="-1" role="presentation"></a>Al igual que en el <a href="20_node.html">capítulo anterior</a>, parte del código en este capítulo está escrito para Node.js y es poco probable que funcione si se ejecuta directamente en la página HTML que estás viendo. El código completo del proyecto se puede descargar desde <a href="https://eloquentjavascript.net/code/skillsharing.zip"><em>https://eloquentjavascript.net/code/skillsharing.zip</em></a>.</p>
<h2><a class="h_ident" id="h-3+hJ1EAoa5" href="#h-3+hJ1EAoa5" tabindex="-1" role="presentation"></a>Diseño</h2>
<p><a class="p_ident" id="p-Jk7z/ajGt7" href="#p-Jk7z/ajGt7" tabindex="-1" role="presentation"></a>Este proyecto tiene una parte de <em>servidor</em>, escrita para Node.js, y una parte de <em>cliente</em>, escrita para el navegador. El servidor almacena los datos del sistema y los proporciona al cliente. También sirve los archivos que implementan el sistema del lado del cliente.</p>
<p><a class="p_ident" id="p-jc0lnz5w+z" href="#p-jc0lnz5w+z" tabindex="-1" role="presentation"></a>El servidor mantiene la lista de charlas propuestas para la próxima reunión, y el cliente muestra esta lista. Cada charla tiene un nombre de presentador, un título, un resumen y un array de comentarios asociados. El cliente permite a los usuarios proponer nuevas charlas (agregándolas a la lista), eliminar charlas y comentar en charlas existentes. Cada vez que el usuario realiza un cambio de este tipo, el cliente realiza una solicitud HTTP para informar al servidor al respecto.</p><figure><img src="img/skillsharing.png" alt="Captura de pantalla del sitio web de intercambio de habilidades"></figure>
<p><a class="p_ident" id="p-oateJBFpXz" href="#p-oateJBFpXz" tabindex="-1" role="presentation"></a>La aplicación se configurará para mostrar una vista <em>en vivo</em> de las charlas propuestas actuales y sus comentarios. Cada vez que alguien, en algún lugar, envíe una nueva charla o agregue un comentario, todas las personas que tengan la página abierta en sus navegadores deberían ver el cambio de inmediato. Esto plantea un desafío —no hay forma de que un servidor web abra una conexión a un cliente, ni hay una buena forma de saber qué clientes están viendo actualmente un sitio web dado.</p>
<p><a class="p_ident" id="p-UaD3BZk280" href="#p-UaD3BZk280" tabindex="-1" role="presentation"></a>Una solución común a este problema se llama <em>long polling</em>, que resulta ser una de las motivaciones del diseño de Node.</p>
<h2><a class="h_ident" id="h-Yxu7U155Cs" href="#h-Yxu7U155Cs" tabindex="-1" role="presentation"></a>Long polling</h2>
<p><a class="p_ident" id="p-+gpJ1YoH6p" href="#p-+gpJ1YoH6p" tabindex="-1" role="presentation"></a>Para poder notificar inmediatamente a un cliente que algo ha cambiado, necesitamos una conexión con ese cliente. Dado que los navegadores web tradicionalmente no aceptan conexiones y los clientes a menudo están detrás de routers que bloquearían tales conexiones de todos modos, no es práctico que sea el servidor quien inicie esta conexión.</p>
<p><a class="p_ident" id="p-XeGPkQ0uyB" href="#p-XeGPkQ0uyB" tabindex="-1" role="presentation"></a>Podemos hacer que el cliente abra la conexión y la mantenga activa para que el servidor pueda usarla para enviar información cuando sea necesario.</p>
<p><a class="p_ident" id="p-5rJbNzHJV5" href="#p-5rJbNzHJV5" tabindex="-1" role="presentation"></a>Sin embargo, una solicitud HTTP permite solo un flujo simple de información: el cliente envía una solicitud, el servidor responde una sola vez, y eso es todo. Existe una tecnología llamada <em>WebSockets</em> que permite abrir conexiones para el intercambio arbitrario de datos. Pero usarlas adecuadamente es algo complicado.</p>
<p><a class="p_ident" id="p-1TWbmtIAKU" href="#p-1TWbmtIAKU" tabindex="-1" role="presentation"></a>En este capítulo, utilizamos una técnica más sencilla —long polling— donde los clientes preguntan continuamente al servidor por nueva información mediante solicitudes HTTP normales, y el servidor retiene su respuesta cuando no tiene nada nuevo que informar.</p>
<div class="translator-note"><p><strong>N. del T.:</strong> Como con otros muchos conceptos, elegimos en esta traducción no traducir el término <em>long polling</em>. Una traducción adecuada sería <em>sondeo prolongado</em>, así que la usaremos aquí, junto con otras posibles traducciones que se entenderán por el contexto.</p>
</div>
<p><a class="p_ident" id="p-t91Kzn6g95" href="#p-t91Kzn6g95" tabindex="-1" role="presentation"></a>Mientras el cliente se asegure de tener una solicitud de sondeo abierta constantemente, recibirá información del servidor rápidamente cuando esté disponible. Por ejemplo, si Fatma tiene nuestra aplicación de intercambio de habilidades abierta en su navegador, ese navegador habrá solicitado actualizaciones y estará esperando una respuesta a esa solicitud. Cuando Iman envía una charla sobre “Extreme Downhill Unicycling”, el servidor notará que Fatma está esperando actualizaciones y enviará una respuesta que contiene la nueva charla a su solicitud pendiente. El navegador de Fatma recibirá los datos y actualizará la pantalla para mostrar la charla.</p>
<p><a class="p_ident" id="p-EqaPXn25K7" href="#p-EqaPXn25K7" tabindex="-1" role="presentation"></a>Para evitar que las conexiones se agoten por tiempo (se aborten debido a una falta de actividad), las técnicas de long polling suelen establecer un tiempo máximo para cada solicitud, tras el cual el servidor responderá de todos modos, aunque no tenga nada que informar. Entonces, el cliente puede iniciar una nueva solicitud. Reiniciar periódicamente la solicitud también hace que la técnica sea más robusta, permitiendo a los clientes recuperarse de fallos temporales de conexión o problemas de servidor.</p>
<p><a class="p_ident" id="p-QyoyMuc05C" href="#p-QyoyMuc05C" tabindex="-1" role="presentation"></a>Un servidor ocupado que utiliza long polling puede tener miles de solicitudes en espera, y, por lo tanto, de conexiones TCP abiertas. Node, que facilita la gestión de muchas conexiones sin crear un hilo de control separado para cada una, es ideal para este tipo de sistema.</p>
<h2><a class="h_ident" id="h-zB2BkFIqom" href="#h-zB2BkFIqom" tabindex="-1" role="presentation"></a>Interfaz HTTP</h2>
<p><a class="p_ident" id="p-GMlJy93L9x" href="#p-GMlJy93L9x" tabindex="-1" role="presentation"></a>Antes de comenzar a diseñar el servidor o el cliente, pensemos en el punto donde se conectan: la interfaz HTTP a través de la cual se comunican.</p>
<p><a class="p_ident" id="p-NRA737W3IG" href="#p-NRA737W3IG" tabindex="-1" role="presentation"></a>Utilizaremos JSON como formato de nuestro cuerpo de solicitud y respuesta. Al igual que en el servidor de archivos del <a href="20_node.html#file_server">Capítulo 20</a>, intentaremos hacer un buen uso de los métodos y cabeceras HTTP. La interfaz se centra en la ruta <code>/talks</code>. Las rutas que no comienzan con <code>/talks</code> se utilizarán para servir archivos estáticos —el código HTML y JavaScript para el sistema del lado del cliente.</p>
<p><a class="p_ident" id="p-z2nHTSSYsb" href="#p-z2nHTSSYsb" tabindex="-1" role="presentation"></a>Una solicitud <code>GET</code> a <code>/talks</code> devuelve un documento JSON como este:</p>
<pre class="snippet" data-language="json" ><a class="c_ident" id="c-7u2gZXiBb3" href="#c-7u2gZXiBb3" tabindex="-1" role="presentation"></a>[{<span class="tok-string">"title"</span>: <span class="tok-string">"Unituning"</span>,
<span class="tok-string">"presenter"</span>: <span class="tok-string">"Jamal"</span>,
<span class="tok-string">"summary"</span>: <span class="tok-string">"Modificando tu bicicleta para darle más estilo"</span>,
<span class="tok-string">"comments"</span>: []}]</pre>
<p><a class="p_ident" id="p-hxd4W57pfk" href="#p-hxd4W57pfk" tabindex="-1" role="presentation"></a>Una nueva charla se creará haciendo una solicitud <code>PUT</code> a una URL como <code>/talks/Unituning</code>, donde la parte después de la segunda barra es el título de la charla. El cuerpo de la solicitud <code>PUT</code> debe contener un objeto JSON que tenga propiedades <code>presenter</code> y <code>summary</code>.</p>
<p><a class="p_ident" id="p-KimDuHu+7+" href="#p-KimDuHu+7+" tabindex="-1" role="presentation"></a>Dado que los títulos de las charlas pueden contener espacios y otros caracteres que normalmente no aparecen en una URL, las cadenas de título deben ser codificadas con la función <code>encodeURIComponent</code> al construir una URL de ese tipo.</p>
<pre tabindex="0" class="snippet" data-language="javascript" ><a class="c_ident" id="c-p3OnyEPmi1" href="#c-p3OnyEPmi1" tabindex="-1" role="presentation"></a>console.log(<span class="tok-string">"/talks/"</span>
+ encodeURIComponent(<span class="tok-string">"Cómo hacer el caballito"</span>));
<span class="tok-comment">// → /talks/Cómo%20hacer%20el%20caballito</span></pre>
<p><a class="p_ident" id="p-wTqsGckkEn" href="#p-wTqsGckkEn" tabindex="-1" role="presentation"></a>Una solicitud para crear una charla sobre hacer el caballito podría ser algo así:</p>
<pre class="snippet" data-language="http" ><a class="c_ident" id="c-8jTK6TIW2A" href="#c-8jTK6TIW2A" tabindex="-1" role="presentation"></a><span class="tok-keyword">PUT</span> <span class="tok-string2">/talks/Cómo%20hacer%20el%20caballito</span> <span class="tok-keyword">HTTP/1.1</span>
<span class="tok-atom">Content-Type:</span><span class="tok-string"> application/json</span>
<span class="tok-atom">Content-Length:</span><span class="tok-string"> 92</span>
{"presenter": "Maureen",
"summary": "Permanecer quieto sobre un monociclo"}</pre>
<p><a class="p_ident" id="p-N/i1a9WVQQ" href="#p-N/i1a9WVQQ" tabindex="-1" role="presentation"></a>Estas URLs también admiten solicitudes <code>GET</code> para recuperar la representación JSON de una charla y solicitudes <code>DELETE</code> para eliminar una charla.</p>
<p><a class="p_ident" id="p-oXduuDcQBO" href="#p-oXduuDcQBO" tabindex="-1" role="presentation"></a>Agregar un comentario a una charla se hace con una solicitud <code>POST</code> a una URL como <code>/<wbr>talks/<wbr>Unituning/<wbr>comments</code>, con un cuerpo JSON que tiene propiedades <code>author</code> y <code>message</code>.</p>
<pre class="snippet" data-language="http" ><a class="c_ident" id="c-FuvYZMKcwn" href="#c-FuvYZMKcwn" tabindex="-1" role="presentation"></a><span class="tok-keyword">POST</span> <span class="tok-string2">/talks/Unituning/comments</span> <span class="tok-keyword">HTTP/1.1</span>
<span class="tok-atom">Content-Type:</span><span class="tok-string"> application/json</span>
<span class="tok-atom">Content-Length:</span><span class="tok-string"> 72</span>
{"author": "Iman",
"message": "¿Vas a hablar sobre cómo levantar una bicicleta?"}</pre>
<p><a class="p_ident" id="p-j52hmLpcX5" href="#p-j52hmLpcX5" tabindex="-1" role="presentation"></a>Para soportar long pollings, las solicitudes <code>GET</code> a <code>/talks</code> pueden incluir encabezados adicionales que informen al servidor para retrasar la respuesta si no hay nueva información disponible. Usaremos un par de encabezados normalmente destinados a gestionar el almacenamiento en caché: <code>ETag</code> y <code>If-None-Match</code>.</p>
<p><a class="p_ident" id="p-wI/PFtLZun" href="#p-wI/PFtLZun" tabindex="-1" role="presentation"></a>Los servidores pueden incluir un encabezado <code>ETag</code> (“etiqueta de entidad”) en una respuesta. Su valor es una cadena que identifica la versión actual del recurso. Los clientes, al solicitar posteriormente ese recurso de nuevo, pueden hacer una <em>solicitud condicional</em> incluyendo un encabezado <code>If-None-Match</code> cuyo valor contenga esa misma cadena. Si el recurso no ha cambiado, el servidor responderá con el código de estado 304, que significa “no modificado”, indicando al cliente que su versión en caché sigue siendo actual. Cuando la etiqueta no coincide, el servidor responde como de costumbre.</p>
<p><a class="p_ident" id="p-Qh6oPo/1bh" href="#p-Qh6oPo/1bh" tabindex="-1" role="presentation"></a>Necesitamos algo como esto, donde el cliente puede decirle al servidor qué versión de la lista de charlas tiene, y el servidor responde solo cuando esa lista ha cambiado. Pero en lugar de devolver inmediatamente una respuesta 304, el servidor debería demorar la respuesta y devolverla solo cuando haya algo nuevo disponible o haya transcurrido una cantidad de tiempo determinada. Para distinguir las solicitudes de encuestas prolongadas de las solicitudes condicionales normales, les damos otro encabezado, <code>Prefer: wait=90</code>, que le indica al servidor que el cliente está dispuesto a esperar hasta 90 segundos por la respuesta.</p>
<p><a class="p_ident" id="p-GAKZD/MSJm" href="#p-GAKZD/MSJm" tabindex="-1" role="presentation"></a>El servidor mantendrá un número de versión que actualiza cada vez que cambian las charlas y lo utilizará como valor <code>ETag</code>. Los clientes pueden hacer solicitudes como esta para ser notificados cuando las charlas cambien:</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-MFF1bCg8y6" href="#c-MFF1bCg8y6" tabindex="-1" role="presentation"></a>GET /talks HTTP/1.1
If-None-Match: "4"
Prefer: wait=90
(pasa el tiempo)
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "5"
Content-Length: 295
[....]</pre>
<p><a class="p_ident" id="p-Q/X5dgPjtl" href="#p-Q/X5dgPjtl" tabindex="-1" role="presentation"></a>El protocolo descrito aquí no realiza ningún control de acceso. Cualquiera puede comentar, modificar charlas e incluso eliminarlas. Dado que Internet está lleno de matones, poner un sistema en línea sin protección adicional probablemente no terminaría bien.</p>
<h2><a class="h_ident" id="h-ws+ngSuF7H" href="#h-ws+ngSuF7H" tabindex="-1" role="presentation"></a>El servidor</h2>
<p><a class="p_ident" id="p-2FUAACQEai" href="#p-2FUAACQEai" tabindex="-1" role="presentation"></a>Comencemos construyendo la parte del programa del lado del servidor. El código en esta sección se ejecuta en Node.js.</p>
<h3><a class="i_ident" id="i-939KJHgV2o" href="#i-939KJHgV2o" tabindex="-1" role="presentation"></a>Enrutamiento</h3>
<p><a class="p_ident" id="p-dOksxO97TB" href="#p-dOksxO97TB" tabindex="-1" role="presentation"></a>Nuestro servidor utilizará <code>createServer</code> de Node para iniciar un servidor HTTP. En la función que maneja una nueva solicitud, debemos distinguir entre los diferentes tipos de solicitudes (como se determina por el método y la ruta) que soportamos. Esto se puede hacer con una larga cadena de declaraciones <code>if</code>, pero hay una manera más elegante.</p>
<p><a class="p_ident" id="p-zXEf+YLpW2" href="#p-zXEf+YLpW2" tabindex="-1" role="presentation"></a>Un <em>enrutador</em> es un componente que ayuda a despachar una solicitud a la función que puede manejarla. Puedes indicarle al enrutador, por ejemplo, que las solicitudes <code>PUT</code> con una ruta que coincida con la expresión regular <code>/<wbr>^\/<wbr>talks\/<wbr>([^\/<wbr>]+)$/<wbr></code> (<code>/talks/</code> seguido de un título de charla) pueden ser manejadas por una función dada. Además, puede ayudar a extraer las partes significativas de la ruta (en este caso el título de la charla), envueltas en paréntesis en la expresión regular, y pasarlas a la función manejadora.</p>
<p><a class="p_ident" id="p-/vuAOqVFDe" href="#p-/vuAOqVFDe" tabindex="-1" role="presentation"></a>Hay varios paquetes de enrutadores buenos en NPM, pero aquí escribiremos uno nosotros mismos para mostrar cómo funciona.</p>
<p><a class="p_ident" id="p-f+1c52SwUl" href="#p-f+1c52SwUl" tabindex="-1" role="presentation"></a>Este es <code>router.mjs</code>, que luego <code>importaremos</code> desde nuestro módulo del servidor:</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-DdjroKRGYC" href="#c-DdjroKRGYC" tabindex="-1" role="presentation"></a>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);
}
}
}</pre>
<p><a class="p_ident" id="p-/ptohdwIb9" href="#p-/ptohdwIb9" tabindex="-1" role="presentation"></a>El módulo exporta la clase <code>Router</code>. Un objeto de enrutador te permite registrar manejadores para métodos específicos y patrones de URL con su método <code>add</code>. Cuando una solicitud se resuelve con el método <code>resolve</code>, el enrutador llama al manejador cuyo método y URL coinciden con la solicitud y devuelve su resultado.</p>
<p><a class="p_ident" id="p-MsWktv3ojr" href="#p-MsWktv3ojr" tabindex="-1" role="presentation"></a>Las funciones manejadoras se llaman con el valor <code>context</code> dado a <code>resolve</code>. Utilizaremos esto para darles acceso al estado de nuestro servidor. Además, reciben las cadenas coincidentes para cualquier grupo que hayan definido en su expresión regular, y el objeto de solicitud. Las cadenas deben ser decodificadas de la URL ya que la URL cruda puede contener códigos estilo <code>%20</code>.</p>
<h3><a class="i_ident" id="i-ZUbq5VoJDs" href="#i-ZUbq5VoJDs" tabindex="-1" role="presentation"></a>Sirviendo archivos</h3>
<p><a class="p_ident" id="p-Qfuyl7XyA7" href="#p-Qfuyl7XyA7" tabindex="-1" role="presentation"></a>Cuando una solicitud no coincide con ninguno de los tipos de solicitud definidos en nuestro enrutador, el servidor debe interpretarlo como una solicitud de un archivo en el directorio <code>public</code>. Sería posible usar el servidor de archivos definido en el <a href="20_node.html#file_server">Capítulo 20</a> para servir dichos archivos, pero ni necesitamos ni queremos admitir solicitudes <code>PUT</code> y <code>DELETE</code> en archivos, y nos gustaría tener funciones avanzadas como el soporte para almacenamiento en caché. Así que usemos en cambio un servidor de archivos estático sólido y bien probado de NPM.</p>
<p><a class="p_ident" id="p-RmKAuAnSZo" href="#p-RmKAuAnSZo" tabindex="-1" role="presentation"></a>Opté por <code>serve-static</code>. Este no es el único servidor de este tipo en NPM, pero funciona bien y se ajusta a nuestros propósitos. El paquete <code>serve-static</code> exporta una función que puede ser llamada con un directorio raíz para producir una función manipuladora de solicitudes. La función manipuladora acepta los argumentos <code>request</code> y <code>response</code> proporcionados por el servidor de <code>"node:http"</code>, y un tercer argumento, una función que se llamará si ningún archivo coincide con la solicitud. Queremos que nuestro servidor primero compruebe las solicitudes que deberíamos manejar de manera especial, según lo definido en el enrutador, por lo que lo envolvemos en otra función.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-lpYEW+IClx" href="#c-lpYEW+IClx" tabindex="-1" role="presentation"></a>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();
}
}</pre>
<p><a class="p_ident" id="p-WcsQxVDR10" href="#p-WcsQxVDR10" tabindex="-1" role="presentation"></a>La función <code>serveFromRouter</code> tiene la misma interfaz que <code>fileServer</code>, tomando los argumentos <code>(request, response, next)</code>. Esto nos permite “encadenar” varios manipuladores de solicitudes, permitiendo que cada uno maneje la solicitud o pase la responsabilidad de eso al siguiente manejador. El manejador final, <code>notFound</code>, simplemente responde con un error de “no encontrado”.</p>
<p><a class="p_ident" id="p-HVx8AlKtha" href="#p-HVx8AlKtha" tabindex="-1" role="presentation"></a>Nuestra función <code>serveFromRouter</code> utiliza una convención similar a la del servidor de archivos del <a href="20_node.html">capítulo anterior</a> para las respuestas: los manejadores en el enrutador devuelven promesas que se resuelven en objetos que describen la respuesta.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-pOBp3UhLkD" href="#c-pOBp3UhLkD" tabindex="-1" role="presentation"></a>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(error), status: 500};
});
if (!resolved) return next();
let {body, status = 200, headers = defaultHeaders} =
await resolved;
response.writeHead(status, headers);
response.end(body);
}</pre>
<h3><a class="i_ident" id="i-ufH3WCBBxA" href="#i-ufH3WCBBxA" tabindex="-1" role="presentation"></a>Charlas como recursos</h3>
<p><a class="p_ident" id="p-1W52Huo5gE" href="#p-1W52Huo5gE" tabindex="-1" role="presentation"></a>Las charlas que se han propuesto se almacenan en la propiedad <code>talks</code> del servidor, un objeto cuyas propiedades son los títulos de las charlas. Agregaremos algunos controladores a nuestro enrutador que expongan estos como recursos HTTP bajo <code>/talks/[título]</code>.</p>
<p><a class="p_ident" id="p-KVcSbBGYSx" href="#p-KVcSbBGYSx" tabindex="-1" role="presentation"></a>El controlador para las solicitudes que <code>GET</code> una sola charla debe buscar la charla y responder ya sea con los datos JSON de la charla o con una respuesta de error 404.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-XHeNXHR1rW" href="#c-XHeNXHR1rW" tabindex="-1" role="presentation"></a>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 se encontró la charla '${title}'`};
}
});</pre>
<p><a class="p_ident" id="p-UfALUG7EtU" href="#p-UfALUG7EtU" tabindex="-1" role="presentation"></a>Para eliminar una charla la eliminamos del objeto <code>talks</code>.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-hHVMqh7is7" href="#c-hHVMqh7is7" tabindex="-1" role="presentation"></a>router.add("DELETE", talkPath, async (server, title) => {
if (Object.hasOwn(server.talks, title)) {
delete server.talks[title];
server.updated();
}
return {status: 204};
});</pre>
<p><a class="p_ident" id="p-pjcjDMRuxA" href="#p-pjcjDMRuxA" tabindex="-1" role="presentation"></a>El método <code>updated</code>, que definiremos <a href="21_skillsharing.html#updated">más adelante</a>, notifica a las solicitudes de long polling sobre el cambio.</p>
<p><a class="p_ident" id="p-nqruthdp+B" href="#p-nqruthdp+B" tabindex="-1" role="presentation"></a>Un manejador que necesita leer cuerpos de solicitud es el manejador <code>PUT</code>, que se utiliza para crear nuevas charlas. Debe verificar si los datos que se le proporcionaron tienen propiedades <code>presenter</code> y <code>summary</code>, que son cadenas de texto. Cualquier dato que provenga de fuera del sistema podría ser un sinsentido y no queremos corromper nuestro modelo de datos interno o fallar cuando lleguen solicitudes incorrectas.</p>
<p><a class="p_ident" id="p-staaYNoCZ/" href="#p-staaYNoCZ/" tabindex="-1" role="presentation"></a>Si los datos parecen válidos, el controlador almacena un objeto que representa la nueva charla en el objeto <code>talks</code>, posiblemente sobrescribiendo una charla existente con este título, y nuevamente llama a <code>updated</code>.</p>
<p><a class="p_ident" id="p-+8INWPaDVV" href="#p-+8INWPaDVV" tabindex="-1" role="presentation"></a>Para leer el cuerpo del flujo de solicitud, utilizaremos la función <code>json</code> de <code>"node:stream/<wbr>consumers"</code>, que recopila los datos en el flujo y luego los analiza como JSON. Hay exportaciones similares llamadas <code>text</code> (para leer el contenido como una cadena) y <code>buffer</code> (para leerlo como datos binarios) en este paquete. Dado que <code>json</code> es un nombre genérico, la importación lo renombra a <code>readJSON</code> para evitar confusiones.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-8gKoWhOhaF" href="#c-8gKoWhOhaF" tabindex="-1" role="presentation"></a>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: "Datos de charla incorrectos"};
}
server.talks[title] = {
title,
presenter: talk.presenter,
summary: talk.summary,
comments: []
};
server.updated();
return {status: 204};
});</pre>
<p><a class="p_ident" id="p-Tqp5JS2x2c" href="#p-Tqp5JS2x2c" tabindex="-1" role="presentation"></a>Agregar un comentario a una charla funciona de manera similar. Usamos <code>readJSON</code> para obtener el contenido de la solicitud, validamos los datos resultantes y los almacenamos como un comentario cuando parecen válidos.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-ItQx3NP6j0" href="#c-ItQx3NP6j0" tabindex="-1" role="presentation"></a>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: "Datos de comentario incorrectos"};
} else if (Object.hasOwn(server.talks, title)) {
server.talks[title].comments.push(comment);
server.updated();
return {status: 204};
} else {
return {status: 404, body: `No se encontró la charla '${title}'`};
}
});</pre>
<p><a class="p_ident" id="p-A4Y3JVemOr" href="#p-A4Y3JVemOr" tabindex="-1" role="presentation"></a>Intentar agregar un comentario a una charla inexistente devuelve un error 404.</p>
<h3><a class="i_ident" id="i-62/Lu6qKad" href="#i-62/Lu6qKad" tabindex="-1" role="presentation"></a>Soporte para long polling</h3>
<p><a class="p_ident" id="p-laDMjH8FTx" href="#p-laDMjH8FTx" tabindex="-1" role="presentation"></a>El aspecto más interesante del servidor es la parte que maneja el <em>long polling</em> (o la larga espera). Cuando llega una solicitud <code>GET</code> para <code>/talks</code>, puede ser una solicitud normal o una solicitud de larga espera.</p>
<p><a class="p_ident" id="p-HOEdzyAw2w" href="#p-HOEdzyAw2w" tabindex="-1" role="presentation"></a>Habrá varios lugares en los que debamos enviar un array de charlas al cliente, por lo que primero definimos un método auxiliar que construya dicho array e incluya un encabezado <code>ETag</code> en la respuesta.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-XiOFQSm9fc" href="#c-XiOFQSm9fc" tabindex="-1" role="presentation"></a>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"}
};
};</pre>
<p><a class="p_ident" id="p-mAxsk3oziO" href="#p-mAxsk3oziO" tabindex="-1" role="presentation"></a>El manejador en sí mismo necesita examinar los encabezados de la solicitud para ver si están presentes los encabezados <code>If-None-Match</code> y <code>Prefer</code>. Node almacena los encabezados, cuyos nombres se especifican como insensibles a mayúsculas y minúsculas, bajo sus nombres en minúsculas.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-/t9cPK34U3" href="#c-/t9cPK34U3" tabindex="-1" role="presentation"></a>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]));
}
});</pre>
<p><a class="p_ident" id="p-pT8IDjdsPB" href="#p-pT8IDjdsPB" tabindex="-1" role="presentation"></a>Si no se proporcionó ninguna etiqueta o se proporcionó una etiqueta que no coincide con la versión actual del servidor, el manejador responde con la lista de charlas. Si la solicitud es condicional y las charlas no han cambiado, consultamos el encabezado <code>Prefer</code> para ver si debemos retrasar la respuesta o responder de inmediato.</p>
<p><a class="p_ident" id="p-YGvJuvHlww" href="#p-YGvJuvHlww" tabindex="-1" role="presentation"></a>Las funciones de callback para solicitudes retardadas se almacenan en el array <code>waiting</code> del servidor para que puedan ser notificadas cuando ocurra algo. El método <code>waitForChanges</code> también establece inmediatamente un temporizador para responder con un estado 304 cuando la solicitud haya esperado el tiempo suficiente.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-4a/A2U+gT3" href="#c-4a/A2U+gT3" tabindex="-1" role="presentation"></a>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);
});
};</pre>
<p id="updated"><a class="p_ident" id="p-ytA6+142s8" href="#p-ytA6+142s8" tabindex="-1" role="presentation"></a>Registrar un cambio con <code>updated</code> incrementa la propiedad <code>versión</code> y despierta todas las solicitudes en espera.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-mZ7p2D8VNW" href="#c-mZ7p2D8VNW" tabindex="-1" role="presentation"></a>SkillShareServer.prototype.updated = function() {
this.version++;
let response = this.talkResponse();
this.waiting.forEach(resolve => resolve(response));
this.waiting = [];
};</pre>
<p><a class="p_ident" id="p-GETCDP1v5q" href="#p-GETCDP1v5q" tabindex="-1" role="presentation"></a>Eso concluye el código del servidor. Si creamos una instancia de <code>SkillShareServer</code> y la iniciamos en el puerto 8000, el servidor HTTP resultante servirá archivos desde el subdirectorio <code>public</code> junto con una interfaz para manejar charlas bajo la URL <code>/talks</code>.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-aaqy/FYjqn" href="#c-aaqy/FYjqn" tabindex="-1" role="presentation"></a>new SkillShareServer({}).start(8000);</pre>
<h2><a class="h_ident" id="h-89YUl2IhC9" href="#h-89YUl2IhC9" tabindex="-1" role="presentation"></a>El cliente</h2>
<p><a class="p_ident" id="p-wtw0eg9iUp" href="#p-wtw0eg9iUp" tabindex="-1" role="presentation"></a>La parte del cliente del sitio web de intercambio de habilidades consiste en tres archivos: una pequeña página HTML, una hoja de estilos y un archivo JavaScript.</p>
<h3><a class="i_ident" id="i-n3OM6EV/KR" href="#i-n3OM6EV/KR" tabindex="-1" role="presentation"></a>HTML</h3>
<p><a class="p_ident" id="p-rYuavmgG93" href="#p-rYuavmgG93" tabindex="-1" role="presentation"></a>Es una convención ampliamente utilizada para servidores web intentar servir un archivo llamado <code>index.html</code> cuando se realiza una solicitud directamente a una ruta que corresponde a un directorio. El módulo de servidor de archivos que utilizamos, <code>serve-static</code>, adopta esta convención. Cuando se realiza una solicitud a la ruta <code>/</code>, el servidor busca el archivo <code>./<wbr>public/<wbr>index.<wbr>html</code> (<code>./public</code> siendo la raíz que le dimos) y devuelve ese archivo si se encuentra.</p>
<p><a class="p_ident" id="p-qrTeFznkLy" href="#p-qrTeFznkLy" tabindex="-1" role="presentation"></a>Por lo tanto, si queremos que una página aparezca cuando un navegador apunta a nuestro servidor, deberíamos colocarla en <code>public/<wbr>index.<wbr>html</code>. Este es nuestro archivo de índice:</p>
<pre tabindex="0" class="snippet" data-language="html" ><a class="c_ident" id="c-/r8K6Wm5bv" href="#c-/r8K6Wm5bv" tabindex="-1" role="presentation"></a><span class="tok-meta"><!doctype html></span>
<<span class="tok-typeName">meta</span> charset=<span class="tok-string">"utf-8"</span>>
<<span class="tok-typeName">title</span>>Intercambio de habilidades</<span class="tok-typeName">title</span>>
<<span class="tok-typeName">link</span> rel=<span class="tok-string">"stylesheet"</span> href=<span class="tok-string">"skillsharing.css"</span>>
<<span class="tok-typeName">h1</span>>Intercambio de habilidades</<span class="tok-typeName">h1</span>>
<<span class="tok-typeName">script</span> src=<span class="tok-string">"skillsharing_client.js"</span>></<span class="tok-typeName">script</span>></pre>
<p><a class="p_ident" id="p-LHLe/6F2Bj" href="#p-LHLe/6F2Bj" tabindex="-1" role="presentation"></a>Define el título del documento e incluye una hoja de estilos, que define algunos estilos para, entre otras cosas, asegurarse de que haya algo de espacio entre las charlas. Luego agrega un encabezado en la parte superior de la página y carga el script que contiene la aplicación del cliente.</p>
<h3><a class="i_ident" id="i-eb0O2RLD25" href="#i-eb0O2RLD25" tabindex="-1" role="presentation"></a>Acciones</h3>
<p><a class="p_ident" id="p-QVOaRxytMq" href="#p-QVOaRxytMq" tabindex="-1" role="presentation"></a>El estado de la aplicación consiste en la lista de charlas y el nombre del usuario, y lo almacenaremos en un objeto <code>{talks, user}</code>. No permitimos que la interfaz de usuario manipule directamente el estado ni envíe solicitudes HTTP. En cambio, puede emitir <em>acciones</em> que describen lo que el usuario está intentando hacer.</p>
<p><a class="p_ident" id="p-TAiYXUsGw+" href="#p-TAiYXUsGw+" tabindex="-1" role="presentation"></a>La función <code>handleAction</code> toma una acción de este tipo y la lleva a cabo. Como nuestras actualizaciones de estado son tan simples, los cambios de estado se manejan en la misma función.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-TZfLAx6p12" href="#c-TZfLAx6p12" tabindex="-1" role="presentation"></a>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;
}</pre>
<p><a class="p_ident" id="p-Eqtvh6MDT4" href="#p-Eqtvh6MDT4" tabindex="-1" role="presentation"></a>Almacenaremos el nombre del usuario en <code>localStorage</code> para que pueda ser restaurado cuando se cargue la página.</p>
<p><a class="p_ident" id="p-xZU8661i5R" href="#p-xZU8661i5R" tabindex="-1" role="presentation"></a>Las acciones que necesitan involucrar al servidor realizan peticiones a la red, utilizando <code>fetch</code>, a la interfaz HTTP descrita anteriormente. Utilizamos una función de envoltura, <code>fetchOK</code>, que se asegura de que la promesa devuelta sea rechazada cuando el servidor devuelve un código de error.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-IM58YE2b7h" href="#c-IM58YE2b7h" tabindex="-1" role="presentation"></a>function fetchOK(url, options) {
return fetch(url, options).then(response => {
if (response.status < 400) return response;
else throw new Error(response.statusText);
});
}</pre>
<p><a class="p_ident" id="p-x0pviGUgx7" href="#p-x0pviGUgx7" tabindex="-1" role="presentation"></a>Esta función auxiliar se utiliza para construir una URL para una charla con un título dado.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-KDfsRI9rOO" href="#c-KDfsRI9rOO" tabindex="-1" role="presentation"></a>function talkURL(title) {
return "talks/" + encodeURIComponent(title);
}</pre>
<p><a class="p_ident" id="p-nE9YcWo4cI" href="#p-nE9YcWo4cI" tabindex="-1" role="presentation"></a>Cuando la petición falla, no queremos que nuestra página simplemente se quede ahí, sin hacer nada sin explicación. Así que definimos una función llamada <code>reportError</code>, que al menos muestra al usuario un cuadro de diálogo que le informa de que algo salió mal.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-upWq63EA/j" href="#c-upWq63EA/j" tabindex="-1" role="presentation"></a>function reportError(error) {
alert(String(error));
}</pre>
<h3><a class="i_ident" id="i-Xb+LQLgwP6" href="#i-Xb+LQLgwP6" tabindex="-1" role="presentation"></a>Renderización de componentes</h3>
<p><a class="p_ident" id="p-Q/3k0gUdfJ" href="#p-Q/3k0gUdfJ" tabindex="-1" role="presentation"></a>Utilizaremos un enfoque similar al que vimos en el <a href="19_paint.html">Capítulo 19</a>, dividiendo la aplicación en componentes. Pero dado que algunos de los componentes nunca necesitan actualizarse o siempre se redibujan por completo cuando se actualizan, definiremos aquellos no como clases, sino como funciones que devuelven directamente un nodo del DOM. Por ejemplo, aquí hay un componente que muestra el campo dónde el usuario puede ingresar su nombre:</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-whT+DkjYvZ" href="#c-whT+DkjYvZ" tabindex="-1" role="presentation"></a>function renderUserField(name, dispatch) {
return elt("label", {}, "Tu nombre: ", elt("input", {
type: "text",
value: name,
onchange(event) {
dispatch({type: "setUser", user: event.target.value});
}
}));
}</pre>
<p><a class="p_ident" id="p-WsiiGyRrRE" href="#p-WsiiGyRrRE" tabindex="-1" role="presentation"></a>La función <code>elt</code> utilizada para construir elementos DOM es la misma que usamos en el <a href="19_paint.html">Capítulo 19</a>.</p>
<p><a class="p_ident" id="p-ANM2bG067I" href="#p-ANM2bG067I" tabindex="-1" role="presentation"></a>Se utiliza una función similar para renderizar charlas, que incluyen una lista de comentarios y un formulario para agregar un nuevo comentario.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-18QxMufj4p" href="#c-18QxMufj4p" tabindex="-1" role="presentation"></a>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});
}
}, "Eliminar")),
elt("div", null, "por ",
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"}, "Añadir comentario")));
}</pre>
<p><a class="p_ident" id="p-zWBsJsWqrO" href="#p-zWBsJsWqrO" tabindex="-1" role="presentation"></a>El controlador de evento <code>"submit"</code> llama a <code>form.reset</code> para limpiar el contenido del formulario después de crear una acción <code>"newComment"</code>.</p>
<p><a class="p_ident" id="p-ZbV8V68t7Z" href="#p-ZbV8V68t7Z" tabindex="-1" role="presentation"></a>Cuando se crean piezas moderadamente complejas del DOM, este estilo de programación comienza a verse bastante desordenado. Para evitar esto, a menudo la gente utiliza un <em>lenguaje de plantillas</em>, que permite escribir la interfaz como un archivo HTML con algunos marcadores especiales para indicar dónde van los elementos dinámicos. O utilizan <em>JSX</em>, un dialecto de JavaScript no estándar que te permite escribir algo muy parecido a etiquetas HTML en tu programa como si fueran expresiones JavaScript. Ambos enfoques utilizan herramientas adicionales para preprocesar el código antes de que pueda ser ejecutado, lo cual evitaremos en este capítulo.</p>
<p><a class="p_ident" id="p-92uR7ZfPQ+" href="#p-92uR7ZfPQ+" tabindex="-1" role="presentation"></a>Los comentarios son simples de renderizar.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-N9+wrVgBuY" href="#c-N9+wrVgBuY" tabindex="-1" role="presentation"></a>function renderComment(comment) {
return elt("p", {className: "comment"},
elt("strong", null, comment.author),
": ", comment.message);
}</pre>
<p><a class="p_ident" id="p-ORWbCmi6gc" href="#p-ORWbCmi6gc" tabindex="-1" role="presentation"></a>Finalmente, el formulario que el usuario puede usar para crear una nueva charla se representa de la siguiente manera:</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-wxCOfM4XMy" href="#c-wxCOfM4XMy" tabindex="-1" role="presentation"></a>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, "Enviar una charla"),
elt("label", null, "Título: ", title),
elt("label", null, "Resumen: ", summary),
elt("button", {type: "submit"}, "Enviar"));
}</pre>
<h3><a class="i_ident" id="i-J1GeO6SRk6" href="#i-J1GeO6SRk6" tabindex="-1" role="presentation"></a>Sondeo</h3>
<p><a class="p_ident" id="p-qSoi5wePE3" href="#p-qSoi5wePE3" tabindex="-1" role="presentation"></a>Para iniciar la aplicación necesitamos la lista actual de charlas. Dado que la carga inicial está estrechamente relacionada con el proceso de sondeo prolongado (<em>long polling</em>), el <code>ETag</code> de la carga debe ser utilizado al sondear, escribiremos una función que siga sondeando al servidor en busca de <code>/talks</code> y llame a una función de callback cuando un nuevo conjunto de charlas esté disponible.</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-k+Yrvp6Xiy" href="#c-k+Yrvp6Xiy" tabindex="-1" role="presentation"></a>async function pollTalks(update) {
let tag = undefined;
for (;;) {
let response;
try {
response = await fetchOK("/charlas", {
headers: tag && {"If-None-Match": tag,
"Prefer": "wait=90"}
});
} catch (e) {
console.log("La solicitud falló: " + e);
await new Promise(resolve => setTimeout(resolve, 500));
continue;
}
if (response.status == 304) continue;
tag = response.headers.get("ETag");
update(await response.json());
}
}</pre>
<p><a class="p_ident" id="p-orGKloo5X9" href="#p-orGKloo5X9" tabindex="-1" role="presentation"></a>Esta es una función <code>async</code> para facilitar el bucle y la espera de la solicitud. Ejecuta un bucle infinito que, en cada iteración, recupera la lista de charlas, ya sea normalmente o, si esta no es la primera solicitud, con las cabeceras incluidas que la convierten en una solicitud de long polling.</p>
<p><a class="p_ident" id="p-2TjP6xQV7g" href="#p-2TjP6xQV7g" tabindex="-1" role="presentation"></a>Cuando una solicitud falla, la función espera un momento y luego lo intenta de nuevo. De esta manera, si tu conexión de red se interrumpe por un tiempo y luego vuelve, la aplicación puede recuperarse y continuar actualizándose. La promesa resuelta a través de <code>setTimeout</code> es una forma de forzar a la función <code>async</code> a esperar.</p>
<p><a class="p_ident" id="p-jL8JC5aQsh" href="#p-jL8JC5aQsh" tabindex="-1" role="presentation"></a>Cuando el servidor devuelve una respuesta 304, eso significa que una solicitud de intercambio de larga duración expiró, por lo que la función debería comenzar inmediatamente la siguiente solicitud. Si la respuesta es un estado 200 normal, su cuerpo se lee como JSON y se pasa a la función de callback, y el valor del encabezado <code>ETag</code> se almacena para la próxima iteración.</p>
<h3><a class="i_ident" id="i-dYAceDsj0Z" href="#i-dYAceDsj0Z" tabindex="-1" role="presentation"></a>La aplicación</h3>
<p><a class="p_ident" id="p-LFDIQcKx5s" href="#p-LFDIQcKx5s" tabindex="-1" role="presentation"></a>El siguiente componente une toda la interfaz de usuario:</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-PzYGJXojtl" href="#c-PzYGJXojtl" tabindex="-1" role="presentation"></a>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;
}
}
}</pre>
<p><a class="p_ident" id="p-Q8LoG2UUP1" href="#p-Q8LoG2UUP1" tabindex="-1" role="presentation"></a>Cuando las charlas cambian, este componente las vuelve a dibujar todas. Esto es simple pero también derrochador. Hablaremos sobre eso en los ejercicios.</p>
<p><a class="p_ident" id="p-CNi4dF2DTL" href="#p-CNi4dF2DTL" tabindex="-1" role="presentation"></a>Podemos iniciar la aplicación de esta manera:</p>
<pre class="snippet" data-language="null" ><a class="c_ident" id="c-6Z6pm4dZOv" href="#c-6Z6pm4dZOv" tabindex="-1" role="presentation"></a>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();</pre>
<p><a class="p_ident" id="p-NkyF0QYQ6t" href="#p-NkyF0QYQ6t" tabindex="-1" role="presentation"></a>Si ejecutas el servidor y abres dos ventanas del navegador para <a href="http://localhost:8000/"><em>http://localhost:8000</em></a> una al lado de la otra, puedes ver que las acciones que realizas en una ventana son inmediatamente visibles en la otra.</p>
<h2><a class="h_ident" id="h-tkm7ntLto1" href="#h-tkm7ntLto1" tabindex="-1" role="presentation"></a>Ejercicios</h2>
<p><a class="p_ident" id="p-t80LR1aRtP" href="#p-t80LR1aRtP" tabindex="-1" role="presentation"></a>Los siguientes ejercicios implicarán modificar el sistema definido en este capítulo. Para trabajar en ellos, asegúrate de descargar primero el código (<a href="https://eloquentjavascript.net/code/skillsharing.zip"><em>https://eloquentjavascript.net/code/skillsharing.zip</em></a>), tener Node instalado (<a href="https://nodejs.org"><em>https://nodejs.org</em></a>), e instalar las dependencias del proyecto con <code>npm install</code>.</p>
<h3><a class="i_ident" id="i-Wzk4TuX1PT" href="#i-Wzk4TuX1PT" tabindex="-1" role="presentation"></a>Persistencia en disco</h3>
<p><a class="p_ident" id="p-ZLeedVXBoa" href="#p-ZLeedVXBoa" tabindex="-1" role="presentation"></a>El servidor de intercambio de habilidades mantiene sus datos puramente en memoria. Esto significa que cuando se produce un fallo o se reinicia por cualquier motivo, se pierden todas las charlas y comentarios.</p>
<p><a class="p_ident" id="p-vXZfoI1M8e" href="#p-vXZfoI1M8e" tabindex="-1" role="presentation"></a>Extiende el servidor para que almacene los datos de las charlas en disco y vuelva a cargar automáticamente los datos cuando se reinicie. No te preocupes por la eficiencia, haz lo más simple que funcione.</p>
<details class="solution"><summary>Mostrar pistas...</summary><div class="solution-text">
<p><a class="p_ident" id="p-sZqaaJ9i56" href="#p-sZqaaJ9i56" tabindex="-1" role="presentation"></a>La solución más simple que se me ocurre es codificar todo el objeto <code>talks</code> como JSON y volcarlo en un archivo con <code>writeFile</code>. Ya existe un método (<code>updated</code>) que se llama cada vez que cambian los datos del servidor. Se puede ampliar para escribir los nuevos datos en el disco.</p>
<p><a class="p_ident" id="p-eH3RY6wVoL" href="#p-eH3RY6wVoL" tabindex="-1" role="presentation"></a>Elige un nombre de archivo, por ejemplo <code>./talks.json</code>. Cuando el servidor se inicie, puede intentar leer ese archivo con <code>readFile</code>, y si tiene éxito, el servidor puede usar el contenido del archivo como datos iniciales.</p>
</div></details>
<h3><a class="i_ident" id="i-E0mEUMJc+G" href="#i-E0mEUMJc+G" tabindex="-1" role="presentation"></a>Restablecimiento del campo de comentarios</h3>
<p><a class="p_ident" id="p-LMd9/ZyRfW" href="#p-LMd9/ZyRfW" tabindex="-1" role="presentation"></a>La remodelación completa de las charlas funciona bastante bien porque generalmente no se puede distinguir entre un nodo del DOM y su sustitución idéntica. Pero hay excepciones. Si empiezas a escribir algo en el campo de comentarios para una charla en una ventana del navegador y luego, en otra, añades un comentario a esa charla, el campo en la primera ventana se volverá a dibujar, eliminando tanto su contenido como su enfoque.</p>
<p><a class="p_ident" id="p-/CzcYyU2Wd" href="#p-/CzcYyU2Wd" tabindex="-1" role="presentation"></a>Cuando varias personas están añadiendo comentarios al mismo tiempo, esto podría resultar molesto. ¿Puedes idear una manera de resolverlo?</p>
<details class="solution"><summary>Mostrar pistas...</summary><div class="solution-text">
<p><a class="p_ident" id="p-DtiTOgpCkK" href="#p-DtiTOgpCkK" tabindex="-1" role="presentation"></a>La mejor manera de hacerlo probablemente sea convertir el componente de la charla en un objeto, con un método <code>syncState</code>, para que se puedan actualizar para mostrar una versión modificada de la charla. Durante el funcionamiento normal, la única forma en que una charla puede cambiar es añadiendo más comentarios, por lo que el método <code>syncState</code> puede ser relativamente sencillo.</p>
<p><a class="p_ident" id="p-LNba5pl/wP" href="#p-LNba5pl/wP" tabindex="-1" role="presentation"></a>La parte difícil es que, cuando llega una lista modificada de charlas, tenemos que conciliar la lista existente de componentes de DOM con las charlas de la nueva lista: eliminar los componentes cuya charla fue eliminada y actualizar los componentes cuya charla cambió.</p>
<p><a class="p_ident" id="p-Weztd66azd" href="#p-Weztd66azd" tabindex="-1" role="presentation"></a>Para hacer esto, podría ser útil mantener una estructura de datos que almacene los componentes de las charlas bajo los títulos de las charlas para que puedas averiguar fácilmente si existe un componente para una charla dada. Luego puedes recorrer el nuevo array de charlas y, para cada una de ellas, sincronizar un componente existente o crear uno nuevo. Para eliminar los componentes de charlas eliminadas, también tendrás que recorrer los componentes y comprobar si las charlas correspondientes aún existen.</p>
</div></details><nav><a href="20_node.html" title="previous chapter" aria-label="previous chapter">◂</a> <a href="index.html" title="cover" aria-label="cover">●</a> <button class=help title="help" aria-label="help"><strong>?</strong></button>
</nav>
</article>
<script src="ejs.js"></script>