Простой блог на Node.js, MongoDB и Preact

Простой блог на Node.js, MongoDB и Preact


Сегодня мы создадим простой блог, использующий данные БД, и отбражающий их в красивом формате.

Строение приложения

Приложение состоит из двух частей: API и Фронтенд. Они работают независимо друг от друга, поэтому мы разделим их на две разные папки.

├── api
│  ├── package.json
│  ├── server.js  
│  └── yarn.lock  
└── www
   ├── components 
   ├── dist       
   ├── index.html 
   ├── index.js     
   ├── html.js 
   ├── package.json 
   └── yarn.lock 

Установка пакетов для фронта и бека

Фронтенд

Для фронта мы будем использовать Preact X +htm + @reach/Router:

yarn add -D parcel-bundler
yarn add preact@beta @reach/router htm
yarn add -D @types/reach__router

Также в package.json прописываем алиас:

"alias": {
    "react": "preact/compat",
    "react-dom": "preact/compat"
}

Ещё нужно прописать NPM скрипты, но можно использовать и NPX:

"scripts": {
    "dev": "parcel index.html -p 80",
    "build": "parcel build index.html",
    "start": "NODE_ENV=production parcel serve dist/index.html -p 80"
}

Если вам не нравится htm, можете поставить Babel и использовать JSX:

yarn remove htm
yarn add -D @babel/core @babel/preset-env @babel/preset-react

Бекенд

Для бека мы будем использовать MongoDB драйвер:

yarn add mongodb
yarn add -D nodemon esm
yarn add -D @types/mongodb # для поддержки в редакторах

Далее, чтобы у нас работали ESM модули, пропишем в package.json:

"scripts": {
  "dev": "nodemon -r esm server.js"
}

Установка и настройка БД

Мы поставим локальную MongoDB, чтобы не заморачиваться с настройкой облачной БД:

apt install mongodb # Debian или Ubuntu
choco install mongodb # Windows + Chocolatey

Далее запустим как сервис:

service mongodb start # Linux
C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe # Windows

Теперь нам нужно создать новую БД. Также мы попробуем создать новый пост в базе.

$ mongo --shell
> use test_db
switched to db test_db
> db
test_db
> db.test_data.insert( { name: 'My cool story', story: 'Lorem Ipsum' })
WriteResult({ "nInserted" : 1 })
> db.test_data.find()
{ "_id" : ObjectId("5d131e800c778a8b83005a36"), "name" : "My cool story", "story" : "Lorem Ipsum" }

Как видим, всё работает.

Бекенд

Мы не будем использовать никаких фреймворков, чистый Node.js.

Начнём с самого просто, выведем Hello World.

import { createServer } from 'http'

createServer((req, res) => {
  res.end('Hello')
}).listen(3000)

Теперь подключимся к MongoDB:

import { createServer } from 'http'
import { MongoClient } from 'mongodb'
import { parse } from 'url'

let cachedDb = null

// Подключаемся к бд, и кешируем, чтобы не подключаться лишний раз
async function connectToDb(uri) {
  if (cachedDb) return cachedDb

  const client = await MongoClient.connect(uri, { useNewUrlParser: true })
  // Обрезаем путь для более удобного использования
  const db = await client.db(parse(uri).pathname.substr(1))

  cachedDb = db
  return db
}

createServer(async (req, res) => {
  // Хранить URI БД нужно в env файле
  const db = await connectToDb('mongodb://localhost:27017/test_db')
  const collection = await db.collection('test_data')
  const posts = await collection.find({}).toArray()
  res.setHeader('Content-Type', 'application/json')
  res.end(JSON.stringify(posts))
}).listen(3000)

Если мы зайдём на наш сервер, то получим JSON объект нашего поста:

Теперь нам нужно сделать так, чтобы у каждого поста был свой slug (т.е. URL), потому что по ID определять - не очень удобно.

Пробежимся по всем постам и будем отправлять каждый из них по специальному URL:

// ...
createServer(async (req, res) => {
  // Подключение к БД (см. предыдущий кусок кода)

  res.setHeader('Content-Type', 'application/json')

  if (req.url === '/') res.end(JSON.stringify(posts))
  else res.end(JSON.stringify(posts.filter(post => `/${post.slug}` === req.url)[0]))
}).listen(3000)

На выходе получаем это. Позже мы сможем воспользоваться этим на фронте.

Осталось лишь прописать функцию добавления поста в блог.

// Всё тоже самое начиная res.setHeader
  req.body = ''
   // Фильтруем тип запроса
  if (req.method === 'POST') {
    req
      .on('data', chunk => req.body += chunk.toString())
      .on('end', () => {
        // Иначе будет пост с URL "/", но он уже занят
        if (req.url === '/') res.end('Please specify a URL')
        // Вставляем в БД
        else {
          collection.insertOne({ slug: req.url.slice(1, req.url.length), ...JSON.parse(req.body)})
          res.end('Saved to database.')
        }
      })
  } else if (req.url === '/') {
    res.end(JSON.stringify(posts))
  } else {
    res.end(JSON.stringify(
      posts.filter(post => `/${post.slug}` === req.url)[0]
    ))
  }
}).listen(3000)

Попробуем создать пост:

$ curl http://localhost:3000/second_post -X POST -d '{ "name": "My second post", "story": "Yes it is" }'
Saved to database.⏎

Зайдём на "/" и увидим, что наш список пополнился.

Также пост доступен отдельно:

Отлично, бек готов. Переходим к фронтенду.

Фронтенд

Для удобства создадим функцию html, чтобы не привязывать htm в каждом файле:

// html.js
import { createElement } from 'react'
import htm from 'htm'

export default htm.bind(createElement)

Ещё пропишем стили (style.css), чтобы блог не выглядел сверх-убого:

body {
  font-family: sans-serif;
  display: grid;
  place-items: center;
  margin: 0;
  height: 100vh;
}

Главная страница

На главной странице нам нужно вывести список всех постов:

import { render, useState, useEffect } from 'react'
import { Router, Link } from '@reach/router'
import html from './html'
import './style.css'

const Home = () => {
  const [posts, setPosts] = useState([])

  useEffect(() => {
    fetch('http://localhost:3000')
      .then(res => res.json())
      .then(data => setPosts(data))
  }, [])

  return html`
    <main>
      <h1>My cool blog</h1>
      <ul>
        ${posts.map(
          post =>
            html`
              <li><${Link} to="${'/post/' + post.slug}">${post.name}</Link></li>
            `
        )}
      </ul>
    </main>
  `
}

На домашней странице мы отслеживаем все посты с сервера, затем создаём их список.

Отдельный пост

Теперь нужно создать компонент для отдельного поста. Он получает JSON данные и ставит их "по местам":

// ...

const Post = ({ slug }) => {
  const [data, setData] = useState({})

  useEffect(() => {
    fetch(`http://localhost:3000/${slug}`)
      .then(res => res.json())
      .then(data => setData(data))
  }, [])

  return html`
    <main>
      <h1>${data.name}</h1>
      <p>${data.story}</p>
      <${Link} to="/">Back to main page</Link>
    </main>
  `
}

Здесь мы также заюзали useEffect для отслеживания данных с сервера. Компонент поста получает URL параметр "slug" в роутере, по которому он уже делает запрос.

Создание поста

Редактор представляет из себя обычную форму, с заголовком и самим постом.

Наш редактор будет выглядеть так:

Нам нужно управлять сразу четыремя состояниями, но можно использовать useReducer, чтобы всё состояние хранилось в объекте. Здесь у нас заголовок поста, его URL, сам пост, и ответ от сервера.

// ...
const New = () => {
  const [title, setTitle] = useState('')
  const [slug, setSlug] = useState('')
  const [story, setStory] = useState('')
  const [reply, setReply] = useState('')

Каждый раз когда юзер будет вводить буквы в заголовок, сразу будет генерироваться URL:

useEffect(() => {
    setSlug(title.toLowerCase().replace(/\s/g, '_'))
  }, [title])

Когда юзер закончил писать пост, он нажмёт на кнопку, которая отошлёт инфу на сервер:

  const sendStory = () => {
    fetch(`http://localhost:3000/${slug}`, {
      method: 'POST',
      body: JSON.stringify({
        name: title,
        story
      })
    })
      .then(res => res.text())
      .then(data => setReply(data))
  }

Ну и отрендерим редактор:

  return html`
    <main className="editor">
      <h1>New post 📝</h1>
      <span>post url: /${slug}</span>
      <input
        placeholder="Give a title to your post"
        onInput=${e => setTitle(e.target.value)}
      />
      <textarea
        placeholder="Write the post itself"
        onInput=${e => setStory(e.target.value)}
      />
      <button onClick=${sendStory}>Post</button>
      <p>Reply from API: ${reply}</p>
      <${Link} to="/">Home</Link>
    </main>
  `
}

Я юзал такие стили для редактора, но это абсолютно не важно:

body {
  margin: 0;
  height: 100vh;
}

* {
  font-family: sans-serif;
}

body, .editor {
  display: grid;
  place-items: center
}

input {
  width: 100%;
  font-size: 3em
}

button {
  font-size: 2em;
  border: 3px solid black;
  
}

textarea, input {
  border: none
}

textarea {
  font-size: 1.2em
}

textarea {
  margin-top: 0.7em;
  width: 50vw;
  height: 60vh;
}

Финальная часть, роутинг

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

render(
  html`
    <${Router}>
      <${Home} path="/" />
      <${Post} path="/post/:slug" />
      <${New} path="/new" />
    </Router>
  `,
  document.getElementById('app')
)

Всё. наш блог готов. Запустим и фронт и бек:

node -r esm api/server
cd www && yarn build && yarn start

Заключение

Мы написали простенький блог, но лучше не использовать его как реальный блог, т.к. тут нет аутентификации, поддержки Markdown, редактирования и удаления постов и т.д.

Всё это делается через интерфейсы MongoDB, фронт нужен только для того чтобы отображать данные.

Я могу сделать вторую часть с аутентификацией, если данный вариант наберёт большинство голосов в опросе.

Report Page