React 同构实践与思考

12

众所周知,目前的 WEB 应用,用户体验要求越来越高,WEB 交互变得越来越丰富!前端可以做的事越来越多,去年 Node 引领了前后端分层的浪潮,而 React 的出现让分层思想可以更多彻底的执行,尤其是 React 同构 (Universal or Isomorphic) 这个黑科技到底是怎么实现的,我们来一探究竟。

React 服务端方法

如果熟悉 React 开发,那么一定对 ReactDOM.render 方法不陌生,这是 React 渲染到 DOM 中的方法。

现有的任何开发模式都离不开 DOM 树,如图:
客户端渲染

服务端渲染就要稍作改动,如图:
服务端渲染

比较两张图可以看出,服务端渲染需要把 React 的初次渲染放到服务端,让 React 帮我们把业务 component 翻译成 string 类型的 DOM 树,再通过后端语言的 IO 流输出至浏览器。

我们来看 React 官方给我们提供的服务端渲染的API:

  • React.renderToString 是把 React 元素转成一个 HTML 字符串,因为服务端渲染已经标识了 reactid,所以在浏览器端再次渲染,React 只是做事件绑定,而不会将所有的 DOM 树重新渲染,这样能带来高性能的页面首次加载!同构黑魔法主要从这个 API 而来。

  • React.renderToStaticMarkup,这个 API 相当于一个简化版的 renderToString,如果你的应用基本上是静态文本,建议用这个方法,少了一大批的 reactid,DOM 树自然精简了,在 IO 流传输上节省一部分流量。

配合 renderToStringrenderToStaticMarkup 使用,createElement 返回的 ReactElement 作为参数传递给前面两个方法。

React 玩转 Node

有了解决方案,我们就可以动手在 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,

  • 服务端: server-dom

  • 浏览器端: browser-dom

react-router 和 koa-router 统一

我们已经建立了服务端渲染的基础了,接着再考虑下如何把后端和前端的路由做统一。

假设我们的路由设置成 /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 输出的用户体验,又保证了代码的简洁性,最重要的是前后端代码是一套,并且由一位工程师开发,有没有觉得很棒?

其中注意几点:

  1. Iso 的 render 模块需要判断isServer,服务端用createMemoryHistory,前端用browserHistory;

  2. 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 分成两块: ReactReconcileTransactionReactServerRenderingTransaction,其在服务端的实现移除掉了在浏览器端的一些特定方法。

而服务端处理数据是线性的,是不可逆的,发起请求 > 去数据库获取数据 > 业务逻辑处理 > 组装成 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() 需要实现两套:

  1. 服务端:封装服务端service层方法

  2. 浏览器端:封装ajax或Fetch方法

服务端调用:

require('ContentView').fetchData((data)=> {
  this.body = this.render('Device', {
    isServer: true,
    microdata: microdata,
    mydata: data
  });
});

这样可以解决数据层的同构!但我并不认为这是一个好的方法,好像回到 JSP 时代。

我们团队现在使用的方法:
流程图

参考资料

本文完整运行的 例子


如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的

makuta · 2016年03月23日

可以试试redux,就可以解决获取数据2套逻辑的问题

+1 回复

qingliang_hu 作者 · 2016年03月24日

我们已经开始redux client side了,后续会考虑redux server side

+1 回复

夜聆风 · 2016年03月24日

同构
确实一股jsp即时感 无奈

回复

dmyang · 2016年03月25日

感觉纯server渲染都不好做,同构更是复杂度高,楼主的探索精神值得学习。
但是感觉把数据存在data-xxx属性里传给前端,算是黑魔法吧,应该还有更优雅的方式。

回复

qingliang_hu 作者 · 2016年03月25日

确实,这种数据传输方式不够优雅,需要解决共同的上下文环境问题!或者跳出上下文搞出个XXX环境共享数据,需要继续研究

回复

载入中...