React 同構實踐與思考

衆所周知,目前的 WEB 應用,用戶體驗要求愈來愈高,WEB 交互變得愈來愈豐富!前端能夠作的事愈來愈多,去年 Node 引領了先後端分層的浪潮,而 React 的出現讓分層思想能夠更多完全的執行,尤爲是 React 同構 (Universal or Isomorphic) 這個黑科技究竟是怎麼實現的,咱們來一探究竟。css

React 服務端方法

若是熟悉 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

配合 renderToStringrenderToStaticMarkup 使用,createElement 返回的 ReactElement 做爲參數傳遞給前面兩個方法。ajax

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 時代。

咱們團隊如今使用的方法:
流程圖

參考資料

本文完整運行的 例子

相關文章
相關標籤/搜索