企业🤖AI Agent构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
[TOC] # 什么是vue-ssr SSR是Server-Side Rendering的简写,即由服务端负责渲染页面直出,亦即同构应用。程序的大部分代码都可以在服务端和客户端运行。在服务端vue组件渲染为html字符串,在客户端生成dom和操作dom。 <br> 能在服务端渲染为html字符串得益于vue组件结构是基于vnode的。vnode是dom的抽象表达,它不是真实的dom,它是由js对象组成的树,每个节点代表了一个dom。因为vnode所以在服务端vue可以把js对象解析为html字符串。同样在客户端vnode因为是存在内存之中的,操作内存总比操作dom快的多,每次数据变化需要更新dom时,新旧vnode树经过diff算法,计算出最小变化集,大大提高了性能。 <br> ![](https://img.kancloud.cn/60/34/603484171e562e7da1ca56b54fd4a27b_1946x892.png) <br> <br> # 实现 ## 返回html文本 ~~~JavaScript import Koa2 from 'koa'; import { createRenderer } from 'vue-server-renderer'; import Vue from 'vue'; const renderer = createRenderer(); const app = new Koa2(); /** * 应用接管路由 */ app.use(async function(ctx) { const vm = new Vue({ template:"<div>hello world</div>" }); ctx.set('Content-Type', 'text/html;charset=utf-8'); const htmlString = await renderer.renderToString(vm); ctx.body = `<html> <head> </head> <body> ${htmlString} </body> </html>`; }); app.listen(3000); ~~~ 我们现在在服务器端创建了一个`vue`实例`vm`。vm是一个对象,对象是不能直接发送给浏览器的,发送前必须转换为字符串。 `vue-server-renderer` 把一个`vue`实例转化成字符串,通过`renderer.renderToString`这个方法,将`vm`作为参数传递进去运行,便很轻松的返回了`vm`转化后的字符串,如下。 ~~~text <div data-server-rendered="true">hello world</div> ~~~ 从上面的案例,可以从宏观上把握服务器端渲染的整个脉络. * 首先是要获取到当前这个请求路径是想请求哪个`vue`组件 * 将组件数据内容填充好转化成字符串 * 最后把字符串拼接成`html`发送给前端. <br> ## 打包 这里客户端和服务端的入口不一样,webpack配置也不一样 客户端 ~~~ { mode: 'development', entry: './src/client/index.js', output: { filename: 'index.js', path: path.resolve(__dirname, 'public'), }, ... } ~~~ 服务端 ~~~ { return { target: 'node', mode: 'development', entry: './src/index.js', devtool: 'eval-source-map', output: { filename: 'bundle.js', path: path.resolve(__dirname, 'build'), libraryTarget: 'commonjs2', }, ... } ~~~ <br> <br> ## 路由集成 在实现`srr`的任务里,主要工作是为了在客户端发送请求后能找出当前的请求路径是匹配哪个`vue`组件。 * 使用`createRouter()`方法创建一个路由实例对象`router`,把它注入到`Vue`实例中. * router.onready 方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件。 这可以有效确保服务端渲染时服务端和客户端输出的一致。 route.js ~~~ import Vue from 'vue'; import Router from 'vue-router'; import List from './pages/List'; import Search from './pages/Search'; Vue.use(Router); export const createRouter = () => { return new Router({ mode: 'history', routes: [ { path: '/list', component: List, }, { path: '/search', component: Search, }, ], }); }; export const routerReady = async (router) => { return new Promise((resolve) => { router.onReady(() => { resolve(null); }); }); }; ~~~ <br> <br> index.js * 执行`router.push(req.url)`,这一步非常关键.相当于告诉`Vue`实例,当前的请求路径已经传给你了,你快点根据路径寻找要渲染的页面组件. * `await routerReady(router);`执行完毕后,就已经可以得到当前请求路径匹配的页面组件了. * `matchedComponents.length`如果等于`0`,说明当前的请求路径和我们定义的路由没有一个匹配上,那么这里应该要定制一个精美的`404`页面返回给浏览器. * `matchedComponents.length`不等于`0`,说明当前的`vm`已经根据请求路径让匹配的页面组件占据了视口.接下来只需要将`vm`转化成字符串发送给浏览器就可以了. ~~~ import Koa2 from 'koa'; // 静态文件处理 import staticFiles from 'koa-static'; import { createRenderer } from 'vue-server-renderer'; import Vue from 'vue'; // Vue部分 import App from './App.vue'; import { createRouter, routerReady } from './route.js'; const renderer = createRenderer(); const app = new Koa2(); app.use(staticFiles('public')); app.use(async function (ctx) { const req = ctx.request; // 创建路由 const router = createRouter(); const vm = new Vue({ // 添加路由 router, render: (h) => h(App), }); // 告诉vue 渲染 当前所需组件 router.push(req.url); // 等到 router 钩子函数解析完 await routerReady(router); //获取匹配的页面组件 const matchedComponents = router.getMatchedComponents(); if (!matchedComponents.length) { ctx.body = '没有找到该网页,404'; return; } ctx.set('Content-Type', 'text/html;charset=utf-8'); let htmlString try { htmlString = await renderer.renderToString(vm); } catch (error) { ctx.status = 500; ctx.body = 'Internal Server Error'; } ctx.body = `<html> <head> </head> <body> ${htmlString} </body> // 引入页面js <script src="./index.js"></script> </html>`; }); app.listen(3000); ~~~ <br> client/index.js ~~~ import Vue from 'vue'; import VueMeta from 'vue-meta'; import App from '../App.vue'; import { createRouter } from '../route'; Vue.config.productionTip = false; Vue.use(VueMeta); //创建路由 const router = createRouter(); new Vue({ router, render: (h) => h(App), }).$mount('#root', true); ~~~ <br> <br> ## Vuex集成 路由集成后虽然能够根据路径渲染指定的页面组件,但是服务器渲染也存在局限性。 <br> 比如你在页面组件模板上加一个`v-click`事件,结果会发现页面在浏览器上渲染完毕后事件无法响应,这样肯定会违背我们的初衷。事件绑定, <br> 点击链接跳转这些都是浏览器赋予的能力。因此可以借助客户端渲染来帮助我们走出困境。 <br> 整个流程可以设计如下. * 浏览器输入链接请求服务器,服务器端将包含页面内容的`html`返回,但是在`html`文件下要加上客户端渲染的`js`脚本. * `html`开始在浏览器上加载,页面上已经呈现出静态内容了.当线程走到`html`文件下的`script`标签,开始请求客户端渲染的脚本并执行. * 此时客户端脚本里面的`vue`实例开始接管了整个应用,它开始赋予原本后端返回的静态`html`各种能力,比如让标签上的事件绑定开始生效. <br> store/index.js ~~~ import Vue from 'vue'; import Vuex from 'vuex'; Vue.use(Vuex); export function createStore() { return new Vuex.Store({ state: { list: [], name: 'kay', }, actions: { getList({ commit }, params) { return new Promise((resolve)=>{ commit("setList",[{ name:"广州" },{ name:"深圳" }]); resolve(); },2000) }, }, mutations: { setList(state, data) { state.list = data || []; }, }, }); } ~~~ page/list/index.vue ~~~ <template> <div class="list"> <p>当前页:列表页</p> <a @click="jumpSearch()">go搜索页</a> <ul> <li v-for="item in list" :key="item.name"> <p>城市: {{ item.name }}</p> </li> </ul> </div> </template> <script> export default { // 服务端获取异步数据公共方法 asyncData({ store, route }) { return store.dispatch("getList"); }, }; </script> ~~~ index.js ~~~ import Koa2 from 'koa'; import staticFiles from 'koa-static'; import { createRenderer } from 'vue-server-renderer'; import Vue from 'vue'; import App from './App.vue'; import { createRouter, routerReady } from './route.js'; import { createStore } from './vuex/store'; const renderer = createRenderer(); const app = new Koa2(); app.use(staticFiles('public')); app.use(async function (ctx) { const req = ctx.request; const router = createRouter(); // 创建Store const store = createStore(); const vm = new Vue({ router, store, render: (h) => h(App), }); router.push(req.url); await routerReady(router); const matchedComponents = router.getMatchedComponents(); if (!matchedComponents.length) { ctx.body = '没有找到该网页,404'; return; } ctx.set('Content-Type', 'text/html;charset=utf-8'); let htmlString try { // 匹配到的组件执行 asyncData方法,调用dispatch来更新store await Promise.all( matchedComponents.map((Component) => { if (Component.asyncData) { Component.asyncData({ store, route: router.currentRoute, }); } }) ); htmlString = await renderer.renderToString(vm); } catch (error) { ctx.status = 500; ctx.body = 'Internal Server Error'; } ctx.body = `<html> <head> </head> <body> ${htmlString} </body> <script src="./index.js"></script> </html>`; }); app.listen(3000); ~~~ <br> ### 脱水 现在ssr和客户端都配置了vuex,但区别是服务端的store里面放着List.vue需要的远程请求的数据,而客户端的store是空的. <br> srr返回的静态html是带着城市列表的,一旦客户端的vue接管了整个应用就会展开各种各样的初始化操作.客户端也要配置vuex,由于它的数据仓库是空的所以重新引发了页面渲染.致使原本来含有城市列表的页面部分消失了. <br> 为了解决这个问题,就要想办法让ssr远程请求来的数据也给客户端的store发一份.这样客户端即使接管了应用,但发现此时store存储的城市列表数据和页面保持一致也不会造成闪烁问题. ~~~ ctx.body = `<html> <head> </head> <body> ${htmlString} // 注入服务端strore的数据 <script> var context = { state: ${JSON.stringify(store.state)} } </script> <script src="/index.js"></script> </body> </html>`; ~~~ ### 注水 服务器端将数据放入了js脚本里,客户端此时就可以轻松拿到这份数据. <Br> 在客户端入口文件里加上 store.replaceState(window.context.state); 如果发现window.context.state存在,就把这部分数据作为vuex的初始数据,这个过程称之为注水. client/index.js ~~~ import Vue from 'vue'; import App from '../App.vue'; import { createRouter } from '../route'; import VueMeta from 'vue-meta'; import { createStore } from '../vuex/store'; Vue.config.productionTip = false; Vue.use(VueMeta); const router = createRouter(); // 创建Store const store = createStore(); // 若有 window.context.state,更新客户端store if (window.context && window.context.state) { store.replaceState(window.context.state); } new Vue({ router, store, render: (h) => h(App), }).$mount('#root', true); ~~~ <br> <br> ## 装载真实数据 上面在`vuex`里是使用定时器模拟的请求数据,接下来利用网上的一些开放`API`接入真实的数据. 对`vuex`里的`action`方法做如下修改. ~~~text actions: { getList({ commit }, params) { const url = '/api/v2/city/lookup?location=guangzhou&key=9423bb18dff042d4b1716d084b7e2fe0'; return axios.get(url).then((res)=>{ commit("setList",res.data.location); }) } } ~~~ <br> `asyncData`一运行就会走到上面`actions`里面的`getList`,它就会对上面那个`url`地址发起请求.但仔细观察发现这个`url`是没有写域名的,这样访问肯定会报错. 那把远程域名给它加上去行不行呢?如果这样硬加是会出现问题的.有一种场景就是客户端接管应用它也可以调用`getList`方法,我们写的这部分`vuex`代码可是服务端和客户端共用的.那如果客户端直接访问带有远程域名的路径就会引起跨域. 那如何解决这一问题呢?这里的`url`最好不要加域名,以`/`开头.那样客户端访问这个路径就会引向`node`服务器.此时只要加一个接口代理转发就搞定了. ~~~text import proxy from 'koa-server-http-proxy'; export const proxyHanlder = (app)=>{ app.use(proxy('/api', { target: 'https://geoapi.qweather.com', //网上寻找的开放API接口,支持返回地理数据. pathRewrite: { '^/api': '' }, changeOrigin: true })); } ~~~ 定义一个中间件函数,在执行服务器端渲染前添加到`koa2`上. 这样`node`服务器只要看到以`/api`开头的请求路径就会转发到远程地址上获取数据,不会再走后面服务器端渲染的逻辑. ### 服务器端路径请求的问题 使用上面的代理转发之后又会带来新的问题,设想一种场景.如果浏览器输入`localhost:3000/list`后,`node`解析请求发现要加载`List.vue`这个页面组件,而这个组件又有一个`asyncData`异步方法,因此就运行异步方法获取数据. ~~~text actions: { getList({ commit }, params) { const url = '/api/v2/city/lookup?location=guangzhou&key=9423bb18dff042d4b1716d084b7e2fe0'; return axios.get(url).then((res)=>{ commit("setList",res.data.location); }) } } ~~~ 这个异步方法就是`getList`,注意此时执行这段脚本的是`node`服务器,不是客户端的浏览器. 浏览器如果请求以`/`开头的`url`,请求会发给`node`服务器.`node`服务器现在需要自己请求自己,只要请求了自己设置的代理就能把请求转发给远程服务器,而如今`node`服务器请求以`/`开头的路径是绝对无法请求到自己的,这个时候只能用绝对路径. 我们上面提到这部分的`vuex`代码是客户端和服务端共用的,最好不用绝对路径写死.还有一个更优雅的方法,就是对`axios`的`baseURL`进行配置生成带有域名的`axios`实例来请求.那这部分代码就可以改成如下. ~~~text export function createStore(_axios) { return new Vuex.Store({ state: { list: [], name: 'kay', }, actions: { getList({ commit }, params) { const url = '/api/v2/city/lookup?location=guangzhou&key=9423bb18dff042d4b1716d084b7e2fe0'; return _axios.get(url).then((res)=>{ commit("setList",res.data.location); }) }, }, mutations: { setList(state, data) { state.list = data || []; }, }, }); } ~~~ `_axios`是配置基础域名后的实例对象,客户端会生成一个`_axios`,服务端也会生成一个,只不过客户端是不用配置`baseURL`的. ~~~text import axios from "axios"; //util/getAxios.js /** * 获取客户端axios实例 */ export const getClientAxios = ()=>{ const instance = axios.create({ timeout: 3000, }); return instance; } /** * 获取服务器端axios实例 */ export const getServerAxios = (ctx)=>{ const instance = axios.create({ timeout: 3000, baseURL: 'http://localhost:3000' }); return instance; } ~~~ <br> index.js ~~~ import { getServerAxios } from "./util/getAxios"; import { proxyHanlder } from "./middleware/proxy"; proxyHanlder(app); app.use(async function (ctx) { // ... const store = createStore(getServerAxios(ctx)); }) ~~~ 通过生成两份`axios`实例既保持了`vuex`代码的统一性,另外还解决了`node`服务器自己访问不了自己的问题. <br> ### cookie如何处理 使用了接口代理之后,怎么确保每次接口转发都能把`cookie`也一并传给远程的服务器.可以按如下配置. 在`ssr`的入口文件里. ~~~text ***省略 ** * 应用接管路由,服务器端渲染代码 */ app.use(async function(ctx) { const req = ctx.request; //图标直接返回 if (req.path === '/favicon.ico') { ctx.body = ''; return false; } const router = createRouter(); //创建路由 const store = createStore(getServerAxios(ctx)); //创建数据仓库 ***省略 }) ~~~ 在创建`ctx`和`axios`实例的时候将`ctx`传递进去. ~~~text /** * 获取服务器端axios实例 */ export const getServerAxios = (ctx)=>{ const instance = axios.create({ timeout: 3000, headers:{ cookie:ctx.req.headers.cookie || "" }, baseURL: 'http://localhost:3000' }); return instance; } ~~~ 将`ctx`中的`cookie`取出来赋值给`axios`的`headers`,这样就确保`cookie`被携带上了. <br> <br> ## 样式处理 `.vue`页面的文件通常把代码分成三个标签`<template>`,`<script>`和`<style>`. `<style scoped lang="scss"></style>`上还可以添加一些属性. 和客户端渲染相比,实现`ssr`的过程要多处理一步.即将`<style>`里面的样式内容提取出来,再渲染到`html`的`<head>`里面. 在`ssr`入口文件`index.js`添加如下代码. ~~~text ...省略 const context = {}; //创建一个上下文对象 htmlString = await renderer.renderToString(vm, context); ctx.body = `<html> <head> ${context.styles ? context.styles : ''} </head> <body> ${htmlString} <script> var context = { state: ${JSON.stringify(store.state)} } </script> <script src="./bundle.js"></script> </body> </html>`; ~~~ 服务端提取样式的过程非常简单,定义一个上下文对象`context`. `renderer.renderToString`函数的第二个参数里传入`context`,该函数执行完毕后,`context`对象的`styles`属性就会拥有页面组件的样式.最后将这份样式拼接到`html`的`head`头部里即可. <br> ## **Head信息处理** 常规的`html`文件的`head`里面不仅包含样式,它可能还需要设置`<title>`和`<meta />`.如何针对每个页面设置个性化的头部信息,可以利用`vue-meta`插件. 现在需要给`List.vue`页面组件添加一些头信息,可以按如下设置. ~~~text <script> export default { metaInfo: { title: "列表页", meta: [ { charset: "utf-8" }, { name: "viewport", content: "width=device-width, initial-scale=1" }, ], }, asyncData({ store, route }) { return store.dispatch("getList"); } ...省略 } ~~~ 在导出的对象上添加一个属性`metaInfo`,在其中分别设置`title`和`meta`; 在`ssr`的入口文件处加入如下代码. ~~~text import Koa2 from 'koa'; import Vue from 'vue'; import App from './App.vue'; import VueMeta from 'vue-meta'; Vue.use(VueMeta); /** * 应用接管路由 */ app.use(async function(ctx) { ...省略 const vm = new Vue({ router, store, render: (h) => h(App), }); const meta_obj = vm.$meta(); // 生成的头信息 router.push(req.url); ...省略 htmlString = await renderer.renderToString(vm, context); const result = meta_obj.inject(); const { title, meta } = result; ctx.body = `<html> <head> ${title ? title.text() : ''} ${meta ? meta.text() : ''} ${context.styles ? context.styles : ''} </head> <body> ${htmlString} <script> var context = { state: ${JSON.stringify(store.state)} } </script> <script src="./index.js"></script> </body> </html>`; }); app.listen(3000); ~~~ 通过`vm.$meta()`生成头信息`meta_obj`,待到`vue`实例加载完毕后,执行`meta_obj.inject()`获取被渲染页面组件的`meta`和`title`数据,再将它们填充到`html`字符串即可. # 参考资料 [从原理上实现Vue的ssr渲染](https://zhuanlan.zhihu.com/p/346674458)