简介:这是一份基于React构建的LanguageSelector前端语言选择器项目源码,面向前端初学者以及需要快速实现多语言切换界面的Web开发者。项目基于Create React App标准工程搭建,提供npm start、npm test、npm run build等常用脚本:开发模式支持热更新和代码检查,测试模式可交互式运行,生产构建会压缩资源并自动生成带哈希值的文件名,便于直接部署。资源共15个文件、压缩后约165KB,以5个JavaScript逻辑文件、3个JSON配置文件和HTML入口文件为主,同时包含图标、页面说明等静态资源,整体体积小巧、目录结构规范。通过阅读源码可学习React工程化配置、组件拆分与常用脚本用法,也可直接作为语言选择功能的基础模板继续扩展。目前已有173人浏览学习,适合边读边改、循序渐进地上手React项目。
1. LanguageSelector 是什么:一个 HTML 语言选择器该管的四件事
很多产品对外发布后,第一波海外用户反馈往往不是功能缺陷,而是界面语言不对。有人默认收到的是英文,有人切了日文但刷新后回到中文,还有人明明选了阿拉伯语,页面的布局却完全没有跟着翻转。LanguageSelector 就是这样一个基于 HTML/CSS/JavaScript 实现的语言切换组件,它要管的不只是点击下拉菜单换文案,还包括语言包的加载与缓存、用户选择的持久化、页面 lang 属性和文字方向的同步,以及切换后所有动态内容的一致性。适合正在做国际化前端的从业者——新手能照着最小实现跑通流程,熟手能直接带走边界处理思路。
2. 从零搭一个 LanguageSelector:DOM 结构、事件绑定与最小可用版
2.1 为什么用原生 HTML/CSS/JS,而不是框架现成组件
第一个要回答的问题是:有 Element UI、Ant Design 这些现成下拉框,为什么还要自己写?我的判断标准是:语言选择器是全局控件,它要同时改 document 的 lang 属性、localStorage、动态插入的 DOM 节点,和构建工具、服务端模板都有耦合。用框架组件反而要把业务逻辑塞进组件的生命周期里,出问题更难排查。原生实现没有额外依赖,一个 JavaScript 文件加一段 CSS 就能嵌入任何页面,甚至后端模板引擎也能直接输出这套结构。
另外,原生实现可以精确控制渲染时机。比如首屏闪白的解法,需要在 head 里提前执行 localStorage 读取,框架组件做不到这个加载顺序。如果你只是做一个内部管理系统的简单双语言切换,框架组件够用;但凡涉及多种语言、多区域、需要防御各种浏览器环境,原生方式的可控性会好很多。
2.2 HTML 结构:按钮 + 菜单,而不是 select
很多新手会直接用<select>做语言切换,因为表单控件天然支持键盘操作,代码量也少。<select>的问题是:选项里的文字也是页面文案的一部分,当语言包尚未加载时,<select>会显示空白或者默认文案;而且在部分移动端浏览器上,<select>的弹层样式完全由系统决定,你没法控制语言名称的展示格式。所以我一般用 button 触发、ul 模拟列表的结构,配合 ARIA 属性达到和 select 等价的可访问性。
<div class="language-selector" id="languageSelector"> <button class="language-selector__trigger" type="button" aria-haspopup="listbox" aria-expanded="false"> <span>(function () { 'use strict'; var LANGS = { 'zh-CN': { 'selector.currentLang': '简体中文', 'welcome.title': '欢迎使用', 'welcome.desc': '这是一个带有语言选择功能的演示页面。' }, 'en-US': { 'selector.currentLang': 'English', 'welcome.title': 'Welcome', 'welcome.desc': 'This is a demo page with language switching.' } }; var DEFAULT_LANG = 'zh-CN'; var STORAGE_KEY = 'languageSelector.locale'; var selector = document.getElementById('languageSelector'); var trigger = selector.querySelector('.language-selector__trigger'); var menu = selector.querySelector('.language-selector__menu'); var options = Array.prototype.slice.call( menu.querySelectorAll('[role="option"]') ); function render(locale) { var dict = LANGS[locale] || LANGS[DEFAULT_LANG]; document.documentElement.setAttribute('lang', locale); Array.prototype.forEach.call( document.querySelectorAll('[data-i18n]'), function (node) { var key = node.getAttribute('data-i18n'); if (dict[key]) { node.textContent = dict[key]; } } ); options.forEach(function (option) { var selected = option.getAttribute('data-lang') === locale; option.setAttribute('aria-selected', selected ? 'true' : 'false'); }); } trigger.addEventListener('click', function () { var expanded = trigger.getAttribute('aria-expanded') === 'true'; trigger.setAttribute('aria-expanded', String(!expanded)); menu.hidden = expanded; }); options.forEach(function (option) { option.addEventListener('click', function () { var locale = option.getAttribute('data-lang'); if (!LANGS[locale]) { return; } render(locale); try { window.localStorage.setItem(STORAGE_KEY, locale); } catch (e) { // 隐私模式或存储被禁用时忽略 } trigger.setAttribute('aria-expanded', 'false'); menu.hidden = true; }); }); render(DEFAULT_LANG); })();render(locale) 里先做document.documentElement.setAttribute('lang', locale)。浏览器会拿这个属性做拼写检查、阅读器朗读发音,也是后面 RTL 切换的决策依据。替换文本用的是 textContent 而不是 innerHTML——语言包里的文案来自翻译文件,万一翻译里夹带了 HTML 标签,innerHTML 等于把 XSS 通道直接开在页面上,textContent 最多让你看到一段带标签的纯文本,不会执行任何脚本。
options 的 NodeList 先通过 Array.prototype.slice.call 转成数组,规避旧版浏览器对 NodeList.forEach 不支持的兼容问题。LANGS[locale] || LANGS[DEFAULT_LANG]是兜底逻辑:万一 URL 参数或 localStorage 里存了一个已下线的语言代码,渲染还是能回退到默认语言。
2.4 语言包组织:JSON 键值对还是嵌套字典
关于语言包的结构,常见做法是平铺的键值对。为什么不推荐嵌套字典?嵌套结构在读取和合并时都更麻烦:读取需要一层层 try/catch,合并翻译时要处理深拷贝,还要担心某个分支缺失。平铺结构用一个点状命名空间(selector.currentLang、welcome.title)就能解决问题,读取时不管在哪个层级都只查一次。
上面演示代码把语言包直接写进了 JS,实际工程里通常是独立 JSON 文件,按需加载。初始化时先加载默认语言包,用户切换到别的语言后再异步加载对应文件,避免“加载四种语言、实际只用一种”的浪费:
function loadLangPack(locale) { if (LANGS[locale]) { return Promise.resolve(LANGS[locale]); } return fetch('assets/i18n/' + locale + '.json') .then(function (response) { if (!response.ok) { throw new Error('语言包加载失败: ' + locale); } return response.json(); }) .then(function (pack) { LANGS[locale] = pack; return pack; }); }fetch 返回的是 Promise,切换语言时先 loadLangPack 再 render,避免点击后出现一小段空白。参数说明:assets/i18n/ 是语言包目录,命名与>var STORAGE_KEY = 'languageSelector.locale'; function getSavedLocale() { var saved = null; try { saved = window.localStorage.getItem(STORAGE_KEY); } catch (e) { // Safari 隐私模式和部分浏览器禁用存储时会抛异常 } return saved; } function setSavedLocale(locale) { try { window.localStorage.setItem(STORAGE_KEY, locale); } catch (e) { // 写入失败静默处理,不影响本次切换 } }
有人会问:try-catch 是不是想多了?我建议你打开 Safari 的隐私浏览模式试一次,localStorage.getItem 会直接抛 SecurityError。如果不包 try-catch,整个 LanguageSelector 初始化会中断,页面上其他脚本也可能跟着挂掉。常见做法是捕获异常后只做内存级的状态保持,session 内可用,刷新后回退默认语言,这属于降级而不是故障。
getItem 返回的是字符串,如果之前不小心写入了非法的语言代码,比如旧版本 bug 写入了一个已经不在支持列表里的 key,就需要校验逻辑:
var SUPPORTED = ['zh-CN', 'en-US', 'ja-JP', 'de-DE']; function normalizeLocale(locale) { if (SUPPORTED.indexOf(locale) > -1) { return locale; } return DEFAULT_LANG; }读取之后过一遍 normalizeLocale 再进入渲染,比在 render 里做 fallback 更先发现问题。第 2 章的 render 里也写了LANGS[locale] || LANGS[DEFAULT_LANG],那是渲染层的兜底;存储层再校验一次,是为了避免把非法值写回 localStorage。
3.3 URL 参数覆盖:分享链接的语言直达
除了本地存储,还要支持 URL 参数。典型场景是分享链接:用户 A 用德文界面,复制给 B 的链接里带着 ?lang=de-DE,B 打开后也应该直接看到德文。URL 参数的优先级应当高于 localStorage,因为它是链接显式表达的意图。
function resolveLocale() { var params = new URLSearchParams(window.location.search); var fromUrl = params.get('lang'); if (fromUrl && SUPPORTED.indexOf(fromUrl) > -1) { return fromUrl; } return normalizeLocale(getSavedLocale()); }URLSearchParams 在较老的浏览器里没有,如果项目要兼容 IE 或旧版 Edge,通常用一个正则解析 search 字符串:
function getParam(name) { var match = new RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search); return match ? decodeURIComponent(match[1]) : null; }两种写法效果等价,选择哪个取决于目标浏览器的下限。如果已经在用构建工具和 Babel,URLSearchParams 省事;如果资源是直接放到服务器上的原生 JS,正则写法更稳。
另一个细节:URL 参数一旦进入页面,要不要把它写入 localStorage?我的习惯是写入。因为用户从分享链接进来后,他后续再切换语言,应该基于当前 URL 的语言继续走,而不是突然跳回旧值。把 URL 参数写入 localStorage 后,下次无参数打开页面也能保持分享时的语言。
3.4 首屏闪白:在 head 里提前恢复 lang 属性
闪白的原理很简单:浏览器先以默认语言渲染了标题、导航、按钮,等外部 JavaScript 文件执行完 localStorage 读取后才改成用户上次选的语言,用户肉眼看到的就是“先显示中文,再闪成英文”。解决方案是在<head>里放一段尽量小的 inline script,抢在首屏渲染前把 lang 属性改好。
<head> <meta charset="UTF-8"> <title>示例页面</title> <script> (function () { var saved = null; try { saved = localStorage.getItem('languageSelector.locale'); } catch (e) {} var lang = saved || 'zh-CN'; document.documentElement.setAttribute('lang', lang); })(); </script> </head>这段脚本只做两件事:读 localStorage、设置 html 标签的 lang。它不负责替换文本,所以很快,基本不会阻塞首屏。真正的文案替换还是等语言包和主 JS 加载完,再由主逻辑执行完整 render。如果你想连文案都提前换,可以把默认语言包也做成 inline script,但那样会让 HTML 体积变大,通常不划算。
这里的失败模式是:如果一段 inline script 抛异常,浏览器会直接停止执行后续的 inline script,但不影响外部 JS 加载。所以 inline script 里一定要写 try-catch,并且代码保持简单,这是首屏恢复的最后一道保险。
4. 避坑排查:LanguageSelector 最常见的五个翻车现场
4.1 现象:按钮点击没反应,控制台报跨域错误
现象:用编辑器直接双击打开 index.html,点了语言选择器的按钮,菜单能展开,但点击语言选项没有任何反应,F12 控制台报 “Cross origin requests are only supported for HTTP”。
原因:页面是通过 file:// 协议打开的本地 HTML,浏览器出于安全策略禁止 fetch 加载本地 JSON 语言包。fetch 请求被拦截后,loadLangPack 的 Promise 直接 reject,render 自然执行不了。这不是代码逻辑错,而是加载环境不对。
解决:本地调试统一用本地静态服务器。在项目根目录跑python3 -m http.server 8000,然后访问http://localhost:8000/。如果嫌 Python 麻烦,直接装 Live Server 插件点 “Go Live”。从那以后我每次搭建前端 demo 的第一件事就是开本地服务器,而不是双击打开 HTML。
4.2 现象:刷新页面后语言设置丢失
现象:在设置里切换成英文,页面看起来也正常,但按 F5 刷新后,界面又变成默认中文。
原因:第一种是初始化入口直接写了 DEFAULT_LANG,没有调用 resolveLocale,getSavedLocale 读到的值根本没进渲染流程。第二种是 localStorage 写入失败被 try-catch 吞掉,刷新后自然拿不到旧值。
解决:优先检查初始化入口,确认调用的是 resolveLocale() 而不是硬编码默认语言。其次在 Safari 隐私模式下用 console.log 打印 getSavedLocale 的返回值,如果为 null 说明存储本身被浏览器禁用。这两步能覆盖 90% 以上的“刷新后设置丢失”问题。
4.3 现象:切换语言后按钮宽度跳动,布局错位
现象:中文标题短,德文标题特别长,切换后顶部导航的语言选择器按钮忽宽忽窄,把旁边的链接挤到下一行。
原因:语言选择器的容器没有设置最小宽度或约束最大宽度,按钮宽度由文案撑开,语言切换后文案长度变化导致重新排布。
解决:给.language-selector__trigger设置 min-width,同时加 white-space: nowrap 防止长文字换行。宽度值按最大语言包的文案长度估算,德文和俄文通常需要预留更多空间:
.language-selector__trigger { min-width: 8em; max-width: 13em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }如果菜单宽度也跟着跳动,给.language-selector__menu设置固定宽度或 min-width,用 max-height 限制超长列表滚动。记住:多语言界面的布局,要按最长语言做设计,而不是按默认语言。
4.4 现象:动态插入的 DOM 节点永远不翻译
现象:页面里有一段异步加载的评论区,切换语言后,已加载的评论、按钮文案都变了,但新插入的节点还是旧语言。
原因:render 只遍历了一次document.querySelectorAll('[data-i18n]')。异步组件插入新节点时没有重新调用 render,新节点上的>var observer = new MutationObserver(function (mutations) { mutations.forEach(function (mutation) { Array.prototype.forEach.call(mutation.addedNodes, function (node) { if (node.nodeType === 1) { translateNode(node); } }); }); }); observer.observe(document.body, { childList: true, subtree: true });
注意:MutationObserver 是异步回调,监听整个 body 会有性能开销,不要在大规模页面里滥用。更实际的做法是让所有模块在数据更新后统一走一次 render,或者在模块自己的渲染函数里声明依赖语言包的版本号,语言包更新时强制重新渲染。
4.5 现象:浏览器自动翻译和语言选择器打架
现象:用户用的是 Chrome,地址栏自动出现翻译提示,一点“翻译成英文”,页面上原本被语言包替换的文案又被浏览器翻译了一遍,出现“中文界面 + 英文标签混排”的诡异效果。
原因:Chrome 的自动翻译是浏览器级别的功能,它根据 html lang 属性和用户浏览器语言判断是否弹出翻译条。如果 html lang 是 en-US,而浏览器是中文环境,Chrome 就会认为页面需要被翻译。语言选择器切换后没有同步更新 lang 属性,或者 lang 写的是不标准的 zh 而不是 zh-CN,都会触发自动翻译。
解决:语言选择器每次切换后把document.documentElement.lang设置为标准 BCP 47 代码,再在 head 里加一个 meta 标签明确声明不需要自动翻译:
<meta name="google" content="notranslate">这个标签只对 Google 系浏览器生效,Firefox 和 Safari 没有统一的自动翻译机制,但设置了不影响其他浏览器。如果产品内部维护了自己的翻译机制,还可以配合 Content-Language 响应头,让服务端明确声明语言。
5. 字体、RTL 与可访问性:语言选择器的收尾工程
5.1 按语言切换字体栈
语言选择器切换的不只是文案,还有字体表现。中文用中文字体,日文用日文字体,英文用西文字体,一套写死的 font-family 在切换后会出现奇怪的显示——比如在中文 Windows 上渲染日文,字体回退逻辑会先挑系统中文字体,日文假名还好,汉字可能显示成简体字形。
常见做法是按 lang 属性给 body 或根元素挂不同的字体栈:
html[lang="zh-CN"] body { font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } html[lang="ja-JP"] body { font-family: -apple-system, "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo", sans-serif; } html[lang="en-US"] body, html[lang="de-DE"] body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }这段 CSS 的触发条件就是 html 标签上的 lang 属性,所以第 2 章里“每次渲染都要 setAttribute lang”不是可有可无的规范,它直接决定了字体栈是否生效。如果项目里用了 web font,还要按语言分开加载,比如拉丁字体只加载 Latin 子集、中文字体只加载需要的字符集,不然语言包没多大,字体文件反而拖垮首屏。
5.2 RTL 布局:切换阿拉伯语时全局翻转
阿拉伯语、希伯来语是从右往左书写的,页面里的导航、文字对齐、箭头方向都要跟着翻转。只把文案换成阿拉伯语不够,布局也得变。标准做法是在 html 标签上切换 dir 属性:
function syncDirection(locale) { var RTL_LANGS = ['ar', 'he', 'fa', 'ur']; var code = locale.split('-')[0]; var dir = RTL_LANGS.indexOf(code) > -1 ? 'rtl' : 'ltr'; document.documentElement.setAttribute('dir', dir); }dir 切换后,大多数布局会自动跟着变:flex 的主轴方向在 rtl 下会反过来,text-align: left/right 的物理方向也会被 dir 重写。真正容易翻车的是那些用了绝对定位或者 left/right 硬编码的组件。比如原本定位在右侧的返回按钮,RTL 下应该跑到左侧,如果写的是position: absolute; right: 16px,它不会跟着变。排查方法很简单:切到 RTL 后扫一遍页面里所有 left/right 写死的样式,改用 inset-inline-start / inset-inline-end 逻辑属性。
逻辑属性是 CSS 里相对容易遗漏的部分,但不需要一次性改完。语言选择器自己的菜单在 RTL 下通常需要把菜单位置对齐到主按钮的右侧边缘,用逻辑属性写一行 CSS 就能同时满足 LTR 和 RTL:
.language-selector__menu { inset-inline-end: 0; inset-inline-start: auto; }5.3 可访问性:读屏软件不是加分项
语言选择器的可访问性,重点在三个细节。
第一,触发按钮的 aria-haspopup="listbox" 和 aria-expanded 必须同步更新。读屏用户在点击按钮时,需要知道展开的是一个语言列表,且当前列表是否展开。第 2 章的 click 里写了 setAttribute('aria-expanded'),但 menu.hidden 控制显隐也要保证和 aria-expanded 一致。有一步不一致,读屏反馈就是错的。
第二,每个选项的 aria-selected 必须只在当前语言上为 true。这个逻辑在 render 里做过了,但要留意初始化时如果省了 render(DEFAULT_LANG),页面标记和实际显示会不一致。
第三,焦点管理。菜单展开后,焦点应该保持在触发按钮上,用户按上下方向键遍历语言选项,Esc 键收起菜单。最精简的键盘处理如下:
menu.addEventListener('keydown', function (e) { if (e.key === 'Escape') { trigger.setAttribute('aria-expanded', 'false'); menu.hidden = true; trigger.focus(); } });不要在一个语言选择器上去实现过于复杂的 roving tabindex,读屏用户使用 listbox 的标准交互模式即可。键盘操作的目标是“能完成选择”,不是“和鼠标操作一样流畅”。
5.4 完整目录与接入检查清单
到这里,一个可以落地的 LanguageSelector 至少包含这几个文件:
| 文件 | 作用 | 是否需要 |
|---|---|---|
| index.html | 页面骨架与语言选择器 DOM | 必须 |
| assets/i18n/zh-CN.json | 默认语言包 | 必须 |
| assets/i18n/en-US.json | 其他语言包 | 按需 |
| assets/css/language-selector.css | 按钮、菜单、RTL 样式 | 建议 |
| assets/js/language-selector.js | 渲染、持久化、事件绑定 | 必须 |
接入新项目时我一般按清单走一遍:确认 html 根元素的 lang 和 dir 有初始值;确认 localStorage 和 URL 参数两种入口都能正确判断优先级;确认所有动态插入的节点都有翻译入口;确认 RTL 语言下菜单位置方向正确;最后用隐私模式刷新两次,确认无异常。
6. 进阶技巧:把 LanguageSelector 封装成全局语言事件总线
语言选择器做完后,最常见的后续需求是:不只页面文案要切换,日期组件、图表 tooltip、富文本编辑器、甚至服务端下发的错误码提示,都要跟着切。如果每个组件各自绑定 LanguageSelector 的点击事件,代码会很快变成一团乱麻。我一般把语言变更做成一个事件,让所有需要响应语言变化的模块订阅它。
function setLocale(locale) { render(locale); saveLocale(locale); syncDirection(locale); window.dispatchEvent(new CustomEvent('language:changed', { detail: { locale: locale, dict: LANGS[locale] } })); }订阅方这样写:
window.addEventListener('language:changed', function (e) { var locale = e.detail.locale; var dict = e.detail.dict; document.title = dict['meta.title'] || DEFAULT_TITLE; if (typeof chartInstance !== 'undefined') { chartInstance.setLocale(locale); } });这套做法的核心是把“语言状态”和“界面更新”解耦。LanguageSelector 只管改状态、发事件,不关心订阅方是谁;订阅方也只需要关心语言变了之后自己怎么做。项目中常见的另一个事件是 language:loaderror,语言包加载失败时发出去,让全局统一弹一次提示,而不是每个订阅方各弹各的。
注意 CustomEvent 在 IE 和旧版 Edge 上缺失,最简降级方案:
function dispatchLangEvent(detail) { var event; try { event = new CustomEvent('language:changed', { detail: detail }); } catch (e) { event = document.createEvent('CustomEvent'); event.initCustomEvent('language:changed', true, false, detail); } window.dispatchEvent(event); }事件总线的坑主要在重复订阅。模块在单页应用里反复挂载、卸载,如果每次挂载都 addEventListener 一次,切换语言时回调会被执行两遍甚至更多。经验做法是:订阅回调放在模块入口处,卸载时 removeEventListener;如果做不到,用一个订阅计数器兜底,回调先检查计数,不等于一次就 return。
这个技巧我第一次用在某跨平台系统上时,坑也被踩了个遍:一开始忘了派发 syncDirection,RTL 语言只在文案上变了,布局还保持 LTR,用户界面看起来像排版事故;后来又在几个子模块里重复注册事件,切换一次语言触发了十几次图表重绘。从那以后我每次接入新的语言包都强制在 staging 环境走一遍:先清空 localStorage 再刷新,再删除 URL 参数刷新,确认回退路径正常,最后再检查事件监听数量。希望帮到你。
本文还有配套的精品资源,点击获取