vue ssr快速采坑

1,988 阅读8分钟

最近写了一个小工具放在自己网站上,网速较慢时呈现空白事件比较长,虽然放置了初始loading但是体验还是不太好,打开控制台查看渲染时间,主要浪费在了初始js文件上,想到可以用ssr同构来优化一下更快呈现网页

按照官方文档描述,ssr大概可以解决

  1. seo 的问题;
  2. 优化首屏打开时间,因为无需通过加载 js 来渲染填充dom结构

下面为了方便分享这个过程,所有的内容都是简化过的,不包含路由部分(这部分对照看文档就 OK 了),分享的部分主要包含两部分

  • 静态渲染
  • 包含ajax的渲染

为了节省时间,部分代码没有放到文章中,可以点击查看vue-ssr-demo

目录结构

.
├─ build
│    ├─ webpack.client.js
│    ├─ webpack.config.js
│    └─ webpack.server.js
├─ package.json
├─ server.js
├─ src
│    ├─ App.vue
│    ├─ api
│    │    └─ index.js
│    ├─ app.js
│    ├─ entry-client.js
│    ├─ entry-server.js
│    ├─ index.template.html
│    ├─ store.js
│    └─ utils
│           └─ service
│                  ├─ config.js
│                  └─ index.js
└─ static
       └─ favicon.ico

这里先把最终的项目结构放出来,为了方便理解,下面讲解一些比较重要的文件和目录。

build 是 webpack 的配置文件,这里没有配置开发环境的代码,如果有需要可以参考官方给出的例子 HackerNews Demo,同时为了简洁,webpack 的配置文件就不放了,直接在我上面贴出地址找到build文件夹参考看就可以了。

utils > serviceaxios的封装代码,需要注意一点,因为代码同时运行在服务器和客户端,所以选用第三方库的时候最好是两端都支持,axios具有 node 和浏览器的统一的api,这里就用它作为请求库。

起步

在使用 vue-CLI 开发项目的时候,会有一个src/main.js入口文件,它的功能很简单执行一个new Vue然后挂载到#app元素上,不过在这里显然是不行的,因为服务器上的代码会持久运行,直接运行一个单例对象可能会导致污染,所以我们先从入口文件进行改造。

这里定义一个app.js它的作用返回一个通用的函数,这样每次运行的时候都是一个新的对象。

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

export function creatApp() {
  const app = new Vue({
    render: h => h(App),
  })
  return { app }
}

注意,我们并没有在这个 app 对象上执行$mount的操作,因为这里返回的是通用部分,执行$mount操作的时候是在客户端的时候。

之后定义两个文件entry-client.jsentry-server.js文件,分别定义客户端代码和服务器端代码

  • entry-client.js
import { creatApp } from './app'

const { app } = creatApp()
app.$mount('#app')

这里只让它执行挂载步骤就 OK 了

  • entry-server.js
import { creatApp } from './app'

export default context => {
  const { app } = creatApp()
  return app
}

这里简单返回一个 app 对象给服务器。

然后再来看一下index.template.html

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

它的作用就是一个模板文件,具体内容请参考官方文档,它会在server.js文件中被我们使用,注意这里不需要定义<div id="app"></div>,取而代之的是必须有一个<!--vue-ssr-outlet--> 它的作用就是作为注入的节点。

{{}}{{{}}}含义基本相同,区别在于{{{}}}不会转义特殊字符。

App.vue

;<template>
  <div id="app">
    <p>这是一段计数器,初始值为1,后面每秒会累加一次,打开源代码看看渲染是否正确把:{{ count }}</p>
  </div>
</template>
export default {
  name: 'app',
  data() {
    return {
      count: 1,
    }
  },
  mounted() {
    setInterval(() => {
      this.count += 1
    }, 1000)
  },
}

上面结构很简单,就是一个定时器不断累加,不过有两个地方需要注意

  1. id="app"

    这个 id 是必须的,因为我们在entry-client.js文件中执行app.$mount('#app')实际上就是挂载到了这里

  2. mounted

    我把定时器的操作写到了mounted生命周期内,因为在服务器我们要避免一些副作用的代码,举例来说如果我们写在了created中,服务器渲染没有销毁的钩子,这个定时器会一直执行下去,这样肯定就是错误的。

    这里贴一下官方给出的编写通用代码指南,只要记住服务器只有beforeCreatecreated两个钩子即可,还有一些特定平台比如window等谨慎使用

server.js

server.js文件的作用就是读取dist文件夹内文件,之后返回一串 html 字符串给浏览器

const Renderer = require('vue-server-renderer')
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const statics = require('koa-static')
// 这里读取的`utf-8`不要省略
const template = fs.readFileSync(path.resolve(__dirname, './src/index.template.html'), 'utf-8')
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')

const server = new Koa()
const renderer = Renderer.createBundleRenderer(serverBundle, {
  template,
  clientManifest,
  // 这里设置为false,因为我们已经用函数包装了,所以不需要
  runInNewContext: false,
})

server.use(statics('dist', { index: 'xxx.html' }))

server.use(async ctx => {
  const context = {
    title: 'hello Vue Ssr',
    meta: `
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
    `,
  }
  ctx.response.type = 'html'
  const html = await renderer.renderToString(context)
  ctx.body = html
})

server.listen(3000, () => {
  console.log('运行成功: http://localhost:3000/')
})

这里使用了koa作为服务器启动的框架,因为不包含路由所以请求任何的 url 地址都直接返回这个固定的html字符串给浏览器(对应的是server.use(async (ctx) => {}这一段代码)

api 的格式比较固参考文档看即可,讲一两个比较容易入坑的地方

  • entry-server.js

    我们打开entry-server.js文件,返回一个函数

// ...
export default context => {
  // ...
}

里面有一个context的函数参数,实际上这个context对应的正是上面server.jscontext对象,这个对象会传递给index.template.html文件内部使用

  • server.use(statics('dist', { index: 'xxx.html' }));

这里打包的目录是 dist,直接在浏览器访问/dist资源会提示不存在,所以我们需要让这个目录可以访问,这里用了 koa 的中间件,后面的{ index: 'xxx.html' }必不可少,因为打包了一个index.html的文件,而statics默认的 index 会跟打包的文件冲突,在后面任意修改一个不存在的名字就可以了。

执行到一步,运行 webpack 打包文件,之后启动server.js,打开浏览器就应该可以看到被服务器渲染过的页面了 ✿✿ヽ(°▽°)ノ✿

ajax

下面说一下的重头戏ajax怎么来写,在这之前我们先准备一下要实现 demo 所需要的用到的vuex

yarn add vuex

这里采用了 vuex 做状态管理,事实上这不是必须的(只要用类似的即可),之后在src定义一个store.js文件,它的作用就是执行ajax请求,把结果保存在state内,然后在App.vue内通过vuex来读取到请求的数据。

先写一个简单的接口

// server.js
const Koa = require('koa')
const Router = require('koa-router')
const cors = require('koa-cors')

const api = new Koa()
const router = new Router()
router.get('/ancientPoetry', ctx => {
  const ancientPoetry = '古木阴中系短篷,\n杖藜扶我过桥东。\n沾衣欲湿杏花雨,\n吹面不寒杨柳风。'
  ctx.body = {
    status: 200,
    message: '操作成功',
    data: ancientPoetry,
  }
})
api
  .use(cors())
  .use(router.routes())
  .use(router.allowedMethods())

// 接口运行地址
api.listen(7000)

上面用到了两个中间件

yarn add koa-cors koa-router

OK,这样接口部分也完成了,之后就是请求这个地址,然后让数据传递给App.vue

下面定义store.js文件

// store.js
import Vue from 'vue'
import Vuex from 'vuex'
// ancientPoetry是api访问的地址
import service, { ancientPoetry } from './utils/service'

Vue.use(Vuex)

export function createStore() {
  return new Vuex.Store({
    state: {
      poetry: '',
    },
    actions: {
      fetchItem({ commit }) {
        return service({
          method: 'get',
          url: ancientPoetry,
        }).then(item => {
          commit('setItem', item)
        })
      },
    },
    mutations: {
      setItem(state, item) {
        Vue.set(state, 'poetry', item)
      },
    },
  })
}

上面返回的依然是一个函数,之后把这个函数注入到一些文件内部

app.js
import Vue from 'vue'
import App from './App.vue'
import { createStore } from './store'

Vue.config.productionTip = false

export function creatApp() {
  const store = createStore()
  const app = new Vue({
    asyncData({ store: s }) {
      return s.dispatch('fetchItem')
    },
    store,
    render: h => h(App),
  })
  return { app, store }
}

注意到asyncData这个函数,我们后面会需要用到

entry-server.js
import { creatApp } from './app'

export default async c => {
  const context = c
  const { app, store } = creatApp()
  if (app.$options.asyncData) {
    await app.$options.asyncData({ store })
    // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
    context.state = store.state
  }
  return app
}

因为这里就定义了一个App.vue的文件,没有路由,所以!

直接在 app 下通过app.$options来检查asyncData存不存在,关于app.$options的定义官方说是new Vue的一些其它选项,这里你可以通过任意方式获取(比如官方是通过匹配路径的文件来循环内部的asyncData方法),但是一定要找到定义的函数,因为它的作用就是来让vuex来请求数据,注入到组件内部的。

之后我们更改contextstate,这里还记得context么,它是一个上下文对象会同时运行在客户端和浏览器,最初由server.js文件提供

entry-client.js
import { creatApp } from './app'

const { app, store } = creatApp()

// 将信息注入到客户端
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

app.$mount('#app')

这一步就比较简单了,直接把我们获取到的数据替换到vuex中,store.replaceState就是执行替换操作

App.vue

下面把异步的数据加上

<template>
  <div id="app">
    <p>这是一段计数器,初始值为1,后面每秒会累加一次,打开源代码看看渲染是否正确把:{{ count }}</p>
    <p>
      下面是一段ajax请求的异步结果
    </p>
    <p class="cs-item">
      <strong>
        {{ item }}
      </strong>
    </p>
  </div>
</template>

<script>
export default {
  name: 'app',
  data() {
    return {
      count: 1,
    };
  },
  computed: {
    // 从 store 的 state 对象中的获取 item。
    item() {
      return this.$store.state.poetry;
    },
  },
  mounted() {
    setInterval(() => {
      this.count += 1;
    }, 1000);
  },
};
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
.cs-item {
  white-space: pre-line;
}
</style>

OK,到这一个简单的 ajax 请求页面就出来了

缓存

才发现忘记说最后一块缓存的问题了,这个需要根据项目的实际来,因为我写的结构比较固定,所以我让它一个小时才变动一次

yarn add lru-cache
server.js
// 省略之前代码
const LRU = require('lru-cache');
const cache = new LRU({
  max: 10000,
  // 毫秒
  maxAge: 1000 * 60 * 60,
});
const server = new Koa();
const renderer = Renderer.createBundleRenderer(serverBundle, {
  template,
  clientManifest,
  runInNewContext: false,
  cache,
});
// ...

一个完整的server.js看起来应该是这样

const Renderer = require('vue-server-renderer');
const fs = require('fs');
const path = require('path');
const Koa = require('koa');
// 缓存
const LRU = require('lru-cache');
const statics = require('koa-static');

const template = fs.readFileSync(path.resolve(__dirname, './src/index.template.html'), 'utf-8');
const Router = require('koa-router');
const cors = require('koa-cors');
const serverBundle = require('./dist/vue-ssr-server-bundle.json');
const clientManifest = require('./dist/vue-ssr-client-manifest.json');

const cache = new LRU({
  max: 10000,
  // 毫秒
  maxAge: 1000 * 60 * 60,
});

const server = new Koa();
const renderer = Renderer.createBundleRenderer(serverBundle, {
  template,
  clientManifest,
  runInNewContext: false,
  cache,
});

server.use(statics('dist', { index: 'xxx.html' }));

server.use(async (ctx) => {
  const context = {
    title: 'hello Vue Ssr',
    url: ctx.url,
    meta: `
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
    `,
  };
  ctx.response.type = 'html';
  const html = await renderer.renderToString(context);
  ctx.body = html;
});

server.listen(3000, () => {
  console.log('运行成功: http://localhost:3000/');
});

// 新开一个api接口主要做测试内容用

const api = new Koa();
const router = new Router();
router.get('/ancientPoetry', (ctx) => {
  const ancientPoetry = '古木阴中系短篷,\n杖藜扶我过桥东。\n沾衣欲湿杏花雨,\n吹面不寒杨柳风。';
  ctx.body = {
    status: 200,
    message: '操作成功',
    data: ancientPoetry,
  };
});
api
  .use(cors())
  .use(router.routes())
  .use(router.allowedMethods());

// 接口运行地址
api.listen(7000);

最后说一下先打包文件在运行server.js文件,撒花,这样一个带有缓存和异步请求的页面就被渲染出来了。