没事造轮子没事造轮子

没事造轮子

网站国际化设置

  • W_Z_C
  • 阅读约 16 分钟
网站国际化设置

这次网站重构的重要目的就是支持国际化,本篇文章介绍国际化相关的基础设置。如果想要国际化一个网站,越早介入越好,因为越到后期,修改的成本越高,所以在其它页面还没完善前咱们就先把国际化的大致框架制定好,这样在以后的实现过程中会自然而然的考虑国际化相关的问题,避免不必要的更改成本。

目前想要把网站国际化,有两个地方需要克服:

  • 一个是网站页面上的文本信息。
  • 一个是以后博客显示的文章详情。

对于网站页面信息来说,需要将页面的文本翻译成不同的语言,并将它们保存在各自的目录中。这里我的做法是,提取现有页面的文字内容,将它们统一放到 json 文件中,页面在显示文本的时候自动从对应语言的 json 文件中提取,进而实现网页的国际化。

对于博客文章来说,在前面的文章中已经提到过,可以直接将 Markdown 文档翻译为指定的语言并保存在本地,当网页需要显示特定语言的文章时,直接渲染对应语言的文本即可。

1. 目录约定

在实现代码之前,我们首先需要制定一个存放多语言文本的规则。前面的文章已经提到过有了一个 i18n 文件夹存放多语言的数据,因此这里直接将以后的多语言文本放入其中,大致的样式如下:

i18n                  
 ├─ docs               
 │  ├─ en              
 │  └─ zh-Hans         
 └─ locales            
    ├─ en              
    │  └─ locale.json  
    ├─ zh-Hans         
    │  └─ locale.json  
    └─ zh-Hant         
       └─ locale.json  

其中 i18n/docs 目录存放 Markdown 的翻译文件,而 i18n/locales 存放翻译的网页文本。这种规则并不是定死的,还是根据自身的需要,目前只是觉得这种方案比较适合博客需要,因为除非网站增加额外的菜单或者其它板块内容,否则 i18n/locales 目录下的文件内容很少改动。而 i18n/docs 文件中的内容属于自动生成的性质,即使删除也不会有任何影响,随时可以根据网站根目录的 docs 内容动态生成,因此才会将它们分开。

2. 增加多语言路径

增加多语言内容的第一步是告诉 Nextjs 你有哪些网页,目前 Nextjs 可以使用 getStaticPaths 函数来获取所有需要预渲染的路径,对于多语言网站来说,每种语言都需要设置单独的页面,所以首先需要修改数据库中查找路径的代码:

和原来的代码类似,只不过我们增加一个获取参数:

backend/db.mjs
async function getPostPaths() {
    //...
    const paths = await db.posts.findMany({        select: {            slug: true,            i18n: true,        }    })
    return paths}

这样该函数的返回值会带上 i18n 参数,该参数内部保存了当前当前路径上对应的语言,Nextjs 对于多语言路径有特殊的返回方式:

[[...slug]].js
export async function getStaticPaths() {
  const meta = await getPostPaths()
  const paths = (locales || ['zh-Hans']).map(v => ({ params: { slug: [] }, locale: v }))
  .concat(meta.map(v => {
    return {
      params: { slug: Array.isArray(v.slug) ? v.slug : [v.slug] },
      locale: v.i18n,
    }
  }))

  return {
    paths,
    fallback: false,
  }
}

返回路径数组的每一项都包含两个参数,params 用来存放路径信息,locale 用来保存当前的语言区域,这样返回之后,Nextjs 会自动对这些多语言页面进行预处理。

3. Markdown 文档国际化

目前 Markdown 文档全部放在根目录的 docs 中,我们接下来需要将这些文档翻译为国际化需要的语言,推荐手动翻译,不过为了方便这里使用 Google 翻译接口自动批量翻译现有的文档。

3.1 Markdown 文档翻译

Google 翻译的接口具体使用大家可以查询 官方文档,这里只是简单介绍,实现之前先在 backend 目录下建立 translate.mjs 文件,该文件用于实现自动翻译逻辑,然后增加一条执行该文件的命令:

package.json
{  "scripts": {    "dev": "next dev",    "prebuild": "node ./src/backend/script.mjs",    "build": "next build",    "trans": "node ./src/backend/translate.mjs",    "start": "next start",    "lint": "next lint"  }}

这样可以随时使用 npm run trans 命令翻译现有的 Markdown 文档。

在写代码之前,先让我们安装接下来会用的其它第三方库:

npm install remark remark-parse remark-stringify
npm install vfile
npm install gray-matter
npm install unist-util-visit
npm install @google-cloud/translate

插件比较多,remark 是一个使用插件转换 Markdown 文档的工具。remark-parse 和 remark-stringify 就是这样的插件,前者可以将 Markdown 转换为语法树,后者可以将语法树转回 Markdown 文档。vfile 主要是提供一种虚拟的文件格式,方便 remark 进行文件相关的操作。 gray-matter 前面提到过,用于提取 Markdown 头部的元信息,unist-util-visit 是用来遍历语法树的辅助工具,最后的 @google-cloud/translate 是 Google 官方封装的翻译库。

下面是加载它们的方式:

translate.mjs
import { VFile } from 'vfile'
import { TranslationServiceClient } from '@google-cloud/translate'
import { remark } from 'remark'
import matter from 'gray-matter'
import { visit } from 'unist-util-visit'
import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'

接下来就是如何实现 Markdown 文档翻译的代码,逻辑上很简单,先遍历目录,找出所有需要翻译的 Markdown 文件,然后使用 remark 将文档转换为语法树,接着使用 Google 将语法树中需要翻译的节点进行转换,最后在转换为 Markdown 文档即可。

按照上面的逻辑,先遍历目录,找到所有的文档:

translate.mjs
function ThroughDir(root, excludesDir = [], files = []) {
    fs.readdirSync(root).forEach(file => {
        const abs = path.join(root, file)
        const isDir = fs.statSync(abs).isDirectory()

        if(isDir && excludesDir.includes(file.toLocaleLowerCase())) {
            return
        }

        if(isDir) {
            return ThroughDir(abs, excludesDir, files)
        } else {
            return files.push(abs)
        }
    })

    return files
}

然后实现翻译函数:

translate.mjs
// Instantiates a client
const translationClient = new TranslationServiceClient();
const translateTextAsync = async (str, lang, retry = 3) => {
    // Construct request
    const request = {
        parent: `projects/${projectId}/locations/${location}`,
        contents: [str],
        mimeType: 'text/plain', // mime types: text/plain, text/html
        sourceLanguageCode: 'zh-CN',
        targetLanguageCode: lang,
    };


    let text = str
    for(let i=0; i<retry; i++) {
        try {
            // Run request
            const [response] = await translationClient.translateText(request);
            text = response.translations[0].translatedText;
            break
        } catch(e) {
            console.log('翻译失败!',str, e.message)
        }            
    }

    return text
}

代码中 projectId 和 location 是 Google 翻译 API 需要提供的工程 ID 和接口位置标识,具体可以参考 官方文档

接来下是使用该函数翻译 Markdown 文档,需要翻译的地方有两处,一处是头部的元信息,包括文章介绍、标签、分类等字段,另一处就是文档的正文。

对于元信息,可以使用下面的函数:

元信息翻译代码
translate.mjs
const translateMatter = async (meta) => {
    if (!meta) {
        return meta
    }

    let { chapter, category, title, description, tags, slug } = meta

    if (chapter) {
        let items = chapter.split('_')

        chapter = await Promise.all(items.map(v => {
            if(isAscii(v))
                return Promise.resolve(v)

            return translateTextAsync(v, lang, retry)
        })).then(arr => arr.join('_'))
    }

    if (category) {
        category = await translateTextAsync(category, lang, retry)
    }

    if (!isAscii(title)) {
        title = await translateTextAsync(title, lang, retry)
    }

    if (!isAscii(description)) {
        description = await translateTextAsync(description, lang, retry)
    }

    if (tags) {
        let items = tags.split('、')

        tags = await Promise.all(items.map(v => {
            if(isAscii(v))
                return Promise.resolve(v)

            return translateTextAsync(v, lang, retry)
        })).then(arr => arr.join('、'))
    }

    slug = `${slug}`

    let obj = {
        ...meta,
        slug,
        title,
        description,
    }

    if (obj.chapter) {
        obj.chapter = chapter
    }

    if (obj.tags) {
        obj.tags = tags
    }

    if (obj.category) {
        obj.category = category
    }

    return obj
}

代码里面引入了一个额外的函数 isAscii,主要用于判断是否为 ASCII 字符串,如果是跳过翻译。

translate.mjs
const isAscii = str => /^[\x00-\x7F]+$/.test(str);

翻译正文部分,主要是编写一个 remark 插件,因为 Markdown 文档中并不是所有地方都需要翻译,例如代码部分就不需要,所以这里定义了一个插件,选择某些节点的文本进行翻译,目前实现中我只翻译了 text 和 image 节点,以后可能会有所改动,看以后翻译的质量再定。

translate.mjs
const remarkTranslate = () => {
    return async (tree) => {
        const tasks = []

        visit(tree, (node) => {
            if (node.type === 'text' && (node.value || '').trim().length > 0 && (!isAscii(node.value))) {
                tasks.push(translateTextAsync(node.value, lang, retry).then((text) => {
                    node.value = text
                }))
            } else if (node.type === 'image' && (node.alt || '').trim().length > 0 && (!isAscii(node.alt))) {
                tasks.push(translateTextAsync(node.alt, lang, retry).then((text) => {
                    node.alt =  text
                }))
            }
        })

        await Promise.all(tasks)
    }
}

因为 Google 提供的翻译接口是异步的,所以只能使用 Promise.all 这样的方式,不知道是否能够保证翻译正确,目前从结果上看还正常,如果以后发现异常再说。

最后,就是整合这些函数,翻译最终的 Markdown 文档:

翻译函数代码
translate.mjs
async function translateFilesAsync(root, files, lang) {
    //建立文件夹
    const dir = path.join(root, lang)
    if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, {recursive: true})
    }

    //读取文件
    for (let i = 0; i < files.length; i++) {
        console.log(i + '/' + files.length)

        const file = matter.read(files[i])

        //如果是草稿,跳过
        if (file.data.isDraft) {
            continue
        }

        //目标文件存在,跳过
        const filepath = path.join(dir, `${file.data.slug}.md`)
        if (fs.existsSync(filepath)) {
            const transFile = matter.read(filepath)
            if (transFile.data.updatedAt && file.data.updatedAt &&
                transFile.data.updatedAt.getTime() === file.data.updatedAt.getTime()) {
                console.log(files[i], '文件已经生成过了')
                continue
            }
        }


        const mdContent = await remark()
                                    .use(remarkParse)
                                    .use(remarkTranslate)
                                    .use(remarkStringify)
                                    .process(new VFile(file.content));

        const metaContent = await translateMatter(file.data);

        const fileContent = matter.stringify(mdContent.value, metaContent);

        fs.writeFileSync(filepath, fileContent);
    }
}

因为在 package.json 中执行的是 translate.mjs 文件,所以还要把执行逻辑放到最后:

translate.mjs
(async() => {
    const files = ThroughDir('./docs', [])
    await translateFilesAsync('./i18n/docs', files, 'zh-Hant')
})()

上面就是所有的翻译逻辑了,但是如果你直接执行脚本,可能会产生下面的错误:

Could not load the default credentials. Browse to https://cloud.google.com/docs/authentication/getting-started for more information.

报错的原因主要是接口授权导致的,你可以点击提示的链接查看相关信息,大致需要你先下载 Google 提供的授权证书,然后设置 GOOGLE_APPLICATION_CREDENTIALS 环境变量为证书所在的路径才可以。

最后在终端执行翻译命令:

npm run trans

你可以在 docs/i18n/zh-Hant 文件夹中查看最终翻译的结果。

3.2 数据库保存

Markdown 除了翻译之外,还需要将文档内容同步保存到数据库。在前面的章节中,我们已经实现了 Markdown 保存到数据库的逻辑,接下来咱们只需要修改一些代码,确保将翻译后的文档也添加进去即可。

打开 backend 目录下的 init.mjs 文件,修改 getMDXList 函数:

backend/init.mjs
async function getMDXList() {     const orginDoc = await getPathAll(DOCROOT)   const i18nDoc = await getPathAll(DOCI18NROOT, {}, {i18n: true})   const files = orginDoc.concat(i18nDoc)
  let posts = []  let onces = []
  for (let i = 0; i < files.length; i++) {
      //...
      //如果是翻译文本       if(files[i].i18n) {           const items = (files[i].dir || '').split(path.sep)           post.i18n = items.length > 0 ? items[items.length -1] : ''       }
      if (!onces[post.slug]) {          onces[post.slug] = true      } else {          console.error(post.filename, 'URL 重复!')      }
      posts.push(post)  }
  return posts}

代码额外读取了 i18n 文件夹的内容,然后在每篇文章对象上追加了一个 i18n 的属性,属性值的内容是文档所处的目录名称,因为我们规定文档所在的目录恰好是当前语言,也就是说,i18n 的属性值为当前文档的语言,默认为简体中文(zh-Hans)。

最后执行 npm run prebuild 命令将翻译后的数据插入到数据库。

4. 网页文本国际化

4.1 静态文本替换

页面国际化的第一步可以先抽取网页中需要翻译的文本,放到 locale.json 文件中,下面是目前主页需要翻译的文本内容:

i18n/zh-Hans/locale.json 文件
zh-Hant/locale.json
{
    "Header": {
        "Title": "没事造轮子",
        "OpenMenu": "打开菜单",
        "CloseMenu": "关闭菜单",
        "Register": "注册",
        "Login": "登陆",
        "LoginQ": "已经注册?",

        "PCMenu": [{
            "ID": "Tutorials",
            "Title": "教程",
            "Description": "系统化讲解,以点带面,引导用户理解相关知识内容。",
            "Items": [{
                "Name": "Win32 入门教程",
                "Description": "Windows 操作系统的核心操作接口,独领风骚二十年。",
                "Link": "windows-bian-cheng-kai-fa"
            }, {
                "Name": "SDL2 游戏开发教程",
                "Description": "开源的多媒体跨平台开发库,纯 C 接口,易于上手。",
                "Link": "sdl2-you-xi-kai-fa-zui-xiao-zhi-shi-zhan"
            }, {
                "Name": "HTML 入门教程",
                "Description": "超文本标记语言,前端网页的骨架,强调语义化。",
                "Link": "html-jian-jie"
            }]
        }, {
            "ID": "Projects",
            "Title": "项目",
            "Description": "遵守最小化知识栈原则,以实战为目的多媒体教程。",
            "Groups": [{
                "Name": "游戏开发",
                "Projects": [{
                    "Title": "俄罗斯方块",
                    "Image": "/assert/images/menu-project-tetris.gif",
                    "Link": "cyu-yan-xiao-you-xi-zhi-e-luo-si-fang-kuai"
                }, {
                    "Title": "方块赛车",
                    "Image": "/assert/images/menu-project-sz.gif",
                    "Link": "windows-you-xi-bian-cheng-zhi-fang-kuai-sai-che"
                }, {
                    "Title": "打砖块",
                    "Image": "/assert/images/menu-project-breakout.gif",
                    "Link": "windows-you-xi-bian-cheng-zhi-da-zhuan-kuai"
                }, {
                    "Title": "扫雷",
                    "Image": "/assert/images/menu-project-mines.jpg",
                    "Link": "windows-you-xi-bian-cheng-zhi-sao-lei"
                }]
            }, {
                "Name": "网站开发",
                "Projects": [{
                    "Title": "Nextjs 博客搭建教程",
                    "Image": "/assert/images/menu-project-nextjs.jpg",
                    "Link": "next-blog-tutorials"
                }]
            }]
        }, {
            "ID": "ReadCodes",
            "Title": "码读",
            "Description": "源码阅读心得",
            "Items": [{
                "Name": "Express 源码刨析",
                "Link": "express"
            }]
        }, {
            "ID": "More",
            "Title": "更多",
            "Description": "",
            "Items": [{
                "Name": "下载",
                "Description": "博客文章中提到的一些第三方资源、演示程序等内容。",
                "Link": "download"
            }, {
                "Name": "关于",
                "Description": "关于博主、博客的一些基本信息。",
                "Link": "about"
            }]
        }]
    },

    "Hero": {
        "Title": "学习、实战、总结、",
        "SubTitle": "分享",
        "Action": "开始学习"
    },


    "TutorialList": {
        "Title": "技术教程",
        "Description": "专注于某项技术的系统性课程,从零开始,深入简出。",

        "Items": [{
            "Title": "Win32",
            "Description":"Windows 操作系统的核心操作接口,独领风骚二十年。",
            "Action": "开始学习",
            "Link": "windows-bian-cheng-kai-fa"
        }, {
            "Title": "SDL2",
            "Description":"开源的多媒体跨平台开发库,纯 C 接口,易于上手。",
            "Action": "开始学习",
            "Link": "sdl2-you-xi-kai-fa-zui-xiao-zhi-shi-zhan"
        }, {
            "Title": "CSS",
            "Description":"前端学习的必备知识,控制网站样式的专用语言。",
            "Action": "敬请期待",
            "Link": "#",
            "UnPublished": true
        }]
    },

    "RecentPosts": {
        "Title": "最新文章",
        "Loading": "加载中……"
    },

    "Footer": {
        "Title": "没事造轮子"
    }
}

接着将该文档的内容翻译为对应的语言放到各自的目录中。接下来需要解决的问题是如何在对应的位置替换对应的语言文字。为了解决这个问题,我们可以定义一个函数,获取对应语言的文本内容:

backend/i18n.js
import * as path from 'path'
import * as fs from 'fs'

let cacheLocales = {}

function loadCache(locale) {
    try {
        cacheLocales[locale] = JSON.parse(fs.readFileSync(locale))
    } catch(e) {
        console.error('加载文件失败!', locale, e.message)
    }
}

export function getLocale(locale = 'zh-Hans') {
    let localeDirectory = path.join(process.cwd(), 'i18n/locales', locale, 'locale.json')
    if(!fs.existsSync(localeDirectory)) {
        localeDirectory = path.join(process.cwd(), 'i18n/locales/zh-Hans/locale.json')
    }

    //查询缓存,尝试加载
    if(!cacheLocales[localeDirectory]) {
        loadCache(localeDirectory)
    }

    return cacheLocales[localeDirectory] || {}
}

getLocale 负责获取对应语言的文本集,之所以获取整个文件内容而不是获取更细粒度的某条文本,主要的原因是 Nextjs 的机制决定的。前面的文章提到过,Nextjs 渲染页面常见的两种模式,一种是静态生成(SSG,Static Site Generation),另一种是服务器渲染(SSR,Server-Side Rendering)。

我们这里会尽量使用静态生成的方式,对于多语言页面来说,会根据不同的 locale 生成不同的页面,而静态生成页面会在编译器间运行,所以不管使用哪种文本读取的方式,都不会对网站运行的效率产生任何影响,再加上读取完整的文本内容在代码处理上更加容易,所以才会选择这样的实现方式。当然为了提高编译效率,函数内部还是做了缓存的。

按照 Nextjs 官方文档的指引,可以修改现有的 getStaticProps 函数代码:

[[...slug]].js
export async function getStaticProps({params, locale, defaultLocale}) {

  //...

}

参数 locale 和 defaultLocale 分别代表当前页面的语言区域和默认的语言区域,前者的值是 Nextjs 自动检测出来的,后者是在 next.config.js 中设置的。两个参数是 Nextjs 自动传递的,不过要想它们生效,需要更改 Nextjs 的配置文件,增加国际化路由的相关设置。

next.config.js
i18n: {
  locales: ['zh-Hans', 'zh-Hant', 'en'],
  defaultLocale: 'zh-Hans',
  localeDetection: false
},

其中 localeDetection 表示是否支持语言的自动检测功能。如果为真, Nextjs 会尝试根据 Accept-Language 和当前区域自动推测用户的使用语言,如果检测到和默认语言不相符,则会重定向到特定语言页面。当使用子路由区分语言时,会自动增加语言前缀;如果使用的是域路由,则会跳转到对应的域名,为了防止误判,这里选择关闭该功能。

设置 i18n 相关配置后,getStaticProps 函数就可以正常接收到国际化相关的参数。该函数返回的是一个对象,props 属性的值可以传递给函数组件,你可以直接在函数组件内使用这些对象,前面的章节中已经使用过了,现在需要增加一个额外的 trans 属性,用来存放翻译的文本内容:

[[...slug]].js
export async function getStaticProps({params, locale, defaultLocale}) {

  //...

  //获取翻译文本
  const loc = locale || defaultLocale
  const trans = getLocale(loc)

  //寻找对应的模板,获取模板属性
  const {layout = type, payload = {}} = await getProps(type, {slug, locale: loc})

  return {
    props: {
      layout,
      locale: loc,
      trans,
      payload,
    }
  }
}

代码中主要做了两件事,一个是通过 getLocale 获取当前区域对应的文本内容,另一个是将 locale 参数传入到 getProps 函数,便于不同区域文章的内容显示。

之所以直接在 getStaticProps 获取翻译文件,而不是在组件中获取的原因在于每个页面都会用到翻译文本。毕竟目前所有页面都会包含 Header 和 Footer 两个组件,而将 getLocale 放到这里可以避免单一组件重复调用 getLocale 函数的尴尬。

这里有一点需要注意,在返回参数的时候,我们也将当前的语言 locale 参数提供给了组件,一般情况下组件是用不到这个参数的,但是有一种情况下你可能会用到它。那就是当需要调用后台 API 接口的时候,因为后台 API 接口是单独存在的,无法获知当前你所请求的语言类型,所以你需要告知它,这里将 locale 参数提供给组件,到时候在组件内部就可以直接将该参数传递给后台,后台有了这个参数才能区分当前接口需要返回的语言类型。

接下来是将翻译文本投递到各个页面布局组件中:

[[...slug]].js
export default function Page({layout, locale, trans, payload}) {  const Layout = getLayout(layout)  return (     <Layout trans={trans} locale={locale} {...payload}/>  )}

这里用主页布局当作例子:

layouts/home.js
export function HomeLayout({locale, trans, posts }) {
    return (
        <>
            <Header
                locale={locale} 
                i18n={{
                    ...trans.Header
                }}
            />
            <Hero 
                locale={locale}
                i18n={{
                    ...trans.Hero
                }} 
            />
            <TutorialList 
                locale={locale}
                i18n={{
                    ...trans.TutorialList
                }} 
            />
            <RecentPosts
                locale={locale}
                i18n={{
                    ...trans.RecentPosts,
                }}
                posts={posts}
            />
            <Footer 
                locale={locale}
                i18n={{
                    ...trans.Footer
                }} 
            />
        </>
    )
}

所有的组件都增加两个参数,接着需要修改每个组件的原有文本内容,这里拿 Hero 组件作为案例,其它的代码类似:

Hero/index.js
export function Hero({locale, i18n}) {    return (        <div className="py-20 md:py-32">            <section className="container md:flex mx-auto">                ...
                <div className="w-full mt-10 md:w-1/2">                    <h2 className="text-6xl text-center leading-normal md:leading-relaxed md:text-[4rem] lg:text-[5rem]">                        <span>{i18n.Title}</span>                        <span className="relative inline-block overflow-visible">                            <span className="font-black z-10 relative">{i18n.SubTitle}</span>                            <svg className="text-primary absolute -translate-y-1/2 -translate-x-1/2 top-1/2 left-1/2 h-[calc(100%)] w-[calc(100%+2rem)]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 150" preserveAspectRatio="none"><path fill="none" stroke="currentColor" strokeWidth="9" d="M7.7,145.6C109,125,299.9,116.2,401,121.3c42.1,2.2,87.6,11.8,87.3,25.7"></path></svg>                        </span>                    </h2>
                    <div className="text-center mt-16">                        <Link href="#tutorial-hero" passHref>                            <a className="inline-block py-4 px-14 rounded-md bg-primary transform duration-500  hover:scale-110 motion-reduce:transform-none">                                <span className="flex text-xl text-white font-bold">                                    <svg className="w-4 mr-2 fill-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M381.2 172.8C377.1 164.9 368.9 160 360 160h-156.6l50.84-127.1c2.969-7.375 2.062-15.78-2.406-22.38S239.1 0 232 0h-176C43.97 0 33.81 8.906 32.22 20.84l-32 240C-.7179 267.7 1.376 274.6 5.938 279.8C10.5 285 17.09 288 24 288h146.3l-41.78 194.1c-2.406 11.22 3.469 22.56 14 27.09C145.6 511.4 148.8 512 152 512c7.719 0 15.22-3.75 19.81-10.44l208-304C384.8 190.2 385.4 180.7 381.2 172.8z"/></svg>                                    <span>{i18n.Action}</span>                                </span>                            </a>                        </Link>                    </div>                </div>            </section>        </div>    )}

按照上面的方法依次类推,可以将所有组件的文本全部替换完毕,这样网页文本的国际化基本上算是初步搞定。

4.2 动态文本替换

除了组件中静态文本的替换外,其实还存在动态文本的替换问题。目前博客有两个模块会涉及到这个内容,一个是首页的最新文章组件,因为该模块会显示最近几篇 Markdown 文档的元信息,例如标题、简介等内容,这样的内容肯定会被翻译为多国语言,所以你需要按照当前的区域显示不同的 Markdown 的翻译文本。另一个地方就是文章显示页面,这个不用多说,就是根据当前语言区域显示对应的 Markdown 文档内容。

如何实现呢?实现的方式非常简单,因为这些内容都是动态加载的,所以只需要重构读取数据的逻辑,在查询数据的过程中增加额外的语言区域参数,这样在查找文档的时候,就可以查询到对应语言的文档内容,这样最后显示页面自然而然的就支持多语言的动态切换。

在前面的内容中,已经提到过,数据的加载在 getStaticProps 函数中完成:

[[...slug]].js
export async function getStaticProps({params, locale, defaultLocale}) {
  //...
  //寻找对应的模板,获取模板属性   const {layout = type, payload = {}} = await getProps(type, {slug, locale: locale || defaultLocale})
  //...}

所以我们可以将 locale 参数的内容透传到 getProps 函数,然后在查询数据的时候,增加额外的 locale 参数,这样查询的结果自然会匹配对应的语言。

例如,对于普通的文章页面,修改查询参数:

props/posts.js
export async function postProps({slug, locale}) {
    const post = await getPostBySlug(slug, locale)

    //...
}

接着修改数据库的查询条件:

backend/db.mjs
async function getPostBySlug(slug, locale) {

    //...

    const post = await db.posts.findFirst({
        where: {
            AND: [{
                slug: { equals: slug }
            }, {
                i18n: { equals: locale }
            }]
        },

        //...
    })

    //...
}

这样查询的结果自然会返回对应的语言。首页的最新文章模块修改和上面类似,但是因为涉及到 API 接口的访问,需要额外传递语言参数来进行区分,所以更加麻烦一点。

该模块的数据查询可以更改为:

props/home.js
export async function homeProps({locale}) {    const posts = await getRecentPosts(locale)
    return {        payload: {            posts,        }    }}

然后在 getRecentPosts 函数中增加 locale 查询条件:

backend/db.mjs
async function getRecentPosts(locale, start = 0) {
    
    //...

    const metas = await db.posts.findMany({
        where: {
            AND: [{
                category: {in: ['博客', '编译', '研究']},
            }, {
                i18n: { equals: locale }
            }]
        },

        //...
    })

    //...
}

不要忘记除了上面提到的代码之外,还有一个地方调用了 getRecentPosts 函数,那里一样需要传入 locale 参数,否则肯定会报错。

遗憾的是在后台 API 接口的函数中,我们暂时无法获取到当前的语言环境,解决这个问题目前我能想到两种方式,一种是使用 cookie,这样所有的后台请求都会自动添加语言参数,另一种前文已经提到了,就是传入前台组件的时候,增加一个额外的变量,用来保存当前使用的语言,每次请求后台 API 接口的时候,将该参数一起传入即可。目前 getStaticProps 函数的返回值已经增加了该变量(locale)。

为了将该变量从前台传递到后台 API 接口,我们需要修改 RecentPosts 组件代码:

RecentPosts.js
export function RecentPosts({ locale, i18n, posts }) {
    const [recentPosts, setRecentPosts] = useState({        posts: posts,        hasMore: true    })
    const fetchMoreData = () => {                fetch('/api/recentposts', {            method: 'POST',            headers: {                'Content-Type': 'application/json',            },            body: JSON.stringify({                locale: locale,                count: recentPosts.posts.length || 0,            })        })
        //...    }
    //...}

后台 API 接口同样需要接收 locale 参数:

api/recentposts.js
export default function RecentPosts(req, res) {
    if(req.method !== 'POST') {        return res.status(200).json({            success: true,            posts: []        })    } else {        const submitSchema = Joi.object({            count: Joi.number().integer().positive(),            locale: Joi.string().max(10).empty('').default('zh-Hans'),        })
        const {err, value} = submitSchema.validate(req.body)        if(err) {            return res.status(200).json({                success: false,                message: err.message || '',            })        }
         return getRecentPosts(value.locale, value.count)        .then(function(posts) {            //...        })    }}

代码中增加了简单的语言校验,然后将 locale 参数传递给前面修改后的 getRecentPosts 函数。

5. 总结

到此为止,算是把网站国际化涉及的内容初步讲解完毕,核心内容在于如何使用 locale 参数,对于页面中导出函数可以直接从参数中获取该值,但是如果使用的是后台 API 接口,那么你需要将当前使用的语言告知该接口,这样接口才能有对该语言特殊处理的可能,否则接口是无法获知当前需要返回哪种语言的。