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

3,164 阅读8分钟

一、为什么使用服务器端渲染?

1. 客户端渲染

2.服务器端渲染

3. 使用 SSR 技术的主要因素

  • 首屏等待: CSR 项目的 TTFP(Time To First Page)时间比较长
  • SEO : CSR 项目的 SEO 能力极弱

4. React SSR 流程

SSR 之所以能够实现,本质上是因为虚拟 DOM 的存在

二、同构

概念:一套React 代码 在服务器端执行一次,在客户端再执行一次

// /containers/Home

const Home = () => {
    return (
        <div>
            <div>This is allValue!</div>
            <button onClick={()=>{alert('click1')}}>
                click
            </button>
        </div>
    )
}

按上面的操作给button 绑定click事件,服务器端渲染,click没有绑定上, 所以 需要在 客户端 再渲染一遍 把事件等 绑定上

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')
    },
    // 因为 Node 环境下通过 NPM 已经安装了这些包,直接引用就可以,不需要额外再打包到代码里
    externals: [nodeExternals()],
    module: {
        rules: [{
            test: /.js?$/,
            loader: 'babel-loader',
            exclude: /node_modules/,
            options: {
                presets: ['react', 'stage-0', ['env', {
                    targets: {
                        browsers: ['last 2 versions']
                    }
                }]]
            }
        }]
    }
}
// src.index.js

import express from 'express';
import Home from './containers/Home';
import React from 'react';
import { renderToString } from 'react-dom/server';

const app = express();
app.use(express.static('public'));
const content = renderToString(<Home />);

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

var server = app.listen(3000);

三、在SSR框架中引入路由机制

  • 实现 React 的 SSR 架构,我们需要让相同的 React 代码在客户端和服务器端各执行一次。这里说的相同的 React 代码,指的是我们写的各种组件代码,所以在同构中,只有组件的代码是可以公用的。

路由为什么没有办法公用?

其实原因很简单,在服务器端需要通过请求路径,找到路由组件,而在客户端需通过浏览器中的网址,找到路由组件,是完全不同的两套机制,所以这部分代码是肯定无法公用。我们来看看在 SSR 中,前后端路由的实现代码:

客户端路由:

客户端路由代码非常简单,大家一定很熟悉,BrowserRouter 会自动从浏览器地址中,匹配对应的路由组件显示出来。

const App = () => {
    return (
        <Provider store={store}>
            <BrowserRouter>
                <div>
                    <Route path='/' component={Home}>
                </div>
            </BrowserRouter>
        </Provider>
    )
}

ReactDom.render(<App/>, document.querySelector('#root'))

通过 BrowserRouter 我们能够匹配到浏览器即将显示的路由组件,对浏览器来说,我们需要把组件转化成 DOM,所以需要我们使用 ReactDom.render 方法来进行 DOM 的挂载。

服务器端路由:

服务器端路由代码相对要复杂一点,需要你把 location(当前请求路径)传递给 StaticRouter 组件,这样 StaticRouter 才能根据路径分析出当前所需要的组件是谁。

PS:StaticRouter 是 React-Router 针对服务器端渲染专门提供的一个路由组件。

const App = () => {
    return
        <Provider store={store}>
            <StaticRouter location={req.path} context={context}>
                <div>
                    <Route path='/' component={Home}>
                </div>
        </StaticRouter>
    </Provider>
}

Return ReactDom.renderToString(<App/>)

StaticRouter 能够在服务器端匹配到将要显示的组件,对服务器端来说,我们要把组件转化成字符串,这时我们只需要调用 ReactDom 提供的 renderToString 方法,就可以得到 App 组件对应的 HTML 字符串。

为了方便统一管理,实际的路由配置是这样的

细节部分可以看它 --> 👉 reactrouter.com/web/guides/…

routes: [

    {
        path: '/',
        component: Home,
        exact: true,
        loadData: Home.loadData,
        key: 'home'
    },
    {
        path: '/goods',
        component: Goods,
        exact: true,
        loadData: Goods.loadData,
        key: 'goods'
    },
    {
         ...xxxx

    {
        path: '*',
        component: NotFound,
        exact: true,
    },
]

四、Node 中间层

在 SSR 架构中,一般 Node 只是一个中间层,用来做 React 代码的服务器端渲染,而 Node 需要的数据通常由 API 服务器单独提供。

这样做一是为了工程解耦,二也是为了规避 Node 服务器的一些计算性能问题(?为什么不适合密集型计算,这个观点正确吗,能解决吗)

io异步完成的处理,是需要通过轮询队列去返回数据给到客户端的,但是这个过程是需要主线程是执行。由于密集型计算的任务,会阻塞主线程,导致无法及时响应异步队列的任务。

解决方法,通过 child_process 等方式,启用多进程或多线程来处理 CPU 密集型的任务,所以以上的方式是很早以前的观点

处理组件当中的数据

class Home extends Component {
    componentWillMount() {
        if (this.props.staticContext) {
            this.props.staticContext.css.push(styles._getCss());
        }
    }
    render() {
        return (
            ...
        )
    }
}

Home.loadData = (store) => {
    return store.dispatch(getHomeList())
}
// 服务端对数据的处理
// matchedRoutes 是当前路由对应的所有需要显示的组件集合

matchedRoutes.forEach(item => {
    if (item.route.loadData) {
        const promise = new Promise((resolve, reject) => {
            item.route.loadData(store).then(resolve).catch(resolve);
        })
        promises.push(promise);
    }
})
Promise.all(promises).then(() => {
    // TODO 生成 HTML 逻辑
})

五、CSS 的处理

当我们的 React 代码中引入了一些 CSS 样式代码时,服务器端打包的过程会处理一遍 CSS,而客户端又会处理一遍。查看配置,我们可以看到,服务器端打包时我们用了 isomorphic-style-loader,它处理 CSS 的时候,只在对应的 DOM 元素上生成 class 类名,然后返回生成的 CSS 样式代码。

而在客户端代码打包配置中,我们使用了 css-loader 和 style-loader,css-loader 不但会在 DOM 上生成 class 类名,解析好的 CSS 代码,还会通过 style-loader 把代码挂载到页面上。不过这么做,由于页面上的样式实际上最终是由客户端渲染时添加上的,所以页面可能会存在一开始没有样式的情况,为了解决这个问题, 我们可以在服务器端渲染时,拿到 isomorphic-style-loader 返回的样式代码,然后以字符串的形式添加到服务器端渲染的 HTML 之中

客户端

// 客户端webpack配置
module: {
    rules: [{
        test: /\.css?$/,
        use: ['style-loader', {
            loader: 'css-loader',
            options: {
                importLoaders: 1,
                modules: true,
                localIdentName: '[name]_[local]_[hash:base64:5]'
            }
        }]
    }]
}

服务端

我们可以在服务器端渲染时,拿到 isomorphic-style-loader 返回的样式代码,然后以字符串的形式添加到服务器端渲染的 HTML 之中

module: {
    rules: [{
        test: /\.css?$/,
        use: ['isomorphic-style-loader', {
            loader: 'css-loader',
            options: {
                importLoaders: 1,
                modules: true,
                localIdentName: '[name]_[local]_[hash:base64:5]'
            }
        }]
    }]
}
const context = {css: []};
export const render = (store, routes, req, context) => {
    const content = renderToString((
        <Provider store={store}>
            <StaticRouter location={req.path} context={context}>
                <div>
                    {renderRoutes(routes)}
                </div>
            </StaticRouter>
        </Provider>
    ));
    const cssStr = context.css.length ? context.css.join('\n') : '';
    
    return `
        <html>
            <head>
                <title>ssr</title>
                <style>${cssStr}</style>
            </head>
            <body>
                ...
            </body>
        </html>
    `;
}

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

服务端直出时资源的搜集

服务端输出html时,需要定义好css资源、js资源,让客户端接管后下载使用

// 我们项目中的处理方式
import { ChunkExtractor } from '@loadable/server';
import { ServerStyleSheet } from 'styled-components';

const extractor = new ChunkExtractor({ statsFile });
....
const { routerPath, search } = this.baseData || {};
const sheet = new ServerStyleSheet();

const jsx = extractor.collectChunks(
    sheet.collectStyles(
        <StaticRouter location={{ pathname: routerPath, search }}>
            <App
                i18nLang={this.i18nLang}
                pathname={routerPath}
                initialData={this.baseData}
                routeList={routeList}
            />
        </StaticRouter>,
    ),
);

六、 数据的脱水和注水

在服务器注水:

把数据作为 window.context 注入到 window 上面成为注水

在客户端脱水:

客户端取数据使用

// 注水
// utils.js
<script>
    window.context = {
        store:${JSON.stringify(store.getState())}
    }
</script>

//脱水
export const getClientStore = ()=>{
    const defaultState = window.context.store;
    return createStore(
        reducer, defaultState, applyMiddleware(thunk)
    );
}

七、SSR 中异步数据的获取 + Redux 的使用

客户端渲染中

异步数据结合 Redux 的使用方式遵循下面的流程(对应图中第 12 步):

  1. 创建 Store
  2. 根据路由显示组件
  3. 派发 Action 获取数据
  4. 更新 Store 中的数据
  5. 组件 Rerender

服务器端

页面一旦确定内容,就没有办法 Rerender 了,这就要求组件显示的时候,就要把 Store 的数据都准备好,所以服务器端异步数据结合 Redux 的使用方式,流程是下面的样子(对应图中第 4 步):

  1. 创建 Store
  2. 根据路由分析 Store 中需要的数据
  3. 派发 Action 获取数据
  4. 更新Store 中的数据
  5. 结合数据和组件生成 HTML,一次性返回

下面,我们分析下服务器端渲染这部分的流程:

客户端渲染中,用户的浏览器中永远只存在一个 Store,所以代码上你可以这么写:

// 客户端写法
const store = createStore(reducer, defaultState)export default store;

// Store 变成了一个单例,所有用户共享 Store
// 返回一个函数,每个用户访问的时候,这个函数重新执行,为每个用户提供一个独立的 Store
const getStore = (req) => {
    return createStore(reducer, defaultState);
}

export default getStore;

八、SEO技巧的融入

1. Title 和 Description的真正作用

- 二代搜索引擎是基于网站全文的
- title 和 description 对搜索的影响比较小
- title 中出现吸引用户的关键字,吸引用户点击,提升转化率,而不是提升排名

2. 如何做好 SEO

  • 网站的组成部分:多媒体、链接、文字
  • 搜索引擎判断网站价值的时候,是从这三方面判断的。
    • 文字优化 -- 原创
    • 链接
      • 内部链接:链接到的内容要与原网站的尽量的相关。
      • 外部链接:越多说明这个网站的影响力比较大
    • 多媒体 -- 可以做图片识别、原创、高清

3. React-Helmet 的使用

class Application extends React.Component {
    render () {
        return (
            <div className="application">
                <Helmet>
                    <meta charSet="utf-8" />
                    <title>My Title</title>
                    <link rel="canonical" href="http://mysite.com/example" />
                </Helmet>
                ...
            </div>
        );
    }
};

// 服务端
const helmet = Helmet.renderStatic()

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

不想使用 SSR 但是想提高搜索引擎排名 -- 预渲染

  • 中间层访问网页,将网页内容拿过来渲染成完整的 html,将完整的 html返回给客户端 具体详情请看 ---> prerender.io/framework/

使用 prerender,启动一个8000的端口号,去访问客户端渲染的网址 localhost:8000/render?url=http://localhost:3000

区分到是蜘蛛访问时,使用 preRender 服务器。

nginx 可以根据 userAgent 来区分

image.png

十、总结

使用 SSR 这种技术,将使原本简单的 React 项目变得非常复杂,项目的可维护性会降低,代码问题的追溯也会变得困难。

所以,使用 SSR 在解决问题的同时,也会带来非常多的副作用,有的时候,这些副作用的伤害比起 SSR 技术带来的优势要大的多。一般建议大家,除非你的项目特别依赖搜索引擎流量,或者对首屏时间有特殊的要求,否则不建议使用 SSR。

参考资料: