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


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




Комментарии (4)


  1. SabMakc
    17.12.2021 17:49

    Хорошее описание, особенно понравились комментарии - очень хорошо поясняют, что происходит.

    Но есть небольшие замечания по оформлению:

    • Клиент: не приведены стили (App.scss). Поcле запуска выдишь совсем другое, относительно приведенных скриншотов. И это сильно портит первое впечатление от запуска.

    • Структура сервера: местами съехала структура / форматирование или еще что - index.ts должен быть в src, а отображается в корне. Съехал и onError.ts.

    И не совсем понимаю, почему сервер при сборке должен стартовать: "build": "tsc --build && cross-env NODE_ENV=production node build/index.js".


  1. Psychosynthesis
    18.12.2021 00:36

    TypeScript не стандарт, а пятое колесо, которое всё коммьюнити без всякой веской причины тащит абсолютно во все проекты, даже туда где он по сути ничего кроме дополнительного времени на описание типов не приносит.


    1. Keyten
      18.12.2021 11:19

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


      1. Psychosynthesis
        18.12.2021 14:50

        Я готов признать что TS хорош в больших проектах, где над разными частями проекта работают разные люди.

        Но сейчас его тащат абсолютно в любой лендинг, это превратилось в настоящую истерию.