
Привет, друзья!
В этой статье я хочу показать вам, как создать шаблон 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.jsontypescript— компилятор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-заголовков, связанных сCORScross-env— утилита для платформонезависимой передачи переменных среды окружения (process.env)
Устанавливаем зависимости для разработки:
yarn add -D typescript nodemon @types/cors @types/express @types/helmet @types/node
@types— типы для соответствующих утилит иNode.jsnodemon— утилита для запуска сервера для разработки
Структура сервера:
- 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с помощьюtscrootDir— корневая директория для предотвращения лишней вложенности сборки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!

