react 同构初步(4)

这是一个即时短课程的系列笔记。本笔记系列进度已更新到:https://github.com/dangjingtao/react-ssr

axios代理

用代理规避跨域其实是很简单的事情,在往期的文章中已经有过类似的案例。但现在需要用"中台"的角度去思考问题。当前的项目分为三大部分:客户端(浏览器),同构服务端(nodejs中台,端口9000)和负责纯粹后端逻辑的后端(mockjs,端口9001)。

到目前为止的代码中,客户端如果要发送请求,会直接请求到mock.js。现实中接口数据来源不一定是node服务器,很可能是java,php或是别的语言。因此,从客户端直接请求会发生跨域问题。而要求后端为他的接口提供的跨域支持,并非是件一定能够满足到你的事。

如果从server端(中台)渲染,跨域就不会发生。于是就衍生了一个问题:客户端能否通过中台获取mockjs的信息?

解决的思路在于对axios也进行同构(区分客户端和服务端)。

redux-chunk传递axios对象

在前面的实践中,我们用到了redux-chunk。

redux-chunk是一个redux中间件,它可以把异步请求放到action中,它实现非常简单,不妨打开node_modules去看看它的源码:

// node_modules/redux-chunk/src/index  function createThunkMiddleware(extraArgument) {    // 高阶函数    return ({ dispatch, getState }) => next => action => {      if (typeof action === 'function') {        return action(dispatch, getState, extraArgument);      }        return next(action);    };  }    // 注意以下两句代码:  const thunk = createThunkMiddleware();  thunk.withExtraArgument = createThunkMiddleware;    export default thunk;  

发现thunk是createThunkMiddleware()的返回值。

我们之前引入chunk时,都是引入直接使用。但是它还有一个withExtraArgument属性,又刚好提供了createThunkMiddleware()方法。

顾名思义,withExtraArgument就是提供额外的参数。当你调用此方法时,createThunkMiddleware就会被激活。非常适合拿来传递全局变量。

我们在store.js中添加两个axios,分别对应客户端和中台:

// 储存的入口  import { createStore, applyMiddleware, combineReducers } from "redux";  import thunk from 'redux-thunk';  import axios from 'axios';  import indexReducer from './index';  import userReducer from './user';    const reducer = combineReducers({      index: indexReducer,      user: userReducer  });    // 创建两个axios,作为参数传递进createStore  const serverAxios=axios.create({      baseURL:'http://localhost:9001'  });  // 客户端直接请求服务端(中台),因此不需要再加个前缀  const clientAxios=axios.create({      baseURL:'/'  });    // 创建store  export const getServerStore = () => {      return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios)));  }    export const getClientStore = () => {      // 把初始状态放到window.__context中,作为全局变量,以此来获取数据。      const defaultState = window.__context ? window.__context : {};      return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)))  }

回到store/index.js和user.js,在定义请求的地方就会多出一个参数,就是我们定义的axios对象:

  // store/index.js  // 不再需要引入axios,直接用参数中的axios  export const getIndexList = server => {      return (dispatch, getState, $axios) => {          return $axios.get('/api/course/list').then((res)=>{              const { list } = res.data;              console.log('list',list)              dispatch(changeList(list));          }).catch(e=>{              // 容错              return dispatch(changeList({                  errMsg:e.message              }));          });      }  }
  // store/user.js  export const getUserInfo = server => {      return (dispatch, getState, $axios) => {          // 返回promise          return $axios.get('/api/user/info').then((res) => {              const { info } = res.data;              console.log('info', info);              dispatch(getInfo(info));          }).catch(e => {              console.log(e)              // 容错              return dispatch(getInfo({                  errMsg: e.message              }));          })      }  }

留意到这里接口多了一个/api/,是为了对路由做区分。我们在mockjs中也增加api。同时取消跨域设置

// mockjs单纯模拟接口  const express=require('express');  const app=express();    app.get('/api/course/list',(req,res)=>{      res.json({          code:0,          list:[              {id:1,name:'javascript 从helloworld到放弃'},              {id:2,name:'背锅的艺术'},              {id:3,name:'撸丝程序员如何征服女测试'},              {id:4,name:'python从入门到跑路'}          ]      });  });    app.get('/api/user/info',(req,res)=>{      res.json({          code:0,          info:{              name:'党某某',              honor:'首席背锅工程师'          }      });  });    app.listen('9001',()=>{      console.log('mock has started..')  });

此时,当数据为空时,前端就会对9000端口发起api请求。

请求转发

现在来处理服务端(中台)的逻辑,在server/index.js下,你可以很直观地这么写:

  // 监听所有页面  app.get('*', (req, res) => {      // 增加路由判断:api下的路由全部做转发处理:      if(req.url.startWith('/api')){          // 转发9001      }        // ...    });

但是这种面向过程编程的写法并不是最好的实践。因此考虑通过中间件处理这种逻辑。在express框架,http-proxy-middlewere可以帮助我们实现此功能。

文档地址:https://github.com/chimurai/http-proxy-middleware

npm i http-proxy-middleware -S
  // 使用方法  var express = require('express');  var proxy = require('http-proxy-middleware');    var app = express();    app.use(    '/api',    proxy({ target: 'http://www.example.org', changeOrigin: true })  );  app.listen(3000);    // http://localhost:3000/api/foo/bar -> http://www.example.org/api/foo/bar

安装好后,如法炮制:

  // server/index.js  import proxy from 'http-proxy-middleware';  // ...  app.use(      '/api',      proxy({ target: 'http://localhost:9001', changeOrigin: true })  );

这时候在客户端接口,就会看到中台9000转发了后台9001的数据了:

由此,中台代理后台请求功能完成。

图标/样式

现在的同构应用,有个不大不小的问题:在network中,请求favicon.ico总是404。

我们从百度盗一个图标过来:https://www.baidu.com/favicon.ico

下载下来然后塞到public中即可。

当前的应用实在太丑了。客户说:"我喜欢字体那种冷冷的暖,暖暖的冷。"

在src下面创建style文件夹,然后创建user.css

* {  color:red}

在container/user.js中引入css:

import '../style/user.css';

此时运行页面还是报错的,想要让它支持css样式,需要webpack层面的支持。

先配置客户端和服务端webpack:

  // webpack.client.js  // webpack.server.js  {      test:/.css$/,      use:['style-loader','css-loader']  }

配好之后,你满心欢喜地npm start:

document对象在server 层根本是不存在的。因此需要安装专门做同构应用的style-loader:isomorphic-style-loader(https://github.com/kriasoft/isomorphic-style-loader)

npm i isomorphic-style-loader -S

对server端webpack做单独配置:

  {      test: /.css$/,      use: [          'isomorphic-style-loader',          {              loader: 'css-loader',              options: {                  importLoaders: 1              }          }      ]  }  

刷新:

你会发现整个页面都红了。查看源代码,发现css是直接插入到header的style标签中的,直接作用于全局。

如何对样式进行模块化(BEM)处理?将在后面解决。

状态码支持

当请求到一个不匹配的路由/接口,如何优雅地告诉用户404?

现在把Index的匹配去掉,增加404NotFound组件:

  // App.js  import NotFound from './container/NotFound';  export default [    // ...      {          component:NotFound,          key:'notFound'      }  ]

404页面:

  // container/NotFound.js  import React from 'react';    function NotFound(props){      return <div>          <h1>404 你来到了没有知识的星球..</h1>                <img id="notFound" src="404.jpeg" />      </div>  }    export default NotFound;

然后在header组件中加上一条404路由:

<Link to={`/${Math.random()}`}>404</Link>

刷新,看到了404的请求:

为什么是200?此时应该是404才对。

去官网学习下:

https://reacttraining.com/react-router/web/guides/server-rendering

We can do the same thing as above. Create a component that adds some context and render it anywhere in the app to get a different status code.

function Status({ code, children }) {    return (      <Route        render={({ staticContext }) => {          if (staticContext) staticContext.status = code;          return children;        }}      />    );  }    // Now you can render a Status anywhere in the app that you want to add the code to staticContext.  function NotFound() {    return (      <Status code={404}>        <div>          <h1>Sorry, can’t find that.</h1>        </div>      </Status>    );  }    function App() {    return (      <Switch>        <Route path="/about" component={About} />        <Route path="/dashboard" component={Dashboard} />        <Route component={NotFound} />      </Switch>    );  }  

你可以传递一个全局的context对象给你创建的notfound组件。

在server/index.js的promise循环中定义一个context空对象,传递给路由组件:

Promise.all(promises).then(data => {          // 定义context空对象          const context={};          // react组件解析为html          const content = renderToString(              <Provider store={store}>                  <StaticRouter location={req.url} context={context}>                   ...

回到NotFound.js,看下它的props,客户端多了一个undefined的staticContext。但是在server端打印的是{}。这是在服务端渲染路由StaticRouter的独有属性:所有子路由都能访问。

在Notfound中定义一个Status组件用来给staticContext赋值:

import React from 'react';  import { Route } from 'react-router-dom';    function Status({ code, children }) {      return <Route render={(props) => {          const { staticContext } = props;          if (staticContext) {              staticContext.statusCode = code;          }          return children;      }} />  }    function NotFound(props) {      // props.staticContext      // 给staticContext赋值 statusCode=404      console.log('props', props)      return <Status code={404}>          <h1>404 你来到了没有知识的星球..</h1>          <img id="notFound" src="404.jpeg" />      </Status>  }    export default NotFound;

回到server/index.js就可以在renderToString之后拿到带有statusCode的context了。

const context = {};  const content = renderToString(      <Provider store={store}>          <StaticRouter location={req.url} context={context}>              <Header />              {routes.map(route => <Route {...route} />)}          </StaticRouter>      </Provider>  );    if (context.statusCode) {      res.status(context.statusCode)  }

这时候就看到404状态了。现在404是非精确匹配的。想要渲染,可以用switch组件来实现

// server/index.js  import { StaticRouter, matchPath, Route, Switch } from 'react-router-dom';    const content = renderToString(      <Provider store={store}>          <StaticRouter location={req.url} context={context}>              <Header />              <Switch>                  {routes.map(route => <Route {...route} />)}              </Switch>          </StaticRouter>      </Provider>  );

然后在客户端也做一个同构处理:

  import { BrowserRouter, Switch} from 'react-router-dom';    const Page = (<Provider store={getClientStore()}>      <BrowserRouter>          <Header/>          <Switch>              {routes.map(route => <Route {...route} />)}          </Switch>      </BrowserRouter>  </Provider>);

404功能搞定

又比如说我要对user页面做登录支持,当访问user页面时,做301重定向:

  // container/User.js  import {Redirect} from 'react-router-dom';    function User(props){    // 判断cookie之类。。。      return  <Redirect to={'/login'}></Redirect>      // ..  }    

定义了redirect,此时context的action是替换("REPLACE"),url是跳转的地址。因此在服务端可以这么判断

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

那么服务端的跳转逻辑就完成了。