最近写了一个小工具放在自己网站上,网速较慢时呈现空白事件比较长,虽然放置了初始loading但是体验还是不太好,打开控制台查看渲染时间,主要浪费在了初始js文件上,想到可以用ssr同构来优化一下更快呈现网页
按照官方文档描述,ssr大概可以解决
- seo 的问题;
- 优化首屏打开时间,因为无需通过加载 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 > service
是axios
的封装代码,需要注意一点,因为代码同时运行在服务器和客户端,所以选用第三方库的时候最好是两端都支持,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.js
和entry-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)
},
}
上面结构很简单,就是一个定时器不断累加,不过有两个地方需要注意
-
id="app"
这个 id 是必须的,因为我们在
entry-client.js
文件中执行app.$mount('#app')
实际上就是挂载到了这里 -
mounted
我把定时器的操作写到了
mounted
生命周期内,因为在服务器我们要避免一些副作用的代码,举例来说如果我们写在了created
中,服务器渲染没有销毁的钩子,这个定时器会一直执行下去,这样肯定就是错误的。这里贴一下官方给出的编写通用代码指南,只要记住服务器只有
beforeCreate
和created
两个钩子即可,还有一些特定平台比如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.js
的context
对象,这个对象会传递给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
来请求数据,注入到组件内部的。
之后我们更改context
的state
,这里还记得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
文件,撒花,这样一个带有缓存和异步请求的页面就被渲染出来了。