实现一个简单的SSR,了解服务端渲染

  • 在前面的文章中,我已经对服务端渲染有了充分介绍,并且实现了最简单的服务端渲染。
  • 在这篇文章中,就基于React,一步一步来搭建一个服务端渲染的项目。

这里是github地址 react-ssr,欢迎start

第一步:React组件渲染

1. 目标

首先,我们将下面这个简单的React组件渲染出来。

在前面的文章中已经渲染出简单的HTML结构,现在需要在页面上渲染出React组件。

// 在页面上渲染出该home组件
const Home = () => {
  return (
    <div>
      <div>Hello World</div>
    </div>
  )
} 

在这里插入图片描述

  • 你可能会想,直接把React组件引入不就可以了吗?就像这样:
import express from 'express';	
import React from 'react';
import ReactDOMServer from 'react-dom/server';	
import Home from '../containers/Home';

const app = express();

const content = ReactDOMServer.renderToString(<Home />); // 引入了组件Home
app.get('/', function(req, res) {
  res.send(
    `
    <html>	
      <head>	
        <title>ssr</title>	
      </head>	
      <body>	
        <div id="root">${content}</div>	
      </body>	
    </html>	
    `
  )
})


app.listen(8000, () => {
  console.log('listen:8000')
})
  • 但是,上面的代码直接执行会失败
  • 首先是es6语法,如果比较高的版本的nodepackage.json中配置就可以了。如果是比较低版本的就不行了。
// 报错信息,无法识别 es mudule
SyntaxError: Cannot use import statement outside a module
  • 还有JSX需要结合babel 使用 @babel/preset-react 进行转换
// 报错信息,无法识别JSX语法
SyntaxError: Unexpected token '<'

所以在正式开始之前,需要将项目进行配置一下

2. 下载依赖

首先需要安装项目所需依赖

express

yarn add express // 用于启动服务

webpack

yarn add -D webpack webpack-cli webpack-dev-server webpack-merge webpack-node-externals

bable

yarn add -D @babel/cli @babel/core @babel/preset-env @babel/preset-react @babel/preset-stage-0 babel-loader @babel/runtime

react

yarn add react react-dom

命令

yarn add -D npm-run-all // 简化命令
yarn add -D nodemon     // 监听变化,自动执行JS文件

注意:可以参考我项目中的依赖版本。

3.项目配置

  • 我们这里开发的Home组件是不能直接在node中运行的,需要借助webpack工具将jsx语法打包编译成js语法,让nodejs可以争取的识别,我们需要创建一个webpack.server.js文件。
// webpack.server.js
const Path = require('path');
const NodeExternals = require('webpack-node-externals'); // 服务端运行webpack需要运行NodeExternals, 他的作用是将express这类node模块不被打包到js里。

module.exports = {
    target: 'node',
    mode: 'development',
    entry: './src/server/index.js',
    output: {
        filename: 'bundle.js',
        path: Path.resolve(__dirname, 'build')
    },
    externals: [NodeExternals()],
    module: {
        rules: [
            {
                test: /.js?$/,
                loader: 'babel-loader',
                exclude: /node_modules/,
                options: {
                    presets: ['react', 'stage-0', ['env', {
                        targets: {
                            browsers: ['last 2 versions']
                        }
                    }]]
                }
            }
        ]
    }
}

4. 编写基于express服务

需要安装react-dom,借助renderToString将Home组件转换为标签字符串

// src/server/index.js
import express from 'express';	
import React from 'react';
import ReactDOMServer from 'react-dom/server';	
import Home from '../containers/Home';

const app = express();

const content = ReactDOMServer.renderToString(<Home />);
app.get('/', function(req, res) {
  res.send(
    `
    <html>	
      <head>	
        <title>ssr</title>	
      </head>	
      <body>	
        <div id="root">${content}</div>	
      </body>	
    </html>	
    `
  )
})

app.listen(8000, () => {
  console.log('listen:8000')
})
// containers/Home/index.js	
import React from 'react';
const Home = () => {
  return (
    <div>
      <div>Hello World</div>
    </div>
  )
} 

export default Home

5. 启动服务

想要访问到页面,我们需要打包产生bundle.js文件,执行该文件就能启动express服务,我们就能通过浏览器窗口的地址栏输入localhost:8000访问到了。

但是在项目进行的过程中,可能需要多次修改,这里我们就需要相应配置从而简化一下。

  • 自动打包:通过--watch监听文件变化进行自动打包
  • 运行JS文件:借助nodemon模块,监听build文件并且发生改变之后重新exec运行node ./build/bundile.js
  • 执行所有命令:创建一个dev命令, 里面执行npm-run-all, --parallel表示并行执行, 执行dev:开头的所有命令。

配置如下:

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

这个时候我想启动服务器同时监听文件改变运行yarn dev就可以了。出现下面内容说明启动成功,在浏览器中可以看到页面内容。

在这里插入图片描述

6. 小结

  • React组件服务端渲染需要使用renderToString方法,将React组件渲染为字符串。并注入到需要发送到客户端的HTML中。

第二步:同构

1. 事件绑定

  • 前面我们实现了最基本的SSR服务端渲染的流程,但是通过renderToString方法将React组件渲染为字符串。
  • 因为只是字符串,所以并不能进行交互。也就是说一系列的事件绑定都没有应用上去。

接下来我们就来学习事件的绑定。

(1)做法

  • SSR两套代码,一套在服务端运行一次,一套在客户端运行一次。
  • 首先浏览器向服务器发送请求,服务器返回一个空的html。
  • 浏览器再请求并加载JS。
  • 执行JS代码接管页面执行流程,这个时候就可以触发点击事件了。

客户端获取到的页面结构如下:

在这里插入图片描述

(2)同构代码

浏览器后续请求的JS就是同构代码。这里我们同构代码使用hydrate代替render

// src/client/index.js
import React from 'react';
import ReactDom from 'react-dom';
import Home from '../containers/Home';


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

原组件中增加点击事件

// containers/Home/index.js	添加点击事件
import React from 'react';
const Home = () => {
  return <div onClick={() => { alert('click'); }}>home</div>
} 

export default Home

在服务端生成的html中引入JS,

// ./src/server/index.js 在输出的内容中引入JS文件
app.get('/', function(req, res) {
  res.send(
    `
    <html>	
      <head>	
        <title>Palate-ssr</title>	
      </head>	
      <body>	
        <div id="root">${content}</div>	
        <script src="/index.js"></script>
      </body>	
    </html>	
    `
  )
})

(3)配置webpack

同构代码也需要先对React语法进行编译

// webpack.client.js
const path = require('path')
const { merge } = require('webpack-merge')
const config = require('./webpack.base') // 公共部分

const clientConfig = {
  mode: 'development',
  entry: './src/client/index.js',
  module: {
    rules: [{
      test: /\.css?$/,
      use: ['style-loader', {
        loader: 'css-loader',
        options: {
          modules: true
        }
      }]
    }]
  },
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'public')
  },
}

module.exports = merge(config, clientConfig)

抽离和webpack.server.js文件的公共部分,使用webpack-merge插件对内容进行合并。

// webpack.base.js
module.exports = {
  module: {
    rules: [{
      test: /\.jsx?$/,
      exclude: '/node_modules/',
      loader: 'babel-loader',
      options: {
        presets: ["@babel/react", ['@babel/env', {
          targets: {
            browsers: ['last 2 versions']
          }
        }]]
      }
    }]
  }
};

2. 前端路由

路由和上面的事件一样也是同构,因为路由本身也是通过修改URL,触发监听URL变化的事件来切换页面内容的。

所以,类似前面的逻辑:

  • 首先浏览器向服务器发送请求,服务器返回一个空的html。
  • 浏览器再请求JS,加载到js后会执行react代码。
  • react代码接管页面执行流程,这个时候可以根据浏览器的地址展示页面内容。

也就是说,首页是服务端拼接好的,后面是基于JS代码进行内容切换,即后续页面内容由JS生成。

(1)下载依赖

yarn add react-router-dom

注意

  • 这里的react-router-dom不能下载6.x版本的。因为最新版已经弃用了staticRouter,而我们需要用到。下载react-router-dom@5.3.0

(2)定义路由规则

// ./src/Routes.js
import React from 'react';
import {Route} from 'react-router-dom'
import Home from './containers/Home';
import Login from './containers/Login';

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

(3)路由导航组件

Header中引入Link, 并且使用他跳转至Home和Login。

// ./src/components/Header/index.js
import React from 'react';
import { Link } from 'react-router-dom';

const Header = () => {
  return (
    <div>
      <Link to="/">Home</Link>
      <br />
      <Link to="/login">Login</Link>
    </div>
  )
}

export default Header;

(4)组件中引入导航组件

新建一个Login组件,用于测试路由跳转

// .src/containers/Login/index.js
import React from 'react';
import Header from '../../components/Header';

const Login = () => {
  return ( 
    <div>
        <Header />
      <div> Login </div> 
    </div>
  )
};

export default Login;

Home组件中也需要引入Header组件

// containers/Home/index.js	
import React from 'react';
import Header from '../../components/Header';
const Home = () => {
  return (
      <div>
        <Header />
        home
        <button onClick={() => { alert('click'); }}>按钮</button>
      </div>
  )
} 

export default Home

同构代码

路由规则需要在客户端执行

import React from 'react';	
import ReactDom from 'react-dom';	
import { BrowserRouter } from 'react-router-dom'	
import Routes from '../Routes'	
 
const App = () => {	
  return (	
    <BrowserRouter>	
      {Routes}	
    </BrowserRouter>	
  )	
}

ReactDom.hydrate(<App />, document.getElementById('root'));

服务端代码

路由规则也需要在服务端执行

  • 服务端要使用StaticRouter组件替代浏览器的browserRouter
  • StaticRouter是不知道请求路径是什么的,因为他运行在服务器端,这是他不如BrowserRouter的地方,他需要在请求体中获取到路径传递给他,。
  • 这里我们就需要将content写在请求里面。将location的值赋为req.path
// ./src/server/index.js
import express from 'express';
import { render } from './utils';

const app = express();

app.use(express.static('public'));

app.get('*', function(req, res) {
  res.send(render(req))
});

app.listen(8000, () => {
  console.log('listen:8000')
})

提取出render模块:

// ./src/server/utils.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Routes from '../Routes';

export const render = (req) => {
  const content = renderToString((
    <StaticRouter location={req.path} context={{}}>
        {Routes}
    </StaticRouter>
  ));
  return `
    <html>
      <head>	
        <title>这里是Palate的博客</title>
      </head>
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>	
  `;
}

启动项目,可以看到

在这里插入图片描述

页面源码

<html>

<head>
  <title>这里是Palate的博客</title>
</head>

<body>
  <div id="root">
    <div>
      <div>
        <div>
          <a href="/">Home</a>
          <br/>
          <a href="/login">Login</a>
        </div>
        home<button>按钮</button>
      </div>
    </div>
  </div>
  <script src="/index.js"></script>
</body>

</html>

注意

  • 当我们在做页面同构的时候,服务器端渲染只放生在我们第一次进入页面的时候,后面使用Link的跳转都是浏览器端的跳转。
  • 所以服务器端渲染不是每个页面都做服务器端渲染,而是只访问的第一个页面具有服务端渲染的特性,其他的页面仍旧是React的路由机制, 这是我们要注意的。

3. 小结

这一步讲同构代码,主要是事件绑定和前端路由的实现。

基本思路就是:

  • 两套代码,一套在服务端运行一次,一套在客户端运行一次。服务端完成html元素的渲染,客户端完成元素事件的绑定。
  • 把React组件和路由规则编译打包成JS文件交给服务端。
  • 服务端先发送HTML给客户端。客户端解析HTML模板时,通过script标签请求并加载JS文件激活页面

路由:服务端需要将路由逻辑执行一遍,服务端的路由使用的是 StaticRouter

第三步:引入Redux

1. 安装依赖

yarn add redux react-redux redux-thunk

2. 创建全局store

// src/client/index.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
//合并项目组件中store的reducer	
const reducer = combineReducers({
  home: homeReducer
})
// 导出创建store方法
const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk));
}
export default getStore;

3. 项目引入store

对于store的连接操作,在同构项目中分两个部分,一个是与客户端store的连接,另一部分是与服务端store的连接。通过react-redux中的Provider来传递store

// utils.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Routes from '../Routes';
import getStore from '../store'; // 使用store
import { Provider } from 'react-redux';

const store = getStore()
export const render = (req) => {

    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={{}}>
                <Routes />
            </StaticRouter>
        </Provider>
    ));
    return `
        <html>
            <body>
                <div id="root">${content}</div>
                <script src="/index.js"></script>
            </body>
        </html>
    `;
}
// client/index.js
import React from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom'
import Routes from '../Routes'
import { Provider } from 'react-redux';
import getstore from '../store'; // 使用store

const store = getStore()

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

ReactDom.hydrate(<App />, document.getElementById('root'));

到这里,就完成了Redux的引入

4. 创建服务端资源

在根目录的public文件夹下

// api/news.json
{
  "data": [
    {
      "id": 1,
      "title": "1111111"
    },
    {
      "id": 2,
      "title": "2222222"
    },
    {
      "id": 3,
      "title": "3333333"
    },
    {
      "id": 4,
      "title": "4444444"
    },
    {
      "id": 5,
      "title": "5555555"
    }
  ]
}
  • 可以通过localhost:8000/api/news.json访问到该数据,可以在浏览器中尝试。
  • 我们下面要做的做的就是让Home组件请求到这个数据。

注意:浏览器会自动请求一个favicon文件,造成代码重复执行,我们可以在public文件夹中加入这个图片解决该问题。

5. 组件内action和reducer的构建

下载axios

yarn add axios

四个JS文件

在Home文件夹下创建store文件夹,创建以下文件

// constants.js	
export const CHANGE_LIST = 'HOME/CHANGE_LIST'; // 存储常量
// actions.js	
import axios from 'axios';
import { CHANGE_LIST } from "./constants";

//普通action	
const changeList = list => ({
  type: CHANGE_LIST,
  list
});

export const getHomeList = () => {
  return dispatch => {
    //请求服务端资源	
    return axios.get('http://localhost:8000/api/news.json')
      .then((res) => {
        const list = res.data.data;
        dispatch(changeList(list))
      })
  }
}	
// reducer.js
import { CHANGE_LIST } from './constants';
const defaultState = { //初始化数据
  name: 'palate',
  list: []
}

export default (state = defaultState, action) => {
  switch (action.type) {
    case CHANGE_LIST:
      const newState = {
        ...state,
        list: action.list // 修改数据
      }
      return newState
    default:
      return state;
  }
}
// index.js
import reducer from "./reducer";
//这么做是为了导出reducer让全局的store来进行合并	
//那么在全局的store下的index.js中只需引入Home/store而不需要Home/store/reducer.js	
//因为脚手架会自动识别文件夹下的index文件	
export { reducer }

6. 组件连接全局store

// src/container/Home/index.js
import React, { Component } from 'react';
import Header from '../../components/Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {

  getList() {
      const { list } = this.props;
      return list.map(item => <div key={item.id}>{item.title}</div>)
  }
  render() {
      return (
      <div>
          <Header/>
          <div>Home</div>
          {this.getList()}
          <button onClick={() => { alert('click1'); }}>按钮</button>
      </div>
      )
    }	

  componentDidMount() {	
    this.props.getHomeList() // 更新数据
  }
}

const mapStatetoProps = state => ({ // 传入sotre中的数据
  list: state.home.list
});

const mapDispatchToProps = dispatch => ({ // 传入更新数据方法
  getHomeList() {	
    dispatch(getHomeList());
  }
})

// 组件使用react-redux提供的connect方法连接store
export default connect(mapStatetoProps, mapDispatchToProps)(Home);

componentDidMount()在服务端无法执行,需要添加一个静态方法Home.loadData加载数据,这个方法只在服务端有效。

// src/container/Home/index.js
// 省略其他内容...
Home.loadData = (store) => {
  // 执行action,扩充store。
  return store.dispatch(getHomeList());
}

7. 改造路由

这里需要改造一下路由配置,根据路由来判断是否需要通过loadData加载数据。

// src/Routes.js
import React from 'react';
import Home from './components/Home';
import Login from './components/Login';

export default [
    {
        path: '/',
        component: Home,
        exact: true,
        key: 'home',
        loadData: Home.loadData // 服务端异步获取数据方法
    },
    {
        path: '/login',
        component: Login,
        key: 'login',
        exact: true
    }
]

使用Router.js的地方也要修改

<Provider store={store}>	
  <BrowserRouter>	
      <div>	
        {	
            routers.map(route => {	
                <Route {...route} />	
            })	
        }	
      </div>	
  </BrowserRouter>	
</Provider>

<Provider store={store}>	
  <StaticRouter>	
      <div>	
        {	
            routers.map(route => {	
                <Route {...route} />	
            })	
        }	
      </div>	
  </StaticRouter>	
</Provider>

6. 渲染list数据

先下载react-router-config

server/utils.js中加入以下逻辑

import { matchRoutes as matchRoute } from 'react-router-config';
  // ...获取store之后:
  //调用matchRoutes用来匹配当前路由(支持多级路由)	
  const matchedRoutes = matchRoute(routes, req.path)	
  //promise对象数组		
  let promises = [];
  matchedRoutes.forEach(item => {
  	//如果这个路由对应的组件有loadData方法
    if (item.route.loadData) {
      promises.push(item.route.loadData(store));
    }
  });	
  Promise.all(promises).then(() => {	
      //此时该有的数据都已经到store里面去了(renderToString操作)	
      //执行渲染的过程(res.send操作)	
  })

将对server的内容整理一下,提取出render函数

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter, Route } from 'react-router-dom';
import Routes from '../Routes';
import getStore from '../store'; // 使用store
import { Provider } from 'react-redux';
import { matchRoutes as matchRoute } from 'react-router-config';

export const render = (req, res) => {
  const store = getStore();
  const matchedRoutes = matchRoute(Routes, req.path);
  let promises = [];
  matchedRoutes.forEach(item => {
    if (item.route.loadData) {
      promises.push(item.route.loadData(store));
    }
  });
  Promise.all(promises).then(() => {
    const content = renderToString(( 
      <Provider store = { store } >
        <StaticRouter location={req.path} context={{}}>
          <div>
            {
              Routes.map(route => (
                <Route {...route} />
              ))
            }
          </div> 
        </StaticRouter > 
      </Provider>
    ));
    res.send(`
          <html>
              <body>
                  <div id="root">${content}</div>
              </body>
              <script src="/index.js"></script>
          </html>
      `);
  })
}

server/index.js

import express from 'express';
import { render } from './utils';

const app = express();
app.use(express.static('public'));

app.get('*', function(req, res) {
  render(req, res)
});

app.listen(8000, () => {
  console.log('listen:8000')
})

7. 数据注水和脱水

目的:解决客户端和服务端的store可能不同步的问题。

  • 因为服务端和客户端的store是分别创建的,如果中间有代码不一致,就有可能导致store不同步。
  • 以服务端的store为准,客户端获取服务端的store。

这就需要分两步进行:

(1)注水

数据的“注水”操作,即把服务端的store数据注入到window全局环境中。

做法:在返回的html代码中加入这样一个script标签:

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

(2)脱水

接下来是“脱水”处理,换句话说也就是把window上绑定的数据给到客户端的store,可以在客户端store产生的源头进行,即在全局的store/index.js中进行。

import { legacy_createStore as createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/Home/store';

//合并项目组件中store的reducer	
const reducer = combineReducers({
  home: homeReducer
})

//创建store,并引入中间件thunk进行异步操作的管理
// 导出创建store的方法,每个用户执行这个函数就会拿到一个新的store	
export const getStore = () => {
  return createStore(reducer, applyMiddleware(thunk));
}


//客户端的store创建函数	
export const getClientStore = () => {
  const defaultState = window.context ? window.context.state : {};
  return createStore(reducer, defaultState, applyMiddleware(thunk));
}

然后对引入getStore的地方进行修改,这里省略了。

这样我们访问浏览器就可以发现页面结构已经渲染出来了。

在这里插入图片描述

8. 注意问题

1.渲染出页面后,可能会遇到报错情况

在这里插入图片描述

需要在script标签添加 defer属性,避免阻塞HTML解析

2.注意需要使用div包裹一下所有Route,否则会报错。因为react-route-dom要求route成组出现。

3.避免组件重复获取数据

  • 因为数据已经在服务端获取并拼接在HTML结构中了,所以判断服务端已经获取到数据,就不重复获取。
 componentDidMount() {	
  	if (!this.props.list.length) {	//判断当前的数据是否已经从服务端获取
    	this.props.getHomeList() // 请求数据
  	}
 }
  • 那为什么不直接删掉?
  • 因为该页不一定是作为首页展现,而是跳转到该页面的,这时候还是需要发送请求。

9. 小结

在这一步完成的redux的引入和异步请求数据。

  • 对于store的连接操作,在同构项目中分两个部分,一个是与客户端store的连接,另一部分是与服务端store的连接。
  • 客户端和服务端都需要异步获取数据创建store
  • 客户端需要获取服务端的store,保持数据同步。(注水、脱水)

第四步:node作中间层

1. 为什么引入中间层

  • 前端每次发送请求都是去请求node层的接口,然后node层对于相应的前端请求做转发,用node层去请求真正的后端接口获取数据,获取后再由node层做对应的数据计算等处理操作,然后返回给前端。
  • node层替前端接管了对数据的操作,减轻对服务器端的性能消耗。
  • 之前搭建的SSR框架中,服务端和客户端请求利用的是同一套请求后端接口的代码,但这是不合理的。

2. 组件请求判断

  • 我们先对组件的请求做个修改,判断请求时哪里发出的。
  • 如果是在客户端,那么是发送给该中间层。中间层请求是发送到真正的服务端。
//actions.js	
//参数server表示当前请求是否发生在node服务端	
const getUrl = (server) => {	
    return server ? 'xxxx(后端接口地址)' : '/api/sanyuan.json(node接口)';	
}	
//这个server参数是Home组件里面传过来的,	
export const getHomeList = (server) => {	
  return dispatch => {	
    return axios.get(getUrl(server))	
      .then((res) => {	
        const list = res.data.data;	
        dispatch(changeList(list))	
      })	
  }	
}

Home组件调用getHome()时需要传入参数

componentDidMount中调用这个action时传入false,因为这个是客户端发送的请求

loadData函数中调用时传入true,因为这个是中间层发送的请求

3. 中间层转发请求

//增加如下代码	
import proxy from 'express-http-proxy';	
//相当于拦截到了前端请求地址中的/api部分,然后换成另一个地址	
app.use('/api', proxy('http://xxxxxx(服务端地址)', {	
  proxyReqPathResolver: function(req) {	
    return '/api'+req.url;	
  }	
}));

4. 代码优化

请求在组件中判断并不合理。其实,每个组件中都需要进行一样的判断。

(1)封装axios

我们把这部分判断提取出来,对axios做一个封装。

//新建server/request.js	
import axios from 'axios'	

const instance = axios.create({	
  baseURL: 'http://xxxxxx(服务端地址)'	
})	

export default instance	
 

	
//新建client/request.js	
import axios from 'axios'	

const instance = axios.create({	
  //即当前路径的node服务	
  baseURL: '/'	
})	

export default instance

(2)通过store传递

import { legacy_createStore as createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/Home/store';
import clientAxios from '../client/request';
import serverAxios from '../server/request';

const reducer = combineReducers({	
  home: homeReducer	
})	
 
	
export const getStore = () => {	
  //让thunk中间件带上serverAxios	
  return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios)));	
}	
export const getClientStore = () => {	
  const defaultState = window.context ? window.context.state : {};	
   //让thunk中间件带上clientAxios	
  return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));	
}

(3)组件内获取axios实例

export const getHomeList = () => {
  //返回函数中的默认第三个参数是withExtraArgument传进来的axios实例	
  return (dispatch, getState, axiosInstance) => {
    return axiosInstance.get('资源地址')
      .then((res) => {
        const list = res.data.data;
        dispatch(changeList(list))
      })
  }
}

5. 另外启动一个服务

这个服务是用来接收中间层发送过来的请求的,将前面放在public/api/news.json放置在该项目中

const express = require("express")
const app = express()

app.use(express.static('public'))

app.listen(4000, () => { console.log('running 4000') })
  • 将这个是目标服务器,先启动该服务,再启动原来的项目,可以看到数据成功展示。(因为请求方式没有变化)
  • 这里可以先将中间层获取数据的逻辑去掉尝试一下,因为中间层获取数据并拼接到HTML中了,客户端不会重新获取。
  • 或者在login页面测试一下,因为这里是经过跳转的,获取数据就需要客户端发送请求。

6. 小结

  • 在这里将启动了另外一个服务器,作为真正的目标服务器。
  • 而之前搭建的作为中间层,只负责页面渲染和请求转发。
  • 这一步实现了请求转发,也就是客户端发送请求到中间层,中间层转发请求给目标服务器,从目标服务器获取数据。
  • 使用thunk.withExtraArgument传递封装的axios请求。

这里需要注意,中间层渲染出页面的时候会向后端发送请求

第五步:多级路由渲染

1. 修改路由规则

现在的需求是,页面中有一个App组件包含所有组件

import Home from './containers/Home';	
import Login from './containers/Login';	
import App from './App'	
 
//这里出现了多级路由	
export default [{	
  path: '/',	
  component: App,	
  routes: [	
    {	
      path: "/",	
      component: Home,	
      exact: true,	
      loadData: Home.loadData,	
      key: 'home',	
    },	
    {	
      path: '/login',	
      component: Login,	
      exact: true,	
      key: 'login',	
    }	
  ]	
}]	

2. App组件

前面我们再Home组件和Login组件中分别引用了Header组件,这里实现共用一个

import React from 'react';	
import Header from './components/Header';	

const  App = (props) => {	
  console.log(props.route)	
  return (	
    <div>	
      <Header></Header>	
    </div>	
  )	
}	

export default App;

记得将Header组件从其它两个组件中去掉。

3. 修改路由渲染形式

将服务端和客户端路由渲染的形式由原来的

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

改为:

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

{renderRoutes(Routes)}

这里用到的renderRoutes方法,就是根据url渲染一层路由的组件(这里渲染的是App组件),然后将下一层的路由通过props传给目前的App组件,依次循环。

4. 小结

这一步比较简单,新建一个App组件包含所有组件。

第六步:CSS服务端渲染

1. 客户端

下载依赖

yarn add -D style-loader css-loader

对应配置

在webpack文件中进行配置

const clientConfig = {	
  mode: 'development',	
  entry: './src/client/index.js',	
  module: {	
    rules: [{	
      test: /\.css?$/,	
      use: ['style-loader', {	
        loader: 'css-loader',	
        options: {	
          modules: true	
        }	
      }]	
    }]	
  },	

引入CSS文件

import styles from './style.css';
  • 这个时候打开启动项目,就可以看到页面有样式了。
  • 但是打开源码,发现并没有样式代码。

2. 服务端

下载依赖

服务端使用的是isomorphic-style-loader,对应配置:

module: {
  rules: [{
    test: /\.css?$/,
    use: ['isomorphic-style-loader', {
      loader: 'css-loader',
      options: {
        modules: true
      }
    }]
  }]
}

获取css代码

  • 引入css文件时,styles中挂了三个函数。通过styles._getCss即可获得CSS代码
  • react-router-dom中的StaticRouter中已经帮我们准备了一个钩子变量context。CSS代码可以从这里传入。
  • 在路由配置对象routes中的组件都能在服务端渲染的过程中拿到这个context,而且这个context对于组件来说,就相当于组件中的props.staticContext。

注意:这个props.staticContext只会在服务端渲染的过程中存在,而客户端渲染的时候不会被定义。

//context从外界传入	
<StaticRouter location={req.path} context={context}>	
    <div>	
        {renderRoutes(routes)}	
    </div>	
</StaticRouter>

我们需要在服务端的render函数执行之前,初始化context变量的值:

let context = { css: [] }

在组件中获取到CSS代码

//context从外界传入	
<StaticRouter location={req.path} context={context}>	
    <div>	
        {renderRoutes(routes)}	
    </div>	
</StaticRouter>

服务端的renderToString执行完成后,拼接css代码

//拼接代码	
const cssStr = context.css.length ? context.css.join('\n') : '';

挂载到页面

//放到返回的html字符串里的header里面	
<style>${cssStr}</style>

接下来就可以查看结果

在这里插入图片描述

这是依赖版本引起的问题,修改webpack配置,将esModule改为false

options: {
	modules: true,
	esModule: false
}

在这里插入图片描述

3. 小结

  • 在CSS引入组件时获取到CSS的代码,放入routes提供的context中。
  • 在输出HTML前,将CSS代码进行拼接,然后注入到HTML代码中。

到这里,一个服务端渲染项目的就基本搭建好了。

参考:

《React服务器渲染原理解析与实践》

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值