Разделение одностраничного приложения на слои имеет ряд преимуществ:

  • лучшее разделение проблем
  • реализация слоя может быть заменена
  • уровень пользовательского интерфейса может быть трудным для тестирования. Перенос логики на другие уровни упрощает тестирование.

Ниже мы можем увидеть схему приложения, разделенного на три основных уровня:

  • UI (также известный как презентация, просмотр)
  • Домен (он же Бизнес)
  • Доступ к данным

Витрина

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

Проверьте полную реализацию на git-hub.

Уровень пользовательского интерфейса

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

Я разделил страницу на следующие компоненты:

  • TodoContainer управляет связью между TodoSearch, TodoList и другими внешними объектами
  • TodoSearchForm - это форма для поиска дел
  • TodoList отображает список дел
  • TodoListItem: отображает одно дело в списке

TodoSearch

Компонент использует обработчик handleChange для чтения входного значения при любом изменении. TodoSearch открывает новое свойство: onSearch. Он может использоваться родительским компонентом для обработки щелчка при поиске.

Компонент не взаимодействует ни с какими другими внешними объектами, кроме своего родителя. TodoSearch - это компонент презентации.

export default class TodoSearch extends React.Component { 
  constructor(props){
    super(props);
    this.search = this.search.bind(this);
    this.handleChange = this.handleChange.bind(this);

    this.state = { text: "" };
  }
  
  search(){
    const query = Object.freeze({ text: this.state.text });
    if(this.props.onSearch)
      this.props.onSearch(query);
  }
  
  handleChange(event) {
    this.setState({text: event.target.value});
  }
  
  render() {
    return <form>
      <input onChange={this.handleChange} value={this.state.text} />
      <button onClick={this.search} type="button">Search</button>
    </form>;
  }
}

Список дел

TodoList получает список todos для рендеринга с использованием свойства. Он отправляет todos один за другим в TodoListItem.

TodoList - это функциональный компонент без сохранения состояния.

export default function TodoList(props) {
  function renderTodoItem(todo){
    return <TodoListItem todo={todo} key={todo.id}></TodoListItem>;
  }

  return <div className="todo-list">
      <ul>
        { props.todos.map(renderTodoItem) }
      </ul>
    </div>;
}

TodoListItem

TodoListItem отображает полученное todo в качестве параметра. Он реализован как функциональный компонент без сохранения состояния.

export default function TodoListItem(props){
  return       <li>
    <div>{ props.todo.title}</div>
    <div>{ props.todo.userName }</div>
  </li>;
}

TodoContainer

TodoContainer подключен к внешнему объекту todoStore и подписывается на его события изменения.

Он использует обработчик onSearch для чтения критериев поиска из TodoSearch. Затем он создает отфильтрованный список с использованием todoStore и отправляет новый список компоненту TodoList.

TodoContainer сохраняет все состояние пользовательского интерфейса, в данном случае объект query.

import TodoList from "./TodoList.jsx";
import TodoSearch from "./TodoSearch.jsx";
  
export default class TodoContainer extends React.Component {
  constructor(props){
    super(props);
    this.todoStore = props.stores.todoStore;
    this.search = this.search.bind(this);
    this.reload = this.reload.bind(this);

    this.query = null;
    this.state = {
      todos: []
    };
  }
  
  componentDidMount(){
    this.todoStore.onChange(this.reload);
    this.todoStore.fetch();
  }
  
  reload(){
    const todos = this.todoStore.getBy(this.query);
    this.setState({ todos });
  }
  
  search(query){
    this.query = query;
    this.reload();
  }
  
  render() {
    return <div>
        <TodoSearch onSearch={this.search} />
        <TodoList todos={this.state.todos} />
      </div>;
  }
}

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

Уровень домена состоит из магазинов доменов. Основное назначение хранилища доменов - хранить состояние домена и синхронизировать его с сервером.

Обязанности приложения разделены между двумя магазинами доменов:

  • TodoStore управляет объектами данных о делах
  • UserStore управляет объектами пользовательских данных

Магазин доменов является издателем. Он генерирует события каждый раз при изменении своего состояния. Компоненты могут подписаться на эти события и обновлять пользовательский интерфейс.

TodoStore - это единственный источник правды относительно текущих дел.

TodoStore

import MicroEmitter from 'micro-emitter';
import partial from "lodash/partial";

export default function TodoStore(gateway, userStore){
    let todos = [];
    const eventEmitter = new MicroEmitter();
    const CHANGE_EVENT = "change";
    
    function fetch() {
      return gateway.get().then(setLocalTodos);
    }

    function setLocalTodos(newTodos){
      todos = newTodos;
      eventEmitter.emit(CHANGE_EVENT);
    }

    function onChange(handler){
      eventEmitter.on(CHANGE_EVENT, handler);
    }
    
    function toTodoView(todo){
      return Object.freeze({
        id : todo.id,
        title : todo.title,
        userName : userStore.getById(todo.userId).name
      });
    }
    
    function descById(todo1, todo2){
      return parseInt(todo2.id) - parseInt(todo1.id);
    }

    function queryContainsTodo(query, todo){
      if(query && query.text){
        return todo.title.includes(query.text);
      }
      return true;
    } 
    
    function getBy(query) {
      const top = 25;
      return todos.filter(partial(queryContainsTodo, query))
                  .map(toTodoView)
                  .sort(descById).slice(0, top);
    }
    
    return Object.freeze({ 
      fetch,
      getBy,
      onChange
    });
 }

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

Я предпочитаю фабричные функции классам. Для получения дополнительной информации по теме ознакомьтесь с Как создавать надежные объекты с фабричными функциями в JavaScript.

Использование отделено от конструкции. Все зависимости TodoStore объявлены как входные параметры.

TodoStore генерирует события при каждом изменении: eventEmitter.emit(CHANGE_EVENT). Используется библиотека эмиттеров микро-событий MicroEmitter.

Ниже приведен пример объекта данных области дел:

{id : 1, title: "This is a title", userId: 10, completed: false }

Уровень доступа к данным

Объект шлюза инкапсулирует связь с серверным API.

TodoGateway

export default function TodoGateway(){
    const url = "https://jsonplaceholder.typicode.com/todos";
    
    function toJson(response){
      return response.json();
    }
    function get() {
      return fetch(url).then(toJson);
    }
    
    function add(todo) {
      return fetch(url, {
        method: "POST",
        body: JSON.stringify(todo),
      }).then(toJson);
    }
    
    return  Object.freeze({
      get,
      add
    });
  }

Оба get и add общедоступные методы возвращают обещание.

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

Точка входа в приложение

main.js - это приложение с единой точкой входа. Это место, где:

  • создаются все объекты и вводятся зависимости (также известный как корень композиции)
  • все компоненты созданы
import React from "react";
import ReactDOM from 'react-dom';
import TodoGateway from "./gateways/TodoGateway";
import UserGateway from "./gateways/UserGateway";
import TodoStore from "./stores/TodoStore";
import UserStore from "./stores/UserStore";
import TodoContainer from "./components/TodoContainer.jsx";

(function startApplication(){
    const userGateway = UserGateway();
    const todoGateway = TodoGateway();
    const userStore = UserStore(userGateway);
    const todoStore = TodoStore(todoGateway, userStore);
    
    const stores = {
      todoStore,
      userStore
    };

    function loadStaticData(){
      return Promise.all([userStore.fetch()]);
    }
	
    function mountPage(){		
      ReactDOM.render(
        <TodoContainer stores={stores} />,
        document.getElementById('root'));
    }

    loadStaticData().then(mountPage);
})();

Бегущий по задачам

Все компоненты и заводские функции экспортируются из модулей. Gulp с Browserify используются для объединения всех модулей вместе. В приложении есть только одна точка входа, файл main.js, поэтому задача scripts, запускающая browserify, содержит только эту запись.

ESLint используется для линтинга. Все правила определены в файле .eslintrc.json.

Выполните npm gulp, чтобы запустить задачу gulp по умолчанию. Он выполняет eslint, а затем scripts задачи.

Задача наблюдения определяется для отслеживания .js и .jsx изменений файлов и повторного выполнения задач eslint и scripts.

var gulp = require('gulp')
var eslint = require('gulp-eslint');
var babelify    = require('babelify');
var browserify  = require('browserify');
var source = require('vinyl-source-stream');
var distFolder = "./dist";

gulp.task('eslint', function () {
    gulp.src(["components/*.jsx", "dataaccess/*.js", "stores/*.js", "tools/*.js", "main.js"])
    .pipe(eslint())
    .pipe(eslint.format());
});

gulp.task('scripts', function () {
    return browserify({
            entries: 'main.js'
        })
        .transform(babelify.configure({
            presets : ["es2015", "react"]
        }))
        .bundle()
        .pipe(source('scripts.js'))
        .pipe(gulp.dest(distFolder));
});

gulp.task('watch', function () {
    gulp.watch(["components/*.jsx", "dataaccess/*.js", "stores/*.js", "tools/*.js", "main.js"], [ "eslint", "scripts" ]);
});

gulp.task( 'default', [ "eslint", "scripts" ] )

Тестирование

Фреймворк Jest используется для тестирования. Все тестовые файлы будут названы с суффиксом .test.js. Запустите npm test, чтобы запустить все тесты.

TodoStore принимает все зависимости в качестве параметров. Мы можем смоделировать зависимости TodoGateway и UserStore и протестировать объект todoStore изолированно.

Ниже приведен тест для метода todoStore.getBy().

import TodoStore from "../stores/TodoStore";

test("TodoStore can filter by title text", function() {
    //arrage
    const allTodos = [
        { id: 1, title : "title 1" },
        { id: 2, title : "title 2" },
        { id: 3, title : "title 3" }
    ];
    const todoGatewayFake = {
        get : function(){
            return Promise.resolve(allTodos);
        }
    };
    const userStoreFake = {
        getById : function(){
            return  {
                name : "Test"
            };
        }
    };
    const todoStore = TodoStore(todoGatewayFake, userStoreFake);
    const query = { text: "title 1" };
    const expectedOutputTodos = [
        { id: 1, title : "title 1" , userName : "Test"}
    ];

    //act
    todoStore.fetch().then(function makeAssertions(){
        //assert
        expect(expectedOutputTodos).toEqual(todoStore.getBy(query));
    });
});

Заключение

Трехуровневая архитектура предлагает лучшее разделение и понимание назначения уровня.

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

Хранилища домена управляют состоянием домена.

Шлюзы взаимодействуют с внешними API.

Использование отделено от строительства. Все объекты и компоненты созданы в main.js. Остальная часть приложения разработана с учетом того, что все объекты построены.

Ознакомьтесь с полной реализацией на git-hub.