git init git remote add origin git@github.com:Saluev/habr-app-demo.git git commit --allow-empty -m "Initial commit" git push
.gitignore
.)docker-compose.yml
file to the repository docker-compose.yml
follows: version: '3' services: mongo: image: "mongo:latest" redis: image: "redis:alpine" backend: build: context: . dockerfile: ./docker/backend/Dockerfile environment: - APP_ENV=dev depends_on: - mongo - redis ports: - "40001:40001" volumes: - .:/code frontend: build: context: . dockerfile: ./docker/frontend/Dockerfile environment: - APP_ENV=dev - APP_BACKEND_URL=backend:40001 - APP_FRONTEND_PORT=40002 depends_on: - backend ports: - "40002:40002" volumes: - ./frontend:/app/src worker: build: context: . dockerfile: ./docker/worker/Dockerfile environment: - APP_ENV=dev depends_on: - mongo - redis volumes: - .:/code
# docker/backend/Dockerfile FROM python:stretch COPY requirements.txt /tmp/ RUN pip install -r /tmp/requirements.txt ADD . /code WORKDIR /code CMD gunicorn -w 1 -b 0.0.0.0:40001 --worker-class gevent backend.server:app
app
in the module backend.server
.docker/backend/.dockerignore
: .git .idea .logs .pytest_cache frontend tests venv *.pyc *.pyo
# docker/worker/Dockerfile FROM python:stretch COPY requirements.txt /tmp/ RUN pip install -r /tmp/requirements.txt ADD . /code WORKDIR /code CMD python -m worker
worker/__main__.py
..dockerignore
worker is completely analogous to the .dockerignore
backend. # docker/frontend/Dockerfile FROM node:carbon WORKDIR /app # package.json package-lock.json npm install, . COPY frontend/package*.json ./ RUN npm install # , # PATH. ENV PATH /app/node_modules/.bin:$PATH # . ADD frontend /app/src WORKDIR /app/src RUN npm run build CMD npm run start
docker-compose exec frontend npm install --save newDependency
works as docker-compose exec frontend npm install --save newDependency
and modifies package.json
in our repository (which would not be the case if we used COPY, as many suggest). It would be undesirable to run just npm install --save newDependency
outside the container anyway, because some dependencies of the new package may already be present and be assembled for another platform (for the one that is inside the docker, and not for our working macbook) ), and we don’t even want to require the presence of a Node on a development machine. One Docker to rule them all!docker/frontend/.dockerignore
: .git .idea .logs .pytest_cache backend worker tools node_modules npm-debug tests venv
flask
, flask-cors
, gevent
and gunicorn
to requirements.txt
and create a simple Flask application in backend/server.py
. # backend/server.py import os.path import flask import flask_cors class HabrAppDemo(flask.Flask): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # CORS # , # ( Access-Control-Origin ). # - . flask_cors.CORS(self) app = HabrAppDemo("habr-app-demo") env = os.environ.get("APP_ENV", "dev") print(f"Starting application in {env} mode") app.config.from_object(f"backend.{env}_settings")
backend.{env}_settings
file backend.{env}_settings
, which means we also need to create (at least an empty) backend/dev_settings.py
file so that everything takes off. habr-app-demo$ docker-compose up backend ... backend_1 | [2019-02-23 10:09:03 +0000] [6] [INFO] Starting gunicorn 19.9.0 backend_1 | [2019-02-23 10:09:03 +0000] [6] [INFO] Listening at: http://0.0.0.0:40001 (6) backend_1 | [2019-02-23 10:09:03 +0000] [6] [INFO] Using worker: gevent backend_1 | [2019-02-23 10:09:03 +0000] [9] [INFO] Booting worker with pid: 9
npm init
in it, after several unsophisticated questions we will get a complete package.json in the spirit of { "name": "habr-app-demo", "version": "0.0.1", "description": "This is an app demo for Habr article.", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", "url": "git+https://github.com/Saluev/habr-app-demo.git" }, "author": "Tigran Saluev <tigran@saluev.com>", "license": "MIT", "bugs": { "url": "https://github.com/Saluev/habr-app-demo/issues" }, "homepage": "https://github.com/Saluev/habr-app-demo#readme" }
npm init
via Docker, but oh well).Dockerfile
we mentioned npm run build
and npm run start
— add the following commands to package.json
: --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,8 @@ "description": "This is an app demo for Habr article.", "main": "index.js", "scripts": { + "build": "echo 'build'", + "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": {
build
command is not doing anything yet, but it still comes in handy.index.js
: --- a/frontend/package.json +++ b/frontend/package.json @@ -17,5 +17,8 @@ "bugs": { "url": "https://github.com/Saluev/habr-app-demo/issues" }, - "homepage": "https://github.com/Saluev/habr-app-demo#readme" + "homepage": "https://github.com/Saluev/habr-app-demo#readme", + "dependencies": { + "express": "^4.16.3" + } }
// frontend/index.js const express = require("express"); app = express(); app.listen(process.env.APP_FRONTEND_PORT); app.get("*", (req, res) => { res.send("Hello, world!") });
docker-compose up frontend
raises our frontend! Moreover, http: // localhost: 40002 should already feature the classic “Hello, world”.App
and configure the assembly. render() { return <MyButton color="blue">{this.props.caption}</MyButton>; }
// frontend/src/components/app.js import React, {Component} from 'react' class App extends Component { render() { return <h1>Hello, world!</h1> } } export default App
frontend/src/template.js
file containing the minimal HTML framework of our future application: // frontend/src/template.js export default function template(title) { let page = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>${title}</title> </head> <body> <div id="app"></div> <script src="/dist/client.js"></script> </body> </html> `; return page; }
// frontend/src/client.js import React from 'react' import {render} from 'react-dom' import App from './components/app' render( <App/>, document.querySelector('#app') );
docker-compose exec frontend npm install --save \ react \ react-dom docker-compose exec frontend npm install --save-dev \ webpack \ webpack-cli \ babel-loader \ @babel/core \ @babel/polyfill \ @babel/preset-env \ @babel/preset-react
// frontend/webpack.config.js const path = require("path"); // . clientConfig = { mode: "development", entry: { client: ["./src/client.js", "@babel/polyfill"] }, output: { path: path.resolve(__dirname, "../dist"), filename: "[name].js" }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" } ] } }; // . : // 1. target: "node" - import path. // 2. .., ../dist -- // , ! serverConfig = { mode: "development", target: "node", entry: { server: ["./index.js", "@babel/polyfill"] }, output: { path: path.resolve(__dirname, ".."), filename: "[name].js" }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" } ] } }; module.exports = [clientConfig, serverConfig];
frontend/.babelrc
: { "presets": ["@babel/env", "@babel/react"] }
npm run build
meaningful: // frontend/package.json ... "scripts": { "build": "webpack", "start": "node /app/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, ...
../dist/client.js
. Add the ability to load it as a static file into our Express application, and in the default route we will begin to return our HTML: // frontend/index.js // , , // - . import express from 'express' import template from './src/template' let app = express(); app.use('/dist', express.static('../dist')); app.get("*", (req, res) => { res.send(template("Habr demo app")); }); app.listen(process.env.APP_FRONTEND_PORT);
docker-compose up --build frontend
, we will see “Hello, world!” In a new, shiny wrapper, and if you have the React Developer Tools ( Chrome , Firefox ) extension installed, then the React component is also in developer tools: # backend/storage/card.py import abc from typing import Iterable class Card(object): def __init__(self, id: str = None, slug: str = None, name: str = None, markdown: str = None, html: str = None): self.id = id self.slug = slug # self.name = name self.markdown = markdown self.html = html class CardDAO(object, metaclass=abc.ABCMeta): @abc.abstractmethod def create(self, card: Card) -> Card: pass @abc.abstractmethod def update(self, card: Card) -> Card: pass @abc.abstractmethod def get_all(self) -> Iterable[Card]: pass @abc.abstractmethod def get_by_id(self, card_id: str) -> Card: pass @abc.abstractmethod def get_by_slug(self, slug: str) -> Card: pass class CardNotFound(Exception): pass
CardDAO
create an implementation of the CardDAO
interface that accepts a Database
object from pymongo
(yes, it's time to add pymongo
to requirements.txt
): # backend/storage/card_impl.py from typing import Iterable import bson import bson.errors from pymongo.collection import Collection from pymongo.database import Database from backend.storage.card import Card, CardDAO, CardNotFound class MongoCardDAO(CardDAO): def __init__(self, mongo_database: Database): self.mongo_database = mongo_database # , slug . self.collection.create_index("slug", unique=True) @property def collection(self) -> Collection: return self.mongo_database["cards"] @classmethod def to_bson(cls, card: Card): # MongoDB BSON. # BSON- # , . result = { k: v for k, v in card.__dict__.items() if v is not None } if "id" in result: result["_id"] = bson.ObjectId(result.pop("id")) return result @classmethod def from_bson(cls, document) -> Card: # , # , # . id # , - . document["id"] = str(document.pop("_id")) return Card(**document) def create(self, card: Card) -> Card: card.id = str(self.collection.insert_one(self.to_bson(card)).inserted_id) return card def update(self, card: Card) -> Card: card_id = bson.ObjectId(card.id) self.collection.update_one({"_id": card_id}, {"$set": self.to_bson(card)}) return card def get_all(self) -> Iterable[Card]: for document in self.collection.find(): yield self.from_bson(document) def get_by_id(self, card_id: str) -> Card: return self._get_by_query({"_id": bson.ObjectId(card_id)}) def get_by_slug(self, slug: str) -> Card: return self._get_by_query({"slug": slug}) def _get_by_query(self, query) -> Card: document = self.collection.find_one(query) if document is None: raise CardNotFound() return self.from_bson(document)
mongo
, so MONGO_HOST = "mongo"
: --- a/backend/dev_settings.py +++ b/backend/dev_settings.py @@ -0,0 +1,3 @@ +MONGO_HOST = "mongo" +MONGO_PORT = 27017 +MONGO_DATABASE = "core"
MongoCardDAO
and give it to the Flask application. Although we now have a very simple hierarchy of objects (settings → pymongo client → pymongo database → MongoCardDAO
), let's immediately create a centralized component king that does dependency injection (it will come in handy again when we do the worker and tools). # backend/wiring.py import os from pymongo import MongoClient from pymongo.database import Database import backend.dev_settings from backend.storage.card import CardDAO from backend.storage.card_impl import MongoCardDAO class Wiring(object): def __init__(self, env=None): if env is None: env = os.environ.get("APP_ENV", "dev") self.settings = { "dev": backend.dev_settings, # ( # , !) }[env] # . # DI, . self.mongo_client: MongoClient = MongoClient( host=self.settings.MONGO_HOST, port=self.settings.MONGO_PORT) self.mongo_database: Database = self.mongo_client[self.settings.MONGO_DATABASE] self.card_dao: CardDAO = MongoCardDAO(self.mongo_database)
# backend/server.py import os.path import flask import flask_cors from backend.storage.card import CardNotFound from backend.wiring import Wiring env = os.environ.get("APP_ENV", "dev") print(f"Starting application in {env} mode") class HabrAppDemo(flask.Flask): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) flask_cors.CORS(self) self.wiring = Wiring(env) self.route("/api/v1/card/<card_id_or_slug>")(self.card) def card(self, card_id_or_slug): try: card = self.wiring.card_dao.get_by_slug(card_id_or_slug) except CardNotFound: try: card = self.wiring.card_dao.get_by_id(card_id_or_slug) except (CardNotFound, ValueError): return flask.abort(404) return flask.jsonify({ k: v for k, v in card.__dict__.items() if v is not None }) app = HabrAppDemo("habr-app-demo") app.config.from_object(f"backend.{env}_settings")
docker-compose up --build backend
: # tools/add_test_content.py from backend.storage.card import Card from backend.wiring import Wiring wiring = Wiring() wiring.card_dao.create(Card( slug="helloworld", name="Hello, world!", markdown=""" This is a hello-world page. """))
docker-compose exec backend python -m tools.add_test_content
fills our monitor with content from inside the docker-compose exec backend python -m tools.add_test_content
container./card/:id_or_slug
, which will open our React-application, load the card data from the API and somehow show it to us. And here, perhaps, the most difficult begins, because we want the server to immediately give us HTML with the contents of the card, suitable for indexing, but at the same time that the application, when navigating between cards, receives all the data in the form of JSON from the API, and the page is not overloaded. And so that all this - without copy-paste! docker-compose exec frontend npm install --save \ redux \ react-redux \ redux-thunk \ redux-devtools-extension
// frontend/src/redux/reducers.js export default function root(state = {}, action) { return state; }
// frontend/src/redux/configureStore.js import {createStore, applyMiddleware} from "redux"; import thunkMiddleware from "redux-thunk"; import {composeWithDevTools} from "redux-devtools-extension"; import rootReducer from "./reducers"; export default function configureStore(initialState) { return createStore( rootReducer, initialState, composeWithDevTools(applyMiddleware(thunkMiddleware)), ); }
// frontend/src/client.js import React from 'react' import {render} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' // ... const store = configureStore(); render( // ... , // <Provider store={store}> <App/> </Provider>, document.querySelector('#app') );
{ "page": { "type": "card", // // type=card: "cardSlug": "...", // "isFetching": false, // API "cardData": {...}, // ( ) // ... }, // ... }
// frontend/src/components/card.js import React, {Component} from 'react'; class Card extends Component { componentDidMount() { document.title = this.props.name } render() { const {name, html} = this.props; return ( <div> <h1>{name}</h1> <!---, HTML React - !--> <div dangerouslySetInnerHTML={{__html: html}}/> </div> ); } } export default Card;
frontend/src/redux/actions.js
and create an action that gets the contents of the card from the API, if not yet: export function fetchCardIfNeeded() { return (dispatch, getState) => { let state = getState().page; if (state.cardData === undefined || state.cardData.slug !== state.cardSlug) { return dispatch(fetchCard()); } }; }
fetchCard
action, which actually makes fetching, is a bit more complicated: function fetchCard() { return (dispatch, getState) => { // , . // , , // . dispatch(startFetchingCard()); // API. let url = apiPath() + "/card/" + getState().page.cardSlug; // , , , // . , , // . return fetch(url) .then(response => response.json()) .then(json => dispatch(finishFetchingCard(json))); }; // , redux-thunk // . } function startFetchingCard() { return { type: START_FETCHING_CARD }; } function finishFetchingCard(json) { return { type: FINISH_FETCHING_CARD, cardData: json }; } function apiPath() { // . server-side // rendering, API - // localhost, // backend. return "http://localhost:40001/api/v1"; }
// frontend/src/redux/reducers.js import { START_FETCHING_CARD, FINISH_FETCHING_CARD } from "./actions"; export default function root(state = {}, action) { switch (action.type) { case START_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: true } }; case FINISH_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: false, cardData: action.cardData } } } return state; }
CardPage
: // frontend/src/components/cardPage.js import React, {Component} from 'react'; import {connect} from 'react-redux' import {fetchCardIfNeeded} from '../redux/actions' import Card from './card' class CardPage extends Component { componentWillMount() { // , React // . // , " // " , // - . - // HTML // renderToString, SSR. this.props.dispatch(fetchCardIfNeeded()) } render() { const {isFetching, cardData} = this.props; return ( <div> {isFetching && <h2>Loading...</h2>} {cardData && <Card {...cardData}/>} </div> ); } } // , // . // react-redux. page // dispatch, . function mapStateToProps(state) { const {page} = state; return page; } export default connect(mapStateToProps)(CardPage);
// frontend/src/components/app.js import React, {Component} from 'react' import {connect} from "react-redux"; import CardPage from "./cardPage" class App extends Component { render() { const {pageType} = this.props; return ( <div> {pageType === "card" && <CardPage/>} </div> ); } } function mapStateToProps(state) { const {page} = state; const {type} = page; return { pageType: type }; } export default connect(mapStateToProps)(App);
page.type
page.cardSlug
URL . // frontend/src/client.js import React from 'react' import {render} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' let initialState = { page: { type: "home" } }; const m = /^\/card\/([^\/]+)$/.exec(location.pathname); if (m !== null) { initialState = { page: { type: "card", cardSlug: m[1] }, } } const store = configureStore(initialState); render( <Provider store={store}> <App/> </Provider>, document.querySelector('#app') );
docker-compose up --build frontend
, helloworld
… --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ flask-cors gevent gunicorn pymongo +redis +rq --- a/backend/dev_settings.py +++ b/backend/dev_settings.py @@ -1,3 +1,7 @@ MONGO_HOST = "mongo" MONGO_PORT = 27017 MONGO_DATABASE = "core" +REDIS_HOST = "redis" +REDIS_PORT = 6379 +REDIS_DB = 0 +TASK_QUEUE_NAME = "tasks" --- a/backend/wiring.py +++ b/backend/wiring.py @@ -2,6 +2,8 @@ import os from pymongo import MongoClient from pymongo.database import Database +import redis +import rq import backend.dev_settings from backend.storage.card import CardDAO @@ -21,3 +23,11 @@ class Wiring(object): port=self.settings.MONGO_PORT) self.mongo_database: Database = self.mongo_client[self.settings.MONGO_DATABASE] self.card_dao: CardDAO = MongoCardDAO(self.mongo_database) + + self.redis: redis.Redis = redis.StrictRedis( + host=self.settings.REDIS_HOST, + port=self.settings.REDIS_PORT, + db=self.settings.REDIS_DB) + self.task_queue: rq.Queue = rq.Queue( + name=self.settings.TASK_QUEUE_NAME, + connection=self.redis)
# worker/__main__.py import argparse import uuid import rq import backend.wiring parser = argparse.ArgumentParser(description="Run worker.") # , # . , rq. parser.add_argument( "--burst", action="store_const", const=True, default=False, help="enable burst mode") args = parser.parse_args() # Redis. wiring = backend.wiring.Wiring() with rq.Connection(wiring.redis): w = rq.Worker( queues=[wiring.settings.TASK_QUEUE_NAME], # # , . name=uuid.uuid4().hex) w.work(burst=args.burst)
# backend/tasks/parse.py import mistune from backend.storage.card import CardDAO def parse_card_markup(card_dao: CardDAO, card_id: str): card = card_dao.get_by_id(card_id) card.html = _parse_markdown(card.markdown) card_dao.update(card) _parse_markdown = mistune.Markdown(escape=True, hard_wrap=False)
CardDAO
, . , , pickle — , RQ. - Wiring
… : --- a/worker/__main__.py +++ b/worker/__main__.py @@ -2,6 +2,7 @@ import argparse import uuid import rq +from rq.job import Job import backend.wiring @@ -16,8 +17,23 @@ args = parser.parse_args() wiring = backend.wiring.Wiring() + +class JobWithWiring(Job): + + @property + def kwargs(self): + result = dict(super().kwargs) + result["wiring"] = backend.wiring.Wiring() + return result + + @kwargs.setter + def kwargs(self, value): + super().kwargs = value + + with rq.Connection(wiring.redis): w = rq.Worker( queues=[wiring.settings.TASK_QUEUE_NAME], - name=uuid.uuid4().hex) + name=uuid.uuid4().hex, + job_class=JobWithWiring) w.work(burst=args.burst)
# backend/tasks/task.py import functools from typing import Callable from backend.wiring import Wiring def task(func: Callable): # : varnames = func.__code__.co_varnames @functools.wraps(func) def result(*args, **kwargs): # . .pop(), # , . wiring: Wiring = kwargs.pop("wiring") wired_objects_by_name = wiring.__dict__ for arg_name in varnames: if arg_name in wired_objects_by_name: kwargs[arg_name] = wired_objects_by_name[arg_name] # # , - . return func(*args, **kwargs) return result
import mistune from backend.storage.card import CardDAO from backend.tasks.task import task @task def parse_card_markup(card_dao: CardDAO, card_id: str): card = card_dao.get_by_id(card_id) card.html = _parse_markdown(card.markdown) card_dao.update(card) _parse_markdown = mistune.Markdown(escape=True, hard_wrap=False)
$ docker-compose up worker ... Creating habr-app-demo_worker_1 ... done Attaching to habr-app-demo_worker_1 worker_1 | 17:21:03 RQ worker 'rq:worker:49a25686acc34cdfa322feb88a780f00' started, version 0.13.0 worker_1 | 17:21:03 *** Listening on tasks... worker_1 | 17:21:03 Cleaning registries for queue: tasks
# tools/add_test_content.py from backend.storage.card import Card, CardNotFound from backend.tasks.parse import parse_card_markup from backend.wiring import Wiring wiring = Wiring() try: card = wiring.card_dao.get_by_slug("helloworld") except CardNotFound: card = wiring.card_dao.create(Card( slug="helloworld", name="Hello, world!", markdown=""" This is a hello-world page. """)) # , card_dao.get_or_create, # ! wiring.task_queue.enqueue_call( parse_card_markup, kwargs={"card_id": card.id})
docker-compose exec worker python -m tools.add_test_content
— - ! worker_1 | 17:34:26 tasks: backend.tasks.parse.parse_card_markup(card_id='5c715dd1e201ce000c6a89fa') (613b53b1-726b-47a4-9c7b-97cad26da1a5) worker_1 | 17:34:27 tasks: Job OK (613b53b1-726b-47a4-9c7b-97cad26da1a5) worker_1 | 17:34:27 Result is kept for 500 seconds
# tools/add_test_content.py def create_or_update(card): try: card.id = wiring.card_dao.get_by_slug(card.slug).id card = wiring.card_dao.update(card) except CardNotFound: card = wiring.card_dao.create(card) wiring.task_queue.enqueue_call( parse_card_markup, kwargs={"card_id": card.id}) create_or_update(Card( slug="helloworld", name="Hello, world!", markdown=""" This is a hello-world page. It can't really compete with the [demo page](demo). """)) create_or_update(Card( slug="demo", name="Demo Card!", markdown=""" Hi there, habrovchanin. You've probably got here from the awkward ["Hello, world" card](helloworld). Well, **good news**! Finally you are looking at a **really cool card**! """ ))
// frontend/src/components/card.js class Card extends Component { componentDidMount() { document.title = this.props.name } navigate(event) { // . // , . if (event.target.tagName === 'A' && event.target.hostname === window.location.hostname) { // event.preventDefault(); // this.props.dispatch(navigate(event.target)); } } render() { const {name, html} = this.props; return ( <div> <h1>{name}</h1> <div dangerouslySetInnerHTML={{__html: html}} onClick={event => this.navigate(event)} /> </div> ); } }
CardPage
, (!) : export function navigate(link) { return { type: NAVIGATE, path: link.pathname } }
// frontend/src/redux/reducers.js import { START_FETCHING_CARD, FINISH_FETCHING_CARD, NAVIGATE } from "./actions"; function navigate(state, path) { // react-router, ! // ( SSR.) let m = /^\/card\/([^/]+)$/.exec(path); if (m !== null) { return { ...state, page: { type: "card", cardSlug: m[1], isFetching: true } }; } return state } export default function root(state = {}, action) { switch (action.type) { case START_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: true } }; case FINISH_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: false, cardData: action.cardData } }; case NAVIGATE: return navigate(state, action.path) } return state; }
CardPage
componentDidUpdate
, componentWillMount
. CardPage
(, cardSlug
) ( componentWillMount
).docker-compose up --build frontend
!navigate
history.pushState
. export function navigate(link) { history.pushState(null, "", link.href); return { type: NAVIGATE, path: link.pathname } }
popstate
window
. , , ( dispatch(navigate(...))
), navigate
« pushState
» ( !). , «» , pushState
. , ! App: // frontend/src/components/app.js class App extends Component { componentDidMount() { // -- // "". history.replaceState({ pathname: location.pathname, href: location.href }, ""); // . window.addEventListener("popstate", event => this.navigate(event)); } navigate(event) { // "" , // , // (or is it a good thing?..) if (event.state && event.state.pathname) { event.preventDefault(); event.stopPropagation(); // " pushState". this.props.dispatch(navigate(event.state, true)); } } render() { // ... } }
// frontend/src/redux/actions.js export function navigate(link, dontPushState) { if (!dontPushState) { history.pushState({ pathname: link.pathname, href: link.href }, "", link.href); } return { type: NAVIGATE, path: link.pathname } }
navigate
, , ? navigate location: --- a/frontend/src/client.js +++ b/frontend/src/client.js @@ -3,23 +3,16 @@ import {render} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' +import {navigate} from "./redux/actions"; let initialState = { page: { type: "home" } }; -const m = /^\/card\/([^\/]+)$/.exec(location.pathname); -if (m !== null) { - initialState = { - page: { - type: "card", - cardSlug: m[1] - }, - } -} const store = configureStore(initialState); +store.dispatch(navigate(location));
App
. HTML ( JS, -). : <script>
, - (, window
) , HTML. ( hydrate HTML, DOM tree ). // frontend/src/server.js import "@babel/polyfill" import React from 'react' import {renderToString} from 'react-dom/server' import {Provider} from 'react-redux' import App from './components/app' import {navigate} from "./redux/actions"; import configureStore from "./redux/configureStore"; export default function render(initialState, url) { // store, . const store = configureStore(initialState); store.dispatch(navigate(url)); let app = ( <Provider store={store}> <App/> </Provider> ); // , ! // , ? let content = renderToString(app); let preloadedState = store.getState(); return {content, preloadedState}; };
// frontend/src/template.js function template(title, initialState, content) { let page = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>${title}</title> </head> <body> <div id="app">${content}</div> <script> window.__STATE__ = ${JSON.stringify(initialState)} </script> <script src="/dist/client.js"></script> </body> </html> `; return page; } module.exports = template;
// frontend/index.js app.get("*", (req, res) => { const initialState = { page: { type: "home" } }; const {content, preloadedState} = render(initialState, {pathname: req.url}); res.send(template("Habr demo app", preloadedState, content)); });
// frontend/src/client.js import React from 'react' import {hydrate} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' import {navigate} from "./redux/actions"; // ! const store = configureStore(window.__STATE__); // render hydrate. hydrate // DOM tree, . hydrate( <Provider store={store}> <App/> </Provider>, document.querySelector('#app') );
utility.js
. // frontend/src/utility.js export function isServerSide() { // , process, // - . return process.env.APP_ENV !== undefined; }
// frontend/src/redux/actions.js function addPromise(promise) { return { type: ADD_PROMISE, promise: promise }; } function removePromise(promise) { return { type: REMOVE_PROMISE, promise: promise, }; }
.then()
. // frontend/src/redux/reducers.js export default function root(state = {}, action) { switch (action.type) { case ADD_PROMISE: return { ...state, promises: [...state.promises, action.promise] }; case REMOVE_PROMISE: return { ...state, promises: state.promises.filter(p => p !== action.promise) }; ...
fetchCard
: // frontend/src/redux/actions.js function fetchCard() { return (dispatch, getState) => { dispatch(startFetchingCard()); let url = apiPath() + "/card/" + getState().page.cardSlug; let promise = fetch(url) .then(response => response.json()) .then(json => { dispatch(finishFetchingCard(json)); // " , " dispatch(removePromise(promise)); }); // " , " return dispatch(addPromise(promise)); }; }
initialState
! render : // frontend/src/server.js function hasPromises(state) { return state.promises.length > 0 } export default async function render(initialState, url) { const store = configureStore(initialState); store.dispatch(navigate(url)); let app = ( <Provider store={store}> <App/> </Provider> ); // renderToString // ( ). CardPage . renderToString(app); // , ! - // ( // , ), // . let preloadedState = store.getState(); while (hasPromises(preloadedState)) { await preloadedState.promises[0]; preloadedState = store.getState() } // renderToString. HTML. let content = renderToString(app); return {content, preloadedState}; };
render
: // frontend/index.js app.get("*", (req, res) => { const initialState = { page: { type: "home" }, promises: [] }; render(initialState, {pathname: req.url}).then(result => { const {content, preloadedState} = result; const response = template("Habr demo app", preloadedState, content); res.send(response); }, (reason) => { console.log(reason); res.status(500).send("Server side rendering failed!"); }); });
Source: https://habr.com/ru/post/444446/
All Articles