搭建自己的 服务端渲染 SSR

一、VUE SSR是什么

  • 官方文档:https://ssr.vuejs.org/
  • Vue SSR(Vue.js Server-Side Rendering) 是 Vue.js 官方提供的一个服务端渲染(同构应用)解决方案
  • 使用它可以构建同构应用
  • 还是基于原有的 Vue.js 技术栈

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

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

二、使用场景

在对你的应用程序使用服务器端渲染 (SSR) 之前,你应该问的第一个问题是,是否真的需要它。

技术层面:

  • 更快的首屏渲染速度
  • 更好的 SEO

业务层面:

  • 不适合管理系统
  • 适合门户资讯类网站,例如企业官网、知乎、简书等
  • 适合移动网站

三、如何实现 Vue SSR

(1)基于 Vue SSR 官方文档提供的解决方案

官方方案具有更直接的控制应用程序的结构,更深入底层,更加灵活,同时在使用官方方案的过程中,也会对Vue SSR有更加深入的了解。

该方式需要你熟悉 Vue.js 本身,并且具有 Node.js 和 webpack 的相当不错的应用经验。

(2)Nuxt.js 开发框架

NUXT提供了平滑的开箱即用的体验,它建立在同等的Vue技术栈之上,但抽象出很多模板,并提供了一些额外的功能,例如静态站点生成。通过 Nuxt.js 可以快速的使用 Vue SSR 构建同构应用。

四、Vue SSR 基本使用

接下来我们以 Vue SSR 的官方文档为参考,来学习一下它的基本用法。

渲染一个 Vue 实例

  • mkdir vue-ssr
  • cd vue-ssr
  • npm init -y
  • npm i vue vue-server-renderder

server.js

const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer()
const app = new Vue({
  template: `
    <div id="app">
      <h1>{{message}}</h1>
    </div>
  `,
  data: {
    message: '肖战'
  }
})

renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)
})

node server.js,运行结果:

<div id="app" data-server-rendered="true"><h1>肖战</h1></div>

data-server-rendered="true"这个属性是为了将来客户端渲染激活接管的接口

五、结合到Web服务器中

使用express对所有的get请求都做同样的处理,new一个Vue,使用vue-server-renderer的renderToString的方法传入Vue实例,回调函数中的html就是最终得到的DOM结构

server.js

const Vue = require('vue')
const express = require('express')

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

const server = express()

server.get('/', (req, res) => {
  const app = new Vue({
    template: `
      <div id="app">
        <h1>{{message}}</h1>
      </div>
    `,
    data: {
      message: '肖战'
    }
  })
  renderer.renderToString(app, (err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 设置编码,防止乱码
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
      </head>
      <body>
        ${html}
      </body>
      </html>
    `)
  })
})

server.listen(3000, () => {
  console.log('server running at port 3000...')
})


六、使用HTML模板

1. 创建HTML模板文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>

<!--vue-ssr-outlet-->是占位符,为了接收将来要渲染的变量,不能写错,不能有多余的空格

2. js代码中的createRenderer方法指定模板文件

server.js

const Vue = require('vue')
const express = require('express')
const fs = require('fs')

const renderer = require('vue-server-renderer').createRenderer({
  // 这里指定模板文件
  template: fs.readFileSync('./index.template.html', 'utf-8')
})

const server = express()

server.get('/', (req, res) => {
  const app = new Vue({
    template: `
      <div id="app">
        <h1>{{message}}</h1>
      </div>
    `,
    data: {
      message: '拉钩教育'
    }
  })
  renderer.renderToString(app, (err, html) => { // 此处的html参数是被模板文件处理过了的,可以直接输出到用户的页面上
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 设置编码,防止乱码
    res.end(html)
  })
})

server.listen(3000, () => {
  console.log('server running at port 3000...')
})


七、在模板中使用外部数据

Index.template.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  {{{ meta }}}
  <title>{{ title }}</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>

使用两个花括号可以数据外部数据变量,而标签也会进行转义后输出在页面上。此时可以使用三个花括号原样输出数据,不会对标签进行转义处理

在js代码中给renderer.renderToString增加第二个参数为外部数据对象

renderer.renderToString(app, {
    title: '拉勾教育',
    meta: `
      <meta name="description" content="拉勾教育" >
    `
  }, (err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 设置编码,防止乱码
    res.end(html)
  })

八、构建配置-基本思路

1、流程图
在这里插入图片描述
2、源码结构

src
├── components
│   ├── Foo.vue
│   ├── Bar.vue
│   └── Baz.vue
├── App.vue
├── app.js # 通用 entry(universal entry)
├── entry-client.js # 仅运行于浏览器
└── entry-server.js # 仅运行于服务器

App.vue

<template>
  <div id="app">
    <h1>{{message}}</h1>
    <h2>客户端动态交互</h2>
    <div>
      <input v-model="message">
    </div>
    <div>
      <button @click="onClick">点击测试</button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'App',
  data: function () {
    return {
      message: '拉勾教育'
    }
  },
  methods: {
    onClick () {
      console.log('Hello World!')
    }
  }
}
</script>

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

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

// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
  const app = new Vue({
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  return { app }
}

entry-client.js 客户端 entry 只需创建应用程序,并且将其挂载到 DOM 中:

import { createApp } from './app'

// 客户端特定引导逻辑……

const { app } = createApp()

// 这里假定 App.vue 模板中根元素具有 `id="app"`
app.$mount('#app')

entry-server.js 服务器 entry 使用 default export 导出函数,并在每次渲染中重复调用此函数。此时,除了创建和返回应用程序实例之外,它不会做太多事情 - 但是稍后我们将在此执行服务器端路由匹配 (server-side route matching) 和数据预取逻辑 (data pre-fetching logic)。

import { createApp } from './app'

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

3、安装依赖
(1) 安装生产依赖

npm i vue vue-server-renderer express cross-env

说明
vueVue.js核心库
vue-server-rendererVue服务端渲染工具
express基于Node的webpack服务框架
cross-env通过npm scripts设置跨平台环境变量

(2) 安装开发依赖

npm i -D webpack webpack-cli webpack-merge webpack-node-externals @babel/core @babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader url-loader file-loader rimraf vue-loader vue-template-compiler friendly-errors-webpack-plugin

说明
webpackwebpack核心包
webpack-cliwebpack的命令行工具
webpack-mergewebpack配置信息合并工具
webpack-node-externals排除webpack中的Node模块
rimraf基于Node封装的一个跨平台rm -rf工具
friendly-errors-webpack-plugin友好的webpack错误提示
@babel/core
@babel/plugin-transform-runtime
@babel/preset-envbabel-loader
Babel相关工具
vue-loader
vue-template-compiler
处理.vue资源
file-loader处理字体资源
css-loader处理CSS资源
url-loader处理图片资源

4、webpack配置文件及打包命令

(1) 初始化webpack打包配置文件

build
|---webpack.base.config.js # 公共配置
|---webpack.client.config.js # 客户端打包配置文件
|---webpack.server.config.js # 服务端打包配置文件

webpack.base.config.js

/**
 * 公共配置
 */
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const path = require('path')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
const resolve = file => path.resolve(__dirname, file)

const isProd = process.env.NODE_ENV === 'production'

module.exports = {
  mode: isProd ? 'production' : 'development',
  output: {
    path: resolve('../dist/'),
    publicPath: '/dist/',
    filename: '[name].[chunkhash].js'
  },
  resolve: {
    alias: {
      // 路径别名,@ 指向 src
      '@': resolve('../src/')
    },
    // 可以省略的扩展名
    // 当省略扩展名的时候,按照从前往后的顺序依次解析
    extensions: ['.js', '.vue', '.json']
  },
  devtool: isProd ? 'source-map' : 'cheap-module-eval-source-map',
  module: {
    rules: [
      // 处理图片资源
      {
        test: /\.(png|jpg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
            },
          },
        ],
      },

      // 处理字体资源
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        use: [
          'file-loader',
        ],
      },

      // 处理 .vue 资源
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },

      // 处理 CSS 资源
      // 它会应用到普通的 `.css` 文件
      // 以及 `.vue` 文件中的 `<style>` 块
      {
        test: /\.css$/,
        use: [
          'vue-style-loader',
          'css-loader'
        ]
      },
      
      // CSS 预处理器,参考:https://vue-loader.vuejs.org/zh/guide/pre-processors.html
      // 例如处理 Less 资源
      // {
      //   test: /\.less$/,
      //   use: [
      //     'vue-style-loader',
      //     'css-loader',
      //     'less-loader'
      //   ]
      // },
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
    new FriendlyErrorsWebpackPlugin()
  ]
}


webpack.client.config.js

/**
 * 客户端打包配置
 */
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: {
    app: './src/entry-client.js'
  },

  module: {
    rules: [
      // ES6 转 ES5
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            cacheDirectory: true,
            plugins: ['@babel/plugin-transform-runtime']
          }
        }
      },
    ]
  },

  // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,
  // 以便可以在之后正确注入异步 chunk。
  optimization: {
    splitChunks: {
      name: "manifest",
      minChunks: Infinity
    }
  },

  plugins: [
    // 此插件在输出目录中生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
})


webpack.server.config.js

/**
 * 服务端打包配置
 */
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: './src/entry-server.js',

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

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

  // 不打包 node_modules 第三方包,而是保留 require 方式直接加载
  externals: [nodeExternals({
    // 白名单中的资源依然正常打包
    allowlist: [/\.css$/]
  })],

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


5、 配置构建命令

"scripts": {
    "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
    "build": "rimraf dist && npm run build:client && npm run build:server"
  }

6、启动应用
server.js

const Vue = require('vue')
const express = require('express')
const fs = require('fs')

const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const { static } = require('express')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
  template,
  clientManifest 
})

const server = express()

// 请求前缀,使用express中间件的static处理
server.use('/dist', express.static('./dist'))

server.get('/', (req, res) => {
  
  renderer.renderToString({
    title: '拉勾教育',
    meta: `
      <meta name="description" content="拉勾教育" >
    `
  }, (err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 设置编码,防止乱码
    res.end(html)
  })
})

server.listen(3001, () => {
  console.log('server running at port 3001...')
})


九、构建配置开发模式-基本思路

基本思路
生产模式直接渲染,开发模式监视打包构建,重新生成Renderer渲染器
package.json

  "scripts": {
    "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
    "build": "rimraf dist && npm run build:client && npm run build:server",
    // 增加  判断开发模式,生产模式
    "start": "cross-env NODE_ENV=production node server.js",
    "dev": "node server.js"
  },

server.js

const Vue = require('vue')
const express = require('express')
const fs = require('fs')
const createBundleRenderer = require('vue-server-renderer')
const setupDevServer = require('./build/setup-dev-server')

const server = express()

// 请求前缀,使用express中间件的static处理
server.use('/dist', express.static('./dist'))

const isProd = process.env.NODE_ENV === 'production'

let renderer
let onReady
if (isProd) {
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  const { static } = require('express')
  const template = fs.readFileSync('./index.template.html', 'utf-8')
  renderer = createBundleRenderer(serverBundle, {
    template,
    clientManifest 
  })
} else {
  // 开发模式 -> 监视打包构建 -> 重新生成Renderer渲染器
  onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
    renderer = createBundleRenderer(serverBundle, {
      template,
      clientManifest 
    })
  })
}

// render 是路由函数
const render = (req, res) => {
  // renderer是Vue SSR的渲染器
  renderer.renderToString({
    title: '拉勾教育',
    meta: `
      <meta name="description" content="拉勾教育" >
    `
  }, (err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 设置编码,防止乱码
    res.end(html)
  })
}

server.get('/', isProd ? render : async (req, res) => {
  // 等待有了Renderer渲染器以后,调用render进行渲染
  await onReady
  render()
})

server.listen(3001, () => {
  console.log('server running at port 3001...')
})


build/setup-dev-server.js

module.exports = (server, callback) => {
  let ready // ready就是promise中的resolve
  const onReady = new Promise(r => ready = r)

  // 监视构建 -> 更新 Renderer

  let template
  let serverBundle
  let clientManifest
  
  return onReady
  
}

十、构建配置开发模式-update更新函数

build/setup-dev-server.js

module.exports = (server, callback) => {
  let ready // ready就是promise中的resolve
  const onReady = new Promise(r => ready = r)

  // 监视构建 -> 更新 Renderer

  let template
  let serverBundle
  let clientManifest
  
  const update = () => {
  if (template && serverBundle && clientManifest) {
    ready()
    callback(serverBundle, template, clientManifest)
  }
}

  return onReady
  
}

十一、构建配置开发模式-处理模板文件

// 监视构建 template -> 调用 update -> 更新 Renderer 渲染器
const templatePath = path.resolve(__dirname, '../index.template.html')
template = fs.readFileSync(templatePath, 'utf-8')
update()
// fs.watch、fs.watchFile
chokidar.watch(templatePath).on('change', () => {
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
})

十二、服务端监视打包

// 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
const serverConfig = require('./webpack.server.config')
// serverCompiler是一个webpack编译器,直接监听资源改变,进行打包构建
const serverCompiler = webpack(serverConfig)
serverCompiler.watch({}, (err, stats) => {
  if (err) throw err
  if (stats.hasErrors()) return
  serverBundle = JSON.parse(
    fs.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
  )
  console.log(serverBundle)
  update()
})


十三、把数据写入内存中

webpack在打包构建的时候,默认会把构建的结构存储到磁盘中,对于生产模式是没有问题的,但在开发模式下会频繁的修改代码触发构建,这就意味着要频繁的读写磁盘中的数据,磁盘中的数据相对来讲是比较慢的。所以有一种方式,开发模式下在内存中进行读写。这样就可以极大提高构建速度。

npm install webpack-dev-middleware --save-dev
  // 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
const serverConfig = require('./webpack.server.config')
const serverCompiler = webpack(serverConfig)
const serverDevMiddleware = devMiddleware(serverCompiler, {
  logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
})
serverCompiler.hooks.done.tap('server', () => {
  serverBundle = JSON.parse(
    serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
  )
  update()
})

十四、客户端构建

  // 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
  const clientConfig = require('./webpack.client.config')
  clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
  clientConfig.entry.app = [
    'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
    clientConfig.entry.app
  ]
  clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash
  const clientCompiler = webpack(clientConfig)
  const clientDevMiddleware = devMiddleware(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  })
  clientCompiler.hooks.done.tap('client', () => {
    clientManifest = JSON.parse(
      clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
    )
    update()
  })
  server.use(hotMiddleware(clientCompiler, {
    log: false // 关闭它本身的日志输出
  }))

  // 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
  server.use(clientDevMiddleware)

十五、热更新

clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())

clientConfig.entry.app = [
  'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
  clientConfig.entry.app
]
clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash

const hotMiddleware = require('webpack-hot-middleware')

server.use(hotMiddleware(clientCompiler, {
  log: false // 关闭它本身的日志输出
}))

十六、配置VueRouter

router/index.js

 import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '@/src/pages/Home'

Vue.use(VueRouter)

export const createRouter = () => {
  const router = new VueRouter({
    mode: 'history', // 兼容前后端,
    routes: [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        component: () => import('@/src/pages/About')
      },
      {
        path: '*',
        name: 'error404',
        component: () => import('@/src/pages/404')
      }
    ]
  })
  return router // 千万别忘了返回router
}

十七、将路由注册到根实例

app.js

/**
 * 同构应用通用启动入口
 */
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/'

// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
  const router = createRouter()
  const app = new Vue({
    router, // 把路由挂载到Vue根实例当中
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  return { app, router }
}

十八、路由处理-适配服务端入口

拷贝官网上提供的entry-server.js

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

export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,
    // 就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()

    // 设置服务器端 router 的位置
    router.push(context.url)

    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }

      // Promise 应该 resolve 应用程序实例,以便它可以渲染
      resolve(app)
    }, reject)
  })
}

路由表里已经配置过404页面了,所以不用额外判断404,然后将Promise改成async/await的形式,最终如下:

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

export default async context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,
    // 就已经准备就绪。
    const { app, router } = createApp()

    // 设置服务器端 router 的位置
    router.push(context.url)

    // 等到 router 将可能的异步组件和钩子函数解析完
    await new Promise(router.onReady.bind(router))

    return app
}

十九、路由处理-服务端server适配

我们的服务器代码使用了一个 * 处理程序,它接受任意 URL。这允许我们将访问的 URL 传递到我们的 Vue 应用程序中,然后对客户端和服务器复用相同的路由配置!
server.js处理

// ...

// render 是路由函数
const render =async (req, res) => {
  // renderer是Vue SSR的渲染器
  try {
    const html = await renderer.renderToString({
      title: '拉勾教育',
      meta: `
        <meta name="description" content="拉勾教育" >
      `,
      url: req.url
    })
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 设置编码,防止乱码
    res.end(html)
  }catch(err) {
    res.status(500).end('Internal Server Error.')
  }
}

// 服务端路由匹配为*,意味着所有的路由都会进入这里
server.get('*', isProd ? render : async (req, res) => {
  // 等待有了Renderer渲染器以后,调用render进行渲染
  await onReady
  render(req, res)
})

// ...

二十、路由处理-适配客户端入口

需要注意的是,你仍然需要在挂载 app 之前调用 router.onReady,因为路由器必须要提前解析路由配置中的异步组件,才能正确地调用组件中可能存在的路由钩子。这一步我们已经在我们的服务器入口 (server entry) 中实现过了,现在我们只需要更新客户端入口 (client entry):

// entry-client.js

import { createApp } from './app'

const { app, router } = createApp()

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

二十一、路由处理-处理完成

<div id="app">
  <ul>
    <li>
      <router-link to="/">Home</router-link>
    </li>
    <li>
      <router-link to="/about">About</router-link>
    </li>
  </ul>

  <!-- 路由出口 -->
  <router-view/>
</div>

二十二、管理页面

1. Head 内容

npm install vue-meta

在src/app.js里面,增加代码

import VueMeta from 'vue-meta'

Vue.use(VueMeta)

Vue.mixin({
  metaInfo: {
    titleTemplate: '%s - 拉勾教育'
  }
})

在entry-server.js的导出函数里,增加代码:

const meta = app.$meta()
context.meta = meta

将meta数据注入到模板页面index.template.html中:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  {{{ meta.inject().title.text() }}}
  {{{ meta.inject().meta.text() }}}
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>

在vue页面中的应用:

export default {
  name: 'Home',
  metaInfo: {
    title: '首页'
  }
}

二十三、获取数据和状态管理

1. 思路分析
在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据

另一个需要关注的问题是在客户端,在挂载 (mount) 到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。

为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container))"中。首先,在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,我们将在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置(inline)状态。

2. 数据预取

npm install vuex

创建src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

export const createStore = () => {
  return new Vuex.Store({
    state: () => ({
      posts: []
    }),
    mutations: {
      setPosts (state, data) {
        state.posts = data
      }
    },
    actions: {
      // 在服务端渲染期间,务必让action返回一个promise 
      async getPosts ({commit}) { // async默认返回Promise
        // return new Promise()
        const { data } = await axios.get('https://cnodejs.org/api/v1/topics')
        commit('setPosts', data.data)
      }
    }
  })
}

将容器注入到入口文件src/app.js

/**
 * 同构应用通用启动入口
 */
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/'
import VueMeat from 'vue-meta'
import { createStore } from './store'

Vue.use(VueMeta)

Vue.mixin({
  metaInfo: {
    titleTemplate: '%s - 拉勾教育'
  }
})

// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
  const router = createRouter()
  const store = createStore()
  const app = new Vue({
    router, // 把路由挂载到Vue根实例当中
    store, // 把容器挂载到Vue根实例中
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  return { app, router, store }
}

页面pages/Posts.vue,使用serverPrefetch方法在服务端发起异步请求。

<template>
  <div>
    <h1>Post List</h1>
    <ul>
      <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
    </ul>
  </div>
</template>

<script>
// import axios from 'axios'
import { mapState, mapActions } from 'vuex'

export default {
  name: 'PostList',
  metaInfo: {
    title: 'Posts'
  },
  data () {
    return {
      // posts: []
    }
  },
  computed: {
    ...mapState(['posts'])
  },

  // Vue SSR 特殊为服务端渲染提供的一个生命周期钩子函数
  serverPrefetch () {
    // 发起 action,返回 Promise
    // this.$store.dispatch('getPosts')
    return this.getPosts()
  },
  methods: {
    ...mapActions(['getPosts'])
  }

  // 服务端渲染
  //     只支持 beforeCreate 和 created
  //     不会等待 beforeCreate 和 created 中的异步操作
  //     不支持响应式数据
  // 所有这种做法在服务端渲染中是不会工作的!!!
  // async created () {
  //   console.log('Posts Created Start')
  //   const { data } = await axios({
  //     method: 'GET',
  //     url: 'https://cnodejs.org/api/v1/topics'
  //   })
  //   this.posts = data.data
  //   console.log('Posts Created End')
  // }
}
</script>

<style>

</style>


将数据预取同步到客户端
entry-server.js

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

export default async context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,
    // 就已经准备就绪。
    const { app, router, store } = createApp()

    const meta = app.$meta()

    // 设置服务器端 router 的位置
    router.push(context.url)

    context.meta = meta

    // 等到 router 将可能的异步组件和钩子函数解析完
    await new Promise(router.onReady.bind(router))

    // 这个rendered函数会在服务端渲染完毕之后被调用
    context.rendered = () => {
      // Renderer会把 context.state 数据对象内联到页面模板中
      // 最终发送到客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
      // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中 
      context.state = store.state
    }

    return app
}

entry-client.js

// entry-client.js

import { createApp } from './app'

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

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

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

在这里插入图片描述

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值