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

Оптимальность функционала и его переиспользование

AngularJS изначально предполагал использование сервисов для управления поведением компонентов и создания ряда функций или переменных, которые могут быть использованы любым компонентом. В Angular2+ этот подход значительно не изменился, но стал намного более гибким сочетаясь в том числе на методы жизненного цикла (lifecycle).  В то время как React и Vue полагались либо на методы жизненного цикла (lifecycle) компонентов, которые выполнялись в зависимости от стадии компонента в DOM – загрузки, рендера, обновления и др., либо на так называемые «миксины» – сторонние компоненты, содержащие бизнес-логику и методы, которые позже по необходимости могли быть включены в другие компоненты. Однако первый метод неизбежно вёл к переписыванию одного и того же кода в каждый компонент, а миксины, с увеличением комплексности проекта, вели к возрастающей невозможности следить за ними всеми, а также к ряду труднозаметных ошибок, связанных с тем, что зачастую миксины полагались на те или иные имена переменных в компонентах и простое переименование чего-то естесвенно не настораживало линтеры и до рантайма ошибки были просто «незаметны». В 2016 году Дэн Абрамов написал интересную статью о вреде миксинов. Начиная с версии 16.8 React фундаментально изменил подход к написанию компонентов и ввёл новый концепт React Hooks. Позже и команда Vue, вдохновившись примером и проанализировав недостатки, ввела Composition API в Vue 3. Однако, у них есть существенные различия, которые можно было бы описать как метод «исключения» против метода «включения». Давайте рассмотрим это более подробно.

1. React Hooks

Хуки позволяют использовать логику состояния компонента без привязки к какой-либо иерархии компонентов и легко могут использоваться рядом различных компонентов или отдельно. Логикой состояния является любой функционал, управляющий переменными, которые описывают состояние компонента локально. По сути, главное задание хуков – изолировать логику функционального компонента, которую разработчик хочет многократно использовать, и каждый хук может быть использован любым компонентом для выполнения одной и той же задачи, такой как получение данных от бекенда или изменения интерфейса. React позволяет писать как кастомные хуки под необходимости разработчика, так и предлагает ряд встроенных хуков, таких как:

  • useState – хук управления состоянием, позволяющий обновлять значение переменных и не создавать новое состояние при каждом повторном рендере компонента;

  • useEffect – хук, позволяющий контролировать, когда выполняется код внутри (так называемые сайд-эффекты) в зависимости от состояния заданных переменных. Код выполнится только при первом рендере компонента или при изменении значений переменных, от которых зависит хук;

  • useRef – создает ref-объект, который ссылается на текущее состояние элемента, зачастую используется для доступа в DOM и манипуляций, например, с инпутами и их значениями;

  • useCallback, useMemo – запоминают значения функций или переменных соответственно для избежания ненужных перерасчетов при каждом рендере, если зависимые переменные не менялись.

    Рассмотрим наиболее часто необходимые useState и useEffect на официальном примере:

import React, { useState, useEffect } from 'react';
function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => { 
	document.title = `You clicked ${count} times`;
  }, [count]);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useState инициализирует попарно переменную с дефолтным значением 0 и функцию для изменения её значения, увеличивая значение на 1 при каждом клике на кнопку, в то время как useEffect будет менять заголовок страницы только в случае клика на кнопку и, как следствие, изменения значения count, что видно во втором аргументе хука – массиве зависимостей. При этом при изначальном рендере компонента, useEffect также изменит заголовок на «You clicked 0 times».

Условно говоря, хуки не подчиняются стандартному выполнению JavaScript кода сверху вниз, позволяя исключать ненужные в данный момент части кода и оптимизировать функциональность компонента в простом и удобном формате. Это и есть принципом «исключения». Однако не все так просто, и хуки всё равно подчиняются своим правилам, их можно выполнять только на верхнем уровне React компонентов, они не могут быть вызваны внутри циклов, условий и других функций. Также существует риск дополнительных ненужных рендеров и «утечек» памяти, если неправильно использовать useEffect. К сожалению, большинство таких ошибок заметны только уже во время рантайма, и единственное, что может предложить React в таком случае, это ESLint плагин для хуков, который, очевидно, не покрывает все случаи неправильного применения хуков.

2. Vue Composition API

Composition API по сути является сборным понятием для трёх концепций:

  • Reactivity API – позволяет создавать реактивные переменные, многократно используемые значения и «наблюдатели», выполняющие определенные функции как результат изменения зависимых переменных;

  • Lifecycle Hooks – позволяет выполнять логику в зависимости от жизненного цикла компонента, от изначального рендера до исчезновения из DOM;

  • Dependency Injection – механизм передачи зависимостей в контексте реактивного API, позволяющий с помощью provide() передать статичное или реактивное значения из родительского компонента и получить его через inject() в любом наследующем компоненте, для избежания передачи этих значений через пропы каждого «верхнего» компонента.

Главным отличием Composition API от React Hooks является то, что setup() метод, инициализирующий API, выполняется только 1(!) раз при первом рендере и обозначает сценарии выполнения логики компонента и последующих рендеров.

Давайте рассмотрим похожий пример со счетчиком:

<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>

<script setup>
import { ref, onMounted } from 'vue'

// реактивное состояние
const count = ref(0)

function increment() {
  count.value++
}

// хуки жизненного цикла
onMounted(() => {
  console.log(`The initial count is ${count.value}.`)
})

// наблюдатели
watchEffect(() => {
  console.log(`The current count is ${count.value}.`)
})

watch((count), (count, prevCount) => {
  console.log(`The current count is ${count.value}.`)
  console.log(`The previous count is ${prevCount.value}.`)
})


</script>

Аналогично useState в React, ref задает состояние переменной компонента. Стоит, однако, помнить, что хоть Composition API и вдохновлялось неизменяемой функциональностью React Hooks, но оно не является функциональным программированием и базируется на основной парадигме изменяемости Vue. Благодаря этому состояние переменных в компоненте можно менять напрямую, и Vue проследит, чтобы новое состояние не было создано. Composition API имеет и аналог useMemo – computed() функция будет запоминать статические или реактивные значения и переназначать их только когда это необходимо. Надобности в useCallback у Vue нет, опять же из-за того, что инициализация всех функций происходит всего раз.

Сохранение программатического выполнения логики на фазах цикла жизни компонента для большего контроля также обусловлено тем, что Composition API выполняется всего один раз. В то время как React Hooks, из-за своего выборочного выполнения, полностью включили зависимости жизненного цикла в базовых хуках.

Аналогом useEffect является функция watchEffect, которая автоматически следит за изменениями использованных переменных и производит повторный рендер компонента на них. В примере также видно функцию watch, которая является смесью реактовских useState и useEffect. Она выполняет ту же работу, что и watchEffect. При этом можно указать более точно за какими значениями стоит следить, кроме того, она имеет доступ к предыдущим значениям переменных.

Простота Composition API для создания функционала повторного использования привела к появлению в сообществе крупнейших проектов компонируемых функций, таких как VueUse. Из-за определенных условий для выполнения React Hooks, кастомные реакт-хуки от сообщества зачастую имеют универсальность несколько ниже, чем кастомные Composition API функции. Хорошим доказательством этого является проект Reactivue, упрощающий жизнь React разработчикам, предоставляя React-обертку для VueUse.

Как я говорил ранее, Composition API вышло относительно позже хуков и учло следующие проблемы React Hooks:

  • Хуки чувствительны к порядку вызова и не могут быть условными.

  • Переменные React-компонента могут условно «застрять» внутри хука и стать «несвежими», если разработчик не передаст правильный массив зависимостей. Это приводит к тому, что разработчики React полагаются на правила ESLint для обеспечения правильной передачи зависимостей, что не всегда покрывает нестандартные ситуации и в результате приводит к труднонаходимым ошибкам.

  • Дорогие вычисления требуют использования useMemo, что опять же требует ручной передачи правильного массива зависимостей.

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

Тогда как Composition API:

  • Вызывает код setup() или <script setup> только один раз. Благодаря этому код лучше согласуется с интуицией идиоматического использования JavaScript, поскольку нет необходимости беспокоиться о захвате хуком значения переменной. Вызовы API также не чувствительны к порядку вызовов и могут зависеть от условия.

  • Система реактивности Vue во время выполнения автоматически собирает реактивные зависимости, используемые в computed() значениях и наблюдателях watch() и др., поэтому нет необходимости вручную объявлять зависимости.

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

Возвращаясь к концепту «исключения» ненужных функций в React Hooks, Composition API наоборот практикует «включение» только необходимой логики для сценария. Это можно показать на примере приготовления блюд по рецепту. React Hooks достают с полок на кухне все ингредиенты для всех рецептов и, приступая к приготовлению блюда по первому рецепту, откладывает все ненужные ингредиенты, повторяя этот процесс потом и для второго рецепта. Тогда как Vue Composition API берёт рецепты и подготавливает наборы продуктов под каждый из них. Согласитесь, звучит более оптимально.

По итогу, можно сделать вывод, что Vue постарались научиться на ошибках своего «оппонента». Но все-таки и React Hooks, и Composition API не являются библиотеками для одного и того же фреймворка, так что их сравнение условно и не указывает на то, что Composition API однозначно лучше. Но, по моему мнению, точно проще.

3. Angular Services

Достаточно трудно сравнивать сервисы Angular с хуками или Composition API, но можно сказать что dependency injection у Vue во многом вдохновлен сервисами. Технически говоря, сервисы в Angular2+ являются обычными JavaScript классами как и например компоненты, в то время как AngularJS имел четкое разделение логики на контроллеры и сервисы. С помощью специального декоратора @Injectable разработчик оставляет сигнал для Angular, что класс предназначен для использования в других компонентах или сервисах:

@Injectable()
class UsefulService {
}

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

Примером счетчика, который мы ранее рассматривали на React и Vue, может быть упрощенное приложение ниже:

app.component.ts

import { Component } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  constructor(private service: CounterService) {}
  counter = this.service.counter;
  increment = () => {
    this.counter = this.service.increment();
  };
  decrement = () => {
    this.counter = this.service.decrement();
  };
  reset = () => {
    this.counter = this.service.reset();
  };
}

counter.service.ts

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class CounterService {
  counter = 0;
  increment() {
    this.counter++;
    return this.counter;
  }
  decrement() {
    this.counter--;
    return this.counter;
  }
  reset() {
    this.counter = 0;
    return this.counter;
  }
}

В данном случае класс CounterService объявляется внутри конструктора класса какого-нибуть компонента и в следствии компонент может использовать его логику для изменения своего состояния. Angular также предлагает ряд встроенных классов, играющих роль сервисов, таких как Location для операций над url страницы или HttpClient для работы с запросами на бэкэнд, при чем их использование носит не опциональный характер, а рекомендательный. То есть эти классы специально были созданы для улучшения производительности Angular-приложения и бессмысленно их не использовать так они уже включены изначально в фреймворк. Здесь и общее требование писать ваш код на TypeScript поскольку большинство кастомных сервисов (а также документация и примеры), которые вы сможете найти в npm проектах и сообществе, написаны на TypeScript.

Роутинг

Роутинг является важной частью каждого SPA (single-page application), независимо от выбранного фреймворка. Роутинг отвечает за создание иллюзии множества страниц и подгрузку нужных компонентов на определенный url.

1. React Router

React Router использует компонентно-ориентированный подход к построению и навигации по маршрутам (вместо подхода config-first, когда маршруты определяются отдельно от компонентов на этапе инициализации приложения). Этот подход может показаться необычным для некоторых разработчиков, но он хорошо сочетается с акцентом React на JSX как подход к программированию.

const App = () => (
  <Router>
    <div>
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/login/">Login</Link>
          </li>
        </ul>
 
      <Route path="/" exact component={Home} />
      <Route path="/login/" component={Login} />
    </div>
  </Router>
);

Как видим на примере, <Link to=””></Link> является React JSX аналогом  стандартных <a href=””></a>. <Route /> элемент определяет, какой компонент должен рендериться на определенный url. Также поддерживается динамический роутинг.

import { BrowserRouter, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <div>
        <Route path="/contact/:id" component={Contact} />
      </div>
    </BrowserRouter>
  );
}

...

import { useParams } from 'react-router-dom';

function Contact() {
  let { id } = useParams();

  return ...;
}

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

React Router также предоставляет хуки useLocation() и useHistory() для использования стандартных API location и history. useEffect() может быть использован для выполнения логики с привязкой к событиям изменения роута:

import { useHistory } from 'react-router-dom';

const App = () => {
  const history = useHistory();

  useEffect(() => {
    const unlisten = this.props.history.listen(...);
    return () => unlisten();
  }, [])

  return ...;
}

React Router не предоставляет дополнительного фунционала для сохранения скроллинга при переходе между роутами, поэтому нужно будет полагаться на дефолтный JavaScript подход:

export default const ScrollToTop = () => {
  const { pathname } = useLocation();

  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);

  return null;
}

Главным плюсом React Router является возможность писать логику для определенного url напрямую в компоненте, ровно как и роутинг для ряда компонентов – напрямую в родительском компоненте. Однако написание логики, которую необходимо выполнить при переходе на определенную ссылку или при выходе из нее, может быть не всегда очевидным из-за использования useEffect() хука.

2. Vue Router

Vue Router, вдохновляясь примером Angular, предоставляет практически исчерпывающий набор функций роутинга, таких как параметры роутов, слушатели событий, вложенные роуты и многое другое. Маршруты определяются как массив объектов, который передается инстанции VueRouter, который, в свою очередь, передается рутовому компоненту Vue приложения.

Vue Router обязывает создавать роутинг как часть конфигурации приложения, и, в отличии от React, все роуты должны быть определенны в одном месте:

const routes = [
  { path: '/', component: Home },
  { path: '/login', component: Login },
];
const router = new VueRouter({
  mode: 'history',
  routes,
});
const app = new Vue({ router }).$mount('#app');

Также необходимо указать место для рендеринга связанных роутингом компонентов с помощью специального элемента <router-view></router-view>. Обычно таким местом является главная секция вашего приложения:

<div id="app">
    <router-view></router-view>
</div>

Vue Router также предоставляет обёртку для стандартного <a href=””></a> - <router-link to="/vue">vue</router-link>. Доступ к location и history API происходит через ключевые объекты this.$route и this.$router:

export default {
  computed: {
    path() {
      return this.$route.path;
    },
    params() {
      return this.$route.params;
    },
    query() {
      return this.$route.query;
    },
    hash() {
      return this.$route.hash;
    },
    fullPath() {
      return this.$route.fullPath;
    },
  },
};
...
export default {
  methods: {
    toHome() {
      this.$router.push('/home');
    },
    toUser(id) {
      this.$router.push({ name: 'user', params: { userId: id } });
    }
  },
};

Динамический роутинг происходит похожим на React образом:

const router = new VueRouter({
  mode: 'history',
  routes: [{ path: '/contact/:id', component: Contact }],
});

export default {
  computed: {
    id() {
      return this.$route.params.id;
    },
  },
};

Vue Router также предлагает обширный контроль над поведением приложения при переходах между ссылками.

-  глобальный:

const router = new VueRouter({ ... })

router.beforeEach((to, from) => { ... })
router.afterEach((to, from) => { ... })

-  для каждого роута:

const router = new VueRouter({
  routes: [
    {
      path: '/foo',
      component: Foo,
      beforeEnter: (to, from) => { ... }
    }
  ]
})

-  компонентный:

const Foo = {
  template: `...`,
  beforeRouteEnter (to, from) { ... },
  beforeRouteUpdate (to, from) { ... },
  beforeRouteLeave (to, from) { ... },
}

Аргументы to и from позволяют ещё более расширенно контролировать поведение не только на этапах жизненного цикла роутинга, но и при переходах из определенных url или на определенные url. Vue router также поддерживает управление скроллингом:

const router = new VueRouter({
  routes: [...],
  scrollBehavior (to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { x: 0, y: 0 }
    }
  }
})

Возможно, минусом является необходимость при работе над созданием новых компонентов каждый раз их роутинг конфигурировать в отрыве, возвращаясь к основному конфигурационному файлу. Однако, исключительно детальное разделение на этапы жизненного цикла и возможность лёгкого написания конкретизированной url-логики делает Vue Router отличной составляющей каждого Vue приложения.

3. Angular Router

Angular интегрирует роутинг во фреймворк с помощью специальных модулей Router и RouterModule. Роуты определяются на основе каждого модуля, используя конфигурацию, схожую с Vue Router:

const routes: Routes = [
  { path: '/', component: Home },
  { path: '/login', component: Login }
];
 
@NgModule({
  imports: [ RouterModule.forRoot(routes) ],
  // ...
})
export class AppModule { }

Как и в Vue Router, вы должны указать, где будут отображаться маршрутизируемые компоненты, используя специальную директиву <router-outlet></router-outlet>.

<h1>Welcome</h1>
<ul>
  <li><a routerLink="/">Home</a></li>
  <li><a routerLink="/login">Login</a></li>
</ul>
<router-outlet></router-outlet>

Как и с Vue, стандартный Angular роутинг позволяет использовать location и history API c помощью Router и ActivatedRoute модулей.

Angular также позволяет контролировать поведение приложения при переходах на разные url, но по названию сервиса для этого – Guards, становится понятно, что основная задумка была в ограничении доступа к некоторым частям роутинга, в зависимости от юзера или аутентификации, например:

const routes: Routes = [
  {path:'about', component: AboutComponent, canActivate:[AuthGuardService1], 
   canActivateChild:[AuthGuardService2], canDeactivate[AuthGuardService3]},
  {path:'contact', component: ContactComponent}
];

Основными интерфейсами являются: CanActivate – реагирует на переход на определённый url, CanActivateChild – то же самое, но на дочерний url (../user => ../user/settings) и canDeactivate – реагирует на переход из текущего url.

Поскольку Vue Router вдохновлялся способностями Angular, то все те же различия в подходе сохраняются и у Angular по сравнению с React – настройка роутинга в компонентах против настройки в основном конфиге приложения. Сохраняется и барьер интуитивности, как видно на примере с Guards против похожего решения в Vue. Согласитесь, что ключевые слова типа beforeRouteEnter, beforeRouteUpdate, beforeRouteLeave гораздо более понятны в плане своих ролей, чем вышеописанные интерфейсы из Guards.

Делаем выводы!

В заключение, хотелось бы упомянуть, что сравнение более продвинутых составляющих фреймворков усложняется тем, что каждая из них строилась в соответствии с базовыми концепциями каждого из них. Построение функциональной логики Angular по определению сложнее из-за необходимости использовать в основном встроенные библиотеки, в то время как React и Vue имеет больший доступ к кастомным библиотекам, расширяющим возможности React Hooks или Vue Composition API. Так же и в роутинге, компонентно-ориентированный подход React в построении роутов имеет как плюсы так и минусы в сравнении с Vue и Angular, строящими полную логику роутинга в отрыве от компонентной. В следующей части мы продолжим тему и поговорим о глобальном управлении состояния приложений, написанных на каждом из фреймворков, и постараемся сделать более полные выводы.