Заготовка Server-Side рендера для React
Попутно можно использовать вместо Redux/Flux/MobX. Кайф в том, что в такой вид тривиально рефакторятся обычные компоненты со state-ом внутри.master
commit
6e20dc794f
|
@ -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>
|
||||
}
|
||||
}
|
|
@ -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/
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
|
@ -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);
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
]
|
||||
};
|
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue