10月04, 2018

react ssr笔记

最近慕课网有了一个新课:React 服务器渲染原理解析与实践,学习了一下,觉得非常地棒。在此记录一下学习过程中的笔记。

所谓的ssr就是指服务端渲染。为什么需要服务端渲染?主要是为了解决下面两个问题:

  • 减少首屏渲染时间
  • SEO

当然,服务端渲染也有一个弊端,就是把之前客户端的渲染拿到了服务端,也就是说服务端的压力会变大,可能需要增加机器。

前提知识

  • node基础
  • express
  • react基础
  • webpack

基础使用

假设目录如下:

├── build (打包的目录)
├── src
    ├── containers
         ├── Home
               ├── index.js
    ├── index.js
├── package.json
├── webpack.server.js

Home组件可以简单一点,如:

export default () => {
     return <div>Hello, ssr</div>
}

这里略过node webpack的配置,在之前的文章有提及。

import express from 'express';
import Home from './containers/Home';
import React from 'react';
import { renderToString } from 'react-dom/server';

const app = express();

app.get('/', function(req, res) {
    res.send(renderToString(<Home />));
});

app.listen(3000)

然后先进行webpack构建,再node服务启动起来即可。

npm script

每次webpack重新构建和重新node服务,这想想就太那个。所以我们需要配置两个script

{
    "start": "nodemon --watch build --exec node \"./build/bundle.js\"",
   "build": "webpack --config webpack.server.js --watch",
}

然后两个命令:npm startnpm run build

看起来好像是OK了,但还有更好的方法:npm-run-all

{
    "dev": "npm-run-all --parallel dev:**"
    "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
    "dev:build": "webpack --config webpack.server.js --watch",
}

这样我们只要运行一条命令即可:npm run dev

同构

为什么要有同构?如果给上面Home组件的div加一个click事件,会发现无法触发。原因是服务端渲染的,没法支持click事件。

所以我们还要在服务端渲染完成之后,再进行一次客户端渲染,来完成这个功能。

做法就是新写一个webpack.client.js,然后服务端输出html,在html中引用js文件(这里会用到express的static)。

app.use(express.static("public"));
{
    ...,
    "dev:build": "webpack --config webpack.client.js --watch",
}

整个目录结构变成:

├── build (服务端打包的目录)
    ├── index.js
├── public (客户端打包的目录)
    ├── index.js
├── src (客户端代码)
    ├── containers
         ├── Home
               ├── index.js
    ├── client (客户端代码)
         ├── index.js           
    ├── server (服务端代码)
         ├── index.js    
├── package.json
├── webpack.server.js

这里需要注意的点是,之前我们写客户端的react,dom渲染节点是这样写的:

import React from 'react';
import ReactDom from 'react-dom';

import Home from '../containers/Home';

ReactDom.render(<Home />, document.getElementById('root'));

现在需要将ReactDom.render改为ReactDom.hydrate

还有服务端那里这样写是错误的:

<div id="root">
     ${content}
</div>

Warning: render(): Target node has markup rendered by React, but there are unrelated nodes as well. This is most commonly caused by white-space inserted around server-rendered markup.

需要写成一行:

<div id="root"> ${content}</div>

webpack代码复用

我们有了两个webpack的配置文件,然后像对js的处理都是相同的,都需要babel-loader,因此会有代码重复。

解决的方案就是用:webpack-merge,我们需要做的仅仅是新建一个webpack.base.js,将公共的配置放到那个新文件即可。

const merge = require('webpack-merge');
const config == require('./webpack.base.js');

const clientConfig = {
    ...
}

module.exports = merge(config, clientConfig);

路由

我们都知道客户端有:BrowserRouterHashRouter,但服务端是不支持的,它支持的是StaticRouter

但是里面的routes是可以公用的。

import React from 'react';
import { Route } from 'react-router-dom';
import Home from './container/Home';
import Login from './container/Login';

export default (
    <div>
        <Route path="/" exact component={Home}></Route>
        <Route path="/login" component={Login}></Route>
    </div>
)

客户端直接用BrowserRouter包裹上面导出的模块即可。

服务端的StaticRouter,则不能简单地包裹,因为这个组件,它需要传两个props:

  • loaction
  • context
...
// 注意这里的匹配,从之前的'/'变为了'*'
app.get('*', function(req, res) {

    const content = renderToString((
        <StaticRouter location={req.path} context={{}}>
        </StaticRouter>
    ));

    res.send(`
        <html>
            <head>
                <title>ssr</title>
            </head>
            <body>
                <div id="root">${content}</div>
                <script src="/index.js"></script>
            </body>
        </html>
    `)

})

redux

相信大家对下面的代码并不陌生:

const reducer = (store = {name: 'zpu'}, action) => {
    return state;
}

const store = createStore(reducer, applyMiddleware(thunk));

const App = () => {
    return (
        <Provider store={store}>
             <BrowserRouter>
                    {Routes}
             </BrowserRouter>
        </Provider>
    )
}

服务端也是类似的代码,所以我们可以将store这一块抽离出去。

需要注意的是,每次服务端请求的时候,都要生成一份新的store,而不是使用同一份store。因此store.js得这样来写:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
const reducer = (store = {name: 'zpu'}, action) => {
    return state;
}
const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk));
}
export default getStore;

关于redux的书写

有些人比较推荐使用这样的目录结构:

├── Home (页面组件)
    ├── index.js
    ├── store
        ├── constants.js
        ├── actions.js
        ├── reducer.js
        ├── index.js

然后如果是公共的数据store,可以考虑放在container目录下。

另外一种目录就是不放在页面组件下,专门放在一个目录下对所有的页面组件进行统一管理。

我觉得各有优劣,考虑到维护的成本,还是选择第一种的比较好。

异步数据

这里说明一下,先请求其他服务器的数据(因为现在的express,get请求都会渲染html)。

在客户端,很好处理,只要在componentDidMount调用action,然后改变store,渲染出list即可。

但是服务端不认componentDidMount这个生命周期的,所以得想个办法。

react-router的文档中有找到一篇server-render的说明,正是我们想要的。

我们可以先在组件下挂载一个静态方法,叫loadData,譬如:

Home.loadData = function(){
    // 这个函数,负责在服务端渲染之前,把这个路由需要的数据提前加载好
}

随后将路由那一块改造一下:

import Home from './container/Home';

import Login from './container/Login';

export default [
    {
        path: '/',
        component: Home,
        exact: true,
        loadData: Home.loadData
    },
    {
        path: '/login',
        component: Login,
        exact: true
    }
]

然后客户端和服务端的路由那里代码就变成了:

import routes from '../Routes';

...
{
    routes.map(route => (
        <Route {...route} />
    ))
}
...

然后接下去的思路,就是找到匹配的路由,加载它们的loadData方法(在那个方法里面其实就是往store里面塞数据)。

匹配路由

const matchRoutes = [];

routes.some(route => {
    const match = matchPath(req.path, route);
    if (match) {
         matchRoutes.push(route);
    }
});

console.log(matchRoutes);

看起来好像没问题,但还是有点问题的:

  • 当favicon不存在时,它返回的结果多了一个空数组。解决方法就是不应该让它进那个方法,所以只要静态文件夹里面有favicon.ico即可。
  • 多级路由时有bug(即比如一个页面加载了A路由和B路由,然后A B都有loadData方法,但上面matchRoutes数组只会得到A,并不会得到B,这样就有问题了,也就是matchPath只能得到一层路由)

针对第二个问题:

alt

点进去,在它里面能找到一个matchRoutes方法

alt

因此上面的代码得要改造成这样的:

import { matchRoutes } from 'react-router-config';

...
const matchedRoutes = matchRoutes(routes, req.path);
console.log(matchedRoutes);

loadData

服务端肯定先要执行页面组件的loadData方法。

const promises = [];

matchedRoutes.forEach(item => {
    promises.push(item.route.loadData(store));
})

所以客户端页面组件的loadData的实现就不难想到了:

Home.loadData = (store) => {
     return store.dispatch(getHomeList()); // 注意这里返回一个promise
}

因为store的action是异步的行为,所以得等待所有的数据都放到store里面去之后,再渲染html。

Promise.all(promises).then(() => {
    console.log(store.getState());
    res.send(`这里输出最终的HTML`);
})

数据的脱水和注水

需要注意的是,上面确实解决了服务端得到数据的问题,然而在客户端,一开始的store是为空的,然后再异步请求成功后再渲染。

这样会造成一个问题,在渲染时,明明是有数据的,但因为客户端的问题,会先出现空白,再有数据的现象。

要解决这个问题,也很容易。

服务端需要先写出一个全局的对象(就是store的数据):

res.send(`
    ...
   <script>
         window.context = {
              state: ${JSON.stringify(store.getState())}
         }
   </script>
`)

当然这个context命名上可能不是那么的合理,建议用__xxx__这样的。

这里要将之前的getStore调整一下,搞成两个方法,一个是客户端接收的store,一个是服务端接收的store(这个和之前一样)

export const getStore = () => {
     // 同上,略
}
export const getClientStore = () => {
     const defaultState = window.context.state;
     return createStore(reducer, defaultState, applyMiddleware(thunk));
}

另外当服务端渲染得到了数据,所以客户端没必要再渲染一次,比较折中的方式是:

componentDidMount() {
    if (!this.props.list.length) {
         this.props.getHomeList();
    }
}

之所以说折中,是因为当list数据为空时,还是会服务端渲染一次,客户端也渲染一次。

请求处理

通常我们会把node作为中间层,然后它去负责请求其他服务器的数据,然后渲染到页面。

使用的模块包是express-http-proxy。简单的理解就是,请求反向代理。

import proxy from 'express-http-proxy';
app.use('/api', proxy('proxy host'));

服务端处理

这里需要注意的是:

当服务端运行api/xxx时,它找的是根目录下/api/xx

所以我们可能想到了用以下的方式来书写代码:

const getHomeList = (server) => {
    let url = '';
    if (server) {
        url = 'proxy host + api/xxx';
   } else {
        url = '/api/xxx';
   }
   return (dispatch) => {
        return axios.get(url).then(res => {
              // ... (略)
        })
   }
}

// 在组件的静态方法loadData里面,调用上面的方法时,传入`true`

但如果要每个方法都这样写,未免太挫了。

axios里面有一个实例,我们只要将上面的axios分为客户端的axios和服务端的axios即可。

alt

// clientAxios
import axios from 'axios';

const instance = axios.create({
    baseURL: '/'
});

export default instance;

随后:

let request = server ? serverAxios : clientAxios;
// ...
request.get(url).then(); // 去处理即可

然而这样写,还是很累啊。还有没有更好的方法,当然是有的。redux-thunk支持传入一个参数,

alt

所以只需要将之前store.js里面applyMiddleware(thunk)代码改成上面的写法即可。(在那个文件里面,其实已经区分出来是客户端还是服务端了,因为客户端的是getClientStore)。

另外还有一个bug需要处理一下,就是node在请求其他服务器的时候,并没有携带cookie,因此这一步也得处理一下:

const createInstance = (req) => axios.create({
    baseUrl: '', 
    headers: {
         cookie: req.get('cookie') || ''
    }
})

export default createInstance;

// 随后在getStore(服务端调用的store)那里得传入一个`req`参数。

多个路由复用layout

// 路由那里
export default [
  {
    path: '/',
    component: Layout,
    routes: [
      {
        path: '/home',
        component: Home
      },
      {
        path: '/xx',
        component: Xx
      }
    ]
  }
]

如果不想用layout模板,可以写在第一条,如:

export default [
  {
    path: '/login',
    exact: true,
    component: Login,
  },
  {
    path: '/',
    component: Layout,
    routes: [
      {
        path: '/home',
        component: Home
      },
      {
        path: '/xx',
        component: Xx
      }
    ]
  }
]

模板那里的代码:

import React from 'react';
import { Redirect } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';

export default (props) => {
  return (
    <div>
      头部
      {renderRoutes(props.route.routes)}
    </div>
  )
}

使用路由那里的代码:

import React, { Component } from 'react';
import { BrowserRouter, Redirect } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import routes from './routes';

...
<BrowserRouter>
      <div>
         {renderRoutes(routes)}
      </div>
</BrowserRouter>
...

404的处理

客户端:

export default [
  {
    path: '/',
    component: Layout,
    routes: [
      {
        path: '/home',
        component: Home
      },
      {
        path: '/xx',
        component: Xx
      },
      {
        component: NotFound   // 404的组件
      }
    ]
  }
]

看似好像没啥问题,但这时候返回的http状态码是200,我们希望的是状态码是404。实现思路就是通过staticRoutecontext参数。

我们先假设NotFound的组件是这样的:

class NotFound extends Component {
  render() {
    this.props.staticContext.NotFound = true;
    return <div>404</div>
  }
}

然后我们就可以在服务端通过context拿到NotFound这个参数。

const context = {};
const html = render(store, routes, req, context); // context传递到<StaticRouter>的`context`属性

if (context.NotFound) {
    res.status(404);
    res.send(html);
} else {
    res.send(html);
}

显然,客户端下面是没有staticContext的,所以上面的NotFount组件这样写是会报错的,因此得判断处理一下:

const { staticContext } = this.props;
staticContext && ( staticContext.NotFound = true );

301重定向

像在客户端,我们会有一些重定向的考虑,然后使用Redirect组件。

但在服务端并没有什么用,但是我们如果这时候打印context(就是staticRouter的context)的值,会得到如下的结果:

{
    action: 'REPLACE', 
    location: {},
    url: 'url'
}

所以我们后端通过这个就可以来做一些处理了:

if (context.action === ‘REPLACE’) {
     res.redirect(301, context.url);
}

服务端数据请求error处理

在上面有一段代码是:

Promise.all(promises).then(() => {
     res.send();
})

显然没有考虑当某一个promise挂掉的可能。

当然我们不能简单粗暴地处理成:如果有接口挂了,直接整个页面就显示error咋的。这里有可能就是一个页面四五个接口,只有一个接口挂了,那最好是挂了的接口不返回数据,其他都能正常处理,然后让客户端去做相应的reject提示。

const promises = [];

matchedRoutes.forEach(item => {
    if (item.route.loadData) {
        const promise = new Promise(resolove => {
            return item.route.loadData(store).then(resolve).catch(resolve);
        })
        promises.push(promise);
    }

})

即如果发生错误,也让它进resolve。

如何支持CSS样式修饰

我们知道在客户端直接使用css-loader + style-loader即可。

但如果服务端的webpack配置和上面的一样,那么就会报:

window is not defined

解决的方案是服务端的webpack配置使用isomorphic-style-loader来替代style-loader

module.exports = {
  /* ... */
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'isomorphic-style-loader',
          'css-loader',
          'postcss-loader'
        ]
      }
    ]
  }
  /* ... */
};

用了这个不会报错了,但我们关闭js后,刷新页面,样式还是没有出来。这个还得利用上面的库开放出来的一个方法:

alt

在具体组件中:

import style from './style.css';

class Home extends Component {
    componentWillMount(){
         if (style. _getCss) {
             console.log(styles._getCss());
         }
    }
    ...
}

简单地说,就是服务器得拼一个style标签出来,然后写到html模板里面。具体实现也要依靠staticRoutercontext上下文。

考虑到有多个组件的css,所以在设计时,必须是一个数组(当然用字符串+=也是可以的)

// 服务端那里的js
const context = {css: []};
...
const cssStr = context.css ? context.css.join("") : '';
// 客户端那里
if (this.props.staticContext) {
    this.props.staticContext.css.push(styles._getCss());
}

特殊注意项

只有路由组件才会拥有staticContext这个props,因此像Header组件(在它里面也可能会有样式,即公共的头部样式),得由父容器手动传值这个属性。

<Header staticContext={props.staticContext} />

高阶组件withStyle.js

如果每个拥有style的组件,都要写一遍push的代码,想来是非常心累的。所以我们可以考虑使用高阶组件来实现。

import React, { Component } from 'react';

export default (DecoratedComponent, styles) => {
    return class NewComponent extends Component {
        componentWillMount() {
            if (this.props.staticContext) {
                this.props.staticContext.css.push(styles._getCss());
            }
        }
        render() {
            return <DecoratedComponent {...this.props} />
        }
    }
}

使用:

withStyle(Header, styles);

如何做好SEO

其实我之前是有一个误区,觉得SEO只要做好titledescription就行了。

但其实这两个权重是比较轻的,它们更多的作用是用来吸引用户点击,从而提高网站的转化率。

一个网页无非有三部分组成:

  • 文字
  • 链接
  • 多媒体

所以要做好SEO,无非就是文字多点原创,然后链接要搞好(能有多一些的外部链接引用自己的网站地址),在某种意义上有图的文章比没图的文章更好一些。

React-Helmet

它的作用就是帮助我们来做网页的SEO(titledescription)。

客户端处理:

import React from "react";
import {Helmet} from "react-helmet";

class Application extends React.Component {
  render () {
    return (
        <div className="application">
            <Helmet>
                <meta charSet="utf-8" />
                <title>My Title</title>
            </Helmet>
            ...
        </div>
    );
  }
};

服务端处理:

alt

alt

预渲染

所谓的预渲染是指构建阶段生成匹配预渲染路径的 html 文件。

github上有一个项目:prerender,就是用来做这个事的。

alt

后面的https://www.example.com/地址只要改为react项目的URL地址即可。

需要注意的是,它跟服务端渲染的区别在于,它没法提升首屏渲染时间,但它可以用于SEO。

然后到时候,只要让蜘蛛爬那个预渲染的URL即可。

一些思考

感觉react-router4的价值意义并不大了。4在我看来有比较革命的一种概念,它结合了组件,也就是说可以在任何的组件里面使用Route

但我们上面的写法,要考虑异步渲染数据,所以一开始得把所有的路由给罗列出来。

当然这里面也可以做一些取巧,比如说有一些数据,我就不做成服务端渲染,就只做客户端渲染(这样一来,客户端也可以异步加载路由组件)。

另外一点是关于样式的处理,在服务端渲染出style标签后,在客户端还是会通过js来append一堆style进去,导致相同的样式会有两份。关于这一点,我想以后看一下next.js或者nuxt.js的具体做法,来扩充自己的知识体系。

结语

感谢DellLee老师输出了他很多react ssr的经验,让小子学习到了很多很多。

本文链接:www.my-fe.pub/post/react-ssr-note.html

-- EOF --

Comments

评论加载中...

注:如果长时间无法加载,请针对 disq.us | disquscdn.com | disqus.com 启用代理。