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>实现仅在客户端渲染的内容.