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

от автора

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

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



ссылка на оригинал статьи https://habr.com/ru/articles/596149/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *