Vue同构赋能之 VUE SSR 篇

4,696 阅读11分钟

👋👋今天和大家分享一下VUE同构方面的相关的内容
同构应用既服务器渲染应用,相比起前后端分离应用,好处当然不言而喻
更快的首屏输出,更好的SEO优化,对低版本浏览器的兼容等等

不过,对于我来说VUE服务端渲染最大优势是它既能拥有直输型web应用的能力
还能享受MVVM前后端分离框架开发的效率与便利
最妙的是SSR首屏渲染输出后,前端就被VUE接管,优雅地变成了单页应用

我对 VUE 同构方面的内容还蛮感兴趣的,可能是我之前做过几年 .net 的缘故吧😂
也前前后后投产过几个SSR项目,有些经验可以分享给大家,自己也好重新整理下相关知识。

demo放在github,地址在文章末尾

原理

其实我们抛开前后端分离,同构
无论是前端动态生成的DOM,亦或是后端输出HTML片段
其实我们想要的结果是生成HTML给浏览器去渲染
所以我们要做的就是,在后端帮用户跑一遍VUE,然后输出HTML

我们知道在浏览器端,
VUE 在 mount 方法中执行 render 函数生成 vnode,
然后在 Watcher 中执行 vm._update 生成真实的DOM
在服务端是不行的,因为没有浏览器上下文

我们需要额外的方法,就是这个包: vue-server-renderer

来看看示例

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

const app = new Vue({
data: {
  url: req.url
},
template: `<div>访问的 URL 是: {{ url }}</div>`
})

renderer.renderToString(app, (err, html) => {
    // 输出HTML
    console.log(html);
})

可以看到,就是这么简单

vue app > serverRender > html

当然如果全是这种方式输出HTML,估计头会被打爆
首先vue页面没有提取出来,不能和前端共用
也不能处理样式,多组件情况下更是要命...

很显然,还有另一种构建方式

const createApp = require('/path/to/built-server-bundle.js')
const { createBundleRenderer } = require('vue-server-renderer')


const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // 推荐
  template, // (可选)页面模板
  clientManifest // (可选)客户端构建 manifest
})

  const context = { url: req.url }
  // 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
  // 现在我们的服务器与应用程序已经解耦!
  renderer.renderToString(context, (err, html) => {
    // 处理异常……
    console.log(html)
  })

这样看起来就顺眼多了,那么built-server-bundle.js哪里来的呢

没错,就是webpack构建出来的

到这里,我们就有一个大概的思路了
使用 webpack 对我们编写的 VUE APP 打前后端两个包
后端构建完Render之后,根据url生成html和相关依赖并输出给前端,之后前端接管

我画了一张图来更好的理解

目录结构

那么首先,我们看一下VUE SSR项目的目录结构

首先是config,这里放的是webpack的三份打包配置
dist是打包之后生成的文件
server是服务端的代码
src是前端VUE的代码

entry-client.js
entry-server.js

这两个,就是webpack打包的入口文件
接下来我们就可以开始编码了

漫漫webpack路

漫漫webpack路是对整个vue ssr 构建流程的评价
可以说有很大一部分时间必须来和webpack配置搏斗,需要沉下心来慢慢调试
一般来说,推荐三份webpack配置
首先需要一份前后端公用的配置,比如通用的vueloader,一些cssloader,图片处理等等
然后前后端再分别写一份webpack配置

这里有几个点要特别注意:

前后端分别使用VueSSRClientPlugin,VueSSRServerPlugin两个插件来构建
因为我们需要分别生成
vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json

// 前端
plugins: [
        new VueSSRClientPlugin()
    ],


//后端
  plugins: [
    new VueSSRServerPlugin()
  ]

热加载

由于后端严重依赖于wepack构建的前端打包文件
所以开发时,热加载变得尤为重要,否则每次都需要重新编译
这里我们后端判断是否是dev环境,监听webpack的事件,
来重新构建server-bundle.json和前端client-manifest.json

const webpack = require('webpack')
const MFS = require('memory-fs')
const clientConfig = require('../config/client.config')
const serverConfig = require('../config/server.config')

const clientCompiler = webpack(clientConfig) // 执行webpack

clientCompiler.watch({}, (err, stats) => {
    if (err) throw err
    stats = stats.toJson()
    stats.errors.forEach(err => console.error(err))
    stats.warnings.forEach(err => console.warn(err))
});

clientCompiler.plugin('done', () => {
    const clientBundlePath = path.join(serverConfig.output.path, 'vue-ssr-client-manifest.json')
    clientManifest = JSON.parse(fs.readFileSync(clientBundlePath, 'utf-8'))

    console.log('client update...')
    if (serverBundle) {
        build.renderer = createBundleRenderer(serverBundle, {
            runInNewContext: false, // 推荐
            template,
            clientManifest
        });
    }
})

// 监听 server renderer
const serverCompiler = webpack(serverConfig)
const mfs = new MFS() // 内存文件系统,在JavaScript对象中保存数据。
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
    if (err) throw err
    stats = stats.toJson()
    stats.errors.forEach(err => console.error(err))
    stats.warnings.forEach(err => console.warn(err))

    // 读取使用vue-ssr-webpack-plugin生成的bundle(vue-ssr-bundle.json)
    const bundlePath = path.join(serverConfig.output.path, 'vue-ssr-server-bundle.json')
    serverBundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))
    console.log('server update...')
    if (clientManifest) {
        build.renderer = createBundleRenderer(serverBundle, {
            runInNewContext: false, // 推荐
            template,
            clientManifest
        });
    }
})

提取公用css

这个功能我们平时构建前端应用的时候是常用到的,提取出来css作为单独的chunk
我们使用的是webpack4来构建,按照VUESSR官方教程,我们使用extract-text-webpack-plugin
却发现会报错,于是接下来我又搜索了很多

mini-css-extract-plugin
extract-css-chunks-webpack-plugin

可是在最后构建的时候,都会报错
去翻了nuxt源码,发现其使用的是 extract-css-chunks-webpack-plugin
是前端配置了,后端构建的时候没有配置这个插件

// 前端
 rules: [
            {
                test: /\.(css|scss)$/,
                use: isDev ? ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader'] :
                    [ExtractTextPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader']
            }
        ]


//后端
rules: [
      {
        test: /\.(css|scss)$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
      }
    ]

后端服务器集成

好,搞定了webpack之后,相当于前期准备工作已经做好了
接下来我们需要和后端集成,请求到后端时,将请求的url传给 renderer.renderToString() 执行,然后输出html
这里选择express或者koa都是可以的,我们选择的是koa(先暂时忽略cache缓存这块逻辑)

const Koa = require('koa');
const app = new Koa();
const path = require('path');
const staticServer = require('koa-static-server');
const dev = require('./dev.js');
require('./routers')(app);
const config = require('./config');
const cache = require('./cache');

// 解析器
let build = dev();
// 静态资源路径
const distPath = path.join(__dirname, '../dist');
// 静态资源
app.use(staticServer({ rootDir: distPath, rootPath: '/dist' }));

app.use(async (ctx, next) => {
    try {
        if (!build.renderer) {
            return ctx.body = "构筑中……";
        }
        let out = await cache(ctx.request, build.renderer);
        ctx.set('Content-Type', 'text/html; charset=utf-8');
        ctx.body = out;
    } catch (e) {
        console.error(e);
        let redirect = '/error';
        if (e.code === 404) redirect += '?code=404';
        ctx.redirect(redirect);
    }
});

app.listen(config.port, () => {
    console.log(`server ${config.port} listened!`);
});

那么接下来,我们执行代码,不出意外已经可以在浏览器中看到我们服务端渲染出来的页面了
当然此时是空空如也的,那么接下来,我们就是需要填充数据

异步数据加载

想想我们平时写前端VUE代码,一般我们会在created方法内请求后台方法进行数据初始化
但是在SSR应用中,我们会发现一个问题
created生命周期是在服务端执行的,之后便马上输出HTML给前端接管了
此时就算异步数据在服务端加载完成,前端也是得不到的
所以需要另外一种方式,官方推荐的是使用vue-router + vuex 搭配使用加载异步数据

我们在每个vue组建内定义 asyncData 方法,内部调用vuex状态改变方法填充数据
不过vuex方法内,我们需要返回一个Promise

接着我们在前后端的入口文件内分别添加vue-router钩子函数,
等待我们自定义的asyncData函数执行完毕之后才输出HTML
此时异步数据是加载完毕了的,可以正确输出
要值得注意的是 我们可以添加这段代码

store.replaceState(window.__INITIAL_STATE__);

前端接管之后,填充vuex数据

// entry-server.js

import { createApp } from './src/app.js'

export default context => {
    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 })
            }

            // 对所有匹配的路由组件调用 `asyncData()`
            Promise.all(matchedComponents.map(Component => {
                if (Component.asyncData) {
                    // 注入request
                    // store.$request = context;
                    return Component.asyncData({
                        store,
                        route: router.currentRoute,
                        request: context
                    })
                }
            })).then(() => {
                // 在所有预取钩子(preFetch hook) resolve 后,
                // 我们的 store 现在已经填充入渲染应用程序所需的状态。
                // 当我们将状态附加到上下文,
                // 并且 `template` 选项用于 renderer 时,
                // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
                context.state = store.state
                // server端自动注入标题
                context.title = router.currentRoute.meta.title;
                resolve(app)
            }).catch(reject)
        }, reject)
    })
}

// entry-client.js
import { createApp } from './src/app.js'
import Vue from 'vue'


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


// a global mixin that calls `asyncData` when a route component's params change
Vue.mixin({
    beforeRouteUpdate(to, from, next) {
        const { asyncData } = this.$options
        if (asyncData) {
            asyncData({
                store: this.$store,
                route: to
            }).then(next).catch(next)
        } else {
            next()
        }
    }
})


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')
})

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

制作一个DEMO

好了,webpack设置好,服务端集成了,异步数据也能加载了,我们可以开始写一个demo了
首先编写vuex的store,我们这里区分module编写,方便隔离各个业务,webpack也能单独打包chunk

module/news.js

import http from '$http';

export default {
    namespaced: true,
    state: {
        list: {},
        count: 0
    },
    actions: {
        fetchList({ commit }, { pageIndex, request }) {
            return http.post(`/api/news/list/${pageIndex}`, request).then((data) => {
                commit('setList', data.data);
            });
        }
    },
    mutations: {
        setList(state, { list, count }) {
            state.list = list;
            state.count = count;
        }
    }
}

接着编写vue页面 Info.vue

<template>
  <div>
    <vmenu type="info" />
    <div class="info-container">
      <div class="info-title">热门信息</div>
      <ul class="info-content">
        <li v-for="item in list" :key="item.title">
          <section class="info-content-r">{{ item.publishDate }}</section>
          <section class="info-content-l">{{ item.title }}</section>
        </li>
      </ul>
      <vpage :count="count" url="info" :pageIndex="pageIndex" />
    </div>
  </div>
</template>

<script>
import menu from "../Menu.vue";
import page from "../common/Page.vue";
// 单独打包chunk
import news from "../../store/modules/news.js";
import { mapState } from "vuex";

export default {
  components: {
    vmenu: menu,
    vpage: page
  },
  data() {
    return {
      pageIndex: this.$route.params.pageIndex || 1
    };
  },
  watch: {
    $route: function(n) {
      this.pageIndex = n.params.pageIndex;
    }
  },
  computed: {
    list() {
      return this.$store.state.news.list || [];
    },
    count() {
      return this.$store.state.news.count;
    }
  },
  asyncData({ store, route, request }) {
    store.registerModule("news", news);
    return store.dispatch("news/fetchList", {
      pageIndex: route.params.pageIndex || 1,
      request
    });
  },
  destroyed() {
    if (this.$store._modules.root._children["news"])
      this.$store.unregisterModule("news");
  },
  mounted() {}
};
</script>

分页组件 Page.vue

<template>
  <div class="page-container">
    <section>
      <router-link :to="{ name: url, params: { pageIndex: 1 } }">
        第一页
      </router-link>
    </section>
    <section
      v-for="(i, ix) in pageCount"
      :key="i"
      :class="{ current: pageIndex == ix + 1 }"
    >
      <router-link :to="{ name: url, params: { pageIndex: ix + 1 } }">
        {{ ix + 1 }}
      </router-link>
    </section>
    <section>
      <router-link :to="{ name: url, params: { pageIndex: pageCount } }">
        最后一页
      </router-link>
    </section>
  </div>
</template>

<script>
export default {
  name: "page",
  props: {
    pageSize: { default: 10 },
    pageIndex: { default: 1 },
    count: { default: 100 },
    url: { default: "" }
  },
  computed: {
    pageCount() {
      return this.count % this.pageSize == 0
        ? this.count / this.pageSize
        : Math.floor(this.count / this.pageSize) + 1;
    }
  },
  mounted() {},
  created() {}
};
</script>

后端数据我们也自己提供接口,
当然生产上可能是其他后端提供的接口
这里我们就是很简单的读一个txt文件,然后输出

newsController.js

const fs = require('fs');
const path = require('path');

module.exports = {

    async list(ctx) {
        let { page = 1, size = 10 } = ctx.params;
        let data = JSON.parse(fs.readFileSync(path.join(__dirname, './tmp.txt')));
        ctx.body = { list: data.slice((page - 1) * size, page * size), count: data.length };
    }

}

来看下运行结果

嗯,还行,再看看生成的HTML

数据都已经在后端加载完成

错误处理/鉴权

我们平时前端请求数据时,偶尔会发生超时或者其他原因等异常情况
当然,后端异步请求的数据也会发生各种错误,我们需要来处理
按照我们现在这套流程,其实处理起来是相对容易的
我们在vue-router的钩子函数内catch,然后reject指定的错误码
然后在我们服务端集成的代码内进行处理,比如我们进行一个简单的重定向

try {
        // ...
    } catch (e) {
        console.error(e);
        let redirect = '/error';
        if (e.code === 404) redirect += '?code=404';
        ctx.redirect(redirect);
    }

对应的前端前端路由也需要catch住asyncData函数内的错误进行同样的处理

否则前后端渲染表现不一致

Vue.mixin({
    beforeRouteUpdate(to, from, next) {
        const { asyncData } = this.$options
        if (asyncData) {
            asyncData({
                store: this.$store,
                route: to
            }).then(next).catch(()=>{
                router.push('/error');
            })
        } else {
            next()
        }
    }
})
router.beforeResolve((to, from, next) => {
        
        //...

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

            // 停止加载指示器(loading indicator)
            next()
        }).catch(()=>{
            router.push('/error');
        })
    })

头部注入

我们顺便可以做一些其他事情,比如我们将title配置在前端路由内,然后前后端都加载title

这里你如果想注入meta标签,头部等等都是可以的

// 前端
router.afterEach((to, from, next) => {
    document.title = to.meta.title;
});

//后端
// 对所有匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
    if (Component.asyncData) {
        // 注入request
        // store.$request = context;
        return Component.asyncData({
            store,
            route: router.currentRoute,
            request: context
        })
    }
})).then(() => {
    // 在所有预取钩子(preFetch hook) resolve 后,
    // 我们的 store 现在已经填充入渲染应用程序所需的状态。
    // 当我们将状态附加到上下文,
    // 并且 `template` 选项用于 renderer 时,
    // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
    context.state = store.state
    // server端自动注入标题
    context.title = router.currentRoute.meta.title;
    resolve(app)
}).catch(reject)

cookie穿透

还有另一个问题也是比较重要的,由于我们是后端请求的数据
后端收到的请求来源IP是我们的服务器出口IP,cookie也是丢失的
我们需要重新设置这些

这里我们使用一个技巧
我们通过webpack的alias为前后端的加载数据模块设置不同的引用
接着在服务端的文件内进行header的改写
IP的话,我们是使用这两个请求头来标识客户正真IP

X-Forwarded-For 和 X-real-ip

当然如果我们的应用前还有其他应用处理,已经设置过这些头了
我们就跳过

import axios from 'axios';

var http = axios.create({
    baseURL: 'http://localhost:8070'
})   // {}中放入上文中的配置项


export default {
    // server端重写header
    post(url, params, request) {
        if (!request && params) [request, params] = [params, request];
        // 如果已经是重定向过的,不做处理
        if (!request.headers["X-Forwarded-For"]) {
            request.headers["X-Forwarded-For"] = request.req.connection.remoteAddress;
            request.headers["X-real-ip"] = request.req.connection.remoteAddress;
        }
        return http.post(url, params, {
            headers: request.headers
        });
    },
    get: http.get
};

其实后端的数据请求方法不一定要用http,
如果内部沟通好使用RPC进行通信是最合适的

第三方组件 与 NOSSR

平时开发中我们会用到很多第三方的VUE组件
当然在VUE SSR项目中,我们也是同样能用的,我们试一下常用的element-ui

  <div class="index-swipe">
      <el-carousel trigger="click">
        <el-carousel-item v-for="item in 4" :key="item">
          <h3 class="small"><img style="width:100%;" :src="urls[item]" /></h3>
        </el-carousel-item>
      </el-carousel>
    </div>

<script>
import { Carousel, CarouselItem } from "element-ui";
import "element-ui/lib/theme-chalk/index.css";

export default {
  components: {
    [Carousel.name]: Carousel,
    [CarouselItem.name]: CarouselItem
  },
  data() {
    return {
      urls: [
        "https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg",
        "https://fuss10.elemecdn.com/1/34/19aa98b1fcb2781c4fba33d850549jpeg.jpeg",
        "https://fuss10.elemecdn.com/0/6f/e35ff375812e6b0020b6b4e8f9583jpeg.jpeg",
        "https://fuss10.elemecdn.com/9/bb/e27858e973f5d7d3904835f46abbdjpeg.jpeg"
      ]
    };
  }
};
</script>

运行一下,嗯,不错

但很遗憾,由于SSR特殊的什么周期和执行环境
并不是所有第三方组件都对SSR支持的比较友好,比如你可能会经常遇到这样的错误

[Vue warn]: Error in beforeCreate hook: "ReferenceError: document is not defined"

[Vue warn]: Error in beforeCreate hook: "ReferenceError: window is not defined"

等等……
看一下错误,哦,服务端环境内肯定是没有window,document这些的

这里我们有两种解决方案

  • 修改这些组件的源码,将在服务端的hook内访问浏览器环境的代码移到浏览器端的hook内

  • 我们可以取舍一下,看看这个第三方组件是否可以不服务端渲染

如果可以的话,我们再前端动态加载这个组件,这个组件的所有生命周期都是在浏览器端执行了
就不会报错了,但是这个组件直输的HTML也将会没有了

我们可以编写一个通用的NOSSR组件来实现

NoSSR.vue

<template>
  <div>
    <component :is="component">
      <slot></slot>
    </component>
  </div>
</template>

<script>
import NoSSRTMP from "./NoSSR_TMP.vue";

export default {
  components: {
    NoSSRTMP
  },
  data() {
    return {
      component: ""
    };
  },
  mounted() {
    this.component = "NoSSRTMP";
  }
};
</script>

<style>
</style>

NoSSR_TMP.vue

<template>
  <div>
    <slot></slot>
  </div>
</template>
<script>
export default {};
</script>

可以看到,其实就是很简单的
我们在mounted的时候,动态加载了这个组件
我用常用的markdown编辑器组件mavon-editor来试一下

  <NoSSR>
      <div id="editor">
        <mavon-editor style="height: 100%"></mavon-editor>
      </div>
    </NoSSR>

来看下效果

可以看到,组件能被正确的加载了
看看输出的HTML

<div><!----></div>

输出成了注释,这就是我们需要取舍考虑的地方

缓存、性能

好,经过上面这些步骤,我们的应用大体已经成形了
当然还不能直接用于生产,还需要用webpack打包一份生产配置
还有一点问题,我们服务端渲染每次请求都会执行一次服务端渲染
显然这些重复的开销是不值得的,我们可以做个缓存模块来处理

cache.js

const config = require('./config');
const isDev = process.env.NODE_ENV === "development";


module.exports = async function (request, renderer) {
    if (isDev) return renderer.renderToString(request);
    const redis = require('redis').createClient(config.redis);
    const lru = require('redis-lru');
    const cache = lru(redis, 100);
    let out = await cache.get(request.url);
    if (!out) {
        out = await renderer.renderToString(request);
        await cache.set(request.url, out);
    }
    return out;

}

这里我们用一个简单的size为100的redis lru来进行缓存
常访问的100个url都会被缓存下来直接输出

组件级别缓存

vue-ssr还提供了组件级别的缓存
createRenderer的时候传入缓存的对象
需要实现get(),set()
接着我们在编写VUE的时候,指定ServerCacheKey就可以实现组件级别的缓存

const renderer = createRenderer({
  cache: //...
})


export default {
  name: 'item', // 必填选项
  props: ['item'],
  serverCacheKey: props => props.item.id,
  render (h) {
    return h('div', this.item.id)
  }
}

当然缓存设置是一个复杂的事情,要针对具体的场景进行缓存策略选择
这里这是一个简单的示例

设置完缓存之后,服务端渲染的性能会有一个质的突进
如果部署的时候能再加上多机负载,上个CDN就更加美滋滋了

虽然VUE SSR比传统的后端字符串模板引擎效率相较而言低一些
但是它所带来的的便利是大于这一些性能损耗的,
尤其是当你的SSR项目越大越复杂的时候,这点就体现的更加明显
而且我们的优化空间还是很大的,所以不用一开始就太担忧性能😂

结语

至此,VUE SSR整个流程就讲完了

其实整个流程如果从头到尾都配置一遍,是有一点繁琐
但是好处是每个环节我们都可以进行修改,自由度更高
当然也是为了深入了解SSR的整个生命周期和各种细节
欢迎大家期待下一篇NUXT的分享,那一篇应该会精简许多。

DEMO地址:github.com/kungithub/s…