Виразний JavaScript: Проект: Веб-сайт обміну досвідом

Зміст



На зустрічах з обміну досвідом люди зі спільними інтересами зустрічаються і роблять невеликі неформальні презентації на тему своїх знань. На зустрічі з обміну досвідом серед фермерів хто-небудь може розповісти про вирощування селери. На зустрічі програмістів ви можете виступити з розповіддю про Node.js

Такі зустрічі — відмінний спосіб розширити свій кругозір, дізнатися про новинки області, або просто поспілкуватися з людьми зі схожими інтересами. У багатьох містах є зустрічі любителів JavaScript. Зазвичай їх відвідування безкоштовне, і я знайшов ті, які відвідував, привітними і гостинними.



В останньому розділі-проекті ми влаштуємо веб-сайт з обслуговування виступів, які робляться на таких зустрічах. Уявіть собі групу людей, які регулярно зустрічаються в офісі одного з учасників, щоб поговорити про моноциклах. Проблема в тому, що коли попередній організатор зустрічей переїхав в інше місто, ніхто не зайняв його місце. Нам потрібна система, яка дозволить учасникам пропонувати і обговорювати теми один з одним, без участі організатора.

Зустрічі моноциклистов

Як і в попередній главі, код написаний для Node.js і запустити його в браузері не вийде. Повний код доступний за адресою.

Дизайн

У проекту є серверна частина, написана для Node.js і клієнтська, написана для браузера. Серверна зберігає системні дані і передає їх клієнту. Також вона віддає файли HTML і JavaScript, які створюють систему на стороні клієнта.

На сервері є список тем для наступного зібрання, і клієнт їх показує. У кожної теми є ім'я виступаючого, назва, опис і список коментарів. Клієнт дозволяє пропонувати нові теми (додавати їх в список), видаляти теми і коментувати вже існуючі. Коли користувач вносить зміни клієнт робить запит HTTP, щоб повідомити про це сервера.



Буде створено програму для показу поточних пропозицій тим і коментарі по них. Коли хтось десь додає нову тему або залишає коментар, у всіх людей, які відкрили сторінку в браузері, зміни повинні відбуватися миттєво. Це непросте завдання, тому що веб-сервер не може відкривати з'єднання з клієнтом, і тому що немає придатного способу дізнатися, хто з клієнтів зараз переглядає цей веб-сайт.

Загальноприйнятим рішенням проблеми є довгі запити (long polling), які послужили однією з мотивацій до розробки Node.

Довгі запити

Щоб миттєво сповістити клієнта про зміни, нам потрібно з'єднання з клієнтом. Браузери традиційно не беруть запитів на з'єднання, і клієнти все одно приховані за пристроями, що ці сполуки не прийняли б, тому починати з'єднання з сервера сенсу не має.

Можна зробити так, щоб клієнт відкривав з'єднання і тримав його, щоб сервер мав можливість відправляти через нього інформацію по необхідності.

Але запит HTTP дозволяє тільки простий обмін інформацією — клієнт надсилає запит, сервер повертає відповідь, і все. Є технологія під назвою web sockets, яка підтримується сучасними браузерами, що дозволяє відкривати з'єднання для обміну довільними даними. Але їх досить складно використовувати.

У цьому розділі ми звернемося до відносно простої технології, довгим запитам, коли клієнти постійно запитують про сервер нової інформації через звичайні HTTP-запити, а сервер просто зволікає з відповіддю, коли йому нема чого повідомити.

Поки клієнт постійно тримає відкритий запит, він буде отримувати інформацію з сервера негайно. Приміром, якщо у Аліси в браузері відкрито додаток для обміну досвідом, браузер зробить запит на оновлення і буде чекати відповіді. Коли Боб зі свого браузера відправить тему «Екстремальний спуск на моноциклі з гори», сервер помітить, що Аліса чекає оновлення, і відправить інформацію за новою темою у відповідь на її чекає запит. Браузер Аліси отримає дані і оновить сторінку, показавши нову тему.

Для запобігання завершення з'єднань по таймауту (по закінченню часу неактивні сполуки обриваються), технологія довгих запитів зазвичай встановлює максимальний час для кожного запиту, після якого сервер в будь-якому випадку відповість, навіть якщо йому нічого повідомити, а потім клієнт запустить новий запит. Періодичне оновлення запиту робить техніку більш надійною, дозволяючи клієнтам відновлюватися після тимчасових обривів або проблем на сервері.

У зайнятого сервера, що використовує довгі запити, можуть висіти відкритими тисячі запитів, а, отже, і TCP з'єднань. Node добре підходить для такої системи, тому, що він дозволяє з легкістю управляти багатьма сполуками без створення окремих потоків.

Інтерфейс HTTP

Перед тим, як ми почнемо робити сервер або клієнт, подумаємо про їх точці дотику: інтерфейсі HTTP, через який вони взаємодіють.

Інтерфейс буде заснований на JSON, і, як і в файловому сервері у главі 20, ми будемо з вигодою використовувати методи HTTP. Інтерфейс зосереджений навколо шляху /talks. Шляхи, які не починаються з /talks, будуть використовуватися для віддачі статичних файлів — HTML і JavaScript, що визначають клієнтську частину.

Запит GET до /talks повертає документ JSON типу цього:

{"serverTime": 1405438911833,
"talks": [{"title": "Unituning ",
"presenter": "Васись",
"summary": "Прикрашаємо свій моноцикл",
"comment": []}]}


Поле serverTime використовується для надійності довгих запитів. Повернемося до нього пізніше.

Створення нової теми відбувається через запит PUT URL виду /talks/Unituning, де частина після другого слеша — назву теми. Тіло запит PUT повинно містити об'єкт JSON, в якому описані властивості presenter і summary.

Оскільки заголовки тим можуть містити пробіли та інші символи, які не можна вставляти в URL, при створенні URL їх треба закодувати за допомогою функції encodeURIComponent.

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


Запит на створення теми може виглядати так:

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

{«presenter»: «Даша»,
«summary»: «Нерухомо стоїмо на моноциклі»}

URL підтримують запити GET для отримання JSON-представлення теми та DELETE для видалення теми.

Додавання коментаря відбувається через POST запит до URL виду /talks/Unituning/comments, з об'єктом JSON, що містить властивості author і message в тілі запиту.

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

{«author»: «Alice»,
«message»: «Will you talk about raising a cycle?»}

Для підтримки довгих запитів, запити GET до /talks можуть включати параметр під ім'ям changesSince, показує, що клієнту потрібні оновлення, що сталися після заданої точки у часі. Коли оновлення з'являються, вони відразу ж повертаються. Коли їх немає, запит затримується, поки що-небудь не станеться, або доки не мине заданий період часу (ми задамо 90 секунд).

Час використовується в форматі кількості мілісекунд з початку 1970 року, в тому ж форматі, що повертає Date.now(). Щоб переконатися, що клієнт отримує всі оновлення, і не отримує одне і те ж оновлення двічі, клієнт повинен передати час, в яке він в останній раз отримав інформацію з сервера. Годинник сервера можуть не збігатися з клієнтом, і навіть якщо б вони збігалися, клієнт не міг би знати точний час, в яке сервер відправляв відповідь, тому що передача даних по мережі займає час.

Тому у відповідях на запити GET до /talks і існує властивість serverTime. Воно повідомляє клієнту точний час по годинниках сервера, коли були створені передані дані. Клієнт просто зберігає час і передає його разом з наступним запитом, щоб переконатися, що він отримує тільки ті оновлення, яких ще не отримував.

GET /talks?changesSince=1405438911833 HTTP/1.1

(минув час)

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 95

{«serverTime»: 1405438913401,
«talks»: [{«title»: «Unituning»,
«deleted»: true}]}

Коли тема змінюється, створюється або коментується, у відповідь на такий запит включається повна інформація про тему. Коли тема видаляється, включаються тільки назва і властивість deleted. Клієнт може додавати теми з заголовками, які він ще не бачив, на сторінці, оновлювати теми, які він вже показує, і видаляти теми, які були видалені.

Протокол, зазначений у цій главі, не здійснює контроль доступу. Кожен може коментувати, змінювати і видаляти теми. Так як інтернет повний хуліганів, розміщення такої системи в онлайні без захисту, швидше за все, закінчиться погано.

Простим рішенням було б розмістити систему за зворотним проксі — це HTTP-сервер, яка приймає з'єднання зовні системи і перенаправляє їх на сервера HTTP, що працюють локально. Такий proxy можна налаштувати, щоб він запитував ім'я і пароль користувача, і ви могли б влаштувати так, щоб пароль був тільки у членів вашої групи.

Почнемо з написання серверної частини програми. Код працює на Node.js

Роутинг

Для запуску сервера буде використовуватися http.createServer. У функції, обробної новий запит, ми повинні розрізняти запити (визначаються методом і шляхом), які ми підтримуємо. Це можна зробити через довгий ланцюжок if / else, але можна і красивіше.

Роутер — компонент, що допомагає розподілити запит до функції, яка може його обробити. Можна сказати роутеру, що запити PUT з шляхом, що збігається з регуляркой /^\/talks\/([^\/]+)$/ (що збігається з /talks/, за яким йде назва теми), можуть бути оброблені заданою функцією. Крім того, він може допомогти витягти осмислені частини шляху, в нашому випадку — назву теми, взяті в лапки, і передати їх допоміжної функції.

У NPM є багато хороших модулів роутінга, але тут ми самі собі такий напишемо, щоб продемонструвати принцип його роботи.

Ось файл router.js, який буде запитуватися через require з модуля сервера:

var Router = module.exports = function() {
this.routes = [];
};

Router.prototype.add = function(method, url, handler) {
this.routes.push({method: method,
url: url,
handler: handler});
};

Router.prototype.resolve = function(request, response) {
var path = require("url").parse(request.url).pathname;

return this.routes.some(function(route) {
var match = route.url.exec(path);
if (!match || route.method != request.method)
return false;

var urlParts = match.slice(1).map(decodeURIComponent);
route.handler.apply(null, [request, response]
.concat(urlParts));
return true;
});
};


Модуль експортує конструктор Router. Об'єкт router дозволяє реєструвати нові обробники з методом add, і розподіляти запити методом resolve.

Останній поверне булевское значення, що показує, був знайдений обробник. Метод some масиву шляхів буде пробувати їх по черзі (в порядку, в якому вони були задані), і зупиниться з поверненням true, якщо шлях знайдено.

Функції обробників викликаються з об'єктами запиту і відповіді. Коли регулярка, перевіряє URL, повертає групи, що представляють їх рядка передаються в обробник в якості додаткових аргументів. Ці рядки треба декодувати з URL-стилю %20.

Видача файлів

Коли тип запиту не збігається ні з одним з типів, які обробляє роутер, сервер повинен інтерпретувати його як запит файлу із загальної директорії. Можна було б використовувати файловий сервер з глави 20 для видачі цих файлів, але нам не потрібна підтримка PUT і DELETE, зате нам потрібні додаткові функції типу підтримки кешування. Тому, давайте використовувати перевірений і протестований файловий сервер з NPM.

Я вибрав ecstatic. Це не єдиний сервер на NPM, але він добре працює і задовольняє нашим вимогам. Модуль ecstatic експортує функцію, яку можна викликати з об'єктом конфігурації, щоб вона видала функцію обробника. Ми використовуємо опцію root, щоб повідомити сервера, де потрібно шукати файли. Обробник приймає параметри запиту і відповіді, і його можна передати безпосередньо в createServer, щоб створити сервер, який віддає тільки файли. Але спочатку нам потрібно перевірити ті запити, які ми обробляємо особливо — тому ми обертаємо його в ще одну функцію.

var http = require("http");
var Router = require("./router");
var ecstatic = require("ecstatic");

var fileServer = ecstatic({root: "./public"});
var router = new Router();

http.createServer(function(request, response) {
if (!router.resolve(request, response))
fileServer(request, response);
}).listen(8000);

Функції respond і respondJSON використовуються в коді сервера, щоб можна було відправляти відповіді одним викликом функції.

function respond(response, status, data, type) {
response.writeHead(status, {
"Content-Type": type || "text/plain"
});
response.end(data);
}

function respondJSON(response, status, data) {
respond(response, status, JSON.stringify(data),
"application/json");
}


Теми як ресурси

Сервер зберігає запропоновані теми в об'єкті talks, у якого іменами властивостей є назви тем. Вони будуть виглядати як ресурси за адресою HTTP /talks/[title], тому нам потрібно додати в роутер обробників, що реалізують різні методи, які клієнти можуть використовувати для роботи з ними.

Обробник запитів GET однієї теми повинен знайти її і повернути дані в JSON, або видати помилку 404.

var talks = Object.create(null);

router.add("GET", /^\/talks\/([^\/]+)$/,
function(request, response, title) {
if (title in talks)
respondJSON(response, 200, talks[title]);
else
respond(response, 404, "No talk '" + title + "' found");
});

Видалення теми робиться вилученням з об'єкта talks.

router.add("DELETE", /^\/talks\/([^\/]+)$/,
function(request, response, title) {
if (title in talks) {
delete talks[title];
registerChange(title);
}
respond(response, 204, null);
});


Функція registerChange, яку ми визначимо пізніше, повідомляє довгі запити про зміни.

Щоб було просто отримувати контент тел запитів, закодованих за допомогою JSON, ми визначаємо функцію readStreamAsJSON, яка читає весь вміст потоку, розбирає його за правилами JSON і потім робить зворотний виклик.

function readStreamAsJSON(stream, callback) {
var data = "";
stream.on("data", function(chunk) {
data += chunk;
});
stream.on("end", function() {
var result, error;
try { result = JSON.parse(data); }
catch (e) { error = e; }
callback(error, result);
});
stream.on("error", function(error) {
callback(error);
});
}


Один з обробників, якому потрібно читати відповіді в JSON — це оброблювач PUT, який використовується для створення нових тим. Він повинен перевірити, чи є у даних властивості presenter і summary, які повинні бути рядками. Дані, що приходять ззовні, можуть виявитися сміттям, і ми не хочемо, щоб з-за поганого запиту була зламана наша система.

Якщо дані виглядають задовільно, обробник зберігає об'єкт, що представляє нову тему, в об'єкті talks, при цьому, можливо, перезаписуючи існуючу тему з таким же заголовком, і знову викликає registerChange.

router.add("PUT", /^\/talks\/([^\/]+)$/,
function(request, response, title) {
readStreamAsJSON(request, function(error, talk) {
if (error) {
respond(response, 400, error.toString());
} else if (!talk ||
typeof talk.presenter != "string" ||
typeof talk.summary != "string") {
respond(response, 400, "Bad talk data");
} else {
talks[title] = {title: title,
presenter: talk.presenter,
summary: talk.summary,
comments: []};
registerChange(title);
respond(response, 204, null);
}
});
});


Додавання коментаря до теми працює подібним чином. Ми використовуємо readStreamAsJSON для отримання вмісту повідомлення, перевіряємо результуючі дані і зберігаємо їх як коментар, якщо вони прийнятні.

router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
function(request, response, title) {
readStreamAsJSON(request, function(error, comment) {
if (error) {
respond(response, 400, error.toString());
} else if (!comment ||
typeof comment.author != "string" ||
typeof comment.message != "string") {
respond(response, 400, "Bad comment data");
} else if (title in talks) {
talks[title].comments.push(comment);
registerChange(title);
respond(response, 204, null);
} else {
respond(response, 404, "No talk '" + title + "' found");
}
});
});


Спроба додати коментар до неіснуючої теми повинна повертати помилку 404.

Підтримка довгих запитів

Найцікавіший аспект сервера — частина, яка підтримує довгі запити. Коли на адресу /talks надходить запит GET, це може бути простий запит усіх тем, або запит на оновлення з параметром changesSince.

Є багато різних ситуацій, в яких нам потрібно відправити клієнту список тем, тому ми спочатку визначимо допоміжну функцію, присоединяющую поле serverTime до таких відповідей.

function sendTalks(talks, response) {
respondJSON(response, 200, {
serverTime: Date.now(),
talks: talks
});
}


Обробник повинен подивитися на всі параметри запиту в URL, щоб перевірити, чи не заданий параметр changesSince. Якщо дати функції parse модуля «url» другий аргумент значення true, він також распарсит другу частину URL — query, частина запиту. У повернутого об'єкта буде властивість query, в якому буде ще один об'єкт, з іменами і значеннями параметрів.

router.add("GET", /^\/talks$/, function(request, response) {
var query = require("url").parse(request.url, true).query;
if (query.changesSince == null) {
var list = [];
for (var title in talks)
list.push(talks[title]);
sendTalks(list, response);
} else {
var since = Number(query.changesSince);
if (isNaN(since)) {
respond(response, 400, "Invalid parameter");
} else {
var changed = getChangedTalks(since);
if (changed.length > 0)
sendTalks(changed, response);
else
waitForChanges(since, response);
}
}
});


При відсутності параметра changesSince обробник просто будує список всіх тем і повертає його.

Інакше, спершу треба перевірити параметр changeSince на предмет того, що це число. Функція getChangedTalks, яку ми незабаром визначимо, повертає масив змінених тим з якогось заданого часу. Якщо вона повертає порожній масив, то серверу нічого повертати клієнту, так що він зберігає об'єкт response (за допомогою waitForChanges), щоб відповісти пізніше.

var waiting = [];

function waitForChanges(since, response) {
var waiter = {since: since, response: response};
waiting.push(waiter);
setTimeout(function() {
var found = waiting.indexOf(waiter);
if (found > -1) {
waiting.splice(found, 1);
sendTalks([], response);
}
}, 90 * 1000);
}


Метод splice використовується для вирізування шматка масиву. Йому задається індекс і кількість елементів, і він змінює масив, видаляючи це кількість елементів після зазначеного індексу. У цьому випадку ми видаляємо один елемент — об'єкт, який чекає на відповідь, чий індекс ми дізналися через indexOf. Якщо ви передасте додаткові аргументи на splice, їх значення будуть вставлені в масив на заданій позиції, і замістити видалені елементи.

Коли об'єкт response збережений у масиві waiting, задається таймаут. Після 90 секунд він перевіряє, чи чекає ще запит, і якщо так — відправляє порожній відповідь і видаляє його з масиву waiting.

Щоб знайти саме ті теми, які змінилися після заданого часу, нам треба відслідковувати історію змін. Реєстрація зміни за допомогою registerChange запам'ятає це зміна, разом з поточним часом, у масиві changes. Коли відбувається зміна, це значить — є нові дані, тому всім чекають запитами можна негайно відповісти.

var changes = [];

function registerChange(title) {
changes.push({title: title, time: Date.now()});
waiting.forEach(function(waiter) {
sendTalks(getChangedTalks(waiter.since), waiter.response);
});
waiting = [];
}


Нарешті, getChangedTalks використовує масив changes, щоб побудувати масив нових тем, включаючи об'єкти з властивістю deleted для тих, яких вже не існує. При побудові масиву getChangedTalks повинна переконатися, що одна і та ж тема не включається двічі, так як тема могла змінитися кілька разів з заданого моменту часу.

function getChangedTalks(since) {
var found = [];
function alreadySeen(title) {
return found.some(function(f) {return f.title == title;});
}
for (var i = changes.length - 1; i >= 0; i--) {
var change = changes[i];
if (change.time <= since)
break;
else if (alreadySeen(change.title))
continue;
else if (change.title in talks)
found.push(talks[change.title]);
else
found.push({title: change.title, deleted: true});
}
return found;
}


Ось і все з кодом сервера. Запуск написаного коду дасть вам сервер, працюючий на порту 8000, який видає файли з публічної піддиректорії і управляє інтерфейсом тим за адресою /talks.

Клієнт

Клієнтська частина веб-сайту з управління темами складається з трьох файлів: HTML-сторінка, таблиця стилів і файл JavaScript.

HTML

Сервери за загальноприйнятою схемою у разі запиту шляху, відповідного директорії, віддають файл під ім'ям index.html з цієї директорії. Модуль файлового сервера ecstatic підтримує цю угоду. При запиті шляху / сервер шукає файл ./public/index.html (де ./public — це коренева директорія) і повертає його, якщо він там є.

Значить, якщо треба показати сторінку, коли браузер буде запитувати наш сервер, її треба покласти в public/index.html. Ось початок файлу index:

<!doctype html>

<title>Обмін досвідом</title>
<link rel="stylesheet" href="skillsharing.css">

<h1>Обмін досвідом</h1>

<p>Ваше ім'я: <input type="text" id="name"></p>

<div id="talks"></div>


Визначається заголовок і включається таблиця стилів, де визначаються стилі — в числі іншого, рамочка навколо тем. Потім доданий заголовок і поле name. Користувач повинен вписати своє ім'я, щоб воно було приєднане до його темами і коментарям.

Елемент
<div>
з ID «talks» буде містити список тем. Скрипт заповнює список, коли він отримує його з сервера.

Потім йде форма для створення нової теми.

<form id="newtalk">
<h3>Submit a talk</h3>
Заголовок: <input type="text" style="width: 40em" name="title">
<br>
Summary: <input type="text" style="width: 40em" name="summary">
<button type="submit"> &Надіслати lt;/button>
</form>


Скрипт додасть обробник події «submit» у форму, з якого він зможе зробити HTTP-запит, повідомляє серверу про тему.

Потім йде загадковий блок, у якого стиль display встановлений в none, і який тому не відображатися на сторінці. Здогадаєтеся, навіщо він потрібен?

<div id="template" style="display: none">
<div class="talk">
<h2>{{title}}</h2>
<div>by <span class="name">{{presenter}}</span></div>
<p>{{summary}}</p>
<div class="comments"></div>
<form>
<input type="text" name="comment">
<button type="submit">Додати коментар</button>
<button type="button" class="del">Видалити тему</button>
</form>
</div>
<div class="comment">
<span class="name">{{author}}</span>: {{message}}
</div>
</div>


Створення складних структур DOM через JavaScript призводить до потворного кодом. Можна зробити його красивіше за допомогою допоміжних функцій типу elt з глави 13, але результат все одно буде виглядати гірше, ніж HTML, який в якомусь сенсі є мовою для побудови DOM-структур.

Для створення DOM-структур для тих обговорень, наша програма визначить просту систему шаблонів, яка використовує приховані структури, що включаються в документ, для створення нових структур — замінюючи мітки у файлі між двома фігурними лапками на значення для конкретної теми.

І нарешті, HTML включає файл скрипта, що містить клієнтський код.

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


Запуск

Перше, що клієнт повинен зробити при завантаженні сторінки, це запросити з сервера поточний набір тем. Так як ми будемо робити багато HTTP-запитів, ми визначимо невелику обгортку навколо XMLHttpRequest, яка прийме об'єкт для налаштування запиту зворотного дзвінка по закінченню запиту.

function request(options, callback) {
var req = new XMLHttpRequest();
req.open(options.method || "GET", options.pathname, true);
req.addEventListener. ("load", function() {
if (req.status < 400)
callback(null, req.responseText);
else
callback(new Error("Request failed: " + req.statusText));
});
req.addEventListener. ("error", function() {
callback(new Error("Network error"));
});
req.send(options.body || null);
}


Початковий запит показує отримані теми на екрані і починає процес довгих запитів, викликаючи waitForChanges.

var lastServerTime = 0;

request({pathname: "talks"}, function(error, response) {
if (error) {
reportError(error);
} else {
response = JSON.parse(response);
displayTalks(response.talks);
lastServerTime = response.serverTime;
waitForChanges();
}
});


Перменная lastServerTime використовується для відстеження часу останнього оновлення, отриманого з сервера. Після початкового запиту, вид тим у клієнта відповідає виду тим сервера, що був у нього у момент запиту. Таким чином, властивість serverTime, що включається у відповідь, надає правильне початкове значення lastServerTime.

Коли запит не вдався, нам не треба, щоб сторінка просто сиділа і нічого не робила. Ми визначимо просту функцію під назвою reportError, яка хоча б покаже користувачеві діалог, який повідомляє про помилку.

function reportError(error) {
if (error)
alert(error.toString());
}


Функція перевіряє, чи є помилка, і виводить повідомлення тільки при її наявності. Таким чином, ми можемо безпосередньо передавати цю функцію в запит для тих запитів, відповідь на які можна ігнорувати. Тоді якщо запит завершиться з помилкою, то про помилку буде повідомлено користувачеві.

Показ тим

Щоб мати можливість оновлювати список тем при надходженні змін, клієнт повинен відслідковувати теми, які він показує зараз. Тоді, якщо надходить нова версія теми, яка вже є на екрані, її можна замінити прямо на місці оновленою версією. Подібним чином, коли надходить інформація про видалення теми, потрібний елемент DOM можна видалити з документа.

Функція displayTalks використовується як для побудови початкового екрану, так і для його відновлення при змінах. Вона буде використовувати об'єкт shownTalks, що зв'язує заголовки тем з вузлами DOM, щоб запам'ятати теми, які вже є на екрані.

var talkDiv = document.querySelector("#talks");
var shownTalks = Object.create(null);

function displayTalks(talks) {
talks.forEach(function(talk) {
var shown = shownTalks[talk.title];
if (talk.deleted) {
if (shown) {
talkDiv.removeChild(shown);
delete shownTalks[talk.title];
}
} else {
var node = drawTalk(talk);
if (shown)
talkDiv.replaceChild(node, shown);
else
talkDiv.appendChild(node);
shownTalks[talk.title] = node;
}
});
}


Структура DOM для будується за шаблоном, включеному в HTML документ. Спочатку потрібно визначити instantiateTemplate, який знаходить і заповнює шаблон.

Параметр name — ім'я шаблону. Щоб знайти елемент шаблону, ми шукаємо елементи, у яких ім'я класу збігається з ім'ям шаблону, який є дочірнім у елемент з ID «template». Метод querySelector полегшує цей процес. На сторінці є шаблони «talk» і «comment».

function instantiateTemplate(name, values) {
function instantiateText(text) {
return text.replace(/\{\{(\w+)\}\}/g, function(_, name) {
return values[name];
});
}
function instantiate(node) {
if (node.nodeType == document.ELEMENT_NODE) {
var copy = node.cloneNode();
for (var i = 0; i < node.childNodes.length; i++)
copy.appendChild(instantiate(node.childNodes[i]));
return copy;
} else if (node.nodeType == document.TEXT_NODE) {
return document.createTextNode(
instantiateText(node.nodeValue));
}
}

var template = document.querySelector("#template ." + name);
return instantiate(template);
}


Метод cloneNode, який є у всіх вузлів DOM, створює копію сайту. Він не скопіює дочірні вузли, якщо не передати йому першим аргументом true. Функція instantiate рекурсивно створює копію шаблону, заповнюючи його по ходу справи.

Другий аргумент instantiateTemplate повинен бути об'єктом, чиї властивості містять рядки, які треба ввести в шаблон. Мітка начебто {{title}} буде замінена значенням властивості «title».

Цей підхід до шаблонів досить грубий, але для створення drawTalk його буде достатньо.

function drawTalk(talk) {
var node = instantiateTemplate("talk", talk);
var comments = node.querySelector(".comments");
talk.comments.forEach(function(comment) {
comments.appendChild(
instantiateTemplate("comment", comment));
});

node.querySelector("button.del").addEventListener. (
"click", deleteTalk.bind(null, talk.title));

var form = node.querySelector("form");
form.addEventListener. ("submit", function(event) {
event.preventDefault();
addComment(talk.title, form.elements.comment.value);
form.reset();
});
return node;
}


Після завершення обробки шаблону «talk» потрібно багато чого підлатати. По-перше, потрібно вивести коментарі, шляхом багаторазового додавання шаблону «comment» і додавання результатів до вузла класу «comments». Потім, обробники подій потрібно приєднати до кнопки, яка видаляє завдання і форми, додає коментар.

Оновлення сервера

Обробники подій, зареєстровані в drawTalk, викликають функції deleteTalk і addComment безпосередньо для дій, необхідних для видалення теми або додавання коментаря. Це буде потрібно для побудови URL, які посилаються на теми з заданим ім'ям, для яких ми визначаємо допоміжну функцію talkURL.

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


Функція deleteTalk запускає запит DELETE і повідомляє про помилку, у разі невдачі.

function deleteTalk(title) {
request({pathname: talkURL(title), method: "DELETE"},
reportError);
}


Для додавання коментаря потрібно побудувати його подання у форматі JSON і відправити його як частину POST-запиту.

function addComment(title, comment) {
var comment = {author: nameField.value, message: comment};
request({pathname: talkURL(title) + "/comments",
body: JSON.stringify(comment),
method: "POST"},
reportError);
}


Змінна nameField, використовувана для установки властивості коментаря author, посилається на полі
<input>
вгорі сторінки, яке дозволяє користувачеві задати його ім'я. Ми також підключаємо це поле до localStorage, щоб його не доводилося заповнювати кожен раз при перезавантаженні сторінки.

var nameField = document.querySelector("#name");

nameField.value = localStorage.getItem("name") || "";

nameField.addEventListener. ("change", function() {
localStorage.setItem("name", nameField.value);
});

Форма внизу сторінки для створення нової теми отримує обробник подій "submit". Цей обробник забороняє дію за замовчуванням (що привело б до перезавантаження сторінки), очищає форму і запускає PUT-запит для створення теми.

var talkForm = document.querySelector("#newtalk");

talkForm.addEventListener. ("submit", function(event) {
event.preventDefault();
request({pathname: talkURL(talkForm.elements.title.value),
method: "PUT",
body: JSON.stringify({
presenter: nameField.value,
summary: talkForm.elements.summary.value
})}, reportError);
talkForm.reset();
});


Виявлення змін

Хочу зазначити, що різні функції, змінюють стан додатки, створюючи або видаляючи теми, або додаючи до них коментарі, абсолютно не піклуються про те, щоб їх діяльність була видна на екрані. Вони просто говорять щось сервера і сподіваються на механізм довгих запитів, який повинен викликати відповідні зміни.

Враховуючи створену на сервері систему і те, як ми визначили displayTalks для обробки змін тим, які вже є на сторінці, сам механізм довгих запитів виявляється несподівано простим.

function waitForChanges() {
request({pathname: "talks?changesSince=" + lastServerTime},
function(error, response) {
if (error) {
setTimeout(waitForChanges, 2500);
console.помилка(error.stack);
} else {
response = JSON.parse(response);
displayTalks(response.talks);
lastServerTime = response.serverTime;
waitForChanges();
}
});
}


Ця функція викликається одного разу, коли програма запускається, і потім продовжує викликати себе, щоб переконатися, що запити завжди працюють. Коли запит не вдається, ми не викликаємо reportError, щоб не дратувати користувача спливаючим вікном кожен раз при проблемі з'єднання з сервером. Замість цього виводиться помилка в консоль (для полегшення налагодження), і робиться наступна спроба через 2.5 секунди.

Коли запит вдається, на екран виводяться нові дані, і lastServerTime оновлюється, щоб відображати той факт, що ми отримали дані у відповідності з цим новим моментом часу. Запит одразу стартує заново, і чекає наступного оновлення.

Якщо ви запустіть сервер, і відкриєте два вікна браузера з адресою localhost:8000, ви побачите, що дії, виконувані вами в одному вікні, моментально відображаються в іншому.

Вправи

Наступні вправи полягають в зміні системи, описаної в цій главі. Для роботи над ними, переконайтеся, що ви завантажили код і встановили Node.js.

Збереження стану на диск
Сервер тримає всі дані в пам'яті. Якщо він впаде або перезапуститься, всі теми і коментарі будуть втрачені.

Розширте його функціональність з тим, щоб він зберігав дані на диску автоматично завантажував їх при перезавантаженні. Не хвилюйтеся щодо ефективності, зробіть найпростіший варіант.

Обнулення полів коментарів
Загальна оновлення всіх тем працює непогано, тому що немає відмінності між вузлом DOM і його заміною, коли вони однакові. Але є винятки. Якщо ви почнете друкувати що-небудь у полі коментаря до теми в одному вікні браузера, а потім в іншому вікні додасте коментар до цієї теми, поле у першому вікні буде перемальованою, і буде втрачено і його вміст, і фокус.

При гарячому обговоренні, коли кілька осіб додають коментарі до однієї теми, це дуже дратувало. Ви Можете придумати, як уникнути цього?

Поліпшені шаблони
Більшість шаблонизаторов роблять більше, ніж просто заповнюють шаблони рядками. Щонайменше вони дозволяють додавати шаблони умови, аналогічно оператору if, і повторення частин шаблону, аналогічно циклів.

Якщо б ми могли повторювати шматок шаблону для кожного елемента масиву, другий шаблон («comment») був би нам не потрібен. Ми могли просто сказати шаблоном «talk», щоб він повторювався для масиву, що міститься у властивості comments, і створював би вузли, які є коментарями, для кожного елемента масиву.

Це могло б виглядати так:

<div class="comments">
<div class="comment" template-repeat="comments">
<span class="name">{{author}}</span>: {{message}}
</div>
</div>


Ідея в наступному: коли при обробці шаблону зустрічається атрибут template-repeat, що повторює шаблон, код проходить циклом по масиву, що міститься у властивості, названому так само, як цей атрибут. Контекст шаблона (змінна values в instantiateTemplate) при роботі циклу показувала б на поточний елемент масиву так, щоб мітку {{author}} шукали б в об'єкті comment, а не в темі.

Перепишіть instantiateTemplate так, щоб вона це вміла, і потім поміняйте шаблони, щоб вони використали цю можливість, і приберіть зайві рядки для створення коментарів функції drawTalk.

Як би ви організували умовне створення сайтів, щоб можна було опускати частини шаблону, якщо певне значення true або false?

А хто без скрипту?
Якщо хто-небудь зайде на наш сайт з відключеним JavaScript, вони отримають зламану непрацюючу сторінку. Це не дуже-то добре.

Деякі різновиди веб-додатків не вийде зробити без JavaScript. Для інших не вистачає фінансування або терпіння, щоб піклуватися про відвідувачів без скриптів. Але для відвідуваних сторінок вважається ввічливим підтримати таких користувачів.

Спробуйте придумати спосіб, яким би веб-сайт обміну досвідом можна було б зробити працюють без JavaScript. Доведеться ввести автоматичні оновлення сторінок, а перезавантажувати сторінки користувачам доведеться по-старому. Але було б непогано вміти переглядати теми, створювати нові і відправляти коментарі.

Не змушую вас його реалізовувати. Досить описати можливе рішення. Здається вам такий варіант сайту більш або менш елегантним, ніж той, що ми вже зробили?

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.