Привет, друзья!
В этой статье я хочу показать вам, как создать шаблон React.js + Express.js + TypeScript приложения.
Обоснование используемых технологий (сугубо личное мнение, которое не обязательно должно совпадать с вашим):
Исходный код проекта.
Если вам это интересно, прошу под кат.
Здесь вы найдете шпаргалку по 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
Общими командами для запуска серверов мы займемся чуть позже.
Создаем директорию 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"
}
]
}
Здесь:
Редактируем настройки в 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.
При попытке отправить любое сообщение, получаем ошибку Failed to fetch.
Логично, ведь у нас еще нет сервера. Давайте это исправим.
Создаем новую директорию, переходим в нее и инициализируем Node.js-проект:
mkdir server
cd server
yarn init -yp
Устанавливаем основные зависимости:
yarn add express helmet cors concurrently cross-env
Устанавливаем зависимости для разработки:
yarn add -D typescript nodemon @types/cors @types/express @types/helmet @types/node
Структура сервера:
- 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"
}
]
}
Здесь:
Код сервера (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.
По адресу http://localhost:8080 автоматически открывается новая вкладка в браузере.
Отправляем неправильное сообщение.
Получаем сообщение об ошибке.
Отправляем правильное сообщение.
Получаем приветствие от сервера.
Изменение любого файла в директории client или server, кроме файлов с настройками snowpack и tsc, приводит к пересборке проекта.
Останавливаем сервера для разработки (Ctrl + C или Cmd + C).
Выполняем команду yarn build.
Получаем сообщения от snowpack об успешной сборке клиента (то, что import.meta будет пустой, нас не интересует), а также о готовности сервера обрабатывать запросы.
Переходим по адресу http://localhost:4000. Видим полностью работоспособное приложение, обслуживаемое сервером.
Пожалуй, это все, чем я хотел поделиться с вами в этой статье.
Благодарю за внимание и happy coding!
В этой статье я хочу показать вам, как создать шаблон 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.
При попытке отправить любое сообщение, получаем ошибку Failed to fetch.
Логично, ведь у нас еще нет сервера. Давайте это исправим.
Сервер
Создаем новую директорию, переходим в нее и инициализируем 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.
По адресу http://localhost:8080 автоматически открывается новая вкладка в браузере.
Отправляем неправильное сообщение.
Получаем сообщение об ошибке.
Отправляем правильное сообщение.
Получаем приветствие от сервера.
Изменение любого файла в директории client или server, кроме файлов с настройками snowpack и tsc, приводит к пересборке проекта.
Останавливаем сервера для разработки (Ctrl + C или Cmd + C).
Выполняем команду yarn build.
Получаем сообщения от snowpack об успешной сборке клиента (то, что import.meta будет пустой, нас не интересует), а также о готовности сервера обрабатывать запросы.
Переходим по адресу http://localhost:4000. Видим полностью работоспособное приложение, обслуживаемое сервером.
Пожалуй, это все, чем я хотел поделиться с вами в этой статье.
Благодарю за внимание и happy coding!
Разрабатываем шаблон React + Express + TypeScript приложения
Привет, друзья! В этой статье я хочу показать вам, как создать шаблон React.js + Express.js + TypeScript приложения. Обоснование используемых технологий (сугубо личное мнение, которое не обязательно...
habr.com