Vue SSR学习

279 阅读7分钟

1.概念

1.传统方式:

客户端请求一次,服务器就返回一次,包括接口的数据查询,客户端无需而外处理。
  • 优点:响应快,seo很好
  • 缺点:网络资源浪费,每次都重新加载资源和解析

2.传统spa方式:

是服务器返回给客户端所有内容,客户端自己渲染
  • 优点:开发方便,容易维护,渲染计算都在客户端,省流量
  • 缺点:不利于seo搜索,首屏加载慢,对客户端性能要求

3.服务端渲染:SSR server side render

客户端第一次请求,在服务端做首屏渲染,后续操作通过客户端激活实现单页面应用,整合了spa和传统方式的优点
  • 优点:首屏响应快,seo很好
  • 缺点:不好维护,开发难度大,不好理解,移植不方便

2.代码实现

2.1express简单实现 SSR

vue-server-renderer + express

const express = require("express");
const app = express();
// 导⼊Vue构造函数
const Vue = require("vue"); 开课吧web全栈架构师
// createRenderer⽤于获取渲染器
const { createRenderer } = require("vue-server-renderer");
// 获取渲染器
const renderer = createRenderer();

app.get("/", async (req, res) => {
    // 创建⼀个待渲染vue实例
    const vm = new Vue({
        data: { name: "xxxxx" },
        template: `
<div >
<h1>{{name}}</h1>
</div>
`
    });
    try {
        // renderToString将vue实例渲染为html字符串,它返回⼀个Promise
        const html = await renderer.renderToString(vm);
        // 返回html给客户端
        res.send(html);
    } catch (error) {
        // 渲染出错返回500错误
        res.status(500).send("Internal Server Error");
    }
});
app.listen(3000);

2.2路由实现

// 引⼊vue-router
const Router = require('vue-router')
Vue.use(Router)
// path修改为通配符
app.get('*', async function (req, res) {
    // 每次创建⼀个路由实例
    const router = new Router({
        routes: [
            { path: "/", component: { template: '<div>index page</div>' } },
            { path: "/detail", component: { template: '<div>detail page</div>' } }
        ]
    });
    const vm = new Vue({
        data: { msg: 'xxx' },
        // 添加router-view显示内容
        template: `
<div>
<router-link to="/">index</router-link>
<router-link to="/detail">detail</router-link>
<router-view></router-view>
</div>`,
        router, // 挂载
    })

    try {
        // 跳转⾄对应路由
        router.push(req.url);
        const html = await renderer.renderToString(vm)
        res.send(html)
    } catch (error) {
        res.status(500).send('渲染出错')
    }
})

3.工程化构建

3.1实现原理

image.png

通过webpack打包生成

  1. 服务器配置json文件
  2. 客户端激活的js信息。

请求过程

  • 用户第一次请求,服务器返回首屏必要的内容
  • 同时也返回spa需要的js文件
  • 后续操作走spa逻辑

3.2代码逻辑实现

  • 所有实例都需要通过工厂方法返回,如App,router,store

  • 不同用户请求服务器时,服务端每次接受一个请求,都单独实例化一个实例处理。(比较占用服务器资源)

  • main.js 提供createApp方法,输出工厂app, router, store

  • entry-server.js 通过createApp方法创建

//由于服务端有可能有异步,通过promise处理异步。
export default context => {
  // 返回一个Promise,确保路由中可能有异步操作
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp() 
        resolve(app)
    })
}
  • entry-client.js 客户端比较简单,通过createApp方法直接挂载
import {createApp} from './main'
// 创建vue、router实例
const {app, router, store}=createApp()
router.onReady(() => {
  // 挂载
  app.$mount('#app')
})
  • 通过设置vue.config.js 定义不同entry 输出客户端和服务端部署的代码

3.3路由代码实现

//server/04-ssr.js
// 最终的服务端渲染脚本
// node代码
// express server
const express = require('express')
const app = express()
const {createBundleRenderer} = require('vue-server-renderer')

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

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


// 获取渲染器
// 第 3 步:服务端打包文件地址
const bundle = resolve("../dist/server/vue-ssr-server-bundle.json");

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

app.get('*', async (req, res) => {

  // 构造renderer上下文
  const context = {
    title: 'ssr test',
    url: req.url // 用户请求的首屏地址
  }
  
  try {
    // renderToString将Vue实例转换为html字符串
    const html = await renderer.renderToString(context)
    res.send(html)
  } catch (error) {
    res.status(500).send('服务器渲染错误')
  }
  
})

app.listen(3000)


//src/router/index.js

import Vue from 'vue'
import Router from 'vue-router'
import Index from '@/views/index.vue'
import Detail from '@/views/detail.vue'

Vue.use(Router)
// 路由配置
const routes =
  [
    // 客户端没有编译器,这里要写成渲染函数
    { path: "/", component: Index },
    { path: "/detail", component: Detail }
  ]
// 不同之处,这里应该是创建路由器实例的工厂函数
export function createRouter() {
  return new Router({
    mode: 'history',
    routes
  })
}

//main.js 这是一个工厂
import Vue from "vue";
import App from "./App.vue"; 
// 每次请求都必须是全新vue实例
// 此方法未来的调用者会是renderer
// context是renderer传递给我们的参数
export function createApp(context) {
  // 创建路由器实例
  const router = createRouter()

  const app = new Vue({
    router,
    context,
    render: h => h(App)
  })

  return { app, router }
}

//src/entry-client.js
// 客户端入口,用于客户端激活
// 下面代码在浏览器执行
import {createApp} from './main'

// 创建vue、router实例
const {app, router}=createApp()

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


//src/entry-server.js

// 主要用于首屏渲染
import { createApp } from './main'

// renderer传入一个url地址,即首屏地址
export default context => {
  // 返回一个Promise,确保路由中可能有异步操作
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()
    // 获取首屏地址, 并且跳转过去
    router.push(context.url) 
    router.onReady(() => {
        resolve(app) 
    }, reject)
  })
}


//vue.config.js
// 两个插件分别负责打包客户端和服务端
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 = {
  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)的依赖模块列入白名单
          whitelist: [/\.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
        });
      });
  }
};

//package.json
  "scripts": {
    "build:client": "vue-cli-service build",
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build",
    "build": "npm run build:server && npm run build:client"
  },
  
  
// public/index.html 
 //<!--vue-ssr-outlet-->用于给客户端插入激活的spa代码
<!DOCTYPE html>
<html lang="en">
  <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>Document</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

3.4 存储与异步代码实现

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

  • 在对应的compoent页面约定asyncData 开头的方法都是异步方法,通过vuex的dispatch返回一个promise,
  • 服务器:渲染时候遍历router.getMatchedComponents() 里面所有组件的异步,通过Promise.all全部执行后才resolve(app),同时把结果 保存到 window.INITIAL_STATE = 'xxxxx' 方便客户端激活时候初始化时拿到数据
  • 客户端:渲染时候通过store.replaceState(window.__INITIAL_STATE__)替换服务端解析好的内容

3.4.1完整代码

//src/store/index.js

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

Vue.use(Vuex)

// 区别也是创建工厂函数
export function createStore () {
  return new Vuex.Store({
    state: {
        count:108
    },
    mutations: {
      add(state){
        state.count += 1;
      },
      init(state, count) {
        state.count = count;
      },
    },
    actions: {
      // 加一个异步请求count的action
      getCount({ commit }) {
        console.log('action:getCount');
        return new Promise(resolve => {
          setTimeout(() => {
            commit("init", Math.random() * 100);
            resolve();
          }, 1000);
        });
      },
    },
  })
}


//src/main.js
import Vue from "vue";
import App from "./App.vue";
import { createRouter } from './router/index';
import { createStore } from './store/index';

Vue.config.productionTip = false;

Vue.mixin({
  beforeMount() {
    const { asyncData } = this.$options;
    if (asyncData) {
      // 将获取数据操作分配给 promise
      // 以便在组件中,我们可以在数据准备就绪后
      // 通过运行 `this.dataPromise.then(...)` 来执行其他任务
      this.dataPromise = asyncData({
        store: this.$store,
        route: this.$route,
      });
    }
  },
});

// 每次请求都必须是全新vue实例
// 此方法未来的调用者会是renderer
// context是renderer传递给我们的参数
export function createApp(context) {
  // 创建路由器实例
  const router = createRouter()
  const store = createStore()

  const app = new Vue({
    router,
    store,
    context,
    render: h => h(App)
  })

  return { app, router, store }
}

//src/entry-client.js
// 客户端入口,用于客户端激活
// 下面代码在浏览器执行
import {createApp} from './main'

// 创建vue、router实例
const {app, router, store}=createApp()

// 回复store初始状态
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

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


//src/entry-server.js
// 主要用于首屏渲染
import { createApp } from './main'

// renderer传入一个url地址,即首屏地址
export default context => {
  // 返回一个Promise,确保路由中可能有异步操作
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    // 获取首屏地址, 并且跳转过去
    router.push(context.url)

    router.onReady(() => {
      // 检查一下当前匹配的组件是否需要请求异步数据
      const matchedComponents = router.getMatchedComponents();
        
      // 若无匹配则抛出异常
      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      console.log(matchedComponents);

      Promise.all(
        matchedComponents.map(Component => {
          if (Component.asyncData) {
            return Component.asyncData({
              store,
              route: router.currentRoute,
            });
          }
        }),
      ).then(() => {
        // 所有异步请求结束,需要将这些状态同步到前端
        // 状态将自动序列化为window.__INITIAL_STATE__ = 'xxxxx'
        // 这个工作是有renderer
        context.state = store.state
        resolve(app)
      }).catch(reject)
     
    }, reject)
  })

}

//App.vue
<template>
  <div id="app">
    <nav>
      <router-link to="/">index</router-link>
      <router-link to="/detail">detail</router-link>
    </nav>
    <h2 @click="$store.commit('add')">{{$store.state.count}}</h2>
    <HelloWorld msg="vue ssr"></HelloWorld>
    <router-view></router-view>
  </div>
</template>

<script>
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  components: {
    HelloWorld
  }
}
</script>
 
 
 //src/views/index.vue
 <template>
  <div>index page</div>
</template>

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

3.5客户端数据预取处理

在客户端激活后,其他页面如果也要拿到数据,需要设置mixin 添加通用方法到每一个页面

//main.js
Vue.mixin({
    beforeMount() {
        const { asyncData } = this.$options;
        if (asyncData) {
            // 将获取数据操作分配给 promise
            // 以便在组件中,我们可以在数据准备就绪后
            // 通过运⾏ `this.dataPromise.then(...)` 来执⾏其他任务
            this.dataPromise = asyncData({
                store: this.$store,
                route: this.$route,
            });
        }
    },
});

//设置后 对应vue页面如 detail.vue
<template>
  <div>
    detail 
  </div>
</template>

<script>
  export default { 
  asyncData({ store }) {
    // 约定预取逻辑编写在预取钩子asyncData中
    // 触发 action 后,返回 Promise 以便确定请求结果
    return store.dispatch("getCount");
  },
  mounted(){
    //这里调用的dataPromise 就是上面自己定义的 asyncData方法返回的pormise对象
    this.dataPromise.then( (res) => {
      //这里处理对应业务
    })
  },
  }
</script> 

4.热更新开发

实现逻辑:判断当前是否为dev环境,则每次请求都通过工厂方法,返回最新的渲染代码

安装依赖 (由于npm是在代码里面执行所以也要安装)

npm i chokidar npm browser-sync -D

代码改造


// 获取渲染器
// 第 3 步:服务端打包文件地址
const bundle = resolve("../dist/server/vue-ssr-server-bundle.json");
//每次都返回最新 的render
function createRenderer() { 
  const renderer = createBundleRenderer(bundle, {
  runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext
  template: require('fs').readFileSync(resolve("../public/index.html"), "utf-8"), // 宿主文件
  clientManifest: require(resolve("../dist/client/vue-ssr-client-manifest.json")) // 客户端清单
});
return renderer
}
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {
  const cp = require('child_process')
  const bs = require('browser-sync').create()
  const chokidar = require('chokidar')
  const watch = chokidar.watch("src/**/*.*")//扫描所有文件夹和文件
  watch.on('change',(path) => {
    console.log("当前数据正在变化.....重新编译中...")
    cp.exec("npm run build",function (error,stdout) {
      if(error) {
        console.log("编译失败",error.stack)
        return
      }
      console.log(stdout)//
      console.log("编译完成")//
      //浏览器刷新
      bs.reload()
    })//重新执行 build构建
  })
  bs.init({proxy:"http://localhost:3000"}) //绑定当前的bs跟 调试的node服务一样的地址,则实现浏览器对应页面 reload
}

let renderer 定义全局变量

app.get('*', async (req, res) => {
  try { 
    //每次确保内容最新
    if (isDev || !renderer) {//第一次 可能renderer为空
      renderer = createRenderer() 
    } 
    console.log("renderer",renderer)
    // renderToString将Vue实例转换为html字符串
    const html = await renderer.renderToString(context)
    res.send(html)
  } catch (error) {
    res.status(500).send('服务器渲染错误')
  }
})

//package.json 新增启动环境变量development 使用 nodemon 实时监听文件变化

  "scripts": {
    "dev": "cross-env NODE_ENV=development nodemon ./server/04-ssr.js --watch server"
  },

5.总结

服务端渲染一些看法

  • 优点:
  1. 输出实际的html内容,便于搜索引擎爬虫的收集。
  2. 首次加载响应速度快
  3. 运算都在服务端进行,所以客户端直接解析速度快
  4. 代码更加安全,逻辑都在服务端执行
  • 缺点:
  1. 每次都在服务端返回,增加的服务端请求次数,服务端性能要求
  2. 增加了前端开发的人才技能全面要求的难度,不便于后期维护
  3. 开发调试比传统spa开发模式不太一样,调试与查错成本较高。
  4. 性能较差,需要后期做缓存优化
  • 使用的场景
  1. 对于需要推广的外部系统,需要seo优化的系统,可以通过区分是爬虫还是普通用户,来实现不同逻辑的返回响应
  2. 适合大型,多并发的应用程序,相同的结果生成一样的html静态内容。
  3. 适合用户体验要求很高的应用程序,如果上次和当前的结果不变,则使用缓存而不请求服务器。