一. 什么是服务端渲染
随着React
或Vue
等前端框架盛行,SPA
也成为前端业务开发中最常用的开发方式,其主要特点是采用客户端渲染。但客户端渲染有一个缺点是对SEO
不友好,所以衍生出服务端渲染解决方案。
简单来说,就是访问一个站点时,会在服务端先解析执行要返回的页面内容html
片段,然后插入到html
文档中返回给客户端,此时客户端拿到的html
文档就是带有页面内容,而不是空文档,然后再执行客户端的水合过程,完成DOM
树更新。
二. 服务端渲染原理
注意: 本文主要讲解服务端同步渲染流程,异步渲染流程会另写一篇文章单独讲解。
主要理解两个要点,一个是服务端如何解析获取html
片段,另一个是客户端如何完成水合过程。
2.1 renderToString
renderToString
方法是React
提供的API
,主要作用是将虚拟DOM
树转换成对应的html
片段。
代码示例如下,即将要渲染的组件作为入参传入即可获取对应的html
片段
javascript
function HelloWorld() {
return (
<div>
<h1>hello world</h1>
</div>
)
}
function App() {
const [count, setCount] = useState(0)
return (
<div>
<h1 onClick={() => setCount(count + 1)}>{count}</h1>
<HelloWorld />
</div>
)
}
const html = renderToString(<App />)
// 输出结果:<div><h1>0</h1><div><h1>hello world</h1></div></div>
主要关注以下处理逻辑:
- 解析获取
html
片段逻辑 - 标签属性处理逻辑
Hook
方法调用逻辑
2.1.1 解析获取html
片段
首先<App />
会转换成React.createElement(App, null)
方法调用,创建对应的ReactElement
对象实例,接着将其作为起始vnode
,采用深度优先遍历算法递归遍历child vnode
,遍历过程中会同步收集对应的html
片段,递归结束后进行拼接即可获取完整的html
。
javascript
function pushStartGenericElement(target, props, tag) {
target.push(`<${tag}`)
let children = null
for (const propKey in props) {
switch (propKey) {
case 'children':
children = props[propKey]
break
default:
pushAttribute(target, propKey, props[propKey])
}
}
target.push('>')
if (typeof children === 'string') {
target.push(children)
return null
}
return children
}
function renderElement(task, type, props) {
if (typeof type === 'function') {
const children = type(props)
task.node = children
retryNode(task)
} else if (typeof type === 'string') {
const { chunks } = task.blockedSegment
const children = pushStartGenericElement(chunks, props, type)
task.node = children
retryNode(task)
chunks.push(`</${type}>`)
}
}
function renderChildrenArray(task, children) {
for (let i = 0; i < children.length; i++) {
task.node = children[i]
retryNode(task)
}
}
function retryNode(task) {
const {
node,
blockedSegment: { chunks },
} = task
if (node === null) return
if (Array.isArray(node)) {
renderChildrenArray(task, node)
return
}
if (typeof node === 'object') {
switch (node.$$typeof) {
case REACT_ELEMENT_TYPE:
renderElement(task, node.type, node.props)
break
}
return
}
chunks.push(`${node}`)
}
function renderToString(children) {
const task = {
node: children, // 当前vnode
blockedSegment: {
chunks: [], // html片段
},
}
// 递归遍历vnode,收集html片段
retryNode(task)
let result = ''
// 拼接html片段
const {
blockedSegment: { chunks },
} = task
for (let i = 0; i < chunks.length; i++) {
result += chunks[i]
}
return result
}
2.1.2 标签属性
这里主要举例className
和style
的处理逻辑,需要注意的是不会处理事件属性。
javascript
const uppercasePattern = /([A-Z])/g
function pushStyleAttribute(target, style) {
const ans = []
for (const styleName in style) {
const styleValue = style[styleName]
ans.push(
`${styleName
.replace(uppercasePattern, '-$1')
.toLowerCase()}:${styleValue}`,
)
}
target.push(` style="${ans.join(';')}"`)
}
function pushAttribute(target, name, value) {
switch (name) {
case 'className':
target.push(` class="${value}"`)
break
case 'style':
pushStyleAttribute(target, value)
break
}
}
2.1.3 Hook
方法调用
服务端渲染有独立一套Hook
方法,这里主要举例useState
和useEffect
两个Hook
方法调用逻辑。逻辑都比较简单,useEffect
是空函数调用,useState
主要是处理初始值。
javascript
function noop() {}
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action
}
function useState(initialState) {
return useReducer(basicStateReducer, initialState)
}
function useReducer(reducer, initialState) {
// 如果传入的初始值是functIon,则调用执行获取返回值作为初始state值
if (typeof initialState === 'function') initialState = initialState()
return [initialState, noop]
}
export const HooksDispatcher = {
useState,
useReducer,
useEffect: noop,
useLayoutEffect: noop,
}
2.2 hydrateRoot
注意:
hydrateRoot
和createRoot
整体逻辑很类似,读者可以参考文档手写mini React,理解React渲染原理了解客户端渲染时React
渲染流程。本文主要解析服务端渲染和客户端渲染的不同点。
当采用服务端渲染,客户端需要调用hydrateRoot
方法构建React
应用。该方法接收两个入参,第一个是挂载dom
节点,第二个是要渲染的组件。
代码示例如下
javascript
import { hydrateRoot } from 'react-dom/client'
import App from './App'
hydrateRoot(document.querySelector('#app'), <App />)
服务端渲染触发的React
渲染流程最大的差异点在于首次渲染,由于采用服务端渲染,挂载节点下会有初始内容,即DOM
树。那在首次渲染构建虚拟DOM
树时会判断当前的DOM
树节点是否可以复用,可以则直接赋值给FiberNode
的stateNode
属性,就不需要额外创建DOM
节点,其优点在于在更新DOM
阶段只需要对当前DOM
树做颗粒度更新即可,优化性能。
2.2.1 enterHydrationState
enterHydrationState
方法用于处理根FiberNode
,核心逻辑如下:
- 将
isHydrating
变量赋值为true
,表示当前渲染处于hydrate
阶段 - 获取挂载节点的
firstChild
并赋值给nextHydratableInstance
变量 - 将根
FiberNode
赋值给hydrationParentFiber
变量
javascript
// 判断是否处于hydrate阶段
let isHydrating = false
// 当前hydrate fiber节点
let hydrationParentFiber = null
// 当前hydrate dom节点
let nextHydratableInstance = null
function enterHydrationState(fiber) {
const parentInstance = fiber.stateNode.containerInfo
nextHydratableInstance = getFirstHydratableChild(parentInstance)
isHydrating = true
hydrationParentFiber = fiber
}
2.2.2 tryToClaimNextHydratableInstance
tryToClaimNextHydratableInstance
方法用于处理标签节点类型的FiberNode
,核心逻辑如下:
- 将当前
hydrate dom
节点赋值给FiberNode
的stateNode
属性 - 获取下一个
hydrate dom
节点并赋值给nextHydratableInstance
变量
javascript
function tryToClaimNextHydratableInstance(fiber) {
if (!isHydrating) return
if (nextHydratableInstance !== null) {
// 将当前hydrate dom节点赋值给fiber的stateNode属性
fiber.stateNode = nextHydratableInstance
hydrationParentFiber = fiber
// 获取下一个hydrate dom节点
nextHydratableInstance = getFirstHydratableChild(nextHydratableInstance)
}
}
2.2.3 tryToClaimNextHydratableTextInstance
tryToClaimNextHydratableInstance
方法用于处理纯文本类型的FiberNode
。逻辑比较简单,需要注意的是纯文本节点是没有child
节点的,所以将nextHydratableInstance
赋值为null
即可。
javascript
function tryToClaimNextHydratableTextInstance(fiber) {
if (!isHydrating) return
if (nextHydratableInstance !== null) {
fiber.stateNode = nextHydratableInstance
hydrationParentFiber = fiber
nextHydratableInstance = null
}
}
2.2.4 popHydrationState
当遍历到叶子节点时会调用popHydrationState
方法,核心逻辑如下:
- 将
hydrationParentFiber
赋值为当前FiberNode
的父节点 - 获取下一个
hydrate dom
节点并赋值给nextHydratableInstance
变量
javascript
function popToNextHostParent(fiber) {
hydrationParentFiber = fiber.return
while (hydrationParentFiber) {
switch (hydrationParentFiber.tag) {
case HostRoot:
case HostComponent:
return
default:
hydrationParentFiber = hydrationParentFiber.return
}
}
}
function popHydrationState(fiber) {
if (!isHydrating) return false
popToNextHostParent(fiber)
nextHydratableInstance = hydrationParentFiber
? getNextHydratableSibling(fiber.stateNode)
: null
return true
}
三. 总结
本篇文章中主要介绍了服务端渲染的同步执行流程。首先需要在服务端拼接需要返回的html
片段,其核心原理是通过深度优先遍历算法递归遍历vnode
,获取其对应的html
片段,最后进行拼接。接着客户端会先渲染初始内容的DOM
树,然后构建React
应用完成水合过程,其核心原理是在构建虚拟DOM
树时判断是否可以复用当前DOM
树的节点,优化更新DOM
树阶段的性能。代码仓库
创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!