服务端渲染原理解析

一. 什么是服务端渲染

随着ReactVue等前端框架盛行,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 标签属性

这里主要举例classNamestyle的处理逻辑,需要注意的是不会处理事件属性。

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方法,这里主要举例useStateuseEffect两个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

注意: hydrateRootcreateRoot整体逻辑很类似,读者可以参考文档手写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树节点是否可以复用,可以则直接赋值给FiberNodestateNode属性,就不需要额外创建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节点赋值给FiberNodestateNode属性
  • 获取下一个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树阶段的性能。代码仓库

创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!

相关推荐
加班是不可能的,除非双倍日工资4 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi4 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip5 小时前
vite和webpack打包结构控制
前端·javascript
excel5 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国5 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼5 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy5 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT6 小时前
promise & async await总结
前端
Jerry说前后端6 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天6 小时前
A12预装app
linux·服务器·前端