React 同构实践与思考

众所周知,目前的 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,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,

  • 服务端:

  • 浏览器端:

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',mydata: data
  });
});

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

我们团队现在使用的方法

参考资料

本文完整运行的 例子

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


react 中的高阶组件主要是对于 hooks 之前的类组件来说的,如果组件之中有复用的代码,需要重新创建一个父类,父类中存储公共代码,返回子类,同时把公用属性...
我们上一节了解了组件的更新机制,但是只是停留在表层上,例如我们的 setState 函数式同步执行的,我们的事件处理直接绑定在了 dom 元素上,这些都跟 re...
我们上一节了解了 react 的虚拟 dom 的格式,如何把虚拟 dom 转为真实 dom 进行挂载。其实函数是组件和类组件也是在这个基础上包裹了一层,一个是调...
react 本身提供了克隆组件的方法,但是平时开发中可能很少使用,可能是不了解。我公司的项目就没有使用,但是在很多三方库中都有使用。本小节我们来学习下如果使用该...
mobx 是一个简单可扩展的状态管理库,中文官网链接。小编在接触 react 就一直使用 mobx 库,上手简单不复杂。
我们在平常的开发中不可避免的会有很多列表渲染逻辑,在 pc 端可以使用分页进行渲染数限制,在移动端可以使用下拉加载更多。但是对于大量的列表渲染,特别像有实时数据...
本小节开始前,我们先答复下一个同学的问题。上一小节发布后,有小伙伴后台来信问到:‘小编你只讲了类组件中怎么使用 ref,那在函数式组件中怎么使用呢?’。确实我们...
上一小节我们了解了固定高度的滚动列表实现,因为是固定高度所以容器总高度和每个元素的 size、offset 很容易得到,这种场景也适合我们常见的大部分场景,例如...
上一小节我们处理了 setState 的批量更新机制,但是我们有两个遗漏点,一个是源码中的 setState 可以传入函数,同时 setState 可以传入第二...
我们知道 react 进行页面渲染或者刷新的时候,会从根节点到子节点全部执行一遍,即使子组件中没有状态的改变,也会执行。这就造成了性能不必要的浪费。之前我们了解...
在平时工作中的某些场景下,你可能想在整个组件树中传递数据,但却不想手动地通过 props 属性在每一层传递属性,contextAPI 应用而生。
楼主最近入职新单位了,恰好新单位使用的技术栈是 react,因为之前一直进行的是 vue2/vue3 和小程序开发,对于这些技术栈实现机制也有一些了解,最少面试...
我们上一节了了解了函数式组件和类组件的处理方式,本质就是处理基于 babel 处理后的 type 类型,最后还是要处理虚拟 dom。本小节我们学习下组件的更新机...
前面几节我们学习了解了 react 的渲染机制和生命周期,本节我们正式进入基本面试必考的核心地带 -- diff 算法,了解如何优化和复用 dom 操作的,还有...
我们在之前已经学习过 react 生命周期,但是在 16 版本中 will 类的生命周期进行了废除,虽然依然可以用,但是需要加上 UNSAFE 开头,表示是不安...
上一小节我们学习了 react 中类组件的优化方式,对于 hooks 为主流的函数式编程,react 也提供了优化方式 memo 方法,本小节我们来了解下它的用...
开源不易,感谢你的支持,❤ star me if you like concent ^_^
hel-micro,模块联邦sdk化,免构建、热更新、工具链无关的微模块方案 ,欢迎关注与了解
本文主题围绕concent的setup和react的五把钩子来展开,既然提到了setup就离不开composition api这个关键词,准确的说setup是由...
ReactsetState的执行是异步还是同步官方文档是这么说的setState()doesnotalwaysimmediatelyupdatethecomponent.Itmaybatchordefertheupdateuntillater.Thismakesreadingthis.staterightaftercallingsetState()apotentialpitfall.Instead,usecom