chiwent / blog

个人博客,只在issue内更新
https://chiwent.github.io/blog
8 stars 0 forks source link

Vue SSR初上手 #11

Open chiwent opened 5 years ago

chiwent commented 5 years ago

Vue SSR上手

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

Vue SSR比起Vue SPA的优势:

我们都知道,浏览器在刚开始访问SPA应用时,服务器会返回一个基本的html骨架和一些js文件,html文件内没有网页的主体内容,需要浏览器解析js并渲染到页面中。这样,搜索引擎不仅不能抓取到关键信息,首屏加载时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

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
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;
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模板,那么资源注入是自动的。但是也可以选择不提供模板,手动注入,详情见:构建配置

通用入口文件的基本职责是创建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,不然数据不会更新。

它的主要工作其实很简单,就是创建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__,我们会在后续谈到。

服务端每次渲染时,都会调用该入口文件,它原本的工作是创建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__)
}

客户端的数据预获取

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

// 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')
})
//搬运自《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 commented 5 years ago

参考一段服务端渲染的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 commented 5 years ago

一些注意的地方

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服务端文件返回的请求上下文对象,可以取到相关的属性,然后再设置对应标题即可。