Membuat aplikasi web modern dari awal

Jadi, Anda memutuskan untuk membuat proyek baru. Dan proyek ini adalah aplikasi web. Berapa banyak waktu yang diperlukan untuk membuat prototipe dasar? Seberapa sulit? Apa yang harus dapat dilakukan oleh situs web modern sejak awal?

Pada artikel ini, kami akan mencoba menguraikan boilerplate dari aplikasi web sederhana dengan arsitektur berikut:


Apa yang akan kita bahas:

  • mengatur lingkungan dev di docker-compose.
  • pembuatan backend pada Flask.
  • membuat tampilan depan pada Express.
  • Bangun JS menggunakan Webpack.
  • Bereaksi, Redux, dan rendering sisi server.
  • tugas antrian dengan RQ.

Pendahuluan


Sebelum pengembangan, tentu saja, Anda harus terlebih dahulu memutuskan apa yang kami kembangkan! Sebagai aplikasi model untuk artikel ini, saya memutuskan untuk membuat mesin wiki primitif. Kami akan mengeluarkan kartu dalam penurunan harga; mereka dapat ditonton dan (suatu saat nanti) menawarkan suntingan. Semua ini akan kami atur sebagai aplikasi satu halaman dengan rendering sisi server (yang mutlak diperlukan untuk mengindeks terabyte konten kami di masa mendatang).

Mari kita lihat lebih detail komponen yang kita butuhkan untuk ini:

  • Pelanggan Kami akan membuat aplikasi satu halaman (mis. Dengan transisi halaman menggunakan AJAX) pada bundel React + Redux , yang sangat umum di dunia front-end.
  • Frontend . Mari kita membuat server Express sederhana yang akan membuat aplikasi Bereaksi kami (meminta semua data yang diperlukan di backend secara tidak sinkron) dan mengeluarkannya kepada pengguna.
  • Backend . Master logika bisnis, backend kami akan menjadi aplikasi Flask kecil. Kami akan menyimpan data (kartu kami) di repositori dokumen MongoDB yang populer, dan untuk antrian tugas dan, mungkin, di masa mendatang, caching, kami akan menggunakan Redis .
  • PEKERJA . Wadah terpisah untuk tugas berat akan diluncurkan oleh perpustakaan RQ .

Infrastruktur: git


Mungkin, kita tidak bisa membicarakan hal ini, tetapi, tentu saja, kita akan melakukan pengembangan di repositori git.

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

(Di sini Anda harus segera mengisi .gitignore .)

Draf akhir dapat dilihat di Github . Setiap bagian dari artikel sesuai dengan satu komit (saya banyak rebazed untuk mencapai ini!).

Infrastruktur: komposisi buruh pelabuhan


Mari kita mulai dengan mengatur lingkungan. Dengan banyaknya komponen yang kita miliki, solusi pengembangan yang sangat logis adalah menggunakan docker-compose.

Tambahkan file docker-compose.yml ke repositori docker-compose.yml konten berikut:

 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 

Mari kita lihat apa yang terjadi di sini.

  • Wadah MongoDB dan wadah Redis dibuat.
  • Sebuah wadah untuk backend kami dibuat (yang kami jelaskan di bawah). Variabel lingkungan APP_ENV = dev diteruskan ke sana (kami akan melihatnya untuk memahami pengaturan Flask apa yang akan dimuat), dan porta 40001 akan terbuka di luar (melalui itu klien browser kami akan pergi ke API).
  • Wadah dari frontend kami dibuat. Berbagai variabel lingkungan juga dimasukkan ke dalamnya, yang akan berguna bagi kita nanti, dan port 40002 terbuka. Ini adalah port utama aplikasi web kita: di browser kita akan pergi ke http: // localhost: 40002 .
  • Wadah pekerja kami dibuat. Dia tidak membutuhkan port eksternal, dan hanya akses yang diperlukan di MongoDB dan Redis.

Sekarang mari kita buat file docker. Saat ini, serangkaian terjemahan artikel hebat tentang Docker akan hadir di HabrΓ© - Anda dapat dengan aman pergi ke sana untuk semua detailnya.

Mari kita mulai dengan 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 

Dapat dipahami bahwa kita menjalankan aplikasi flask gunicorn, bersembunyi di bawah nama app di modul backend.server .

docker/backend/.dockerignore tidak kalah pentingnya:

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

Pekerja itu umumnya mirip dengan backend, hanya saja bukannya gunicorn kita memiliki peluncuran modul pit yang biasa:

 # 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 

Kami akan melakukan semua pekerjaan di worker/__main__.py .

Pekerja .dockerignore benar-benar mirip dengan backend .dockerignore .

Akhirnya, frontend. Ada seluruh artikel terpisah tentang dia di HabrΓ©, tetapi menilai dengan diskusi yang luas tentang StackOverflow dan komentar dalam semangat "Guys, apakah sudah 2018, apakah masih ada solusi normal?" semuanya tidak begitu sederhana di sana. Saya menyelesaikan versi file buruh pelabuhan ini.

 # 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 

Pro:

  • semuanya di-cache seperti yang diharapkan (di lapisan bawah - dependensi, di atas - pembuatan aplikasi kita);
  • docker-compose exec frontend npm install --save newDependency berfungsi sebagaimana mestinya dan memodifikasi package.json dalam repositori kami (yang tidak akan menjadi masalah jika kami menggunakan COPY, seperti yang disarankan banyak orang). Akan tidak diinginkan untuk menjalankan npm install --save newDependency luar wadah, karena beberapa dependensi dari paket baru mungkin sudah ada dan dibangun di bawah platform yang berbeda (di bawah yang di dalam buruh pelabuhan, dan tidak di bawah macbook kami yang bekerja, misalnya ), namun kami umumnya tidak ingin memerlukan kehadiran Node pada mesin pengembangan. Satu Docker untuk menguasai semuanya!

Baik dan tentu saja docker/frontend/.dockerignore :

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

Jadi, kerangka kontainer kami sudah siap dan Anda dapat mengisinya dengan konten!

Backend: Kerangka kerja flask


Tambahkan flask , flask-cors gevent , gevent dan gunicorn ke requirements.txt dan buat aplikasi Flask sederhana di 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") 

Kami memberi tahu Flask untuk menarik pengaturan dari file backend.{env}_settings , yang berarti kami juga perlu membuat ( backend/dev_settings.py ) file backend/dev_settings.py agar semuanya lepas landas.

Sekarang kita dapat secara resmi NAIK backend kami!

 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 

Kami melanjutkan.

Frontend: Express framework


Mari kita mulai dengan membuat paket. Setelah membuat folder frontend dan menjalankan npm init di dalamnya, setelah beberapa pertanyaan tidak canggih, kami mendapatkan paket jadi.

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

Di masa depan, kami sama sekali tidak membutuhkan Node.js di mesin pengembang (meskipun kami masih bisa mengelak dan memulai npm init melalui Docker, tapi oh well).

Di Dockerfile kami menyebutkan npm run build dan npm run start - Anda perlu menambahkan perintah yang sesuai ke 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": { 

Perintah build belum melakukan apa-apa, tetapi masih akan bermanfaat bagi kita.

Tambahkan dependensi Express dan buat aplikasi sederhana di 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!") }); 

Sekarang docker-compose up frontend memunculkan frontend kami! Selain itu, di http: // localhost: 40002 , klasik "Hello, world" seharusnya sudah pamer.

Frontend: build with webpack dan React application


Saatnya untuk menggambarkan sesuatu yang lebih dari sekadar teks dalam aplikasi kita. Di bagian ini, kita akan menambahkan komponen Bereaksi paling sederhana dari App dan mengkonfigurasi perakitan.

Ketika pemrograman dalam Bereaksi, sangat mudah untuk menggunakan JSX , dialek JavaScript yang diperpanjang oleh konstruksi sintaksis formulir

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

Namun, mesin JavaScript tidak memahaminya, jadi biasanya fase build ditambahkan ke frontend. Kompiler JavaScript khusus (yeah-yeah) mengubah gula sintaksis menjadi JavaScript klasik yang jelek , menangani impor, mengecilkan, dan sebagainya.



Tahun 2014. java pencarian apt-cache

Jadi, komponen Bereaksi paling sederhana terlihat sangat sederhana.

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

Dia hanya akan menampilkan salam kami dengan pin yang lebih meyakinkan.

Tambahkan frontend/src/template.js file frontend/src/template.js berisi kerangka kerja HTML minimum aplikasi masa depan kita:

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

Tambahkan titik masuk klien:

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

Untuk membangun semua keindahan ini, kita perlu:

webpack adalah pembangun pemuda modis untuk JS (meskipun saya belum membaca artikel di frontend selama tiga jam, jadi saya tidak yakin tentang fashion);
babel adalah kompiler untuk semua jenis lotion seperti JSX, dan pada saat yang sama penyedia polyfill untuk semua case IE.

Jika iterasi frontend sebelumnya masih berjalan, yang harus Anda lakukan adalah

 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 

untuk menginstal dependensi baru. Sekarang konfigurasikan 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]; 

Untuk membuat babel berfungsi, Anda perlu mengonfigurasi frontend/.babelrc :

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

Akhirnya, buat npm run build perintah kami bermakna:

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

Sekarang klien kami, bersama dengan bundel polyfill dan semua dependensinya, berjalan melalui babel, mengkompilasi dan melipat menjadi file minified monolitik ../dist/client.js . Tambahkan kemampuan untuk mengunggahnya sebagai file statis ke aplikasi Express kami, dan dalam rute default kami akan mulai mengembalikan HTML kami:

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

Sukses! Sekarang, jika kita menjalankan docker-compose up --build frontend , kita akan melihat "Halo, dunia!" di bungkus baru, mengkilap, dan jika Anda memiliki ekstensi Alat Pengembang Bereaksi diinstal ( Chrome , Firefox ), maka ada juga komponen komponen Bereaksi dalam alat pengembang:



Backend: Data dalam MongoDB


Sebelum pindah dan menghidupkan kehidupan ke aplikasi kami, Anda harus terlebih dahulu menghirupnya ke backend. Tampaknya kami akan menyimpan kartu yang ditandai di Markdown - saatnya untuk melakukannya.

Meskipun ada ORM untuk MongoDB dalam python , saya menganggap penggunaan ORM sebagai setan dan saya meninggalkan studi tentang solusi yang tepat untuk Anda. Sebagai gantinya, kami akan membuat kelas sederhana untuk kartu dan DAO yang menyertainya:

 # 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 

(Jika Anda masih tidak menggunakan anotasi jenis dengan Python, pastikan untuk memeriksa artikel ini !)

Sekarang mari kita membuat implementasi antarmuka CardDAO yang mengambil objek Database dari pymongo (ya, waktu untuk menambahkan pymongo ke 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) 

Saatnya mendaftarkan konfigurasi Monga di pengaturan backend. Kami cukup memberi nama wadah kami dengan mongo mongo , jadi 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" 

Sekarang kita perlu membuat MongoCardDAO dan memberikan akses aplikasi Flask padanya. Meskipun sekarang kita memiliki hierarki objek yang sangat sederhana (pengaturan β†’ klien pymongo β†’ database pymongo β†’ MongoCardDAO ), mari kita segera membuat komponen raja terpusat yang melakukan injeksi ketergantungan (itu akan berguna lagi ketika kita melakukan pekerja dan peralatan).

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


Saatnya menambahkan rute baru ke aplikasi Flask dan nikmati pemandangannya!

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

Mulai ulang dengan docker-compose up --build backend :



Ups ... oh, tepatnya. Kami perlu menambahkan konten! Kami akan membuka folder alat dan menambahkan skrip ke dalamnya yang menambahkan satu kartu tes:

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

Perintah docker-compose exec backend python -m tools.add_test_content mengisi monga kami dengan konten dari dalam wadah docker-compose exec backend python -m tools.add_test_content .



Sukses! Sekarang adalah waktu untuk mendukung ini di ujung depan.

Frontend: Redux


Sekarang kita ingin membuat rute /card/:id_or_slug , dimana aplikasi Bereaksi kita akan terbuka, memuat data kartu dari API dan menunjukkannya kepada kita entah bagaimana. Dan di sini, mungkin, bagian yang paling sulit dimulai, karena kami ingin server segera memberi kami HTML dengan isi kartu, cocok untuk pengindeksan, tetapi pada saat yang sama, ketika aplikasi menavigasi antara kartu, ia menerima semua data dalam bentuk JSON dari API, dan halaman tidak kelebihan beban. Dan agar semua ini - tanpa copy-paste!

Mari kita mulai dengan menambahkan Redux. Redux adalah pustaka JavaScript untuk menyimpan keadaan. Idenya adalah bahwa alih-alih ribuan status tersirat bahwa komponen Anda berubah selama tindakan pengguna dan acara menarik lainnya, mereka memiliki satu status terpusat, dan melakukan perubahan melalui mekanisme tindakan terpusat. Jadi, jika sebelumnya untuk navigasi, pertama-tama kita menghidupkan loading GIF, maka kita membuat permintaan melalui AJAX dan, akhirnya, dalam keberhasilan panggilan balik, kita memperbarui bagian-bagian halaman yang diperlukan, maka dalam paradigma Redux kita diundang untuk mengirim tindakan "mengubah konten menjadi gif dengan animasi", yang akan mengubah status global sehingga salah satu komponen Anda akan membuang konten sebelumnya dan memasukkan animasinya, kemudian membuat permintaan, dan mengirim tindakan lain dalam panggilan balik keberhasilannya, "ubah konten menjadi dimuat". Secara umum, sekarang kita akan melihatnya sendiri.

Mari kita mulai dengan menginstal dependensi baru di wadah kami.

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

Yang pertama adalah, pada kenyataannya, Redux, yang kedua adalah perpustakaan khusus untuk menyeberang React dan Redux (ditulis oleh para ahli kawin), yang ketiga adalah hal yang sangat diperlukan, kebutuhan yang dibuktikan dengan baik dalam README-nya , dan, akhirnya, yang keempat adalah perpustakaan yang diperlukan agar Redux DevTools dapat berfungsi. Ekstensi .

Mari kita mulai dengan kode Redux boilerplate: membuat peredam yang tidak melakukan apa-apa, dan menginisialisasi keadaan.

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

Klien kami sedikit berubah, secara mental bersiap untuk bekerja dengan Redux:

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

Sekarang kita dapat menjalankan docker-compose up --build frontend untuk memastikan tidak ada yang rusak, dan status primitif kita muncul di Redux DevTools:



Frontend: Halaman Kartu


Sebelum Anda dapat membuat halaman dengan SSR, Anda perlu membuat halaman tanpa SSR! Akhirnya mari kita gunakan API kami yang cerdik untuk mengakses kartu dan membuat halaman kartu di ujung depan.

Saatnya mengambil keuntungan dari intelijen dan mendesain ulang struktur negara kita. Ada banyak materi tentang hal ini, jadi saya sarankan untuk tidak menyalahgunakan kecerdasan dan akan fokus pada yang sederhana. Misalnya, seperti:

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

Mari kita dapatkan komponen "kartu", yang mengambil konten cardData sebagai alat peraga (sebenarnya isi kartu kita di 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; 

Sekarang mari kita dapatkan komponen untuk seluruh halaman dengan kartu. Dia akan bertanggung jawab untuk mendapatkan data yang diperlukan dari API dan mentransfernya ke Kartu. Dan kami akan melakukan pengambilan data dengan cara React-Redux.

Pertama, buat file frontend/src/redux/actions.js dan buat tindakan yang mengekstraksi isi kartu dari API, jika belum:

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

Tindakan fetchCard , yang sebenarnya membuat pengambilan, sedikit lebih rumit:

 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, kami mendapat tindakan yang SESUATU! Ini harus didukung di peredam:

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

(Perhatikan sintaksis yang trendi untuk mengkloning objek dengan mengubah bidang individual.)

Sekarang semua logika dilakukan dalam tindakan Redux, komponen itu sendiri CardPageakan terlihat relatif sederhana:

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

Tambahkan pengolahan halaman.type sederhana ke komponen Aplikasi root kami:

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

Dan sekarang saat terakhir tetap - Anda perlu menginisialisasi page.typedan page.cardSlugtergantung pada URL halaman.

Tetapi masih ada banyak bagian dalam artikel ini, tetapi kami tidak dapat membuat solusi berkualitas tinggi saat ini. Mari kita lakukan itu bodoh untuk saat ini. Itu benar-benar bodoh. Misalnya, biasa ketika menginisialisasi aplikasi!

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

Sekarang kita dapat membangun kembali frontend dengan bantuan docker-compose up --build frontenduntuk menikmati kartu kita helloworld...



Jadi, tunggu sebentar ... dan di mana konten kita? Oh, kami lupa menguraikan Markdown!

Pekerja: RQ


Parsing Markdown dan menghasilkan HTML untuk kartu dengan ukuran yang berpotensi tidak terbatas adalah tugas β€œberat” yang khas, yang, alih-alih diselesaikan langsung di backend sambil menyimpan perubahan, biasanya diantrekan dan dieksekusi pada mesin kerja yang terpisah.

Ada banyak implementasi open source dari antrian tugas; kami akan mengambil Redis dan perpustakaan sederhana RQ (Redis Queue), yang mentransmisikan parameter tugas dalam format acar dan mengatur proses pemijahan kami untuk diproses.

Waktu untuk menambahkan lobak tergantung, pengaturan dan kabel!

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

Sedikit kode boilerplate untuk pekerja.

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

Untuk penguraian itu sendiri, hubungkan pustaka kabut dan tulis fungsi sederhana:

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

Logikanya: kita perlu CardDAOmendapatkan kode sumber kartu dan menyimpan hasilnya. Tetapi objek yang berisi koneksi ke penyimpanan eksternal tidak dapat diserialkan melalui acar - yang berarti bahwa tugas ini tidak dapat segera diambil dan diantrekan untuk RQ. Dengan cara yang baik, kita perlu menciptakan Wiringpekerja di samping dan melemparkannya dalam segala macam ... Ayo lakukan:

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

Kami menyatakan kelas pekerjaan kami, melemparkan kabel sebagai argumen kwarg tambahan dalam semua masalah. (Harap perhatikan bahwa ia membuat kabel BARU setiap saat, karena beberapa klien tidak dapat dibuat sebelum garpu yang terjadi di dalam RQ sebelum tugas diproses.) Sehingga semua tugas kami tidak bergantung pada kabel - yaitu, pada SEMUA objek kami - mari Mari kita membuat dekorator yang hanya akan mendapatkan yang dibutuhkan dari pemasangan kabel:

 # 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 

Tambahkan dekorator ke tugas kami dan nikmati hidup:

 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) 

Nikmati hidup? Ugh, saya ingin mengatakan, kami mulai pekerja:

 $ 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 ... dia tidak melakukan apa-apa! Tentu saja, karena kami tidak menetapkan satu tugas!

Mari kita menulis ulang alat kita, yang membuat kartu uji, sehingga: a) tidak jatuh jika kartu sudah dibuat (seperti dalam kasus kami); b) menempatkan tugas pada penguraian 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}) 

Alat sekarang dapat dijalankan tidak hanya di backend, tetapi juga pada pekerja. Pada prinsipnya, sekarang kita tidak peduli. Kami meluncurkannya docker-compose exec worker python -m tools.add_test_contentdan di tab tetangga terminal kami melihat keajaiban - pekerja itu SESUATU!

 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 

Setelah membangun kembali wadah dengan backend, kita akhirnya dapat melihat isi kartu kita di browser:



Navigasi Frontend


Sebelum kita beralih ke SSR, kita perlu membuat semua keributan kita dengan Bereaksi setidaknya agak bermakna dan membuat aplikasi satu halaman kita benar-benar satu halaman. Mari kita perbarui alat kami untuk membuat dua (TIDAK SATU, DAN DUA! IBU, SAYA SEKARANG PEMBANGUN TANGGAL BESAR!) Kartu yang saling bertautan, dan kemudian kita akan berurusan dengan navigasi di antara mereka.

Teks tersembunyi
 # 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**! """ )) 


Sekarang kita dapat mengikuti tautan dan merenungkan bagaimana setiap kali aplikasi indah kita restart. Hentikan itu!

Pertama, letakkan handler Anda pada klik pada tautan. Karena HTML dengan tautan berasal dari backend, dan kami memiliki aplikasi pada Bereaksi, kami memerlukan sedikit fokus spesifik Bereaksi.

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

Karena semua logika dengan memuat kartu di komponen kami CardPage, dalam aksi itu sendiri (luar biasa!), Tidak ada tindakan yang perlu diambil:

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

Tambahkan peredam konyol untuk kasus ini:

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

Karena sekarang keadaan aplikasi kita dapat berubah, CardPagekita perlu menambahkan metode yang componentDidUpdateidentik dengan yang sudah kita tambahkan componentWillMount. Sekarang, setelah memperbarui properti CardPage(misalnya, properti cardSlugselama navigasi), konten kartu dari backend juga akan diminta (hanya componentWillMountmelakukan ini ketika komponen diinisialisasi).

Baiklah, docker-compose up --build frontenddan kami memiliki navigasi yang berfungsi!



Pembaca yang penuh perhatian akan mencatat bahwa URL halaman tidak akan berubah ketika menavigasi antara kartu - bahkan dalam tangkapan layar kita melihat Halo, kartu dunia di alamat kartu demo. Dengan demikian, navigasi maju-mundur juga jatuh. Mari kita tambahkan beberapa ilmu hitam dengan sejarah segera untuk memperbaikinya!

Hal paling sederhana yang dapat Anda lakukan adalah menambah aksi.navigatesebuah tantangan history.pushState.

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

Sekarang, ketika mengklik tautan, URL di bilah alamat browser akan benar-benar berubah. Namun, tombol kembali akan rusak !

Untuk membuatnya berfungsi, kita perlu mendengarkan acara popstateobjek window. Selain itu, jika dalam acara ini kita ingin melakukan navigasi mundur serta maju (yaitu, melalui dispatch(navigate(...))), kita harus navigatemenambahkan bendera "jangan" khusus ke fungsi pushState(jika tidak semuanya akan pecah bahkan lebih!). Selain itu, untuk membedakan antara status "kita", kita harus menggunakan kemampuan pushStateuntuk menyimpan metadata. Ada banyak keajaiban dan debug, jadi mari kita langsung ke kode! Begini tampilan Aplikasi:

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

Dan inilah tindakan navigasi:

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

Sekarang ceritanya akan berhasil.

Sentuhan terakhir: karena kita sekarang memiliki tindakan navigate, mengapa kita tidak menyerahkan kode tambahan pada klien yang menghitung keadaan awal? Kami dapat memanggil navigasi ke lokasi saat ini:

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

Copy-paste hancur!

Frontend: rendering sisi server


Saatnya chip utama kami (menurut saya) - SEO-friendly. Agar mesin telusur dapat mengindeks konten kami, yang sepenuhnya dibuat secara dinamis di komponen-React, kami harus dapat memberi mereka hasil rendering React, dan juga belajar bagaimana membuat hasil ini interaktif lagi.

Skema umum sederhana. Pertama: kita perlu memasukkan HTML yang dihasilkan oleh komponen Bereaksi kita ke dalam template HTML kita App. HTML ini akan dilihat oleh mesin pencari (dan browser dengan JS dimatikan, hehe). Kedua: tambahkan tag ke templat <script>yang menyimpan suatu tempat (misalnya, suatu objek window) suatu dump keadaan dari mana HTML ini dibuat. Kemudian kita dapat segera menginisialisasi aplikasi kita di sisi klien dengan keadaan ini dan menunjukkan apa yang diperlukan (kita bahkan dapat menggunakan hidratke HTML yang dihasilkan, agar tidak membuat ulang pohon DOM aplikasi).

Mari kita mulai dengan menulis fungsi yang mengembalikan HTML yang dirender dan status akhir.

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

Tambahkan argumen dan logika baru ke template kita, yang kita bicarakan di atas:

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

Server Express kami menjadi sedikit lebih rumit:

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

Tetapi klien lebih mudah:

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

Selanjutnya, Anda perlu membersihkan kesalahan lintas-platform seperti "histori tidak ditentukan". Untuk melakukan ini, tambahkan fungsi sederhana (sejauh ini) di suatu tempat di utility.js.

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

Kemudian akan ada sejumlah perubahan rutin yang saya tidak akan bawa ke sini (tetapi mereka dapat ditemukan di komit yang sesuai ). Akibatnya, aplikasi Bereaksi kami akan dapat merender di browser dan di server.

Itu berhasil!Tapi ada, seperti kata mereka, satu peringatan ...



PEMUATAN? Semua yang dilihat Google di layanan fesyen super keren saya adalah LOADING?!

Yah, tampaknya semua asinkronisme kita telah melawan kita. Sekarang kita perlu cara untuk membuat server mengerti bahwa respons dari backend dengan konten kartu perlu menunggu sebelum merender aplikasi Bereaksi menjadi string dan mengirimkannya ke klien. Dan diharapkan bahwa metode ini cukup umum.

Mungkin ada banyak solusi. Salah satu pendekatan adalah untuk menggambarkan dalam file terpisah yang jalur data apa yang harus diamankan, dan lakukan ini sebelum membuat aplikasi ( artikel ). Solusi ini memiliki banyak keunggulan. Sederhana, eksplisit, dan berfungsi.

Sebagai percobaan (konten asli harus ada di artikel setidaknya di suatu tempat!) Saya mengusulkan skema lain. Mari kita setiap kali kita menjalankan sesuatu yang tidak sinkron, yang harus kita tunggu, tambahkan janji yang sesuai (misalnya, yang mengembalikan mengambil) di suatu tempat di negara kita. Jadi kami akan memiliki tempat di mana Anda selalu dapat memeriksa apakah semuanya telah diunduh.

Tambahkan dua tindakan baru.

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


Yang pertama akan dipanggil saat pengambilan diluncurkan, yang kedua - di akhir itu .then().

Sekarang tambahkan pemrosesan mereka ke peredam:

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

Sekarang kita akan meningkatkan tindakan 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)); }; } 

Tetap menambahkan initialStatejanji ke array kosong dan membuat server menunggu semuanya! Fungsi render menjadi asinkron dan mengambil bentuk berikut:

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

Karena renderasinkron yang diperoleh , penangan permintaan juga sedikit lebih rumit:

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

Dan lagi!



Kesimpulan


Seperti yang Anda lihat, membuat aplikasi berteknologi tinggi tidak sesederhana itu. Tetapi tidak begitu sulit! Aplikasi terakhir ada di repositori di Github dan, secara teoritis, Anda hanya perlu Docker untuk menjalankannya.

Jika artikel ini diminati, repositori ini bahkan tidak akan ditinggalkan! Kami akan dapat melihatnya dengan sesuatu dari pengetahuan lain yang diperlukan:

  • logging, pemantauan, pengujian beban.
  • pengujian, CI, CD.
  • fitur keren seperti otorisasi atau pencarian teks lengkap.
  • pengaturan dan pengembangan lingkungan produksi.

Terima kasih atas perhatian anda!

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


All Articles