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

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

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

代码示例如下,需要注意传递给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 异步操作

异步操作最常见的业务场景是获取数据,本文基于此解析服务端渲染中异步操作的处理流程。异步数据处理涉及React的Suspense组件和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类型FiberNode的hydrate处理逻辑。

2.3.1 tryToClaimNextHydratableSuspenseInstance

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

  • 获取SuspenseComponent对应的hydrate dom节点,即注释节点,将其赋值给FiberNode的memoizedState属性
  • 创建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片段解析完成后才返回,通过这种方式优化接口响应时间,能更好优化前端性能指标TTFB和FCP。另外还支持异步操作,能在服务端前置完成数据请求,然后通过script脚本实现页面内容更新。代码仓库

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

相关推荐
小羊没烦恼!4 小时前
T-SQL查询高级—SQL Server索引中的碎片和填充因子
开发语言·前端·javascript·数据库·后端·python·web安全
定栓4 小时前
Webpack迁到Vite第一节:模块系统迁移:require → import
运维·前端·webpack
小熊在忙fpga4 小时前
N32G430芯片的flash表示
前端·javascript·html
Data analyse4564 小时前
App埋点版本兼容的字段迁移校验
大数据·运维·服务器·前端·数据库·数据分析
NC 王炸5 小时前
AI 做的便签,换个浏览器就没了?加一个导出按钮,把数据带走
javascript·ai编程·数据备份·localstorage·txt导出
todoitbo5 小时前
本来只想给内网开个口子,Natapp 却多给了一道防护
前端·javascript·vue.js·内网穿透·网络防护·natapp
会灭火的程序员5 小时前
SuperMap AgentX Server智能体接入WEB应用技术文档
前端·supermap·agentx·agentx server
CRMEB5 小时前
商品 SKU 设计,从简单规格到多规格踩过什么
java·前端·数据库·开源
Chloe.Zz5 小时前
一篇入门Agent
开发语言·前端·javascript
java1234_小锋5 小时前
【技术专题】HTML5 网页技术 - HTML5 表格标签
前端·html·html5