Заготовка Server-Side рендера для React

Попутно можно использовать вместо Redux/Flux/MobX.
Кайф в том, что в такой вид тривиально рефакторятся обычные компоненты со state-ом внутри.
master
Vitaliy Filippov 2018-05-03 17:42:32 +03:00
commit 6e20dc794f
10 changed files with 585 additions and 0 deletions

55
App.js Normal file
View File

@ -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 <div>
{this.state.text}
<Button raised primary label="Добавить плюсик" onClick={() => this.setState({ text: this.state.text+' +' })} />
<SubComponent
state={this.props.state.sub('sub')}
/>
</div>
}
}
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 <div>
SUB: {this.state.text}
</div>
}
}

18
README.md Normal file
View File

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

142
Server.js Normal file
View File

@ -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 =
'<!DOCTYPE html><html><head>'+
'<meta http-equiv="content-type" content="text/html; charset=utf-8" />'+
'</head><body><div id="app">'+
ReactDOMServer.renderToString(<App state={st} />)+
'</div></body><script src="main.c.js"></script>'+
'<script>initApp('+JSON.stringify(data)+')</script></html>';
resp.write(html);
resp.end();
};
st = new StateTree({
doQuery: (cb) =>
{
pending++;
this.handle('data', (r) =>
{
cb(r);
pending--;
if (!pending)
{
testr.update(<App state={st} />);
finish();
}
});
}
});
testr = TestRenderer.create(<App state={st} />);
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);
}
}

100
StateTree.js Normal file
View File

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

26
main.js Normal file
View File

@ -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(
<App state={st} />, document.getElementById('app')
);
};

40
package.json Normal file
View File

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

19
postcss.config.js Normal file
View File

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

21
server-main.js Normal file
View File

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

47
webpack.config.js Normal file
View File

@ -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")
}
})
]
};

117
xhr.js Normal file
View File

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