什么是服务器端渲染 (SSR)
Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记”激活”为客户端上完全可交互的应用程序。
服务器渲染的 Vue.js 应用程序也可以被认为是”同构”或”通用”,因为应用程序的大部分代码都可以在服务器和客户端上运行。
为什么使用服务器端渲染 (SSR)
优势:
- 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
- 更利于首屏渲染,首屏的渲染是node发送过来的html字符串,并不依赖于js文件了,这就会使用户更快的看到页面的内容。尤其是针对大型单页应用,打包后文件体积比较大,普通客户端渲染加载所有所需文件时间较长,首页就会有一个很长的白屏等待时间。
劣势:
- 开发条件所限。浏览器特定的代码,只能在某些生命周期钩子函数 (lifecycle hook) 中使用;一些外部扩展库 (external library) 可能需要特殊处理,才能在服务器渲染应用程序中运行。
- 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。
- 更多的服务器端负载。
- 学习成本高,除了对webpack、Vue要熟悉,还需要掌握node等相关技术。相对于客户端渲染,项目构建、部署过程更加复杂。
服务端渲染与客户端渲染的区别
服务端渲染与客户端渲染的本质区别是谁来渲染html页面,如果html页面在服务器端那边拼接完成后,那么它就是服务器端渲染,而如果是前端做的html拼接及渲染的话,那么它就属于客户端渲染的。
构建
构建图
app.js入口文件
app.js是我们的通用entry,它的作用就是构建一个Vue的实例以供服务端和客户端使用,注意一下,在纯客户端的程序中我们的app.js将会挂载实例到dom中,而在ssr中这一部分的功能放到了Client entry中去做了。
两个entry
接下里我们来看Client entry和Server entry,这两者分别是客户端的入口和服务端的入口。Client entry的功能很简单,就是挂载我们的Vue实例到指定的dom元素上;Server entry是一个使用export导出的函数。主要负责调用组件内定义的获取数据的方法,获取到SSR渲染所需数据,并存储到上下文环境中。这个函数会在每一次的渲染中重复的调用。
webpack打包构建
然后我们的服务端代码和客户端代码通过webpack分别打包,生成Server Bundle和Client Bundle,前者会运行在服务器上通过node生成预渲染的HTML字符串,发送到我们的客户端以便完成初始化渲染;而客户端bundle就自由了,初始化渲染完全不依赖它了。客户端拿到服务端返回的HTML字符串后,会去“激活”这些静态HTML,是其变成由Vue动态管理的DOM,以便响应后续数据的变化。
vue-server-renderer
该软件包的作用是:vue2.0提供在node.js 服务器端呈现的。、
API
- createRenderer
该方法是创建一个renderer实列。如下代码:
1 | const renderer = require('vue-server-renderer').createRenderer(); |
- renderer.renderToString(vm, cb);
该方法的作用是:将Vue实列呈现为字符串。该方法的回调函数是一个标准的Node.js回调,它接收错误作为第一个参数。如下代码:
1 | // renderer.js 代码如下: |
div中的data-server-rendered属性告诉VUE这是服务器渲染的元素。并且应该以激活的模式进行挂载。
- createBundleRenderer(code, [rendererOptions])
Vue SSR依赖包 vue-server-render, 它的调用支持有2种格式,createRenderer() 和 createBundleRenderer(), 那么createRenderer()是以vue组件为入口的,而 createBundleRenderer() 以打包后的JS文件或json文件为入口的。所以createBundleRenderer()的作用和 createRenderer() 作用是一样的,无非就是支持的入口文件不一样而已。
与服务器集成
服务器端渲染,需要用到 vue-server-renderer 组件包。该包的基本的作用是拿到vue实列并渲染成html结构
server.js
1 | const Vue = require('vue'); |
启动服务后访问localhost:3000/index
可以将模板页面抽出,通过fs模块读取模板页面
Index.html
1 |
|
html中必须包含 , renderer.renderToString函数 真正渲染成html后,会把内容插入到该地方来。
server.js
1 | const Vue = require('vue'); |
启动服务后访问localhost:3000/index
为每个请求创建一个新的根vue实列
服务器渲染过程中,只会调用 beforeCreate 和 created两个生命周期函数。其他的生命周期函数只会在客户端调用。因此在created生命周期函数中不要使用的不能销毁的变量存在。比如常见的 setTimeout, setInterval 等这些。并且window,document这些也不能在该两个生命周期中使用,因为node中并没有这两个东西,因此如果在服务器端执行的话,也会发生报错的。但是我们可以使用 axios来发请求的。因为它在服务器端和客户端都暴露了相同的API。但是浏览器原生的XHR在node中也是不支持的。
目录结构
1 | ├── package-lock.json |
app.js
1 | const Vue = require('vue'); |
暴露createApp方法是为了避免状态单例。Node.js 服务器是一个长期运行的进程,当我们运行到该进程的时候,它会将进行一次取值并且留在内存当中,那么这样很容易导致每个实列中的状态值会发生混乱。因此我们这边把app.js代码抽离一份出来,就是需要为每个请求创建一个新的实列。
index.html
1 |
|
server.js
1 | const Vue = require('vue'); |
路由实现和代码分割
上面的demo,我们只是使用 node server.js 运行服务器端的启动程序,然后进行服务器端渲染页面。但是没有将相同的vue代码提供给客户端,因此我们要实现这一点的话,我们需要在项目中引用我们的webpack来打包我们的应用程序。
目录结构
1 | ├── build |
需要安装的依赖
1 | "dependencies": { |
src/index.html
1 |
|
src/App.vue
1 | <style lang="stylus"> |
src/app.js
这边只是导出一个函数,返回app实例,没有做$mount操作
1 | import Vue from 'vue'; |
src/entry-client.js
客户端入口,做$mount挂载操作
1 | import { createApp } from './app'; |
src/entry-server.js
服务端入口,实例化一个vue对象,然后返回实例化对象后的对象
1 | import { createApp } from './app'; |
src/router.js
这边也是与app.js一样,导出一个方法,每次调用创建新的实例
1 | import Vue from 'vue'; |
更新src/app.js文件
1 | import Vue from 'vue'; |
更新src/entry-server.js
实现服务器端的路由逻辑
1 | import { createApp } from './app'; |
更新src/entry-client.js
由于路由有可能是异步组件或路由钩子,因此在 src/entry-client.js 中挂载元素之前也需要调用router.onReady因此代码需要改成如下所示
1 | import { createApp } from './app'; |
src/components/home.vue
1 | <template> |
src/components/item.vue
1 | <template> |
webpack配置
webpack.base.config.js 基本配置
1 | const path = require('path') |
webpack.client.config.js
该配置主要对客户端代码进行打包,并且它通过 webpack-merge 插件来对 webpack.base.config.js 代码配置进行合并
1 | const path = require('path') |
webpack.server.config.js
1 | const path = require('path'); |
编辑package.json文件
1 | ... |
构建
1 | npm run build:all |
打包出来的文件
服务
server.js
我们在server.js 中需要引入我们刚刚打包完的客户端的 vue-ssr-client-manifest.json 文件 和 服务器端渲染的vue-ssr-server-bundle.json 文件,及 html模板 作为参数传入 到 createBundleRenderer 函数中
1 | const Vue = require('vue'); |
编辑package.json
添加一个启动server的命令
1 | "server":"node server.js" |
启动服务并且访问localhost:3000/home
数据预获取和状态
在服务器端渲染(SSR)期间,比如说我们的应用程序有异步请求,在服务器端渲染之前,我们希望先返回异步数据后,我们再进行SSR渲染,因此我们需要的是先预取和解析好这些数据。
并且在客户端,在挂载(mount)到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据。否则的话,客户端应用程序会因为使用与服务器端应用程序不同的状态。会导致混合失败。
因此为了解决上面的两个问题,我们需要把专门的数据放置到预取存储容器或状态容器中,因此store就这样产生了。我们可以把数据放在全局变量state中。并且,我们将在html中序列化和内联预置状态,这样,在挂载到客户端应用程序之前,可以直接从store获取到内联预置状态。
目录结构:
1 | ├── build |
src/store/index.js
1 | import Vue from 'vue'; |
src/api/index.js
1 | export function fetchItem(id) { |
src/app.js
1 | import Vue from 'vue'; |
我们需要在什么地方使用 dispatch来触发action代码呢?
按照官网说的,我们需要通过访问路由,来决定获取哪部分数据,这也决定了哪些组件需要被渲染。因此我们在组件 Item.vue 路由组件上暴露了一个自定义静态函数 asyncData。
注意:asyncData函数会在组件实例化之前被调用。因此不能使用this,需要将store和路由信息作为参数传递进去。
src/components/item.vue
1 | <template> |
服务器端数据预取
服务器端预取的原理是:在 entry-server.js中,我们可以通过路由获得与 router.getMatchedComponents() 相匹配的组件,该方法是获取到所有的组件,然后我们遍历该所有匹配到的组件。如果组件暴露出 asyncData 的话,我们就调用该方法。并将我们的state挂载到context上下文中。vue-server-renderer 会将state序列化 window.__INITAL_STATE__. 这样,entry-client.js客户端就可以替换state,实现同步。
src/entry-server.js
1 | import { createApp } from './app'; |
如上官网代码,当我们使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序之前,store 就应该获取到状态
entry-client.js
1 | import { createApp } from './app'; |
客户端数据预取
在客户端,处理数据预取有 2种方式 :分别是:在路由导航之前解析数据 和 匹配要渲染的视图后,再获取数据。
- 在路由导航之前解析数据
在这种方式下,应用程序会在所需要的数据全部解析完成后,再传入数据并处理当前的视图。它的优点是:可以直接在数据准备就绪时,传入数据到视图渲染完整的内容。但是如果数据预取需要很长时间的话,那么用户在当前视图会感受到 “明显卡顿”。因此,如果我们使用这种方式预取数据的话,我们可以使用一个菊花加载icon,等所有数据预取完成后,再把该菊花消失掉。
src/entry-client.js
1 | import { createApp } from './app'; |
- 匹配渲染的视图后,再获取数据
根据官网介绍:该方式是将客户端数据预取,放在视图组件的 beforeMount 函数中。当路由导航被触发时,我们可以立即切换视图,因此应用程序具有更快的响应速度。但是,传入视图在渲染时不会有完整的可用数据。因此,对于使用此策略的每个视图组件,都需要具有条件的加载状态。因此这可以通过纯客户端的全局mixin来实现
src/entry-client.js
1 | import { createApp } from './app'; |
构建并且访问localhost:3000/item
页面注入不同的Head
在如上服务器端渲染的时候,我们会根据不同的页面会有不同的meta或title。因此我们需要注入不同的Head内容, 我们按照官方
文档来实现一个简单的title注入。如何做呢?
我们需要在我们的index.html
模块中定义 <title>vue-ssr-1</title>
, 它的基本原理和数据预取是类似的。
index.html
1 |
|
注意:
- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation),以避免 XSS 攻击。
- 应该在创建 context 对象时提供一个默认标题,以防在渲染过程中组件没有设置标题。
目录结构:
1 | ├── build |
src/mixins/title-mixin.js
1 | function getTitle (vm) { |
在webpack配置里设置的全局变量
src/components/item.vue
1 | <template> |
src/components/home.vue
1 | <template> |
构建并访问localhost:3000/home
和localhost:3000/item
title发生变化
页面级别的缓存
缓存(官网介绍):虽然vue的服务器端渲染非常快,但是由于创建组件实列和虚拟DOM节点的开销,无法与纯基于字符串拼接的模板性能相当。因此我们需要使用缓存策略,可以极大的提高响应时间且能减少服务器的负载。
页面级别缓存
server.js
1 | const Vue = require('vue'); |
构建项目并且访问localhost:3000/item
参考资料
webpack4+koa2+vue 实现服务器端渲染(详解)