Разделение одностраничного приложения на слои имеет ряд преимуществ:
- лучшее разделение проблем
- реализация слоя может быть заменена
- уровень пользовательского интерфейса может быть трудным для тестирования. Перенос логики на другие уровни упрощает тестирование.
Ниже мы можем увидеть схему приложения, разделенного на три основных уровня:
- 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.