Введение

В статьях по  Loopback мы разработали API из двух связанных моделей - это блоги и статьи. Настало время освежить наши знания по dva и сделать небольшой практический пример. Давайте разработаем интерфейс для просмотра наших блогов и статей. Будем строить наш интерфейс поэтапно. Сначала разработаем интерфейс простых страниц нашего приложения, подключим роутинг, добавим mock данные. А затем будем добавлять более сложные кейсы, такие как регистрация, фильтрация данных, вывод связанных данных и т.д.

Наше приложения будет состоять из следующих страниц:

Фронт часть

  • Страница списка блогов.
  • Страница отдельного блога.
  • Страница отдельной статьи.
  • Главная страница.
  • Страницы регистрации и авторизации.

Бэк часть

  • Страница добавления блога.
  • Страница добавления статьи в блог.

Для построения приложения используем фреймворке DVA, который использует react + redux + saga внутри себя и предоставляет удобное API. Интерфейс будем строить на ant design компонентах. Бэк часть будет построена на фреймворке Loopback.

Настройка приложения

Начнем с установки нового приложения. В папке с проектами выполним команду по установке пустого приложения:

cd ~/projects
dva new blog-client

После запуска данной команды, dva скачает пакет первоначального приложения и установит все зависимости с помощью npm. Теперь у нас есть первоначальная структура приложения.

Выполнив команду npm start, приложение можно запустить и посмотреть, что оно открылось по адресу. Давайте сначала добавим необходимые библиотеки в package.json, настроим простой роутинг и для удобства разработки hot релоадинг.

Добавляем в packege.json новые зависимости dependencies:

"antd": "^3.2.0",
"dva-loading": "^1.0.4"

и в devDependencies:

"babel-plugin-import": "^1.1.1"

После этого в консоли запускаем команду: npm i

Для добавления hot релоадинга необходимо поправить файл .webpackrc, в котором содержатся инструкции для webpack. Все дело в том, что фреймворк предоставляет свою обертку над create-react-script с возможностью конфигурирования webpack. Добавив в файл следующие строчки и перезапустив npm start, получим работающий hot релоадинг:

{
  "extraBabelPlugins": [
    ["import", { "libraryName": "antd", "style": true }]
  ],
  "env": {
    "development": {
      "extraBabelPlugins": [
        "dva-hmr"
      ]
    }
  }
}

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

  • Добавить компоненты отдельных страниц.
  • Добавить общий шаблон для страниц (что-то типа layout), который будет отображаться на каждой странице.
  • Непосредственно настроить функцию роутинга.

Сначала добавим компоненты отдельных страниц BlogsPageBlogPage и ArticlePage. На данном этапе это будут просто компоненты с одним блоком div и названием страницы внутри. Позже, при разработке самих страниц, мы их, конечно, доработаем. Также нам надо добавить три модели blogs, blog и article, которые будут соответствовать состоянию нашего store на этих страницах.

Код для компонентов страниц в начале выглядит вот так:

import React from 'react';
import {connect} from 'dva';

function BlogPage({blog}) {
  return (
    <div>
    <h1>Страница блога: {blog.id}</h1>
    </div>
  );
}

BlogPage.propTypes = {};

export default connect(({blog}) => ({blog}))(BlogPage);

Возможно, самая не очевидная часть данного кода - это функция connect. На самом деле там все просто. Она принимает на вход функцию, в аргументах мы, с помощью деструктуризации, получаем часть нашего store (blog). Компонент страницы для отображения статьи будет выглядеть аналогично.

Теперь давайте рассмотрим компонент для отображения шаблона (MyLayout). Он будет оборачивать наши страницы и добавит к ним шапку и подвал (header и footer):

import React from 'react';
import {connect} from 'dva';
import {withRouter} from 'dva/router';
import {Layout} from 'antd'
import MyMenu from './Menu';

const {Header, Content, Footer} = Layout;

function MyLayout({children, dispatch, app}) {
return (
  <div>
    <Layout>
      <Layout style={{height: '100vh', overflow: 'scroll'}} id="mainContainer">
        <Header style={{position: 'fixed', width: '100%', height: '44px', zIndex: 999}}>
          <MyMenu/>
        </Header>
        <Content style={{margin: '50px'}}>
         {children}
        </Content>
        <Footer>
          <h3 style={{textAlign: 'center'}}>Найди свой любимый блог!</h3>
        </Footer>
      </Layout>
    </Layout>
  </div>
);
}

MyLayout.propTypes = {};

export default withRouter(connect((app) => ({app}))(MyLayout));

В компоненте MyLayout мы использовали стандартные составляющие из библиотеки Ant design. Но также добавили свой компонент по отображению и работе с меню (MyMenu):

import React, {Component} from 'react';
import {NavLink} from 'react-router-dom';
import {connect} from 'dva';
import {Menu} from 'antd';

class MyMenu extends Component {

  render() {
    let me = this;
    return (
      <div>
        <Menu
        theme="dark"
        mode="horizontal"
        selectedKeys={[me.props.menu.key]}
        style={{lineHeight: '44px'}}
        >
          <Menu.Item key="main">
            <NavLink activeClassName="active" to="/">Главная</NavLink>
          </Menu.Item>
          <Menu.Item key="blogs">
            <NavLink activeClassName="active" to="/blogs">Блоги</NavLink>
          </Menu.Item>
        </Menu>
      </div>
    );
  }

}

MyMenu.propTypes = {};

export default connect(({menu}) => ({menu}))(MyMenu);

Компонент MyMenu будет у нас statefull компонентом, т.е. будет хранить состояние текущего активного пункта меню. Для управления добавим модель menu, где будем хранить текущий активный пункт меню. Далее функцией connect свяжем компонент MyMenu и модель menu.

export default {

  namespace: 'menu',

  state: {
    current: 'main',
  },

  subscriptions: {
    setup ({dispatch, history}) {
      history.listen(({pathname}) => {
        let key = '';
        if (pathname === '/') {
          key = 'main';
        }
        else if (pathname === '/blogs') {
          key = 'blogs';
        }
        dispatch({type: 'setCurrent', payload: {key}})
      })
    },
  },

  reducers: {
    setCurrent(state, action) {
      return {...state, ...action.payload};
    },
  },

};

Давайте рассмотрим стандартное определение модели, на примере модели blogs:

import pathToRegexp from 'path-to-regexp';
import {query} from '../services/blogs';

export default {

namespace: 'blogs',

  state: {
    list: []
  },

  subscriptions: {
    setup ({dispatch, history}) {
      history.listen(({pathname}) => {
        const match = pathToRegexp('/blogs').exec(pathname);
        if (match) {
          dispatch({type: 'fetch'})
        }
      })
    },
  },

  effects: {
    *fetch({payload}, {call, put}) {
      const blogs = yield call(query);
      const {list} = blogs;
      yield put({type: 'show', payload: {list}});
    },
  },

  reducers: {
    show(state, action) {
      return {...state, ...action.payload};
    },
  },

};

Итак, модель представляет собой обычный javascript объект с определенными свойствами и методами:

  • namespace указывает на часть нашего store (т.е. в store мы можем получить его как store.blogs). 
  • state - определяет структуру этой части store и является обычным javascript объектом. 
  • subscriptions - объект, в котором указываются функции подписки на глобальные события (setup).
  • в объекте reducers указываются синхронные функции-действия для изменения store.
  • в объекте effects описываются side-эффекты, т.е. какие-то асинхронные действия.

На текущий момент у нас получилась следующая структура приложения:

И его внешний вид:

Mock данные

На текущем этапе построения приложения, чтобы не отвлекаться от реализации интерфейса и не подключать backend-часть, воспользуемся встроенной в roadhog сервер возможностью по предоставлению mock api. Сами данные будем генерировать с помощью небольшой, но удобной библиотеки mockjs. Для начала установим ее:

npm i mockjs --save

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

const path = require('path');
const fs = require('fs');
const mock = {};

fs.readSync(path.join(__dirname + './mock')).forEach(function(file) {
     Object.assign(mock, require('./mock/' + file))
});
module.exports = mock;

Страница списка блогов

На странице списка блогов будем выводить наши блоги в виде карточек (компонент Card) в две колонки (на среднем и большом размерах экрана) и в одну колонку (на маленьких экранах). Для этого воспользуемся двумя компонентами из UI библиотеки ant - List и Card.

Определим два своих кастомных компонента BlogsList и BlogCard. Это  будут stateless компоненты, они будут просто отображать наши блоги. Их свойства будут отправляться им с верхнего уровня - компонента BlogsPage

Код компонентов BlogsList и BlogCard, в принципе, довольно простой. Единственное, что мы добавили в данные компоненты, так это проверку типов свойств с помощью библиотеки prop-types. Пример кода компонента BlogsList:

import React from 'react';
import PropTypes from 'prop-types';
import BlogCard from './BlogCard';
import {List} from 'antd';

function BlogsList({items}) {

  const view = items.length
    ? <List
      grid={{gutter: 8, xs: 1, sm: 2}}
      dataSource={items}
      renderItem={item => (
      <List.Item>
      <BlogCard {...item} />
      </List.Item>
      )}
    />
    : <h2>На данный момент не заведено ни одного блога!</h2>;

  return (
    <div>
      {view}
    </div>
  );
}

BlogsList.propTypes = {
  items: PropTypes.array
};

export default BlogsList;

Получилась симпатичная страница со списком блогов:

В итоге получили следующий алгоритм работы страницы: 

- наша модель blogs в подписках слушает, когда адрес страницы станет равен /blogs;

далее она вызывает свой асинхронный метод fetch, в котором отправляется запрос на сервер и возвращается список блогов;

- далее модель вызывает свой метод show для изменения состояния store приложения;

- так как наш компонент BlogsPage, через функцию connect, подключен к нашей модели, то после того, как состояние store поменялось, он перерисовывает себя. При этом он пробрасывает необходимые  свойства своим дочерним компонентам BlogsList.

Страница отдельного блога

На странице отдельного блога нам необходимо вывести список статей, которые относятся только к этому блогу. Очевидно, что отбирать мы их будем по id блога.

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

import React from 'react';
import PropTypes from 'prop-types';
import {List} from 'antd';
import {Link} from 'react-router-dom';

function ArticlesList({items}) {

  const view = items.length
    ? <List
      itemLayout="horizontal"
      dataSource={items}
      renderItem={item => (
      <List.Item actions={[<Link to={`/article/${item.id}`}>Подробнее</Link>]}>
        <List.Item.Meta
title={<Link to={`/article/${item.id}`}>{item.title}</Link>}
description={item.except}
      />
    </List.Item>
    )}
  />
  : <h2>На данный момент в этом блоге нет ни одной статьи!
  </h2>;

    return (
    <div>
     {view}
    </div>
  );
}

ArticlesList.propTypes = {
  items: PropTypes.array
};

export default ArticlesList;

Страница блога (компонент BlogPage) будет использовать этот компонент для отображения списка статей. Так как он связан функцией connect с моделью blog, то нам остается только реализовать в этой модели загрузку необходимых статей. Также мы в данной модели применим функцию select для поиска в нашем store текущего блога (хотя какую-то расширенную информацию мы могли бы запросить и с сервера).

Код модели blog:

import pathToRegexp from 'path-to-regexp';
import {blogArticles} from '../services/blogs';
import {findById} from '../services/blogs';

export default {

namespace: 'blog',

  state: {
    id : 0,
    blog: {
      id : 0,
      title : '',
      description: ''
    },
    list: []
  },

  subscriptions: {
    setup ({dispatch, history}) {
      history.listen(({pathname}) => {
        const match = pathToRegexp('/blog/:id').exec(pathname);
        if (match) {
          dispatch({type: 'fetch', payload: {id: match[1]}})
        }
      })
    },
  },

  effects: {
  *fetch({payload}, {call, select, put}) {
    const {id} = payload;
    const articles = yield call(blogArticles, payload.id);
    const {list} = articles;

    const data = yield call(findById, payload.id);
    const blog = {
      id: data.id,
      title: data.title,
      description: data.description,
    };

    yield put({type: 'show', payload: {id, blog, list}});
    },
  },

  reducers: {
    show(state, action) {
      return {...state, ...action.payload};
    },
  },

};

После того, как модель получает все необходимые данные, она отдает их компоненту BlogPage, который в свою очередь передает список  статей компоненту ArticlesList.

Вот какая страница у нас получилась:

Страница отдельной статьи

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

Итоги

В данном небольшом руководстве мы собрали скелет нашего приложения с нуля. Определили модели, сделали навигацию, добавили mock данные, написали компоненты страниц. В следующей части нам предстоит реализовать главную страницу приложения, регистрацию и авторизацию, а также подключить api loopback, который мы разрабатывали в серии статей по фреймворку loopback.

Скачать исходный код можно по ссылке:

git clone https://github.com/dionic-i/lb-blog-client.git

После загрузки переходим в папку проекта:

npm i
git checkout part1
npm run dev