Pull to refresh

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

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

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

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

image

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

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

Немного об используемых технологиях:


Написание проекта на TypeScript влечет за собой множество трудностей, особенно при первом знакомстве с языком. На взгляд автора, преимущества строгой типизации стоят этих усилий.

Помимо возможностей самого языка, компилятор TypeScript генерирует JavaScript код под все версии стандарта, и позволяет отказаться от использования Babel в проекте (автор не имеет ничего против этого замечательного инструмента, но одновременное использование TS и Babel вносит небольшую путаницу на старте).

React — зарекомендовавшая себя библиотека для создания веб-интерфейсов, с огромным сообществом и инфраструктурой.

Недавно вышла новая версия библиотеки со множеством улучшений и переработанной документацией.

Для сборки проекта мы будем использовать Webpack — лучший друг frontend разработчика. Базовые настройки этого инструмента очень просты в изучении и использовании. Серьезно.

Используемые версии инструментов и библиотек
NodeJs v6.*.*
Npm v5.*.*
TypeScript v2.*.*
Webpack v3.*.*
React v16.*.*

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

Шаг первый — добавление TypeScript в проект.


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

git checkout step-1

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

npm i webpack typescript awesome-typescript-loader --save-dev
awesome-typescript-loader — TypeScript загрузчик для webpack, считается быстрее основного конкурента — ts-loader.

Для исходников нашего проекта создадим папку src.
Результаты сборки будем отправлять в dist.

Базовые настройки для компилятора TypeScript — файл tsconfig.json в корневой директории проекта

tsconfig.json
{
  "compilerOptions": {
    "target": "es5", // компилируем ts код в js код версии ES5
    "module": "esnext" // для поддержки динамического импорта модулей
  }
}


Базовые настройки сборщика — файл webpack.config.js в корневой директории проекта:

webpack.config.js
const path = require('path');
const webpack = require('webpack');

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

module.exports = {
    context: paths.src, // базовая директория для точек входа и загрузчиков    
    entry: {
        app: './index'  // точка входа в приложение, наш src/index.ts файл, названием итогового бандла будет имя свойства - app
    },
    
    output: {
        path: paths.dist,  // путь для результатов сборки 
        filename: '[name].bundle.js'  // название итогового бандла, получится dist/app.bundle.js
    },
    
    resolve: {
        extensions: ['.ts'] // указание расширений файлов, которые webpack будет обрабатывать, и пытаться добавить автоматически (например получив запрос на index, не найдет его и попробует index.ts)
    },

    devtool: 'inline-source-map', // дополнительные настройки и загрузчики не требуются, хотя даже официальный рецепт от TypeScript рекомендует source-map-loader и поле в tsconfig - "sourceMap": true 
    
    module: {
        rules: [
            {
                test: /\.ts$/,
                loader: 'awesome-typescript-loader'
            } // загрузчик для обработки файлов с расширением .ts
        ]
    }
};


Внутри src создадим файл index.ts с любым кодом, использующим синтаксис TypeScript, например:

index.ts
interface Props {
    world: string;
}
  
function hello(props: Props) {
    alert(`Hello, ${props.world}`);
}

hello({ world: 'TypeScript!' });


Команда для компиляции и сборки нашего кода:
webpack — разовый билд проекта

В итоговом файле dist/app.bundle.js внутри webpack модулей вы увидите аккуратный и читаемый JavaScript код выбранной нами версии стандарта.

Созданное нами окружение легко расширить любыми библиотеками, и удобно использовать для создания прототипов (Ваша Любимая Технология + TypeScript).

Идем дальше!

Шаг второй — создание крохотного React приложения.


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

git checkout step-2

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

npm i webpack react react-dom --save
npm i webpack @types/react @types/react-dom html-webpack-plugin clean-webpack-plugin --save-dev

html-webpack-plugin — плагин для генерации html-файла с подключенными результатами сборки.
clean-webpack-plugin — для очистки директории с результатами сборки.
@types/react и @types/react-dom — пакеты с декларацией соответствующих JS библиотек, дающие компилятору TS информацию о типах всех экспортируемых модулей.

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

Внутри src создадим файл index.html с элементов для монтирования корневого react компонента:

index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>React and Typescript</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>


Обновляем настройки webpack:

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

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: '[name].bundle.js'
    },
    
    resolve: {
        extensions: ['.ts', '.tsx', '.js', '.jsx'] // добавляем расширение tsx для файлов с react компонентами
    },

    devtool: 'inline-source-map',
    
    module: {
        rules: [
            {
                test: /\.tsx?$/, // добавляем расширение tsx для файлов с react компонентами
                loader: 'awesome-typescript-loader'
            }
        ]
    },
    
    plugins: [
        new CleanWebpackPlugin(['dist']),
        new HtmlWebpackPlugin({
            template: './index.html'
        }) // генерация html-файла на основе нашего шаблона
    ]
};

module.exports = config;


Обновим настройки компилятора TypeScript:

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "esnext",
    "jsx": "react" // опция для поддержи синтаксиса JSX
  }
}


Перейдем к компонентам.

Необходимо изменить расширение у index.ts на index.tsx. Напишем код нашего компонента, и отобразим его на странице:

index.tsx
// импорт react в TS отличается от привычного import React from 'react' из-за особенностей модульной системы в TS
import * as React from 'react';
import * as ReactDOM from 'react-dom';

// необходимо описывать интерфейсы для props и state компонентов
interface IAppProps {
    title: string;
}

// функциональный компонент
const App = (props: IAppProps) => <h1>{props.title}</h1>;

ReactDOM.render(
    <App title="Hello, React!" />,
    document.getElementById('root')
);


Добавим команду для компиляции и сборки нашего кода:

webpack-dev-server — поднимаем сервер с нашим приложением, страница index.html будет доступна по адресу — http://localhost:8080/.
Так же, webpack будет осуществлять автоматическую пересборку проекта, при изменении исходных файлов.

На данном этапе могут возникнуть вопросы к размеру сборки — автор уделит особое внимание разделению на production и development сборки, в следующих шагах. На первых этапах стоит акцент на минимально необходимых настройках и библиотеках, для полного осознания процесса.

Шаг третий — рецепты приготовления React и TypeScript


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

git checkout step-3

Зависимости на этом шаге не меняются.
Рекомендуется ознакомиться с обобщениями на этом этапе — generics (дженерики)

Более подробно о стандартных React паттернах можно узнать из этой статьи.

1) Стандартный компонент, имеющий свойства и состояние
Создадим компонент simple.tsx, который будет выводит контролируемое поле ввода:

simple.tsx
import * as React from 'react';

/**
 * Описываем доступные свойства для компонента.
 * Наследование от дженерика React.HTMLProps позволяет не писать руками
 * все стандартные атрибуты для поля ввода.
 */
interface Props extends React.HTMLProps<HTMLInputElement> {
    customProperty: string;
}

// Описываем состояние компонента
interface State {
    value: string;
}

// указываем дженерику React.Component интерфейсы наших свойств и состояния
class Simple extends React.Component<Props, State> {
    // Объявление состояния по умолчанию, в качестве свойства класса
    state: State = {
        value: ''
    }

    /*
     * Для обработки события onChange на поле ввода, используем соответствующую сигнатуру 
     * свойства onChange у JSX элемента input.
     * Примеры дженериков на другие события - MouseEvent, FocusEvent, KeyboardEvent
     */
    handleChange = (event: React.FormEvent<HTMLInputElement>) => {
        const value = event.currentTarget.value;
        this.setState(() => ({ value }));
    }

    render() {
        /*
         * Так как мы наследовались от HTMLProps для HTMLInputElement, можем записать
         * кастомные свойства в переменные, а для записи всех свойств, поддерживаемых
         * полем ввода, использовать оператор расширения.
         */
        const {
            customProperty,
            ...inputProps
        } = this.props;
        const { value } = this.state;

        /*
         * <input {...inputProps} /> - это короткая запись для применения всех свойств объекта
         * к JSX элементу (превратится в placeholder={inputProps.placeholder} 
         * и так далее для всех указанных свойств)
         * Поставив свойства value и onChange после оператора расширения {...inputProps}, мы 
         * гарантируем перезапись одноименных свойств, содержащихся в inputProps
         */
        return (
            <div>
                <h4>{customProperty}</h4>
                <input
                    {...inputProps}
                    value={value}
                    onChange={this.handleChange}
                />
             </div>
        );
    }
}

export default Simple;


2) Компонент высшего порядка
Описание компонентов высшего порядка в официальной документации React — по ссылке
Статья, подробно расписывающая написание компонента высшего порядка на TypeScript (примеры из этой статьи частично заимствованы автором) — по ссылке

Если коротко, компонент высшего порядка (далее hoc) — это функция, которая принимает аргументом компонент (и по желанию дополнительные опции), и возвращает новый компонент, который выводит старый в методе render, передавая ему свои свойства и состояние.

Сигнатура выглядит так: (Component) => WrapComponent => Component

Так как TypeScript строго следит за тем, какие свойства мы передаем в компоненты, нам нужно определиться с интерфейсами этих свойств.
OriginProps — уникальные свойства компонента, hoc ничего о них не знает, только передает в компонент.
ExternalProps — уникальные свойства hoc.
InjectedProps — свойства, которые мы будем передавать в компонент из hoc, рассчитываются на основе ExternalProps и State.
State — интерфейс состояния hoc. Так как мы будем передавать компоненту все состояние hoc, State не может иметь свойств, которые отличаются от InjectedProps (либо мы должны передавать доступные свойства, не используя оператор расширения).

Перейдем к коду, напишем простой счетчик нажатий кнопки.
Создадим папку hoc, в ней компонент displayCount.tsx и hoc withCount.tsx

код компонента displayCount.tsx
import * as React from 'react';
import { InjectedProps } from './withCount';

// уникальные свойства компонента
interface OriginProps {
    title: string;
}

/*
 * Объединяем уникальные и внешние свойства, что бы иметь возможность передавать
 * в компонент InjectedProps, которые добавляет withCount
 */
const DisplayCount = (props: OriginProps & InjectedProps) => (
    <div>
        <h4>{props.title}</h4>
        <div>Count: {props.count}</div>
    </div>
);

export default DisplayCount;


код компонента withCount.tsx
import * as React from 'react';

// свойства, которые hoc добавит компоненту
export interface InjectedProps {
    count: number;
}

// свойства, которые нужны только hoc
interface ExternalProps {
    increment: number;
}

// состояние hoc, в этом случае идентично InjectedProps, так как именно его мы будем передавать в компонент
interface State {
    count: number;
}

/**
  * Объявляем функцию, наш компонент высшего порядка, как дженерик, 
  * в который мы будем передавать OriginProps - уникальные свойства компонента.
  * Дженерик React.ComponentType - означает ComponentClass или StatelessComponent, 
  * то есть компонент, объявленный как класс, или функциональный компонент.
  * Функция ожидает, что интерфейс свойств компонента будет смесью его уникальных свойств, 
  * и свойств которые добавит hoc - OriginProps & InjectedProps
  */
function withCount<OriginProps>(Component: React.ComponentType<OriginProps & InjectedProps>) {
    // Интерфейс свойств нового компонента
    type ResultProps = OriginProps & ExternalProps;

    return class extends React.Component<ResultProps, State> {
        /**
         * Имя компонента доступно в свойстве name или displayName, 
         * изменяем имя нового компонента, для удобного отображения в React DevTools
         */
        static displayName = `WithCount(${Component.displayName || Component.name})`;

        state: State = {
            count: 0
        }

        increment = () => {
            const { increment } = this.props;
            this.setState((prevState: State) => ({ count: prevState.count + increment }));
        }

        render() {
            // {...this.props} и {...this.state} - стандартная передача всех свойств и состояния.
            return (
                <div>
                    <Component {...this.props} {...this.state} />
                    <button
                        type="button"
                        onClick={this.increment}
                    > + </button>
                </div>
            )
        }
    }
}

export default withCount;


Далее, опишем использование нашего компонента высшего порядка:

const Counter = withCount(DisplayCount);
/*
 * title - свойство напрямую передается компоненту DisplayCount
 * increment - свойство используется в компоненте высшего порядка
 */
const App = () => <Counter title="High Order Component" increment={1} /> ;

Итоговое дерево:



Свойства и состояние WithCount(DisplayCount):



Свойства и состояние DisplayCount:

Здесь мы видим лишнее свойство increment, в случае необходимости от него можно избавиться, используя например метод omit в lodash.

3) Ленивая загрузка компонентов:
Для загрузки компонентов по требованию воспользуемся синтаксисом динамического импорта модулей.
В TypeScript этот синтаксис появился в версии 2.4.
Webpack, встречая динамический импорт, создает отдельный бандл для модулей, которые попадают под условие импорта.
Простейшее выражение для импорта:
import('module.ts').then((module) => {
    // элемент, который в модуле экспортируется по умолчанию, окажется в свойстве default
    const defaultExport = module.default;
    // остальные экспорты, например export function foo() {} - окажется в одноименном свойстве, 
    // например module.foo
    const otherExport = module.OtherExport;
});

Далее мы напишем компонент, который принимает функцию, возвращающую import, и выводит полученный компонент.
Создадим папку lazy, в ней компоненты lazyComponent.tsx и lazyLoad.tsx

LazyComponent — простой функциональный компонент, в реально приложении это может быть отдельная страница, или автономный виджет:

lazyComponent.tsx
import * as React from 'react';

const LazyComponent = () => <h3>I'm so lazy!</h3>;

export default LazyComponent;


LazyLoad — универсальный компонент для загрузки и вывода динамического компонента.
В случае необходимости прокидывать свойства в динамический компонент, LazyLoad можно переписать на компонент высшего порядка.

lazyLoad.tsx
import * as React from 'react';

/*
 * В load ожидается функция вида:
 * () => import('path/to/module')
 * Я не нашел готового интерфейса для результата выполнения import(), поэтому для 
 * упрощения в итоговом промисе ожидаем только свойство default.
 * Результат экспорта не по умолчанию можно было бы описать так - 
 * [key: string]: React.ComponentType
 */
interface LazyLoadProps {
    load: () => Promise<{ default: React.ComponentType }>;
}

// Динамический компонент будем хранить в состоянии
interface LazyLoadState {
    Component: React.ComponentType;
}

class LazyLoad extends React.Component<LazyLoadProps, LazyLoadState> {
    // null будет говорить нам о том, что компонент еще загружается
    state: LazyLoadState = {
        Component: null
    }

    // Вместо async await можно использовать промисы, зависит от ваших предпочтений
    async componentDidMount() {
        const { load } = this.props;

        try {
            // Получаем результат импорта - модуль
            const module = await load();
            // Получаем наш компонент из свойства default
            const Component = module.default;
            // Обновление state вызовет новый рендер
            this.setState({ Component });
        } catch (e) {
            // Обработка ошибок по вкусу
        }
    }

    render() {
        const { Component } = this.state;

        // Тернарный оператор, для вывода заглушки на момент отсутствия компонента.
        // Можно добавить прелоадер, можно скрывать весь компонент LazyLoad
        return (
            <div>
                <h4>Lazy load component</h4>
                {Component ? <Component /> : '...'}
            </div>
        );
    }
}

export default LazyLoad;


Все-таки обновим настройки webpack, для возможности задавать бандлам имя:

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

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: '[name].bundle.js',
        chunkFilename: '[name].bundle.js' // динамически загружаемые модули считаются chunk'ами
    },
    
    resolve: {
        extensions: ['.ts', '.tsx', '.js', '.jsx']
    },

    devtool: 'inline-source-map',
    
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                loader: 'awesome-typescript-loader'
            }
        ]
    },
    
    plugins: [
        new CleanWebpackPlugin(['dist']),
        new HtmlWebpackPlugin({
            template: './index.html'
        })
    ]
};

module.exports = config;


И обновим настройки tsconfig.json — вручную укажем библиотеки, которые надо использовать TypeScript при компиляции. Нам нужна конкретно «es2015.promise», но для удобства добавим полный список по ES стандартам, и конечно DOM.

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


Использование компонента:
// webpackChunkName - имя для итогового бандла с динамическим модулем
// chunkFilename: '[name].bundle.js' создаст нам lazy-component.bundle.js
const load = () => import(/* webpackChunkName: 'lazy-component' */'./lazy/lazyComponent');
const App = ({title}: IAppProps) => <LazyLoad load={load} />;


4) Render props
Описание компонентов с render property в официальной документации React — по ссылке

Для удобства использования таких компонентов, обычно предоставляется несколько способов рендера.
Рассмотрим два основных: свойство render и свойство children.

Создадим папку renderProps, в ней компонент displaySize.tsx и компонент windowQueries.tsx

код компонента displaySize.tsx
import * as React from 'react';
import { IRenderProps } from './windowQueries';

// Для удобства наследуем IRenderProps, но это не обязательно, так как компонент
// может ожидать всего одно свойство из этого интерфейса.
interface IProps extends IRenderProps {
    title: string;
}

const DisplaySize = ({ title, width, height }: IProps) => (
    <div>
        <h4>{title}</h4>
        <p>Width: {width}px</p>
        <p>Height: {height}px</p>
    </div>
);

export default DisplaySize;


код компонента windowQueries.tsx
import * as React from 'react';

// React.ReactNode - самый полный тип возможных элементов для рендера.
interface IProps {
    children?: ((props: IRenderProps) => React.ReactNode);
    render?: ((props: IRenderProps) => React.ReactNode);
}

interface IState {
    width: number;
    height: number;
}

export interface IRenderProps {
    width?: number;
    height?: number;
}

/**
    * Создадим компонент для отслеживания изменений размера окна браузера.
    */
class WindowQueries extends React.Component<IProps, IState> {
    state: IState = {
        width: window.innerWidth,
        height: window.innerHeight,
    }

    componentDidMount() {
        window.addEventListener('resize', this.handleWindowResize);
    }

    componentWillUnmount() {
        window.removeEventListener('resize', this.handleWindowResize);
    }

    handleWindowResize = () => {
        this.setState({
            width: window.innerWidth,
            height: window.innerHeight,
        })
    }

    gerRenderProps = (): IRenderProps => {
        const { width, height } = this.state;
        return { width, height };
    }

    // Так как мы указали в интерфейсе свойств, что render и children
    // являются функциями, возвращаем их результат.
    // В реальном приложении не стоит пренебрегать проверками типов этих свойств.
    render() {
        const { children, render } = this.props;

        if (render) {
            return render(this.gerRenderProps())
        }

        if (children) {
            return children(this.gerRenderProps())
        }

        return null;
    }
}

export default WindowQueries;


Далее, опишем использование нашего компонента:

<WindowQueries>
    {({ width, height }) => <DisplaySize title="render children" width={width} height={height} />}
</WindowQueries>

<WindowQueries
    render={
        ({ width, height }) => <DisplaySize title="render property" width={width} height={height} />
    }
/>


5) Нюансы:

Описание свойства children для доступа к дочерним элементам (не обязательно):
interface Props {
    children: React.ReactNode;
}

Описание свойства с JSX элементом, может использоваться для компонентов разметки:
interface Props {
    header: JSX.Element,
    body: JSX.Element
}

<Component
    header={<h1>Заголовок</h1>}
    body={<div>Содержимое</div>}
/>

Заключение
Мы создали окружение для разработки на React и TypeScript с минимально необходимыми настройками, и написали несколько простых компонентов.

TypeScript позволяет отказаться от использования PropTypes, и проверяет свойства компонентов во время разработки и компиляции (PropTypes же выдает ошибки только в запущенном приложении).

Такое преимущество строгой типизации, как автодополнение, распространяется и на JSX, а в файлах — декларациях библиотеки React вы можете быстро увидеть все возможные свойства для встроенных JSX элементов.

В сложных проектах использование TypeScript полностью оправдает себя — мы увидим это в таких моментах, как использование Redux (благодаря интерфейсам для вашего store), и работа с внешним API.

В статье №2 мы рассмотрим следующее:

1) Подключение Redux
2) Стандартные рецепты React, Redux и TypeScript
3) Работа с API
4) Production и development сборка проекта

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

Автор просит прощения за не самое удачное оформление статьи, и повторно просит вносить свои предложения, по улучшению восприятия и читаемости этой статьи.

Благодарю за внимание!

Update 22.10.2017: Добавлен рецепт lazy load компонентов

Update 17.02.2018: Добавлен рецепт компонента с render property, обновлены зависимости (для устранения ошибок с типом ReactNode)
Tags:
Hubs:
+26
Comments 11
Comments Comments 11

Articles