• 大约 8 分钟

概念

服务端渲染:将vue实例渲染为HTML字符串直接返回,在前端激活为交互程序

优点

  • seo
  • 首屏内容到达时间

缺点

  • 复杂度
  • 服务端性能
    • 缓存
    • 降级 监控cpu内存
    • 静态化

场景

  • seo: 不一定需要ssr, 比如pre-render
  • 服务器检测爬虫 -> puppeteer -> spa
  • nuxt.js

服务端知识 express

npm i express -S

基础http服务

// nodejs代码
const express = require('express');
// 获取express实例
const server = express();
// 编写路由处理不同url请求
server.get('/', (req, res) => {
  res.send('hello world!');
});
// 监听端口
server.listen(80, () => {
  console.log('server running!');
}); 

基础实现

使用渲染器将vue实例成HTML字符串并返回

安装vue-server-renderer

npm i vue-server-renderer -S

vue vue-server-renderer 的版本要一致

// nodejs代码
const express = require('express');
const Vue = require("vue");
const {createRenderer} = require("vue-server-renderer");
const renderer = createRenderer()
// 获取express实例
const server = express();
// 编写路由处理不同url请求
server.get('/', (req, res) => {
  // 1.创建Vue实例
  const app = new Vue({
    template: `<div @click="onClick">{{msg}}</div>`,
    data() {
      return {msg: 'Hello World! SSR'}
    },
    methods: {
      // 不起作用
      onClick() {
        console.log('onClick', this.msg);
      }
    }
  })
  // 3.用渲染器渲染vue实例
  renderer.renderToString(app).then(html => {
    console.log('html =>', html);
    res.send(html);
  }).catch(err => {
    console.log('err =>', err);
    res.status(500);
    res.end('Internal Server Error, 500 !')
  })
});
// 监听端口
server.listen(80, () => {
  console.log('server running!');
});

理解ssr

传统web开发

传统web开发,网页内容在服务端渲染完成,一次性传输到浏览器

客户端->>服务器: 访问url(请求)
Note right of 服务器: 查询数据库,拼接html字符串(模板)
服务器-->>客户端: 给你html(响应)
Note left of 客户端: 渲染html

单页应用 Single Page App

单页应用优秀的用户体验,使其逐渐成为主流,页面内容由js渲染出来,这种方式成为客户端渲染。

客户端->>服务器: 访问url(请求)
Note right of 服务器: 返回html结构
服务器-->>客户端: 给你html(没有dom结构)
Note left of 客户端: 渲染html
Note left of 客户端: 渲染执行js 比如vue.js
Note left of 客户端: 渲染template
客户端->>服务器: 要数据
服务器-->>客户端: 给json

打开页面查看源码,浏览器拿到的仅有宿主元素#app,并没有内容。

spa两个问题。

  • 首屏内容到达数据长
  • seo不友好

服务端渲染 Server Side Render

ssr解决方案,后端渲染出完整的首屏dom结构返回,前端拿到的内容包括首屏及完整spa结构,应用激活后仍然按照spa方式运行买这种页面渲染方式被称为服务端渲染

客户端->>服务器: 访问url(请求)
Note right of 服务器: 读取Vue模板,解析成dom节点
Note right of 服务器: 返回html结构
服务器-->>客户端: 给你html(首屏HTML)
Note left of 客户端: 显示首屏、激活

路由

路由支持仍然使用vue-router

同构开发SSR应用

对于同构开发,我们仍然使用webpack打包,我们要解决两个问题:服务器首屏渲染和客户端渲染

构建流程

目标是生成一个【服务器bundle】用于服务器首屏渲染,和一个【客户端bundle】用于客户端激活。

代码结构

除了两个不同入口之外,其他结构和之前vue应用完全相同。

|-- src
    |-- router
    |   |-- index.js #路由声明
    |-- store
    |   |-- index.js #全局状态
    |-- main.js #用于创建vue实例
    |-- entry-client.js #客户端入口,用于静态内容"激活"
    |-- entry-server.js #服务端入口,用于首屏内容渲染

路由配置

创建 src/router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '../views/HomeView.vue'
import abortView from '../views/AboutView.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    component: abortView
  }
]

// 返回一个工厂函数,它可以创建路由实例
export default function createRouter() {
  return new VueRouter({
    mode: 'history',
    routes
  })
}

主文件

跟之前不同,主文件是负责创建vue实例的工厂,每次请求均会有独立的vue实例创建。创建main.js

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

Vue.config.productionTip = false

// 需要返回一个应用程序工厂, 返回Vue实例和Router实例、 Store实例
export default function createApp(context) {
  // 处理首屏,就要先处理路由跳转
  const router = createRouter();
  const app = new Vue({
    router,
    context,
    render: h => h(App)
  })
  return {app, router};
}

服务器入口

上面的bundle就是webpack打包的服务器bundle,我们需要编写服务端入口文件 src/entry-server.js

它的任务是创建Vue实例并根据传入url指定首屏

import createApp from "@/main";

// 用于首屏渲染
// context由renderer传入
export default context => {
  return new Promise((resolve, reject) => {
    // 获取路由器和app实例
    const {app, router} = createApp();
    // 获取首屏地址
    router.push(context.url);
    router.onReady(() => {
      resolve(app);
    }, reject);
  })
};

客户端入口

客户端入口只需创建vue实例并执行挂载,这一步称为激活。创建entry-client.js

import createApp from "@/main";

// 客户端激活
const {app, router} = createApp();

router.onReady(() => {
  // 挂载激活
  app.$mount('#app');
});

webpack配置

安装依赖

npm install webpack-node-externals loadsh.merge -D

具体配置,vue.config.js

const { defineConfig } = require('@vue/cli-service');
// 两个插件分别负责打包客户端和服务端
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin");
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");
const nodeExternals = require("webpack-node-externals");
const merge = require("lodash.merge");
// 根据传入环境变量决定入口文件和相应配置项
const TARGET_NODE = process.env.WEBPACK_TARGET === "node";
const target = TARGET_NODE ? "server" : "client";

module.exports = defineConfig({
  transpileDependencies: true,
  css: {
    extract: false
  },
  outputDir: './dist/'+target,
  configureWebpack: () => ({
    // 将 entry 指向应用程序的 server / client 文件
    entry: `./src/entry-${target}.js`,
    // 对 bundle renderer 提供 source map 支持
    devtool: 'source-map',
    // target设置为node使webpack以Node适用的方式处理动态导入,
    // 并且还会在编译Vue组件时告知`vue-loader`输出面向服务器代码。
    target: TARGET_NODE ? "node" : "web",
    // 是否模拟node全局变量
    node: TARGET_NODE ? undefined : false,
    output: {
      // 此处使用Node风格导出模块
      libraryTarget: TARGET_NODE ? "commonjs2" : undefined
    },
    // https://webpack.js.org/configuration/externals/#function
    // https://github.com/liady/webpack-node-externals
    // 外置化应用程序依赖模块。可以使服务器构建速度更快,并生成较小的打包文件。
    externals: TARGET_NODE
      ? nodeExternals({
        // 不要外置化webpack需要处理的依赖模块。
        // 可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
        // 还应该将修改`global`(例如polyfill)的依赖模块列入白名单
        allowlist: [/\.css$/]
      })
      : undefined,
    optimization: {
      splitChunks: undefined
    },
    // 这是将服务器的整个输出构建为单个 JSON 文件的插件。
    // 服务端默认文件名为 `vue-ssr-server-bundle.json`
    // 客户端默认文件名为 `vue-ssr-client-manifest.json`。
    plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
  }),
  chainWebpack: config => {
    // cli4项目添加
    if (TARGET_NODE) {
      config.optimization.delete('splitChunks')
    }

    config.module
      .rule("vue")
      .use("vue-loader")
      .tap(options => {
        merge(options, {
          optimizeSSR: false
        });
      });
  }
})

脚本配置

安装依赖

npm i cross-env -D

定义创建脚本,package.json

{
  "scripts": {
    "build": "npm run build:server & npm run build:client",
    "build:client": "vue-cli-service build",
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build"
  }
}

执行打包:npm run build

宿主文件

最后需要定义宿主文件,修改 public/index.html

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>SSR</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

服务器启动文件

修改服务器启动文件,现在需要处理所有路由。 server/04-ssr.js

// nodejs代码
const express = require('express');
const fs = require('fs');
const path = require('path');
const {createBundleRenderer} = require("vue-server-renderer");

// 获取express实例
const server = express();

// 获取文件绝对路径
const resolve = dir => path.resolve(__dirname, dir)

// 第 1 步:开放dist/client目录,关闭默认下载index页的选项,不然到不了后面路由
server.use(express.static(resolve('../dist/client'), {index: false}))

// 获取渲染器
const bundle = resolve("../dist/server/vue-ssr-server-bundle.json");
const renderer = createBundleRenderer(bundle, {
  runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext
  template: fs.readFileSync(resolve("../public/index.html"), "utf-8"), // 宿主文件
  clientManifest: require(resolve("../dist/client/vue-ssr-client-manifest.json")) // 客户端清单
})

// 编写路由处理不同url请求
server.get('*', (req, res) => {
  // 用渲染器渲染vue实例
  const context = {url: req.url};
  renderer.renderToString(context).then(html => {
    console.log('html =>', html);
    res.send(html);
  }).catch(err => {
    console.log('err =>', err);
    res.status(500);
    res.end('Internal Server Error, 500 !')
  })
});
// 监听端口
server.listen(80, () => {
  console.log('server running!');
});

整合vue

安装vuex

npm i -S vuex

store/index.js

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default function createStore() {
  return new Vuex.Store({
    state: {
      count: 108
    },
    mutations: {
      add(state) {
        state.count += 1
      }
    }
  })
}

挂载store main.js

import Vue from 'vue'
import App from './App.vue'
import createRouter from './router'
import createStore from './store'

Vue.config.productionTip = false

// 需要返回一个应用程序工厂, 返回Vue实例和Router实例、 Store实例
export default function createApp(context) {
  // 处理首屏,就要先处理路由跳转
  const router = createRouter();
  const store = createStore();
  const app = new Vue({
    router,
    store,
    context,
    render: h => h(App)
  })
  return {app, router, store};
}

数据预取

服务器端渲染的是应用程序的"快照",如果应用依赖于一些异步数据,那么在开始渲染之前,需要先预取和解析好这些数据。

异步数据获取, store/index.js

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default function createStore() {
  return new Vuex.Store({
    state: {
      count: 0
    },
    mutations: {
      init(state, count) {
        state.count = count;
      },
      add(state) {
        state.count += 1
      }
    },
    actions: {
      // 加一个异步请求count的action
      getCount({commit}) {
        return new Promise(resolve => {
          setTimeout(() => {
            commit('init', Math.random() * 100);
            resolve();
          }, 1000);
        })
      }
    }
  })
}

组件中的数据预取逻辑。 Home.vue

export default {
  // 约定预取逻辑编写在预期钩子asyncData中
  asyncData({store}) {
    // 触发action后,返回Promise以便确定请求结果
    return store.dispatch('getCount');
  }
}

服务器数据余预期 entry-server.js

import createApp from "@/main";

// 用于首屏渲染
// context由renderer传入
export default context => {
  return new Promise((resolve, reject) => {
    // 获取路由器和app实例
    const {app, router, store} = createApp();
    // 获取首屏地址
    router.push(context.url);
    router.onReady(() => {
      // 获取当前匹配的所有组件
      const matched = router.getMatchedComponents();
      // 404
      if (!matched.length) {
        return reject({
          code: 404,
          message: '未查询到对应组件',
        });
      }
      // 遍历matched,判断它们内部有没有asyncData
      // 如果有就执行它们,等待执行完毕再返回
      Promise.all(matched.map(component => {
        if (component.asyncData) {
          return component.asyncData({
            store,
            route: router.currentRoute,
          })
        }
      })).then(() => {
        // 约定将app数据状态放入context.state
        // 渲染器会将state序列化成字符串 window.__INITIAL_STATE__
        // 未来在前端激活之前可以再恢复它
        context.state = store.state;
        resolve(app);
      }).catch(reject);
    }, reject);
  })
};

客户端在挂载到应用程序之前,store就应该获取到状态 entry-client.js

import createApp from "@/main";

// 客户端激活
const {app, router, store} = createApp();

// 当使用template时,context.state将作为window.__INITIAL_STATE__状态自动嵌入到最终的HTML
// 在客户端挂载到应用程序前,store就应该获取到状态
// 还原state
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__);
}

router.onReady(() => {
  // 挂载激活
  app.$mount('#app');
});

客户端预期处理 main.js

// 加一个全局混入,处理客户端asyncData调用
Vue.mixin({
  beforeMount() {
    const {asyncData} = this.$options;
    if (asyncData) {
      // 将获取数据操作分配给promise
      // 以便在组件中,我们可以在数据准备就绪后
      // 通过运行`this.dataPromise.then`来执行其他任务
      this.dataPromise = asyncData({
        store: this.$store,
        route: this.$route,
      });
    }
  }
})
上次编辑于:
贡献者: haozg