破酥 | C4iN
实现一个简单的MVVM-5

实现一个简单的MVVM-5

这部分我们来看服务端渲染(SSR)。

同构渲染

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

CSR、SSR 以及同构渲染

传统的服务端渲染:

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

与 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 应用程序。

激活完成后,整个应用程序已经完全被 Vue.js 接管为 CSR 应用程序了。如果刷新页面,仍然会进行服务端渲染,然后再进行激活,如此往复。

SSR CSR 同构渲染
SEO 友好 不友好 友好
白屏问题
占用服务端资源
用户体验

同构渲染无法提升可交互时间。

同构渲染的“同构”一词的含义是,同样一套代码既可以在服务端运行,也可以在客户端运行。

将虚拟 DOM 渲染为 HTML 字符串

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function renderElementVNode(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
} else if (Array.isArray(children)) {
// 如果子节点类型是数组则递归
children.forEach(child => {
ret += renderElementVNode(child)
})
}

// 结束标签
ret += `</${tag}>`

return ret

}

上面的代码存在几点缺陷:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const VOID_TAGS = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr'
]

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

1
2
3
4
// 闭合开始标签,如果是 void element,则自闭合
ret += isVoidElement ? '/>' : '>'
// void element没有子节点,直接返回
if (isVoidElement) { return ret }

处理属性需要考虑多个方面,首先是对 boolean attribute 的处理。所谓 boolean attribute,并不是说这类属性的值是布尔类型,而是指,如果这类指令存在,则代表true,否则代表false。另外一点需要考虑的是安全问题,可以查看WHATWG 规范的13.1.2.3 节。

另外,在虚拟节点中的 props 对象中,通常会包含仅用于组件运行时逻辑的相关属性。例如,key 属性仅用于虚拟 DOM 的 Diff 算法,ref属性仅用于实现template ref的功能等。在进行服务端渲染时,应该忽略这些属性。除此之外,服务端渲染也无须考虑事件绑定。因此,也应该忽略props对象中的事件处理函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* 处理Props
* */
const shouldIgnoreProp = ['key', 'ref']
export function renderAttrs(props: Record<string, any>) {
let ret = ''
for (const key in props) {
if (shouldIgnoreProp.includes(key) || /^on[^a-z]/.test(key)) {
// 检测属性名称,如果是事件或应该被忽略的属性,则忽略它
continue
}

const value = props[key]

ret += renderDynamicAttr(key, value)

}
return ret
}

/**
* 判断是否是布尔属性
* */
const booleanAttrs = [
'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`
]
const isBooleanAttr = (key: string): boolean => {
return booleanAttrs.includes(key)
}

/**
* 判断属性名称是否合法且安全
* */
const isSSRSafeAttrName = (key: string): boolean => {
return !/[>/="'\u0009\u000a\u000c\u0020]/.test(key)
}


/**
* 处理动态属性,包括布尔属性以及SSR安全
* */
export function renderDynamicAttr(key: string, value: any) {
if (isBooleanAttr(key)) {
// 对于布尔属性如果值为 false 则什么都不做
return value === false ? '' : ` ${key}`

} else if (isSSRSafeAttrName(key)) {
// 对于其他安全的属性,执行完整的渲染
// 对于属性值,我们需要对它执行 HTML 转义操作
return value === '' ? ` ${key}`
: `${key}="${escapeHTML(value)}"`

} else if (key === 'class' || key === 'style') {
// 处理 class 和 style
return ` ${key}=${normalize(value)}`
} else {
// 跳过不安全的属性,并打印警告信息
console.warn(
`[server-renderer] Skipped rendering unsafe attribute name: ${key}`
)
return ''
}

}

对于使用不同数据结构表示的 class 或 style,我们只需要将不同类型的数据结构序列化成字符串表示即可。

在处理属性值时,我们调用了escapeHtml对其进行转义处理,这对于防御 XSS 攻击至关重要。HTML 转义指的是将特殊字符转换为对应的 HTML 实体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
const escapeRE = /["'&<>]/
function escapeHTML(value: string): string {
const str = '' + value
const match = escapeRE.exec(str)

if (!match) {
return str
}

let html = ''
let escaped: string
let index: number
let lastIndex = 0

for (index = match.index ; index < str.length; index++) {
switch (str.charCodeAt(index)) {
case 34: // "
escaped = '&quot'
break

case 38: // &
escaped = '&amp'
break

case 39: // '
escaped = '&#39'
break

case 60: // <
escaped = '&lt'
break

case 62: // >
escaped = '&gt'
break

default:
continue
}

if (lastIndex !== index) {
html += str.substring(lastIndex++, index)
}

html += escaped
}

return lastIndex !== index ? html : str.substring(lastIndex, index)
}

将组件渲染为 HTML 字符串

实际上,把组件渲染为 HTML 字符串与把普通标签节点渲染为 HTML 字符串并没有本质区别。由于组件要渲染的内容可能是任意类型的节点,我们需要封装一个通用渲染函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function renderVNode(vnode: VNode) {
const type = typeof vnode.type
if (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 {
...
}
}

下面是客户端渲染的初始流程:

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

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

服务端渲染初始化组件:

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

当组件的代码在服务端运行时,由于不会对组件进行真正的挂载操作,即不会把虚拟 DOM 渲染为真实 DOM 元素,所以组件的beforeMountmounted这两个钩子函数不会执行。又因为服务端渲染的是应用的快照,所以不存在数据变化后的重新渲染,因此,组件的beforeUpdateupdated这两个钩子函数也不会执行。另外,在服务端渲染时,也不会发生组件被卸载的情况,所以组件的beforeUnmountunmounted这两个钩子函数也不会执行。实际上,只有beforeCreatecreated这两个钩子函数会在服务端执行,所以当你编写组件代码时需要额外注意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
export function renderComponentVNode(vnode: VNode) {
// 检查是否是函数式组件
const isFunctional = typeof vnode.type === 'function'

// 获取组件
let componentOptions = vnode.type as componentOptions
// 如果是函数式组件,则将compVNode.type作为渲染函数
// 将compVNode.type.props作为props选项
if (isFunctional) {
componentOptions.render = vnode.type as FuncComponent
componentOptions.props = (vnode.type as FuncComponent).props as Record<string, any>
}

// 获取组件渲染函数与props定义
let { render, data, setup, props: propsOption, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions

// 创建实例前钩子
beforeCreate && beforeCreate()

// 无须使用 reactive() 创建 data 的响应式版本
const state = data ? data() : undefined
const [props, attrs] = resolveProps(propsOption, vnode.props)

const slots = vnode.children || {}

// 创建实例
const instance: ComponentInstance = {
// 状态数据data
state,
// 这里 props 无须 shallowReactive
props,
// 是否挂载
isMounted: false,
// 渲染内容subTree
subTree: null,
// 插槽
slots: slots,
// 生命周期函数
mounted: [],
unmounted: [],

// keepAlive
keepAliveCtx: undefined
}

function emit(event: string, ...payload: any[]) {
const eventName =`on${event[0].toUpperCase() + event.slice(1)}`
// 根据处理后的事件名去寻找对用的事件处理函数
const handler = instance.props[eventName]
if (handler) {
handler(...payload)
} else {
console.error(`[${eventName}] ${eventName} does not exist`)
}
}

// setupContext
const setupContext = { attrs, emit, slots }
// setupState用于存储由setup返回的数据
let setupState: any | null = null

// 在调用setup函数前设置当前组件实例
setCurrentInstance(instance)
// 处理setup相关
if (setup) {
// 调用setup函数
// 将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值
// 将setupContext作为第二个参数
const setupResult = setup(shallowReadonly(instance.props), setupContext)
// setup返回函数,将其作为渲染函数
if (typeof setupResult === 'function') {
if (render) {
// 如果存在渲染函数,则报告冲突错误
console.error('render conflicts')
// 将setupResult作为渲染函数
render = setupResult
} else {
// 否则作为数据状态赋值给setupState
setupState = setupResult
}
}
// 执行完成后重置currentInstance
setCurrentInstance(null)
}

// 将组件实例设置到vnode上,用于后续更新
vnode.component = instance

// 创建渲染上下文对象,本质是组件实例的代理
const renderContext = new Proxy(instance, {
get(target, key) {
const { state, props, slots } = target

// 如果key值为$slots,直接返回对应插槽
if (key === "$slots") return slots

// 先尝试读取自身状态数据
if (state && key in state) {
return state[key]
} else if (key in props) {
return props[key]
} else {
console.error('state does not exist')
}
},
set (target, key, value) {
const { state, props } = target
if (state && key in state) {
state[key] = value
return true
} else if (key in props) {
console.warn(`Attempting to mutate prop "${String(key)}". Props are readonly.`)
return false
} else if (setupState && key in setupState) {
// setupState处理
setupState[key] = value
return true
} else {
console.error('state does not exist')
return false
}
}
})

// 完成创建钩子
created && created.call(renderContext)

const subTree = render?.call(renderContext, renderContext)

return renderVNode(subTree)
}

该实现与客户端渲染的逻辑基本一致。

客户端激活的原理

当组件的代码在客户端执行时,不会再次创建 DOM 。

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

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

对于同构渲染,为了应用程序在后续更新过程中能够正确运行,我们需要在页面中已经存在的 DOM 对象与虚拟节点对象之间建立正确的联系。在服务端渲染的过程中,会忽略虚拟节点中与事件相关的 props。所以,当组件代码在客户端运行时,我们需要将这些事件正确地绑定到元素上。其实,这两个步骤就体现了客户端激活的含义。

对于同构应用,我们将使用独立的renderer.hydrate函数来完成激活。我们先实现hydrateNode,该函数作为激活的总入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function hydrateNode(node: Node | null, vnode: VNode, container: Container) {
const { type } = vnode

// vnode.el 引用真实 DOM
vnode.el = node as Container

// 检查虚拟 DOM 的类型,如果是组件,则调用 mountComponent 函数完成激活
if (typeof type === 'object') {
mountComponent(vnode, container, null)
} else if (typeof type === 'string') {
// 检查真实 DOM 的类型与虚拟 DOM 的类型是否匹配
if (node?.nodeType !== 1) {
console.error('mismatch', node, vnode)
} else {
// 普通元素则调用hydrateElement
hydrateElement(node as Container, vnode)
}
}

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

对于组件类型节点的激活操作,则可以直接通过mountComponent函数来完成。对于普通元素的激活操作,则可以通过hydrateElement函数来完成。最后,hydrateNode函数需要返回当前激活节点的下一个兄弟节点,以便进行后续的激活操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function hydrateElement(el: Container, vnode: VNode) {
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], el)
}
}
}

对于组件的激活,我们还需要针对性地处理mountComponent函数。由于服务端渲染的页面中已经存在真实 DOM 元素,所以当调用mountComponent函数进行组件的挂载时,无须再次创建真实 DOM 元素。我们调整mountComponent函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
instance.update = watchEffect(() => {

...

if (!instance.isMounted) {

// 挂载前钩子
beforeMount && beforeMount().call(state)
// 如果 vnode.el 存在,则意味着要执行激活
if (compVNode.el) {
hydrateNode(compVNode.el, subTree, container)
} else {
// 初次挂载组件subTree
patch(undefined, subTree, container, anchor)
}

// 将组件实例的 isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作
instance.isMounted = true
instance.renderContext = renderContext

// 完成挂载钩子
mounted && mounted.call(state)
// 遍历mounted生命周期函数数组执行生命周期函数
instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
// instance.unmounted && instance.unmounted.forEach(hook => hook.call(renderContext))

}

...

}, {
scheduler: queueJob
})

编写同构的代码

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

组件的生命周期

在前面的内容中我们已经知道只有beforeCreatecreated这两个钩子函数会在服务端执行。所以我们在created钩子函数中设置定时器对于服务端渲染没有任何意义。这是因为服务端渲染的是应用程序的快照,所谓快照,指的是在当前数据状态下页面应该呈现的内容。所以,在定时器到时,修改数据状态之前,应用程序的快照已经渲染完毕了。所以我们说,在服务端渲染时,定时器内的代码没有任何意义。遇到这类问题时,我们通常有两个解决方案:

  • 方案一:将创建定时器的代码移动到 mounted 钩子中,即只在客户端执行定时器;
  • 方案二:使用环境变量包裹这段代码,让其不在服务端运行。
    • 在通过webpackVite等构建工具搭建的同构项目中,通常带有这种环境变量。

使用跨平台的 API

由于组件的代码既运行于浏览器,又运行于服务器,所以在编写代码的时候要避免使用平台特有的 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>

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

在服务端渲染时,我们会为每一个请求创建一个全新的应用实例,这是为了避免不同请求共用同一个应用实例所导致的状态污染。

除了要为每一个请求创建独立的应用实例之外,状态污染的情况还可能发生在单个组件的代码中。因为服务器与用 户是一对多的关系,在编写组件代码时,要额外注意组件中出现的全局变量,很容易因为用户请求相互影响造成请求间的交叉污染。

<ClientOnly>组件

我们可以自行实现一个<ClientOnly>的组件,该组件可以让模板的一部分内容仅在客户端渲染。用 <ClientOnly> 组件包裹了不兼容 SSR 的组件,这样,在服务端渲染时就会忽略该组件,且该组件仅会在客户端被渲染。

<ClientOnly>利用了 CSR 与 SSR 的差异,原理是利用了onMounted钩子只会在客户端执行的特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
export const ClientOnly = {
setup(props: Record<symbol | string, any>, { slots }) {
// 标记变量,仅在客户端渲染时为 true
const show = ref(false)
// onMounted钩子只在客户端执行
onMounted(() => {
show.value = true
})

// 服务端什么都不做
return () => (show.value && slots.default ? slots.default() : null)
}
}

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

总结

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

     SSR | CSR | 同构渲染 |
:————: | :–: | :——: | :: |
     SEO | 友好 | 不友好 | 友好 |
   白屏问题 | 无 | 有 | 无 |
占用服务端资源 | 多 | 少 | 中 |
   用户体验 | 差 | 好 | 好 |

接着,我们讨论了如何把虚拟节点渲染为字符串,以及如何将组件渲染为 HTML 字符串。在服务端渲染组件与渲染普通标签并没有本质区别。我们只需要通过执行组件的render函数,得到该组件所渲染的subTree并将其渲染为 HTML 字符串即可。

之后,我们讨论了客户端激活的原理。在同构渲染过程中,组件的代码会分别在服务端和浏览器中执行一次。在服务端,组件会被渲染为静态的 HTML 字符串,并发送给浏览器。浏览器则会渲染由服务端返回的静态的 HTML 内容,并下载打包在静态资源中的组件代码。当下载完毕后,浏览器会解释并执行该组件代码。当组件代码在客户端执行时,由于页面中已经存在对应的 DOM 元素,所以渲染器并不会执行创建 DOM 元素的逻辑,而是会执行激活操作。对于编写同构的组件代码,组件代码既运行于服务端,也运行于客户端,所以当我们编写组件代码时要额外注意。

至此,我们分别实现了响应式,渲染器,编译器以及同构渲染部分的功能,内容较为分散,并不是一个完整的MVVM框架系统。我们将整合目前实现的基础功能,真正实现一个简单的MVVM。

Author:破酥 | C4iN
Link:https://c4in1.github.io/2024/09/04/MVVM/实现一个简单的MVVM-5/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可