众所周知,目前的 WEB 应用,用户体验要求愈来愈高,WEB 交互变得愈来愈丰富!前端能够作的事愈来愈多,去年 Node 引领了先后端分层的浪潮,而 React 的出现让分层思想能够更多完全的执行,尤为是 React 同构 (Universal or Isomorphic) 这个黑科技究竟是怎么实现的,咱们来一探究竟。css
若是熟悉 React 开发,那么必定对 ReactDOM.render
方法不陌生,这是 React 渲染到 DOM 中的方法。html
现有的任何开发模式都离不开 DOM 树,如图:前端
服务端渲染就要稍做改动,如图:react
比较两张图能够看出,服务端渲染须要把 React 的初次渲染放到服务端,让 React 帮咱们把业务 component 翻译成 string 类型的 DOM 树,再经过后端语言的 IO 流输出至浏览器。webpack
咱们来看 React 官方给咱们提供的服务端渲染的API:git
React.renderToString
是把 React 元素转成一个 HTML 字符串,由于服务端渲染已经标识了 reactid,因此在浏览器端再次渲染,React 只是作事件绑定,而不会将全部的 DOM 树从新渲染,这样能带来高性能的页面首次加载!同构黑魔法主要从这个 API 而来。github
React.renderToStaticMarkup
,这个 API 至关于一个简化版的 renderToString,若是你的应用基本上是静态文本,建议用这个方法,少了一大批的 reactid,DOM 树天然精简了,在 IO 流传输上节省一部分流量。web
配合 renderToString
和 renderToStaticMarkup
使用,createElement
返回的 ReactElement 做为参数传递给前面两个方法。ajax
有了解决方案,咱们就能够动手在 Node 来作一些事了。后面会利用 KOA 这个 Node 框架来作实践。数据库
咱们新建应用,目录结构以下,
react-server-koa-simple ├── app │ ├── assets │ │ ├── build │ │ ├── src │ │ │ ├── img │ │ │ ├── js │ │ │ └── css │ │ ├── package.json │ │ └── webpack.config.js │ ├── middleware │ │ └── static.js(前端静态资源托管中间件) │ ├── plugin │ │ └── reactview(reactview 插件) │ └── views │ ├── layout │ │ └── Default.js │ ├── Device.js │ └── Home.js ├── .babelrc ├── .gitgnore ├── app.js ├── package.json └── README.md
首先,咱们须要实现一个 KOA 插件,用来实现 React 做为服务端模板的渲染工做,方法是将 render
方法插入到 app 上下文中,目的是在 controller 层中调用,this.render(viewFileName, props, children)
并经过 this.body
输出文档流至浏览器端。
/* * koa-react-view.js * 提供 react server render 功能 * { * options : { * viewpath: viewpath, // the root directory of view files * doctype: '<!DOCTYPE html>', * extname: '.js', // view层直接渲染文件名后缀 * writeResp: true, // 是否须要在view层直接输出 * } * } */ module.exports = function(app) { const opts = app.config.reactview || {}; assert(opts && opts.viewpath && util.isString(opts.viewpath), '[reactview] viewpath is required, please check config!'); const options = Object.assign({}, defaultOpts, opts); app.context.render = function(filename, _locals, children) { let filepath = path.join(options.viewpath, filename); let render = opts.internals ? ReactDOMServer.renderToString : ReactDOMServer.renderToStaticMarkup; // merge koa state let props = Object.assign({}, this.state, _locals); let markup = options.doctype || '<!DOCTYPE html>'; try { let component = require(filepath); // Transpiled ES6 may export components as { default: Component } component = component.default || component; markup += render(React.createElement(component, props, children)); } catch (err) { err.code = 'REACT'; throw err; } if (options.writeResp) { this.type = 'html'; this.body = markup; } return markup; }; };
而后,咱们来写用 React 实现的服务端的 Components,
/* * react-server-koa-simple - app/views/Home.js * home模板 */ render() { let { microdata, mydata } = this.props; let homeJs = `${microdata.styleDomain}/build/${microdata.styleVersion}/js/home.js`; let scriptUrls = [homeJs]; return ( <Default microdata={microdata} scriptUrls={scriptUrls} title={"demo"}> <div id="demoApp" data-microdata={JSON.stringify(microdata)} data-mydata={JSON.stringify(mydata)}> <Content mydata={mydata} microdata={microdata} /> </div> </Default> ); }
这里作了几件事,初始化 DOM 树,用 data 属性做服务端数据埋点,渲染先后端公共 Content 模块,引用前端模块
而客户端,咱们就能够很方便地拿到了服务端的数据,能够直接拿来使用,
import ReactDOM from 'react-dom'; import Content from './components/Content.js'; const microdata = JSON.parse(appEle.getAttribute('data-microdata')); const mydata = JSON.parse(appEle.getAttribute('data-mydata')); ReactDOM.render( <Content mydata={mydata} microdata={microdata} />, document.getElementById('demoApp') );
而后,到了启动一个简单的 koa 应用的时候,完善入口 app.js 来验证咱们的想法,
const koa = require('koa'); const koaRouter = require('koa-router'); const path = require('path'); const reactview = require('./app/plugin/reactview/app.js'); const Static = require('./app/middleware/static.js'); const App = ()=> { let app = koa(); let router = koaRouter(); // 初始化 /home 路由 dispatch 的 generator router.get('/home', function*() { // 执行view插件 this.body = this.render('Home', { microdata: { domain: "//localhost:3000" }, mydata: { nick: 'server render body' } }); }); app.use(router.routes()).use(router.allowedMethods()); // 注入 reactview const viewpath = path.join(__dirname, 'app/views'); app.config = { reactview: { viewpath: viewpath, // the root directory of view files doctype: '<!DOCTYPE html>', extname: '.js', // view层直接渲染文件名后缀 beautify: true, // 是否须要对dom结构进行格式化 writeResp: false, // 是否须要在view层直接输出 } } reactview(app); return app; }; const createApp = ()=> { const app = App(); // http服务端口监听 app.listen(3000, ()=> { console.log('3000 is listening!'); }); return app; }; createApp();
如今,访问上面预先设置好的路由,http://localhost:3000/home 来验证 server render,
服务端:
浏览器端:
咱们已经创建了服务端渲染的基础了,接着再考虑下如何把后端和前端的路由作统一。
假设咱们的路由设置成 /device/:deviceID
这种形式,
那么服务端是这么来实现的,
// 初始化 device/:deviceID 路由 dispatch 的 generator router.get('/device/:deviceID', function*() { // 执行view插件 let deviceID = this.params.deviceID; this.body = this.render('Device', { isServer: true, microdata: microdata, mydata: { path: this.path, deviceID: deviceID, } }); });
以及服务端 View 模板,
render() { const { microdata, mydata, isServer } = this.props; const deviceJs = `${microdata.styleDomain}/build/${microdata.styleVersion}/js/device.js`; const scriptUrls = [deviceJs]; return ( <Default microdata={microdata} scriptUrls={scriptUrls} title={"demo"}> <div id="demoApp" data-microdata={JSON.stringify(microdata)} data-mydata={JSON.stringify(mydata)}> <Iso microdata={microdata} mydata={mydata} isServer={isServer} /> </div> </Default> ); }
前端 app 入口:app.js
function getServerData(key) { return JSON.parse(appEle.getAttribute(`data-${key}`)); }; // 从服务端埋点处 <div id="demoApp"> 获取 microdata, mydata let microdata = getServerData('microdata'); let mydata = getServerData('mydata'); ReactDOM.render( <Iso microdata={microdata} mydata={mydata} isServer={false} />, document.getElementById('demoApp'));
先后端公用的 Iso.js 模块,前端路由一样设置成 /device/:deviceID
:
class Iso extends Component { static propTypes = { // ... }; // 包裹 Route 的 Component,目的是注入服务端传入的 props wrapComponent(Component) { const { microdata, mydata } = this.props; return React.createClass({ render() { return React.createElement(Component, { microdata: microdata, mydata: mydata }, this.props.children); } }); } // LayoutView 为路由的布局; DeviceView 为参数处理模块 render() { const { isServer, mydata } = this.props; return ( <Router history={isServer ? createMemoryHistory(mydata.path || '/') : browserHistory}> <Route path="/" component={this.wrapComponent(LayoutView)}> <IndexRoute component={this.wrapComponent(DeviceView)} /> <Route path="/device/:deviceID" component={DeviceView} /> </Route> </Router> ); } }
这样我就实现了服务端和前端路由的同构!
不管你是初次访问这些资源路径: /device/all, /device/pc, /device/wireless
,仍是在页面手动切换这些资源路径效果都是同样的,既保证了初次渲染有符合预期的 DOM 输出的用户体验,又保证了代码的简洁性,最重要的是先后端代码是一套,而且由一位工程师开发,有没有以为很棒?
其中注意几点:
Iso 的 render 模块须要判断isServer,服务端用createMemoryHistory,前端用browserHistory;
react-router 的 component 若是须要注入 props 必须对其进行包裹 wrapComponent。由于服务端渲染的数据须要经过传 props 的方式,而react-router-route 只提供了 component,并不支持继续追加 props。截取 Route 的源码,
propTypes: { path: string, component: _PropTypes.component, components: _PropTypes.components, getComponent: func, getComponents: func },
为何服务端获取数据不和前端保持一致,在 Component 里做数据绑定,使用 fetchData 和数据绑定!只能说,你能够大胆的假设。接下来就是咱们要继续探讨的同构model!
咱们都知道,浏览器端获取数据须要发起 ajax 请求,实际上发起的请求 URL 就是对应服务端一个路由控制器。
React 是有生命周期的,官方给咱们指出的绑定 Model,fetchData 应该在 componentDidMount
里来进行。在服务端,React 是不会去执行componentDidMount
方法的,由于,React 的 renderTranscation
分红两块: ReactReconcileTransaction
和ReactServerRenderingTransaction
,其在服务端的实现移除掉了在浏览器端的一些特定方法。
而服务端处理数据是线性的,是不可逆的,发起请求 > 去数据库获取数据 > 业务逻辑处理 > 组装成 html-> IO流输出给浏览器。显然,服务端和浏览器端是矛盾的!
你或许会想到利用 ReactClass
提供的 statics 来作点文章,React 确实提供了入口,不只能包裹静态属性,还能包裹静态方法,而且能 DEFINE_MANY:
/** * An object containing properties and methods that should be defined on * the component's constructor instead of its prototype (static methods). * * @type {object} * @optional */ statics: SpecPolicy.DEFINE_MANY,
利用 statics 把咱们的组件扩展成这样,
class ContentView extends Component { statics: { fetchData: function (callback) { ContentData.fetch().then((data)=> { callback(data); }); } }; // 浏览器端这样获取数据 componentDidMount() { this.constructor.fetchData((data)=> { this.setState({ data: data }); }); } ... });
ContentData.fetch() 须要实现两套:
服务端:封装服务端service层方法
浏览器端:封装ajax或Fetch方法
服务端调用:
require('ContentView').fetchData((data)=> { this.body = this.render('Device', { isServer: true, microdata: microdata, mydata: data }); });
这样能够解决数据层的同构!但我并不认为这是一个好的方法,好像回到 JSP 时代。
咱们团队如今使用的方法:
本文完整运行的 例子