服务端渲染(SSR)

服务端渲染(SSR)

一、什么是浏览器端渲染 (CSR)?

CSR是Client Side Render简称;页面上的内容是我们加载的js文件渲染出来的,js文件运行在浏览器上面,服务端只返回一个html模板。

CSR加载图

二、什么是服务器端渲染 (SSR)?

SSR是Server Side Render简称;页面上的内容是通过服务端渲染生成的,浏览器直接显示服务端返回的html就可以了。

SSR加载图


本文以Vue.js 做为演示框架来区分SSR和CSR。默认情况下,Vue.js可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行。

附:vue-ssr官方文档

三、不同渲染方式在浏览器解析情况

从输入页面URL到页面渲染完成大致流程为:

  • 解析URL
  • 浏览器本地缓存
  • DNS解析
  • 建立TCP/IP连接
  • 发送HTTP请求
  • 服务器处理请求并返回HTTP报文
  • 浏览器根据深度遍历的方式把html节点遍历构建DOM树
  • 遇到CSS外链,异步加载解析CSS,构建CSS规则树
  • 遇到script标签,如果是普通JS标签则同步加载并执行,阻塞页面渲染,如果标签上有defer / async属性则异步加载JS资源
  • 将dom树和CSS DOM树构造成render树
  • 渲染render树
performance.timing
CSR-浏览器performance情况
SSR-浏览器performance情况
  • FP:首次绘制。用于标记导航之后浏览器在屏幕上渲染像素的时间点。这个不难理解,就是浏览器开始请求网页到网页首帧绘制的时间点。这个指标表明了网页请求是否成功。
  • FCP:首次内容绘制。FCP 标记的是浏览器渲染来自 DOM 第一位内容的时间点,该内容可能是文本、图像、SVG 甚至 <canvas> 元素。
  • FMP:首次有效绘制。这是一个很主观的指标。根据业务的不同,每一个网站的有效内容都是不相同的,有效内容就是网页中"主角元素"。对于视频网站而言,主角元素就是视频。对于搜索引擎而言,主角元素就是搜索框。
  • TTI:可交互时间。用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点。应用可能会因为多种原因而无法响应用户输入:①页面组件运行所需的JavaScript尚未加载完成。②耗时较长的任务阻塞主线程

根据上图devtool时间轴的结果,虽然CSR配合预渲染方式(loading、骨架图)可以提前FP、FCP从而减少白屏问题,但无法提前FMP;SSR将FMP提前至js加载前触发,提前显示网页中的"主角元素"。SSR不仅可以减少白屏时间还可以大幅减少首屏加载时间。

附:首屏时间获取方法

四、node服务(server.js)

第一步 利用express框架写一个简单node服务

Express是基于Node.js平台,快速、开放、极简的 Web 开发框架

/*
第一步 利用express框架写一个简单node服务
*/
let express = require('express');
let app = express();

app.get('*', function(req, res){
 res.send('hello world');
});

const port = process.env.PORT || 8080
app.listen(port, () => {
 console.log(`server started at localhost:${port}`)
})

附:express文档

第二步 利用vue-server-renderer提供的createRenderer将vue与node结合

renderer.renderToString(vm, context?, callback?): ?Promise<string>

将 Vue 实例渲染为字符串。上下文对象 (context object) 可选。回调函数是典型的 Node.js 风格回调,其中第一个参数是可能抛出的错误,第二个参数是渲染完毕的字符串。

/*
第一步 利用express框架写一个简单node服务
第二步 利用vue-server-renderer提供的createRenderer将vue与node结合
*/
let express = require('express');
let app = express();

const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer()

app.get('*', function(req, res){
    render(req,res)
});
function render(req, res) {
    const app = new Vue({
        data: {
            url: req.url
        },
        template: `<div>req.url:{{ url }}</div>`
    })
    renderer.renderToString(app, (err, html) => {
        if (err) {
            res.status(500).end('Internal Server Error')
            return
        } else {
            res.end(`${html}`)
        }
    })
}

const port = process.env.PORT || 8080
app.listen(port, () => {
  console.log(`server started at localhost:${port}`)
})


第三步 读入index.template.html文件

创建 renderer 时提供一个页面模板。多数时候,我们会将页面模板放在特有的文件中,例如index.template.html

<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

<!--vue-ssr-outlet-->注释 -- 这里将是应用程序 HTML 标记注入的地方。

/*
第一步 利用express框架写一个简单node服务
第二步 利用vue-server-renderer提供的createRenderer将vue与node结合
第三步 读入index.template.html文件
*/
let express = require('express');
let app = express();
const Vue = require('vue')

const path = require('path')
const resolve = file => path.resolve(__dirname, file)
const renderer = require('vue-server-renderer').createRenderer({
    template: require('fs').readFileSync( resolve('./src/index.template.html'), 'utf-8')
})

app.get('*', function(req, res){
    render(req,res)
});
function render(req ,res){
    const app = new Vue({
        data: {
            url: req.url
        },
        template: `<div>req.url:{{ url }}</div>`
    })
    const context = {
        title: 'ssr测试',
    }
    renderer.renderToString(app,context, (err, html) => {
        if (err) {
            res.status(500).end('Internal Server Error')
            return
        }else{
            res.end(`${html}`)
        }
    }) 
}


const port = process.env.PORT || 8080
app.listen(port, () => {
  console.log(`server started at localhost:${port}`)
})


第四步 引入已经打包好的vue-ssr-server-bundle.json

vue-server-renderer 提供一个名为 createBundleRenderer 的 API,用于处理此问题,通过使用 webpack 的自定义插件,server bundle 将生成为可传递到 bundle renderer 的特殊 JSON 文件。所创建的 bundle renderer,用法和普通 renderer 相同,但是 bundle renderer 提供以下优点:

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // 推荐,bundle 代码将与服务器进程在同一个 global 上下文中运行
  template, // (可选)页面模板
  clientManifest // (可选)客户端构建 manifest
})
  • 内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map'
  • 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
  • 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。
  • 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。
/*
第一步 利用express框架写一个简单node服务
第二步 利用vue-server-renderer提供的createRenderer将vue与node结合
第三步 读入index.template.html文件
第四步 引入已经打包好的vue-ssr-server-bundle.json
*/
let express = require('express');
let app = express();

const path = require('path')
const resolve = file => path.resolve(__dirname, file)
const templatePath = resolve('./src/index.template.html')
const serverBundle = require('./dist/vue-ssr-server-bundle.json')

const { createBundleRenderer } = require('vue-server-renderer')
let renderer = createBundleRenderer(serverBundle, {
    template: require('fs').readFileSync(templatePath, 'utf-8'),
    //clientManifest 客户端构建 manifest 暂不演示
})

app.get('*', function (req, res) {
    render(req, res)
});
function render(req, res) {
    const context = {
        title: 'ssr测试',
        url: req.url // 传递path,这个参数很重要
    }
    renderer.renderToString(context, (err, html) => {
        if (err) {
            res.status(500).end('Internal Server Error')
            return
        } else {
            res.end(`${html}`)
        }
    })
}

const port = process.env.PORT || 8080
app.listen(port, () => {
    console.log(`server started at localhost:${port}`)
})


第五步 将bundle换成webpack实时输入的内存的bundle(非生产环境)

webpack 默认使用普通文件系统来读取文件并将文件写入磁盘。但是,还可以使用不同类型的文件系统(内存(memory), webDAV 等)来更改输入或输出行为。为了实现这一点,可以改变inputFileSystem或outputFileSystem

调用watch方法会触发 webpack 执行器,但之后会监听变更(很像 CLI 命令:webpack --watch),一旦 webpack 检测到文件变更,就会重新执行编译。该方法返回一个Watching实例。

/*
第一步 利用express框架写一个简单node服务
第二步 利用vue-server-renderer提供的createRenderer将vue与node结合
第三步 读入index.template.html文件
第四步 引入已经打包好的vue-ssr-server-bundle.json
第五步 将bundle换成webpack实时输入的内存的bundle
*/
let express = require('express');
let app = express();

const path = require('path')
const resolve = file => path.resolve(__dirname, file)
const templatePath = resolve('./src/index.template.html')
//const bundle = require('./dist/vue-ssr-server-bundle.json')

const webpack = require('webpack')
const serverConfig = require('./build/webpack.server.config')
const MFS = require('memory-fs')
const readFile = (fs, file) => {
    try {
        return fs.readFileSync(path.join(serverConfig.output.path, file), 'utf-8')
    } catch (e) { }
}


const { createBundleRenderer } = require('vue-server-renderer')
let renderer;
app.get('*', function (req, res) {
    render(req, res)
});
function render(req, res) {
    const context = {
        title: 'ssr测试',
        url: req.url // 传递path,这个参数很重要
    }
    renderer.renderToString(context, (err, html) => {
        if (err) {
            res.status(500).end('Internal Server Error')
            return
        } else {
            res.end(`${html}`)
        }
    })
}


const serverCompiler = webpack(serverConfig)
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs //打包至内存中
serverCompiler.watch({}, (err, stats) => {
    if (err) throw err
    let bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
    renderer = createBundleRenderer(bundle, {
        template: require('fs').readFileSync(templatePath, 'utf-8'),
    })
})

const port = process.env.PORT || 8080
app.listen(port, () => {
    console.log(`server started at localhost:${port}`)
})

附:webpack在Node.js 中的API


五、剖析构建流程

构建流程


通用配置(Base Config)


服务器配置 (Server Config)

服务器配置,是用于生成传递给 createBundleRenderer 的 server bundle。它应该是这样的:

const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseConfig, {
  // 将 entry 指向应用程序的 server entry 文件
  entry: '/path/to/entry-server.js',

  // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
  // 并且还会在编译 Vue 组件时,
  // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  target: 'node',

  // 对 bundle renderer 提供 source map 支持
  devtool: 'source-map',

  // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
  output: {
    libraryTarget: 'commonjs2'
  },

  // https://webpack.js.org/configuration/externals/#function
  // https://github.com/liady/webpack-node-externals
  // 外置化应用程序依赖模块。可以使服务器构建速度更快,
  // 并生成较小的 bundle 文件。
  externals: nodeExternals({
    // 不要外置化 webpack 需要处理的依赖模块。
    // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
    // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
    whitelist: /\.css$/
  }),

  // 这是将服务器的整个输出
  // 构建为单个 JSON 文件的插件。
  // 默认文件名为 `vue-ssr-server-bundle.json`
  plugins: [
    new VueSSRServerPlugin()
  ]
})

在生成 vue-ssr-server-bundle.json 之后,只需将文件路径传递给 createBundleRenderer

const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer('/path/to/vue-ssr-server-bundle.json', {
  // ……renderer 的其他选项
})

客户端配置 (Client Config)

除了 server bundle 之外,我们还可以生成客户端构建清单 (client build manifest)。使用客户端清单 (client manifest) 和服务器 bundle(server bundle),renderer 现在具有了服务器和客户端的构建信息,因此它可以自动推断和注入资源预加载 / 数据预取指令(preload / prefetch directive),以及 css 链接 / script 标签到所渲染的 HTML。

好处是双重的:

  1. 在生成的文件名中有哈希时,可以取代 html-webpack-plugin 来注入正确的资源 URL。
  2. 在通过 webpack 的按需代码分割特性渲染 bundle 时,我们可以确保对 chunk 进行最优化的资源预加载/数据预取,并且还可以将所需的异步 chunk 智能地注入为 <script> 标签,以避免客户端的瀑布式请求 (waterfall request),以及改善可交互时间 (TTI - time-to-interactive)。

要使用客户端清单 (client manifest),客户端配置 (client config) 将如下所示:

const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseConfig, {
  entry: '/path/to/entry-client.js',
  plugins: [
    // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,
    // 以便可以在之后正确注入异步 chunk。
    // 这也为你的 应用程序/vendor 代码提供了更好的缓存。
    new webpack.optimize.CommonsChunkPlugin({
      name: "manifest",
      minChunks: Infinity
    }),
    // 此插件在输出目录中
    // 生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
})

六、编写通用代码

  1. 组件生命周期钩子函数,由于没有动态更新,所有的生命周期钩子函数中,只有beforeCreatecreated会在服务器端渲染 (SSR) 过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如beforeMountmounted),只会在客户端执行

2.通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像windowdocument,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此(global)

解决方案:

  • (1)在beforeCreate,created生命周期以及全局的执行环境中调用特定的api前需要判断执行环境;
  • (2)使用adapter模式,写一套adapter兼容不同环境的api。

七、数据预取存储容器

通用 entry(app.js

app.js 是我们应用程序的「通用 entry」。在纯客户端应用程序中,我们将在此文件中创建根 Vue 实例,并直接挂载到 DOM。但是,对于服务器端渲染(SSR),责任转移到纯客户端 entry 文件。app.js 简单地使用 export 导出一个 createApp 函数

服务端数据预取 (Server entry)

entry-server.js中,我们可以通过路由获得与router.getMatchedComponents()相匹配的组件,如果组件暴露出asyncData,我们就调用这个方法。然后我们需要将解析完成的状态,附加到渲染上下文(render context)中。

// entry-server.js
import { createApp } from './app'

export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  // 以便服务器能够等待所有的内容在渲染前,
  // 就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    // 设置服务器端 router 的位置
    router.push(context.url)
    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()//当前路由匹配到组件
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      // 等到 router 将可能的异步组件和钩子函数解析完
      // 对所有匹配的路由组件调用 `asyncData()`
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        }
      })).then(() => {
        // 在所有预取钩子(preFetch hook) resolve 后,
        // 我们的 store 现在已经填充入渲染应用程序所需的状态。
        // 当我们将状态附加到上下文,
        // 并且 `template` 选项用于 renderer 时,
        // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
        context.state = store.state

        resolve(app)
      }).catch(reject)
    }, reject)
  })
}


客户端数据预取 (Client entry)

router.onReady该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件。router.beforeResolve在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后,解析守卫就被调用。

router.onReady(() => {
  // 添加路由钩子函数,用于处理 asyncData.
  // 在初始路由 resolve 后执行,
  // 以便我们不会二次预取(double-fetch)已有的数据。
  // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to) //当前路由匹配的组件数组 
    const prevMatched = router.getMatchedComponents(from)

    // 我们只关心非预渲染的组件
    // 所以我们对比它们,找出两个匹配列表的差异组件
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })

    if (!activated.length) {
      return next()
    }

    // 这里如果有加载指示器 (loading indicator),就触发

    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.asyncData({ store, route: to })
      }
    })).then(() => {

      // 停止加载指示器(loading indicator)

      next()
    }).catch(next)
  })

  app.$mount('#app')
})

同一个组件不同参数切换路由时会触发重用组件内部beforeRouteUpdate,通过全局mixin路由钩子来监听调用asyncData方法拉取数据进行客户端渲染

Vue.mixin({
  beforeRouteUpdate (to, from, next) {
    const { asyncData } = this.$options
    if (asyncData) {
      asyncData({
        store: this.$store,
        route: to
      }).then(next).catch(next)
    } else {
      next()
    }
  }
})

附:完整的导航解析流程

  1. 导航被触发。
  2. 在失活的组件里调用离开守卫。
  3. 调用全局的 beforeEach 守卫。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。
  5. 在路由配置里调用 beforeEnter
  6. 解析异步路由组件。
  7. 在被激活的组件里调用 beforeRouteEnter
  8. 调用全局的 beforeResolve 守卫 (2.5+)。
  9. 导航被确认。
  10. 调用全局的 afterEach 钩子。
  11. 触发 DOM 更新。
  12. 用创建好的实例调用 beforeRouteEnter 守卫中传给 next 的回调函数。

八、服务器部署

进程管理pm2

cluster模式(多实例多进程模式)启动服务--watch参数,意味着当你的express应用代码发生变化时,pm2会帮你重启服务。

pm2 start server.js -i 4 --watch

或者pm2 -i 4 start npm -- run start --watch(同npm run start)

查询所有服务 pm2 list

附:pm2的cluster模式官方介绍

nginx反向代理

修改nginx.config文件,增加对应虚拟主机反向代理到node对应的服务端口

    server {
        listen       80;
        server_name  csyry.com;
        location / {
            proxy_pass   http://127.0.0.1:8080;
            index  index.html index.htm;
        }
    }

重启nginx服务器: sudo nginx -s reload

附:nginx中文配置文档

修改DNS

正式环境通过域名服务商修改映射解析,本机测试修改/etc/hosts文件

附录:源码参考官方demo

编辑于 2022-11-24 19:45・IP 属地广东