Vue中同构开发SSR应用(中)

1,000 阅读11分钟

前言

上一篇我们通过Web开发的发展了解了SSR的基本原理和实现逻辑,同时初步在Vue中进行了简单的实践,还记得我们上一篇最后留下来的问题吗?(可以看一下上篇理解SSR以及实践于Vue(上)那么这篇我们就来看看如何才能在Vue中同构开发SSR应用~

PS:Vue-SSR将会分为三部分完成:理解SSR以及实践于Vue(上)、Vue中同构开发SSR应用(中)、Nuxt.js实践(下)

两个问题

我们上一篇抛出了疑问,那就是我们是前端开发,总不能说按照前后端混合的方式来进行日常开发吧,而且前后端杂糅在一起看着就很复杂,那我们能不能按照我们熟悉的Vue开发方式呢?并且我们也能使用webpack打包工具吗?

答案自然是肯定的,那到底应该怎么做呢?首先在正式开始之前,我们应该要明确两个问题,通过上篇对SSR的学习,我们知道了无非要解决的问题就两个:服务端首屏渲染客户端激活

那接下来我们就按照这个思路一步步的完成这个操作!

构建流程

我们首先来构建大概的流程,我们的主要目标是生成一个「服务器bubundle」用于服务端首屏渲染,和一个「客户端bundle」用于客户端激活,那么很明显,我们打包之前的入口就不能再是一个了,我们先来看图:

构建流程图.png 然后我们再来看一看代码结构和之前发生了变化:

src
├── router
├────── index.js # 路由声明
├── store
├────── index.js # 全局状态
├── main.js # ⽤于创建vue实例
├── entry-client.js # 客户端⼊⼝,⽤于静态内容“激活”
└── entry-server.js # 服务端⼊⼝,⽤于⾸屏内容渲染

我们发现和之前相比就多了两个不同的入口,其余的并没有什么变化,接下来我们就对不同文件做相应的更改

1、路由配置

首先我们先来修改一下路由文件的配置,我们先直接看修改过后的代码,看看和之前有哪些不一样:

import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import About from './views/About.vue'

Vue.use(Router)

// 导出工厂函数,服务端不能再是以前单实例的模式,否则用户访问便是会出现污染
export default function createRouter() {
  return new Router({
    mode: 'history',
    routes: [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        component: About
      }
    ]
  })
}

我们看到更改后的代码最大的改变那就是将以前的单例模式改成了现在的工厂函数模式,因为服务端渲染不一样,每次不同用户的访问都应该返回单独的实例对象

2、主文件更改

主文件更改也一样,也是需要写成创建vue实例的⼯⼚,每次请求均会有独⽴的vue实例创建,具体改变看代码:

import Vue from "vue";
import App from "./App.vue";
import createRouter from "./router";

Vue.config.productionTip = false;

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

我们可以看到,这里的改动大致和路由的改动差不多,也是返回创建工厂函数

我们这里可以想一个问题:那就是这个context是从哪儿传过来的?或者换句话说这个createApp这个函数是由谁来调用的呢?

我们先接着往下看

3、创建服务端入口

上⾯图中的bundle就是webpack打包的服务端bundle,因此我们需要编写服务端⼊⼝⽂件src/entry-server.js 它的任务是:创建Vue实例并根据传⼊url指定⾸屏

import createApp from "./main";

// 用于首屏渲染
// context由renderer传入
// 返回⼀个函数,接收请求上下⽂,返回创建的vue实例
export default (context) => {
  // 这⾥返回⼀个Promise,确保路由或组件准备就绪
  return new Promise((resolve, reject) => {
    // 1.获取路由器和app实例
    const { app, router } = createApp(context);
    // 获取首屏地址
    // 跳转到⾸屏的地址
    router.push(context.url);
    // 路由就绪,返回结果
    router.onReady(() => {
      resolve(app)
    }, reject);
  });
};

我们看到这里是服务端的入口文件,在这里我们调用了一次createApp,并且将接收的context传入了进去,那它又是从哪来的呢?

4、创建客户端入口

客户端⼊⼝只需创建vue实例并执⾏挂载,这⼀步称为激活。创建entry-client.js:

import createApp from "./main";
// 客户端激活
const {app, router} = createApp()

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

这里我们来看一个图:

hydrating.png 其实我们如果看过Vue源码的话,我们知道$mount还有第二个参数hydrating(吸水注水的意思),其实这个参数如果为true的话,就代表着启用SSR的方式(今天这个例子我们用另外一种方式)

PS:我们看到app的挂载我们写在了这里,以前都是在主文件里面做了,但是现在我们写在了这里,因为服务端没有挂载这一说,的等到传到了客户端再进行

5、webpack配置

1、安装依赖 PS:这里注意版本号的问题,最新的如果报错的可以考虑降低版本

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

2、具体配置,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
        });
      });
  }
};

6、自定义脚本配置

1、安装依赖

npm i cross-env -D

2、定义创建脚本,package.json

"scripts": {
  "serve": "vue-cli-service serve",
  "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"
}

PS:执行打包:npm run build

7、修改宿主文件

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

<!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>

我们看到这个地方,body内部以前的宿主元素被替换成了上面的东西,这就是SSR的约定输出口,约定照写就行

PS:不要自己加一些空格之类的,就照约定写,不然会报错

好了,到这里我们的主要步骤就基本上完成了,最后我们使用node来做服务端,编写服务端脚本将我们的例子跑起来,马上就可以看到结果啦~

8、编写服务器启动文件

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

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

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

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

// 服务端渲染模块vue-server-renderer
// 第 2 步:获得⼀个createBundleRenderer
const {createBundleRenderer} = require('vue-server-renderer')

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

// 第 4 步:创建渲染器
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) => {
  try {
    // 设置url和title两个重要参数
    const context = {
      url: req.url,
      title: 'ssr'
    }
    const html = await renderer.renderToString(context)
    res.send(html)
  } catch (error) {
    res.status(500).send('服务器内部错误')
  }
})

// 监听
app.listen(3000)

OK,全部都准备就绪了,接下来我们只需要执行npm run build 和 node执行一下ssr.js文件启动一下服务器,接下来我们就可以在浏览器中看到我们要的结果啦(不容易啊🎉🎉🎉),如图:

ssr结果显示.png 我们同样查看源代码:

ssr显示源代码.png 看到红色框的属性了吗?熟悉吗,看过上一篇的一定很熟悉,这是vue中使用SSR的标志,然后我们再看到蓝色框的,defer属性,我们也了解,那么也就是说SSR除了返回了首屏外,一些JS脚本都是偷偷后台下载之后然后延迟执行的,这对用户的体验无疑有了提升

整合Vuex

看到前面,vue的SSR的同构开发其实我们已经做的差不多了,接下来我们需要考虑的就是数据问题了,下面我们再来将vuex也给整合进去

1、安装vuex

vue add vuex

2、修改store.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;
      },
    },
  });
}

3、挂载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,
    context,
    store,
    render: (h) => h(App)
  })
  return { app, router, store }
}

完成这三步vuex就便是成功的整合进去了,我们来检验一下,在Home组件里面使用store:

PS:千万记得要重新打包之后然后重启服务,因为我们这是自己搭建的SSR开发,没有配置自动更新等功能,这个下篇使用Nuxt.js的时候我们就会有所体会

<h2 @click="$store.commit('add')">{{$store.state.count}}</h2>

最后的结果也和我们预测的一样,如图:

整合vuex.png

至此,vue的SSR同构开发我们就算是全部搭建完成了,相信如果我们自己能从头到尾走一遍的话,我们会对SSR会有一个比较深的认识。

但是我们还剩下最后一个问题:那就是使用SSR的时候,如果首屏渲染就要依赖异步请求数据我们又该怎么做呢?

数据预取

举个🌰:我们可以把SSR渲染的看作是应用的“照片”,那我们想要将照片完整的洗出来,那我们在这之前是不是要准备好“底片”呢,这其实就是数据预取的意思

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

1、我们先在store里面加上异步操作:

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export 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);
        });
      },
    },
  });
}

2、组件中的数据预取逻辑,Home组件里面:

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

PS:这里的asyncData的写法是约定的写法,我们现在直接这么写有助于后面Nuxt.js里面写法的理解

3、服务端数据预取,entry-server.js

import createApp from "./main";

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

      // 若⽆匹配则抛出异常
      if (!matchedComponents.length) {
        return reject({code: 404})
      }
      // 遍历matchedComponents,判断它内部又没有asyncData
      // 如果有就执行,等待执行完毕之后再返回app
      Promise.all(
        matchedComponents.map(Component => {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        })
      )
        .then(() => {
          // 约定将app数据状态放入context.state
          // 渲染器会将state序列化为字符串,Window.__INITIAL_STATE__
          // 未来在前端激活之前可以再恢复它
          context.state = store.state
          resolve(app)
        })
        .catch(reject)
    }, reject);
  });
};

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

import createApp from "./main";

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

// 恢复state
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

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

PS:在app挂载之前将state恢复

如此,我们的数据预处理部分就基本上都完成了,最后一个小问题就是如果我们刚开始请求的不是首屏然后再跳到首屏来的话,我们怎么处理asyncdata呢?换句话说,我们怎么处理客户端asyncdata的调用

5、客户端数据预取处理,main.js

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

Vue.config.productionTip = false;

// 加一个全局混入,处理客户端asyncData的调用
Vue.mixin({
  beforeMount() {
    const {asyncData} = this.$options
    if (asyncData) {
      asyncData({
        store: this.$store,
        route: this.$route
      })
    } 
  },
})

OK,到这里整篇文章就是真的要结束了,虽然文章篇幅是挺长的,但是基本上从头到尾将如何构建vue SSR的整个过程完整的叙述了一遍,如果有兴趣的可以自己从头到尾敲一边,相信自己会有较大的收获~

这一篇我们主要从头构建了一遍vue SSR同构开发的整个流程,这对于我们理解SSR和后面学习Nuxt.js有着较大的帮助,因为主要原理搞懂了,再使用开箱即用的框架,那理解起来自然会顺畅很多,下一篇我们就将继续学习Nuxt.js

文末

欢迎关注「前端光影」公众号,公众号都是以系统专题模块的形式来展示的,这样看起来就会比较方便,系统,让我们一起持续学习各种前端知识,加油!