使用 next-i18next 實作中英文多語系 - Modern Next.js Blog 系列 #28
- Published on
本文同步發佈於 it 邦幫忙 2022 iThome 鐵人賽
讓我們來完成這個系列最後一塊重點功能:「i18next 中英文多語系支援」,讓部落格走向國際!
最終效果如下:
Note:
如果你的網站沒有打算支援多語系,這篇文章可以忽略不看。
不影響剩餘 2 篇文章的閱讀與實作。
Next.js 的 多語系支援
Next.js 內建就支援了多語系路由,可以做到根據 path 切分不同語系,效果如下:
- https://easonchang.com/ 指到中文版網站
- https://easonchang.com/en 指到英文版網站
或是根據 subdomain 切分語系,例如:
- example.com 英文版網站
- example.fr 法文版網站
詳細可參考官方文件:Advanced Features: Internationalized Routing | Next.js。
而實際處理多語系字串,要搭配額外套件,像是 next-i18next 或 next-intl 等。
這邊我們使用最熱門的 next-i18next 來實作。
使用 next-i18next 實作中英文多語系
安裝 next-i18next
輸入指令安裝套件:
已複製!pnpm add next-i18next
新增 next-i18next.config.js
:
已複製!module.exports = { i18n: { locales: ["en", "zh-TW"], defaultLocale: "zh-TW", }, };
修改 next.config.mjs
,啟用 next-i18next:
已複製!// ... import i18nConfig from "./next-i18next.config.js"; const { i18n } = i18nConfig; /** @type {import('next').NextConfig} */ const nextConfig = withContentlayer({ // ... i18n, }); export default nextConfig;
修改 src/pages/_app.tsx
,用 appWithTranslation 包住整個 App:
已複製!// ... import { appWithTranslation } from "next-i18next"; import nextI18nConfig from "../../next-i18next.config"; // ... // Explicitly pass nextI18nConfig to suppress i18next console warning // `react-i18next:: You will need to pass in an i18next instance by using initReactI18next` // Ref: https://github.com/i18next/next-i18next/issues/718#issuecomment-1190468800 export default appWithTranslation(MyApp, nextI18nConfig);
加入中英文語系檔案
想在 next-i18next 裡讓一段文字的中英文不同,需要在 public/locales/<locale>/<namespace>.json
路徑新增 JSON 檔,定義每個 i18n key 在不同語系呈現的文字。
我們這裡會新增 en
和 zh-TW
這兩個語系,以及分成兩個 namespace:全站共用的 common
和首頁專屬的 indexPage
。
因此我們需要新增下面四個檔案:
public/locales/en/common.json
public/locales/en/indexPage.json
public/locales/zh-TW/common.json
public/locales/zh-TW/indexPage.json
內容的 key, value 可以依照你的網站內容調整。
新增 public/locales/en/common.json
:
已複製!{ "copied": "Copied!", "table-of-contents": "Table of contents", "home": "Home", "posts": "Posts", "search": "Search", "search-posts": "Search Posts", "next-article": "Next Article", "previous-article": "Previous Article", "published-time": "Published time", "toggle-theme": "Toggle theme", "dark-mode": "Dark mode", "light-mode": "Light mode", "page": "Page", "operation": "Operation", "toggle-language": "Toggle language", "english": "English", "chinese": "中文" }
新增 public/locales/en/indexPage.json
:
已複製!{ "latest-posts": "Latest Posts", "intro-title": "Hey,I am Iron Man ?", "intro-1": "I'm Tony Stark,not Stank!", "intro-2": "I'm rich and have saved the world lots of times.", "intro-3": "I have aliens, purple things, and purple aliens." }
新增 public/locales/zh-TW/common.json
:
已複製!{ "copied": "已複製!", "table-of-contents": "目錄", "home": "首頁", "posts": "文章", "search": "搜尋", "search-posts": "搜尋文章", "next-article": "下一篇文章", "previous-article": "上一篇文章", "published-time": "發佈時間", "toggle-theme": "切換主題", "dark-mode": "暗黑模式", "light-mode": "明亮模式", "page": "頁面", "operation": "操作", "toggle-language": "切換語言", "english": "English", "chinese": "中文" }
新增 public/locales/zh-TW/indexPage.json
:
已複製!{ "latest-posts": "最新文章", "intro-title": "Hey,I am Iron Man ?", "intro-1": "我是 Tony Stark,不是 Stank!", "intro-2": "老子很有錢,拯救過很多次世界。", "intro-3": "我討厭外星人、紫色的東西、和紫色外星人。" }
在各頁面引用語系檔案
修改 src/pages/index.tsx
:
已複製!import { useTranslation } from "next-i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; // ... export const getStaticProps: GetStaticProps<Props> = async (context) => { const locale = context.locale!; // ... return { props: { ...(await serverSideTranslations(locale, ["indexPage", "common"])), // ... }, }; }; // ...
新增 src/configs/i18nConfigs.ts
:
已複製!export const LOCALES = ["en", "zh-TW"]; export const DEFAULT_LOCALE = "zh-TW";
修改 src/pages/posts/[slug].tsx
:
已複製!import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { ParsedUrlQuery } from "querystring"; import { LOCALES } from "@/configs/i18nConfigs"; // ... interface Params extends ParsedUrlQuery { slug: string; } export const getStaticPaths: GetStaticPaths = () => { const paths: string[] = []; LOCALES.forEach((locale) => { paths.push(...allPosts.map((post) => `/${locale}${post.path}`)); }); return { paths, fallback: false, }; }; export const getStaticProps: GetStaticProps<Props, Params> = async ( context ) => { const { slug } = context.params!; const locale = context.locale!; // ... return { props: { ...(await serverSideTranslations(locale, ["common"])), // ... }, }; }; // ...
這樣就能在各元件內用 next-i18next 的 useTranslation
的 t
function 來根據語系顯示不同文字了,範例如下:
已複製!const { t } = useTranslation(["indexPage", "common"]); <p>{t("home")}</p>; // en 顯示 'Home' // zh-TW 顯示 '首頁'
在 Header 加入語系切換按鈕
接著來加入下圖這個語系切換按鈕:
新增 src/components/LanguageSwitch.tsx
:
已複製!/* eslint-disable jsx-a11y/anchor-is-valid */ import Link from "next/link"; import { useRouter } from "next/router"; const LanguageSwitch = () => { const router = useRouter(); const { pathname, query } = router; const nextLocale = router.locale === "en" ? "zh-TW" : "en"; return ( <Link locale={nextLocale} href={{ pathname, query }}> <a aria-label="Toggle Language" className="rounded p-2 text-2xl leading-6 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 sm:p-3" > {router.locale === "en" ? "??" : "??"} </a> </Link> ); }; export default LanguageSwitch;
修改 src/components/Header.tsx
,顯示 LanguageSwitch:
已複製!import LanguageSwitch from "@/components/LanguageSwitch"; // ... export default function Header() { return ( <header className="sticky top-0 z-10 border-b border-slate-900/10 bg-white/70 py-3 backdrop-blur transition-colors dark:border-slate-50/[0.06] dark:bg-gray-900/60"> <SectionContainer> <div className="flex items-baseline justify-between"> // ... <div className="flex items-center text-base leading-5 sm:gap-1"> // ... // 加入 LanguageSwitch <LanguageSwitch /> <ThemeSwitch /> <CommandPaletteToggle /> <MobileNav /> </div> </div> </SectionContainer> </header> ); }
在 Command Palette 指令面板加入語系切換選單
在第 26 篇我們加入了 Command Palette 指令面板,讓讀者能用鍵盤快速執行各種操作。
切換語系也是一個很重要的動作,讓我們把它加入 Command Palette 吧。
效果如下圖:
這邊和第 27 篇在 Command Palette 實作文章搜尋一樣有遇到技術問題。
如果把切換語系的 action 寫進
<CommandPalette/>
的 actions array,changeLocale
函式只會在全站 mount 時初始化一次,會記憶住第一頁 useRouter 的網址。 進到第二頁後呼叫changeLocale
時,雖然能切換語系,但會被強制導回第一頁。因此這邊採用迂迴的方法,在每個 page 使用 useRegisterActions,動態加入每個頁面的 action,
changeLocale
才會在每個頁面重新初始化。
修改 src/components/CommandPalette/CommandPalette.tsx
,加入 language section:
已複製!import { // ... LanguageIcon, } from "@heroicons/react/24/outline"; import { useTranslation } from "next-i18next"; // ... export default function CommandPalette({ children }: Props) { const { t } = useTranslation(["common"]); // ... const actions = [ // ... // - Language toggle { id: "language", name: t("toggle-language"), keywords: "change toggle locale language translation 切換 更換 語言 語系 翻譯", icon: <LanguageIcon className="h-6 w-6" />, section: t("operation"), }, ]; // ... } // ...
新增 src/components/CommandPalette/useCommandPaletteLocaleActions.tsx
:
已複製!import { useRegisterActions } from "kbar"; import { useRouter } from "next/router"; import { useTranslation } from "next-i18next"; export const useCommandPaletteLocaleActions = () => { const router = useRouter(); const { pathname, asPath, query } = router; const { t } = useTranslation(["common"]); const changeLocale = (locale: string) => { router.push({ pathname, query }, asPath, { locale: locale }); }; useRegisterActions( [ { id: "language-english", name: "English", keywords: "locale language translation english 語言 語系 英文 英語", perform: () => changeLocale("en"), icon: <span className="p-1">??</span>, parent: "language", section: t("operation"), }, { id: "language-chinese", name: "中文", keywords: "locale language translation traditional chinese taiwanese 語言 語系 翻譯 中文 台灣 繁體", perform: () => changeLocale("zh-TW"), icon: <span className="p-1">??</span>, parent: "language", section: t("operation"), }, ], [asPath] ); };
新增 src/components/LayoutPerPage.tsx
,用來在每個頁面呼叫 useCommandPaletteLocaleActions
:
已複製!import { useCommandPaletteLocaleActions } from "@/components/CommandPalette/useCommandPaletteLocaleActions"; type Props = { children: React.ReactNode; }; const LayoutPerPage = ({ children }: Props) => { useCommandPaletteLocaleActions(); return <>{children}</>; }; export default LayoutPerPage;
接著將每個頁面用 <LayoutPerPage/>
包住。
修改 src/pages/index.tsx
:
已複製!// ... const Home: NextPage<Props> = ({ posts, commandPalettePosts }) => { // ... return <LayoutPerPage>// ...</LayoutPerPage>; }; // ...
修改 src/pages/posts/[slug].tsx
:
已複製!// ... const PostPage: NextPage<Props> = ( { // ... } ) => { // ... return <LayoutPerPage>// ...</LayoutPerPage>; }; // ...
這樣就成功把切換語系 action 加入 Command Palette 了。
讓 giscus 留言區塊支援多語系
在第 25 篇加入的 giscus 留言板也支援多語系,我們可以把 locale 傳給 <Giscus/>
的 lang
,來讓留言板語言與網站顯示的語言一致。
修改 src/components/Comment.tsx
:
已複製!import { useRouter } from "next/router"; // ... const Comment = () => { // ... const { locale } = useRouter(); return ( <div id="comment" className="mx-auto max-w-prose py-6"> <Giscus // ... lang={locale} /> </div> ); }; // ...
把所有頁面和元件文字換成 i18n key
最後把每個頁面和元件的文字換成 i18n key,就完成多語系處理了。
這邊改動檔案很多,寫法都一樣,這邊只列 2 個當參考,完整改動可以看這支 commit: https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/commit/988eec15c02172c3de1b1de88630afc5bc5e5397
修改 src/components/Header.tsx
,把 CustomLink 內的文字用 t
function 轉換成翻譯後文字:
已複製!import { useTranslation } from 'next-i18next'; // ... export default function Header() { const { t } = useTranslation(['common']); return ( <header className="sticky top-0 z-10 border-b border-slate-900/10 bg-white/70 py-3 backdrop-blur transition-colors dark:border-slate-50/[0.06] dark:bg-gray-900/60"> <SectionContainer> <div className="flex items-baseline justify-between"> // ... <div className="flex items-center text-base leading-5 sm:gap-1"> <div className="hidden gap-1 sm:flex"> {headerConfigs.navLinks.map((link) => ( <CustomLink key={link.title} href={link.href} className="rounded p-3 font-medium text-gray-900 transition-colors hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800" > {t(link.title)} // <-- 修改這一行 </CustomLink> ))} </div> // ... </div> </div> </SectionContainer> </header> ); }
修改 src/components/CustomPre.tsx
,一樣把文字改成 t
function:
已複製!// ... function CustomPre({ children, className, ...props }: Props) { // ... return ( <div className="group relative"> <pre {...props} ref={preRef} className={clsx(className, 'focus:outline-none')} > <div className="absolute top-0 right-0 m-2 flex items-center rounded-md bg-[#282a36] dark:bg-[#262626]"> <span className={clsx('hidden px-2 text-xs text-green-400 ease-in', { 'group-hover:flex': copied, })} > {t('copied')} // <-- 修改這一行 </span> </div> {children} </pre> </div> ); } // ...
成果
這樣就完成了!使用 pnpm dev
,進去網站後按下 Header 的切換語系按鈕,或用 Command Palette 切換語系,就能在中英文語系切換,看到不同語系的網站了!
網址也會根據語系不同:
最終效果如下:
References
- i18next/next-i18next: The easiest way to translate your NextJs apps.
- Advanced Features: Internationalized Routing | Next.js
下一篇
恭喜你成功使用 next-i18next 加入多語系了!
下一篇是 30 天系列最後一篇實作,最後來加入一個微小但重要的功能:舊路徑轉址!