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-репозиторий — здесь.

Комментарии (32)


  1. Siemargl
    21.03.2018 23:06
    +3

    Для авторов одностраничных приложений предусмотрено 4 одноуровневых котла в аду.


    1. TheShock
      22.03.2018 11:09
      +2

      Почему?


      1. DistortNeo
        22.03.2018 13:42

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


        1. justboris
          22.03.2018 14:37

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


          1. DistortNeo
            22.03.2018 15:08

            Ломают привычный способ работы не сами приложения, а кривая их реализация

            В этом и проблема. Если часто реализация отказывается кривой, значит, что-то не так с идеей?


            Это как множественное наследование в C++ — штука мощная, но из-за криворуких программистов за использование множественного наследования бьют по рукам всех.


            1. justboris
              22.03.2018 16:08
              +1

              Прямой связи "пишем SPA" — "ломаем удобство" нет, зато есть связь "много кода" — "легче что-то испортить".


              По такой логике, не нужно писать большие проекты вообще, неважно сайт там или веб-приложение.


              1. DistortNeo
                22.03.2018 17:53

                По такой логике, не нужно писать большие проекты вообще, неважно сайт там или веб-приложение.

                Ну вообще-то да. Для написания подобных проектов нужна соответствующая квалификация, а не "ыыы, заюзаю новый крутой фреймворк и всё будет зашибись".


                1. justboris
                  22.03.2018 17:55

                  Возвращаемся к изначальному вопросу этого треда: Почему для авторов одностраничных приложений предусмотрено 4 одноуровневых котла в аду?


                  Не для криворуких сайтописателей, а конкретно для авторов приложений. Почему?


                  1. DistortNeo
                    22.03.2018 19:15

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

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


                    1. justboris
                      22.03.2018 21:18
                      +1

                      Вот навскидку несколько сервисов, которые сделаны как типичные SPA, но при этом все работает нормально.



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

                      По моей выборке пользователь ничего такого не увидит. А какие у вас есть примеры плохо сделанных SPA?


                      1. DistortNeo
                        22.03.2018 22:45

                        Вот навскидку несколько сервисов, которые сделаны как типичные SPA, но при этом все работает нормально.

                        Да, это хорошие примеры:


                        1. Загрузка быстрая.
                        2. Нормальная работа с History API.
                        3. Есть возможность открывать ссылки в новом окне.

                        Хороший SPA — это когда пользователь, работающий с сайтом, даже не замечает, что сайт — SPA.


                        А какие у вас есть примеры плохо сделанных SPA?

                        https://www.tinkoff.ru/
                        https://mail.google.com/
                        https://mail.yandex.ru/
                        https://drive.google.com/


                        1. justboris
                          22.03.2018 23:27

                          Про быструю загрузку ничего сказать не могу, "быстро" – слишком субъективное понятие.


                          Работа с History API во всех приведенных вами примерах работает как надо. Url при кликах обновляется, перезагрузка страницы приводит туда же где и был до этого.


                          Открытие ссылок через ctrl+клик работает и в Gmail, и в Yamail. Google Drive и Тинькофф переходы на новую страницу блокируют. Им незачет.


                          В общем, ситуация все еще не выглядит достойной котла в аду.


                          1. DistortNeo
                            22.03.2018 23:41

                            Открытие ссылок через ctrl+клик работает и в Gmail, и в Yamail.

                            В Gmail не работает открытие по среднему и правому кликам, по Ctrl + клик — открываются только письма, но не элементы меню.


                            Yandex Mail — долгая загрузка при открытии в новой вкладке + перехват правого клика.


                            В общем, ситуация все еще не выглядит достойной котла в аду.

                            Ну почему же?


                            Одно радует: современные технологии создания SPA эволюционируют в лучшую сторону.


                            1. justboris
                              22.03.2018 23:57

                              Понятно, значит повезло мне, что мой способ с ctrl+click поддерживают, хотя другие варианты нет. Хорошо, буду иметь в виду на будущее.


                      1. TheShock
                        23.03.2018 00:35

                        mobile.twitter.com

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


        1. TheShock
          22.03.2018 20:18

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

          Скажите, вам кажется, что авторы этой документации должны гореть в аду за то, что сломали привычный способ работы с вебом и создали SPA вместо обычного сайта?


          1. DistortNeo
            22.03.2018 22:48

            Нет, они как раз ничего не ломали. Функционально сайт ничем не отличается от традиционного, а SPA используется только для повышения отзывчивости.


    1. jakobz
      22.03.2018 21:37

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


  1. Aries_ua
    21.03.2018 23:15
    +2

    Скажите, зачем так сложно? Читая код, меня охватывает ужас. Т.е вместо простого решения задачи вы делаете кучу обстракций ради простого -> взять данные из точки А и передать в компонент B.

    Скажите, зачем настолько усложнять фронтенд логику?


    1. biziwalker
      22.03.2018 11:30

      Потому что при большом количестве фич с течением времени, начинается ад из-за отсутствия архитектуры

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

      PS: отличный перевод статьи! особенно в дополнению к medium.freecodecamp.org/scaling-your-redux-app-with-ducks-6115955638be приходит виденье как хорошо спроектировать архитектуру фронтенда на годы


    1. justboris
      22.03.2018 11:54

      А зачем такие абстракции делают на бекенде? Сильно упрощая, там же тоже "получить данные по эндпойнту А и передать в хранилище B".


      Одностраничные приложения стараются отвязать от сервера, перенести логику на клиенсткую сторону. Кода становится больше, нужно его правильно организовывать.


      В этом примере, где у вас только одна сущность Article, это не очевидно, но когда таких сущностей десятки, правильное распределение кода по иерархии приходит на помощь.


  1. MrCheater
    21.03.2018 23:55
    +1

    Презентационные компоненты знают о домене и сервисах. Так быть не должно.


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

    Почитайте Дэна Абрамова https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0. Он определяет чёткие признаки, того чем Контейнеры о Компонентов отличаются.


    // ArticleContainer.js:
    
    import {articleStore} from "../store/ArticleStore";

    Для серьезного проекта это тоже неуместно. Получается, что articleStore это глобальный объект. И это работает только потому, что один браузер — один инстанс store. Если захочется, сделать Server Side Rendering, то придется весь код выбрасывать. Ибо там один инстанс сервера — множество инстансов store. Рекомендуется для пробрасывания store использовать Context API .


    1. MrCheater
      22.03.2018 00:08

      Ещё вот это антипаттерн:


      <button onClick={() => likeArticle(article)} />

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


      1. x07
        22.03.2018 09:28

        Как правильно?


        1. MrCheater
          22.03.2018 09:44

          Нужно создавать заранее замкнутые на props колбеки и дальше уже их всем кому надо прокидывать.
          В React-Redux из коробки есть connectAdvanced для таких вещей https://github.com/reactjs/react-redux/blob/master/docs/api.md#examples-1


      1. Checkmatez
        22.03.2018 12:58

        Есть и другой взгляд: twitter.com/ryanflorence/status/905574498400845825


        1. MrCheater
          22.03.2018 13:25

          Так медленно не создание новой функции. Медленно отрывать от DOM узла одну функцию и всаживать другую.