Nextjs 实现国际化翻译 - App Router 模式解决方案

Nextjs 实现国际化翻译 - App Router 模式解决方案

老板:最近我们网站想部署到国外去啊,我们加一个国际化翻译的功能,O 不 OK?

:接一下谷歌翻译嘛,啪的一下,很快嗷!

老板 :忘了说了,这个翻译最好能自己配置、没有配置文件的话需要兜底、变量插值,如果可以的话,页面的 title 也要能作用到哈,我们最近要搞 SEO 的。

:???

前言

如果拒绝的话,明天大家就看不到我了(不是),所以本篇文章我们从零开始构建一个 Next + App Router 的国际化翻译方案,其中将涉及到语言切换、翻译文件管理等要点。国际化翻译对于一个门户网站来说,是一个至关重要的功能,不管是为了你的网站有更多的 SEO 权重,还是为你的海外用户提供更好的用户体验,都是一个很值得做的优化。本文中所使用的代码已上传至 Github 仓库,这里是 Demo 访问地址

⚠️ 本文更偏向于介绍在国际化翻译过程中,处理问题的思路与优化方向,所以我们在这里不使用 next-intl 等国际化翻译库,有兴趣了解的同学可以点这里:Next Intl

起步

首先我们创建一个新的 Next 项目,创建的过程中全选 Yes 即可,请注意这里的 Next 版本需要超过 13,最好需要使用 v13.4 之后的稳定版本,我这里的版本是 13.4.12,node 版本为 20.13.1

shell 复制代码
npx create-next-app@latest my-i18n-app
cd my-i18n-app
yarn dev

现在我们已经有了一个基础的 Next 项目,并且可以运行,目前大概的文件结构为:

lua 复制代码
/.next
/node_modules
/src
  /app
    /fonts
    favicon.ico
    globals.css
    layout.tsx
    page.tsx
.eslintrc.json
.gitignore
next-env.d.ts
next.config.mjs
package-lock.json
package.json
postcss.config.ts
README.md
tailwind.config.ts
tsconfig.json

执行 yarn dev 完毕之后成功运行了,这个时候你应该可以看到以下内容:

接下来我们安装我们本次需要用到的库,执行 yarn add server-onlyserver-only 库的作用是确保该模块仅在服务器端执行。安装完毕之后,我们的 package.json 文件预计应该是如下内容:

json 复制代码
{
  "name": "my-i18n-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "14.2.15",
    "react": "^18",
    "react-dom": "^18",
    "server-only": "^0.0.1"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.2.15",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}

设置页面结构

我们先从搭建页面开始,这个页面我们用来承载国际化翻译的函数与使用,并且默认为服务端组件,如果需要转化为客户端组件的话,可以在组件的头部标注 'use client',Next 会自动将这个组件转为客户端组件。什么是客户端组件

我们创建一个 src/app/[lang]/page.tsx 作为主页,并使用 src/app/[lang]/layout.tsx 作为布局文件。[lang] 这种结构允许我们为每种语言创建独特的 URL 路径。

[lang] 这种写法叫做动态 API 路由,在 NextJSApp RouterPage Router 模式下都可使用,允许根据 URL 中的参数动态地生成页面内容,并且对 SEO 友好,详情参见:动态 API 路由

tsx 复制代码
// ./src/app/[lang]/page.tsx
import { Metadata } from 'next'

export default async function Home({
  params: { lang },
}: {
  params: { lang: string }
}) {
  return (
    <div>
      <main>
        <h1>
          Welcome to Next.js I18N
        </h1>
        <p>
          This is a Next.js app with server-side internationalization.
        </p>
        <div>
          This language is: {lang}
        </div>
      </main>
    </div>
  )
}

export async function generateMetadata({
  params: { lang },
}: {
  params: { lang: string }
}): Promise<Metadata> {
  return {
    title: "Next.js I18N App " + lang,
    description: "This is a Next.js app with server-side internationalization."
  }
}
tsx 复制代码
// ./src/app/[lang]/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Next.js I18N App',
  description: 'A Next.js app with server-side internationalization',
}

export default function RootLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: { lang: string }
}) {
  return (
    <html lang={params.lang}>
      <body className={inter.className}>{children}</body>
    </html>
  )
}

当大家访问 http://localhost:3000/en 的时候,就会发现可以看到页面上出现了语言标识 en,我们后续就可以使用这个标识去作为请求、加载语言文件的依赖。

翻译文件准备

我们的目标,除了需要支持远程加载语言文件外,还需要当请求失败的时候,做翻译的兜底,所以我们在这个环节,暂时使用本地的翻译文件来跑通翻译功能 。我们先在 /src 文件夹下创建一个 /dictionaries 文件夹,并在 /dictionaries 文件夹下创建三个翻译 JSON 文件,分别为 zh.jsonen.jsonja.json,并在其中置入初始的翻译配置,这里为了方便展示,合并在一起写:

json 复制代码
// ./src/dictionaries/zh.json
{
  "title": "你好,Cooyue柯同学",
  "welcome to {posi}, {name}": "欢迎来到 {posi}, {name}",
  "description": "这是一个具有服务器端国际化功能的 Next.js 应用。"
}

// ./src/dictionaries/en.json
{
  "title": "Hello Cooyue Ke",
  "welcome to {posi}, {name}": "Welcome to {posi}, {name}",
  "description": "This is a Next.js app with server-side internationalization."
}

// ./src/dictionaries/ja.json
{
  "title": "こんにちは Cooyue Ke",
  "welcome to {posi}, {name}": "{posi} へようこそ, {name}",
  "description": "これはサーバーサイドの国際化機能を持つ Next.js アプリです。"
}

我们定义了中文、英文、日文三种翻译配置,那么我们提供一个配置文件,便于后续的使用和统一管理。我们在 /src 文件夹下新建一个 i18n-config.ts 文件:

ts 复制代码
// ./src/i18n-config.ts
export const i18n = {
  defaultLocale: 'zh', // 默认为中文
  locales: ['zh', 'en', 'ja'], // 支持中文、英文、日文
} as const

export type Locale = (typeof i18n)['locales'][number]

默认语言

用户在访问我们的网站的时候,一般是不会显式地去输入某种语言标识的,作为一个使用者,我只希望我输入 https://yourdomain 之后,可以看到一个有默认语言的页面即可,所以我们需要一个中间件来做到这个事情。在 Next 中,存在一个默认的中间件配置 middleware.js 文件,通常位于项目的根文件夹或 /app 文件夹中。这个中间件的主要作用是做请求拦截、重定向、修改请求及响应,还可以集成鉴权等功能。详情可见:NextJS middleware,我们这边就需要使用到他的重定向等功能,我们先在 /src 文件夹下创建一个 middleware.js 文件:

ts 复制代码
// ./src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { i18n } from './i18n-config'

// 获取用户语言偏好
function getLocale(request: NextRequest): string {
  // 获取请求头中的 accept-language 字段
  const acceptLanguage = request.headers.get('accept-language')
  const languages = acceptLanguage ? acceptLanguage.split(',') : []
  // 可用的语言列表
  const locales: string[] = i18n.locales as unknown as string[]

  // 开始匹配语言
  for (const lang of languages) {
    const language = lang.split(';')[0].trim().toLowerCase(); // 处理优先级,提取语言代码
    if (locales.includes(language)) {
      return language;
    }
  }
  
  // 未匹配上,返回默认语言
  return i18n.defaultLocale;
}

// 中间件
export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname
  // 检查路径是否缺少语言前缀
  const pathnameIsMissingLocale = i18n.locales.every(
    (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
  )

  // 无语言前缀,重定向
  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);
    return NextResponse.redirect(
      new URL(`/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`, request.url)
    )
  }
}

// 配置中间件的匹配规则
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], // 排除 API 请求和静态文件
}

配置后大家可以发现,当请求 http://localhost:3000 的时候,会自动跳转到 /zh 路由,这样可以避免当只访问根路由的时候,丢失语言标识的情况。

我们本文中使用的这种国际化翻译的路由方案叫做 Sub Router(子路由),语言标识会作为路径的一部分添加到 URL 中,如本文中的 /zh/en。这种方案有以下显著的优势:

  1. 结构清晰,页面、组件、语言资源共享可以在同一个域名下共享,方便统一管理,适合内容较为相似的多语言站点。
  2. SEO 友好,搜索引擎可以轻松识别不同语言的页面。

提问!除了这种区分语言的方案,还有别的方案吗?

当然有!另一种国际化翻译路由方案叫做 Domain Router(域名路由),顾名思义,这种方案通过不同的域名来决定该域名下的语言,如 https://www.nextjs.cnhttps://nextjs.org 的区别,在这种方案下,每个语言都可以有独立的设置、内容和用户体验,不过这种方案要求多域名 + 对应的服务,一般出现在比较强调地域性的海外需求中,理论上在同一个项目中就只是单语言,所以这里我们这里的演示使用 Sub Router 方案。

拉取翻译

既然我们现在可以在服务端的页面上拿到 lang 标识了,那么我们就尝试使用这个标识去拿对应的静态语言配置吧,现在在 /src 文件夹下创建一个 get-dictionary.ts 文件:

ts 复制代码
// ./src/get-dictionary.ts
import 'server-only'
import type { Locale } from './i18n-config'

const dictionaries = {
  zh: async () => import('./dictionaries/zh.json'),
  en: async () => import('./dictionaries/en.json'),
  ja: async () => import('./dictionaries/ja.json'),
}

export const getDictionary = async (locale: Locale) => {
  // 其他拉取策略
  // 兜底
  if (['zh', 'en', 'ja'].includes(locale)) {
    return dictionaries[locale]()
  }
  return {} as any
}

这里设计的 getDictionary 函数是统一的拉取函数,这个函数会根据标识,拿到 ./src/dictionaries 下的对应的 JSON 文件,并转为对象形式返回回去,然后我们修改一下 page.tsx 文件,让他可以使用 getDictionary 函数来展示正确的内容。我们暂定这个 getDictionary 函数会返回一个普通对象:

tsx 复制代码
// ./src/app/[lang]/page.tsx
import { getDictionary } from '@/get-dictionary'
import { i18n, Locale } from '@/i18n-config'
import { Metadata } from 'next'
import Link from 'next/link'

export default async function Home({
  params: { lang },
}: {
  params: { lang: Locale }
}) {

  const dictionary = await getDictionary(lang)

  return (
    <div style={{ padding: 24 }}>
      <main>
        <h1>
          {dictionary.title}
        </h1>
        <p>
          {dictionary.description}
        </p>
        <div>
          {i18n.locales.map((locale) => {
            return (
              <ol key={locale}>
                <Link href={locale}>{locale}</Link>
              </ol>
            )
          })}
        </div>
      </main>
    </div>
  )
}

export async function generateMetadata({
  params: { lang },
}: {
  params: { lang: Locale }
}): Promise<Metadata> {

  const dictionary = await getDictionary(lang)

  return {
    title: dictionary.title,
    description: dictionary.description
  }
}

成功运行后,我们将获得了上面的页面,为了提升美观性,添加了适当的内边距,这个页面的内容及 head 中的 meta 信息已完成国际化翻译,正确有效的 meta 信息对 SEO 很有帮助。我们现在已经做到了从零搭建一个 Next 的国际化翻译功能,做到目前已经可以初步使用了!但是还是存在一些很明显的可以优化的地方:

  1. 翻译文件存在本地,需要修改的话非常麻烦,需要变更文件、推代码、发版本。
  2. 【我是一个「杭州」的前端】,这个杭州两个字我希望可以动态插入,比如类似 dictionary.t('i_am_a_{job}_front_end', { job: '北京' }) 的形式。
  3. 我还有浏览器端的页面,所以我希望能有非 await 的方式可以拿到翻译内容的方法。

优化

远程翻译文件

这种远程拉取翻译文件的方案,是需要除了本项目外的其他支持的,因为你希望语言可以做灵活的配置发布的话,让运营同学手改 JSON 是个不现实的事情,也不能很好的维护公司内使用的翻译字典,所以你最好能有一个配置的平台,在这个平台修改翻译的配置后,可以转成 JSON 文件并发布到远程的静态文件文件夹或者是 OSS 等云储存上**(如果大家对这个有兴趣,后续也可以补一下这个项目)**。这里以 ${process.env.NEXT_PUBLIC_I18N_HOSTS}/article/${locale}_web.json 来举例,这里的 NEXT_PUBLIC_I18N_HOSTS 被放置在了 env 中防止泄漏,后续大家部署的时候还可以放置在环境变量中。我们来修改一下 getDictionary 函数:

ts 复制代码
// ./src/get-dictionary.ts
import 'server-only'
import { i18n, type Locale } from './i18n-config'

const dictionaries = {
  zh: async () => import('./dictionaries/zh.json'),
  en: async () => import('./dictionaries/en.json'),
  ja: async () => import('./dictionaries/ja.json'),
}

export const getDictionary = async (locale: Locale) => {
  const ossResult = await getDictionaryByOss(locale);
  if (ossResult) {
    return ossResult
  }
  if (i18n.locales.includes(locale)) {
    return dictionaries[locale]()
  }
  return {} as any
}

export const getDictionaryByOss = async (locale: Locale) => {
  // 无配置跳过拉取
  if (!process.env.NEXT_PUBLIC_I18N_HOSTS) return;
  // 远程拉取地址
  const url = `${process.env.NEXT_PUBLIC_I18N_HOSTS}/article/${locale}.json`
  try {
    const res = await fetch(url)
    if (res.ok) {
      return await res.json()
    }
  } catch (error) {}
  return;
}

以上代码修改后,如果存在正确的拉取前缀 NEXT_PUBLIC_I18N_HOSTS,则可在服务运行前就将翻译文件拉取至服务器端,如果不存在,则直接拿本项目内置的 JSON 文件,这里大家可以根据自己的需求来决定拉取的方案,比如我们可以在项目打包的时候,用脚本拉一次所有远程翻译文件至服务器,用这一份"缓存"作为兜底。

动态插入

另外不知道大家有没有发现这么使用翻译 dictionary.title 会存在的几个问题,如果我的翻译 key 是携带空格或者是存在非下划线的字符的话,就只能使用 dictionary['sub title'] 或者 dictionary['email@google.com'] 的形式,并且如果 title 无内容的时候,会展示 undefined 而不是 key 的原内容!我们接下来就把这个翻译的获取形式,由 dictionary.title,转为 dictionary('title') 的形式,并且支持类似 dictionary.t('i_am_a_{job}_front_end', { job: '北京' }) 的值插入的能力。

ts 复制代码
// ./src/get-dictionary.ts
import 'server-only'
import { i18n, type Locale } from './i18n-config'

const dictionaries = {
  zh: async () => import('./dictionaries/zh.json'),
  en: async () => import('./dictionaries/en.json'),
  ja: async () => import('./dictionaries/ja.json'),
}

export const getDictionary = async (locale: Locale) => {
  const ossResult = await getDictionaryByOss(locale);
  if (ossResult) {
    return createDictionaryFunction(ossResult);
  }
  if (i18n.locales.includes(locale)) {
    const dict = await dictionaries[locale]()
    return createDictionaryFunction(dict as unknown as Record<string, string>);
  }
  return createDictionaryFunction({});
}

export const getDictionaryByOss = async (locale: Locale) => {
  if (!process.env.NEXT_PUBLIC_I18N_HOSTS) return;
  const url = `${process.env.NEXT_PUBLIC_I18N_HOSTS}/article/${locale}.json`
  try {
    const res = await fetch(url)
    if (res.ok) {
      return await res.json()
    }
  } catch (error) {
    console.log(error)
  }
  return;
}

// 创建字典函数
const createDictionaryFunction = (dictionary: Record<string, string>) => {
  return (key: string, variables?: Record<string, string>): string => {
    const template = dictionary[key] || key; // 如果没有找到则返回 key
    return interpolate(template, variables);
  };
};

// 动态插入功能
const interpolate = (template: string, variables?: Record<string, string>): string => {
  if (!variables) return template;
  return template.replace(/\{(\w+)\}/g, (_, key) => variables[key] || '');
};
tsx 复制代码
// ./src/app/[lang]/page.tsx
import { getDictionary } from '@/get-dictionary'
import { i18n, Locale } from '@/i18n-config'
import { Metadata } from 'next'
import Link from 'next/link'

export default async function Home({
  params: { lang },
}: {
  params: { lang: Locale }
}) {

  const t = await getDictionary(lang)

  return (
    <div style={{ padding: 24 }}>
      <main>
        <h1>
          {t('title')}
        </h1>
        <p>
          {t('description')}
        </p>
        <p>
          {t('welcome to {posi}, {name}', { posi: 'Next.js', name: 'Cooyue' })}
        </p>
        <p>
          {t('这是不存在翻译的文本')}
        </p>
        <div>
          {i18n.locales.map((locale) => {
            return (
              <ol key={locale}>
                <Link href={locale}>{locale}</Link>
              </ol>
            )
          })}
        </div>
      </main>
    </div>
  )
}

export async function generateMetadata({
  params: { lang },
}: {
  params: { lang: Locale }
}): Promise<Metadata> {

  const t = await getDictionary(lang)

  return {
    title: t('title'),
    description: t('description')
  }
}

修改完毕并执行后,大家可以在 http://localhost:3000/zh 下看到如下内容,可以看到额外展示了【欢迎来到 Next.js, Cooyue 】的字样,在这个例子中,posiname 参数都可以自定义传入,并且支持无翻译时展示原 key

hooks

那么我们在客户端如何去使用国际化翻译的文件呢?现在我们来创建一个客户端的组件 LocaleSwitcher,并把这个组件置入 page.tsx 文件中,我们将用这个组件来演示客户端的翻译内容获取。

tsx 复制代码
// ./src/components/locale-switcher.tsx
'use client'

import { usePathname } from 'next/navigation'
import Link from 'next/link'
import { i18n } from '@/i18n-config'
import { useDictionary } from '@/hooks/useDictionary'

export default function LocaleSwitcher() {
  
  const pathName = usePathname()

  const t = useDictionary()

  const redirectedPathName = (locale: string) => {
    if (!pathName) return '/'
    const segments = pathName.split('/')
    segments[1] = locale
    return segments.join('/')
  }

  return (
    <div>
      <p>这里客户端组件的翻译结果:{t('title')}</p>
      <ul>
        {i18n.locales.map((locale) => {
          return (
            <li key={locale}>
              <Link href={redirectedPathName(locale)}>{locale}</Link>
            </li>
          )
        })}
      </ul>
    </div>
  )
}
tsx 复制代码
// ./src/app/[lang]/page.tsx
import LocaleSwitcher from '@/components/locale-switcher'
import { getDictionary } from '@/get-dictionary'
import { Locale } from '@/i18n-config'
import { Metadata } from 'next'

export default async function Home({
  params: { lang },
}: {
  params: { lang: Locale }
}) {

  const t = await getDictionary(lang)

  return (
    <div style={{ padding: 24 }}>
      <main>
        <h1>
          {t('title')}
        </h1>
        <p>
          {t('description')}
        </p>
        <p>
          {t('welcome to {posi}, {name}', { posi: 'Next.js', name: 'Cooyue' })}
        </p>
        <p>
          {t('这是不存在翻译的文本')}
        </p>
        <LocaleSwitcher />
      </main>
    </div>
  )
}

export async function generateMetadata({
  params: { lang },
}: {
  params: { lang: Locale }
}): Promise<Metadata> {

  const t = await getDictionary(lang)

  return {
    title: t('title'),
    description: t('description')
  }
}

现在我们着重来处理这个 useDictionary 函数!这个由于是客户端组件的原因,所以我们不能用异步函数的形式来写,所以我们考虑他的初始状态应该是以下这个样子的 Hooks,下面的一小串代码是不可用的,我们只看思路:

tsx 复制代码
// 示例代码,只用于看思路
export const useDictionary = () => {
  // 获取语言,不希望每次都需要外部手动获取语言
  const locale = useLocale()

  // 获取 OSS 的 hooks,这个 hooks 应该在 locale 变动的时候只请求一次
  const ossResult = useDictionaryByOss();
  // 如果存在返回的内容,直接使用
  if (ossResult) {
    // 某种处理翻译对象 + 自定义参数,这个函数最后需要返回一个函数供外部使用
    return createDictionaryFunction(ossResult);
  }

  // 获取远程文件错误,确认语言是否合法,拿对应的项目文件
  if (i18n.locales.includes(locale)) {
    const dict_map = {
      'zh': ZH_CONFIG,
      'en': EN_CONFIG,
      'ja': JA_CONFIG,
    }
    const dict = dict_map[locale] as Record<string, string>
    return createDictionaryFunction(dict);
  }

  // 兜底
  return createDictionaryFunction({});
}

这里需要注意的是 useDictionaryByOss 的性能,必须只在语言存在变动的时候只请求一次,另外 createDictionaryFunction 函数需要创建一个外部可使用的函数,所以我们可以根据这个思路得出以下 Hooks 代码:

ts 复制代码
// ./src/hooks/useDictionary.ts
import { useEffect, useState } from "react"
import { i18n, Locale } from "@/i18n-config"
import { usePathname } from "next/navigation"
import ZH_CONFIG from '../dictionaries/zh.json'
import EN_CONFIG from '../dictionaries/en.json'
import JA_CONFIG from '../dictionaries/ja.json'

export const useLocale = () => {
  const pathName = usePathname()
  if (!pathName) return i18n.defaultLocale
  return pathName.split('/')[1] as Locale
}

export const useDictionary = () => {
  const locale = useLocale()

  const ossResult = useDictionaryByOss();
  if (ossResult) {
    return createDictionaryFunction(ossResult);
  }

  if (i18n.locales.includes(locale)) {
    const dict_map = {
      'zh': ZH_CONFIG,
      'en': EN_CONFIG,
      'ja': JA_CONFIG,
    }
    const dict = dict_map[locale] as Record<string, string>
    return createDictionaryFunction(dict);
  }

  return createDictionaryFunction({});
}

const useDictionaryByOss = () => {
  const [result, setResult] = useState<Record<string, string> | null>(null)
  const locale = useLocale()
  useEffect(() => {
    if (!process.env.NEXT_PUBLIC_I18N_HOSTS) return;
    const url = `${process.env.NEXT_PUBLIC_I18N_HOSTS}/article/${locale}.json?${Date.now()}`
    fetch(url)
      .then(res => res.json())
      .then(setResult)
      .catch(() => setResult(null))
  }, [locale])
  return result
}

// 创建字典函数
const createDictionaryFunction = (dictionary: Record<string, string>) => {
  return (key: string, variables?: Record<string, string>): string => {
    const template = dictionary[key] || key; // 如果没有找到则返回 key
    return interpolate(template, variables);
  };
};

const interpolate = (template: string, variables?: Record<string, string>): string => {
  if (!variables) return template;
  return template.replace(/\{(\w+)\}/g, (_, key) => variables[key] || '');
};

修改完毕后,我们可以发现在 ./src/components/locale-switcher.tsx 这个客户端组件中,能成功拿到翻译的内容。

尾声

至此我们已经完成了,为 Next 项目添加国际化翻译的功能,并通过这个案例来系统地思考过程中遇到的问题与优化点,以及解决的思路,以下是本文涉及到的相关链接,本文的项目已经部署到了 Vercel

相关链接:

Demo 访问地址 github地址 客户端组件 动态 API 路由 NextJS middleware Next Intl NextJS 官方文档 NextJS 中文文档

我是 Cooyue柯同学,欢迎关注我~

相关推荐
迷雾漫步者3 分钟前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-30 分钟前
验证码机制
前端·后端
燃先生._.2 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖2 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235243 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240253 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar3 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人4 小时前
前端知识补充—CSS
前端·css
GISer_Jing4 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试