![](https://habrastorage.org/webt/x0/xe/nu/x0xenun1kvyf7bp4vyngpxjyuuk.png)
Привет, друзья!
В этой статье я хочу показать вам, как создать шаблон React.js
+ Express.js
+ TypeScript
приложения.
Обоснование используемых технологий (сугубо личное мнение, которое не обязательно должно совпадать с вашим):
React
— далеко не идеальный, но лучший на сегодняшний день фреймворк для фронтенда (или, согласно официальной документации, "для создания пользовательских интерфейсов");Express
— несмотря на наличие большого количества альтернативных решений, по-прежнему лучшийNode.js-фреймворк
для разработки веб-серверов;TypeScript
— система типов дляJavaScript
(и еще кое-что), фактический стандарт современной веб-разработки.
Если вам это интересно, прошу под кат.
Здесь вы найдете шпаргалку по Express API
, а здесь — Карманную книгу по TypeScript
в формате PWA
.
Несмотря на то, что в мире сборщиков модулей доминирующее положение по-прежнему занимает Webpack
, для сборки React-приложения
, мы будем использовать Snowpack
. Он не такой кастомизируемый, зато проще в настройке и быстрее как при запуске и перезапуске сервера для разработки, так и при сборке проекта.
Для установки зависимостей и выполнения команд я буду использовать Yarn
. Установить его можно так:
npm i -g yarn
Наши сервисы (имеется в виду клиент и сервер) будут полностью автономными, но, вместе с тем, они будут иметь доступ к общим типам.
Функционал нашего приложения будет следующим:
- клиент может отправить серверу либо неправильное сообщение, либо правильное;
- сервер проверяет сообщение, полученное от клиента, и если оно правильное, отправляет приветствие в ответ;
- если сообщение от клиента неправильное, сервер возвращает сообщение об ошибке.
Структурно сообщение будет состоять из заголовка (title
) и тела (body
). Синоним типа (type alias) сообщения будет общим для клиента и сервера.
Рекомендую вкратце ознакомиться с флагами tsc
(CLI
для сборки TS-проектов
) и настройками tsconfig.json
.
Подготовка и настройка проекта
Создаем новую директорию, переходим в нее и инициализируем Node.js-проект
:
# ret - react + express + typescript
mkdir ret-template
cd ret-template # cd !$
# -y | --yes - пропускаем вопросы о структуре и назначении проекта
# -p | --private - частный/закрытый проект (не для публикации в реестре npm, не является библиотекой)
yarn init -yp
На верхнем уровне нам потребуется две зависимости:
yarn add concurrently
# -D | --save-dev - зависимость для разработки
yarn add -D typescript
concurrently
— утилита, позволяющая одновременно выполнять несколько команд, определенных вpackage.json
typescript
— компиляторTypeScript
Общими командами для запуска серверов мы займемся чуть позже.
Создаем директорию shared
, в которой будут храниться общие типы, а также файлы index.d.ts
и tsconfig.json
:
mkdir shared
cd shared
touch index.d.ts
touch tsconfig.json
Файлы с расширением d.ts
— это так называемые файлы деклараций. Их основное отличие от обычных TS-файлов
(с расширением ts
) состоит в том, что декларации могут содержать только объявления типов (но не выполняемый код), и не компилируются в JS
. Если мы создадим файл types.ts
, то после компиляции получим файл types.js
с export {}
внутри. Нам это ни к чему.
Наличие файла tsconfig.json
в директории сообщает компилятору, что он имеет дело с TS-проектом
.
Определяем общий синоним типа сообщения в index.d.ts
:
export type Message = {
title: string
body: string
}
Типы в декларациях часто объявляются с помощью ключевого слова declare
, но в нашем случае это не имеет принципиального значения.
Определяем единственную настройку в tsconfig.json
:
{
"compilerOptions": {
"composite": true
}
}
Эта настройка сообщает TypeScript
, что данный проект является частью другого проекта.
Создаем файл .gitignore
следующего содержания:
node_modules
# настройки, вместо переменных среды окружения (.env)
config
# директория сборки
build
yarn-error.log*
.snowpack
# mac
.DS_Store
Осталось определить команды для запуска проекта в режиме для разработки и производственном режиме. Режим для разработки предполагает запуск 2 серверов для разработки: одного для клиента и еще одного для сервера. Производственный режим предполагает сборку клиента, сборку сервера и запуск сервера (сборка клиента будет обслуживаться сервером в качестве директории со статическими файлами). Поэтому для определения названных команд придется сначала разобраться с клиентом и сервером.
Клиент
Создаем шаблон React + TypeScript-приложения
с помощью create-snowpack-app
:
# client - название проекта
# --template @snowpack/app-template-react-typescript - название используемого шаблона
# --use-yarn - использовать yarn вместо npm для установки зависимостей
yarn create snowpack-app client --template @snowpack/app-template-react-typescript --use-yarn
Переходим в созданную директорию (cd client
) и приводим ее к такой структуре:
- public
- index.html
- favicon.ico
- src
- api
- index.ts
- config
- index.ts
- App.scss
- App.tsx
- index.tsx
- types
- static.d.ts
- .prettierrc
- package.json
- snowpack.config.mjs
- tsconfig.json
Отредактируем несколько файлов. Начнем с .prettierrc
:
{
"singleQuote": true,
"trailingComma": "none",
"jsxSingleQuote": true,
"semi": false
}
Разбираемся с зависимостями в package.json
:
{
"scripts": {
"start": "snowpack dev",
"build": "snowpack build",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\""
},
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@snowpack/plugin-react-refresh": "^2.5.0",
"@snowpack/plugin-sass": "^1.4.0",
"@snowpack/plugin-typescript": "^1.2.1",
"@types/react": "^17.0.4",
"@types/react-dom": "^17.0.3",
"@types/snowpack-env": "^2.3.4",
"prettier": "^2.2.1",
"snowpack": "^3.3.7",
"typescript": "^4.5.2"
}
}
Выполняем команду yarn
для переустановки зависимостей.
Редактируем настройки в tsconfig.json
:
{
"compilerOptions": {
"allowJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"jsx": "preserve",
"module": "esnext",
"moduleResolution": "node",
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
},
"include": [
"src",
"types"
],
"references": [
{
"path": "../shared"
}
]
}
Здесь:
"noEmit": true
означает, чтоTS
в проекте используется только для проверки типов (type checking). Это объясняется тем, что компиляция кода вJS
выполняетсяsnowpack
;- этим же объясняется настройка
"jsx": "preserve"
, которая означает, чтоTS
оставляетJSX
как есть; - этим же объясняется отсутствие настройки
target
(эта настройка содержится вsnowpack.config.mjs
); references
позволяет указать ссылку на другойTS-проект
. В нашем случае этим "проектом" является директорияshared
с общим типом сообщения.
Редактируем настройки в snowpack.config.mjs
:
/** @type {import("snowpack").SnowpackUserConfig } */
export default {
mount: {
public: { url: '/', static: true },
src: { url: '/dist' }
},
plugins: [
'@snowpack/plugin-react-refresh',
// здесь был плагин для переменных среды окружения,
// но с TS они работают очень плохо
// добавляем плагин для sass, опционально (можете использовать чистый CSS)
'@snowpack/plugin-sass',
[
'@snowpack/plugin-typescript',
{
...(process.versions.pnp ? { tsc: 'yarn pnpify tsc' } : {})
}
]
],
// оптимизация сборки для продакшна
optimize: {
bundle: true,
minify: true,
treeshake: true,
// компиляция TS в JS двухлетней давности
target: 'es2019'
},
// удаление директории со старой сборкой перед созданием новой сборки
// может негативно сказаться на производительности в больших проектах
buildOptions: {
clean: true
}
}
Определяем адрес сервера в файле с настройками (config/index.ts
):
export const SERVER_URI = 'http://localhost:4000/api'
Определяем API
для клиента в файле api/index.ts
:
// адрес сервера
import { SERVER_URI } from '../config'
// общий тип сообщения
import { Message } from '../../../shared'
// общие настройки запроса
const commonOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}
// функция отправки неправильного сообщения
const sendWrongMessage = async () => {
const options = {
...commonOptions,
body: JSON.stringify({
title: 'Message from client',
// как самонадеянно
body: 'I know JavaScript'
})
}
try {
const response = await fetch(SERVER_URI, options)
if (!response.ok) throw response
const data = await response.json()
if (data?.message) {
// это называется утверждением типа (type assertion)
// при использовании JSX возможен только такой способ
return data.message as Message
}
} catch (e: any) {
if (e.status === 400) {
// сообщение об ошибке
const data = await e.json()
throw data
}
throw e
}
}
// функция отправки правильного сообщения
const sendRightMessage = async () => {
const options = {
...commonOptions,
body: JSON.stringify({
title: 'Message from client',
body: 'Hello from client!'
})
}
try {
const response = await fetch(SERVER_URI, options)
if (!response.ok) throw response
const data = await response.json()
if (data?.message) {
// !
return data.message as Message
}
} catch (e) {
throw e
}
}
export default { sendWrongMessage, sendRightMessage }
Наконец, само приложение (App.tsx
):
import './App.scss'
import React, { useState } from 'react'
// API
import messageApi from './api'
// общий тип сообщения
import { Message } from '../../shared'
function App() {
// состояние сообщения
const [message, setMessage] = useState<Message | undefined>()
// состояние ошибки
const [error, setError] = useState<any>(null)
// метод для отправки неправильного сообщения
const sendWrongMessage = () => {
// обнуляем приветствие от сервера
setMessage(undefined)
messageApi.sendWrongMessage().then(setMessage).catch(setError)
}
const sendRightMessage = () => {
// обнуляем сообщение об ошибке
setError(null)
messageApi.sendRightMessage().then(setMessage).catch(setError)
}
return (
<>
<header>
<h1>React + Express + TypeScript Template</h1>
</header>
<main>
<div>
<button onClick={sendWrongMessage} className='wrong-message'>
Send wrong message
</button>
<button onClick={sendRightMessage} className='right-message'>
Send right message
</button>
{/* onClick={window.location.reload} не будет работать из-за того, что this потеряет контекст, т.е. window.location */}
<button onClick={() => window.location.reload()}>Reload window</button>
</div>
{/* блок для приветствия от сервера */}
{message && (
<div className='message-container'>
<h2>{message.title}</h2>
<p>{message.body}</p>
</div>
)}
{/* блок для сообщения об ошибке */}
{error && <p className='error-message'>{error.message}</p>}
</main>
<footer>
<p>© 2021. Not all rights reserved</p>
</footer>
</>
)
}
export default App
Запускаем клиента в режиме для разработки с помощью команды yarn start
.
![](https://habrastorage.org/webt/pj/2d/oh/pj2dohcqilkny3un_auypulh_xu.png)
При попытке отправить любое сообщение, получаем ошибку Failed to fetch
.
![](https://habrastorage.org/webt/la/le/hp/lalehpg-enjks3ubgptymcfg6t4.png)
Логично, ведь у нас еще нет сервера. Давайте это исправим.
Сервер
Создаем новую директорию, переходим в нее и инициализируем Node.js-проект
:
mkdir server
cd server
yarn init -yp
Устанавливаем основные зависимости:
yarn add express helmet cors concurrently cross-env
helmet
— утилита для установки HTTP-заголовков, связанных с безопасностьюcors
— утилита для установки HTTP-заголовков, связанных сCORS
cross-env
— утилита для платформонезависимой передачи переменных среды окружения (process.env
)
Устанавливаем зависимости для разработки:
yarn add -D typescript nodemon @types/cors @types/express @types/helmet @types/node
@types
— типы для соответствующих утилит иNode.js
nodemon
— утилита для запуска сервера для разработки
Структура сервера:
- src
- config
- index.ts
- middleware
- verifyAndCreateMessage.ts
- routes
- api.routes.ts
- services
- api.services.ts
- types
- index.d.ts
- utils
onError.ts
- index.ts
- package.json
- tsconfig.json
Начнем с редактирования настроек в tsconfig.json
:
{
"compilerOptions": {
"allowJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"outDir": "./build",
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": "es2019"
},
"references": [
{
"path": "../shared"
}
]
}
Здесь:
"target": "es2019"
— в отличие от клиента, код сервера компилируется вJS
с помощьюtsc
rootDir
— корневая директория для предотвращения лишней вложенности сборкиoutDir
— название директории сборкиreferences
— ссылка на общие типы
Код сервера (src/index.ts
):
// библиотеки и утилиты
import cors from 'cors'
import express, { json, urlencoded } from 'express'
import helmet from 'helmet'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
// настройки
import { developmentConfig, productionConfig } from './config/index.js'
// роуты
import apiRoutes from './routes/api.routes.js'
// обработчик ошибок
import onError from './utils/onError.js'
// путь к текущей директории
const __dirname = dirname(fileURLToPath(import.meta.url))
// определяем режим
const isProduction = process.env.NODE_ENV === 'production'
// выбираем настройки
let config
if (isProduction) {
config = productionConfig
} else {
config = developmentConfig
}
// создаем экземпляр приложения
const app = express()
// устанавливаем заголовки, связанные с безопасностью
app.use(helmet())
// устанавливаем заголовки, связанные с CORS
app.use(
cors({
// сервер будет обрабатывать запросы только из разрешенного источника
origin: config.allowedOrigin
})
)
// преобразование тела запроса из JSON в обычный объект
app.use(json())
// разбор параметров строки запроса
app.use(urlencoded({ extended: true }))
// если сервер запущен в производственном режиме,
// сборка клиента обслуживается в качестве директории со статическими файлами
if (isProduction) {
app.use(express.static(join(__dirname, '../../client/build')))
}
// роуты
app.use('/api', apiRoutes)
// роут not found
app.use('*', (req, res) => {
res.status(404).json({ message: 'Page not found' })
})
// обработчик ошибок
app.use(onError)
// запуск сервера
app.listen(config.port, () => {
console.log('🚀 Server ready to handle requests')
})
Обратите внимание: импортируемые файлы имеют расширение js
, а не ts
.
Взглянем на типы (types/index.d.ts
):
import { Request, Response, NextFunction } from 'express'
export type Route = (req: Request, res: Response, next: NextFunction) => void
И на настройки (config/index.ts
):
export const developmentConfig = {
port: 4000,
allowedOrigin: 'http://localhost:8080'
}
export const productionConfig = {
port: 4000,
allowedOrigin: 'http://localhost:4000'
}
Утилита (utils/onError.ts
):
import { ErrorRequestHandler } from 'express'
const onError: ErrorRequestHandler = (err, req, res, next) => {
console.log(err)
const status = err.status || 500
const message = err.message || 'Something went wrong. Try again later'
res.status(status).json({ message })
}
export default onError
Роутер (routes/api.routes.ts
):
import { Router } from 'express'
// посредник, промежуточный слой
import { verifyAndCreateMessage } from '../middleware/verifyAndCreateMessage.js'
// сервис
import { sendMessage } from '../services/api.services.js'
const router = Router()
router.post('/', verifyAndCreateMessage, sendMessage)
export default router
Посредник (middleware/verifyAndCreateMessage.ts
):
// локальный тип
import { Route } from '../types'
// глобальный тип
import { Message } from '../../../shared'
export const verifyAndCreateMessage: Route = (req, res, next) => {
// извлекаем сообщение из тела запроса
// утверждение типа, альтернатива as Message
const message = <Message>req.body
// если сообщение отсутствует
if (!message) {
return res.status(400).json({ message: 'Message must be provided' })
}
// если тело сообщения включает слово "know"
if (message.body.includes('know')) {
// возвращаем сообщение об ошибке
return res.status(400).json({ message: 'Nobody knows JavaScript' })
}
// создаем и записываем сообщение в res.locals
res.locals.message = {
title: 'Message from server',
body: 'Hello from server!'
}
// передаем управление сервису
next()
}
Сервис (services/api.services.ts
):
// локальный тип
import { Route } from '../types'
export const sendMessage: Route = (req, res, next) => {
try {
// извлекаем сообщение из res.locals
const { message } = res.locals
if (message) {
res.status(200).json({ message })
} else {
res
.status(404)
.json({ message: 'There is no message for you, my friend' })
}
} catch (e) {
next(e)
}
}
В package.json
нам необходимо определить 3 вещи: основной файл сервера, тип кода сервера и команды для запуска сервера в режиме для разработки и производственном режиме. Основной файл и тип:
"main": "build/index.js",
"type": "module",
Команды для запуска сервера в режиме для разработки:
"scripts": {
"ts:watch": "tsc -w",
"node:dev": "cross-env NODE_ENV=development nodemon",
"start": "concurrently \"yarn ts:watch\" \"yarn node:dev\"",
}
tsc
означает сборку проекта — компиляцию TS
в JS
. Сборка проекта приводит к генерации директории, указанной в outDir
, т.е. build
. Флаг -w
или --watch
означает наблюдение за изменениями файлов, находящихся в корневой директории проекта, указанной в rootDir
, т.е. src
.
Для одновременного выполнения команд ts:watch
и node:dev
используется concurrently
(обратите внимание на экранирование (\"
), в JSON
можно использовать только двойные кавычки). Вообще, для одновременного выполнения команд предназначен синтаксис ts:watch & node:dev
, но это не работает в Windows
.
Команда для запуска сервера в производственном режиме:
"scripts": {
...,
"build": "tsc --build && cross-env NODE_ENV=production node build/index.js"
}
Флаг --build
предназначен для выполнения инкрементальной сборки. Это означает, что повторно собираются только модифицированные файлы, что повышает скорость повторной сборки. &&
означает последовательное выполнение команд. Для начала выполнения последующей команды необходимо завершение выполнения предыдущей команды. Поэтому при выполнении tsc -w && nodemon
, например, выполнение команды nodemon
никогда не начнется.
Обратите внимание: в данном случае расположение основного файла сервера должно быть определено в явном виде как node build/index.js
.
Проверка работоспособности
Поднимаемся на верхний уровень (ret-template
) и определяем команды для запуска серверов в package.json
:
"scripts": {
"start": "concurrently \"yarn --cwd client start\" \"yarn --cwd server start\"",
"build": "yarn --cwd client build && yarn --cwd server build"
}
Флаг --cwd
означает текущую рабочую директорию (current working directory). yarn --cwd client start
, например, означает выполнение команды start
, определенной в package.json
, находящемся в директории client
.
Выполняем команду yarn start
.
![](https://habrastorage.org/webt/r5/b0/kx/r5b0kxrolib7diipkuub1eqabbw.png)
По адресу http://localhost:8080
автоматически открывается новая вкладка в браузере.
Отправляем неправильное сообщение.
![](https://habrastorage.org/webt/vt/o5/bd/vto5bd5knyjbe4bynifpl1rfm2o.png)
Получаем сообщение об ошибке.
Отправляем правильное сообщение.
![](https://habrastorage.org/webt/ly/m9/rm/lym9rmrgrtsbqqhx68bk0t1jnwe.png)
Получаем приветствие от сервера.
Изменение любого файла в директории client
или server
, кроме файлов с настройками snowpack
и tsc
, приводит к пересборке проекта.
![](https://habrastorage.org/webt/dl/k-/rn/dlk-rnxzkdin4livnqpprcrmmm4.png)
Останавливаем сервера для разработки (Ctrl + C
или Cmd + C
).
Выполняем команду yarn build
.
![](https://habrastorage.org/webt/bj/3w/87/bj3w87glwzw-6rrxaowqutknqrc.png)
Получаем сообщения от snowpack
об успешной сборке клиента (то, что import.meta
будет пустой, нас не интересует), а также о готовности сервера обрабатывать запросы.
Переходим по адресу http://localhost:4000
. Видим полностью работоспособное приложение, обслуживаемое сервером.
![](https://habrastorage.org/webt/pj/2d/oh/pj2dohcqilkny3un_auypulh_xu.png)
Пожалуй, это все, чем я хотел поделиться с вами в этой статье.
Благодарю за внимание и happy coding!