Pull to refresh
VK
Building the Internet

Четыре уровня одностраничных приложений, о которых вам нужно знать

Reading time 14 min
Views 26K
Original author: Daniel Dughila

image


В этой статье мы с нуля разработаем React-приложение, обсудим домен и его сервисы, хранение, сервисы приложения и представление (view).



Четыре уровня одностраничных (SPA) приложений


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



Он описывает требования:


Наше приложение будет отображать список статей. При этом пользователи смогут создавать и удалять статьи, а также ставить им лайки.


А затем тимлид просит вас это реализовать!


Вопросов нет, начнём с архитектуры


Я выбрал Create React App и Flow для проверки типов. Чтобы не раздувать код, наше приложение будет без стилей. А теперь давайте поговорим о декларативной природе современных фреймворков, затрагивающей концепцию состояния.


Современные фреймворки декларативны


React, Angular, Vue декларативны, они подталкивают нас к использованию элементов функционального программирования.


Вы когда-нибудь в детстве развлекались «мультфильмами», нарисованными на страницах блокнота или тетради, которые нужно было быстро перелистывать, — самопальными кинеографами?


Кине́ограф (Kineograph) — приспособление для создания анимированного изображения, состоящего из отдельных кадров, нанесённых на листы бумаги, сшитые в тетрадь. Зритель, перелистывая особым способом тетрадь, наблюдает эффект анимации.


А вот часть описания React:


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


И вот часть описания Angular:


Быстро создавайте функциональность с помощью простых, декларативных шаблонов. Расширяйте язык шаблонов с помощью собственных компонентов.


Знакомо звучит?


Фреймворки помогают нам собирать приложения из представлений. Представления (views) олицетворяют собой состояние. Но что такое состояние?


Состояние


Состояние отображает все изменившиеся в приложении части данных.


Вы перешли по URL — это состояние; сделали Ajax-вызов для получения списка фильмов — это тоже состояние; вы положили информацию в локальное хранилище — и это состояние.


Состояние формируется из неизменяемых объектов.


У неизменяемой архитектуры много преимуществ, одно из которых относится к уровню состояний.


Вот цитата из руководства React по оптимизации производительности:


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


Уровень домена


Домен описывает состояние и содержит бизнес-логику. Он олицетворяет ядро приложения и не должен зависеть от уровня представления (view). Нам нужна возможность использовать свой домен вне зависимости от фреймворка.



Уровень домена


Поскольку мы работаем с неизменяемой архитектурой, уровень домена будет состоять из сущностей (entities) и сервисов домена. Применение анемичной доменной модели в ООП спорно, особенно в больших приложениях, однако для работы с неизменяемыми данными она вполне пригодна. Для меня в своё время стал открытием курс Владимира Хорикова.


Поскольку нам нужно отображать список статей, то в первую очередь смоделируем сущность Article.


Все будущие объекты типа Article должны быть неизменяемы. Flow может сделать это принудительно, определив каждое свойство доступным только для чтения (см. значок плюса перед каждым свойством).


Article.js:


// @flow
export type Article = {
  +id: string;
  +likes: number;
  +title: string;
  +author: string;
}

Теперь с помощью шаблона функции «фабрика» создадим articleService. Этот момент прекрасно объясняется здесь.


Поскольку нам в приложении нужен только один articleService, экспортируем в виде синглтона. Метод createArticle позволит создать замороженные объекты типа Article. Каждая новая статья получит уникальный автоматически сгенерированный ID и 0 лайков, а мы указываем только автора и заголовок.


Метод Object.freeze() замораживает объект, то есть препятствует добавлению к объекту новых свойств. (с)


Метод createArticle возвращает maybe-тип Article.


Maybe-типы (опциональные типы) заставляют проверять, существует ли объект Article, прежде чем проводить с ним операции.


Если поле, необходимое для создания статьи, не проходит проверку, метод createArticle возвращает null. Кто-то скажет, что лучше бросать определяемое пользователем исключение. Но если мы заставим так делать, а верхние уровни не реализуют блоки ловли исключений, то программа упадёт во время исполнения.


Метод updateLikes поможет обновить количество лайков у существующей статьи, вернув её копию с новым счётчиком.


Наконец, методы isTitleValid и isAuthorValid не позволяют createArticle работать с повреждёнными данными.


ArticleService.js:


// @flow
import v1 from 'uuid';
import * as R from 'ramda';

import type {Article} from "./Article";
import * as validators from "./Validators";

export type ArticleFields = {
  +title: string;
  +author: string;
}

export type ArticleService = {
  createArticle(articleFields: ArticleFields): ?Article;
  updateLikes(article: Article, likes: number): Article;
  isTitleValid(title: string): boolean;
  isAuthorValid(author: string): boolean;
}

export const createArticle = (articleFields: ArticleFields): ?Article => {
  const {title, author} = articleFields;
  return isTitleValid(title) && isAuthorValid(author) ?
    Object.freeze({
      id: v1(),
      likes: 0,
      title,
      author
    }) :
    null;
};

export const updateLikes = (article: Article, likes: number) =>
  validators.isObject(article) ?
    Object.freeze({
      ...article,
      likes
    }) :
    article;

export const isTitleValid = (title: string) =>
  R.allPass([
    validators.isString,
    validators.isLengthGreaterThen(0)
  ])(title);

export const isAuthorValid = (author: string) =>
  R.allPass([
    validators.isString,
    validators.isLengthGreaterThen(0)
  ])(author);

export const ArticleServiceFactory = () => ({
  createArticle,
  updateLikes,
  isTitleValid,
  isAuthorValid
});

export const articleService = ArticleServiceFactory();

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


Validators.js:


// @flow
export const isObject = (toValidate: any) => !!(toValidate && typeof toValidate === 'object');

export const isString = (toValidate: any) => typeof toValidate === 'string';

export const isLengthGreaterThen = (length: number) => (toValidate: string) => toValidate.length > length;

Исключительно ради демонстрационных целей эти проверки поданы со щепоткой соли. В JavaScript не так легко проверять, на самом ли деле объект — это объект :)


Теперь мы настроили уровень домена!


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


domain-demo.js:


// @flow
import {articleService} from "../domain/ArticleService";

const article = articleService.createArticle({
  title: '12 rules for life',
  author: 'Jordan Peterson'
});
const incrementedArticle = article ? articleService.updateLikes(article, 4) : null;

console.log('article', article);
/*
   const itWillPrint = {
     id: "92832a9a-ec55-46d7-a34d-870d50f191df",
     likes: 0,
     title: "12 rules for life",
     author: "Jordan Peterson"
   };
 */

console.log('incrementedArticle', incrementedArticle);
/*
   const itWillPrintUpdated = {
     id: "92832a9a-ec55-46d7-a34d-870d50f191df",
     likes: 4,
     title: "12 rules for life",
     author: "Jordan Peterson"
   };
 */

Уровень хранения


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



Уровень хранения


Смоделировать состояние можно с помощью массива статей.


ArticleState.js:


// @flow
import type {Article} from "./Article";

export type ArticleState = Article[];

ArticleStoreFactory реализует шаблон «публикация-подписка» и экспортирует articleStore в качестве синглтона.


Хранилище содержит статьи и выполняет с ними неизменяемые операции добавления, удаления и обновления. Помните, что хранилище только оперирует статьями. Создавать и обновлять их может лишь articleService. Заинтересовавшиеся стороны могут подписываться и отписываться в articleStore, который хранит в памяти список всех подписчиков и уведомляет их об изменениях.


ArticleStore.js:


// @flow
import {update} from "ramda";

import type {Article} from "../domain/Article";
import type {ArticleState} from "./ArticleState";

export type ArticleStore = {
  addArticle(article: Article): void;
  removeArticle(article: Article): void;
  updateArticle(article: Article): void;
  subscribe(subscriber: Function): Function;
  unsubscribe(subscriber: Function): void;
}

export const addArticle = (articleState: ArticleState, article: Article) => articleState.concat(article);

export const removeArticle = (articleState: ArticleState, article: Article) =>
  articleState.filter((a: Article) => a.id !== article.id);

export const updateArticle = (articleState: ArticleState, article: Article) => {
  const index = articleState.findIndex((a: Article) => a.id === article.id);
  return update(index, article, articleState);
};

export const subscribe = (subscribers: Function[], subscriber: Function) =>
  subscribers.concat(subscriber);

export const unsubscribe = (subscribers: Function[], subscriber: Function) =>
  subscribers.filter((s: Function) => s !== subscriber);

export const notify = (articleState: ArticleState, subscribers: Function[]) =>
  subscribers.forEach((s: Function) => s(articleState));

export const ArticleStoreFactory = (() => {
  let articleState: ArticleState = Object.freeze([]);
  let subscribers: Function[] = Object.freeze([]);

  return {
    addArticle: (article: Article) => {
      articleState = addArticle(articleState, article);
      notify(articleState, subscribers);
    },
    removeArticle: (article: Article) => {
      articleState = removeArticle(articleState, article);
      notify(articleState, subscribers);
    },
    updateArticle: (article: Article) => {
      articleState = updateArticle(articleState, article);
      notify(articleState, subscribers);
    },
    subscribe: (subscriber: Function) => {
      subscribers = subscribe(subscribers, subscriber);
      return subscriber;
    },
    unsubscribe: (subscriber: Function) => {
      subscribers = unsubscribe(subscribers, subscriber);
    }
  }
});

export const articleStore = ArticleStoreFactory();

Наша реализация хранилища вполне подходит в качестве иллюстрации и помогает понять саму концепцию. В реальных проектах я рекомендую использовать системы управления состояниями Redux, ngrx, MobX или хотя бы Observable-сервисы данных.


Итак, теперь у нас настроены уровни домена и хранения.


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


store-demo.js:


// @flow
import type {ArticleState} from "../store/ArticleState";
import {articleService} from "../domain/ArticleService";
import {articleStore} from "../store/ArticleStore";

const article1 = articleService.createArticle({
  title: '12 rules for life',
  author: 'Jordan Peterson'
});

const article2 = articleService.createArticle({
  title: 'The Subtle Art of Not Giving a F.',
  author: 'Mark Manson'
});

if (article1 && article2) {
  const subscriber1 = (articleState: ArticleState) => {
    console.log('subscriber1, articleState changed: ', articleState);
  };

  const subscriber2 = (articleState: ArticleState) => {
    console.log('subscriber2, articleState changed: ', articleState);
  };

  articleStore.subscribe(subscriber1);
  articleStore.subscribe(subscriber2);

  articleStore.addArticle(article1);
  articleStore.addArticle(article2);

  articleStore.unsubscribe(subscriber2);

  const likedArticle2 = articleService.updateLikes(article2, 1);
  articleStore.updateArticle(likedArticle2);

  articleStore.removeArticle(article1);
}

Сервисы приложения


Этот уровень полезен для выполнения операций, связанных с потоком состояний (state flow), вроде Ajax-вызовов для получения данных с сервера, или проекций состояния (state projections).



Уровень сервисов приложения


По какой-то причине дизайнер требует, чтобы имена авторов писались заглавными буквами. Требование глупое, и мы не хотим из-за него портить свою модель. Для работы с этой фичей создаём ArticleUiService. Сервис берёт часть состояния — имя автора — и проецирует его, возвращая вызывающему версию, написанную заглавными буквами.


ArticleUiService.js:


// @flow
export const displayAuthor = (author: string) => author.toUpperCase();

Вот демо, использующее этот сервис.


app-service-demo.js:


// @flow
import {articleService} from "../domain/ArticleService";
import * as articleUiService from "../services/ArticleUiService";

const article = articleService.createArticle({
  title: '12 rules for life',
  author: 'Jordan Peterson'
});

const authorName = article ?
  articleUiService.displayAuthor(article.author) :
  null;

console.log(authorName);
// It will print JORDAN PETERSON

if (article) {
  console.log(article.author);
  // It will print Jordan Peterson
}

Уровень представления


Сейчас у нас есть полностью работоспособное приложение, не зависящее от фреймворка. Оно готово к тому, чтобы React вдохнул в него жизнь. Уровень представления состоит из отображающих (presentational) и контейнерных компонентов. Отображающие компоненты отвечают за то, как выглядят элементы, а контейнерные — за то, как элементы работают. Подробнее всё описано в статье Дэна Абрамова.



Уровень представления


Создадим компонент App, состоящий из ArticleFormContainer и ArticleListContainer.


App.js:


// @flow
import React, {Component} from 'react';

import './App.css';

import {ArticleFormContainer} from "./components/ArticleFormContainer";
import {ArticleListContainer} from "./components/ArticleListContainer";

type Props = {};

class App extends Component<Props> {
  render() {
    return (
      <div className="App">
        <ArticleFormContainer/>
        <ArticleListContainer/>
      </div>
    );
  }
}

export default App;

Теперь создадим ArticleFormContainer. Неважно, React, Angular — формы получаются сложными. Также рекомендую познакомиться с библиотекой Ramda и посмотреть, как её методы дополняют декларативную природу нашего кода.


Форма берёт введенные пользователем данные и передаёт в articleService. На основе этих данных сервис создает Article и добавляет её в ArticleStore, чтобы оттуда статью могли брать другие компоненты. Вся логика изначально хранится в методе submitForm.


ArticleFormContainer.js:


// @flow
import React, {Component} from 'react';
import * as R from 'ramda';

import type {ArticleService} from "../domain/ArticleService";
import type {ArticleStore} from "../store/ArticleStore";
import {articleService} from "../domain/ArticleService";
import {articleStore} from "../store/ArticleStore";
import {ArticleFormComponent} from "./ArticleFormComponent";

type Props = {};

type FormField = {
  value: string;
  valid: boolean;
}

export type FormData = {
  articleTitle: FormField;
  articleAuthor: FormField;
};

export class ArticleFormContainer extends Component<Props, FormData> {
  articleStore: ArticleStore;
  articleService: ArticleService;

  constructor(props: Props) {
    super(props);

    this.state = {
      articleTitle: {
        value: '',
        valid: true
      },
      articleAuthor: {
        value: '',
        valid: true
      }
    };

    this.articleStore = articleStore;
    this.articleService = articleService;
  }

  changeArticleTitle(event: Event) {
    this.setState(
      R.assocPath(
        ['articleTitle', 'value'],
        R.path(['target', 'value'], event)
      )
    );
  }

  changeArticleAuthor(event: Event) {
    this.setState(
      R.assocPath(
        ['articleAuthor', 'value'],
        R.path(['target', 'value'], event)
      )
    );
  }

  submitForm(event: Event) {
    const articleTitle = R.path(['target', 'articleTitle', 'value'], event);
    const articleAuthor = R.path(['target', 'articleAuthor', 'value'], event);

    const isTitleValid = this.articleService.isTitleValid(articleTitle);
    const isAuthorValid = this.articleService.isAuthorValid(articleAuthor);

    if (isTitleValid && isAuthorValid) {
      const newArticle = this.articleService.createArticle({
        title: articleTitle,
        author: articleAuthor
      });
      if (newArticle) {
        this.articleStore.addArticle(newArticle);
      }
      this.clearForm();
    } else {
      this.markInvalid(isTitleValid, isAuthorValid);
    }
  };

  clearForm() {
    this.setState((state) => {
      return R.pipe(
        R.assocPath(['articleTitle', 'valid'], true),
        R.assocPath(['articleTitle', 'value'], ''),
        R.assocPath(['articleAuthor', 'valid'], true),
        R.assocPath(['articleAuthor', 'value'], '')
      )(state);
    });
  }

  markInvalid(isTitleValid: boolean, isAuthorValid: boolean) {
    this.setState((state) => {
      return R.pipe(
        R.assocPath(['articleTitle', 'valid'], isTitleValid),
        R.assocPath(['articleAuthor', 'valid'], isAuthorValid)
      )(state);
    });
  }

  render() {
    return (
      <ArticleFormComponent
        formData={this.state}
        submitForm={this.submitForm.bind(this)}
        changeArticleTitle={(event) => this.changeArticleTitle(event)}
        changeArticleAuthor={(event) => this.changeArticleAuthor(event)}
      />
    )
  }
}

Обратите внимание, что ArticleFormContainer возвращает именно такую форму, какую видит пользователь, то есть представленную ArticleFormComponent. Этот компонент отображает переданные контейнером данные и генерирует события вроде changeArticleTitle, changeArticleAuthor и submitForm.


ArticleFormComponent.js:


// @flow
import React from 'react';

import type {FormData} from './ArticleFormContainer';

type Props = {
  formData: FormData;
  changeArticleTitle: Function;
  changeArticleAuthor: Function;
  submitForm: Function;
}

export const ArticleFormComponent = (props: Props) => {
  const {
    formData,
    changeArticleTitle,
    changeArticleAuthor,
    submitForm
  } = props;

  const onSubmit = (submitHandler) => (event) => {
    event.preventDefault();
    submitHandler(event);
  };

  return (
    <form
      noValidate
      onSubmit={onSubmit(submitForm)}
    >
      <div>
        <label htmlFor="article-title">Title</label>
        <input
          type="text"
          id="article-title"
          name="articleTitle"
          autoComplete="off"
          value={formData.articleTitle.value}
          onChange={changeArticleTitle}
        />
        {!formData.articleTitle.valid && (<p>Please fill in the title</p>)}
      </div>
      <div>
        <label htmlFor="article-author">Author</label>
        <input
          type="text"
          id="article-author"
          name="articleAuthor"
          autoComplete="off"
          value={formData.articleAuthor.value}
          onChange={changeArticleAuthor}
        />
        {!formData.articleAuthor.valid && (<p>Please fill in the author</p>)}
      </div>
      <button
        type="submit"
        value="Submit"
      >
        Create article
      </button>
    </form>
  )
};

Теперь у нас есть форма для создания статей, пришла очередь списка. ArticleListContainer подписывается на ArticleStore, получает все статьи и отображает ArticleListComponent.


ArticleListContainer.js:


// @flow
import * as React from 'react'

import type {Article} from "../domain/Article";
import type {ArticleStore} from "../store/ArticleStore";
import {articleStore} from "../store/ArticleStore";
import {ArticleListComponent} from "./ArticleListComponent";

type State = {
  articles: Article[]
}

type Props = {};

export class ArticleListContainer extends React.Component<Props, State> {
  subscriber: Function;
  articleStore: ArticleStore;

  constructor(props: Props) {
    super(props);
    this.articleStore = articleStore;
    this.state = {
      articles: []
    };
    this.subscriber = this.articleStore.subscribe((articles: Article[]) => {
      this.setState({articles});
    });
  }

  componentWillUnmount() {
    this.articleStore.unsubscribe(this.subscriber);
  }

  render() {
    return <ArticleListComponent {...this.state}/>;
  }
}

ArticleListComponent — это компонент, отвечающий за представление. Он через свойства получает имеющиеся статьи и отрисовывает компоненты ArticleContainer.


ArticleListComponent.js:


// @flow
import React from 'react';

import type {Article} from "../domain/Article";
import {ArticleContainer} from "./ArticleContainer";

type Props = {
  articles: Article[]
}

export const ArticleListComponent = (props: Props) => {
  const {articles} = props;
  return (
    <div>
      {
        articles.map((article: Article, index) => (
          <ArticleContainer
            article={article}
            key={index}
          />
        ))
      }
    </div>
  )
};

ArticleContainer передаёт данные статей в отвечающий за представление ArticleComponent. Он также реализует методы likeArticle и removeArticle.


Метод likeArticle обновляет количество лайков, заменяя статью в хранилище обновленной копией, а метод removeArticle удаляет статью из хранилища.


ArticleContainer.js:


// @flow
import React, {Component} from 'react';

import type {Article} from "../domain/Article";
import type {ArticleService} from "../domain/ArticleService";
import type {ArticleStore} from "../store/ArticleStore";
import {articleService} from "../domain/ArticleService";
import {articleStore} from "../store/ArticleStore";
import {ArticleComponent} from "./ArticleComponent";

type Props = {
  article: Article;
};

export class ArticleContainer extends Component<Props> {
  articleStore: ArticleStore;
  articleService: ArticleService;

  constructor(props: Props) {
    super(props);

    this.articleStore = articleStore;
    this.articleService = articleService;
  }

  likeArticle(article: Article) {
    const updatedArticle = this.articleService.updateLikes(article, article.likes + 1);
    this.articleStore.updateArticle(updatedArticle);
  }

  removeArticle(article: Article) {
    this.articleStore.removeArticle(article);
  }

  render() {
    return (
      <div>
        <ArticleComponent
          article={this.props.article}
          likeArticle={(article: Article) => this.likeArticle(article)}
          deleteArticle={(article: Article) => this.removeArticle(article)}
        />
      </div>
    )
  }
}

ArticleContainer передаёт данные статьи в ArticleComponent, который их отображает. Также этот метод с помощью исполнения соответствующих коллбэков уведомляет контейнерный компонент о нажатии кнопок «Нравится» или «Удалить».


Помните безумное требование, как должно выглядеть имя автора? ArticleComponent использует ArticleUiService из уровня приложения для проецирования части состояния из его исходного значения (строковое без заглавных букв) в желаемое, написанное заглавными буквами.


ArticleComponent.js:


// @flow
import React from 'react';

import type {Article} from "../domain/Article";
import * as articleUiService from "../services/ArticleUiService";

type Props = {
  article: Article;
  likeArticle: Function;
  deleteArticle: Function;
}

export const ArticleComponent = (props: Props) => {
  const {
    article,
    likeArticle,
    deleteArticle
  } = props;

  return (
    <div>
      <h3>{article.title}</h3>
      <p>{articleUiService.displayAuthor(article.author)}</p>
      <p>{article.likes}</p>
      <button
        type="button"
        onClick={() => likeArticle(article)}
      >
        Like
      </button>
      <button
        type="button"
        onClick={() => deleteArticle(article)}
      >
        Delete
      </button>
    </div>
  );
};

Отлично!


Теперь у нас есть полностью работоспособное React-приложение с надёжной и понятной архитектурой. Любой новичок в команде может прочесть эту статью и уверенно подключиться к нашей работе :)


Готовое приложение лежит здесь, а GitHub-репозиторий — здесь.

Tags:
Hubs:
+25
Comments 33
Comments Comments 33

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен