Erstellen einer modernen Webanwendung von Grund auf neu

Sie haben sich also für ein neues Projekt entschieden. Und dieses Projekt ist eine Webanwendung. Wie lange dauert die Erstellung eines Basisprototyps? Wie schwer ist es Was sollte eine moderne Website von Anfang an können?

In diesem Artikel werden wir versuchen, das Boilerplate einer einfachen Webanwendung mit der folgenden Architektur zu skizzieren:


Was wir behandeln werden:

  • Einrichten der Entwicklungsumgebung in Docker-Compose.
  • Backend-Erstellung auf Flask.
  • Erstellen eines Frontends in Express.
  • Erstellen Sie JS mit Webpack.
  • Reagieren, Redux und serverseitiges Rendern.
  • Aufgabenwarteschlangen mit RQ.

Einführung


Vor der Entwicklung müssen Sie natürlich erst entscheiden, was wir entwickeln! Als Modellanwendung für diesen Artikel habe ich beschlossen, eine primitive Wiki-Engine zu erstellen. Wir werden Karten in Markdown ausstellen lassen; Sie können angesehen werden und (irgendwann in der Zukunft) Änderungen anbieten. All dies werden wir als einseitige Anwendung mit serverseitigem Rendering arrangieren (was für die Indizierung unserer zukünftigen Terabyte an Inhalten unbedingt erforderlich ist).

Schauen wir uns die Komponenten, die wir dafür benötigen, etwas genauer an:

  • Kunde Erstellen wir eine einseitige Anwendung (d. H. Mit Seitenübergängen unter Verwendung von AJAX) für das React + Redux- Bundle, das in der Front-End-Welt sehr verbreitet ist.
  • Frontend . Erstellen wir einen einfachen Express- Server, der unsere React-Anwendung rendert (alle erforderlichen Daten im Backend asynchron anfordert) und an den Benutzer ausgibt.
  • Backend . Unser Backend ist eine kleine Flask-Anwendung. Wir werden Daten (unsere Karten) im beliebten MongoDB- Dokumenten-Repository speichern und für die Task-Warteschlange und möglicherweise in Zukunft für das Caching Redis verwenden .
  • ARBEITNEHMER . Ein separater Container für schwere Aufgaben wird von der RQ- Bibliothek gestartet.

Infrastruktur: git


Wahrscheinlich konnten wir nicht darüber sprechen, aber wir werden natürlich die Entwicklung im Git-Repository durchführen.

git init git remote add origin git@github.com:Saluev/habr-app-demo.git git commit --allow-empty -m "Initial commit" git push 

(Hier sollten Sie sofort .gitignore .)

Der endgültige Entwurf kann auf Github eingesehen werden. Jeder Abschnitt des Artikels entspricht einem Commit (ich habe viel zurückgewiesen, um dies zu erreichen!).

Infrastruktur: Docker-Compose


Beginnen wir mit der Einrichtung der Umgebung. Angesichts der Fülle an Komponenten wäre es eine sehr logische Entwicklungslösung, Docker-Compose zu verwenden.

Fügen Sie die Datei docker-compose.yml folgenden Inhalt zum Repository hinzu:

 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 

Werfen wir einen kurzen Blick darauf, was hier passiert.

  • Ein MongoDB-Container und ein Redis-Container werden erstellt.
  • Ein Container für unser Backend wird erstellt (den wir unten beschreiben). Die Umgebungsvariable APP_ENV = dev wird an sie übergeben (wir werden sie untersuchen, um zu verstehen, welche Flask-Einstellungen geladen werden sollen), und ihr Port 40001 wird außerhalb geöffnet (über sie geht unser Browser-Client zur API).
  • Ein Container unseres Frontends wird erstellt. Es werden auch verschiedene Umgebungsvariablen eingefügt, die später für uns nützlich sein werden, und Port 40002 wird geöffnet. Dies ist der Hauptport unserer Webanwendung: Im Browser gehen wir zu http: // localhost: 40002 .
  • Der Container unseres Arbeiters wird erstellt. Er benötigt keine externen Ports und nur in MongoDB und Redis ist Zugriff erforderlich.

Jetzt erstellen wir Docker-Dateien. Im Moment kommt eine Reihe von Übersetzungen exzellenter Artikel über Docker nach Habré - Sie können sicher dorthin gehen, um alle Details zu erfahren.

Beginnen wir mit dem Backend.

 # 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 

Es versteht sich, dass wir die Gunicorn Flask-Anwendung durchlaufen und uns unter der Namens- app im Modul backend.server verstecken.

Nicht weniger wichtig docker/backend/.dockerignore :

 .git .idea .logs .pytest_cache frontend tests venv *.pyc *.pyo 

Der Arbeiter ist im Allgemeinen dem Backend ähnlich, nur haben wir anstelle von Gunicorn den üblichen Start eines Pit-Moduls:

 # 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 

Wir werden die ganze Arbeit in worker/__main__.py .

.dockerignore Worker ist dem .dockerignore Backend völlig ähnlich.

Endlich das Frontend. Es gibt einen separaten Artikel über ihn über Habré, aber nach der ausführlichen Diskussion über StackOverflow und den Kommentaren im Geiste von "Jungs, ist es schon 2018, gibt es noch keine normale Lösung?" dort ist nicht alles so einfach. Ich habe mich für diese Version der Docker-Datei entschieden.

 # 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 

Vorteile:

  • Alles wird wie erwartet zwischengespeichert (auf der untersten Ebene - Abhängigkeiten, auf der obersten Ebene - dem Build unserer Anwendung).
  • docker-compose exec frontend npm install --save newDependency funktioniert wie es sollte und ändert package.json in unserem Repository (was nicht der Fall wäre, wenn wir COPY verwenden würden, wie viele Leute vorschlagen). Es wäre ohnehin nicht wünschenswert, npm install --save newDependency außerhalb des Containers npm install --save newDependency , da einige Abhängigkeiten des neuen Pakets möglicherweise bereits vorhanden sind und auf einer anderen Plattform erstellt werden (z. B. unter der im Docker und nicht unter unserem funktionierenden Macbook) ), und dennoch möchten wir im Allgemeinen nicht die Anwesenheit von Node auf der Entwicklungsmaschine benötigen. Ein Docker, der sie alle regiert!

Na ja und natürlich docker/frontend/.dockerignore :

 .git .idea .logs .pytest_cache backend worker tools node_modules npm-debug tests venv 

Unser Containerrahmen ist also fertig und Sie können ihn mit Inhalt füllen!

Backend: Flask Framework


Fügen Sie flask , flask-cors , gevent und gunicorn zu gevent gunicorn und erstellen Sie eine einfache Flask-Anwendung 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") 

Wir haben Flask backend.{env}_settings , die Einstellungen aus der backend.{env}_settings Datei backend.{env}_settings , was bedeutet, dass wir auch eine (mindestens leere) Datei backend/dev_settings.py damit alles abheben kann.

Jetzt können wir unser Backend offiziell ERHÖHEN!

 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 

Wir gehen weiter.

Frontend: Express Framework


Beginnen wir mit der Erstellung eines Pakets. Nachdem wir den Frontend-Ordner erstellt und npm init darin ausgeführt haben, erhalten wir nach einigen ungekünstelten Fragen das fertige package.json im Geiste

 { "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" } 

In Zukunft brauchen wir Node.js auf dem Entwicklercomputer überhaupt nicht mehr (obwohl wir npm init über Docker ausweichen und starten könnten, aber na ja).

In Dockerfile wir npm run build und npm run start - Sie müssen package.json die entsprechenden Befehle 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": { 

Der Befehl build führt noch nichts aus, ist aber für uns weiterhin nützlich.

Fügen Sie Express- Abhängigkeiten hinzu und erstellen Sie eine einfache Anwendung in 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!") }); 

Jetzt erhöht docker-compose up frontend unser Frontend! Außerdem sollte auf http: // localhost: 40002 der Klassiker „Hallo Welt“ bereits zur Geltung kommen.

Frontend: Mit Webpack und React-Anwendung erstellen


Es ist Zeit, in unserer Anwendung mehr als nur einfachen Text darzustellen. In diesem Abschnitt fügen wir die einfachste React-Komponente der App und konfigurieren die Assembly.

Bei der Programmierung in React ist es sehr praktisch, JSX zu verwenden, einen JavaScript-Dialekt, der durch syntaktische Konstruktionen des Formulars erweitert wird

 render() { return <MyButton color="blue">{this.props.caption}</MyButton>; } 

JavaScript-Engines verstehen dies jedoch nicht. Daher wird normalerweise die Erstellungsphase zum Frontend hinzugefügt. Spezielle JavaScript-Compiler (yeah-yeah) verwandeln syntaktischen Zucker in hässliches klassisches JavaScript, verarbeiten Importe, minimieren und so weiter.



2014 Jahr. apt-cache search java

Die einfachste React-Komponente sieht also sehr einfach aus.

 // frontend/src/components/app.js import React, {Component} from 'react' class App extends Component { render() { return <h1>Hello, world!</h1> } } export default App 

Er wird unseren Gruß einfach mit einer überzeugenderen Stecknadel anzeigen.

Fügen Sie die Datei frontend/src/template.js die das minimale HTML-Framework unserer zukünftigen Anwendung enthält:

 // 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; } 

Fügen Sie einen Client-Einstiegspunkt hinzu:

 // frontend/src/client.js import React from 'react' import {render} from 'react-dom' import App from './components/app' render( <App/>, document.querySelector('#app') ); 

Um all diese Schönheit aufzubauen, brauchen wir:

webpack ist ein modischer Jugendbauer für JS (obwohl ich drei Stunden lang keine Artikel im Frontend gelesen habe, bin ich mir also nicht sicher über die Mode);
babel ist ein Compiler für alle Arten von Lotionen wie JSX und gleichzeitig ein Polyfill-Anbieter für alle IE-Fälle.

Wenn die vorherige Iteration des Frontends noch ausgeführt wird, müssen Sie nur noch etwas tun

 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 

um neue Abhängigkeiten zu installieren. Konfigurieren Sie nun das Webpack:

 // 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]; 

Damit Babel funktioniert, müssen Sie frontend/.babelrc konfigurieren:

 { "presets": ["@babel/env", "@babel/react"] } 

Machen Sie unseren Befehl npm run build aussagekräftig:

 // frontend/package.json ... "scripts": { "build": "webpack", "start": "node /app/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, ... 

Jetzt durchläuft unser Client zusammen mit einem Bündel von Polyfills und all seinen Abhängigkeiten babel, kompiliert und faltet sich zu einer monolithisch minimierten Datei ../dist/client.js . Fügen Sie die Möglichkeit hinzu, es als statische Datei in unsere Express-Anwendung hochzuladen, und in der Standardroute geben wir unser HTML zurück:

 // 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); 

Erfolg! Wenn wir jetzt docker-compose up --build frontend , sehen wir "Hallo Welt!" In einem neuen, glänzenden Wrapper und wenn Sie die Erweiterung React Developer Tools ( Chrome , Firefox ) installiert haben, gibt es in den Entwicklertools auch einen React-Komponentenbaum:



Backend: Daten in MongoDB


Bevor Sie fortfahren und unserer Anwendung Leben einhauchen, müssen Sie sie zuerst in das Backend einatmen. Es scheint, dass wir die in Markdown markierten Karten aufbewahren wollten - es ist Zeit, dies zu tun.

Während es in Python ORMs für MongoDB gibt, halte ich die Verwendung von ORMs für bösartig und überlasse Ihnen das Studium der geeigneten Lösungen. Stattdessen erstellen wir eine einfache Klasse für die Karte und das zugehörige DAO :

 # 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 

(Wenn Sie in Python immer noch keine Typanmerkungen verwenden, lesen Sie unbedingt diese Artikel !)

Erstellen wir nun eine Implementierung der CardDAO Schnittstelle, die ein Database aus pymongo (ja, Zeit, um pymongo zur requirements.txt hinzuzufügen):

 # 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) 

Zeit, die Monga-Konfiguration in den Backend-Einstellungen zu registrieren. Wir haben unseren Container einfach mit MONGO_HOST = "mongo" benannt, also 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" 

Jetzt müssen wir MongoCardDAO erstellen und der Flask-Anwendung Zugriff darauf gewähren. Obwohl wir jetzt eine sehr einfache Hierarchie von Objekten haben (Einstellungen → Pymongo-Client → Pymongo-Datenbank → MongoCardDAO ), erstellen wir sofort eine zentralisierte King-Komponente, die die Abhängigkeitsinjektion MongoCardDAO (dies wird sich wieder als nützlich erweisen, wenn wir den Worker und die Tools MongoCardDAO ).

 # 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) 


Zeit, der Flask-Anwendung eine neue Route hinzuzufügen und die Ansicht zu genießen!

 # 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") 

Starten Sie mit dem docker-compose up --build backend :



Ups ... oh genau. Wir müssen Inhalte hinzufügen! Wir öffnen den Tools-Ordner und fügen ein Skript hinzu, das eine Testkarte hinzufügt:

 # 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. """)) 

Der docker-compose exec backend python -m tools.add_test_content füllt unsere Monga mit Inhalten aus dem docker-compose exec backend python -m tools.add_test_content Container.



Erfolg! Jetzt ist es an der Zeit, dies am Frontend zu unterstützen.

Frontend: Redux


Jetzt wollen wir die Route /card/:id_or_slug , über die unsere React-Anwendung geöffnet wird, die Kartendaten aus der API laden und uns irgendwie anzeigen. Und hier beginnt vielleicht der schwierigste Teil, weil wir möchten, dass der Server uns sofort HTML mit dem Inhalt der Karte gibt, der für die Indizierung geeignet ist. Gleichzeitig empfängt die Anwendung beim Navigieren zwischen den Karten alle Daten in Form von JSON von der API und die Seite wird nicht überladen. Und das alles - ohne Copy-Paste!

Beginnen wir mit dem Hinzufügen von Redux. Redux ist eine JavaScript-Bibliothek zum Speichern des Status. Die Idee ist, dass anstelle der Tausenden impliziter Zustände, die Ihre Komponenten während Benutzeraktionen und anderen interessanten Ereignissen ändern, sie einen zentralen Status haben und Änderungen über einen zentralisierten Aktionsmechanismus vornehmen. Wenn wir also früher für die Navigation zuerst das GIF zum Laden aktiviert haben, dann eine Anfrage über AJAX gestellt und schließlich im Erfolgsrückruf die erforderlichen Teile der Seite aktualisiert haben, werden wir im Redux-Paradigma aufgefordert, die Aktion "Inhalt in ein GIF mit Animation ändern" zu senden ändert den globalen Status, sodass eine Ihrer Komponenten den vorherigen Inhalt löscht und die Animation einfügt, dann eine Anfrage stellt und eine weitere Aktion in ihrem erfolgreichen Rückruf sendet: "Ändern Sie den Inhalt in geladen". Im Allgemeinen werden wir es jetzt selbst sehen.

Beginnen wir mit der Installation neuer Abhängigkeiten in unserem Container.

 docker-compose exec frontend npm install --save \ redux \ react-redux \ redux-thunk \ redux-devtools-extension 

Das erste ist in der Tat Redux, das zweite ist eine spezielle Bibliothek zum Überqueren von React und Redux (geschrieben von Paarungsexperten), das dritte ist eine sehr notwendige Sache, deren Notwendigkeit in der README- Datei gut begründet ist, und schließlich ist das vierte die Bibliothek, die für die Arbeit von Redux DevTools erforderlich ist Erweiterung .

Beginnen wir mit dem Redux-Code der Boilerplate: Erstellen eines Reduzierers, der nichts tut, und Initialisieren des Status.

 // 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)), ); } 

Unser Kunde ändert sich ein wenig und bereitet sich mental auf die Arbeit mit Redux vor:

 // 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') ); 

Jetzt können wir Docker-Compose Up - Build Frontend ausführen, um sicherzustellen, dass nichts kaputt ist, und unser primitiver Status wurde in Redux DevTools angezeigt:



Frontend: Kartenseite


Bevor Sie Seiten mit SSRs erstellen können, müssen Sie Seiten ohne SSRs erstellen! Lassen Sie uns endlich unsere geniale API für den Zugriff auf Karten verwenden und die Kartenseite im Frontend erstellen.

Zeit, die Intelligenz zu nutzen und unsere Staatsstruktur neu zu gestalten. Es gibt viele Materialien zu diesem Thema, daher empfehle ich, die Intelligenz nicht zu missbrauchen und mich auf das Einfache zu konzentrieren. Zum Beispiel:

 { "page": { "type": "card", //     //       type=card: "cardSlug": "...", //     "isFetching": false, //      API "cardData": {...}, //   (  ) // ... }, // ... } 

Lassen Sie uns die "Karten" -Komponente erhalten, die den Inhalt von cardData als Requisiten verwendet (es ist eigentlich der Inhalt unserer Karte in Mongo):

 // 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; 

Lassen Sie uns nun eine Komponente für die gesamte Seite mit der Karte erhalten. Er ist dafür verantwortlich, die erforderlichen Daten von der API abzurufen und auf die Karte zu übertragen. Und wir werden Daten auf React-Redux-Weise abrufen.

Erstellen Sie zunächst die Datei frontend/src/redux/actions.js und erstellen Sie eine Aktion, die den Inhalt der Karte aus der API extrahiert, falls noch nicht geschehen:

 export function fetchCardIfNeeded() { return (dispatch, getState) => { let state = getState().page; if (state.cardData === undefined || state.cardData.slug !== state.cardSlug) { return dispatch(fetchCard()); } }; } 

Die fetchCard Aktion, die den Abruf tatsächlich macht, ist etwas komplizierter:

 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"; } 

Oh, wir haben eine Aktion, die ETWAS TUT! Dies muss im Reduzierer unterstützt werden:

 // 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; } 

(Beachten Sie die trendige Syntax zum Klonen eines Objekts mit sich ändernden einzelnen Feldern.)

Nachdem die gesamte Logik in Redux-Aktionen ausgeführt CardPagewird, sieht die Komponente selbst relativ einfach aus:

 // 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); 

Fügen Sie unserer Root-App-Komponente eine einfache page.type-Verarbeitung hinzu:

 // 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); 

Und jetzt bleibt der letzte Moment - Sie müssen irgendwie initialisieren page.typeund page.cardSlugabhängig von der URL der Seite.

Es gibt zwar noch viele Abschnitte in diesem Artikel, aber wir können derzeit keine qualitativ hochwertige Lösung finden. Lass es uns erstmal dumm machen. Das ist einfach völlig dumm. Zum Beispiel eine regelmäßige bei der Initialisierung der Anwendung!

 // 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') ); 

Jetzt können wir das Frontend mit Hilfe docker-compose up --build frontendunserer Karte neu erstellen helloworld...



Warten Sie also eine Sekunde ... und wo ist unser Inhalt? Oh, wir haben vergessen, Markdown zu analysieren!

Arbeiter: RQ


Das Parsen von Markdown und das Generieren von HTML für eine Karte mit potenziell unbegrenzter Größe ist eine typische „schwere“ Aufgabe, die nicht direkt im Backend gelöst wird, während Änderungen gespeichert werden, sondern normalerweise auf separaten Arbeitsmaschinen in die Warteschlange gestellt und ausgeführt wird.

Es gibt viele Open Source-Implementierungen von Task-Warteschlangen. Wir werden Redis und eine einfache Bibliothek RQ (Redis Queue) nehmen, die Aufgabenparameter im Pickle- Format überträgt und uns Laichprozesse für deren Verarbeitung organisiert.

Zeit, Radieschen hinzuzufügen, abhängig von Einstellungen und Verkabelung!

 --- 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) 

Ein bisschen Code für den Arbeiter.

 # 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) 

Verbinden Sie für das Parsen selbst die Mistune- Bibliothek und schreiben Sie eine einfache Funktion:

 # 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) 

Logischerweise: Wir müssen CardDAOden Quellcode der Karte erhalten und das Ergebnis speichern. Ein Objekt, das eine Verbindung zu einem externen Speicher enthält, kann jedoch nicht über pickle serialisiert werden. Dies bedeutet, dass diese Aufgabe nicht sofort übernommen und für RQ in die Warteschlange gestellt werden kann. Auf eine gute Weise müssen wir Wiringeinen Arbeiter auf die Seite stellen und ihn in alle möglichen Richtungen werfen ... Lass es uns tun:

 --- 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) 

Wir haben unsere Klasse von Jobs deklariert und die Verkabelung als zusätzliches Argument für alle Probleme verwendet. (Beachten Sie, dass jedes Mal eine NEUE Verkabelung erstellt wird, da einige Clients nicht vor dem Fork erstellt werden können, der in RQ vor der Verarbeitung der Aufgabe auftritt.) Damit alle unsere Aufgaben nicht von der Verkabelung abhängen, dh von ALLEN unseren Objekten, lassen Sie uns Machen wir einen Dekorateur, der nur das Nötigste aus der Verkabelung herausholt:

 # 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 

Fügen Sie unserer Aufgabe einen Dekorateur hinzu und genießen Sie das Leben:

 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) 

Das Leben genießen? Ugh, ich wollte sagen, wir starten den Arbeiter:

 $ 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 

III ... er macht nichts! Natürlich, weil wir keine einzige Aufgabe gestellt haben!

Schreiben wir unser Tool, das eine Testkarte erstellt, so um, dass es: a) nicht herunterfällt, wenn die Karte bereits erstellt wurde (wie in unserem Fall); b) Aufgabe beim Parsen von Marqdown.

 # 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}) 

Tools können jetzt nicht nur im Backend, sondern auch auf dem Worker ausgeführt werden. Im Prinzip ist uns das jetzt egal. Wir starten es docker-compose exec worker python -m tools.add_test_contentund in einem benachbarten Tab des Terminals sehen wir ein Wunder - der Arbeiter hat ETWAS getan!

 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 

Nachdem wir den Container mit dem Backend neu erstellt haben, können wir endlich den Inhalt unserer Karte im Browser sehen:



Frontend-Navigation


Bevor wir zu SSR übergehen, müssen wir all unsere Reaktionsreaktionen ein wenig aussagekräftig machen und unsere Einzelseitenanwendung wirklich zu einer einzigen Seite machen. Lassen Sie uns unser Tool aktualisieren, um zwei (NICHT EINE, ZWEI! MAMMA, ICH JETZT BIG DATE DEVELOPER!) Karten zu erstellen, die miteinander verknüpft sind, und dann werden wir uns mit der Navigation zwischen ihnen befassen.

Versteckter Text
 # 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**! """ )) 


Jetzt können wir den Links folgen und überlegen, wie unsere wunderbare Anwendung jedes Mal neu gestartet wird. Hör auf!

Setzen Sie Ihren Handler zunächst auf Klicks auf die Links. Da HTML mit Links aus dem Backend stammt und die Anwendung mit React ausgeführt wird, benötigen wir einen kleinen reaktionsspezifischen Fokus.

 // 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> ); } } 

Da die gesamte Logik beim Laden der Karten in unserer Komponente CardPagein der Aktion selbst (erstaunlich!), Muss keine Aktion ausgeführt werden:

 export function navigate(link) { return { type: NAVIGATE, path: link.pathname } } 

Fügen Sie für diesen Fall einen albernen Reduzierer hinzu:

 // 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; } 

Da sich jetzt der Status unserer Anwendung ändern kann, müssen CardPagewir eine Methode hinzufügen, die componentDidUpdatemit der bereits hinzugefügten identisch ist componentWillMount. Nach dem Aktualisieren der Eigenschaften CardPage(z. B. Eigenschaften cardSlugwährend der Navigation) wird nun auch der Inhalt der Karte aus dem Backend angefordert (dies componentWillMountgeschah erst, als die Komponente initialisiert wurde).

Okay, docker-compose up --build frontendund wir haben eine funktionierende Navigation!



Ein aufmerksamer Leser wird feststellen, dass sich die URL der Seite beim Navigieren zwischen Karten nicht ändert - selbst im Screenshot sehen wir Hallo, die Weltkarte an der Adresse der Demokarte. Dementsprechend fiel auch die Vorwärts-Rückwärts-Navigation ab. Fügen wir sofort etwas schwarze Magie mit Geschichte hinzu, um das Problem zu beheben!

Das Einfachste, was Sie tun können, ist, der Aktion etwas hinzuzufügen.navigateeine Herausforderung history.pushState.

 export function navigate(link) { history.pushState(null, "", link.href); return { type: NAVIGATE, path: link.pathname } } 

Wenn Sie nun auf die Links klicken, ändert sich die URL in der Adressleiste des Browsers wirklich. Der Zurück-Knopf wird jedoch brechen !

Damit es funktioniert, müssen wir das Ereignis des popstateObjekts abhören window. Wenn wir in diesem Fall sowohl vorwärts als auch rückwärts navigieren möchten (dh durch dispatch(navigate(...))), müssen wir der Funktion navigateein spezielles "Nicht pushState" -Flag hinzufügen (sonst wird alles noch mehr kaputt gehen!). Um zwischen „unseren“ Zuständen zu unterscheiden, sollten wir außerdem die Möglichkeit nutzen pushState, Metadaten zu speichern. Es gibt viel Magie und Debugging, also lasst uns gleich zum Code kommen! So sieht die App aus:

 // 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() { // ... } } 

Und hier ist die Navigationsaktion:

 // 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 } } 

Jetzt wird die Geschichte funktionieren.

Nun, die letzte Berührung: Da wir jetzt eine Aktion haben navigate, warum geben wir nicht den zusätzlichen Code im Client auf, der den Anfangszustand berechnet? Wir können einfach die Navigation zum aktuellen Standort anrufen:

 --- 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)); 

Kopieren-Einfügen zerstört!

Frontend: Serverseitiges Rendern


Es ist Zeit für unsere wichtigsten (meiner Meinung nach) Chips - SEO-Freundlichkeit. Damit Suchmaschinen unseren Inhalt indizieren können, der vollständig dynamisch in React-Komponenten erstellt wird, müssen wir in der Lage sein, ihnen das Ergebnis des Renderns von React zu geben und zu lernen, wie dieses Ergebnis wieder interaktiv gemacht werden kann.

Das allgemeine Schema ist einfach. Erstens: Wir müssen den von unserer React-Komponente generierten HTML-Code in unsere HTML-Vorlage einfügen App. Dieser HTML-Code wird von Suchmaschinen (und Browsern mit deaktiviertem JS, hehe) angezeigt. Zweitens: Sie müssen der Vorlage ein Tag hinzufügen, das <script>irgendwo (z. B. ein Objekt window) einen Statusspeicherauszug speichert, aus dem dieser HTML-Code gerendert wurde. Dann können wir unsere Anwendung auf der Client-Seite sofort mit diesem Status initialisieren und zeigen, was benötigt wird (wir können sogar Hydrat verwendenauf den generierten HTML-Code, um den DOM-Baum der Anwendung nicht neu zu erstellen).

Beginnen wir mit dem Schreiben einer Funktion, die gerenderten HTML-Code und den Endzustand zurückgibt.

 // 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}; }; 

Fügen Sie unserer Vorlage, über die wir oben gesprochen haben, neue Argumente und Logik hinzu:

 // 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; 

Unser Express-Server wird etwas komplizierter:

 // 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)); }); 

Aber der Kunde ist einfacher:

 // 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') ); 

Als Nächstes müssen Sie plattformübergreifende Fehler wie "Verlauf ist nicht definiert" bereinigen. Fügen Sie dazu irgendwo in eine einfache (bisher) Funktion hinzu utility.js.

 // frontend/src/utility.js export function isServerSide() { //   ,      process, //     -   . return process.env.APP_ENV !== undefined; } 

Dann wird es eine bestimmte Anzahl von Routineänderungen geben, die ich hier nicht einbringen werde (aber sie finden Sie im entsprechenden Commit ). Dadurch kann unsere React-Anwendung sowohl im Browser als auch auf dem Server rendern.

Es funktioniert!Aber es gibt, wie sie sagen, eine Einschränkung ...



LADEN? Alles, was Google in meinem supercoolen Modedienst sieht, ist LADEN ?!

Nun, es scheint, dass all unser Asynchronismus gegen uns gespielt hat. Jetzt müssen wir dem Server eine Möglichkeit geben, zu verstehen, dass die Antwort vom Backend mit dem Inhalt der Karte warten muss, bevor die React-Anwendung in eine Zeichenfolge gerendert und an den Client gesendet wird. Und es ist wünschenswert, dass diese Methode ziemlich allgemein ist.

Es kann viele Lösungen geben. Ein Ansatz besteht darin, in einer separaten Datei zu beschreiben, für welche Pfade welche Daten gesichert werden sollen, und dies vor dem Rendern der Anwendung ( Artikel ). Diese Lösung hat viele Vorteile. Es ist einfach, es ist explizit und es funktioniert.

Als Experiment (der ursprüngliche Inhalt sollte zumindest irgendwo im Artikel enthalten sein!) Schlage ich ein anderes Schema vor. Lassen Sie uns jedes Mal, wenn wir etwas Asynchrones ausführen, auf das wir warten müssen, irgendwo in unserem Status das entsprechende Versprechen hinzufügen (z. B. das, das Fetch zurückgibt). So haben wir einen Ort, an dem Sie immer überprüfen können, ob alles heruntergeladen wurde.

Fügen Sie zwei neue Aktionen hinzu.

 // frontend/src/redux/actions.js function addPromise(promise) { return { type: ADD_PROMISE, promise: promise }; } function removePromise(promise) { return { type: REMOVE_PROMISE, promise: promise, }; } 


Der erste wird aufgerufen, wenn der Abruf gestartet wird, der zweite - am Ende .then().

Fügen Sie nun ihre Verarbeitung zum Reduzierer hinzu:

 // 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) }; ... 

Jetzt werden wir die Aktion verbessern 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)); }; } 

Es bleibt, dem initialStateleeren Array Versprechen hinzuzufügen und den Server auf sie alle warten zu lassen! Die Renderfunktion wird asynchron und hat folgende Form:

 // 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}; }; 

Aufgrund der erfassten renderAsynchronität ist der Request-Handler auch etwas komplizierter:

 // 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!"); }); }); 

Et voilà!



Fazit


Wie Sie sehen, ist das Erstellen einer High-Tech-Anwendung nicht so einfach. Aber nicht so schwer! Die endgültige Anwendung befindet sich im Repository von Github, und theoretisch benötigen Sie nur Docker, um sie auszuführen.

Wenn der Artikel gefragt ist, wird dieses Repository nicht einmal verlassen! Wir werden es mit etwas anderem Wissen betrachten können, das notwendig ist:

  • Protokollierung, Überwachung, Lasttest.
  • Testen, CI, CD.
  • coolere Funktionen wie Autorisierung oder Volltextsuche.
  • Einrichtung und Entwicklung der Produktionsumgebung.

Vielen Dank für Ihre Aufmerksamkeit!

Source: https://habr.com/ru/post/de444446/


All Articles