From 6e20dc794fb766bb9011afab74ef28f532cc9b9b Mon Sep 17 00:00:00 2001 From: Vitaliy Filippov Date: Thu, 3 May 2018 17:42:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20Server-Side=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=D0=B4=D0=BB=D1=8F=20React?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Попутно можно использовать вместо Redux/Flux/MobX. Кайф в том, что в такой вид тривиально рефакторятся обычные компоненты со state-ом внутри. --- App.js | 55 ++++++++++++++++++ README.md | 18 ++++++ Server.js | 142 ++++++++++++++++++++++++++++++++++++++++++++++ StateTree.js | 100 ++++++++++++++++++++++++++++++++ main.js | 26 +++++++++ package.json | 40 +++++++++++++ postcss.config.js | 19 +++++++ server-main.js | 21 +++++++ webpack.config.js | 47 +++++++++++++++ xhr.js | 117 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 585 insertions(+) create mode 100644 App.js create mode 100644 README.md create mode 100644 Server.js create mode 100644 StateTree.js create mode 100644 main.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 server-main.js create mode 100644 webpack.config.js create mode 100644 xhr.js diff --git a/App.js b/App.js new file mode 100644 index 0000000..3de5ea8 --- /dev/null +++ b/App.js @@ -0,0 +1,55 @@ +import React from 'react'; + +import Button from 'react-toolbox/lib/button'; + +import { StateTreeComponent } from './StateTree.js'; + +export class App extends StateTreeComponent +{ + initialState = { + text: 'Not loaded', + loaded: false, + }; + + componentWillMount() + { + if (!this.state.loaded) + { + this.ctx.doQuery((d) => this.setState({ text: d.text, loaded: true })); + } + } + + render() + { + return
+ {this.state.text} +
+ } +} + +class SubComponent extends StateTreeComponent +{ + initialState = { + text: 'Not loaded', + loaded: false, + } + + componentWillMount() + { + if (!this.state.loaded) + { + this.ctx.doQuery((d) => this.setState({ text: d.text, loaded: true })); + } + } + + render() + { + return
+ SUB: {this.state.text} +
+ } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2c6e29 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +Заготовка Server-Side рендера для React + +Кроме SSR по-своему (и очень просто) решает проблему управления состоянием. +Можно смело использовать такой подход вместо Redux/Flux/MobX. +Отдельный кайф заключается в том, что в такой вид тривиально рефакторятся +обычные компоненты со state-ом внутри. + +Для демонстрации подключён React Toolbox (на CSS-модулях). + +Как протестить: + +``` +npm install +npm run compile +nodejs server-main.js +``` + +После чего зайти на http://localhost:9223/ diff --git a/Server.js b/Server.js new file mode 100644 index 0000000..9825e2c --- /dev/null +++ b/Server.js @@ -0,0 +1,142 @@ +import http from 'http'; +import url from 'url'; +import path from 'path'; +import fs from 'fs'; +//import pg from 'pg'; + +import React from 'react'; +import TestRenderer from 'react-test-renderer'; +import ReactDOMServer from 'react-dom/server'; + +import { App } from './App.js'; +import { StateTree } from './StateTree.js'; + +function promisify(f) +{ + return new Promise((resolve, reject) => f((error, result) => error ? reject(error) : resolve(result))); +} + +export class SSRServer +{ + static defaultConfig = { + host: '127.0.0.1', + port: 9223, + }; + + start(cfg) + { + this.cfg = cfg; + for (let k in this.constructor.defaultConfig) + { + this.cfg[k] = this.cfg[k] || this.constructor.defaultConfig[k]; + } + this.server = http.Server((req, resp) => this.handleRequest(req, resp)); + this.server.listen(this.cfg.port, this.cfg.host); + if (this.cfg.db) + { + this.connectPg(); + } + this.dir = __dirname; + } + + async connectPg() + { + this.db = new pg.Client(this.cfg.db); + this.db.on('error', () => setTimeout(() => this.connectPg(), 30000)); + this.db.on('notification', (msg) => this.onNotification(msg)); + await this.db.connect(); + } + + onNotification({ name, length, processId, channel, payload }) + { + } + + async handleRequest(req, resp) + { + const u = url.parse(req.url, true); + const path = u.pathname.replace(/^\/+|\/+$/g, ''); + if (!path) + { + resp.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + }); + let pending = 0; + let st, testr; + let finish = () => + { + st.update(); + const data = st.export(); + testr.unmount(); + testr = null; + st = new StateTree({ doQuery: () => {} }); + st.import(data); + const html = + ''+ + ''+ + '
'+ + ReactDOMServer.renderToString()+ + '
'+ + ''; + resp.write(html); + resp.end(); + }; + st = new StateTree({ + doQuery: (cb) => + { + pending++; + this.handle('data', (r) => + { + cb(r); + pending--; + if (!pending) + { + testr.update(); + finish(); + } + }); + } + }); + testr = TestRenderer.create(); + if (testr && !pending) + { + finish(); + } + } + else if (path == 'main.c.js') + { + const data = await promisify(h => fs.readFile(this.dir + '/main.c.js', h)); + resp.writeHead(200, { + 'Content-Type': 'application/javascript', + }); + resp.write(data); + resp.end(); + } + else + { + this.handle(path, (r) => + { + resp.writeHead(200, { + 'Content-Type': 'application/json', + }); + resp.write(JSON.stringify(r)); + resp.end(); + }); + } + } + + handle(path, cb) + { + let r; + if (path == 'data') + { + // Data + r = { text: 'Hello world!' }; + } + else + r = { error: 'Unknown action' }; + setTimeout(() => + { + cb(r); + }, 100); + } +} diff --git a/StateTree.js b/StateTree.js new file mode 100644 index 0000000..fa717fa --- /dev/null +++ b/StateTree.js @@ -0,0 +1,100 @@ +import React from 'react'; + +const canUseDOM = !!( + (typeof window !== 'undefined' && window.document && window.document.createElement) +); + +export class StateTree +{ + state = {} + subs = {} + context = {} + instance = null + + constructor(context) + { + this.context = context || {}; + } + + get() + { + return this.state; + } + + set(state) + { + this.state = state; + } + + setInstance(instance) + { + this.instance = instance; + } + + update() + { + if (this.instance) + { + this.state = this.instance.state; + } + for (const k in this.subs) + { + this.subs[k].update(); + } + } + + export() + { + const r = { + state: this.state, + subs: {}, + }; + for (const k in this.subs) + { + r.subs[k] = this.subs[k].export(); + } + return r; + } + + import(st) + { + this.state = st.state; + this.subs = {}; + for (const k in st.subs) + { + this.sub(k).import(st.subs[k]); + } + } + + sub(key) + { + if (!this.subs[key]) + { + this.subs[key] = new StateTree(this.context); + } + return this.subs[key]; + } +} + +export class StateTreeComponent extends React.Component +{ + constructor(props) + { + super(props); + if (props.state) + { + this.state = props.state.get(); + this.ctx = props.state.context; + this.props.state.setInstance(this); + } + } + + componentWillUnmount() + { + if (this.props.state) + { + this.props.state.set(this.state); + this.props.state.setInstance(null); + } + } +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..9176a47 --- /dev/null +++ b/main.js @@ -0,0 +1,26 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { App } from './App.js'; +import { StateTree } from './StateTree.js'; +import { GET } from './xhr.js'; + +window.initApp = function(data) +{ + const st = new StateTree({ + doQuery: (cb) => + { + GET('/data', {}, (r, d) => + { + cb(d); + }); + }, + }); + if (data) + { + st.import(data); + } + ReactDOM.hydrate( + , document.getElementById('app') + ); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..7b3a159 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "ssr-test", + "author": { + "name": "Vitaliy Filippov", + "email": "vitalif@yourcmc.ru", + "url": "http://yourcmc.ru/wiki/" + }, + "description": "SSR-Test", + "dependencies": {}, + "devDependencies": { + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-preset-env": "latest", + "babel-preset-react": "latest", + "babel-preset-stage-1": "latest", + "css-loader": "latest", + "css-modules-require-hook": "^4.2.3", + "eslint": "latest", + "postcss": "latest", + "postcss-cssnext": "latest", + "postcss-each": "latest", + "postcss-import": "latest", + "postcss-loader": "latest", + "postcss-mixins": "latest", + "react": "^16.2.0", + "react-dom": "^16.2.0", + "react-test-renderer": "^16.2.0", + "react-toolbox": "^2.0.0-beta.12", + "style-loader": "latest", + "webpack": "^4.6.0", + "webpack-bundle-analyzer": "^2.9.1", + "webpack-cli": "^2.1.2" + }, + "scripts": { + "compile": "webpack --optimize-minimize", + "stats": "NODE_ENV=production webpack --optimize-minimize --profile --json > stats.json; webpack-bundle-analyzer stats.json -h 0.0.0.0", + "watch-dev": "NODE_ENV=development webpack -w", + "watch": "webpack -w --optimize-minimize" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..543110d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,19 @@ +module.exports = { + plugins: { + 'postcss-import': { + root: __dirname, + }, + 'postcss-mixins': {}, + 'postcss-each': {}, + 'postcss-cssnext': { + features: { + customProperties: { + variables: { + 'color-primary': 'var(--palette-light-blue-500)', + 'color-primary-dark': 'var(--palette-light-blue-700)' + } + } + } + } + }, +}; diff --git a/server-main.js b/server-main.js new file mode 100644 index 0000000..c0e4e26 --- /dev/null +++ b/server-main.js @@ -0,0 +1,21 @@ +require("babel-register")({ + ignore: /node_modules/, + presets: [ [ "env", { "targets": { "node": "current" }, "exclude": [ "transform-regenerator" ] } ], "stage-1", "react" ], + retainLines: true +}); + +const hook = require('css-modules-require-hook'); + +hook({ + generateScopedName: '[name]--[local]--[hash:base64:8]', +}); + +const fs = require('fs'); +const SSRServer = require('./Server.js').SSRServer; + +var options; +if (process.argv.length > 2) +{ + options = JSON.parse(fs.readFileSync(process.argv[2], { encoding: 'utf-8' })); +} +new SSRServer().start(options); diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..ded3c77 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,47 @@ +const webpack = require('webpack'); +const path = require('path'); + +module.exports = { + entry: { main: './main.js' }, + context: __dirname, + output: { + path: __dirname, + filename: '[name].c.js' + }, + module: { + rules: [ + { + test: /.jsx?$/, + loader: 'babel-loader', + exclude: /node_modules/, + options: { + presets: [ "env", "stage-1", "react" ], + retainLines: true, + } + }, + { + test: /\.css$/, + use: [ + "style-loader", + { + loader: "css-loader", + options: { + modules: true, // default is false + sourceMap: true, + importLoaders: 1, + localIdentName: "[name]--[local]--[hash:base64:8]" + } + }, + "postcss-loader" + ] + } + ] + }, + plugins: [ + new webpack.DefinePlugin({ + "process.env": { + NODE_ENV: JSON.stringify(process.env.NODE_ENV || "production") + } + }) + ] +}; diff --git a/xhr.js b/xhr.js new file mode 100644 index 0000000..6645945 --- /dev/null +++ b/xhr.js @@ -0,0 +1,117 @@ +export { GET, POST }; + +function GET(url, data, cb) +{ + var options = {}; + if (typeof url == 'object') + { + options = url; + url = options.url; + } + var r = create_request_object(); + url = url + (url.indexOf('?') >= 0 ? '&' : '?') + http_build_query(data); + r.open('GET', url); + set_request_callback(r, cb, options); + r.send(); + return r; +} + +function POST(url, data, cb) +{ + var options = {}; + if (typeof url == 'object') + { + options = url; + url = options.url; + } + var r = create_request_object(); + r.open('POST', url); + set_request_callback(r, cb, options); + if (typeof data != 'string' && (!window.FormData || !(data instanceof FormData))) + { + r.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + data = http_build_query(data); + } + r.send(data); + return r; +} + +function create_request_object() +{ + if (typeof XMLHttpRequest === 'undefined') + { + XMLHttpRequest = function() + { + try { return new ActiveXObject("Msxml2.XMLHTTP"); } + catch(e) {} + try { return new ActiveXObject("Microsoft.XMLHTTP"); } + catch(e) {} + throw new Error("This browser does not support XMLHttpRequest."); + }; + } + return new XMLHttpRequest(); +} + +function set_request_callback(r, cb, options) +{ + if (options.timeout) + r.timeout = options.timeout; + if (options.headers) + for (var k in options.headers) + r.setRequestHeader(k, options.headers[k]); + r.onreadystatechange = function() + { + if (r.readyState == 4) + { + var d; + if (r.getResponseHeader('Content-Type').indexOf('/json') > 0) + { + d = json_decode(r.responseText); + } + cb(r, d); + } + }; +} + +function build_array_query(data, prefix) +{ + var s = '', k; + for (var i in data) + { + k = prefix ? prefix+'['+encodeURIComponent(i)+']' : encodeURIComponent(i); + if (typeof data[i] == 'object' && data[i] !== null) + s += build_array_query(data[i], k); + else + s = s+'&'+k+'='+(data[i] === false || data[i] === null || data[i] === undefined ? '' : encodeURIComponent(data[i])); + } + return s; +} + +function http_build_query(data) +{ + return build_array_query(data).substr(1); +} + +function json_decode(text) +{ + if (!text) + { + return null; + } + try + { + if (window.JSON) + { + return JSON.parse(text); + } + return eval(text); + } + catch(e) + { + if (window.console) + { + console.log(e); + } + } + return null; +}