前言
我是准备从现在开始学习服务端渲染(Server-Side Render
,简称SSR
)相关的知识点,我学习这部分知识的出发点是为了补齐我前端开发中最后缺失的几块较为重要知识拼图,我相信在完全掌握SSR的知识点之后,可以使得我的技术眼界提高到更高的档次。
我在本系列的前半部分的文章主要将会是大家阐述清楚SSR的基本原理,在中后篇部分文章,我将会尝试向大家分享一些Nuxt源码的技术细节。
好了,废话不多说,我们就开始SSR相关知识点的学习吧。
动态网页的前世今生
我作为一个有着10年开发经验的前端程序员,在职业生涯开始的时候,还接触过一些远古时期的动态网站技术,这儿所说的动态网站是数据动态,即网站渲染的内容是从指定的数据源获得,然后在服务端运行,生成Html字符串,返回给浏览器完成渲染的技术。
比如,我在14-15年还学过Asp.Net
,PHP
这样的动态网站开发技术,然后前端使用jQuery
操作DOM,处理一些交互逻辑。这种场景下,一般是后端语言作为模板引擎,比如常见的文件就是.php文件,.aspx文件,.jsp文件,这种文件里面,既有前端代码,又有后端代码。这种文件在服务器上执行,得到Html字符串,这个处理逻辑跟我们现在使用EJS
开发脚手架其实是一样的,只不过生成的内容,一个是用来帮我们创建一些项目的初始化文件(文件流写入到磁盘),服务端的模板引擎执行得到的内容,作为响应内容返回给前端(网络流供浏览器解析)。
这种开发方式,有一个很大的弊端,就是要求程序员必须要同时具备前后端开发的能力,而这种活儿一般都是后端程序员完成的(为什么是这样的呢,因为后端的上手难度相对于前端来说要复杂的多,而基础的前端开发,只要经过简单的学习,处理一些简单的交互,还是绰绰有余了,这样是为什么有些后端程序员觉得前端就是搞几个破页面,没有任何技术难度的历史背景),前后端的业务逻辑其实是杂糅在一起的,比如我就看过有些人闹过笑话,在模板引擎里面使用前端的注释,结果渲染出来,用户就看到了一些本不该看到的技术实现细节,啼笑皆非。
另外,还有一个弊端(但是这个弊端是可以通过一些技术手段进行优化的,后面我们会在SSG
的小节中聊到),因为用户的请求发送到服务器,服务器生成了页面内容就返回了,当下一个用户访问时,又需要把这样的流程再走一遍,这浪费了服务器的性能。
不过,随着angular.js的问世(我是在2015年接触angular.js的1.x版本的,在当时完全颠覆了jQuery处理前端交互设计理念),Web开发开始朝着前后端分离的方向发展了,尤其是后续Vue,React不断大火,更是加剧了这样的技术迭代进程。
传统的,比如我们要渲染一个列表的话,需要使用动态的创建DOM或者使用字符串的拼接+innerHTML实现,而新框架的出现,比如Angular一个ng-for
指令就搞定了,于是大家发现,咦?既然前端能够方便快捷的渲染页面逻辑,那不如我们后端直接把数据通过Ajax
发送给前端,前端自行处理不就可以了吗,我们后端程序员就再也不用去使用模板引擎处理数据了,就可以专注自己的业务开发了,于是Web开发变成了CSR(Client-Side-Render
)渲染主导的方式。
前后端交互合作方面的问题解决了,但是又带来了一个新的问题,由于现在我们访问网页时,服务器返回的仅仅是一个空壳,所以网站的SEO(Search-Engine-Optimization
)是比较糟糕的,这对有些业务来说是完全不可接受的(比如To C的电商业务较为明显)。另外,页面需要等到JS加载执行之后,才能填充数据渲染,这也很大程度的影响了页面的首屏渲染时间。
所以,技术发展到这个时候,就有了最终出现的SSR技术,因此,有人就调侃,Web开发从SSR发展至CSR,后面又发展回SSR了,之前我们说的比如php这种动态网页技术,也是SSR,因为网页内容生成是发生在服务端的,而现在的新的SSR技术跟以前的SSR技术有一些细微的差异,即现在的SSR是采用NodeJS作为服务器,而且它的运行时环境是依赖JS环境的(也有一些其它的技术hack,我只阐述主流的),现在的SSR技术,我通过我的学习,我得出的结论是它是对CSR的一种补充,所以,它跟早期的SSR还是有很大的差异的。
SSG与SSR
SSG(Static-Site Generation
),静态站点生成技术,也被称为预渲染,是一种针对CSR的补充,不过SSG有一个最重要的使用条件要求:如果用服务端渲染一个页面所需的数据对每个用户来说都是相同的,那么我们可以只渲染一次
在上一节中,我们聊动态网页技术的发展的时候,就已经聊到这个问题了,我已知的像php,asp.net,都可以通过开启页面的缓存机制,让服务器在生成内容的时候,把结果缓存下来,后面的用户再访问的时候,直接从缓存里面取用就行了,不过,这要求页面的内容都是一致的,否则缓存就没有意义了。
像Vitepress、Gatsby这样的静态网站托管理念而出现的技术,因为所有的用户获取的内容都是一样的,只需要实现把内容构建出来部署就可以了,而如果我们的内容需要发生变化,只需要重新的把网站的构建流水线跑一次,就可以完成页面内容的更新。
但是对于电商网站这样的业务场景就行不通了,举个简单点儿的例子,不同的用户可能有不同的喜好,所以不同的用户获取到的推荐内容也是不一样的,所以在这种场景下,就只能使用SSR技术了,不过,像某个商品具体的介绍页,这种数据的变化是不大的,所以这种场景下是可以使用SSG的,所以,我猜测它们应该是SSR+SSG相结合的。
SSR的本质
SSR的本质:将组件在服务端直接渲染成HTML字符串,作为服务端响应返回给浏览器,最后在浏览器端将静态的 HTML"激活"(hydrate) 为能够交互的客户端应用。
上面的描述对一个新手来说,太官方,太深奥了,我们使用Vue官方的一个例子来加强对这句话的理解,大家可能一下子就明白了。
我们使用Express创建一个简单的服务器,大家可以照着我的这个demo做,新建一个package.json文件:
json
{
"name": "simple-ssr",
"type": "module",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "[email protected]",
"dependencies": {
"express": "^4.17.2",
"vue": "^3.2.26"
}
}
然后新建一个server.js:
js
import express from 'express';
import { renderToString } from 'vue/server-renderer';
import { createApp } from './app.js';
const server = express();
server.get('/', (req, res) => {
const app = createApp();
renderToString(app).then((html) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR Example</title>
<!-- 因为浏览器不直接认识Vue,所以需要配置一个vue包的映射规则 -->
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<script type="module" src="/client.js"></script>
</head>
<body>
<div id="app">${html}</div>
</body>
</html>
`);
});
});
server.use(express.static('.'));
server.listen(3000, () => {
console.log('ready');
});
再新建一个app.js:
js
import { createSSRApp } from "vue";
export function createApp() {
return createSSRApp({
data: () => ({ count: 1 }),
template: `<div @click="count++">{{ count }}</div>`,
});
}
注意哈,上面用的是createSSRApp
这个方法,而不是我们常见的createApp
方法。
另外,我们在HTML中,引用了一个文件,client.js,它的内容是这样的:
js
import { createApp } from "./app.js";
const app = createApp();
app.mount("#app");
这个准备工作做好了,浏览器中渲染的内容是一个平平无奇的内容: 我们审查一下这个页面的内容:
服务器在返回给浏览器HTML字符串时,就已经把内容生成好了。
目前来说,我们点击页面能正常响应,我们来做一些骚操作的事情,我们把页面中的这行代码拿掉:
html
<script type="module" src="/client.js"></script>
现在,我们再来看,页面初始化渲染是OK的,唯一的区别就是目前我们点击页面上的数字,界面不会有任何变更了。
好,接下来就是我们的思考时间了,如果把这行代码改成这样:
html
<div id="app"></div>
那这个是不是就是一个彻彻底底的CSR了嘛。
所以说,要能够让页面能够正常使用,我们本质上需要的CSR这个步骤是不能少的,而现在,我们在服务端额外多余的一个操作,即得到html字符串的过程,是不是有一种感觉像是对CSR的一种补充的感觉呢,所以,事情经过简化,就好理解了,SSR就是在CSR已有的基础上,首先在服务器端先执行一遍,然后得到Html字符串,进而解决了搜索引擎优化和首屏渲染的问题。
但是,我们可以看到的是,在得到这个html字符串的过程中,我们必须要依赖JS的运行时才能得到预期的字符串(即我们编写的组件,事先要在服务器上先运行一遍),所以,这就是为什么现在的SSR技术跟早期的SSR技术有很大的差别。
最后,我们再思考一个问题,客户端的代码,好像是又执行了一次,那我们来观察一下组件的生命周期是什么样的呢?
服务端输出:
客户端输出:
可以看到,服务端的生命周期只有2个,而客户端有完整的生命周期(并没有跳过
beforeCreate
和created
),那么,又有一个新的问题值得探讨了,SSR应该不止就是单纯的渲染出一个用于SEO和首屏优化的html字符串,然后客户端初始化之后又重新去渲染了一遍内容吧?客户端接管过程中又发生了什么呢,我们需要探究一下这个过程,这个过程有个专业的名词叫做水合
,具体这个过程的原理是什么,我们在下一小节再聊。
软件开发领域内,没有银弹,我们虽然在服务器端生成了用以首屏加载优化和SEO的的内容,但是这个过程是发生在服务器端的,所以这又增加了服务器的处理压力,这是大家必须要明确的一点。
水合(hydrate)
VNode和Node的区别与联系
在这个过程中,我们先研究一下VNode(即Virtual DOM
)和Node(即DOM
)。关于VNode,想必大家都很熟悉了吧,这个可是面试八股文的常考知识点。
虚拟DOM关联着真实DOM,当双向绑定的数据发生变更的时候,diff算法会检测出差量的DOM更新内容,这个过程,我们不做重点介绍。
在这儿,我主要是想带大家看一下我们在写的template
,最终被编译成了什么样子?我在这篇文章中,我就以Vue3为例了哈。
以下是源代码: 以下是编译结果:
大家有没有发现一个问题,如果我们已知了一个HTML结构,我们是能够去倒推其对应的虚拟DOM的结构,这儿说的结构就指的是代码层次结构,哪怕有
v-if
表达式,Vue渲染出来之后也会带有一些有意义的标签。
比如:
那这个事儿,是不是就感觉有解了呢?哈哈哈,当这两者的结构有确定的对应关系的时候,我们是不是可以就可以进行一个match
操作,我在渲染的时候,首先我们就正常的创建出VNode就行了,然后我根据当前接管的这块DOM结构下已有的HTML结构,把这里面的DOM跟VNode关联起来,是不是这个事儿基本上就可以大功告成了,剩下的页面更新,事件处理都是照常的逻辑嘛,完全没有压力,哈哈哈。
水合的实现
我们通过研究template
的编译和VNode和Node的对应关系,我们推测水合的过程是根据HTML结构和VNode结构做深度优先遍历,但是推测归终究只是推测,至于这个过程具体是怎么实现的,我们只能通过源码进行学习了。
在Vue的源码里面找到createSSRApp
这个方法:
关键是看一下这个
createHydrationFunctions
方法,在这之前,我们先把它的调用链搞清楚。
好了,到现在,函数的调用链理清楚了之后,我们就正式开始看一下
createHydrationFunctions
函数的实现: 接下来,从
hydrateNode
方法开始,就要进行DFS遍历了。
我们先看一下hydrateNode
这个方法的参数定义,大家就可能观察到一些猫腻了。 因为VNode的层次结构跟Node的层次结构是一样的,所以我们就可以从起始节点开始这个遍历过程。
VNode和Node进行绑定:
简单起见,我们就不看那些复杂的case了,我们就简单的
default
处理。
对于Component和元素节点有不同的处理方式:
接着,是在一个回调函数里面处理子树:
在这个
hydrateSubTree
方法里面调用了hydrateNode
,好了,到现在为止,递归调用链就已经出现了,直到所有的内容都处理完成就可以了。
所以,到目前这个位置,我们看了一下源码的实现,能够佐证之前我们得出使用VNode和Node进行DFS匹配的结论是正确。
水合也有可能出现匹配失败的场景,关于如何避免水合失败,大家可以参考Vue的官方文档: 激活不匹配
结语
在这篇文章中,我们先是向大家阐述了Web开发技术的演变过程,阐述了为什么会从最开始的SSR到CSR再到SSR的原因。
接着,我们又聊了一下SSG和SSR的区别,介绍了一下SSG的应用场景。
然后,我们用了一个简单的Demo来向大家解释了现代Web的SSR是对CSR的补充,SSR虽然解决了首屏优化和SEO的问题,但是带来的新的问题就是增加了服务器渲染资源的开销,因此,我们可以根据SSR的优缺点决定是否在自己的项目中应用这项技术。
最后,我们向大家阐述了水合的过程。水合的过程,就是Vue根据VNode和已有的DOM进行深度优先遍历,进行相互的关联的过程。至此,我们对SSR就基本上有了一个全局的认识,有了一个笼统的认知之后,其实对于我们来说,最后一块知识拼图也就补全了。
对于Nuxt框架来说的话,它肯定也是基于这样的架构的,只不过框架的实现者补充了一些内容,使得我们可以在少写代码的情况下就可以快速开发出基于SSR架构的Web应用。
如果大家对我的文章内容感兴趣,欢迎大家点赞关注,谢谢大家。