与 SSR 在服务端完成模板和数据的融合不同,CSR
是在浏览器中完成模板与数据的融合,并渲染出最终的 HTML 页面。
客户端向服务器或 CDN 发送请求,获取静态的 HTML
页面。注意,此时获取的 HTML 页面通常是空页面。在 HTML
页面中,会包含<style>、<link> 和
<script> 等标签。
虽然 HTML 页面是空的,但浏览器仍然会解析 HTML 内容。浏览器会加载
HTML 中引用的资源,服务器或 CDN 会将相应的资源返回给浏览器,浏览器对 CSS
和 JavaScript 代码进行解释和执行。因为页面的渲染任务是由 JavaScript
来完成的,所以当 JavaScript
被解释和执行后,才会渲染出页面内容,即“白屏”结束。但初始渲染出来的内容通常是一个“骨架”,因为还没有请求
API 获取数据。
客户端再通过 AJAX 技术请求 API
获取数据,一旦接口返回数据,客户端就会完成动态内容的渲染,并呈现完整的页面。
当用户再次通过点击“跳转”到其他页面时,浏览器并不会真正的进行跳转动作,即不会进行刷新,而是通过前端路由的方式动态地渲染页面,这对用户的交互体验会非常友好。但很明显的是,与
SSR 相比,CSR 会产生所谓的“白屏”问题。
同构渲染中的首次渲染与 SSR
的工作流程是一致的。当首次访问或者刷新页面时,整个页面的内容是在服务端完
成渲染的,浏览器最终得到的是渲染好的 HTML
页面。但是该页面是纯静态的,这意味着用户还不能与页面进行任何交互,因为整个应用程序的脚本还没有加载和执行。
同构渲染所产生的 HTML 页面与 SSR 所产生的 HTML
页面有一点最大的不同,即前者会包含当前页面所需要的初始化数据。直白地说,服务器通过
API 请求的数据会被序列化为字符串,并拼接到静态的 HTML
字符串中,最后一并发送给浏览器。这么做实际上是为了后续的激活操作。
假设浏览器已经接收到初次渲染的静态 HTML
页面,接下来浏览器会解析并渲染该页面。在解析过程中,浏览器会发现 HTML
代码中存在<link>和<script>标签,于是会从
CDN 或服务器获取相应的资源,这一步与 CSR 一致。当 JavaScript
资源加载完毕后,会进行激活操作,这里的激活就是我们在 Vue.js 中常说的
“hydration”。激活包含两部分工作内容。
Vue.js 在当前页面已经渲染的 DOM 元素以及 Vue.js 组件所渲染的虚拟 DOM
之间建立联系。
Vue.js 从 HTML
页面中提取由服务端序列化后发送过来的数据,用以初始化整个 Vue.js
应用程序。
functionrenderElementVNode(vnode) { // 取出标签名称和标签属性、子节点 const { type: tag , props, children } = vnode
// 开始标签的头部 let ret = `<${tag}` // 处理标签属性 if (props) { for (const key in props){ // key = "value"形式拼接 ret += `${key}=${props[key]}` } } // 闭合开始标签 ret += `>`
// 处理子节点 // 子节点类型是字符串则是文本内容,直接拼接 if (typeof children === 'string') { ret += children } elseif (Array.isArray(children)) { // 如果子节点类型是数组则递归 children.forEach(child => { ret += renderElementVNode(child) }) }
服务端渲染只需要获取组件要渲染的subTree即可,无须调用渲染器完成真实
DOM 的创建。因此,在服务端渲染时,可以忽略“设置 render effect
完成渲染”这一步。
服务端渲染初始化组件:
只需要对客户端初始化组件的逻辑稍作调整,即可实现组件在服务端的渲染。另外,由于组件在服务端渲染时,不需要渲染真实
DOM 元素,所以无须创建并执行 render
effect。这意味着,组件的beforeMount以及mounted钩子不会被触发。而且,由于服务端渲染不存在数据变更后的重新渲染逻辑,所以beforeUpdate和updated钩子也不会在服务端执行。
当组件的代码在服务端运行时,由于不会对组件进行真正的挂载操作,即不会把虚拟
DOM 渲染为真实 DOM
元素,所以组件的beforeMount与mounted这两个钩子函数不会执行。又因为服务端渲染的是应用的快照,所以不存在数据变化后的重新渲染,因此,组件的beforeUpdate与updated这两个钩子函数也不会执行。另外,在服务端渲染时,也不会发生组件被卸载的情况,所以组件的beforeUnmount与unmounted这两个钩子函数也不会执行。实际上,只有beforeCreate与created这两个钩子函数会在服务端执行,所以当你编写组件代码时需要额外注意。
由于浏览器在渲染了由服务端发送过来的 HTML
字符串之后,页面中已经存在对应的 DOM
元素了,所以组件代码在客户端运行时,不需要再次创建相应的 DOM
元素。但是,组件代码在客户端运行时,仍然需要做两件重要的事:
在页面中的 DOM 元素与虚拟节点对象之间建立联系;
为页面中的 DOM 元素添加事件绑定。
对于同构渲染,为了应用程序在后续更新过程中能够正确运行,我们需要在页面中已经存在的
DOM
对象与虚拟节点对象之间建立正确的联系。在服务端渲染的过程中,会忽略虚拟节点中与事件相关的
props。所以,当组件代码在客户端运行时,我们需要将这些事件正确地绑定到元素上。其实,这两个步骤就体现了客户端激活的含义。
由于组件的代码既运行于浏览器,又运行于服务器,所以在编写代码的时候要避免使用平台特有的
API。例如,仅在浏览器环境中才存在的window.document等对象。然而,有时你不得不使用这些平台特有的
API。这时你可以使用诸如import.meta.env.SSR这样的环境变量来做代码守卫。类似地,Node.js
中特有的 API
也无法在浏览器中运行。因此,为了减轻开发时的心智负担,我们可以选择跨平台的第三方库。例如,使用
Axios 作为网络请求库。
只在某一端引入模块
我们自己编写的组件的代码是可控的,这时我们可以使用跨平台的 API
来保证代码“同构”。然而,第三方模块的代码非常不可控。如果模块中存在非同构的代码,则仍然会发生错误。对于这个问题,有两种解决方案,方案一是使用import.meta.env.SSR等来做代码守卫,这样做虽然能解决问题,但是在大多数情况下我们无法修改第三方模块的代码。因此,更多时候我们会采用方案二来解决问题,即条件引入:
1 2 3 4 5 6 7 8 9 10
<script> let storage if (!import.meta.env.SSR) { // CSR storage = import('./storage.js') } else { // SSR storage = import('./server-storage.js') } </script>
接着,我们讨论了如何把虚拟节点渲染为字符串,以及如何将组件渲染为 HTML
字符串。在服务端渲染组件与渲染普通标签并没有本质区别。我们只需要通过执行组件的render函数,得到该组件所渲染的subTree并将其渲染为
HTML 字符串即可。
之后,我们讨论了客户端激活的原理。在同构渲染过程中,组件的代码会分别在服务端和浏览器中执行一次。在服务端,组件会被渲染为静态的
HTML 字符串,并发送给浏览器。浏览器则会渲染由服务端返回的静态的 HTML
内容,并下载打包在静态资源中的组件代码。当下载完毕后,浏览器会解释并执行该组件代码。当组件代码在客户端执行时,由于页面中已经存在对应的
DOM 元素,所以渲染器并不会执行创建 DOM
元素的逻辑,而是会执行激活操作。对于编写同构的组件代码,组件代码既运行于服务端,也运行于客户端,所以当我们编写组件代码时要额外注意。