React国际化方案实践与解析

这篇文章探讨React国际化方案实践与原理,内容涵盖下面一些问题:

  1. i18next是什么?
  2. 如何自动识别客户端语言?
  3. 如何在网页跳转以及下次打开页面时保持同一语言?
  4. 如何只加载需要的语言文件?
  5. 如何实现语言切换?需要刷新整个页面吗?
  6. 如何将语言文件与代码分开维护?
  7. 服务端渲染与客户端渲染中的多语言实现有什么不同?
  8. zh-CNzh都表示中文,如何统一呢?
  9. 是否存在XSS攻击,如何防范?

i18next

首先介绍下目前最主流的国际化方案,i18next。它是一个使用JavaScript编写的国际化方案,其生态非常丰富,jQuery,React、Vue、Angualar等都有集成库。但它本身是不限于浏览器使用的,它也可以在其他JavaScript运行环境中使用,如Node.js,Deno等。

下面以React项目为例展示下i18next的基本使用方法:

jsx 复制代码
import i18next from "i18next";

i18next.init({
  resources: {
    en: {
      translation: {
        welcome: "Welcome",
      },
    },
    zh: {
      translation: {
        welcome: "欢迎",
      },
    },
  },
  lng: "zh",
});

function App() {
  return <h2>{i18next.t("welcome")}</h2>; // 欢迎
}

export default App;

上述示例非常简单,在i18next.init()中初始化了enzh两种语言的语料,并将zh作为默认语言,接着就可以直接通过i18next.t("key")来获取当前语言内容了。

i18next还支持一些稍复杂的方法,如模版插值。示例代码如下:

jsx 复制代码
i18next.init({
  resources: {
    en: {
      translation: {
        welcome: "Welcome to i18next, {{name}}",
      },
    },
  },
  lng: "en",
});

function App() {
  return <h2>{i18next.t("welcome", { name: "John" })}</h2>; // Welcome to i18next, John
}

更多用法不在本文讨论范围内,读者可前往官方文档查看。

自动识别客户端语言

上述示例中是在初始化时手动指定lng的值,这不太实用。如果默认为zh,那国外的朋友打开后得直接一脸懵逼。好在,我们可以通过一些方法自动获取到用户浏览器上的首选语言

我们可以直接使用i18next的插件,i18next-browser-languageDetector

jsx 复制代码
import LanguageDetector from 'i18next-browser-languagedetector';

i18next.use(LanguageDetector).init({
  // ...
});

使用了LanguageDetector后我们就不需要再手动指定lng了。那它是怎么做的呢?下面的代码来自其README,它会根据order中规则的顺序来依次识别。以querystring为例,/products?lng=zh的路径将识别语言为zh(这个key是由lookupQuerystring选项设置的)。cookielocalStorage等就同理了。

jsx 复制代码
{
  // order and from where user language should be detected
  order: ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag', 'path', 'subdomain'],

  // keys or params to lookup language from
  lookupQuerystring: 'lng',
  lookupCookie: 'i18next',
  lookupLocalStorage: 'i18nextLng',
  lookupSessionStorage: 'i18nextLng',
  lookupFromPathIndex: 0,
  lookupFromSubdomainIndex: 0,

  // cache user language on
  caches: ['localStorage', 'cookie'],
  excludeCacheFor: ['cimode'], // languages to not persist (cookie, localStorage)

  // optional expire and domain for set cookie
  cookieMinutes: 10,
  cookieDomain: 'myDomain',

  // optional htmlTag with lang attribute, the default is:
  htmlTag: document.documentElement,

  // optional set cookie options, reference:[MDN Set-Cookie docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie)
  cookieOptions: { path: '/', sameSite: 'strict' },

  // optional conversion function to use to modify the detected language code
  convertDetectedLanguage: 'Iso15897',
  convertDetectedLanguage: (lng) => lng.replace('-', '_')
}

那这些规则都没有显式指定怎么办呢?这时候就会看navigator。其判断依据就是浏览器的navigator.languages

这个值是可以在浏览器设置中修改的,如下所示:

你可能注意到浏览器语言里存在zh-CN的选项,i18n也会将它匹配到"zh"的语言文件,如果"zh-CN"的语言文件不存在的话。

需要注意的是,i18next在使用时会自动将识别的语言设置到Local storage,这样就能"记住"用户选择的语言了。下次打开页面时,i18next会优先根据Local Storage中的值设置语言。

从文件中加载语言

另外一个问题来了,当我们的应用越来越大,语言内容越来越多,下面的形式就不优雅了。因为它会一次性加载所有语言的语料,即使用户只会用到其中一种。有没有办法只加载用户需要的那份语料内容呢?

jsx 复制代码
i18next.init({
  resources: {
    en: {
      translation: {
        welcome: "Welcome",
      },
    },
    zh: {
      translation: {
        welcome: "欢迎",
      },
    },
  },
  lng: "zh",
});

这时候可以使用i18next-http-backend。它使用XMLHttpRequest或fetch API来动态加载语言文件。使用示例如下:

jsx 复制代码
import i18next from "i18next";
import Backend from "i18next-http-backend";

i18next
  .use(Backend)
  .init({
    lng: "zh",
  })
  .then((t) => {
    console.log(t("welcome")); // "欢迎"
  });

它默认会从/locales/{{lng}}/{{ns}}.json路径中加载文件,比如locales/zh/translation.json。需要注意,由于是动态加载,所以需要在回调中获取请求内容

不过,这样一来,之前那样获取语料值的方法在这里就行不通了:

jsx 复制代码
function App() {
  // 直接使用i18next.t()无法获取到最新的值
  return <h2>{i18next.t("welcome")}</h2>; 
}

怎么在React组件中获取到最新的值呢?如果交给我们手动实现,显然还要做一些特殊处理,比如维护一个状态来保存语料值,并注册一个文件加载完成的回调,在回调里更新状态,进而页面能够更新。好在,开源已经实现了,后面会讲到用react-i18next来在React应用中更好的集成i18next。

在那之前,再多看两个示例。第一个,将LanguageDetector与Backend结合,如下所示:

jsx 复制代码
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";

i18next
  .use(LanguageDetector)
  .use(Backend)
  .init()
  .then((t) => {
    console.log(t("welcome")); // "欢迎"
  });

容易推断,i18next可以用这种方式集成任意多的插件,这里就不多讨论了。

第二,把翻译文件维护在前端代码中是否合理呢?这样每次更新翻译文件都需要翻译人员把语料发给前端,前端更新代码后再重新发版,而且中间还可能出现人为误差(比如粘贴错了)。能不能搞个服务来专门维护翻译内容呢?前端只需要从远端请求这个翻译文件即可,实现语言文件与代码的解耦。这确实是可行的,并且i18next还推荐了一个服务i18next-locize-backend,专门用来打通翻译与代码开发。

Bridging the gap between translation and development with locize, a modern and affordable localization-management-platform。

react-i18next

react-i18next是基于i18next的,针对React与React Native的国际化框架。其实前面看到,直接在React中用i18next也是可以的,只是在需要远程加载文件的时候遇到了点问题,所以react-18next做了啥呢?还是先看看使用示例:

jsx 复制代码
import React from "react";
import i18n from "i18next";
import { useTranslation, initReactI18next } from "react-i18next";

// 将i18n传入react-i18next中
i18n.use(initReactI18next).init({
  resources: {
    en: {
      translation: {
        welcome: "Welcome",
      },
    },
    zh: {
      translation: {
        welcome: "欢迎",
      },
    },
  },
  lng: "zh",
});

function App() {
  // 通过useTranslation这个自定义hook来获取翻译内容
  const { t } = useTranslation();

  return <h2>{t("welcome")}</h2>; // 欢迎
}

export default App;

这里也是可以结合LanguageDetector与Backend的:

jsx 复制代码
import React from "react";
import i18n from "i18next";
import { useTranslation, initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";

i18n.use(LanguageDetector).use(Backend).use(initReactI18next).init();

function App() {
  const { t } = useTranslation();

  return <h2>{t("welcome")}</h2>; // 欢迎
}

export default App;

代码很简单清晰,也不会再出现之前翻译内容不存在的情况了。玄机就在这个useTranslation里。useTranslation这个hook里维护了 t的一个状态 const [t, setT] = useState(),并会在语言文件加载完成时更新它,从而页面上可以渲染出更新后的值。那么问题又来了,如果网络比较慢,语言还没加载完,想展示一个loading状态怎么做呢?官方提供的示例如下,通过Suspense

jsx 复制代码
function Component() {
  const { t } = useTranslation();

  return <h2>{t("welcome")}</h2>;
}

function App() {
  return (
    <Suspense fallback="loading">
      <Component />
    </Suspense>
  );
}

在useTranslation中是这样实现的:

jsx 复制代码
// 如果语言文件已加载完成则返回,否则先加载文件,并触发suspense
if (ready) return ret;

// not yet loaded namespaces -> load them -> and trigger suspense
throw new Promise((resolve) => {
  if (props.lng) {
    loadLanguages(i18n, props.lng, namespaces, () => resolve());
  } else {
    loadNamespaces(i18n, namespaces, () => resolve());
  }
});

有兴趣的读者也可以自己试试,目前使用Suspense是有一定条件的,React官方示例中是通过一个自定义的use来触发,以在组件加载完成前展示一个fallback。

react-i18next中还有一些其他使用语料内容的方式,如高阶组件,这里就不多讨论了。

语言切换

使用react-i18next可以轻松实现语言切换,即通过i18n.changeLanguageAPI。在切换语言时,i18next会动态加载新的语言文件

jsx 复制代码
function Component() {
  const { t, i18n } = useTranslation();

  return (
    <div>
      <select
        name="language"
        defaultValue={i18n.language}
        onChange={(e) => i18n.changeLanguage(e.target.value)}
      >
        <option value="zh">中</option>
        <option value="en">En</option>
      </select>
      <h2>{t("welcome")}</h2>
    </div>
  );
}

结合LanguageDetector、Backend与语言切换功能,就是一个比较智能的多语言方案了:首先根据客户端偏好动态请求需要的语言文件,如果后续切换语言,则再加载新的语言文件。

到这里讲的内容都是关于客户端渲染的,我们已经可以回答前面的一些问题了:

  1. 如何自动识别客户端语言?通过querystring、path、localstorage等方式显式标记,或通过navigator.languages来自动识别。
  2. 如何在网页跳转以及下次打开页面时保持同一语言?通过localstorage等方式留痕,如设置i18nLang=zh,下次打开页面时优先通过此方式判断语言。
  3. 如何只加载需要的语言文件?将不同语言的语料维护在不同文件中,通过i18next-http-backend动态获取。
  4. 如何实现语言切换?需要刷新整个网页吗?结合react-i18next与i18n.changeLanguage()可以切换语言。这种方式会动态请求新的语言文件,并通过React机制更新页面内容,不需要刷新整个网页。
  5. 如何将语言文件与代码分开维护?实现一个专门的语料管理平台(或使用locize),前端只需要从平台中拉取语料文件即可。

接下来我们将继续探讨服务端的实现,上面一些问题的回答或许会有所不同。

Next.js

我们以Next.js的App Router为例,首先看看官方推荐的实现:Internationalization

识别客户端语言的逻辑放在middleware中。

  1. 首先判断请求路径中是否存在语言,如/zh/products的语言为zh,如果存在则直接返回。
  2. 不存在则进一步通过请求头中的 accept-language获取信息,并匹配到我们支持的语言。
  3. 重定向到带有语言的路径,如/products重定向/zh/products
jsx 复制代码
// middleware.ts
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'

const locales = ['zh', 'en'];

function getLocale(request: NextRequest) {
  const headers = {
    'accept-language': request.headers.get('accept-language') || ''
  };
  const languages = new Negotiator({ headers }).languages();

  const defaultLocale = locales[0];

  return match(languages, locales, defaultLocale);
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  const pathnameHasLocale = locales.some((locale) =>
    pathname.startsWith(`/${locale}`)
                                        );
  if (pathnameHasLocale) return;

  const locale = getLocale(request);
  request.nextUrl.pathname = `/${locale}${pathname}`;
  // e.g. /products => /en/products

  return Response.redirect(request.nextUrl);
}

这就要求我们所有的页面文件都要放到app/[lang]目录中,目录下的page或layout文件可以直接通过params获取到当前的语言lang

jsx 复制代码
export default async function Page({ params: { lang } }) {
  return ...
}

语言文件则分开维护到不同的json文件中,如:

json 复制代码
// dictionaries/en.json
{
  "products": {
    "cart": "Add to Cart"
  }
}

// dictionaries/nl.json
{
  "products": {
    "cart": "Toevoegen aan Winkelwagen"
  }
}

并通过如下逻辑动态加载:

jsx 复制代码
import 'server-only'
 
const dictionaries = {
  en: () => import('./dictionaries/en.json').then((module) => module.default),
  nl: () => import('./dictionaries/nl.json').then((module) => module.default),
}
 
export const getDictionary = async (locale) => dictionaries[locale]()

这样页面中即可通过getDictionary(lang)获取到语料的JSON内容。

jsx 复制代码
import { getDictionary } from './dictionaries'
 
export default async function Page({ params: { lang } }) {
  const dict = await getDictionary(lang) // en
  return <button>{dict.products.cart}</button> // Add to Cart
}

整体逻辑还是比较清晰的,流程如下:

原始的语言文件并不会返回到客户端,而是会生成到html里返回 ,所以它不会加载不需要的语言文件。那如何切换语言呢?只需替换路径即可,如将/zh/products替换为/en/products。使用Next.js自带的<Link>history.push都行,它会动态请求新的语言文件,并更新页面内容,同样它也是不需要刷新网页的。

但这种基于path的方案存在一些问题:

  1. 无法保留语言设置 。比如我第一次请求/products自动重定向到了/zh/products,我切换了语言为/en/products。第二次请求/products仍然会重定向到/zh/products
  2. 重定向会增加白屏时间 。如/products会自动重定向到/zh/products,这会增加页面的白屏时间。

出现这两个问题的原因是接收的客户端请求是依赖path识别语言的,有没有其他方式让服务端在客户端"留痕",以在下次请求时自动识别语言呢?答案是cookie(不过这种方法需要用户浏览器设置允许Cookie)。

首先修改middleware,通过种一个名为i18nLang的cookie来标记当前使用语言。

jsx 复制代码
export function middleware(request: NextRequest) {
  // 如果cookie中存在预先设定的语言,则直接返回
  if (request.cookies.has('i18nLang')) {
    const lang = request.cookies.get('i18nLang');
    if (lang && locales.indexOf(lang.value as Locale) >= 0) {
      return;
    }
  }

  // 如果cookie中不存在预先设定的语言,则根据请求头获取默认语言,并设置cookie
  const locale = getLocale(request);

  const response = NextResponse.next();
  response.cookies.set('i18nLang', locale);

  return response;
}

相应的,在我们的page与layout中,需要通过cookie()API来获取语言。

jsx 复制代码
import { cookies } from 'next/headers';

// ...
cookies().get('i18nLang')?.value

这样前面两个问题就解决了(似乎太顺利了点)。然而还有两个问题,第一,怎么切换语言呢?这里就不是切换路径了,而是在客户端修改cookie,并刷新网页。像这样:

jsx 复制代码
Cookies.set('i18nLang', locale);
window.location.reload();

这里就真的是需要刷新网页了,不过在实际场景中,切换语言是一个低频操作,而且一般只会切换一次,所以可以忽略这个刷新网页的影响。另外还有一个问题,也可以说是Next.js的一个bug,就是第一次在middleware中种cookie, cookies() API是拿不到最新的cookie值的

jsx 复制代码
// middleware.ts
response.cookies.set('i18nLang', locale);

// layout.ts
cookies().get('i18nLang')?.value  // undifined

这个问题可以采用社区中的一种办法来解决,代码如下:

jsx 复制代码
/**
 * Copy cookies from the Set-Cookie header of the response to the Cookie header of the request,
 * so that it will appear to SSR/RSC as if the user already has the new cookies.
 */
function applySetCookie(req: NextRequest, res: NextResponse): void {
  // parse the outgoing Set-Cookie header
  const setCookies = new ResponseCookies(res.headers);
  // Build a new Cookie header for the request by adding the setCookies
  const newReqHeaders = new Headers(req.headers);
  const newReqCookies = new RequestCookies(newReqHeaders);
  setCookies.getAll().forEach((cookie) => newReqCookies.set(cookie));
  // set "request header overrides" on the outgoing response
  NextResponse.next({
    request: { headers: newReqHeaders }
  }).headers.forEach((value, key) => {
    if (
      key === 'x-middleware-override-headers' ||
      key.startsWith('x-middleware-request-')
    ) {
      res.headers.set(key, value);
    }
  });
}

export function middleware(request: NextRequest) {
  // ...
  const response = NextResponse.next();
  response.cookies.set('i18nLang', locale);

  // https://github.com/vercel/next.js/issues/49442
  // 让 cookies.get() 可以获取到最新值
  applySetCookie(request, response);

  return response;
}

可以看到,这里Next.js中的实现其实与i18next关系不大,并且相比客户端而言,其逻辑还更为简单。再回顾下前面的几个问题,看看是不是有新的答案呢。

  1. 如何自动识别客户端语言?通过请求头中的 accept-language匹配语言。
  2. 如何在网页跳转以及下次打开页面时保持同一语言?服务端种cookie ,比如i18nLang=zh,下次请求页面时根据cookie值判断。
  3. 如何只加载需要的语言文件?结合需要的语言文件生成html返回。
  4. 如何实现语言切换?需要刷新整个页面吗?如果是依赖path的切换则不需要刷新页面,依赖cookie则需要刷新页面。
  5. 如何将语言文件与代码分开维护?同之前的回答。
  6. 服务端渲染与客户端渲染中的多语言实现有什么不同?服务端渲染是在服务端识别语言,并整合生成html后返回;客户端渲染是在客户端识别语言,请求语言文件内容,并渲染页面。

next-i18next

next-i18next是i18next推荐的在Next.js中集成i18n的方案。里面说到,如果使用的是Next.js App Router,则不需要用到next-i18next了,可以直接使用i18next与react-i18next,具体见这篇博客:i18n with Next.js 13/14 and app directory / App Router。里面是如何做的呢?这里截取了一些关键部分。

语言的判断逻辑是相似的:页面放到[lng]目录下,在中间件中判断,如果path中lng不存在则重定向

text 复制代码
.
└── app
    └── [lng]
        ├── second-page
        |   └── page.js
        ├── layout.js
        └── page.js
jsx 复制代码
// Redirect if lng in path is not supported
if (
  !languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
  !req.nextUrl.pathname.startsWith('/_next')
) {
  return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}`, req.url))
}

不同的是,它这里还用到了cookie来记录用户语言。

jsx 复制代码
let lng
// 获取cookie
if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)

// ...

if (req.headers.has('referer')) {
  const refererUrl = new URL(req.headers.get('referer'))
  const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`))
  const response = NextResponse.next()
  // 设置cookie
  if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
  return response
}

在服务端页面中通过下面的方式引入语言文件与i18n。

jsx 复制代码
const initI18next = async (lng, ns) => {
  const i18nInstance = createInstance()
  await i18nInstance
    .use(initReactI18next)
    // 加载对应lng的语言文件
    .use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
    .init(getOptions(lng, ns))
  return i18nInstance
}

// 这个useTranslation是在服务端使用的,虽然名称是use开头
export async function useTranslation(lng, ns, options = {}) {
  // 初始化i18n的事例,加载lng的语言文件后返回
  const i18nextInstance = await initI18next(lng, ns)
  return {
    t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
    i18n: i18nextInstance
  }
}
jsx 复制代码
import Link from 'next/link'
import { useTranslation } from '../i18n'

export default async function Page({ params: { lng } }) {
  // 通过await调用获取到i18n实例
  const { t } = await useTranslation(lng)
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}/second-page`}>
        {t('to-second-page')}
      </Link>
    </>
  )
}

其实与前面的path方案是基本相同的,只不过多了一步初始化i18n实例 ,从而可以使用i18n的能力。path方案还有一个麻烦的点:每次跳转路径都要带上lng,这确实是一个开发负担。如果不是页面,而是组件中需要跳转路径,还要将lng从页面中传递下去。

jsx 复制代码
<Link href={`/${lng}/second-page`}>
  {t('to-second-page')}
</Link>

上面的useTranslation只能在服务端组件中使用,如果客户端组件要使用多语言呢?一个方法是直接从服务端页面传参下来,另一个方案就是在客户端也初始化一个i18n,具体可以见原文内容。如果读者还记得前面的内容,应该可以想到,在这里客户端的i18n中,我们可以用LanguageDetector来根据path获取初始化语言

XSS攻击的防范

最后讨论一下多语言方案中的XSS攻击。i18n有一个unescape的配置,用来减轻XSS攻击。默认情况下,i18n会对模版插值的变量进行转义

jsx 复制代码
{
  "keyEscaped": "no danger {{myVar}}",
  "keyUnescaped": "dangerous {{- myVar}}"
}

示例:

jsx 复制代码
i18next.t('keyEscaped',{myVar:'<img />'});
// -> "no danger &lt;img &#x2F;&gt;"

i18next.t('keyUnescaped', { myVar: '<img />' });
// -> "dangerous <img />"

在实际使用中,比如在React中,是可以将escape关闭的,因为React本身已经做了XSS的防范。

jsx 复制代码
i18n
  .init({
    interpolation: {
      escapeValue: false // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape
    }
  });

比如下面的代码,最终页面上并不会出现<img />元素,而只会有一个""字符串。

jsx 复制代码
function App() {
  const str = "<img />";
  return <h2>{str}</h2>; // 欢迎
}

但要注意,这要是在服务端就不一样了。如果服务端拼接了一个完整的html字符串返回,它是无法区分哪些是标签,哪些是文字的,所以还需要做一定的转义处理来减轻XSS攻击。

总结

实现多语言的方法还是很多的,并且服务端组件与客户端组件的实现还有所不同,读者需要理解其中的差别。但有一个思路是统一的:首先自动识别语言并"留痕",然后加载对应的语言文件,接着渲染页面内容。在客户端页面中,i18n有很多开箱即用的插件来实现这些功能,比如i18next-browser-languagedetector与i18next-http-backend,以及整合了React的react-i18next。但在服务端页面中,比如Next.js,要实现一个简易的多语言功能会更为直接简单,你甚至不需要依赖i18next;如果需要用到i18next的模版插值等功能,则可以再将i18next引入进来。读者可以按需选择自己项目中适合的方案。

相关推荐
正小安1 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch3 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光3 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   3 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   3 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web3 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常3 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇4 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr4 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
Tiffany_Ho5 小时前
【TypeScript】知识点梳理(三)
前端·typescript