服务端渲染原理解析姐妹篇

一. 什么是服务端流式渲染

在往期文章服务端渲染原理解析中介绍了ReactrenderToString方法实现服务端同步渲染原理。但是该方法存在以下两个问题,一个是不支持Stream,意味着需要在服务端获取完整的html片段之后才能返回,导致接口耗时。二是不支持异步操作,如数据请求。而ReactrenderToPipeableStream方法解决了上述两个问题。

代码示例如下,需要注意传递给renderToPipeableStream方法的组件需要包含完整DOM树结构。

javascript 复制代码
const app = express()

function App() {
  return (
    <div>
      <h1>hello world</h1>
    </div>
  )
}

function ServerRenderTemplate() {
  return (
    <html>
      <head>
        <title>react-ssr</title>
      </head>
      <body>
        <div id='app'>
          <App />
        </div>
      </body>
    </html>
  )
}

app.use('/', (req, res) => {
  const { pipe } = renderToPipeableStream(<ServerRenderTemplate />, {
    bootstrapScripts: ['/main.js'],
    onShellReady() {
      pipe(res)
    },
  })
})

请求响应的DOM树结构如下,需要注意通过bootstrapScripts属性插入的script标签带有一个属性是async,并不是defer

javascript 复制代码
<!DOCTYPE html>
<html>
  <head>
    <title>react-ssr</title>
  </head>
  <body>
    <div id="app">
      <div>
        <h1>hello world</h1>
      </div>
    </div>
    <script src="/main.js" async></script>
  </body>
</html>

二. 服务端流式渲染原理

2.1 Stream

renderToPipeableStream方法获取html片段的逻辑与renderToString方法大体相同,本文不再赘述。

在解析html片段过程中,会将字符串转换成字节,当获取到完整的html片段后,会以字节流形式流式返回给客户端。

2.1.1 stringToPrecomputedChunk

该方法会将字符串转换成字节。核心原理是使用了Nodejs util模块的TextEncoder对象。具体参考文档

javascript 复制代码
import { TextEncoder } from 'util'

const textEncoder = new TextEncoder()

function stringToPrecomputedChunk(content) {
  const precomputedChunk = textEncoder.encode(content)
  return precomputedChunk
}

2.1.2 writeChunk

该方法会将字节流写入到缓存区中。核心逻辑是创建2kb大小的数据块,当数据块溢出时则写入缓存区中,然后清空数据块,重新收集数据。

javascript 复制代码
// 数据块大小,为2kb
const VIEW_SIZE = 2048
// 数据块
let currentView = null
// 当前数据块字节数
let writtenBytes = 0

const textEncoder = new TextEncoder()

function writeToDestination(destination, view) {
  destination.write(view)
}

function writeStringChunk(destination, stringChunk) {
  if (stringChunk.length === 0) return
  // 由于当个字符占的字节数不定,有可能1-4个字节,如果当前字符串所占最大字节数已经超过了2kb,需要作为当个数据块写入缓存区
  if (stringChunk.length * 3 > VIEW_SIZE) {
    // 先把当前数据块写入缓冲区
    if (writtenBytes > 0) {
      writeToDestination(destination, currentView.subarray(0, writtenBytes))
      currentView = new Uint8Array(VIEW_SIZE)
      writtenBytes = 0
    }
    writeToDestination(destination, stringChunk)
    return
  }
  let target = currentView
  if (writtenBytes > 0) {
    target = currentView.subarray(writtenBytes)
  }
  const { written, read } = textEncoder.encodeInto(stringChunk, target)
  writtenBytes += written
  // 说明当前数据块不够容纳stringChunk
  if (read < stringChunk.length) {
    writeToDestination(destination, currentView.subarray(0, writtenBytes))
    currentView = new Uint8Array(VIEW_SIZE)
    writtenBytes = textEncoder.encodeInto(
      stringChunk.slice(read),
      currentView,
    ).written
  }
  if (writtenBytes === VIEW_SIZE) {
    writeToDestination(destination, currentView)
    currentView = new Uint8Array(VIEW_SIZE)
    writtenBytes = 0
  }
}

function writeViewChunk(destination, chunk) {
  if (chunk.byteLength === 0) return
  if (chunk.byteLength > VIEW_SIZE) {
    if (writtenBytes > 0) {
      writeToDestination(destination, currentView.subarray(0, writtenBytes))
      currentView = new Uint8Array(VIEW_SIZE)
      writtenBytes = 0
    }
    writeToDestination(destination, chunk)
    return
  }
  let bytesToWrite = chunk
  const allowableBytes = currentView.length - writtenBytes
  // 说明当前数据块不够容纳chunk
  if (allowableBytes < bytesToWrite.byteLength) {
    if (allowableBytes === 0) writeToDestination(destination, currentView)
    else {
      currentView.set(bytesToWrite.subarray(0, allowableBytes), writtenBytes)
      writtenBytes += allowableBytes
      writeToDestination(destination, currentView)
      bytesToWrite = bytesToWrite.subarray(allowableBytes)
    }
    currentView = new Uint8Array(VIEW_SIZE)
    writtenBytes = 0
  }
  currentView.set(bytesToWrite, writtenBytes)
  writtenBytes += bytesToWrite.byteLength
  if (writtenBytes === VIEW_SIZE) {
    writeToDestination(destination, currentView)
    currentView = new Uint8Array(VIEW_SIZE)
    writtenBytes = 0
  }
}

/**
 * @param {*} destination response实例
 * @param {*} chunk 要返回的数据,字符串/字节流数据格式
 */
function writeChunk(destination, chunk) {
  if (typeof chunk === 'string') writeStringChunk(destination, chunk)
  else writeViewChunk(destination, chunk)
}

2.2 异步操作

异步操作最常见的业务场景是获取数据,本文基于此解析服务端渲染中异步操作的处理流程。异步数据处理涉及ReactSuspense组件和use方法,可以参考文档了解,本文不再赘述,主要讲解两者在服务端渲染中的处理逻辑。

代码示例如下,主要逻辑是数据请求promise实例处于pending时会展示Loading组件,fulfilled之后展示HelloWorld组件内容。

javascript 复制代码
function HelloWorld({ fetchData }) {
  const data = use(fetchData)
  return <h1>{data}</h1>
}

function App() {
  const fetchData = new Promise(resolve => {
    setTimeout(() => {
      resolve('hello world')
    }, 1000)
  })

  return (
    <div>
      <Suspense fallback={<h1>Loading...</h1>}>
        <HelloWorld fetchData={fetchData} />
      </Suspense>
    </div>
  )
}

当数据请求promise实例处于pending状态时,返回的DOM树结构如下,需要注意此时请求仍然处于pending态,还在等服务端继续返回内容。

javascript 复制代码
<!DOCTYPE html>
<html>
  <head>
    <title>react-ssr</title>
  </head>
  <body>
    <div id="app">
      <div>
        <!--$?-->
        <template id="B:0"></template>
        <h1>Loading...</h1>
        <!--/$-->
      </div>
    </div>

当数据请求promise实例处于fulfilled状态时,返回的DOM树结构如下,核心关注$RC方法,该方法会用Suspense组件的children内容替换掉fallback

javascript 复制代码
<!DOCTYPE html>
<html>
  <head>
    <title>react-ssr</title>
  </head>
  <body>
    <div id="app">
      <div>
        <!--$?-->
        <template id="B:0"></template>
        <h1>Loading...</h1>
        <!--/$-->
      </div>
    </div>
    <div hidden id="S:0"><h1>hello world</h1></div>
    <script>
      $RC = function (b, c, e) {
        c = document.getElementById(c)
        c.parentNode.removeChild(c)
        var a = document.getElementById(b)
        if (a) {
          b = a.previousSibling
          if (e) (b.data = '$!'), a.setAttribute('data-dgst', e)
          else {
            e = b.parentNode
            a = b.nextSibling
            var f = 0
            do {
              if (a && 8 === a.nodeType) {
                var d = a.data
                if ('/$' === d)
                  if (0 === f) break
                  else f--
                else ('$' !== d && '$?' !== d && '$!' !== d) || f++
              }
              d = a.nextSibling
              e.removeChild(a)
              a = d
            } while (a)
            for (; c.firstChild; ) e.insertBefore(c.firstChild, a)
            b.data = '$'
          }
          b._reactRetry && b._reactRetry()
        }
      }
      $RC('B:0', 'S:0')
    </script>
  </body>
</html>

2.2.1 renderSuspenseBoundary

在解析html片段过程中如果遇到SuspenseComponent,会调用renderSuspenseBoundary方法。核心逻辑如下:

  • 创建解析SuspenseComponent child的任务,将其作为promise.then监听回调
  • 创建解析SuspenseComponent fallback的任务
javascript 复制代码
function renderSuspenseBoundary(request, task, props) {
  const parentBoundary = task.blockedBoundary
  const parentSegment = task.blockedSegment
  const newBoundary = createSuspenseBoundary()
  // 记录插入suspense chunk的下标
  const insertionIndex = parentSegment.chunks.length
  const boundarySegment = createPendingSegment(insertionIndex, newBoundary)
  parentSegment.children.push(boundarySegment)
  parentSegment.lastPushedText = false
  const { fallback, children } = props
  task.node = children
  try {
    retryNode(request, task)
  } catch (thrownValue) {
    if (thrownValue === SuspenseException) {
      const wakeable = getSuspendedThenable()
      const newTask = createRenderTask(
        request,
        children,
        newBoundary,
        createPendingSegment(0, null),
      )
      wakeable.then(newTask.ping, newTask.ping)
    }
  }
  // 获取fallback chunks
  const suspendedFallbackTask = createRenderTask(
    request,
    fallback,
    parentBoundary,
    boundarySegment,
  )
  request.pingedTasks.push(suspendedFallbackTask)
}

2.2.2 writeStartPendingSuspenseBoundary

该方法用于返回SuspenseCompoennt fallback对应的html片段

javascript 复制代码
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk(
  '<!--$?--><template id="',
)
const startPendingSuspenseBoundary2 = stringToPrecomputedChunk('"></template>')
const endSuspenseBoundary = stringToPrecomputedChunk('<!--/$-->')

function writeStartPendingSuspenseBoundary(
  destination,
  renderState,
  id,
) {
  writeChunk(destination, startPendingSuspenseBoundary1)
  writeChunk(destination, renderState.boundaryPrefix)
  writeChunk(destination, id.toString(16))
  writeChunk(destination, startPendingSuspenseBoundary2)
}

function writeEndPendingSuspenseBoundary(destination) {
  writeChunk(destination, endSuspenseBoundary)
}

2.2.3 writeCompletedSegmentInstruction

该方法用于返回SuspenseComponent children对应的html片段

javascript 复制代码
const startSegmentHTML = stringToPrecomputedChunk('<div hidden id="')
const startSegmentHTML2 = stringToPrecomputedChunk('">')
const endSegmentHTML = stringToPrecomputedChunk('</div>')

function writeStartSegment(destination, renderState, id) {
  writeChunk(destination, startSegmentHTML)
  writeChunk(destination, renderState.segmentPrefix)
  writeChunk(destination, id.toString(16))
  writeChunk(destination, startSegmentHTML2)
}

function writeEndSegment(destination) {
  writeChunk(destination, endSegmentHTML)
}

const completeBoundaryScript1Full = stringToPrecomputedChunk(
  completeBoundary + '$RC("',
)
const completeBoundaryScript2 = stringToPrecomputedChunk('","')
const completeBoundaryScript3b = stringToPrecomputedChunk('"')
const completeBoundaryScriptEnd = stringToPrecomputedChunk(')</script>')

function writeCompletedSegmentInstruction(destination, renderState, id) {
  writeChunk(destination, renderState.startInlineScript)
  writeChunk(destination, completeBoundaryScript1Full)
  const idChunk = id.toString(16)
  writeChunk(destination, renderState.boundaryPrefix)
  writeChunk(destination, idChunk)
  writeChunk(destination, completeBoundaryScript2)
  writeChunk(destination, renderState.segmentPrefix)
  writeChunk(destination, idChunk)
  writeChunk(destination, completeBoundaryScript3b)
  writeChunk(destination, completeBoundaryScriptEnd)
}

2.3 hydrateRoot

在往期文章服务端渲染原理解析介绍了客户端水合过程,本文不再赘述,主要讲解水合过程中SuspenseComponent类型FiberNodehydrate处理逻辑。

2.3.1 tryToClaimNextHydratableSuspenseInstance

tryToClaimNextHydratableSuspenseInstance方法用于处理SuspenseComponent类型的FiberNode,核心逻辑如下:

  • 获取SuspenseComponent对应的hydrate dom节点,即注释节点,将其赋值给FiberNodememoizedState属性
  • 创建DehydratedFragment类型FiberNode,建立父子关联关系
javascript 复制代码
function tryToClaimNextHydratableSuspenseInstance(fiber) {
  if (!isHydrating) return
  if (nextHydratableInstance !== null) {
    const suspenseInstance = canHydrateSuspenseInstance(nextHydratableInstance)
    if (suspenseInstance === null) return
    const suspenseState = {
      dehydrated: suspenseInstance,
    }
    fiber.memoizedState = suspenseState
    const dehydratedFragment =
      createFiberFromDehydratedFragment(suspenseInstance)
    dehydratedFragment.return = fiber
    fiber.child = dehydratedFragment
    hydrationParentFiber = fiber
    nextHydratableInstance = null
  }
}

三. 常见问题

3.1 如何插入defer属性的script标签

由于通过renderToPipeableStream方法的bootstrapScripts属性插入的script标签添加的属性是async,并非defer。如果需要插入defer属性的script标签,可以通过组件传参实现。

代码实例如下,核心原理是遍历bootstrapScripts属性生成自定义的script标签,就可以自由控制script标签属性。

javascript 复制代码
function ServerRenderTemplate({ bootstrapScripts }) {
  return (
    <html>
      <head>
        <title>react-ssr</title>
      </head>
      <body>
        <div id='app'>
          <App />
        </div>
        {bootstrapScripts.map(s => (
          <script key={s} src={s} defer></script>
        ))}
      </body>
    </html>
  )
}

四. 总结

服务端流式渲染通过Stream实现html片段按需返回,而非等完整html片段解析完成后才返回,通过这种方式优化接口响应时间,能更好优化前端性能指标TTFBFCP。另外还支持异步操作,能在服务端前置完成数据请求,然后通过script脚本实现页面内容更新。代码仓库

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

相关推荐
却尘2 小时前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare2 小时前
浅浅看一下设计模式
前端
Lee川2 小时前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix2 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人2 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl3 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人3 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼3 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端
布列瑟农的星空3 小时前
前端都能看懂的Rust入门教程(三)——控制流语句
前端·后端·rust