服务端渲染原理解析

一. 什么是服务端渲染

随着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树阶段的性能。代码仓库

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

相关推荐
前端极客探险家1 分钟前
Flutter vs React Native:跨平台移动开发框架对比
flutter·react native·react.js
大莲芒1 小时前
react 15-16-17-18各版本的核心区别、底层原理及演进逻辑的深度解析--react17
前端·react.js·前端框架
木木黄木木3 小时前
html5炫酷3D文字效果项目开发实践
前端·3d·html5
Li_Ning213 小时前
【接口重复请求】axios通过AbortController解决页面切换过快,接口重复请求问题
前端
胡八一4 小时前
Window调试 ios 的 Safari 浏览器
前端·ios·safari
Dontla4 小时前
前端页面鼠标移动监控(鼠标运动、鼠标监控)鼠标节流处理、throttle、限制触发频率(setTimeout、clearInterval)
前端·javascript
再学一点就睡4 小时前
深拷贝与浅拷贝:代码世界里的永恒与瞬间
前端·javascript
CrimsonHu5 小时前
B站首页的 Banner 这么好看,我用原生 JS + 三大框架统统给你复刻一遍!
前端·javascript·css
Enti7c5 小时前
前端表单输入框验证
前端·javascript·jquery
拉不动的猪5 小时前
几种比较实用的指令举例
前端·javascript·面试