Заготовка 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