Тази публикация се основава на поредицата от публикации: Модернизиране на интерфейс на jQuery с React. Ако искате да получите по-добър преглед на мотивацията за тази публикация, ви препоръчваме първо да прочетете нашата първоначална публикация.

В днешно време е много лесно да настроите малко React приложение или да стартирате такова от нулата. Особено ако използвате create-react-app. Повечето проекти вероятно просто се нуждаят от няколко зависимости (например за управление на състоянието и за интернационализация) и папка src с поне папка components. Предполагам, че така започват повечето проекти на React. Обикновено обаче с нарастването на проекта броят на зависимостите, компонентите, редукторите и другите споделени помощни програми има тенденция да се увеличава по, понякога, не толкова контролиран начин. Какво правите, когато вече не е ясно защо са необходими някои зависимости или как работят заедно? Или когато имате толкова много компоненти, става трудно да намерите този, от който се нуждаете? Какво правите, когато искате да намерите компонент, но всъщност не помните името му?

Това са само някои примери от въпросите, на които се натъкнахме, докато възстановявахме нашия интерфейс в Karify. Знаехме, че броят на зависимостите и компонентите в крайна сметка щеше да нарасне извън контрол. Това означаваше, че се нуждаехме от план, който да бъде достатъчно мащабируем, за да бъде в крак с бъдещото развитие. Този план включваше дефиниране на конвенции за нашата структура на файлове и папки, качество на кода и също дефиниране на цялостната архитектура. Най-важното е, че всичко това трябва да бъде лесно за разбиране от новите разработчици, без да се изисква от тях да имат много представа за всички наши зависимости и стил на код.

В момента на писане имаме около 1200 JavaScript файла, от които 350 са компоненти с 80% покритие на единичен тест. Тъй като все още вярваме в архитектурата и конвенциите, които създадохме, решихме, че е добра идея да ги споделим. В следващите раздели ще прегледам как създадохме нашия проект, както и някои от уроците, които научихме.

Как да организираме файлове и папки?

Преминахме през множество фази, докато разберем как искаме да организираме нашия интерфейс на React. Първоначално мислехме да го поставим в същото хранилище като нашия интерфейс на jQuery. Въпреки това, наложената структура на папките на нашата бекенд рамка направи тази опция нежелана. След това решихме да го преместим в отделно хранилище. Първоначално това работеше добре, но с времето започнахме да мислим за създаване на други интерфейси, като React Native frontend, което мотивира необходимостта от библиотека с компоненти. Това ни накара да разделим това ново хранилище на две отделни хранилища: едно за библиотеката с компоненти и едно за новия интерфейс на React. Въпреки че това изглеждаше като добра идея, това доведе до много сложен процес на преглед. Връзката между промените в двете хранилища стана неясна. Накрая избрахме да ги обединим отново в едно хранилище, но този път като монорепо.

Избрахме монорепо, защото искахме да създадем разделение между нашата библиотека с компоненти и нашите фронтенд приложения. Разликата между нашето monorepo и други там е, че всъщност не е необходимо да публикуваме пакетите вътре в него. За нас пакетите служат само като средство за модулност и разделяне на концерните. Особено полезно е да имате различни пакети за всяко различно приложение, тъй като можем да посочим различни зависимости и скриптове за всяко едно от тях.

Настроихме нашето monorepo, използвайки работни пространства за прежди със следната конфигурация в нашия корен package.json:

"workspaces": [
    "app/*",
    "lib/*",
    "tool/*"
]

Сега някои от вас може да се чудят защо просто не използвахме папка с пакети, както в други монорепо. Главно защото искахме да създадем разделение между нашите приложения и нашата библиотека с компоненти. Освен това знаехме също, че трябва да създадем собствени инструменти. И така, измислихме папките, които виждате по-горе, и ето обяснение за всяка една от тях:

  • приложение: всички пакети в тази папка се отнасят за интерфейсни приложения като нашия интерфейс Karify, някои вътрешни интерфейси, а също и нашия Storybook;
  • lib: всички пакети в тази папка излагат споделени помощни програми на нашите предни приложения и са възможно най-независими от приложения. Тези пакети основно формират нашата библиотека с компоненти. Някои примери биха били нашите пакети typography, media и primitive;
  • инструмент: всички пакети в тази папка са специфични за Node.js и или показват инструменти, които сме създали сами, или са конфигурация и помощни програми за инструменти, от които зависим. Някои примери биха били помощни програми за webpack, конфигурации на линтер и линтер на файлова система.

Независимо къде ги поставяме, всичките ни пакети винаги имат папка src и по желание имат папка bin. Папката src на нашите пакети app и lib може да съдържа някои от следните папки:

  • действия: съдържа функции за създаване на действия, чиято върната стойност може да бъде предадена на функцията за изпращане от redux или useReducer;
  • компоненти: съдържа папки с компоненти със съответните им дефиниции, преводи, модулни тестове, моментни снимки и истории (ако е приложимо);
  • константи: съхранява постоянни стойности, използвани повторно в различни среди, а също и различни помощни програми;
  • fetch: съдържа дефиниции на типове за полезните данни от нашия API и съответните асинхронни действия за тяхното извличане;
  • помощници: съдържа помощни програми, които не се вписват в никоя от другите категории;
  • редуктори: съдържа редуктори за използване в нашия redux магазин или useReducer;
  • маршрути: съдържа дефиниции на маршрути, които да се използват в react-router компоненти или history функции;
  • селектори: съдържа помощни функции, които четат или преобразуват данни от нашето redux състояние или нашите API полезни натоварвания.

Тази структура на папките ни позволява да пишем наистина модулен код, тъй като създава ясно разделение на проблемите между различните концепции, дефинирани от нашите зависимости. Това помага при търсенето на променливи, функции или компоненти в хранилището, независимо от това дали знаем дали съществуват или не. Освен това, това също помага да се запази съдържанието на тези папки до минимум, което от своя страна ги прави по-лесни за обработка.

Едно предизвикателство с тази нова структура на папките е да сме сигурни, че се придържаме към нея. Изкушаващо е да започнете да създавате различни папки в различни пакети и да организирате файловете по различен начин между тях. Въпреки че това може да не винаги е лоша идея, ние ще се окажем в бъркотия, ако не го правим последователно. За да помогнем с това, създадохме линтер за файлова система, който ще опиша по-подробно в следващия раздел.

Как да наложим ръководство за стил?

По същия начин, по който искахме да имаме последователна структура на файлове и папки, ние също искахме да имаме възможно най-голяма последователност в нашия код. Това е нещо, което вече направихме доста добре в нашия интерфейс на jQuery, но може да бъде подобрено, особено що се отнася до CSS. Затова се опитахме да дефинираме ръководство за стил от самото начало и да го наложим с линтер. Правилата, които не могат да бъдат определени с линтер, се прилагат по време на преглед на кода.

Настройването на линтер в монорепо е същото като във всяко друго хранилище, страхотно е, защото можете да го стартирате веднъж, за да валидирате цялото хранилище. Ако не сте запознати с линтери, препоръчвам ви да разгледате ESLint и Stylelint, които използваме.

Наличието на JavaScript линтер се оказа особено полезно за следните случаи на употреба:

  • Налагане на използването на съобразени с достъпността компоненти пред техните HTML аналогове: по време на проектирането дефинирахме няколко насоки за достъпност за котви, бутони, изображения и икони. След това в кода искахме да приложим тези насоки и да сме сигурни, че няма да ги забравим в бъдеще. Направихме това с правилото react/forbid-elements от eslint-plugin-react. Пример за това как изглежда:
'react/forbid-elements': [
    'error',
    {
        forbid: [
            {
                element: 'img',
                message: 'Use "<Image>" instead. This is important for accessibility reasons.',
            },
        ],
    },
],
  • Забрана за импортиране на пакети с приложения от вътре в библиотечни пакети и забрана на импортиране на пакети с приложения вътре в други приложения: главно за избягване на кръгови зависимости между пакетите в monorepo и за да сме сигурни, че се придържаме към разделянето на проблемите, които създадохме. Ние забраняваме това с правилото import/no-restricted-paths от eslint-plugin-import.

В допълнение към JavaScript и CSS linting, ние също имаме собствена файлова система linter. Така се уверяваме, че се придържаме към нашата структура на папките. Тъй като този линтер е наш, винаги можем да го променим, ако решим да променим структурата. Ето няколко примера за правилата, които имаме:

  • Проверете структурата на нашите компонентни папки: уверете се, че винаги има index.ts и .tsx файл със същото име като папката.
  • Проверете нашите package.json файлове: уверете се, че винаги има по един за пакет и че е зададен като частен, за да избегнете случайно публикуване на пакети.

Какъв тип система да използвам?

Днес отговорът на горния въпрос вероятно е много по-ясен за всеки. Просто изберете TypeScript! Независимо от размера на вашия проект, това може да ви забави в някои случаи, но според нас си заслужава качеството и стриктността, които добавя към вашия код.

За съжаление, когато започнахме този проект, все още беше много обичайно да се използват проп-типове. В началото това беше достатъчно, но с разрастването на проекта наистина започнахме да ни липсва възможността да дефинираме типове за повече от компоненти. Можем да видим стойността, която биха добавили към, например, нашите редуциращи и селекторни функции. Въпреки това, добавянето на различна система от типове би изисквало много рефакторинг, за да се стигне до момент, в който цялата кодова база има типове.

В крайна сметка все пак го направихме, но направихме грешката да опитаме първо „Flow“, защото изглеждаше по-лесно за интегриране в нашата кодова база. Въпреки че това беше вярно, работата с Flow също стана трудна, тъй като не се интегрира добре с нашата IDE, произволно не успя да забележи някои грешки при типове и създаването на генерични типове беше кошмар. Поради тези причини в крайна сметка преместихме всичко към TypeScript. Ако тогава знаехме това, което знаем сега, щяхме да използваме TypeScript от самото начало.

Посоката, в която TypeScript се разви през последните няколко години, помогна този преход да бъде много по-лесен. „Отмяната на TSLint спрямо ESLint“ беше особено полезна за нас.

Какъв подход за тестване да използвате?

Когато започнахме, не беше съвсем очевидно за нас какви инструменти за тестване да използваме. В днешно време бих казал, че „jest“ и „cypress“ са най-добрите за тестване на единици и съответно за интеграционно тестване. Тяхната конфигурация е добре документирана и не е сложна. Жалко е, че cypress не поддържа Fetch API и неговият API не поддържа async /await. Отне ни известно време, за да разберем това в началото. Надяваме се, че това ще се промени в близко бъдеще.

В началото беше доста трудно да разберем как най-добре да напишем модулни тестове за нашето приложение. С течение на времето изпробвахме неща като „тестване на моментни снимки“, „тестово рендиране“, „плитко рендиране“ и „тестваща библиотека“. В крайна сметка решихме да се придържаме към плиткия рендер, за да валидираме изхода на компонентите, и да използваме тестовия рендер, за да тестваме вътрешната логика на компонентите.

Според нас библиотеката за тестване е страхотна за малки проекти, но фактът, че разчита на рендеринга на dom, има голямо влияние върху ефективността на тестовете. Освен това смятаме, че „аргументът“ срещу тестването на моментни снимки с плитко рендиране няма смисъл, когато имате много дълбок слой от компоненти. За нас тези моментни снимки са много полезни за валидиране на всички възможни изходи на компонент. Важно е обаче те да са четливи. Това може да се постигне, като поддържате вашите компоненти малки и като дефинирате метода toJSON за реквизити на обекти, които не са подходящи за моментната снимка.

За да ни поддържат мотивирани да пишем модулни тестове и да не забравяме за тях, ние също дефинирахме „праг на покритие“. Това е много лесно за конфигуриране, когато използвате шега. Не е нужно да го мислите много. Просто започнете с глобален праг и го подобрявайте с времето. Започнахме от 60% и с течение на времето, когато покритието се увеличи, преместихме прага на 80%. За нас това изглежда като достатъчно добър праг, тъй като също така не смятаме, че е реалистично или необходимо да се насочваме към 100%.

Как да стартирате приложението?

Обикновено стартирането на React приложение е толкова просто, колкото да напишете нещо като ReactDOM.render(<App />, document.getElementById(‘#root’));. Това обаче става по-сложно, когато искате да поддържате и SSR (Server-Side Rendering). Освен това, ако имате нужда и от други зависимости освен React, те може да се нуждаят от отделни конфигурации за страна на клиента и страна на сървъра. Например, използваме react-intl за интернационализация, react-redux за глобално управление на състоянието, react-router за маршрутизиране и redux-sagas за управление на асинхронни действия. Тези зависимости изискват известна настройка, която може да стане сложна много лесно.

Нашето решение на този проблем се основава на шаблоните за проектиране „Стратегия“ и „Абстрактна фабрика“. Основно създадохме два различни класа/стратегии: един за конфигурация от страна на клиента и един за конфигурация от страна на сървъра. И двете получават конфигурацията за приложението, което се зарежда, което включва неговото име, лого, редуктори, маршрути, език по подразбиране, саги и т.н. Редукторите, маршрутите и сагите могат да идват от различни пакети в нашето monorepo. След това тази конфигурация се използва за създаване на магазина за редуциране, създаване на междинния софтуер на sagas, създаване на обекта на хронологията на рутера, извличане на преводите и накрая изобразяване на приложението. За да дам пример, по-долу е опростена версия на това как изглежда подписът на двете ни стратегии:

type BootstrapConfiguration = {
  logo: string,
  name: string,
  reducers: ReducersMapObject,
  routes: Route[],
  sagas: Saga[],
};
class AbstractBootstrap {
  configuration: BootstrapConfiguration;
  intl: IntlShape;
  store: Store;
  rootSaga: Task;
abstract public run(): void;
  abstract public render<T>(): T;
  abstract protected createIntl(): IntlShape;
  abstract protected createRootSaga(): Task;
  abstract protected createStore(): Store;
}
// Strategy for client-side
class WebBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<ReactNode>(): ReactNode;
}
// Strategy for server-side
class ServerBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<string>(): string;
}

Намерихме това разделяне за полезно, тъй като има няколко разлики в това как са настроени нашият магазин, саги, обект за интернационализация и исторически обект, в зависимост от средата. Например, магазинът за редуциране от страна на клиента се създава с помощта на предварително зареденото състояние от сървъра и „подобрителя на редукс devtools“, докато от страната на сървъра не се нуждае от нищо от това. Друг пример е нашият обект за интернализиране, който от страната на клиента получава текущия език от navigator.languages, докато от страната на сървъра го получава от Accept-Language HTTP header.

Важно е да се отбележи, че вече измислихме това решение преди известно време. По това време все още беше много по-често да се използват класове в React приложения и нямаше лесни решения за SSR. С течение на времето React се промени към по-функционален стил и се появиха решения като Next.js. Във връзка с това, ако търсите решение за същия проблем, препоръчваме ви също да разгледате какво е възможно в днешно време, тъй като може да намерите по-прост и по-функционален начин.

Как да поддържаме качеството на кода?

Линтерите, тестовете и типовете са полезни за подобряване на качеството на вашия код, но е лесно да забравите да проверите дали всички те са валидни, преди да обедините някои промени във вашия главен клон. Най-доброто решение е да направите това автоматично. Някои предпочитат да правят това при всеки комит с git hooks, които ви пречат да се ангажирате, освен ако всичко не премине. Ние обаче смятаме, че това е твърде натрапчиво, тъй като можете да работите върху клон няколко дни, докато не стане готов. Поради тази причина ние валидираме ангажиментите си с помощта на канали за CI (непрекъсната интеграция), които се изпълняват само когато клонът е свързан със заявка за сливане. По този начин избягваме да изпълняваме конвейери, които се очаква да се провалят, тъй като през повечето време създаваме заявка за сливане само когато смятаме, че кодът е готов.

Нашите тръбопроводи започват с инсталиране на всички наши зависимости и след това валидират типовете, изпълняват линтърите, изпълняват модулните тестове, изграждат приложението и изпълняват тестовете на cypress. Почти всичко това се случва паралелно. Ако някоя от тези стъпки е неуспешна, тогава конвейерът се проваля и клонът не може да бъде обединен. По-долу е даден пример за работещ тръбопровод.

Най-трудната част от настройването на този тръбопровод беше и все още е поддържането му бърз. Преминахме през много фази на оптимизация и в момента работи стабилно на около 20 минути. Вероятно бихме могли да подобрим това, като проведем паралелно някои тестове за кипарис, но засега това е приемливо.

Заключение

Настройването на широкомащабно приложение на React не е лесна работа. Има много избори, които трябва да направите, и много инструменти, които трябва да конфигурирате. Няма само един правилен отговор как да направите това.

Все още сме доволни от нашата настройка и се надяваме, че ще вдъхнови другите, които се затрудняват да настроят свои собствени приложения. Но преди да последвате нашия пример, опитайте се да се уверите, че това е правилната настройка за вас или вашата компания. Най-важното е да добавите само зависимостите, от които се нуждаете. Не го усложнявайте прекалено.

Ако имате въпроси, отзиви или предложения относно прочетеното тук, ще се радвам да чуя от вас в раздела за коментари по-долу.