Статьи

Создание универсального блогового приложения React: реализация Flux

В первой части этого мини-сериала мы начали копаться в мире React, чтобы посмотреть, как мы можем использовать его вместе с Node.js для создания React Universal Blog App.

Во второй и последней части мы выведем наш блог на новый уровень, узнав, как добавлять и редактировать контент. Мы также познакомимся с тем, как легко масштабировать наше универсальное блог-приложение React с использованием организационных концепций React и шаблона Flux .

Создание универсального блогового приложения React: реализация Flux

Сломай это для меня

Когда мы добавим больше страниц и контента в наш блог, наш файл routes.js быстро станет большим. Поскольку одним из руководящих принципов React является разделение элементов на более мелкие, управляемые части, давайте разделим наши маршруты на разные файлы.

Откройте файл routes.js и отредактируйте его так, чтобы он имел следующий код:

 // routes.js import React from 'react' import { Route, IndexRoute } from 'react-router' // Store import AppStore from './stores/AppStore' // Main component import App from './components/App' // Pages import Blog from './components/Pages/Blog' import Default from './components/Pages/Default' import Work from './components/Pages/Work' import NoMatch from './components/Pages/NoMatch' export default ( <Route path="/" data={AppStore.data} component={App}> <IndexRoute component={Blog}/> <Route path="about" component={Default}/> <Route path="contact" component={Default}/> <Route path="work" component={Work}/> <Route path="/work/:slug" component={Work}/> <Route path="/blog/:slug" component={Blog}/> <Route path="*" component={NoMatch}/> </Route> ) 

Мы добавили несколько разных страниц в наш блог и значительно сократили размер нашего файла routes.js , разбив страницы на отдельные компоненты. Кроме того, обратите внимание, что мы добавили Store, включив AppStore , что очень важно для следующих шагов в масштабировании нашего приложения React.

Магазин: единственный источник истины

В паттерне Flux Store является очень важной частью, поскольку он служит единственным источником правды для управления данными. Это важная концепция в понимании того, как работает разработка React, и одно из самых рекламируемых преимуществ React. Прелесть этой дисциплины в том, что в любом состоянии нашего приложения мы можем получить доступ к данным AppStore и точно знать, что происходит внутри него. Если вы хотите создать приложение React, управляемое данными, необходимо учитывать несколько ключевых моментов:

  1. Мы никогда не манипулируем DOM напрямую.
  2. Наш пользовательский интерфейс отвечает на данные и данные живут в магазине
  3. Если нам нужно изменить наш пользовательский интерфейс, мы можем перейти в магазин, и магазин создаст новое состояние данных нашего приложения.
  4. Новые данные поступают в компоненты более высокого уровня, а затем передаются в компоненты более низкого уровня через props составляющие новый пользовательский интерфейс, на основе полученных новых данных.

С этими четырьмя пунктами у нас есть фундамент для одностороннего приложения потока данных. Это также означает, что в любом состоянии нашего приложения мы можем console.log(AppStore.data) , и если мы правильно построим наше приложение, мы точно будем знать, что мы можем ожидать увидеть. Вы также почувствуете, насколько это эффективно для отладки.

Теперь давайте создадим папку stores под названием stores . Внутри него создайте файл AppStore.js со следующим содержимым:

 // AppStore.js import { EventEmitter } from 'events' import _ from 'lodash' export default _.extend({}, EventEmitter.prototype, { // Initial data data: { ready: false, globals: {}, pages: [], item_num: 5 }, // Emit change event emitChange: function(){ this.emit('change') }, // Add change listener addChangeListener: function(callback){ this.on('change', callback) }, // Remove change listener removeChangeListener: function(callback) { this.removeListener('change', callback) } }) 

Вы можете видеть, что мы прикрепили источник событий. Это позволяет нам редактировать данные в нашем магазине, а затем повторно визуализировать наше приложение, используя AppStore.emitChange() . Это мощный инструмент, который должен использоваться только в определенных местах нашего приложения. В противном случае может быть трудно понять, где данные AppStore изменяются, что приводит нас к следующему пункту …

Реактивные компоненты: выше и ниже уровня

Дан Абрамов написал отличный пост о концепции умных и немых компонентов . Идея состоит в том, чтобы сохранить действия по изменению данных только в компонентах более высокого уровня (интеллектуальных), в то время как компоненты более низкого уровня (немых) принимают данные, которые им передаются через реквизиты, и визуализируют пользовательский интерфейс на основе этих данных. Каждый раз, когда выполняется действие над компонентом более низкого уровня, это событие передается через реквизиты компонентам более высокого уровня для обработки в действие. Затем он перераспределяет данные (односторонний поток данных) обратно через приложение.

Сказал, что давайте начнем строить некоторые компоненты. Для этого создайте папку с именем components . Внутри него создайте файл с именем App.js с таким содержимым:

 // App.js import React, { Component } from 'react' // Dispatcher import AppDispatcher from '../dispatcher/AppDispatcher' // Store import AppStore from '../stores/AppStore' // Components import Nav from './Partials/Nav' import Footer from './Partials/Footer' import Loading from './Partials/Loading' export default class App extends Component { // Add change listeners to stores componentDidMount(){ AppStore.addChangeListener(this._onChange.bind(this)) } // Remove change listeners from stores componentWillUnmount(){ AppStore.removeChangeListener(this._onChange.bind(this)) } _onChange(){ this.setState(AppStore) } getStore(){ AppDispatcher.dispatch({ action: 'get-app-store' }) } render(){ const data = AppStore.data // Show loading for browser if(!data.ready){ document.body.className = '' this.getStore() let style = { marginTop: 120 } return ( <div className="container text-center" style={ style }> <Loading /> </div> ) } // Server first const Routes = React.cloneElement(this.props.children, { data: data }) return ( <div> <Nav data={ data }/> { Routes } <Footer data={ data }/> </div> ) } } 

В нашем компоненте App.js мы подключили прослушиватель событий к нашему AppStore который будет повторно отображать состояние, когда AppStore генерирует событие onChange . Эти повторно обработанные данные затем будут переданы в качестве реквизитов дочерним компонентам. Также обратите внимание, что мы добавили метод getStore который будет отправлять действие get-app-store для отображения наших данных на стороне клиента. После того, как данные были получены из Cosmic JS API, это вызовет изменение AppStore которое будет включать AppStore.data.ready со значением true , удалит знак загрузки и отобразит наш контент.

Компоненты страницы

Чтобы создать первую страницу нашего блога, создайте папку Pages . Внутри мы создадим файл с именем Blog.js со следующим кодом:

 // Blog.js import React, { Component } from 'react' import _ from 'lodash' import config from '../../config' // Components import Header from '../Partials/Header' import BlogList from '../Partials/BlogList' import BlogSingle from '../Partials/BlogSingle' // Dispatcher import AppDispatcher from '../../dispatcher/AppDispatcher' export default class Blog extends Component { componentWillMount(){ this.getPageData() } componentDidMount(){ const data = this.props.data document.title = config.site.title + ' | ' + data.page.title } getPageData(){ AppDispatcher.dispatch({ action: 'get-page-data', page_slug: 'blog', post_slug: this.props.params.slug }) } getMoreArticles(){ AppDispatcher.dispatch({ action: 'get-more-items' }) } render(){ const data = this.props.data const globals = data.globals const pages = data.pages let main_content if(!this.props.params.slug){ main_content = &lt;BlogList getMoreArticles={ this.getMoreArticles } data={ data }/&gt; } else { const articles = data.articles // Get current page slug const slug = this.props.params.slug const articles_object = _.keyBy(articles, 'slug') const article = articles_object[slug] main_content = &lt;BlogSingle article={ article } /&gt; } return ( <div> <Header data={ data }/> <div id="main-content" className="container"> <div className="row"> <div className="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1"> { main_content } </div> </div> </div> </div> ) } } 

Эта страница будет служить шаблоном для нашей страницы со списком блогов (главная страница) и отдельных страниц нашего блога. Здесь мы добавили метод к нашему компоненту, который будет получать данные страницы перед монтированием компонента, используя метод React жизненного цикла componentWillMount . Затем, как только компонент будет смонтирован в componentDidMount() , мы добавим заголовок страницы в <title> документа.

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

Давайте теперь BlogList к нашему компоненту BlogList чтобы увидеть, как это работает.

Создайте новую папку с именем Partials . Затем внутри него создайте файл с именем BlogList.js со следующим содержимым:

 // BlogList.js import React, { Component } from 'react' import _ from 'lodash' import { Link } from 'react-router' export default class BlogList extends Component { scrollTop(){ $('html, body').animate({ scrollTop: $("#main-content").offset().top }, 500) } render(){ let data = this.props.data let item_num = data.item_num let articles = data.articles let load_more let show_more_text = 'Show More Articles' if(data.loading){ show_more_text = 'Loading...' } if(articles && item_num <= articles.length){ load_more = ( <div> <button className="btn btn-default center-block" onClick={ this.props.getMoreArticles.bind(this) }> { show_more_text } </button> </div> ) } articles = _.take(articles, item_num) let articles_html = articles.map(( article ) => { let date_obj = new Date(article.created) let created = (date_obj.getMonth()+1) + '/' + date_obj.getDate() + '/' + date_obj.getFullYear() return ( <div key={ 'key-' + article.slug }> <div className="post-preview"> <h2 className="post-title pointer"> <Link to={ '/blog/' + article.slug } onClick={ this.scrollTop }>{ article.title }</Link> </h2> <p className="post-meta">Posted by <a href="https://cosmicjs.com" target="_blank">Cosmic JS</a> on { created }</p> </div> <hr/> </div> ) }) return ( <div> <div>{ articles_html }</div> { load_more } </div> ) } } 

В нашем компоненте BlogList мы добавили событие onClick к нашей кнопке Show More Articles . Последний выполняет метод getMoreArticles который был передан в качестве подпорки из компонента страницы более высокого уровня. Когда эта кнопка нажата, событие всплывает до компонента Blog а затем запускает действие в AppDispatcher . AppDispatcher действует как посредник между нашими компонентами более высокого уровня и нашим AppStore .

Для краткости мы не собираемся создавать все компоненты Page и Partial в этом руководстве, поэтому, пожалуйста, загрузите репозиторий GitHub и добавьте их из папки components .

AppDispatcher

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

Чтобы продолжить этот урок, создайте папку с именем dispatcher . Внутри него создайте файл с именем AppDispatcher.js , содержащий следующий код:

 // AppDispatcher.js import { Dispatcher } from 'flux' import { getStore, getPageData, getMoreItems } from '../actions/actions' const AppDispatcher = new Dispatcher() // Register callback with AppDispatcher AppDispatcher.register((payload) => { let action = payload.action switch(action) { case 'get-app-store': getStore() break case 'get-page-data': getPageData(payload.page_slug, payload.post_slug) break case 'get-more-items': getMoreItems() break default: return true } return true }) export default AppDispatcher 

Мы ввели модуль Flux в этот файл для построения нашего диспетчера. Давайте добавим наши действия сейчас.

Действия: последняя остановка перед магазином

Для начала давайте создадим файл actions.js во вновь созданной папке с именем actions . Этот файл будет содержать следующее содержание:

 // actions.js import config from '../config' import Cosmic from 'cosmicjs' import _ from 'lodash' // AppStore import AppStore from '../stores/AppStore' export function getStore(callback){ let pages = {} Cosmic.getObjects(config, function(err, response){ let objects = response.objects /* Globals ======================== */ let globals = AppStore.data.globals globals.text = response.object['text'] let metafields = globals.text.metafields let menu_title = _.find(metafields, { key: 'menu-title' }) globals.text.menu_title = menu_title.value let footer_text = _.find(metafields, { key: 'footer-text' }) globals.text.footer_text = footer_text.value let site_title = _.find(metafields, { key: 'site-title' }) globals.text.site_title = site_title.value // Social globals.social = response.object['social'] metafields = globals.social.metafields let twitter = _.find(metafields, { key: 'twitter' }) globals.social.twitter = twitter.value let facebook = _.find(metafields, { key: 'facebook' }) globals.social.facebook = facebook.value let github = _.find(metafields, { key: 'github' }) globals.social.github = github.value // Nav const nav_items = response.object['nav'].metafields globals.nav_items = nav_items AppStore.data.globals = globals /* Pages ======================== */ let pages = objects.type.page AppStore.data.pages = pages /* Articles ======================== */ let articles = objects.type['post'] articles = _.sortBy(articles, 'order') AppStore.data.articles = articles /* Work Items ======================== */ let work_items = objects.type['work'] work_items = _.sortBy(work_items, 'order') AppStore.data.work_items = work_items // Emit change AppStore.data.ready = true AppStore.emitChange() // Trigger callback (from server) if(callback){ callback(false, AppStore) } }) } export function getPageData(page_slug, post_slug){ if(!page_slug || page_slug === 'blog') page_slug = 'home' // Get page info const data = AppStore.data const pages = data.pages const page = _.find(pages, { slug: page_slug }) const metafields = page.metafields if(metafields){ const hero = _.find(metafields, { key: 'hero' }) page.hero = config.bucket.media_url + '/' + hero.value const headline = _.find(metafields, { key: 'headline' }) page.headline = headline.value const subheadline = _.find(metafields, { key: 'subheadline' }) page.subheadline = subheadline.value } if(post_slug){ if(page_slug === 'home'){ const articles = data.articles const article = _.find(articles, { slug: post_slug }) page.title = article.title } if(page_slug === 'work'){ const work_items = data.work_items const work_item = _.find(work_items, { slug: post_slug }) page.title = work_item.title } } AppStore.data.page = page AppStore.emitChange() } export function getMoreItems(){ AppStore.data.loading = true AppStore.emitChange() setTimeout(function(){ let item_num = AppStore.data.item_num let more_item_num = item_num + 5 AppStore.data.item_num = more_item_num AppStore.data.loading = false AppStore.emitChange() }, 300) } 

Здесь есть несколько методов, которые доступны в этом файле actions.js . getStore() подключается к Cosmic JS API для обслуживания содержимого нашего блога. getPageData() получает данные страницы из предоставленного slug (или ключа страницы). getMoreItems() контролирует, сколько элементов будет отображаться в наших компонентах BlogList и WorkList .

Когда getMoreItems() запускается, сначала для AppStore.data.loading устанавливается значение true . Затем, через 300 миллисекунд (для эффекта), он позволяет добавить еще пять элементов в наш список сообщений в блоге или рабочих элементов. Наконец, он устанавливает AppStore.data.loading в false .

Настройте свою космическую JS CMS

Чтобы начать получать данные из вашего облачного API контента в Cosmic JS , давайте создадим файл config.js . Откройте этот файл и вставьте следующее содержимое:

 // config.js export default { site: { title: 'React Universal Blog' }, bucket: { slug: process.env.COSMIC_BUCKET || 'react-universal-blog', media_url: 'https://cosmicjs.com/uploads', read_key: process.env.COSMIC_READ_KEY || '', write_key: process.env.COSMIC_WRITE_KEY || '' }, } 

Это означает, что контент будет поступать из react-universal-blog Cosmic JS react-universal-blog . Чтобы создать контент для собственного блога или приложения, зарегистрируйтесь в Cosmic JS . Когда появится запрос «Добавить новое ведро», нажмите «Установить начальное ведро», и вы сможете выполнить шаги по установке «React Universal Blog». Как только это будет сделано, вы можете добавить слаг вашего уникального сегмента в этот файл конфигурации.

Рендеринг на стороне сервера

Теперь, когда у нас настроено большинство наших компонентов React и архитектуры Flux, давайте закончим, отредактировав наш файл app-server.js для рендеринга всего на стороне сервера. Этот файл будет иметь следующий код:

 // app-server.js import React from 'react' import { match, RoutingContext, Route, IndexRoute } from 'react-router' import ReactDOMServer from 'react-dom/server' import express from 'express' import hogan from 'hogan-express' import config from './config' // Actions import { getStore, getPageData } from './actions/actions' // Routes import routes from './routes' // Express const app = express() app.engine('html', hogan) app.set('views', __dirname + '/views') app.use('/', express.static(__dirname + '/public/')) app.set('port', (process.env.PORT || 3000)) app.get('*',(req, res) => { getStore(function(err, AppStore){ if(err){ return res.status(500).end('error') } match({ routes, location: req.url }, (error, redirectLocation, renderProps) => { // Get page data for template const slug_arr = req.url.split('/') let page_slug = slug_arr[1] let post_slug if(page_slug === 'blog' || page_slug === 'work') post_slug = slug_arr[2] getPageData(page_slug, post_slug) const page = AppStore.data.page res.locals.page = page res.locals.site = config.site // Get React markup const reactMarkup = ReactDOMServer.renderToStaticMarkup(<RoutingContext {...renderProps} />) res.locals.reactMarkup = reactMarkup if (error) { res.status(500).send(error.message) } else if (redirectLocation) { res.redirect(302, redirectLocation.pathname + redirectLocation.search) } else if (renderProps) { // Success! res.status(200).render('index.html') } else { res.status(404).render('index.html') } }) }) }) app.listen(app.get('port')) console.info('==> Server is listening in ' + process.env.NODE_ENV + ' mode') console.info('==> Go to http://localhost:%s', app.get('port')) 

Этот файл использует наш getStore действия getStore для получения нашего контента от серверной части Cosmic JS API, а затем проходит через React Router, чтобы определить, какой компонент будет подключен. Затем все будет преобразовано в статическую разметку с помощью renderToStaticMarkup . Этот вывод затем сохраняется в переменной шаблона для использования нашим файлом views/index.html .

Еще раз, давайте обновим раздел scripts нашего файла package.json чтобы он выглядел так, как показано ниже:

 "scripts": { "start": "npm run production", "production": "rm -rf public/index.html && NODE_ENV=production webpack -p && NODE_ENV=production babel-node app-server.js --presets es2015", "webpack-dev-server": "NODE_ENV=development PORT=8080 webpack-dev-server --content-base public/ --hot --inline --devtool inline-source-map --history-api-fallback", "development": "cp views/index.html public/index.html && NODE_ENV=development webpack && npm run webpack-dev-server" }, 

Теперь мы можем работать в режиме разработки с горячей перезагрузкой, и мы можем работать в производственном режиме с серверной разметкой. Запустите следующую команду, чтобы запустить полное приложение React Universal Blog в рабочем режиме:

 npm start 

Наш блог готов для просмотра по адресу http: // localhost: 3000 . Его можно просматривать на стороне сервера, на стороне браузера, а нашим контентом можно управлять через Cosmic JS , нашу облачную платформу контента.

Вывод

React — очень сложный способ управления пользовательским интерфейсом и данными в приложении. Это также очень хороший выбор для рендеринга контента на стороне сервера, для успокоения веб-сканеров, лишенных JavaScript, и для рендеринга на стороне браузера для обеспечения быстрого просмотра. И мы можем получить лучшие результаты обоих миров, сделав наше приложение универсальным.

Я действительно надеюсь, что вам понравилась эта статья. Еще раз, полный код можно скачать с GitHub .