# 慕课实战:React服务器渲染原理解析与实践

# 课程笔记

# 课程简介

  • SSR简介
  • 搭建React SSR框架,解决CSR的问题
  • 在框架中如何实现同构
  • 框架中路由机制的实现
  • 框架与Redux的融合
  • 框架作为中间层的只能处理
  • 细节调优
  • 样式相关的webpack配置
  • 框架SEO特性优化
  • 扩展:预渲染技术

# 第一章 服务器端渲染基础

# 什么是服务端渲染SSR

  • 创建服务器,比如用express
  • 启动服务器
  • 服务器直接通过 res.send(<html><title>SSR</title></html>) 返回显示内容
    • 即显示内容在服务器直接生成了,浏览器直接显示

# 什么是客户端渲染CSR

  • 即服务端返回只有一个页面架子,一个div id="root"的标签,页面中具体内容都是没有的
  • 通过引用bundle.js文件,加载页面内容

# React客户端渲染的优劣势

  • CSR优势:前后端分离,各自负责自己的事情,提升团队开发效率:
    • 「前端」 --Ajax--> 「后端」
    • 「前端」 <--JSON-- 「后端」
  • CSR劣势:首屏等待时间较长(TTFP)、SEO(搜索引擎优化)支持不好
    • CSR要经过 HTML下载、JS文件下载、运行JS代码(比如React代码)、React会渲染页面;而SSR从服务器直接下载HTML文档并渲染 CSRvsSSR
    • 只有SSR服务端渲染的页面才可能在搜索引擎中有好的排名,因为搜索引擎爬虫可以拿到html中的内容
    • 而CSR客户端渲染的html中只有一个root,没有内容,所以react和vue的CSR页面是不可能有好的SEO支持的

# 第二章 React中的服务端渲染

# 在服务器端编写React组件

CSR流程是:

  • 浏览器发送请求
  • 服务器返回HTML
  • 浏览器发送bundle.js请求
  • 服务器返回bundle.js
  • 浏览器执行bundle.js中的React代码

SSR流程是:

  • 浏览器发送请求
  • 服务器运行React代码生成页面
  • 服务器返回页面

因此,react代码相当于后端代码的一部分

# 服务器端Webpack的配置

webpack-node-externals,此模块用于SSR中,使webpack选择性打包。

// webpack.server.js
const path = require('path');
const nodeExternals = require('webpack-node-externals')

module.exports = {
    target: 'node',
    mode: 'development',
    entry: './src/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']
                    }
                }]]
            }
        }]
    }
}

# 实现服务器端组件渲染

import express from 'express'
import Home from './containers/Home'
import React from 'react' // 因为<Home /> 是jsx,所以要引入react
import { renderToString } from 'react-dom/server' // 直接res.send(Home)是不能渲染的,客户端用render,服务器端就要用renderToString转成字符串

const app = express()

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

const content = renderToString(<Home />)

app.get('/', (req, res) => {
    res.send(`
        <html>
            <head>
                <title>ssr</title>
            </head>
            <body>
                ${content}
            </body>
        </html>
    `)
})

var server = app.listen(3000)

# 建立在虚拟DOM上的服务器端渲染

SSR的基础是因为虚拟DOM,有了虚拟DOM,服务器才能把VDOM的js对象转成字符串返回给浏览器,如果没有虚拟DOM,只有真实DOM的话,那服务器端是没有真实DOM的,所以不能再服务器端渲染。

SSR有什么劣势呢?

  • 客户端渲染,react代码在浏览器上执行,消耗的是用户浏览器的性能;而服务器端渲染,react代码在服务器上执行,消耗的是服务器端的性能,可能原来CSR只要一台服务器,但SSR可能要用十台服务器,给公司增加了非常大的成本。
  • 所以没有必要的情况下是不建议用服务端渲染的。

# Webpack的自动打包与服务器自动重启

"dev:build": "webpack --config webpack.server.js --watch"

加了--watch之后,webpack会监听入口文件和所有引入的依赖文件,一旦变化就重新build。

但是虽然动态build了,但是没有重启,新的内容就无法实时显示在页面上,所以还需要使用nodemon(node monitor)。

npm install nodemon -g
"dev:start": "nodemon --watch build --exec node \"./build/bundle.js\""

因此,只要源代码改变,就会监听生成新的bundle文件,而只要build目录下的文件变了,比如bundle变了,nodemon就会监听到build文件夹的变化,并且重新执行bundle.js。这样就能实现实时生效页面代码的效果(这里的两步需要启动两个控制台窗口,并且还需要手动刷新页面,还不够完美)。

# 使用 npm-run-all 提升开发效率

能不能不用分开控制台执行两条命令呢,可以使用npm-run-all插件来实现:

"dev": "npm-run-all --parallel dev:**"

# 第三章 同构的概念梳理

# 什么是同构

renderToString方法,对于组件中的事件,是不会渲染出来的,所以就要用到「同构」技术。即服务器端先把组件渲染出来,然后在浏览器端,相同的代码再执行一遍(添加上事件)

同构:一套React代码,在服务器端执行一次,在客户端再执行一次。

# 在浏览器上执行一段js代码

// server.js
import express from 'express'
import Home from './containers/Home'
import React from 'react' // 因为<Home /> 是jsx,所以要引入react
import { renderToString } from 'react-dom/server' // 直接res.send(Home)是不能渲染的,客户端用render,服务器端就要用renderToString转成字符串

const app = express()
app.use(express.static('public')) // 通过express.static中间件添加静态资源访问路由,对于所有静态资源,比如js文件,json文件,都去public目录中去查找

const content = renderToString(<Home />)

app.get('/', (req, res) => {
    res.send(`
        <html>
            <head>
                <title>ssr</title>
            </head>
            <body>
                <div id="root">${content}</div>
                <script src='/index.js'></script>  // 引用一个js文件,如果不用express.static,则因为没有index.js文件的路由会报错
            </body>
        </html>
    `)
})

var server = app.listen(3000)

# 让React代码在浏览器上运行

// webpack.client.js
const path = require('path');

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

打包时也应该把client端的代码也打包:

"dev": "npm-run-all --parallel dev:**"
"dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
"dev:build:server": "webpack --config webpack.server.js --watch",
"dev:build:client": "webpack --config webpack.client.js --watch"
// client/index.js
import React from "react";
import ReactDOM from "react-dom";

import Home from "../containers/Home";

// 同构,不能使用 ReactDOM.render,而应该使用 ReactDOM.hydrate渲染
ReactDOM.hydrate(<Home />, document.getElementById('root'))

这样,相同的代码在客户端也运行了一遍,事件就被添加上了,js中的react代码接管了页面操作。

# 工程代码优化整理

  1. 通过webpack-merge和添加webpack.base.js文件来优化webpack配置文件,提取相同部分。
  2. 目录结构优化,区分server目录和client目录

# 第四章 在SSR框架中引入路由机制

# 服务器端渲染中的路由

  • 服务器端返回HTML

  • 发送HTML给浏览器

  • 浏览器接收到内容展示

  • 浏览器加载JS文件

  • JS中的React代码在浏览器端执行

  • JS中的React代码接管页面操作

  • JS代码拿到浏览器上的地址

  • JS代码根据地址返回不同的路由地址

  • StaticRouter

  • BrowserRouter

// 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 path='/login' exact component={Login} />
    </div>
)

首先让路由在客户端跑一遍:

// client/index.js
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'))

路由要统一,所以也要在服务器端也跑一次:

// server.js
import express from 'express'
import React from 'react' // 因为<Home /> 是jsx,所以要引入react
import { renderToString } from 'react-dom/server' // 直接res.send(Home)是不能渲染的,客户端用render,服务器端就要用renderToString转成字符串
import { StaticRouter } from 'react-router-dom'
import Home from './containers/Home'
import Routes from '../Routes'

const app = express()
app.use(express.static('public')) // 通过express.static中间件添加静态资源访问路由,对于所有静态资源,比如js文件,json文件,都去public目录中去查找

app.get('/', (req, res) => {
    const content = renderToString((
        <StaticRouter location={req.path} context={{}}>
            {Routes}
        </StaticRouter>
    ))

    res.send(`
        <html>
            <head>
                <title>ssr</title>
            </head>
            <body>
                <div id="root">${content}</div>
                <script src='/index.js'></script>  // 引用一个js文件,如果不用express.static,则因为没有index.js文件的路由会报错
            </body>
        </html>
    `)
})

var server = app.listen(3000)
  1. 使用<StaticRouter context={{}}>时,必须传一个context的对象,做服务端渲染时,获取数据的。
  2. StaticRouter是服务器端路由,不像客户端路由BrowserRouter可以感知到浏览器当前路径url变化,因此需要拿到用户请求的req(location={req.path}),传递给它当前请求的路径是什么。

# 多页面路由跳转

添加一个login页面,需要将路由匹配改为*号:

app.get('/', (req, res) => {}
// 更改为:
app.get('*', (req, res) => {}

# 使用Link标签串联起整个路由流程

  • 服务器端渲染,只发生在第一次进入页面的时候,之后的路由跳转,不再加载任何东西。
  • 即之后点击link标签跳转路由,不是服务器端的跳转,而是浏览器端的路由跳转。浏览器加载bundle.js文件后,JS中的React代码接管页面操作,与服务端渲染就没有关系了。
  • 即只有第一次访问的页面是服务端渲染,之后的页面都是react的路由机制。

# 第五章 SSR框架与Redux的结合

# 中间层是什么

中间层就是node-server

三者的职责分工明确:

  • 浏览器负责执行js,node-server负责拼接页面,java-server负责做计算和数据操作以提升性能。
  • 「浏览器 + React」 ---- 「Node Server + React」---- 「Java Server或其他负责计算的服务器」 中间层

# 在同构的项目中引入Redux

需要再client路由和server路由中均创建store和传给组件

// store/index.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

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

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

export default store;

WARNING

!!!如果直接导出这个store,它是个单例,则服务器上只有这个一个store,所有用户使用的是相同的这个store,被所有用户共享。

改为导出一个创建store的方法,每次生成自己的store。

// store/index.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

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

const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk));
}

export default getStore;
// client/index.js
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from 'react-router-dom'
import Routes from '../Routes'
import getStore from '../store'
import { Provider } from 'react-redux'

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

ReactDOM.hydrate(<App />, document.getElementById('root'))
// server.js
import express from 'express'
import React from 'react' // 因为<Home /> 是jsx,所以要引入react
import { renderToString } from 'react-dom/server' // 直接res.send(Home)是不能渲染的,客户端用render,服务器端就要用renderToString转成字符串
import { StaticRouter } from 'react-router-dom'
import Home from './containers/Home'
import Routes from '../Routes'
import getStore from '../store'
import { Provider } from 'react-redux'

const app = express()
app.use(express.static('public')) // 通过express.static中间件添加静态资源访问路由,对于所有静态资源,比如js文件,json文件,都去public目录中去查找

app.get('*', (req, res) => {
    const content = renderToString((
        <Provider store={getStore()}>
            <StaticRouter location={req.path} context={{}}>
                {Routes}
            </StaticRouter>
        </Provider>
    ))

    res.send(`
        <html>
            <head>
                <title>ssr</title>
            </head>
            <body>
                <div id="root">${content}</div>
                <script src='/index.js'></script>  // 引用一个js文件,如果不用express.static,则因为没有index.js文件的路由会报错
            </body>
        </html>
    `)
})

var server = app.listen(3000)

# 构建 Redux 代码结构

(这部分数据获取,只是在客户端完成的,componentDidMount在服务器端是不执行的,所以本部分没有完成SSR)

// reducer.js
const defaultState = {
    newsList: []
}

export default (state = defaultState, action) => {
    switch(action.type) {
        case 'change_home_list':
            const newState = {
                ...state,
                newsList: action.list
            }
            return newState
        default:
            return state;
    }
}
// actions.js
import axios from 'axios';

const changeList = (list) => ({
    type: 'change_home_list',
    list
})

export const getHomeList = () => {
    return (dispatch) => {
        return axios.get('http://47.95.113.63/ssr/api/news.json?secret=abcd')
            .then((res) => {
                const list = res.data.data;
                dispatch(changeList(list))
            })
    }
}
// Home
import React, { Component } from 'react';
import Header from '../../components/Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {
    render() {
        return (
            <div>
                <Header />
                <div>This is {this.props.name}</div>
                {
                    this.props.list.map((item) => {
                        return <div key={item.id}>{item.title}</div>
                    })
                }
                <button onClick={() => {alert('click1')}}>
                    click
                </button>
            </div>
        )
    }

    // componentDidMount在服务器端是不执行的
    componentDidMount() {
        this.props.getHomeList();
    }
}

const mapStateToProps = state => ({
    list: state.home.newsList,
    name: state.home.name
})

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList())
    }
})

export default connect(mapStateToProps, mapDispatchToProps)(Home);
// store/index.js
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/Home/store'

const reducer = combineReducers({
    home: homeReducer
})

const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk));
}

export default getStore;

# 上阶段流程回顾及问题分析

上阶段其实只是实现了「部分服务端渲染,和完整的客户端渲染」。客户端获取到了数据,而服务端渲染是没有获取到数据列表的,具体流程如下:

  • (服务端渲染)
    • 1、服务器接收到请求,这个时候store是空的;
    • 2、服务器端执行Home组件代码,不会执行componentDidMount,所以列表内容获取不到;
  • (客户端渲染)
    • 3、客户端代码运行,这时候store依然是空的;
    • 4、Home组件代码在客户端重新执行一次,客户端componentDidMount会执行获取到数据;
    • 5、数据获取到,store中的列表数据被更新,触发重新render;
    • 6、客户端渲染出store中list数据对应的列表;

接下来要解决的问题,就是解决「服务器端也能获取数据的问题」

# 异步数据服务器渲染:loadData方法及路由重构

  • 第一步:添加loadData方法
// Home
import React, { Component } from 'react';
import Header from '../../components/Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {
    render() {
        return (
            <div>
                <Header />
                <div>This is {this.props.name}</div>
                {
                    this.props.list.map((item) => {
                        return <div key={item.id}>{item.title}</div>
                    })
                }
                <button onClick={() => {alert('click1')}}>
                    click
                </button>
            </div>
        )
    }

    // componentDidMount在服务器端是不执行的
    componentDidMount() {
        this.props.getHomeList();
    }
}

// 给Home组件添加静态方法
Home.loadData = () => {
    // 这个函数,负责在服务器端渲染之前,把这个路由需要的数据提前加载好
}

const mapStateToProps = state => ({
    list: state.home.newsList,
    name: state.home.name
})

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList())
    }
})

export default connect(mapStateToProps, mapDispatchToProps)(Home);
  • 第二步:改造路由
// Routes.js
import React from 'react'
import { Route } from 'react-router-dom'
import Home from './containers/Home'
import Login from './containers/Login'

export default [
  {
    path: '/',
    component: Home,
    key: 'home',
    exact: true,
    loadData: Home.loadData
  }, {
    path: '/login',
    component: Login,
    key: 'login',
    exact: true
  },
]
// server.js
import routes from '../Routes';
import { StaticRouter, Route, matchPath } from 'react-router-dom';
// ...

// 由于路由改造成了数组列表,下面的路由渲染需要map出来
app.get('*', (req, res) => {
    const store = getStore();

    // 如果在这里,我能够拿到异步数据,并填充到store之中
    // store里面到底填充什么,我们不知道,我们需要结合当前用户请求地址,和路由,做出判断
    // 如果用户访问 / 路径,我们就拿home组件的异步数据
    // 如果用户访问login路径,我们就拿login组件的异步数据

    // 根据路由的路径,来往store里面加数据,使用matchPath
    const matchRoutes = [];
    routes.some(route => {
        const match = matchPath(req.path, route);
        if (match) {
            matchRoutes.push(route)
        };
    });

    // 让matchRoutes里面所有的组件,对应的loadData方法执行一次

    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={{}}>
                {routes.map(route => (
                    <Route {...route} />
                ))}
            </StaticRouter>
        </Provider>
    ))

    // ...
})
// ...

# 多级路由匹配问题处理,服务器端渲染获取数据

// 给Home组件添加静态方法
Home.loadData = (store) => {
    // 这个函数,负责在服务器端渲染之前,把这个路由需要的数据提前加载好
    return store.dispatch(getHomeList())
}
  • 如果是多级路由,使用matchPath方法有问题,因为只能匹配一层,应该使用matchRoutes方法;
  • 另外,对应的loadData方法执行一次,因为请求是异步的,所以并不能立刻得到更新后的store,因此需要用到promise。
// server.js
import routes from '../Routes';
import { StaticRouter, Route } from 'react-router-dom';
import { matchRoutes } from 'react-router-config';
// ...

// 由于路由改造成了数组列表,下面的路由渲染需要map出来
app.get('*', (req, res) => {
    const store = getStore();

    // 根据路由的路径,来往store里面加数据,使用 matchRoutes
    const matchedRoutes = matchRoutes(routes, req.path);

    // 让matchRoutes里面所有的组件,对应的loadData方法执行一次
    const promises = []

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

    Promise.all(promises).then(() => {
        console.log(store.getState()) // 此时,store已经在异步请求后变化了,可以拿到数据变化

        const content = renderToString((
            <Provider store={getStore()}>
                <StaticRouter location={req.path} context={{}}>
                    {Routes}
                </StaticRouter>
            </Provider>
        ))

        res.send(`
            <html>
                <head>
                    <title>ssr</title>
                </head>
                <body>
                    <div id="root">${content}</div>
                    <script src='/index.js'></script>  // 引用一个js文件,如果不用express.static,则因为没有index.js文件的路由会报错
                </body>
            </html>
        `)
    })
})

// ...

现在已经实现了SSR端数据获取和渲染,流程总结:

  • 1、当用户请求一个网页,此时创建一个空的store;
  • 2、然后使用matchRoutes匹配当前路由项,并执行匹配到的路由的loadData方法
  • 3、使用Promise.all控制,所有的loadData数据获取到之后,再拼接html的内容
  • 4、res.send(content)返回给用户;

# 数据的注水和脱水

「页面闪烁:双端渲染不一致」

上面的实现有个问题在于:SSR端渲染完成后,CSR端又重新渲染了一遍,而CSR端的store一开始是空的,需要在didMount中调用接口拿到,所以CSR端一开始渲染的数据是空的,与SSR端不一致,所以「双端渲染不一致」。 等到CSR端的didMount中loadData成功后,再次渲染,所以会有页面闪烁一次的现象。

解决方法是:「注水」「脱水」

需要在SSR端获取到数据后,将数据挂在HTML中(或者window.context下),而在CSR端就不要再去请求数据了(即componentDidMount中无须调用loadData了),而是从HTML中的变量中直接去取。

  • 数据的注水:SSR端获取到的数据,挂在HTML中(或者window.context下)
  • 数据的脱水:CSR端不在componentDidMount中去调loadData了,而是直接从HTML中的变量中去取。
    // 数据注水:
    // window.context = {
    //     state: ${JSON.stringify(store.getState())}
    // }
    res.send(`
        <html>
            <head>
                <title>ssr</title>
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                    window.context = {
                        state: ${JSON.stringify(store.getState())}
                    }
                </script>
                <script src='/index.js'></script>  // 引用一个js文件,如果不用express.static,则因为没有index.js文件的路由会报错
            </body>
        </html>
    `)

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

const reducer = combineReducers({
    home: homeReducer
})

export const getStore = () => {
    return createStore(reducer, applyMiddleware(thunk));
}

// 改为提供两个方法
export const getClientStore = () => {
    const defaultState = window.context.state; // 将 defaultState 作为 reducer的默认值
    return createStore(reducer, defaultState, applyMiddleware(thunk));
}
// client/index.js
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from 'react-router-dom'
import Routes from '../Routes'
import { getClientStore } from '../store'
import { Provider } from 'react-redux'

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

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

didMount中的loadData还不能直接注释掉!

如果直接在「脱水」而把componentDidMount中的loadData注释掉,那么如果用户先访问的是非SSR页面,比如login页面(login页面没有loadData方法,所以window.context中的state是空的),之后再切到首页时,因为window.context中的state是空的,而componentDidMount中的loadData注释掉了,所以首页的数据也就拿不到了。因此didMount中的loadData还不能直接注释掉!

(因为我们的服务端渲染,指的只是访问的第一个页面是SSR的,其他页面是CSR的)

解决方法就是,条件执行

class Home extends Component {
    // ...

    // 折中方案:条件执行
    componentDidMount() {
        if (!this.props.list.length) {
            this.props.getHomeList();
        }
    }
}

至此,SSR的主要难点已经学习完,之后的章节是一些边角问题的学习和项目优化。

# 第六章 使用Node作为数据获取中间层

当前代码的三层结构代码:

  • 客户端代码:public/index.js
  • node中间层代码:build/bundle.js
  • java服务器:axios访问的地址

当客户端即CSR端在didMount中去调取loadData时,没有通过中间层做中转,而是直接向java server发送接口请求。而这不符合职责分离,应该是浏览器只跟中间层打交道,由中间层去javaserver获取数据。

本章的目标就是这个,将node server变成一个代理服务器即可。

# 使用proxy代理,让中间层承担数据获取职责

使用 express-http-proxy 中间件,负责代理转发。

// server.js
import express from 'express'
import proxy from 'express-http-proxy';

const app = express()
app.use(express.static('public')) // 通过express.static中间件添加静态资源访问路由,对于所有静态资源,比如js文件,json文件,都去public目录中去查找

// 这段儿 express-http-proxy 上有,当发现是/api开始的路由时,则代理到java server地址去
// 比如请求地址是 http://47.95.113.63/ssr/api/news.json?secret=abcd
// req.url 是 news.json
// proxyReqPathResolver 返回 :/ssr/api/news.json
// http://47.95.113.63 + proxyReqPathResolver()
app.use('/api', proxy('http://47.95.113.63', {
    proxyReqPathResolver: function (req) {
        return '/ssr/api' + req.url
    }
}));

app.get('*', (req, res) => {
// ...
// actions.js
import axios from 'axios';

// ...
export const getHomeList = () => {
    return (dispatch) => {
        // http://47.95.113.63/ssr/api/news.json?secret=abcd
        return axios.get('/api/news.json?secret=abcd')
            .then((res) => {
                const list = res.data.data;
                dispatch(changeList(list))
            })
    }
}

# 服务器端请求和客户端请求的不同处理

proxy代理有问题

上一节的代理其实有问题,因为服务端的渲染根本不能完成,页面会一直加载中。

因为axios.get('/api/news.json?secret=abcd')这段代码

  • 在客户端运行时:
    • /api/news.json 等价于 http://localhost:3000/api/news.json
  • 在服务端运行时:
    • /api/news.json 等价于 服务器根目录下/api/news.json

而服务器根目录是找不到这个接口的。

因此需要提供一个环境变量来判断是哪里调用

{
    // componentDidMount在服务器端是不执行的,只在客户端执行
    componentDidMount() {
        // 传环境变量 false,表示是客户端调用
        this.props.getHomeList(false);
    }
}

// 给Home组件添加静态方法
Home.loadData = (store) => {
    // 这个函数,负责在服务器端渲染之前,把这个路由需要的数据提前加载好
    // 传环境变量 true,表示是server端调用
    return store.dispatch(getHomeList(true))
}

// actions.js
import axios from 'axios';

// ...
export const getHomeList = (isServer) => {
    let url = ''
    if (isServer) {
        url = 'http://47.95.113.63/ssr/api/news.json?secret=abcd'
    } else {
        url = '/api/news.json?secret=abcd'
    }
    return (dispatch) => {
        return axios.get(url)
            .then((res) => {
                const list = res.data.data;
                dispatch(changeList(list))
            })
    }
}

# axios中的instance使用

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

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

export default instance;
// server/request.js
import axios from 'axios'

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

export default instance;
// actions.js
import clientAxios from '../../../client/request';
import serverAxios from '../../../server/request';

// ...
export const getHomeList = (isServer) => {
    let request = isServer ? serverAxios : clientAxios;

    return (dispatch) => {
        return request.get('/api/news.json?secret=abcd')
            .then((res) => {
                const list = res.data.data;
                dispatch(changeList(list))
            })
    }
}

# 第七章 细节问题处理

# 借助context实现404页面

// NotFound.js
import React, { Component } from 'react'

class NotFound extends Component {
    componentWillMount() {
        const { staticContext } = this.props
        staticContext && (staticContext.NOT_FOUND = true)
    }
    render() {
        return <div>404, sorry, page not found</div>
    }
}
// server/utils.js
// ...
const render = (store, routes, req, context) => {
    // 增加context的传递
    const content = renderToString((
        <Provider store={getStore()}>
            <StaticRouter location={req.path} context={context}>
                {Routes}
            </StaticRouter>
        </Provider>
    ))

    return `<html>...</html>`
}
// ...
// server.js
// ...
Promise.all(promises).then(() => {
    const context = {};
    const html = render(store, routes, req, context);
    // context 对象传入render函数中,如果是404组件,则会给context添加一个NOT_FOUND标志

    if (context.NOT_FOUND) {
        res.status(404) // 需要主动设置返回状态码,否则依然返回200
        res.send(html)
    } else {
        res.send(html)
    }
})
// ...

# 实现服务器端301重定向

在未登录,进入translation页面时,当前只做到客户端重定向回首页,而服务器端并没有重定向回首页,查看源代码,发现服务器端还是在translation页面。这是为什么呢?

// Translation.js
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { getTranslationList } from './store/actions'
import { Redirect } from 'react-router-dom'

class Translation extends Component {
    getList() {
        const { list } = this.props
        return list.map(item => <div key={item.id}>{item.title}</div>)
    }

    render() {
        return this.props.login ? (
            <div>
                {this.getList()}
            </div>
        ) : <Redirect to='/' />
    }

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

Translation.loadData = (store) => {
    return store.dispatch(getTranslationList())
}

// ...
  • Translation组件中,判断为登录时,会通过react-router-dom导出的 Redirect方法做重定向。
  • Redirect只能做客户端重定向,不能做服务端重定向
// server/utils.js
import { renderRoutes } from 'react-router-config'
// ...
const render = (store, routes, req, context) => {
    // 增加context的传递
    const content = renderToString((
        <Provider store={getStore()}>
            <StaticRouter location={req.path} context={context}>
                {renderRoutes(routes)}
            </StaticRouter>
        </Provider>
    ))

    return `<html>...</html>`
}
// ...

当使用react-router-config来定义路由表的时候,使用renderRoutes与StaticRouter相结合,如果发现组件内部出现了Redirect方法时,它会自动操作context添加重定向信息。

// context对象上会有重定向信息,
{
    action: 'REPLACE',
    location: { pathname: '/', search: '', hash: '', state: undefined },
    url: '/'
}

有'REPLACE'操作,把当前页面替换成url: '/'。据此,我们就可以做服务端重定向了:

// server.js
// ...
Promise.all(promises).then(() => {
    const context = {};
    const html = render(store, routes, req, context);
    // context 对象传入render函数中,如果是404组件,则会给context添加一个NOT_FOUND标志
    // 如果识别组件中有 Redirect方法,则给context添加重定向信息

    if (context.action === 'REPLACE') {
        res.redirect(301, context.url) // 服务端主动重定向
    } else if (context.NOT_FOUND) {
        res.status(404) // 需要主动设置返回状态码,否则依然返回200
        res.send(html)
    } else {
        res.send(html)
    }
})
// ...

# 数据请求失败情况下promise的处理

如果promises中某个请求出错了,Promise.all也没有catch,页面就不会有返回,而是一直转圈加载。

// server.js
// ...

Promise.all(promises).then(() => {
    const content = {};
    const html = render(store, routes, req, context);
    // context 对象传入render函数中,如果是404组件,则会给context添加一个NOT_FOUND标志
    // 如果识别组件中有 Redirect方法,则给context添加重定向信息

    if (context.action === 'REPLACE') {
        res.redirect(301, context.url) // 服务端主动重定向
    } else if (context.NOT_FOUND) {
        res.status(404) // 需要主动设置返回状态码,否则依然返回200
        res.send(html)
    } else {
        res.send(html)
    }
})
// ...
  • 假设一个页面总共要加载 A,B,C,D四个组件,这四个组件都需要服务器端加载数据(即都有loadData方法)
  • promises = [A, B, C, D]
  • 假设当A组件加载数据错误时,B,C,D组件有几种情况:
      1. B,C,D组件数据已经加载完成了
      • 在这种情况下,在Promise.all().catch()中也执行渲染,则只有A组件没有渲染,其他都能正确渲染
      1. 假设网速慢,B,C,D接口慢,则B,C,D组件数据没有加载完成,而A组件已经加载报错了
      • 在这种情况下,即使在Promise.all().catch()中也执行渲染,也是不行的
      • 因为A, B, C, D 4个组件的store都是空的,则依然会渲染空页面

需要对promises内的promise做进一步的处理:

// 由于路由改造成了数组列表,下面的路由渲染需要map出来
app.get('*', (req, res) => {
    const store = getStore();

    // 根据路由的路径,来往store里面加数据,使用 matchRoutes
    const matchedRoutes = matchRoutes(routes, req.path);

    // 让matchRoutes里面所有的组件,对应的loadData方法执行一次
    const promises = []

    matchedRoutes.forEach(item => {
        if (item.route.loadData) {
            // promises.push(item.route.loadData(store));
            // 包装一层promise
            const promise = new Promise((resolve, reject) => {
                item.route.loadData(store).then(resolve).catch(resolve)
            })
            promises.push(promise);
        }
    })

// ...

这样无论item.route.loadData是否成功,都会把能加载到的数据都注入store中,并且resolve包装层的promise,所以Promise.all(promises)肯定会走到then函数中。

# 第八章 处理SSR框架中的CSS样式

# 如何支持CSS样式修饰

js文件中直接引入css文件肯定会直接报错,所以需要webpack配置编译:

// webpack.client.js

module.exports = {
    // ...
    module: {
        rules: [{
            test: /\.css?$/, // 在webpack中,loader处理顺序是从下到上,从右往左的
            use: ['style-loader', {
                loader: 'css-loader',
                options: {
                    importLoaders: 1,
                    modules: true, // 支持模块化css
                    localIdentName: '[name]_[local]_[hash:base64:5]'
                }
            }]
        }]
    }
}
  • 会报window is not defined,因为style-loader需要往浏览器中window上挂载一些样式,注入style标签,而在服务器端渲染时,没有window,所以使用style-loader肯定会报错。
  • isomorphic-style-loader 是服务端渲染的时候使用的style-loader,使用方式基本是一样的。
// webpack.server.js
const nodeExternals = require('webpack-node-externals')

module.exports = {
    // ...
    externals: [nodeExternals()],
    module: {
        rules: [{
            test: /\.css?$/, // 在服务端使用isomorphic-style-loader替代style-loader
            use: ['isomorphic-style-loader', {
                loader: 'css-loader',
                options: {
                    importLoaders: 1,
                    modules: true, // 支持模块化css
                    localIdentName: '[name]_[local]_[hash:base64:5]'
                }
            }]
        }]
    }
}

# 如何实现CSS样式的服务器端渲染

上节课的实现会导致页面加载后样式抖动,这说明服务端渲染样式是不生效的,为了找原因我们先比较下isomorphic-style-loaderstyle-loader

  • isomorphic-style-loader
    • 关闭网页的js运行,查看源代码,可以看到标签上已经有了类名,这说明isomorphic-style-loader已经解析了样式,会在服务器渲染页面时把class的名字添加到html字符串里面。
    • isomorphic-style-loader只会解析class的名字。
    • 所以样式不能正常显示。
  • style-loader
    • style-loader也会做相同的事情,在浏览器渲染时把class的名字添加到对应标签上,但是,它还会去向DOM中注入style标签,引入对应样式代码。
    • style-loader不但会解析class的名字,还会向DOM的head中注入style标签引入样式代码。
    • 所以样式可以正常显示。

在做服务端渲染时,引入的styles模块里包含_getCss()_getContent()_insertCss()方法。其中,_getCss()就能获得css样式代码内容。

在服务端的组件逻辑中,通过**_getCss给context注入样式代码**:

// Home
// ...
import styles from './styles.css'; // 支持css模块化

class Home extends Component {
    componentWillMount() {
        // staticContext在客户端是undefined,在服务端是对象
        // 只在服务端的`isomorphic-style-loader`才有styles._getCss()方法,因此要加条件判断
        // 只在服务端执行
        if (this.props.staticContext) {
            // _getCss() 方法可以得到插入DOM的style样式代码,将其注入到context中
            this.props.staticContext.css = styles._getCss()
        }
    }

    getList() {
        // ...
    }

    render() {
        return (
            // ...
        )
    }
}

// ...
export const render = (store, routes, req, context) => {
    const content = renderToString((
        <Provider store={getStore()}>
            <StaticRouter location={req.path} context={context}>
                {renderRoutes(routes)}
            </StaticRouter>
        </Provider>
    ))

    // 从context上获取css样式字符串,插入到服务端渲染的html字符串中
    const cssStr = context.css ? context.css : ''

    res.send(`
        <html>
            <head>
                <title>ssr</title>
                <style>${cssStr}</style>
            </head>
            <body>
                <div id="root">${content}</div>
                <script src='/index.js'></script>  // 引用一个js文件,如果不用express.static,则因为没有index.js文件的路由会报错
            </body>
        </html>
    `)
})

# 多组件中的样式如何整合

服务端渲染多级路由表组件时,staticContext.css被后面的组件替换为自己对应的样式,所以先渲染的组件的样式被覆盖,无法显示样式。

解决方法:将context.css改为数组,每个组件都push自己对应的样式都staticContext.css之中:

class Header extends Component {
    componentWillMount() {
        if (this.props.staticContext) {
            this.props.staticContext.css.push(styles._getCss())
        }
    }

    render() {
        // ...
    }
}

context.css 改为数组:

// server.js
// ...

Promise.all(promises).then(() => {
    const context = {css: []}; // 改为数组
    const html = render(store, routes, req, context);
    // context 对象传入render函数中,如果是404组件,则会给context添加一个NOT_FOUND标志
    // 如果识别组件中有 Redirect方法,则给context添加重定向信息

    if (context.action === 'REPLACE') {
        res.redirect(301, context.url) // 服务端主动重定向
    } else if (context.NOT_FOUND) {
        res.status(404) // 需要主动设置返回状态码,否则依然返回200
        res.send(html)
    } else {
        res.send(html)
    }
})
// ...

如果有多个样式,则拼接(注意用换行符来连接):

export const render = (store, routes, req, context) => {
    const content = renderToString((
        <Provider store={getStore()}>
            <StaticRouter location={req.path} context={context}>
                {renderRoutes(routes)}
            </StaticRouter>
        </Provider>
    ))

    // 如果有多个样式,则拼接(注意用换行符来连接)
    const cssStr = context.css.length ? context.css.join('\n') : ''

    res.send(`
        <html>
            <head>
                <title>ssr</title>
                <style>${cssStr}</style>
            </head>
            <body>
                <div id="root">${content}</div>
                <script src='/index.js'></script>  // 引用一个js文件,如果不用express.static,则因为没有index.js文件的路由会报错
            </body>
        </html>
    `)
})

# loadData方法潜在问题修正

之前的loadData的实现方式中,有一个潜在问题(不够直观):

  • 在Home组件上挂载静态方法loadData
  • 导出Home组件时,并不是直接导出,而是通过connect包裹的组件(比如命名为ExportHome)
  • 而ExportHome上按理说是没有loadData方法的,之所以没报错,是因为connect检测到Home组件上有静态方法时,就帮我们自动将其挂载到ExportHome上了
  • 而为了逻辑清晰直观,我们应该在ExportHome上手动挂载loadData方法
// Home
import React, { Component } from 'react';
import Header from '../../components/Header';
import { connect } from 'react-redux';
import { getHomeList } from './store/actions';

class Home extends Component {
    render() {
        // ...
    }

    // componentDidMount在服务器端是不执行的
    componentDidMount() {
        this.props.getHomeList();
    }
}

// 不直接给Home组件添加静态方法
// Home.loadData = () => {
//     // ...
// }

const mapStateToProps = state => ({
    list: state.home.newsList,
    name: state.home.name
})

const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList())
    }
})

const ExportHome = connect(mapStateToProps, mapDispatchToProps)(Home)

// 给真正导出的组件添加静态方法
ExportHome.loadData = () => {
    // ...
}

export default ExportHome;

# 使用高阶组件精简代码

现有的方式,对于每个组件都需要重复写:

componentWillMount() {
    if (this.props.staticContext) {
        this.props.staticContext.css.push(styles._getCss())
    }
}

可以使用高阶组件来优化:

// withStyle.js   之所以小写是因为它只是一个方法,不是一个组件
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} />
        }
    }
}

以Header为例,使用高阶组件导出Header组件:

// ...
import styles from './styles.css'
import withStyle from '../../../withStyle'

class Header extends Component {
    render() {
        // ...
    }
}

const mapState = (state) => ({
    login: state.header.login
})

const mapDispatch = (dispatch) => ({
    handleLogin() {
        dispatch(actions.login())
    },
    handleLogout() {
        dispatch(actions.logout())
    }
})

export default connect(mapState, mapDispatch)(withStyle(Header, styles))

# 第九章 SEO技巧的融入

# 什么是SEO?为什么服务端渲染对SEO更加友好?

通过一些技术手段,使网站在搜索引擎搜索结果中尽量排名靠前。而排名是否靠前,取决于百度和谷歌是否认为你的网站有价值,而一个网站的价值就需要搜索引擎通过爬取网站内容来判断和评级。

绝大部分搜索引擎,是不认识js渲染(即CSR端渲染)出来的内容的。右键点击查看源代码时,发现只有个根节点,没有内容。因此要做SSR服务器端渲染。

# Title和Description的真正作用

基于全文索引的搜索引擎」:其实搜索引擎在匹配网站时,不会仅仅根据TDK来匹配,而是通过全文匹配来分析是否和所搜索的关键词契合。

所以TDK对SEO来说,作用可以说是非常有限了。但TDK又是非常重要的,那TDK的真正作用是什么呢?

TDK的真正价值

其实TDK真正有价值的地方在于,搜索出来的词条展示形式,标题、缩略图、简介等等是否更有吸引力,提升转化率。

# 如何做好SEO

一个网站,基本是由3部分组成的:文字、链接、多媒体。

  • 文字优化:(提升原创性)对于文字,搜索引擎会非常看重原创性,如果是重复内容,SEO就不会太好;
  • 链接优化:
    • (提升内部链接相关性)对于内部链接,搜索引擎会非常看重链接与跳转的内容是否相关;
    • (提升外部链接的分布)对于外部链接,如果很多其他网站都有外链到我的网站,说明我的网站重要,这个搜索引擎非常看重;
  • 多媒体优化:
    • 提升多媒体内容的丰富度,还有比如图片清晰度等。

# React-Helmet的使用

定制每个页面独立的TDK:github: react-helmet (opens new window)

# 课程总结

# 使用预渲染解决SEO问题的新思路

  • Q:什么是预渲染prerender
  • A:即由预渲染服务器判断访问者是用户还是爬虫,如果是用户则直接使用普通的react网站即可,如果是爬虫则将当前react网站渲染完的全部内容,保存并返回给爬虫去访问。

github: prerender (opens new window)

  • Q:如何区分访问者的角色呢?

  • A:通过架设一台nginx访问拦截服务,来拿到访问者的用户信息,从而转发到不同的服务器。

  • Q:prerender的原理是什么?原理prerender.io (opens new window)

  • A:当访问prerender服务器时,这个服务器会自动生成一个小的浏览器,这个小浏览器会再去访问网站地址,就可以查看网站的元素和内容,拿到后再保存内容返回给爬虫。这个过程耗时是比较长的。

  • 适用场景:当网站仅仅是为了SEO好一些,对首屏时间不是很在意时,推荐使用prerender这种技术,不建议使用复杂的SSR同构技术。

Last Updated: 10/22/2020, 5:32:38 PM