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);}

那麼服務端的跳轉邏輯就完成了。