NodeJS. Part3. Express.js



Код https://github.com/irinainina/node.js/tree/module3

Урок 1. Настройка приложения


Быстрое создание проекта

npm init -y

Мы соглашаемся со всеми предлагаемыми полями по умолчанию

Устанавливаем фреймворк Express.js

npm install express

В файле package.json в поле dependencies появилась запись об установленной версии express

  "dependencies": {
    "express""^4.17.1"
  }

Устанавливаем модуль nodemon для обновления данных на серверу без перезагрузки

npm i -D nodemon

Флаг -D (-dev) означает, что модуль устанавливается только для разработки и в конечный продукт не войдёт. Флаг -S (-save), что модуль будет работать и в самом приложении тоже.
Информация о nodemon прописывается в файле package.json в поле devDependencies

  "devDependencies": {
    "nodemon""^2.0.4"
  }

Создаём файл index.js, подключаем фреймворк Express.js

const express = require('express')

Создаём сервер

const app = express()

Прослушиваем что происходит на сервере

app.listen(3000, () => {
  console.log('Server is running')
})

Можно вынести порт в отдельную переменную

const express = require('express')
const app = express()

const PORT = process.env.PORT || 3000

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`)
})

Урок 2. Работа с HTML-файлами


Создадим файл index.html и подключим его при помощи express.js

const express = require('express')
const path = require('path')

const app = express()

app.get('/', (reqres=> {
  res.status(200)
  res.sendFile(path.join(__dirname, 'views''index.html'))
})

const PORT = process.env.PORT || 3000

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`)
})

Express сам добавляет заголовки странице, сам указывает статус 200 по умолчанию, т.е без строки res.status(200) тоже всё будет работать

Рассмотрим как работает роутинг. Для этого создадим страницу about.html, сделаем в ней ссылку на главную страницу, а на главной - ссылку на страницу about и в index.js напишем код

app.get('/', (reqres=> {
  res.sendFile(path.join(__dirname, 'views''index.html'))
})

app.get('/about', (reqres=> {
  res.sendFile(path.join(__dirname, 'views''about.html'))
})

Всё прекрасно переключается, при этом кода понадобилось совсем немного.

Урок 3. Подключение Handlebars


Статические html-страницы - это не то, чего мы ожидаем от js
Для динамического рендеринга html в node.js могут использоваться разные движки

pug - https://pugjs.org/api/getting-started.html
ejs - https://ejs.co/
handlebars - https://handlebarsjs.com/

Наиболее популярные среди них - pug и handlebars.

pug не предполагает создания html-кода
ejs изначально присутствует в node.js, его не нужно устанавливать, достаточно зарегистрировать
с handlebars познакомимся ближе

Устанавливаем библиотеку express-handlebars в свой проект https://www.npmjs.com/package/express-handlebars

npm i express-handlebars

Подключаем её

const exphbs = require('express-handlebars')

Создаём объект handlebars (hbs)

const hbs = exphbs.create({
  defaultLayout: 'main',
  extname: 'hbs'
})

Здесь указано имя главного файла handlebars - main.hbs  и сокращённое имя, которое будем использовать для handlebars - hbs

Регистрируем handlebars

app.engine('hbs', hbs.engine)

И начинаем его использовать

app.set('view engine''hbs')

Указываем название папки, в которой будут храниться все наши шаблоны

app.set('views''views')

Весь код подключения handlebars

const express = require('express')
const exphbs = require('express-handlebars')

const app = express()

const hbs = exphbs.create({
  defaultLayout: 'main',
  extname: 'hbs'
})

app.engine('hbs', hbs.engine)
app.set('view engine''hbs')
app.set('views''views')

Переименуем файлы index.html и about.html в папке views в index.hbs и about.hbs

Теперь роутинг сильно сокращается
Вместо

app.get('/', (reqres=> {
  res.sendFile(path.join(__dirname, 'views''index.html'))
})

Достаточно написать

app.get('/', (reqres=> {
  res.render('index')
})

Для страницы about действуем аналогично

app.get('/about', (reqres=> {
  res.render('about')
})

Внутри папки views создаём папку layouts и в ней файл main.hbs
В этот файл добавляем обычный html-код. Если в нём написать любой текст, он отобразится на странице. Но вообще main.hbs предназначен для интеграции всех остальных .hbs файлов и скриптов


Урок 4. Настройка Layout


Для того, чтобы файл main.hbs отображал размещённые в папке views  .hbs файлы, изменим его код

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  {{{ body }}}
</body>
</html>

В файлах index.hbs и about.hbs оставляем только контентную часть, заголовок генерирует main.hbs

Например, index.hbs выглядит так

<h1>Home page</h1>
<a href="/about">About Page</a>

Внутри папки views создаём папку partials
В ней можно создавать фрагменты кода, которые можно использовать в приложении

В папке partials создаём файл head.hbs и переносим в него фрагмент кода из main.hbs
Чтобы этот фрагмент подключить в main.hbs  используем две фигурные скобки внутри которых знак > и указываем имя файла

{{> head }}

Точно так же создаём и подключаем footer

{{> footer }}

Подключаем materialize css - https://materializecss.com/

Для этого в head.hbs подключаем стили, а в footer.hbs скрипт

Добавляем обёртку - класс container
Код main.hbs

{{> head }}

<body>
  <div class="container">
    {{{ body }}}
  </div>
  {{> footer }}
</body>

</html>


Урок 5. Добавление навигации


Копируем код navbar из materialize.css
https://materializecss.com/navbar.html

Создаём в папке partials файл navbar.hbs, добавляем в него скопированный код. Подключаем его в файл main.hbs

{{> navbar}}

Добавим собственные стили. Для этого в корне приложения создаём папку public, в ней файл index.css, в нём указываем нужные стили

В файле index.js регистрируем папку public как статическую

app.use(express.static('public'))

В файле head.hbs подключаем стили

<link rel="stylesheet" href="/index.css">

Здесь важно, что хоть путь к папке public намного длиннее, но так как папка зарегистрирована, для указания пути к ней достаточно поставить косой слэш.

Урок 6. Рендеринг данных


Как можно выводить динамические значения рассмотрим на примере динамического заголовка разного для разных страниц.

Значение заголовка указываем в файле index.js в методе render

app.get('/', (reqres=> {
  res.render('index', {
    title: 'Home Page'
  })
})

Так для каждой страницы
Добавить нужный заголовок можно в файле head.hbs указав внутри тега title вместо текста переменную

<title>{{ title }}</title>

Рассмотрим как подсвечивать активную ссылку, указывая ей класс active
Для этого в файле index.js в методе render добавим ещё одно свойство - флаг, разный для каждой страницы

app.get('/', (reqres=> {
  res.render('index', {
    title: 'Home Page',
    isHome: true
  })
})

Теперь в файле navbar.hbs добавляем логику

    {{#if isHome}}
      <li class="active"><a href="/">Главная</a></li>
    {{ else }}
      <li><a href="/">Главная</a></li>
    {{/if}}


Урок 7. Регистрация роутов


Добавлять логику в метод render файла index.js можно для очень небольших приложений. Но когда приложение увеличивается, логики становится слишком много, и её желательно разнести по разным файлам.

В корне приложения создаём папку routes и в ней три файла home.js, courses.js, add.js

Внутри каждого из этих файлов создаём роутер

const {Router} = require('express')
const router = Router()

module.exports = router

и внутрь копируем метод app.get каждой страницы, только вместо app пишем router

const {Router} = require('express')
const router = Router()

router.get('/', (reqres=> {
  res.render('index', {
    title: 'Home Page',
    isHome: true
  })
})

module.exports = router

Импортируем роуты в файл index.js

const homeRoute = require('./routes/home')

app.use(homeRoute)

Не очень удобно, что мы не видим сразу путь к файлу. Для того, чтобы его указать, можно использовать префиксы

Для этого в index.js прописываем пути

app.use('/', homeRoute)
app.use('/courses', coursesRoute)
app.use('/add', addRoute)

А в файлах папки routes их убираем, оставляя только слэш

Урок 8. Обработка форм


Для создания формы копируем код на странице https://materializecss.com/text-inputs.html и добавляем его в файл add.hbs

<form action="/add" method="POST">
  <div class="input-field">
    <input id="title" name="title" type="text" class="validate" required>
    <label for="title">Title of course</label>
    <span class="helper-text" data-error="Add title"></span>
  </div>
  <button class="btn waves-effect waves-light">Add course</button>
</form>

В файле add.js пишем код обработки post-запроса, который выводит запрос в консоль и перенаправляет на страницу курсов

router.post('/', (reqres=> {
  console.log(req.body)
  res.redirect('/courses')
})

В файле index.js регистрируем обработчик

app.use(express.urlencoded({ extended: true }));

Урок 9. Создание модели

Создадим модель для добавления, редактирования, удаления курса.
Для этого в корне проекта создаём папку models, в ней файл course.js
В этом файле создаём класс Course с параметрами titlepriceimg

class Course {
  constructor(titlepriceimg) {
    this.title = title;
    this.price = price;
    this.img = img;
  }
}

Добавим курсу уникальный id. Для этого устанавливаем библиотеку uuid

npm install uuidv4

Подключаем её

const { uuid } = require('uuidv4');

и добавляем в конструктор класса Course строку

this.id = uuid();

Для хранения информации используем файловую систему
В корне проекта создаём папку data в ней файл courses.json

Чтобы изменить содержание файла, его нужно прочитать. За это отвечает метод geatAll()

  static getAll() {
    return new Promise((resolvereject=> {
      fs.readFile(
        path.join(__dirname, "..""data""courses.json"),
        "utf-8",
        (errcontent=> {
          if(err) {
            reject(err);
          } else {
            resolve(JSON.parse(content))
          }
        }
      );
    });
  }

Это вспомогательный метод.
Для сохранения курса отвечает метод save()

Получаем информацию из файла  courses.json

  async save() {
    const courses = await Course.getAll();
console.log(courses);
  }

Подключаем модуль к файлу add.js

const Course = require('../models/course')
router.post('/'async (reqres=> {
  const course = new Course(req.body.title, req.body.price, req.body.img)
  await course.save()
  res.redirect('/courses')
})

Теперь при отправке формы в консоль выводится содержимое файла courses.json

Добавим данные формы в массив
Для этого создадим вспомогательную функцию toJSON()

  toJSON() {
    return {
      title: this.title,
      price: this.price,
      img: this.img,
      id: this.id
    }
  }

И запушим её в массив courses

  async save() {
    const courses = await Course.getAll();
    courses.push(this.toJSON())
    console.log(courses);
  }

Донные, добавленные в форму, выводятся в консоль
Запишем их в файл courses.json

async save() {
    const courses = await Course.getAll();
    courses.push(this.toJSON());
    return new Promise((resolvereject=> {
      fs.writeFile(
        path.join(__dirname, "..""data""courses.json"),
        JSON.stringify(courses),
        (err=> {
          if (err) {
            reject(err);
          } else {
            resolve();
          }
        }
      );
    });
  }

Теперь данные, введённые в форму, сохраняются в файле courses.json

Урок 10. Вывод списка курсов

Научившись записывать данные в файл, можем выводить эти данные на страницу

Вначале импортируем клас Courses в файл course.js

const Course = require('../models/course')

Мы уже выполняли данный импорт в файл add.js, там мы записали данные о курсе, а в файле course.js выведем записанные данные на страницу

router.get('/'async (reqres=> {
  const courses = await Course.getAll();
  res.render('courses', {
    title: 'Courses',
    isCourses: true,
    courses
  })
})

Перейдём в файл courses.hbs
Проверим, если ли курсы

{{#if courses.length}}
<p>{{courses.length}}</p>
{{else}}
<p>not any course</p>
{{/if}}

Создадим карточку курса
https://materializecss.com/cards.html

{{#if courses.length}}
{{#each courses}}
<div class="row">
  <div class="col s6 offset-s3">
    <div class="card">
      <div class="card-image">
        <img src={{img}} alt={{title}}>
      </div>
      <div class="card-content">
        <span class="card-title">{{title}}</span>
        <p class="price">{{price}}</p>
      </div>
      <div class="card-action">
        <a href="/courses/{{id}}">Open course</a>
      </div>
    </div>
  </div>
</div>
{{/each}}
{{else}}
<p>not any course</p>
{{/if}}

Урок 11. Подключение клиентских скриптов

В папке public создаём файл app.js - клиентский скрипт
В нём пишем код для стилизации валюты

const prices = document.querySelectorAll('.price');
prices.forEach(el => {
  el.textContent = new Intl.NumberFormat('ru-Ru', {
    currency: 'rub',
    style: 'currency'
  }).format(el.textContent)
})

Скрипт app.js подключаем в футере footer.hbs

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

Урок 12. Динамические параметры


На страницу курса ведёт ссылка вида

<a href="/courses/{{id}}">Open course</a>

У каждого курса свой уникальный id, который указан в файле courses.json
Создадим страницу курса
Создаём файл course.hbs в папке view и там добавляем разметку

<div class="course">
  <h1>{{course.title}}</h1>
  <img src={{course.img}} alt={{course.title}}>
  <p class="price big">{{course.price}}</p>
</div>

Нам нужно получить курс по id
Для этого идём в папку models, открываем файл course.js и на основе метода getAll() создаём метод getById()

  static async getById(id) {
    const courses = await Course.getAll();
    return courses.find(el => el.id === id)
  }

В файле courses.js пишем код

router.get("/:id"async (reqres=> {
  const course = await Course.getById(req.params.id);
  res.render('course', {
laout: 'empty',
      title: `Course ${course.title}`,
      course,
    });
});

При переходе по ссылке открывается страница курса
Вынесем курс в layout
за это отвечает строчка в методе render

laout: 'empty',

В папке layouts создадим файл empty.hbs
Его код

{{> head }}
<body> 
    {{{ body }}}
  {{> footer }}
</body>
</html>

Урок 13. Редактирование курса

Создадим функционал для редактирования курса - возможности менять название, изображение, цену

В папке routes в файле courses.js добавим ещё один роутер

router.get("/:id/edit"async (reqres=> {

});

Проверяем, можно ли редактировать курс

router.get("/:id/edit"async (reqres=> {
  if(!req.query.allow) {
    return res.redirect('/')
  }
});

Для добавления параметра, разрешающего редактирование курса, в файле courses.hbs создаём ссылку

<a href="/courses/{{id}}/edit?allow=true">Edit course</a>

Создадим страницу редактирования. В папке views создаём файл course-edit.hbs копируем в него форму из файла add.hbs

В каждый input формы в атрибут value добавим текущие значения полей
Также добавляем скрытый input, содержащий значение id курса

 <input type="hidden" name="id" value="{{course.id}}">

Дописываем метод router.get

router.get("/:id/edit"async (reqres=> {
  if(!req.query.allow) {
    return res.redirect('/')
  }
  const course = await Course.getById(req.params.id);
  res.render('course-edit', {
    title: `Edit ${course.title}`,
    course
  })
});

Страница редактирования готова.

Обработаем клик по кнопке Edit course.

router.post("/edit"async(reqres=> {
  await Course.update(req,body)
  res.redirect("/courses")
})

Создадим у модели курсов метод update()

  static async update(course) {
    const courses = await Course.getAll();
    const idx = courses.findIndex(c => c.id === course.id)
    courses[idx] = course
    return new Promise((resolvereject=> {
      fs.writeFile(
        path.join(__dirname, "..""data""courses.json"),
        JSON.stringify(courses),
        (err=> {
          if (err) {
            reject(err);
          } else {
            resolve();
          }
        }
      );
    });
  }

Большая часть логики скопирована из метода save()
Всё, редактирование работает

Урок 14. Подготовка корзины

В файл courses.js добавим кнопку для покупки курса

        <form action="/card/add" method="POST">
          <input type="hidden" name="id" value="{{id}}">
          <button type="submit" class="btn btn-primary">Sale course</button>
        </form>

Создадим роут, который будет отвечать за корзину card.js

const {Router} = require('express')
const router = Router()

router.post('/add'async (reqres=> {
  
})

module.exports = router

Регистрируем его в index.js

const cardRoute = require("./routes/card");

app.use("/card", cardRoute);

Создадим модель корзины. Для этого в папке models создадим файл card.js

class Card {
  add() {

  }

  fetch() {
    
  }
}

module.exports = Card

Дополним роутер card.js

const {Router} = require('express')
const Card = require('../models/card')
const Course = require('../models/course')
const router = Router()

router.post('/add'async (reqres=> {
  const course = await Course.getById(req.body.id);
  await Card.add(course)
  res.redirect('/card')
})

router.get('/'async (reqres=> {
  const Card = await Card.feth()
  res.render('card', {
    title: 'Card',
    card
  })
})

module.exports = router

В navbar.hbs добавляем ссылку на корзину

      {{#if isCard}}
      <li class="active"><a href="/card">Card</a></li>
      {{ else }}
      <li><a href="/card">Card</a></li>
      {{/if}}

Урок 15. Модель корзины

В модели корзины метод add() добавляет данные в корзину, метод fetch() считывает их оттуда.
Реализуем эти методы.

В папке data создадим файл card.json
В нём создадим формат данных по умолчанию - массив с курсами и их цена

{
  "courses": [],
  "price"0
}

Важно, что кавычки в данном случае могут быть только двойными, именно в таком формате хранятся данные в .json

Метод fetch()  повторяет метод getAll() из модели курсов. Его задача - прочитать данные из файла

  static async fetch() {
    return new Promise((resolvereject=> {
      fs.readFile(
        path.join(__dirname, "..""data""card.json"),
        "utf-8",
        (errcontent=> {
          if (err) {
            reject(err);
          } else {
            resolve(JSON.parse(content));
          }
        }
      );
    });
  }

Добавим в корзину новый курс

  static async add(course) {
    const card = await Card.fetch();

    const idx = card.courses.findIndex(c => c.id === course.id)
    const candidate = card.courses[idx]
    if(candidate) {
      candidate.count +=1
      card.courses[idx] = candidate
    } else {
      course.count = 1
      card.courses.push(course)
    }

    card.price += +course.price

    return new Promise((resolvereject=> {
      fs.writeFile(
        path.join(__dirname, "..""data""card.json"),
        JSON.stringify(card),
        (err=> {
          if (err) {
            reject(err);
          } else {
            resolve();
          }
        }
      );
    });
  }

Переходим в папку router файл card.js

const { Router } = require("express");
const Card = require("../models/card");
const Course = require("../models/course");
const router = Router();

router.post("/add"async (reqres=> {
  const course = await Course.getById(req.body.id);
  await Card.add(course);
  res.redirect("/card");
});

router.get("/"async (reqres=> {
  const card = await Card.fetch();
  res.render("card", {
    title: "Card",
    isCard: true,
    courses: card.courses,
    price: card.prise,
  });
});

module.exports = router;

При клике по кнопке "Добавить в корзину" на странице курсов срабатывает метод POST и курс записывается в файл course.json. при загрузке страницы корзины срабатывает метод GET

Урок 16. Вывод данных в корзине

Файл card.hbs

<h1>Card</h1>

{{#if courses.length}} 
<table>
  <thead>
    <tr>
      <td>Title</td>
      <td>Count</td>
      <td>Action</td>
    </tr>
  </thead>
  <tbody>
    {{#each courses}}
    <tr>
      <td>{{title}}</td>
      <td>{{count}}</td>
      <td><button class="btn btn-small">Delete</button></td>
    </tr>
    {{/each}}
  </tbody>
</table>

<p><strong>Price:</strong> <span class="price">{{price}}</span></p>
{{else}}
<p>Card is empty</p>
{{/if}}


Урок 17. Обработка асинхронных запросов


Обработку асинхронных запросов рассмотрим на примере удаления элемента из корзины

Добавим обработчик событий кнопке Delete в файле card.hbs

Для этого добавим кнопке класс и data-атрибут

<button class="btn btn-small js-remove" data-id="{{id}}">Delete</button>

и весь блок обернём в div с id card для того, чтобы отслеживать клики на нём и делегировать событие клика кнопкам с классом js-remove

В папке index.js исправим путь к папке public

app.use(express.static(path.join(__dirname, 'public')));

Затем переходим в папку public файл app.js

const $card = document.querySelector('#card')
if ($card) {
  $card.addEventListener('click'event => {
    if (event.target.classList.contains('js-remove')) {
      const id = event.target.dataset.id
      
      fetch('/card/remove/' + id, {
        method: 'delete'
      }).then(res => res.json())
        .then(card => {
          console.log(card)
        })
    }    
  })

В файле card.js папка models пишем метод remove() класса Card

  static async remove(id) {
    const card = await Card.fetch();
    const idx = card.courses.findIndex(c => c.id === id)
    const course = card.courses[idx]

    if(course.count === 1) {
      card.courses = card.courses.filter(c => c.id !== id)
    } else {
      card.courses[idx].count -=1
    }
    card.price -= course.price

    return new Promise((resolvereject=> {
      fs.writeFile(
        path.join(__dirname, "..""data""card.json"),
        JSON.stringify(card),
        (err=> {
          if (err) {
            reject(err);
          } else {
            resolve(card);
          }
        }
      );
    });
  }


Урок 18. Динамическое изменение корзины

В предыдущем уроке мы научились выводить обновлённые данные в консоль. В этом будем выводить их на страницу без её обновления.

Переходим в файл app.js

const toCurrency = price => {
  return new Intl.NumberFormat('ru-RU', {
    currency: 'rub',
    style: 'currency'
  }).format(price)
}

document.querySelectorAll('.price').forEach(node => {
  node.textContent = toCurrency(node.textContent)
})

const $card = document.querySelector('#card')
if ($card) {
  $card.addEventListener('click'event => {
    if (event.target.classList.contains('js-remove')) {
      const id = event.target.dataset.id
      
      fetch('/card/remove/' + id, {
        method: 'delete'
      }).then(res => res.json())
        .then(card => {
          if (card.courses.length) {
            const html = card.courses.map(c => {
              return `
              <tr>
                <td>${c.title}</td>
                <td>${c.count}</td>
                <td>
                  <button class="btn btm-small js-remove" data-id="${c.id}">Удалить</button>
                </td>
              </tr>
              `
            }).join('')
            $card.querySelector('tbody').innerHTML = html
            $card.querySelector('.price').textContent = toCurrency(card.price)
          } else {
            $card.innerHTML = '<p>Корзина пуста</p>'
          }
        })
    }    
  })
}