一. 什么是服务端流式渲染
在往期文章服务端渲染原理解析中介绍了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
脚本实现页面内容更新。代码仓库
创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!