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

目录

[18.4 客户端激活的原理](#18.4 客户端激活的原理)

[18.5 编写同构的代码](#18.5 编写同构的代码)

[18.5.1 组件的生命周期](#18.5.1 组件的生命周期)

[18.5.2 使用跨平台的 API](#18.5.2 使用跨平台的 API)

[18.5.3 只在某一端引入模块](#18.5.3 只在某一端引入模块)

[18.5.4 避免交叉请求引起的状态污染](#18.5.4 避免交叉请求引起的状态污染)

[18.5.5 ClientOnly 组件](#18.5.5 ClientOnly 组件)

[18.6 总结](#18.6 总结)


18.4 客户端激活的原理

什么是客户端激活呢?我们知道,对于同构渲染来说,组件的代码会在服务端和客户端分别执行一次。

在服务端,组件会被渲染为静态的 HTML 字符串,然后发送给浏览器,浏览器再把这段纯静态的 HTML 渲染出来。

此时页面中已经存在对应的 DOM 元素。同时,该组件还会被打包到一个 JavaScript 文件中,最终在浏览器解释并执行。

这时问题来了,当组件的代码在客户端执行时,会再次创建 DOM 元素吗?答案是"不会"。

由于浏览器在渲染了由服务端发送过来的 HTML 字符串之后,页面中已经存在对应的 DOM 元素了,所以组件代码在客户端运行时,不需要再次创建相应的 DOM 元素。

但是,组件代码在客户端运行时,仍然需要做两件重要的事:

  • 在页面中的 DOM 元素与虚拟节点对象之间建立联系。
  • 为页面中的 DOM 元素添加事件绑定。

我们知道,一个虚拟节点被挂载之后,为了保证更新程序能正确运行,需要通过该虚拟节点的 vnode.el 属性存储对真实 DOM 对象的引用。

而同构渲染也是一样,为了应用程序在后续更新过程中能够正确运行,我们需要在页面中已经存在的 DOM 对象与虚拟节点对象之间建立正确的联系。

另外,在服务端渲染的过程中,会忽略虚拟节点中与事件相关的 props。所以,当组件代码在客户端运行时,我们需要将这些事件正确地绑定到元素上。

这两个步骤就体现了客户端激活的含义。

我们来看下客户端激活的具体实现。当组件进行纯客户端渲染时,我们通过渲染器的 renderer.render 函数来完成渲染,例如:

复制代码
renderer.render(vnode, container)
复制代码

而对于同构应用,我们将使用独立的 renderer.hydrate 函数来完成激活:

复制代码
renderer.hydrate(vnode, container)

实际上,我们可以用代码模拟从服务端渲染到客户端激活的整个过程,如下所示:

复制代码
// html 代表由服务端渲染的字符串
const html = renderComponentVNode(compVNode)

// 假设客户端已经拿到了由服务端渲染的字符串
// 获取挂载点
const container = document.querySelector('#app')
// 设置挂载点的 innerHTML,模拟由服务端渲染的内容
container.innerHTML = html

// 接着调用 hydrate 函数完成激活
renderer.hydrate(compVNode, container)

其中 CompVNode 的代码如下:

复制代码
const MyComponent = {
  name: 'App',
  setup() {
    const str = ref('foo')

    return () => {
      return {
        type: 'div',
        children: [
          {
            type: 'span',
            children: str.value,
            props: {
              onClick: () => {
                str.value = 'bar'
              }
            }
          },
          { type: 'span', children: 'baz' }
        ]
      }
    }
  }
}

const CompVNode = {
  type: MyComponent
}

接下来,我们着手实现 renderer.hydrate 函数。

与 renderer.render 函数一样,renderer.hydrate 函数也是渲染器的一部分,因此它也会作为 createRenderer 函数的返回值,如下面的代码所示:

复制代码
function createRenderer(options) {
  function hydrate(node, vnode) {
    // ...
  }

  return {
    render,
    // 作为 createRenderer 函数的返回值
    hydrate
  }
}

这样,我们就可以通过 renderer.hydrate 函数来完成客户端激活了。

在具体实现其函数之前,我们先来看一下页面中已经存在的真实 DOM 元素与虚拟 DOM 对象之间的关系,如下图:

可以看到,真实 DOM 元素与虚拟 DOM 对象都是树型结构,并且节点之间存在一一对应的关系。

因此,我们可以认为它们是"同构"的。而激活的原理就是基于这一事实,递归地在真实 DOM 元素与虚拟 DOM 节点之间建立关系。

另外,在虚拟 DOM 中并不存在与容器元素(挂载点)对应的节点。

因此,在激活的时候,应该从容器元素的第一个子节点开始,如下面的代码所示:

复制代码
function hydrate(vnode, container) {
  // 从容器元素的第一个子节点开始
  hydrateNode(container.firstChild, vnode)
}

其中,hydrateNode 函数接收两个参数,分别是真实 DOM 元素和虚拟 DOM 元素。hydrateNode 函数的具体实现如下

复制代码
function hydrateNode(node, vnode) {
  const { type } = vnode
  // 1. 让 vnode.el 引用真实 DOM
  vnode.el = node

  // 2. 检查虚拟 DOM 的类型,如果是组件,则调用 mountComponent 函数完成激活
  if (typeof type === 'object') {
    mountComponent(vnode, container, null)
  } else if (typeof type === 'string') {
    // 3. 检查真实 DOM 的类型与虚拟 DOM 的类型是否匹配
    if (node.nodeType !== 1) {
      console.error('mismatch')
      console.error('服务端渲染的真实 DOM 节点是:', node)
      console.error('客户端渲染的虚拟 DOM 节点是:', vnode)
    } else {
      // 4. 如果是普通元素,则调用 hydrateElement 完成激活
      hydrateElement(node, vnode)
    }
  }

  // 5. 重要:hydrateNode 函数需要返回当前节点的下一个兄弟节点,以便继续进行后续的激活操作
  return node.nextSibling
}

上述代码关键:首先,要在真实 DOM 元素与虚拟 DOM 元素之间建立联系,即 vnode.el = node。这样才能保证后续更新操作正常进行。

其次,我们需要检测虚拟 DOM 的类型,并据此判断应该执行怎样的激活操作。

在上面的代码中,我们展示了对组件和普通元素类型的虚拟节点的处理。

可以看到,在激活普通元素类型的节点时,我们检查真实 DOM 元素的类型与虚拟 DOM 的类型是否相同,如果不同,则需要打印 mismatch 错误,即客户端渲染的节点与服务端渲染的节点不匹配。

同时,为了能够让用户快速定位问题节点,保证开发体验,我们最好将客户端渲染的虚拟节点与服务端渲染的真实 DOM 节点都打印出来,供用户参考。

对于组件类型节点的激活操作,则可以直接通过 mountComponent 函数来完成。

对于普通元素的激活操作,则可以通过 hydrateElement 函数来完成。

最后,hydrateNode 函数需要返回当前激活节点的下一个兄弟节点,以便进行后续的激活操作。

hydrateNode 函数的返回值非常重要,它的用途体现在hydrateElement 函数内,如下所示:

复制代码
// 用来激活普通元素类型的节点
function hydrateElement(el, vnode) {
  // 1. 为 DOM 元素添加事件
  if (vnode.props) {
    for (const key in vnode.props) {
      // 只有事件类型的 props 需要处理
      if (/^on/.test(key)) {
        patchProps(el, key, null, vnode.props[key])
      }
    }
  }
  // 递归地激活子节点
  if (Array.isArray(vnode.children)) {
    // 从第一个子节点开始
    let nextNode = el.firstChild
    const len = vnode.children.length
    for (let i = 0; i < len; i++) {
      // 激活子节点,注意,每当激活一个子节点,hydrateNode 函数都会返回当前子节点的下一个兄弟节点,
      // 于是可以进行后续的激活了
      nextNode = hydrateNode(nextNode, vnode.children[i])
    }
  }
}

hydrateElement 函数有两个关键点:

  • 因为服务端渲染是忽略事件的,浏览器只是渲染了静态的 HTML 而已,所以激活 DOM 元素的操作之一就是为其添加事件处理程序。
  • 递归地激活当前元素的子节点,从第一个子节点 el.firstChild 开始,递归地调用 hydrateNode 函数完成激活。注意这里的小技巧,hydrateNode 函数会返回当前节点的下一个兄弟节点,利用这个特点即可完成所有子节点的处理。

对于组件的激活,我们还需要针对性地处理 mountComponent 函数。

由于服务端渲染的页面中已经存在真实 DOM 元素,所以当调用 mountComponent 函数进行组件的挂载时,无须再次创建真实 DOM 元素。

基于此,我们需要对mountComponent 函数做一些调整,如下所示:

复制代码
function mountComponent(vnode, container, anchor) {
  // 省略部分代码

  instance.update = effect(
    () => {
      const subTree = render.call(renderContext, renderContext)
      if (!instance.isMounted) {
        beforeMount && beforeMount.call(renderContext)
        // 如果 vnode.el 存在,则意味着要执行激活
        if (vnode.el) {
          // 直接调用 hydrateNode 完成激活
          hydrateNode(vnode.el, subTree)
        } else {
          // 正常挂载
          patch(null, subTree, container, anchor)
        }
        instance.isMounted = true
        mounted && mounted.call(renderContext)
        instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
      } else {
        beforeUpdate && beforeUpdate.call(renderContext)
        patch(instance.subTree, subTree, container, anchor)
        updated && updated.call(renderContext)
      }
      instance.subTree = subTree
    },
    {
      scheduler: queueJob
    }
  )
}

可以看到,hydrateNode 函数所做的第一件事是什么吗?是在真实 DOM 与虚拟 DOM 之间建立联系,即 vnode.el = node。

所以,当渲染副作用执行挂载操作时,我们优先检查虚拟节点的 vnode.el 属性是否已经存在,如果存在,则意味着无须进行全新的挂载,只需要进行激活操作即可,否则仍然按照之前的逻辑进行全新的挂载。

最后一个关键点是,组件的激活操作需要在真实 DOM 与 subTree 之间进行。

18.5 编写同构的代码

"同构"一词指的是一份代码既在服务端运行,又在客户端运行。因此,在编写组件代码时,应该额外注意因代码运行环境的不同所导致的差异。

18.5.1 组件的生命周期

我们知道,当组件的代码在服务端运行时,由于不会对组件进行真正的挂载操作,即不会把虚拟 DOM 渲染为真实 DOM 元素,所以组件的 beforeMount 与mounted 这两个钩子函数不会执行。

又因为服务端渲染的是应用的快照,所以不存在数据变化后的重新渲染,因此,组件的 beforeUpdate 与 updated 这两个钩子函数也不会执行。

另外,在服务端渲染时,也不会发生组件被卸载的情况,所以组件的 beforeUnmount 与 unmounted 这两个钩子函数也不会执行。

实际上,只有 beforeCreate 与 created 这两个钩子函数会在服务端执行,所以当你编写组件代码时需要额外注意。如下是一段常见的问题代码:

复制代码
<script>
  export default {
    created() {
      this.timer = setInterval(() => {
        // 做一些事情
      }, 1000)
    },
    beforeUnmount() {
      // 清除定时器
      clearInterval(this.timer)
    }
  }
</script>

观察上面这段组件代码,我们在 created 钩子函数中设置了一个定时器,并尝试在组件被卸载之前将其清除,即在 beforeUnmount 钩子函数执行时将其清除。

如果在客户端运行这段代码,并不会产生任何问题;但如果在服务端运行,则会造成内存泄漏。因为 beforeUnmount 钩子函数不会在服务端运行,所以这个定时器将永远不会被清除。

实际上,在 created 钩子函数中设置定时器对于服务端渲染没有任何意义。

这是因为服务端渲染的是应用程序的快照,所谓快照,指的是在当前数据状态下页面应该呈现的内容。

所以,在定时器到时,修改数据状态之前,应用程序的快照已经渲染完毕了。

所以我们说,在服务端渲染时,定时器内的代码没有任何意义。遇到这类问题时,我们通常有两个解决方案:

  • 方案一:将创建定时器的代码移动到 mounted 钩子中,即只在客户端执行定时器。
  • 方案二:使用环境变量包裹这段代码,让其不在服务端运行。

方案二依赖项目的环境变量。例如,在通过 webpack 或 Vite 等构建工具搭建的同构项目中,通常带有这种环境变量。

以Vite 为例,我们可以使用 import.meta.env.SSR 来判断当前代码的运行环境:

复制代码
<script>
  export default {
    created() {
      // 只在非服务端渲染时执行,即只在客户端执行
      if (!import.meta.env.SSR) {
        this.timer = setInterval(() => {
          // 做一些事情
        }, 1000)
      }
    },
    beforeUnmount() {
      clearInterval(this.timer)
    }
  }
</script>

可以看到,我们通过 import.meta.env.SSR 来使代码只在 SSR 环境运行。

实际上,构建工具会分别为客户端和服务端输出两个独立的包。

构建工具在为客户端打包资源的时候,会在资源中排除被 import.meta.env.SSR 包裹的代码。上面的代码中被 !import.meta.env.SSR 包裹的代码只会在客户端包中存在。

18.5.2 使用跨平台的 API

编写同构代码的另一个关键点是使用跨平台的 API。

由于组件的代码既运行于浏览器,又运行于服务器,所以在编写代码的时候要避免使用平台特有的API。

例如,仅在浏览器环境中才存在的 window、document 等对象。

然而,有时不得不使用这些平台特有的 API。这时可以使用诸如 import.meta.env.SSR 这样的环境变量来做代码守卫:

复制代码
<script>
	if (!import.meta.env.SSR) {
	  // 使用浏览器平台特有的 API
	  window.xxx
	}

	export default {
	  // ...
	}
</script>

类似地,Node.js 中特有的 API 也无法在浏览器中运行。

因此,为了减轻开发时的心智负担,我们可以选择跨平台的第三方库。例如,使用 Axios 作为网络请求库。

18.5.3 只在某一端引入模块

通常情况下,我们自己编写的组件的代码是可控的,这时我们可以使用跨平台的 API 来保证代码"同构"。

然而,第三方模块的代码非常不可控。假设我们有如下组件:

复制代码
<script>
	import storage from './storage.js'
	export default {
		// ...
	}
</script>

上面这段组件代码本身没有任何问题,但它依赖了 ./storage.js 模块。

如果该模块中存在非同构的代码,则仍然会发生错误。假设 ./storage.js 模块的代码如下:

复制代码
// storage.js
export const storage = window.localStorage

可以看到,./storage.js 模块中依赖了浏览器环境下特有的 API,即window.localStorage。因此,当进行服务端渲染时会发生错误。

对于这个问题,有两种解决方案:

方案一是使用 import.meta.env.SSR 来做代码守卫:

复制代码
// storage.js
export const storage = !import.meta.env.SSR ? window.localStorage : {}

这样做虽然能解决问题,但是在大多数情况下我们无法修改第三方模块的代码。

因此,更多时候我们会采用接下来介绍的方案二来解决问题,即条件引入:

复制代码
<script>
  let storage
  // 只有在非 SSR 下才引入 ./storage.js 模块
  if (!import.meta.env.SSR) {
    storage = import('./storage.js')
  }
  export default {
    // ...
  }
</script>

上面这段代码是修改后的组件代码。可以看到,我们通过 import.meta.env.SSR 做了代码守卫,实现了特定环境下的模块加载。

但是在上面的代码中,./storage.js 模板的代码仅会在客户端生效。也就是说,服务端将会缺失该模块的功能。

为了弥补这个缺陷,我们通常需要根据实际情况,再实现一个具有同样功能并且可运行于服务端的模块,如下面的代码所示:

复制代码
<script>
	let storage
	if (!import.meta.env.SSR) {
	  // 用于客户端
	  storage = import('./storage.js')
	} else {
	  // 用于服务端
	  storage = import('./storage-server.js')
	}
	export default {
	  // ...
	}
</script>

可以看到,我们根据环境的不同,引入不用的模块实现。

18.5.4 避免交叉请求引起的状态污染

编写同构代码时,额外需要注意的是,避免交叉请求引起的状态污染。

在服务端渲染时,我们会为每一个请求创建一个全新的应用实例,例如:

复制代码
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from 'App.vue'

// 每个请求到来,都会执行一次 render 函数
async function render(url, manifest) {
	// 为当前请求创建应用实例
	const app = createSSRApp(App)

	const ctx = {}
	const html = await renderToString(app, ctx)

	return html
}

可以看到,每次调用 render 函数进行服务端渲染时,都会为当前请求调用 createSSRApp 函数来创建一个新的应用实例。

这是为了避免不同请求共用同一个应用实例所导致的状态污染。

除了要为每一个请求创建独立的应用实例之外,状态污染的情况还可能发生在单个组件的代码中,如下所示:

复制代码
<script>
  // 模块级别的全局变量
  let count = 0

  export default {
    create() {
      count++
    },
  }
</script>

如果上面这段组件的代码在浏览器中运行,则不会产生任何问题,因为浏览器与用户是一对一的关系,每一个浏览器都是独立的。

但如果这段代码在服务器中运行,因为服务器与用户是一对多的关系。

当用户 A 发送请求到服务器时,服务器会执行上面这段组件的代码,即执行 count++。

接着,用户 B 也发送请求到服务器,服务器再次执行上面这段组件的代码,此时的 count 已经因用户 A 的请求自增了一次,因此对于用户 B 而言,用户A 的请求会影响到他,于是就会造成请求间的交叉污染。所以,在编写组件代码时,要额外注意组件中出现的全局变量。

18.5.5 ClientOnly 组件

最后,我们再来介绍一个对编写同构代码非常有帮助的组件,即 <ClientOnly> 组件。

在日常开发中,我们经常会使用第三方模块。而它们不一定对 SSR 友好,例如:

复制代码
<template>
	<SsrIncompatibleComp />
</template>

假设 <SsrIncompatibleComp /> 是一个不兼容 SSR 的第三方组件,我们没有办法修改它的源代码,这时应该怎么办呢?

这时我们会想,既然这个组件不兼容 SSR,那么能否只在客户端渲染该组件呢?

其实是可以的,我们可以自行实现一个 <ClientOnly> 的组件,该组件可以让模板的一部分内容仅在客户端渲染,如下面这段模板所示:

复制代码
<template>
	<ClientOnly>
		<SsrIncompatibleComp />
	</ClientOnly>
</template>

可以看到,我们使用 <ClientOnly> 组件包裹了不兼容 SSR 的<SsrIncompatibleComp/> 组件。

这样,在服务端渲染时就会忽略该组件,且该组件仅会在客户端被渲染。

那么,<ClientOnly> 组件是如何做到这一点的呢?这其实是利用了 CSR 与 SSR 的差异。如下是 <ClientOnly> 组件的实现:

复制代码
import { ref, onMounted, defineComponent } from 'vue'

export const ClientOnly = defineComponent({
	setup(_, { slots }) {
		// 标记变量,仅在客户端渲染时为 true
		const show = ref(false)
		// onMounted 钩子只会在客户端执行
		onMounted(() => {
			show.value = true
		})
		// 在服务端什么都不渲染,在客户端才会渲染 <ClientOnly> 组件的插槽内容
		return () => (show.value && slots.default ? slots.default() : null)
	},
})

可以看到,整体实现非常简单。其原理是利用了 onMounted 钩子只会在客户端执行的特性。

注意 <ClientOnly> 组件并不会导致客户端激活失败。因为在客户端激活的时候,mounted 钩子还没有触发,所以服务端与客户端渲染的内容一致,即什么都不渲染。等到激活完成,且 mounted 钩子触发执行之后,才会在客户端将 <ClientOnly> 组件的插槽内容渲染出来。

18.6 总结

在本章中,我们首先讨论了 CSR、SSR 和同构渲染的工作机制,以及它们各自的优缺点。

当我们为应用程序选择渲染架构时,需要结合软件的需求及场景,选择合适的渲染方案。

接着,我们讨论了 Vue.js 是如何把虚拟节点渲染为字符串的。以普通标签节点为例,在将其渲染为字符串时,要考虑以下内容。

  • 自闭合标签的处理。对于自闭合标签,无须为其渲染闭合标签部分,也无须处理其子节点。
  • 属性名称的合法性,以及属性值的转义。
  • 文本子节点的转义。

具体的转义规则如下。

  • 对于普通内容,应该对文本中的以下字符进行转义。
    • 将字符 & 转义为实体 &。
    • 将字符 < 转义为实体 <。
    • 将字符 > 转义为实体 >。
  • 对于属性值,除了上述三个字符应该转义之外,还应该转义下面两个字符。
    • 将字符 " 转义为实体 "。
    • 将字符 ' 转义为实体 '。

然后,我们讨论了如何将组件渲染为 HTML 字符串。在服务端渲染组件与渲染普通标签并没有本质区别。

我们只需要通过执行组件的 render 函数,得到该组件所渲染的 subTree 并将其渲染为 HTML 字符串即可。

另外,在渲染组件时,需要考虑以下几点:

  • 服务端渲染不存在数据变更后的重新渲染,所以无须调用 reactive 函数对 data 等数据进行包装,也无须使用 shallowReactive 函数对 props 数据进行包装。正因如此,我们也无须调用 beforeUpdate 和 updated 钩子。
  • 服务端渲染时,由于不需要渲染真实 DOM 元素,所以无须调用组件的 beforeMount 和 mounted 钩子。

之后,我们讨论了客户端激活的原理。在同构渲染过程中,组件的代码会分别在服务端和浏览器中执行一次。

在服务端,组件会被渲染为静态的 HTML 字符串,并发送给浏览器。浏览器则会渲染由服务端返回的静态的 HTML 内容,并下载打包在静态资源中的组件代码。当下载完毕后,浏览器会解释并执行该组件代码。

当组件代码在客户端执行时,由于页面中已经存在对应的DOM 元素,所以渲染器并不会执行创建 DOM 元素的逻辑,而是会执行激活操作。激活操作可以总结为两个步骤:

  • 在虚拟节点与真实 DOM 元素之间建立联系,即 vnode.el = el。这样才能保证后续更新程序正确运行。
  • 为 DOM 元素添加事件绑定。

最后,我们讨论了如何编写同构的组件代码。由于组件代码既运行于服务端,也运行于客户端,所以当我们编写组件代码时要额外注意。具体可以总结为以下几点:

  • 注意组件的生命周期。beforeUpdate、updated、beforeMount、mounted、beforeUnmount、unmounted 等生命周期钩子函数不会在服务端执行。
  • 使用跨平台的 API。由于组件的代码既要在浏览器中运行,也要在服务器中运行,所以编写组件代码时,要额外注意代码的跨平台性。通常我们在选择第三方库的时候,会选择支持跨平台的库,例如使用 Axios 作为网络请求库。
  • 特定端的实现。无论在客户端还是在服务端,都应该保证功能的一致性。例如,组件需要读取 cookie 信息。在客户端,我们可以通过 document.cookie 来实现读取;而在服务端,则需要根据请求头来实现读取。所以,很多功能模块需要我们为客户端和服务端分别实现。
  • 避免交叉请求引起的状态污染。状态污染既可以是应用级的,也可以是模块级的。对于应用,我们应该为每一个请求创建一个独立的应用实例。对于模块,我们应该避免使用模块级的全局变量。这是因为在不做特殊处理的情况下,多个请求会共用模块级的全局变量,造成请求间的交叉污染。
  • 仅在客户端渲染组件中的部分内容。这需要我们自行封装 <ClientOnly> 组件,被该组件包裹的内容仅在客户端才会被渲染。
相关推荐
qq_402605653 小时前
python爬虫(二) ---- JS动态渲染数据抓取
javascript·爬虫·python
U.2 SSD3 小时前
ECharts 日历坐标示例
前端·javascript·echarts
2301_772093563 小时前
tuchuang_myfiles&&share文件列表_共享文件
大数据·前端·javascript·数据库·redis·分布式·缓存
Never_Satisfied3 小时前
在JavaScript / HTML中,词内断行
开发语言·javascript·html
IT_陈寒4 小时前
Java并发编程避坑指南:7个常见陷阱与性能提升30%的解决方案
前端·人工智能·后端
HBR666_4 小时前
AI编辑器(FIM补全,AI扩写)简介
前端·ai·编辑器·fim·tiptap
excel4 小时前
一文读懂 Vue 组件间通信机制(含 Vue2 / Vue3 区别)
前端·javascript·vue.js
JarvanMo4 小时前
Flutter 应用生命周期:使用 AppLifecycleListener 阻止应用崩溃
前端
我的xiaodoujiao5 小时前
从 0 到 1 搭建 Python 语言 Web UI自动化测试学习系列 9--基础知识 5--常用函数 3
前端·python·测试工具·ui