文章转载自「葉裕安」的个人博客[1]
React 官方在前几天(6/8)发表了新的文章The Plan for React 18[2],新增了一些功能,
像是Automatic batching[3]、startTransition[4]以及今天的主题New Suspense SSR Architecture in React 18[5]。
React18这次带来了全新的SSR
架构,本文重点节选自该文章,并在文末附上我对这个架构的看法。
SSR
架构有什麽缺陷?过去SSR
在服务端运行的步骤如下:
app
所需要的数据app
渲染为静态HTML
并发送response
app
的JavaScript
app
中所有JavaScript
逻辑与服务端产生的HTML
连结在一起(React
官方称hydration
)这种连续而无法中断的流程,衍生了许多的问题。
HTML
之前,必须获取所有数据现在的SSR
不允许component
等待数据。
在渲染HTML
前必须获取所有数据,这样在处理部分缓慢的库或API
时效果并不好。
在载入所有JavaScript
后,React
必须进行hydrate
让所有的HTML
可以被操作。
React
在render
时会走过所有的HTML tree
,并把event handler
绑定到HTML
上。
因此在客户端产生的tree
要跟HTML tree
完全吻合,所以在hydrate
之前必须载入所有组件的JavaScript
。
UI
之前,必须hydrate
所有element
hydrate
本身也有一样的问题,他的过程是连续且不中断的,在整个HTML tree
hydrate
结束前,所有的HTML
都无法被操作。
由于获取数据(server)→ 渲染成HTML
(server)→ 载入code
(client)→ hydrate
(client)的流程本身就是一个waterfall
,
所以为了解决此问题,React
官方提出的新架构就是将整个app
的waterfall
,拆分成多个组件分别执行此流程。
注:
waterfall
原意瀑布,这里指流程必须串行执行
解决办法就是采用了之前提出的Suspense API
。
当前的SSR
在render HTML
及hydrate
时是个0或1的过程,首先你会render HTML
:
<main>
<nav>
<!--NavBar -->
<a href="/">Home</a>
</nav>
<aside>
<!-- Sidebar -->
<a href="/profile">Profile</a>
</aside>
<article>
<!-- Post -->
<p>Hello world</p>
</article>
<section>
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</section>
</main>
客户端会收到一个静态的HTML
(灰色区块代表无法操作):
接著会载入所有代码并进行hydrate
(绿色区块代表可操作):
但在React18
,你可以使用<Suspense>
将需要延迟载入的component
包起来。
例如我们将<Comments>
包起来,告诉React
这个区块准备好之前,先显示 <Spinner />
:
<Layout>
<NavBar />
<Sidebar />
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
因为<Comments>
被<Suspense>
包起来了,所以React
不会等待这个区块渲染完成,就会开始向客户端发送streaming HTML
。
而该区块会显示为fallback
的placeholder
。
现在得到的SSR HTML
会长的像这样:
<main>
<nav>
<!--NavBar -->
<a href="/">Home</a>
</nav>
<aside>
<!-- Sidebar -->
<a href="/profile">Profile</a>
</aside>
<article>
<!-- Post -->
<p>Hello world</p>
</article>
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>
接着当<Comments>
组件在server
准备好时,React
会将额外的HTML
送到同一个stream
,并包含一个inline script
,将该区块放入正确的位置:
<div hidden id="comments">
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</div>
<script>
// 简化实现
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>
结果如下,即便 React 还没被载入,之后的<Comments>
也会被放入正确的位置:
这个架构解决了现行SSR
的第一个问题。
现在render HTML
前就不需获取所有的数据。
而且这个做法与传统的HTML streaming
不同,它并不在乎顺序。
像是你也可以将<Sidebar>
使用<Suspense>
包起来。
因为React
会连带将该组件插入正确位置的script
一起发送,所以不按照顺序也会插入正确的位置。
我们现在已经可以尽早的发送HTML
,但是在<Comments>
的代码载入之前,我们无法为整个客户端的app
进行hydrate
。
如果文件体积很大的话需要一段时间。
为了避免较大的文件体积,你可以使用code splitting
指定部分代码不需要同步载入。
import { lazy } from 'React';
const Comments = lazy(() => import('./Comments.js'));
// ...
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
在过去这是不支持SSR
的。
不过在React18
允许你在<Comments>
组件载入前就开始hydrate
。
从使用者的角度看,他们会先收到无法进行操作的HTML
:
接着React
会进行hydrate
,即便<Comments>
组件的代码还没载入:
这就是Selective Hydration
的例子。
通过将<Comments>
包在<Suspense>
内,告诉React
这个区块不应该block stream
。
同时他也不会block hydrate
。
这样也解决的第二个问题:现在不需等待所有的代码被载入后才开始hydrate
。
React
会在<Comments>
的代码载入完成后继续剩下的hydrate
流程:
React
会自动地处理这些hydrate
流程,例如HTML
还需要一点时间才会stream
完成:
如果JavaScript
代码在HTML stream
完成前就提前载入,React
不会等待HTML
而是直接开始hydrate
:
当<Comments>
的HTML
载入完成后,该区块并不能马上进行操作,因为他的JavaScript
还没被载入:
最后当<Comments>
的JavaScript
载入后整个页面都会变得可以操作:
components
hydrate
完成前进行操作当我们把<Comments>
包在<Suspense>
内时还有额外的加强,现在hydrate
不会block
浏览器的其他行为。
举个例子,当<Comments>
正在hydrate
时点击侧边栏:
在React18
中,<Suspense>
内的hydrate
行为会穿插在浏览器处理事件的间隙之间。
所以点击事件会立即被处理而不会造成浏览器的卡顿,即便在性能较差的设备也是如此。
在我们的例子中只有<Comments>
被<Suspense>
包起来,所以只要一次额外的hydrate
就可以完成整个页面的hydrate
。
我们可以再使用更多的<Suspense>
来调整这个问题:
<Layout>
<NavBar />
<Suspense fallback={<Spinner />}>
<Sidebar />
</Suspense>
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
现在除了<NavBar>
及<Post>
会从服务端拿到HTML
,其他的两个部分都会通过stream
取得。
这样调整也会影响到hydrate
的行为。假设<Suspense>
区块的JavaScript
还没载入:
接着两者的JavaScript
被载入,React
会对这两个<Suspense>
区块进行hydrate
。
因为<Sidebar>
是tree
中较早被找到的,所以会先进行:
若此时用户点击了<Comments>
(该JavaScript
已载入):
React
会纪录这个点击事件,并转而优先对<Comments>
进行hydrate
:
在<Comments>
hydrate
完成后,React
会重播被记录的点击事件(再执行一次)。
最后React
再对<Sidebar>
进行hydrate
:
如此一来就解决了第三个问题,我们不必在互动时就将所有元件都hydrate
。
React
会尽量提早进行hydrate
,并根据使用者操作的部分优先处理。
如果考虑在整个app
中使用<Suspense>
时,Selective Hydration
所带来的好处会更加明显:
在这个例子中,使用者在hydrate
开始时就点击第一个Comment
。
React
会优先处理所有parent <Suspense>
的内容,但跳过所有不相关的sibling
组件。
这就会产生一种hydrate
是即时的错觉,因为被操作的组件至root
路径上的所有组件都会优先被hydrate
。
实际运用时你可能会在root
附近加上<Suspense>
:
<Layout>
<NavBar />
<Suspense fallback={<BigSpinner />}>
<Suspense fallback={<SidebarGlimmer />}>
<Sidebar />
</Suspense>
<RightPane>
<Post />
<Suspense fallback={<CommentsGlimmer />}>
<Comments />
</Suspense>
</RightPane>
</Suspense>
</Layout>
上述范例的初始HTML
内容只会包含NavBar
,其余部分会采用streaming HTML
及部分hydrate
的方式载入,并优先处理使用者操作的区块。
这次React18
在SSR
带来架构性的革新,也取消了当初Concurrent mode
只能选择全用或者不用的情境。
改成Concurrent rendering
并让开发者可以自由的尝试新功能。
这种渐进升级的策略更有助于React
推广新版本。
而过去最常听到需要SSR
的情境通常都是用在SEO
比较多,但其实这次React
发布的新架构反倒是为了使用者体验的推出的。
以官方的例子来说,被Suspense
的区块并不会在第一次render
中出现,所以在搜寻引擎爬到的时候可能会影响SEO
。
不过「Dan」自己也有在该文底下回复关于SEO的问题[6]。
其实只要在遇到搜索引擎时使用onCompleteAll
取代onReadyToStream
就会跟过去SSR
的行为一样了。
但这么做可能造成response
的速度变慢,也会影响排名。
「Sebastian」也有在后续留言补充更多关于此架构在SEO
上能做的调整及取舍。
Google
将在2021年6月中旬将web vitals
纳入搜索引擎排名的一部分,该如何在速度及内容之间作出权衡可能是未来开发者所要面临的课题。
可以窥见未来SEO
及SSR
的玩法会擦出更多火花。
[1]
个人博客: https://jigsawye.com/about
[2]
The Plan for React 18: https://reactjs.org/blog/2021/06/08/the-plan-for-react-18.html
[3]
Automatic batching: https://github.com/Reactwg/React-18/discussions/21
[4]
startTransition: https://github.com/Reactwg/React-18/discussions/41
[5]
New Suspense SSR Architecture in React 18: https://github.com/Reactwg/React-18/discussions/37
[6]
关于SEO的问题: https://github.com/reactwg/react-18/discussions/37#discussioncomment-842581