基于显示来自 Unsplash 的街头摄影图像的多语言应用程序示例,Jan Amann 探索next-intl
在 React Server Components 中实现所有国际化需求,并分享了一种通过简约的客户端占用空间引入交互性的技术。
随着Next.js 13的推出和 App Router 测试版的发布,React 服务器组件已公开可用。这种新模式允许不需要 React 交互功能的组件(例如useState
和useEffect
)仅保留在服务器端。
受益于这一新功能的一个领域是国际化。传统上,国际化需要牺牲性能,因为加载翻译会导致客户端包变大,而使用消息解析器会影响应用的客户端运行时性能。
React Server Components的承诺是,我们可以鱼与熊掌兼得。如果国际化完全在服务器端实现,我们可以为我们的应用程序实现新的性能水平,而将交互式功能留给客户端。但是,当我们需要在国际化消息中反映交互式控制的状态时,我们该如何使用这种模式呢?
在本文中,我们将探索一款显示 Unsplash 街头摄影图片的多语言应用。我们将使用next-intl
React Server Components 实现所有国际化需求,并研究一种以极简的客户端占用空间引入交互性的技术。
从Unsplash获取照片
服务器组件的一个主要优点是能够通过async
/直接从组件内部获取数据await
。我们可以使用它在我们的页面组件中从 Unsplash 获取照片。
但首先,我们需要基于官方 Unsplash SDK 创建我们的 API 客户端。
import {createApi} from 'unsplash-js'; export default createApi({ accessKey: process.env.UNSPLASH_ACCESS_KEY });
一旦我们有了 Unsplash API 客户端,我们就可以在我们的页面组件中使用它。
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
渲染每张照片的组件中的函数来修复格式。
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
。
import {useTranslations} from 'next-intl'; export default function PhotoViewer(/* ... */) { const t = useTranslations('PhotoViewer'); return ( <> <Header title={t('title')} description={t('description')} /> {/* ... */} </> ); }
对于我们添加的每个国际化标签,我们需要确保为所有语言设置了适当的条目。
// 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." } }
// 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
。但是,这需要我们将所有组件移至客户端,从而导致包大小增加。
我们有其他选择吗?有。而且这项功能在网络上已经存在很久了:搜索参数(有时称为查询参数)。搜索参数之所以成为我们用例的绝佳选择,是因为它们可以在服务器端读取。
searchParams
因此让我们修改我们的页面组件以通过 props接收。
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
元素所需的标记。
<select> <option value="popular">Popular</option> <option value="latest">Latest</option> </select>
我们可以将此标记分为两部分:
select
使用交互式客户端组件渲染元素。option
使用服务器组件呈现国际化元素并将它们传递children
给select
元素。
让我们select
为客户端实现该元素。
'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
。
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
它与服务器组件集成在一起。这使我们能够在等待服务器响应时禁用元素。
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 语法,可以表达这些语言细节。
// en.json { "Pagination": { "info": "Page {page, number} of {totalPages, number} ({totalElements, plural, =1 {one result} other {# results}} in total)", // ... } }
这次我们不需要用 来标记组件'use client';
。相反,我们可以用常规锚标签来实现这一点。
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 是用户界面的一部分,因此使用许多搜索参数可能会影响可读性。
网友评论文明上网理性发言 已有0人参与
发表评论: