前言
如今提到前端技术栈,VUE、REACT等 MV* 框架已经是基本的前端知识,用这些框架可以快速地构建单页面应用(SPA),解决了多页面应用资源浪费的问题,同时提高了开发效率。但万物都有对立统一性,SPA也带来了它的问题:较差的SEO和首屏加载速度慢。如何解决这个问题呢?优秀的先驱者们给出的解决方案是:服务端渲染(SSR)。
SSR其实不是开创性的新技术,在前后端未分离之前,所有的应用程序几乎都是服务端渲染。SSR分为两种模式:单页面模式和非单页面模式。这篇文章主要了解的是单页面模式,将VUE单页面应用改造成SSR项目。
这篇文章将分为三个部分,第一个部分主要理解服务端渲染概念和做服务端渲染能解决的问题。第二个部分做一个改造VUE2的项目的服务端渲染实践。最后一个部分总结。
1. 什么是SSR
1.1 定义
先来看一下vue官网上关于服务端渲染的解释:
Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。
简单理解一下,即将组装html的工作交给服务端完成,服务端输出html字符串给浏览器。我们知道,vue-cli构建的项目,一般是浏览器渲染(CSR)的。下面我们对比了解一下CSR和SSR的过程。
1.2 CSR 和 SSR
客户端渲染(CSR)时,和前端服务器的交互过程如图所示:
sequenceDiagram
客户端->>服务端: 请求,例如访问www.xxx.com/test1
服务端->>客户端: 返回html, app.bundle.js
Note left of 客户端: 渲染HTML,执行js
客户端->>服务端: 交互,发送数据请求
服务端->>客户端: 响应请求,返回数据
大体流程描述如下:
①首先浏览器请求URL,前端服务器直接返回一个空的静态HTML文件(不需要任何查数据库和模板组装),这个HTML文件中加载了很多渲染页面需要的 JavaScript 脚本和 CSS 样式表。
②浏览器拿到 HTML 文件后开始加载脚本和样式表,并且执行脚本,这个时候请求后端服务提供的API,获取数据,获取完成后将数据通过JavaScript脚本动态的将数据渲染到页面中,完成页面显示。
sequenceDiagram
客户端->>服务端: 请求,例如访问www.xxx.com/test1
Note right of 服务端: 读取请求,组装HTML
服务端-->>客户端: 返回对应路由的html
Note left of 客户端: 加载首屏html
大体流程描述如下:
①浏览器请求URL
②前端服务器接收到URL请求根据不同的URL,前端服务器向后端服务器请求数据,请求完成后,前端服务器会组装一个携带了具体数据的HTML文本,并且返回给浏览器
③浏览器得到HTML之后开始渲染页面,同时,浏览器加载并执行 JavaScript 脚本,给页面上的元素绑定事件,让页面变得可交互,当用户与浏览器页面进行交互,如跳转到下一个页面时,浏览器会执行 JavaScript 脚本,向后端服务器请求数据,获取完数据之后再次执行 JavaScript 代码动态渲染页面。
由此可见,客户端渲渲染时,浏览器承担着请求URL, 执行JS脚本以及渲染页面的工作,服务端渲染时,浏览器不用负责执行JS脚本来组装HTML这一步,减轻了浏览器的压力。同时应注意,当跳转下一下页面时,都是执行JavaScript脚本,进行路由跳转,也就是说,SPA的客户端渲染和服务端渲染的主要区别在于首屏html。
1.3 服务端渲染解决的问题
分析以上客户端渲染和服务端渲染,可以发现,纯浏览器渲染存在的问题:
①浏览器承担执行JS,渲染页面,请求数据等一系列工作, 会占用浏览器更多的运行内存,导致浏览器变卡。
②服务端返回的是空的HTML,不利于SEO
③浏览器拿到html后才请求下载JS, CSS, IMG等资源,然后在执行脚本生成页面,造成了一定的白屏时间,降低用户体验。
服务端渲染有更好的 SEO,更快的内容到达时间 (time-to-content)的优点,为解决浏览器渲染提供了一个好的方案。
2. VUE2项目改造成服务端渲染
上图为vue服务端渲染官网里的图。由上图可以看出,服务端渲染包括三个部分:
- Source。包括通用的应用代码(vue项目的话就是vue-cli构建的项目), 服务端的打包入口文件(Server entry和客户端的打包入口文件(Client entry)。
- webpack build。包括服务端打包配置文件和客户端打包配置文件,这一步目的将项目代码打包输出bundle。
- Node Server。要将bundle最终输出为html字符串,需要一个node服务。 根据以上,我们将分三步来改造vue2项目。
2.1 第一步,改造vue(main,router,vuex),编写server-entry.js和client-entry.js
main, router, vuex
在非SSR情况下,每个用户访问网站时,是将代码下载到客户端,然后独立渲染运行,因此每个用户的操作都是互不影响的。但是服务端渲染时,所有的用户访问的都是同一套运行在服务端的代码,如果创建一个单例对象,那么所有的用户将共享这个实例,很容易造成交叉请求状态污染,结果就是用户请求的页面不是自己当前请求处于的状态。这显然是不合理的。因此,应该为每次请求创建新的根实例,我们应该暴露一个可以重复执行的工厂函数。
- main.js
export function createApp () {
...
const app = new Vue({
router,
store,
// 根实例简单的渲染应用程序组件。
render: h => h(App)
})
return { app, router, store}
}
- router/index.js
const router = new VueRouter({
mode: 'history', //
...
})
export default function createRouter () {
return router
}
- vuex/index.js
const store = new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
}
})
export default function createStore () {
return store;
}
- server-entry.js sever-entry.js 暴露一个函数,接收一个参数context, 这个参数即为服务端渲染的上下文对象,包含服务端请求体中的信息。每次渲染时重复调用此函数,接受前端路由,将页面内的router-view内的内容显示为对应的页面,同时路由准备好之后执行数据预取逻辑(asyncData暴露在每个component上)
import { createApp } from './main'
// context:地址 context = req.url {url: /page1}
// 每次访问时都会执行context => {}这个方法
export default context => {
// promise
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
// 让router-view里的内容变成page1的内容
router.push(context.url)
// 跳转完成,将实例返回
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 对所有匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
- client-entry.js client-entry.js实现客户端激活(client-side hydration),即将应用程序挂载在根元素上,将其变为由Vue管理的动态DOM的过程。通过在根元素上添加 data-server-rendered="true"属性,标记html为服务端渲染的,同时激活html进行后期交互和数据处理。
import { createApp } from './main'
// 客户端激活,将app里的内容挂到响应的页面上。
const { app, router,store } = createApp()
router.onReady(() => {
app.$mount('#app')
})
注意点:vrouter.onReady
2.2 第二步,编写打包配置文件
核心模块是vue-server-renderer。server-plugin和client-plugin分别负责打包服务端依赖和客户端依赖。
- webpack.server.config.js
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = {
css: {
// 不提取 CSS
extract: false
},
outputDir: 'serverDist',
configureWebpack: () => ({
// 服务器入口文件
entry: `./src/server-entry.js`,
devtool: 'source-map',
// 构建目标为nodejs环境
target: 'node', // 运行在node端,所以指定打包目标(webpack,不写默认以浏览器标准)
output: {
filename: 'server-bundle.js',
// 构建目标加载模式 commonjs
libraryTarget: 'commonjs2',
},
// 跳过 node_mdoules,运行时会自动加载,不需要编译
externals: nodeExternals({
// 允许css文件,方便css module
allowlist: [/\.css$/]
}),
// 关闭代码切割
optimization: {
splitChunks: false
},
plugins: [
new VueSSRServerPlugin()
]
})
}
- webpack.client.config.js
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const config = {
// 这是将服务器的整个输出
// 构建为单个 JSON 文件的插件。
// 默认文件名为 `vue-ssr-server-bundle.json`
configureWebpack: () => ({
entry: `../src/client-entry.js`,
devtool: 'source-map',
plugins: [
new VueSSRClientPlugin()
]
}),
chainWebpack: config => {
config.plugins.delete('html');
config.plugins.delete('preload');
config.plugins.delete('prefetch');
}
}
module.exports = config
2.3 第三步,构建服务端代码
1.server.js
主要步骤:
①读取server bundle和 client bundle
②获取vue-server-renderer的createBundleRenderer对象
③利用createBundleRenderer对象将template, server bundle,client bundle结合生成渲染器renderer,生成vue实例。
④获取路由,renderer的renderToString方法将页面序列化,将序列化后的html返回。
const vue = require('vue')
const express = require('express');
const server = express()
const fs = require('fs')
const path = require('path')
const serverBundle = require(path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json'));
const clientManifestPath = path.resolve(process.cwd(), 'dist', 'vue-ssr-client-manifest.json');
const clientManifest = JSON.parse(fs.readFileSync(clientManifestPath, 'utf-8'));
// html文件不是模块化的,所有只能fs读取文件的方式读取
const template = fs.readFileSync(path.resolve(__dirname,'index.html'), 'utf-8')
server.use(express.static(path.resolve(process.cwd(), 'dist')));
const { createBundleRenderer } = require('vue-server-renderer')
// renderer:生成vue实例
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
clientManifest: clientManifest,
template:template
});
// const { } = require('./main')
// * 代表用户只要访问1000的这个服务,无论请求哪个地址都会执行这个方法。
server.get('*', (req, res) => {
// req 请求体
// res 响应体
if(req.url !== '/favicon.icon') {
// context为传给server-entry的context
const context = {
url: req.url,
}
renderer.renderToString(context).then((html) => {
res.end(html)
})
}
})
server.listen(1000)
2.index.html
vue-ssr-outlet这个注释,告诉服务器将html注入的地方。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ssr-demo</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
2.4. 补充用vue + nuxtJs做服务端渲染
nuxt做vue的服务端渲染超级简单,只需要用 npx create-nuxt-app app-name 就可以快速初始化一个SSR项目。项目目录如下:
- pages文件夹:放路由对应的页面,一个.vue文件代表一个路由,文件路劲即为URL,因此命名需谨慎。
- 其他文件夹的作用同vue项目。
- 运行只需要根据package.json里的脚本执行。
3. 总结
当用户访问服务端渲染的网站时,服务器将URL传递给渲染器,渲染器根据server-client暴露的函数,跳转到对应的路由组件,在服务器端组装html,返回给浏览器,浏览器加载html,减少了浏览器端执行脚本组装html的时间,使用户的体验更佳,同时返回组装好的html里有更多的网站信息,更利于SEO.
服务端渲染已经是很久之前出现的知识了,之前仅仅停留在看下文档的阶段,一直没有亲自写过,这次仅仅按照文档做了一个最基础的项目,过程中依然出现了很多问题,所以开发者要提升自己,还是要多动手。
最后,以几句七言绝句 “ 纸上得来终觉浅,绝知此事要躬行 ” 时刻提醒自己。
源代码的位置为:SSR-demo。