最近写了一个小工具放在自己网站上,网速较慢时呈现空白事件比较长,虽然放置了初始loading但是体验还是不太好,打开控制台查看渲染时间,主要浪费在了初始js文件上,想到可以用ssr同构来优化一下更快呈现网页
按照官方文档描述,ssr大概可以解决
下面为了方便分享这个过程,所有的内容都是简化过的,不包含路由部分(这部分对照看文档就 OK 了),分享的部分主要包含两部分
为了节省时间,部分代码没有放到文章中,可以点击查看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
文件,分别定义客户端代码和服务器端代码
import { creatApp } from './app' const { app } = creatApp() app.$mount('#app') 复制代码
这里只让它执行挂载步骤就 OK 了
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-->
它的作用就是作为注入的节点。
{{}}
和{{{}}}
含义基本相同,区别在于{{{}}}
不会转义特殊字符。
;<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
文件的作用就是读取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
怎么来写,在这之前我们先准备一下要实现 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) }, }, }) } 复制代码
上面返回的依然是一个函数,之后把这个函数注入到一些文件内部
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
这个函数,我们后面会需要用到
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
文件提供
import { creatApp } from './app' const { app, store } = creatApp() // 将信息注入到客户端 if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } app.$mount('#app') 复制代码
这一步就比较简单了,直接把我们获取到的数据替换到vuex中,store.replaceState
就是执行替换操作
下面把异步的数据加上
<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 复制代码
// 省略之前代码 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
文件,撒花,这样一个带有缓存和异步请求的页面就被渲染出来了。