消失的时间在往返里,不在渲染里
一句话总结
即使服务端渲染很快,只要请求数据的顺序不对,首屏之后仍会接连出现一串往返。边界(boundary)放在哪里,决定了这个顺序。
为什么需要它
常有人说引入 SSR 之后,体感并没有好多少。测一下服务端渲染耗时是 30ms,但用户真正能用上页面要等 2 秒。消失的时间不在渲染上,而在往返上。
典型的形态是这样的:页面先获取用户信息,再用其中的团队 id 获取团队,再获取该团队的项目列表,再获取每个项目的最近活动。从代码上看是很自然的嵌套,从网络上看却是排成一列的四次往返。如果延迟是 200ms,800ms 就这样凭空消失了。
还有更糟的形态。如果每个组件都自己获取自己的数据,父组件渲染出来之后子组件才存在,子组件存在之后它的请求才会发出。这种结构里,组件树的深度就是往返次数。
工作原理
不必互相等待的请求,一起发出
消除瀑布(waterfall)的第一步,是问一句“这个请求真的用到了上一个响应的值吗”。大多数时候并没有。团队信息和通知列表互相并不需要知道对方,只是因为代码的写法才排起了队。把它们一次发出,往返就缩减成一次。
如果确实需要上一个响应,就走合并请求的路线。在服务端一次性组装好再下发,客户端只需要一次往返,而服务端内部的查询发生在同一个数据中心里。
等什么,由边界决定
在 React 的流式渲染中,位于 <Suspense> 边界之外的部分称为外壳(shell)。外壳先下发,边界内部的内容则在准备好之后陆续流出(renderToPipeableStream)。所以把慢数据放进边界里就是设计的全部。只要有一个慢的东西留在外壳里,整个首字节就会被它拖慢。
有一点需要注意:只有读取了能激活边界的数据源,才会激活边界。React 文档以用 use 读取的 Promise 为例,并明确指出,在 Effect 或事件处理函数里获取的数据不会让渲染暂停(Suspense)。如果包了边界,却在 Effect 里获取数据,就看不到回退内容(fallback),输出的 HTML 里首屏只是一块空位,而这块空位正是水合不一致的素材。
边界切得越细,首屏越快,但页面会多次抖动。边界划得大,画面安静却更慢。这里的标准不是个人喜好,而是这个位置是不是用户一看到就要用的东西。
缓存和个性化不能共用同一个响应
预先填好数据的 HTML,本身就是该用户的数据。正如在 ssr-core 中看到的,如果给个性化响应加上公共缓存指令,CDN 就会把别人的页面交给你。而且问题不只在响应头。如果同一个地址会根据条件返回不同内容,缓存就必须知道这个条件。这就是 Vary 的作用:它是一个响应头,记录的不是方法和 URL,而是请求的哪些部分影响了响应内容;Vary: * 则表示有请求头之外的因素参与,暗示不能缓存(Vary)。
实际工作中可行的分法是这样的:先下发可以公共缓存的外壳,每个人不同的片段在边界内部单独获取。这样缓存只作用于外壳,个性化被关在边界里面,这条线与圈住水合不一致的边界是同一条线。
在现场相遇的样子
“服务端渲染只要 30ms,页面却要 2 秒”这类反馈,大多数是瀑布。确认的方法不是看性能分析器,而是看网络面板里的瀑布图。请求条像台阶一样错开,就是排了队;并排同时开始,就是并行。台阶有几级,就是能消除的往返数。
经常还能看到包了边界、数据却在 Effect 里获取的代码。页面上能看到转圈动画,感觉像是正常工作,但那个转圈动画不是流式回退内容,而是客户端状态。服务端下发的 HTML 里只有空位,首屏的值依然要等浏览器收到之后才会出现。
最后,根据是否登录输出不同 HTML、却没有写 Vary 的页面,总有一天会在 CDN 前面出事。因为缓存会把最先收到的那一份给所有人。
选择边界这件事,最终可以归结为一句话:用户一看到就要用的放进外壳,可以稍等的放进边界里。这条线一划,首字节被什么拖住、最多能公共缓存到哪里、不一致会蔓延到哪里,就一并决定了。这三件事位于同一条线上,是 SSR 设计中最晚才会意识到的事实。
下一项测验要确认什么
通过六道题确认:如何识别瀑布、外壳是什么、是什么激活了边界,以及为什么个性化响应需要 Vary。