Pull to refresh

Окружение для разработки веб-приложений на TypeScript и React: от 'hello world' до современного SPA. Часть 2

Reading time14 min
Views15K
Цель данной статьи — вместе с читателем написать окружение для разработки современных веб-приложений, последовательно добавляя и настраивая необходимые инструменты и библиотеки. По аналогии с многочисленными starter-kit / boilerplate репозиториями, но наш, собственный.

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

image

Статья не рассматривает подробный синтаксис TypeScript и основы работы с React, если вы не имеет опыта использования указанных выше технологий, рекомендуется разделить их изучение.

Ссылка на первую часть статьи

Репозиторий проекта содержит код в отдельных ветках под каждый шаг.

Главная тема 2-й части — подключение и использование менеджера состояний Redux.


Причины использования Redux, и его сравнение с другими реализациями паттерна Flux — темы для отдельных статей, эту информацию легко найти и изучить.

Отмечу несколько преимуществ — большое сообщество и экосистема, возможность полного контроля над поведением вашего приложения, легкость в тестировании, и определенные шаги в сторону изучения функционального программирования.

React-redux — небольшая библиотека, которая дает нам несколько React компонентов — Provider, для передачи Redux хранилища в контекст, и connect, компонент высшего порядка для точечной передачи и обновления данных из хранилища в свойства обернутого компонента.

Перейдем к коду!

Шаг четвертый — добавление Redux в проект, базовый hello world


Для просмотра итогового кода:

git checkout step-4

В папке src удаляем компоненты — примеры с шага №3, остаются только index.html и index.tsx.

Установка зависимостей (redux включает в исходники файл — декларацию):

npm install redux react-redux -S
npm install @types/react-redux -D

Изменение настроек проекта:

В tsconfig.json мы добавляем свойство moduleResolution: node, для того что бы компилятор находил декларации, определенные в package.json библиотеки (в нашем случае redux):

tsconfig.json
{
  "compilerOptions": {
    "lib": [
      "es5",
      "es6",
      "es7",
      "dom"
    ],
    "target": "es5",
    "module": "esnext",
    "jsx": "react",
    "moduleResolution": "node"
  }
}


Создадим простые действия и редьюсер для будущего хранилища, используя методологию модулей ducks.

В папке с исходниками создаем папку redux для хранения ducks модулей. Внутри создадим файл field.ts:

field.ts

/**
 * State
 * 
 * Для начала определим интерфейс этого кусочка хранилища, 
 * и создадим объект с начальным состоянием.
 */
export interface FieldState {
    value: string;
    focus: boolean;
}

const initialState: FieldState = {
    value: '',
    focus: false
}

/**
 * Constants
 * 
 * Каждой константе помимо переменной, необходимо задать тип. 
 * Это тянет дополнительную строку кода под константу, но благодаря этому 
 * мы можем создавать интерфейсы действий с уникальными типами.
 */
const SET = 'field/SET';
type SET = typeof SET;

const FOCUS = 'field/FOCUS';
type FOCUS = typeof FOCUS;

const BLUR = 'field/BLUR';
type BLUR = typeof BLUR;

/** 
 * Actions
 * 
 * Используя Redux с TypeScript, достаточно определить интерфейс возможных 
 * действий, и определить общий тип (FieldAction) для редьюсера
 */
export interface SetAction {
    type: SET;
    payload: string;
}

export interface FocusAction {
    type: FOCUS;
}

export interface BlurAction {
    type: BLUR;
}

type FieldAction = SetAction | FocusAction | BlurAction;

/** 
 * Reducer
 * 
 * В редьюсере мы указываем, что получим в качестве аргумента, и 
 * вернем один и тот же объект. 
 * Аргумент action позволяет проверить только те действия, которые 
 * мы добавили в общий тип действий (FieldAction), и благодаря 
 * интерфейсам в каждом условном блоке (например case SET) мы знаем 
 * точное содержимое аргумента action.
 */
export default function reducer(state: FieldState = initialState, action: FieldAction): FieldState {
    switch (action.type) {
        case SET:
            return {
                ...state,
                value: action.payload
            }
        case FOCUS:
            return {
                ...state,
                focus: true
            }
        case BLUR:
            return {
                ...state,
                focus: false
            }
        default:
            return state;
    }
}

/** 
 * Action Creators
 * 
 * Для отправки действий используются создатели действий, 
 * благодаря указанию результата вызова функции как интерфейс 
 * конкретного действия, мы не отправим неправильные данные в 
 * хранилище.
 */
export const set = (payload: string): SetAction => ({
    type: SET,
    payload
});

export const focus = (): FocusAction => ({ type: FOCUS });

export const blur = (): BlurAction => ({ type: BLUR });


Добавим в папку redux файл index.ts — именно его мы будем импортировать в хранилище в качестве корневого редьюсера (rootReducer).

redux/index.ts

import { combineReducers } from 'redux';
import fieldReducer from './field';

export default combineReducers({
    field: fieldReducer
})


Далее мы будем использовать инструменты для разработки на Redux — Redux DevTools.
В папке с исходниками создаем папку store, внутри файл index.ts:

store/index.ts

import { createStore } from 'redux';
import rootReducer from '../redux';
import { FieldState } from '../redux/field';

/**
 * Интерфейс хранилища будет использоваться в каждом mapStateToProps, 
 * и в остальных местах, где мы напрямую получаем состояние хранилища 
 * (например, в асинхронных действиях с redux-thunk)
 */
export interface IStore {
    field: FieldState
}

/**
 * Этот же интерфейс указывается в качестве состояния при инициализации хранилища.
 */
const configureStore = (initialState?: IStore) => {
    return createStore(
        rootReducer,
        initialState,
        window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
    )
};

export default configureStore;


Компилятор TypeScript ничего не знает о свойстве __REDUX_DEVTOOLS_EXTENSION__ глобального объекта window, поэтому пришло время добавить свои декларации.

Далее в эти декларации мы будем добавлять глобальные флаги, которые будем передавать через Webpack, например __DEV__ или __PRODUCTION__.

В корневой папке создаем папку typings, внутри файл window.d.ts:

window.d.ts

interface Window {
    __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any;
    __REDUX_DEVTOOLS_EXTENSION__: any;
}


Далее, напишем компонент, который получает данные из хранилища, и вызывает его обновление. Для упрощения, не будет разделения на компоненты и контейнеры. В папке с исходниками создаем папку components, внутри файл Field.tsx:

Field.tsx

import * as React from 'react';
import { connect, Dispatch, DispatchProp } from 'react-redux';
import { IStore } from '../store';
import { set, focus, blur } from '../redux/field';
/**
 * Наследование от DispatchProp указывает, что метод dispatch ожидается 
 * в свойствах компонента. Это свойство добавляет connect, вызванный с одним 
 * аргументом (без mapDispatchToProps) 
 */
interface FieldProps extends DispatchProp<IStore>, React.HTMLProps<HTMLInputElement> {
    value?: string;
}

class Field extends React.Component<FieldProps, {}> {
    handleChange = (event: React.FormEvent<HTMLInputElement>) => {
        const { dispatch } = this.props;
        const value = event.currentTarget.value;
        /**
         * Любой неверный аргумент в set или dispatch будет 
         * безжалостно подсвечен красненьким.
         */
        dispatch(set(value));
    }

    handleFocus = () => {
        const { dispatch } = this.props;
        dispatch(focus());
    }

    handleBlur = () => {
        const { dispatch } = this.props;
        dispatch(blur());
    }

    render() {
        const {
            value,
            dispatch,
            ...inputProps
        } = this.props;

        return (
            <input
                {...inputProps}
                type="text"
                value={value}
                onChange={this.handleChange}
                onFocus={this.handleFocus}
                onBlur={this.handleBlur}
            />
        );
    }
}

/**
 * Стандартная сигнатура mapStateToProps, и благодаря (в очередной раз) 
 * интерфейсам мы пользуемся автоподбором всех свойств аргументов
 */
const mapStateToProps = (state: IStore, ownProps: FieldProps) => ({
    value: state.field.value
});

/**
 * Сигнатура mapDispatchToProps:
 * (dispatch: Dispatch<IStore>, ownProps: FieldProps) => ({ ... })
 */

 /**
  * connect имеет сложную сигнатура, точнее более 10 сигнатур на 
  * все случаи жизни.
  * По хорошему, нужно создавать 3 интерфейса:
  * результат mapStateToProps, результат mapDispatchToProps, и 
  * собственные свойства компонента.
  * На практике, достаточно указать дженерику два первых пустых объекта, 
  * и в третий аргумент отправить единый интерфейс свойств компонента.
  */
export default connect<{}, {}, FieldProps>(mapStateToProps)(Field);


И наконец-то соберем все в нашем приложении, в точке входа — src/index.tsx:

src/index.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from './store';
import Field from './components/Field';

/**
 * То самое место, где качестве initialState ожидают 
 * window.__INITIAL_STATE__, но в нашем случае можно передать 
 * подходящий объект или пустоту.
 */
const store = configureStore();

/**
 * Все изменения значения поля ввода вы можете отследить в 
 * панели DevTools
 */
const App = () => (
    <Provider store={store}>
        <div>
            <h1>Hello, Redux!</h1>
            <Field placeholder='I like dev tools!' />
        </div>
    </Provider>
);

ReactDOM.render(<App />, document.getElementById('root'));


Шаг пятый — несколько рецептов Redux на Typescript


Для просмотра итогового кода:

git checkout step-5

Рецепт первый — middleware.

В папке с исходниками создаем папку middlewares, внутри файл logger.ts (код взят из официальной документации):

middlewares/logger.ts
import { Middleware, MiddlewareAPI, Dispatch, Action } from 'redux';
import { IStore } from '../store';

/**
 * В дженерик аргумента store - MiddlewareAPI<S & IStore> - мы добавляем 
 * интерфейс нашего хранилища, для возможности обратиться к любому 
 * заданному нами состоянию хранилища. Это не обязательно для логгера, 
 * но пригодится для middleware с бизнес-логикой.
 */
const logger: Middleware = <S>(store: MiddlewareAPI<S & IStore>) => 
    (next: Dispatch<S>) => 
        // Правильная типизация - <A extends Action>(action: A), обычно слишком многословна.
        (action: any) => {
            // Пример доступа к хранилищу
            store.getState().field.value;
            
            console.log('dispatching', action);
            let result = next(action);
            console.log('next state', store.getState());
            return result;
}

export default logger;


Обновим код для создания нашего хранилища:

store/index.ts
import { createStore, compose, applyMiddleware } from 'redux';
import rootReducer from '../redux';
import { FieldState } from '../redux/field';
import logger from '../middlewares/logger';

export interface IStore {
    field: FieldState
}

let composeEnhancers = compose;
// готовим почву для добавления других middleware
const middlewares = [
    logger
];

if (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) {
    composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
}

const configureStore = (initialState?: IStore) => {
    return createStore(
        rootReducer,
        initialState,
        composeEnhancers(
            applyMiddleware(...middlewares)
        )
    )
};

export default configureStore;


Рецепт второй — редьюсер высшего порядка.

В папке redux, создаем файл createNamedReducer.ts (код взят из официальной документации):

createNamedReducer.ts
import { Reducer, Action } from 'redux';
import { IStore } from '../store';

/**
 * От этого интерфейса можно наследовать действия, для 
 * редьюсеров, обернутых в createNamedReducer
 */
export interface namedAction extends Action {
  name: string;
}

function createNamedReducer<S>(reducer: Reducer<S>, reducerName: string): Reducer<S> {
    return (state: S, action: namedAction) => {
        const { name } = action;
        const isInitializationCall = state === undefined;

        if (name !== reducerName && !isInitializationCall) {
            return state;
        }

        return reducer(state, action);    
    }
}

export default createNamedReducer;


Шаг шестой — работа с API


Для просмотра итогового кода:

git checkout step-6

Внимание! Я предпочитаю выносить методы для работы с API в отдельные сервисы, и привязывать данные к хранилищу, вызывая эти методы внутри thunk действий.

Но существуют такие библиотеки, как redux-axios-middleware и redux-api, которые созданы для уменьшения шаблонного кода и создают обертку над созданием http запросов.

Поэтому я хотел бы дополнить эту статью вашими советами и комментариями по связке Redux с REST API, и в будущем подробно описать самые популярные техники.

Для моков API воспользуемся сервисом jsonplaceholder.

Установка зависимостей (обе библиотеки содержат декларации):

npm install axios redux-thunk -S

Создадим папку services в исходниках проекта, внутри файлы client.ts и users.ts:

client.ts
import axios from 'axios';

/**
 * Этот экземпляр клиента мы можем использовать внутри redux 
 * действий, для добавления токенов авторизации и других заголовков.
 */
const client = axios.create({
    baseURL: 'https://jsonplaceholder.typicode.com'
});

export default client;


users.ts
import { AxiosPromise } from 'axios';
import client from './client';

// Опишем упрощенный интерфейс ответа от сервера
export interface IUser {
    id: number;
    name: string;
    username: string;
    email: string;
    address: any;
    phone: string;
    website: string;
    company: any;
}

export function get(id: number): AxiosPromise<IUser> {
    return client.get(`/users/${id}`);
}

export function getList(): AxiosPromise<IUser[]> {
    return client.get('/users');
}


Далее создадим новый ducks модуль users.ts, как раз на этом этапе и возникает множество вопросов, и множество вариантов их разрешения:

redux/users.ts
import { Dispatch } from 'redux';
import { IStore } from '../store';
import * as client from '../services/users';

// Упрощаем интерфейс серверной ошибки
type Error = any;

// Типовой интерфейс для хранения состояния любого http запроса
interface AsyncState<D> {
    isFetching: boolean;
    error: Error;
    data: D;
}

// Для уменьшения количества действий, определяем их состояние по статусам
interface AsyncAction<P> {
    status?: 'error' | 'success';
    payload?: P | Error;
}

/**
 * State
 */
export interface UsersState {
    get: AsyncState<client.IUser>;
    getList: AsyncState<client.IUser[]>;
}

const initialState: UsersState = {
    get: {
        isFetching: false,
        error: null,
        data: null
    },
    getList: {
        isFetching: false,
        error: null,
        data: []
    }
}

/**
 * Constants
 */
const GET = 'users/GET';
type GET = typeof GET;

const GET_LIST = 'users/GET_LIST';
type GET_LIST = typeof GET_LIST;

/** 
 * Actions
 */
export interface GetAction extends AsyncAction<client.IUser> {
    type: GET;
}

export interface GetListAction extends AsyncAction<client.IUser[]> {
    type: GET_LIST;
}

type UsersAction = GetAction | GetListAction;

/** 
 * Reducer
 * 
 * Здравствуй, шаблонный код!
 * На самом деле, в любом приложении возможно большое количество 
 * нюансов для разных запросов, поэтому непосредственно обновленное 
 * состояние сложно унифицировать.
 * Но можно унифицировать создатели действий, и обработку нужного статуса 
 * запроса, главное - сохранить чистоту интерфейсов. 
 */
export default function reducer(state: UsersState = initialState, action: UsersAction): UsersState {
    switch (action.type) {
        case GET:
            if (!action.status) {
                return {
                    ...state,
                    get: {
                        ...state.get,
                        isFetching: true,
                        error: null
                    }
                }
            }

            if (action.status === 'error') {
                return {
                    ...state,
                    get: {
                        isFetching: false,
                        error: action.payload,
                        data: null
                    }
                }
            }

            return {
                ...state,
                get: {
                    isFetching: false,
                    error: null,
                    data: action.payload
                }
            }
        case GET_LIST:
            if (!action.status) {
                return {
                    ...state,
                    getList: {
                        ...state.getList,
                        isFetching: true,
                        error: null
                    }
                }
            }

            if (action.status === 'error') {
                return {
                    ...state,
                    getList: {
                        isFetching: false,
                        error: action.payload,
                        data: []
                    }
                }
            }

            return {
                ...state,
                getList: {
                    isFetching: false,
                    error: null,
                    data: action.payload
                }
            }
        default:
            return state;
    }
}

/** 
 * Action Creators
 */
export const getActionCreator = (
    status?: 'error' | 'success',
    payload?: client.IUser | Error
): GetAction => ({
    type: GET,
    status,
    payload,
});

export const getListActionCreator = (
    status?: 'error' | 'success',
    payload?: client.IUser[] | Error
): GetListAction => ({
    type: GET_LIST,
    status,
    payload,
});

/**
 * Thunk Actions
 */
 export function get(id: number) {
    return async (dispatch: Dispatch<IStore>, getState: () => IStore) => {
        dispatch(getActionCreator());

        try {
            const response = await client.get(id);
            dispatch(getActionCreator('success', response.data));
        } catch (e) {
            dispatch(getActionCreator('error', e));
            throw new Error(e);
        }
    }
}

export function getList() {
   return async (dispatch: Dispatch<IStore>, getState: () => IStore) => {
       dispatch(getListActionCreator());

       try {
           const response = await client.getList();
           dispatch(getListActionCreator('success', response.data));
       } catch (e) {
           dispatch(getListActionCreator('error', e));
           throw new Error(e);
       }
   }
}


Обновляем rootReducer и интерфейс хранилища, добавляем thunk-middleware:

redux/index.ts
import { combineReducers } from 'redux';
import fieldReducer from './field';
import usersReducer from './users';

export default combineReducers({
    field: fieldReducer,
    users: usersReducer
});


store/index.ts
import { createStore, compose, applyMiddleware } from 'redux';
import ReduxThunk from 'redux-thunk'
import rootReducer from '../redux';
import { FieldState } from '../redux/field';
import { UsersState } from '../redux/users';
import logger from '../middlewares/logger';

export interface IStore {
    field: FieldState,
    users: UsersState
}

let composeEnhancers = compose;
const middlewares = [
    logger,
    ReduxThunk
];

if (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) {
    composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
}

const configureStore = (initialState?: IStore) => {
    return createStore(
        rootReducer,
        initialState,
        composeEnhancers(
            applyMiddleware(...middlewares)
        )
    )
};

export default configureStore;


Далее напишем компонент, который выводи список пользователей, сообщение об ошибке, или условный прелоадер:

Users.tsx
import * as React from 'react';
import { connect, Dispatch, DispatchProp } from 'react-redux';
import { IStore } from '../store';
import { getList, Error } from '../redux/users';
import { IUser } from '../services/users';

interface UsersProps extends DispatchProp<IStore> {
    isFetching?: boolean;
    error?: Error;
    users?: IUser[];
}

class Users extends React.Component<UsersProps, {}> {
    componentDidMount() {
        const { dispatch } = this.props;
        dispatch(getList());
    }

    render() {
        const { isFetching, error, users } = this.props;

        if (error) {
            return <b>Произошла ошибка!</b>
        }

        if (isFetching) {
            return '...';
        }

        return users.map((user) => <div>{user.name}</div>);
    }
}

const mapStateToProps = (state: IStore, ownProps: UsersProps) => ({
    isFetching: state.users.getList.isFetching,
    error: state.users.getList.error,
    users: state.users.getList.data
});

export default connect<{}, {}, UsersProps>(mapStateToProps)(Users);


Далее просто вызываем компонент <Users /> в корневом компоненте нашего приложения.

Вопросы без однозначных ответов:

Надо ли хранить объект запроса в хранилище, и какие преимущества это может дать? Возможно, это упростит отмену запросов.

Как поступать, когда один GET запрос с динамическим :id в url, используют множество компонентов, на одном экране?

Аналогичная проблема с асинхронным автокомплитом, когда на один запрос уходят разные параметры. Можно кэшировать ответы, но в таких случаях так же понадобится отслеживать отдельно состояния каждого из запросов, что требует отдельных редьюсеров.

Имеет ли смысл использовать компоненты, которые добавляют редьюсеры динамически, под один конкретный запрос, или часть асинхронных данных, которые используются только локально, вообще не нужно хранить в Redux?

Напишем обстоятельный комментарий к статье о том, как мы работаем с API в наших React + Redux приложениях, и перейдем к следующему шагу.

Шаг седьмой — production и development сборка


Для просмотра итогового кода:

git checkout step-7

1) Кроссбраузерность
Установка зависимостей:

npm install core-js -S
npm install @types/core-js -D

Core-js — библиотека с полифиллами современных JS конструкций. Импорт модуля core-js/shim практически аналогичен использованию плагина babel-polyfill.

Мы используем только несколько необходимых полифиллов, добавим их в начало точки входа в приложение:

src/index.ts
import 'core-js/es6/promise';
import 'core-js/es6/map';
import 'core-js/es6/set';

if (typeof window.requestAnimationFrame !== 'function') {
    window.requestAnimationFrame = (callback: FrameRequestCallback) => window.setTimeout(callback, 0);
}

...


В файле tsconfig.json свойство «target» уже указано как «es5», поэтому нет необходимости в большинстве полифиллов. Текущая сборка поддерживает IE9+.

1) Production сборка

На этом этапе нам необходимо добавить параметры сборки, изменить сами настройки webpack и отправить значение process.env.NODE_ENV в качестве глобального параметра — некоторые библиотеки, например React, используют prod или dev исходники в зависимости от этого параметра.

Установка зависимостей:

npm install better-npm-run -D

better-npm-run — прокачивает наши npm скрипты.

Отредактируем npm скрипты в package.json, переменные окружения очень удобно определяются в блоке «betterScripts»:

package.json
{
  ...
  "scripts": {
    "start": "better-npm-run dev",
    "build": "better-npm-run build"
  },
  "betterScripts": {
    "dev": {
      "command": "webpack-dev-server",
      "env": {
        "NODE_ENV": "development"
      }
    },
    "build": {
      "command": "webpack",
      "env": {
        "NODE_ENV": "production"
      }
    }
  },
  ...
}


Когда настройки webpack усложняются, на помощь приходит плагин webpack-merge — в данный момент мы не будем его использовать, что бы не усложнять код.

Изменения в webpack.config.js:

webpack.config.js
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');

// определяем переменные окружения
const env = process.env.NODE_ENV;
const __DEV__ = env === 'development';
const __PRODUCTION__ = env === 'production';

const paths = {
    src: path.resolve(__dirname, 'src'),
    dist: path.resolve(__dirname, 'dist')
};

const config = {
    context: paths.src,
    
    entry: {
        app: './index'
    },
    
    // предотвращаем кэширование обновленных результатов сборки, добавляя хэш
    output: {
        path: paths.dist,
        filename: __PRODUCTION__ ? '[name].bundle.[chunkhash].js' : '[name].bundle.js',
        chunkFilename: __PRODUCTION__ ? '[name].bundle.[chunkhash].js' : '[name].bundle.js'
    },
    
    resolve: {
        extensions: ['.ts', '.tsx', '.js', '.jsx']
    },
    
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                loader: 'awesome-typescript-loader'
            }
        ]
    },
    
    plugins: [
        // отправляем значение NODE_ENV в качестве глобального параметра
        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify(env)
        }),
        new HtmlWebpackPlugin({
            template: './index.html'
        }),
        // ускорение выполнения кода в браузере
        new webpack.optimize.ModuleConcatenationPlugin()
    ]
};

if (__DEV__) {
    // выносим source map в development сборку
    config.devtool = 'inline-source-map';
}

if (__PRODUCTION__) {
    config.plugins.push(new CleanWebpackPlugin(['dist']));
    // минификация сборки
    config.plugins.push(new webpack.optimize.UglifyJsPlugin());
}

module.exports = config;


Используем команду для production сборки:

npm run build

По завершению сборки, мы получаем общий бандл размером около 180кб, примерно 55кб gzipped. В дальнейшем, библиотеки из node_modules можно выносить в отдельный бандл, используя CommonsChunkPlugin.

Темы для следующих статей: роутинг, создание прогрессивного веб-приложения (PWA), серверный рендеринг, тестирование с Jest.

Благодарю за внимание!
Tags:
Hubs:
+6
Comments2

Articles