CSR、SSR以及同构渲染
同构渲染分为首次渲染以及非首次渲染. 首次渲染与SSR的工作流程一致, 即当首次访问或刷新页面时, 整个页面的内容是服务端完成渲染的, 浏览器最终得到的是渲染好的HTML页面, 接下来浏览器会解析并渲染该页面.
在解析过程中, 浏览器会发现HTML代码中存在<link>
和<script>
标签, 于是会从CDN或服务器获取相应资源, 这一步与CSR一致. 当js资源加载完毕后, 会进行激活操作, 就是vusjs中的“hydration”. 包括:
- vuejs在当前页面已经渲染的dom元素以及vuejs组件所渲染的虚拟dom之间建立联系.
- vuejs从HTML页面中提取由服务端序列化后发送过来的数据, 用以初始化整个vuejs应用程序.
激活完成后, 整个应用程序已经完全被vuejs接管为CSR应用了. 后续操作都会按照CSR应用程序的流程来执行.
对同构渲染最多的误解是, 它能提升可交互时间. 事实是同构渲染仍然需要像CSR那样等待js资源加载完成, 并且在客户端激活完成, 才能响应用户操作. 因此理论上同构渲染无法提升可交互时间. 同构的含义是指同样一套代码既可以在服务端运行, 也可以在客户端运行. 例如编写一个vue组件, 既可以在服务端运行, 被渲染为HTML字符串; 也可以在客户端运行, 和普通的CSR应用程序一样.
将虚拟DOM渲染为HTML字符串
将普通标签类的虚拟节点渲染为HTML字符串, 本质上是字符串的拼接. 当然还需要考虑到一些边界条件:
- 是否为自闭合标签
- props属性名称是否合法, 对属性值进行HTML转义
- 子节点类型多种多样, 如Fragment、组件、函数式组件、文本等
- 标签的文本子节点也需要进行HTML转义
// 自闭合标签(void elemnt WHATWG规范)
const VOID_TAGS = 'area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr'.split(',')
// 将虚拟节点渲染为html字符串
function renderElementVnode(vnode){
const { type: tag, props, children } = vnode
const isVoidElement = VOID_TAGS.includes(tag)
let ret = `<${tag}`
if(props) {
for(const k in props) {
ret += ` ${k}=${props[key]}`
}
}
// 判断是否为自闭合标签, 且不需要处理children
ret += isVoidElement ? '/>' : '>'
if(isVoidElement) return
if(typeof children === 'string') {
ret += children
} else {
children.forEach(child => {
ret += renderElementVnode(child)
})
}
ret += `</${tag}>`
return ret
}
// 处理props, ref、key或事件绑定等服务端渲染时忽略
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 val = props[key]
ret += renderDynamicAttr(key, val)
}
return ret
}
// 完成props属性渲染
function renderDynamicAttr(key, val){
// 1. 属性是否为 bool attribute 如 multiple、controls等
if(isBooleanAttr(key)) {
return val === false ? '' : ` ${key}`
// 2. 属性名称是否合法且安全, 并对属性值进行转义(防御XSS攻击)
} else if(isSSRSafeAttrName(key)) {
return val === '' ? ` ${key}` : ` ${key}=${escapeHtml(val)}`
} else {
// error...
}
return ``
}
将组件渲染为HTML字符串
把组件渲染为HTML字符串与把普通标签节点渲染为HTML字符串并没有本质区别. 组件的渲染函数用来描述要渲染的内容, 只需要执行组件的渲染函数得到对应的虚拟dom, 再将其渲染为HTML字符串.
服务端渲染时, 组件的初始化流程与客户端渲染时基本一致, 有两个重要区别.
- 服务端渲染的是当前应用的快照, 不存在数据变更后重新渲染. 所有数据在服务端都无需是响应式的.
- 服务端渲染只需要获取组件要渲染的
subTree
, 无须调用渲染器完成真实dom的创建.
function renderCompoentVnode(vnode){
const isFuncal = typeof vnode.type === 'function'
let compOptions = vnode.type
if(isFuncal){
compOptions = {
render: vnode.type,
props: vnode.type.props
}
}
let { render, data, setup, beforeCreate, created, props: propsOptions } = compOptions
// 1. 执行 beforeCreate
beforeCreate?.()
// 2. 初始化data/props的非响应式数据
const state = data ? data() : null
// data不再是响应式数据 props也不是浅响应的
const [props, attrs] = resolveProps(propsOptions, vnode.props)
const slots = vnode.children || {}
// 3. 创建组件实例
const instance = {
state,
props,
isMounted: false,
subTree: null,
slots,
mounted: [],
keepAliveCtx: null
}
function emit(){
// ...
}
let setupState = null
// 4. 执行setup
if(setup){
const setupContext = { attrs, emit, slots }
// ...
// 如果 setup 返回渲染函数, 则忽略render选项
}
const renderContext = new Proxy(instance, {
get(){ ... },
set(){ ... }
})
// 5. created. 无需设置render effect完成渲染, 也就没有其他生命周期
created && created.call(renderContext)
const subTree = render.call(renderContext, renderContext)
return renderVnode(subTree)
}
function renderVnode(vnode){
const type = typeof vnode.type
if(type === 'string') {
return renderElementVnode(vnode)
} else if(type === 'object' || type === 'function') {
return renderCompoentVnode(vnode)
} else if(type === Text) {
// 文本...
} else if(type === Fragment) {
// 片段...
} else {
// ...其他类型
}
}
客户端的激活原理
对于同构渲染, 组件的代码会在服务端与客户端分别执行一次. 在服务端组件被渲染为静态的HTML字符串, 发送给浏览器后将其渲染出来, 此时页面已存在对应的dom元素. 同时组件也会被打包到js文件中, 并在客户端被下载到浏览器中解释并执行. 组件代码在客户端浏览器执行时不会再创建相应的dom元素, 而需要做以下事:
- 在页面的dom元素与虚拟节点对象之间建立联系
- 为页面中的dom元素添加事件绑定
renderer.render(vnode, container) -> renderer.hydrate(vnode, container)
// 服务端渲染->客户端激活过程
// 1.服务端渲染的字符串
const html = renderCompoentVnode(compVnode)
// 2.假设客户端拿到了渲染字符串
// 设置挂载点
const container = document.querySelector('#app')
// 设置innerHTML 模拟由服务端渲染的内容
container.innerHTML = html
// 3.调用 hydrate 激活
renderer.hydrate(compVnode, container)
function hydrate(vnode, container){
// 从容器元素的第一个子节点开始
hydrateNode(container.firstChild, vnode)
}
function hydrateNode(node, vnode){
// 引用真实dom
vnode.el = node
if(typeof vnode.type === 'object') {
// 组件类型调用mountComponent进行激活
mountComponent(vnode, container, null)
} else if(typeof vnode.type === 'string') {
if(node.nodeTyle !== 1) {
// 真实dom的类型与虚拟dom的类型不一样, 打印错误
} else {
// 普通元素调用hydrateElement进行激活
hydrateElement(node, vnode)
}
}
// 返回下一个兄弟节点, 方便后续的激活操作
return node.nextSibling
}
// 激活普通元素类型的节点
function hydrateElement(el, vnode){
if(vnode.props) {
// 只有事件类型的props需要处理
}
// 递归激活子节点
if(isArray(vnode.children)){
let nextNode = el.firstChild
const len = vnode.children.length
for(i ... len) {
// 激活子节点
nextNode = hydrateNode(nextNode, vnode.children[i])
}
}
}
// 服务端渲染的页面中已经存在真实dom
// 组件挂载时无须再次创建真实dom元素
function mountComponent(vnode, container, anchor){
// ...
instance.update = effect(() => {
// ... if(!instance.isMounted) 未挂载
if(vnode.el) {
// 真实dom已存在, 进行激活
hydrateNode(vnode.el, subTree)
} else {
// 正常挂载
patch(null, subTree, container, anchor)
}
})
}
编写同构的代码
由于一份代码既要在服务端运行, 又要在客户端运行. 所以在编写代码时需要注意运行环境的不同导致的差异.
组件的生命周期
组件的代码在服务端运行时, 并不会进行真正的挂载操作. 而是生成对应的HTML字符串. 又因为服务端渲染是应用的快照(当前数据状态下页面应该呈现的内容), 所以不存在数据变化后的重新渲染. 只有created
和beforeCreate
两个钩子函数会在服务端执行.
构建工具会分别为客户端与服务端输出两个独立的包. 被if(!import.meta.env.SSR)
判断包裹的代码只会在客户端包中存在.
使用跨平台的API
在编写代码时应该避免使用平台特有的API, 比如浏览器环境才有的window
、document
等对象. 不得不使用时, 可以利用如import.meta.env.SSR
等环境变量做代码守卫. 同样nodejs中特有的API也无法在浏览器中运行, 这时可以选择跨平台的第三方库, 如Axios
作为网络请求库.
只在某一端引入模块
有些功能需要客户端与服务端分别进行实现. 如客户端使用document.cookie
、服务端根据请求头来获取cookie
. 或是使用守卫只在某一端实现的特定功能.
// 客户端与服务端分别使用不同手段实现某个功能
let storage
if(import.meta.env.SSR){
storage = import('./storage-server.js')
} else {
storage = import('./storage.js')
}
// ...
避免交叉请求引起的状态污染
污染既可以是应用级的, 也可以是模块级的.
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from 'App.vue'
// 每次调用render函数进行服务端渲染时
// 都会为当前请求调用createSSRApp函数创建一个新的应用实例
// 避免不同请求共用一个应用实例所导致的状态污染
async function render(url, manifest){
const app = createSSRApp(App)
const ctx = {}
const html = await renderToString(app, ctx)
return html
}
<script>
// 模块级别的全局变量
let count = 0
export default {
create(){
count++
}
}
</script>
// A发送请求到服务器时, 服务器会执行组件的代码即count自增
// B也发送请求到服务器, 服务器再次执行组件的代码count自增, 被A的请求影响便会造成交叉污染
<ClientOnly>
组件
假设使用一个不兼容SSR的第三方组件, 我们可以通过<ClientOnly>
组件实现只在客户端渲染该组件.
<ClientOnly>
<ThirdComp />
</ClientOnly>
// 利用生命周期钩子控制是否渲染
export const ClientOnly = defineComponent({
setup(_, { slots }){
const show = ref(false)
onMounted(() => {
show.value = true
})
return () => (show.value && slots.default) ? slots.default() : null
}
})
在客户端激活时, mounted
钩子还没有被触发, 服务端与客户端渲染的内容一致. 等到激活完成, 且mounted
钩子触发之后, 才会在客户端将<ClientOnly>
组件插槽内容渲染出来.
总结
编写同构的组件代码时, 需要注意以下几点:
- 注意组件的生命周期. 更新、挂载、卸载等钩子不会在服务端执行.
- 使用跨平台的API. 如网络请求库
Axios
. - 特定端的实现, 保证功能的一致性.
- 避免交叉请求引起的状态污染.
<ClientOnly>
实现仅在客户端渲染的内容.