当前位置:   article > 正文

从头开始,彻底理解服务端渲染原理(8千字汇总长文)

神三元博客

大家好,我是神三元,这一次,让我们来以React为例,把服务端渲染(Server Side Render,简称“SSR”)学个明明白白。

这里附上这个项目的github地址: https://github.com/sanyuan0704/react-ssr

欢迎大家点star,提issue,一起进步!

part1:实现一个基础的React组件SSR

这一部分来简要实现一个React组件的SSR。

一. SSR vs CSR

什么是服务端渲染?

废话不多说,直接起一个express服务器。

  1. var express = require('express')
  2. var app = express()
  3. app.get('/', (req, res) => {
  4. res.send(
  5. `
  6. <html>
  7. <head>
  8. <title>hello</title>
  9. </head>
  10. <body>
  11. <h1>hello</h1>
  12. <p>world</p>
  13. </body>
  14. </html>
  15. `
  16. )
  17. })
  18. app.listen(3001, () => {
  19. console.log('listen:3001')
  20. })

启动之后打开localhost:3001可以看到页面显示了hello world。而且打开网页源代码:

640?wx_fmt=png也能够完成显示。

这就是服务端渲染。其实非常好理解,就是服务器返回一堆html字符串,然后让浏览器显示。

与服务端渲染相对的是客户端渲染(Client Side Render)。那什么是客户端渲染?现在创建一个新的React项目,用脚手架生成项目,然后run起来。这里你可以看到React脚手架自动生成的首页。

640?wx_fmt=png然而打开网页源代码。

640?wx_fmt=pngbody中除了兼容处理的noscript标签之外,只有一个id为root的标签。那首页的内容是从哪来的呢?很明显,是下面的script中拉取的JS代码控制的。

因此,CSR和SSR最大的区别在于前者的页面渲染是JS负责进行的,而后者是服务器端直接返回HTML让浏览器直接渲染。

为什么要使用服务端渲染呢?

640?wx_fmt=png传统CSR的弊端:

1.由于页面显示过程要进行JS文件拉取和React代码执行,首屏加载时间会比较慢。2.对于SEO(Search Engine Optimazition,即搜索引擎优化),完全无能为力,因为搜索引擎爬虫只认识html结构的内容,而不能识别JS代码内容。

SSR的出现,就是为了解决这些传统CSR的弊端。

二、实现React组件的服务端渲染

刚刚起的express服务返回的只是一个普通的html字符串,但我们讨论的是如何进行React的服务端渲染,那么怎么做呢?首先写一个简单的React组件:

  1. // containers/Home.js
  2. import React from 'react';
  3. const Home = () => {
  4. return (
  5. <div>
  6. <div>This is sanyuan</div>
  7. </div>
  8. )
  9. }
  10. export default Home

现在的任务就是将它转换为html代码返回给浏览器。 众所周知,JSX中的标签其实是基于虚拟DOM的,最终要通过一定的方法将其转换为真实DOM。虚拟DOM也就是JS对象,可以看出整个服务端的渲染流程就是通过虚拟DOM的编译来完成的,因此虚拟DOM巨大的表达力也可见一斑了。

而react-dom这个库中刚好实现了编译虚拟DOM的方法。做法如下:

  1. // server/index.js
  2. import express from 'express';
  3. import { renderToString } from 'react-dom/server';
  4. import Home from './containers/Home';
  5. const app = express();
  6. const content = renderToString(<Home />);
  7. app.get('/', function (req, res) {
  8. res.send(
  9. `
  10. <html>
  11. <head>
  12. <title>ssr</title>
  13. </head>
  14. <body>
  15. <div id="root">${content}</div>
  16. </body>
  17. </html>
  18. `
  19. );
  20. })
  21. app.listen(3001, () => {
  22. console.log('listen:3001')
  23. })

启动express服务,再浏览器上打开对应端口,页面显示出"this is sanyuan"。到此,就初步实现了一个React组件是服务端渲染。当然,这只是一个非常简陋的SSR,事实上对于复杂的项目而言是无能为力的,在之后会一步步完善,打造出一个功能完整的React的SSR框架。

part2: 初识同构

一.引入同构

其实前面的SSR是不完整的,平时在开发的过程中难免会有一些事件绑定,比如加一个button:

  1. // containers/Home.js
  2. import React from 'react';
  3. const Home = () => {
  4. return (
  5. <div>
  6. <div>This is sanyuan</div>
  7. <button onClick={() => {alert('666')}}>click</button>
  8. </div>
  9. )
  10. }
  11. export default Home

再试一下,你会惊奇的发现,事件绑定无效!那这是为什么呢?原因很简单,react-dom/server下的renderToString并没有做事件相关的处理,因此返回给浏览器的内容不会有事件绑定。

那怎么解决这个问题呢?

这就需要进行同构了。所谓同构,通俗的讲,就是一套React代码在服务器上运行一遍,到达浏览器又运行一遍。服务端渲染完成页面结构,浏览器端渲染完成事件绑定。

那如何进行浏览器端的事件绑定呢?

唯一的方式就是让浏览器去拉取JS文件执行,让JS代码来控制。于是服务端返回的代码变成了这样:

640?wx_fmt=png有没有发现和之前的区别?区别就是多了一个script标签。而它拉取的JS代码就是来完成同构的。

那么这个index.js我们如何生产出来呢?

在这里,要用到react-dom。具体做法其实就很简单了:

  1. //client/index. js
  2. import React from 'react';
  3. import ReactDom from 'react-dom';
  4. import Home from '../containers/Home';
  5. ReactDom.hydrate(<Home />, document.getElementById('root'))

然后用webpack将其编译打包成index.js:

  1. //webpack.client.js
  2. const path = require('path');
  3. const merge = require('webpack-merge');
  4. const config = require('./webpack.base');
  5. const clientConfig = {
  6. mode: 'development',
  7. entry: './src/client/index.js',
  8. output: {
  9. filename: 'index.js',
  10. path: path.resolve(__dirname, 'public')
  11. },
  12. }
  13. module.exports = merge(config, clientConfig);
  14. //webpack.base.js
  15. module.exports = {
  16. module: {
  17. rules: [{
  18. test: /\.js$/,
  19. loader: 'babel-loader',
  20. exclude: /node_modules/,
  21. options: {
  22. presets: ['@babel/preset-react', ['@babel/preset-env', {
  23. targets: {
  24. browsers: ['last 2 versions']
  25. }
  26. }]]
  27. }
  28. }]
  29. }
  30. }
  31. //package.json的script部分
  32. "scripts": {
  33. "dev": "npm-run-all --parallel dev:**",
  34. "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
  35. "dev:build:server": "webpack --config webpack.server.js --watch",
  36. "dev:build:client": "webpack --config webpack.client.js --watch"
  37. },

在这里需要开启express的静态文件服务:

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

现在前端的script就能拿到控制浏览器的JS代码啦。

绑定事件完成!

现在来初步总结一下同构代码执行的流程:

640?wx_fmt=png

二.同构中的路由问题

现在写一个路由的配置文件:

  1. // Routes.js
  2. import React from 'react';
  3. import {Route} from 'react-router-dom'
  4. import Home from './containers/Home';
  5. import Login from './containers/Login'
  6. export default (
  7. <div>
  8. <Route path='/' exact component={Home}></Route>
  9. <Route path='/login' exact component={Login}></Route>
  10. </div>
  11. )

在客户端的控制代码,也就是上面写过的client/index.js中,要做相应的更改:

  1. import React from 'react';
  2. import ReactDom from 'react-dom';
  3. import { BrowserRouter } from 'react-router-dom'
  4. import Routes from '../Routes'
  5. const App = () => {
  6. return (
  7. <BrowserRouter>
  8. {Routes}
  9. </BrowserRouter>
  10. )
  11. }
  12. ReactDom.hydrate(<App />, document.getElementById('root'))

这时候控制台会报错,

640?wx_fmt=png因为在Routes.js中,每个Route组件外面包裹着一层div,但服务端返回的代码中并没有这个div,所以报错。如何去解决这个问题?需要将服务端的路由逻辑执行一遍。

  1. // server/index.js
  2. import express from 'express';
  3. import {render} from './utils';
  4. const app = express();
  5. app.use(express.static('public'));
  6. //注意这里要换成*来匹配
  7. app.get('*', function (req, res) {
  8. res.send(render(req));
  9. });
  10. app.listen(3001, () => {
  11. console.log('listen:3001')
  12. });
  1. // server/utils.js
  2. import Routes from '../Routes'
  3. import { renderToString } from 'react-dom/server';
  4. //重要是要用到StaticRouter
  5. import { StaticRouter } from 'react-router-dom';
  6. import React from 'react'
  7. export const render = (req) => {
  8. //构建服务端的路由
  9. const content = renderToString(
  10. <StaticRouter location={req.path} >
  11. {Routes}
  12. </StaticRouter>
  13. );
  14. return `
  15. <html>
  16. <head>
  17. <title>ssr</title>
  18. </head>
  19. <body>
  20. <div id="root">${content}</div>
  21. <script src="/index.js"></script>
  22. </body>
  23. </html>
  24. `
  25. }

现在路由的跳转就没有任何问题啦。注意,这里仅仅是一级路由的跳转,多级路由的渲染在之后的系列中会用react-router-config中renderRoutes来处理。

part3: 同构项目中引入Redux

这一节主要是讲述Redux如何被引入到同构项目中以及其中需要注意的问题。

重新回顾一下redux的运作流程:

640?wx_fmt=png再回顾一下同构的概念,即在React代码客户端和服务器端各自运行一遍。

一、创建全局store

现在开始创建store。在项目根目录的store文件夹(总的store)下:

  1. import {createStore, applyMiddleware, combineReducers} from 'redux';
  2. import thunk from 'redux-thunk';
  3. import { reducer as homeReducer } from '../containers/Home/store';
  4. //合并项目组件中store的reducer
  5. const reducer = combineReducers({
  6. home: homeReducer
  7. })
  8. //创建store,并引入中间件thunk进行异步操作的管理
  9. const store = createStore(reducer, applyMiddleware(thunk));
  10. //导出创建的store
  11. export default store

二、组件内action和reducer的构建

Home文件夹下的工程文件结构如下:

640?wx_fmt=png在Home的store目录下的各个文件代码示例:

  1. //constants.js
  2. export const CHANGE_LIST = 'HOME/CHANGE_LIST';
  1. //actions.js
  2. import axios from 'axios';
  3. import { CHANGE_LIST } from "./constants";
  4. //普通action
  5. const changeList = list => ({
  6. type: CHANGE_LIST,
  7. list
  8. });
  9. //异步操作的action(采用thunk中间件)
  10. export const getHomeList = () => {
  11. return (dispatch) => {
  12. return axios.get('xxx')
  13. .then((res) => {
  14. const list = res.data.data;
  15. console.log(list)
  16. dispatch(changeList(list))
  17. });
  18. };
  19. }
  1. //reducer.js
  2. import { CHANGE_LIST } from "./constants";
  3. const defaultState = {
  4. name: 'sanyuan',
  5. list: []
  6. }
  7. export default (state = defaultState, action) => {
  8. switch(action.type) {
  9. default:
  10. return state;
  11. }
  12. }
  1. //index.js
  2. import reducer from "./reducer";
  3. //这么做是为了导出reducer让全局的store来进行合并
  4. //那么在全局的store下的index.js中只需引入Home/store而不需要Home/store/reducer.js
  5. //因为脚手架会自动识别文件夹下的index文件
  6. export {reducer}

三、组件连接全局store

下面是Home组件的编写示例。

  1. import React, { Component } from 'react';
  2. import { connect } from 'react-redux';
  3. import { getHomeList } from './store/actions'
  4. class Home extends Component {
  5. render() {
  6. const { list } = this.props
  7. return list.map(item => <div key={item.id}>{item.title}</div>)
  8. }
  9. }
  10. const mapStateToProps = state => ({
  11. list: state.home.newsList,
  12. })
  13. const mapDispatchToProps = dispatch => ({
  14. getHomeList() {
  15. dispatch(getHomeList());
  16. }
  17. })
  18. //连接store
  19. export default connect(mapStateToProps, mapDispatchToProps)(Home);

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

客户端:

  1. //src/client/index.js
  2. import React from 'react';
  3. import ReactDom from 'react-dom';
  4. import {BrowserRouter, Route} from 'react-router-dom';
  5. import { Provider } from 'react-redux';
  6. import store from '../store'
  7. import routes from '../routes.js'
  8. const App = () => {
  9. return (
  10. <Provider store={store}>
  11. <BrowserRouter>
  12. {routes}
  13. </BrowserRouter>
  14. </Provider>
  15. )
  16. }
  17. ReactDom.hydrate(<App />, document.getElementById('root'))

服务端:

  1. //src/server/index.js的内容保持不变
  2. //下面是src/server/utils.js
  3. import Routes from '../Routes'
  4. import { renderToString } from 'react-dom/server';
  5. import { StaticRouter } from 'react-router-dom';
  6. import { Provider } from 'react-redux';
  7. import React from 'react'
  8. export const render = (req) => {
  9. const content = renderToString(
  10. <Provider store={store}>
  11. <StaticRouter location={req.path} >
  12. {Routes}
  13. </StaticRouter>
  14. </Provider>
  15. );
  16. return `
  17. <html>
  18. <head>
  19. <title>ssr</title>
  20. </head>
  21. <body>
  22. <div id="root">${content}</div>
  23. <script src="/index.js"></script>
  24. </body>
  25. </html>
  26. `
  27. }

四、潜在的坑

其实上面这样的store创建方式是存在问题的,什么原因呢?

上面的store是一个单例,当这个单例导出去后,所有的用户用的是同一份store,这是不应该的。那么这么解这个问题呢?

在全局的store/index.js下修改如下:

  1. //导出部分修改
  2. export default () => {
  3. return createStore(reducer, applyMiddleware(thunk))
  4. }

这样在客户端和服务端的js文件引入时其实引入了一个函数,把这个函数执行就会拿到一个新的store,这样就能保证每个用户访问时都是用的一份新的store。

part4: 异步数据的服务端渲染方案(数据注水与脱水)

一、问题引入

在平常客户端的React开发中,我们一般在组件的componentDidMount生命周期函数进行异步数据的获取。但是,在服务端渲染中却出现了问题。

现在我在componentDidMount钩子函数中进行Ajax请求:

  1. import { getHomeList } from './store/actions'
  2. //......
  3. componentDidMount() {
  4. this.props.getList();
  5. }
  6. //......
  7. const mapDispatchToProps = dispatch => ({
  8. getList() {
  9. dispatch(getHomeList());
  10. }
  11. })
  1. //actions.js
  2. import { CHANGE_LIST } from "./constants";
  3. import axios from 'axios'
  4. const changeList = list => ({
  5. type: CHANGE_LIST,
  6. list
  7. })
  8. export const getHomeList = () => {
  9. return dispatch => {
  10. //另外起的本地的后端服务
  11. return axiosInstance.get('localhost:4000/api/news.json')
  12. .then((res) => {
  13. const list = res.data.data;
  14. dispatch(changeList(list))
  15. })
  16. }
  17. }
  18. //reducer.js
  19. import { CHANGE_LIST } from "./constants";
  20. const defaultState = {
  21. name: 'sanyuan',
  22. list: []
  23. }
  24. export default (state = defaultState, action) => {
  25. switch(action.type) {
  26. case CHANGE_LIST:
  27. const newState = {
  28. ...state,
  29. list: action.list
  30. }
  31. return newState
  32. default:
  33. return state;
  34. }
  35. }

好,现在启动服务。

640?wx_fmt=png现在页面能够正常渲染,但是打开网页源代码。

640?wx_fmt=png源代码里面并没有这些列表数据啊!那这是为什么呢?

让我们来分析一下客户端和服务端的运行流程,当浏览器发送请求时,服务器接受到请求,这时候服务器和客户端的store都是空的,紧接着客户端执行componentDidMount生命周期中的函数,获取到数据并渲染到页面,然而服务器端始终不会执行componentDidMount,因此不会拿到数据,这也导致服务器端的store始终是空的。换而言之,关于异步数据的操作始终只是客户端渲染。

现在的工作就是让服务端将获得数据的操作执行一遍,以达到真正的服务端渲染的效果。

二、改造路由

在完成这个方案之前需要改造一下原有的路由,也就是routes.js

  1. import Home from './containers/Home';
  2. import Login from './containers/Login';
  3. export default [
  4. {
  5. path: "/",
  6. component: Home,
  7. exact: true,
  8. loadData: Home.loadData,//服务端获取异步数据的函数
  9. key: 'home'
  10. },
  11. {
  12. path: '/login',
  13. component: Login,
  14. exact: true,
  15. key: 'login'
  16. }
  17. }];

此时客户端和服务端中编写的JSX代码也发生了相应变化

  1. //客户端
  2. //以下的routes变量均指routes.js导出的数组
  3. <Provider store={store}>
  4. <BrowserRouter>
  5. <div>
  6. {
  7. routers.map(route => {
  8. <Route {...route} />
  9. })
  10. }
  11. </div>
  12. </BrowserRouter>
  13. </Provider>
  1. //服务端
  2. <Provider store={store}>
  3. <StaticRouter>
  4. <div>
  5. {
  6. routers.map(route => {
  7. <Route {...route} />
  8. })
  9. }
  10. </div>
  11. </StaticRouter>
  12. </Provider>

其中配置了一个loadData参数,这个参数代表了服务端获取数据的函数。每次渲染一个组件获取异步数据时,都会调用相应组件的这个函数。因此,在编写这个函数具体的代码之前,我们有必要想清楚如何来针对不同的路由来匹配不同的loadData函数。

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

  1. import { matchRoutes } from 'react-router-config';
  2. //调用matchRoutes用来匹配当前路由(支持多级路由)
  3. const matchedRoutes = matchRoutes(routes, req.path)
  4. //promise对象数组
  5. const promises = [];
  6. matchedRoutes.forEach(item => {
  7. //如果这个路由对应的组件有loadData方法
  8. if (item.route.loadData) {
  9. //那么就执行一次,并将store传进去
  10. //注意loadData函数调用后需要返回Promise对象
  11. promises.push(item.route.loadData(store))
  12. }
  13. })
  14. Promise.all(promises).then(() => {
  15. //此时该有的数据都已经到store里面去了
  16. //执行渲染的过程(res.send操作)
  17. }
  18. )

现在就可以安心的写我们的loadData函数,其实前面的铺垫工作做好后,这个函数是相当容易的。

  1. import { getHomeList } from './store/actions'
  2. Home.loadData = (store) => {
  3. return store.dispatch(getHomeList())
  4. }
  1. //actions.js
  2. export const getHomeList = () => {
  3. return dispatch => {
  4. return axios.get('xxxx')
  5. .then((res) => {
  6. const list = res.data.data;
  7. dispatch(changeList(list))
  8. })
  9. }
  10. }

根据这个思路,服务端渲染中异步数据的获取功能就完成啦。

三、数据的注水和脱水

其实目前做了这里还是存在一些细节问题的。比如当我将生命周期钩子里面的异步请求函数注释,现在页面中不会有任何的数据,但是打开网页源代码,却发现:

640?wx_fmt=png数据已经挂载到了服务端返回的HTML代码中。那这就说明服务端和客户端的store不同步的问题。

其实也很好理解。当服务端拿到store并获取数据后,客户端的js代码又执行一遍,在客户端代码执行的时候又创建了一个空的store,两个store的数据不能同步。

那如何才能让这两个store的数据同步变化呢?

首先,在服务端获取获取之后,在返回的html代码中加入这样一个script标签:

  1. <script>
  2. window.context = {
  3. state: ${JSON.stringify(store.getState())}
  4. }
  5. </script>

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

  1. //store/index.js
  2. import {createStore, applyMiddleware, combineReducers} from 'redux';
  3. import thunk from 'redux-thunk';
  4. import { reducer as homeReducer } from '../containers/Home/store';
  5. const reducer = combineReducers({
  6. home: homeReducer
  7. })
  8. //服务端的store创建函数
  9. export const getStore = () => {
  10. return createStore(reducer, applyMiddleware(thunk));
  11. }
  12. //客户端的store创建函数
  13. export const getClientStore = () => {
  14. const defaultState = window.context ? window.context.state : {};
  15. return createStore(reducer, defaultState, applyMiddleware(thunk));
  16. }

至此,数据的脱水和注水操作完成。但是还是有一些瑕疵,其实当服务端获取数据之后,客户端并不需要再发送Ajax请求了,而客户端的React代码仍然存在这样的浪费性能的代码。怎么办呢?

还是在Home组件中,做如下的修改:

  1. componentDidMount() {
  2. //判断当前的数据是否已经从服务端获取
  3. //要知道,如果是首次渲染的时候就渲染了这个组件,则不会重复发请求
  4. //若首次渲染页面的时候未将这个组件渲染出来,则一定要执行异步请求的代码
  5. //这两种情况对于同一组件是都是有可能发生的
  6. if (!this.props.list.length) {
  7. this.props.getHomeList()
  8. }
  9. }

一路做下来,异步数据的服务端渲染还是比较复杂的,但是难度并不是很大,需要耐心地理清思路。

至此一个比较完整的SSR框架就搭建的差不多了,但是还有一些内容需要补充,之后会继续更新的。加油吧!

part5: node作中间层及请求代码优化

一、为什么要引入node中间层?

其实任何技术都是与它的应用场景息息相关的。这里我们反复谈的SSR,其实不到万不得已我们是用不着它的,SSR所解决的最大的痛点在于SEO,但它同时带来了更昂贵的成本。不仅因为服务端渲染需要更加复杂的处理逻辑,还因为同构的过程需要服务端和客户端都执行一遍代码,这虽然对于客户端并没有什么大碍,但对于服务端却是巨大的压力,因为数量庞大的访问量,对于每一次访问都要另外在服务器端执行一遍代码进行计算和编译,大大地消耗了服务器端的性能,成本随之增加。如果访问量足够大的时候,以前不用SSR的时候一台服务器能够承受的压力现在或许要增加到10台才能抗住。痛点在于SEO,但如果实际上对SEO要求并不高的时候,那使用SSR就大可不必了。

那同样地,为什么要引入node作为中间层呢?它是处在哪两者的中间?又是解决了什么场景下的问题?

在不用中间层的前后端分离开发模式下,前端一般直接请求后端的接口。但真实场景下,后端所给的数据格式并不是前端想要的,但处于性能原因或者其他的因素接口格式不能更改,这时候需要在前端做一些额外的数据处理操作。前端来操作数据本身无可厚非,但是当数据量变得庞大起来,那么在客户端就是产生巨大的性能损耗,甚至影响到用户体验。在这个时候,node中间层的概念便应运而生。

它最终解决的前后端协作的问题。

一般的中间层工作流是这样的:前端每次发送请求都是去请求node层的接口,然后node对于相应的前端请求做转发,用node去请求真正的后端接口获取数据,获取后再由node层做对应的数据计算等处理操作,然后返回给前端。这就相当于让node层替前端接管了对数据的操作。

640?wx_fmt=png

二、SSR框架中引入中间层

在之前搭建的SSR框架中,服务端和客户端请求利用的是同一套请求后端接口的代码,但这是不科学的。

对客户端而言,最好通过node中间层。而对于这个SSR项目而言,node开启的服务器本来就是一个中间层的角色,因而对于服务器端执行数据请求而言,就可以直接请求真正的后端接口啦。

  1. //actions.js
  2. //参数server表示当前请求是否发生在node服务端
  3. const getUrl = (server) => {
  4. return server ? 'xxxx(后端接口地址)' : '/api/sanyuan.json(node接口)';
  5. }
  6. //这个server参数是Home组件里面传过来的,
  7. //在componentDidMount中调用这个action时传入false,
  8. //在loadData函数中调用时传入true, 这里就不贴组件代码了
  9. export const getHomeList = (server) => {
  10. return dispatch => {
  11. return axios.get(getUrl(server))
  12. .then((res) => {
  13. const list = res.data.data;
  14. dispatch(changeList(list))
  15. })
  16. }
  17. }

在server/index.js应拿到前端的请求做转发,这里是直接用proxy形式来做,也可以用node单独向后端发送一次HTTP请求。

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

三、请求代码优化

其实请求的代码还是有优化的余地的,仔细想想,上面的server参数其实是不用传递的。

现在我们利用axios的instance和thunk里面的withExtraArgument来做一些封装。

  1. //新建server/request.js
  2. import axios from 'axios'
  3. const instance = axios.create({
  4. baseURL: 'http://xxxxxx(服务端地址)'
  5. })
  6. export default instance
  7. //新建client/request.js
  8. import axios from 'axios'
  9. const instance = axios.create({
  10. //即当前路径的node服务
  11. baseURL: '/'
  12. })
  13. export default instance

然后对全局下store的代码做一个微调:

  1. import {createStore, applyMiddleware, combineReducers} from 'redux';
  2. import thunk from 'redux-thunk';
  3. import { reducer as homeReducer } from '../containers/Home/store';
  4. import clientAxios from '../client/request';
  5. import serverAxios from '../server/request';
  6. const reducer = combineReducers({
  7. home: homeReducer
  8. })
  9. export const getStore = () => {
  10. //让thunk中间件带上serverAxios
  11. return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios)));
  12. }
  13. export const getClientStore = () => {
  14. const defaultState = window.context ? window.context.state : {};
  15. //让thunk中间件带上clientAxios
  16. return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));
  17. }

现在Home组件中请求数据的action无需传参,actions.js中的请求代码如下:

  1. export const getHomeList = () => {
  2. //返回函数中的默认第三个参数是withExtraArgument传进来的axios实例
  3. return (dispatch, getState, axiosInstance) => {
  4. return axiosInstance.get('/api/sanyuan.json')
  5. .then((res) => {
  6. const list = res.data.data;
  7. console.log(res)
  8. dispatch(changeList(list))
  9. })
  10. }
  11. }

至此,代码优化就做的差不多了,这种代码封装的技巧其实可以用在其他的项目当中,其实还是比较优雅的。

part6: 多级路由渲染(renderRoutes)

现在将routes.js的内容改变如下:

  1. import Home from './containers/Home';
  2. import Login from './containers/Login';
  3. import App from './App'
  4. //这里出现了多级路由
  5. export default [{
  6. path: '/',
  7. component: App,
  8. routes: [
  9. {
  10. path: "/",
  11. component: Home,
  12. exact: true,
  13. loadData: Home.loadData,
  14. key: 'home',
  15. },
  16. {
  17. path: '/login',
  18. component: Login,
  19. exact: true,
  20. key: 'login',
  21. }
  22. ]
  23. }]

现在的需求是让页面公用一个Header组件,App组件编写如下:

  1. import React from 'react';
  2. import Header from './components/Header';
  3. const App = (props) => {
  4. console.log(props.route)
  5. return (
  6. <div>
  7. <Header></Header>
  8. </div>
  9. )
  10. }
  11. export default App;

对于多级路由的渲染,需要服务端和客户端各执行一次。因此编写的JSX代码都应有所实现:

  1. //routes是指routes.js中返回的数组
  2. //服务端:
  3. <Provider store={store}>
  4. <StaticRouter location={req.path} >
  5. <div>
  6. {renderRoutes(routes)}
  7. </div>
  8. </StaticRouter>
  9. </Provider>
  10. //客户端:
  11. <Provider store={getClientStore()}>
  12. <BrowserRouter>
  13. <div>
  14. {renderRoutes(routes)}
  15. </div>
  16. </BrowserRouter>
  17. </Provider>

这里都用到了renderRoutes方法,其实它的工作非常简单,就是根据url渲染一层路由的组件(这里渲染的是App组件),然后将下一层的路由通过props传给目前的App组件,依次循环。

那么,在App组件就能通过props.route.routes拿到下一层路由进行渲染:

  1. import React from 'react';
  2. import Header from './components/Header';
  3. //增加renderRoutes方法
  4. import { renderRoutes } from 'react-router-config';
  5. const App = (props) => {
  6. console.log(props.route)
  7. return (
  8. <div>
  9. <Header></Header>
  10. <!--拿到Login和Home组件的路由-->
  11. {renderRoutes(props.route.routes)}
  12. </div>
  13. )
  14. }
  15. export default App;

至此,多级路由的渲染就完成啦。

part7: CSS的服务端渲染思路(context钩子变量)

一、客户端项目中引入CSS

还是以Home组件为例

  1. //Home/style.css
  2. body {
  3. background: gray;
  4. }

现在,在Home组件代码中引入:

import styles from './style.css';

要知道这样的引入CSS代码的方式在一般环境下是运行不起来的,需要在webpack中做相应的配置。首先安装相应的插件。

npm install style-loader css-loader --D
  1. //webpack.client.js
  2. const path = require('path');
  3. const merge = require('webpack-merge');
  4. const config = require('./webpack.base');
  5. const clientConfig = {
  6. mode: 'development',
  7. entry: './src/client/index.js',
  8. module: {
  9. rules: [{
  10. test: /\.css?$/,
  11. use: ['style-loader', {
  12. loader: 'css-loader',
  13. options: {
  14. modules: true
  15. }
  16. }]
  17. }]
  18. },
  19. output: {
  20. filename: 'index.js',
  21. path: path.resolve(__dirname, 'public')
  22. },
  23. }
  24. module.exports = merge(config, clientConfig);
  1. //webpack.base.js代码,回顾一下,配置了ES语法相关的内容
  2. module.exports = {
  3. module: {
  4. rules: [{
  5. test: /\.js$/,
  6. loader: 'babel-loader',
  7. exclude: /node_modules/,
  8. options: {
  9. presets: ['@babel/preset-react', ['@babel/preset-env', {
  10. targets: {
  11. browsers: ['last 2 versions']
  12. }
  13. }]]
  14. }
  15. }]
  16. }
  17. }
'
运行

好,现在在客户端CSS已经产生了效果。640?wx_fmt=png可是打开网页源代码:

640?wx_fmt=png咦?里面并没有出现任何有关CSS样式的代码啊!那这是什么原因呢?很简单,其实我们的服务端的CSS加载还没有做。接下来我们来完成CSS代码的服务端的处理。

二、服务端CSS的引入

首先,来安装一个webpack的插件,

npm install -D isomorphic-style-loader

然后再webpack.server.js中做好相应的css配置:

  1. //webpack.server.js
  2. const path = require('path');
  3. const nodeExternals = require('webpack-node-externals');
  4. const merge = require('webpack-merge');
  5. const config = require('./webpack.base');
  6. const serverConfig = {
  7. target: 'node',
  8. mode: 'development',
  9. entry: './src/server/index.js',
  10. externals: [nodeExternals()],
  11. module: {
  12. rules: [{
  13. test: /\.css?$/,
  14. use: ['isomorphic-style-loader', {
  15. loader: 'css-loader',
  16. options: {
  17. modules: true
  18. }
  19. }]
  20. }]
  21. },
  22. output: {
  23. filename: 'bundle.js',
  24. path: path.resolve(__dirname, 'build')
  25. }
  26. }
  27. module.exports = merge(config, serverConfig);

它做了些什么事情?

再看看这行代码:

import styles from './style.css';

引入css文件时,这个isomorphic-style-loader帮我们在styles中挂了三个函数。输出styles看看:

640?wx_fmt=png现在我们的目标是拿到CSS代码,直接通过styles._getCss即可获得。

那我们拿到CSS代码后放到哪里呢?其实react-router-dom中的StaticRouter中已经帮我们准备了一个钩子变量context。如下

  1. //context从外界传入
  2. <StaticRouter location={req.path} context={context}>
  3. <div>
  4. {renderRoutes(routes)}
  5. </div>
  6. </StaticRouter>

这就意味着在路由配置对象routes中的组件都能在服务端渲染的过程中拿到这个context,而且这个context对于组件来说,就相当于组件中的props.staticContext。并且,这个props.staticContext只会在服务端渲染的过程中存在,而客户端渲染的时候不会被定义。这就让我们能够通过这个变量来区分两种渲染环境啦。

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

let context = { css: [] }'
运行

我们只需要在组件的componentWillMount生命周期中编写相应的逻辑即可:

  1. componentWillMount() {
  2. //判断是否为服务端渲染环境
  3. if (this.props.staticContext) {
  4. this.props.staticContext.css.push(styles._getCss())
  5. }
  6. }

服务端的renderToString执行完成后,context的CSS现在已经是一个有内容的数组,让我们来获取其中的CSS代码:

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

现在挂载到页面:

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

640?wx_fmt=png网页源代码中看到了CSS代码,效果也没有问题。CSS渲染完成!

三、利用高阶组件优化代码

也许你已经发现,对于每一个含有样式的组件,都需要在componentWillMount生命周期中执行完全相同的逻辑,对于这些逻辑我们是否能够把它封装起来,不用反复出现呢?

其实是可以实现的。利用高阶组件就可以完成:

  1. //根目录下创建withStyle.js文件
  2. import React, { Component } from 'react';
  3. //函数返回组件
  4. //需要传入的第一个参数是需要装饰的组件
  5. //第二个参数是styles对象
  6. export default (DecoratedComponent, styles) => {
  7. return class NewComponent extends Component {
  8. componentWillMount() {
  9. //判断是否为服务端渲染过程
  10. if (this.props.staticContext) {
  11. this.props.staticContext.css.push(styles._getCss())
  12. }
  13. }
  14. render() {
  15. return <DecoratedComponent {...this.props} />
  16. }
  17. }
  18. }

然后让这个导出的函数包裹我们的Home组件。

  1. import WithStyle from '../../withStyle';
  2. //......
  3. const exportHome = connect(mapStateToProps, mapDispatchToProps)(withStyle(Home, styles));
  4. export default exportHome;

这样是不是简洁很多了呢?将来对于越来越多的组件,采用这种方式也是完全可以的。

part8: 做好SEO的一些技巧,引入react-helmet

这一节我们来简单的聊一点SEO相关的内容。

一、SEO技巧分享

所谓SEO(Search Engine Optimization),指的是利用搜索引擎的规则提高网站在有关搜索引擎内的自然排名。现在的搜索引擎爬虫一般是全文分析的模式,分析内容涵盖了一个网站主要3个部分的内容:文本、多媒体(主要是图片)和外部链接,通过这些来判断网站的类型和主题。因此,在做SEO优化的时候,可以围绕这三个角度来展开。

对于文本来说,尽量不要抄袭已经存在的文章,以写技术博客为例,东拼西凑抄来的文章排名一般不会高,如果需要引用别人的文章要记得声明出处,不过最好是原创,这样排名效果会比较好。多媒体包含了视频、图片等文件形式,现在比较权威的搜索引擎爬虫比如Google做到对图片的分析是基本没有问题的,因此高质量的图片也是加分项。另外是外部链接,也就是网站中a标签的指向,最好也是和当前网站相关的一些链接,更容易让爬虫分析。

当然,做好网站的门面,也就是标题和描述也是至关重要的。如:

640?wx_fmt=png网站标题中不仅仅包含了关键词,而且有比较详细和靠谱的描述,这让用户一看到就觉得非常亲切和可靠,有一种想要点击的冲动,这就表明网站的转化率比较高。

二、引入react-helmet

而React项目中,开发的是单页面的应用,页面始终只有一份title和description,如何根据不同的组件显示来对应不同的网站标题和描述呢?

其实是可以做到的。

npm install react-helmet --save

组件代码:(还是以Home组件为例)

  1. import { Helmet } from 'react-helmet';
  2. //...
  3. render() {
  4. return (
  5. <Fragment>
  6. <!--Helmet标签中的内容会被放到客户端的head部分-->
  7. <Helmet>
  8. <title>这是三元的技术博客,分享前端知识</title>
  9. <meta name="description" content="这是三元的技术博客,分享前端知识"/>
  10. </Helmet>
  11. <div className="test">
  12. {
  13. this.getList()
  14. }
  15. </div>
  16. </Fragment>
  17. );
  18. //...

这只是做了客户端的部分,在服务端仍需要做相应的处理。

其实也非常简单:

  1. //server/utils.js
  2. import { renderToString } from 'react-dom/server';
  3. import { StaticRouter } from 'react-router-dom';
  4. import React from 'react';
  5. import { Provider } from "react-redux";
  6. import { renderRoutes } from 'react-router-config';
  7. import { Helmet } from 'react-helmet';
  8. export const render = (store, routes, req, context) => {
  9. const content = renderToString(
  10. <Provider store={store}>
  11. <StaticRouter location={req.path} context={context}>
  12. <div>
  13. {renderRoutes(routes)}
  14. </div>
  15. </StaticRouter>
  16. </Provider>
  17. );
  18. //拿到helmet对象,然后在html字符串中引入
  19. const helmet = Helmet.renderStatic();
  20. const cssStr = context.css.length ? context.css.join('\n') : '';
  21. return `
  22. <html>
  23. <head>
  24. <style>${cssStr}</style>
  25. ${helmet.title.toString()}
  26. ${helmet.meta.toString()}
  27. </head>
  28. <body>
  29. <div id="root">${content}</div>
  30. <script>
  31. window.context = {
  32. state: ${JSON.stringify(store.getState())}
  33. }
  34. </script>
  35. <script src="/index.js"></script>
  36. </body>
  37. </html>
  38. `
  39. };

现在来看看效果:

640?wx_fmt=png网页源代码中显示出对应的title和description, 客户端的显示也没有任何问题,大功告成!

关于React的服务端渲染原理,就先分享到这里,内容还是比较复杂的,对于前端的综合能力要求也比较高,但是坚持跟着学下来,一定会大有裨益的。相信你看了这一系列之后也有能力造出自己的SSR轮子,更加深刻地理解这一方面的技术。

参考资料:

慕课网《React服务器渲染原理解析与实践》课程

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/weixin_40725706/article/detail/971573
推荐阅读
相关标签
  

闽ICP备14008679号