Глава 21
Проект: Skill-Sharing Website

Skill-sharing срещите е събитие, където хората с общи интереси се събират и споделят малки, неформални презентации за неща, които те знаят. На skill-sharing срещите за градинарство, някой може да обясни как се култивира целина. Или в програмно-ориентирана skill-sharing група, вие може да се отбиете и да кажете на всички за Node.js.

Такива срещи също често се наричат групи от потребители, когато те са за компютри и са чудесен начин за разширяване на вашия хоризонт, да научите за нови разработки или само да се срещнете с хора със сходни интереси. Много от големите градове имат JavaScript срещи. Те обикновено са свободни за посетители, аз открих една и когато и гостувах, бяха приятелски настроени и приветливи.

В тази последна глава - проект, нашата цел е да се създаде уеб сайт за управление на разговорите по-време на дадена skill-sharing среща. Представете си една малка група от хора, които се срещат редовно в офиса на някой член, за да говорят за unicycling. Проблемът е, че когато предишният организатор на срещите се премести в друг град, никой не иска да поеме тази задача. Искаме система, която да позволи на участниците да продължат разговорите си без централния организатор.

The unicycling meetup

Точно както в предишната глава, кодът в тази глава е написан за Node.js и използван директно в HTML страницата, която разглеждате най - вероятно няма да работи. Пълният код на проекта може да бъде изтеглен от eloquentjavascript.net/code/skillsharing.zip.

Дизайн

Има една сървър част на този проект написана за Node.js и една клиентска част написана за браузъра. Сървърът съхранява данни за системата и ги предоставя на клиента. Той също така обслужва HTML и JavaScript файловете, които се прилагат от страна на системата на клиента.

Сървърът поддържа списък на темите, предложени за следващата среща и клиента показва този списък. Всяка тема има име на водещ, заглавие, резюме, както и списък с коментари свързани с нея. Клиентът дава възможност на потребителя да предложи нови теми (да ги добави към списъка), да изтрива теми и коментари към съществуващи теми. Всеки път, когато потребителя прави такава промяна, клиентът прави HTTP заявка, за да каже на сървъра за това.

Screenshot of the skill-sharing website

Приложението се създава за да покаже на живо предложенията за текущите теми и техните коментари. Всеки път, когато някой, някъде представя нова тема или добавя коментар, всички хора, които имат страницата отворена на своя браузър трябва незабавно да видят промяната. Това създава малко предизвикателство, тъй като няма начин за един уеб сървър да отвори връзка с клиента, нито пък има добър начин да знае кои клиенти в момента разглеждат даден уеб сайт.

Едно общо решение на този проблем се нарича long polling, което е един от мотивите за проектиране на Node.

Long polling

За да бъдем в състояние незабавно да уведомим клиента, че нещо се променя, ни трябва връзка към този клиент. Понеже браузърите обичайно не приемат връзки и клиенти, които обикновено са зад устройства и които ще блокират такива връзки така или иначе, започването на тази връзка от сървъра не е практично.

Ние можем да организираме за клиента да отвори връзката и да я държи наоколо, така че сървърт да може да я използва, за да изпрати информация, когато трябва да го направите.

Но заявката за HTTP позволява само прост поток от информация, където клиента изпраща заявка, а сървърът връща един отговор и толкова. Има технология, наречена web sockets, поддържана от съвременните браузъри, която прави възможно отварянето на връзка за произволен обмен на данни. Но правилното и използване е малко по сложно.

В тази глава ще използваме сравнително проста техника - long polling, където клиентите непрекъснато питат сървъра за нова информация посредством редовни HTTP заявки и сървъра просто излага своя отговор, когато има нещо ново да се докладва.

Стига клиента да се грижи постоянно да има отворени заявки към long polling, той веднага ще получи информация от сървъра. Например ако Алис има skill-sharing приложение отворено на нейния браузър, този браузър ще направи искане за актуализации и ще чака отговор за това искане. Когато Боб представи тема на Extreme Downhill Unicycling, сървърът ще забележи, че Алис е в очакване на актуализации и ще изпрати информацията за новата тема в отговор на нейното искане, което е на изчакване. Браузърът на Алис ще получи данните и ще актуализира екрана, за да покаже темата.

За да се предотврати затваряне на връзката поради изтичане на времето (прекратяване поради липса на дейност), техниката на long polling обикновено определя максимално време за всяко искане, след което сървърът ще отговори така или иначе въпреки, че няма, какво да докладва, и клиента ще започне нова заявка. Периодичното рестартиране на искането, също така прави техниката по-стабилна, което позволява на клиента да се възстанови от предходни неуспехи на връзката или проблеми със сървъра.

Зает сървър, който използва long polling може да има хиляди искания на изчакване и следователно отворени TCP връзки. Node, който прави лесно управлението на много връзки без да създава отделни нишки на контрол за всяка, е подходящ за такава система.

HTTP интерфейс

Преди да започнем с уточняване на съдържанието на сървъра или клиента, нека помислим за точката където те се допират: това е HTTP интерфейса, чрез който общуват.

Ние ще базираме интерфейса на JSON, както файловия сървър от Chapter 20 и ще се опитаме да направим добра употреба на HTTP методи. Интерфейса е центриран около /talks пътя. Пътища, които не започват с /talks ще бъдат използвани за обслужване на статични файлове на HTML и JavaScript кода, който се реализира от страна на системата на клиента.

GET заявката до /talks връща JSON документ, като този:

{"serverTime": 1405438911833,
 "talks": [{"title": "Unituning",
            "presenter": "Carlos",
            "summary": "Modifying your cycle for extra style",
            "comment": []}]}

Полето serverTime ще използваме за направи long polling възможно. Ще се върнем към това по-късно.

Създаването на нова тема се извършва чрез вземане на PUT заявка към URL, както /talks/Unituning, където частта след втората черта е заглавието на темата. Тялото на PUT искането следва да съдържа JSON обект, който има presenter и summary свойства.

Понеже заглавията на темите могат да съдържат интервали и други характери, които не могат да се появяват обикновено в URL, string на заглавието трябва да бъде кодирано от encodeURIComponent функцията, когато изграждаме такъв URL.

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": "Dana",
 "summary": "Standing still on a unicycle"}

Такива 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?"}

За поддръжка на long polling, GET заявките за /talks могат да включват параметър на заявката наречен changesSince, който се използва за да покаже, че клиента се интересува от актуализации, което се случва в даден момент от време. Когато има такива промени те ще бъдат незабавно върнати. Когато няма, отговора се бави докато нещо се случи или докато определен период от време не изтече (ние ще използваме 90 секунди).

Времето трябва да бъде посочено, като брой на милисекунди изминали от началото на 1970г. със същия номер, който се връща от Date.now(). За да се гарантира, че се получават всички актуализации и не получава една и съща актуализация повече от веднъж, клиента трябва да премине момента, в който последно е получил информация от сървъра. Часовника на сървъра може да не е точно в синхрон с часовника на клиента, но дори и да беше, пак щеше да е невъзможно за клиента да знае точното време, в което сървърът е изпратил отговор, защото прехвърлянето на данни през мрежата отнема време.

Това е причината за съществуването на serverTime свойството в отговорите изпратени към GET заявките на /talks. Това свойство казва на клиента точното време от гледна точка на сървъра, на който са създадени данните, които получава. Клиента може да съхрани това време и да го подаде в следващата заявка на long polling, за да се увери, че получава точно актуализациите, които не е виждал преди.

GET /talks?changesSince=1405438911833 HTTP/1.1

(time passes)

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

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

Когато една тема е променена, тя е новосъздадена или има добавен коментар, пълното представяне на темата е включено в отговора на следващото искане на long polling на клиента. Когато темата се заличава са включени само заглавието и свойството deleted. Клиента може после да добави теми със заглавия, които не е виждал преди на екрана, но актуализацията казва, че са били показани и премахва тези, които са били изтрити.

Протокола описан в тази глава не прави никакъв контрол на достъпа. Всеки може да коментира, модифицира теми и дори да ги изтрива. Тъй като Интернет е пълен с хулигани, извеждайки такава система онлайн без допълнителна защита, най-вероятно ще завърши с катастрофа.

Просто решение би било да се сложи системата зад reverse proxy, което е сървър за HTTP, който приема връзки от страни извън системата и препраща на HTTP сървъри, които се изпълняват локално. Такъв помощник може да бъде конфигуриран да изисква потребителско име и парола и може да се погрижите само участниците в skill-sharing групата да получат тази парола.

Сървърът

Нека започнем с писането на страната на сървърната част от програмата. Кодът в този раздел се изпълнява на Node.js.

Routing

Нашият сървър ще използва http.createServer за начало на HTTP сървъра. Във функцията, която се занимава с нова заявка, ще трябва да се прави разлика между различните видове заявки (определени с метода и пътя), които поддържаме. Това може да стане с дълга верига от if изявления, но има и по-добър начин.

Router е компонент, който помага да се изпрати искане до функцията, която може да се справи с това. Можем да кажем на рутера, например, че PUT заявки с пътя, който съвпада с този регулярен израз /^\/talks\/([^\/]+)$/ (което съвпада с /talks/ последвано от заглавието на темата) може да се обработва от дадената функция. В допълнение, тя може да помогне за извличане на смислени части от пътя, в този случай заглавието на темата, увито в скоби на регулярен израз и подадено към функция манипулатор.

Има редица пакети на добри рутери за NPM, но тука ще напишем един за да илюстрираме принципа.

Това е router.js, който по-късно ще се изисква от нашия сървър модул.

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 конструктор. Обекта на рутера позволява да бъдат регистрирани нови манипулатори с add метода и могат да разрешат искания със своя resolve метод.

Последното ще върне Булева стойност, която показва дали е намерен манипулатор. В some метода на масива на рутера, ще пробва рутерите един по един (в реда, в който са дефинирани) и ще спре връщайки true, ако е намерил съвпадение с един.

Функцията на манипулатора се извиква с request и response обекти. Когато регулярния израз съвпадне с URL адреса, който съдържа всички групи, съответстващите srings се подават на манипулатора, като допълнителни аргументи. Тези srings трябва да бъдат декодирани URL, тъй като суровия URL съдържа %20 - стил на кода.

Сервиране на файлове

Когато искането не съвпадне с нито една от видовете заявки, определени в нашия рутер, сървърът трябва да го тълкува, като искане за даден файл в public директорията. Щеше да е възможно използването на файловия сървър дефиниран в Глава 20 за сервиране на такива файлове, но ние нито имаме нужда нито искаме поддръжка на PUT и DELETE искания на файловете, а бихме желали да имаме допълнителни функции, като поддръжка на кеширане. Така че, вместо това, нека използваме солиден, добре изпитан статичен файлов сървър от NPM.

Аз съм избрал ecstatic. Той не е единственият такъв сървър на NPM, но работи добре и пасва на нашите цели. Модула на ecstatic изнася функция, която може да се извика с конфигуриращия обект за производство на искания за манипулатора на функцията. Ние използваме root опцията да кажем на сървъра, къде да търси за файлове. Функцията манипулатор приема request и response параметри и може да се подава директно на 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, която ще определим по-късно, не е уведомена да чака искания на long polling за промяна.

За да извлече съдържанието на JSON - кодирана заявка на тялото, ние определяме функция наречена readStreamAsJSON и след това извикваме функцията за обратно извикване.

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 свойства, които са strings. Всякакви данни идващи от вън на системата, може да са глупости, а ние не искаме да покваряваме нашия вътрешен модел с данни или дори да се срине, когато лошите искания идват отново.

Ако данните изглеждат валидни, манипулатора съхранява един обект, който представлява нова тема в 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 грешка, разбира се.

Поддръжка на long polling

Най-интересният аспект на сървърната част е, когато се занимава с long polling. Когато едно GET искане идва за /talks, то може да бъде, както обикновено искане за всички теми или искане за актуализации с changesSince параметър.

Ще има различни ситуации, в които трябва да се изпрати списък с теми на клиента, така че ние първо дефинираме малка помощна функция, която подава на serverTime полето такива отговори.

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

Самият манипулатор трябва да погледне в параметрите на заявката в URL искането, за да види дали changesSince е даден, като параметър. Ако дадем на "url" модула parse функция с втори аргумент true, тя също ще направи разбор на query частта на URL. Обекта, който се връща ще има 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, дефинирана на скоро, връща масив от променени теми в даден момент от време. Ако тя връща празен масив, сървърът все още няма нищо, което да изпрати обратно на клиента, така че той съхранява обекта на отговора (използвайки 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, неговите стойности ще бъдат вмъкнати в масива на дадена позиция заменяйки махнатите елементи.

Когато един обект на отговора се съхранява в 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, който обслужва файлове от public поддиректорията заедно с управляващ интерфейс за темите в /talks URL адреса.

Клиентът

Страната на клиентската част на уеб-сайта за управление на темите се състои от три файла: HTML страница, списък със стилове и файлове на JavaScript.

HTML

Той е широко използвана конвенция за уеб сървъри, като за опити служи файл с име index.html, когато искането е направено директно в пътя, който съответства на директорията. Модулът за файлов сървър, който използваме ecstatic поддържа тази конвенция. Когато е отправено искане към пътя /, сървърът търси файл на ./public/index.html (./public е основата, която даваме) и връща този файл ако е намерен.

По този начин ако искаме една страница да се появи, когато браузърът е настроен към нашия сървър, ние трябва да я сложим в public/index.html. Ето как започва нашия index файл.

<!doctype html>

<title>Skill Sharing</title>
<link rel="stylesheet" href="skillsharing.css">

<h1>Skill sharing</h1>

<p>Your name: <input type="text" id="name"></p>

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

Той дефинира заглавието на документа и включва списък със стилове, който определя някои стилове наред с други неща, както и добавяне на граница около темите. След това, добавя заглавие и поле за име. От потребителя се очаква да въведе името си в последното, така че да може да бъде прикрепено към темите и коментарите, когато се представят.

<div> елемента с ID "talks" ще съдържа текущия списък с темите. Скрипта запълва списъка, когато получава теми от сървъра.

След това идва под-формата, която се използва за създаване на нова тема.

<form id="newtalk">
  <h3>Submit a talk</h3>
  Title: <input type="text" style="width: 40em" name="title">
  <br>
  Summary: <input type="text" style="width: 40em" name="summary">
  <button type="submit">Send</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">Add comment</button>
      <button type="button" class="del">Delete talk</button>
    </form>
  </div>
  <div class="comment">
    <span class="name">{{author}}</span>: {{message}}
  </div>
</div>

Създаване на сложни DOM структури с JavaScript код, произвежда грозен код. Можем да направим кода малко по-добър чрез въвеждане на помощни функции, като elt функцията от Глава 13, но резултата ще продължи да бъде зле изглеждащ HTML, който може да се мисли за домейн-специфичен език за изразяване на DOM структури.

За да създадем DOM структури за темите в нашата програма ще дефинираме една проста (templating) шаблонна система, която използва скрити структури на 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);
}

Първоначално искането показва темите, които получава на екрана и започва процеса на long polling, като извика 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());
}

Функцията проверява дали има действително грешка и предупреждава само, когато има такава. По този начин можем директно да подадем тази функция на искането към request, където можем да игнорираме отговора. Това гарантира, че ако искането не успее, грешката се съобщава на потребителя.

Показване на теми

За да бъде в състояние да актуализира изгледа на темите, когато промените дойдат, клиента трябва да следи темите, които в момента се показват. По този начин, когато дойде нова версия на тема, която вече е на екрана, тя може да бъде заменена (на място) с актуализираната си форма. По същия начин, когато информацията идва в тази тема тя се заличава и правилният 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" в HTML страницата.

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));
    } else {
      return node;
    }
  }

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

Метода cloneNode, който всички DOM разклонения имат, създава копие на едно разклонение. Той няма да копира децата разклонения на разклонението, освен ако не му е подаден true, като първи аргумент. Функцията instantiate рекурсивно изгражда копие на шаблона и попълва вътре шаблона докато преминава.

Вторият аргумент на instantiateTemplate трябва да бъде обект, чийто свойства съдържат strings, с които трябва да се запълни шаблона. Контейнер, като {{title}} ще бъде заменен със стойността на values на свойството 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();
});

Забелязване на промени

Трябва да отбележа, че различните функции, които променят състоянието на приложението, чрез създаване или изтриване на теми или добавяне на коментари, не правят абсолютно нищо за да гарантират, че извършените промени са видими на екрана. Те просто казват на сървъра и разчитат на механизма на long polling да задейства съответните актуализации на страницата.

Като се има в предвид, че механизмът се изпълнява в нашия сървър и начина, по който е дефиниран displayTalks за справяне с актуализациите на темите, които вече са на страницата, действителното long polling е учудващо просто.

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

Тази функция се извиква веднъж, когато програмата се стартира и след това продължава да се само-извиква за да гарантира, че искането на long polling е винаги активно. Когато искането не успее, ние не извикваме reportError, тъй като ще се появява диалогов прозорец всеки път, когато не успеем да се свържем със сървъра и ще ни безпокои постоянно, когато сървъра е паднал. Вместо това, грешката се изписва на конзолата (за дебъгване), а следващият опит се прави 2,5 секунди по-късно.

Когато искането е успешно, новите данни се поставят върху екрана и lastServerTime се актуализира за да отрази факта, че ние сме получили информация отговаряща на този нов момент във времето. Искането незабавно се рестартира, за да изчака следващата актуализация.

Ако стартирате сървъра и отворите два прозореца на браузъра за localhost:8000/ един до друг, можете са видите, че действията, които изпълнявате на единия прозорец са незабавно видими на другия.

Упражнения

Следните упражнения ще включват изменение на системата дефинирана в тази глава. За да работите върху тях не забравяйте да изтеглите първо кода (eloquentjavascript.net/code/skillsharing.zip) и да инсталирате Node (nodejs.org).

Disk стабилност

Skill-sharing сървъра пази своите данни единствено в паметта. Това означава, че когато забие или се рестартира по някаква причина, всички теми и коментари ще се загубят.

Разширете сървъра така, че да съхранява данните за темите на диска и автоматично да презарежда данните, когато се рестартира. Не се притеснявайте за ефективността, направете най-простото нещо, което работи.

Най-простото решение, с което може да излезете е да кодирате целия обект на talks, като JSON и да го складирате във fs.writeFile. Вече има функция (registerChange), която се извиква всеки път, когато на сървъра данните се променят. Тя може да бъде удължена за да запише новите данни на диска.

Изберете името на файла, например, ./talks.json. Когато сървърт се стартира, той може да се опита да прочете този файл с fs.readFile и ако успее, сървърт може да използва съдържанието на файла, като изходни данни.

Пазете се все пак. Обекта talks започва, като малък обект прототип, така че оператора in да може да се използва нормално. JSON.parse ще върне редовни обекти с Object.prototype, като техен прототип. Ако използвате JSON, като файлов формат, ще трябва да се копират свойствата на обекта върнат от JSON.parse в нов малък обект прототип.

Нулиране на полето на коментарите

Безразборното преначертаване на темите работи доста добре, защото обикновено не можем да направим разлика между едно DOM разклонение и идентичното ми заменяне. Но има изключения. Ако започнем да въвеждаме нещо в полето за коментари на темата в единия прозорец на браузъра и след това в другия прозорец добавим коментар към тази тема, полето в първия прозорец ще бъде преначертано, премахвайки съдържанието и неговия фокус.

В разгорещена дискусия, в която много хора добавят коментари към една тема, това ще бъде досадно. Можете ли да излезете с начин за избягване на това?

Ad hoc подхода е просто да съхранявате състоянието на полето на коментарите на темата (съдържанието му и дали е фокусирано) преди преначертаването на темата и след това да възстановите полето със старото му състояние.

Друго решение би било да не се заменя просто старата DOM структура с нова, а рекурсивно да се сравни разклонение по разклонение и се актуализират само частите, които действително са се променили. Това е много по-трудно за изпълнение, но е по-общо и продължава да работи дори ако добавим още едно текстово поле.

По-добри шаблони

Повечето системи за шаблони правят повече от това просто да попълват някой string. Най-малко, те позволяват условно включване на части от шаблона, аналогично на 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 функцията.

Как бихме добавили условие в инстанцията на разклоненията, което прави възможно пропускането на части от шаблона, когато дадена стойност е вярна или невярна?

Вие можете да промените instantiateTemplate, така че вътрешната му функция да взема не само разклонение, но и текущия контекст, като аргумент. Тогава можете с цикъл над децата-разклонения на разклонението да проверите дали детето има template-repeat атрибут. Ако е така, не ги инстанцирате наведнъж, а с цикъл над масива, посочен от стойноста на атрибута ги инстанцирате веднъж за всеки елемент в масива, подавайки текущия елемент от масива, като контекст.

Условия могат да бъдат приложени по подобен начин с атрибути, наречени template-when и template-unless, които карат дадено разклонение да бъде инстанция само, когато дадено свойство е true (или false).

Uncriptables

Когато някой посети нашия сайт с браузър, който няма разрешение за JavaScript или просто неспособен да изобразява JavaScript, ще получи напълно счупена, не работеща страница. Това не е хубаво.

Някои видове уеб приложения наистина не могат да бъдат направени без JavaScript. Други, просто не разполагат с бюджет и търпение за да се притесняват за клиенти, които не могат да изпълняват скриптове. Но за страници с по-широка аудитория е политика да поддържат scriptless потребители.

Опитайте се да измислите начин уеб-сайта skill-sharing да може да запази основната функционалност, когато се изпълнява без JavaScript. Автоматичните актуализации трябва да изчезнат и потребителите ще трябва да актуализират своята страница по старомодния начин. Но те ще бъдат в състояние да виждат съществуващите теми и да създават нови такива, както и да добавят коментари, което би било хубаво.

Не се чувствайте длъжни да прилагате това в действителност. Очертаване на решението би било достатъчно. Дали ревизирания подход ви се струва повече или по-малко елегантен, отколкото това, което направихме в началото?

Два централни аспекта на подхода, приети от тази глава- чист HTTP интерфейс и клиентски шаблон за редактиране, не работят без JavaScript. Нормални HTML форми могат да изпращатат GET и POST заявки, но не и PUT или DELETE искания и могат да изпращат своите данни само до определен URL.

По този начин, сървърът ще трябва да бъде преразгледан, за да приеме коментари, нови теми и изтриване на теми, чрез POST заявки, чиито тела не са JSON, а по-скоро се използва URL- кодиран формат, който HTML формите използват (виж Глава 17). Тези искания ще трябва да върнат изцяло нова страница, така че потребителите да виждат новото състояние на сайта, след извършени промени. Това не би било твърде трудно да се конструира и приложи едновременно с "чист" HTML интерфейс.

Кодът за рендериране на темите ще трябва да се дублира на сървъра. index.html файла, а не статичен файл, трябва да се генерира динамично, чрез добавяне на манипулатор за това към рутера. По този начин той вече включва текущите теми и коментари, когато получи обслужване.

Относно превода.

Надявам се, тази книга да ви помогне за вашето обучение и утвърждаване, като JavaScript програмисти. Но както знаете, всяко обучение изисква усилия и труд. Същото е и с превода на тази книга, ако искате да стимулирате и други подобни преводи, моля направете дарение по PayPal на to6esko69@gmail.com или на Bitcoin адрес: 13S3EkkNDzAtKCDs8SsdXKcrBQrdyfabBc

Успех.