文章转载自「葉裕安」的个人博客[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并发送responseapp的JavaScriptapp中所有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所有elementhydrate本身也有一样的问题,他的过程是连续且不中断的,在整个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