Bootstrap

SSR 服务端渲染

前言

闲来无事,研究一下SSR,主要原因在于上周一位后端同学在一次组内技术分享的时候说,对前后端分离、服务端渲染特别感兴趣,在他分享了后端微服务之后,专门点名邀请我下周分享服务端渲染,然后我还没同意,领导就内定让我下周分享了(其实就是下周愿意下周分享,我是那个替死鬼)。本人主要从个人角度介绍了对服务端渲染的理解,读完本文后,你将了解到:

  • 什么是服务端渲染,与客户端渲染的区别是什么?
  • 为什么需要服务端渲染,服务端渲染的利弊是什么?
  • 如何对VUE项目进行同构?

服务端渲染的定义

在讲服务度渲染之前,我们先回顾一下页面的渲染流程:

  1. 浏览器通过请求得到一个HTML文本
  2. 渲染进程解析HTML文本,构建DOM树
  3. 解析HTML的同时,如果遇到内联样式或者样式脚本,则下载并构建样式规则(stytle rules),若遇到JavaScript脚本,则会下载执行脚本。
  4. DOM树和样式规则构建完成之后,渲染进程将两者合并成渲染树(render tree)
  5. 渲染进程开始对渲染树进行布局,生成布局树(layout tree)
  6. 渲染进程对布局树进行绘制,生成绘制记录
  7. 渲染进程的对布局树进行分层,分别栅格化每一层,并得到合成帧
  8. 渲染进程将合成帧信息发送给GPU进程显示到页面中
可以看到,页面的渲染其实就是浏览器将HTML文本转化为页面帧的过程。而如今我们大部分WEB应用都是使用 JavaScript 框架(Vue、React、Angular)进行页面渲染的,也就是说,在执行 JavaScript 脚本的时候,HTML页面已经开始解析并且构建DOM树了,JavaScript 脚本只是动态的改变 DOM 树的结构,使得页面成为希望成为的样子,这种渲染方式叫动态渲染,也可以叫客户端渲染(client side rende)。那么什么是服务端渲染(server side render)?顾名思义,服务端渲染就是在浏览器请求页面URL的时候,服务端将我们需要的HTML文本组装好,并返回给浏览器,这个HTML文本被浏览器解析之后,不需要经过 JavaScript 脚本的执行,即可直接构建出希望的 DOM 树并展示到页面中。这个服务端组装HTML的过程,叫做服务端渲染。

在这里插入图片描述

服务端渲染的由来

Web1.0

在没有AJAX的时候,也就是web1.0时代,几乎所有应用都是服务端渲染(此时服务器渲染非现在的服务器渲染),那个时候的页面渲染大概是这样的,浏览器请求页面URL,然后服务器接收到请求之后,到数据库查询数据,将数据丢到后端的组件模板(php、asp、jsp等)中,并渲染成HTML片段,接着服务器在组装这些HTML片段,组成一个完整的HTML,最后返回给浏览器,这个时候,浏览器已经拿到了一个完整的被服务器动态组装出来的HTML文本,然后将HTML渲染到页面中,过程没有任何JavaScript代码的参与。image20200731115513579

客户端渲染

在WEB1.0时代,服务端渲染看起来是一个当时的最好的渲染方式,但是随着业务的日益复杂和后续AJAX的出现,也渐渐开始暴露出了WEB1.0服务器渲染的缺点。

  • 每次更新页面的一小的模块,都需要重新请求一次页面,重新查一次数据库,重新组装一次HTML
  • 前端JavaScript代码和后端(jsp、php、jsp)代码混杂在一起,使得日益复杂的WEB应用难以维护

而且那个时候,根本就没有前端工程师这一职位,前端js的活一般都由后端同学 jQuery 一把梭。但是随着前端页面渐渐地复杂了之后,后端开始发现js好麻烦,虽然很简单,但是坑太多了,于是让公司招聘了一些专门写js的人,也就是前端,这个时候,前后端的鄙视链就出现了,后端鄙视前端,因为后端觉得js太简单,无非就是写写页面的特效(JS),切切图(CSS),根本算不上是真正的程序员。随之 nodejs 的出现,前端看到了翻身的契机,为了摆脱后端的指指点点,前端开启了一场前后端分离的运动,希望可以脱离后端独立发展。前后端分离,表面上看上去是代码分离,实际上是为了前后端人员分离,也就是前后端分家,前端不再归属于后端团队。前后端分离之后,网页开始被当成了独立的应用程序(SPA,Single Page Application),前端团队接管了所有页面渲染的事,后端团队只负责提供所有数据查询与处理的API,大体流程是这样的:首先浏览器请求URL,前端服务器直接返回一个空的静态HTML文件(不需要任何查数据库和模板组装),这个HTML文件中加载了很多渲染页面需要的 JavaScript 脚本和 CSS 样式表,浏览器拿到 HTML 文件后开始加载脚本和样式表,并且执行脚本,这个时候脚本请求后端服务提供的API,获取数据,获取完成后将数据通过JavaScript脚本动态的将数据渲染到页面中,完成页面显示。
这一个前后端分离的渲染模式,也就是客户端渲染(CSR)。

服务端渲染

随着单页应用(SPA)的发展,程序员们渐渐发现 SEO(Search Engine Optimazition,即搜索引擎优化)出了问题,而且随着应用的复杂化,JavaScript 脚本也不断的臃肿起来,使得首屏渲染相比于 Web1.0时候的服务端渲染,也慢了不少。自己选的路,跪着也要走下去。于是前端团队选择了使用 nodejs 在服务器进行页面的渲染,进而再次出现了服务端渲染。大体流程与客户端渲染有些相似,首先是浏览器请求URL,前端服务器接收到URL请求之后,根据不同的URL,前端服务器向后端服务器请求数据,请求完成后,前端服务器会组装一个携带了具体数据的HTML文本,并且返回给浏览器,浏览器得到HTML之后开始渲染页面,同时,浏览器加载并执行 JavaScript 脚本,给页面上的元素绑定事件,让页面变得可交互,当用户与浏览器页面进行交互,如跳转到下一个页面时,浏览器会执行 JavaScript 脚本,向后端服务器请求数据,获取完数据之后再次执行 JavaScript 代码动态渲染页面。

服务端渲染的利弊

相比于客户端渲染,服务端渲染有什么优势?

利于SEO

有利于SEO,其实就是有利于爬虫来爬你的页面,然后在别人使用搜索引擎搜索相关的内容时,你的网页排行能靠得更前,这样你的流量就有越高。那为什么服务端渲染更利于爬虫爬你的页面呢?其实,爬虫也分低级爬虫和高级爬虫。

  • 低级爬虫:只请求URL,URL返回的HTML是什么内容就爬什么内容。
  • 高级爬虫:请求URL,加载并执行JavaScript脚本渲染页面,爬JavaScript渲染后的内容。

也就是说,低级爬虫对客户端渲染的页面来说,简直无能为力,因为返回的HTML是一个空壳,它需要执行 JavaScript 脚本之后才会渲染真正的页面。而目前像百度、谷歌、微软等公司,有一部分年代老旧的爬虫还属于低级爬虫,使用服务端渲染,对这些低级爬虫更加友好一些。

白屏时间更短

相对于客户端渲染,服务端渲染在浏览器请求URL之后已经得到了一个带有数据的HTML文本,浏览器只需要解析HTML,直接构建DOM树就可以。而客户端渲染,需要先得到一个空的HTML页面,这个时候页面已经进入白屏,之后还需要经过加载并执行 JavaScript、请求后端服务器获取数据、JavaScript 渲染页面几个过程才可以看到最后的页面。特别是在复杂应用中,由于需要加载 JavaScript 脚本,越是复杂的应用,需要加载的 JavaScript 脚本就越多、越大,这会导致应用的首屏加载时间非常长,进而降低了体验感。

服务端渲染缺点

并不是所有的WEB应用都必须使用SSR,这需要开发者自己来权衡,因为服务端渲染会带来以下问题:

  • 代码复杂度增加。为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行情况,而一部分依赖的外部扩展库却只能在客户端运行,需要对其进行特殊处理,才能在服务器渲染应用程序中运行。
  • 需要更多的服务器负载均衡。由于服务器增加了渲染HTML的需求,使得原本只需要输出静态资源文件的nodejs服务,新增了数据获取的IO和渲染HTML的CPU占用,如果流量突然暴增,有可能导致服务器down机,因此需要使用响应的缓存策略和准备相应的服务器负载。
  • 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。

所以在使用服务端渲染SSR之前,需要开发者考虑投入产出比,比如大部分应用系统都不需要SEO,而且首屏时间并没有非常的慢,如果使用SSR反而小题大做了。

同构

知道了服务器渲染的利弊后,假如我们需要在项目中使用服务端渲染,我们需要做什么呢?那就是同构我们的项目。

同构的定义

在服务端渲染中,有两种页面渲染的方式:

  • 前端服务器通过请求后端服务器获取数据并组装HTML返回给浏览器,浏览器直接解析HTML后渲染页面
  • 浏览器在交互过程中,请求新的数据并动态更新渲染页面

这两种渲染方式有一个不同点就是,一个是在服务端中组装html的,一个是在客户端中组装html的,运行环境是不一样的。所谓同构,就是让一份代码,既可以在服务端中执行,也可以在客户端中执行,并且执行的效果都是一样的,都是完成这个html的组装,正确的显示页面。也就是说,一份代码,既可以客户端渲染,也可以服务端渲染。

同构的条件

为了实现同构,我们需要满足什么条件呢?首先,我们思考一个应用中一个页面的组成,假如我们使用的是Vue.js,当我们打开一个页面时,首先是打开这个页面的URL,这个URL,可以通过应用的路由匹配,找到具体的页面,不同的页面有不同的视图,那么,视图是什么?从应用的角度来看,视图 = 模板 + 数据,那么在 Vue.js 中, 模板可以理解成组件,数据可以理解为数据模型,即响应式数据。所以,对于同构应用来说,我们必须实现客户端与服务端的路由、模型组件、数据模型的共享。

实践

知道了服务端渲染、同构的原理之后,下面从头开始,一步一步完成一次同构,通过实践来了解SSR。

实现基础的NODEJS服务端渲染

首先,模拟一个最简单的服务器渲染,只需要向页面返回我们需要的html文件。

const express = require('express');
const app = express();

app.get('/', function(req, res) {
    res.send(`
        <html>
            <head>
                <title>SSR</title>
            </head>
            <body>
                <p>hello world</p>
            </body>
        </html>
    `);
});

app.listen(3001, function() {
    console.log('listen:3001');
});

启动之后打开localhost:3001可以看到页面显示了hello world。
也就是说,当浏览器拿到服务器返回的这一段HTML源代码的时候,不需要加载任何JavaScript脚本,就可以直接将hello world显示出来。

实现基础的VUE客户端渲染

我们用 vue-cli新建一个vue项目,修改一个App.vue组件:

<template>
      <div>
            <p>hello world</p>
            <button @click="sayHello">say hello</button>
      </div>
</template>

<script>
export default {
    methods: {
        sayHello() {
              alert('hello ssr');
        }
    }
}
</script>

然后运行npm run serve启动项目,打开浏览器,一样可以看到页面显示了 hello world,但是打开我们开网页源代码:
除了简单的兼容性处理 noscript 标签以外,只有一个简单的id为app的div标签,没有关于hello world的任何字眼,可以说这是一个空的页面(白屏),而当加载了下面的 script 标签的 JavaScript 脚本之后,页面开始这行这些脚本,执行结束,hello world 正常显示。也就是说真正渲染 hello world 的是 JavaScript 脚本。

同构VUE项目

构建配置

模板组件的共享,其实就是使用同一套组件代码,为了实现 Vue 组件可以在服务端中运行,首先我们需要解决代码编译问题。一般情况,vue项目使用的是webpack进行代码构建,同样,服务端代码的构建,也可以使用webpack,借用官方的一张。

第一步:构建服务端代码

由前面的图可以看到,在服务端代码构建结束后,需要将构建结果运行在nodejs服务器上,但是,对于服务端代码的构建,有一下内容需要注意:

  • 不需要编译CSS,样式表只有在浏览器(客户端)运行时需要。
  • 构建的目标的运行环境是commonjs,nodejs的模块化模式为commonjs
  • 不需要代码切割,nodejs将所有代码一次性加载到内存中更有利于运行效率

于是,我们得到一个服务端的 webpack 构建配置文件 vue.server.config.js

const nodeExternals = require("webpack-node-externals");
const VueSSRServerPlugin = require('[vue-server-renderer](https://www.npmjs.com/package/vue-server-renderer)[](https://raw.githubusercontent.com/yacan8/blog/master/images/%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86/image-20200801233249503.png)[](https://raw.githubusercontent.com/yacan8/blog/master/images/%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%B8%B2%E6%9F%93%E5%8E%9F%E7%90%86/image-20200802011753771.png)/server-plugin')

module.exports = {
    css: {
        extract: false // 不提取 CSS
    },
    configureWebpack: () => ({
        entry: `./src/server-entry.js`, // 服务器入口文件
        devtool: 'source-map',
        target: 'node', // 构建目标为nodejs环境
        output: {
            libraryTarget: 'commonjs2' // 构建目标加载模式 commonjs
        },
        // 跳过 node_mdoules,运行时会自动加载,不需要编译
        externals: nodeExternals({
            allowlist: [/\.css$/] // 允许css文件,方便css module
        }),
        optimization: {
            splitChunks: false // 关闭代码切割
        },
          plugins: [
            new VueSSRServerPlugin()
        ]
    })
};

使用 vue-server-renderer提供的server-plugin,这个插件主要配合下面讲到的client-plugin使用,作用主要是用来实现nodejs在开发过程中的热加载、source-map、生成html文件。

第二步:构建客户端代码

在构建客户端代码时,使用的是客户端的执行入口文件,构建结束后,将构建结果在浏览器运行即可,但是在服务端渲染中,HTML是由服务端渲染的,也就是说,我们要加载那些JavaScript脚本,是服务端决定的,因为HTML中的script标签是由服务端拼接的,所以在客户端代码构建的时候,我们需要使用插件,生成一个构建结果清单,这个清单是用来告诉服务端,当前页面需要加载哪些JS脚本和CSS样式表。于是我们得到了客户端的构建配置,vue.client.config.js

const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = {
    configureWebpack: () => ({
        entry: `./src/client-entry.js`,
        devtool: 'source-map',
        target: 'web',
        plugins: [
            new VueSSRClientPlugin()
        ]
    }),
    chainWebpack: config => {
          // 去除所有关于客户端生成的html配置,因为已经交给后端生成
        config.plugins.delete('html');
        config.plugins.delete('preload');
        config.plugins.delete('prefetch');
    }
};

使用vue-server-renderer提供的client-server,主要作用是生成构建加过清单vue-ssr-client-manifest.json,服务端在渲染页面时,根据这个清单来渲染HTML中的script标签(JavaScript)和link标签(CSS)。接下来,我们需要将vue.client.config.js和vue.server.config.js都交给vue-cli内置的构建配置文件vue.config.js,根据环境变量使用不同的配置

// vue.config.js
const TARGET_NODE = process.env.WEBPACK_TARGET === 'node';
const serverConfig = require('./vue.server.config');
const clientConfig = require('./vue.client.config');

if (TARGET_NODE) {
    module.exports = serverConfig;
} else {
    module.exports = clientConfig;
}

使用cross-env区分环境

{  "scripts": {    "server": "babel-node src/server.js",    "serve": "vue-cli-service serve",    "build": "vue-cli-service build",    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server"
  }}
模板组件共享
第一步:创建VUE实例

为了实现模板组件共享,我们需要将获取 Vue 渲染实例写成通用代码,如下 createApp:

import Vue from 'vue';
import App from './App';

export default function createApp (context) {
    const app = new Vue({
        render: h => h(App)
    });
      return {
          app    };
};
第二步:客户端实例化VUE

新建客户端项目的入口文件,client-entry.js

import Vue from 'vue'
import createApp from './createApp';

const {app} = createApp();

app.$mount('#app');

client-entry.js是浏览器渲染的入口文件,在浏览器加载了客户端编译后的代码后,组件会被渲染到id为app的元素节点上。

第三步:服务端实例化VUE

新建服务端代码的入口文件,server-entry.js

import createApp from './createApp'

export default context => {
    const { app } = createApp(context);
    return app;
}

server-entry.js是提供给服务器渲染vue组件的入口文件,在浏览器通过URL访问到服务器后,服务器需要使用server-entry.js提供的函数,将组件渲染成html。

第四步:HTTP服务

所有东西的准备好之后,我们需要修改nodejs的HTTP服务器的启动文件。首先,加载服务端代码server-entry.js的webpack构建结果

const path = require('path');
const serverBundle = path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json');
const {createBundleRenderer} = require('vue-server-renderer');
const serverBundle = path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json');

加载客户端代码client-entry.js的webpack构建结果

const clientManifestPath = path.resolve(process.cwd(), 'dist', 'vue-ssr-client-manifest.json');
const clientManifest = require(clientManifestPath);

使用 vue-server-renderercreateBundleRenderer创建一个html渲染器:

const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
const renderer = createBundleRenderer(serverBundle, {
    template,  // 使用HTML模板
    clientManifest // 将客户端的构建结果清单传入
});

创建HTML模板,index.html

<html>
  <head>
    <title>SSR</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

在HTML模板中,通过传入的客户端渲染结果clientManifest,将自动注入所有link样式表标签,而占位符将会被替换成模板组件被渲染后的具体的HTML片段和script脚本标签。HTML准备完成后,我们在server中挂起所有路由请求

const express = require('express');
const app = express();

/* code todo 实例化渲染器renderer */

app.get('*', function(req, res) {
    renderer.renderToString({}, (err, html) => {
        if (err) {
            res.send('500 server error');
            return;
        }
        res.send(html);
    })
});

接下来,我们构建客户端、服务端项目,然后执行 node server.js,打开页面源代码,
看起来是符合预期的,但是发现控制台有报错,加载不到客户端构建css和js,报404,原因很明确,我们没有把客户端的构建结果文件挂载到服务器的静态资源目录,在挂载路由前加入下面代码:

app.use(express.static(path.resolve(process.cwd(), 'dist')));

看起来大功告成,点击say hello也弹出了消息,细心的同学会发现根节点有一个data-server-rendered属性,这个属性有什么作用呢?由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。如果检查服务器渲染的输出结果,应用程序的根元素上添加了一个特殊的属性:

<div id="app" data-server-rendered="true">

data-server-rendered是特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。

路由的共享和同步

完成了模板组件的共享之后,下面完成路由的共享,我们前面服务器使用的路由是*,接受任意URL,这允许所有URL请求交给Vue路由处理,进而完成客户端路由与服务端路由的复用。

第一步:创建ROUTER实例

为了实现复用,与createApp一样,我们创建一个createRouter.js

import Vue from 'vue';
import Router from 'vue-router';
import Home from './views/Home';
import About from './views/About';
Vue.use(Router)
const routes = [{
    path: '/',
    name: 'Home',
    component: Home
}, {
    path: '/about',
    name: 'About',
    component: About
}];
export default function createRouter() {
    return new Router({
        mode: 'history',
        routes    })
}

在createApp.js中创建router

import Vue from 'vue';
import App from './App';
import createRouter from './createRouter';

export default function createApp(context) {
    const router = createRouter(); // 创建 router 实例
    const app = new Vue({
        router, // 注入 router 到根 Vue 实例
        render: h => h(App)
    });
    return { router, app };
};
第二步:路由匹配

router准备好了之后,修改server-entry.js,将请求的URL传递给router,使得在创建app的时候可以根据URL匹配到对应的路由,进而可知道需要渲染哪些组件

import createApp from './createApp';

export default context => {
    // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前就已经准备就绪。
    return new Promise((resolve, reject) => {
        const { app, router } = createApp();
        // 设置服务器端 router 的位置
        router.push(context.url)
        // onReady 等到 router 将可能的异步组件和钩子函数解析完
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();
            // 匹配不到的路由,执行 reject 函数,并返回 404
            if (!matchedComponents.length) {
                return reject({
                    code: 404
                });
            }
            // Promise 应该 resolve 应用程序实例,以便它可以渲染
            resolve(app)
        }, reject)
    })
}

修改server.js的路由,把url传递给renderer

app.get('*', function(req, res) {
    const context = {
        url: req.url
    };
    renderer.renderToString(context, (err, html) => {
        if (err) {
            console.log(err);
            res.send('500 server error');
            return;
        }
        res.send(html);
    })
});

为了测试,我们将App.vue修改为router-view

<template>
    <div id="app">
        <router-link to="/">Home</router-link>
        <router-link to="/about">About</router-link>
        <router-view />    </div>
</template>

Home.vue

<template>
    <div>Home Page</div>
</template>

About.vue

<template>
    <div>About Page</div>
</template>

编译,运行,查看源代码image20200802011753771点击路由并没有刷新页面,而是客户端路由跳转的,一切符合预期。

数据模型的共享与状态同步

前面我们简单的实现了服务端渲染,但是实际情况下,我们在访问页面的时候,还需要获取需要渲染的数据,并且渲染成HTML,也就是说,在渲染HTML之前,我们需要将所有数据都准备好,然后传递给renderer。一般情况下,在Vue中,我们将状态数据交给Vuex进行管理,当然,状态也可以保存在组件内部,只不过需要组件实例化的时候自己去同步数据。

第一步:创建STORE实例

首先第一步,与createApp类似,创建一个createStore.js,用来实例化store,同时提供给客户端和服务端使用

import Vue from 'vue';
import Vuex from 'vuex';
import {fetchItem} from './api';

Vue.use(Vuex);

export default function createStore() {
    return new Vuex.Store({
        state: {
            item: {}
        },
        actions: {
            fetchItem({ commit }, id) {
                return fetchItem(id).then(item => {
                    commit('setItem', item);
                })
            }
        },
        mutations: {
            setItem(state, item) {
                Vue.set(state.item, item);
            }
        }
    })
}

actions封装了请求数据的函数,mutations用来设置状态。将createStore加入到createApp中,并将store注入到vue实例中,让所有Vue组件可以获取到store实例

export default function createApp(context) {
    const router = createRouter();
    const store = createStore();
    const app = new Vue({
        router,
        store, // 注入 store 到根 Vue 实例
        render: h => h(App)
    });
    return { router, store, app };
};

为了方便测试,我们mock一个远程服务函数fetchItem,用于查询对应item

export function fetchItem(id) {
    const items = [
        { name: 'item1', id: 1 },
        { name: 'item2', id: 2 },
        { name: 'item3', id: 3 }
    ];
    const item = items.find(i => i.id == id);
    return Promise.resolve(item);
}
第二步:STORE连接组件

一般情况下,我们需要通过访问路由,来决定获取哪部分数据,这也决定了哪些组件需要渲染。事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。所以,我们需要在路由的组件中放置数据预取逻辑函数。在Home组件中自定义一个静态函数asyncData,需要注意的是,由于此函数会在组件实例化之前调用,所以它无法访问 this。需要将 store 和路由信息作为参数传递进去

<template>
<div>
    <div>id: {{item.id}}</div>
    <div>name: {{item.name}}</div>
</div>
</template>

<script>
export default {
    asyncData({ store, route }) {
        // 触发 action 后,会返回 Promise
        return store.dispatch('fetchItems', route.params.id)
    },
    computed: {
        // 从 store 的 state 对象中的获取 item。
        item() {
            return this.$store.state.item;
        }
    }
}
</script>
第三步:服务端获取数据

在服务器的入口文件server-entry.js中,我们通过URL路由匹配 router.getMatchedComponents()得到了需要渲染的组件,这个时候我们可以调用组件内部的asyncData方法,将所需要的所有数据都获取完后,传递给渲染器renderer上下文。修改createApp,在路由组件匹配到了之后,调用asyncData方法,获取数据后传递给renderer

import createApp from './createApp';

export default context => {
    // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前就已经准备就绪。
    return new Promise((resolve, reject) => {
        const { app, router, store } = createApp();
        // 设置服务器端 router 的位置
        router.push(context.url)
        // onReady 等到 router 将可能的异步组件和钩子函数解析完
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();
            // 匹配不到的路由,执行 reject 函数,并返回 404
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }
            // 对所有匹配的路由组件调用 `asyncData()`
            Promise.all(matchedComponents.map(Component => {
                if (Component.asyncData) {
                    return Component.asyncData({
                        store,
                        route: router.currentRoute
                    });
                }
            })).then(() => {
                // 状态传递给renderer的上下文,方便后面客户端激活数据
                context.state = store.state
                resolve(app)
            }).catch(reject);
        }, reject);
    })
}

将state存入context后,在服务端渲染HTML时候,也就是渲染template的时候,context.state会被序列化到window.__INITIAL_STATE__中,方便客户端激活数据。

第四步:客户端激活状态数据

服务端预请求数据之后,通过将数据注入到组件中,渲染组件并转化成HTML,然后吐给客户端,那么客户端为了激活后端返回的HTML被解析后的DOM节点,需要将后端渲染组件时用的store的state也同步到浏览器的store中,保证在页面渲染的时候保持与服务器渲染时的数据是一致的,才能完成DOM的激活,也就是我们前面说到的data-server-rendered标记。在服务端的渲染中,state已经被序列化到了window.__INITIAL_STATE__,比如我们访问

()可以看到,状态已经被序列化到window.__INITIAL_STATE__中,我们需要做的就是将这个window.__INITIAL_STATE__在客户端渲染之前,同步到客户端的store中,下面修改client-entry.js](http://localhost:3001/?id=1,查看页面源代码)image20200802153036538可以看到,状态已经被序列化到window.__INITIAL_STATE__中,我们需要做的就是将这个window.__INITIAL_STATE__在客户端渲染之前,同步到客户端的store中,下面修改client-entry.js)

const { app, router, store } = createApp();

if (window.__INITIAL_STATE__) {
      // 激活状态数据
    store.replaceState(window.__INITIAL_STATE__);
}

router.onReady(() => {
    app.$mount('#app', true);
});

通过使用store的replaceState函数,将window.__INITIAL_STATE__同步到store内部,完成数据模型的状态同步。

Next.js

Next.js 默认有做 route pre-fetching ,当 Link 出现在视图中时,会去提前请求相关的资源,这样跳转路由界面切换会很快。

PS:咋感觉这种路由跳转的预处理是基操呢,之前在 [React 官网为什么这么快](findxc/blog#47) 里面提到的 [Gatsby](https://www.gatsbyjs.com/) 有做类似处理,然后在 [通过切割代码和预加载来提高页面加载速度](findxc/blog#48) 里面提到的 [quicklink](https://github.com/GoogleChromeLabs/quicklink) 也是用来做路由层面的预加载的。

好吧,不扯远了,在开始学习 Next.js 之前,我们先来弄清楚 SSG、SSR、CSR 分别是什么,这样当你实际开发时,可以针对具体业务场景选择不同方式。

SSG、SSR、CSR 分别是什么

CSR

Client Side Rendering ,客户端渲染。

用户访问某个 URL 时,服务端返回的 HTML 不包含页面具体内容,这种方式就无法考虑 SEO 了。

当 JS 文件下载和执行后,页面才会更新。所以相对于 SSR 和 SSG 来说页面内容展示会慢一些。

SSG

Static Site Generation ,静态站点生成。

打包时就会为每个页面生成包含内容的 HTML 文件,这样用户访问某个 URL 时,服务端直接返回 HTML 即可。

这种形式页面加载是最快的,也利于 SEO ,前提是页面内容在打包时就能确定,或者说大部分内容能确定,然后小部分内容通过客户端渲染来更新(结合 SSG 和 CSR 的方式)。

SSR

Server Side Rendering ,服务端渲染。

当用户访问某个 URL 时,服务端实时生成包含内容的 HTML 文件再返回,由于需要实时生成 HTML ,对服务器的压力会大一些。页面加载相对 SSG 会慢一些。由于会返回包含内容的 HTML,所以也是利于 SEO 的。

如何选择

  • SSG :页面内容在打包时就能确定,比如静态官网、文档网站
  • SSG + CSR :页面大部分内容在打包时就能确定,小部分动态内容不用考虑 SEO ,比如博客网站,内容部分用 SSG ,评论部分用 CSR
  • CSR :页面主要是动态内容,不用考虑 SEO ,也不太在意首屏加载速度,比如后台管理网站
  • SSR :页面存在需要考虑 SEO 的动态内容(如果动态内容比较多,那考虑 SEO 还有意义吗?🤔),或者页面主要是动态内容并且希望首屏完整内容加载尽量快

Next.js 是建议尽量采用 SSG ,或者 SSG + CSR 的方式,这样页面加载会比较快。在必要时才去使用 SSR 。

然后在 Next.js 中,如果有用到 SSG 或者 SSR ,其实也只有首屏加载时是服务端返回包含内容的 HTML ,后续通过 Link 跳转是客户端跳转,页面内容的更新是通过 JS 实现的,而不是说又去请求相应的 HTML 。

在 Next.js 中怎么实现 SSG 和 SSR

Next.js 默认认为 [pages](https://nextjs.org/docs/basic-features/pages) 目录下每个文件对应一个路由,每个文件中默认导出的组件就是页面组件。

SSR

对于 SSR ,页面文件中需要额外导出 [getServerSideProps](https://nextjs.org/docs/basic-features/pages#server-side-rendering) ,这个函数只会在服务端执行。

// 这里的 data 来自于下面 getServerSideProps 的返回值
function Page({ data }) {
  // Render data...
}

// 每次请求这个页面时,这个函数就会在服务端执行
export async function getServerSideProps() {
  const res = await fetch(`https://.../data`)
  const data = await res.json()

  // 这里返回的 props 会注入到页面组件中
  return { props: { data } }
}

export default Page

如果是通过 Link 进行的客户端跳转,这个函数也会执行,但是还是在服务端执行的。通过 Link 跳转时, Next.js 会发一个请求让服务端来执行这个函数,详见 [When does getServerSideProps run](https://nextjs.org/docs/basic-features/data-fetching/get-server-side-props#when-does-getserversideprops-run) 。

SSG

如果页面文件没有导出 getServerSideProps ,那么 Next.js 就会认为这个页面是 SSG 。

如果静态页面生成时,需要请求额外数据,比如博客网站可能需要向 [CMS - 内容管理系统](https://zh.wikipedia.org/wiki/%E5%86%85%E5%AE%B9%E7%AE%A1%E7%90%86%E7%B3%BB%E7%BB%9F) 请求目录和博客内容,这时页面文件可以导出 [getStaticProps](https://nextjs.org/docs/basic-features/pages#scenario-1-your-page-content-depends-on-external-data) 函数来为页面组件注入属性。

// 这里的 posts 来自于下面 getStaticProps 的返回值
function Blog({ posts }) {
  // Render posts...
}

// getStaticProps 在打包时会执行
export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()
  // 这里 return 的 props 会注入给页面组件
  return { props: { posts } }
}

export default Blog

如果静态页面生成时,页面对应的路由是希望动态生成的,比如博客网站每篇博客的路由是文件名,可以使用 [getStaticPaths](https://nextjs.org/docs/basic-features/data-fetching/get-static-paths) 来实现,详见 [Scenario 2: Your page paths depend on external data](https://nextjs.org/docs/basic-features/pages#scenario-2-your-page-paths-depend-on-external-data) 。

export async function getStaticPaths() {
  // 发请求获取相关数据,然后构造 paths
  return {
    // paths 就是所有的路由
    paths: [
      { params: { ... } }
    ],
    fallback: true // false or 'blocking'
  };
}

getStaticPaths 返回值中的 [fallback](https://nextjs.org/docs/api-reference/data-fetching/get-static-paths#fallback-false) 是用来设置当用户请求的路径在 paths 中不存在时期望 Next.js 如何处理。

然后 Next.js 还支持 [增量静态生成 - Incremental Static Regeneration](https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration) 这个功能,通过在 getStaticProps 的返回值中设置 revalidate 来生效。

比如我有个接口 http://localhost:4000/date 可以获取服务器当前时间,然后我写了一个 SSG 的页面来展示这个时间,我设置了 revalidate 为 10 ,也就是 10s 。

function Page(props) {
  const { date } = props
  return <div>服务端当前时间:{date}</div>
}

export async function getStaticProps(context) {
  const res = await fetch('http://localhost:4000/date')
  const { date } = await res.json()
  // 10s 内服务端最多更新一次该页面
  return { props: { date }, revalidate: 10 }
}

export default Page

那当我请求这个页面时, Next.js 会先返回已经生成的 HTML ,同时会去生成新的 HTML ,这样下次再请求就能拿到这个新的 HTML 。服务端的更新频率就是由 revalidate 来设置的。注意,只有在生产环境下才是这样的,在开发环境每次请求页面都会生成新的 HTML 。

增量静态生成这个功能适用于静态页面可能会更新的场景,就不用重新打包和部署了,只是说用户可能会访问到旧的 HTML 。

再补充一下, Next.js 打包后显示的信息会告诉你哪些路由是服务端渲染哪些是静态生成的,比如下图中,路径前有 λ 的表示是服务端渲染,有 ○ 表示是静态生成。

在 Next.js 中怎么加上 layout

可以使用 [custom app](https://nextjs.org/docs/advanced-features/custom-app) 的方式来为页面统一加上 layout ,详见 [Layouts](https://nextjs.org/docs/basic-features/layouts) 。

// pages/_app.js
import { useRouter } from 'next/router'

function MyApp({ Component, pageProps }) {
  const router = useRouter()
  console.log(router.pathname)

  return (
    // 使用同一个 Layout 或者根据不同 pathname 使用不同 Layout
    // <Layout>
    <Component {...pageProps} />
    // </Layout>
  )
}

export default MyApp

在 Next.js 中怎么代理 API 请求

可以使用 [Rewrites](https://nextjs.org/docs/api-reference/next.config.js/rewrites) 功能来代理 API 请求。

module.exports = {
  async rewrites() {
    return [
      {
        // 把 /api 的请求都代理到 http://localhost:4000 上去
        source: '/api/:path*',
        destination: 'http://localhost:4000/:path*',
      },
    ]
  },
}

在 Next.js 中怎么设置环境变量

比如希望把后端请求地址配置在环境变量中,增加 .env.development.env.production 文件即可,分别对应开发环境和生产环境的环境变量,在 next.config.js 或者 getStaticProps 等中通过 process.env.xxx 即可读取。更多配置见 [Basic Features: Environment Variables | Next.js](https://nextjs.org/docs/basic-features/environment-variables) 。

Next.js 默认支持 styled-jsx

详见 [CSS-in-JS](https://nextjs.org/docs/basic-features/built-in-css-support#css-in-js) 。

function HelloWorld() {
  return (
    <div>
      Hello world      <p>scoped!</p>
      <style jsx>{`
        p {
          color: blue;
        }
      `}</style>
    </div>
  )
}

export default HelloWorld

总结

当浏览器访问服务端渲染项目时,服务端将URL传给到预选构建好的VUE应用渲染器,渲染器匹配到对应的路由的组件之后,执行我们预先在组件内定义的asyncData方法获取数据,并将获取完的数据传递给渲染器的上下文,利用template组装成HTML,并将HTML和状态state一并吐给前端浏览器,浏览器加载了构建好的客户端VUE应用后,将state数据同步到前端的store中,并根据数据激活后端返回的被浏览器解析为DOM元素的HTML文本,完成了数据状态、路由、组件的同步,同时使得页面得到直出,较少了白屏时间,有了更好的加载体验,同时更有利于SEO。个人觉得了解服务端渲染,有助于提升前端工程师的综合能力,因为它的内容除了前端框架,还有前端构建和后端内容,是一个性价比还挺高的知识,不学白不学,加油!
原文链接:https://github.com/ChelesteWang/FE-Review/issues/54

;