Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

初探SSR,React + Koa + Dva-Core #35

Open
closertb opened this issue Oct 13, 2019 · 3 comments
Open

初探SSR,React + Koa + Dva-Core #35

closertb opened this issue Oct 13, 2019 · 3 comments
Labels
JS 原生JS相关 react about react

Comments

@closertb
Copy link
Owner

closertb commented Oct 13, 2019

过去一年半一直在用React + Dva + Antd写中后台项目,从最初的6小时一个页面,到现在的两小时一套页面,其中的秘诀就是不断总结与熟悉,写一些适合业务的轮子,比如:antd-doddle。今年随着业务稳定,有机会去尝试一些自己感兴趣的方向,比如前端工程化、SSR、 小程序;最近由于苹果对App上架流程的调整,部门需要写一个官网。虽然说一个上午就写出来了,但从官网的角度,以及对成品不断追求的态度,现在这个官网太Low了, 无Seo,无移动端适配,无首屏渲染。所以最近开始接触SSR,试图用一个更加专业的方案去重新打造这个官网。

方案筛选

其实没啥筛选的,页面框架React是铁打的。而React的服务端渲染,市面上一般就有完全自己搭和选择NextJs

  • NextJs不用多说,只要开始写好了配置,就可以像写中后台一样,安心的写页面就行了,无需过多关心服务端路由,打包这些(但不得不说的是,写配置也真的是一项浩大的工程, 10个问题,9个是关于配置的)。
  • 但秉承学习的态度,而不是交任务,从一开始就选择了自己去搭,自己曾经看到有人用react + Dva + Express搭SSR的文章,所以基于对Dva的熟悉与钟爱,就直接选择了这个方案,只是将Express换成了Koa。但问题真的是一堆一堆的出现,当在Issues中看到这张图,我是奔溃的,究其原因是随着React 16用Fiber进行了重写,同构渲染(hydrate)与客户端(render)进行了分离。而Dva2.0并没有对这一个特性进行支持。但@sorrycc 大佬并没有说不支持,而只是说没有Demo。说明还是有路的,借着自己对Dva的了解,就很快的想到了用Dva-core代替Dva,将Render这一步交给自己。
    image

细说方案

同构渲染一套代码两端运行:即可以像SPA项目一样,打包一套静态资源代码,在浏览器独立运行;又可以像传统jsp,php页面一样,由服务端页面直出,但又高于这些技术,因为在首屏时依赖直出,而在后面的操作又有SPA一样的操作体验,这就是同构的优势所在。但这也要求了更高的架构思想,对前端提出了更高的要求,主要体现在下面几个方面:

  • 怎么同时兼容浏览器端和服务端两种模式的路由;
  • 数据流管理怎么通用;
  • 服务端的开发及负债均衡;
  • 服务的部署

image

路由的兼容

如果对SPA和SSR了解的话,就知道:SPA一般我们用Hash路由HashRoutr(#/home),而SSR在浏览器端则采用传统路由,即浏览器路由BrowserRouter(/home),但只有去尝试SSR后我才知道,还有一种路由被称之为静态路由StaticRouter(/home),看起来和BrowserRouter相似,之所以称之为静态的,就是它没有前进,后退,跳转这些路由操作。具体可参考React-Router的相关介绍。这三种路由分别对应三种入口:

  • 浏览器端HashRoutr
import { HashRouter as Router } from 'react-router-dom';
import { createHashHistory as createHistory } from 'history';
import Layout from './Layout';
import createApp from './model/createApp';
import './style/index.less';

const history = createHistory();
const app = createApp({ history });
app.start();

const App = () => (
  <Provider store={app._store}>
    <Router history={history}>
      <Layout path="/" />
    </Router>
  </Provider>
);

render(<App />, document.getElementById('app'));
  • 服务端StaticRouter
import { StaticRouter as Router } from 'react-router-dom';
import createHistory from 'history/createMemoryHistory';
import createApp from './model/createApp';
import Layout from './Layout';

export default function CreateDom({ location, context }) {
  const history = createHistory(location);
  const app = createApp({ history });
  app.start();
  return {
    app,
    render: () => (
      <Provider store={app._store}>
        <Router location={location} context={context} history={history}>
          <Layout location={location} context={context} history={history} />
        </Router>
      </Provider>)
  };
}
  • 服务器渲染浏览器同构端BrowserRouter, 后面会解释为什么这里没有用BrowserRouter,而是采用Router作为代替
import { Router } from 'react-router-dom';
import { createBrowserHistory as createHistory } from 'history';
import Layout from './Layout';
import createApp from './model/createApp';
import './style/index.less';

// ssr渲染,浏览器端渲染入口
const history = createHistory();

const app = createApp({
  history,
  initialState: window.states && JSON.parse(window.states),
});
app.start();
delete window.states;
const App = () => (
  <Provider store={app._store}>
    <Router history={history}>
      <Layout isWindow />
    </Router>
  </Provider>
);

ReactDOM.hydrate(<App />, document.getElementById('app'));

从上面三种代码也可以看出,在reactDom的渲染方式上我们也分别对应三种:

  • render: spa常用;
  • renderToString:服务端渲染专用,用于将React对象渲染成Dom字符串;
  • hydrate:服务端渲染专用,用于延用已存在的dom节点

数据流的管理

在上面的三段代码中,都看到了两个共同的模块,一个Layout,一个createApp,分别对应页面与数据流。自己搭框架,其实难点就在怎么公用数据流管理,让差异最小化。由于自己中后台写的太多,对Dva这一套情有独钟,所以怎么绕,最后都把眼神聚焦到了这里。所以最后数据流的管理,还是选择了Dva-core。createApp源码:

import { create } from 'dva-core';
import hook from '@doddle/dva';
import * as models from './model';

export default function createApp(opts) {
 const app = create(opts);
 app._history = opts.history;
 hook({ app }); // 扩展对象, 增加listen, update, loading等插件
 Object.keys(models).forEach(key => app.model(models[key]));
 return app;
}

代码非常简短,没有做什么差异化的兼容处理,只做了dva数据对象的初始化;扩展了这个数据对象;加了一个history属性,目的是listen插件需要;model对象的加载。

SPA渲染与SSR渲染数据流处理的差异就在首屏。通常我们在做SPA时,将获取页面初始状态的操作都放在页面监听中(dva model的subsciption),而不是最初的componentDidMount这个钩子里。但在服务端做首屏渲染时,这种方案就不可取,没有history变化这一说,所以需要采用其他方案。最早写React的人都知道,曾今还有个方法叫getInitialState,但后面这个方法被弃用。在NextJs中也存在一个这样一个方法,其目的就是做服务端渲染的首屏数据获取。我在自己的设计中也沿用了这个思想,具体是:

  • 我将页面组件,需要做首屏数据获取的,组件增加getInitialState这个方法,并在方法中返回需要做的操作,like:{ type: 'index/add', payload }
  • 在服务器获取到路由后,匹配到对应的页面组件。判断是否有getInitialState属性,即需要在首屏做数据获取的,如果有,获取数据;
  • 获取到数据后,数据对象被更新,渲染对应页面html做出响应,并保存数据对象,将其转化成js文件作为html的引用;
  • 待首屏渲染后,同构js获取数据对象js保存的数据对象作为初始化浏览器端的数据对象,以保证浏览器端渲染获得和服务端相同的dom结果;

这样做还有一个好处就是,BrowerRouter由于是初次进这个页面,所以listen监听不会生效,所以不会存在重复获取初始状态这个问题。以上就是数据流方案的整体思路,也是整个SSR中比较重点的。

服务端代码实现

SSR渲染和纯前端渲染最大的区别就是,你需要写一个服务器。而Node给我们提供了这样的能力,让我们可以用js语言写后端服务。之所以从众多的后端框架中选择了Koa,是因为前段时间刚好对Koa有一个比较全面的了解。Koa经典的洋葱模型,将服务实现插件化,非常易于扩展,Async Await的插件语法,也非常符合时代的潮流。后端服务主要在功能上要实现:

  • 静态资源服务:koa-static完成
  • 路由的转发与拦截:koa-router完成
  • html的动态生成: renderToString服务端渲染

静态资源服务和路由的转发拦截比较简单,基本几行代码就搞定。

const path = require('path');
const Koa = require('koa');
const staticSource = require('koa-static');
const router = require('./router');

const app = new Koa();
const staticPath = '../public';

app.use(staticSource(path.join(__dirname, staticPath)));

app.use(router.routes())
  .use(router.allowedMethods());

// router.js
const Router = require('koa-router');
const stateMiddleaWare = require('./stateMiddleaWare');
const ssrMiddleware = require('./ssrMiddleware');

const router = new Router();
router.get('/states/:key.js', stateMiddleaWare); // 提供同构的初始状态对象
router.get('/:url', ssrMiddleware); // 提供页面的服务端渲染

重点还是在服务端渲染这一块,在我的项目里,这部分是由ssrMiddleware中间件来完成的,源码也比较简单,如果认真读且理解了前面讲的,那这一部分的源码就比较好理解了。大体上讲,做了三件事:

  • 非目标路由重定向,
  • 初始状态获取;
  • 初始状态保存,提供给stateMiddleaWare,生成初始状态js
  • 动态html生成
async (ctx, next) => {
  const { url } = ctx;
  const renderProps = { location: url };

  // redirect to home when route is not a validRoutes
  if (url === '/' || !validRoutes.includes(url)) {
    ctx.redirect('/home');
    return;
  }
  const title = routesTitle[url];

  const server = CreateDom(renderProps);
  const store = server.app._store;
  const dataRequirements = routes
    .filter(route => matchPath(url, route)) // filter matching paths
    .map(route => route.component) // map to components
    .filter(comp => comp.getInitialState) // check if components have data requirement
    .map(comp => store.dispatch(comp.getInitialState({ count: 5 }))); // dispatch data requirement
  // get initialState
  await Promise.all(dataRequirements);

  // cache states to genrate dynamic js
  const initialState = store.getState();
  const stateKey = stateServe.set(JSON.stringify(initialState));

  // generate html source
  const html = renderToString(server.render());
  ctx.body = renderFullPage(html, stateKey, title);
  await next();
}

好了以上,就是主要代码的实现,关于stateMiddleaWare,实现就简单了,感兴趣的,可以看源码了解。

一些没提到但又很重要的点

  • window的处理,由于Layout内的代码,既要在服务端(Node)执行,又要在Browser执行,所以要注意window使用时,执行环境的检测;
  • fetch的使用,由于fetch仅仅存在于浏览器端,所以服务端获取初始状态时,就需要替代品,isomorphic-unfetch是个很好的替代;
  • 前面提到SSR的浏览器端渲染,将BrowserRouter换成了低阶的Router,是因为,由于我进页面会用到history的监听,以获取这个页面的初始状态。但怎么试都没成功,最后Debug发现,dva的history是我生成的那个,但BrowserRouter那个history并不是我传进去的哪一个,花了5分钟在源码中找到了答案:
  BrowserRouter.prototype.componentDidMount = function () {
    warning(!this.props.history, "<BrowserRouter> ignores the history prop. To use a custom history, " + "use `import { Router }` instead of `import { BrowserRouter as Router }`.") ;
 };
  • 还有些还在探索,比如服务的部署,还有负债均衡,需要一个好的业务场景来考验,后面有点眉目了再单独写

写在最后

由于公司项目,不便提供源码,如果你感兴趣,可以去fork我的示例项目SSrTemplate, 分支ssr, 也可通过下面两种方式下载:

  • clone:
git clone -b ssr https://github.com/closertb/template.git
  • 脚手架, 你的项目目录下执行
 npx create-doddle ssr youProjectName

关联文章收集

从零到一搭建React SSR工程架构

@hzfvictory
Copy link

大佬我用的dva-core 为啥store老是公用,我设成回调函数了,还是不行

@closertb
Copy link
Owner Author

大佬我用的dva-core 为啥store老是公用,我设成回调函数了,还是不行

你store 是那种概念的,model 中的初始state? 比如:

export default ({
  namespace: 'index',
  state: {
    total: 0,
    num: Math.floor(Math.random() * 1000),
  },
  // ...
}

上面这种写法肯定是公用的,因为其实质就是一个静态导出,这是语法决定的。

如果不是,show your code

@hzfvictory
Copy link

hzfvictory commented Jul 22, 2020

就是上面的那种用法,跟直接的客户端的用法是一样的,我看打包输出的是全局的store,所以肯定是所有用户公用了,但是我看你源码中也是这么写的,在你里面没有发现这个问题。

export default {
  namespace: 'menuTree',
  state: {
    routes: []
  },
  effects: {
    * reset(payload, {call, put, select, update}) {
      const {routes} = yield select(state => state.menuTree);
      routes.push(111111)
      yield put({
        type: 'save',
        payload: {
          routes: [...routes]
        },
      });
    },
  },
  reducers: {
    save(state, {payload}) {
      return {...state, ...payload};
    },
  },
};

经你这么点播,我把model原有的对象形式,换成换成函数的形式,然后在导出没问题了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
JS 原生JS相关 react about react
Projects
None yet
Development

No branches or pull requests

2 participants