写在前面: 这篇文章是19年初时候接手的一个需要服务端渲染的某官网项目时做的一些关于 SSR 的研究和积累. 由于阿里内部的 NodeJS 应用选型基本都会选择 Egg.js 作为基础业务框架, 所以本文也是从 Vue.js + Egg.js 入手和实现, 主要介绍 SSR 本身特点, 构建逻辑, 实现思路和踩坑填坑经验, 以及一些总结向的优化思路.
SSR(Server-Side Rendering) — 服务端渲染
服务端渲染是相对于客户端渲染而言的(Client Side Render), 它的渲染行为发生在服务器端, 渲染完成之后再将完整页面以HTML字符串的形式交给浏览器, 最后经过”注水”hydrate
过程将一些事件绑定和Vue状态等注入到输出的静态的页面中, 由同步下发给浏览器的的Vue bundle接管状态, 继续处理接下来的交互逻辑. 这也是一种同构应用的实现(代码可以运行在客户端和服务端中).
那么 什么情况下该使用SSR方案呢 , 其实一句话总结下来就是展示型应用:
在早期的web开发技术栈中, 实际上都是服务端渲染, 像是Java, PHP, ROR, ASP.NET. 当前端MVC出现之后, 浏览器端渲染模型开始出现并且流行 — “开局一块HTML模板, 元素全靠JS加载”. 这种方式带来了比较快速的页面切换体验和极好的开发效率, 以及丰富的技术生态. 但是CSR也不是没有缺点, 缺点很明显而且不改变其核心思路的话没办法克服:
其实在大多数场景下, 你都没必要使用SPA的方案(可以看看这篇文章 ). 那么既然某些场景下不适合使用CSR方案, 我们直接退回到以前的web开发方式就好了, 干嘛要去踩SSR新的坑呢? 那么对比传统的web应用, 使用框架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();// 同步store
if (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: `
const renderer = require('vue-server-renderer').createRenderer()// 第 3 步:将 Vue 实例渲染为 HTML
const html = await renderer.renderToString(app)// 第 4 步, 返回 HTML
this.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) {// 过滤 HMRif (ctx.url.indexOf(&#39;webpack_hmr&#39;) <0) {return next();}const stream &#61; new PassThrough();ctx.body &#61; stream;return hotMiddleware(ctx.req,{end: () &#61;> {},write: stream.write.bind(stream),writeHead: (state, headers) &#61;> {ctx.state &#61; state;ctx.set(headers);console.log(&#39;hotMiddleware headers&#39;, state, headers);},},next);
}
app.use(hotFn);
...
通过服务端渲染, 我们将应用从IO密集型转为了CPU密集型, CPU的压力会在QPS爆发时剧增, 这时候就需要针对SSR进行一些优化, 常见的优化方式有:
一般分为页面级别缓存和组件级别缓存.
// 使用 LRU
const microCache &#61; LRU({max: 100,maxAge: 1000 // 重要提示&#xff1a;条目在 1 秒后过期。
})...
// 命中缓存
const hit &#61; microCache.get(req.url)
if (hit) {return res.end(hit)
}
...// 缓存下来
microCache.set(req.url, html)
vue-server-renderer
内置的组件级别缓存配置参数, 在创建 renderer 时传入. 更多参数可以参考 具体缓存实现方式 .const renderer &#61; createRenderer({cache: LRU({max: 10000,maxAge: ...})
})
核心思路就是当CPU使用率过高的时候即使切换到CSR模式. 可以结合Egg提供的schedule能力, 在启动时执行一个定时任务, 监控CPU使用率, 当大于阈值时切换到CSR模式. 而Egg也提供了单核schedule能力, 这样可以将定时任务的性能损耗降到很小; 或者在渲染执行时计时, 如果超时则自动返回CSR bundle, 降级为CSR应用, 这样虽然能临时解决CPU高开销无法及时响应的问题, 但用户体验并没有什么实质性改良.
Vue-SSR的坑还是不少的, 特别是和Egg.js结合时, 经过了一段比较蛋疼的踩坑阶段, 现在终于稳定下来支撑业务. 这里把开发期间遇到的问题总结一下:
beforeCreate
和created
会被执行. 而在CSR中所有周期都会再执行一遍. 另外需要注意的是, 在服务端代码中不要写有全局副作用的代码, 例如写了 setInterval
而不清除它. 因为在SSR周期没有beforeDestory
阶段, 所以以往CSR中销毁页面前清除副作用的方法就没法继续使用了, 而此时的setInterval
就会被永远不会清除了!window
, document
, DOM操作都没法执行. 同理, 浏览器端是没有process
对象的. 他们各自的API实现也有差别, 这点需要特别留意. 比较麻烦的就是第三方库的引入, 有时候你并不知道引入的库能不能完全运行在Node端/浏览器端. 如果它只能运行在纯浏览器环境, 那可以在created
阶段之后引入和执行来避开Node.js下执行.Store
和Router
也要这样处理:// main.js
import Vue from &#39;vue&#39;;
import App from &#39;./App.vue&#39;;
import { createStore } from &#39;./store&#39;;
import { createRouter } from &#39;./router&#39;;export function createApp() {const store &#61; createStore();const router &#61; createRouter();const app &#61; new Vue({router,store,render: /h/ &#61;> h(App),});return { app, router, store };
}// router.js
import Vue from &#39;vue&#39;;
import Router from &#39;vue-router&#39;;
import Home from &#39;./views/Home&#39;;
import About from &#39;./views/About&#39;;Vue.use(Router);export function createRouter() {return new Router({mode: &#39;history&#39;,routes: [{path: &#39;/&#39;,component: Home,},{path: &#39;/about&#39;,component: About,},],});
}
Vuex
, 然后交给Vue页面去渲染. 这块的代码比较分散, 可以看这里的实现v-html
注入动态获取的HTML内容的时候. 如果HTML内容有
所包含的JS代码, 会发现script
中的事件绑定失效. 其实在这里页面被渲染了两次, 第一次是发生在SSR直接交给浏览器的时候: 此时
完整被渲染在浏览器里, 其内容正常执行, 事件绑定也正常的绑定在了当时的DOM元素上. 而第二次渲染时, 走的是CSR: 在这时由于是以v-html
的方式来渲染替换HTML, 但是v-html
实质上是innerHTML
操作, 这样
虽然会被替换上去, 但是其中的内容不会执行(innerHTML
为安全性考虑而设计). 所以经过这样两次渲染之后, 此时的DOM元素是第二次渲染时得到的, 而正常执行过的JS的事件绑定是绑在在第一次渲染出来的DOM元素上, 这样就出现了虽然DOM存在. 但是无法触发该DOM上的事件的情况.
内容, 无论第一次或第二次渲染, 只将内容交给v-html
页面, 然后单独在生命周期updated
(页面已将HTML内容渲染完成)中将
创建出来, 添加到页面里自动执行, 实现绑定. 并在下一次页面重渲染(可能是页面跳转来到)时判断
是否存在, 如果真则先删去再添加, 这样避免添加多余的
块.hydration
过程, 在这个过程中会照常执行流程mounted
等生命周期. 此时会有一个自动判定, 判定是否此时的组件渲染出的内容与SSR渲染得到的内容一致, 如果出现不一致就会单独执行一次额外的CSR, 以达到页面被能正确地渲染. 而因为我们使用了v-html
, 这个过程只有在CSR时才会被执行, 所以导致了两次渲染出来的内容不一致, 触发了Vue SSR的”补偿渲染机制”, 进而执行了第二次渲染.const res &#61; await this.ctx.curl(&#39;/logout&#39;, {method: &#39;POST&#39;,dataType: &#39;json&#39;,headers: {// 传递 COOKIECOOKIE: this.ctx.request.header.COOKIE,},
});if (res) {// 处理客户端 COOKIEthis.ctx.COOKIEs.set(&#39;passport_login&#39;, null, {domain: &#39;.XXX.com&#39;,});
}
在实现一个展示型web应用, 或者对首屏性能要求较高的场景(例如官网, 技术文档). SSR都是一个很有效且值得投入的技术方案, 一方面享受了框架带来的便利和生态福利, 一方面也兼顾了性能和可扩展性, 对团队的横向探索和技术栈统一也很有好处. 得益于Vue(或React)和Egg.js生态的优势, 丰富的第三方库和繁荣生态支持, 让开发者有更多的选择和优化空间. 同时开发效率也更适合现代web应用的开发节奏, 不需要很高的技术门槛, 就可以上手开发并且支撑大部分的业务场景. 同时Egg.js框架的成熟也让服务端的能力更为强大, 对于安全性和稳定性的设计让我们在处理复杂场景中更加得心应手.
当然SSR中的坑也不少, 主要是服务端环境下的API规则的不同和一些数据和逻辑处理的约定(例如生命周期, 环境变量, 数据预取), 但只要注意规避这些问题, 在SSR的开发中还是比较顺滑的. 以目前的API设计来说, SPA的代码无法直接放到服务端就能跑(除非实现CSR应用时就考虑了SSR), 其中想改造的话成本可能还不小. 但是一个新应用想要设计为SSR方案, 基本上开发与维护成本和一个SPA应用差不多, 所以在业务开发中是一个较为可靠的技术方案.