多语言方案介绍与迁移(二) - 结合nextjs-ssg方案

2,217 阅读6分钟

介绍

在上篇文章中,我介绍了如何同步翻译。而本篇文章将结合实例,介绍在nextjs-ssg的框架中如何使用多语言。

目前nextjs对ssg支持并不完善,仍存在不少不支持的功能,其中就有多语言路由部分。在ssr模式下,可以通过简单的配置实现通过路径区分页面语言。但ssg缺少相应的支持。

结合next-i18next实现多语言

首先整理下需求:

  1. 获取当前语言并根据语言获取翻译
  2. 通过路由路径决定页面语言,如/en/about
  3. 站内跳转链接,正确跳转到当前语言页面
  4. 在html标签中添加lang属性,优化SEO

接下来就开始实现的步骤:

1. 安装next-i18next

该库用于获取当前语言并根据语言获取翻译。

yarn add next-i18next react-i18next i18next

2. 根据上篇文章,结合Google Sheets将翻译文案下载至项目。

3. 根目录下创建next-i18next.config.js设置语言种类、翻译文件路径等。

// next-i18next.config.js
module.exports = {
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'de'],
  },
  localePath: '/public/my-custom/path',
}

4. 创建多语言页面

由于nextjs没有提供配置化生成多语言页面的功能,所以我们需要用比较繁琐的方式自行实现该功能。

通过路由路径决定页面语言,即每种语言都生成对应的页面,而nextjs在ssg模式下动态生成页面是通过getStaticPaths函数实现的。在页面组件中额外导出一个getStaticPaths即可根据参数生成多个页面。

// pages/posts/[id].js

// Generates `/posts/1` and `/posts/2`
export async function getStaticPaths() {
  return {
    paths: [{ params: { id: '1' } }, { params: { id: '2' } }],
    fallback: false, // can also be true or 'blocking'
  }
}

而页面的传入参数可以使用getStaticProps的函数实现。在页面组件中额外导出一个getStaticPaths即可将参数传入页面中。

export async function getStaticProps(context) {
  return {
    props: {}, // will be passed to the page component as props
  }
}

利用这两个功能,我们可以创建一个动态的语言路径,指定语言作为路径标识,并将翻译与语言标识传入页面中。为了方便调用,我们还可以将其集中在utils文件夹中。

// src/utils/intl/get-static.ts
import { GetStaticPaths } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import i18nextConfig from '../../../next-i18next.config';

export const getI18nPaths = () =>
  i18nextConfig.i18n.locales.map((lng) => ({
    params: {
      locale: lng,
    },
  }));

export const getStaticPaths: GetStaticPaths = () => ({
  fallback: false,
  paths: getI18nPaths(),
});

export async function getI18nProps(ctx: any, ns = ['global']) {
  const locale = ctx?.params?.locale ?? i18nextConfig.i18n.defaultLocale;
  let props = {
    ...(await serverSideTranslations(locale, ns)),
    locale,
  };
  return props;
}

// ns 代表翻译模块,不同页面引入不同模块列表
export function makeStaticProps(ns: string[]) {
  return async function getStaticProps(ctx: any) {
    return {
      props: await getI18nProps(ctx, ns),
    };
  };
}
// 页面调用时
// pages/[locale]/about/index.tsx
import { useTranslation } from 'next-i18next';
import { makeStaticProps, getStaticPaths } from '@/utils/intl/get-static';

// 每个页面必须包含这部分代码,代表初始化翻译参数
const getStaticProps = makeStaticProps(['global']);
export { getStaticPaths, getStaticProps };

export default function About() {
  const globalT = useTranslation('global');

  return <div>{globalT('titile')}</div>;
}

完成了这一步后,就可以打开你的页面,根据路径查看到不同语言页面。

5. 站内跳转链接

由于多语言的路由前缀是我们自行添加的,next原本的Link标签与useRouter方法是不会自动添加语言前缀的,如果每次调用时都手动获取当前语言标识并添加到链接上也是比较繁琐。所以这里可以直接将Link标签与useRouter方法进行封装。

// Link标签
import React from 'react';
import { default as NextLink, LinkProps } from 'next/link';
import { useRouter } from 'next/router';

type PropsType = {
  children: React.ReactNode;
  skipLocaleHandling?: boolean;
} & LinkProps;

const Link = ({ children, skipLocaleHandling, ...rest }: PropsType) => {
  const router = useRouter();
  const locale = (rest.locale || router.query.locale || '') as string;

  let href = String(rest.href || router.asPath);
  if (href.indexOf('http') === 0) skipLocaleHandling = true;
  if (locale && !skipLocaleHandling) {
    href = href
      ? `/${locale}${href}`
      : router.pathname.replace('[locale]', locale);
  }

  return (
    <>
      <NextLink href={href}>
        <a {...rest} href={href}>
          {children}
        </a>
      </NextLink>
    </>
  );
};

export default Link;
// useRouter方法
import { useRouter } from 'next/router';

// 重写useRouter的push\replace方法,添加国际化前缀
export default function useIntlRouter() {
  const router = useRouter();

  const routerPush: typeof router.push = (url, ...rest) => {
    if (typeof url === 'string' && url.startsWith('/')) {
      url = `/${router.query.locale}${url}`;
    } else if (
      typeof url === 'object' &&
      url.pathname &&
      url.pathname.startsWith('/')
    ) {
      url.pathname = `/${router.query.locale}${url.pathname}`;
    }
    return router.push(url, ...rest);
  };

  const routerReplace: typeof router.replace = (url, ...rest) => {
    if (typeof url === 'string' && url.startsWith('/')) {
      url = `/${router.query.locale}${url}`;
    } else if (
      typeof url === 'object' &&
      url.pathname &&
      url.pathname.startsWith('/')
    ) {
      url.pathname = `/${router.query.locale}${url.pathname}`;
    }
    return router.replace(url, ...rest);
  };
  return {
    ...router,
    push: routerPush,
    replce: routerReplace,
  };
}

6. 添加浏览器标识

_app.js_document.js文件中,可以添加语言标识与相应的全局样式。

import Document, {
  Html,
  Head,
  Main,
  NextScript,
} from 'next/document';
import { Locales } from '@/constants/intl';
import i18nextConfig from '../../next-i18next.config';

class MyDocument extends Document {
  render() {
    const currentLocale = (this.props.__NEXT_DATA__.query.locale ||
      i18nextConfig.i18n.defaultLocale) as string;
    const isAr = currentLocale === Locales.ar;
    return (
      <Html
        lang={currentLocale}
        className={isAr ? 'rtl' : ''}
        dir={isAr ? 'rtl' : 'auto'}
      >
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}
export default MyDocument;

7. 默认页面重定向

由于在创建页面时,我们只创建了多语言的页面,而在访问非多语言页面时,目前会直接显示404,我们应该将其重定向至用户本地语言的页面。

首先创建检测语言的方法,这里的检测方法在不同的需求中需要视自身情况进行调整。

// src/utils/intl/languge-detector.ts
import { Locales } from '@/constants/intl';
import { isBrowser } from '@/utils/is-browser';
import { getQuery } from '../get-query';

const getLangByUL = (defaultLang?: string) => {
  // 通过navigator.language获取
  const navigatorLang = window.navigator.language;
  if (!navigatorLang) return defaultLang || '';

  let lang = defaultLang || '';
  if (navigatorLang.startsWith('ar')) {
    lang = Locales['ar-EG'];
  } else if (navigatorLang.startsWith('en')) {
    lang = Locales['en-US'];
  }
  return lang;
};

// 在浏览器环境检测当前语言
// 在React组件中使用useIntlRouter().query.locale代替
export default function languageDetector() {
  if (!isBrowser()) return Locales['en-US'];

  // 1. 检测路径前缀是否包含语言
  const pathname = location.pathname;
  for (let locale in Locales) {
    if (pathname.startsWith(`/${locale}`)) {
      return locale;
    }
  }
  // 2. 检测查询参数
  const query = getQuery();
  for (let locale in Locales) {
    if (query.lang === locale) {
      return locale;
    }
  }
  // 3. 检测navigator.language
  const uaLang = getLangByUL();
  if (uaLang) {
    return uaLang;
  }
  return Locales['en-US'];
}

检测到当前用户本地语言后,就可以根据目前的路径与语言进行重定向

// src/utils/intl/redirect.ts
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { Locales } from '@/constants/intl';
import languageDetector from './language-detector';

export const useRedirect = (to?: string) => {
  const router = useRouter();
  const target = to || router.asPath;

  // language detection
  useEffect(() => {
    const detectedLng = languageDetector();
    console.log(detectedLng);
    if (
      router.asPath.startsWith('/' + detectedLng) &&
      router.asPath === target
    ) {
      // 如果当前路径就是目标路径
      return;
    }
    if (target.startsWith('/' + detectedLng) && router.route === '/404') {
      // prevent endless loop
      const targetPath = '/' + detectedLng + router.route;
      location.href = targetPath;
      return;
    }

    const targetPath = '/' + detectedLng + target;
    location.href = targetPath;
  }, []);

  return <></>;
};

export const Redirect = () => {
  useRedirect();
  return <></>;
};

// eslint-disable-next-line react/display-name
export const getRedirect = (to: string) => () => {
  useRedirect(to);
  return <></>;
};
// 页面调用时
// pages/about
import { Redirect } from '@/utils/intl/redirect';

export default Redirect;

总结

本文详细描述了如何在nextjs的ssg模式下引入多语言方案,由于nextjs官方无支持,所以整个流程较为繁琐。希望nextjs官方能早日提供更方便解决方案,在那之前,希望本文能够帮助到正在寻求解决方案的同学。

目前实践中本方案仍有些不方便的地方:1. 需要每个页面都添加getStaticPaths, makeStaticProps两个方法(推荐使用plop自动生成模板页面)。2. 如果全局组件需要翻译,得在页面中先引入对应翻译模块,无法绕过引入环节。

在下篇文章中,我将介绍在一些多语言处理技巧与迁移原生html页面多语言的过程。

参考链接

多语言方案介绍与迁移(一) - Google Sheets

多语言方案介绍与迁移(三) - 技巧与迁移

nextjs-ssg不支持功能列表

nextjs-i18n讨论

next-i18next文档

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天