Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue SSR初上手 #11

Open
chiwent opened this issue May 15, 2019 · 2 comments
Open

Vue SSR初上手 #11

chiwent opened this issue May 15, 2019 · 2 comments
Labels

Comments

@chiwent
Copy link
Owner

chiwent commented May 15, 2019

Vue SSR上手

本文将介绍如何使用vue-server-renderer进行服务端渲染,很多内容是搬运自官方文档,可以看作是笔记吧,对vue ssr的过程和原理有大致的描述。

Vue SSR比起Vue SPA的优势:

  • 更好的SEO
  • 首屏加载更快

我们都知道,浏览器在刚开始访问SPA应用时,服务器会返回一个基本的html骨架和一些js文件,html文件内没有网页的主体内容,需要浏览器解析js并渲染到页面中。这样,搜索引擎不仅不能抓取到关键信息,首屏加载时js还会阻塞页面渲染,导致白屏现象。在这种情况下,我们可以用服务端渲染进行优化。

上手前的注意点:

  • vue-server-renderer和vue必须版本匹配
  • vue ssr的实现最好使用Node,因为需要在服务端中执行前端的代码。当然也不反对用其他语言,详情见:在非Node.js环境中使用

当然,坑点不仅仅是以上的内容,更多内容见后续

先来一个最简单的demo

先保证已经安装了插件:npm install vue vue-server-renderer --save

开始一步步来完成吧:

const Vue = require('vue');
const app = new Vue({
    template: `<div>Hello World</div>`
});

// 创建渲染器
const renderer = require('vue-server-renderer').createRenderer();

// 生成预渲染的HTML字符串
renderer.renderToString(app).then(html => {
    ///
}).catch(err => {});

和node配合使用:

const fs = require('fs');
const path = require('path');
const express = require('express');
const server = express();
server.use(express.static('dist'));

const bundle = fs.readFileSync(path.resolve(__dirname, 'dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, 'dist/index.ssr.html'), 'utf-8') // 服务端渲染数据
});


server.get('*', (req, res) => {
  renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      res.status(500).end('服务器内部错误');
      return;
    }
    res.end(html); // html是注入应用的完整页面
  })
});


server.listen(8010, () => {
  console.log('listening on http://127.0.0.1:8010');
});

然后我们创建一个模板文件:

<!DOCTYPE html>
<html lang="en">
  <head>
  <title>Hello</title>
  {{{ meta }}}
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

<!--vue-ssr-outlet-->标记的位置就是注入HTML文档的地方。

我们还可以在模板中使用插值,比如上述的meta,我们可以这样注入:

const context = {
  meta: `<meta>`
}

renderer.renderToString(app, context, (err, html) => {
  // meta 标签会注入
})

模板还支持一些高级特性(搬运至vue ssr指南):

在使用 *.vue 组件时,自动注入「关键的 CSS(critical CSS)」;
在使用 clientManifest 时,自动注入「资源链接(asset links)和资源预加载提示(resource hints)」;
在嵌入 Vuex 状态进行客户端融合(client-side hydration)时,自动注入以及 XSS 防御。

当然,我们正常的开发不可能像上面这个demo那样简单粗暴,就像正常的vue开发也不会在一个html文件里面引用vue开发。

SSR的流程

vue ssr官方流程

由于vue ssr是同构的,所以在客户端和服务端都要在入口文件中创建vue实例,通过webpack分别进行打包,生成client bundle和server bundle。前者是客户端标记,等待拿到服务端渲染完成的数据后,混入完成初始化渲染;后者会在服务端上运行并生成预渲染的HTML字符串,再发送给客户端以完成初始化渲染。

vue ssr的常见目录结构
常用的webpack配置文件结构如下:

├── build
│   ├── setup-dev-server.js  # 设置webpack-dev-middleware开发环境
│   ├── webpack.base.config.js # 基础通用配置,和SPA配置一样
│   ├── webpack.client.config.js  # 定义客户端入口文件,通过VueSSRClientPlugin编译出 vue-ssr-client-manifest.json 文件和 js、css 等文件,供浏览器调用
│   └── webpack.server.config.js  # 通过VueSSRClientPlugin编译出 vue-ssr-server-bundle.json 供 nodejs 调用
|
|—— src
|   |—— entry-client.js # 客户端入口文件
|   |—— entry-server.js # 服务端入口文件
|
|—— server.js # 在此调用renderToString渲染出HTML字符串,通过vue-server-renderer调用编译生成的vue-ssr-server-bundle.json,启动node服务。将                                 vue-ssr-client-manifest.json自动注入,通过node处理http请求。是整个站点的入口

同时,还需要创建客户端的入口文件entry-client.js和服务端的入口文件entry-server.js,以及通用的入口文件app.js

  • webpack.client.config.js
    通用配置:
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
  mode: 'development',
  entry: {
    app: './src/entry-client.js'
  },
  resolve: {},
  plugins: [
    // 这将 webpack 运行时分离到一个引导 chunk 中,
    // 以便可以在之后正确注入异步 chunk。
    // 这也为你的 应用程序/vendor 代码提供了更好的缓存。
    new webpack.optimize.CommonsChunkPlugin({
      name: "manifest",
      minChunks: Infinity
    }),

    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(
        process.env.NODE_ENV || 'development'
      ),
      'process.env.VUE_ENV': '"client"'
    }),
    
    new VueSSRClientPlugin()
  ]
})
module.exports = config
  • webpack.server.config.js
    通用配置:
const merge = require('webpack-merge');
const base = require('./webpack.base.config');
const nodeExternals = require('webpack-node-externals');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const path = require('path');

module.exports = merge(base, {
  target: 'node',
  devtool: '#source-map',
  entry: path.join(__dirname, './src/entry-server.js'),
  output: {
    filename: 'server-bundle.js', // server.js
    libraryTarget: 'commonjs2'
  },
  resolve: {},
  externals: nodeExternals({
      whitelist: [/\.vue$/, /\.css$/], //将css和vue文件列入白名单,因为从依赖模块导入的css和vue还应该由webpack处理
  }),
  plugins: [
        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
            'process.env.VUE_ENV': '"server"',
        }),
        new VueSSRServerPlugin(),
  ]
});
module.exports = config;
  • setup-dev-server.js
    这里的配置比较复杂,而且定义的自由度较高,不过多阐述。

  • server.js
    这是整个ssr项目的入口文件。具体功能见上面的描述。定义的自由度较高,下面值放出一段最基础的配置(没有用到HMR),不过多阐述:

const bundle = fs.readFileSync(path.resolve(__dirname, 'dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, 'dist/index.ssr.html'), 'utf-8') // 服务端渲染数据
});
server.get('*', (req, res) => {
  renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      res.status(500).end('服务器内部错误');
      return;
    }
    res.end(html);
  })
});

常见的优化参考下面:

缓存

流式渲染

手动资源注入
server.js文件中,如果提供了template模板,那么资源注入是自动的。但是也可以选择不提供模板,手动注入,详情见:构建配置

  • 通用入口文件app.js

通用入口文件的基本职责是创建vue实例,然后将其模块化导入到客户端和服务端的入口文件。所以,在通用入口文件中,我们将创建一个工程函数。并且,vuex和vue-router也在此创建实例(还需要引入vuex-router-sync进行store和router的同步)。
如下:

import Vue from 'vue';
import App from './App.vue';
import { createStore } from './store'; // vuex文件
import { createRouter } from './router'; // vue-router文件
import { sync } from 'vuex-router-sync';

export function createApp(context) {
    const store = createStore();
    const router = createRouter();
    sync(store, router);

    // 这里可以插入路由劫持等等内容   

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

服务端每次请求渲染时,都会重写执行createApp方法,初始化store、router,不然数据不会更新。

  • 客户端入口文件entry-client.js

它的主要工作其实很简单,就是创建vue实例,并挂载到DOM。

它的基本结构如下:

import { createApp } from './app';
const { app, router, store } = createApp();

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

// 在此可以插入router.beforeResolve,比较数据是否更新和触发数据获取

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

这里有个陌生的概念window.__INITIAL_STATE__,我们会在后续谈到。

  • 服务端入口文件entry-server.js

服务端每次渲染时,都会调用该入口文件,它原本的工作是创建vue实例,但是在此我们可以赋予它更多内容,比如服务端路由匹配和数据预获取:

import { createApp } from './app';
const { app, router, store } = createApp();

export default context => {
    const { app, router, store } = createApp();

    return new Promise((resolve, reject) => {
        // 设置服务端router位置
        router.push(context.url);

        // 等待router将可能的异步组件和钩子函数解析完毕
        router.onReady(() => {
             const matchedComponents = router.getMatchedComponents();
             // 匹配不到的路由,执行reject,返回404
            if (!matchedComponents.length) {
                reject({ code: 404 });
            }
            Promise.all(matchedComponents.map(component => {
                if (component.asyncData) {
                    // 调用组件上的asyncData(这部分只能拿到router第一级别组件,子组件的asyncData拿不到)
                    return component.asyncData(store);
                }
            })).then(() => {
                // 暴露数据到HTMl,让客户端渲染拿到数据和服务端渲染匹配
                context.state = store.state;
                context.state.posts.forEach((element, index) => {
                    context.state.posts[index].content = '';
                });
                resolve(app);
            }).catch(reject);
        });
    });
}

上面函数的参数context等同于node中的ctx,是一个全局上下文环境对象。

数据获取和状态

在服务端渲染生成html前,我们需要预先获取并解析依赖的数据。同时,在客户端挂载(mounted)之前,需要获取和服务端完全一致的数据,否则客户端会因为数据不一致导致混入失败。如果在beforeCreatecreated时执行请求,由于这两个生命周期函数会在服务端执行(也就只有这两个vue生命周期函数会在服务端执行了,其他vue生命周期函数都是客户端中执行),且请求是异步的,导致请求发出后,数据还没有返回,渲染就结束了。

为了解决这个问题,预获取的数据要存储在状态管理器(store)中,以保证数据一致性。vue ssr有一个名为asyncData的函数,用来请求数据,它需要在服务端入口文件中预先配置,需要返回一个promise,等待所有请求都完成再渲染组件。然后在单独的视图组件中调用该方法,asyncData方法会在组件(限于页面组件)每次加载之前被调用。它可以在服务端或路由更新之前被调用。在这个方法被调用的时候,第一个参数被设定为当前页面的上下文对象,你可以利用 asyncData方法来获取数据并返回给当前组件。注意,由于asyncData方法是在组件 初始化 前被调用的,所以在方法内是没有办法通过 this 来引用组件的实例对象。

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

// 单独的组件中
created() {},
preFetch(store) {
    return store.dispatch("getData");
},
mounted(){}

也可以将数据预获取放在路由钩子完成,比如:

// minxin全局混入,让所有组件都可以在beforeRouteEnter钩子中执行以下方法
Vue.mixin({
  beforeRouteEnter(to, from, next) {
    next(vm => {
      const { asyncData } = vm.$options; // https://cn.vuejs.org/v2/api/index.html#vm-options
      if (asyncData) {
        asyncData(vm.$store, vm.$route)
          .then(next)
          .catch(next);
      } else {
        next();
      }
    });
  }
});

逻辑配置组件的数据预获取

// 单独在某个组件使用
<template>
  <div>{{ item }}</div>
</template>

<script>
  export default {
    asyncData({ store, route }) {
      // 组件实例化前无法访问this,所以需要将store和路由信息作为参数传递进去
      return store.dispatch('fetchData', route.params.id)
    },
    computed: {
      item() {
        return this.$store.state.items[this.$route.params.id]
      }
    }
  }
</script>

服务端的数据预获取

// entry-server.js
//搬运自《Vue SSR指南》,也可以参考前面的服务端入口文件
import { createApp } from './app'

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) {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        }
      })).then(() => {
        // 在所有预取钩子(preFetch hook) resolve 后,
        // 我们的 store 现在已经填充入渲染应用程序所需的状态。
        // 当我们将状态附加到上下文,
        // 并且 `template` 选项用于 renderer 时,
        // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
        context.state = store.state

        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

还记得前面提到的window.__INITIAL_STATE__吗?当我们预获取数据完成,就会将context.state作为window.__INITIAL_STATE__的状态,自动混入客户端:

// entry-client.js

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

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

客户端的数据预获取

客户端数据预获取的方式有两种:在路由导航之前获取,在匹配待渲染的视图后再获取

  • 1.在路由导航前获取
    通过这种策略,应用会在等待视图所需的数据全部解析之后,再传入数据并处理当前视图。好处是可以直接再数据准备就绪时,传入视图渲染完整内容,但是如果数据预获取的实践过长,用户在视图中就需要等待一段时间。
// entry-client.js
//搬运自《Vue SSR指南》
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')
})
  • 匹配需要渲染的视图后,再获取数据
    在此策略中,客户端数据预获取的逻辑是放在视图组件beforeMount中的,当路由导航触发后,立即切换视图,因此有更快的响应速度。 但是,在渲染视图时不能得到完整的数据,所以需要条件判断:
//搬运自《Vue SSR指南》
Vue.mixin({
  beforeMount () {
    const { asyncData } = this.$options
    if (asyncData) {
      // 将获取数据操作分配给 promise
      // 以便在组件中,我们可以在数据准备就绪后
      // 通过运行 `this.dataPromise.then(...)` 来执行其他任务
      this.dataPromise = asyncData({
        store: this.$store,
        route: this.$route
      })
    }
  }
})

使用以上哪种方式取决于用户体验场景,但是无论是哪种,在路由组件复用的情况下,更改路由params或query,也需要调用asyncData

//搬运自《Vue SSR指南》
Vue.mixin({
  beforeRouteUpdate (to, from, next) {
    const { asyncData } = this.$options
    if (asyncData) {
      asyncData({
        store: this.$store,
        route: to
      }).then(next).catch(next)
    } else {
      next()
    }
  }
})

vue ssr的坑

一套代码,两套执行环境

在vue的生命周期函数中,只有beforeCreatecreated会在ssr过程中执行,其他的生命周期函数只会在客户端执行。所以应该避免在这两个生命周期函数中产生全局副作用的代码,比如定时器。同时,由于前端代码会在后端中执行,而Node.js和浏览器JavaScript有区别,导致在前端视图中的部分JavaScript属性或方法在执行时会报错。比如在使用一些插件的时候会提示windowdocumentundefined,在这种情况下,可以用vue-no-ssr让相关组件不走ssr

cookie不可用

关于vue ssr不可用的解决方案,可以参考:再说Vue SSR的Cookies问题



参考:

Vue SSR指南

解密Vue SSR

Vue SSR Demo

一个极简版本的 VUE SSR demo

带你走近Vue服务器端渲染(VUE SSR)

基于vue-ssr服务端渲染入门详解

理解vue ssr原理,自己搭建简单的ssr框架

@chiwent chiwent added the vue label May 15, 2019
@chiwent
Copy link
Owner Author

chiwent commented May 26, 2019

参考一段服务端渲染的js脚本:

// FROM: https://juejin.im/post/5a9ca40b6fb9a028b77a4aac
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const KoaRuoter = require('koa-router')
const serve = require('koa-static')
const { createBundleRenderer } = require('vue-server-renderer')
const LRU = require('lru-cache')

const resolve = file => path.resolve(__dirname, file)
const app = new Koa()
const router = new KoaRuoter()
const template = fs.readFileSync(resolve('./src/index.template.html'), 'utf-8')

function createRenderer (bundle, options) {
    return createBundleRenderer(
        bundle,
        Object.assign(options, {
            template,
            cache: LRU({
                max: 1000,
                maxAge: 1000 * 60 * 15
            }),
            basedir: resolve('./dist'),
            runInNewContext: false
        })
    )
}

let renderer
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
    clientManifest
})

/**
 * 渲染函数
 * @param ctx
 * @param next
 * @returns {Promise}
 */
function render (ctx, next) {
    ctx.set("Content-Type", "text/html")
    return new Promise (function (resolve, reject) {
        const handleError = err => {
            if (err && err.code === 404) {
                ctx.status = 404
                ctx.body = '404 | Page Not Found'
            } else {
                ctx.status = 500
                ctx.body = '500 | Internal Server Error'
                console.error(`error during render : ${ctx.url}`)
                console.error(err.stack)
            }
            resolve()
        }
        const context = {
            title: 'Vue Ssr 2.3',
            url: ctx.url
        }
        renderer.renderToString(context, (err, html) => {
            if (err) {
                return handleError(err)
            }
            console.log(html)
            ctx.body = html
            resolve()
        })
    })
}

app.use(serve('/dist', './dist', true))
app.use(serve('/public', './public', true))

router.get('*', render)
app.use(router.routes()).use(router.allowedMethods())

const port = process.env.PORT || 8089
app.listen(port, '0.0.0.0', () => {
    console.log(`server started at localhost:${port}`)
})

@chiwent
Copy link
Owner Author

chiwent commented Jun 8, 2019

一些注意的地方

preFetch预渲染

一般的,我们会在preFetch中预加载数据到vuex中,然后组件对其中的vuex state进行渲染(当然,你也可以不将数据放到vuex中,那样在钩子函数完成数据请求也是可以的)。在数据预获取时,需要注意数据的对称性,假设组件中依赖的vuex数据缺失,将导致组件的渲染失败。另外,需要注意前面的preFetch仅在一级组件中有效,在子组件中是不会调用的。

关于storage、document、window的使用

在vue的生命周期函数中,beforeCreatecreated会在服务端和客户端执行,其他钩子都在客户端执行,所以,如果在beforeCreatecreated,或是直接在vuex和router入口文件中使用了storage、document以及window这些在浏览器端js才有的属性或对象时,就会报错。为了避免这个问题,应该在客户端渲染的钩子中执行。

关于部分第三方组件引用时报错

在vue入口文件引用一些第三方组件时,会提示windowdocument为undefined,这是因为组件的渲染经过了服务端。此时我们应该注意区分服务端和浏览器端的渲染,一般的我们会在服务端和客户端的webpack配置文件中,设置环境变量,比如:

// webpack.client.config.js
new webpack.DefinePlugin({
     'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
    'process.env.VUE_ENV': '"client"',
})

// webpack.server.config.js
new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
    'process.env.VUE_ENV': '"server"',
})

通过这个环境变量,我们可以判断上下文环境是处于客户端还是服务器端,当判定在客户端时,我们就可以引入或渲染(v-if)第三方组件:

<mavon-editor v-model="mdVal" v-if="isBrowser"></mavon-editor>
<script>
var mavonEditor = require("mavon-editor");
import "mavon-editor/dist/css/index.css";
export default {
   components: {
      mavonEditor 
   },
  data() {
    return {
      mdVal: '',
      isBrowser:
        process.env.VUE_ENV === "client" || process.env.VUE_ENV !== "server",
     }
   }
}
</script>

如何动态修改标签标题

如果要简单一点的方式,可以在组件内用路由监听或者路由钩子对document.title赋值,另外一种方法可以在entry-server.js中拿到context属性,它是node服务端文件返回的请求上下文对象,可以取到相关的属性,然后再设置对应标题即可。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant