Простой блог на 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, фронт нужен только для того чтобы отображать данные.
Я могу сделать вторую часть с аутентификацией, если данный вариант наберёт большинство голосов в опросе.