当前位置: 首页 > news >正文

《Vuejs设计与实现》第 18 章(同构渲染)(上)

目录

18.1 CSR、SSR 以及同构渲染

18.2 将虚拟 DOM 渲染为 HTML 字符串

18.3 将组件渲染为 HTML 字符串


Vue.js 可以用于构建客户端应用程序,组件的代码在浏览器中运行,并输出 DOM 元素。
同时,Vue.js 还可以在 Node.js 环境中运行,它可以将同样的组件渲染为字符串并发送给浏览器。
这实际上描述了 Vue.js 的两种渲染方式,即客户端渲染(client-side rendering,CSR),以及服务端渲染(server-side rendering,SSR)。
另外,Vue.js 作为现代前端框架,不仅能够独立地进行 CSR 或 SSR,还能够将两者结合,形成所谓的同构渲染(isomorphicrendering)。
本章,我们将讨论 CSR、SSR 以及同构渲染之间的异同,以及 Vue.js 同构渲染的实现机制。

18.1 CSR、SSR 以及同构渲染

服务端渲染并不是一项新技术,也不是一个新概念。
在 Web 2.0 之前,网站主要负责提供各种各样的内容,通常是一些新闻站点、个人博客、小说站点等。这些站点主要强调内容本身,而不强调与用户之间具有高强度的交互。
当时的站点基本采用传统的服务端渲染技术来实现。例如,比较流行的 PHP/JSP 等技术。下面给出服务端渲染的工作流程图:

image.png

  1. 用户通过浏览器请求站点。
  2. 服务器请求 API 获取数据。
  3. 接口返回数据给服务器。
  4. 服务器根据模板和获取的数据拼接出最终的 HTML 字符串。
  5. 服务器将 HTML 字符串发送给浏览器,浏览器解析 HTML 内容并渲染。

当用户再次通过超链接进行页面跳转,会重复上述 5 个步骤。
传统的服务端渲染的用户体验非常差,任何一个微小的操作都可能导致页面刷新。
后来以 AJAX 为代表,催生了 Web 2.0。在这个阶段,大量的 SPA(single-page application)诞生,也就是接下来我们要介绍的 CSR 技术。
与 SSR 在服务端完成模板和数据的融合不同,CSR 是在浏览器中完成模板与数据的融合,并渲染出最终的 HTML 页面。CSR 工作流程图:
 

image.png


客户端向服务器或 CDN 发送请求,获取静态的 HTML 页面。
注意,此时获取的 HTML 页面通常是空页面。在 HTML 页面中,会包含 <style><link>和 <script> 等标签。例如:

<!DOCTYPE html>
<html lang="zh"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>My App</title><link rel="stylesheet" href="/dist/app.css" /></head><body><div id="app"></div><script src="/dist/app.js"></script></body>
</html>

是一个包含 <link rel="stylesheet"> 与 <script> 标签的空 HTML 页面。
浏览器在得到该页面后,不会渲染出任何内容,所以从用户的视角看,此时页面处于“白屏”阶段。
解析 HTML 内容。通过 <link rel="stylesheet"> 和 <script> 等标签加载引用的资源。
因为页面的渲染任务是由 JavaScript 来完成的,所以当 JavaScript 被解释和执行后,才会渲染出页面内容,即“白屏”结束。
但初始渲染出来的内容通常是一个“骨架”,因为还没有请求 API 获取数据。
客户端再通过 AJAX 技术请求 API 获取数据,一旦接口返回数据,客户端就会完成动态内容的渲染,并呈现完整的页面。
当用户再次通过点击“跳转”到其他页面时,浏览器并不会真正的进行跳转动作,即不会进行刷新,而是通过前端路由的方式动态地渲染页面,这对用户的交互体验会非常友好。
但很明显的是,与 SSR 相比,CSR 会产生所谓的“白屏”问题。并且它对 SEO(搜索引擎优化)也不友好。
下图从多个方面比较了 SSR 与 CSR:
 

image.png


可以看到,无论是 SSR 还是 CSR,都不可以作为“银弹”,我们需要从项目的实际需求出发,决定到底采用哪一个。例如你的项目非常需要 SEO,那么就应该采用 SSR。
那么,我们能否融合 SSR 与 CSR 两者的优点于一身呢?答案是“可以的”,这就是接下来我们要讨论的同构渲染。
同构渲染分为首次渲染(即首次访问或刷新页面)以及非首次渲染。下图是同构渲染首次渲染的工作流程:
 

image.png


实际上,同构渲染中的首次渲染与 SSR 的工作流程是一致的。
当首次访问或者刷新页面时,整个页面的内容是在服务端完成渲染的,浏览器最终得到的是渲染好的 HTML 页面。
但是该页面是纯静态的,这意味着用户还不能与页面进行任何交互,因为整个应用程序的脚本还没有加载和执行。
另外,该静态的 HTML 页面中也会包含 <link><script> 等标签。
同构渲染所产生的 HTML 页面会包含当前页面所需要的初始化数据。而 SSR 不会。
服务器通过 API 请求的数据会被序列化为字符串,并拼接到静态的 HTML 字符串中,最后一并发送给浏览器。这么做实际上是为了后续的激活操作,后文讲解。
假设浏览器已经接收到初次渲染的静态 HTML 页面,接下来浏览器会解析并渲染该页面。
在解析过程中,浏览器会发现 HTML 代码中存在 <link> 和 <script> 标签,于是会从 CDN 或服务器获取相应的资源,这一步与 CSR 一致。
当 JavaScript 资源加载完毕后,会进行激活操作,这里的激活就是我们在 Vue.js 中常说的 “hydration”。激活包含两部分工作内容。

  • Vue.js 在当前页面已经渲染的 DOM 元素以及 Vue.js 组件所渲染的虚拟 DOM 之间建立联系。
  • Vue.js 从 HTML 页面中提取由服务端序列化后发送过来的数据,用以初始化整个 Vue.js 应用程序。

激活完成后,整个应用程序已经完全被 Vue.js 接管为 CSR 应用程序了。
后续操作都会按照 CSR 应用程序的流程来执行。当然,如果刷新页面,仍然会进行服务端渲染,然后再进行激活,如此往复。
下图对比了 SSR、CSR 和同构渲染的优劣:
 

image.png


可以看到,同构渲染除了也需要部分服务端资源外,其他方面的表现都非常棒。
由于同构渲染方案在首次渲染时和浏览器刷新时仍然需要服务端完成渲染工作,所以也需要部分服务端资源。
但相比所有页面跳转都需要服务端完成渲染来说,同构渲染所占用的服务端资源相对少一些。
注意理论上同构渲染无法提升可交互时间(TTI)。还是需要像 CSR 那样等待 JavaScript 资源加载完成,并且客户端激活完成后,才能响应用户操作。
同构渲染的“同构”一词的含义是,同样一套代码既可以在服务端运行,也可以在客户端运行。
例如,我们用 Vue.js 编写一个组件,该组件既可以在服务端运行,被渲染为 HTML 字符串;也可以在客户端运行,就像普通的 CSR 应用程序一样。

18.2 将虚拟 DOM 渲染为 HTML 字符串

既然“同构”指的是,同样的代码既能在服务端运行,也能在客户端运行,我们来说说如何在服务端将虚拟 DOM 渲染为 HTML 字符串。
给出如下虚拟节点对象,它用来描述一个普通的 div 标签:

const ElementVNode = {type: 'div',props: {id: 'foo',},children: [{ type: 'p', children: 'hello' }],
}

为了将虚拟节点 ElementVNode 渲染为字符串,我们需要实现 renderElementVNode 函数。
该函数接收用来描述普通标签的虚拟节点作为参数,并返回渲染后的 HTML 字符串:

function renderElementVNode(vnode) {// 返回渲染后的结果,即 HTML 字符串
}

在不考虑任何边界条件的情况下,实现 renderElementVNode 非常简单,如下所示:

function renderElementVNode(vnode) {// 取出标签名称 tag 和标签属性 props,以及标签的子节点const { type: tag, props, children } = vnode// 开始标签的头部let ret = `<${tag}`// 处理标签属性if (props) {for (const k in props) {// 以 key="value" 的形式拼接字符串ret += ` ${k}="${props[k]}"`}}// 开始标签的闭合ret += `>`// 处理子节点// 如果子节点的类型是字符串,则是文本内容,直接拼接if (typeof children === 'string') {ret += children} else if (Array.isArray(children)) {// 如果子节点的类型是数组,则递归地调用 renderElementVNode 完成渲染children.forEach(child => {ret += renderElementVNode(child)})}// 结束标签ret += `</${tag}>`// 返回拼接好的 HTML 字符串return ret
}

接着,我们可以调用 renderElementVNode 函数完成对 ElementVNode 的渲染:

console.log(renderElementVNode(ElementVNode)) // <div id="foo"><p>hello</p></div>

可以看到,输出结果是我们所期望的 HTML 字符串。实际上,将一个普通标签类型的虚拟节点渲染为 HTML 字符串,本质上是字符串的拼接。
不过,上面给出的 renderElementVNode 函数的实现仅仅用来展示将虚拟 DOM 渲染为 HTML 字符串的核心原理,并不满足生产要求,因为它存在以下几点缺陷:

  • renderElementVNode 函数在渲染标签类型的虚拟节点时,还需要考虑该节点是否是自闭合标签。
  • 对于属性(props)的处理会比较复杂,要考虑属性名称是否合法,还要对属性值进行 HTML 转义。
  • 子节点的类型多种多样,可能是任意类型的虚拟节点,如 Fragment、组件、函数式组件、文本等,这些都需要处理。
  • 标签的文本子节点也需要进行 HTML 转义。

上述这些问题都属于边界条件,接下来我们逐个处理。首先处理自闭合标签,它的术语叫作 void element,它的完整列表如下:

const VOID_TAGS = 'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'

对于 void element,由于它无须闭合标签,所以在为此类标签生成 HTML 字符串时,无须为其生成对应的闭合标签,如下面的代码所示:

const VOID_TAGS = 'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'.split(',')function renderElementVNode2(vnode) {const { type: tag, props, children } = vnode// 判断是否是 void elementconst isVoidElement = VOID_TAGS.includes(tag)let ret = `<${tag}`if (props) {for (const k in props) {ret += ` ${k}="${props[k]}"`}}// 如果是 void element,则自闭合ret += isVoidElement ? `/>` : `>`// 如果是 void element,则直接返回结果,无须处理 children,因为 void element 没有 childrenif (isVoidElement) return retif (typeof children === 'string') {ret += children} else {children.forEach(child => {ret += renderElementVNode2(child)})}ret += `</${tag}>`return ret
}
 

接下来,我们需要更严谨地处理 HTML 属性。处理属性需要考虑多个方面,首先是对 boolean attribute 的处理。
所谓 boolean attribute,并不是说这类属性的值是布尔类型,而是指,如果这类指令存在,则代表 true,否则代表 false。
例如 <input/> 标签的 checked 属性和 disabled 属性:

<!-- 选中的 checkbox -->
<input type="checkbox" checked />
<!-- 未选中的 checkbox -->
<input type="checkbox" />

当渲染 boolean attribute 时,通常无须渲染它的属性值。
另外一点需要考虑的是安全问题,WHATWG 规范的 13.1.2.3 节中明确定义了属性名称的组成。
属性名称必须由一个或多个非以下字符组成。

  • 控制字符集(control character)的码点范围是:[0x01, 0x1f] 和 [0x7f,0x9f]。
  • U+0020 (SPACE)、U+0022 (")、U+0027 (')、U+003E (>)、U+002F (/)以及 U+003D (=)。
  • noncharacters,这里的 noncharacters 代表 Unicode 永久保留的码点,这些码点在 Unicode 内部使用,它的取值范围是:[0xFDD0, 0xFDEF],还包括:0xFFFE、0xFFFF、0x1FFFE、0x1FFFF、0x2FFFE、0x2FFFF、0x3FFFE、0x3FFFF、0x4FFFE、0x4FFFF、0x5FFFE、0x5FFFF、0x6FFFE、0x6FFFF、0x7FFFE、0x7FFFF、0x8FFFE、0x8FFFF、0x9FFFE、0x9FFFF、0xAFFFE、0xAFFFF、0xBFFFE、0xBFFFF、0xCFFFE、0xCFFFF、0xDFFFE、0xDFFFF、0xEFFFE、0xEFFFF、0xFFFFE、0xFFFFF、0x10FFFE、0x10FFFF。

考虑到 Vue.js 的模板编译器在编译过程中已经对 noncharacters 以及控制字符集进行了处理,所以我们只需要小范围处理即可,任何不满足上述条件的属性名称都是不安全且不合法的。
另外,在虚拟节点中的 props 对象中,通常会包含仅用于组件运行时逻辑的相关属性。
例如,key 属性仅用于虚拟 DOM 的 Diff 算法,ref 属性仅用于实现 template ref 的功能等。在进行服务端渲染时,应该忽略这些属性。
除此之外,服务端渲染也无须考虑事件绑定。因此,也应该忽略 props 对象中的事件处理函数。
更加严谨的属性处理方案如下:

function renderElementVNode(vnode) {const { type: tag, props, children } = vnodeconst isVoidElement = VOID_TAGS.includes(tag)let ret = `<${tag}`if (props) {// 调用 renderAttrs 函数进行严谨处理ret += renderAttrs(props)}ret += isVoidElement ? `/>` : `>`if (isVoidElement) return retif (typeof children === 'string') {ret += children} else {children.forEach(child => {ret += renderElementVNode(child)})}ret += `</${tag}>`return ret
}

对应 renderAttrs 函数对 props 处理,具体实现如下:

// 应该忽略的属性
const shouldIgnoreProp = ['key', 'ref']function renderAttrs(props) {let ret = ''for (const key in props) {if (// 检测属性名称,如果是事件或应该被忽略的属性,则忽略它shouldIgnoreProp.includes(key) ||/^on[^a-z]/.test(key)) {continue}const value = props[key]// 调用 renderDynamicAttr 完成属性的渲染ret += renderDynamicAttr(key, value)}return ret
}

renderDynamicAttr 函数的实现如下:、

// 用来判断属性是否是 boolean attribute
const isBooleanAttr = key =>(`itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly` +`,async,autofocus,autoplay,controls,default,defer,disabled,hidden,` +`loop,open,required,reversed,scoped,seamless,` +`checked,muted,multiple,selected`).split(',').includes(key)// 用来判断属性名称是否合法且安全
const isSSRSafeAttrName = key => !/[>/="'\u0009\u000a\u000c\u0020]/.test(key)function renderDynamicAttr(key, value) {if (isBooleanAttr(key)) {// 对于 boolean attribute,如果值为 false,则什么都不需要渲染,否则只需要渲染 key 即可return value === false ? `` : ` ${key}`} else if (isSSRSafeAttrName(key)) {// 对于其他安全的属性,执行完整的渲染,// 注意:对于属性值,我们需要对它执行 HTML 转义操作return value === '' ? ` ${key}` : ` ${key}="${escapeHtml(value)}"`} else {// 跳过不安全的属性,并打印警告信息console.warn(`[@vue/server-renderer] Skipped rendering unsafe attribute name: ${key}`)return ``}
}

这样我们就实现了对普通元素类型的虚拟节点的渲染。
实际上,在 Vue.js中,由于 class 和 style 这两个属性可以使用多种合法的数据结构来表示,例如 class 的值可以是字符串、对象、数组,所以理论上我们还需要考虑这些情况。
不过原理都是相通的,对于使用不同数据结构表示的 class 或 style,我们只需要将不同类型的数据结构序列化成字符串表示即可。
另外,观察上面代码中的 renderDynamicAttr 函数的实现能够发现,在处理属性值时,我们调用了 escapeHtml 对其进行转义处理,这对于防御 XSS 攻击至关重要。HTML 转义指的是将特殊字符转换为对应的 HTML 实体。其转换规则很简单。

  • 如果该字符串作为普通内容被拼接,则应该对以下字符进行转义。
    • 将字符 & 转义为实体 &。
    • 将字符 < 转义为实体 <。
    • 将字符 > 转义为实体 >。
  • 如果该字符串作为属性值被拼接,那么除了上述三个字符应该被转义之外,还应该转义下面两个字符。
    • 将字符 " 转义为实体 "。
    • 将字符 ' 转义为实体 '。

具体实现如下:

const escapeRE = /["'&<>]/
function escapeHtml(string) {const str = '' + stringconst match = escapeRE.exec(str)if (!match) {return str}let html = ''let escapedlet indexlet lastIndex = 0for (index = match.index; index < str.length; index++) {switch (str.charCodeAt(index)) {case 34: // "escaped = '&quot;'breakcase 38: // &escaped = '&amp;'breakcase 39: // 'escaped = '&#39;'breakcase 60: // <escaped = '&lt;'breakcase 62: // >escaped = '&gt;'breakdefault:continue}if (lastIndex !== index) {html += str.substring(lastIndex, index)}lastIndex = index + 1html += escaped}return lastIndex !== index ? html + str.substring(lastIndex, index) : html
}

原理很简单,只需要在给定字符串中查找需要转义的字符,然后将其替换为对应的 HTML 实体即可。

18.3 将组件渲染为 HTML 字符串

在上节,我们讨论了如何将普通标签类型的虚拟节点渲染为 HTML 字符串。
本节,我们将在此基础上,讨论如何将组件类型的虚拟节点渲染为 HTML 字符串。
假设我们有如下组件,以及用来描述组件的虚拟节点:

// 组件
const MyComponent = {setup() {return () => {// 该组件渲染一个 div 标签return {type: 'div',children: 'hello',}}},
}// 用来描述组件的 VNode 对象
const CompVNode = {type: MyComponent,
}

我们将实现 renderComponentVNode 函数,并用它把组件类型的虚拟节点渲染为 HTML 字符串:

  • subTree 本身可能是任意类型的虚拟节点,包括组件类型。因此,我们不能直接使用 renderElementVNode 来渲染它。
  • 执行 setup 函数时,也应该提供 setupContext 对象。而执行渲染函数 render 时,也应该将其 this 指向 renderContext 对象。实际上,在组件的初始化和渲染方面,其完整流程与第 13 章讲解的客户端的渲染流程一致。例如,也需要初始化 data,也需要得到 setup 函数的执行结果,并检查 setup 函数的返回值是函数还是 setupState 等。

对于第一个问题,我们可以通过封装通用函数来解决,如下所示:

function renderVNode(vnode) {const type = typeof vnode.typeif (type === 'string') {return renderElementVNode(vnode)} else if (type === 'object' || type === 'function') {return renderComponentVNode(vnode)} else if (vnode.type === Text) {// 处理文本...} else if (vnode.type === Fragment) {// 处理片段...} else {// 其他 VNode 类型}
}

有了 renderVNode 后,我们就可以在 renderComponentVNode 中使用它来渲染 subTree 了:

function renderComponentVNode(vnode) {let {type: { setup },} = vnodeconst render = setup()const subTree = render()// 使用 renderVNode 完成对 subTree 的渲染return renderVNode(subTree)
}

第二个问题则涉及组件的初始化流程。我们先回顾一下组件在客户端渲染时的整体流程:
 

image.png


在进行服务端渲染时,组件的初始化流程与客户端渲染时组件的初始化流程基本一致,但有两个重要的区别:

  • 服务端渲染的是应用的当前快照,它不存在数据变更后重新渲染的情况。因此,所有数据在服务端都无须是响应式的。利用这一点,我们可以减少服务端渲染过程中创建响应式数据对象的开销。
  • 服务端渲染只需要获取组件要渲染的 subTree 即可,无须调用渲染器完成真实 DOM 的创建。因此,在服务端渲染时,可以忽略“设置 render effect 完成渲染”这一步。

下图给出了服务端渲染时初始化组件的流程:
 

image.png


可以看到,只需要对客户端初始化组件的逻辑稍作调整,即可实现组件在服务端的渲染。
另外,由于组件在服务端渲染时,不需要渲染真实 DOM 元素,所以无须创建并执行 render effect。
这意味着,组件的 beforeMount 以及 mounted 钩子不会被触发。
而且,由于服务端渲染不存在数据变更后的重新渲染逻辑,所以 beforeUpdate 和 updated 钩子也不会在服务端执行。完整的实现如下:

function renderComponentVNode(vnode) {const isFunctional = typeof vnode.type === 'function'let componentOptions = vnode.typeif (isFunctional) {componentOptions = {render: vnode.type,props: vnode.type.props,}}let { render, data, setup, beforeCreate, created, props: propsOption } = componentOptionsbeforeCreate && beforeCreate()// 无须使用 reactive() 创建 data 的响应式版本const state = data ? data() : nullconst [props, attrs] = resolveProps(propsOption, vnode.props)const slots = vnode.children || {}const instance = {state,props, // props 无须 shallowReactiveisMounted: false,subTree: null,slots,mounted: [],keepAliveCtx: null,}function emit(event, ...payload) {const eventName = `on${event[0].toUpperCase() + event.slice(1)}`const handler = instance.props[eventName]if (handler) {handler(...payload)} else {console.error('事件不存在')}}// setuplet setupState = nullif (setup) {const setupContext = { attrs, emit, slots }const prevInstance = setCurrentInstance(instance)const setupResult = setup(shallowReadonly(instance.props), setupContext)setCurrentInstance(prevInstance)if (typeof setupResult === 'function') {if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略')render = setupResult} else {setupState = setupContext}}vnode.component = instanceconst renderContext = new Proxy(instance, {get(t, k, r) {const { state, props, slots } = tif (k === '$slots') return slotsif (state && k in state) {return state[k]} else if (k in props) {return props[k]} else if (setupState && k in setupState) {return setupState[k]} else {console.error('不存在')}},set(t, k, v, r) {const { state, props } = tif (state && k in state) {state[k] = v} else if (k in props) {props[k] = v} else if (setupState && k in setupState) {setupState[k] = v} else {console.error('不存在')}},})created && created.call(renderContext)const subTree = render.call(renderContext, renderContext)return renderVNode(subTree)
}
 

观察上面的代码可以发现,该实现与客户端渲染的逻辑基本一致。
这段代码与第 13 章给出的关于组件渲染的代码也非常相似。
唯一的区别在于,在服务端渲染时,无须使用 reactive 函数为 data 数据创建响应式版本,并且 props 数据也无须是浅响应的。

http://www.dtcms.com/a/452909.html

相关文章:

  • 【前端基础】20、CSS属性——transform、translate、transition
  • ChartStudio: New Chart Types Added for Enhanced Data Visualization
  • 测试用例设计万能公式:功能到安全
  • 做招投标应该了解的网站广州产品网站设计
  • Apache StreamPark 快速上手从一键安装到跑起第一个 Flink SQL 任务
  • 珠海市手机网站建设公司广州头条新闻最新
  • 多模卫星导航定位与应用-原理与实践(RTKLib)5
  • 【数据结构】汉诺塔问题
  • AI - 自然语言处理(NLP) - part 2 - 词向量
  • 焦作做网站最专业的公司滨海新区做网站电话
  • 【JavaScript Proxy 与 Reflect 指南】
  • 【软件开发】管理类系统
  • 使用Unity引擎开发Rokid主机应用的全面配置交互操作
  • web服务器有哪些?服务器和web服务器有什么区别
  • 大数据Spark(六十七):Transformation转换算子distinct和mapValues
  • 【寰宇光锥舟】
  • 计算机视觉(opencv)——嘴部表情检测
  • 唤醒手腕2025年最新机器学习K近邻算法详细教程
  • 广州化妆品网站建设公司排名北京网站建设91086
  • 【纯AI观点】用于协作内容创建和知识管理的MediaWiki
  • 贵州省网站建设网站打开时的客户引导页
  • C++新标准——decltype 关键字
  • Java中通过.xml文件管理测试用例类
  • 清空全网题目系列 · 洛谷 · P1054 [NOIP 2005 提高组] 等价表达式
  • 偏振光阴影投影的三元光学逻辑处理器
  • GitLab 安装指南
  • 磁共振成像原理(理论)20:K空间采样 (Sampling of k-Space) - 采样定理
  • 安装wslgui
  • 激光+摄像头:打造高精度视觉测量系统
  • ie的常用网站渭南市建设局网站