perf: 优化体积,highlight.js按需导入与i18n按需加载 (#696)
* perf: 优化打包体积,highlight.js按需导入与i18n按需加载 1. highlight.js: 从全量导入改为 core + 注册27种常用语言,减少约500~800KB 2. i18n: 只同步加载en语言包,其他8种语言改为异步加载,首屏减少约350~400KB 3. 使用vue-i18n的setLocaleMessage API动态注册语言包 4. 新增switchLocale函数统一处理语言切换 5. 同步更新相关测试文件的mock路径和API适配 * 修复类型断言
This commit is contained in:
committed by
GitHub
parent
1b4733e755
commit
f6df0fecfa
@@ -1,6 +1,62 @@
|
|||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js/lib/core'
|
||||||
|
import bash from 'highlight.js/lib/languages/bash'
|
||||||
|
import c from 'highlight.js/lib/languages/c'
|
||||||
|
import cpp from 'highlight.js/lib/languages/cpp'
|
||||||
|
import csharp from 'highlight.js/lib/languages/csharp'
|
||||||
|
import css from 'highlight.js/lib/languages/css'
|
||||||
|
import diff from 'highlight.js/lib/languages/diff'
|
||||||
|
import go from 'highlight.js/lib/languages/go'
|
||||||
|
import xml from 'highlight.js/lib/languages/xml'
|
||||||
|
import java from 'highlight.js/lib/languages/java'
|
||||||
|
import javascript from 'highlight.js/lib/languages/javascript'
|
||||||
|
import json from 'highlight.js/lib/languages/json'
|
||||||
|
import kotlin from 'highlight.js/lib/languages/kotlin'
|
||||||
|
import lua from 'highlight.js/lib/languages/lua'
|
||||||
|
import markdown from 'highlight.js/lib/languages/markdown'
|
||||||
|
import php from 'highlight.js/lib/languages/php'
|
||||||
|
import python from 'highlight.js/lib/languages/python'
|
||||||
|
import r from 'highlight.js/lib/languages/r'
|
||||||
|
import ruby from 'highlight.js/lib/languages/ruby'
|
||||||
|
import rust from 'highlight.js/lib/languages/rust'
|
||||||
|
import scss from 'highlight.js/lib/languages/scss'
|
||||||
|
import shell from 'highlight.js/lib/languages/shell'
|
||||||
|
import sql from 'highlight.js/lib/languages/sql'
|
||||||
|
import swift from 'highlight.js/lib/languages/swift'
|
||||||
|
import typescript from 'highlight.js/lib/languages/typescript'
|
||||||
|
import yaml from 'highlight.js/lib/languages/yaml'
|
||||||
|
import plaintext from 'highlight.js/lib/languages/plaintext'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
|
|
||||||
|
hljs.registerLanguage('bash', bash)
|
||||||
|
hljs.registerLanguage('c', c)
|
||||||
|
hljs.registerLanguage('cpp', cpp)
|
||||||
|
hljs.registerLanguage('csharp', csharp)
|
||||||
|
hljs.registerLanguage('css', css)
|
||||||
|
hljs.registerLanguage('diff', diff)
|
||||||
|
hljs.registerLanguage('go', go)
|
||||||
|
hljs.registerLanguage('xml', xml)
|
||||||
|
hljs.registerLanguage('html', xml)
|
||||||
|
hljs.registerLanguage('java', java)
|
||||||
|
hljs.registerLanguage('javascript', javascript)
|
||||||
|
hljs.registerLanguage('js', javascript)
|
||||||
|
hljs.registerLanguage('json', json)
|
||||||
|
hljs.registerLanguage('kotlin', kotlin)
|
||||||
|
hljs.registerLanguage('lua', lua)
|
||||||
|
hljs.registerLanguage('markdown', markdown)
|
||||||
|
hljs.registerLanguage('php', php)
|
||||||
|
hljs.registerLanguage('python', python)
|
||||||
|
hljs.registerLanguage('r', r)
|
||||||
|
hljs.registerLanguage('ruby', ruby)
|
||||||
|
hljs.registerLanguage('rust', rust)
|
||||||
|
hljs.registerLanguage('scss', scss)
|
||||||
|
hljs.registerLanguage('shell', shell)
|
||||||
|
hljs.registerLanguage('sql', sql)
|
||||||
|
hljs.registerLanguage('swift', swift)
|
||||||
|
hljs.registerLanguage('typescript', typescript)
|
||||||
|
hljs.registerLanguage('ts', typescript)
|
||||||
|
hljs.registerLanguage('yaml', yaml)
|
||||||
|
hljs.registerLanguage('plaintext', plaintext)
|
||||||
|
|
||||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||||
shellscript: 'bash',
|
shellscript: 'bash',
|
||||||
sh: 'bash',
|
sh: 'bash',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { NSelect } from 'naive-ui'
|
import { NSelect } from 'naive-ui'
|
||||||
|
import { switchLocale } from '@/i18n'
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
@@ -16,7 +17,8 @@ const options = [
|
|||||||
{ label: 'Português', value: 'pt' },
|
{ label: 'Português', value: 'pt' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleChange(val: string) {
|
async function handleChange(val: string) {
|
||||||
|
await switchLocale(val)
|
||||||
locale.value = val
|
locale.value = val
|
||||||
localStorage.setItem('hermes_locale', val)
|
localStorage.setItem('hermes_locale', val)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import { messages } from './messages'
|
import { en, loadLocale, supportedLocales } from './messages'
|
||||||
|
import type { SupportedLocale } from './messages'
|
||||||
|
|
||||||
const saved = localStorage.getItem('hermes_locale')
|
const saved = localStorage.getItem('hermes_locale')
|
||||||
|
|
||||||
const supportedLocales = ['en', 'zh', 'zh-TW', 'ja', 'ko', 'fr', 'es', 'de', 'pt'] as const
|
|
||||||
type SupportedLocale = (typeof supportedLocales)[number]
|
|
||||||
|
|
||||||
function resolveLocale(saved: string | null): SupportedLocale {
|
function resolveLocale(saved: string | null): SupportedLocale {
|
||||||
if (saved && (supportedLocales as readonly string[]).includes(saved)) {
|
if (saved && (supportedLocales as readonly string[]).includes(saved)) {
|
||||||
return saved as SupportedLocale
|
return saved as SupportedLocale
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize a single BCP-47 tag to a supported locale key.
|
|
||||||
// Covers zh-Hant-TW, zh-TW, zh-HK, zh-MO, zh-Hant → zh-TW
|
|
||||||
// zh-Hans-*, zh-CN, zh-SG, zh → zh
|
|
||||||
function normalize(tag: string): SupportedLocale | null {
|
function normalize(tag: string): SupportedLocale | null {
|
||||||
const lower = tag.toLowerCase()
|
const lower = tag.toLowerCase()
|
||||||
if (lower.startsWith('zh')) {
|
if (lower.startsWith('zh')) {
|
||||||
@@ -38,9 +33,28 @@ function resolveLocale(saved: string | null): SupportedLocale {
|
|||||||
return 'en'
|
return 'en'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locale = resolveLocale(saved)
|
||||||
|
|
||||||
export const i18n = createI18n({
|
export const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: resolveLocale(saved),
|
locale: 'en',
|
||||||
fallbackLocale: 'en',
|
fallbackLocale: 'en',
|
||||||
messages,
|
messages: { en },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export async function setupI18n(): Promise<void> {
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const msgs = await loadLocale(locale)
|
||||||
|
if (msgs) {
|
||||||
|
i18n.global.setLocaleMessage(locale, msgs as any)
|
||||||
|
}
|
||||||
|
;(i18n.global.locale as any).value = locale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchLocale(newLocale: string): Promise<void> {
|
||||||
|
const msgs = await loadLocale(newLocale)
|
||||||
|
if (msgs) {
|
||||||
|
i18n.global.setLocaleMessage(newLocale, msgs as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,9 @@
|
|||||||
import de from './locales/de'
|
|
||||||
import en from './locales/en'
|
import en from './locales/en'
|
||||||
import es from './locales/es'
|
|
||||||
import fr from './locales/fr'
|
|
||||||
import ja from './locales/ja'
|
|
||||||
import ko from './locales/ko'
|
|
||||||
import pt from './locales/pt'
|
|
||||||
import zh from './locales/zh'
|
|
||||||
import zhTW from './locales/zh-TW'
|
|
||||||
|
|
||||||
export type LocaleMessages = Record<string, unknown>
|
export type LocaleMessages = Record<string, any>
|
||||||
|
|
||||||
export const rawMessages = {
|
export const supportedLocales = ['en', 'zh', 'zh-TW', 'ja', 'ko', 'fr', 'es', 'de', 'pt'] as const
|
||||||
'en': en,
|
export type SupportedLocale = (typeof supportedLocales)[number]
|
||||||
'zh': zh,
|
|
||||||
'zh-TW': zhTW,
|
|
||||||
'ja': ja,
|
|
||||||
'ko': ko,
|
|
||||||
'fr': fr,
|
|
||||||
'es': es,
|
|
||||||
'de': de,
|
|
||||||
'pt': pt,
|
|
||||||
} satisfies Record<string, LocaleMessages>
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is LocaleMessages {
|
function isPlainObject(value: unknown): value is LocaleMessages {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
@@ -42,11 +25,25 @@ export function mergeMessagesWithFallback(
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messages = Object.fromEntries(
|
const localeLoaders: Record<string, () => Promise<{ default: LocaleMessages }>> = {
|
||||||
Object.entries(rawMessages).map(([locale, localeMessages]) => [
|
'zh': () => import('./locales/zh'),
|
||||||
locale,
|
'zh-TW': () => import('./locales/zh-TW'),
|
||||||
locale === 'en'
|
'ja': () => import('./locales/ja'),
|
||||||
? localeMessages
|
'ko': () => import('./locales/ko'),
|
||||||
: mergeMessagesWithFallback(en, localeMessages),
|
'fr': () => import('./locales/fr'),
|
||||||
]),
|
'es': () => import('./locales/es'),
|
||||||
) as typeof rawMessages
|
'de': () => import('./locales/de'),
|
||||||
|
'pt': () => import('./locales/pt'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export { en }
|
||||||
|
|
||||||
|
export async function loadLocale(locale: string): Promise<LocaleMessages | null> {
|
||||||
|
if (locale === 'en') return en
|
||||||
|
|
||||||
|
const loader = localeLoaders[locale]
|
||||||
|
if (!loader) return null
|
||||||
|
|
||||||
|
const mod = await loader()
|
||||||
|
return mergeMessagesWithFallback(en, mod.default)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import { i18n } from './i18n'
|
import { i18n, setupI18n } from './i18n'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './styles/global.scss'
|
import './styles/global.scss'
|
||||||
|
|
||||||
@@ -36,4 +36,6 @@ const app = createApp(App)
|
|||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
setupI18n().then(() => {
|
||||||
|
app.mount('#app')
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ const highlightJsMock = vi.hoisted(() => ({
|
|||||||
highlight: vi.fn((content: string, { language }: { language: string }) => ({
|
highlight: vi.fn((content: string, { language }: { language: string }) => ({
|
||||||
value: `<span class="mock-${language}">${content}</span>`,
|
value: `<span class="mock-${language}">${content}</span>`,
|
||||||
})),
|
})),
|
||||||
|
registerLanguage: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('highlight.js', () => ({
|
vi.mock('highlight.js/lib/core', () => ({
|
||||||
default: highlightJsMock,
|
default: highlightJsMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, beforeAll } from 'vitest'
|
||||||
import { readdirSync, readFileSync } from 'fs'
|
import { readdirSync, readFileSync } from 'fs'
|
||||||
import { join, relative } from 'path'
|
import { join, relative } from 'path'
|
||||||
|
|
||||||
import { changelog } from '@/data/changelog'
|
import { changelog } from '@/data/changelog'
|
||||||
import { messages, rawMessages } from '@/i18n/messages'
|
import { loadLocale, supportedLocales } from '@/i18n/messages'
|
||||||
|
import en from '@/i18n/locales/en'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const SOURCE_ROOT = join(process.cwd(), 'packages/client/src')
|
const SOURCE_ROOT = join(process.cwd(), 'packages/client/src')
|
||||||
|
|
||||||
|
const allMessages: Record<string, Record<string, unknown>> = { en }
|
||||||
|
|
||||||
function walkFiles(dir: string, files: string[] = []): string[] {
|
function walkFiles(dir: string, files: string[] = []): string[] {
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
const path = join(dir, entry.name)
|
const path = join(dir, entry.name)
|
||||||
@@ -93,15 +97,23 @@ function labelLength(value: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('i18n locale coverage', () => {
|
describe('i18n locale coverage', () => {
|
||||||
// Keys that are newly added but not yet translated in all locales
|
|
||||||
const ALLOWED_MISSING_KEYS = new Set([
|
const ALLOWED_MISSING_KEYS = new Set([
|
||||||
'changelog.new_0_5_4_7',
|
'changelog.new_0_5_4_7',
|
||||||
'chat.sessionNotFound',
|
'chat.sessionNotFound',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
supportedLocales.filter(l => l !== 'en').map(async l => {
|
||||||
|
const msgs = await loadLocale(l)
|
||||||
|
if (msgs) allMessages[l] = msgs
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('defines every statically referenced translation key in the English source locale', () => {
|
it('defines every statically referenced translation key in the English source locale', () => {
|
||||||
const missing = collectLiteralTranslationKeys()
|
const missing = collectLiteralTranslationKeys()
|
||||||
.filter((key) => !hasPath(rawMessages.en, key))
|
.filter((key) => !hasPath(en, key))
|
||||||
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
|
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
|
||||||
|
|
||||||
expect(missing).toEqual([])
|
expect(missing).toEqual([])
|
||||||
@@ -109,7 +121,7 @@ describe('i18n locale coverage', () => {
|
|||||||
|
|
||||||
it('defines every statically referenced translation key in effective runtime messages', () => {
|
it('defines every statically referenced translation key in effective runtime messages', () => {
|
||||||
const requiredKeys = collectLiteralTranslationKeys()
|
const requiredKeys = collectLiteralTranslationKeys()
|
||||||
const missing = Object.entries(messages).flatMap(([locale, localeMessages]) =>
|
const missing = Object.entries(allMessages).flatMap(([locale, localeMessages]) =>
|
||||||
requiredKeys
|
requiredKeys
|
||||||
.filter((key) => !hasPath(localeMessages, key))
|
.filter((key) => !hasPath(localeMessages, key))
|
||||||
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
|
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ vi.mock('vue-i18n', () => ({
|
|||||||
useI18n: () => ({
|
useI18n: () => ({
|
||||||
t: (key: string) => key,
|
t: (key: string) => key,
|
||||||
}),
|
}),
|
||||||
|
createI18n: () => ({
|
||||||
|
global: { locale: { value: 'en' }, setLocaleMessage: vi.fn() },
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useTheme', () => ({
|
vi.mock('@/composables/useTheme', () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user