博客已启用服务端渲染

提醒:本文最后更新于 2040 天前,文中所描述的信息可能已发生改变,请谨慎使用。

SPA

何为服务端渲染?英文简称SSR,全名Server Side Render。

提到SSR,就不得不提SPA,全名Single Page Applaction,即单页面应用程序。

在以前没有出现SPA的岁月里,所有的网页都是由服务端将数据填充进模版,然后直接将html字符串发送给浏览器。

所有的操作都在服务端进行,浏览器只负责一些很简单的交互,服务端压力过大,而且每次跳转到别的页面都需要刷新页面,体验非常差劲。

前端在整个业务部门缺少话语权,因为只是写写页面,这大概就是切图仔称号的由来。

再到了后来,出现了Ajax技术,并由Google发扬光大。Ajax可以异步局部修改页面,大大提高了页面的浏览体验。

SPA技术按照WIKI百科的说法,最早在2003年就讨论过相关的概念,但是直到最近几年,移动互联网的井喷,才使的SPA技术爆发式发展。

SPA的出现,服务端再也不用去填充模版生成字符串,而只是简单的将打包好的js文件和初始的html文件发送到浏览器,浏览器使用js代码来操作页面的修改与跳转,然后使用API与服务端进行通讯。

同时因为前端路由技术的出现,可以使页面无刷新进行跳转,假以时日体验接近原生App也不是没有可能。

尴尬的SSR

SPA的体验上去了,现在又出现了另外的两个问题 — SEO和首页白屏问题。

SEO也就是搜索引擎优化,搜索引擎的爬虫对页面进行爬取然后进行收录,这在SPA没有出现的岁月里很简单,爬虫直接读取服务端发送过来的HTML文件就可以了。

SPA出现之后,爬虫直接读取服务端发送的HTML的方法行不通了。因为此时服务端发过来的HTML文件页面内容是空白的,尚待浏览器执行js代码生成页面。

我的博客在我今天上线SSR之前是完全的SPA应用,但是在google里已经可以搜索到一些二级页面了,这证明了Google搜索引擎的爬虫目前已经有了执行js代码爬取页面内容的能力(百度尚不清楚)。

但是目前搜索引擎爬虫读取html并进行索引收录的方式,对SEO比较重视的SPA应用来说还是很重要的。

第二个问题,首页白屏问题。

现在的SPA都是使用API和服务端进行通讯,由于网络延迟等原因,组件加载完毕在等待服务器返回数据的时间里不可避免的会出现白屏等情况,这样一来,服务端渲染势在必行。

服务端渲染

服务端渲染在第一次访问时直接返回生成好的html字符串,搜索引擎爬取时可以直接爬取到完整页面,浏览器也不会有首页白屏问题,而且之后还可以像正常的SPA应用一样只使用API和服务端交互,真是棒棒哒!

服务端渲染生成的html字符串需要在浏览器上可以直接执行,这就需要服务端在服务器上执行一遍代码并填充然后直接发送给浏览器。

浏览器上可以执行的只有JS代码,这时Node.js便有了天生的优势,可以很轻松的进行同构改造,服务端和客户端使用同一套代码。

还等什么? 开整!

重构代码

在此之前我的博客代码结构是很简单的前后分离架构,服务端只负责api,返回文章列表和其他数据。

客户端是用creact-react-app快速创建的,单纯的SPA应用,打包后放在nginx的静态文件目录下,所有的路由交给前端来处理。

接下来重构代码,由于要服务端同构,那么就不用将服务端和客户端分离开了。

直接新建src文件夹,然后将web和server文件夹移动进去,接下来调整细节问题。

重构 - 客户端

之前浏览器或者爬虫访问博客直接由nginx返回打包好的前端静态文件,现在为了服务端渲染肯定不能这么搞了,删除nginx的静态文件规则,将所有请求转发给node服务端。

server {
    listen 443 ssl;
    ...
    ...
    location / {
        proxy_pass http://127.0.0.1:3000;
    }
    ...
}

现在由node处理所有请求了,首先需要返回生成好的html字符串,react为了解决服务端的问题,提供了renderToStringrenderToStaticMarkuprenderToNodeStreamrenderToStaticNodeStream这几个API。

在这里我使用了renderToString。

客户端入口文件:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import './index.css';

ReactDOM.render((
    <BrowserRouter>
        <App />
    </BrowserRouter>
), document.getElementById('root'));

在服务端进行渲染,只需要将根组件渲染成字符串然后填充进模版发送出去即可。

同时服务端也需要获取路由信息,返回相应的路由页面和数据。

BrowserRouter组件在服务端是获取不到路由信息的,需要使用StaticRouter组件并且手动提供路由信息,服务端路由信息可以用req.url取得。

另外很多组件需要使用ComponentDidMount或者useEffect进行数据获取,然而这两个API在服务端并不会运行。

所以需要在服务端手动提供数据给下面的组件,StaticRouter组件可以用context这个prop传递一个对象来传递数据。

并且为了在浏览器端第一次加载时不用再次请求数据,可以将数据写在window对象上直接发送过去。

import React from 'react';
import { StaticRouter } from 'react-router-dom';
import { renderToString } from 'react-dom/server';
import App from '../App';

export default function getHtml(url, postList) 
    ...
    ...

    const data = {
        postList
    };

    const body = renderToString(
        <StaticRouter location={url} context={data}>
            <App />
        </StaticRouter>
    );

    const js = `window.__initData_PostList__ = ${JSON.stringify(postList)};`
    // 将数据直接写在window对象上以便在客户端复用。
    const html = `
        <!DOCTYPE html>
        <html lang="en">
            <head>
                ...
                <​script>${js}<​/script>
            </head>
            <body>
                <div id="root">${body}</div>
            </body>
        </html>
    `;
    return html;
    ...
}

然后在组件里可以使用变通的方法设置数据:

import React, { useState, useEffect } from 'react';
import http from '../../tools/http';

export default function PostPage({
    staticContext
}) {
    let __postList = [];
    if (staticContext && staticContext.postList) __postList = staticContext.postList;
    // 在浏览器运行时不存在staticContext,postList为空数组。
    // 在服务端运行时postList默认值为传入的数据。
    

    const [postList, setPostList] = useState(__postList);

    useEffect(() => {
        async function getPostList() {
            const { data, status } = await http.get('/api/post');
            if (status === 200) {
                ...
                setPostList(data);
            }
            ...
        }

        if (window.__initData_PostList__) {
            setPostList(window.__initData_PostList__);
            delete window.__initData_PostList__;

            // useEffect在服务端并不会执行,所以含有window对象并不会报错。
            // 在浏览器第一次运行时会使用服务端传送过来的数据。
            // 之后删除此数据,防止无法重新获取数据。
        } else {
            getPostList();
        }

    }, []);

    ...

    return (
        <div>
            {
                postList.map((i, index) => <div key={index}>{i}</div>);
            }
        </div>
    );
}

重构 - 服务端

组件里大概就是这个样子,然而数据怎么来呢?那么就得在服务端做路由,不同的路由发送不同的数据给渲染模块。

示例如下:

import express from 'express';
import http from '../tools/http';
import getHtml from '../lib/getHtml';
...

const app = express();

app.get('/api', async (req, res) => {
    ...
    ...
    ...
    res.send(data);
})

app.get('/', async (req, res) => {
    const result = await http.get('/api/post');
    const data = await result.json();
    const html = getHtml(req.url,data);
    res.send(html);
});

app.get('/post/:id', async (req, res) => {
    const result = await http.get(`/api/post/${id}`);
    const data = await result.json();
    const html = getHtml(req.url, data);
    res.send(html);
});

...
app.listen(3000);

结束

至此,服务端渲染基本结束,其他细节留待以后继续完善了。

Powered By Hexo & Theme Veni