java vue 服务端渲染_Vue.js服务端渲染(SSR)不完全指北

本文详细介绍了Vue.js结合Egg.js实现服务端渲染(SSR)的过程,包括SSR的特点、何时使用SSR、为何选择SSR、构建逻辑以及踩坑与优化经验。内容涵盖首屏加载速度、SEO需求、安全性考量等方面,帮助开发者理解SSR在现代web开发中的优势和应用场景。
摘要由CSDN通过智能技术生成

写在前面: 这篇文章是19年初时候接手的一个需要服务端渲染的某官网项目时做的一些关于 SSR 的研究和积累. 由于阿里内部的 NodeJS 应用选型基本都会选择 Egg.js 作为基础业务框架, 所以本文也是从 Vue.js + Egg.js 入手和实现, 主要介绍 SSR 本身特点, 构建逻辑, 实现思路和踩坑填坑经验, 以及一些总结向的优化思路.

What’s this?

SSR(Server-Side Rendering) — 服务端渲染

服务端渲染是相对于客户端渲染而言的(Client Side Render), 它的渲染行为发生在服务器端, 渲染完成之后再将完整页面以HTML字符串的形式交给浏览器, 最后经过”注水”hydrate过程将一些事件绑定和Vue状态等注入到输出的静态的页面中, 由同步下发给浏览器的的Vue bundle接管状态, 继续处理接下来的交互逻辑. 这也是一种同构应用的实现(代码可以运行在客户端和服务端中).

When SSR?

那么 什么情况下该使用SSR方案呢 , 其实一句话总结下来就是展示型应用:对SEO有需求. CSR无法直接满足SEO, 他需要切换成SSR或者借助Prerendering方案. Prerendering: 一种在服务端使用无头浏览器渲染出页面, 再输出静态页面的解决方案, 也能实现SEO需求, 好处是比较简单(通过webpack插件就可以实现)可以保持前端模块不需要SSR改造, 但是性能比SSR差不少.

对首屏渲染速度和性能有需求. 如果需要更早的将页面展现给用户而不是白屏的话, 最好的方案还是把渲染工作交给服务端, CSR(SPA)应用的更适合场景应该是中后台web应用, 一般有较多的交互逻辑和页面数据处理, 同时CSR会使用更多的内存, 对浏览器造成较大的压力.

过多依赖客户端环境的场景. 直接将渲染工作全部交给客户端和JavaScript来处理其实对于web应用来说是很脆弱的, 浏览器的情况千差万别, 网络环境也是无法预测, 即使做了再多的兼容工作, 也无法保证任何情况下都能完美展示. 有时候即使框架本身也不足以(不愿意)支持所有情况.

安全性考量. 对于有权限控制和内容限制的应用, 使用SPA的时候就要考虑很多安全性限制的问题, 对于应用结构的设计增加了不少复杂度.

Why SSR ?

在早期的web开发技术栈中, 实际上都是服务端渲染, 像是Java, PHP, ROR, ASP.NET. 当前端MVC出现之后, 浏览器端渲染模型开始出现并且流行 — “开局一块HTML模板, 元素全靠JS加载”. 这种方式带来了比较快速的页面切换体验和极好的开发效率, 以及丰富的技术生态. 但是CSR也不是没有缺点, 缺点很明显而且不改变其核心思路的话没办法克服:SEO不友好 首先因为开局服务器丢给浏览器的是一块空白(有标记)的HTML模板和一坨打包好的JS代码, 所以做SEO时候普通的爬虫没办法实抓取到真实的web内容, 虽然现代爬虫号称已经能够处理CSR页面, 但是处理成本过高等问题还是让CSR的SEO结果不尽如人意.

首屏加载速度 同样因为浏览器会在接受到完整的一坨JS代码之后才能执行他, 导致了首屏加载经历了: 解析HTML(渲染HTML模板) -> 获取JS -> 执行JS -> JS渲染页面 -> JS处理数据相关逻辑 -> 页面加载完成. 这样一个过程之后才能完整呈献给用户, 速度自然就下来了, 更不用说网络因素和客户端环境因素对体验的影响. 即使有前端缓存的存在, 但是页面渲染过程仍然不会轻松.

鉴权等安全性功能实现起来较复杂 其次, 因为是服务端一股脑把打包好的JS代码交给用户, 所以如果在应用中有鉴权的逻辑, 就会牵扯到鉴权逻辑的设计. 这时候就要前后端合作来保证安全性, 复杂度增加.

其实在大多数场景下, 你都没必要使用SPA的方案(可以看看这篇文章 ). 那么既然某些场景下不适合使用CSR方案, 我们直接退回到以前的web开发方式就好了, 干嘛要去踩SSR新的坑呢? 那么对比传统的web应用, 使用框架SSR的好处有哪些呢:对比传统方式, 首先最大的好处当然就是技术栈生态, Vue, React等前端MVC给开发带来很大便利, 相应的生态也蓬勃发展, 配套的UI套件, 框架组件, 设计语言, API设计很多程度上已经改变了如今的开发方式. 换句话说, 使用jQuery纯手撸的人原来越少了.

其次是同构带来的便利. 一套代码由两边的执行环境使用, 可以同时支持SSR和CSR的渲染, 当SSR失效的时候还可以降级为CSR, 或者当服务器压力过大的时候主动降级以增强鲁棒性.

对技术人员的技术栈统一. SSR还是使用的前端工程师常规熟悉的技术栈, 没有过大的技术门槛, 也没有太多的技术债, 更适合项目的可持续维护和前端团队的技术发展.

构建逻辑

在Vue-SSR构建过程中, 会将代码打包分成两个部分: 服务端bundle; 客户端bundle. Node.js会处理服务端bundle用于SSR, 客户端bundle会在用户请求时和已经由SSR渲染出的页面一起返回给用户, 然后在浏览器执行”注水”(hydrate), 接管Vue接下来的业务逻辑. 这里就会有一个问题, 服务端是如何将store状态交给客户端的呢, 因为整个构建流程是彼此独立的, 数据预取(在进入渲染页面之前获取到页面所需要的数据)之后交给了store, 而注水过程怎么接收store数据? 其实中间有一个特殊的状态保存: window.__INITIAL_STATE__, 这个state会在服务端渲染执行context.state = store.state;的时候自动写入window中, 所以在客户端代码中就就可以直接通过store.replaceState()接收服务端预取的数据了.

构建逻辑示意图很经典, 如下:

所以webpack需要两个入口(服务端, 客户端):

entry-client.js: 客户端 entry 只需创建应用程序, 并且将其挂载到DOM, 然后将Store状态同步给客户端bundle:

import { createApp } from '../main';

let { app, store, router } = createApp();

// 同步storeif (window.__INITIAL_STATE__) {

store.replaceState(window.__INITIAL_STATE__);

}

router.onReady(() => {

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);

});

const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _);

if (!asyncDataHooks.length) {

return next();

}

Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))

.then(() => {

console.log('client entry asyncData function emit');

next();

})

.catch(next);

});

app.$mount('#app');

})

entry-server.js: 服务端入口需要处理路由, 并触发数据预取逻辑

import { createApp } from '../main';

export default context => {

return new Promise((resolve, reject) => {

let { app, router, store } = createApp();

router.push(context.url);

router.onReady(() => {

const matchedComponents = router.getMatchedComponents();

// 对所有匹配的路由组件调用 `asyncData()` Promise.all(

matchedComponents.map(Component => {

if (Component.asyncData) {

return Component.asyncData({ store, route: router.currentRoute });

}

})

).then(() => {

context.state = store.state;

resolve(app);

})

.catch(reject);

}, reject);

})

}

具体实现

明确了选型原因和框架特性, 我们就要开始着手搭建一个Vue-SSR框架, 推荐文档: 官方出品的SSR指南 https://ssr.vuejs.org/ . 里面讲解了你所需要知道的全部名词解释和API介绍, 基本不需要再单独查资料了. 同时他给出了一个官方出品的 Demo, 作者是尤雨溪, 你想要参考的内容里面也基本都实现了一遍, 不过这个 Demo 是基于 Express 的, 改造成 Egg.js 还需要一些工作.

Vue-SSR + Egg.js, 在结构上跟普通的Vue服务端渲染也是一样的. 首次请求到达时, 由 Egg.js 处理, 在服务端执行渲染器逻辑并输出静态页面, 一共输出的还有客户端bundle, 然后客户端bundle接管应用, 继续接下来的任务, 变成一个”普通”的Vue应用. 同时Egg.js还会处理来自页面的异步请求, 处理正常服务端的工作. 下面是应用整体结构的示意:

具体来说, SSR的核心思路就是使用vue-server-renderer创建一个渲染器(renderer), 然后给这个渲染器传入Vue实例, 渲染器会得到HTML页面, 最后由Egg.js将HTML返回, 实现代码有些繁杂这里就不一一放出来了, 核心流程可以简化成:

// 第 1 步:创建一个 Vue 实例const app = new Vue({ template: `

Hello World
` })

// 第 2 步:创建一个 rendererconst renderer = require('vue-server-renderer').createRenderer()

// 第 3 步:将 Vue 实例渲染为 HTMLconst html = await renderer.renderToString(app)

// 第 4 步, 返回 HTMLthis.ctx.body = await this.ctx.renderString(html)

具体的实现代码可以在k2搭建的SSR框架中的app/controller/template.js文件中找到.

开发环境

SSR + Egg 的生产实现并不难, 但是支持HMR的开发环境搭建就稍麻烦些, 主要是devMiddleware, hotMiddleware和Egg.js配合使用. 原理是使用这些开发中间件监听并构建Vue文件后放在内存中, 再响应用户的请求.

具体来说, 在开发环境下, dev renderer中会执行dev-server并监听Vue代码, 当Vue代码发生变化时更新渲染renderer, 并返回给Egg, Egg会等待dev-server的返回后去执行输出. 因为egg的是基于koa, 所以dev Middleware简单封装一下直接挂载egg中的app上就可以, 具体实现代码可以查看k2搭建的SSR框架中的build/setup-dev-server.js文件.

这里有个值得注意的地方就是hotMiddleware中间键在注册之后就会接管所有请求, 这时候本希望走到Egg的请求就会被指向Vue, 这是不希望看到的, 所以需要在hotMiddleware中过滤一下:

// setup-dev-server.js...

function hotFn(ctx, next) {

// 过滤 HMR if (ctx.url.indexOf('webpack_hmr') < 0) {

return next();

}

const stream = new PassThrough();

ctx.body = stream;

return hotMiddleware(

ctx.req,

{

end: () => {},

write: stream.write.bind(stream),

writeHead: (state, headers) => {

ctx.state = state;

ctx.set(headers);

console.log('hotMiddleware headers', state, headers);

},

},

next

);

}

app.use(hotFn);

...

SSR优化

通过服务端渲染, 我们将应用从IO密集型转为了CPU密集型, CPU的压力会在QPS爆发时剧增, 这时候就需要针对SSR进行一些优化, 常见的优化方式有:

缓存

一般分为页面级别缓存和组件级别缓存.页面级别缓存: 对于相同的页面的请求, 其内容也相同(不考虑个性化页面情况), 所以将路由与对应页面缓存下来可以很有效命中缓存, 降低性能开销.

// 使用 LRUconst microCache = LRU({

max: 100,

maxAge: 1000 // 重要提示:条目在 1 秒后过期。})

...

// 命中缓存const hit = microCache.get(req.url)

if (hit) {

return res.end(hit)

}

...

// 缓存下来microCache.set(req.url, html)组件级别缓存: 组件缓存在组件渲染过程进行命中判断, 所以会影响组件渲染结果, 所以要确保组件不依赖上下文状态且无副作用, 换句话说缓存的是不会改变内容的展示型组件. 实现方法是使用vue-server-renderer内置的组件级别缓存配置参数, 在创建 renderer 时传入. 更多参数可以参考 具体缓存实现方式 .

const renderer = createRenderer({

cache: LRU({

max: 10000,

maxAge: ...

})

})

降级方案

核心思路就是当CPU使用率过高的时候即使切换到CSR模式. 可以结合Egg提供的schedule能力, 在启动时执行一个定时任务, 监控CPU使用率, 当大于阈值时切换到CSR模式. 而Egg也提供了单核schedule能力, 这样可以将定时任务的性能损耗降到很小; 或者在渲染执行时计时, 如果超时则自动返回CSR bundle, 降级为CSR应用, 这样虽然能临时解决CPU高开销无法及时响应的问题, 但用户体验并没有什么实质性改良.

要注意的坑

Vue-SSR的坑还是不少的, 特别是和Egg.js结合时, 经过了一段比较蛋疼的踩坑阶段, 现在终于稳定下来支撑业务. 这里把开发期间遇到的问题总结一下:生命周期不同. 这个问题最为明显, 在CSR和SSR中生命周期钩子是不同的. SSR中只有beforeCreate和created会被执行. 而在CSR中所有周期都会再执行一遍. 另外需要注意的是, 在服务端代码中不要写有全局副作用的代码, 例如写了 setInterval而不清除它. 因为在SSR周期没有beforeDestory阶段, 所以以往CSR中销毁页面前清除副作用的方法就没法继续使用了, 而此时的setInterval就会被永远不会清除了!

因为我们采用同构的目的是写一份尽量通用的代码, 让它运行在两端. 所以我们需要对不同端的运行环境特别熟悉才行, Node.js端是没有浏览器对象的, 所以window, document, DOM操作都没法执行. 同理, 浏览器端是没有process对象的. 他们各自的API实现也有差别, 这点需要特别留意. 比较麻烦的就是第三方库的引入, 有时候你并不知道引入的库能不能完全运行在Node端/浏览器端. 如果它只能运行在纯浏览器环境, 那可以在created阶段之后引入和执行来避开Node.js下执行.

避免单例. 在CSR中, 每次我们打开页面都是从服务端下载代码(或缓存), 然后创建一个全局的根Vue实例. 但在SSR中情况有所变化, 因为服务端会一直运行, 如果一直用同一个全局的Vue实例, 就会导致每次客户端的请求到指向了同一个根Vue实例, 就有可能造成状态污染. 所以这里要使用工厂函数在每次请求到来时, 新建一个Vue实例, 执行逻辑返回结果. 同样的, Store和Router也要这样处理:

// main.jsimport Vue from 'vue';

import App from './App.vue';

import { createStore } from './store';

import { createRouter } from './router';

export function createApp() {

const store = createStore();

const router = createRouter();

const app = new Vue({

router,

store,

render: /h/ => h(App),

});

return { app, router, store };

}

// router.jsimport Vue from 'vue';

import Router from 'vue-router';

import Home from './views/Home';

import About from './views/About';

Vue.use(Router);

export function createRouter() {

return new Router({

mode: 'history',

routes: [

{

path: '/',

component: Home,

},

{

path: '/about',

component: About,

},

],

});

}数据预取问题. 在业务中经常会遇到需要我们输出的页面是带有动态数据的, 也就是在渲染页面的时候先异步取一次数据, 然后将返回的数据放入页面中, 最后输出给用户. 其实处理这个逻辑的原理很简单, 就是在服务端整个请求周期中预取数据再注入最后页面就行. 为了尽早的发出请求, 可以在路由模块中执行数据预取逻辑, 在实例化之前就执行异步请求, 然后放入Vuex, 然后交给Vue页面去渲染. 这块的代码比较分散, 可以看这里的实现

值得注意的是使用v-html注入动态获取的HTML内容的时候. 如果HTML内容有

解决方法: 将获取到的HTML内容进行匹配, 剔出

而页面为什么会渲染两次呢?这是由于Vue SSR在初始页面渲染完成后会有一次hydration过程, 在这个过程中会照常执行流程mounted等生命周期. 此时会有一个自动判定, 判定是否此时的组件渲染出的内容与SSR渲染得到的内容一致, 如果出现不一致就会单独执行一次额外的CSR, 以达到页面被能正确地渲染. 而因为我们使用了v-html, 这个过程只有在CSR时才会被执行, 所以导致了两次渲染出来的内容不一致, 触发了Vue SSR的”补偿渲染机制”, 进而执行了第二次渲染.Cookie传递. 用户的请求会先在服务端被处理, 此时会带客户端的cookie, 但如果在服务端中Egg.js发出异步请求, 这个异步请求中并不带客户端的cookie, 所以如果异步请求需要带携带用户信息的cookie, 此时需要将上下文中的cookie加入Egg的异步请求headers中. 同时返回给客户端时如果涉及到有关cookie的操作也要同步处理一下, 例如用户注销:

const res = await this.ctx.curl('/logout', {

method: 'POST',

dataType: 'json',

headers: {

// 传递 cookie cookie: this.ctx.request.header.cookie,

},

});

if (res) {

// 处理客户端 cookie this.ctx.cookies.set('passport_login', null, {

domain: '.XXX.com',

});

}

总结

在实现一个展示型web应用, 或者对首屏性能要求较高的场景(例如官网, 技术文档). SSR都是一个很有效且值得投入的技术方案, 一方面享受了框架带来的便利和生态福利, 一方面也兼顾了性能和可扩展性, 对团队的横向探索和技术栈统一也很有好处. 得益于Vue(或React)和Egg.js生态的优势, 丰富的第三方库和繁荣生态支持, 让开发者有更多的选择和优化空间. 同时开发效率也更适合现代web应用的开发节奏, 不需要很高的技术门槛, 就可以上手开发并且支撑大部分的业务场景. 同时Egg.js框架的成熟也让服务端的能力更为强大, 对于安全性和稳定性的设计让我们在处理复杂场景中更加得心应手.

当然SSR中的坑也不少, 主要是服务端环境下的API规则的不同和一些数据和逻辑处理的约定(例如生命周期, 环境变量, 数据预取), 但只要注意规避这些问题, 在SSR的开发中还是比较顺滑的. 以目前的API设计来说, SPA的代码无法直接放到服务端就能跑(除非实现CSR应用时就考虑了SSR), 其中想改造的话成本可能还不小. 但是一个新应用想要设计为SSR方案, 基本上开发与维护成本和一个SPA应用差不多, 所以在业务开发中是一个较为可靠的技术方案.

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值