Кода за тази глава можете да намерите тук.
В тази глава ще създадем различни страници на нашето приложение и ще направим възможно навигирането между тях.
💡 React Router е библиотека за навигиране между страниците във вашето React приложение. Може да бъде използвано както от страна на клиента, така и от страна на сървъра.
След v4 релийз React Router получи доста нови неща и въпреки че е още в бета версия, ще използвам него, тъй като искам това ръководство да бъде валидно и в бъдеще.
- Изпълнете
yarn add react-router@next react-router-dom@next
От страна на клиента, първо ще трябва да вмъкнем нашето приложение в BrowserRouter
компонент.
- Редактирайте
src/client/index.jsx
файла, както следва:
// [...]
import { BrowserRouter } from 'react-router-dom'
// [...]
const wrapApp = (AppComponent, reduxStore) =>
<Provider store={reduxStore}>
<BrowserRouter>
<AppContainer>
<AppComponent />
</AppContainer>
</BrowserRouter>
</Provider>
Нашето приложение ще има 4 страници:
-
Home page - начална страница
-
A Hello page - показваща бутон и съобщение за синхронните действия.
-
A Hello Async - показваща бутон и съобщение за асинхронните действия.
-
A 404 "Not Found" page - страница, показваща се когато търсената страница не е налична, стандартното име за такъв тип страници е 404 Not Found.
-
Създайте
src/client/component/page/home.jsx
файл, съдържащ:
// @flow
import React from 'react'
const HomePage = () => <p>Home</p>
export default HomePage
- Създайте
src/client/component/page/hello.jsx
файл, съдържащ:
// @flow
import React from 'react'
import HelloButton from '../../container/hello-button'
import Message from '../../container/message'
const HelloPage = () =>
<div>
<Message />
<HelloButton />
</div>
export default HelloPage
- Създайте
src/client/component/page/hello-async.jsx
файл, съдържащ:
// @flow
import React from 'react'
import HelloAsyncButton from '../../container/hello-async-button'
import MessageAsync from '../../container/message-async'
const HelloAsyncPage = () =>
<div>
<MessageAsync />
<HelloAsyncButton />
</div>
export default HelloAsyncPage
- Създайте
src/client/component/page/not-found.jsx
файл, съдържащ:
// @flow
import React from 'react'
const NotFoundPage = () => <p>Page not found</p>
export default NotFoundPage
Нека да добавим пътищата (routes) в конфигурационния файл, който може да се използва от всички части на приложението ни.
- Редактирайте
src/shared/routes.js
файла, както следва:
// @flow
export const HOME_PAGE_ROUTE = '/'
export const HELLO_PAGE_ROUTE = '/hello'
export const HELLO_ASYNC_PAGE_ROUTE = '/hello-async'
export const NOT_FOUND_DEMO_PAGE_ROUTE = '/404'
export const helloEndpointRoute = (num: ?number) => `/ajax/hello/${num || ':num'}`
/404
пътя ще бъде използван в навигацията просто, за да се покаже какво се случва по принцип когато се кликне на неработеща хипервръзка (broken link).
- Създайте
src/client/component/nav.jsx
файл, съдържащ:
// @flow
import React from 'react'
import { NavLink } from 'react-router-dom'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
NOT_FOUND_DEMO_PAGE_ROUTE,
} from '../../shared/routes'
const Nav = () =>
<nav>
<ul>
{[
{ route: HOME_PAGE_ROUTE, label: 'Home' },
{ route: HELLO_PAGE_ROUTE, label: 'Say Hello' },
{ route: HELLO_ASYNC_PAGE_ROUTE, label: 'Say Hello Asynchronously' },
{ route: NOT_FOUND_DEMO_PAGE_ROUTE, label: '404 Demo' }
].map(link => (
<li key={link.route}>
<NavLink to={link.route} activeStyle={{ color: 'limegreen' }} exact>{link.label}</NavLink>
</li>
))}
</ul>
</nav>
export default Nav
Тук просто създаваме няколко NavLink
елемента, които използват пътищата, които декларирахме преди малко.
- И накрая редактирайте
src/client/app.jsx
файла, както следва:
// @flow
import React from 'react'
import { Switch } from 'react-router'
import { Route } from 'react-router-dom'
import { APP_NAME } from '../shared/config'
import Nav from './component/nav'
import HomePage from './component/page/home'
import HelloPage from './component/page/hello'
import HelloAsyncPage from './component/page/hello-async'
import NotFoundPage from './component/page/not-found'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
} from '../shared/routes'
const App = () =>
<div>
<h1>{APP_NAME}</h1>
<Nav />
<Switch>
<Route exact path={HOME_PAGE_ROUTE} render={() => <HomePage />} />
<Route path={HELLO_PAGE_ROUTE} render={() => <HelloPage />} />
<Route path={HELLO_ASYNC_PAGE_ROUTE} render={() => <HelloAsyncPage />} />
<Route component={NotFoundPage} />
</Switch>
</div>
export default App
🏁 Изпълнете yarn start
и yarn dev:wds
. Отворете http://localhost:8000
и кликнете на хипервръзките, за да навигирате между различните страници. Би трябвало да виждате, че URL адреса се променя динамично. Променете текущата страница като отидете на друга и използвайте бутона "Назад" (Back) на вашия браузър, за да се върнете на предишната страница - по този начин виждаме, че историята на браузъра (browsing history) все още работи коректно.
Сега, да кажем, че сте отворили http://localhost:8000/hello
по този начин. Натиснете бутона за опресняване на страницата (refresh button), обикновено това е F5 от клавиатурата ви ако използвате операционна система Windows. Би трябвало да видите 404, тъй като нашия Express сървър отговаря само на /
. И тъй като навигирайки между страниците вие всъщност го правехте само от страна на клиента, нека сега да добавим тази функционалност и от страна на сървъра, за да получим накрая желаното поведение.
💡 Server-Side Rendering означава показване/рендиране на вашето приложение при първоначалното зареждане на страницата, вместо да се разчита на JavaScript да я рендира в браузъра на клиента.
SSR е от основно значение за оптимизациите, които се правят за търсещите машини(SEO - search engine optimization) и предоставя по-добро изживяване (user experience) на крайния потребител като му показва приложението моментално.
Първото нещо, което ще направим тук е да преместим по-голямата част от нашия клиентски код в частта със споделения код (shared / isomorphic / universal part of our codebase), тъй като сега сървърът също ще рендира нашето React приложение.
- Преместете всички файлове от
client
вshared
, с изключение наsrc/client/index.jsx
.
Ще трябва да отразим промените от местенето в няколко import команди:
-
В
src/client/index.jsx
, заместете'./app'
с'../shared/app'
на трите места където се среща и'./reducer/hello'
с'../shared/reducer/hello'
-
В
src/shared/app.jsx
, заместете'../shared/routes'
с'./routes'
и'../shared/config'
с'./config'
-
В
src/shared/component/nav.jsx
, заместете'../../shared/routes'
с'../routes'
- Създайте
src/server/routing.js
файл, съдържащ:
// @flow
import {
homePage,
helloPage,
helloAsyncPage,
helloEndpoint,
} from './controller'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
helloEndpointRoute,
} from '../shared/routes'
import renderApp from './render-app'
export default (app: Object) => {
app.get(HOME_PAGE_ROUTE, (req, res) => {
res.send(renderApp(req.url, homePage()))
})
app.get(HELLO_PAGE_ROUTE, (req, res) => {
res.send(renderApp(req.url, helloPage()))
})
app.get(HELLO_ASYNC_PAGE_ROUTE, (req, res) => {
res.send(renderApp(req.url, helloAsyncPage()))
})
app.get(helloEndpointRoute(), (req, res) => {
res.json(helloEndpoint(req.params.num))
})
app.get('/500', () => {
throw Error('Fake Internal Server Error')
})
app.get('*', (req, res) => {
res.status(404).send(renderApp(req.url))
})
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
// eslint-disable-next-line no-console
console.error(err.stack)
res.status(500).send('Something went wrong!')
})
}
Това е файлът, където обработваме заявките към и отговорите от сървъра (requests and responses). Обръщенията (calls ) към бизнес логиката са отделени в отделен controller
модул.
Забележка: Ще видите в доста React Router примери, че се използва *
като път към сървъра. По този начин цялата работа по рутирането се оставя на React Router компонента. Тъй като всички заявки се изпълняват от една и съща функция, това би направило доста неудобно имплементирането на страници в стил MVC (model-view-controller). Вместо това, ние декларираме изрично нашите пътища и отговорите (responses), които трябва да се получат при използването им. По този начин ще можем лесно да зареждаме информация от база с данни и да я показваме на определена страница от нашето приложение.
- Създайте
src/server/controller.js
файл, съдържащ:
// @flow
export const homePage = () => null
export const helloPage = () => ({
hello: { message: 'Server-side preloaded message' },
})
export const helloAsyncPage = () => ({
hello: { messageAsync: 'Server-side preloaded message for async page' },
})
export const helloEndpoint = (num: number) => ({
serverMessage: `Hello from the server! (received ${num})`,
})
Това е нашия контролер. Обикновено тук би била бизнес логиката и обръщенията към базата с данни, но в нашия случай просто добавяме някои резултати. Тези резултати се подават обратно на routing
модула, за да бъдат използвани да се инициализира нашия server-side Redux store компонент.
- Създайте
src/server/init-store.js
файл, съдържащ:
// @flow
import Immutable from 'immutable'
import { createStore, combineReducers, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import helloReducer from '../shared/reducer/hello'
const initStore = (plainPartialState: ?Object) => {
const preloadedState = plainPartialState ? {} : undefined
if (plainPartialState && plainPartialState.hello) {
// flow-disable-next-line
preloadedState.hello = helloReducer(undefined, {})
.merge(Immutable.fromJS(plainPartialState.hello))
}
return createStore(combineReducers({ hello: helloReducer }),
preloadedState, applyMiddleware(thunkMiddleware))
}
export default initStore
Единственото нещо, което правим тук освен извикването на createStore
и прилагането на middleware частта, е да комбинираме чистия JS обект, който получаваме от controller
с Redux state обекта по подразбиране, който съдържа немутиращи обекти (Immutable objects).
- Редактирайте
src/server/index.js
файла, както следва:
// @flow
import compression from 'compression'
import express from 'express'
import routing from './routing'
import { WEB_PORT, STATIC_PATH } from '../shared/config'
import { isProd } from '../shared/util'
const app = express()
app.use(compression())
app.use(STATIC_PATH, express.static('dist'))
app.use(STATIC_PATH, express.static('public'))
routing(app)
app.listen(WEB_PORT, () => {
// eslint-disable-next-line no-console
console.log(`Server running on port ${WEB_PORT} ${isProd ? '(production)' :
'(development).\nKeep "yarn dev:wds" running in an other terminal'}.`)
})
Нищо специално тук, просто извикваме routing(app)
вместо да имплементираме рутирането в този файл.
- Преименувайте
src/server/render-app.js
наsrc/server/render-app.jsx
и го редактирайте както следва:
// @flow
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { Provider } from 'react-redux'
import { StaticRouter } from 'react-router'
import initStore from './init-store'
import App from './../shared/app'
import { APP_CONTAINER_CLASS, STATIC_PATH, WDS_PORT } from '../shared/config'
import { isProd } from '../shared/util'
const renderApp = (location: string, plainPartialState: ?Object, routerContext: ?Object = {}) => {
const store = initStore(plainPartialState)
const appHtml = ReactDOMServer.renderToString(
<Provider store={store}>
<StaticRouter location={location} context={routerContext}>
<App />
</StaticRouter>
</Provider>)
return (
`<!doctype html>
<html>
<head>
<title>FIX ME</title>
<link rel="stylesheet" href="${STATIC_PATH}/css/style.css">
</head>
<body>
<div class="${APP_CONTAINER_CLASS}">${appHtml}</div>
<script>
window.__PRELOADED_STATE__ = ${JSON.stringify(store.getState())}
</script>
<script src="${isProd ? STATIC_PATH : `http://localhost:${WDS_PORT}/dist`}/js/bundle.js"></script>
</body>
</html>`
)
}
export default renderApp
ReactDOMServer.renderToString
е мястото където се случва магията. React ще обработи всичко от нашия shared
App
и ще върне обикновен стринг от HTML елементи. Provider
работи по същия начин както от страна на клиента, само че в този случай е от страна на сървъра, поставяме нашето приложение в StaticRouter
вместо в BrowserRouter
. За да можем да подадем Redux store обекта от сървъра към клиента го подаваме на window.__PRELOADED_STATE__
, което е просто случайно избрано име на променливата.
Забележка: Immutable обектите имплементират toJSON()
метода, което значи, че можете да използвате JSON.stringify
, за да ги превръщате в обикновени JSON стрингове.
- Редактирайте
src/client/index.jsx
, за да използва това предварително заредено състояние (preloaded state):
import Immutable from 'immutable'
// [...]
/* eslint-disable no-underscore-dangle */
const composeEnhancers = (isProd ? null : window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
const preloadedState = window.__PRELOADED_STATE__
/* eslint-enable no-underscore-dangle */
const store = createStore(combineReducers(
{ hello: helloReducer }),
{ hello: Immutable.fromJS(preloadedState.hello) },
composeEnhancers(applyMiddleware(thunkMiddleware)))
Тук подаваме на нашия клиентски store обект preloadedState
, което идва от сървъра.
🏁 Сега можете да изпълните yarn start
и yarn dev:wds
и да навигирате между страниците. Опресняването на страницата /hello
, /hello-async
и /404
(или което и да е друго URI) сега би трябвало да работи правилно. Обърнете внимание как message
и messageAsync
варират в зависимост от това дали идвате на тази страница чрез рендирането от клиента или от сървъра.
💡 React Helmet: библиотека за инжектиране на съдържание в
head
частта на едно React приложение, работи от страната на клиента и на сървъра (on both the client and the server).
Нарочно ви накарах да напишете FIX ME
в заглавието, за да подчертаем факта, че въпреки че използваме рендиране от страна на сървъра в момента, не попълваме title
тага правилно (или който и да било друг таг в head
частта, което варира според зависимост на коя страница сте).
-
Изпълнете
yarn add react-helmet
-
Редактирайте
src/server/render-app.jsx
файла, както следва:
import Helmet from 'react-helmet'
// [...]
const renderApp = (/* [...] */) => {
// [...]
const appHtml = ReactDOMServer.renderToString(/* [...] */)
const head = Helmet.rewind()
return (
`<!doctype html>
<html>
<head>
${head.title}
${head.meta}
<link rel="stylesheet" href="${STATIC_PATH}/css/style.css">
</head>
[...]
`
)
}
React Helmet използва react-side-effect's rewind
, за да добива информация от рендирането на нашето приложение, което скоро ще съдържа <Helmet />
компоненти. Тези <Helmet />
компоненти са мястото където ще добавим нашето заглавие (title
) и друга head
информация за всяка страница. Обърнете внимание, че Helmet.rewind()
трябва да бъде използвано след ReactDOMServer.renderToString()
.
- Редактирайте
src/shared/app.jsx
файла, както следва:
import Helmet from 'react-helmet'
// [...]
const App = () =>
<div>
<Helmet titleTemplate={`%s | ${APP_NAME}`} defaultTitle={APP_NAME} />
<Nav />
// [...]
- Редактирайте
src/shared/component/page/home.jsx
файла, както следва:
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import { APP_NAME } from '../../config'
const HomePage = () =>
<div>
<Helmet
meta={[
{ name: 'description', content: 'Hello App is an app to say hello' },
{ property: 'og:title', content: APP_NAME },
]}
/>
<h1>{APP_NAME}</h1>
</div>
export default HomePage
- Редактирайте
src/shared/component/page/hello.jsx
файла, както следва:
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import HelloButton from '../../container/hello-button'
import Message from '../../container/message'
const title = 'Hello Page'
const HelloPage = () =>
<div>
<Helmet
title={title}
meta={[
{ name: 'description', content: 'A page to say hello' },
{ property: 'og:title', content: title },
]}
/>
<h1>{title}</h1>
<Message />
<HelloButton />
</div>
export default HelloPage
- Редактирайте
src/shared/component/page/hello-async.jsx
файла, както следва:
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import HelloAsyncButton from '../../container/hello-async-button'
import MessageAsync from '../../container/message-async'
const title = 'Async Hello Page'
const HelloAsyncPage = () =>
<div>
<Helmet
title={title}
meta={[
{ name: 'description', content: 'A page to say hello asynchronously' },
{ property: 'og:title', content: title },
]}
/>
<h1>{title}</h1>
<MessageAsync />
<HelloAsyncButton />
</div>
export default HelloAsyncPage
- Редактирайте
src/shared/component/page/not-found.jsx
, както следва:
// @flow
import React from 'react'
import Helmet from 'react-helmet'
const title = 'Page Not Found'
const NotFoundPage = () =>
<div>
<Helmet
title={title}
meta={[
{ name: 'description', content: 'A page to say hello' },
{ property: 'og:title', content: title },
]}
/>
<h1>{title}</h1>
</div>
export default NotFoundPage
<Helmet>
компонента всъщност не рендира нищо сам по себе си, просто инжектира съдържание в head
частта на вашия документ и предлага за използване същата информация и на сървъра.
🏁 Изпълнете yarn start
и yarn dev:wds
и навигирайте между страниците. Заглавието във вашия таб би трябвало да се променя докато навигирате и да остава същото когато опресните страницата. Отворете сорс кода на страницата и вижте как React Helmet попълва title
и meta
таговете дори за server-side рендирането.
Следваща глава: 07 - Socket.IO
Назад към предишната глава или към съдържанието.