随着Next.js 13和 App Router 测试版的推出,React Server Components 开始公开可用。这种新范例允许不需要 React 交互功能的组件(例如useState
和useEffect
)仅保留在服务器端。
受益于这一新功能的一个领域是国际化。传统上,国际化需要在性能上进行权衡,因为加载翻译会导致更大的客户端包,而使用消息解析器会影响应用程序的客户端运行时性能。
React Server Components的承诺是我们可以鱼与熊掌兼得。如果国际化完全在服务器端实现,我们可以为我们的应用程序实现新的性能水平,而将交互功能留给客户端。但是,当我们需要应在国际化消息中反映出来的交互式控制状态时,我们如何使用这种范式呢?
在本文中,我们将探索一款显示来自 Unsplash 的街头摄影图像的多语言应用程序。我们将使用next-intl
在 React Server 组件中来实现所有国际化需求,并且我们将研究一种通过简约的客户端足迹引入交互性的技术。
从 Unsplash 获取照片
服务器组件的一个主要优点是能够通过async
/await
直接从内部组件获取数据。我们可以使用它从页面组件中的 Unsplash 获取照片。
但首先,我们需要基于官方 Unsplash SDK 创建 API 客户端。
js
import {createApi} from 'unsplash-js';
export default createApi({
accessKey: process.env.UNSPLASH_ACCESS_KEY
});
一旦我们有了 Unsplash API 客户端,我们就可以在我们的页面组件中使用它。
js
import {OrderBy} from 'unsplash-js';
import UnsplashApiClient from './UnsplashApiClient';
export default async function Index() {
const topicSlug = 'street-photography';
const [topicRequest, photosRequest] = await Promise.all([
UnsplashApiClient.topics.get({topicIdOrSlug: topicSlug}),
UnsplashApiClient.topics.getPhotos({
topicIdOrSlug: topicSlug,
perPage: 4
})
]);
return (
<PhotoViewer
coverPhoto={topicRequest.response.cover_photo}
photos={photosRequest.response.results}
/>
);
}
注意: 我们用Promise.all
调用需要并行发出的两个请求。这样,我们就避免了请求瀑布。
此时,我们的应用程序渲染了一个简单的照片网格。
该应用程序目前使用硬编码的英文标签,并且照片的日期显示为时间戳,这对于用户来说还不是很友好。
使用next-intl
添加国际化
除了英语之外,我们还希望我们的应用程序能够提供西班牙语版本。对next-intl
服务器组件的支持目前处于测试阶段,因此我们可以使用最新测试版的安装说明来设置我们的应用程序以实现国际化。
格式化日期
除了添加第二语言之外,我们已经发现该应用程序不太适合英语用户,因为日期应该被格式化。为了获得良好的用户体验,我们希望告诉用户照片上传的相对时间(例如"8天前")。
next-intl
设置完成后,我们可以使用format.relativeTime
渲染每张照片的组件中的函数来修复格式。
js
import {useFormatter} from 'next-intl';
export default function PhotoGridItem({photo}) {
const format = useFormatter();
const updatedAt = new Date(photo.updated_at);
return (
<a href={photo.links.html}>
{/* ... */}
<p>{format.relativeTime(updatedAt)}</p>
</div>
</a>
);
}
现在,照片更新的日期更易于阅读。
提示: 在服务器和客户端都呈现的传统 React 应用程序中,确保显示的相对日期在服务器和客户端之间同步可能是一个相当大的挑战。由于这些是不同的环境并且可能位于不同的时区,因此您需要配置一种机制将服务器时间传输到客户端。通过仅在服务器端执行格式化,我们不必在一开始就担心这个问题。
你好!将我们的应用程序翻译成西班牙语
接下来,我们可以用本地化消息替换标头中的静态标签。这些标签作为PhotoViewer
组件的 props 传递,因此这是我们通过钩子引入动态标签的机会useTranslations
。
js
import {useTranslations} from 'next-intl';
export default function PhotoViewer(/* ... */) {
const t = useTranslations('PhotoViewer');
return (
<>
<Header
title={t('title')}
description={t('description')}
/>
{/* ... */}
</>
);
}
对于我们添加的每个国际化标签,我们需要确保为所有语言设置了适当的条目。
js
// en.json
{
"PhotoViewer": {
"title": "Street photography",
"description": "Street photography captures real-life moments and human interactions in public places. It is a way to tell visual stories and freeze fleeting moments of time, turning the ordinary into the extraordinary."
}
}
js
// es.json
{
"PhotoViewer": {
"title": "Street photography",
"description": "La fotografía callejera capta momentos de la vida real y interacciones humanas en lugares públicos. Es una forma de contar historias visuales y congelar momentos fugaces del tiempo, convirtiendo lo ordinario en lo extraordinario."
}
}
提示: next-intl
提供 TypeScript 集成,帮助您确保仅引用有效的消息键。
完成此操作后,我们可以访问该应用程序的西班牙语版本/es
: 。
到目前为止,一切都很好!
添加交互性:照片的动态排序
默认情况下,Unsplash API 返回最受欢迎的照片。我们希望用户能够更改顺序以首先显示最新的照片。
这里,问题就出现了,我们是否应该求助于客户端数据获取,以便我们可以使用useState
。然而,这需要我们将所有组件移至客户端,从而导致包大小增加。
我们还有其他选择吗?是的。这是一项在网络上已经存在多年的功能:搜索参数(有时称为查询参数)。搜索参数对于我们的用例来说是一个很好的选择,因为它们可以在服务器端读取。
因此,让我们修改页面组件以通过 props 接收searchParams
。
js
export default async function Index({searchParams}) {
const orderBy = searchParams.orderBy || OrderBy.POPULAR;
const [/* ... */, photosRequest] = await Promise.all([
/* ... */,
UnsplashApiClient.topics.getPhotos({orderBy, /* ... */})
]);
进行此更改后,用户可以导航到/?orderBy=latest
更改显示照片的顺序。
为了使用户能够轻松更改搜索参数的值,我们希望从组件内呈现交互式select
元素。
我们可以标记组件以'use client';
附加事件处理程序并处理来自select
元素的更改事件。尽管如此,我们希望将国际化问题保留在服务器端,以减少客户端包的大小。
让我们看看我们的select
元素所需的标记。
js
<select>
<option value="popular">Popular</option>
<option value="latest">Latest</option>
</select>
我们可以将此标记分为两部分:
- 使用交互式客户端组件渲染
select
元素。 - 用Server Component呈现国际化的
option
元素,并将它们作为children
传递给select
元素。
让我们为客户端实现select
元素。
js
'use client';
import {useRouter} from 'next-intl/client';
export default function OrderBySelect({orderBy, children}) {
const router = useRouter();
function onChange(event) {
// The `useRouter` hook from `next-intl` automatically
// considers a potential locale prefix of the pathname.
router.replace('/?orderBy=' + event.target.value);
}
return (
<select defaultValue={orderBy} onChange={onChange}>
{children}
</select>
);
}
现在,让我们在PhotoViewer
中使用我们的组件,并提供本地化的option
元素作为children
。
js
import {useTranslations} from 'next-intl';
import OrderBySelect from './OrderBySelect';
export default function PhotoViewer({orderBy, /* ... */}) {
const t = useTranslations('PhotoViewer');
return (
<>
{/* ... */}
<OrderBySelect orderBy={orderBy}>
<option value="popular">{t('orderBy.popular')}</option>
<option value="latest">{t('orderBy.latest')}</option>
</OrderBySelect>
</>
);
}
使用此模式,option
元素的标记现在在服务器端生成并传递给OrderBySelect
,后者处理客户端的更改事件。
提示 :由于当订单更改时我们必须等待服务器端生成更新的标记,因此我们可能希望向用户显示加载状态。React 18 引入了hook useTransition
,它与服务器组件集成。这允许我们在等待服务器响应时禁用select
元素。
js
import {useRouter} from 'next-intl/client';
import {useTransition} from 'react';
export default function OrderBySelect({orderBy, children}) {
const [isTransitioning, startTransition] = useTransition();
const router = useRouter();
function onChange(event) {
startTransition(() => {
router.replace('/?orderBy=' + event.target.value);
});
}
return (
<select disabled={isTransitioning} /* ... */>
{children}
</select>
);
}
添加更多交互性:页面控件
通过引入page
搜索参数,我们探索的用于更改顺序的相同模式可以应用于页面控件。
请注意,语言对于处理小数点和千位分隔符有不同的规则。此外,语言具有不同的复数形式:而英语仅在一个和零/多个元素之间进行语法区分,例如,克罗地亚语对"少数"元素有单独的形式。
next-intl
使用ICU 语法可以表达这些语言的微妙之处。
js
// en.json
{
"Pagination": {
"info": "Page {page, number} of {totalPages, number} ({totalElements, plural, =1 {one result} other {# results}} in total)",
// ...
}
}
这次我们不需要用'use client';
来标记组件。相反,我们可以使用常规锚标记来实现这一点。
js
import {ArrowLeftIcon, ArrowRightIcon} from '@heroicons/react/24/solid';
import {Link, useTranslations} from 'next-intl';
export default function Pagination({pageInfo, orderBy}) {
const t = useTranslations('Pagination');
const totalPages = Math.ceil(pageInfo.totalElements / pageInfo.size);
function getHref(page) {
return {
// Since we're using `Link` from next-intl, a potential locale
// prefix of the pathname is automatically considered.
pathname: '/',
// Keep a potentially existing `orderBy` parameter.
query: {orderBy, page}
};
}
return (
<>
{pageInfo.page > 1 && (
<Link aria-label={t('prev')} href={getHref(pageInfo.page - 1)}>
<ArrowLeftIcon />
</Link>
)}
<p>{t('info', {...pageInfo, totalPages})}</p>
{pageInfo.page < totalPages && (
<Link aria-label={t('prev')} href={getHref(pageInfo.page + 1)}>
<ArrowRightIcon />
</Link>
)}
</>
);
}
结论
服务器组件非常适合国际化
无论您支持多种语言还是想要正确理解单一语言的微妙之处,国际化都是用户体验的重要组成部分。像这样的库next-intl
可以帮助解决这两种情况。
历史上,在 Next.js 应用程序中实现国际化会带来性能权衡,但对于服务器组件,情况不再如此。但是,可能需要一些时间来探索和学习将帮助您在服务器端保持国际化关注的模式。
在我们的街头摄影查看器应用程序中,我们只需将一个组件移动到客户端:OrderBySelect
。
需要注意的另一个方面是,您可能需要考虑实现加载状态,因为网络延迟会在用户看到其操作结果之前引入延迟。
搜索参数是useState
的一个很好的替代方案
搜索参数是在 Next.js 应用程序中实现交互功能的好方法,因为它们有助于减少客户端的包大小。
除了性能之外,使用搜索参数还有其他好处:
- 可以共享带有搜索参数的 URL,同时保留应用程序状态。
- 书签也保留状态。
- 您可以选择与浏览器历史记录集成,从而可以通过后退按钮撤销状态更改。
但请注意,还需要考虑权衡:
- 搜索参数值是字符串,因此您可能需要序列化和反序列化数据类型。
- URL 是用户界面的一部分,因此使用许多搜索参数可能会影响可读性。
您可以在 GitHub 上查看示例的完整代码。