Pull to refresh
1777.54
Timeweb Cloud
То самое облако

Разрабатываем шаблон React + Express + TypeScript приложения

Reading time13 min
Views14K


Привет, друзья!


В этой статье я хочу показать вам, как создать шаблон 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>&copy; 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!




Tags:
Hubs:
Total votes 6: ↑6 and ↓0+6
Comments4

Articles

Information

Website
timeweb.cloud
Registered
Founded
Employees
201–500 employees
Location
Россия
Representative
Timeweb Cloud