ソースを参照

ssr version 0.0.1

nodejh 6 年 前
コミット
bab9da39f4
共有13 個のファイルを変更した433 個の追加0 個の削除を含む
  1. 4
    0
      .babelrc
  2. 4
    0
      .eslintrc
  3. 3
    0
      .gitignore
  4. 4
    0
      README.md
  5. 52
    0
      package.json
  6. 32
    0
      src/block.js
  7. 16
    0
      src/dvaServerSync.js
  8. 9
    0
      src/index.js
  9. 75
    0
      src/preSSRService.js
  10. 148
    0
      src/render.jsx
  11. 36
    0
      src/runtimeSSRMiddle.js
  12. 6
    0
      src/ssrModel.js
  13. 44
    0
      src/utils.js

+ 4
- 0
.babelrc ファイルの表示

@@ -0,0 +1,4 @@
1
+{
2
+  "presets": ["es2017", "react"],
3
+  "plugins": ["babel-plugin-add-module-exports", "babel-plugin-transform-es2015-modules-commonjs"]
4
+}

+ 4
- 0
.eslintrc ファイルの表示

@@ -0,0 +1,4 @@
1
+{
2
+    "presets": ["es2017"],
3
+    "plugins": ["babel-plugin-add-module-exports", "babel-plugin-transform-es2015-modules-commonjs"]   
4
+}

+ 3
- 0
.gitignore ファイルの表示

@@ -0,0 +1,3 @@
1
+node_modules
2
+.DS_Store
3
+npm-debug.log

+ 4
- 0
README.md ファイルの表示

@@ -0,0 +1,4 @@
1
+## Server Side Render
2
+
3
+TODO...
4
+

+ 52
- 0
package.json ファイルの表示

@@ -0,0 +1,52 @@
1
+{
2
+  "name": "ssr",
3
+  "description": "dva server side render",
4
+  "main": "lib/index.js",
5
+  "version": "0.0.1",
6
+  "scripts": {
7
+    "compile": "babel ./src  --out-dir ./lib",
8
+    "prepublish": "npm run compile",
9
+    "test": "echo \"Error: no test specified\" && exit 1"
10
+  },
11
+  "keywords": [
12
+    "ssr"
13
+  ],
14
+  "author": "node",
15
+  "repository": {
16
+    "type": "git",
17
+    "url": "git+https://git.links123.net/node/ssr.git"
18
+  },
19
+  "license": "MIT",
20
+  "peerDependencies": {
21
+    "react-router": ">= 4.0.0",
22
+    "dva": ">= 2.0.0"
23
+  },
24
+  "dependencies": {
25
+    "history": "^4.7.2",
26
+    "lodash.merge": "^4.6.0",
27
+    "mobile-detect": "^1.3.6",
28
+    "react": "^15.4.0",
29
+    "react-dom": "^15.4.0",
30
+    "react-router": "^4.2.0",
31
+    "uid": "^0.0.2"
32
+  },
33
+  "devDependencies": {
34
+    "@types/history": "^4.6.1",
35
+    "@types/react-dom": "^16.0.1",
36
+    "@types/react-router": "^4.0.15",
37
+    "babel-cli": "^6.24.1",
38
+    "babel-core": "^6.25.0",
39
+    "babel-eslint": "^8.0.1",
40
+    "babel-plugin-add-module-exports": "^0.2.1",
41
+    "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1",
42
+    "babel-preset-es2017": "latest",
43
+    "babel-preset-react": "^6.24.1",
44
+    "babel-types": "^6.25.0",
45
+    "dva": "^2.0.0",
46
+    "eslint": "^4.9.0",
47
+    "eslint-config-airbnb": "^16.1.0",
48
+    "eslint-plugin-import": "^2.7.0",
49
+    "eslint-plugin-jsx-a11y": "^6.0.2",
50
+    "eslint-plugin-react": "^7.4.0"
51
+  }
52
+}

+ 32
- 0
src/block.js ファイルの表示

@@ -0,0 +1,32 @@
1
+const container = new Map();
2
+const callbacks = new Map();
3
+
4
+export default {
5
+  lock: id => {
6
+    let count = container.get(id) || 0;
7
+    container.set(id, ++count);
8
+  },
9
+  release: function (id) {
10
+    let count = container.get(id) || 0;
11
+    if (count) {
12
+      container.set(id, --count);
13
+    }
14
+    this.check();
15
+  },
16
+  check: () => {
17
+    for (let [id, callback] of callbacks) {
18
+      const count = container.get(id);
19
+      if (count === 0) {
20
+        container.delete(id);
21
+        callback();
22
+      }
23
+    }
24
+  },
25
+  wait: function(id, callback) {
26
+    callbacks.set(id, function() {
27
+      callbacks.delete(id);
28
+      callback();
29
+    });
30
+    this.check();
31
+  }
32
+};

+ 16
- 0
src/dvaServerSync.js ファイルの表示

@@ -0,0 +1,16 @@
1
+export default function sync(key, filter, block) {
2
+  return {
3
+    onEffect: function (effect, { put }, model, actionType) {
4
+      const temp = [];
5
+      return function* (...args) {
6
+        if (filter(args[0])) {
7
+          block.lock(key);
8
+        }
9
+        yield effect(...args);
10
+        if (filter(args[0])) {
11
+          block.release(key);
12
+        }
13
+      }
14
+    }
15
+  };
16
+}

+ 9
- 0
src/index.js ファイルの表示

@@ -0,0 +1,9 @@
1
+import runtimeSSRMiddle from './runtimeSSRMiddle';
2
+import preSSRService from './preSSRService';
3
+import render from './render';
4
+
5
+export default {
6
+  runtimeSSRMiddle,
7
+  preSSRService,
8
+  render,
9
+};

+ 75
- 0
src/preSSRService.js ファイルの表示

@@ -0,0 +1,75 @@
1
+import Render from './render';
2
+import { searchRoutes } from './utils';
3
+
4
+function getPathsFromRoutes(routes) {
5
+  const paths = [];
6
+  searchRoutes(routes, (route) => {
7
+    paths.push(route.props.path);
8
+  });
9
+  return paths;
10
+}
11
+
12
+export class RenderService {
13
+  constructor({ url, interval, renderOptions }) {
14
+    this.url = url;
15
+    this.timeout = interval;
16
+    this.renderOptions = renderOptions;
17
+  }
18
+
19
+  async render() {
20
+    await Render(this.renderOptions);
21
+    this.timer = setTimeout(this.run.bind(this), this.timeout);
22
+  }
23
+
24
+  run() {
25
+    this.render();
26
+  }
27
+
28
+  stop() {
29
+    if (this.timer) {
30
+      clearTimeout(this.timer);
31
+    }
32
+  }
33
+}
34
+
35
+export default function preSSRService({
36
+  renderFullPage, createApp, initialState, interval = 10000, onRenderSuccess, routes, timeout=6000, verbose=true
37
+}) {
38
+  const paths = getPathsFromRoutes(routes);
39
+  paths.forEach((path) => {
40
+    new RenderService({
41
+      url: path,
42
+      interval,
43
+      renderOptions: {
44
+        routes,
45
+        url: path,
46
+        renderFullPage,
47
+        createApp,
48
+        initialState,
49
+        onRenderSuccess,
50
+        timeout,
51
+        env: {
52
+          platform: 'pc',
53
+        },
54
+        verbose
55
+      },
56
+    }).run();
57
+    new RenderService({
58
+      url: path,
59
+      interval,
60
+      renderOptions: {
61
+        routes,
62
+        url: path,
63
+        renderFullPage,
64
+        createApp,
65
+        initialState,
66
+        onRenderSuccess,
67
+        timeout,
68
+        env: {
69
+          platform: 'mobile',
70
+        },
71
+        verbose
72
+      },
73
+    }).run();
74
+  });
75
+}

+ 148
- 0
src/render.jsx ファイルの表示

@@ -0,0 +1,148 @@
1
+import React from 'react';
2
+import uid from 'uid';
3
+import { StaticRouter } from 'react-router';
4
+import { createMemoryHistory } from 'history';
5
+import merge from 'lodash.merge';
6
+import { renderToStaticMarkup } from 'react-dom/server';
7
+import ssrModel from './ssrModel';
8
+import { findRouteByUrl } from './utils';
9
+import dvaServerSync from './dvaServerSync';
10
+import block from './block';
11
+
12
+function existSSRModel(app) {
13
+  try {
14
+    let model = null;
15
+    app._models.forEach((m) => {
16
+      if (m.namespace === 'ssr') {
17
+        model = m;
18
+      }
19
+    });
20
+    return !!model;
21
+  } catch (e) {
22
+    return false;
23
+  }
24
+}
25
+
26
+function getAsyncActions(app) {
27
+  try {
28
+    let actions = [];
29
+    app._models.forEach((model) => {
30
+      if (model.effects) {
31
+        actions = actions.concat(Object.keys(model.effects));
32
+      }
33
+    });
34
+    return actions;
35
+  } catch (e) {
36
+    return [];
37
+  }
38
+}
39
+
40
+function findSync(branch) {
41
+  let sync = false;
42
+  branch.forEach((b) => {
43
+    sync = !!b.props.sync;
44
+  });
45
+  return sync;
46
+}
47
+
48
+async function renderFragment(createApp, routes, url, initialState, timeout, verbose) {
49
+  const history = createMemoryHistory();
50
+  history.push(url);
51
+  const context = {};
52
+  const app = createApp({
53
+    history,
54
+    initialState,
55
+  });
56
+  if (!existSSRModel(app)) {
57
+    app.model(ssrModel);
58
+  }
59
+  app.router(options => (<StaticRouter location={url} context={options.context}>
60
+    <div>
61
+      {routes}
62
+    </div>
63
+  </StaticRouter>));
64
+  const asyncActions = getAsyncActions(app);
65
+  const branch = findRouteByUrl(routes, url);
66
+  if (branch.length === 0) {
67
+    return {};
68
+  }
69
+  const sync = findSync(branch);
70
+  if (!sync && asyncActions && asyncActions.length > 0) {
71
+    const id = uid(10);
72
+    app.use(dvaServerSync(id, (action) => {
73
+      if (asyncActions.indexOf(action.type) > -1) {
74
+        return true;
75
+      }
76
+      return false;
77
+    }, block));
78
+    const appDOM = app.start()({
79
+      context,
80
+    });
81
+    if (verbose) {
82
+      console.time(`${url}: async wait time`);
83
+    }
84
+    const result = await new Promise((resolve, reject) => {
85
+      const timer = setTimeout(() => {
86
+        reject(new Error('render timeout'));
87
+      }, timeout)
88
+      block.wait(id, () => {
89
+        if (verbose) {
90
+          console.timeEnd(`${url}: async wait time`);
91
+        }
92
+        clearTimeout(timer);
93
+        const curState = appDOM.props.store.getState();
94
+        const html = renderToStaticMarkup(appDOM);
95
+        resolve({ html, state: curState, context });
96
+      });
97
+    });
98
+    return result;
99
+  }
100
+  const appDOM = app.start()({
101
+    context,
102
+  });
103
+  const html = renderToStaticMarkup(appDOM);
104
+  const curState = appDOM.props.store.getState();
105
+  return { html, state: curState, context };
106
+}
107
+
108
+export default async function render({
109
+  url, env, routes, renderFullPage, createApp, initialState, onRenderSuccess, timeout = 6000, verbose = true
110
+}) {
111
+  try {
112
+    if (verbose) {
113
+      console.log(`[${url}]`)
114
+      console.time(`${url}: render time`);
115
+    }
116
+    const state = merge({}, initialState || {}, {
117
+      ssr: {
118
+        env,
119
+      }
120
+    });
121
+    const fragment = await renderFragment(createApp, routes, url, state, timeout, verbose);
122
+    if (verbose) {
123
+      console.timeEnd(`${url}: render time`);
124
+    }
125
+    const context = fragment.context;
126
+    if (!context) {
127
+      return { code: 404, url, env };
128
+    } else if (context.url) {
129
+      return {
130
+        code: 302, url, env, redirect: context.url,
131
+      };
132
+    }
133
+    const html = await renderFullPage(fragment);
134
+    if (onRenderSuccess) {
135
+      await onRenderSuccess({
136
+        html, url, env, state: fragment.state,
137
+      });
138
+    }
139
+    return {
140
+      code: 200, url, env, html,
141
+    };
142
+  } catch (e) {
143
+    console.error(e);
144
+    return {
145
+      code: 500, url, env, error: e,
146
+    };
147
+  }
148
+}

+ 36
- 0
src/runtimeSSRMiddle.js ファイルの表示

@@ -0,0 +1,36 @@
1
+import MobileDetect from 'mobile-detect';
2
+import render from './render';
3
+
4
+export default function runtimeSSRMiddle({
5
+  routes, renderFullPage, createApp, initialState, onRenderSuccess, timeout=6000, verbose=true
6
+}) {
7
+  return async (req, res, next) => {
8
+    const isMobile = !!new MobileDetect(req.headers['user-agent']).mobile();
9
+    const result = await render({
10
+      url: req.url,
11
+      env: { platform: (isMobile ? 'mobile' : 'pc') },
12
+      routes,
13
+      renderFullPage,
14
+      createApp,
15
+      initialState,
16
+      onRenderSuccess,
17
+      timeout,
18
+      verbose
19
+    });
20
+    switch (result.code) {
21
+      case 200:
22
+        return res.end(result.html);
23
+      case 302:
24
+        return res.redirect(302, result.redirect);
25
+      case 404:
26
+        next();
27
+        break;
28
+      case 500:
29
+        next(result.error);
30
+        break;
31
+      default:
32
+        next();
33
+        break;
34
+    }
35
+  };
36
+}

+ 6
- 0
src/ssrModel.js ファイルの表示

@@ -0,0 +1,6 @@
1
+export default {
2
+  namespace: 'ssr',
3
+  state: {
4
+    env: null,
5
+  },
6
+};

+ 44
- 0
src/utils.js ファイルの表示

@@ -0,0 +1,44 @@
1
+import { matchPath } from 'react-router';
2
+import React from 'react';
3
+
4
+function isArray(element) {
5
+  return Object.prototype.toString.call(element) === '[object Array]';
6
+}
7
+
8
+export function searchRoutes(r, callback) {
9
+  function searchPaths(routes) {
10
+    if (isArray(routes)) {
11
+      routes.forEach((route) => {
12
+        searchPaths(route);
13
+      });
14
+    }
15
+    if (typeof routes === 'object' && routes.props && routes.props.children) {
16
+      React.Children.forEach(routes.props.children, (route) => {
17
+        searchPaths(route);
18
+      });
19
+    }
20
+    if (typeof routes === 'object' && routes.props && routes.props.path) {
21
+      callback(routes);
22
+    }
23
+  }
24
+  searchPaths(r);
25
+}
26
+
27
+
28
+export function findRouteByUrl(routes, url) {
29
+  const rtn = [];
30
+  const queryIndex = url.indexOf('?');
31
+  if (queryIndex > -1) {
32
+    url = url.slice(0, queryIndex);
33
+  }
34
+  if (url.length > 1 && url[url.length - 1] === '/') {
35
+    url = url.slice(0, -1);
36
+  }
37
+  searchRoutes(routes, (route) => {
38
+    const match = matchPath(url, route.props);
39
+    if (match) {
40
+      rtn.push(route);
41
+    }
42
+  });
43
+  return rtn;
44
+}