commit 6e20dc794fb766bb9011afab74ef28f532cc9b9b Author: Vitaliy Filippov Date: Thu May 3 17:42:32 2018 +0300 Заготовка Server-Side рендера для React Попутно можно использовать вместо Redux/Flux/MobX. Кайф в том, что в такой вид тривиально рефакторятся обычные компоненты со state-ом внутри. 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; +}