closertb.github.io icon indicating copy to clipboard operation
closertb.github.io copied to clipboard

初探SSR,React + Koa + Dva-Core

Open closertb opened this issue 5 years ago • 3 comments

过去一年半一直在用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工程架构

closertb avatar Oct 13 '19 16:10 closertb

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

hzfvictory avatar Jul 21 '20 08:07 hzfvictory

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

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

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

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

如果不是,show your code

closertb avatar Jul 21 '20 15:07 closertb

就是上面的那种用法,跟直接的客户端的用法是一样的,我看打包输出的是全局的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原有的对象形式,换成换成函数的形式,然后在导出没问题了。

hzfvictory avatar Jul 22 '20 09:07 hzfvictory