closertb.github.io
closertb.github.io copied to clipboard
初探SSR,React + Koa + Dva-Core
起
过去一年半一直在用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这一步交给自己。
细说方案
同构渲染一套代码两端运行:即可以像SPA项目一样,打包一套静态资源代码,在浏览器独立运行;又可以像传统jsp,php页面一样,由服务端页面直出,但又高于这些技术,因为在首屏时依赖直出,而在后面的操作又有SPA一样的操作体验,这就是同构的优势所在。但这也要求了更高的架构思想,对前端提出了更高的要求,主要体现在下面几个方面:
- 怎么同时兼容浏览器端和服务端两种模式的路由;
- 数据流管理怎么通用;
- 服务端的开发及负债均衡;
- 服务的部署
路由的兼容
如果对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
关联文章收集
大佬我用的dva-core 为啥store老是公用,我设成回调函数了,还是不行
大佬我用的dva-core 为啥store老是公用,我设成回调函数了,还是不行
你store 是那种概念的,model 中的初始state? 比如:
export default ({
namespace: 'index',
state: {
total: 0,
num: Math.floor(Math.random() * 1000),
},
// ...
}
上面这种写法肯定是公用的,因为其实质就是一个静态导出,这是语法决定的。
如果不是,show your code
就是上面的那种用法,跟直接的客户端的用法是一样的,我看打包输出的是全局的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原有的对象形式,换成换成函数的形式,然后在导出没问题了。