Shopify 是国外的一个允许客户自由搭建商城的 nocode 产品,工程师 Cathryn Griffiths 分享了他在 Shopify 中实用 React Server Component 的最佳实践。
Hydrogen 是基于 React 的框架用来创建自定义店面的框架,他们试用 RSC(React Server Component)有两个理由:
- 再见了,臃肿的 bundle 体积,你好,更棒的购物体验!
- 技术人的一种自私情结:这玩意一定很有趣!
这是一件很有挑战性的事。RSC 是一种范式转变,一开始他们遇到的问题是构建的客户端组件太多,服务器组件太少。经过数月的反复尝试和重构才找到较好的方案。
这篇文章将着重讨论工程师在构建 Hydrogen 时候发现的 RSC 最佳实践,不光是对个人的,也是对团队的。希望能让读者们更加理解如何在 RSC 应用中编写组件,减少你的无效时间。
优先写共享组件
当你需要在 RSC 应用程序中从头构建组件时,请从共享组件开始。共享组件可以同时在服务器和客户端上下文中执行,而不会出现任何问题。它们是客户端和服务器组件之间的天然中间地带,是个不错的起点。
从中间地带开始,可以帮助你更好的思考,引导你构建正确类型的组件。你必须问自己:“这段代码只能在客户机上运行吗?”,类似地,“这段代码应该在客户机上执行吗?”下一节列出了一些您应该问的问题。
不要总是默认构建客户端组件。虽然方便,但最后应用程序会太臃肿,很多组建更适合在服务端运行。
在少数情况下选择客户端组件
RSC 应用程序中的大多数组件应该是服务器组件,因此在确定是否需要客户端组件时,需要仔细分析用例。
通常只有客户端特定的逻辑部分需要被提取到客户端组件中:
- 整合客户端交互性
- 用了
useState
或useReducer
- 用了生命周期渲染逻辑(比如
useEffect
) - 用了不支持 RSC 的第三方库
- 用了服务端不支持的浏览器 APIs
重要说明:不要只是盲目将整个共享组件转换为客户端组件。相反,有意地提取需要的特定功能。这有助于保持您的客户端组件和 bundle 尺寸尽可能的小。文章末尾会有一些示例。
尽可能以服务端组件为主
如果组件不包含任何客户端组件用例,那么它应该被改为服务器组件(如果它符合以下条件之一):
- 该组件包含不应该在客户端上暴露的代码,如专用业务逻辑和密钥。
- 客户端组件中不会使用该组件。(RSC 的限制,客户端组件中不能直接导入服务端组件)
- 代码从不在客户端上执行(据你所知)。
- 代码需要访问文件系统或数据库(客户端上不可用)。
- 代码需要从 StoreFront API 获取数据(在 Hydrogen 中特定的情况)。
如果组件需要在客户端组件中使用,可以先深入研究用例和实现。很可能你可以将组件实例作为 children props 传递给客户端组件,而不是让客户端组件直接导入并实用它。这样就不需要把组件转换为客户端组件了。
探索一些例子
有很多东西需要记住,我们可以用 Hydrogen 启动模板来试几个例子。
订阅注册
第一个示例是一个组件,它允许买家注册订阅我的在线商店的时事通讯。它出现在每个页面的页脚,看起来像这样:
我们从一个名为 NewsletterSignup.jsx
的共享组件开始:
export default function NewsletterSignup() {
return (
<div>
<p>
Sign up for our newsletter to never miss out on latest news and product
drops!
</p>
<label for="emailInput">Email</label>
<input type="text" id="emailInput" name="email" placeholder="Email" />
<button
onClick={() => {
/* TODO */
}}
>
Sign me up
</button>
</div>
);
}
在这个组件中,我们有两个客户端交互部分(输入字段和提交按钮),这说明这个当前编写的组件不能是共享组件。
我们别将其完全转换为客户端组件,而是将客户端功能提取到一个单独的 NewsletterSignupForm.client.jsx
组件里:
export default function NewsletterSignupForm() {
return (
<>
<label for="emailInput">Email</label>
<input type="text" id="emailInput" name="email" placeholder="Email" />
<button
onClick={() => {
/* TODO */
}}
>
Sign me up
</button>
</>
);
}
然后更新 NewsletterSignup
组件来使用这个客户端组件:
import NewsletterSignupForm from './NewsletterSignupForm.client';
export default function NewsletterSignup() {
return (
<div>
<p>
Sign up for our newsletter to never miss out on latest news and product
drops!
</p>
<NewsletterSignupForm />
</div>
);
}
我们很容易到此为止,并将 NewsletterSignup
组件保持为一个共享组件。然而我知道这个组件只在我的在线商店的页脚中使用,而我的页脚组件是一个服务端组件。所以它不需要是一个共享组件,也不需要成为客户端 bundle 的一部分,简单地将其重命名为 NewsletterSignup.server.jsx
来安全地将其更改为服务端组件。
搞定,你可以在最终的 Stackblitz 代码示例 中查看这个时事通讯注册组件。
产品常见问题组件
在下一个示例中,我们将产品常见问题部分添加到产品页面。这里的内容是静态的,对我的在线商店中的每个产品都是一样的。来自买家的互动可以展开或收起内容。它看起来是这样的:
让我们从一个共享的ProductFAQs.jsx
开始。jsx 组件:
export default function ProductFAQs() {
return (
<ul>
<li>
<span>Where was this board made?</span>
<p>
All our boards are designed in Canada by our Hydrogen design team.
</p>
<p>Materials are sourced from local manufacturers.</p>
<p>
Assembly is done by our skilled team on site in our brick and mortar
shop.
</p>
</li>
<li>
<span>What if I don't like it?</span>
<p>
The Hydrogen team stands by their products. We strive to delivery high
quality boards that will last a lifetime and, importantly, make you
happy.
</p>
<p>
That said, if you don't like it, you can return it to us (free of
cost!) and we'll reimburse you the money. Contact us directly for more
details.
</p>
</li>
</ul>
);
}
接下来,我们将把它添加到产品页面。ProductDetails.client
组件用于展示此页面的主要内容,因此很容易把ProductFAQs
转换为客户端组件,这样 ProductDetails 组件可以直接导入使用它。但是,我们可以通过将 ProductFAQs 传递给 product/[handle].server.jsx
页面来避免这种情况:
import ProductFAQs from '../../components/ProductFAQs';
export default function Product({ country = { isoCode: 'US' } }) {
// ...
return (
<Layout>
<ProductDetails product={data.product}>
<ProductFAQs />
</ProductDetails>
</Layout>
);
}
然后更新 ProductDetails
组件来使用 children
:
export default function ProductDetails({ product, children }) {
// ...
return (
<>
<Seo product={product} />
<Product product={product} initialVariantId={initialVariant.id}>
...
</Product>
{children}
</>
);
}
接下来,我们想要将客户端交互部分添加到 ProductFAQs
组件。同样,我们很容易直接将 ProductFAQ 组件从共享组件转换为客户端组件,但没必要。这些交互仅用于展开和收起 FAQ 内容,而内容本身是硬编码的,不需要成为客户端 bundle 的一部分。我们要做的是将客户端交互提取到一个专门的客户端组件Accordion.client.jsx
:
import { useState } from 'react';
export default function Accordion({ heading, children }) {
const [open, setOpen] = useState(false);
return (
<div>
<div
onClick={() => {
setOpen(!open);
}}
>
<span>{heading}</span>
<span>{open ? '-' : ' '}</span>
</div>
{open && children}
</div>
);
}
更新ProductFAQs
组件来使用Accordion
:
import Accordion from './Accordion.client';
export default function ProductFAQs() {
return (
<ul>
<li>
<Accordion heading="Where was this board made?">
<>
<p>
All our boards are designed in Canada by our Hydrogen design team.
</p>
<p>Materials are sourced from local manufacturers.</p>
<p>
Assembly is done by our skilled team on site in our brick and
mortar shop.
</p>
</>
</Accordion>
</li>
<li>
<Accordion heading="What if I don't like it?">
<>
<p>
The Hydrogen team stands by their products. We strive to delivery
high quality boards that will last a lifetime and, importantly,
make you happy.
</p>
<p>
That said, if you don't like it, you can return it to us (free of
cost!) and we'll reimburse you the money. Contact us directly for
more details.
</p>
</>
</Accordion>
</li>
</ul>
);
}
此时,不再有理由让 ProductFAQs 组件保持为共享组件了。所有的客户端交互都已经被提取出来,并且,类似于NewsletterSignup
组件,我知道这个组件永远不会被客户端组件使用。现在剩下的就是:
- 重命名
ProductFAQs.jsx
文件为ProductFAQs.server.jsx
- 更新
product/[handle].server.jsx
中的 import 声明 - 通过 Tailwind 添加一些漂亮的样式。
你可以在 Stackblitz 中查看 Product FAQ 代码
React Server Components 是一种范式转变,为 RSC 应用程序编写组件可能需要一些时间来适应。当你在构建时,请记住以下几点:
- 从共享组件开始。
- 在特定情况下,将功能提取到客户端组件中。
- 如果代码永远不需要或永远不应该在客户机上执行,则改写为服务端组件。