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

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

在往期文章服务端渲染原理解析中介绍了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脚本实现页面内容更新。代码仓库

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

相关推荐
PBitW3 分钟前
工作中突然发现零宽字符串的作用了!
前端·javascript·vue.js
VeryCool4 分钟前
React Native新架构升级实战【从 0.62 到 0.72】
前端·javascript·架构
小小小小宇6 分钟前
JS匹配两数组中全相等对象
前端
xixixin_8 分钟前
【uniapp】uni.setClipboardData 方法失效 bug 解决方案
java·前端·uni-app
狂炫一碗大米饭9 分钟前
大厂一面,刨析题型,把握趋势🔭💯
前端·javascript·面试
星空寻流年15 分钟前
css3新特性第五章(web字体)
前端·css·css3
加油乐21 分钟前
JS计算两个地理坐标点之间的距离(支持米与公里/千米)
前端·javascript
小小小小宇21 分钟前
前端在 WebView 和 H5 环境下的缓存问题
前端
懒羊羊我小弟25 分钟前
React JSX 语法深度解析与最佳实践
前端·react.js·前端框架
冷冷清清中的风风火火28 分钟前
关于敏感文件或备份 安全配置错误 禁止通过 URL 访问 Vue 项目打包后的 .gz 压缩文件
前端·vue.js·安全