• home > webfront > ECMAS > react >

    React 同构实践与思考

    Author:[email protected] Date:

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

    f4513046730bafdc52fad4a862004bcf_b.png

    众所周知,目前的 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 流传输上节省一部分流量。

    配合 renderToString 和 renderToStaticMarkup 使用,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();

    现在,访问上面预先设置好的路由,localhost:3000/home 来验证 server render,

    • 服务端:

    • 浏览器端:

    react-router 和 koa-router 统一

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

    假设我们的路由设置成 /device/:deviceID 这种形式,
    那么服务端是这么来实现的,

    // 初始化 device/:deviceID 路由 dispatch 的 generatorrouter.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, mydatalet 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 分成两块: 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() 需要实现两套:

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

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

    服务端调用:

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

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

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

    参考资料

    本文完整运行的 例子



    转载本站文章《React 同构实践与思考》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/jsBase/2016_0817_7881.html