服务端渲染原理解析
随着React或Vue等前端框架盛行,SPA也成为前端业务开发中最常用的开发方式,其主要特点是采用客户端渲染。但客户端渲染有一个缺点是对SEO不友好,所以衍生出服务端渲染解决方案。简单来说,就是访问一个站点时,会在服务端先解析执行要返回的页面内容html片段,然后插入到html文档中返回给客户端,此时客户端拿到的html文档就是带有页面内容,而不是空文档,然后再执行客户端的水合过程,完成DOM树更新。二. 服务端渲染原理
注意: 本文主要讲解服务端同步渲染流程,异步渲染流程会另写一篇文章单独讲解。
主要理解两个要点,一个是服务端如何解析获取html片段,另一个是客户端如何完成水合过程。2.1 renderToStringrenderToString方法是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 = nullfor (const propKey in props) {switch (propKey) {case 'children':children = props[propKey]breakdefault: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 = childrenretryNode(task)} else if (typeof type === 'string') {const { chunks } = task.blockedSegmentconst children = pushStartGenericElement(chunks, props, type)task.node = childrenretryNode(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 },} = taskif (node === null) returnif (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, // 当前vnodeblockedSegment: {chunks: [], // html片段},}// 递归遍历vnode,收集html片段retryNode(task)let result = ''// 拼接html片段const {blockedSegment: { chunks },} = taskfor (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}"
)breakcase '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 enterHydrationStateenterHydrationState方法用于处理根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.containerInfonextHydratableInstance = getFirstHydratableChild(parentInstance)isHydrating = truehydrationParentFiber = fiber}
2.2.2 tryToClaimNextHydratableInstancetryToClaimNextHydratableInstance方法用于处理标签节点类型的FiberNode,核心逻辑如下:
将当前hydrate dom节点赋值给FiberNode的stateNode属性获取下一个hydrate dom节点并赋值给nextHydratableInstance变量
javascript 代码解读复制代码function tryToClaimNextHydratableInstance(fiber) {if (!isHydrating) returnif (nextHydratableInstance !== null) {// 将当前hydrate dom节点赋值给fiber的stateNode属性fiber.stateNode = nextHydratableInstancehydrationParentFiber = fiber// 获取下一个hydrate dom节点nextHydratableInstance = getFirstHydratableChild(nextHydratableInstance)}}
2.2.3 tryToClaimNextHydratableTextInstancetryToClaimNextHydratableInstance方法用于处理纯文本类型的FiberNode。逻辑比较简单,需要注意的是纯文本节点是没有child节点的,所以将nextHydratableInstance赋值为null即可。javascript 代码解读复制代码function tryToClaimNextHydratableTextInstance(fiber) {if (!isHydrating) returnif (nextHydratableInstance !== null) {fiber.stateNode = nextHydratableInstancehydrationParentFiber = fibernextHydratableInstance = null}}
2.2.4 popHydrationState当遍历到叶子节点时会调用popHydrationState方法,核心逻辑如下:
将hydrationParentFiber赋值为当前FiberNode的父节点获取下一个hydrate dom节点并赋值给nextHydratableInstance变量
javascript 代码解读复制代码function popToNextHostParent(fiber) {hydrationParentFiber = fiber.returnwhile (hydrationParentFiber) {switch (hydrationParentFiber.tag) {case HostRoot:case HostComponent:returndefault:hydrationParentFiber = hydrationParentFiber.return}}}
function popHydrationState(fiber) {if (!isHydrating) return falsepopToNextHostParent(fiber)nextHydratableInstance = hydrationParentFiber? getNextHydratableSibling(fiber.stateNode): nullreturn true}
三. 总结本篇文章中主要介绍了服务端渲染的同步执行流程。首先需要在服务端拼接需要返回的html片段,其核心原理是通过深度优先遍历算法递归遍历vnode,获取其对应的html片段,最后进行拼接。接着客户端会先渲染初始内容的DOM树,然后构建React应用完成水合过程,其核心原理是在构建虚拟DOM树时判断是否可以复用当前DOM树的节点,优化更新DOM树阶段的性能。代码仓库创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!