React 客户端 / 服务端同构应用开发

前端2016-09-010 篇评论 react Node.js JavaScript

React 的好处很多,组件化简化了开发,虚拟 DOM 提升了页面性能,等等。当然,React 也有其缺点,其中影响最大的一点便是由于 React 是一个非常重型的框架,超过 2w 行的代码,即便是压缩后也超过 300k,导致了它的初始化加载非常费时。光是把这样的一个 JS 打包文件加载出来就要耗费一大笔时间,跟何况它的初始化执行过程也要达到百毫秒级,这样还是很影响用户体验的。

那么有没有什么方法能避免 React 这样的不足之处呢?

React 的渲染机制和其它类型的 MV * 框架有很大的区别。通过由数据构成的虚拟 DOM,React 便可在浏览器中一个根节点上渲染出一个完整的真实 DOM 树,从而完成整个页面的渲染。这是在浏览器客户端中完成的渲染操作。而在服务器(Node.js 环境)上,虽然没有 DOM,在客户端初次请求时,可以通过虚拟 DOM,将组件和数据渲染成真实 DOM 的 HTML 字符串,像其它后端语言如 PHP 那样把一个完整的 HTML 页面传输给浏览器,这就是服务端渲染。这同时也给了 React 一个很大的优势,让一个前端 View 层也有了 SEO 友好的展现方式。

同构(isomorphism)有多种含义,用在 JavaScript 中,指的就是使用同一套代码,可以在客户端(浏览器)及服务器端(Node.js)中运行。当然,同构对于 React 来说最重要的意义就是服务端渲染。

了解了原理,那么我们就来看看这是如何实现的了。由于代码量较多,我写了一个 Demo,放在了 GitHub 上,节选了其中最为重要的一些与同构相关的代码片段来讲解。

先简要介绍一下 Demo 中所运用到的相关库:

  • react-router —— React 的路由组件,可在客户端及服务端进行路由操作
  • Redux —— 最受好评的类 Flux 数据流库,方便 React 单向数据流的管理
  • normalizr —— 把嵌套的 API Json 进行拆分,方便存入 Redux 的 Store 中
  • immutable.js —— 实现了不可变数据结构,使用在 Redux 的 Store 中能使得 App 性能及开发效率得到提高
  • Webpack —— 代码打包工具,同时也提供模块热重载等功能
  • webpack-isomorphic-tools —— 与 Webpack 搭配使用的服务端同构工具,能够让服务端解析各种静态资源文件。

目录结构如下

├── bin/
│   ├── dev-server.js
│   └── server.js
├── src/
│   ├── actions/
│   ├── components/
│   ├── containers/
│   ├── helpers/
│   ├── reducers/
│   ├── stores/
│   ├── client.js
│   ├── routes.js
│   └── server.js
├── webpack/
│   ├── webpack.config.dev.js
│   ├── webpack.config.prod.js
│   └── webpack.isomorphic-tools.js
└── package.json
  • bin 目录下是一些可执行文件:
    • server.js 是整个项目运行在服务端的入口;
    • dev-server.js 是 webpack-dev-server 的启动文件,用在开发环境下以支持模块热重载等功能。
  • src 目录是项目主要代码目录,其中的目录结构按照 Redux 的规范来安排:
    • components 及 containers 目录用来存放 React 的相关组件;
    • actions、reducers 及 stores 是 Redux 的一些组成,存放相应功能代码;
    • client.js 是客户端文件打包入口,server.js 是服务端文件执行入口,routes.js 是 react-router 的路由配置文件。
    • helpers 存放其它辅助文件;
  • webpack 目录下包含 webpack 的开发和生产环境配置文件,以及 webpack-isomorphic-tools 的配置文件。

首先我们来关注一下 src/client.js 客户端打包入口文件:

import 'babel-polyfill';
import 'isomorphic-fetch';

import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { Router, browserHistory } from 'react-router';
import { syncHistoryWithStore } from 'react-router-redux'
import { fromJS } from 'immutable';
import configureStore from './stores';
import routes from './routes'
import DevTools from './components/ReduxDevTools.js';

// 为了保证服务端与客户端状态一致,需要将服务端注入的全局变量得到初始 state,并转换成 immutable 数据结构
const initialState = fromJS(window.__INITIAL_STATE__);

// 使用初始 state 创建 Redux store
const store = configureStore(initialState);

const history = syncHistoryWithStore(browserHistory, store, {
  // 使用 immutable 数据结构替换原有普通结构作为路由
  selectLocationState: (state) => state.get('routing').toJS()
});

render(
  <Provider store={store}>
    <div>
      <Router
        onUpdate={() => window.scrollTo(0, 0)}
        history={history}
        routes={routes}
      />
      { __DEVTOOLS__ ? <DevTools /> : null}
    </div>
  </Provider>,
  document.getElementById('app')
);

src/server.js:

import path from 'path';
import Express from 'express';
import React from 'react';
import configureStore from './stores';
import { Map } from 'immutable';
import { Provider } from 'react-redux';
import { renderToString } from 'react-dom/server'
import { syncHistoryWithStore } from 'react-router-redux'
import { match, RouterContext, createMemoryHistory } from 'react-router'
import routes from './routes'

import Html from './helpers/Html';
import { dispatchFetches } from './helpers/utils';

// 使用 express 来作服务端路由处理
const app = Express();
const port = 8000;

// 处理静态文件
app.use('/static', Express.static(path.join(__dirname, '../', 'dist')));

// 处理所有的路由请求
app.use(handleRender);

function handleRender(req, res) {

  if (__DEVELOPMENT__) {
    webpackIsomorphicTools.refresh();
  }

  // 与在客户端的情况差不多,都是
  // 在服务器端只能使用 MemoryHistory
  const memoryHistory = createMemoryHistory(req.path);
  // 初始状态为空
  const store = configureStore(Map({}));
  const history = syncHistoryWithStore(memoryHistory, store, {
    selectLocationState: (state) => state.get('routing').toJS()
  });


  match({ history, routes }, (error, redirectLocation, renderProps) => {

    // 判断是否出错,给出相应的错误码
    if (error) {
      res.status(500).send(error.message);
    } else if (redirectLocation) {
      res.redirect(302, redirectLocation.pathname + redirectLocation.search);
    } else if (renderProps) {
      // 这里是异步渲染的重点,在所有获取数据等异步操作完成后才进行组件的渲染操作
      dispatchFetches(store.dispatch, renderProps.components, renderProps.params).then(() => {
        // 与客户端不一样的是,这里需要使用 RouterContext 替代 Router
        const component = (
          <Provider store={store}>
            <RouterContext {...renderProps} />
          </Provider>
        );
        // 发送拼装后的 HTML 字符串
        res.send('<!doctype html>\n' +
          renderToString(<Html assets={webpackIsomorphicTools.assets()} component={component} store={store}/>)
            .replace(/ data-reactid="(.*?)"/g, '')
            .replace(/<!--\s*\/?react.*?-->/g, '')
        );
      }).catch(err => {
        console.error(err);
        if (__DEVELOPMENT__) {
          return res.status(500).send(`<pre>${err.stack}</pre>`);
        }
        return res.status(500).send('Oh! Some error happened (;′⌒`)');
      });
    } else {
      res.status(404).send('Not found')
    }
  })
}

app.listen(port, function () {
  console.info('Express server running on %d', port);
});

bin/server.js

#!/usr/bin/env node

var path = require('path');
var rootDir = path.resolve(__dirname, '..');
var fs = require('fs');

// 解析. babelrl 配置文件
var babelrc = fs.readFileSync(rootDir + '/.babelrc');
var config;
try {
  config = JSON.parse(babelrc);
} catch (err) {
  console.error('==>     ERROR: Error parsing your .babelrc.');
  console.error(err);
}

// 使用 babel-register 进行运行时编译
require('babel-register')(config);
/**
 * Define isomorphic constants.
 */
global.__CLIENT__ = false;
global.__SERVER__ = true;
global.__DEVELOPMENT__ = process.env.NODE_ENV !== 'production';
global.__DEVTOOLS__ = __DEVELOPMENT__;

if (__DEVELOPMENT__) {
  // 开发环境下监视文件修改,修改后重新运行
  if (!require('piping')({
      hook: true,
      ignore: /(\/\.|~$|\.json|\.css$|^node_modules\/)/i
    })) {
    return;
  }
}

// webpack-isomorphic-tools 控制服务端入口,在服务端运行项目
var WebpackIsomorphicTools = require('webpack-isomorphic-tools');
global.webpackIsomorphicTools = new WebpackIsomorphicTools(require('../webpack/webpack.isomorphic-tools'))
  .development(__DEVELOPMENT__)
  .server(__DEVELOPMENT__ ? __dirname : rootDir, function() {
    require('../src/server');
  });

下面是一个需要从 api 获取数据的页面组件,src/containers/SubredditPage.js

import React from 'react';
import { connect } from 'react-redux';
import RedditItem  from '../components/RedditItem/RedditItem';
import { fetchSubredditIfNeed } from '../actions/subreddits'
import { dispatchFetch } from '../helpers/utils'

// 通过 react-redux 将 redux 的 store 中的 state 绑定到组件的 props 中
@connect(state => ({
  subreddits: state.getIn(['subreddit', 'entities']),
  redditItems: state.getIn(['redditItem', 'entities'])
}))
export default class SubredditPage extends React.Component {

  // 需要在路由阶段完成的 actions
  static fetches = [
    fetchSubredditIfNeed
  ];

  // 在组件加载后 dispatch 需要的 actions,只会在客户端中生效,因为服务端没有 DOM,组件生命周期只会到 componentWillMount
  componentDidMount() {
    dispatchFetch(SubredditPage.fetches, this.props)
  }

  render() {

    // 从 props 中获取数据,params 是 react-router 中提供的参数
    const { redditItems, subreddits, params } = this.props;
    const subredditName = params.subredditName;

    // 判断是否加载完成
    const isSubredditFetched = subreddits && subreddits.get(subredditName);
    if (!isSubredditFetched) {
      return <div>Loading</div>;
    }

    const subredditItems = subreddits.getIn([subredditName, 'children']);

    return (
      <div>
        <h1>{subredditName}</h1>
        <div>
          {subredditItems.toSeq().map(item => {
            var data = item.get('data');
            var theItem = redditItems.get(data);
            return <RedditItem key={data} item={theItem}/>
          })}
        </div>
      </div>
    )
  }
}

工具类 src/helper/utils.js


// 用于服务端,将页面需要的全部数据 dispatch 到 redux 中,这是服务端渲染的重点部分
export const dispatchFetches = (dispatch, components, params) => {
  const fetches = components
    .filter(component => !!component.fetches)                       // 过滤无请求需求的组件
    .reduce((prev, next) => prev.concat(next.fetches), [])          // 将所有组件的请求合并成一个数组
    .filter((value, index, self) => self.indexOf(value) === index); // 过滤重复的请求
  return Promise.all(fetches.map(f => dispatch(f(params))));        // 返回一个 Promise 对象
};

// 用于客户端,将当前页面需要的全部数据 dispatch 到 redux 中
export const dispatchFetch = (fetches, props) => {
  fetches.forEach(f => props.dispatch(f(props.params)));            // 通过路由参数来决定获取的数据请求
};

src/actions/subreddit.js:

import fetch from 'isomorphic-fetch';
import { normalize, arrayOf} from 'normalizr'
import { subredditSchema } from '../reducers/schema';

function fetchSubredditFetching() {
  return {
    type: 'FETCH_SUBREDDIT_FETCHING'
  }
}

function fetchSubredditSuccess(data) {
  return {
    type: 'FETCH_SUBREDDIT_SUCCESS',
    data: data
  }
}

// 判断是否需要发送请求
function shouldFetchSubreddit(subredditName, state) {
  return !(state.getIn(['subreddit', 'entities', subredditName]));
}

// 只将 react-router 中的 params 作为参数,并在需要的时候发送请求
export const fetchSubredditIfNeed = (params) => {
  const subredditName = params.subredditName;
  // 异步 dispatch
  return (dispatch, getState) => {
    if (subredditName && shouldFetchSubreddit(subredditName, getState())) {
      dispatch(fetchSubredditFetching());
      return fetch(`https://www.reddit.com/r/${subredditName}.json`)
        .then(res => res.json())
        .then(data => {
          if (!data || !data.data) {
            throw new Error('No data');
          }
          data.data.subredditName = subredditName;
          data = normalize(data.data, subredditSchema);
          return dispatch(fetchSubredditSuccess(data));
        });
    } else {
      return Promise.resolve();
    }
  }
};

以上便是 React 中与同构相关的主要代码,代码量略大,对于初次尝试 React 服务端渲染的人来说可能会遇到许多的坑,当然我也不例外,一路上踩了不少坑,最终还是掌握了 React 同构相关的技术,并把它用在了我的博客之中。至于目的,很单纯,就是为了能够有更好的 SEO,以及更快的首屏加载速度,也算是拼了吧。

如果对本文有什么问题或者建议可以在评论中指出,共同进步!

评论区

发表评论
用户名
(必填)
电子邮箱
(必填)
个人网站
(选填)
评论内容
Copyright © 2017 dremy.cn
皖ICP备16015002号